Skip to content

Commit

Permalink
Merge pull request #955 from sirosen/gcs-connector-map
Browse files Browse the repository at this point in the history
Introduce GCS `ConnectorTable` as mapping-like
  • Loading branch information
sirosen authored Jun 21, 2024
2 parents 517d8f5 + 2bb99ae commit 6adfa5b
Show file tree
Hide file tree
Showing 9 changed files with 277 additions and 17 deletions.
12 changes: 12 additions & 0 deletions changelog.d/20240223_153006_sirosen_gcs_connector_map.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Added
~~~~~

- Add ``globus_sdk.ConnectorTable`` which provides information on supported
Globus Connect Server connectors. This object maps names to IDs and vice
versa. (:pr:`NUMBER`)

Deprecated
~~~~~~~~~~

- ``GCSClient.connector_id_to_name`` has been deprecated. Use
``ConnectorTable.lookup`` instead. (:pr:`NUMBER`)
8 changes: 8 additions & 0 deletions docs/services/gcs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ Storage Gateway Policies
:members:
:show-inheritance:

.. autoclass:: ConnectorTable
:members:
:show-inheritance:

.. autoclass:: GlobusConnectServerConnector
:members:
:show-inheritance:

Client Errors
-------------

Expand Down
6 changes: 6 additions & 0 deletions src/globus_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ def _force_eager_imports() -> None:
"UserCredentialDocument",
"IterableGCSResponse",
"UnpackingGCSResponse",
"GlobusConnectServerConnector",
"ConnectorTable",
},
"services.flows": {
"FlowsClient",
Expand Down Expand Up @@ -207,6 +209,8 @@ def _force_eager_imports() -> None:
from .services.gcs import UserCredentialDocument
from .services.gcs import IterableGCSResponse
from .services.gcs import UnpackingGCSResponse
from .services.gcs import GlobusConnectServerConnector
from .services.gcs import ConnectorTable
from .services.flows import FlowsClient
from .services.flows import FlowsAPIError
from .services.flows import IterableFlowsResponse
Expand Down Expand Up @@ -291,6 +295,7 @@ def __getattr__(name: str) -> t.Any:
"CollectionDocument",
"CollectionPolicies",
"ConfidentialAppAuthClient",
"ConnectorTable",
"DeleteData",
"DependentScopeSpec",
"EndpointDocument",
Expand All @@ -304,6 +309,7 @@ def __getattr__(name: str) -> t.Any:
"GetIdentitiesResponse",
"GlobusAPIError",
"GlobusConnectPersonalOwnerInfo",
"GlobusConnectServerConnector",
"GlobusConnectionError",
"GlobusConnectionTimeoutError",
"GlobusError",
Expand Down
2 changes: 2 additions & 0 deletions src/globus_sdk/_generate_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ def __getattr__(name: str) -> t.Any:
"UserCredentialDocument",
"IterableGCSResponse",
"UnpackingGCSResponse",
"GlobusConnectServerConnector",
"ConnectorTable",
),
),
(
Expand Down
3 changes: 3 additions & 0 deletions src/globus_sdk/services/gcs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .client import GCSClient
from .connector_table import ConnectorTable, GlobusConnectServerConnector
from .data import (
ActiveScaleStoragePolicies,
AzureBlobStoragePolicies,
Expand Down Expand Up @@ -59,4 +60,6 @@
"IterableGCSResponse",
"UnpackingGCSResponse",
"UserCredentialDocument",
"GlobusConnectServerConnector",
"ConnectorTable",
)
37 changes: 22 additions & 15 deletions src/globus_sdk/services/gcs/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import typing as t
import uuid

from globus_sdk import client, paging, response, scopes, utils
from globus_sdk import client, exc, paging, response, scopes, utils
from globus_sdk._types import UUIDLike
from globus_sdk.authorizers import GlobusAuthorizer

from .connector_table import ConnectorTable
from .data import (
CollectionDocument,
EndpointDocument,
Expand Down Expand Up @@ -97,24 +98,30 @@ def get_gcs_collection_scopes(
@staticmethod
def connector_id_to_name(connector_id: UUIDLike) -> str | None:
"""
Helper that converts a given connector_id into a human readable
connector name string. Will return None if the id is not recognized.
.. warning::
Note that it is possible for valid connector_ids to be unrecognized
due to differing SDK and GCS versions.
This method is deprecated -- use
``ConnectorTable.lookup`` instead.
Helper that converts a given connector ID into a human-readable
connector name string.
:param connector_id: The ID of the connector
"""
connector_dict = {
"7c100eae-40fe-11e9-95a3-9cb6d0d9fd63": "Box",
"1b6374b0-f6a4-4cf7-a26f-f262d9c6ca72": "Ceph",
"56366b96-ac98-11e9-abac-9cb6d0d9fd63": "Google Cloud Storage",
"976cf0cf-78c3-4aab-82d2-7c16adbcc281": "Google Drive",
"145812c8-decc-41f1-83cf-bb2a85a2a70b": "POSIX",
"7643e831-5f6c-4b47-a07f-8ee90f401d23": "S3",
"7e3f3f5e-350c-4717-891a-2f451c24b0d4": "SpectraLogic BlackPearl",
}
return connector_dict.get(str(connector_id))
exc.warn_deprecated(
"`connector_id_to_name` has been replaced with "
"`ConnectorTable.lookup`. Use that instead, "
"and retrieve the `name` attribute from the result."
)
connector_obj = ConnectorTable.lookup(connector_id)
if connector_obj is None:
return None
name = connector_obj.name
# compatibility shim due to name change in the data (which was updated to
# match internal sources referring to this only as "BlackPearl")
if name == "BlackPearl":
name = "Spectralogic BlackPearl"
return name

#
# endpoint methods
Expand Down
127 changes: 127 additions & 0 deletions src/globus_sdk/services/gcs/connector_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
from __future__ import annotations

import dataclasses
import re
import typing as t

from globus_sdk._types import UUIDLike

_NORMALIZATION_PATTERN = re.compile(r"[_\- ]+")


def _normalize_name(name: str) -> str:
return _NORMALIZATION_PATTERN.sub("-", name.strip()).lower()


@dataclasses.dataclass
class GlobusConnectServerConnector:
"""
A container for Globus Connect Server Connector descriptions.
Contains a ``name`` and a ``connector_id``.
"""

name: str
connector_id: str


class ConnectorTable:
"""
This class defines the known Globus Connect Server Connectors in a mapping
structure.
It supports access by attribute or via a helper method for doing lookups.
For example, all of the following three usages retrieve the Azure Blob connector:
.. code-block:: pycon
>>> ConnectorTable.AZURE_BLOB
>>> ConnectorTable.lookup("Azure Blob")
>>> ConnectorTable.lookup("9436da0c-a444-11eb-af93-12704e0d6a4d")
Given the results of such a lookup, you can retrieve the canonical name and ID for
a connector like so:
.. code-block:: pycon
>>> connector = ConnectorTable.AZURE_BLOB
>>> connector.name
'Azure Blob'
>>> connector.connector_id
'9436da0c-a444-11eb-af93-12704e0d6a4d'
"""

_connectors: tuple[tuple[str, str, str], ...] = (
("ACTIVESCALE", "ActiveScale", "7251f6c8-93c9-11eb-95ba-12704e0d6a4d"),
("AZURE_BLOB", "Azure Blob", "9436da0c-a444-11eb-af93-12704e0d6a4d"),
("BLACKPEARL", "BlackPearl", "7e3f3f5e-350c-4717-891a-2f451c24b0d4"),
("BOX", "Box", "7c100eae-40fe-11e9-95a3-9cb6d0d9fd63"),
("CEPH", "Ceph", "1b6374b0-f6a4-4cf7-a26f-f262d9c6ca72"),
("DROPBOX", "Dropbox", "49b00fd6-63f1-48ae-b27f-d8af4589f876"),
(
"GOOGLE_CLOUD_STORAGE",
"Google Cloud Storage",
"56366b96-ac98-11e9-abac-9cb6d0d9fd63",
),
("GOOGLE_DRIVE", "Google Drive", "976cf0cf-78c3-4aab-82d2-7c16adbcc281"),
("HPSS", "HPSS", "fb656a17-0f69-4e59-95ff-d0a62ca7bdf5"),
("IRODS", "iRODS", "e47b6920-ff57-11ea-8aaa-000c297ab3c2"),
("POSIX", "POSIX", "145812c8-decc-41f1-83cf-bb2a85a2a70b"),
("POSIX_STAGING", "POSIX Staging", "052be037-7dda-4d20-b163-3077314dc3e6"),
("ONEDRIVE", "OneDrive", "28ef55da-1f97-11eb-bdfd-12704e0d6a4d"),
("S3", "S3", "7643e831-5f6c-4b47-a07f-8ee90f401d23"),
)

ACTIVESCALE: t.ClassVar[GlobusConnectServerConnector]
AZURE_BLOB: t.ClassVar[GlobusConnectServerConnector]
BLACKPEARL: t.ClassVar[GlobusConnectServerConnector]
BOX: t.ClassVar[GlobusConnectServerConnector]
CEPH: t.ClassVar[GlobusConnectServerConnector]
DROPBOX: t.ClassVar[GlobusConnectServerConnector]
GOOGLE_CLOUD_STORAGE: t.ClassVar[GlobusConnectServerConnector]
GOOGLE_DRIVE: t.ClassVar[GlobusConnectServerConnector]
HPSS: t.ClassVar[GlobusConnectServerConnector]
IRODS: t.ClassVar[GlobusConnectServerConnector]
ONEDRIVE: t.ClassVar[GlobusConnectServerConnector]
POSIX: t.ClassVar[GlobusConnectServerConnector]
POSIX_STAGING: t.ClassVar[GlobusConnectServerConnector]
S3: t.ClassVar[GlobusConnectServerConnector]

@classmethod
def all_connectors(cls) -> t.Iterable[GlobusConnectServerConnector]:
"""
Return an iterator of all known connectors.
"""
for attribute, _, _ in cls._connectors:
item: GlobusConnectServerConnector = getattr(cls, attribute)
yield item

@classmethod
def lookup(cls, name_or_id: str | UUIDLike) -> GlobusConnectServerConnector | None:
"""
Convert a name or ID into a connector object.
Returns None if the name or ID is not recognized.
Names are normalized before lookup so that they are case-insensitive and
spaces, dashes, and underscores are all treated equivalently. For
example, ``Google Drive``, ``google-drive``, and ``gOOgle_dRiVe`` are
all equivalent.
:param name_or_id: The name or ID of the connector
"""
normalized = _normalize_name(str(name_or_id))
for connector in cls.all_connectors():
if normalized == connector.connector_id or normalized == _normalize_name(
connector.name
):
return connector
return None


# "render" the _connectors to live attributes of the ConnectorTable
for _attribute, _name, _id in ConnectorTable._connectors:
setattr(
ConnectorTable,
_attribute,
GlobusConnectServerConnector(name=_name, connector_id=_id),
)
del _attribute, _name, _id
7 changes: 5 additions & 2 deletions tests/functional/services/gcs/test_user_credential.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json

from globus_sdk import UserCredentialDocument
from globus_sdk import ConnectorTable, UserCredentialDocument
from globus_sdk._testing import get_last_request, load_response


Expand All @@ -27,7 +27,10 @@ def test_get_user_credential(client):
assert res.full_data["DATA_TYPE"] == "result#1.0.0"
assert res["id"] == uc_id
assert res["display_name"] == "posix_credential"
assert client.connector_id_to_name(res["connector_id"]) == "POSIX"

connector = ConnectorTable.lookup(res["connector_id"])
assert connector is not None
assert connector.name == "POSIX"


def test_create_user_credential(client):
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/helpers/gcs/test_connector_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from __future__ import annotations

import inspect
import sys
import uuid

import pytest

from globus_sdk import ConnectorTable, GCSClient, GlobusConnectServerConnector, exc


def test_deprecated_connector_lookup_method_warns():
client = GCSClient("foo.bar.example.org")
with pytest.warns(exc.RemovedInV4Warning):
assert client.connector_id_to_name("foo") is None


@pytest.mark.parametrize("connector_data", ConnectorTable._connectors)
def test_lookup_by_attribute(connector_data):
attrname, connector_name, _ = connector_data

connector = getattr(ConnectorTable, attrname)
assert connector.name == connector_name


@pytest.mark.parametrize("connector_data", ConnectorTable._connectors)
@pytest.mark.parametrize("as_uuid", (True, False))
def test_lookup_by_id(connector_data, as_uuid):
_, connector_name, connector_id = connector_data

if as_uuid:
connector_id = uuid.UUID(connector_id)

connector = ConnectorTable.lookup(connector_id)
assert connector.name == connector_name


@pytest.mark.parametrize("connector_data", ConnectorTable._connectors)
def test_lookup_by_name(connector_data):
_, connector_name, connector_id = connector_data

connector = ConnectorTable.lookup(connector_name)
assert connector.connector_id == connector_id


@pytest.mark.parametrize(
"lookup_name, expect_name",
(
("Google Drive", "Google Drive"),
("google drive", "Google Drive"),
("google_drive", "Google Drive"),
("google-drive", "Google Drive"),
("google-----drive", "Google Drive"),
("google-_-drive", "Google Drive"), # moody
(" google_-drIVE", "Google Drive"),
("google_-drIVE ", "Google Drive"),
(" GOOGLE DRIVE ", "Google Drive"),
),
)
def test_lookup_by_name_normalization(lookup_name, expect_name):
connector = ConnectorTable.lookup(lookup_name)
assert connector.name == expect_name


@pytest.mark.parametrize("name", [c.name for c in ConnectorTable.all_connectors()])
def test_all_connector_names_map_to_attributes(name):
connector = ConnectorTable.lookup(name)
assert connector is not None
name = name.replace(" ", "_").upper()
assert getattr(ConnectorTable, name) == connector


@pytest.mark.skipif(
sys.version_info < (3, 10), reason="inspect.get_annotations added in 3.10"
)
def test_all_connector_attributes_are_assigned():
# build a list of attribute names annotated with
# `t.ClassVar[GlobusConnectServerConnector]`
annotated_attributes = []
for attribute, annotation in inspect.get_annotations(ConnectorTable).items():
# get_annotations does not interpret string-ized annotations by default, so we
# receive the relevant values as strings, making comparison simple
if annotation != "t.ClassVar[GlobusConnectServerConnector]":
continue
annotated_attributes.append(attribute)

# confirm that we got the right number of annotated items
assert len(annotated_attributes) == len(ConnectorTable._connectors)
# now confirm that all of these are assigned values
for attribute in annotated_attributes:
instance = getattr(ConnectorTable, attribute)
assert isinstance(instance, GlobusConnectServerConnector)

0 comments on commit 6adfa5b

Please sign in to comment.