From 8cf6e98ce498e8cf3c27fde0dc00c39b03cf0e31 Mon Sep 17 00:00:00 2001 From: derek-globus <113056046+derek-globus@users.noreply.github.com> Date: Mon, 19 Feb 2024 09:47:47 -0600 Subject: [PATCH] Added globus gcs endpoint show and update (#951) * Added globus gcs endpoint show and update * PR Comment Resolution * I choose Kurt's! * Custom network use formatting --- Makefile | 1 + ...2016_derek_gcs_endpoint_update_sc_28586.md | 6 + .../commands/gcs/endpoint/__init__.py | 2 + .../commands/gcs/endpoint/_common.py | 68 +++++ .../gcs/endpoint/set_subscription_id.py | 2 +- src/globus_cli/commands/gcs/endpoint/show.py | 22 ++ .../commands/gcs/endpoint/update.py | 244 ++++++++++++++++++ .../parsing/param_types/nullable.py | 23 ++ src/globus_cli/termio/formatters/primitive.py | 22 +- .../api_fixtures/endpoint_introspect.yaml | 110 ++++++++ .../api_fixtures/gcs_endpoint_operations.yaml | 56 ++++ tests/functional/gcs/endpoint/test_show.py | 65 +++++ tests/functional/gcs/endpoint/test_update.py | 45 ++++ 13 files changed, 657 insertions(+), 9 deletions(-) create mode 100644 changelog.d/20240216_122016_derek_gcs_endpoint_update_sc_28586.md create mode 100644 src/globus_cli/commands/gcs/endpoint/_common.py create mode 100644 src/globus_cli/commands/gcs/endpoint/show.py create mode 100644 src/globus_cli/commands/gcs/endpoint/update.py create mode 100644 tests/files/api_fixtures/endpoint_introspect.yaml create mode 100644 tests/files/api_fixtures/gcs_endpoint_operations.yaml create mode 100644 tests/functional/gcs/endpoint/test_show.py create mode 100644 tests/functional/gcs/endpoint/test_update.py diff --git a/Makefile b/Makefile index af5208ddc..0aea3c770 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ CLI_VERSION=$(shell grep '^__version__' src/globus_cli/version.py | cut -d '"' - virtualenv --python=$(PYTHON_VERSION) .venv .venv/bin/pip install -U pip setuptools .venv/bin/pip install -e '.[development]' + .venv/bin/pip install -e '.[test]' .PHONY: localdev localdev: .venv diff --git a/changelog.d/20240216_122016_derek_gcs_endpoint_update_sc_28586.md b/changelog.d/20240216_122016_derek_gcs_endpoint_update_sc_28586.md new file mode 100644 index 000000000..2ecd0b029 --- /dev/null +++ b/changelog.d/20240216_122016_derek_gcs_endpoint_update_sc_28586.md @@ -0,0 +1,6 @@ + +### Enhancements + +* Added support for GCSv5 endpoint displaying & updating: + * `globus gcs endpoint show ENDPOINT_ID` + * `globus gcs endpoint update ENDPOINT_ID` diff --git a/src/globus_cli/commands/gcs/endpoint/__init__.py b/src/globus_cli/commands/gcs/endpoint/__init__.py index 3efc2ad39..7f310eed9 100644 --- a/src/globus_cli/commands/gcs/endpoint/__init__.py +++ b/src/globus_cli/commands/gcs/endpoint/__init__.py @@ -6,6 +6,8 @@ lazy_subcommands={ "role": (".role", "role_command"), "set-subscription-id": (".set_subscription_id", "set_subscription_id_command"), + "show": (".show", "show_command"), + "update": (".update", "update_command"), }, ) def endpoint_command() -> None: diff --git a/src/globus_cli/commands/gcs/endpoint/_common.py b/src/globus_cli/commands/gcs/endpoint/_common.py new file mode 100644 index 000000000..187de1b80 --- /dev/null +++ b/src/globus_cli/commands/gcs/endpoint/_common.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import typing as t + +from globus_cli.termio import Field, formatters +from globus_cli.termio.formatters import FieldFormatter + + +class NetworkUseFormatter(FieldFormatter[t.Union[str, t.Tuple[int, int]]]): + """ + Custom Formatter to make network use associated fields better grouped. + + Data is expected to be passed as a list of three elements: + [network_use, preferred, max] + + Examples: + ("custom", 1, 2) -> "Preferred: 1, Max: 2" + ("aggressive", None, None) -> "aggressive" + ("normal", None, 3) -> "normal" + """ + + def parse(self, value: t.Any) -> str | tuple[int, int]: + if isinstance(value, list) and len(value) == 3: + if value[0] == "custom": + if isinstance(value[1], int) and isinstance(value[2], int): + return value[1], value[2] + elif isinstance(value[0], str): + return value[0] + + raise ValueError( + f"Invalid network use data shape. Expected [str, int, int]; found {value}." + ) + + def render(self, value: str | tuple[int, int]) -> str: + if isinstance(value, tuple): + return f"Preferred: {value[0]}, Max: {value[1]}" + return value + + +# https://docs.globus.org/globus-connect-server/v5.4/api/schemas/Endpoint_1_2_0_schema/ +GCS_ENDPOINT_FIELDS = [ + Field("Endpoint ID", "id"), + Field("Display Name", "display_name"), + Field("Allow UDT", "allow_udt", formatter=formatters.FuzzyBool), + Field("Contact Email", "contact_email"), + Field("Contact Info", "contact_info"), + Field("Department", "department"), + Field("Description", "description"), + Field("Earliest Last Access", "earliest_last_access", formatter=formatters.Date), + Field("GCS Manager URL", "gcs_manager_url"), + Field("GridFTP Control Channel Port", "gridftp_control_channel_port"), + Field("Info Link", "info_link"), + Field("Keywords", "keywords", formatter=formatters.Array), + Field("Network Use", "network_use"), + Field( + "Network Use (Concurrency)", + "[network_use, preferred_concurrency, max_concurrency]", + formatter=NetworkUseFormatter(), + ), + Field( + "Network Use (Parallelism)", + "[network_use, preferred_parallelism, max_parallelism]", + formatter=NetworkUseFormatter(), + ), + Field("Organization", "organization"), + Field("Public", "public", formatter=formatters.FuzzyBool), + Field("Subscription ID", "subscription_id"), +] diff --git a/src/globus_cli/commands/gcs/endpoint/set_subscription_id.py b/src/globus_cli/commands/gcs/endpoint/set_subscription_id.py index 593994206..9cc61207a 100644 --- a/src/globus_cli/commands/gcs/endpoint/set_subscription_id.py +++ b/src/globus_cli/commands/gcs/endpoint/set_subscription_id.py @@ -35,7 +35,7 @@ def convert( self.fail(msg, param, ctx) -@command("set-subscription-id", short_help="Update an endpoint's subscription") +@command("set-subscription-id", short_help="Set a GCS Endpoint's subscription.") @endpoint_id_arg @click.argument("SUBSCRIPTION_ID", type=GCSSubscriptionIdType()) @LoginManager.requires_login("transfer") diff --git a/src/globus_cli/commands/gcs/endpoint/show.py b/src/globus_cli/commands/gcs/endpoint/show.py new file mode 100644 index 000000000..14987f42d --- /dev/null +++ b/src/globus_cli/commands/gcs/endpoint/show.py @@ -0,0 +1,22 @@ +import uuid + +from globus_cli.commands.gcs.endpoint._common import GCS_ENDPOINT_FIELDS +from globus_cli.login_manager import LoginManager +from globus_cli.parsing import command, endpoint_id_arg +from globus_cli.termio import TextMode, display + + +@command("show") +@endpoint_id_arg +@LoginManager.requires_login("transfer") +def show_command( + login_manager: LoginManager, + *, + endpoint_id: uuid.UUID, +) -> None: + """Display information about a particular GCS Endpoint.""" + gcs_client = login_manager.get_gcs_client(endpoint_id=endpoint_id) + + res = gcs_client.get_endpoint() + + display(res, text_mode=TextMode.text_record, fields=GCS_ENDPOINT_FIELDS) diff --git a/src/globus_cli/commands/gcs/endpoint/update.py b/src/globus_cli/commands/gcs/endpoint/update.py new file mode 100644 index 000000000..cdf9def6a --- /dev/null +++ b/src/globus_cli/commands/gcs/endpoint/update.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import functools +import typing as t +import uuid + +import click +import globus_sdk + +from globus_cli.commands.gcs.endpoint._common import GCS_ENDPOINT_FIELDS +from globus_cli.constants import EXPLICIT_NULL, ExplicitNullType +from globus_cli.login_manager import LoginManager +from globus_cli.parsing import CommaDelimitedList, command, endpoint_id_arg +from globus_cli.parsing.param_types.nullable import IntOrNull +from globus_cli.termio import TextMode, display + + +class SubscriptionIdType(click.ParamType): + def get_type_annotation(self, _: click.Parameter) -> type: + return t.cast(type, str | ExplicitNullType) + + def get_metavar(self, _: click.Parameter) -> t.Optional[str]: + return "[|DEFAULT|null]" + + def convert( + self, value: str, param: click.Parameter | None, ctx: click.Context | None + ) -> t.Any: + if value is None or (ctx and ctx.resilient_parsing): + return None + if value.lower() == "null": + return EXPLICIT_NULL + if value.lower() == "default": + return "DEFAULT" + try: + uuid.UUID(value) + return value + except ValueError: + self.fail(f"{value} is not a valid Subscription ID", param, ctx) + + +def network_use_constraints(func: t.Callable) -> t.Callable: + """ + Enforces that custom network use related parameters are present when network use is + set to custom. + """ + + _CUSTOM_NETWORK_USE_PARAMS = frozenset( + { + "max_concurrency", + "max_parallelism", + "preferred_concurrency", + "preferred_parallelism", + } + ) + + @functools.wraps(func) + def wrapped(*args: t.Any, **kwargs: t.Any) -> t.Any: + if kwargs.get("network_use") == "custom": + if any(kwargs.get(k) is None for k in _CUSTOM_NETWORK_USE_PARAMS): + raise click.UsageError( + "When network-use is set to custom, you must also supply " + "`--preferred-concurrency`, `--max-concurrency`, " + "`--preferred-parallelism`, and `--max-parallelism`" + ) + return func(*args, **kwargs) + + return wrapped + + +@command("update") +@endpoint_id_arg +@click.option( + "--allow-udt", + type=bool, + help="A flag indicating whether UDT is allowed for this endpoint.", +) +@click.option( + "--contact-email", + type=str, + help="Email address of the end-user-facing support contact for this endpoint.", +) +@click.option( + "--contact-info", + type=str, + help=( + "Other end-user-facing non-email contact information for the endpoint, e.g. " + "phone and mailing address." + ), +) +@click.option( + "--department", + type=str, + help=( + "[Searchable] The department within an organization which runs the server(s) " + "represented by this endpoint." + ), +) +@click.option( + "--description", + type=str, + help="A human-readable description of the endpoint (max: 4096 characters).", +) +@click.option( + "--display-name", + type=str, + help="[Searchable] A human-readable, non-unique name for the endpoint.", +) +@click.option( + "--gridftp-control-channel-port", + type=IntOrNull(), + help="The TCP port which the Globus control channel should listen on.", +) +@click.option( + "--info-link", + type=str, + help=( + "An end-user-facing URL for a webpage with more information about the endpoint." + ), +) +@click.option( + "--keywords", + type=CommaDelimitedList(), + help="[Searchable] A comma-separated list of search keywords.", +) +@click.option( + "--max-concurrency", + type=int, + help=( + "The endpoint network's custom max concurrency. Requires `network-use` be " + "set to `custom`." + ), +) +@click.option( + "--max-parallelism", + type=int, + help=( + "The endpoint network's custom max parallelism. Requires `network-use` be " + "set to `custom`." + ), +) +@click.option( + "--network-use", + type=click.Choice(["normal", "minimal", "aggressive", "custom"]), + help=( + "A control valve for how Globus will interact with this endpoint over the " + "network. If custom, you must also provide max and preferred concurrency " + "as well as max and preferred parallelism." + ), +) +@click.option( + "--organization", + type=str, + help="The organization which runs the server(s) represented by the endpoint.", +) +@click.option( + "--preferred-concurrency", + type=int, + help=( + "The endpoint network's custom preferred concurrency. Requires `network-use` " + "be set to `custom`." + ), +) +@click.option( + "--preferred-parallelism", + type=int, + help=( + "The endpoint network's custom preferred parallelism. Requires `network-use` " + "be set to `custom`." + ), +) +@click.option( + "--public/--private", + is_flag=True, + default=None, + help=( + "A flag indicating whether this endpoint is visible to all other Globus users. " + "If private, it will only be visible to users which have been granted a " + "role on the endpoint, have been granted a role on one of its collections, or " + "belong to a domain which has access to any of its storage gateways." + ), +) +@click.option( + "--subscription-id", + type=SubscriptionIdType(), + help=( + "'' will set an exact subscription. 'null' will remove the current " + "subscription. 'DEFAULT' will instruct GCS to infer and set the subscription " + "from your user (requires that you are a subscription manager of exactly one " + "subscription)" + ), +) +@network_use_constraints +@LoginManager.requires_login("transfer") +def update_command( + login_manager: LoginManager, + *, + endpoint_id: uuid.UUID, + allow_udt: bool | None, + contact_email: str | None, + contact_info: str | None, + department: str | None, + description: str | None, + display_name: str | None, + gridftp_control_channel_port: int | None | ExplicitNullType, + info_link: str | None, + keywords: list[str] | None, + max_concurrency: int | None, + max_parallelism: int | None, + network_use: t.Literal["normal", "minimal", "aggressive", "custom"] | None, + organization: str | None, + preferred_concurrency: int | None, + preferred_parallelism: int | None, + public: bool | None, + subscription_id: str | None | ExplicitNullType, +) -> None: + """Update a GCS Endpoint.""" + gcs_client = login_manager.get_gcs_client(endpoint_id=endpoint_id) + + endpoint_data = { + "allow_udt": allow_udt, + "contact_email": contact_email, + "contact_info": contact_info, + "department": department, + "description": description, + "display_name": display_name, + "gridftp_control_channel_port": gridftp_control_channel_port, + "info_link": info_link, + "keywords": keywords, + "max_concurrency": max_concurrency, + "max_parallelism": max_parallelism, + "network_use": network_use, + "organization": organization, + "preferred_concurrency": preferred_concurrency, + "preferred_parallelism": preferred_parallelism, + "public": public, + "subscription_id": subscription_id, + } + endpoint_document = globus_sdk.EndpointDocument( + **ExplicitNullType.nullify_dict(endpoint_data) # type: ignore[arg-type] + ) + + res = gcs_client.update_endpoint(endpoint_document, include="endpoint") + + display(res, text_mode=TextMode.text_record, fields=GCS_ENDPOINT_FIELDS) diff --git a/src/globus_cli/parsing/param_types/nullable.py b/src/globus_cli/parsing/param_types/nullable.py index b523a034c..d99e42df3 100644 --- a/src/globus_cli/parsing/param_types/nullable.py +++ b/src/globus_cli/parsing/param_types/nullable.py @@ -48,3 +48,26 @@ def convert( f"'{value}' is not a well-formed http or https URL" ) return value + + +class IntOrNull(click.ParamType): + """ + Very similar to a basic int type, but one in which the empty string will + be converted into an EXPLICIT_NULL + """ + + def get_metavar(self, param: click.Parameter) -> str: + return "[INT|null]" + + def convert( + self, value: str, param: click.Parameter | None, ctx: click.Context | None + ) -> int | ExplicitNullType: + if value == "null": + return EXPLICIT_NULL + else: + try: + return int(value) + except ValueError: + self.fail( + f'{value} is not a valid integer or the string "null"', param, ctx + ) diff --git a/src/globus_cli/termio/formatters/primitive.py b/src/globus_cli/termio/formatters/primitive.py index 2746131ce..5cddd1e26 100644 --- a/src/globus_cli/termio/formatters/primitive.py +++ b/src/globus_cli/termio/formatters/primitive.py @@ -14,16 +14,22 @@ def render(self, value: str) -> str: return value -class DateFormatter(FieldFormatter[datetime.datetime]): - def parse(self, value: t.Any) -> datetime.datetime: +class DateFormatter(FieldFormatter[t.Union[datetime.datetime, datetime.date]]): + def parse(self, value: t.Any) -> datetime.datetime | datetime.date: if not isinstance(value, str): raise ValueError("cannot parse date from non-str value") - return datetime.datetime.fromisoformat(value) - - def render(self, value: datetime.datetime) -> str: - if value.tzinfo is None: - return value.strftime("%Y-%m-%d %H:%M:%S") - return value.astimezone().strftime("%Y-%m-%d %H:%M:%S") + try: + return datetime.date.fromisoformat(value) + except ValueError: + return datetime.datetime.fromisoformat(value) + + def render(self, value: datetime.datetime | datetime.date) -> str: + if isinstance(value, datetime.datetime): + if value.tzinfo is None: + return value.strftime("%Y-%m-%d %H:%M:%S") + return value.astimezone().strftime("%Y-%m-%d %H:%M:%S") + + return value.strftime("%Y-%m-%d") class BoolFormatter(FieldFormatter[bool]): diff --git a/tests/files/api_fixtures/endpoint_introspect.yaml b/tests/files/api_fixtures/endpoint_introspect.yaml new file mode 100644 index 000000000..fc7b81efb --- /dev/null +++ b/tests/files/api_fixtures/endpoint_introspect.yaml @@ -0,0 +1,110 @@ +# Any patching done against `gcs:` requires the magic clue to a manager url at +# https://abc.xyz.data.globus.org (from globus-sdk). This fixture provides that glue. +# No other routes should be registered here to ensure that this fixture can be as +# broadly applicable as possible. + +metadata: + endpoint_id: c574170d-3690-4343-bd16-2c9e49bd1ab0 + gcs_manager_url: https://abc.xyz.data.globus.org + +transfer: + # Endpoint Introspect + - path: /endpoint/c574170d-3690-4343-bd16-2c9e49bd1ab0 + method: get + json: + { + "DATA": [ + { + "DATA_TYPE": "server", + "hostname": "abc.xyz.data.globus.org", + "id": 199521, + "incoming_data_port_end": null, + "incoming_data_port_start": null, + "is_connected": true, + "is_paused": false, + "outgoing_data_port_end": null, + "outgoing_data_port_start": null, + "port": 2811, + "scheme": "gsiftp", + "subject": null, + "uri": "gsiftp://abc.xyz.data.globus.org:2811" + } + ], + "DATA_TYPE": "endpoint", + "acl_available": false, + "acl_editable": false, + "activated": false, + "authentication_assurance_timeout": null, + "authentication_policy_id": null, + "authentication_timeout_mins": null, + "canonical_name": "u_f8e33pwjszg6liri3mve3bzpre#e1763157-9fa0-4f0f-88f5-a8b49d317bee", + "contact_email": "foo@globus.org", + "contact_info": null, + "default_directory": null, + "department": null, + "description": null, + "disable_anonymous_writes": false, + "disable_verify": false, + "display_name": "My Cool GCSv5.4 Sandbox Endpoint", + "entity_type": "GCSv5_endpoint", + "expire_time": null, + "expires_in": 0, + "force_encryption": false, + "force_verify": false, + "french_english_bilingual": false, + "gcp_connected": null, + "gcp_paused": null, + "gcs_manager_url": "https://abc.xyz.data.globus.org", + "gcs_version": "5.4.71", + "globus_connect_setup_key": null, + "high_assurance": false, + "host_endpoint": null, + "host_endpoint_display_name": null, + "host_endpoint_id": null, + "host_path": null, + "https_server": null, + "id": "c574170d-3690-4343-bd16-2c9e49bd1ab0", + "in_use": false, + "info_link": null, + "is_globus_connect": false, + "keywords": null, + "last_accessed_time": null, + "local_user_info_available": null, + "location": "Automatic", + "mapped_collection_display_name": null, + "mapped_collection_id": null, + "max_concurrency": 4, + "max_parallelism": 8, + "mfa_required": false, + "my_effective_roles": [ + "administrator", + "activity_manager", + "activity_monitor" + ], + "myproxy_dn": null, + "myproxy_server": null, + "name": "e1763157-9fa0-4f0f-88f5-a8b49d317bee", + "network_use": "normal", + "non_functional": true, + "non_functional_endpoint_display_name": null, + "non_functional_endpoint_id": null, + "oauth_server": null, + "organization": "Globus", + "owner_id": "c574170d-3690-4343-bd16-2c9e49bd1ab0", + "owner_string": "foo@globus.org", + "preferred_concurrency": 2, + "preferred_parallelism": 4, + "public": true, + "requester_pays": false, + "s3_owner_activated": false, + "s3_url": null, + "shareable": true, + "sharing_target_endpoint": null, + "sharing_target_root_path": null, + "storage_type": null, + "subscription_id": "d6887f92-052b-42aa-a3f8-993781450c4d", + "tlsftp_server": null, + "user_message": null, + "user_message_link": null, + "username": "u_f8e33pwjszg6liri3mve3bzpre" + } diff --git a/tests/files/api_fixtures/gcs_endpoint_operations.yaml b/tests/files/api_fixtures/gcs_endpoint_operations.yaml new file mode 100644 index 000000000..eb2371afd --- /dev/null +++ b/tests/files/api_fixtures/gcs_endpoint_operations.yaml @@ -0,0 +1,56 @@ + +gcs: + # Show Endpoint + - path: /endpoint + method: GET + json: + { + "DATA_TYPE": "result#1.0.0", + "code": "success", + "data": [ + { + "DATA_TYPE": "endpoint#1.2.0", + "allow_udt": false, + "contact_email": "foo@globus.org", + "display_name": "My Cool GCSv5.4 Sandbox Endpoint", + "earliest_last_access": "2023-12-15", + "gcs_manager_url": "https://abc.xyz.data.globus.org", + "gridftp_control_channel_port": 2811, + "id": "c574170d-3690-4343-bd16-2c9e49bd1ab0", + "network_use": "normal", + "organization": "Globus", + "public": true, + "subscription_id": "d6887f92-052b-42aa-a3f8-993781450c4d" + } + ], + "detail": "success", + "has_next_page": false, + "http_response_code": 200 + } + # Update Endpoint (assumes request specifies include=["endpoint"]) + - path: /endpoint + method: PATCH + json: + { + "DATA_TYPE": "result#1.0.0", + "code": "success", + "data": [ + { + "DATA_TYPE": "endpoint#1.2.0", + "allow_udt": false, + "contact_email": "foo@globus.org", + "display_name": "My Cool GCSv5.4 Sandbox Endpoint", + "earliest_last_access": "2023-12-15", + "gcs_manager_url": "https://abc.xyz.data.globus.org", + "gridftp_control_channel_port": 2811, + "id": "c574170d-3690-4343-bd16-2c9e49bd1ab0", + "network_use": "normal", + "organization": "Globus", + "public": true, + "subscription_id": "d6887f92-052b-42aa-a3f8-993781450c4d" + } + ], + "detail": "success", + "has_next_page": false, + "http_response_code": 200 + } diff --git a/tests/functional/gcs/endpoint/test_show.py b/tests/functional/gcs/endpoint/test_show.py new file mode 100644 index 000000000..f825bf468 --- /dev/null +++ b/tests/functional/gcs/endpoint/test_show.py @@ -0,0 +1,65 @@ +import globus_sdk +import responses +from globus_sdk._testing import load_response_set + + +def test_endpoint_show(run_line, add_gcs_login): + endpoint_id = load_response_set("cli.endpoint_introspect").metadata["endpoint_id"] + load_response_set("cli.gcs_endpoint_operations") + + add_gcs_login(endpoint_id) + + resp = run_line(f"globus gcs endpoint show {endpoint_id}") + + assert endpoint_id in resp.stdout + + +def test_endpoint_show__normal_network_use_formatting(run_line, add_gcs_login): + meta = load_response_set("cli.endpoint_introspect").metadata + endpoint_id = meta["endpoint_id"] + manager_url = meta["gcs_manager_url"] + load_response_set("cli.gcs_endpoint_operations") + + add_gcs_login(endpoint_id) + + response = globus_sdk.GCSClient(manager_url).get_endpoint().full_data + response["data"][0]["network_use"] = "normal" + responses.replace("GET", f"{manager_url}/api/endpoint", json=response) + + resp = run_line(f"globus gcs endpoint show {endpoint_id}") + + assert _printed_table_val(resp.stdout, "Network Use") == "normal" + assert _printed_table_val(resp.stdout, "Network Use (Concurrency)") == "normal" + assert _printed_table_val(resp.stdout, "Network Use (Parallelism)") == "normal" + + +def _test_endpoint_show__custom_network_use_formatting(run_line, add_gcs_login): + meta = load_response_set("cli.endpoint_introspect").metadata + endpoint_id = meta["endpoint_id"] + manager_url = meta["gcs_manager_url"] + load_response_set("cli.gcs_endpoint_operations") + + add_gcs_login(endpoint_id) + + response = globus_sdk.GCSClient(manager_url).get_endpoint().full_data + response["data"][0]["network_use"] = "custom" + response["data"][0]["preferred_concurrency"] = 1 + response["data"][0]["max_concurrency"] = 2 + response["data"][0]["preferred_parallelism"] = 3 + response["data"][0]["max_parallelism"] = 4 + responses.replace("GET", f"{manager_url}/api/endpoint", json=response) + + resp = run_line(f"globus gcs endpoint show {endpoint_id}") + + assert _printed_table_val(resp.stdout, "Network Use") == "custom" + actual_concurrency = _printed_table_val(resp.stdout, "Network Use (Concurrency)") + assert actual_concurrency == "Preferred: 1, Max: 2" + actual_parallelism = _printed_table_val(resp.stdout, "Network Use (Parallelism)") + assert actual_parallelism == "Preferred: 3, Max: 4" + + +def _printed_table_val(stdout: str, key: str) -> str: + prefix = f"{key}:" + for line in stdout.splitlines(): + if line.startswith(prefix): + return line.split(":")[1].strip() diff --git a/tests/functional/gcs/endpoint/test_update.py b/tests/functional/gcs/endpoint/test_update.py new file mode 100644 index 000000000..7bc582f48 --- /dev/null +++ b/tests/functional/gcs/endpoint/test_update.py @@ -0,0 +1,45 @@ +import pytest +from globus_sdk._testing import load_response_set + + +def test_update_endpoint(run_line, add_gcs_login): + endpoint_id = load_response_set("cli.endpoint_introspect").metadata["endpoint_id"] + load_response_set("cli.gcs_endpoint_operations") + + add_gcs_login(endpoint_id) + + resp = run_line(f"globus gcs endpoint update {endpoint_id} --display-name new-name") + + assert endpoint_id in resp.stdout + + +@pytest.mark.parametrize("field", ("subscription-id", "gridftp-control-channel-port")) +def test_update_endpoint__nullable_fields_are_nullable(field, run_line, add_gcs_login): + endpoint_id = load_response_set("cli.endpoint_introspect").metadata["endpoint_id"] + load_response_set("cli.gcs_endpoint_operations") + + add_gcs_login(endpoint_id) + + run_line(f"globus gcs endpoint update {endpoint_id} --{field} null") + + +def test_update_endpoint__network_use_custom_fields_are_required( + run_line, add_gcs_login +): + endpoint_id = load_response_set("cli.endpoint_introspect").metadata["endpoint_id"] + load_response_set("cli.gcs_endpoint_operations") + + add_gcs_login(endpoint_id) + + resp = run_line( + f"globus gcs endpoint update {endpoint_id} --network-use custom", + assert_exit_code=2, + ) + + for k in ( + "max-concurrency", + "max-parallelism", + "preferred-concurrency", + "preferred-parallelism", + ): + assert k in resp.stderr