Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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`)
15 changes: 15 additions & 0 deletions docs/experimental/gcs_collection_client.rst
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions docs/experimental/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------------------
Expand Down
95 changes: 95 additions & 0 deletions src/globus_sdk/experimental/gcs_collection_client.py
Original file line number Diff line number Diff line change
@@ -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__("<stub>")

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."
)
44 changes: 44 additions & 0 deletions tests/unit/experimental/test_gcs_collection_client.py
Original file line number Diff line number Diff line change
@@ -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"
Loading