From 4c7c7571826a96685172251f84e5c79306392120 Mon Sep 17 00:00:00 2001 From: m1yag1 <8730430+m1yag1@users.noreply.github.com> Date: Fri, 8 May 2026 08:00:16 -0500 Subject: [PATCH 1/4] Added globus flows registered-api show command --- ...30+m1yag1_sc_49699_add_gra_show_command.md | 3 + pyproject.toml | 2 +- src/globus_cli/commands/flows/__init__.py | 2 + src/globus_cli/commands/flows/_fields.py | 54 +++++++++++++++++ .../commands/flows/registered_api/__init__.py | 11 ++++ .../commands/flows/registered_api/show.py | 33 +++++++++++ tests/functional/flows/conftest.py | 28 +++++++++ .../flows/test_show_registered_api.py | 58 +++++++++++++++++++ 8 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 changelog.d/20260507_153740_8730430+m1yag1_sc_49699_add_gra_show_command.md create mode 100644 src/globus_cli/commands/flows/registered_api/__init__.py create mode 100644 src/globus_cli/commands/flows/registered_api/show.py create mode 100644 tests/functional/flows/test_show_registered_api.py diff --git a/changelog.d/20260507_153740_8730430+m1yag1_sc_49699_add_gra_show_command.md b/changelog.d/20260507_153740_8730430+m1yag1_sc_49699_add_gra_show_command.md new file mode 100644 index 000000000..c4bfef840 --- /dev/null +++ b/changelog.d/20260507_153740_8730430+m1yag1_sc_49699_add_gra_show_command.md @@ -0,0 +1,3 @@ +### Enhancements + +* Added `globus flows registered-api show` command to display details of a registered API diff --git a/pyproject.toml b/pyproject.toml index ae1b2e3d0..6c8512846 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ ] requires-python = ">=3.9" dependencies = [ - "globus-sdk==4.4.0", + "globus-sdk==4.6.0", "click>=8.1.4,<8.4", "jmespath==1.1.0", "packaging>=17.0", diff --git a/src/globus_cli/commands/flows/__init__.py b/src/globus_cli/commands/flows/__init__.py index d67a1491f..1395aea94 100644 --- a/src/globus_cli/commands/flows/__init__.py +++ b/src/globus_cli/commands/flows/__init__.py @@ -13,6 +13,8 @@ "start": (".start", "start_command"), # "run" is a subgroup of commands. "run": (".run", "run_command"), + # "registered-api" is a subgroup of commands. + "registered-api": (".registered_api", "registered_api_command"), }, ) def flows_command() -> None: diff --git a/src/globus_cli/commands/flows/_fields.py b/src/globus_cli/commands/flows/_fields.py index 06fdbc653..a620b4374 100644 --- a/src/globus_cli/commands/flows/_fields.py +++ b/src/globus_cli/commands/flows/_fields.py @@ -111,3 +111,57 @@ def flow_run_format_fields( Field("Run Managers", "run_managers", formatter=csv_principal_list), Field("Run Monitors", "run_monitors", formatter=csv_principal_list), ] + + +class RegisteredAPIPrincipalFormatter(PrincipalURNFormatter): + """A principal formatter which pre-registers all principals for a registered API.""" + + def __init__( + self, auth_client: globus_sdk.AuthClient, registered_api: dict[str, t.Any] + ) -> None: + super().__init__(auth_client) + roles = registered_api.get("roles", {}) + self.add_items(*roles.get("owners", ())) + self.add_items(*roles.get("administrators", ())) + self.add_items(*roles.get("viewers", ())) + + +def registered_api_format_fields( + auth_client: globus_sdk.AuthClient, + registered_api: dict[str, t.Any], +) -> list[Field]: + """ + The standard list of fields to render for a registered API resource. + + :param auth_client: An AuthClient, used to resolve principal URNs. + :param registered_api: The registered API resource, used to pre-register + principals. + """ + principal = RegisteredAPIPrincipalFormatter(auth_client, registered_api) + csv_principal_list = formatters.ArrayFormatter( + element_formatter=principal, + delimiter=", ", + ) + + return [ + Field("Registered API ID", "id"), + Field("Name", "name"), + Field("Description", "description"), + Field("Status", "status"), + Field("Subscription ID", "subscription_id"), + Field("Created At", "created_timestamp", formatter=formatters.Date), + Field("Updated At", "updated_timestamp", formatter=formatters.Date), + Field("Edited At", "edited_timestamp", formatter=formatters.Date), + Field( + "Scheduled Deletion", + "scheduled_deletion_timestamp", + formatter=formatters.Date, + ), + Field("Owners", "roles.owners", formatter=csv_principal_list), + Field("Administrators", "roles.administrators", formatter=csv_principal_list), + Field("Viewers", "roles.viewers", formatter=csv_principal_list), + Field("Target Type", "target.type"), + Field("OpenAPI Version", "target.openapi_version"), + Field("Destination Method", "target.destination.method"), + Field("Destination URL", "target.destination.url"), + ] diff --git a/src/globus_cli/commands/flows/registered_api/__init__.py b/src/globus_cli/commands/flows/registered_api/__init__.py new file mode 100644 index 000000000..395389edb --- /dev/null +++ b/src/globus_cli/commands/flows/registered_api/__init__.py @@ -0,0 +1,11 @@ +from globus_cli.parsing import group + + +@group( + "registered-api", + lazy_subcommands={ + "show": (".show", "show_command"), + }, +) +def registered_api_command() -> None: + """Interact with registered APIs in the Globus Flows service.""" diff --git a/src/globus_cli/commands/flows/registered_api/show.py b/src/globus_cli/commands/flows/registered_api/show.py new file mode 100644 index 000000000..3c71a1cce --- /dev/null +++ b/src/globus_cli/commands/flows/registered_api/show.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import uuid + +import click + +from globus_cli.commands.flows._fields import registered_api_format_fields +from globus_cli.login_manager import LoginManager +from globus_cli.parsing import command +from globus_cli.termio import display + + +@command("show") +@click.argument("registered_api_id") +@LoginManager.requires_login("auth", "flows") +def show_command(login_manager: LoginManager, *, registered_api_id: str) -> None: + """ + Show a registered API. + """ + flows_client = login_manager.get_flows_client() + auth_client = login_manager.get_auth_client() + + # Convert string to UUID if it's a valid UUID + try: + api_id: uuid.UUID | str = uuid.UUID(registered_api_id) + except ValueError: + api_id = registered_api_id + + res = flows_client.get_registered_api(api_id) + + fields = registered_api_format_fields(auth_client, res.data) + + display(res, fields=fields, text_mode=display.RECORD) diff --git a/tests/functional/flows/conftest.py b/tests/functional/flows/conftest.py index a6358b9c2..a08b0d49c 100644 --- a/tests/functional/flows/conftest.py +++ b/tests/functional/flows/conftest.py @@ -274,3 +274,31 @@ def setup_custom_orderby_response() -> None: "total_items": 26, }, ) + + +@pytest.fixture +def load_identities_for_registered_api(get_identities_mocker): + """ + Callable fixture. + Load identities for a provided registered API response object, configuring + auth.get_identities to return unique usernames for each identity associated with the + registered API. + + Returns an identity pool object to facilitate lookup of username by principal urn. + """ + + def _load_identities_for_registered_api( + registered_api: dict[str, t.Any], + ) -> FlowsIdentityPool: + principals: set[str] = set() + roles = registered_api.get("roles", {}) + principals.update(set(roles.get("owners", []))) + principals.update(set(roles.get("administrators", []))) + principals.update(set(roles.get("viewers", []))) + + pool = FlowsIdentityPool(principals) + + get_identities_mocker.configure(pool.create_get_identities_documents()) + return pool + + return _load_identities_for_registered_api diff --git a/tests/functional/flows/test_show_registered_api.py b/tests/functional/flows/test_show_registered_api.py new file mode 100644 index 000000000..a14d73f3a --- /dev/null +++ b/tests/functional/flows/test_show_registered_api.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import re + +from globus_sdk.testing import load_response + + +def test_show_registered_api_text_output(run_line, load_identities_for_registered_api): + loaded_response = load_response("flows.get_registered_api") + response, meta = loaded_response.json, loaded_response.metadata + + registered_api_id = meta["registered_api_id"] + + pool = load_identities_for_registered_api(response) + + result = run_line(f"globus flows registered-api show {registered_api_id}") + + # all fields present + for fieldname in ( + "Registered API ID", + "Name", + "Description", + "Status", + "Subscription ID", + "Created At", + "Updated At", + "Owners", + "Administrators", + "Viewers", + "Target Type", + "OpenAPI Version", + "Destination Method", + "Destination URL", + ): + assert fieldname in result.output + + # Verify principal resolution + roles = response.get("roles", {}) + assert_usernames(result, pool, "Owners", roles.get("owners", [])) + assert_usernames(result, pool, "Administrators", roles.get("administrators", [])) + assert_usernames(result, pool, "Viewers", roles.get("viewers", [])) + + +def assert_usernames(result, pool, field_name, principals): + expected_usernames = {pool.get_username(principal) for principal in principals} + + output_value = _get_output_value(field_name, result.output) + output_usernames = [x.strip() for x in output_value.split(",")] + assert expected_usernames == set(output_usernames) + + +def _get_output_value(name, output): + """ + Return the value for a specified field from the output of a command. + """ + match = re.search(rf"^{name}:[^\S\n\r]+(?P.*)$", output, flags=re.M) + assert match is not None + return match.group("value") From 7b059182cf6ce9338e3885a60bf0043118719b28 Mon Sep 17 00:00:00 2001 From: m1yag1 <8730430+m1yag1@users.noreply.github.com> Date: Thu, 14 May 2026 14:03:40 -0500 Subject: [PATCH 2/4] fixup! Added globus flows registered-api show command --- src/globus_cli/commands/flows/registered_api/show.py | 4 +++- tests/functional/flows/test_show_registered_api.py | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/globus_cli/commands/flows/registered_api/show.py b/src/globus_cli/commands/flows/registered_api/show.py index 3c71a1cce..e89097c7c 100644 --- a/src/globus_cli/commands/flows/registered_api/show.py +++ b/src/globus_cli/commands/flows/registered_api/show.py @@ -16,9 +16,10 @@ def show_command(login_manager: LoginManager, *, registered_api_id: str) -> None: """ Show a registered API. + + Accepts a registered API UUID. """ flows_client = login_manager.get_flows_client() - auth_client = login_manager.get_auth_client() # Convert string to UUID if it's a valid UUID try: @@ -28,6 +29,7 @@ def show_command(login_manager: LoginManager, *, registered_api_id: str) -> None res = flows_client.get_registered_api(api_id) + auth_client = login_manager.get_auth_client() fields = registered_api_format_fields(auth_client, res.data) display(res, fields=fields, text_mode=display.RECORD) diff --git a/tests/functional/flows/test_show_registered_api.py b/tests/functional/flows/test_show_registered_api.py index a14d73f3a..dd9cdcb1e 100644 --- a/tests/functional/flows/test_show_registered_api.py +++ b/tests/functional/flows/test_show_registered_api.py @@ -45,14 +45,14 @@ def assert_usernames(result, pool, field_name, principals): expected_usernames = {pool.get_username(principal) for principal in principals} output_value = _get_output_value(field_name, result.output) - output_usernames = [x.strip() for x in output_value.split(",")] - assert expected_usernames == set(output_usernames) + output_usernames = {x.strip() for x in output_value.split(",")} + assert expected_usernames == output_usernames def _get_output_value(name, output): """ Return the value for a specified field from the output of a command. """ - match = re.search(rf"^{name}:[^\S\n\r]+(?P.*)$", output, flags=re.M) + match = re.search(rf"^{re.escape(name)}:[^\S\n\r]+(?P.*)$", output, flags=re.M) assert match is not None return match.group("value") From adca1d28d6c65e884a03f3ea7dd29bc95ccad64e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 19:17:06 +0000 Subject: [PATCH 3/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/functional/flows/test_show_registered_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/functional/flows/test_show_registered_api.py b/tests/functional/flows/test_show_registered_api.py index dd9cdcb1e..cf5f07ec7 100644 --- a/tests/functional/flows/test_show_registered_api.py +++ b/tests/functional/flows/test_show_registered_api.py @@ -53,6 +53,8 @@ def _get_output_value(name, output): """ Return the value for a specified field from the output of a command. """ - match = re.search(rf"^{re.escape(name)}:[^\S\n\r]+(?P.*)$", output, flags=re.M) + match = re.search( + rf"^{re.escape(name)}:[^\S\n\r]+(?P.*)$", output, flags=re.M + ) assert match is not None return match.group("value") From 7e0d5aef1d4b7892fab227da468625efd8d194fd Mon Sep 17 00:00:00 2001 From: m1yag1 <8730430+m1yag1@users.noreply.github.com> Date: Thu, 14 May 2026 14:35:49 -0500 Subject: [PATCH 4/4] fixup! Added globus flows registered-api show command --- .../commands/flows/registered_api/show.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/globus_cli/commands/flows/registered_api/show.py b/src/globus_cli/commands/flows/registered_api/show.py index e89097c7c..8a683e8da 100644 --- a/src/globus_cli/commands/flows/registered_api/show.py +++ b/src/globus_cli/commands/flows/registered_api/show.py @@ -11,23 +11,15 @@ @command("show") -@click.argument("registered_api_id") +@click.argument("registered_api_id", type=click.UUID) @LoginManager.requires_login("auth", "flows") -def show_command(login_manager: LoginManager, *, registered_api_id: str) -> None: +def show_command(login_manager: LoginManager, *, registered_api_id: uuid.UUID) -> None: """ Show a registered API. - - Accepts a registered API UUID. """ flows_client = login_manager.get_flows_client() - # Convert string to UUID if it's a valid UUID - try: - api_id: uuid.UUID | str = uuid.UUID(registered_api_id) - except ValueError: - api_id = registered_api_id - - res = flows_client.get_registered_api(api_id) + res = flows_client.get_registered_api(registered_api_id) auth_client = login_manager.get_auth_client() fields = registered_api_format_fields(auth_client, res.data)