Repository: SFDO-Tooling/CumulusCI
Pull request #3481 (2023-01-06 20:24:31)

`cci service update`
# Changes

* `cci` has a new command: `cci service update`. Users can now update individual service attributes with `cci service update` instead of having to completely remove and re-add the service. You can utilize the `--attribute` option to set specific service attributes in a headless environment (i.e. `--attribute name value`).


Patch:
diff --git a/cumulusci/cli/service.py b/cumulusci/cli/service.py
index 2ccf1b6b8..f9a6208c4 100644
--- a/cumulusci/cli/service.py
+++ b/cumulusci/cli/service.py
@@ -1,7 +1,7 @@
 import json
 import os
 from pathlib import Path
-from typing import Callable, Optional
+from typing import Callable, Dict, Optional
 
 import click
 from rich.console import Console
@@ -10,7 +10,7 @@ from cumulusci.core.config import ServiceConfig
 from cumulusci.core.exceptions import CumulusCIException, ServiceNotConfigured
 from cumulusci.core.utils import import_class, import_global, make_jsonable
 
-from .runtime import pass_runtime
+from .runtime import CliRuntime, pass_runtime
 from .ui import CliTable
 
 
@@ -161,7 +161,7 @@ class ConnectServiceCommand(click.MultiCommand):
             service_config = services[service_type]
         except KeyError:
             raise click.UsageError(
-                f"Sorry, I don't know about the '{service_type}' service."
+                f"Sorry, I don't know about the '{service_type}' service type."
             )
 
         attributes = service_config.get("attributes", {}).items()
@@ -272,6 +272,67 @@ def service_connect():
     pass
 
 
+@service.command(name="update")
+@click.argument("service_type")
+@click.argument("service_name")
+@click.option(
+    "--attribute",
+    "-a",
+    "attributes",
+    type=(str, str),
+    multiple=True,
+    help="Provide values to update the service with directly. Using `--attribute foo var` will set the 'foo' attribute to a value of 'bar' on the specified service",
+)
+@pass_runtime(require_project=False, require_keychain=True)
+def service_update(
+    runtime: CliRuntime,
+    service_type: str,
+    service_name: str,
+    attributes: Dict[str, str],
+):
+    """Allow users to update attribute values of a particular service."""
+    console = Console()
+    service_update_success = (
+        f"\nThe {service_type} service {service_name} has been updated successfully!"
+    )
+    # This will throw a ServiceNotConfigured error if the type or name is not present.
+    service_config = runtime.keychain.get_service(service_type, service_name)
+    service_attributes = get_service_attributes(runtime, service_type)
+
+    attributes_were_updated = False
+    # attributes can be provided directly for operability in headless environments
+    if attributes:
+        for attr in attributes:
+            attr_name, attr_value = attr
+            if attr_name in service_config.config:
+                service_config.config[attr_name] = attr_value
+                attributes_were_updated = True
+            else:
+                available_attributes = ", ".join(service_attributes)
+                console.print(
+                    f"Unrecognized service attribute '{attr_name}' for service type '{service_type}'. Acceptable values include: {available_attributes}"
+                )
+                return
+    else:
+        # if no attributes are provided, then users can update interactively
+        for attr in service_attributes:
+            attr_name = service_config.config[attr]
+            console.print(f"\n\n{attr} is currently: {attr_name}")
+
+            user_input = console.input(
+                "Enter a new value or press <ENTER> to continue: "
+            )
+            if user_input != "":
+                attributes_were_updated = True
+                service_config.config[attr] = user_input
+
+    if attributes_were_updated:
+        runtime.keychain.set_service(service_type, service_name, service_config)
+        console.print(service_update_success)
+    else:
+        console.print("\nNo updates made. Exiting.")
+
+
 @service.command(name="info")
 @click.argument("service_type")
 @click.argument("service_name", required=False)
@@ -321,7 +382,11 @@ def get_service_data(service_config, sensitive_attributes) -> list:
     return service_data
 
 
-def get_sensitive_service_attributes(runtime, service_type) -> list:
+def get_service_attributes(
+    runtime: CliRuntime, service_type: str, only_sensitive_attributes: bool = False
+) -> list:
+    """Returns the attributes for the given service type as they are defined in the
+    standard lib (universal cumulusci.yml file)."""
     services = (
         runtime.project_config.services
         if runtime.project_config
@@ -329,10 +394,18 @@ def get_sensitive_service_attributes(runtime, service_type) -> list:
     )
     try:
         service_type_attributes = services[service_type]["attributes"]
-        return [k for k, v in service_type_attributes.items() if v.get("sensitive")]
     except KeyError:
         return []
 
