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..f50b4fde7 --- /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 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 + 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. + + .. 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() + + def __init__( + self, + collection_id: str | uuid.UUID, + collection_address: 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) + + if not collection_address.startswith("https://"): + collection_address = f"https://{collection_address}" + + super().__init__( + environment=environment, + base_url=collection_address, + 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..7aba70d10 --- /dev/null +++ b/tests/unit/experimental/test_gcs_collection_client.py @@ -0,0 +1,44 @@ +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") + ] + + +# 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"