From 19c3066856b4ea1a4bca5e3a13a5cbef86857fbd Mon Sep 17 00:00:00 2001 From: aaschaer Date: Thu, 26 Feb 2026 16:13:01 -0600 Subject: [PATCH 1/3] TransferClientV2, more JSON:API support, and tunnels fixes --- .../20260226_154303_aaschaer_transfer_v2.rst | 17 + docs/experimental/index.rst | 13 + .../experimental/transfer_v2/__init__.py | 7 + .../experimental/transfer_v2/client.py | 314 ++++++++++++++++++ .../experimental/transfer_v2/data/__init__.py | 9 + .../transfer_v2}/data/tunnel_data.py | 8 +- .../experimental/transfer_v2/transport.py | 50 +++ src/globus_sdk/paging/__init__.py | 2 + src/globus_sdk/paging/jsonapi.py | 39 +++ src/globus_sdk/response.py | 7 + src/globus_sdk/services/transfer/client.py | 75 ++++- .../services/transfer/data/__init__.py | 5 +- 12 files changed, 527 insertions(+), 19 deletions(-) create mode 100644 changelog.d/20260226_154303_aaschaer_transfer_v2.rst create mode 100644 src/globus_sdk/experimental/transfer_v2/__init__.py create mode 100644 src/globus_sdk/experimental/transfer_v2/client.py create mode 100644 src/globus_sdk/experimental/transfer_v2/data/__init__.py rename src/globus_sdk/{services/transfer => experimental/transfer_v2}/data/tunnel_data.py (86%) create mode 100644 src/globus_sdk/experimental/transfer_v2/transport.py create mode 100644 src/globus_sdk/paging/jsonapi.py diff --git a/changelog.d/20260226_154303_aaschaer_transfer_v2.rst b/changelog.d/20260226_154303_aaschaer_transfer_v2.rst new file mode 100644 index 000000000..3838a9edd --- /dev/null +++ b/changelog.d/20260226_154303_aaschaer_transfer_v2.rst @@ -0,0 +1,17 @@ +Added +----- + +- Added the experimental ``TransferClientV2`` client class (:pr:`NUMBER`) +- JSON:API iteration and pagination is now supported through + ``IterableJSONAPIResponse`` and ``JSONAPIPaginator`` (:pr:`NUMBER`) + +Changed +------- +- all tunnels support in ``TransferClient`` has been labeled as Beta as the + underlying API is still in development. For the most up to date interfaces + it is recommended to use the ``TransferClientV2`` (:pr:`NUMBER`) + +Fixed +----- + +- various fixes for tunnels support in ``TransferClient`` (:pr:`NUMBER`) diff --git a/docs/experimental/index.rst b/docs/experimental/index.rst index d9a56000b..53a65d288 100644 --- a/docs/experimental/index.rst +++ b/docs/experimental/index.rst @@ -23,3 +23,16 @@ Once an interface has been evaluated and proven to be sufficiently coherent, we "stabilize" it, moving it to an appropriate section in the main module and leaving behind an alias in the requisite experimental module to minimize import breakage. These aliases will persist until the next major version release of the SDK (e.g., v3 -> v4). + + +Service Client Lifecycle +------------------------ + +A service client is added in the ``experimental`` module when the service +functionality it provides an interface to in Beta or under active development +and may experience changes in that could break the interfaces in the SDK. + +Once the service has moved out of active development the service client +will be moved into the main services module leaving behind an alias in the +experimental module to minimize import breakage. These aliases will persist +until the next major version release of the SDK (e.g., v3 -> v4). diff --git a/src/globus_sdk/experimental/transfer_v2/__init__.py b/src/globus_sdk/experimental/transfer_v2/__init__.py new file mode 100644 index 000000000..2f46a691d --- /dev/null +++ b/src/globus_sdk/experimental/transfer_v2/__init__.py @@ -0,0 +1,7 @@ +from .client import TransferClientV2 +from .data import CreateTunnelData + +__all__ = ( + "TransferClientV2", + "CreateTunnelData", +) diff --git a/src/globus_sdk/experimental/transfer_v2/client.py b/src/globus_sdk/experimental/transfer_v2/client.py new file mode 100644 index 000000000..713faf457 --- /dev/null +++ b/src/globus_sdk/experimental/transfer_v2/client.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import logging +import typing as t +import uuid + +from globus_sdk import client, exc, paging, response +from globus_sdk._missing import MISSING +from globus_sdk.response import IterableJSONAPIResponse +from globus_sdk.scopes import TransferScopes +from globus_sdk.services.transfer.errors import TransferAPIError +from globus_sdk.transport import RetryConfig + +from .data import CreateTunnelData +from .transport import TRANSFER_V2_DEFAULT_RETRY_CHECKS + +log = logging.getLogger(__name__) + + +class TransferClientV2(client.BaseClient): + r""" + Client for the `/v2/ routes of the Globus Transfer API + `_. + + .. sdk-sphinx-copy-params:: BaseClient + + This class provides helper methods for /v2/ functionality in the Transfer + API as it is developed. + + **Paginated Calls** + + Methods which support pagination can be called as paginated or unpaginated methods. + If the method name is ``TransferClientV2.foo``, the paginated version is + ``TransferClientV2.paginated.foo``. + Using ``TransferClientV2.list_stream_access_points`` as an example:: + + from globus_sdk.experimental.transfer_v2 import TransferClientV2 + tc = TransferClientV2(...) + + # this is the unpaginated version + for stream_access_point in tc.list_stream_access_points(): + print(stream_access_point["attributes"]["display_name"]) + + # this is the paginated version + for page in tc.paginated.list_stream_access_points(): + for stream_access_point in page: + print(stream_access_point["attributes"]["display_name"]) + + .. automethodlist:: globus_sdk.TransferClient + """ + + service_name = "transfer" + error_class = TransferAPIError + scopes = TransferScopes + default_scope_requirements = [TransferScopes.all] + + def _register_standard_retry_checks(self, retry_config: RetryConfig) -> None: + """Override the default retry checks.""" + retry_config.checks.register_many_checks(TRANSFER_V2_DEFAULT_RETRY_CHECKS) + + # Tunnel methods + + def create_tunnel( + self, + data: dict[str, t.Any] | CreateTunnelData, + ) -> response.GlobusHTTPResponse: + """ + :param data: Parameters for the tunnel creation + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + tc = globus_sdk.TrasferClientV2(...) + result = tc.create_tunnel(data) + print(result["data"]["id"]) + + .. tab-item:: API Info + + ``POST /v2/tunnels`` + """ + log.debug("TransferClientV2.create_tunnel(...)") + try: + data_element = data["data"] + except KeyError as e: + raise exc.GlobusSDKUsageError( + "create_tunnel() body was malformed (missing the 'data' key). " + "Use CreateTunnelData to easily create correct documents." + ) from e + + try: + attributes = data_element["attributes"] + except KeyError: + data_element["attributes"] = {} + attributes = data_element["attributes"] + if attributes.get("submission_id", MISSING) is MISSING: + log.debug("create_tunnel auto-creating submission_id") + attributes["submission_id"] = str(uuid.uuid1()) + + r = self.post("/v2/tunnels", data=data) + return r + + def update_tunnel( + self, + tunnel_id: str | uuid.UUID, + update_doc: dict[str, t.Any], + ) -> response.GlobusHTTPResponse: + r""" + :param tunnel_id: The ID of the Tunnel. + :param update_doc: The document that will be sent to the patch API. + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + tc = globus_sdk.TrasferClientV2(...) + "data" = { + "type": "Tunnel", + "attributes": { + "state": "STOPPING", + }, + } + result = tc.update_tunnel(tunnel_id, data) + print(result["data"]) + + .. tab-item:: API Info + + ``PATCH /v2/tunnels/`` + """ + log.debug(f"TransferClientV2.update_tunnel({tunnel_id}, {update_doc})") + r = self.patch(f"/v2/tunnels/{tunnel_id}", data=update_doc) + return r + + def get_tunnel( + self, + tunnel_id: str | uuid.UUID, + *, + query_params: dict[str, t.Any] | None = None, + ) -> response.GlobusHTTPResponse: + """ + :param tunnel_id: The ID of the Tunnel which we are fetching details about. + :param query_params: Any additional parameters will be passed through + as query params. + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + tc = globus_sdk.TrasferClientV2(...) + result = tc.show_tunnel(tunnel_id) + print(result["data"]) + + .. tab-item:: API Info + + ``GET /v2/tunnels/`` + """ + log.debug("TransferClientV2.get_tunnel({tunnel_id}, {query_params})") + r = self.get(f"/v2/tunnels/{tunnel_id}", query_params=query_params) + return r + + def delete_tunnel( + self, + tunnel_id: str | uuid.UUID, + ) -> response.GlobusHTTPResponse: + """ + :param tunnel_id: The ID of the Tunnel to be deleted. + + This will clean up all data associated with a Tunnel. + Note that Tunnels must be stopped before they can be deleted. + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + tc = globus_sdk.TrasferClientV2(...) + tc.delete_tunnel(tunnel_id) + + .. tab-item:: API Info + + ``DELETE /v2/tunnels/`` + """ + log.debug(f"TransferClientV2.delete_tunnel({tunnel_id})") + r = self.delete(f"/v2/tunnels/{tunnel_id}") + return r + + def list_tunnels( + self, + *, + query_params: dict[str, t.Any] | None = None, + ) -> IterableJSONAPIResponse: + """ + :param query_params: Any additional parameters will be passed through + as query params. + + This will list all the Tunnels created by the authorized user. + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + tc = globus_sdk.TrasferClientV2(...) + tc.list_tunnels(tunnel_id) + + .. tab-item:: API Info + + ``GET /v2/tunnels/`` + """ + log.debug(f"TransferClientV2.list_tunnels({query_params})") + r = self.get("/v2/tunnels", query_params=query_params) + return IterableJSONAPIResponse(r) + + def get_tunnel_events( + self, + tunnel_id: str | uuid.UUID, + *, + query_params: dict[str, t.Any] | None = None, + ) -> IterableJSONAPIResponse: + """ + :param tunnel_id: The ID of the Tunnel which we are fetching events about. + :param query_params: Any additional parameters will be passed through + as query params. + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + tc = globus_sdk.TrasferClientV2(...) + result = tc.get_tunnel_events(tunnel_id) + print(result["data"]) + + .. tab-item:: API Info + + ``GET /v2/tunnels//events`` + """ + log.debug(f"TransferClientV2.get_tunnel_events({tunnel_id}, {query_params})") + r = self.get(f"/v2/tunnels/{tunnel_id}/events", query_params=query_params) + return IterableJSONAPIResponse(r) + + # Stream access point methods + + def get_stream_access_point( + self, + stream_ap_id: str | uuid.UUID, + *, + query_params: dict[str, t.Any] | None = None, + ) -> response.GlobusHTTPResponse: + """ + :param stream_ap_id: The ID of the steaming access point to lookup. + :param query_params: Any additional parameters will be passed through + as query params. + + Get a stream access point by id. + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + tc = globus_sdk.TrasferClientV2(...) + tc.get_stream_access_point(stream_ap_id) + + .. tab-item:: API Info + + ``GET /v2/stream_access_points/`` + """ + log.debug( + f"TransferClientV2.get_stream_access_point({stream_ap_id}, {query_params})" + ) + r = self.get( + f"/v2/stream_access_points/{stream_ap_id}", query_params=query_params + ) + return r + + @paging.has_paginator(paging.JSONAPIPaginator) + def list_stream_access_points( + self, + *, + query_params: dict[str, t.Any] | None = None, + ) -> IterableJSONAPIResponse: + """ + :param query_params: Any additional parameters will be passed through + as query params. + + List stream access points. + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + tc = globus_sdk.TrasferClientV2(...) + tc.list_stream_access_points() + + .. tab-item:: API Info + + ``GET /v2/stream_access_points`` + """ + log.debug(f"TransferClientV2.list_stream_access_points({query_params})") + r = self.get("/v2/stream_access_points", query_params=query_params) + return IterableJSONAPIResponse(r) diff --git a/src/globus_sdk/experimental/transfer_v2/data/__init__.py b/src/globus_sdk/experimental/transfer_v2/data/__init__.py new file mode 100644 index 000000000..9e43ec2b8 --- /dev/null +++ b/src/globus_sdk/experimental/transfer_v2/data/__init__.py @@ -0,0 +1,9 @@ +""" +Data helper classes for constructing Transfer v2 API documents. All classes should +be Payload types, so they can be passed seamlessly to +:class:`TransferClient ` methods without conversion. +""" + +from .tunnel_data import CreateTunnelData + +__all__ = ("CreateTunnelData",) diff --git a/src/globus_sdk/services/transfer/data/tunnel_data.py b/src/globus_sdk/experimental/transfer_v2/data/tunnel_data.py similarity index 86% rename from src/globus_sdk/services/transfer/data/tunnel_data.py rename to src/globus_sdk/experimental/transfer_v2/data/tunnel_data.py index 0098a76fc..e06a6f730 100644 --- a/src/globus_sdk/services/transfer/data/tunnel_data.py +++ b/src/globus_sdk/experimental/transfer_v2/data/tunnel_data.py @@ -11,6 +11,12 @@ class CreateTunnelData(GlobusPayload): + """ + Convenience class for constructing a tunnel document to use as the + ``data`` parameter to + :meth:`create_tunnel `. + """ + def __init__( self, initiator_stream_access_point: uuid.UUID | str, @@ -23,7 +29,7 @@ def __init__( additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - log.debug("Creating a new TunnelData object") + log.debug("Creating a new CreateTunnelData object") relationships = { "listener": { diff --git a/src/globus_sdk/experimental/transfer_v2/transport.py b/src/globus_sdk/experimental/transfer_v2/transport.py new file mode 100644 index 000000000..980b19855 --- /dev/null +++ b/src/globus_sdk/experimental/transfer_v2/transport.py @@ -0,0 +1,50 @@ +""" +Custom retry check collection for the TransferClientV2 that overrides +the default check_transient_error +""" + +from __future__ import annotations + +from globus_sdk import exc +from globus_sdk.transport import RetryCheck, RetryCheckResult, RetryContext +from globus_sdk.transport.default_retry_checks import ( + DEFAULT_RETRY_CHECKS, + check_transient_error, +) + + +def check_transfer_v2_transient_error(ctx: RetryContext) -> RetryCheckResult: + """ + check for transient error status codes which could be resolved by + retrying the request. Does not retry ExternalErrors or EndpointErrors + as those are unlikely to actually be transient. + + :param ctx: The context object which describes the state of the request and the + retries which may already have been attempted + """ + retry_config = ctx.caller_info.retry_config + if ctx.response is not None and ( + ctx.response.status_code in retry_config.transient_error_status_codes + ): + codes = [] + try: + if isinstance(ctx.exception, exc.GlobusAPIError): + errors = ctx.exception.errors + codes = [error.code for error in errors] + except ValueError: + pass + + for non_retry_code in ("ExternalError", "EndpointError"): + if non_retry_code in codes: + return RetryCheckResult.no_decision + + return RetryCheckResult.do_retry + + return RetryCheckResult.no_decision + + +# Transfer retry checks are the defaults with the transient error one replaced +TRANSFER_V2_DEFAULT_RETRY_CHECKS: tuple[RetryCheck, ...] = tuple( + check_transfer_v2_transient_error if check is check_transient_error else check + for check in DEFAULT_RETRY_CHECKS +) diff --git a/src/globus_sdk/paging/__init__.py b/src/globus_sdk/paging/__init__.py index dca262dca..10c5b7f7e 100644 --- a/src/globus_sdk/paging/__init__.py +++ b/src/globus_sdk/paging/__init__.py @@ -1,4 +1,5 @@ from .base import Paginator, has_paginator +from .jsonapi import JSONAPIPaginator from .last_key import LastKeyPaginator from .limit_offset import HasNextPaginator, LimitOffsetTotalPaginator from .marker import MarkerPaginator, NullableMarkerPaginator @@ -15,4 +16,5 @@ "LastKeyPaginator", "HasNextPaginator", "LimitOffsetTotalPaginator", + "JSONAPIPaginator", ) diff --git a/src/globus_sdk/paging/jsonapi.py b/src/globus_sdk/paging/jsonapi.py new file mode 100644 index 000000000..b9f0c37a8 --- /dev/null +++ b/src/globus_sdk/paging/jsonapi.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import typing as t +from urllib.parse import parse_qs, urlsplit + +from .base import PageT, Paginator + + +class JSONAPIPaginator(Paginator[PageT]): + """ + A paginator which uses the next link of a JSON:API response if present + to fetch more pages. + + Assumes the next link only changes query parameters between calls to + `method` and that `method` supports a keyword argument of `query_params` + which those changed query parameters will be passed to. + """ + + _REQUIRES_METHOD_KWARGS = ("get_method",) + + def _get_next_link(self, page: dict[str, t.Any]) -> str | None: + links = page.get("links") + if links: + next_link = links.get("next") + if isinstance(next_link, str): + return next_link + return None + + def pages(self) -> t.Iterator[PageT]: + while True: + current_page = self.method(*self.client_args, **self.client_kwargs) + yield current_page + + next_link = self._get_next_link(current_page) + if next_link: + next_link_query_params = parse_qs(urlsplit(next_link).query) + self.client_kwargs["query_params"] = next_link_query_params + else: + break diff --git a/src/globus_sdk/response.py b/src/globus_sdk/response.py index a5c282c44..0d2ea2d7b 100644 --- a/src/globus_sdk/response.py +++ b/src/globus_sdk/response.py @@ -251,3 +251,10 @@ def __len__(self) -> int: f"type is '{type(self.data).__name__}'" ) return len(self.data) + + +class IterableJSONAPIResponse(IterableResponse): + """This response class is for iterating on JSON:API responses that have an + array of data in their top level `data` key.""" + + default_iter_key = "data" diff --git a/src/globus_sdk/services/transfer/client.py b/src/globus_sdk/services/transfer/client.py index bdd3fb71e..c8f661957 100644 --- a/src/globus_sdk/services/transfer/client.py +++ b/src/globus_sdk/services/transfer/client.py @@ -10,6 +10,7 @@ from globus_sdk._internal.remarshal import commajoin from globus_sdk._internal.type_definitions import DateLike, IntLike from globus_sdk._missing import MISSING, MissingType +from globus_sdk.response import IterableJSONAPIResponse from globus_sdk.scopes import GCSCollectionScopes, Scope, TransferScopes from globus_sdk.transport import RetryConfig @@ -2707,6 +2708,11 @@ def create_tunnel( data: dict[str, t.Any] | CreateTunnelData, ) -> response.GlobusHTTPResponse: """ + .. note:: + + Tunnels functionality is currently in Beta and may experience + changes that will break this interface + :param data: Parameters for the tunnel creation .. tab-set:: @@ -2715,7 +2721,7 @@ def create_tunnel( .. code-block:: python - tc = globus_sdk.TunnelClient(...) + tc = globus_sdk.TransferClient(...) result = tc.create_tunnel(data) print(result["data"]["id"]) @@ -2738,8 +2744,8 @@ def create_tunnel( data_element["attributes"] = {} attributes = data_element["attributes"] if attributes.get("submission_id", MISSING) is MISSING: - log.debug("create_tunnel auto-fetching submission_id") - attributes["submission_id"] = self.get_submission_id()["value"] + log.debug("create_tunnel auto-creating submission_id") + attributes["submission_id"] = str(uuid.uuid1()) r = self.post("/v2/tunnels", data=data) return r @@ -2750,6 +2756,11 @@ def update_tunnel( update_doc: dict[str, t.Any], ) -> response.GlobusHTTPResponse: r""" + .. note:: + + Tunnels functionality is currently in Beta and may experience + changes that will break this interface + :param tunnel_id: The ID of the Tunnel. :param update_doc: The document that will be sent to the patch API. @@ -2759,7 +2770,7 @@ def update_tunnel( .. code-block:: python - tc = globus_sdk.TunnelClient(...) + tc = globus_sdk.TransferClient(...) "data" = { "type": "Tunnel", "attributes": { @@ -2773,6 +2784,7 @@ def update_tunnel( ``PATCH /v2/tunnels/`` """ + log.debug(f"TransferClient.update_tunnel({tunnel_id}, {update_doc})") r = self.patch(f"/v2/tunnels/{tunnel_id}", data=update_doc) return r @@ -2783,6 +2795,11 @@ def get_tunnel( query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ + .. note:: + + Tunnels functionality is currently in Beta and may experience + changes that will break this interface + :param tunnel_id: The ID of the Tunnel which we are fetching details about. :param query_params: Any additional parameters will be passed through as query params. @@ -2793,7 +2810,7 @@ def get_tunnel( .. code-block:: python - tc = globus_sdk.TunnelClient(...) + tc = globus_sdk.TransferClient(...) result = tc.show_tunnel(tunnel_id) print(result["data"]) @@ -2801,7 +2818,7 @@ def get_tunnel( ``GET /v2/tunnels/`` """ - log.debug("TransferClient.get_tunnel(...)") + log.debug("TransferClient.get_tunnel({tunnel_id}, {query_params})") r = self.get(f"/v2/tunnels/{tunnel_id}", query_params=query_params) return r @@ -2810,6 +2827,11 @@ def delete_tunnel( tunnel_id: str | uuid.UUID, ) -> response.GlobusHTTPResponse: """ + .. note:: + + Tunnels functionality is currently in Beta and may experience + changes that will break this interface + :param tunnel_id: The ID of the Tunnel to be deleted. This will clean up all data associated with a Tunnel. @@ -2821,14 +2843,14 @@ def delete_tunnel( .. code-block:: python - tc = globus_sdk.TunnelClient(...) + tc = globus_sdk.TransferClient(...) tc.delete_tunnel(tunnel_id) .. tab-item:: API Info ``DELETE /v2/tunnels/`` """ - log.debug("TransferClient.delete_tunnel(...)") + log.debug(f"TransferClient.delete_tunnel({tunnel_id})") r = self.delete(f"/v2/tunnels/{tunnel_id}") return r @@ -2836,8 +2858,13 @@ def list_tunnels( self, *, query_params: dict[str, t.Any] | None = None, - ) -> IterableTransferResponse: + ) -> IterableJSONAPIResponse: """ + .. note:: + + Tunnels functionality is currently in Beta and may experience + changes that will break this interface + :param query_params: Any additional parameters will be passed through as query params. @@ -2849,16 +2876,16 @@ def list_tunnels( .. code-block:: python - tc = globus_sdk.TunnelClient(...) + tc = globus_sdk.TransferClient(...) tc.list_tunnels(tunnel_id) .. tab-item:: API Info ``GET /v2/tunnels/`` """ - log.debug("TransferClient.list_tunnels(...)") + log.debug(f"TransferClient.list_tunnels({query_params})") r = self.get("/v2/tunnels", query_params=query_params) - return IterableTransferResponse(r) + return IterableJSONAPIResponse(r) def get_stream_access_point( self, @@ -2867,11 +2894,16 @@ def get_stream_access_point( query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ + .. note:: + + Tunnels functionality is currently in Beta and may experience + changes that will break this interface + :param stream_ap_id: The ID of the steaming access point to lookup. :param query_params: Any additional parameters will be passed through as query params. - This will list all the Tunnels created by the authorized user. + Get a stream access point by id. .. tab-set:: @@ -2879,14 +2911,16 @@ def get_stream_access_point( .. code-block:: python - tc = globus_sdk.TunnelClient(...) + tc = globus_sdk.TransferClient(...) tc.get_stream_ap(stream_ap_id) .. tab-item:: API Info ``GET /v2/stream_access_points/`` """ - log.debug("TransferClient.get_stream_ap(...)") + log.debug( + f"TransferClient.get_stream_access_point({stream_ap_id}, {query_params})" + ) r = self.get( f"/v2/stream_access_points/{stream_ap_id}", query_params=query_params ) @@ -2899,17 +2933,24 @@ def get_tunnel_events( query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ + .. note:: + + Tunnels functionality is currently in Beta and may experience + changes that will break this interface + :param tunnel_id: The ID of the Tunnel which we are fetching events about. :param query_params: Any additional parameters will be passed through as query params. + List all events for a specific tunnel. + .. tab-set:: .. tab-item:: Example Usage .. code-block:: python - tc = globus_sdk.TunnelClient(...) + tc = globus_sdk.TransferClient(...) result = tc.get_tunnel_events(tunnel_id) print(result["data"]) @@ -2917,6 +2958,6 @@ def get_tunnel_events( ``GET /v2/tunnels//events`` """ - log.debug("TransferClient.get_tunnel_events(...)") + log.debug(f"TransferClient.get_tunnel_events({tunnel_id}, {query_params})") r = self.get(f"/v2/tunnels/{tunnel_id}/events", query_params=query_params) return r diff --git a/src/globus_sdk/services/transfer/data/__init__.py b/src/globus_sdk/services/transfer/data/__init__.py index 26e7ce2b7..e786aae79 100644 --- a/src/globus_sdk/services/transfer/data/__init__.py +++ b/src/globus_sdk/services/transfer/data/__init__.py @@ -4,8 +4,11 @@ :class:`TransferClient ` methods without conversion. """ +# CreateTunnelData has been moved to experimental.transfer_v2 but is +# provided here for backwards compatibility +from globus_sdk.experimental.transfer_v2.data import CreateTunnelData + from .delete_data import DeleteData from .transfer_data import TransferData -from .tunnel_data import CreateTunnelData __all__ = ("TransferData", "DeleteData", "CreateTunnelData") From e4edb5fddfd7f9967dcba99feb8089e84f561fd4 Mon Sep 17 00:00:00 2001 From: aaschaer Date: Tue, 3 Mar 2026 16:11:02 -0600 Subject: [PATCH 2/3] TransferClientV2 testing data and tests --- src/globus_sdk/experimental/__init__.py | 6 ++ .../experimental/transfer_v2/client.py | 2 +- .../experimental/transfer_v2/transport.py | 21 ++--- .../services/transfer/data/__init__.py | 4 +- src/globus_sdk/testing/registry.py | 9 ++- .../services/transfer/test_create_tunnel.py | 1 - .../transfer/test_get_stream_access_point.py | 2 +- .../services/transfer/test_list_tunnel.py | 2 +- tests/unit/test_paging.py | 79 ++++++++++++++++++- 9 files changed, 104 insertions(+), 22 deletions(-) diff --git a/src/globus_sdk/experimental/__init__.py b/src/globus_sdk/experimental/__init__.py index e69de29bb..c42a60f82 100644 --- a/src/globus_sdk/experimental/__init__.py +++ b/src/globus_sdk/experimental/__init__.py @@ -0,0 +1,6 @@ +from .transfer_v2 import CreateTunnelData, TransferClientV2 + +__all__ = ( + "TransferClientV2", + "CreateTunnelData", +) diff --git a/src/globus_sdk/experimental/transfer_v2/client.py b/src/globus_sdk/experimental/transfer_v2/client.py index 713faf457..d59a208b2 100644 --- a/src/globus_sdk/experimental/transfer_v2/client.py +++ b/src/globus_sdk/experimental/transfer_v2/client.py @@ -34,7 +34,7 @@ class TransferClientV2(client.BaseClient): ``TransferClientV2.paginated.foo``. Using ``TransferClientV2.list_stream_access_points`` as an example:: - from globus_sdk.experimental.transfer_v2 import TransferClientV2 + from globus_sdk.experimental import TransferClientV2 tc = TransferClientV2(...) # this is the unpaginated version diff --git a/src/globus_sdk/experimental/transfer_v2/transport.py b/src/globus_sdk/experimental/transfer_v2/transport.py index 980b19855..c18609978 100644 --- a/src/globus_sdk/experimental/transfer_v2/transport.py +++ b/src/globus_sdk/experimental/transfer_v2/transport.py @@ -5,7 +5,6 @@ from __future__ import annotations -from globus_sdk import exc from globus_sdk.transport import RetryCheck, RetryCheckResult, RetryContext from globus_sdk.transport.default_retry_checks import ( DEFAULT_RETRY_CHECKS, @@ -22,22 +21,24 @@ def check_transfer_v2_transient_error(ctx: RetryContext) -> RetryCheckResult: :param ctx: The context object which describes the state of the request and the retries which may already have been attempted """ + # JSON:API uses "the most generally applicable HTTP error code" for the + # status of the response, so if its a transient status code then all of the + # error objects should also have transient status codes. retry_config = ctx.caller_info.retry_config if ctx.response is not None and ( ctx.response.status_code in retry_config.transient_error_status_codes ): - codes = [] try: - if isinstance(ctx.exception, exc.GlobusAPIError): - errors = ctx.exception.errors - codes = [error.code for error in errors] - except ValueError: + # if any of the error objects have a `code` of ExternalError or + # EndpointError then the error likely isn't transient + errors = ctx.response.json()["errors"] + for error in errors: + if error["code"] in ("ExternalError", "EndpointError"): + return RetryCheckResult.no_decision + + except (ValueError, KeyError): pass - for non_retry_code in ("ExternalError", "EndpointError"): - if non_retry_code in codes: - return RetryCheckResult.no_decision - return RetryCheckResult.do_retry return RetryCheckResult.no_decision diff --git a/src/globus_sdk/services/transfer/data/__init__.py b/src/globus_sdk/services/transfer/data/__init__.py index e786aae79..faf12f232 100644 --- a/src/globus_sdk/services/transfer/data/__init__.py +++ b/src/globus_sdk/services/transfer/data/__init__.py @@ -4,8 +4,8 @@ :class:`TransferClient ` methods without conversion. """ -# CreateTunnelData has been moved to experimental.transfer_v2 but is -# provided here for backwards compatibility +# CreateTunnelData has been moved to experimental but is provided here for +# backwards compatibility from globus_sdk.experimental.transfer_v2.data import CreateTunnelData from .delete_data import DeleteData diff --git a/src/globus_sdk/testing/registry.py b/src/globus_sdk/testing/registry.py index b575f1e0e..54bd47af9 100644 --- a/src/globus_sdk/testing/registry.py +++ b/src/globus_sdk/testing/registry.py @@ -42,11 +42,14 @@ def _resolve_qualname(name: str) -> str: if "." not in name: return name prefix, suffix = name.split(".", 1) - if not hasattr(globus_sdk, prefix): - return name # something from globus_sdk, could be a client class - maybe_client = getattr(globus_sdk, prefix) + if hasattr(globus_sdk, prefix): + maybe_client = getattr(globus_sdk, prefix) + elif hasattr(globus_sdk.experimental, prefix): + maybe_client = getattr(globus_sdk.experimental, prefix) + else: + return name # there are a dozen ways of writing this check, but the point is # "if it's not a client class" diff --git a/tests/functional/services/transfer/test_create_tunnel.py b/tests/functional/services/transfer/test_create_tunnel.py index 6ffad8ba0..3a7026c0e 100644 --- a/tests/functional/services/transfer/test_create_tunnel.py +++ b/tests/functional/services/transfer/test_create_tunnel.py @@ -37,7 +37,6 @@ def test_create_tunnel(client): def test_create_tunnel_no_submission(client): meta = load_response(client.create_tunnel).metadata - load_response(client.get_submission_id) data = CreateTunnelData( meta["initiator_ap"], meta["listener_ap"], label=meta["display_name"] diff --git a/tests/functional/services/transfer/test_get_stream_access_point.py b/tests/functional/services/transfer/test_get_stream_access_point.py index 39d266b94..a20ba2e7c 100644 --- a/tests/functional/services/transfer/test_get_stream_access_point.py +++ b/tests/functional/services/transfer/test_get_stream_access_point.py @@ -1,7 +1,7 @@ from globus_sdk.testing import get_last_request, load_response -def test_get_tunnel(client): +def test_get_stream_access_point(client): meta = load_response(client.get_stream_access_point).metadata res = client.get_stream_access_point(meta["access_point_id"]) diff --git a/tests/functional/services/transfer/test_list_tunnel.py b/tests/functional/services/transfer/test_list_tunnel.py index 2418084c6..d4e17780e 100644 --- a/tests/functional/services/transfer/test_list_tunnel.py +++ b/tests/functional/services/transfer/test_list_tunnel.py @@ -1,7 +1,7 @@ from globus_sdk.testing import get_last_request, load_response -def test_list_tunnel(client): +def test_list_tunnels(client): load_response(client.list_tunnels) res = client.list_tunnels() diff --git a/tests/unit/test_paging.py b/tests/unit/test_paging.py index 0f43a234b..282272a45 100644 --- a/tests/unit/test_paging.py +++ b/tests/unit/test_paging.py @@ -4,8 +4,8 @@ import pytest import requests -from globus_sdk.paging import HasNextPaginator -from globus_sdk.response import GlobusHTTPResponse +from globus_sdk.paging import HasNextPaginator, JSONAPIPaginator +from globus_sdk.response import GlobusHTTPResponse, IterableJSONAPIResponse from globus_sdk.services.transfer.response import IterableTransferResponse N = 25 @@ -39,11 +39,65 @@ def simulate_get(self, *args, **params): return IterableTransferResponse(GlobusHTTPResponse(response, mock.Mock())) +class JSONAPIPagingSimulator: + + def __init__(self, n) -> None: + self.n = n # the number of simulated items + self.page_size = 10 # arbitrary page size + + def simulate_get(self, *args, **params): + """ + Simulates a paginated response from a Globus API get supporting + optional query_parameters and including a links object with a next + link in the response using a simple id based page marker + """ + query_params = params.get("query_params", {}) + marker = query_params.get("page[marker]") + + if not marker: + marker = 0 + elif isinstance(marker, list): + marker = int(marker[0]) + + if marker > self.n: + raise Exception("BadMarker") + + else: + response_top_level = { + "data": [], + } + for i in range(marker, min(self.n, marker + self.page_size)): + response_top_level["data"].append( + { + "type": "foo", + "id": i, + } + ) + + # add links object with next link if there is remaining data + if marker + self.page_size < self.n: + next_marker = marker + self.page_size + response_top_level["links"] = { + "next": f"https://foo.globus.org/foo?page[marker]={next_marker}" + } + + # make the simulated response + response = requests.Response() + response._content = json.dumps(response_top_level).encode() + response.headers["Content-Type"] = "application/json" + return IterableJSONAPIResponse(GlobusHTTPResponse(response, mock.Mock())) + + @pytest.fixture def paging_simulator(): return PagingSimulator(N) +@pytest.fixture +def jsonapi_paging_simulator(): + return JSONAPIPagingSimulator(N) + + def test_has_next_paginator(paging_simulator): """ Walk the paging simulator with HasNextPaginator and confirm the results are good @@ -59,8 +113,27 @@ def test_has_next_paginator(paging_simulator): def all_items(): for page in paginator: - yield from page["DATA"] + yield from list(page) # confirm results for item, expected in zip(all_items(), range(N)): assert item["value"] == expected + + +def test_jsonapi_paginator(jsonapi_paging_simulator): + """ + Walk the JSONAPIPagingSimulator with JSONAPIPaginator and confirm results are good + """ + paginator = JSONAPIPaginator( + jsonapi_paging_simulator.simulate_get, + client_args=[], + client_kwargs={}, + ) + + def all_items(): + for page in paginator: + yield from list(page) + + # confirm results + for item, expected in zip(all_items(), range(N)): + assert item["id"] == expected From 8706c10c3dd34ec08fb41830fe40b4b969997381 Mon Sep 17 00:00:00 2001 From: aaschaer Date: Mon, 9 Mar 2026 09:46:40 -0500 Subject: [PATCH 3/3] add missing tests --- .../testing/data/transfer/v2/__init__.py | 0 .../testing/data/transfer/v2/create_tunnel.py | 59 ++++++++++ .../testing/data/transfer/v2/delete_tunnel.py | 18 +++ .../transfer/v2/get_stream_access_point.py | 47 ++++++++ .../testing/data/transfer/v2/get_tunnel.py | 56 ++++++++++ .../data/transfer/v2/get_tunnel_events.py | 58 ++++++++++ .../transfer/v2/list_stream_access_points.py | 75 +++++++++++++ .../testing/data/transfer/v2/list_tunnels.py | 93 ++++++++++++++++ .../testing/data/transfer/v2/update_tunnel.py | 57 ++++++++++ .../services/transfer/v2/__init__.py | 0 .../services/transfer/v2/conftest.py | 10 ++ .../transfer/v2/test_create_tunnel.py | 70 ++++++++++++ .../transfer/v2/test_delete_tunnel.py | 11 ++ .../v2/test_get_stream_access_point.py | 13 +++ .../services/transfer/v2/test_get_tunnel.py | 13 +++ .../v2/test_list_stream_access_points.py | 17 +++ .../services/transfer/v2/test_list_tunnels.py | 12 ++ .../transfer/v2/test_update_tunnel.py | 24 ++++ .../transport/test_transfer_v2_transport.py | 103 ++++++++++++++++++ 19 files changed, 736 insertions(+) create mode 100644 src/globus_sdk/testing/data/transfer/v2/__init__.py create mode 100644 src/globus_sdk/testing/data/transfer/v2/create_tunnel.py create mode 100644 src/globus_sdk/testing/data/transfer/v2/delete_tunnel.py create mode 100644 src/globus_sdk/testing/data/transfer/v2/get_stream_access_point.py create mode 100644 src/globus_sdk/testing/data/transfer/v2/get_tunnel.py create mode 100644 src/globus_sdk/testing/data/transfer/v2/get_tunnel_events.py create mode 100644 src/globus_sdk/testing/data/transfer/v2/list_stream_access_points.py create mode 100644 src/globus_sdk/testing/data/transfer/v2/list_tunnels.py create mode 100644 src/globus_sdk/testing/data/transfer/v2/update_tunnel.py create mode 100644 tests/functional/services/transfer/v2/__init__.py create mode 100644 tests/functional/services/transfer/v2/conftest.py create mode 100644 tests/functional/services/transfer/v2/test_create_tunnel.py create mode 100644 tests/functional/services/transfer/v2/test_delete_tunnel.py create mode 100644 tests/functional/services/transfer/v2/test_get_stream_access_point.py create mode 100644 tests/functional/services/transfer/v2/test_get_tunnel.py create mode 100644 tests/functional/services/transfer/v2/test_list_stream_access_points.py create mode 100644 tests/functional/services/transfer/v2/test_list_tunnels.py create mode 100644 tests/functional/services/transfer/v2/test_update_tunnel.py create mode 100644 tests/unit/transport/test_transfer_v2_transport.py diff --git a/src/globus_sdk/testing/data/transfer/v2/__init__.py b/src/globus_sdk/testing/data/transfer/v2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/globus_sdk/testing/data/transfer/v2/create_tunnel.py b/src/globus_sdk/testing/data/transfer/v2/create_tunnel.py new file mode 100644 index 000000000..cc0b80f48 --- /dev/null +++ b/src/globus_sdk/testing/data/transfer/v2/create_tunnel.py @@ -0,0 +1,59 @@ +import uuid + +from globus_sdk.testing.models import RegisteredResponse, ResponseSet + +TUNNEL_ID = str(uuid.uuid4()) + +_initiator_ap = str(uuid.uuid4()) +_listener_ap = str(uuid.uuid4()) + +_default_display_name = "Test Tunnel" + + +RESPONSES = ResponseSet( + default=RegisteredResponse( + service="transfer", + method="POST", + path="/v2/tunnels", + json={ + "data": { + "attributes": { + "created_time": "2025-12-12T21:49:22.183977", + "initiator_ip_address": None, + "initiator_port": None, + "label": _default_display_name, + "lifetime_mins": 10, + "listener_ip_address": None, + "listener_port": None, + "restartable": False, + "state": "AWAITING_LISTENER", + "status": "The tunnel is waiting for listening.", + "submission_id": "6ab42cda-d7a4-11f0-ad34-0affc202d2e9", + }, + "id": "34d97133-f17e-4f90-ad42-56ff5f3c2550", + "relationships": { + "initiator": { + "data": {"id": _initiator_ap, "type": "StreamAccessPoint"} + }, + "listener": { + "data": {"id": _listener_ap, "type": "StreamAccessPoint"} + }, + "owner": { + "data": { + "id": "4d443580-012d-4954-816f-e0592bd356e1", + "type": "Identity", + } + }, + }, + "type": "Tunnel", + }, + "meta": {"request_id": "e6KkKkNmw"}, + }, + metadata={ + "tunnel_id": TUNNEL_ID, + "display_name": _default_display_name, + "initiator_ap": _initiator_ap, + "listener_ap": _listener_ap, + }, + ), +) diff --git a/src/globus_sdk/testing/data/transfer/v2/delete_tunnel.py b/src/globus_sdk/testing/data/transfer/v2/delete_tunnel.py new file mode 100644 index 000000000..f06802cf9 --- /dev/null +++ b/src/globus_sdk/testing/data/transfer/v2/delete_tunnel.py @@ -0,0 +1,18 @@ +import uuid + +from globus_sdk.testing.models import RegisteredResponse, ResponseSet + +TUNNEL_ID = str(uuid.uuid4()) + + +RESPONSES = ResponseSet( + default=RegisteredResponse( + service="transfer", + method="DELETE", + path=f"/v2/tunnels/{TUNNEL_ID}", + json={"data": None, "meta": {"request_id": "ofayi2B4R"}}, + metadata={ + "tunnel_id": TUNNEL_ID, + }, + ), +) diff --git a/src/globus_sdk/testing/data/transfer/v2/get_stream_access_point.py b/src/globus_sdk/testing/data/transfer/v2/get_stream_access_point.py new file mode 100644 index 000000000..bc69e9ce4 --- /dev/null +++ b/src/globus_sdk/testing/data/transfer/v2/get_stream_access_point.py @@ -0,0 +1,47 @@ +import uuid + +from globus_sdk.testing.models import RegisteredResponse, ResponseSet + +ACCESS_POINT_ID = str(uuid.uuid4()) + + +RESPONSES = ResponseSet( + default=RegisteredResponse( + service="transfer", + method="GET", + path=f"/v2/stream_access_points/{ACCESS_POINT_ID}", + json={ + "data": { + "attributes": { + "advertised_owner": "john@globus.org", + "contact_email": None, + "contact_info": None, + "department": None, + "description": None, + "display_name": "Buzz Dev Listener", + "info_link": None, + "keywords": None, + "organization": None, + "tlsftp_server": ( + "tlsftp://s-463c7.e7d5e.8540." + "test3.zones.dnsteam.globuscs.info:443" + ), + }, + "id": ACCESS_POINT_ID, + "relationships": { + "host_endpoint": { + "data": { + "id": "d6428474-c308-4a2d-8a86-d377915d978b", + "type": "Endpoint", + } + } + }, + "type": "StreamAccessPoint", + }, + "meta": {"request_id": "55QRq2iBa"}, + }, + metadata={ + "access_point_id": ACCESS_POINT_ID, + }, + ), +) diff --git a/src/globus_sdk/testing/data/transfer/v2/get_tunnel.py b/src/globus_sdk/testing/data/transfer/v2/get_tunnel.py new file mode 100644 index 000000000..10af0bdbf --- /dev/null +++ b/src/globus_sdk/testing/data/transfer/v2/get_tunnel.py @@ -0,0 +1,56 @@ +import uuid + +from globus_sdk.testing.models import RegisteredResponse, ResponseSet + +TUNNEL_ID = str(uuid.uuid4()) + +_initiator_ap = str(uuid.uuid4()) +_listener_ap = str(uuid.uuid4()) + + +RESPONSES = ResponseSet( + default=RegisteredResponse( + service="transfer", + method="GET", + path=f"/v2/tunnels/{TUNNEL_ID}", + json={ + "data": { + "attributes": { + "created_time": "2025-12-12T21:11:50.525278", + "initiator_ip_address": None, + "initiator_port": None, + "label": "Buzz Tester", + "lifetime_mins": 360, + "listener_ip_address": None, + "listener_port": None, + "restartable": False, + "state": "AWAITING_LISTENER", + "status": "The tunnel is waiting for listening", + "submission_id": "292b0054-7084-46eb-83d6-7a6821b1f77e", + }, + "id": TUNNEL_ID, + "relationships": { + "initiator": { + "data": {"id": _initiator_ap, "type": "StreamAccessPoint"} + }, + "listener": { + "data": {"id": _listener_ap, "type": "StreamAccessPoint"} + }, + "owner": { + "data": { + "id": "4d443580-012d-4954-816f-e0592bd356e1", + "type": "Identity", + } + }, + }, + "type": "Tunnel", + }, + "meta": {"request_id": "M6kFaS949"}, + }, + metadata={ + "tunnel_id": TUNNEL_ID, + "initiator_ap": _initiator_ap, + "listener_ap": _listener_ap, + }, + ), +) diff --git a/src/globus_sdk/testing/data/transfer/v2/get_tunnel_events.py b/src/globus_sdk/testing/data/transfer/v2/get_tunnel_events.py new file mode 100644 index 000000000..baf3566e6 --- /dev/null +++ b/src/globus_sdk/testing/data/transfer/v2/get_tunnel_events.py @@ -0,0 +1,58 @@ +import uuid + +from globus_sdk.testing.models import RegisteredResponse, ResponseSet + +TUNNEL_ID = str(uuid.uuid4()) + +GET_TUNNEL_EVENTS_DOC = { + "data": [ + { + "type": "TunnelEvent", + "id": 2768, + "attributes": { + "code": "STARTED", + "is_error": False, + "description": "started", + "details": "Attempting tunnel establishment", + "time": "2026-02-12T21:59:01.857473", + }, + }, + { + "type": "TunnelEvent", + "id": 2769, + "attributes": { + "code": "TUNNEL_ACTIVE", + "is_error": False, + "description": "tunnel is active", + "details": "Tunnel has been established", + "time": "2026-02-12T21:59:02.876253", + }, + }, + { + "type": "TunnelEvent", + "id": 2777, + "attributes": { + "code": "TUNNEL_STOPPED", + "is_error": False, + "description": "tunnel has been stopped", + "details": "Tunnel stopped as requested.", + "time": "2026-02-12T22:12:03.655877", + }, + }, + ], + "links": None, + "meta": {"request_id": "655TZe5vm"}, +} + + +RESPONSES = ResponseSet( + metadata={ + "tunnel_id": TUNNEL_ID, + }, + default=RegisteredResponse( + service="transfer", + path=f"/v2/tunnels/{TUNNEL_ID}/events", + json=GET_TUNNEL_EVENTS_DOC, + method="GET", + ), +) diff --git a/src/globus_sdk/testing/data/transfer/v2/list_stream_access_points.py b/src/globus_sdk/testing/data/transfer/v2/list_stream_access_points.py new file mode 100644 index 000000000..44ea34c0a --- /dev/null +++ b/src/globus_sdk/testing/data/transfer/v2/list_stream_access_points.py @@ -0,0 +1,75 @@ +import uuid + +from globus_sdk.testing.models import RegisteredResponse, ResponseSet + +ACCESS_POINT_ID_1 = str(uuid.uuid4()) +ACCESS_POINT_ID_2 = str(uuid.uuid4()) + + +RESPONSES = ResponseSet( + default=RegisteredResponse( + service="transfer", + method="GET", + path="/v2/stream_access_points", + json={ + "data": [ + { + "attributes": { + "advertised_owner": "john@globus.org", + "contact_email": None, + "contact_info": None, + "department": None, + "description": None, + "display_name": "Buzz Dev Listener 1", + "info_link": None, + "keywords": None, + "organization": None, + "tlsftp_server": ( + "tlsftp://s-463c7.e7d5e.8540." + "test3.zones.dnsteam.globuscs.info:443" + ), + }, + "id": ACCESS_POINT_ID_1, + "relationships": { + "host_endpoint": { + "data": { + "id": "d6428474-c308-4a2d-8a86-d377915d978b", + "type": "Endpoint", + } + } + }, + "type": "StreamAccessPoint", + }, + { + "attributes": { + "advertised_owner": "john@globus.org", + "contact_email": None, + "contact_info": None, + "department": None, + "description": None, + "display_name": "Buzz Dev Listener 2", + "info_link": None, + "keywords": None, + "organization": None, + "tlsftp_server": ( + "tlsftp://s-a845b.29a01.671e." + "test3.zones.dnsteam.globuscs.info:443" + ), + }, + "id": ACCESS_POINT_ID_2, + "relationships": { + "host_endpoint": { + "data": { + "id": "d6428474-c308-4a2d-8a86-d377915d978b", + "type": "Endpoint", + } + } + }, + "type": "StreamAccessPoint", + }, + ], + "meta": {"request_id": "55QRq2iBa"}, + }, + metadata={"access_point_ids": [ACCESS_POINT_ID_1, ACCESS_POINT_ID_2]}, + ), +) diff --git a/src/globus_sdk/testing/data/transfer/v2/list_tunnels.py b/src/globus_sdk/testing/data/transfer/v2/list_tunnels.py new file mode 100644 index 000000000..4aacbc902 --- /dev/null +++ b/src/globus_sdk/testing/data/transfer/v2/list_tunnels.py @@ -0,0 +1,93 @@ +from globus_sdk.testing.models import RegisteredResponse, ResponseSet + +TUNNEL_LIST_DOC = { + "data": [ + { + "attributes": { + "created_time": "2025-12-12T21:11:50.525278", + "initiator_ip_address": None, + "initiator_port": None, + "label": "Buzz Tester", + "lifetime_mins": 360, + "listener_ip_address": None, + "listener_port": None, + "restartable": False, + "state": "AWAITING_LISTENER", + "status": "The tunnel is waiting for listening contact detail setup.", + "submission_id": "292b0054-7084-46eb-83d6-7a6821b1f77e", + }, + "id": "1c1be52d-2d4d-4200-b4ad-d75d43eb0d9c", + "relationships": { + "initiator": { + "data": { + "id": "80583f05-75f3-4825-b8a5-6c3edf0bbc5c", + "type": "StreamAccessPoint", + } + }, + "listener": { + "data": { + "id": "dd5fa993-749f-48fb-86cf-f07ad5797d7e", + "type": "StreamAccessPoint", + } + }, + "owner": { + "data": { + "id": "4d443580-012d-4954-816f-e0592bd356e1", + "type": "Identity", + } + }, + }, + "type": "Tunnel", + }, + { + "attributes": { + "created_time": "2025-12-12T21:22:11.018233", + "initiator_ip_address": None, + "initiator_port": None, + "label": "part 2", + "lifetime_mins": 360, + "listener_ip_address": None, + "listener_port": None, + "restartable": False, + "state": "AWAITING_LISTENER", + "status": "The tunnel is waiting for listening contact detail setup.", + "submission_id": "fb3b1220-1d5f-4dcf-92f5-e7056a514319", + }, + "id": "bf1b0d16-7d93-44eb-8773-9066a750c13e", + "relationships": { + "initiator": { + "data": { + "id": "34c6e671-c011-4bf8-bc30-5ccebada8f3b", + "type": "StreamAccessPoint", + } + }, + "listener": { + "data": { + "id": "dd5fa993-749f-48fb-86cf-f07ad5797d7e", + "type": "StreamAccessPoint", + } + }, + "owner": { + "data": { + "id": "4d443580-012d-4954-816f-e0592bd356e1", + "type": "Identity", + } + }, + }, + "type": "Tunnel", + }, + ], + "links": None, + "meta": {"request_id": "fAAfpnino"}, +} + + +RESPONSES = ResponseSet( + metadata={}, + default=RegisteredResponse( + service="transfer", + path="/v2/tunnels", + json=TUNNEL_LIST_DOC, + method="GET", + ), +) diff --git a/src/globus_sdk/testing/data/transfer/v2/update_tunnel.py b/src/globus_sdk/testing/data/transfer/v2/update_tunnel.py new file mode 100644 index 000000000..809944cc1 --- /dev/null +++ b/src/globus_sdk/testing/data/transfer/v2/update_tunnel.py @@ -0,0 +1,57 @@ +import uuid + +from globus_sdk.testing.models import RegisteredResponse, ResponseSet + +TUNNEL_ID = str(uuid.uuid4()) + + +RESPONSES = ResponseSet( + default=RegisteredResponse( + service="transfer", + method="PATCH", + path=f"/v2/tunnels/{TUNNEL_ID}", + json={ + "data": { + "attributes": { + "created_time": "2025-12-12T21:11:50.525278", + "initiator_ip_address": None, + "initiator_port": None, + "label": "Buzz Tester", + "lifetime_mins": 360, + "listener_ip_address": None, + "listener_port": None, + "restartable": False, + "state": "STOPPING", + "status": "A request to stop tunnel has been received.", + "submission_id": "292b0054-7084-46eb-83d6-7a6821b1f77e", + }, + "id": "1c1be52d-2d4d-4200-b4ad-d75d43eb0d9c", + "relationships": { + "initiator": { + "data": { + "id": "80583f05-75f3-4825-b8a5-6c3edf0bbc5c", + "type": "StreamAccessPoint", + } + }, + "listener": { + "data": { + "id": "dd5fa993-749f-48fb-86cf-f07ad5797d7e", + "type": "StreamAccessPoint", + } + }, + "owner": { + "data": { + "id": "4d443580-012d-4954-816f-e0592bd356e1", + "type": "Identity", + } + }, + }, + "type": "Tunnel", + }, + "meta": {"request_id": "pN0Aact40"}, + }, + metadata={ + "tunnel_id": TUNNEL_ID, + }, + ), +) diff --git a/tests/functional/services/transfer/v2/__init__.py b/tests/functional/services/transfer/v2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/functional/services/transfer/v2/conftest.py b/tests/functional/services/transfer/v2/conftest.py new file mode 100644 index 000000000..a4da52224 --- /dev/null +++ b/tests/functional/services/transfer/v2/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from globus_sdk.experimental import TransferClientV2 + + +@pytest.fixture +def client(): + client = TransferClientV2() + with client.retry_config.tune(max_retries=0): + yield client diff --git a/tests/functional/services/transfer/v2/test_create_tunnel.py b/tests/functional/services/transfer/v2/test_create_tunnel.py new file mode 100644 index 000000000..d35e5c049 --- /dev/null +++ b/tests/functional/services/transfer/v2/test_create_tunnel.py @@ -0,0 +1,70 @@ +import json +import uuid + +import pytest + +from globus_sdk import exc +from globus_sdk.experimental import CreateTunnelData +from globus_sdk.testing import get_last_request, load_response + + +def test_create_tunnel(client): + meta = load_response(client.create_tunnel).metadata + + submission_id = uuid.uuid4() + data = CreateTunnelData( + meta["initiator_ap"], + meta["listener_ap"], + submission_id=submission_id, + label=meta["display_name"], + ) + + res = client.create_tunnel(data) + assert res.http_status == 200 + assert res["data"]["type"] == "Tunnel" + + req = get_last_request() + sent = json.loads(req.body) + assert ( + sent["data"]["relationships"]["initiator"]["data"]["id"] == meta["initiator_ap"] + ) + assert ( + sent["data"]["relationships"]["listener"]["data"]["id"] == meta["listener_ap"] + ) + assert sent["data"]["attributes"]["submission_id"] == str(submission_id) + assert sent["data"]["attributes"]["label"] == meta["display_name"] + + +def test_create_tunnel_no_submission(client): + meta = load_response(client.create_tunnel).metadata + + data = CreateTunnelData( + meta["initiator_ap"], meta["listener_ap"], label=meta["display_name"] + ) + + res = client.create_tunnel(data) + assert res.http_status == 200 + + req = get_last_request() + sent = json.loads(req.body) + assert sent["data"]["attributes"]["submission_id"] is not None + + +def test_create_tunnel_bad_input(client): + data = { + "relationships": { + "listener": { + "data": { + "type": "StreamAccessPoint", + } + }, + "initiator": { + "data": { + "type": "StreamAccessPoint", + } + }, + } + } + + with pytest.raises(exc.GlobusSDKUsageError): + client.create_tunnel(data) diff --git a/tests/functional/services/transfer/v2/test_delete_tunnel.py b/tests/functional/services/transfer/v2/test_delete_tunnel.py new file mode 100644 index 000000000..230e41b32 --- /dev/null +++ b/tests/functional/services/transfer/v2/test_delete_tunnel.py @@ -0,0 +1,11 @@ +from globus_sdk.testing import get_last_request, load_response + + +def test_delete_tunnel(client): + meta = load_response(client.delete_tunnel).metadata + + res = client.delete_tunnel(meta["tunnel_id"]) + assert res.http_status == 200 + + req = get_last_request() + assert req.body is None diff --git a/tests/functional/services/transfer/v2/test_get_stream_access_point.py b/tests/functional/services/transfer/v2/test_get_stream_access_point.py new file mode 100644 index 000000000..a20ba2e7c --- /dev/null +++ b/tests/functional/services/transfer/v2/test_get_stream_access_point.py @@ -0,0 +1,13 @@ +from globus_sdk.testing import get_last_request, load_response + + +def test_get_stream_access_point(client): + meta = load_response(client.get_stream_access_point).metadata + + res = client.get_stream_access_point(meta["access_point_id"]) + assert res.http_status == 200 + assert res["data"]["type"] == "StreamAccessPoint" + assert res["data"]["id"] == meta["access_point_id"] + + req = get_last_request() + assert req.body is None diff --git a/tests/functional/services/transfer/v2/test_get_tunnel.py b/tests/functional/services/transfer/v2/test_get_tunnel.py new file mode 100644 index 000000000..a8bea5436 --- /dev/null +++ b/tests/functional/services/transfer/v2/test_get_tunnel.py @@ -0,0 +1,13 @@ +from globus_sdk.testing import get_last_request, load_response + + +def test_get_tunnel(client): + meta = load_response(client.get_tunnel).metadata + + res = client.get_tunnel(meta["tunnel_id"]) + assert res.http_status == 200 + assert res["data"]["type"] == "Tunnel" + assert res["data"]["id"] == meta["tunnel_id"] + + req = get_last_request() + assert req.body is None diff --git a/tests/functional/services/transfer/v2/test_list_stream_access_points.py b/tests/functional/services/transfer/v2/test_list_stream_access_points.py new file mode 100644 index 000000000..15d2b5341 --- /dev/null +++ b/tests/functional/services/transfer/v2/test_list_stream_access_points.py @@ -0,0 +1,17 @@ +from globus_sdk.testing import get_last_request, load_response + + +def test_list_stream_access_points(client): + meta = load_response(client.list_stream_access_points).metadata + + res = client.list_stream_access_points() + assert res.http_status == 200 + list_results = list(res) + + assert len(list_results) == 2 + for stream_access_point in list_results: + assert stream_access_point["type"] == "StreamAccessPoint" + assert stream_access_point["id"] in meta["access_point_ids"] + + req = get_last_request() + assert req.body is None diff --git a/tests/functional/services/transfer/v2/test_list_tunnels.py b/tests/functional/services/transfer/v2/test_list_tunnels.py new file mode 100644 index 000000000..d4e17780e --- /dev/null +++ b/tests/functional/services/transfer/v2/test_list_tunnels.py @@ -0,0 +1,12 @@ +from globus_sdk.testing import get_last_request, load_response + + +def test_list_tunnels(client): + load_response(client.list_tunnels) + + res = client.list_tunnels() + assert res.http_status == 200 + assert len(res["data"]) == 2 + + req = get_last_request() + assert req.body is None diff --git a/tests/functional/services/transfer/v2/test_update_tunnel.py b/tests/functional/services/transfer/v2/test_update_tunnel.py new file mode 100644 index 000000000..cc8e23684 --- /dev/null +++ b/tests/functional/services/transfer/v2/test_update_tunnel.py @@ -0,0 +1,24 @@ +import json + +from globus_sdk.testing import get_last_request, load_response + + +def test_update_tunnel(client): + meta = load_response(client.update_tunnel).metadata + + label = "New Name" + update_doc = { + "data": { + "type": "Tunnel", + "attributes": { + "label": "New Name", + }, + } + } + res = client.update_tunnel(meta["tunnel_id"], update_doc) + assert res.http_status == 200 + assert res["data"]["type"] == "Tunnel" + + req = get_last_request() + sent = json.loads(req.body) + assert sent["data"]["attributes"]["label"] == label diff --git a/tests/unit/transport/test_transfer_v2_transport.py b/tests/unit/transport/test_transfer_v2_transport.py new file mode 100644 index 000000000..0159d0d2c --- /dev/null +++ b/tests/unit/transport/test_transfer_v2_transport.py @@ -0,0 +1,103 @@ +from unittest import mock + +from globus_sdk.experimental.transfer_v2.transport import ( + TRANSFER_V2_DEFAULT_RETRY_CHECKS, +) +from globus_sdk.transport import ( + RequestCallerInfo, + RetryCheckCollection, + RetryCheckRunner, + RetryConfig, + RetryContext, +) +from globus_sdk.transport.default_retry_checks import DEFAULT_RETRY_CHECKS + + +def test_transfer_only_replaces_checks(): + # their length matches, meaning things line up + assert len(TRANSFER_V2_DEFAULT_RETRY_CHECKS) == len(DEFAULT_RETRY_CHECKS) + + # also confirm that this holds once loaded + # if the implementation of the RetryCheckCollection becomes sensitive to + # the contents of these tuples, this could fail + default_variant = RetryCheckCollection() + default_variant.register_many_checks(DEFAULT_RETRY_CHECKS) + + transfer_variant = RetryCheckCollection() + transfer_variant.register_many_checks(TRANSFER_V2_DEFAULT_RETRY_CHECKS) + + assert len(default_variant) == len(transfer_variant) + + +def test_transfer_does_not_retry_external(): + retry_config = RetryConfig() + retry_config.checks.register_many_checks(TRANSFER_V2_DEFAULT_RETRY_CHECKS) + checker = RetryCheckRunner(retry_config.checks) + + body = { + "errors": [ + { + "status": 502, + "code": "ExternalError", + "detail": "The GCP endpoint is not currently connected to Globus", + } + ], + "meta": { + "request_id": "rhvcR0aHX", + }, + } + + dummy_response = mock.Mock() + dummy_response.json = lambda: body + dummy_response.status_code = 502 + caller_info = RequestCallerInfo(retry_config=retry_config) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) + + assert checker.should_retry(ctx) is False + + +def test_transfer_does_not_retry_endpoint_error(): + retry_config = RetryConfig() + retry_config.checks.register_many_checks(TRANSFER_V2_DEFAULT_RETRY_CHECKS) + checker = RetryCheckRunner(retry_config.checks) + + body = { + "errors": [ + { + "status": 502, + "code": "EndpointError", + "detail": ( + "This GCSv5 is older than version 5.4.62 and does " + "not support local user selection" + ), + } + ], + "meta": { + "request_id": "istNh0Zpz", + }, + } + + dummy_response = mock.Mock() + dummy_response.json = lambda: body + dummy_response.status_code = 502 + caller_info = RequestCallerInfo(retry_config=retry_config) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) + + assert checker.should_retry(ctx) is False + + +def test_transfer_retries_others(): + retry_config = RetryConfig() + retry_config.checks.register_many_checks(TRANSFER_V2_DEFAULT_RETRY_CHECKS) + checker = RetryCheckRunner(retry_config.checks) + + def _raise_value_error(): + raise ValueError() + + dummy_response = mock.Mock() + dummy_response.json = _raise_value_error + dummy_response.status_code = 502 + caller_info = RequestCallerInfo(retry_config=retry_config) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) + + assert checker.should_retry(ctx) is True