From 5ef619adcc715e1a383d524bb284df01da923d35 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Fri, 6 Mar 2026 18:37:12 -0600 Subject: [PATCH 1/3] Add experimental GCSCollectionClient The new client definition is minimal, providing no methods. It only maps collection ID + base URL to existing SDK methods, and defines the scopes appropriately for the client. Based on the solution for `scope` as a class-attribute of clients used in the `SpecificFlowClient`, a stub is defined for the scopes of this client class which raises appropriate attribute errors on access. It thereby makes itself type-safe in contexts where it is used correctly. (Adjusting such that scope based access at the class level is a type error for this class and `SpecificFlowClient` is considered out of scope for this change.) Unit tests for the scope behaviors + a new (small) doc page are included. --- ...6_183052_sirosen_gcs_collection_client.rst | 8 ++ docs/experimental/gcs_collection_client.rst | 15 +++ docs/experimental/index.rst | 5 + .../experimental/gcs_collection_client.py | 95 +++++++++++++++++++ .../test_gcs_collection_client.py | 36 +++++++ 5 files changed, 159 insertions(+) create mode 100644 changelog.d/20260306_183052_sirosen_gcs_collection_client.rst create mode 100644 docs/experimental/gcs_collection_client.rst create mode 100644 src/globus_sdk/experimental/gcs_collection_client.py create mode 100644 tests/unit/experimental/test_gcs_collection_client.py diff --git a/changelog.d/20260306_183052_sirosen_gcs_collection_client.rst b/changelog.d/20260306_183052_sirosen_gcs_collection_client.rst new file mode 100644 index 000000000..30380dab8 --- /dev/null +++ b/changelog.d/20260306_183052_sirosen_gcs_collection_client.rst @@ -0,0 +1,8 @@ +Added +----- + +- Added a new ``GCSCollectionClient`` class in + ``globus_sdk.experimental.gcs_collection_client``. + The new client has no methods other than the base HTTP ones, but contains the + collection ID and scopes in the correct locations for the SDK token management + mechanisms to use. (:pr:`NUMBER`) diff --git a/docs/experimental/gcs_collection_client.rst b/docs/experimental/gcs_collection_client.rst new file mode 100644 index 000000000..7a94bef48 --- /dev/null +++ b/docs/experimental/gcs_collection_client.rst @@ -0,0 +1,15 @@ +.. _gcs_collection_client: + +.. currentmodule:: globus_sdk.experimental.gcs_collection_client + +GCS Collection Client +===================== + +The ``GCSCollectionClient`` class provides an interface for collections, as +resource servers. +It should not be confused with ``globus_sdk.GCSClient``, which provides an +interface to GCS Endpoints. + +.. autoclass:: GCSCollectionClient + :members: + :member-order: bysource diff --git a/docs/experimental/index.rst b/docs/experimental/index.rst index d9a56000b..ee0b42bf2 100644 --- a/docs/experimental/index.rst +++ b/docs/experimental/index.rst @@ -10,6 +10,11 @@ Globus SDK Experimental Components **Use at your own risk.** +.. toctree:: + :caption: Experimental Constructs + :maxdepth: 1 + + gcs_collection_client Experimental Construct Lifecycle -------------------------------- diff --git a/src/globus_sdk/experimental/gcs_collection_client.py b/src/globus_sdk/experimental/gcs_collection_client.py new file mode 100644 index 000000000..4f27f27c8 --- /dev/null +++ b/src/globus_sdk/experimental/gcs_collection_client.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import typing as t +import uuid + +import globus_sdk +from globus_sdk.authorizers import GlobusAuthorizer +from globus_sdk.scopes import GCSCollectionScopes + + +# NOTE: this stub class idea is inspired by the SpecificFlowScopes class stub +# it implements the same interface as the base class, so it's type-compatible +# but it raises errors at runtime -- because it can't *actually* be populated with data +class _GCSCollectionScopesClassStub(GCSCollectionScopes): + """ + This internal stub object ensures that the type deductions for type checkers (e.g. + mypy) on SpecificFlowClient.scopes are correct. + + Primarily, it should be possible to access the `scopes` attribute, the `user` + scope, and the `resource_server`, but these usages should raise specific and + informative runtime errors. + + Our types are therefore less accurate for class-var access, but more accurate for + instance-var access. + """ + + def __init__(self) -> None: + super().__init__("") + + def __getattribute__(self, name: str) -> t.Any: + if name == "https": + _raise_attr_error("https") + if name == "data_access": + _raise_attr_error("data_access") + if name == "resource_server": + _raise_attr_error("resource_server") + return object.__getattribute__(self, name) + + +class GCSCollectionClient(globus_sdk.BaseClient): + """ + A client for interacting directly with a GCS Collection. + Typically for HTTPS upload/download via HTTPS-enabled collections. + + .. note:: + + Because the client communicates directly with paths on the collection, rather + than with the Endpoint hosting it, the ``base_url`` parameter is required. + + .. sdk-sphinx-copy-params:: BaseClient + + :param collection_id: The ID of the collection. + """ + + scopes: GCSCollectionScopes = _GCSCollectionScopesClassStub() + + def __init__( + self, + collection_id: str | uuid.UUID, + base_url: str, + *, + environment: str | None = None, + app: globus_sdk.GlobusApp | None = None, + app_scopes: list[globus_sdk.scopes.Scope] | None = None, + authorizer: GlobusAuthorizer | None = None, + app_name: str | None = None, + transport: globus_sdk.transport.RequestsTransport | None = None, + retry_config: globus_sdk.transport.RetryConfig | None = None, + ) -> None: + self.collection_id = str(collection_id) + self.scopes = GCSCollectionScopes(self.collection_id) + + super().__init__( + environment=environment, + base_url=base_url, + app=app, + app_scopes=app_scopes, + authorizer=authorizer, + app_name=app_name, + transport=transport, + retry_config=retry_config, + ) + + @property + def default_scope_requirements(self) -> list[globus_sdk.Scope]: + return [self.scopes.https] + + +def _raise_attr_error(name: str) -> t.NoReturn: + raise AttributeError( + f"It is not valid to attempt to access the '{name}' attribute of the " + "GCSCollectionClient class. " + f"Instead, instantiate a GCSCollectionClient and access the '{name}' attribute " + "from that instance." + ) diff --git a/tests/unit/experimental/test_gcs_collection_client.py b/tests/unit/experimental/test_gcs_collection_client.py new file mode 100644 index 000000000..eba603b5b --- /dev/null +++ b/tests/unit/experimental/test_gcs_collection_client.py @@ -0,0 +1,36 @@ +import pytest + +import globus_sdk +from globus_sdk.experimental.gcs_collection_client import GCSCollectionClient + + +@pytest.mark.parametrize("attrname", ["https", "data_access", "resource_server"]) +def test_class_level_scopes_access_raises_useful_attribute_error(attrname): + with pytest.raises( + AttributeError, + match=( + f"It is not valid to attempt to access the '{attrname}' attribute of " + "the GCSCollectionClient class" + ), + ): + getattr(GCSCollectionClient.scopes, attrname) + + +def test_instance_level_scopes_access_ok(): + client = GCSCollectionClient("foo_id", "https://example.com/foo") + + assert client.resource_server == "foo_id" + assert client.scopes.https == globus_sdk.Scope( + "https://auth.globus.org/scopes/foo_id/https" + ) + assert client.scopes.data_access == globus_sdk.Scope( + "https://auth.globus.org/scopes/foo_id/data_access" + ) + + +def test_default_scope_is_https(): + client = GCSCollectionClient("foo_id", "https://example.com/foo") + + assert client.default_scope_requirements == [ + globus_sdk.Scope("https://auth.globus.org/scopes/foo_id/https") + ] From e0d5e3e5bc4dfcac73ebcb98635e024488314df0 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Tue, 10 Mar 2026 14:28:14 -0500 Subject: [PATCH 2/3] Update src/globus_sdk/experimental/gcs_collection_client.py Co-authored-by: derek-globus <113056046+derek-globus@users.noreply.github.com> --- src/globus_sdk/experimental/gcs_collection_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/globus_sdk/experimental/gcs_collection_client.py b/src/globus_sdk/experimental/gcs_collection_client.py index 4f27f27c8..344309feb 100644 --- a/src/globus_sdk/experimental/gcs_collection_client.py +++ b/src/globus_sdk/experimental/gcs_collection_client.py @@ -14,7 +14,7 @@ class _GCSCollectionScopesClassStub(GCSCollectionScopes): """ This internal stub object ensures that the type deductions for type checkers (e.g. - mypy) on SpecificFlowClient.scopes are correct. + mypy) on GCSCollectionClient.scopes are correct. Primarily, it should be possible to access the `scopes` attribute, the `user` scope, and the `resource_server`, but these usages should raise specific and From 4cceedc8ed1a46c76d2ce1e70836a1e8d636ead9 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Wed, 11 Mar 2026 11:55:00 -0500 Subject: [PATCH 3/3] Rename GCSCollectionClient base_url param Use `collection_address` to match `GCSClient`. --- .../experimental/gcs_collection_client.py | 14 +++++++------- .../experimental/test_gcs_collection_client.py | 8 ++++++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/globus_sdk/experimental/gcs_collection_client.py b/src/globus_sdk/experimental/gcs_collection_client.py index 344309feb..f50b4fde7 100644 --- a/src/globus_sdk/experimental/gcs_collection_client.py +++ b/src/globus_sdk/experimental/gcs_collection_client.py @@ -42,14 +42,11 @@ class GCSCollectionClient(globus_sdk.BaseClient): A client for interacting directly with a GCS Collection. Typically for HTTPS upload/download via HTTPS-enabled collections. - .. note:: - - Because the client communicates directly with paths on the collection, rather - than with the Endpoint hosting it, the ``base_url`` parameter is required. - .. sdk-sphinx-copy-params:: BaseClient :param collection_id: The ID of the collection. + :param collection_address: The URL of the collection, as might be retrieved from + the ``https_server`` field in Globus Transfer. """ scopes: GCSCollectionScopes = _GCSCollectionScopesClassStub() @@ -57,7 +54,7 @@ class GCSCollectionClient(globus_sdk.BaseClient): def __init__( self, collection_id: str | uuid.UUID, - base_url: str, + collection_address: str, *, environment: str | None = None, app: globus_sdk.GlobusApp | None = None, @@ -70,9 +67,12 @@ def __init__( self.collection_id = str(collection_id) self.scopes = GCSCollectionScopes(self.collection_id) + if not collection_address.startswith("https://"): + collection_address = f"https://{collection_address}" + super().__init__( environment=environment, - base_url=base_url, + base_url=collection_address, app=app, app_scopes=app_scopes, authorizer=authorizer, diff --git a/tests/unit/experimental/test_gcs_collection_client.py b/tests/unit/experimental/test_gcs_collection_client.py index eba603b5b..7aba70d10 100644 --- a/tests/unit/experimental/test_gcs_collection_client.py +++ b/tests/unit/experimental/test_gcs_collection_client.py @@ -34,3 +34,11 @@ def test_default_scope_is_https(): assert client.default_scope_requirements == [ globus_sdk.Scope("https://auth.globus.org/scopes/foo_id/https") ] + + +# this behavior imitates the `GCSClient`, which accepts a bare hostname and prepends the +# scheme if it is missing +def test_https_scheme_is_added_to_bare_address(): + client = GCSCollectionClient("foo_id", "example.com") + + assert client.base_url == "https://example.com"