+    if only_sensitive_attributes:
+        return [k for k, v in service_type_attributes.items() if v.get("sensitive")]
+    else:
+        return [k for k, v in service_type_attributes.items()]
+
+
+def get_sensitive_service_attributes(runtime: CliRuntime, service_type: str) -> list:
+    return get_service_attributes(runtime, service_type, only_sensitive_attributes=True)
+
 
 @service.command(
     name="default", help="Set the default service for a given service type."


Test patch:
diff --git a/cumulusci/cli/tests/test_service.py b/cumulusci/cli/tests/test_service.py
index dc12cb5f2..774f11f53 100644
--- a/cumulusci/cli/tests/test_service.py
+++ b/cumulusci/cli/tests/test_service.py
@@ -4,8 +4,10 @@ from unittest import mock
 import click
 import pytest
 
+from cumulusci.cli.runtime import CliRuntime
 from cumulusci.cli.service import get_sensitive_service_attributes, get_service_data
 from cumulusci.core.config import ServiceConfig
+from cumulusci.core.exceptions import ServiceNotConfigured
 from cumulusci.core.runtime import BaseCumulusCI
 from cumulusci.core.tests.utils import EnvironmentVarGuard
 
@@ -293,6 +295,187 @@ def test_service_connect_validator_failure():
         run_cli_command("service", "connect", "test", "test-alias", runtime=runtime)
 
 
+def test_service_update__success():
+    # Create a new color-picker service type
+    runtime = CliRuntime(
+        config={
+            "services": {"color-picker": {"attributes": {"color": {"required": False}}}}
+        }
+    )
+    # Setup an existing service of type color-picker
+    original_color = "Turquoise"
+    runtime.keychain.set_service(
+        "color-picker",
+        "foo",
+        ServiceConfig({"color": original_color}),
+    )
+    # Update the existing service
+    chosen_color = "Maroon"
+    result = run_cli_command(
+        "service",
+        "update",
+        "color-picker",
+        "foo",
+        input=f"{chosen_color}\n",
+        runtime=runtime,
+    )
+
+    assert "updated successfully!" in result.output
+
+    # ensure info was written to disk
+    result = run_cli_command(
+        "service",
+        "info",
+        "color-picker",
+        "foo",
+        runtime=runtime,
+    )
+    assert chosen_color in result.output
+    assert original_color not in result.output
+
+
+def test_service_update_headless__success():
+    # Create a new color-picker service type
+    runtime = CliRuntime(
+        config={
+            "services": {
+                "color-picker": {
+                    "attributes": {
+                        "primary": {"required": False},
+                        "secondary": {"required": False},
+                    }
+                }
+            }
+        }
+    )
+    # Setup an existing service of type color-picker
+    original_primary = "Turquoise"
+    original_secondary = "Burgundy"
+    runtime.keychain.set_service(
+        "color-picker",
+        "foo",
+        ServiceConfig({"primary": original_primary, "secondary": original_secondary}),
+    )
+    # Update the existing service
+    new_primary = "Maroon"
+    new_secondary = "Purple"
+    result = run_cli_command(
+        "service",
+        "update",
+        "color-picker",
+        "foo",
+        "--attribute",
+        "primary",
+        new_primary,
+        "-a",
+        "secondary",
+        new_secondary,
+        runtime=runtime,
+    )
+
+    assert "updated successfully!" in result.output
+
+    # ensure info was written to disk
+    result = run_cli_command(
+        "service",
+        "info",
+        "color-picker",
+        "foo",
+        runtime=runtime,
+    )
+    assert new_primary in result.output
+    assert new_secondary in result.output
+    assert original_primary not in result.output
+    assert original_secondary not in result.output
+
+
+def test_service_update_headless__unrecognized_service_attribute():
+    # Create a new color-picker service type
+    runtime = CliRuntime(
+        config={
+            "services": {
+                "color-picker": {
+                    "attributes": {
+                        "primary": {"required": False},
+                        "secondary": {"required": False},
+                    }
+                }
+            }
+        }
+    )
+    # Setup an existing service of type color-picker
+    original_primary = "Turquoise"
+    original_secondary = "Burgundy"
+    runtime.keychain.set_service(
+        "color-picker",
+        "foo",
+        ServiceConfig({"primary": original_primary, "secondary": original_secondary}),
+    )
+    # Update the service
+    new_primary = "Maroon"
+    result = run_cli_command(
+        "service",
+        "update",
+        "color-picker",
+        "foo",
+        "--attribute",
+        "does-not-exist",
+        new_primary,
+        runtime=runtime,
+    )
+
+    assert "Unrecognized service attribute 'does-not-exist" in result.output
+    assert "Acceptable values include: primary, secondary" in result.output
+
+
+def test_service_update__service_does_not_exist():
+    # Create a new color-picker service type
+    runtime = CliRuntime(
+        config={
+            "services": {"color-picker": {"attributes": {"color": {"required": False}}}}
+        }
+    )
+    # Update service that doesn't exist
+    chosen_color = "Maroon"
+
+    with pytest.raises(ServiceNotConfigured):
+        run_cli_command(
+            "service",
+            "update",
+            "color-picker",
+            "foo",
+            input=f"{chosen_color}\n",
+            runtime=runtime,
+        )
+
+
+def test_service_update__nothing_updated():
+    # Create a new color-picker service type
+    runtime = CliRuntime(
+        config={
+            "services": {"color-picker": {"attributes": {"color": {"required": False}}}}
+        }
+    )
+    # Setup an existing service of type color-picker
+    original_color = "Turquoise"
+    runtime.keychain.set_service(
+        "color-picker",
+        "foo",
+        ServiceConfig({"color": original_color}),
+    )
+    # Update the existing service
+    result = run_cli_command(
+        "service",
+        "update",
+        "color-picker",
+        "foo",
+        input="\n",
+        runtime=runtime,
+    )
+    print(f">>> {result.output=}")
+    assert "No updates made. Exiting." in result.output
+
+
 def test_service_connect__connected_app():
     runtime = BaseCumulusCI()
     run_cli_command(
@@ -338,7 +521,7 @@ def test_service_connect__connected_app__with_cli_options():
 
 
 def test_service_info():
-    runtime = BaseCumulusCI(
+    runtime = CliRuntime(
         config={
             "services": {
                 "test": {
@@ -415,7 +598,7 @@ def test_get_service_data():
 
 
 def test_get_sensitive_service_attributes():
-    runtime = BaseCumulusCI(
+    runtime = CliRuntime(
         config={
             "services": {
                 "universal_test": {