diff --git a/.flake8 b/.flake8 index 492a84648..2a56c1def 100644 --- a/.flake8 +++ b/.flake8 @@ -8,5 +8,5 @@ per-file-ignores = *.pyi:E302,E305 [flake8:local-plugins] extension = - SDK = _globus_sdk_flake8:Plugin -paths = ./src/globus_sdk/ + SDK = globus_sdk_flake8:Plugin +paths = ./src/globus_sdk/_internal/extensions/ diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1dba99bc4..f8d054e57 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -80,8 +80,12 @@ jobs: - "pylint" - "test-lazy-imports" - "twine-check" - cache-paths: - - ".mypy_cache/" + # TODO: revisit caching strategy for '.mypy_cache' + # we believe a bad cache can be restored when the package changes, + # tricking 'mypy-test' into seeing an old version of the SDK + # + # cache-paths: + # - ".mypy_cache/" uses: "globus/workflows/.github/workflows/tox.yaml@f41714f6a8b102569807b348fce50960f9617df8" # v1.2 with: diff --git a/Makefile b/Makefile index f88400f69..c3dbbd7d1 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -SDK_VERSION=$(shell grep '^__version__' src/globus_sdk/version.py | cut -d '"' -f2) +SDK_VERSION=$(shell grep '^version' pyproject.toml | head -n 1 | cut -d '"' -f2) # these are just tox invocations wrapped nicely for convenience .PHONY: lint test docs all-checks diff --git a/RELEASING.md b/RELEASING.md index dda1f453e..8a191709d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -18,7 +18,7 @@ - Decide on the new version number and create a branch; `git checkout -b release-$SDK_VERSION` -- Update the version in `src/globus_sdk/version.py` +- Update the version in `pyproject.toml` - Update metadata and changelog, then verify changes in `changelog.rst` @@ -28,7 +28,7 @@ $EDITOR changelog.rst ``` - Add changed files; - `git add changelog.d/ changelog.rst src/globus_sdk/version.py` + `git add changelog.d/ changelog.rst pyproject.toml` - Commit; `git commit -m 'Bump version and changelog for release'` diff --git a/changelog.d/check-version-is-new.py b/changelog.d/check-version-is-new.py index f514b9523..5e3777ba5 100755 --- a/changelog.d/check-version-is-new.py +++ b/changelog.d/check-version-is-new.py @@ -6,24 +6,22 @@ import re import sys +if sys.version_info >= (3, 11): + import tomllib +else: + # Older Python versions + import tomli as tomllib + PATTERN_FORMAT = "^v{version}\\s+\\({date}\\)$" CHANGELOG_D = os.path.dirname(__file__) REPO_ROOT = os.path.dirname(CHANGELOG_D) def parse_version(): - # single source of truth for package version - version_string = "" - version_pattern = re.compile(r'__version__ = "([^"]*)"') - with open(os.path.join(REPO_ROOT, "src", "globus_sdk", "version.py")) as f: - for line in f: - match = version_pattern.match(line) - if match: - version_string = match.group(1) - break - if not version_string: - raise RuntimeError("Failed to parse version information") - return version_string + with open(os.path.join(REPO_ROOT, "pyproject.toml"), "rb") as f: + pyproject = tomllib.load(f) + + return pyproject["project"]["version"] def get_header_re(version): diff --git a/changelog.rst b/changelog.rst index afbeb8038..a2334dd91 100644 --- a/changelog.rst +++ b/changelog.rst @@ -12,6 +12,368 @@ to a major new version of the SDK. .. scriv-insert-here +.. _changelog-4.0.0: + +v4.0.0 (2025-10-08) +=================== + +*No changes from v4.0.0b2* + +.. _changelog-4.0.0b2: + +v4.0.0b2 (2025-09-24) +===================== + +Added +----- + +- On Python 3.11+, the SDK will populate the ``__notes__`` of API errors with a + message containing the full body of the error response. + ``__notes__`` is part of the default presentation of a traceback. (:pr:`1299`) + +Removed +------- + +- The following methods and parameters, which were deprecated in globus-sdk v3, + have been removed (:pr:`1309`): + + - The ``skip_activation_check`` parameter for ``TransferData`` and ``DeleteData``. + - The ``recursive_symlinks`` parameter for ``TransferData``. + - The ``add_symlink_item`` method of ``TransferData``. + +Changed +------- + +- Passing non-``Scope`` types to ``Scope.with_dependency`` and + ``Scope.with_dependencies`` now raises a ``TypeError``. Previously, this was + allowed at runtime but created an invalid ``Scope`` object. (:pr:`1300`) + +.. _changelog-4.0.0b1: + +v4.0.0b1 (2025-07-31) +===================== + +Breaking Changes +---------------- + +- The ``RequestsTransport`` object has been refactored to separate it from + configuration which controls request retries. A new ``RetryConfig`` object is + introduced and provided as ``client.retry_config`` on all client types. The + interface for controlling these configurations has been updated. + (:pr:`1275`) + + - The ``transport_class`` attribute has been removed from client classes. + + - Clients now accept ``transport``, an instance of ``RequestsTransport``, and + ``retry_config``, an instance of ``RetryConfig``, instead of + ``transport_params``. + + - Users seeking to customize the retry backoff, sleep maximum, and max + retries should now use ``retry_config``, as these are no longer controlled + through ``transport``. + + - The capabilities of the ``RequestsTransport.tune()`` context manager have + been divided into ``RequestsTransport.tune()`` and ``RetryConfig.tune()``. + + - The retry configuration is exposed to retry checks as an attribute of the + ``RequestCallerInfo``, which is provided on the ``RetryContext``. As a + result, checks can examine the configuration. + +- Interfaces for normalizing scope data have changed. (:pr:`1289`) + + - The ``scopes_to_str`` function has been replaced with + ``ScopeParser.serialize``. + + - ``ScopeParser.serialize`` will raise an error if the serialized data is + empty. A flag, ``reject_empty=False``, can be passed to disable this check. + + - The ``scopes_to_scope_list`` function has been removed. + +Removed +------- + +- Removed the ``filter_role`` parameter to ``FlowsClient.list_flows``. + This parameter was deprecated in ``globus-sdk`` version 3. (:pr:`1291`) + +- Removed ``SearchClient.update_entry``. + This method was deprecated in ``globus-sdk`` version 3. (:pr:`1292`) + +- Removed ``SearchClient.create_entry``. + This method was deprecated in ``globus-sdk`` version 3. (:pr:`1293`) + +- Removed the ``SearchQuery`` type. Users should use ``SearchQueryV1`` instead. + ``SearchQuery`` was deprecated in ``globus-sdk`` version 3. (:pr:`1294`) + +Changed +------- + +- The legacy token storage adapters are now only available from the + ``globus_sdk.token_storage.legacy`` subpackage. + + Users are encouraged to migrate to the newer tooling available directly from + ``globus_sdk.token_storage``. (:pr:`1290`) + +- Update ``warn_deprecated`` to emit ``RemovedInV5Warning`` and remove + ``RemovedInV4Warning`` class (:pr:`1295`) + +.. _changelog-4.0.0a4: + +v4.0.0a4 (2025-07-25) +===================== + +Breaking Changes +---------------- + +- The ``function_data`` argument to ``ComputeClientV2.register_function`` has + been renamed to ``data`` to be consistent with other usages. + +- ``AuthClient`` no longer accepts ``client_id`` as a parameter and does not + provide it as an attribute. This was deprecated in globus-sdk version 3. (:pr:`1271`) + +Added +----- + +- Add ``RequestCallerInfo`` data object to ``RequestsTransport.request`` for passing caller context information. (:pr:`1261`) + +Removed +------- + +- The ``TimerJob.from_transfer_data`` classmethod, which was deprecated in + globus-sdk version 3, has been removed. Users should use the ``TransferTimer`` + class to construct timers which submit transfer tasks. (:pr:`1269`) + +- The ``oauth2_validate_token`` method has been removed from + ``NativeAppAuthClient`` and ``ConfidentialAppAuthClient``. + This method was deprecated in globus-sdk v3. (:pr:`1270`) + +- Removed ``AuthClient.oauth2_userinfo``. This method was deprecated in + ``globus-sdk`` version 3. (:pr:`1272`) + +- Removed support for ``ConfidentialAppAuthClient.get_identities``. + This usage was deprecated in ``globus-sdk`` version 3. (:pr:`1273`) + + - Users calling the Get Identities API on behalf of a client identity should + instead get tokens for the client and use those tokens to call + ``AuthClient.get_identities``. For example, by instantiating an + ``AuthClient`` using a ``ClientCredentialsAuthorizer``. + + - This also means that it is no longer valid to use a + ``ConfidentialAppAuthClient`` to initialize an ``IdentityMap``. + +- ``TransferClient.create_endpoint`` has been removed. This method primarily + supported creation of GCSv4 servers and was deprecated in ``globus-sdk`` v3. + (:pr:`1276`) + +- ``GCSClient.connector_id_to_name()`` has been removed. It was deprecated in + ``globus-sdk`` version 3. Users should use ``globus_sdk.ConnectorTable`` + instead. (:pr:`1277`) + +- Removed support for Endpoint Activation, a feature which was specific to + Globus Connect Server v4. (:pr:`1279`) + + - Removed the activation methods: ``TransferClient.endpoint_autoactivate``, + ``TransferClient.endpoint_activate``, + ``TransferClient.endpoint_deactivate``, and + ``TransferClient.endpoint_get_activation_requirements`` + + - Removed the specialized ``ActivationRequirementsResponse`` parsed response + type + + - ``TransferClient.update_endpoint`` would previously check the + ``myproxy_server`` and ``oauth_server`` parameters, which were solely used + for the purpose of configuring activation. It no longer does so. + +- Removed the ``ComputeClient`` alias. This name was deprecated in + ``globus-sdk`` version 3. Users should use ``ComputeClientV2`` or + ``ComputeClientV3`` instead. (:pr:`1282`) + +- Removed ``GlobusAPIError.raw_text``. This attribute was deprecated in + ``globus-sdk`` version 3. Users should use the ``text`` attribute instead. + (:pr:`1283`) + +- Removed ``TransferClient`` methods for modifying "endpoint servers", a + feature specific to Globus Connect Server v4. Specifically, + ``add_endpoint_server``, ``update_endpoint_server``, and + ``delete_endpoint_server``. + These methods were deprecated in ``globus-sdk`` version 3. (:pr:`1284`) + +- Removed the ``ComputeFunctionDocument`` and ``ComputeFunctionMetadata`` + classes. These helpers were deprecated in ``globus-sdk`` version 3. + +- Removed ``TransferClient.operation_symlink``. This method was deprecated in + ``globus-sdk`` version 3. (:pr:`1286`) + +Changed +------- + +- Renamed the ``globus_sdk._testing`` subpackage to ``globus_sdk.testing``. (:pr:`1251`) + +- Renamed the ``globus_sdk.tokenstorage`` subpackage to ``globus_sdk.token_storage`` and removed the ``globus_sdk.experimental.tokenstorage`` (:pr:`1252`) + +- Remove support for normalizing nested iterables of scopes, e.g. ``[["scope1"], "scope2"]`` (:pr:`1259`) + +.. _changelog-4.0.0a3: + +v4.0.0a3 (2025-07-10) +===================== + +Breaking Changes +---------------- + +- All defaults of ``None`` converted to ``globus_sdk.MISSING`` for all payload types in the Transfer client. (:pr:`1216`) + +- The ``transfer_client`` parameter to ``TransferData`` and ``DeleteData`` has been removed. + See the upgrading doc for transition details. (:pr:`1236`) + +- In Globus Auth client classes, defaults of ``None`` are converted to + ``MISSING`` for optional fields. (:pr:`1236`) + +Added +----- + +- ``SpecificFlowClient`` has a new method, + ``add_app_transfer_data_access_scope`` which facilitates declaration of scope + requirements when starting flows which interact with collections that need + ``data_access`` scopes. (:pr:`1166`) + +Removed +------- + +- ``globus_sdk.experimental.scope_parser`` has been removed. Use + ``globus_sdk.scopes`` instead. (:pr:`1236`) + +Changed +------- + +- ``Scope`` objects are now immutable. (:pr:`1208`) + + - ``Scope.dependencies`` is now a tuple, not a list. + + - The ``add_dependency`` method has been removed, since mutating a ``Scope`` + is no longer possible. + + - A new evolver method, ``Scope.with_dependency`` has been added. It extends + the ``dependencies`` tuple in a new ``Scope`` object. + + - A batch version of ``Scope.with_dependency`` has been added, + ``Scope.with_dependencies``. + + - An evolver for the ``optional`` field of a ``Scope`` is also now available, + named ``Scope.with_optional``. + +- Scope parsing has been separated from the main ``Scope`` class into a + dedicated ``ScopeParser`` which provides parsing methods. (:pr:`1208`) + + - Use ``globus_sdk.scopes.ScopeParser`` for complex parsing use-cases. The + ``ScopeParser.parse`` classmethod parses strings into lists of scope + objects. + + - ``Scope.merge_scopes`` has been moved to ``ScopeParser.merge_scopes``. + + - ``Scope.parse`` is changed to call ``ScopeParser.parse`` and verify that + there is exactly one result, which it returns. This means that + ``Scope.parse`` now returns a single ``Scope``, not a ``list[Scope]``. + + - ``Scope.serialize`` and ``Scope.deserialize`` have been removed as methods. + Use ``str(scope_object)`` as a replacement for ``serialize()`` and + ``Scope.parse`` as a replacement for ``deserialize()``. + +- Payload types now inherit from ``dict`` rather than ``UserDict``. The + ``PayloadWrapper`` utility class has been replaced with ``Payload``. + (:pr:`1222`) +- Payload types are more consistent about encoding missing values using ``MISSING``. + (:pr:`1222`) + +- The SDK's ``ScopeBuilder`` types have been replaced with + ``StaticScopeCollection`` and ``DynamicScopeCollection`` types. (:pr:`1237`) + + - Scopes provided as constants by the SDK are now ``Scope`` objects, not + strings. They can be converted to strings trivially with ``str(scope)``. + + - The various scope builder types have been renamed. ``SpecificFlowScopes``, + ``GCSEndpointScopes``, and ``GCSCollectionScopes`` replace + ``SpecificFlowScopeBuilder``, ``GCSEndpointScopeBuilder``, and + ``GCSCollectionScopeBuilder``. + +- The ``ScopeBuilder`` types have been simplified and improved as the new + ``ScopeCollection`` types. (:pr:`1237`) + + - ``ScopeBuilder`` is replaced with ``StaticScopeCollection`` and + ``DynamicScopeCollection``. The ``scopes`` attribute of client classes is + now a scope collection. + + - The attributes of ``ScopeCollection``\s are ``Scope`` objects, not strings. + + - ``ScopeCollection``\s define ``__iter__``, yielding the provided scopes, + but not ``__str__``. + +.. _changelog-4.0.0a2: + +v4.0.0a2 (2025-06-05) +===================== + +Breaking Changes +---------------- + +- The SDK version is no longer available in ``globus_sdk.version.__version__``. (:pr:`1195`) + + Packages that want to query the SDK version must use ``importlib.metadata``: + + .. code-block:: python + + import importlib.metadata + + GLOBUS_SDK_VERSION = importlib.metadata.distribution("globus_sdk").version + +- The legacy ``MutableScope`` type has been removed. (:pr:`1198`) + + - The ``make_mutable`` method on ``ScopeBuilder`` objects has also been + removed as a consequence of this change. + +- Defaults of ``None`` were converted to ``globus_sdk.MISSING`` for multiple client + methods and payload types, covering Compute, Flows, Groups, GCS, and Search. + (:pr:`1205`, :pr:`1207`, :pr:`1212`, :pr:`1214`) + +Removed +------- + +- ``globus_sdk.experimental.auth_requirements_error`` has been removed. Use + ``globus_sdk.gare`` instead. (:pr:`1202`) + +- ``GlobusAPIError`` no longer provides a setter for ``message``. The + ``message`` property is now read-only. (:pr:`1204`) + +- Deprecated aliases for ``TimersClient``, ``TimersScopes``, and + ``TimersAPIError`` have been removed. (:pr:`1206`) + +.. _changelog-4.0.0a1: + +v4.0.0a1 (2025-05-20) +===================== + +Breaking Changes +---------------- + +- The SDK no longer sets default scopes for direct use + of client credentials and auth client login flow methods. + Users should either use ``GlobusApp`` objects, + which can specify scopes based on the clients in use, + or else pass a list of scopes explicitly to + ``oauth2_client_credentials_tokens`` or ``oauth2_start_flow``. (:pr:`1186`) + +- The default ``GlobusAPIError.code`` value is now ``None`` + when ``code`` is not supplied in the error body. + Previously, the default was ``"Error"``. (:pr:`1190`) + +- The default ``TimersAPIError.code`` value is now ``None`` + when an error which appears to be validation-related has no ``code``. + Previously, the default was ``"ValidationError"``. (:pr:`1191`) + +- SDK client classes no longer define nor prepend a ``base_path`` attribute to paths. + Make sure to use the full path now when using client methods. (:pr:`1185`) + +- Updated MappedCollectionDoc and GuestCollectionDoc with MissingType. (:pr:`1189`) + .. _changelog-3.65.0: v3.65.0 (2025-10-02) @@ -59,10 +421,6 @@ Deprecated v3.63.0 (2025-09-04) ==================== - -Changed -------- - - Renamed the ``GroupsClient`` method ``set_subscription_admin_verified_id`` to ``set_subscription_admin_verified``. (:pr:`1302`) @@ -74,9 +432,6 @@ Changed v3.62.0 (2025-07-31) ==================== -Added ------ - - Added support for setting a group's ``subscription_id`` via ``GroupsClient.set_subscription_admin_verified_id``. (:pr:`1287`) diff --git a/docs/authorization/scopes_and_consents/index.rst b/docs/authorization/scopes_and_consents/index.rst index 0f16517d8..ee9dc53b7 100644 --- a/docs/authorization/scopes_and_consents/index.rst +++ b/docs/authorization/scopes_and_consents/index.rst @@ -30,5 +30,6 @@ which make learning about and manipulating these data easier. :maxdepth: 1 scopes - mutable_scopes + scope_collections consents + scope_parsing diff --git a/docs/authorization/scopes_and_consents/mutable_scopes.rst b/docs/authorization/scopes_and_consents/mutable_scopes.rst deleted file mode 100644 index 17dd8dff3..000000000 --- a/docs/authorization/scopes_and_consents/mutable_scopes.rst +++ /dev/null @@ -1,95 +0,0 @@ -.. _mutable_scopes: - -.. currentmodule:: globus_sdk.scopes - -MutableScopes -============= - -.. warning:: - - The ``MutableScope`` class and its interfaces are considered a legacy - feature. They will be deprecated and removed in a future SDK release. - - Users should prefer to use ``globus_sdk.Scope`` instead. - ``globus_sdk.Scope``, documented in :ref:`the scopes documentation `, - provides strictly more features and has a superior interface. - -In order to support optional and dependent scopes, a type is -provided by ``globus_sdk.scopes``: the ``MutableScope`` class. - -``MutableScope`` can be constructed directly or via a ``ScopeBuilder``'s -``make_mutable`` method, given a scope's short name. - -For example, one can create a ``MutableScope`` from the Groups "all" scope -as follows: - -.. code-block:: python - - from globus_sdk.scopes import GroupsScopes - - scope = GroupsScopes.make_mutable("all") - -``MutableScope`` objects primarily provide two main pieces of functionality: -dynamically building a scope tree and serializing to a string. - -Dynamic Scope Construction -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -``MutableScope`` objects provide a tree-like interface for constructing scopes -and their dependencies. - -For example, the transfer scope dependent upon a collection scope may be -constructed by means of ``MutableScope`` methods and the ``make_mutable`` method -of scope builders thusly: - -.. code-block:: python - - from globus_sdk.scopes import GCSCollectionScopeBuilder, TransferScopes - - MAPPED_COLLECTION_ID = "...ID HERE..." - - # create the scopes with make_mutable - transfer_scope = TransferScopes.make_mutable("all") - data_access_scope = GCSCollectionScopeBuilder(MAPPED_COLLECTION_ID).make_mutable( - "data_access", optional=True - ) - # add data_access as a dependency - transfer_scope.add_dependency(data_access_scope) - -``MutableScope``\s can be used in most of the same locations where scope -strings can be used, but you can also call ``str()`` on them to get a -stringified representation. - -Serializing Scopes -~~~~~~~~~~~~~~~~~~ - -Whenever scopes are being sent to Globus services, they need to be encoded as -strings. All mutable scope objects support this by means of their defined -``serialize`` method. Note that ``__str__`` for a ``MutableScope`` is just an -alias for ``serialize``. For example, the following is valid usage to demonstrate -``str()``, ``repr()``, and ``serialize()``: - -.. code-block:: pycon - - >>> from globus_sdk.scopes import MutableScope - >>> foo = MutableScope("foo") - >>> bar = MutableScope("bar") - >>> bar.add_dependency("baz") - >>> foo.add_dependency(bar) - >>> print(str(foo)) - foo[bar[baz]] - >>> print(bar.serialize()) - bar[baz] - >>> alpha = MutableScope("alpha") - >>> alpha.add_dependency(MutableScope("beta", optional=True)) - >>> print(str(alpha)) - alpha[*beta] - >>> print(repr(alpha)) - MutableScope("alpha", dependencies=[MutableScope("beta", optional=True)]) - -MutableScope Reference -~~~~~~~~~~~~~~~~~~~~~~ - -.. autoclass:: MutableScope - :members: - :show-inheritance: diff --git a/docs/authorization/scopes_and_consents/scope_collections.rst b/docs/authorization/scopes_and_consents/scope_collections.rst new file mode 100644 index 000000000..57788c561 --- /dev/null +++ b/docs/authorization/scopes_and_consents/scope_collections.rst @@ -0,0 +1,202 @@ +.. _scope_collections: + +.. currentmodule:: globus_sdk.scopes + +ScopeCollections +================ + +OAuth2 Scopes for various Globus services are represented by ``ScopeCollection`` +objects. +These are containers for constant :class:`Scope` objects. + +Scope collections are provided directly via ``globus_sdk.scopes`` and are also +accessible via the relevant client classes. + +Direct Use +---------- + +To use the scope collections directly, import from ``globus_sdk.scopes``. + +For example, one might use the Transfer "all" scope during a login flow like +so: + +.. code-block:: python + + import globus_sdk + from globus_sdk.scopes import TransferScopes + + CLIENT_ID = "" + + client = globus_sdk.NativeAppAuthClient(CLIENT_ID) + client.oauth2_start_flow(requested_scopes=[TransferScopes.all]) + ... + +As Client Attributes +-------------------- + +Token scopes are associated with a particular client which will use that token. +Because of this, each service client contains a ``ScopeCollection`` attribute +(``client.scopes``) defining the relevant scopes for that client. + +For most client classes, this is a class attribute. For example, accessing +``TransferClient.scopes`` is valid: + +.. code-block:: python + + import globus_sdk + + CLIENT_ID = "" + + client = globus_sdk.NativeAppAuthClient(CLIENT_ID) + client.oauth2_start_flow(requested_scopes=[globus_sdk.TransferClient.scopes.all]) + ... + + # or, potentially, after there is a concrete client + tc = globus_sdk.TransferClient() + client.oauth2_start_flow(requested_scopes=[tc.scopes.all]) + +As Instance Attributes and Methods +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Some client classes only provide their scopes for instances. These cases cover +services which are distributed or contain multiple subservices with their own +scopes. + +For example, ``GCSClient`` and ``SpecificFlowClient`` each have a ``scopes`` +attribute of ``None`` on their classes. + +In the case of ``SpecificFlowClient``, scopes are populated whenever an +instance is instantiated. So the following usage is valid: + +.. code-block:: python + + import globus_sdk + + FLOW_ID = "" + + client = globus_sdk.SpecificFlowClient(FLOW_ID) + flow_user_scope = client.scopes.user + +In the case of GCS, a distributed service, ``scopes`` is always ``None``. +However, :meth:`globus_sdk.GCSClient.get_gcs_endpoint_scopes` and +:meth:`globus_sdk.GCSClient.get_gcs_collection_scopes` are available helpers +for getting specific collections of scopes. + +Using a Scope Collection to Get Matching Tokens +----------------------------------------------- + +A ``ScopeCollection`` contains the resource server name used to get token data +from a token response. +To elaborate on the above example: + +.. code-block:: python + + import globus_sdk + from globus_sdk.scopes import TransferScopes + + CLIENT_ID = "" + + client = globus_sdk.NativeAppAuthClient(CLIENT_ID) + client.oauth2_start_flow(requested_scopes=[TransferScopes.all]) + authorize_url = client.oauth2_get_authorize_url() + print("Please go to this URL and login:", authorize_url) + auth_code = input("Please enter the code you get after login here: ").strip() + token_response = client.oauth2_exchange_code_for_tokens(auth_code) + + # use the `resource_server` of a ScopeBuilder to grab the associated token + # data from the response + tokendata = token_response.by_resource_server[TransferScopes.resource_server] + + +Reference +--------- + +Collection Types +~~~~~~~~~~~~~~~~ + +.. autoclass:: ScopeCollection + :members: + :show-inheritance: + +.. autoclass:: StaticScopeCollection + :members: + :show-inheritance: + +.. autoclass:: DynamicScopeCollection + :members: + :show-inheritance: + +.. autoclass:: GCSEndpointScopes + :members: + :show-inheritance: + +.. autoclass:: GCSCollectionScopes + :members: + :show-inheritance: + +.. autoclass:: SpecificFlowScopes + :members: + :show-inheritance: + +Collection Constants +~~~~~~~~~~~~~~~~~~~~ + +.. py:data:: globus_sdk.scopes.data.AuthScopes + + Globus Auth scopes. + + .. listknownscopes:: globus_sdk.scopes.AuthScopes + :example_scope: view_identity_set + + +.. py:data:: globus_sdk.scopes.data.ComputeScopes + + Compute scopes. + + .. listknownscopes:: globus_sdk.scopes.ComputeScopes + + +.. py:data:: globus_sdk.scopes.data.FlowsScopes + + Globus Flows scopes. + + .. listknownscopes:: globus_sdk.scopes.FlowsScopes + + +.. py:data:: globus_sdk.scopes.data.GroupsScopes + + Groups scopes. + + .. listknownscopes:: globus_sdk.scopes.GroupsScopes + + +.. py:data:: globus_sdk.scopes.data.NexusScopes + + Nexus scopes. + + .. listknownscopes:: globus_sdk.scopes.NexusScopes + + .. warning:: + + Use of Nexus is deprecated. Users should use Groups instead. + + +.. py:data:: globus_sdk.scopes.data.SearchScopes + + Globus Search scopes. + + .. listknownscopes:: globus_sdk.scopes.SearchScopes + + +.. py:data:: globus_sdk.scopes.data.TimersScopes + + Globus Timers scopes. + + .. listknownscopes:: globus_sdk.scopes.TimersScopes + + +.. py:data:: globus_sdk.scopes.data.TransferScopes + + Globus Transfer scopes. + + .. listknownscopes:: globus_sdk.scopes.TransferScopes diff --git a/docs/authorization/scopes_and_consents/scope_parsing.rst b/docs/authorization/scopes_and_consents/scope_parsing.rst new file mode 100644 index 000000000..1f0b80e85 --- /dev/null +++ b/docs/authorization/scopes_and_consents/scope_parsing.rst @@ -0,0 +1,26 @@ +.. _scope_parsing: + +.. currentmodule:: globus_sdk.scopes + +Scope Parsing +============= + +Scope parsing is handled by the :class:`ScopeParser` type. + +Additionally, :class:`Scope` objects define a +:meth:`parse() ` method which wraps parser +usage. + +:class:`ScopeParser` provides classmethods as its primary interface, so there +is no need to instantiate the parser in order to use it. + +ScopeParser Reference +--------------------- + +.. autoclass:: ScopeParser + :members: + :show-inheritance: + +.. autoclass:: ScopeParseError + +.. autoclass:: ScopeCycleError diff --git a/docs/authorization/scopes_and_consents/scopes.rst b/docs/authorization/scopes_and_consents/scopes.rst index 2d6687338..b302372b2 100644 --- a/docs/authorization/scopes_and_consents/scopes.rst +++ b/docs/authorization/scopes_and_consents/scopes.rst @@ -2,141 +2,37 @@ .. currentmodule:: globus_sdk.scopes -Scopes and ScopeBuilders -======================== - -OAuth2 Scopes for various Globus services are represented by ``ScopeBuilder`` -objects. - -A number of preset scope builders are provided and populated with useful data, -and they are also accessible via the relevant client classes. - -Direct Use (As Constants) -------------------------- - -To use the scope builders directly, import from ``globus_sdk.scopes``. - -For example, one might use the Transfer "all" scope during a login flow like -so: - -.. code-block:: python - - import globus_sdk - from globus_sdk.scopes import TransferScopes - - CLIENT_ID = "" - - client = globus_sdk.NativeAppAuthClient(CLIENT_ID) - client.oauth2_start_flow(requested_scopes=[TransferScopes.all]) - ... - -As Client Attributes --------------------- - -Token scopes are associated with a particular client which will use that token. -Because of this, each service client contains a ``ScopeBuilder`` attribute (``client.scopes``) defining the relevant scopes for that client. - -For most client classes, this is a class attribute. For example, accessing -``TransferClient.scopes`` is valid: - -.. code-block:: python - - import globus_sdk - - CLIENT_ID = "" - - client = globus_sdk.NativeAppAuthClient(CLIENT_ID) - client.oauth2_start_flow(requested_scopes=[globus_sdk.TransferClient.scopes.all]) - ... - - # or, potentially, after there is a concrete client - _tc = globus_sdk.TransferClient() - client.oauth2_start_flow(requested_scopes=[_tc.scopes.all]) - -As Instance Attributes and Methods -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Some client classes only provide their scopes for instances. These cases cover -services which are distributed or contain multiple subservices with their own -scopes. - -For example, ``GCSClient`` and ``SpecificFlowClient`` each have a ``scopes`` -attribute of ``None`` on their classes. - -In the case of ``SpecificFlowClient``, scopes are populated whenever an -instance is instantiated. So the following usage is valid: - -.. code-block:: python - - import globus_sdk - - FLOW_ID = "" - - client = globus_sdk.SpecificFlowClient(FLOW_ID) - flow_user_scope = client.scopes.user - -In the case of GCS, a distributed service, ``scopes`` is always ``None``. -However, :meth:`globus_sdk.GCSClient.get_gcs_endpoint_scopes` and -:meth:`globus_sdk.GCSClient.get_gcs_collection_scopes` are available helpers -for getting specific collections of scopes. - -Using a Scope Builder to Get Matching Tokens --------------------------------------------- - -A ``ScopeBuilder`` contains the resource server name used to get token data -from a token response. -To elaborate on the above example: - -.. code-block:: python - - import globus_sdk - from globus_sdk.scopes import TransferScopes - - CLIENT_ID = "" - - client = globus_sdk.NativeAppAuthClient(CLIENT_ID) - client.oauth2_start_flow(requested_scopes=[TransferScopes.all]) - authorize_url = client.oauth2_get_authorize_url() - print("Please go to this URL and login:", authorize_url) - auth_code = input("Please enter the code you get after login here: ").strip() - token_response = client.oauth2_exchange_code_for_tokens(auth_code) - - # use the `resource_server` of a ScopeBuilder to grab the associated token - # data from the response - tokendata = token_response.by_resource_server[TransferScopes.resource_server] - -Scope objects -------------- +Scopes +====== The SDK provides a ``Scope`` object which is the class model for a scope. ``Scope``\s can be parsed from strings and serialized to strings, and support programmatic manipulations to describe dependent scopes. -``Scope`` can be constructed using its initializer, or one of its two main -parsing methods: ``Scope.parse`` and ``Scope.deserialize``. -``parse`` produces a list of scopes from a string, while ``deserialize`` -produces exactly one. +``Scope`` can be constructed using its initializer, via ``Scope.parse``, or via +:meth:`ScopeParser.parse`. -For example, one can create a ``Scope`` from the Groups "all" scope -as follows: +For example, one can create a ``Scope`` object for the OIDC ``openid`` scope: .. code-block:: python - from globus_sdk.scopes import GroupsScopes, Scope + from globus_sdk.scopes import Scope - group_scope = Scope.deserialize(GroupsScopes.all) + openid_scope = Scope("openid") ``Scope`` objects primarily provide three main pieces of functionality: - * parsing (deserializing) - * stringifying (serializing) + * deserializing (parsing a single scope) + * serializing (stringifying) * scope tree construction -Scope Construction -~~~~~~~~~~~~~~~~~~ +Tree Construction +~~~~~~~~~~~~~~~~~ ``Scope`` objects provide a tree-like interface for constructing scopes and their dependencies. +Because ``Scope`` objects are immutable, trees are constructed by building new +scopes. For example, the transfer scope dependent upon a collection scope may be constructed by means of ``Scope`` methods thusly: @@ -148,13 +44,12 @@ constructed by means of ``Scope`` methods thusly: MAPPED_COLLECTION_ID = "...ID HERE..." # create the scope object, and get the data_access_scope as a string - transfer_scope = Scope(TransferScopes.all) data_access_scope = GCSCollectionScopeBuilder(MAPPED_COLLECTION_ID).data_access # add data_access as an optional dependency - transfer_scope.add_dependency(data_access_scope, optional=True) + transfer_scope = TransferScopes.all.with_dependency(data_access_scope, optional=True) ``Scope``\s can be used in most of the same locations where scope -strings can be used, but you can also call ``scope.serialize()`` to get a +strings can be used, but you can also call ``str(scope)`` to get a stringified representation. Serializing Scopes @@ -162,133 +57,30 @@ Serializing Scopes Whenever scopes are being sent to Globus services, they need to be encoded as strings. All scope objects support this by means of their defined -``serialize`` method. Note that ``__str__`` for a ``Scope`` is just an -alias for ``serialize``. For example, the following is an example of -``str()``, ``repr()``, and ``serialize()`` usage: +``__str__`` method. For example, the following is an example of +``str()`` and ``repr()`` usage: .. code-block:: pycon >>> from globus_sdk.scopes import Scope >>> foo = Scope("foo") >>> bar = Scope("bar") - >>> bar.add_dependency("baz") - >>> foo.add_dependency(bar) + >>> bar = bar.with_dependency(Scope("baz")) + >>> foo = foo.with_dependency(bar) >>> print(str(foo)) foo[bar[baz]] - >>> print(bar.serialize()) + >>> print(str(bar)) bar[baz] >>> alpha = Scope("alpha") - >>> alpha.add_dependency("beta", optional=True) + >>> alpha = alpha.with_dependency("beta", optional=True) >>> print(str(alpha)) alpha[*beta] >>> print(repr(alpha)) Scope("alpha", dependencies=[Scope("beta", optional=True)]) -Scope Reference -~~~~~~~~~~~~~~~ +Reference +~~~~~~~~~ .. autoclass:: Scope :members: :member-order: bysource - -.. autoclass:: ScopeParseError - -.. autoclass:: ScopeCycleError - -.. rubric:: Utility Functions - -``globus_sdk.scopes`` also provides helper functions which are used to -manipulate scope objects. - -.. autofunction:: scopes_to_str - -.. autofunction:: scopes_to_scope_list - -ScopeBuilders -------------- - -ScopeBuilder Types -~~~~~~~~~~~~~~~~~~ - -.. autoclass:: ScopeBuilder - :members: - :show-inheritance: - -.. autoclass:: GCSEndpointScopeBuilder - :members: - :show-inheritance: - -.. autoclass:: GCSCollectionScopeBuilder - :members: - :show-inheritance: - -.. autoclass:: SpecificFlowScopeBuilder - :members: - :show-inheritance: - -ScopeBuilder Constants -~~~~~~~~~~~~~~~~~~~~~~ - -.. py:data:: globus_sdk.scopes.data.AuthScopes - - Globus Auth scopes. - - .. listknownscopes:: globus_sdk.scopes.AuthScopes - :example_scope: view_identity_set - - -.. py:data:: globus_sdk.scopes.data.ComputeScopes - - Compute scopes. - - .. listknownscopes:: globus_sdk.scopes.ComputeScopes - - -.. py:data:: globus_sdk.scopes.data.FlowsScopes - - Globus Flows scopes. - - .. listknownscopes:: globus_sdk.scopes.FlowsScopes - - -.. py:data:: globus_sdk.scopes.data.GroupsScopes - - Groups scopes. - - .. listknownscopes:: globus_sdk.scopes.GroupsScopes - - -.. py:data:: globus_sdk.scopes.data.NexusScopes - - Nexus scopes. - - .. listknownscopes:: globus_sdk.scopes.NexusScopes - - .. warning:: - - Use of Nexus is deprecated. Users should use Groups instead. - - -.. py:data:: globus_sdk.scopes.data.SearchScopes - - Globus Search scopes. - - .. listknownscopes:: globus_sdk.scopes.SearchScopes - - -.. py:data:: globus_sdk.scopes.data.TimersScopes - - Globus Timers scopes. - - .. listknownscopes:: globus_sdk.scopes.TimersScopes - - .. note:: - - ``TimersScopes`` is also available under the legacy name ``TimerScopes``. - - -.. py:data:: globus_sdk.scopes.data.TransferScopes - - Globus Transfer scopes. - - .. listknownscopes:: globus_sdk.scopes.TransferScopes diff --git a/docs/authorization/token_caching/index.rst b/docs/authorization/token_caching/index.rst index 0c03a2abe..c9a36a53a 100644 --- a/docs/authorization/token_caching/index.rst +++ b/docs/authorization/token_caching/index.rst @@ -9,7 +9,7 @@ recommend using the former. ``TokenStorage`` is a newer iteration of the token s interface and includes a superset of the functionality previously supported in ``StorageAdapter``. -All constructs from both hierarchies are importable from the ``globus_sdk.tokenstorage`` +All constructs from both hierarchies are importable from the ``globus_sdk.token_storage`` namespace. .. toctree:: diff --git a/docs/authorization/token_caching/storage_adapters.rst b/docs/authorization/token_caching/storage_adapters.rst index 164a8d9ea..a7969250a 100644 --- a/docs/authorization/token_caching/storage_adapters.rst +++ b/docs/authorization/token_caching/storage_adapters.rst @@ -15,7 +15,7 @@ received from authentication and token refreshes. Usage ----- -StorageAdapter is available under the name ``globus_sdk.tokenstorage``. +StorageAdapter is available under the name ``globus_sdk.token_storage.legacy``. Storage adapters are the main objects of this subpackage. Primarily, usage should revolve around creating a storage adapter, potentially loading data from @@ -27,7 +27,7 @@ For example: import os import globus_sdk - from globus_sdk.tokenstorage import SimpleJSONFileAdapter + from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter my_file_adapter = SimpleJSONFileAdapter(os.path.expanduser("~/mytokens.json")) @@ -76,19 +76,12 @@ For example: tc = globus_sdk.TransferClient(authorizer=authorizer) -Complete Example Usage -~~~~~~~~~~~~~~~~~~~~~~ - -The :ref:`Group Listing With Token Storage Script ` -provides a complete and runnable example which leverages ``tokenstorage``. - - Adapter Types ------------- -.. module:: globus_sdk.tokenstorage +.. module:: globus_sdk.token_storage.legacy -``globus_sdk.tokenstorage`` provides base classes for building your own storage +``globus_sdk.token_storage.legacy`` provides base classes for building your own storage adapters, and several complete adapters. The :class:`SimpleJSONFileAdapter` is good for the "simplest possible" diff --git a/docs/authorization/token_caching/token_storages.rst b/docs/authorization/token_caching/token_storages.rst index 5c127bba4..eec710c30 100644 --- a/docs/authorization/token_caching/token_storages.rst +++ b/docs/authorization/token_caching/token_storages.rst @@ -1,6 +1,6 @@ .. _token_storages: -.. currentmodule:: globus_sdk.tokenstorage +.. currentmodule:: globus_sdk.token_storage Token Storages ============== diff --git a/docs/conf.py b/docs/conf.py index 1075b1aa6..5e12036bd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,7 +28,7 @@ "sphinx_design", "sphinx_issues", # our custom one - "globus_sdk._sphinxext", + "globus_sdk._internal.extensions.sphinxext", ] project = "globus-sdk" @@ -38,6 +38,7 @@ version = globus_sdk.__version__ # The full version, including alpha/beta/rc tags. release = version +major_version = version.partition(".")[0] issues_github_path = "globus/globus-sdk-python" @@ -56,7 +57,7 @@ # HTML Theme Options html_show_sourcelink = True html_theme = "furo" -html_title = "globus-sdk v3" +html_title = f"globus-sdk v{major_version}" html_theme_options = { "light_css_variables": { "color-brand-primary": "#27518F", diff --git a/docs/core/utils.rst b/docs/core/utils.rst index 5e2432b70..8fd7bf530 100644 --- a/docs/core/utils.rst +++ b/docs/core/utils.rst @@ -3,24 +3,14 @@ Utilities .. warning:: - The components in this module are *not* intended for outside use, but are - internal to the Globus SDK. + These components are *not* intended for outside use, but are internal to + the Globus SDK. They may change in backwards-incompatible ways in minor or patch releases of the SDK. This documentation is included here for completeness. -PayloadWrapper --------------- - -The ``PayloadWrapper`` class is used as a base class for all Globus SDK -payload datatypes to provide nicer interfaces for payload construction. - -The objects are a type of ``UserDict`` with no special methods. - -.. autoclass:: globus_sdk.utils.PayloadWrapper - MissingType and MISSING ----------------------- diff --git a/docs/core/warnings.rst b/docs/core/warnings.rst index 89c33e2d7..5e46c57c6 100644 --- a/docs/core/warnings.rst +++ b/docs/core/warnings.rst @@ -4,7 +4,7 @@ Warnings The following warnings can be emitted by the Globus SDK to indicate a problem, or a future change, which is not necessarily an error. -.. autoclass:: globus_sdk.RemovedInV4Warning +.. autoclass:: globus_sdk.RemovedInV5Warning :members: :show-inheritance: diff --git a/docs/examples/auth_manage_projects/list_and_create_projects.py b/docs/examples/auth_manage_projects/list_and_create_projects.py index 50d44eda1..12f7cfdc7 100644 --- a/docs/examples/auth_manage_projects/list_and_create_projects.py +++ b/docs/examples/auth_manage_projects/list_and_create_projects.py @@ -4,7 +4,7 @@ import os import globus_sdk -from globus_sdk.tokenstorage import SimpleJSONFileAdapter +from globus_sdk.token_storage import SimpleJSONFileAdapter MY_FILE_ADAPTER = SimpleJSONFileAdapter( os.path.expanduser("~/.sdk-manage-projects.json") @@ -38,7 +38,7 @@ def get_auth_client(): def create_project(args): auth_client = get_auth_client() - userinfo = auth_client.oauth2_userinfo() + userinfo = auth_client.userinfo() print( auth_client.create_project( args.name, diff --git a/docs/examples/auth_manage_projects/manage_projects.py b/docs/examples/auth_manage_projects/manage_projects.py index 535c56383..3c735304c 100644 --- a/docs/examples/auth_manage_projects/manage_projects.py +++ b/docs/examples/auth_manage_projects/manage_projects.py @@ -4,7 +4,7 @@ import os import globus_sdk -from globus_sdk.tokenstorage import SimpleJSONFileAdapter +from globus_sdk.token_storage import SimpleJSONFileAdapter MY_FILE_ADAPTER = SimpleJSONFileAdapter( os.path.expanduser("~/.sdk-manage-projects.json") @@ -59,7 +59,7 @@ def get_auth_client(): def create_project(args): auth_client = get_auth_client() - userinfo = auth_client.oauth2_userinfo() + userinfo = auth_client.userinfo() print( auth_client.create_project( args.name, diff --git a/docs/examples/create_and_run_flow/manage_flow.py b/docs/examples/create_and_run_flow/manage_flow.py index d96a22d9a..8f070c888 100644 --- a/docs/examples/create_and_run_flow/manage_flow.py +++ b/docs/examples/create_and_run_flow/manage_flow.py @@ -4,7 +4,7 @@ import sys import globus_sdk -from globus_sdk.tokenstorage import SimpleJSONFileAdapter +from globus_sdk.token_storage import SimpleJSONFileAdapter MY_FILE_ADAPTER = SimpleJSONFileAdapter(os.path.expanduser("~/.sdk-manage-flow.json")) @@ -94,7 +94,7 @@ def delete_flow(args): def list_flows(): flows_client = get_flows_client() - for flow in flows_client.list_flows(filter_role="flow_owner"): + for flow in flows_client.list_flows(filter_roles="flow_owner"): print(f"title: {flow['title']}") print(f"id: {flow['id']}") print() diff --git a/docs/examples/create_and_run_flow/manage_flow_minimal.py b/docs/examples/create_and_run_flow/manage_flow_minimal.py index 75f97232c..18881ccca 100644 --- a/docs/examples/create_and_run_flow/manage_flow_minimal.py +++ b/docs/examples/create_and_run_flow/manage_flow_minimal.py @@ -5,7 +5,7 @@ import sys import globus_sdk -from globus_sdk.tokenstorage import SimpleJSONFileAdapter +from globus_sdk.token_storage import SimpleJSONFileAdapter MY_FILE_ADAPTER = SimpleJSONFileAdapter(os.path.expanduser("~/.sdk-manage-flow.json")) @@ -86,7 +86,7 @@ def delete_flow(args): def list_flows(): flows_client = get_flows_client() - for flow in flows_client.list_flows(filter_role="flow_owner"): + for flow in flows_client.list_flows(filter_roles="flow_owner"): print(f"title: {flow['title']}") print(f"id: {flow['id']}") print() diff --git a/docs/examples/create_and_run_flow/run_flow_minimal.py b/docs/examples/create_and_run_flow/run_flow_minimal.py index 60e79c598..e6dbbb2a7 100644 --- a/docs/examples/create_and_run_flow/run_flow_minimal.py +++ b/docs/examples/create_and_run_flow/run_flow_minimal.py @@ -5,7 +5,7 @@ import sys import globus_sdk -from globus_sdk.tokenstorage import SimpleJSONFileAdapter +from globus_sdk.token_storage import SimpleJSONFileAdapter MY_FILE_ADAPTER = SimpleJSONFileAdapter(os.path.expanduser("~/.sdk-manage-flow.json")) diff --git a/docs/examples/group_listing.rst b/docs/examples/group_listing.rst index a46f3448b..0e9ca4724 100644 --- a/docs/examples/group_listing.rst +++ b/docs/examples/group_listing.rst @@ -62,8 +62,8 @@ For simplicity, the script will prompt for login on each use. Group Listing With Token Storage -------------------------------- -``globus_sdk.tokenstorage`` provides tools for managing refresh tokens. The -following example script shows how you might use this to provide a complete +``globus_sdk.token_storage.legacy`` provides tools for managing refresh tokens. +The following example script shows how you might use this to provide a complete script which lists the current user's groups using refresh tokens. @@ -72,7 +72,7 @@ script which lists the current user's groups using refresh tokens. import os from globus_sdk import GroupsClient, NativeAppAuthClient, RefreshTokenAuthorizer - from globus_sdk.tokenstorage import SimpleJSONFileAdapter + from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" AUTH_CLIENT = NativeAppAuthClient(CLIENT_ID) diff --git a/docs/examples/guest_collection_creation.rst b/docs/examples/guest_collection_creation.rst index 468f032d6..173441136 100644 --- a/docs/examples/guest_collection_creation.rst +++ b/docs/examples/guest_collection_creation.rst @@ -39,8 +39,10 @@ policy documents passed to create the user credential. # The scope the client will need, note that primary scope is for the endpoint, # but it has a dependency on the mapped collection's data_access scope - scope = scopes.GCSEndpointScopeBuilder(endpoint_id).make_mutable("manage_collections") - scope.add_dependency(scopes.GCSCollectionScopeBuilder(mapped_collection_id).data_access) + scope = scopes.Scope(scopes.GCSEndpointScopeBuilder(endpoint_id).manage_collections) + scope = scope.with_dependency( + scopes.GCSCollectionScopeBuilder(mapped_collection_id).data_access + ) # Build a GCSClient to act as the client by using a ClientCredentialsAuthorizor confidential_client = globus_sdk.ConfidentialAppAuthClient( diff --git a/docs/examples/index.rst b/docs/examples/index.rst index bfea118e2..8b0b07e2d 100644 --- a/docs/examples/index.rst +++ b/docs/examples/index.rst @@ -9,7 +9,7 @@ Each of these pages contains an example of a piece of SDK functionality. minimal_transfer_script/index auth_manage_projects/index create_and_run_flow/index - tokenstorage/index + token_storage/index group_listing authorization native_app diff --git a/docs/examples/tokenstorage/dynamodb_tokenstorage.py b/docs/examples/token_storage/dynamodb_token_storage.py similarity index 99% rename from docs/examples/tokenstorage/dynamodb_tokenstorage.py rename to docs/examples/token_storage/dynamodb_token_storage.py index f37198dad..552b3bc28 100644 --- a/docs/examples/tokenstorage/dynamodb_tokenstorage.py +++ b/docs/examples/token_storage/dynamodb_token_storage.py @@ -7,7 +7,7 @@ import boto3 import globus_sdk -from globus_sdk.tokenstorage import StorageAdapter +from globus_sdk.token_storage import StorageAdapter CLIENT_ID = "61338d24-54d5-408f-a10d-66c06b59f6d2" tablename = "example-globus-tokenstorage" diff --git a/docs/examples/tokenstorage/index.rst b/docs/examples/token_storage/index.rst similarity index 79% rename from docs/examples/tokenstorage/index.rst rename to docs/examples/token_storage/index.rst index dadafaefe..ad23630df 100644 --- a/docs/examples/tokenstorage/index.rst +++ b/docs/examples/token_storage/index.rst @@ -1,4 +1,4 @@ -.. _example_tokenstorage: +.. _example_token_storage: Token Storage Adapters ====================== @@ -16,6 +16,6 @@ sequential scans for enumeration. The example therefore demonstrates that key-value stores with limited or no capabilities for table scans can be used to implement the token storage interface. -.. literalinclude:: dynamodb_tokenstorage.py - :caption: ``dynamodb_tokenstorage.py`` [:download:`download `] +.. literalinclude:: dynamodb_token_storage.py + :caption: ``dynamodb_token_storage.py`` [:download:`download `] :language: python diff --git a/docs/services/compute.rst b/docs/services/compute.rst index 6f022a904..522ed0ffb 100644 --- a/docs/services/compute.rst +++ b/docs/services/compute.rst @@ -37,16 +37,6 @@ supports the latest API features and improvements. .. listknownscopes:: globus_sdk.scopes.ComputeScopes :base_name: ComputeClientV3.scopes - -.. py:class:: ComputeClient - - A deprecated alias for :class:`ComputeClientV2`. - - .. warning:: - - This class will be removed in ``globus-sdk`` version 4. - Users should migrate to one of the explicitly-versioned classes. - Client Errors ------------- diff --git a/docs/services/index.rst b/docs/services/index.rst index 3416a7e79..267c340fb 100644 --- a/docs/services/index.rst +++ b/docs/services/index.rst @@ -45,6 +45,6 @@ very simply: flows groups search - timer + timers transfer gcs diff --git a/docs/services/search.rst b/docs/services/search.rst index 38146ac9e..d5d439773 100644 --- a/docs/services/search.rst +++ b/docs/services/search.rst @@ -12,19 +12,6 @@ Globus Search Helper Objects -------------- -Note that you should not use -:class:`SearchQueryBase ` directly, -and it is not importable from the top level of the SDK. It is included in documentation -only to document the methods it provides to its subclasses. - -.. autoclass:: globus_sdk.services.search.data.SearchQueryBase - :members: - :show-inheritance: - -.. autoclass:: SearchQuery - :members: - :show-inheritance: - .. autoclass:: SearchQueryV1 :members: :show-inheritance: diff --git a/docs/services/timer.rst b/docs/services/timers.rst similarity index 85% rename from docs/services/timer.rst rename to docs/services/timers.rst index f9c778760..4cd8633c4 100644 --- a/docs/services/timer.rst +++ b/docs/services/timers.rst @@ -3,10 +3,6 @@ Globus Timers .. currentmodule:: globus_sdk -.. note:: - - ``TimersClient`` is also available under a legacy alias, ``TimerClient``. - .. autoclass:: TimersClient :members: :member-order: bysource @@ -54,10 +50,6 @@ Client Errors When an error occurs on calls to the Timers service, a :class:`TimersClient` will raise a ``TimersAPIError``. -.. note:: - - ``TimersAPIError`` is also available under a legacy alias, ``TimerAPIError``. - .. autoclass:: TimersAPIError :members: :show-inheritance: diff --git a/docs/services/transfer.rst b/docs/services/transfer.rst index 9bd2c90ce..f79624585 100644 --- a/docs/services/transfer.rst +++ b/docs/services/transfer.rst @@ -42,10 +42,6 @@ error, rather than a generic :class:`GlobusAPIError`. Transfer Responses ------------------ -.. autoclass:: ActivationRequirementsResponse - :members: - :show-inheritance: - .. autoclass:: IterableTransferResponse :members: :show-inheritance: diff --git a/docs/testing/getting_started.rst b/docs/testing/getting_started.rst index 8ace2283f..d274f4d5c 100644 --- a/docs/testing/getting_started.rst +++ b/docs/testing/getting_started.rst @@ -3,15 +3,15 @@ This component is an *alpha*. Interfaces may change outside of the normal semver policy. -Getting Started with _testing -============================= +Getting Started with testing +============================ Dependencies ------------ This toolchain requires the ``responses`` library. -``globus_sdk._testing`` is tested to operate with the latest version of +``globus_sdk.testing`` is tested to operate with the latest version of ``responses``. Recommended Fixtures @@ -41,7 +41,7 @@ activated by name: .. code-block:: python - from globus_sdk._testing import load_response + from globus_sdk.testing import load_response # load_response will add the response to `responses` and return it load_response("auth.get_identities") @@ -54,7 +54,7 @@ unbound, as in: .. code-block:: python import globus_sdk - from globus_sdk._testing import load_response + from globus_sdk.testing import load_response load_response(globus_sdk.AuthClient.get_identities) load_response(globus_sdk.AuthClient.get_identities, case="unauthorized") @@ -71,7 +71,7 @@ load all of them at once: .. code-block:: python - from globus_sdk._testing import load_response_set + from globus_sdk.testing import load_response_set fixtures = load_response_set("scenario.foo") @@ -86,7 +86,7 @@ response is ``"default"``. .. code-block:: python from globus_sdk import AuthClient - from globus_sdk._testing import get_response_set + from globus_sdk.testing import get_response_set # rset will not be activated rset = get_response_set(AuthClient.get_identities) @@ -110,7 +110,7 @@ override the builtin response sets, if names match. .. code-block:: python - from globus_sdk._testing import load_response_set, register_response_set + from globus_sdk.testing import load_response_set, register_response_set import uuid # register a scenario under which Globus Auth get_identities and Globus @@ -152,7 +152,7 @@ Loading Responses without Registering Because ``RegisteredResponse`` takes care of resolving ``"auth"`` to the Auth URL, ``"transfer"`` to the Transfer URL, and so forth, you might want to use -``globus_sdk._testing`` in lieu of ``responses`` even when registering single +``globus_sdk.testing`` in lieu of ``responses`` even when registering single responses for individual tests. To support this mode of usage, ``load_response`` can take a @@ -165,7 +165,7 @@ Consider the following example of a parametrized test which uses .. code-block:: python - from globus_sdk._testing import load_response, RegisteredResponse + from globus_sdk.testing import load_response, RegisteredResponse import pytest @@ -191,7 +191,7 @@ outside of the specific test. Using non-default responses.RequestsMock objects ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -By default, all methods in ``globus_sdk._testing`` which converse with +By default, all methods in ``globus_sdk.testing`` which converse with ``responses`` use the default mock. This is the behavior offered by ``responses.add(...)`` and similar methods. @@ -208,7 +208,7 @@ e.g. .. code-block:: python - from globus_sdk._testing import get_last_request + from globus_sdk.testing import get_last_request import responses custom_mock = responses.RequestsMock(...) diff --git a/docs/testing/index.rst b/docs/testing/index.rst index 9de0abb21..47d97fb40 100644 --- a/docs/testing/index.rst +++ b/docs/testing/index.rst @@ -3,14 +3,14 @@ This component is an *alpha*. Interfaces may change outside of the normal semver policy. -.. _testing_root: +.. testing_root: -Globus SDK _testing -=================== +Globus SDK testing +================== .. warning:: - The exact data and payloads provided via ``_testing`` are a best + The exact data and payloads provided via ``testing`` are a best approximation of API responses. They may change in any SDK release to be more accurate. diff --git a/docs/testing/methods/auth.rst b/docs/testing/methods/auth.rst index 538af2410..0c43fb93b 100644 --- a/docs/testing/methods/auth.rst +++ b/docs/testing/methods/auth.rst @@ -1,4 +1,4 @@ -Globus Auth _testing Method List -================================ +Globus Auth testing Method List +=============================== .. enumeratetestingfixtures:: globus_sdk.AuthClient diff --git a/docs/testing/methods/flows.rst b/docs/testing/methods/flows.rst index 65d6587b9..38401ba9e 100644 --- a/docs/testing/methods/flows.rst +++ b/docs/testing/methods/flows.rst @@ -1,5 +1,5 @@ -Globus Flows _testing Method List -================================= +Globus Flows testing Method List +================================ .. enumeratetestingfixtures:: globus_sdk.FlowsClient diff --git a/docs/testing/methods/gcs.rst b/docs/testing/methods/gcs.rst index f9f32bc92..dbdb157ed 100644 --- a/docs/testing/methods/gcs.rst +++ b/docs/testing/methods/gcs.rst @@ -1,4 +1,4 @@ -Globus Connect Server _testing Method List -========================================== +Globus Connect Server testing Method List +========================================= .. enumeratetestingfixtures:: globus_sdk.GCSClient diff --git a/docs/testing/methods/groups.rst b/docs/testing/methods/groups.rst index cf0885ef8..f60978d55 100644 --- a/docs/testing/methods/groups.rst +++ b/docs/testing/methods/groups.rst @@ -1,4 +1,4 @@ -Globus Groups _testing Method List -================================== +Globus Groups testing Method List +================================= .. enumeratetestingfixtures:: globus_sdk.GroupsClient diff --git a/docs/testing/methods/index.rst b/docs/testing/methods/index.rst index a47b86348..33dff9633 100644 --- a/docs/testing/methods/index.rst +++ b/docs/testing/methods/index.rst @@ -3,10 +3,10 @@ This component is an *alpha*. Interfaces may change outside of the normal semver policy. -_testing Method List -==================== +testing Method List +=================== -These pages list all methods which have ``globus_sdk._testing`` response data, +These pages list all methods which have ``globus_sdk.testing`` response data, and the casenames for those data. .. toctree:: diff --git a/docs/testing/methods/search.rst b/docs/testing/methods/search.rst index effa27c3a..be09ad003 100644 --- a/docs/testing/methods/search.rst +++ b/docs/testing/methods/search.rst @@ -1,4 +1,4 @@ -Globus Search _testing Method List -================================== +Globus Search testing Method List +================================= .. enumeratetestingfixtures:: globus_sdk.SearchClient diff --git a/docs/testing/methods/timers.rst b/docs/testing/methods/timers.rst index 638982725..301376fed 100644 --- a/docs/testing/methods/timers.rst +++ b/docs/testing/methods/timers.rst @@ -1,4 +1,4 @@ -Globus Timers _testing Method List -================================== +Globus Timers testing Method List +================================= .. enumeratetestingfixtures:: globus_sdk.TimersClient diff --git a/docs/testing/methods/transfer.rst b/docs/testing/methods/transfer.rst index d6b71a2e5..8b52f6976 100644 --- a/docs/testing/methods/transfer.rst +++ b/docs/testing/methods/transfer.rst @@ -1,4 +1,4 @@ -Globus Transfer _testing Method List -==================================== +Globus Transfer testing Method List +=================================== .. enumeratetestingfixtures:: globus_sdk.TransferClient diff --git a/docs/testing/reference.rst b/docs/testing/reference.rst index 51a5f9d1a..490935add 100644 --- a/docs/testing/reference.rst +++ b/docs/testing/reference.rst @@ -3,10 +3,10 @@ This component is an *alpha*. Interfaces may change outside of the normal semver policy. -_testing Reference -================== +testing Reference +================= -.. module:: globus_sdk._testing +.. module:: globus_sdk.testing Functions --------- diff --git a/docs/upgrading.rst b/docs/upgrading.rst index dd0a541ee..532cfe347 100644 --- a/docs/upgrading.rst +++ b/docs/upgrading.rst @@ -25,10 +25,10 @@ the globus-sdk at the same time, consider adding this snippet: .. code-block:: python - import globus_sdk + import importlib.metadata - GLOBUS_SDK_VERSION = tuple(globus_sdk.__version__.split(".")) - GLOBUS_SDK_MAJOR_VERSION = int(GLOBUS_SDK_VERSION[0]) + GLOBUS_SDK_VERSION = importlib.metadata.distribution("globus_sdk").version + GLOBUS_SDK_MAJOR_VERSION = int(GLOBUS_SDK_VERSION.split(".")[0]) This will parse the Globus SDK version information into a tuple and grab the first element (the major version number) as an integer. @@ -42,6 +42,615 @@ Then, code can dispatch with else: pass # do another +From 3.x to 4.0 +--------------- + +``TransferData`` and ``DeleteData`` Do Not Take a ``TransferClient`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The signatures for these two data constructors have changed to remove support +for ``transfer_client`` as their first parameter. + +Generally, update usage which passed a client to omit it: + +.. code-block:: python + + from globus_sdk import TransferClient, TransferData, DeleteData + + # globus-sdk v3 + + tc = TransferClient(...) + tdata = TransferData(tc, SRC_COLLECTION, DST_COLLECTION) + tc.submit_transfer(tdata) + + tc = TransferClient(...) + ddata = DeleteData(tc, COLLECTION) + tc.submit_delete(tdata) + + # globus-sdk v4 + + tdata = TransferData(SRC_COLLECTION, DST_COLLECTION) + tc = TransferClient(...) + tc.submit_transfer(tdata) + + ddata = DeleteData(COLLECTION) + tc = TransferClient(...) + tc.submit_delete(tdata) + +Users who are using keyword arguments to pass collection IDs without a +``transfer_client`` do not need to make any change. For example: + +.. code-block:: python + + from globus_sdk import TransferData, DeleteData + + # globus-sdk v3 or v4 + + tdata = TransferData( + source_endpoint=SRC_COLLECTION, destination_endpoint=DST_COLLECTION + ) + ddata = DeleteData(endpoint=COLLECTION) + +The client object was used to fetch a ``submission_id`` on initialization. +Users typically will rely on ``TransferClient.submit_transfer()`` and +``TransferClient.submit_delete()`` filling in this value. +To control when a submission ID is fetched, use +``TransferClient.get_submission_id()``, as in: + +.. code-block:: python + + from globus_sdk import TransferClient, TransferData + + # globus-sdk v3 or v4 + + tc = TransferClient(...) + submission_id = tc.get_submission_id()["value"] + + tdata = TransferData( + source_endpoint=SRC_COLLECTION, + destination_endpoint=DST_COLLECTION, + submission_id=submission_id, + ) + + +``ConfidentialAppAuthClient`` Cannot Directly Call ``get_identities`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Users of client identities are now required to get tokens in order to use the +Get Identities API, and will need to use the ``AuthClient`` class for this +purpose. +This can most simply be managed by use of a ``ClientApp`` to automatically +fetch the appropriate tokens. + +Update usage like so: + +.. code-block:: python + + # globus-sdk v3 + from globus_sdk import ConfidentialAppAuthClient + + client = ConfidentialAppAuthClient(CLIENT_ID, CLIENT_SECRET) + + identities = client.get_identities(usernames="globus@globus.org") + + # globus-sdk v4 + from globus_sdk import ClientApp, AuthClient + + app = ClientApp(client_id=CLIENT_ID, client_secret=CLIENT_SECRET) + client = AuthClient(app=app) + + identities = client.get_identities(usernames="globus@globus.org") + + +Scope Constants Are Now Objects +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Under version 3, many scopes were provided as string constants. +For example, ``globus_sdk.TransferClient.scopes.all`` was a string. + +In version 4, these constants are now :class:`Scope ` +objects. They can be rendered to strings using ``str()`` and no longer need to +be converted to :class:`Scope `\s in order to use +methods. + +Convert usage which stringifies scopes like so: + +.. code-block:: python + + # globus-sdk v3 + from globus_sdk.scopes import AuthScopes + + my_scope_str: str = AuthScopes.openid + + # globus-sdk v4 + from globus_sdk.scopes import AuthScopes + + my_scope_str: str = str(AuthScopes.openid) + +And convert usage which builds scope objects like so: + +.. code-block:: python + + # globus-sdk v3 + from globus_sdk.scopes import AuthScopes, Scope + + my_scope: Scope = Scope(AuthScopes.openid) + + # globus-sdk v4 + from globus_sdk.scopes import AuthScopes, Scope + + my_scope: Scope = AuthScopes.openid + +``ScopeBuilder``\s are now ``ScopeCollection``\s +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +As part of the refactor of scope constants, the objects which were previously +called "scope builders" are now "scope collections". +Scope collections may be static or dynamic, depending on whether or not they +statically provide their scopes at the class level or dynamically compute scopes +as instance attributes. + +The following entities are therefore renamed in addition to having changes to +their implementations: + +.. csv-table:: + :header: "Old name", "New name" + + "``GCSEndpointScopeBuilder``", "``GCSEndpointScopes``" + "``GCSCollectionScopeBuilder``", "``GCSCollectionScopes``" + "``SpecificFlowScopeBuilder``", "``SpecificFlowScopes``" + +Scope collections provide ``Scope`` objects, not strings. +Therefore, update code like so: + +.. code-block:: python + + # globus-sdk v3 + from globus_sdk.scopes import Scope, SpecificFlowScopeBuilder + + my_flow_scope = Scope(SpecificFlowScopeBuilder(FLOW_ID).user) + + # globus-sdk v4 + from globus_sdk.scopes import SpecificFlowScopes + + my_flow_scope = SpecificFlowScopes(FLOW_ID).user + +Scopes Are Immutable and Have New Methods +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:class:`Scope ` object in v3 of the SDK could be +updated with in-place modifications. +In v4, these objects are now frozen, and their methods have been altered to +suit their immutability. + +In particular, ``add_dependency`` has been replaced with ``with_dependency``, +which builds and returns a new scope rather than making changes to an existing +value. + +Update ``add_dependency`` usage like so: + +.. code-block:: python + + # globus-sdk v3 + from globus_sdk.scopes import Scope + + my_scope = Scope(ROOT_SCOPE_STRING) + my_scope.add_dependency(DEPENCENCY_STRING) + + # globus-sdk v4 + from globus_sdk.scopes import Scope + + my_scope = Scope(ROOT_SCOPE_STRING) + my_scope = my_scope.with_dependency(DEPENCENCY_STRING) + +For optional dependencies, the ``optional`` parameter must now be specified when +creating the dependency scope, not when adding it: + +.. code-block:: python + + # globus-sdk v3 + from globus_sdk.scopes import Scope + + my_scope = Scope(ROOT_SCOPE_STRING) + my_scope.add_dependency(DEPENDENCY_STRING, optional=True) + + # globus-sdk v4 + from globus_sdk.scopes import Scope + + my_scope = Scope(ROOT_SCOPE_STRING) + dependency = Scope(DEPENDENCY_STRING, optional=True) + my_scope = my_scope.with_dependency(dependency) + +ScopeParser Is Now Separate from Scope +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Scope parsing has been split from ``Scope`` to a new class, ``ScopeParser``. +Additionally, ``Scope.serialize`` and ``Scope.deserialize`` have been removed, +and ``Scope.parse`` is now a wrapper over ``ScopeParser.parse`` which always +builds and returns one scope. + +Users who need to parse multiple scopes should rely on ``ScopeParser.parse``. +For example, update like so: + +.. code-block:: python + + # globus-sdk v3 + from globus_sdk.scopes import Scope + + my_scopes: list[Scope] = Scope.parse(scope_string) + + # globus-sdk v4 + from globus_sdk.scopes import Scope, ScopeParser + + my_scopes: list[Scope] = ScopeParser.parse(scope_string) + +Scope Collections Provide ``__iter__``, not ``__str__`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In version 3, the SDK scope collection objects provided a pretty printer in the +form of ``str()``. Users could call ``str(TransferClient.scopes)`` to see the +available scopes. + +In version 4, this has been removed, but the collection types provide +``__iter__`` over their member scopes instead. Therefore, you can fetch all +scopes for the Globus Transfer service via ``list(TransferClient.scopes)`` or +similar usage. + +Token Storage Subpackage Renamed +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The subpackage providing token storage components has been renamed and slightly +restructured. + +The package name is changed from +``globus_sdk.tokenstorage`` to ``globus_sdk.token_storage``. + +Furthermore, the legacy :ref:`storage adapters ` are now only +available from ``globus_sdk.token_storage.legacy``. + +Therefore, usages of the modern :ref:`token storage interface ` +should update like so: + +.. code-block:: python + + # globus-sdk v3 + from globus_sdk.tokenstorage import JSONTokenStorage + + # globus-sdk v4 + from globus_sdk.token_storage import JSONTokenStorage + +For legacy adapter usage, update like so: + +.. code-block:: python + + # globus-sdk v3 + from globus_sdk.tokenstorage import SimpleJSONFileAdapter + + # globus-sdk v4 + from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter + +.. note:: + + The ``legacy`` interface is soft-deprecated. + In version 4.0.0 it will not emit deprecation warnings. + Future SDK versions will eventually deprecate and remove these interfaces. + +Deprecated Timers Aliases Removed +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +During the version 3 lifecycle, the ``TimersClient`` and ``TimersAPIError`` +classes were renamed. Their original names, ``TimerClient`` and +``TimerAPIError`` were retained as compatibility aliases. + +These have been removed. Use ``TimersClient`` and ``TimersAPIError``. + +Deprecated Experimental Aliases Removed +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +During the version 3 lifecycle, several modules were added under +``globus_sdk.experimental`` and later promoted to new names in the main +``globus_sdk`` namespace. +Compatibility aliases were left in place. + +Under version 4, the compatibility aliases have been removed. +The removed alias and new module names are shown in the table below. + +.. csv-table:: + :header: "Removed alias", "New name" + + "``globus_sdk.experimental.auth_requirements_error``", "``globus_sdk.gare``" + "``globus_sdk.experimental.globus_app``", "``globus_sdk.globus_app``" + "``globus_sdk.experimental.scope_parser``", "``globus_sdk.scopes``" + "``globus_sdk.experimental.consents``", "``globus_sdk.scopes.consents``" + "``globus_sdk.experimental.tokenstorage``", "``globus_sdk.token_storage``" + "``globus_sdk.experimental.login_flow_manager``", "``globus_sdk.login_flows``" + +``SearchQuery`` is Removed, use ``SearchQueryV1`` Instead +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``SearchQuery`` helper was removed in version 4 in favor of the +:class:`SearchQueryV1 ` type. + +Simply replace one type with the other for most simple usages: + +.. code-block:: python + + # globus-sdk v3 + from globus_sdk import SearchQuery + + query = SearchQuery(q="foo") + + # globus-sdk v4 + from globus_sdk import SearchQuery + + query = SearchQueryV1(q="foo") + +Note that ``SearchQuery`` supported the query string, ``q``, as a positional +argument, but ``SearchQueryV1`` requires that it is passed as a named +parameter. + +``SearchQuery`` also supported helper methods which are not provided by +``SearchQueryV1``. +These must be replaced by setting the relevant parameters directly or on +initialization. +For example: + +.. code-block:: python + + # globus-sdk v3 + from globus_sdk import SearchQuery + + query = SearchQuery(q="foo") + query.set_offset(100) # removed in v4 + + # globus-sdk v4 + from globus_sdk import SearchQuery + + query = SearchQueryV1(q="foo", offset=100) # on init + # or + query = SearchQueryV1(q="foo") + query["offset"] = 100 # by setting a field + +.. note:: + + :class:`SearchQueryV1 ` was added in + ``globus-sdk`` version 3, so this transition can be made prior to upgrading + to version 4. + +``SearchClient.create_entry`` and ``SearchClient.update_entry`` Removed +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +These methods were deprecated in version 3 in favor of ``SearchClient.ingest``, +which provides greater functionality and a more uniform interface. + +For any document being passed by these methods, upgrade to using an ingest +document with ``"ingest_type": "GMetaEntry"``. +Consult the :extdoclink:`Search Ingest Guide ` +for details on the document formats. + +``MutableScope`` is Removed, use ``Scope`` Instead +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``MutableScope`` type was removed in version 4 in favor of the +:class:`Scope ` type. +When manipulating scopes as objects, use +:class:`Scope ` anywhere that +``MutableScope`` was used, for example: + +.. code-block:: python + + # globus-sdk v3 + from globus_sdk.scopes import MutableScope + + my_scope = MutableScope("urn:globus:auth:scopes:transfer.api.globus.org:all") + + # globus-sdk v4 + from globus_sdk.scopes import Scope + + my_scope = Scope("urn:globus:auth:scopes:transfer.api.globus.org:all") + +.. note:: + + The :class:`Scope ` type was added in Globus SDK + v3, so this transition can be made prior to upgrading to version 4. + +``requested_scopes`` is Required +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Several methods have historically taken an optional parameter, +``requested_scopes``. + +- ``ConfidentialAppAuthClient.oauth2_client_credentials_tokens`` +- ``ConfidentialAppAuthClient.oauth2_start_flow`` +- ``NativeAppAuthClient.oauth2_start_flow`` + +In previous versions of the SDK, these methods provided a default value for +``requested_scopes`` of +``"openid profile email urn:globus:auth:scopes:transfer.api.globus.org:all"``. +This default has now been removed and users should always specify the scopes +they need when using these methods. + +Users of ``GlobusApp`` constructs (``UserApp`` and ``ClientApp``) do not need +to update their usage. + +The default could only be used by applications which only use Globus Transfer +and Globus Auth. +Change: + +.. code-block:: python + + # globus-sdk v3 + auth_client.oauth2_start_flow() + authorize_url = auth_client.oauth2_get_authorize_url() + + # globus-sdk v4 + auth_client.oauth2_start_flow(requested_scopes=globus_sdk.TransferClient.scopes.all) + authorize_url = auth_client.oauth2_get_authorize_url() + +Customizing the Transport Has Changed +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In version 3, SDK users could customize the ``RequestsTransport`` object +contained within a client in two ways. +One was to customize a client class by setting the ``transport_class`` class +attribute, and the other was to pass ``transport_params`` to the client +initializer. + +In version 4, these mechanisms have been replaced with support for passing a +``RequestsTransport`` object directly to the initializer. + +For users who are customizing the parameters to the transport class, they +should now explicitly instantiate the transport object: + +.. code-block:: python + + # globus-sdk v3 + import globus_sdk + + client = globus_sdk.GroupsClient(transport_params={"http_timeout": 120.0}) + + # globus-sdk v4 + import globus_sdk + from globus_sdk.transport import RequestsTransport + + client = globus_sdk.GroupsClient(transport=RequestsTransport(http_timeout=120.0)) + +or use the ``tune()`` context manager: + +.. code-block:: python + + # globus-sdk v4 + import globus_sdk + + client = globus_sdk.GroupsClient() + with client.transport.tune(http_timeout=120.0): + my_groups = client.get_my_groups() + +Retry Check Configuration Moved to ``retry_config`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In Globus SDK v3, a client's ``transport`` contained all of its retry +behaviors, including the checks which are run on each request, the +configuration of those checks, and the sleep and backoff behaviors. + +Under v4, the configuration of checks has been split off into a separate +attribute of the client, ``retry_config``. + +These changes primarily impact users who were using a custom +``RequestsTransport`` class, and should simplify their usage. + +For example, in order to treat only 502s as retriable transient errors, users +previously had a custom transport type. +This could then be configured on a custom client class: + +.. code-block:: python + + # globus-sdk v3 + import globus_sdk + from globus_sdk.transport import RequestsTransport + + + class MyTransport(RequestsTransport): + TRANSIENT_ERROR_STATUS_CODES = (502,) + + + class MyClientClass(globus_sdk.GroupsClient): + transport_class = MyTransport + + + client = MyClientClass() + +Under SDK v4, in order to customize the same information, users can simply +create a client and then modify the attributes of the ``retry_config`` object: + +.. code-block:: python + + # globus-sdk v4 + import globus_sdk + + client = globus_sdk.GroupsClient() + client.retry_config.transient_error_status_codes = (502,) + +Similar to the ``tune()`` context manager of ``RequestsTransport``, there is +also a ``tune()`` context manager for the retry configuration. ``tune()`` +supports the ``max_sleep``, ``max_retries``, and ``backoff`` configurations, +which users of ``RequestsTransport.tune()`` may already recognize. +For example, users can suppress retries: + +.. code-block:: python + + # globus-sdk v4 + import globus_sdk + + client = globus_sdk.GroupsClient() + with client.retry_config.tune(max_retries=1): + my_groups = client.get_my_groups() + +A ``retry_config`` can also be passed to clients on initialization: + +.. code-block:: python + + # globus-sdk v4 + import globus_sdk + from globus_sdk.transport import RetryConfig + + client = globus_sdk.GroupsClient(retry_config=RetryConfig(max_retries=2)) + my_groups = client.get_my_groups() + +Clients No Longer Define ``base_path`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In version 3 and earlier, client classes defined an attribute ``base_path`` +which was joined as a prefix to request paths for the HTTP methods: ``get()``, +``put()``, ``post()``, ``patch()``, ``delete()``, ``head()``, and ``request()``. +The ``base_path`` attribute has been removed and direct use of HTTP APIs now +requires the full path when bare HTTP methods are used. + +``base_path`` values were also used in the testing tools defined by +``globus_sdk.testing`` and have similarly been removed. + +The ``base_path`` was automatically deduplicated when provided to SDK version 3, +meaning that code which includes this prefix will work on both SDK version 3 and +version 4. + +For example, ``TransferClient`` defined a ``base_path`` of ``"v0.10"``. +As a result, the request URI for a ``get()`` HTTP call would be mapped as follows: + +.. code-block:: python + + import globus_sdk + + tc = globus_sdk.TransferClient() + + # GET https://transfer.api.globus.org/v0.10/foo/bar + tc.get("/foo/bar") + +In version 4, without the ``base_path``, the mapping is as follows: + +.. code-block:: python + + # GET https://transfer.api.globus.org/foo/bar + tc.get("/foo/bar") + +Due to the deduplication of a leading ``base_path`` in version 3, the following +snippet has the same effect in both versions: + +.. code-block:: python + + # GET https://transfer.api.globus.org/v0.10/foo/bar + tc.get("/v0.10/foo/bar") + +Clients with a ``base_path`` and the values they defined in version 3 are listed +below. + +================== =========== +Client Class base_path +================== =========== +``TransferClient`` ``"v0.10"`` +``GroupsClient`` ``"v2"`` +================== =========== + + From 1.x or 2.x to 3.0 ----------------------- diff --git a/docs/user_guide/getting_started/list_groups_noapp_with_storage.py b/docs/user_guide/getting_started/list_groups_noapp_with_storage.py index 4425fe96e..8156e4d8a 100644 --- a/docs/user_guide/getting_started/list_groups_noapp_with_storage.py +++ b/docs/user_guide/getting_started/list_groups_noapp_with_storage.py @@ -1,7 +1,7 @@ import os import globus_sdk -from globus_sdk.tokenstorage import JSONTokenStorage +from globus_sdk.token_storage import JSONTokenStorage # this is the tutorial client ID # replace this string with your ID for production use diff --git a/docs/user_guide/getting_started/minimal_script_noapp.rst b/docs/user_guide/getting_started/minimal_script_noapp.rst index ef8d9758c..088d08373 100644 --- a/docs/user_guide/getting_started/minimal_script_noapp.rst +++ b/docs/user_guide/getting_started/minimal_script_noapp.rst @@ -182,7 +182,7 @@ Defining a token storage object is simple: .. code-block:: python import os - from globus_sdk.tokenstorage import JSONTokenStorage + from globus_sdk.token_storage import JSONTokenStorage token_storage = JSONTokenStorage( os.path.expanduser("~/.list-my-globus-groups-tokens.json") diff --git a/docs/user_guide/usage_patterns/data_transfer/create_guest_collection/create_guest_collection_client_owned.py b/docs/user_guide/usage_patterns/data_transfer/create_guest_collection/create_guest_collection_client_owned.py index 2b0577569..394f7a213 100644 --- a/docs/user_guide/usage_patterns/data_transfer/create_guest_collection/create_guest_collection_client_owned.py +++ b/docs/user_guide/usage_patterns/data_transfer/create_guest_collection/create_guest_collection_client_owned.py @@ -42,10 +42,10 @@ def attach_data_access_scope(gcs_client, collection_id): endpoint_scopes = gcs_client.get_gcs_endpoint_scopes(gcs_client.endpoint_client_id) collection_scopes = gcs_client.get_gcs_collection_scopes(collection_id) - manage_collections = globus_sdk.Scope(endpoint_scopes.manage_collections) data_access = globus_sdk.Scope(collection_scopes.data_access, optional=True) - - manage_collections.add_dependency(data_access) + manage_collections = globus_sdk.Scope( + endpoint_scopes.manage_collections, dependencies=(data_access,) + ) gcs_client.add_app_scope(manage_collections) diff --git a/docs/user_guide/usage_patterns/data_transfer/create_guest_collection/create_guest_collection_user_owned.py b/docs/user_guide/usage_patterns/data_transfer/create_guest_collection/create_guest_collection_user_owned.py index 2401d4fba..784c612b0 100644 --- a/docs/user_guide/usage_patterns/data_transfer/create_guest_collection/create_guest_collection_user_owned.py +++ b/docs/user_guide/usage_patterns/data_transfer/create_guest_collection/create_guest_collection_user_owned.py @@ -36,10 +36,10 @@ def attach_data_access_scope(gcs_client, collection_id): endpoint_scopes = gcs_client.get_gcs_endpoint_scopes(gcs_client.endpoint_client_id) collection_scopes = gcs_client.get_gcs_collection_scopes(collection_id) - manage_collections = globus_sdk.Scope(endpoint_scopes.manage_collections) data_access = globus_sdk.Scope(collection_scopes.data_access, optional=True) - - manage_collections.add_dependency(data_access) + manage_collections = globus_sdk.Scope( + endpoint_scopes.manage_collections, dependencies=(data_access,) + ) gcs_client.add_app_scope(manage_collections) diff --git a/docs/user_guide/usage_patterns/data_transfer/detecting_data_access/submit_transfer_detect_data_access.py b/docs/user_guide/usage_patterns/data_transfer/detecting_data_access/submit_transfer_detect_data_access.py index 6a1a5eb7a..4347f52c0 100644 --- a/docs/user_guide/usage_patterns/data_transfer/detecting_data_access/submit_transfer_detect_data_access.py +++ b/docs/user_guide/usage_patterns/data_transfer/detecting_data_access/submit_transfer_detect_data_access.py @@ -42,10 +42,7 @@ def uses_data_access( if uses_data_access(transfer_client, DST_COLLECTION): transfer_client.add_app_data_access_scope(DST_COLLECTION) -transfer_request = globus_sdk.TransferData( - source_endpoint=SRC_COLLECTION, - destination_endpoint=DST_COLLECTION, -) +transfer_request = globus_sdk.TransferData(SRC_COLLECTION, DST_COLLECTION) transfer_request.add_item(SRC_PATH, DST_PATH) task = transfer_client.submit_transfer(transfer_request) diff --git a/docs/user_guide/usage_patterns/data_transfer/scheduled_transfers/create_timer.py b/docs/user_guide/usage_patterns/data_transfer/scheduled_transfers/create_timer.py index a101953f3..5191c3111 100644 --- a/docs/user_guide/usage_patterns/data_transfer/scheduled_transfers/create_timer.py +++ b/docs/user_guide/usage_patterns/data_transfer/scheduled_transfers/create_timer.py @@ -19,10 +19,7 @@ # as with an immediate data transfer, we take our input data and wrap them in # a TransferData object, representing the transfer task -transfer_request = globus_sdk.TransferData( - source_endpoint=SRC_COLLECTION, - destination_endpoint=DST_COLLECTION, -) +transfer_request = globus_sdk.TransferData(SRC_COLLECTION, DST_COLLECTION) transfer_request.add_item(SRC_PATH, DST_PATH) # we'll define the timer as one which runs every hour for 3 days diff --git a/docs/user_guide/usage_patterns/data_transfer/scheduled_transfers/create_timer_detect_data_access.py b/docs/user_guide/usage_patterns/data_transfer/scheduled_transfers/create_timer_detect_data_access.py index 5eeba25c2..fe076a9cd 100644 --- a/docs/user_guide/usage_patterns/data_transfer/scheduled_transfers/create_timer_detect_data_access.py +++ b/docs/user_guide/usage_patterns/data_transfer/scheduled_transfers/create_timer_detect_data_access.py @@ -41,10 +41,7 @@ def uses_data_access(collection_id: str) -> bool: # as with an immediate data transfer, we take our input data and wrap them in # a TransferData object, representing the transfer task -transfer_request = globus_sdk.TransferData( - source_endpoint=SRC_COLLECTION, - destination_endpoint=DST_COLLECTION, -) +transfer_request = globus_sdk.TransferData(SRC_COLLECTION, DST_COLLECTION) transfer_request.add_item(SRC_PATH, DST_PATH) # we'll define the timer as one which runs every hour for 3 days diff --git a/docs/user_guide/usage_patterns/data_transfer/submit_transfer/submit_transfer_collections_known.py b/docs/user_guide/usage_patterns/data_transfer/submit_transfer/submit_transfer_collections_known.py index 4cddca9b5..229da759f 100644 --- a/docs/user_guide/usage_patterns/data_transfer/submit_transfer/submit_transfer_collections_known.py +++ b/docs/user_guide/usage_patterns/data_transfer/submit_transfer/submit_transfer_collections_known.py @@ -24,10 +24,7 @@ def main(): transfer_client.add_app_data_access_scope(SRC_COLLECTION) transfer_client.add_app_data_access_scope(DST_COLLECTION) - transfer_request = globus_sdk.TransferData( - source_endpoint=SRC_COLLECTION, - destination_endpoint=DST_COLLECTION, - ) + transfer_request = globus_sdk.TransferData(SRC_COLLECTION, DST_COLLECTION) transfer_request.add_item(SRC_PATH, DST_PATH) task = transfer_client.submit_transfer(transfer_request) diff --git a/docs/user_guide/usage_patterns/data_transfer/submit_transfer/submit_transfer_collections_unknown.py b/docs/user_guide/usage_patterns/data_transfer/submit_transfer/submit_transfer_collections_unknown.py index 4c7bf1cba..3971736e9 100644 --- a/docs/user_guide/usage_patterns/data_transfer/submit_transfer/submit_transfer_collections_unknown.py +++ b/docs/user_guide/usage_patterns/data_transfer/submit_transfer/submit_transfer_collections_unknown.py @@ -19,10 +19,7 @@ def main(): transfer_client = globus_sdk.TransferClient(app=USER_APP) - transfer_request = globus_sdk.TransferData( - source_endpoint=SRC_COLLECTION, - destination_endpoint=DST_COLLECTION, - ) + transfer_request = globus_sdk.TransferData(SRC_COLLECTION, DST_COLLECTION) transfer_request.add_item(SRC_PATH, DST_PATH) try: diff --git a/docs/user_guide/usage_patterns/data_transfer/transfer_relative_deadline/index.rst b/docs/user_guide/usage_patterns/data_transfer/transfer_relative_deadline/index.rst index e6f99d853..31f2f63b6 100644 --- a/docs/user_guide/usage_patterns/data_transfer/transfer_relative_deadline/index.rst +++ b/docs/user_guide/usage_patterns/data_transfer/transfer_relative_deadline/index.rst @@ -79,8 +79,8 @@ a sample task document with a deadline set for "an hour from now": # create a Transfer Task request document, including a relative deadline transfer_request = globus_sdk.TransferData( - source_endpoint=SRC_COLLECTION, - destination_endpoint=DST_COLLECTION, + SRC_COLLECTION, + DST_COLLECTION, deadline=make_relative_deadline(datetime.timedelta(hours=1)), ) transfer_request.add_item(SRC_PATH, DST_PATH) diff --git a/docs/user_guide/usage_patterns/data_transfer/transfer_relative_deadline/submit_transfer_relative_deadline.py b/docs/user_guide/usage_patterns/data_transfer/transfer_relative_deadline/submit_transfer_relative_deadline.py index 96a5fbd15..22c4c7f68 100644 --- a/docs/user_guide/usage_patterns/data_transfer/transfer_relative_deadline/submit_transfer_relative_deadline.py +++ b/docs/user_guide/usage_patterns/data_transfer/transfer_relative_deadline/submit_transfer_relative_deadline.py @@ -32,8 +32,8 @@ def make_relative_deadline(offset: datetime.timedelta) -> str: transfer_client.add_app_data_access_scope(DST_COLLECTION) transfer_request = globus_sdk.TransferData( - source_endpoint=SRC_COLLECTION, - destination_endpoint=DST_COLLECTION, + SRC_COLLECTION, + DST_COLLECTION, deadline=make_relative_deadline(datetime.timedelta(hours=1)), ) transfer_request.add_item(SRC_PATH, DST_PATH) diff --git a/pyproject.toml b/pyproject.toml index fec83d7ce..7cb673404 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "globus-sdk" +version = "4.0.0" authors = [ { name = "Globus Team", email = "support@globus.org" }, ] @@ -36,7 +37,6 @@ dependencies = [ # python versions older than 3.9 don't have importlib.resources 'importlib_resources>=5.12.0; python_version<"3.9"', ] -dynamic = ["version"] [project.readme] file = "README.rst" @@ -79,7 +79,7 @@ typing = [ "types-requests", "typing-extensions>=4.0", # although 'responses' is provided by the test requirements, it also - # must be installed for proper type-checking on globus_sdk._testing + # must be installed for proper type-checking on globus_sdk.testing "responses", # similarly, sphinx is needed to type-check our sphinx extension "sphinx", @@ -104,15 +104,12 @@ globus_sdk = [ ] "globus_sdk.login_flows.local_server_login_flow_manager.html_files" = ["*.html"] -[tool.setuptools.dynamic.version] -attr = "globus_sdk.__version__" - # non-packaging tool configs follow [tool.pytest.ini_options] addopts = "--no-success-flaky-report --color=yes" testpaths = ["tests"] -norecursedirs = ["tests/non-pytest"] +norecursedirs = ["tests/non-pytest", "tests/benchmark"] filterwarnings = [ "error", ] @@ -123,7 +120,7 @@ source = ["globus_sdk"] # omit must be specified in a way which matches the # tox environment installations, so lead with `**` omit = [ - "**/globus_sdk/_testing/*", + "**/globus_sdk/testing/*", ] [tool.coverage.paths] @@ -153,13 +150,14 @@ exclude_lines =[ ] [tool.scriv] -version = "literal: src/globus_sdk/version.py: __version__" +version = "literal: pyproject.toml: project.version" format = "rst" output_file = "changelog.rst" entry_title_template = 'v{{ version }} ({{ date.strftime("%Y-%m-%d") }})' rst_header_chars = "=-" categories = [ "Python Support", + "Breaking Changes", "Added", "Removed", "Changed", diff --git a/scripts/ensure_exports_are_documented.py b/scripts/ensure_exports_are_documented.py index 7c53f0974..9d8108e78 100755 --- a/scripts/ensure_exports_are_documented.py +++ b/scripts/ensure_exports_are_documented.py @@ -20,16 +20,10 @@ "globus_sdk/globus_app/", "globus_sdk/scopes/", "globus_sdk/response.py", - "globus_sdk/_testing/", + "globus_sdk/testing/", ) -DEPRECATED_NAMES = { - "ComputeFunctionDocument", - "ComputeFunctionMetadata", - "TimerAPIError", - "TimerClient", - "TimerScopes", -} +DEPRECATED_NAMES: set[str] = set() def load_docs() -> dict[str, str]: diff --git a/scripts/rtd-pre-sphinx-build.sh b/scripts/rtd-pre-sphinx-build.sh index ae4c98515..e4d625245 100755 --- a/scripts/rtd-pre-sphinx-build.sh +++ b/scripts/rtd-pre-sphinx-build.sh @@ -1,6 +1,6 @@ #!/bin/bash -VERSION=$(grep '^__version__' src/globus_sdk/version.py | cut -d '"' -f2) +VERSION=$(grep '^version' pyproject.toml | head -n 1 | cut -d '"' -f2) case "$READTHEDOCS_VERSION_TYPE" in external) diff --git a/src/globus_sdk/__init__.py b/src/globus_sdk/__init__.py index d95f12e93..4eaf17880 100644 --- a/src/globus_sdk/__init__.py +++ b/src/globus_sdk/__init__.py @@ -1,12 +1,14 @@ +import importlib.metadata import logging import sys -from ._lazy_import import ( +from ._internal.lazy_import import ( default_dir_implementation, default_getattr_implementation, load_all_tuple, ) -from .version import __version__ # noqa: F401 + +__version__ = importlib.metadata.distribution("globus_sdk").version def _force_eager_imports() -> None: diff --git a/src/globus_sdk/__init__.pyi b/src/globus_sdk/__init__.pyi index 9e0e57277..883e1b17a 100644 --- a/src/globus_sdk/__init__.pyi +++ b/src/globus_sdk/__init__.pyi @@ -1,3 +1,4 @@ +from ._missing import MISSING, MissingType from .authorizers import ( AccessTokenAuthorizer, BasicAuthorizer, @@ -15,7 +16,7 @@ from .exc import ( GlobusSDKUsageError, GlobusTimeoutError, NetworkError, - RemovedInV4Warning, + RemovedInV5Warning, ValidationError, ) from .globus_app import ClientApp, GlobusApp, GlobusAppConfig, UserApp @@ -47,10 +48,7 @@ from .services.compute import ( ComputeAPIError, ComputeClientV2, ComputeClientV3, - ComputeFunctionDocument, - ComputeFunctionMetadata, ) -from .services.compute.deprecated_client import ComputeClient from .services.flows import ( FlowsAPIError, FlowsClient, @@ -105,11 +103,9 @@ from .services.groups import ( from .services.search import ( SearchAPIError, SearchClient, - SearchQuery, SearchQueryV1, SearchScrollQuery, ) -from .services.timer import TimerAPIError, TimerClient from .services.timers import ( FlowTimer, OnceTimerSchedule, @@ -120,15 +116,14 @@ from .services.timers import ( TransferTimer, ) from .services.transfer import ( - ActivationRequirementsResponse, DeleteData, IterableTransferResponse, TransferAPIError, TransferClient, TransferData, ) -from .utils import MISSING, MissingType -from .version import __version__ + +__version__ = "x.y.z" def _force_eager_imports() -> None: ... @@ -147,7 +142,7 @@ __all__ = ( "GlobusSDKUsageError", "GlobusTimeoutError", "NetworkError", - "RemovedInV4Warning", + "RemovedInV5Warning", "ValidationError", "ClientApp", "GlobusApp", @@ -178,11 +173,8 @@ __all__ = ( "OAuthTokenResponse", "IDTokenDecoder", "ComputeAPIError", - "ComputeClient", "ComputeClientV2", "ComputeClientV3", - "ComputeFunctionDocument", - "ComputeFunctionMetadata", "FlowsAPIError", "FlowsClient", "IterableFlowsResponse", @@ -230,11 +222,8 @@ __all__ = ( "GroupVisibility", "SearchAPIError", "SearchClient", - "SearchQuery", "SearchQueryV1", "SearchScrollQuery", - "TimerAPIError", - "TimerClient", "OnceTimerSchedule", "RecurringTimerSchedule", "TimerJob", @@ -242,7 +231,6 @@ __all__ = ( "TimersClient", "FlowTimer", "TransferTimer", - "ActivationRequirementsResponse", "DeleteData", "IterableTransferResponse", "TransferAPIError", diff --git a/src/globus_sdk/_globus_sdk_flake8.py b/src/globus_sdk/_globus_sdk_flake8.py deleted file mode 100644 index ae9635366..000000000 --- a/src/globus_sdk/_globus_sdk_flake8.py +++ /dev/null @@ -1,139 +0,0 @@ -from __future__ import annotations - -import ast -import typing as t - -CODEMAP: dict[str, str] = { - # SDK001 is necessary for SDK002 enforcement to be easy - # otherwise, we would have to have a more sophisticated linter which knows about - # lexical scopes! - "SDK001": "SDK001 loggers should be named 'log'", - "SDK002": "SDK002 never use 'log.info'", -} - - -class Plugin: - name = "globus-sdk-flake8" - version = "1.0.0" - - # args to init determine plugin behavior. see: - # https://flake8.pycqa.org/en/latest/plugin-development/plugin-parameters.html#indicating-desired-data - # - # by having "tree" as an init arg, we tell flake8 that we are an AST-handling - # plugin, run once per file - def __init__(self, tree: ast.AST) -> None: - self.tree = tree - - # Plugin.run() is how checks will run. For detail, see implementation of: - # https://flake8.pycqa.org/en/latest/internal/checker.html#flake8.checker.FileChecker.run_ast_checks - def run(self) -> t.Iterator[tuple[int, int, str, type]]: - visitor = SDKVisitor() - visitor.visit(self.tree) - for lineno, col, code in visitor.collect: - yield lineno, col, CODEMAP[code], type(self) - - -class SDKVisitor(ast.NodeVisitor): - def __init__(self) -> None: - super().__init__() - self.collect: list[tuple[int, int, str]] = [] - - def _record(self, node: ast.expr | ast.stmt, code: str) -> None: - self.collect.append((node.lineno, node.col_offset, code)) - - # see the structure of an assignment: - # - # >>> print(ast.dump(ast.parse("""\ - # ... log = logging.getLogger(__name__) - # ... """), indent=4)) - # - # Module( - # body=[ - # Assign( - # targets=[ - # Name(id='log', ctx=Store())], - # value=Call( - # func=Attribute( - # value=Name(id='logging', ctx=Load()), - # attr='getLogger', - # ctx=Load()), - # args=[ - # Name(id='__name__', ctx=Load())], - # keywords=[]))], - # type_ignores=[]) - # - # we will try to find an optimal path to bail out quickly on most assignments - def visit_Assign(self, node: ast.Assign) -> None: - # the value must be a call and must be using attr access in the function call - # this eliminates bare funcs like `x = foo()` but allows `x = foo.bar()` - if not ( - isinstance(node.value, ast.Call) - and isinstance(node.value.func, ast.Attribute) - ): - self.generic_visit(node) - return - call_node: ast.Call = node.value - func_node: ast.Attribute = node.value.func - - # not getLogger? irrelevant! - # not a name access (e.g., `foo().bar`)? irrelevant! - # not `getLogger` of `logging` (e.g., `x.getLogger`)? irrelevant! (and weird!) - if not ( - func_node.attr == "getLogger" - and isinstance(func_node.value, ast.Name) - and func_node.value.id == "logging" - ): - self.generic_visit(node) - return - - # the assignee must be a single variable and it must be a name node - if not (len(node.targets) == 1 and isinstance(node.targets[0], ast.Name)): - self.generic_visit(node) - return - name_node: ast.Name = node.targets[0] - - # confirm that the `logging.getLogger` args look right, if not... ignore - if len(call_node.args) != 1 or not isinstance(call_node.args[0], ast.Name): - self.generic_visit(node) - return - logger_arg: ast.Name = call_node.args[0] - - # now, all data prepared, do the check: - # - if the argument to `getLogger` is `"__name__"` - # - and the assignee is not "log" - # that fails - # - # other usages are allowed, e.g. `liblog = logging.getLogger(otherlib_name) - if name_node.id != "log" and logger_arg.id == "__name__": - self._record(node, "SDK001") - - # see the structure of a call: - # - # >>> print(ast.dump(ast.parse("log.info('foo')"), indent=4)) - # Module( - # body=[ - # Expr( - # value=Call( - # func=Attribute( - # value=Name(id='log', ctx=Load()), - # attr='info', - # ctx=Load()), - # args=[ - # Constant(value='foo')], - # keywords=[]))], - # type_ignores=[]) - # - def visit_Call(self, node: ast.Call) -> None: - # if it's not a call to 'x.info(...)', ignore - if not (isinstance(node.func, ast.Attribute) and node.func.attr == "info"): - self.generic_visit(node) - return - func_node: ast.Attribute = node.func - - # if the function was not a method of something named "log", ignore - if not (isinstance(func_node.value, ast.Name) and func_node.value.id == "log"): - self.generic_visit(node) - return - - # nothing left, it failed SDK002! - self._record(node, "SDK002") diff --git a/src/globus_sdk/_testing/data/__init__.py b/src/globus_sdk/_internal/__init__.py similarity index 100% rename from src/globus_sdk/_testing/data/__init__.py rename to src/globus_sdk/_internal/__init__.py diff --git a/src/globus_sdk/_internal/classprop.py b/src/globus_sdk/_internal/classprop.py new file mode 100644 index 000000000..80ea76865 --- /dev/null +++ b/src/globus_sdk/_internal/classprop.py @@ -0,0 +1,67 @@ +""" +WARNING: for internal use only. +Everything in SDK private modules is meant to be internal only, but that +holds for this module **in particular**. + +Usage: + + from globus_sdk._internal.classprop import classproperty + + class A: + @classproperty + def foo(self_or_cls): ... +""" + +from __future__ import annotations + +import os +import sys +import typing as t + +T = t.TypeVar("T") +R = t.TypeVar("R") + + +def _in_sphinx_build() -> bool: # pragma: no cover + # check if `sphinx-build` was used to invoke + return os.path.basename(sys.argv[0]) in ["sphinx-build", "sphinx-build.exe"] + + +class _classproperty(t.Generic[T, R]): + """ + A hybrid class/instance property descriptor. + + On a class, the decorated method will be invoked with `cls`. + On an instance, the decorated method will be invoked with `self`. + """ + + def __init__(self, func: t.Callable[[type[T] | T], R]) -> None: + self.func = func + + def __get__(self, obj: T | None, cls: type[T]) -> R: + # NOTE: our __get__ here prefers the object over the class when possible + # although well-defined behavior for a descriptor, this contradicts the + # expectation that developers may have from `classmethod` + if obj is None: + return self.func(cls) + return self.func(obj) + + +# if running under sphinx, define this as the stacked classmethod(property(...)) +# decoration, so that proper autodoc generation happens +# this is based on the python3.9 behavior which supported stacking these decorators +# however, that support was pulled in 3.10 and is not going to be reintroduced at +# present +# therefore, this sphinx behavior may not be stable in the long term +if _in_sphinx_build(): # pragma: no cover + + def classproperty(func: t.Callable[[T | type[T]], R]) -> _classproperty[T, R]: + # type ignore this because + # - it doesn't match the return type + # - mypy doesn't understand classmethod(property(...)) on older pythons + return classmethod(property(func)) # type: ignore + +else: + + def classproperty(func: t.Callable[[T | type[T]], R]) -> _classproperty[T, R]: + return _classproperty(func) diff --git a/src/globus_sdk/_testing/data/auth/__init__.py b/src/globus_sdk/_internal/extensions/__init__.py similarity index 100% rename from src/globus_sdk/_testing/data/auth/__init__.py rename to src/globus_sdk/_internal/extensions/__init__.py diff --git a/src/globus_sdk/_internal/extensions/globus_sdk_flake8.py b/src/globus_sdk/_internal/extensions/globus_sdk_flake8.py new file mode 100644 index 000000000..f2fac7c9b --- /dev/null +++ b/src/globus_sdk/_internal/extensions/globus_sdk_flake8.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import ast +import typing as t + +CODEMAP: dict[str, str] = { + # SDK001 is necessary for SDK002 enforcement to be easy + # otherwise, we would have to have a more sophisticated linter which knows about + # lexical scopes! + "SDK001": "SDK001 loggers should be named 'log'", + "SDK002": "SDK002 never use 'log.info'", + # don't do `isinstance(x, MissingType)` -- use `x is MISSING` instead + "SDK003": "SDK003 use `is MISSING`, not `isinstance(..., MissingType)`", +} + + +class Plugin: + name = "globus-sdk-flake8" + version = "1.0.0" + + # args to init determine plugin behavior. see: + # https://flake8.pycqa.org/en/latest/plugin-development/plugin-parameters.html#indicating-desired-data + # + # by having "tree" as an init arg, we tell flake8 that we are an AST-handling + # plugin, run once per file + def __init__(self, tree: ast.AST) -> None: + self.tree = tree + + # Plugin.run() is how checks will run. For detail, see implementation of: + # https://flake8.pycqa.org/en/latest/internal/checker.html#flake8.checker.FileChecker.run_ast_checks + def run(self) -> t.Iterator[tuple[int, int, str, type]]: + visitor = SDKVisitor() + visitor.visit(self.tree) + for lineno, col, code in visitor.collect: + yield lineno, col, CODEMAP[code], type(self) + + +class SDKVisitor(ast.NodeVisitor): + def __init__(self) -> None: + super().__init__() + self.collect: list[tuple[int, int, str]] = [] + + def _record(self, node: ast.expr | ast.stmt, code: str) -> None: + self.collect.append((node.lineno, node.col_offset, code)) + + def visit_Assign(self, node: ast.Assign) -> None: + if matches_sdk001(node): + self._record(node, "SDK001") + + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + if matches_sdk003(node): + self._record(node, "SDK003") + elif matches_sdk002(node): + self._record(node, "SDK002") + + self.generic_visit(node) + + +def matches_sdk001(node: ast.Assign) -> bool: + """ + A matcher for the SDK001 lint rule. + + Checks for `x = logging.getLogger(__name__)` where `x` is not `log`. + + :param node: the assignment statement AST node to check + """ + # the value must be a call and must be using attr access in the function call + # this eliminates bare funcs like `x = foo()` but allows `x = foo.bar()` + if not ( + isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Attribute) + ): + return False + + call_node: ast.Call = node.value + func_node: ast.Attribute = node.value.func + + # make sure it's 'logging.getLogger' and no other function + if not ( + func_node.attr == "getLogger" + and isinstance(func_node.value, ast.Name) + and func_node.value.id == "logging" + ): + return False + + # the assignee must be a single variable and it must be a name node + if not (len(node.targets) == 1 and isinstance(node.targets[0], ast.Name)): + return False + + # if the assigned name is `log`, then it cannot be a match + name_node: ast.Name = node.targets[0] + if name_node.id == "log": + return False + + # confirm that the `logging.getLogger` args are a single name -- that's the + # form it will be when `__name__` is passed + if len(call_node.args) != 1 or not isinstance(call_node.args[0], ast.Name): + return False + + # if the argument is some other name, e.g. `logging.getLogger(my_variable)`, + # then that cannot be a match + logger_arg: ast.Name = call_node.args[0] + if logger_arg.id != "__name__": + return False + + return True # all conditions met! + + +def matches_sdk002(node: ast.Call) -> bool: + """ + A matcher for the SDK002 lint rule. + + Checks for `log.info(...)` + + :param node: the function call AST node to check + """ + func_node = node.func + # the function call must be of the form 'OBJ.info(...)' + if not (isinstance(func_node, ast.Attribute) and func_node.attr == "info"): + return False + + # and, more specifically, the object must be named 'log', so + # it's `log.info(...) + if not (isinstance(func_node.value, ast.Name) and func_node.value.id == "log"): + return False + + return True # all conditions met! + + +def matches_sdk003(node: ast.Call) -> bool: + """ + A matcher for the SDK003 lint rule. + + Checks for `isinstance(x, MissingType)` + + :param node: the function call AST node to check + """ + # it must be a call to a function named "isinstance" + if not (isinstance(node.func, ast.Name) and node.func.id == "isinstance"): + return False + # if the number of arguments is improper, it can't be a violation (not a + # valid 'isinstance' call) + if len(node.args) != 2: + return False + right_hand_side = node.args[1] + # the second argument must be the name 'MissingType' + if not ( + isinstance(right_hand_side, ast.Name) and right_hand_side.id == "MissingType" + ): + return False + + return True # all conditions met! diff --git a/src/globus_sdk/_sphinxext/__init__.py b/src/globus_sdk/_internal/extensions/sphinxext/__init__.py similarity index 100% rename from src/globus_sdk/_sphinxext/__init__.py rename to src/globus_sdk/_internal/extensions/sphinxext/__init__.py diff --git a/src/globus_sdk/_sphinxext/autodoc_hooks.py b/src/globus_sdk/_internal/extensions/sphinxext/autodoc_hooks.py similarity index 100% rename from src/globus_sdk/_sphinxext/autodoc_hooks.py rename to src/globus_sdk/_internal/extensions/sphinxext/autodoc_hooks.py diff --git a/src/globus_sdk/_sphinxext/directives/__init__.py b/src/globus_sdk/_internal/extensions/sphinxext/directives/__init__.py similarity index 100% rename from src/globus_sdk/_sphinxext/directives/__init__.py rename to src/globus_sdk/_internal/extensions/sphinxext/directives/__init__.py diff --git a/src/globus_sdk/_sphinxext/directives/add_content_directive.py b/src/globus_sdk/_internal/extensions/sphinxext/directives/add_content_directive.py similarity index 100% rename from src/globus_sdk/_sphinxext/directives/add_content_directive.py rename to src/globus_sdk/_internal/extensions/sphinxext/directives/add_content_directive.py diff --git a/src/globus_sdk/_sphinxext/directives/automethodlist.py b/src/globus_sdk/_internal/extensions/sphinxext/directives/automethodlist.py similarity index 100% rename from src/globus_sdk/_sphinxext/directives/automethodlist.py rename to src/globus_sdk/_internal/extensions/sphinxext/directives/automethodlist.py diff --git a/src/globus_sdk/_sphinxext/directives/copy_params.py b/src/globus_sdk/_internal/extensions/sphinxext/directives/copy_params.py similarity index 100% rename from src/globus_sdk/_sphinxext/directives/copy_params.py rename to src/globus_sdk/_internal/extensions/sphinxext/directives/copy_params.py diff --git a/src/globus_sdk/_sphinxext/directives/enumerate_testing_fixtures.py b/src/globus_sdk/_internal/extensions/sphinxext/directives/enumerate_testing_fixtures.py similarity index 94% rename from src/globus_sdk/_sphinxext/directives/enumerate_testing_fixtures.py rename to src/globus_sdk/_internal/extensions/sphinxext/directives/enumerate_testing_fixtures.py index f6194a555..9734bee9e 100644 --- a/src/globus_sdk/_sphinxext/directives/enumerate_testing_fixtures.py +++ b/src/globus_sdk/_internal/extensions/sphinxext/directives/enumerate_testing_fixtures.py @@ -17,7 +17,7 @@ class EnumerateTestingFixtures(AddContentDirective): } def gen_rst(self) -> t.Iterator[str]: - from globus_sdk._testing import get_response_set + from globus_sdk.testing import get_response_set underline_char = self.options.get("header_underline_char", "-") @@ -46,7 +46,7 @@ def gen_rst(self) -> t.Iterator[str]: for casename in rset.cases(): # use "attr" rather than "meth" so that sphinx does not add parens # the use of the method as an attribute of the class or instance better - # matches how `_testing` handles things + # matches how `testing` handles things yield ( ".. dropdown:: " f':py:attr:`~{classname}.{methodname}` (``case="{casename}"``)' diff --git a/src/globus_sdk/_sphinxext/directives/expand_testing_fixture.py b/src/globus_sdk/_internal/extensions/sphinxext/directives/expand_testing_fixture.py similarity index 93% rename from src/globus_sdk/_sphinxext/directives/expand_testing_fixture.py rename to src/globus_sdk/_internal/extensions/sphinxext/directives/expand_testing_fixture.py index f41450db5..0bcf109cc 100644 --- a/src/globus_sdk/_sphinxext/directives/expand_testing_fixture.py +++ b/src/globus_sdk/_internal/extensions/sphinxext/directives/expand_testing_fixture.py @@ -3,7 +3,7 @@ from docutils.parsers.rst import directives -from globus_sdk._testing import ResponseList +from globus_sdk.testing import ResponseList from .add_content_directive import AddContentDirective @@ -17,7 +17,7 @@ class ExpandTestingFixture(AddContentDirective): } def gen_rst(self) -> t.Iterator[str]: - from globus_sdk._testing import get_response_set + from globus_sdk.testing import get_response_set response_set_name = self.arguments[0] casename = "default" diff --git a/src/globus_sdk/_sphinxext/directives/externaldoclink.py b/src/globus_sdk/_internal/extensions/sphinxext/directives/externaldoclink.py similarity index 100% rename from src/globus_sdk/_sphinxext/directives/externaldoclink.py rename to src/globus_sdk/_internal/extensions/sphinxext/directives/externaldoclink.py diff --git a/src/globus_sdk/_sphinxext/directives/list_known_scopes.py b/src/globus_sdk/_internal/extensions/sphinxext/directives/list_known_scopes.py similarity index 59% rename from src/globus_sdk/_sphinxext/directives/list_known_scopes.py rename to src/globus_sdk/_internal/extensions/sphinxext/directives/list_known_scopes.py index 95d9c5fe2..a68e6bd29 100644 --- a/src/globus_sdk/_sphinxext/directives/list_known_scopes.py +++ b/src/globus_sdk/_internal/extensions/sphinxext/directives/list_known_scopes.py @@ -5,7 +5,7 @@ from docutils.parsers.rst import directives -from globus_sdk.scopes import ScopeBuilder +from globus_sdk.scopes import Scope, ScopeCollection from .add_content_directive import AddContentDirective @@ -16,20 +16,21 @@ class ListKnownScopes(AddContentDirective): optional_arguments = 0 option_spec = { "example_scope": directives.unchanged, - # Allow overriding the base name to match how the ScopeBuilder will be accessed. + # Allow overriding the base name to match how the ScopeCollection will + # be accessed. "base_name": directives.unchanged, } def gen_rst(self) -> t.Iterator[str]: - sb_name = self.arguments[0] - sb_basename = sb_name.split(".")[-1] + sc_name = self.arguments[0] + sc_basename = sc_name.split(".")[-1] if "base_name" in self.options: - sb_basename = self.options["base_name"] + sc_basename = self.options["base_name"] example_scope = None if "example_scope" in self.options: example_scope = self.options["example_scope"].strip() - known_scopes = extract_known_scopes(sb_name) + known_scopes = extract_known_scopes(sc_name) if example_scope is None: example_scope = known_scopes[0] @@ -37,7 +38,7 @@ def gen_rst(self) -> t.Iterator[str]: yield "Various scopes are available as attributes of this object." yield f"For example, access the ``{example_scope}`` scope with" yield "" - yield f">>> {sb_basename}.{example_scope}" + yield f">>> {sc_basename}.{example_scope}" yield "" yield "**Supported Scopes**" yield "" @@ -47,10 +48,11 @@ def gen_rst(self) -> t.Iterator[str]: yield "" -def extract_known_scopes(scope_builder_name: str) -> list[str]: - sb = locate(scope_builder_name) - if not isinstance(sb, ScopeBuilder): - raise RuntimeError( - f"Expected {sb} to be a ScopeBuilder, but got {type(sb)} instead" - ) - return sb.scope_names +def extract_known_scopes(scope_collection_name: str) -> list[str]: + sc = locate(scope_collection_name) + if isinstance(sc, ScopeCollection): + return [name for name in dir(sc) if isinstance(getattr(sc, name), Scope)] + + raise RuntimeError( + f"Expected {sc} to be a scope collection, but got {type(sc)} instead" + ) diff --git a/src/globus_sdk/_sphinxext/directives/paginated_usage.py b/src/globus_sdk/_internal/extensions/sphinxext/directives/paginated_usage.py similarity index 100% rename from src/globus_sdk/_sphinxext/directives/paginated_usage.py rename to src/globus_sdk/_internal/extensions/sphinxext/directives/paginated_usage.py diff --git a/src/globus_sdk/_sphinxext/roles.py b/src/globus_sdk/_internal/extensions/sphinxext/roles.py similarity index 100% rename from src/globus_sdk/_sphinxext/roles.py rename to src/globus_sdk/_internal/extensions/sphinxext/roles.py diff --git a/src/globus_sdk/_sphinxext/utils.py b/src/globus_sdk/_internal/extensions/sphinxext/utils.py similarity index 100% rename from src/globus_sdk/_sphinxext/utils.py rename to src/globus_sdk/_internal/extensions/sphinxext/utils.py diff --git a/src/globus_sdk/_guards.py b/src/globus_sdk/_internal/guards.py similarity index 98% rename from src/globus_sdk/_guards.py rename to src/globus_sdk/_internal/guards.py index ef2f717ab..bd4d701cd 100644 --- a/src/globus_sdk/_guards.py +++ b/src/globus_sdk/_internal/guards.py @@ -8,7 +8,7 @@ from globus_sdk.exc.base import ValidationError if t.TYPE_CHECKING: - from globus_sdk._serializable import Serializable + from globus_sdk._internal.serializable import Serializable if sys.version_info >= (3, 10): from typing import TypeGuard diff --git a/src/globus_sdk/_lazy_import.py b/src/globus_sdk/_internal/lazy_import.py similarity index 100% rename from src/globus_sdk/_lazy_import.py rename to src/globus_sdk/_internal/lazy_import.py diff --git a/src/globus_sdk/_internal/remarshal.py b/src/globus_sdk/_internal/remarshal.py new file mode 100644 index 000000000..41fedbce3 --- /dev/null +++ b/src/globus_sdk/_internal/remarshal.py @@ -0,0 +1,188 @@ +""" +This module provides internal helpers for remarshalling data from one shape +into another. + +This may be as simple as converting strings from one form to another, or as +sophisticated as building a specific internal object in a configurable way. +""" + +from __future__ import annotations + +import collections.abc +import sys +import typing as t +import uuid + +from globus_sdk._missing import MISSING, MissingType + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + from typing_extensions import TypeAlias + +T = t.TypeVar("T") +R = t.TypeVar("R") + +# Omittable[T] means "T may be missing" +# NullableOmittable[T] means "T may be missing or null" +# +# in type systems this kind of construction is sometimes called "Optional" or "Maybe" +# but Python uses "Optional" to mean "T | None" and in the SDK, "None" means "null" +Omittable: TypeAlias[T] = t.Union[T, MissingType] +NullableOmittable: TypeAlias[T] = t.Union[Omittable[T], None] + + +def stringify(value: NullableOmittable[object]) -> NullableOmittable[str]: + """ + Convert a value to a string, with handling for None and Missing. + + :param value: The stringifiable object + """ + if value is None: + return None + if value is MISSING: + return MISSING + return str(value) + + +@t.overload +def listify(value: None) -> None: ... +@t.overload +def listify(value: MissingType) -> MissingType: ... +@t.overload +def listify(value: t.Iterable[T]) -> list[T]: ... + + +def listify(value: NullableOmittable[t.Iterable[T]]) -> NullableOmittable[list[T]]: + """ + Convert any iterable to a list, with handling for None and Missing. + + :param value: The iterable of objects + """ + if value is None: + return None + if value is MISSING: + return MISSING + if isinstance(value, list): + return value + return list(value) + + +def strseq_iter( + value: t.Iterable[str | uuid.UUID] | str | uuid.UUID, +) -> t.Iterator[str]: + """ + Iterate over one or more string/string-convertible values. + + :param value: The stringifiable object or objects to iterate over + + This function handles strings, which are themselves iterable, + by producing the string itself, not it's characters: + + >>> list("foo") + ['f', 'o', 'o'] + >>> list(strseq_iter("foo")) + ['foo'] + + It also accepts and converts UUIDs and iterables thereof: + + >>> list(strseq_iter(UUID(int=0))) + ['00000000-0000-0000-0000-000000000000'] + >>> list(strseq_iter([UUID(int=0), UUID(int=1)])) + ['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'] + + This helps handle cases where a string is passed to a function expecting an iterable + of strings, as well as cases where an iterable of UUID objects is accepted for a + list of IDs, or something similar. + """ + if isinstance(value, str): + yield value + elif isinstance(value, uuid.UUID): + yield str(value) + else: + for x in value: + yield str(x) + + +@t.overload +def strseq_listify(value: None) -> None: ... +@t.overload +def strseq_listify(value: MissingType) -> MissingType: ... + + +@t.overload +def strseq_listify( + value: t.Iterable[str | uuid.UUID] | str | uuid.UUID, +) -> list[str]: ... + + +def strseq_listify( + value: NullableOmittable[t.Iterable[str | uuid.UUID] | str | uuid.UUID], +) -> NullableOmittable[list[str]]: + """ + A wrapper over strseq_iter which produces list outputs. + This method takes responsibility for checking for MISSING and None values. + + Unlike strseq_iter, this may be the "last mile" remarshalling step before + data is actually passed to the network layer. Therefore, it makes sense for this + helper to handle (MISSING | None). + + :param value: The stringifiable object or iterable of objects + """ + if value is None: + return None + if value is MISSING: + return MISSING + return list(strseq_iter(value)) + + +@t.overload +def list_map(value: None, mapped_function: t.Callable[[T], R]) -> None: ... + + +@t.overload +def list_map( + value: MissingType, mapped_function: t.Callable[[T], R] +) -> MissingType: ... + + +@t.overload +def list_map(value: t.Iterable[T], mapped_function: t.Callable[[T], R]) -> list[R]: ... + + +def list_map( + value: NullableOmittable[t.Iterable[T]], mapped_function: t.Callable[[T], R] +) -> NullableOmittable[list[R]]: + """ + Like list(map()) but handles None|MISSING. + + :param value: The iterable of objects over which to map + :param mapped_function: The function to map + """ + if value is None: + return None + if value is MISSING: + return MISSING + return [mapped_function(element) for element in value] + + +@t.overload +def commajoin(value: MissingType) -> MissingType: ... +@t.overload +def commajoin(value: None) -> None: ... +@t.overload +def commajoin(value: str | uuid.UUID | t.Iterable[str | uuid.UUID]) -> str: ... + + +def commajoin( + value: NullableOmittable[str | uuid.UUID | t.Iterable[str | uuid.UUID]], +) -> NullableOmittable[str]: + if value is None: + return None + if value is MISSING: + return MISSING + # note that this explicit handling of Iterable allows for objects to be + # passed to this function and be stringified by the `str()` call + if isinstance(value, collections.abc.Iterable): + return ",".join(strseq_iter(value)) + return str(value) diff --git a/src/globus_sdk/_serializable.py b/src/globus_sdk/_internal/serializable.py similarity index 96% rename from src/globus_sdk/_serializable.py rename to src/globus_sdk/_internal/serializable.py index 1eee60b51..97ffdf1a9 100644 --- a/src/globus_sdk/_serializable.py +++ b/src/globus_sdk/_internal/serializable.py @@ -18,7 +18,7 @@ class Serializable: Serializable classes: - know what fields they have, based on their initializer signatures - support `to_dict()` and `from_dict()` conversions - - typically use `globus_sdk._guards.validators` to check attribute types + - typically use `globus_sdk._internal.guards.validators` to check attribute types """ _EXCLUDE_VARS: t.ClassVar[tuple[str, ...]] = ("self", "extra") diff --git a/src/globus_sdk/_types.py b/src/globus_sdk/_internal/type_definitions.py similarity index 74% rename from src/globus_sdk/_types.py rename to src/globus_sdk/_internal/type_definitions.py index cd1f632b7..b17b7cc31 100644 --- a/src/globus_sdk/_types.py +++ b/src/globus_sdk/_internal/type_definitions.py @@ -3,21 +3,10 @@ import datetime import typing as t -if t.TYPE_CHECKING: - from globus_sdk.scopes import MutableScope, Scope - - # these types are aliases meant for internal use IntLike = t.Union[int, str] DateLike = t.Union[str, datetime.datetime] -ScopeCollectionType = t.Union[ - str, - "MutableScope", - "Scope", - t.Iterable["ScopeCollectionType"], -] - class ResponseLike(t.Protocol): @property diff --git a/src/globus_sdk/_internal/utils.py b/src/globus_sdk/_internal/utils.py new file mode 100644 index 000000000..5930d676a --- /dev/null +++ b/src/globus_sdk/_internal/utils.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import hashlib +import platform + + +def sha256_string(s: str) -> str: + return hashlib.sha256(s.encode("utf-8")).hexdigest() + + +def get_nice_hostname() -> str | None: + """ + Get the current hostname, with the following added behavior: + + - if it ends in '.local', strip that suffix, as this is a frequent macOS behavior + 'DereksCoolMacbook.local' -> 'DereksCoolMacbook' + + - if the hostname is undiscoverable, return None + """ + name = platform.node() + if name.endswith(".local"): + return name[: -len(".local")] + return name or None + + +def slash_join(a: str, b: str | None) -> str: + """ + Join a and b with a single slash, regardless of whether they already + contain a trailing/leading slash or neither. + + :param a: the first path component + :param b: the second path component + """ + if not b: # "" or None, don't append a slash + return a + if a.endswith("/"): + if b.startswith("/"): + return a[:-1] + b + return a + b + if b.startswith("/"): + return a + b + return a + "/" + b diff --git a/src/globus_sdk/_missing.py b/src/globus_sdk/_missing.py new file mode 100644 index 000000000..d30ac9b80 --- /dev/null +++ b/src/globus_sdk/_missing.py @@ -0,0 +1,100 @@ +""" +The definition of the MISSING sentinel and its type. + +These are exposed publicly as `globus_sdk.MISSING` and `globus_sdk.MissingType`. +""" + +from __future__ import annotations + +import typing as t + +T = t.TypeVar("T") + +# type checkers don't know that MISSING is a sentinel, so we will describe it +# differently at typing time, allowing for type narrowing on `is MISSING` and +# similar checks +if t.TYPE_CHECKING: + # pretend that `MISSING: MissingType` is an enum at type-checking time + # + # enums are treated as `Literal[...]` values and are narrowed under simple + # checks, as unions and literal types are + # therefore, under this definition, `MissingType ~= Literal[MissingType.MISSING]` + # + # Therefore, consider this example: + # + # x: int | float | MissingType + # if x is not MISSING: + # reveal_type(x) + # + # This is effectively the same as if we wrote: + # + # x: int | float | Literal["a"] + # if x != "a": + # reveal_type(x) + # + # Both should show `x: int | float` + import enum + + class MissingType(enum.Enum): + MISSING = enum.auto() + + MISSING = MissingType.MISSING +else: + + class MissingType: + def __init__(self) -> None: + # disable instantiation, but gated to be able to run once + # when this module is imported + if "MISSING" in globals(): + raise TypeError("MissingType should not be instantiated") + + def __bool__(self) -> bool: + return False + + def __copy__(self) -> MissingType: + return self + + def __deepcopy__(self, memo: dict[int, t.Any]) -> MissingType: + return self + + # unpickling a MissingType should always return the "MISSING" sentinel + def __reduce__(self) -> str: + return "MISSING" + + def __repr__(self) -> str: + return "" + + # a sentinel value for "missing" values which are distinguished from `None` (null) + # this is the default used to indicate that a parameter was not passed, so that + # method calls passing `None` can be distinguished from those which did not pass any + # value + # users should typically not use this value directly, but it is part of the + # public SDK interfaces along with its type for annotation purposes + MISSING = MissingType() + + +@t.overload +def filter_missing(data: dict[str, t.Any]) -> dict[str, t.Any]: ... + + +@t.overload +def filter_missing(data: None) -> None: ... + + +def filter_missing(data: dict[str, t.Any] | None) -> dict[str, t.Any] | None: + if data is None: + return None + return {k: v for k, v in data.items() if v is not MISSING} + + +def none2missing(obj: T | None) -> T | MissingType: + """ + A converter for interfaces which take "nullable" to mean "omittable", to + adapt them to usage sites which require use of MISSING for omittable + elements. + + :param obj: The nullable object to convert to an omittable object. + """ + if obj is None: + return MISSING + return obj diff --git a/src/globus_sdk/_payload.py b/src/globus_sdk/_payload.py new file mode 100644 index 000000000..3e52857f0 --- /dev/null +++ b/src/globus_sdk/_payload.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import abc +import sys +import typing as t + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + +# TODO: Remove this dispatch after we drop Python 3.8 support. +# In 3.9+ `dict.__class_getitem__` is available. +if t.TYPE_CHECKING: + # pylint: disable=unsubscriptable-object + _PayloadBaseDict = t.Dict[str, t.Any] +else: + _PayloadBaseDict = dict + + +class GlobusPayload(_PayloadBaseDict): + """ + A class for defining helper objects which wrap some kind of "payload" dict. + Typical for helper objects which formulate a request payload. + + Payload types inheriting from this class can be passed directly to the client + ``post()``, ``put()``, and ``patch()`` methods. These methods will + recognize a ``PayloadBase`` and apply conversions for serialization with + the requested encoder (e.g. as a JSON request body). + """ + + +class AbstractGlobusPayload(GlobusPayload, abc.ABC): + """ + An abstract class which is a GlobusPayload. + + This is a shim which is needed because we have a metaclass conflict between + dict:type and ABC:ABCMeta. + + Setting the metaclass helps type checkers understand that such classes are + abstract. + """ + + # explicitly define `__new__` in order to check for abstract methods which + # were not redefined + def __new__(cls, *args: t.Any, **kwargs: t.Any) -> Self: + obj = super().__new__(cls, *args, **kwargs) + abstractmethods: frozenset[str] = ( + obj.__abstractmethods__ # type: ignore[attr-defined] + ) + if abstractmethods: + s = "" if len(abstractmethods) == 1 else "s" + methodnames = ", ".join(f"'{f}'" for f in abstractmethods) + # this error very closely imitates the errors produced by ABCMeta + raise TypeError( + f"Can't instantiate abstract class {cls.__name__} without " + f"an implementation for abstract method{s} {methodnames}" + ) + return obj diff --git a/src/globus_sdk/_testing/data/auth/oauth2_userinfo.py b/src/globus_sdk/_testing/data/auth/oauth2_userinfo.py deleted file mode 100644 index 5f7b2190b..000000000 --- a/src/globus_sdk/_testing/data/auth/oauth2_userinfo.py +++ /dev/null @@ -1,5 +0,0 @@ -# this is a clone of the userinfo.py data for compatibility across testing -# it should be removed in a future release -from .userinfo import RESPONSES - -__all__ = ("RESPONSES",) diff --git a/src/globus_sdk/_testing/data/transfer/create_endpoint.py b/src/globus_sdk/_testing/data/transfer/create_endpoint.py deleted file mode 100644 index c31128696..000000000 --- a/src/globus_sdk/_testing/data/transfer/create_endpoint.py +++ /dev/null @@ -1,22 +0,0 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet - -from ._common import ENDPOINT_ID - -RESPONSES = ResponseSet( - metadata={"endpoint_id": ENDPOINT_ID}, - default=RegisteredResponse( - service="transfer", - method="POST", - path="/endpoint", - json={ - "DATA_TYPE": "endpoint_create_result", - "display_name": "my cool endpoint", - "code": "Created", - "globus_connect_setup_key": None, - "id": ENDPOINT_ID, - "message": "Endpoint created successfully", - "request_id": "d4MqMwFJ9", - "resource": "/endpoint", - }, - ), -) diff --git a/src/globus_sdk/authorizers/access_token.py b/src/globus_sdk/authorizers/access_token.py index 6aafd64ef..a8090bebe 100644 --- a/src/globus_sdk/authorizers/access_token.py +++ b/src/globus_sdk/authorizers/access_token.py @@ -1,6 +1,6 @@ import logging -from globus_sdk import utils +from globus_sdk._internal.utils import sha256_string from .base import StaticGlobusAuthorizer @@ -24,5 +24,5 @@ def __init__(self, access_token: str) -> None: self.access_token = access_token self.header_val = "Bearer %s" % access_token - self.access_token_hash = utils.sha256_string(self.access_token) + self.access_token_hash = sha256_string(self.access_token) log.debug(f'Bearer token has hash "{self.access_token_hash}"') diff --git a/src/globus_sdk/authorizers/basic.py b/src/globus_sdk/authorizers/basic.py index 6960cc8c8..8531e41ee 100644 --- a/src/globus_sdk/authorizers/basic.py +++ b/src/globus_sdk/authorizers/basic.py @@ -1,7 +1,6 @@ +import base64 import logging -from globus_sdk import utils - from .base import StaticGlobusAuthorizer log = logging.getLogger(__name__) @@ -27,4 +26,8 @@ def __init__(self, username: str, password: str) -> None: self.password = password to_b64 = f"{username}:{password}" - self.header_val = f"Basic {utils.b64str(to_b64)}" + self.header_val = f"Basic {_b64str(to_b64)}" + + +def _b64str(s: str) -> str: + return base64.b64encode(s.encode("utf-8")).decode("utf-8") diff --git a/src/globus_sdk/authorizers/client_credentials.py b/src/globus_sdk/authorizers/client_credentials.py index 9dd59e1e2..4888d46bd 100644 --- a/src/globus_sdk/authorizers/client_credentials.py +++ b/src/globus_sdk/authorizers/client_credentials.py @@ -4,8 +4,7 @@ import typing as t import globus_sdk -from globus_sdk._types import ScopeCollectionType -from globus_sdk.scopes import scopes_to_str +from globus_sdk.scopes import Scope, ScopeParser from .renewing import RenewingAuthorizer @@ -58,7 +57,7 @@ class ClientCredentialsAuthorizer( def __init__( self, confidential_client: globus_sdk.ConfidentialAppAuthClient, - scopes: ScopeCollectionType, + scopes: str | Scope | t.Iterable[str | Scope], *, access_token: str | None = None, expires_at: int | None = None, @@ -68,7 +67,7 @@ def __init__( ) -> None: # values for _get_token_data self.confidential_client = confidential_client - self.scopes = scopes_to_str(scopes) + self.scopes = ScopeParser.serialize(scopes) log.debug( "Setting up ClientCredentialsAuthorizer with confidential_client=" diff --git a/src/globus_sdk/authorizers/renewing.py b/src/globus_sdk/authorizers/renewing.py index 16093db33..f4ea3356a 100644 --- a/src/globus_sdk/authorizers/renewing.py +++ b/src/globus_sdk/authorizers/renewing.py @@ -5,7 +5,8 @@ import time import typing as t -from globus_sdk import exc, utils +from globus_sdk import exc +from globus_sdk._internal.utils import sha256_string from .base import GlobusAuthorizer @@ -97,7 +98,7 @@ def access_token(self) -> str | None: def access_token(self, value: str | None) -> None: self._access_token = value if value: - self._access_token_hash = utils.sha256_string(value) + self._access_token_hash = sha256_string(value) @abc.abstractmethod def _get_token_response(self) -> ResponseT: diff --git a/src/globus_sdk/client.py b/src/globus_sdk/client.py index 3be1dcb86..75e2a31dd 100644 --- a/src/globus_sdk/client.py +++ b/src/globus_sdk/client.py @@ -1,23 +1,31 @@ from __future__ import annotations import logging +import sys import typing as t import urllib.parse -from globus_sdk import GlobusSDKUsageError, config, exc, utils -from globus_sdk._types import ScopeCollectionType +from globus_sdk import GlobusSDKUsageError, config, exc +from globus_sdk._internal.classprop import classproperty +from globus_sdk._internal.utils import slash_join from globus_sdk.authorizers import GlobusAuthorizer from globus_sdk.paging import PaginatorTable from globus_sdk.response import GlobusHTTPResponse -from globus_sdk.scopes import Scope, ScopeBuilder -from globus_sdk.transport import RequestsTransport +from globus_sdk.scopes import Scope, ScopeCollection +from globus_sdk.transport import RequestCallerInfo, RequestsTransport, RetryConfig +from globus_sdk.transport.default_retry_checks import DEFAULT_RETRY_CHECKS + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + from typing_extensions import TypeAlias if t.TYPE_CHECKING: from globus_sdk.globus_app import GlobusApp log = logging.getLogger(__name__) -_DataParamType = t.Union[None, str, bytes, t.Dict[str, t.Any], utils.PayloadWrapper] +_DataParamType: TypeAlias = t.Union[None, str, bytes, t.Dict[str, t.Any]] class BaseClient: @@ -41,9 +49,11 @@ class BaseClient: intelligently by default. Set it when inheriting from BaseClient or communicating through a proxy. This value takes precedence over the class attribute of the same name. - :param transport_params: Options to pass to the transport for this client - - All other parameters are for internal use and should be ignored. + :param transport: A :class:`RequestsTransport` object for sending and + retrying requests. By default, one will be constructed by the client. + :param retry_config: A :class:`RetryConfig` object with parameters to + control request retry behavior. By default, one will be constructed by + the client. """ # service name is used to lookup a service URL from config @@ -54,21 +64,12 @@ class BaseClient: # `BaseClient._resolve_base_url` method for more details. base_url: str = "_base" - # path under the client base URL - # NOTE: using this attribute is now considered bad practice for client definitions, - # as it prevents calls to new routes at the root of an API's base_url - # Consider removing in a future release - base_path: str = "/" - #: the class for errors raised by this client on HTTP 4xx and 5xx errors #: this can be set in subclasses, but must always be a subclass of GlobusError error_class: type[exc.GlobusAPIError] = exc.GlobusAPIError - #: the type of Transport which will be used, defaults to ``RequestsTransport`` - transport_class: type[RequestsTransport] = RequestsTransport - - #: the scopes for this client may be present as a ``ScopeBuilder`` - scopes: ScopeBuilder | None = None + #: the scopes for this client may be present as a ``ScopeCollection`` + scopes: ScopeCollection | None = None def __init__( self, @@ -79,7 +80,8 @@ def __init__( app_scopes: list[Scope] | None = None, authorizer: GlobusAuthorizer | None = None, app_name: str | None = None, - transport_params: dict[str, t.Any] | None = None, + transport: RequestsTransport | None = None, + retry_config: RetryConfig | None = None, ) -> None: # check for input parameter conflicts if app_scopes and not app: @@ -105,10 +107,11 @@ def __init__( # resolve the base_url for the client (see docstring for resolution precedence) self.base_url = self._resolve_base_url(base_url, self.environment) - # append the base_path to the base_url if necessary - self.base_url = utils.slash_join(self.base_url, self.base_path) - self.transport = self.transport_class(**(transport_params or {})) + self.retry_config: RetryConfig = retry_config or RetryConfig() + self._register_standard_retry_checks(self.retry_config) + + self.transport = transport if transport is not None else RequestsTransport() log.debug(f"initialized transport of type {type(self.transport)}") # setup paginated methods @@ -140,6 +143,14 @@ def default_scope_requirements(self) -> list[Scope]: """ raise NotImplementedError + def _register_standard_retry_checks(self, retry_config: RetryConfig) -> None: + """ + Setup the standard checks for this client. + + This is called during init and may be overridden by subclasses. + """ + retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) + @classmethod def _resolve_base_url(cls, init_base_url: str | None, environment: str) -> str: """ @@ -252,7 +263,9 @@ def attach_globus_app( # finally, register the scope requirements on the app side self._app.add_scope_requirements({self.resource_server: self.app_scopes}) - def add_app_scope(self, scope_collection: ScopeCollectionType) -> BaseClient: + def add_app_scope( + self, scope_collection: str | Scope | t.Iterable[str | Scope] + ) -> BaseClient: """ Add a given scope collection to this client's ``GlobusApp`` scope requirements for this client's ``resource_server``. This allows defining additional scope @@ -263,8 +276,9 @@ def add_app_scope(self, scope_collection: ScopeCollectionType) -> BaseClient: Raises ``GlobusSDKUsageError`` if this client was not initialized with a ``GlobusApp``. - :param scope_collection: A scope or scopes of ``ScopeCollectionType`` to be - added to the app's required scopes. + :param scope_collection: A scope or scopes of + ``str | Scope | t.Iterable[str | Scope]`` to be added to + the app's required scopes. .. tab-set:: @@ -301,7 +315,7 @@ def app_name(self) -> str | None: def app_name(self, value: str) -> None: self._app_name = self.transport.user_agent = value - @utils.classproperty + @classproperty def resource_server( # pylint: disable=missing-param-doc self_or_cls: BaseClient | type[BaseClient], ) -> str | None: @@ -484,12 +498,7 @@ def request( if path.startswith("https://") or path.startswith("http://"): url = path else: - # if passed a path which has a prefix matching the base_path, strip it - # this means that if a client has a base path of `/v1/`, a request for - # `/v1/foo` will hit `/v1/foo` rather than `/v1/v1/foo` - if path.startswith(self.base_path): - path = path[len(self.base_path) :] - url = utils.slash_join(self.base_url, urllib.parse.quote(path)) + url = slash_join(self.base_url, urllib.parse.quote(path)) # either use given authorizer or get one from app if automatic_authorization: @@ -499,16 +508,21 @@ def request( else: authorizer = None + # capture info about this client as the caller to pass to the transport + caller_info = RequestCallerInfo( + retry_config=self.retry_config, authorizer=authorizer + ) + # make the request log.debug("request will hit URL: %s", url) r = self.transport.request( - method=method, - url=url, + method, + url, + caller_info=caller_info, data=data, query_params=query_params, headers=rheaders, encoding=encoding, - authorizer=authorizer, allow_redirects=allow_redirects, stream=stream, ) diff --git a/src/globus_sdk/exc/__init__.py b/src/globus_sdk/exc/__init__.py index 8f0b30282..2ee4f0c3b 100644 --- a/src/globus_sdk/exc/__init__.py +++ b/src/globus_sdk/exc/__init__.py @@ -10,7 +10,7 @@ ErrorInfo, ErrorInfoContainer, ) -from .warnings import RemovedInV4Warning, warn_deprecated +from .warnings import RemovedInV5Warning, warn_deprecated __all__ = ( "GlobusError", @@ -27,14 +27,14 @@ "ErrorInfoContainer", "AuthorizationParameterInfo", "ConsentRequiredInfo", - "RemovedInV4Warning", + "RemovedInV5Warning", "warn_deprecated", ) # imports from `globus_sdk.exc.convert` are done lazily # # this ensures that we do not eagerly import `requests` when attempting to use SDK -# components which do not need it, but which do need errors (e.g., RemovedInV4Warning) +# components which do not need it, but which do need errors (e.g., RemovedInV5Warning) # and we avoid paying the performance penalty for importing the relevant dependencies if t.TYPE_CHECKING: from .convert import ( diff --git a/src/globus_sdk/exc/api.py b/src/globus_sdk/exc/api.py index 6eba66a76..393293dc6 100644 --- a/src/globus_sdk/exc/api.py +++ b/src/globus_sdk/exc/api.py @@ -2,13 +2,14 @@ import enum import logging +import sys +import textwrap import typing as t -from globus_sdk import _guards +from globus_sdk._internal import guards from .base import GlobusError from .err_info import ErrorInfoContainer -from .warnings import warn_deprecated if t.TYPE_CHECKING: import requests @@ -29,7 +30,7 @@ class GlobusAPIError(GlobusError): Wraps errors returned by a REST API. :ivar int http_status: HTTP status code - :ivar str code: Error code from the API or "Error" for unclassified errors + :ivar str code: Error code from the API or ``None`` for unclassified errors :ivar str request_id: The 'request_id' included in the error data, if any. :ivar list[str] messages: A list of error messages, extracted from the response data. If the data cannot be parsed or does not contain any clear message fields, @@ -46,7 +47,7 @@ def __init__(self, r: requests.Response, *args: t.Any, **kwargs: t.Any) -> None: self.http_status = r.status_code # defaults, may be rewritten during parsing - self.code: str | None = "Error" + self.code: str | None = None self.request_id: str | None = None self.messages: list[str] = [] self.errors: list[ErrorSubdocument] = [] @@ -54,6 +55,15 @@ def __init__(self, r: requests.Response, *args: t.Any, **kwargs: t.Any) -> None: self._info: ErrorInfoContainer | None = None self._underlying_response = r self._parse_response() + + if sys.version_info >= (3, 11): + self.add_note( # pylint: disable=no-member + ( + "This exception was caused by an API error. " + "The response body is as follows:\n\n" + ) + + textwrap.indent(self.text, " ") + ) super().__init__(*self._get_args()) @property @@ -68,14 +78,6 @@ def message(self) -> str | None: return "; ".join(self.messages) return None - @message.setter - def message(self, value: str) -> None: - warn_deprecated( - "Setting a message on GlobusAPIError objects is deprecated. " - "This overwrites any parsed messages. Append to 'messages' instead." - ) - self.messages = [value] - @property def http_reason(self) -> str: """ @@ -159,17 +161,6 @@ def text(self) -> str: """ return self._underlying_response.text - @property - def raw_text(self) -> str: - """ - Deprecated alias of the ``text`` property. - """ - warn_deprecated( - "The 'raw_text' property of GlobusAPIError objects is deprecated. " - "Use the 'text' property instead." - ) - return self.text - @property def binary_content(self) -> bytes: """ @@ -276,7 +267,7 @@ def _detect_error_format(self) -> _ErrorFormat: # well-formed if self._jsonapi_mimetype(): errors = self._dict_data.get("errors") - if not _guards.is_list_of(errors, dict): + if not guards.is_list_of(errors, dict): return _ErrorFormat.undefined elif len(errors) < 1: return _ErrorFormat.undefined @@ -324,7 +315,7 @@ def _parse_type_zero_error_format(self) -> bool: self.code = self._dict_data["code"] self.messages = [self._dict_data["message"]] self.request_id = self._dict_data.get("request_id") - if _guards.is_list_of(self._dict_data.get("errors"), dict): + if guards.is_list_of(self._dict_data.get("errors"), dict): raw_errors = self._dict_data["errors"] else: raw_errors = [self._dict_data] @@ -342,7 +333,7 @@ def _parse_undefined_error_format(self) -> bool: """ # attempt to pull out errors if possible and valid - if _guards.is_list_of(self._dict_data.get("errors"), dict): + if guards.is_list_of(self._dict_data.get("errors"), dict): raw_errors = self._dict_data["errors"] # if no 'errors' were found, or 'errors' is invalid, then # 'errors' should be set to contain the root document diff --git a/src/globus_sdk/exc/err_info.py b/src/globus_sdk/exc/err_info.py index 2846c2dea..cd3bb8f9b 100644 --- a/src/globus_sdk/exc/err_info.py +++ b/src/globus_sdk/exc/err_info.py @@ -3,7 +3,7 @@ import logging import typing as t -from globus_sdk import _guards +from globus_sdk._internal import guards log = logging.getLogger(__name__) @@ -100,7 +100,7 @@ def _parse_session_required_identities( self, data: dict[str, t.Any] ) -> list[str] | None: session_required_identities = data.get("session_required_identities") - if _guards.is_list_of(session_required_identities, str): + if guards.is_list_of(session_required_identities, str): return session_required_identities elif session_required_identities is not None: self._warn_type( @@ -114,7 +114,7 @@ def _parse_session_required_single_domain( self, data: dict[str, t.Any] ) -> list[str] | None: session_required_single_domain = data.get("session_required_single_domain") - if _guards.is_list_of(session_required_single_domain, str): + if guards.is_list_of(session_required_single_domain, str): return session_required_single_domain elif session_required_single_domain is not None: self._warn_type( @@ -130,7 +130,7 @@ def _parse_session_required_policies( session_required_policies = data.get("session_required_policies") if isinstance(session_required_policies, str): return session_required_policies.split(",") - elif _guards.is_list_of(session_required_policies, str): + elif guards.is_list_of(session_required_policies, str): return session_required_policies elif session_required_policies is not None: self._warn_type( @@ -172,7 +172,7 @@ def __init__(self, error_data: dict[str, t.Any]) -> None: self._has_data = has_code and bool(self.required_scopes) def _parse_required_scopes(self, data: dict[str, t.Any]) -> list[str]: - if _guards.is_list_of(data.get("required_scopes"), str): + if guards.is_list_of(data.get("required_scopes"), str): return t.cast("list[str]", data["required_scopes"]) elif isinstance(data.get("required_scope"), str): return [data["required_scope"]] diff --git a/src/globus_sdk/exc/warnings.py b/src/globus_sdk/exc/warnings.py index 1050c8499..60b1e99c7 100644 --- a/src/globus_sdk/exc/warnings.py +++ b/src/globus_sdk/exc/warnings.py @@ -3,14 +3,14 @@ import warnings -class RemovedInV4Warning(DeprecationWarning): +class RemovedInV5Warning(DeprecationWarning): """ This warning indicates that a feature or usage was detected which will be - unsupported in globus-sdk version 4. + unsupported in globus-sdk version 5. Users are encouraged to resolve these warnings when possible. """ def warn_deprecated(message: str, stacklevel: int = 2) -> None: - warnings.warn(message, RemovedInV4Warning, stacklevel=stacklevel) + warnings.warn(message, RemovedInV5Warning, stacklevel=stacklevel) diff --git a/src/globus_sdk/experimental/auth_requirements_error.py b/src/globus_sdk/experimental/auth_requirements_error.py deleted file mode 100644 index 3a6521de9..000000000 --- a/src/globus_sdk/experimental/auth_requirements_error.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -import sys -import typing as t - -__all__ = ( - "GlobusAuthRequirementsError", - "GlobusAuthorizationParameters", - "to_auth_requirements_error", - "to_auth_requirements_errors", - "is_auth_requirements_error", - "has_auth_requirements_errors", -) - -# legacy aliases -# (when accessed, these will emit deprecation warnings) -if t.TYPE_CHECKING: - from globus_sdk.gare import GARE as GlobusAuthRequirementsError - from globus_sdk.gare import GlobusAuthorizationParameters - from globus_sdk.gare import has_gares as has_auth_requirements_errors - from globus_sdk.gare import is_gare as is_auth_requirements_error - from globus_sdk.gare import to_gare as to_auth_requirements_error - from globus_sdk.gare import to_gares as to_auth_requirements_errors -else: - - _RENAMES = { - "GlobusAuthRequirementsError": "GARE", - "to_auth_requirements_error": "to_gare", - "to_auth_requirements_errors": "to_gares", - "is_auth_requirements_error": "is_gare", - "has_auth_requirements_errors": "has_gares", - } - - def __getattr__(name: str) -> t.Any: - import globus_sdk.gare as gare_module - from globus_sdk.exc import warn_deprecated - - new_name = _RENAMES.get(name, name) - - warn_deprecated( - "'globus_sdk.experimental.auth_requirements_error' has been renamed to " - "'globus_sdk.gare'. " - f"Importing '{name}' from `globus_sdk.experimental` is deprecated. " - f"Use `globus_sdk.gare.{new_name}` instead." - ) - - value = getattr(gare_module, new_name, None) - if value is None: - raise AttributeError(f"module {__name__} has no attribute {name}") - setattr(sys.modules[__name__], name, value) - return value diff --git a/src/globus_sdk/experimental/consents.py b/src/globus_sdk/experimental/consents.py deleted file mode 100644 index 85cc295db..000000000 --- a/src/globus_sdk/experimental/consents.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -import sys -import typing as t - -__all__ = ( - "Consent", - "ConsentTree", - "ConsentForest", - "ConsentParseError", - "ConsentTreeConstructionError", -) - -# legacy aliases -# (when accessed, these will emit deprecation warnings in a future release) -if t.TYPE_CHECKING: - from globus_sdk.scopes.consents import ( - Consent, - ConsentForest, - ConsentParseError, - ConsentTree, - ConsentTreeConstructionError, - ) -else: - - def __getattr__(name: str) -> t.Any: - import globus_sdk.scopes.consents as consents_module - from globus_sdk.exc import warn_deprecated - - warn_deprecated( - "'globus_sdk.experimental.consents' has been renamed to " - "'globus_sdk.scopes.consents'. " - f"Importing '{name}' from `globus_sdk.experimental` is deprecated." - ) - value = getattr(consents_module, name, None) - if value is None: - raise AttributeError(f"module {__name__} has no attribute {name}") - setattr(sys.modules[__name__], name, value) - return value diff --git a/src/globus_sdk/experimental/globus_app.py b/src/globus_sdk/experimental/globus_app.py deleted file mode 100644 index edaa3d61c..000000000 --- a/src/globus_sdk/experimental/globus_app.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -import sys -import typing as t - -__all__ = ( - "GlobusApp", - "UserApp", - "ClientApp", - "GlobusAppConfig", - "TokenValidationErrorHandler", -) - -# legacy aliases -# (when accessed, these will emit deprecation warnings) -if t.TYPE_CHECKING: - from globus_sdk.globus_app import ( - ClientApp, - GlobusApp, - GlobusAppConfig, - TokenValidationErrorHandler, - UserApp, - ) -else: - - def __getattr__(name: str) -> t.Any: - import globus_sdk.globus_app as globus_app_module - from globus_sdk.exc import warn_deprecated - - # Certain GlobusApp constructs are exposed from `globus_sdk`. - new_import_path = f"globus_sdk.globus_app.{name}" - if name in ("GlobusApp", "UserApp", "ClientApp", "GlobusAppConfig"): - new_import_path = f"globus_sdk.{name}" - - warn_deprecated( - "'globus_sdk.experimental.globus_app' has been renamed to " - "'globus_sdk.globus_app'. " - f"Importing '{name}' from `globus_sdk.experimental` is deprecated. " - f"Use `{new_import_path}` instead." - ) - - value = getattr(globus_app_module, name, None) - if value is None: - raise AttributeError(f"module {__name__} has no attribute {name}") - setattr(sys.modules[__name__], name, value) - return value diff --git a/src/globus_sdk/experimental/login_flow_manager.py b/src/globus_sdk/experimental/login_flow_manager.py deleted file mode 100644 index c4edd67a4..000000000 --- a/src/globus_sdk/experimental/login_flow_manager.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -import sys -import typing as t - -__all__ = ( - "CommandLineLoginFlowManager", - "LocalServerLoginFlowManager", - "LoginFlowManager", -) - -# legacy aliases -# (when accessed, these will emit deprecation warnings) -if t.TYPE_CHECKING: - from globus_sdk.login_flows import ( - CommandLineLoginFlowManager, - LocalServerLoginFlowManager, - LoginFlowManager, - ) -else: - - def __getattr__(name: str) -> t.Any: - import globus_sdk.login_flows as login_flows_module - from globus_sdk.exc import warn_deprecated - - warn_deprecated( - "'globus_sdk.experimental.login_flow_manager' has been renamed to " - "'globus_sdk.login_flows'. " - f"Importing '{name}' from `globus_sdk.experimental` is deprecated. " - f"Use `globus_sdk.login_flows.{name}` instead." - ) - - value = getattr(login_flows_module, name, None) - if value is None: - raise AttributeError(f"module {__name__} has no attribute {name}") - setattr(sys.modules[__name__], name, value) - return value diff --git a/src/globus_sdk/experimental/scope_parser.py b/src/globus_sdk/experimental/scope_parser.py deleted file mode 100644 index fc62228e7..000000000 --- a/src/globus_sdk/experimental/scope_parser.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -Scope parsing has been moved out of experimental into the main SDK. -This module will be removed in a future release but is maintained in the interim for - backwards compatibility. -""" - -from __future__ import annotations - -import sys -import typing as t - -__all__ = ( - "Scope", - "ScopeParseError", - "ScopeCycleError", -) - -if t.TYPE_CHECKING: - from globus_sdk.scopes import Scope, ScopeCycleError, ScopeParseError -else: - - def __getattr__(name: str) -> t.Any: - import globus_sdk.scopes as scopes_module - from globus_sdk.exc import warn_deprecated - - warn_deprecated( - "'globus_sdk.experimental.scope_parser' has been merged into " - "'globus_sdk.scopes'. " - f"Importing '{name}' from `globus_sdk.experimental` is deprecated. " - f"Use `globus_sdk.scopes.{name}` instead." - ) - - value = getattr(scopes_module, name, None) - if value is None: - raise AttributeError(f"module {__name__} has no attribute {name}") - setattr(sys.modules[__name__], name, value) - return value diff --git a/src/globus_sdk/experimental/tokenstorage.py b/src/globus_sdk/experimental/tokenstorage.py deleted file mode 100644 index 51b0a11ce..000000000 --- a/src/globus_sdk/experimental/tokenstorage.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -import sys -import typing as t - -__all__ = ( - "JSONTokenStorage", - "SQLiteTokenStorage", - "TokenStorage", - "FileTokenStorage", - "MemoryTokenStorage", - "TokenStorageData", -) - -# legacy aliases -# (when accessed, these will emit deprecation warnings) -if t.TYPE_CHECKING: - from globus_sdk.tokenstorage import ( - FileTokenStorage, - JSONTokenStorage, - MemoryTokenStorage, - SQLiteTokenStorage, - TokenStorage, - TokenStorageData, - ) -else: - - def __getattr__(name: str) -> t.Any: - import globus_sdk.tokenstorage as tokenstorage_module - from globus_sdk.exc import warn_deprecated - - warn_deprecated( - "'globus_sdk.experimental.tokenstorage' has been renamed to " - "'globus_sdk.tokenstorage'. " - f"Importing '{name}' from `globus_sdk.experimental` is deprecated. " - f"Use `globus_sdk.tokenstorage.{name}` instead." - ) - - value = getattr(tokenstorage_module, name, None) - if value is None: - raise AttributeError(f"module {__name__} has no attribute {name}") - setattr(sys.modules[__name__], name, value) - return value diff --git a/src/globus_sdk/gare/_auth_requirements_error.py b/src/globus_sdk/gare/_auth_requirements_error.py index 9082aff60..7a798d2be 100644 --- a/src/globus_sdk/gare/_auth_requirements_error.py +++ b/src/globus_sdk/gare/_auth_requirements_error.py @@ -2,8 +2,8 @@ import typing as t -from globus_sdk._guards import validators -from globus_sdk._serializable import Serializable +from globus_sdk._internal.guards import validators +from globus_sdk._internal.serializable import Serializable class GlobusAuthorizationParameters(Serializable): diff --git a/src/globus_sdk/gare/_variants.py b/src/globus_sdk/gare/_variants.py index 8ea9053c4..3d4fc9d92 100644 --- a/src/globus_sdk/gare/_variants.py +++ b/src/globus_sdk/gare/_variants.py @@ -3,8 +3,8 @@ import typing as t from globus_sdk import exc -from globus_sdk._guards import validators -from globus_sdk._serializable import Serializable +from globus_sdk._internal.guards import validators +from globus_sdk._internal.serializable import Serializable from ._auth_requirements_error import GARE, GlobusAuthorizationParameters diff --git a/src/globus_sdk/globus_app/app.py b/src/globus_sdk/globus_app/app.py index 466aafd9c..ce6801cc2 100644 --- a/src/globus_sdk/globus_app/app.py +++ b/src/globus_sdk/globus_app/app.py @@ -11,13 +11,11 @@ AuthLoginClient, GlobusSDKUsageError, IDTokenDecoder, - Scope, ) -from globus_sdk._types import ScopeCollectionType from globus_sdk.authorizers import GlobusAuthorizer from globus_sdk.gare import GlobusAuthorizationParameters -from globus_sdk.scopes import AuthScopes, scopes_to_scope_list -from globus_sdk.tokenstorage import ( +from globus_sdk.scopes import AuthScopes, Scope, ScopeParser +from globus_sdk.token_storage import ( ScopeRequirementsValidator, TokenStorage, TokenValidationError, @@ -68,7 +66,9 @@ def __init__( login_client: AuthLoginClient | None = None, client_id: uuid.UUID | str | None = None, client_secret: str | None = None, - scope_requirements: t.Mapping[str, ScopeCollectionType] | None = None, + scope_requirements: ( + t.Mapping[str, str | Scope | t.Iterable[str | Scope]] | None + ) = None, config: GlobusAppConfig = DEFAULT_CONFIG, ) -> None: self.app_name = app_name @@ -118,16 +118,19 @@ def __init__( # # additionally, this will ensure that openid scope requirement is always # registered (it's required for token identity validation). - consent_client.attach_globus_app(self, app_scopes=[Scope(AuthScopes.openid)]) + consent_client.attach_globus_app(self, app_scopes=[AuthScopes.openid]) def _resolve_scope_requirements( - self, scope_requirements: t.Mapping[str, ScopeCollectionType] | None + self, + scope_requirements: ( + t.Mapping[str, str | Scope | t.Iterable[str | Scope]] | None + ), ) -> dict[str, list[Scope]]: if scope_requirements is None: return {} return { - resource_server: scopes_to_scope_list(scopes) + resource_server: list(self._iter_scopes(scopes)) for resource_server, scopes in scope_requirements.items() } @@ -382,12 +385,14 @@ def _auth_params_with_required_scopes( auth_params = GlobusAuthorizationParameters() parsed_required_scopes = [] for s in auth_params.required_scopes or []: - parsed_required_scopes.extend(Scope.parse(s)) + parsed_required_scopes.extend(ScopeParser.parse(s)) # merge scopes for deduplication to minimize url request length # this is useful even if there weren't any auth_param scope requirements # as the app's scope_requirements can have duplicates - combined_scopes = Scope.merge_scopes(required_scopes, parsed_required_scopes) + combined_scopes = ScopeParser.merge_scopes( + required_scopes, parsed_required_scopes + ) auth_params.required_scopes = [str(s) for s in combined_scopes] return auth_params @@ -408,7 +413,7 @@ def _disabled_token_validation_error_handler(self) -> t.Iterator[None]: self._token_validation_error_handling_enabled = initial_val def add_scope_requirements( - self, scope_requirements: t.Mapping[str, ScopeCollectionType] + self, scope_requirements: t.Mapping[str, str | Scope | t.Iterable[str | Scope]] ) -> None: """ Add given scope requirements to the app's scope requirements. Any duplicate @@ -419,7 +424,7 @@ def add_scope_requirements( """ for resource_server, scopes in scope_requirements.items(): curr = self._scope_requirements.setdefault(resource_server, []) - curr.extend(scopes_to_scope_list(scopes)) + curr.extend(self._iter_scopes(scopes)) self._authorizer_factory.clear_cache(*scope_requirements.keys()) @@ -457,3 +462,18 @@ def scope_requirements(self) -> dict[str, list[Scope]]: """ # Scopes are mutable objects so we return a deepcopy return copy.deepcopy(self._scope_requirements) + + def _iter_scopes( + self, scopes: str | Scope | t.Iterable[str | Scope] + ) -> t.Iterator[Scope]: + """Normalize scopes in various formats to an iterator of Scope objects.""" + if isinstance(scopes, str): + yield from ScopeParser.parse(scopes) + elif isinstance(scopes, Scope): + yield scopes + else: + for item in scopes: + if isinstance(item, str): + yield from ScopeParser.parse(item) + else: + yield item diff --git a/src/globus_sdk/globus_app/authorizer_factory.py b/src/globus_sdk/globus_app/authorizer_factory.py index 173a69614..5ed78d4ad 100644 --- a/src/globus_sdk/globus_app/authorizer_factory.py +++ b/src/globus_sdk/globus_app/authorizer_factory.py @@ -12,8 +12,8 @@ RefreshTokenAuthorizer, ) from globus_sdk.services.auth import OAuthTokenResponse -from globus_sdk.tokenstorage import ValidatingTokenStorage -from globus_sdk.tokenstorage.v2.validating_token_storage import MissingTokenError +from globus_sdk.token_storage import ValidatingTokenStorage +from globus_sdk.token_storage.validating_token_storage import MissingTokenError GA = t.TypeVar("GA", bound=GlobusAuthorizer) diff --git a/src/globus_sdk/globus_app/client_app.py b/src/globus_sdk/globus_app/client_app.py index 2bda06281..4718099c0 100644 --- a/src/globus_sdk/globus_app/client_app.py +++ b/src/globus_sdk/globus_app/client_app.py @@ -1,10 +1,11 @@ from __future__ import annotations +import typing as t import uuid from globus_sdk import AuthLoginClient, ConfidentialAppAuthClient, GlobusSDKUsageError -from globus_sdk._types import ScopeCollectionType from globus_sdk.gare import GlobusAuthorizationParameters +from globus_sdk.scopes import Scope from .app import GlobusApp from .authorizer_factory import ClientCredentialsAuthorizerFactory @@ -59,7 +60,9 @@ def __init__( login_client: ConfidentialAppAuthClient | None = None, client_id: uuid.UUID | str | None = None, client_secret: str | None = None, - scope_requirements: dict[str, ScopeCollectionType] | None = None, + scope_requirements: ( + dict[str, str | Scope | t.Iterable[str | Scope]] | None + ) = None, config: GlobusAppConfig = DEFAULT_CONFIG, ) -> None: if config.login_flow_manager is not None: @@ -119,6 +122,10 @@ def _run_login_flow( only the required_scopes parameter is used. """ auth_params = self._auth_params_with_required_scopes(auth_params) + if not auth_params.required_scopes: + raise GlobusSDKUsageError( + "A ClientApp cannot get tokens without configured required scopes." + ) token_response = self._login_client.oauth2_client_credentials_tokens( requested_scopes=auth_params.required_scopes ) diff --git a/src/globus_sdk/globus_app/config.py b/src/globus_sdk/globus_app/config.py index 778013150..8c713ee1b 100644 --- a/src/globus_sdk/globus_app/config.py +++ b/src/globus_sdk/globus_app/config.py @@ -10,14 +10,14 @@ LocalServerLoginFlowManager, LoginFlowManager, ) -from globus_sdk.tokenstorage import ( +from globus_sdk.token_storage import ( JSONTokenStorage, MemoryTokenStorage, SQLiteTokenStorage, TokenStorage, TokenValidationError, ) -from globus_sdk.tokenstorage.v2.validating_token_storage import IdentityMismatchError +from globus_sdk.token_storage.validating_token_storage import IdentityMismatchError from .protocols import ( IDTokenDecoderProvider, @@ -71,9 +71,9 @@ class GlobusAppConfig: :ivar str | ``TokenStorage`` | ``TokenStorageProvider`` token_storage: A class responsible for storing and retrieving tokens. This may be either a well-known provider (one of - :class:`"json" `, - :class:`"sqlite" `, or - :class:`"memory" `) or a custom + :class:`"json" `, + :class:`"sqlite" `, or + :class:`"memory" `) or a custom storage/provider. Default: ``"json"``. :ivar str | ``LoginFlowManager`` | ``LoginFlowManagerProvider`` login_flow_manager: diff --git a/src/globus_sdk/globus_app/protocols.py b/src/globus_sdk/globus_app/protocols.py index 006d363b4..615aa963b 100644 --- a/src/globus_sdk/globus_app/protocols.py +++ b/src/globus_sdk/globus_app/protocols.py @@ -6,7 +6,7 @@ if t.TYPE_CHECKING: from globus_sdk import AuthLoginClient, IDTokenDecoder from globus_sdk.login_flows import LoginFlowManager - from globus_sdk.tokenstorage import TokenStorage, TokenValidationError + from globus_sdk.token_storage import TokenStorage, TokenValidationError from .app import GlobusApp from .config import GlobusAppConfig diff --git a/src/globus_sdk/globus_app/user_app.py b/src/globus_sdk/globus_app/user_app.py index 5bc21b8e6..f7bd4731f 100644 --- a/src/globus_sdk/globus_app/user_app.py +++ b/src/globus_sdk/globus_app/user_app.py @@ -11,10 +11,9 @@ NativeAppAuthClient, Scope, ) -from globus_sdk._types import ScopeCollectionType from globus_sdk.gare import GlobusAuthorizationParameters from globus_sdk.login_flows import CommandLineLoginFlowManager, LoginFlowManager -from globus_sdk.tokenstorage import ( +from globus_sdk.token_storage import ( HasRefreshTokensValidator, NotExpiredValidator, TokenStorage, @@ -81,7 +80,9 @@ def __init__( login_client: AuthLoginClient | None = None, client_id: uuid.UUID | str | None = None, client_secret: str | None = None, - scope_requirements: t.Mapping[str, ScopeCollectionType] | None = None, + scope_requirements: ( + t.Mapping[str, str | Scope | t.Iterable[str | Scope]] | None + ) = None, config: GlobusAppConfig = DEFAULT_CONFIG, ) -> None: super().__init__( diff --git a/src/globus_sdk/login_flows/command_line_login_flow_manager.py b/src/globus_sdk/login_flows/command_line_login_flow_manager.py index 26a2ae097..5fc42564b 100644 --- a/src/globus_sdk/login_flows/command_line_login_flow_manager.py +++ b/src/globus_sdk/login_flows/command_line_login_flow_manager.py @@ -5,9 +5,9 @@ from contextlib import contextmanager import globus_sdk +from globus_sdk._internal.utils import get_nice_hostname from globus_sdk.exc.base import GlobusError from globus_sdk.gare import GlobusAuthorizationParameters -from globus_sdk.utils import get_nice_hostname from .login_flow_manager import LoginFlowManager diff --git a/src/globus_sdk/login_flows/local_server_login_flow_manager/local_server_login_flow_manager.py b/src/globus_sdk/login_flows/local_server_login_flow_manager/local_server_login_flow_manager.py index 0bee663ed..3b5da3378 100644 --- a/src/globus_sdk/login_flows/local_server_login_flow_manager/local_server_login_flow_manager.py +++ b/src/globus_sdk/login_flows/local_server_login_flow_manager/local_server_login_flow_manager.py @@ -8,9 +8,9 @@ from string import Template import globus_sdk +from globus_sdk._internal.utils import get_nice_hostname from globus_sdk.gare import GlobusAuthorizationParameters from globus_sdk.login_flows.login_flow_manager import LoginFlowManager -from globus_sdk.utils import get_nice_hostname from .errors import LocalServerEnvironmentalLoginError, LocalServerLoginError from .local_server import DEFAULT_HTML_TEMPLATE, RedirectHandler, RedirectHTTPServer diff --git a/src/globus_sdk/login_flows/login_flow_manager.py b/src/globus_sdk/login_flows/login_flow_manager.py index 79e481f4c..09e791a01 100644 --- a/src/globus_sdk/login_flows/login_flow_manager.py +++ b/src/globus_sdk/login_flows/login_flow_manager.py @@ -3,6 +3,7 @@ import abc import globus_sdk +from globus_sdk._missing import none2missing from globus_sdk.gare import GlobusAuthorizationParameters @@ -51,14 +52,22 @@ def _get_authorize_url( """ self._oauth2_start_flow(auth_parameters, redirect_uri) - session_required_single_domain = auth_parameters.session_required_single_domain + # prompt is assigned first because its usage is type-ignored below + # this makes it clear that the ignore applies to that usage + prompt = none2missing(auth_parameters.prompt) return self.login_client.oauth2_get_authorize_url( - session_required_identities=auth_parameters.session_required_identities, - session_required_single_domain=session_required_single_domain, - session_required_policies=auth_parameters.session_required_policies, - session_required_mfa=auth_parameters.session_required_mfa, - session_message=auth_parameters.session_message, - prompt=auth_parameters.prompt, # type: ignore + session_required_identities=none2missing( + auth_parameters.session_required_identities + ), + session_required_single_domain=none2missing( + auth_parameters.session_required_single_domain + ), + session_required_policies=none2missing( + auth_parameters.session_required_policies + ), + session_required_mfa=none2missing(auth_parameters.session_required_mfa), + session_message=none2missing(auth_parameters.session_message), + prompt=prompt, # type: ignore[arg-type] ) def _oauth2_start_flow( @@ -70,6 +79,12 @@ def _oauth2_start_flow( """ login_client = self.login_client requested_scopes = auth_parameters.required_scopes + if not requested_scopes: + raise globus_sdk.GlobusSDKUsageError( + f"{type(self).__name__} cannot start a login flow without scopes " + "in the authorization parameters." + ) + # Native and Confidential App clients have different signatures for this method, # so they must be type checked & called independently. if isinstance(login_client, globus_sdk.NativeAppAuthClient): @@ -77,7 +92,7 @@ def _oauth2_start_flow( requested_scopes, redirect_uri=redirect_uri, refresh_tokens=self.request_refresh_tokens, - prefill_named_grant=self.native_prefill_named_grant, + prefill_named_grant=none2missing(self.native_prefill_named_grant), ) elif isinstance(login_client, globus_sdk.ConfidentialAppAuthClient): login_client.oauth2_start_flow( diff --git a/src/globus_sdk/response.py b/src/globus_sdk/response.py index ea2359ff0..a5c282c44 100644 --- a/src/globus_sdk/response.py +++ b/src/globus_sdk/response.py @@ -5,7 +5,7 @@ import logging import typing as t -from globus_sdk import _guards +from globus_sdk._internal import guards log = logging.getLogger(__name__) @@ -145,7 +145,7 @@ def get(self, key: str, default: t.Any = None) -> t.Any: :param key: The string key to lookup in the response if it is a dict :param default: The default value to be used if the data is null or a list """ - if _guards.is_optional(self.data, list): + if guards.is_optional(self.data, list): return default # NB: `default` is provided as a positional because the native dict type # doesn't recognize a keyword argument `default` diff --git a/src/globus_sdk/scopes/__init__.py b/src/globus_sdk/scopes/__init__.py index bfd6b04a2..8bd7ccacc 100644 --- a/src/globus_sdk/scopes/__init__.py +++ b/src/globus_sdk/scopes/__init__.py @@ -1,59 +1,38 @@ -import sys -import typing as t - -from ._normalize import scopes_to_scope_list, scopes_to_str -from .builder import ScopeBuilder +from .collection import DynamicScopeCollection, ScopeCollection, StaticScopeCollection from .data import ( AuthScopes, ComputeScopes, FlowsScopes, - GCSCollectionScopeBuilder, - GCSEndpointScopeBuilder, + GCSCollectionScopes, + GCSEndpointScopes, GroupsScopes, NexusScopes, SearchScopes, - SpecificFlowScopeBuilder, + SpecificFlowScopes, TimersScopes, TransferScopes, ) from .errors import ScopeCycleError, ScopeParseError +from .parser import ScopeParser from .representation import Scope -from .scope_definition import MutableScope __all__ = ( - "ScopeBuilder", - "MutableScope", + "ScopeCollection", + "StaticScopeCollection", + "DynamicScopeCollection", "Scope", + "ScopeParser", "ScopeParseError", "ScopeCycleError", - "GCSCollectionScopeBuilder", - "GCSEndpointScopeBuilder", + "GCSCollectionScopes", + "GCSEndpointScopes", "AuthScopes", "ComputeScopes", "FlowsScopes", - "SpecificFlowScopeBuilder", + "SpecificFlowScopes", "GroupsScopes", "NexusScopes", "SearchScopes", - "TimerScopes", "TimersScopes", "TransferScopes", - "scopes_to_str", - "scopes_to_scope_list", ) - - -if t.TYPE_CHECKING: - TimerScopes = TimersScopes -else: - - def __getattr__(name: str) -> t.Any: - from globus_sdk.exc import warn_deprecated - - if name == "TimerScopes": - warn_deprecated( - "'TimerScopes' is a deprecated name. Use 'TimersScopes' instead." - ) - setattr(sys.modules[__name__], name, TimersScopes) - return TimersScopes - raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/src/globus_sdk/scopes/_parser.py b/src/globus_sdk/scopes/_graph_parser.py similarity index 79% rename from src/globus_sdk/scopes/_parser.py rename to src/globus_sdk/scopes/_graph_parser.py index 8d05fc0b9..86bf06e57 100644 --- a/src/globus_sdk/scopes/_parser.py +++ b/src/globus_sdk/scopes/_graph_parser.py @@ -1,5 +1,7 @@ from __future__ import annotations +import dataclasses +import sys import typing as t from collections import defaultdict, deque @@ -9,6 +11,159 @@ SPECIAL_TOKENS = set("[]*") +class ScopeGraph: + def __init__(self) -> None: + self.top_level_scopes: set[tuple[str, bool]] = set() + self.nodes: set[str] = set() + self.edges: set[tuple[str, str, bool]] = set() + self.adjacency_matrix: dict[str, set[tuple[str, str, bool]]] = defaultdict(set) + + def breadth_first_walk(self) -> t.Iterator[tuple[str, bool]]: + """ + Do a BFS across the forest, returning nodes (as tuples) in BFS order. + + For inspection of the graph after parsing. + """ + bfs_queue: t.Deque[tuple[str, bool]] = deque(self.top_level_scopes) + + while bfs_queue: + yield (current := bfs_queue.popleft()) + + edges = self.adjacency_matrix[current[0]] + for _, dest, optional in edges: + bfs_queue.append((dest, optional)) + + def add_edge(self, src: str, dest: str, optional: bool) -> None: + self.edges.add((src, dest, optional)) + self.adjacency_matrix[src].add((src, dest, optional)) + + def _normalize_optionals(self) -> None: + to_remove: set[tuple[str, str, bool]] = set() + for edge in self.edges: + src, dest, optional = edge + if not optional: + continue + # The current edge is optional; see if it's superseded by required edge + required_variant = (src, dest, False) + if required_variant in self.edges: + to_remove.add(edge) + self.edges = self.edges - to_remove + for edge in to_remove: + src, _, _ = edge + self.adjacency_matrix[src].remove(edge) + + def _check_cycles(self) -> None: + # explore the graph using an iterative Depth-First Search + # as we explore the graph, keep track of paths of ancestry being explored + # if we ever find a back-edge along one of those paths of ancestry, that + # means that there must be a cycle + + # start from the top-level nodes (which we know to be the roots of this + # forest-shaped graph) + # we will track this as the set of paths to continue to branch and explore in a + # stack and pop from it until it is empty, thus implementing DFS + # + # conceptually, the paths could be implemented as `list[str]`, which would + # preserve the order in which we encountered each node. Using a set is a + # micro-optimization which makes checks faster, since we only care to detect + # *that* there was a cycle, not what the shape of that cycle was + paths_to_explore: list[tuple[set[str], str]] = [ + ({node}, node) for node, _ in self.top_level_scopes + ] + + while paths_to_explore: + path, terminus = paths_to_explore.pop() + + # get out-edges from the last node in the path + children = self.adjacency_matrix[terminus] + + # if the node was a leaf, no children, we are done exploring this path + if not children: + continue + + # for each child edge, do two basic things: + # - check if we found a back-edge (cycle!) + # - create a new path to explore, with the child node as its current + # terminus + for edge in children: + _, dest, _ = edge + if dest in path: + raise ScopeCycleError(f"A cycle was found involving '{dest}'") + paths_to_explore.append((path.union((dest,)), dest)) + + def __str__(self) -> str: + lines = ["digraph scopes {", ' rankdir="LR";', ""] + for node, optional in self.top_level_scopes: + lines.append(f" {'*' if optional else ''}{node}") + lines.append("") + + # do two passes to put all non-optional edges first + for source, dest, optional in self.edges: + if optional: + continue + lines.append(f" {source} -> {dest};") + for source, dest, optional in self.edges: + if not optional: + continue + lines.append(f' {source} -> {dest} [ label = "optional" ];') + lines.append("") + lines.append("}") + return "\n".join(lines) + + @classmethod + def parse(cls, scopes: str) -> ScopeGraph: + trees = ScopeTreeNode.parse(scopes) + graph = cls._convert_trees(trees) + graph._normalize_optionals() + graph._check_cycles() + return graph + + @classmethod + def _convert_trees(cls, trees: list[ScopeTreeNode]) -> ScopeGraph: + graph = ScopeGraph() + node_queue: t.Deque[ScopeTreeNode] = deque() + + for tree_node in trees: + node_queue.append(tree_node) + graph.top_level_scopes.add((tree_node.scope_string, tree_node.optional)) + + while node_queue: + tree_node = node_queue.pop() + scope_string = tree_node.scope_string + graph.nodes.add(scope_string) + for dep in tree_node.dependencies: + node_queue.append(dep) + graph.add_edge(scope_string, dep.scope_string, dep.optional) + + return graph + + +# pass slots=True on 3.10+ +# it's not strictly necessary, but it improves performance +if sys.version_info >= (3, 10): + _add_dataclass_kwargs: dict[str, bool] = {"slots": True} +else: + _add_dataclass_kwargs: dict[str, bool] = {} + + +@dataclasses.dataclass(**_add_dataclass_kwargs) +class ScopeTreeNode: + # + # This is an intermediate representation for scope parsing. + # + scope_string: str + optional: bool + dependencies: list[ScopeTreeNode] = dataclasses.field(default_factory=list) + + def add_dependency(self, subtree: ScopeTreeNode) -> None: + self.dependencies.append(subtree) + + @staticmethod + def parse(scope_string: str) -> list[ScopeTreeNode]: + tokens = _tokenize(scope_string) + return _parse_tokens(tokens) + + def _tokenize(scope_string: str) -> list[str]: tokens: list[str] = [] start = 0 @@ -105,140 +260,6 @@ def _reject_bad_adjacent_tokens(current_token: str, next_token: str | None) -> N raise ScopeParseError("found double left-bracket") -class ScopeTreeNode: - # - # This is an intermediate representation for scope parsing. - # - def __init__( - self, - scope_string: str, - *, - optional: bool, - ) -> None: - self.scope_string = scope_string - self.optional = optional - self.dependencies: list[ScopeTreeNode] = [] - - def add_dependency(self, subtree: ScopeTreeNode) -> None: - self.dependencies.append(subtree) - - def __repr__(self) -> str: - parts: list[str] = [f"'{self.scope_string}'"] - if self.optional: - parts.append("optional=True") - if self.dependencies: - parts.append(f"dependencies={self.dependencies!r}") - return "ScopeTreeNode(" + ", ".join(parts) + ")" - - @staticmethod - def parse(scope_string: str) -> list[ScopeTreeNode]: - tokens = _tokenize(scope_string) - return _parse_tokens(tokens) - - -class ScopeGraph: - def __init__(self) -> None: - self.top_level_scopes: set[tuple[str, bool]] = set() - self.nodes: set[str] = set() - self.edges: set[tuple[str, str, bool]] = set() - self.adjacency_matrix: dict[str, set[tuple[str, str, bool]]] = defaultdict(set) - - def add_edge(self, src: str, dest: str, optional: bool) -> None: - self.edges.add((src, dest, optional)) - self.adjacency_matrix[src].add((src, dest, optional)) - - def _normalize_optionals(self) -> None: - to_remove: set[tuple[str, str, bool]] = set() - for edge in self.edges: - src, dest, optional = edge - if not optional: - continue - alter_ego = (src, dest, not optional) - if alter_ego in self.edges: - to_remove.add(edge) - self.edges = self.edges - to_remove - for edge in to_remove: - src, _, _ = edge - self.adjacency_matrix[src].remove(edge) - - def _check_cycles(self) -> None: - # explore the graph using an iterative Depth-First Search - # as we explore the graph, keep track of paths of ancestry being explored - # if we ever find a back-edge along one of those paths of ancestry, that - # means that there must be a cycle - - # start from the top-level nodes (which we know to be the roots of this - # forest-shaped graph) - # we will track this as the set of paths to continue to branch and explore in a - # stack and pop from it until it is empty, thus implementing DFS - # - # conceptually, the paths could be implemented as `list[str]`, which would - # preserve the order in which we encountered each node. Using a set is a - # micro-optimization which makes checks faster, since we only care to detect - # *that* there was a cycle, not what the shape of that cycle was - paths_to_explore: list[tuple[set[str], str]] = [ - ({node}, node) for node, _ in self.top_level_scopes - ] - - while paths_to_explore: - path, terminus = paths_to_explore.pop() - - # get out-edges from the last node in the path - children = self.adjacency_matrix[terminus] - - # if the node was a leaf, no children, we are done exploring this path - if not children: - continue - - # for each child edge, do two basic things: - # - check if we found a back-edge (cycle!) - # - create a new path to explore, with the child node as its current - # terminus - for edge in children: - _, dest, _ = edge - if dest in path: - raise ScopeCycleError(f"A cycle was found involving '{dest}'") - paths_to_explore.append((path.union((dest,)), dest)) - - def __str__(self) -> str: - lines = ["digraph scopes {", ' rankdir="LR";', ""] - for node, optional in self.top_level_scopes: - lines.append(f" {'*' if optional else ''}{node}") - lines.append("") - - # do two passes to put all non-optional edges first - for source, dest, optional in self.edges: - if optional: - continue - lines.append(f" {source} -> {dest};") - for source, dest, optional in self.edges: - if not optional: - continue - lines.append(f' {source} -> {dest} [ label = "optional" ];') - lines.append("") - lines.append("}") - return "\n".join(lines) - - -def _convert_trees(trees: list[ScopeTreeNode]) -> ScopeGraph: - graph = ScopeGraph() - node_queue: t.Deque[ScopeTreeNode] = deque() - - for tree_node in trees: - node_queue.append(tree_node) - graph.top_level_scopes.add((tree_node.scope_string, tree_node.optional)) - - while node_queue: - tree_node = node_queue.pop() - scope_string = tree_node.scope_string - graph.nodes.add(scope_string) - for dep in tree_node.dependencies: - node_queue.append(dep) - graph.add_edge(scope_string, dep.scope_string, dep.optional) - - return graph - - def _peek_enumerate(data: str | list[str]) -> t.Iterator[tuple[int, str, str | None]]: """ An iterator producing (index, character, next_char) @@ -255,11 +276,3 @@ def _peek_enumerate(data: str | list[str]) -> t.Iterator[tuple[int, str, str | N prev = c yield (len(data) - 1, prev, None) - - -def parse_scope_graph(scopes: str) -> ScopeGraph: - trees = ScopeTreeNode.parse(scopes) - graph = _convert_trees(trees) - graph._normalize_optionals() - graph._check_cycles() - return graph diff --git a/src/globus_sdk/scopes/_normalize.py b/src/globus_sdk/scopes/_normalize.py deleted file mode 100644 index 5ee32c475..000000000 --- a/src/globus_sdk/scopes/_normalize.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -import typing as t - -from .representation import Scope -from .scope_definition import MutableScope - -if t.TYPE_CHECKING: - from globus_sdk._types import ScopeCollectionType - - -def scopes_to_str(scopes: ScopeCollectionType) -> str: - """ - Normalize a scope collection to a space-separated scope string. - - :param scopes: A scope string or object, or an iterable of scope strings or objects. - :returns: A space-separated scope string. - - Example usage: - - .. code-block:: pycon - - >>> scopes_to_str(Scope("foo")) - 'foo' - >>> scopes_to_str(Scope("foo"), "bar", MutableScope("qux")) - 'foo bar qux' - """ - scope_iter = _iter_scope_collection(scopes, split_root_scopes=False) - return " ".join(str(scope) for scope in scope_iter) - - -def scopes_to_scope_list(scopes: ScopeCollectionType) -> list[Scope]: - """ - Normalize a scope collection to a list of Scope objects. - - :param scopes: A scope string or object, or an iterable of scope strings or objects. - :returns: A list of Scope objects. - - Example usage: - - .. code-block:: pycon - - >>> scopes_to_scope_list(Scope("foo")) - [Scope('foo')] - >>> scopes_to_scope_list(Scope("foo"), "bar baz", MutableScope("qux")) - [Scope('foo'), Scope('bar'), Scope('baz'), Scope('qux')] - """ - scope_list: list[Scope] = [] - for scope in _iter_scope_collection(scopes): - if isinstance(scope, str): - scope_list.extend(Scope.parse(scope)) - elif isinstance(scope, MutableScope): - scope_list.extend(Scope.parse(str(scope))) - else: - scope_list.append(scope) - return scope_list - - -def _iter_scope_collection( - obj: ScopeCollectionType, - *, - split_root_scopes: bool = True, -) -> t.Iterator[str | MutableScope | Scope]: - """ - Provide an iterator over a scope collection type, flattening nested scope - collections as encountered. - - Collections of scope representations are yielded one at a time. - Individual scope representations are yielded as-is. - - :param obj: A scope collection or scope representation. - :param iter_scope_strings: If True, scope strings with multiple root scopes are - split. This flag allows a caller to optimize, skipping a bfs operation if - merging will be done later purely with strings. - - Example usage: - - .. code-block:: pycon - - >>> list(_iter_scope_collection("foo")) - ['foo'] - >>> list(_iter_scope_collection(Scope.parse("foo bar"), "baz qux")) - [Scope('foo'), Scope('bar'), 'baz', 'qux'] - >>> list(_iter_scope_collection("foo bar[baz qux]")) - ['foo', 'bar[baz qux]'] - >>> list(_iter_scope_collection("foo bar[baz qux]", split_root_scopes=False)) - 'foo bar[baz qux]' - """ - if isinstance(obj, str): - yield from _iter_scope_string(obj, split_root_scopes) - elif isinstance(obj, MutableScope) or isinstance(obj, Scope): - yield obj - else: - for item in obj: - yield from _iter_scope_collection(item, split_root_scopes=split_root_scopes) - - -def _iter_scope_string(scope_str: str, split_root_scopes: bool) -> t.Iterator[str]: - if not split_root_scopes or " " not in scope_str: - yield scope_str - - elif "[" not in scope_str: - yield from scope_str.split(" ") - else: - for scope_obj in Scope.parse(scope_str): - yield str(scope_obj) diff --git a/src/globus_sdk/scopes/builder.py b/src/globus_sdk/scopes/builder.py deleted file mode 100644 index c74ae30dc..000000000 --- a/src/globus_sdk/scopes/builder.py +++ /dev/null @@ -1,160 +0,0 @@ -from __future__ import annotations - -import typing as t - -from .scope_definition import MutableScope - -ScopeBuilderScopes = t.Union[ - None, - str, - t.Tuple[str, str], - t.List[t.Union[str, t.Tuple[str, str]]], -] - - -class ScopeBuilder: - """ - Utility class for creating scope strings for a specified resource server. - - :param resource_server: The identifier, usually a domain name or a UUID, for the - resource server to return scopes for. - :param known_scopes: A list of scope names to pre-populate on this instance. This - will set attributes on the instance using the URN scope format. - :param known_url_scopes: A list of scope names to pre-populate on this instance. - This will set attributes on the instance using the URL scope format. - """ - - _classattr_scope_names: list[str] = [] - - def __init__( - self, - resource_server: str, - *, - known_scopes: ScopeBuilderScopes = None, - known_url_scopes: ScopeBuilderScopes = None, - ) -> None: - self.resource_server = resource_server - - self._registered_scope_names: list[str] = [] - self._register_scopes(known_scopes, self.urn_scope_string) - self._register_scopes(known_url_scopes, self.url_scope_string) - - def _register_scopes( - self, scopes: ScopeBuilderScopes, transform_func: t.Callable[[str], str] - ) -> None: - scopes_dict = self._scopes_input_to_dict(scopes) - for scope_name, scope_val in scopes_dict.items(): - self._registered_scope_names.append(scope_name) - setattr(self, scope_name, transform_func(scope_val)) - - def _scopes_input_to_dict(self, items: ScopeBuilderScopes) -> dict[str, str]: - """ - ScopeBuilders accepts many collection-style types of scopes. This function - normalizes all of those types into a standard {scope_name: scope_val} dict - - Translation Map: - None => {} - "my-str" => {"my-str": "my-str"} - ["my-list"] => {"my-list": "my-list"} - ("my-tuple-key", "my-tuple-val") => {"my-tuple-key": "my-tuple-val"} - """ - if items is None: - return {} - elif isinstance(items, str): - return {items: items} - elif isinstance(items, tuple): - return {items[0]: items[1]} - else: - items_dict = {} - for item in items: - if isinstance(item, str): - items_dict[item] = item - else: - items_dict[item[0]] = item[1] - return items_dict - - @property - def scope_names(self) -> list[str]: - return self._classattr_scope_names + self._registered_scope_names - - # custom __getattr__ instructs `mypy` that unknown attributes of a ScopeBuilder are - # of type `str`, allowing for dynamic attribute names - # to test, try creating a module with - # - # from globus_sdk.scopes import TransferScopes - # x = TransferScopes.all - # - # without this method, the assignment to `x` would fail type checking - # because `all` is unknown to mypy - # - # note that the implementation just raises AttributeError; this is okay because - # __getattr__ is only called as a last resort, when __getattribute__ has failed - # normal attribute access will not be disrupted - def __getattr__(self, name: str) -> str: - raise AttributeError(f"Unrecognized Attribute '{name}'") - - def urn_scope_string(self, scope_name: str) -> str: - """ - Return a complete string representing the scope with a given name for this - client, in the Globus Auth URN format. - - Note that this module already provides many such scope strings for use with - Globus services. - - **Examples** - - >>> sb = ScopeBuilder("transfer.api.globus.org") - >>> sb.urn_scope_string("transfer.api.globus.org", "all") - "urn:globus:auth:scope:transfer.api.globus.org:all" - - :param scope_name: The short name for the scope involved. - """ - return f"urn:globus:auth:scope:{self.resource_server}:{scope_name}" - - def url_scope_string(self, scope_name: str) -> str: - """ - Return a complete string representing the scope with a given name for this - client, in URL format. - - **Examples** - - >>> sb = ScopeBuilder("actions.globus.org") - >>> sb.url_scope_string("actions.globus.org", "hello_world") - "https://auth.globus.org/scopes/actions.globus.org/hello_world" - - :param scope_name: The short name for the scope involved. - """ - return f"https://auth.globus.org/scopes/{self.resource_server}/{scope_name}" - - def make_mutable(self, scope: str, *, optional: bool = False) -> MutableScope: - """ - For a given scope, create a MutableScope object. - - The ``scope`` name given refers to the name of a scope attached to the - ScopeBuilder. It is given by attribute name, not by the full scope string. - - **Examples** - - Using the ``TransferScopes`` object, one could reference ``all`` as follows: - - >>> TransferScopes.all - 'urn:globus:auth:scope:transfer.api.globus.org:all' - >>> TransferScopes.make_mutable("all") - Scope('urn:globus:auth:scope:transfer.api.globus.org:all') - - This is equivalent to constructing a Scope object from the resolved - scope string, as in - - >>> Scope(TransferScopes.all) - Scope('urn:globus:auth:scope:transfer.api.globus.org:all') - - :param scope: The name of the scope to convert to a MutableScope - :param optional: If true, the created MutableScope object will be marked - optional - """ - return MutableScope(getattr(self, scope), optional=optional) - - def __str__(self) -> str: - return f"{self.__class__.__name__}[{self.resource_server}]\n" + "\n".join( - f" {name}:\n {getattr(self, name)}" for name in self.scope_names - ) diff --git a/src/globus_sdk/scopes/collection.py b/src/globus_sdk/scopes/collection.py new file mode 100644 index 000000000..0aee1d7d0 --- /dev/null +++ b/src/globus_sdk/scopes/collection.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import abc +import typing as t + +from .representation import Scope + + +class ScopeCollection(abc.ABC): + """ + The common base for scope collections. + + ScopeCollections act as namespaces with attribute access to get scopes. + + They can also be iterated to get all of their defined scopes and provide + the appropriate resource_server string for use in OAuth2 flows. + """ + + @property + @abc.abstractmethod + def resource_server(self) -> str: ... + + @abc.abstractmethod + def __iter__(self) -> t.Iterator[Scope]: ... + + +class StaticScopeCollection(ScopeCollection): + """ + A static scope collection is a data container which provides various scopes + as class attributes. + + ``resource_server`` must be available as a class attribute. + """ + + resource_server: t.ClassVar[str] + + def __iter__(self) -> t.Iterator[Scope]: + for view in (vars(self).values(), vars(self.__class__).values()): + for value in view: + if isinstance(value, Scope): + yield value + + +class DynamicScopeCollection(ScopeCollection): + """ + The base type for dynamic scope collections, where the resource server is + variable. + + The default implementation takes the resource server as the only init-time + parameter. + + :param resource_server: The resource_server to use for all scopes attached + to this scope collection. + """ + + # DynamicScopeCollection classes are expected to provide + # the scope names which they provide as a classvar + # these are often properties for dynamic computation + _scope_names: t.ClassVar[tuple[str, ...]] + + def __init__(self, resource_server: str) -> None: + self._resource_server = resource_server + + def __iter__(self) -> t.Iterator[Scope]: + for name in self._scope_names: + value = getattr(self, name) + if isinstance(value, Scope): + yield value + + @property + def resource_server(self) -> str: + return self._resource_server + + +def _urn_scope(resource_server: str, scope_name: str) -> Scope: + """ + Convert a short name + resource server string to a scope, in the Globus + Auth URN format. + + Example Usage: + + >>> _urn_scope("transfer.api.globus.org", "all") + Scope('urn:globus:auth:scope:transfer.api.globus.org:all') + + :param resource_server: The resource server string. + :param scope_name: The short name for the scope. + """ + return Scope(f"urn:globus:auth:scope:{resource_server}:{scope_name}") + + +def _url_scope(resource_server: str, scope_name: str) -> Scope: + """ + Convert a short name + resource server string to a scope, in the Globus + Auth URL format. + + Example Usage: + + >>> _url_scope("actions.globus.org", "hello_world") + Scope('https://auth.globus.org/scopes/actions.globus.org/hello_world') + + :param resource_server: The resource server string. + :param scope_name: The short name for the scope. + """ + return Scope(f"https://auth.globus.org/scopes/{resource_server}/{scope_name}") diff --git a/src/globus_sdk/scopes/consents/_model.py b/src/globus_sdk/scopes/consents/_model.py index 42f8f6db5..07d454cbd 100644 --- a/src/globus_sdk/scopes/consents/_model.py +++ b/src/globus_sdk/scopes/consents/_model.py @@ -31,6 +31,7 @@ from dataclasses import dataclass from datetime import datetime +from ..parser import ScopeParser from ..representation import Scope from ._errors import ConsentParseError, ConsentTreeConstructionError @@ -107,7 +108,7 @@ class ConsentForest: It exists to expose a simple interface for evaluating whether resource server grant requirements, as defined by a scope object are satisfied. - Consents should be retrieved from the AuthClient's `get_consents` method. + Consents should be retrieved from the AuthClient's ``get_consents`` method. Example usage: @@ -115,10 +116,10 @@ class ConsentForest: >>> identity_id = ... >>> forest = auth_client.get_consents(identity_id).to_forest() >>> - >>> # Check whether the forest contains a scope relationship - >>> dependent_scope = GCSCollectionScopeBuilder(collection_id).data_access - >>> scope = f"{TransferScopes.all}[{dependent_scope}]" - >>> forest.contains_scopes(scope) + >>> # Check whether the forest meets a scope requirement + >>> data_access_scope = GCSCollectionScopes(collection_id).data_access + >>> scope = TransferScopes.all.with_dependency(data_access_scope) + >>> forest.meets_scope_requirements(scope) The following diagram demonstrates a Consent Forest in which a user has consented @@ -312,7 +313,7 @@ def _normalize_scope_types( """ Normalize the input scope types into a list of Scope objects. - Strings are parsed into 1 or more Scopes using `Scope.parse`. + Strings are parsed into 1 or more Scopes using `ScopeParser.parse`. :param scopes: Some collection of 0 or more scopes as Scope or scope strings. :returns: A list of Scope objects. @@ -321,12 +322,12 @@ def _normalize_scope_types( if isinstance(scopes, Scope): return [scopes] elif isinstance(scopes, str): - return Scope.parse(scopes) + return ScopeParser.parse(scopes) else: scope_list = [] for scope in scopes: if isinstance(scope, str): - scope_list.extend(Scope.parse(scope)) + scope_list.extend(ScopeParser.parse(scope)) else: scope_list.append(scope) return scope_list diff --git a/src/globus_sdk/scopes/data/__init__.py b/src/globus_sdk/scopes/data/__init__.py index 35b9ab164..94caff0b3 100644 --- a/src/globus_sdk/scopes/data/__init__.py +++ b/src/globus_sdk/scopes/data/__init__.py @@ -1,7 +1,7 @@ from .auth import AuthScopes from .compute import ComputeScopes -from .flows import FlowsScopes, SpecificFlowScopeBuilder -from .gcs import GCSCollectionScopeBuilder, GCSEndpointScopeBuilder +from .flows import FlowsScopes, SpecificFlowScopes +from .gcs import GCSCollectionScopes, GCSEndpointScopes from .groups import GroupsScopes, NexusScopes from .search import SearchScopes from .timers import TimersScopes @@ -11,9 +11,9 @@ "AuthScopes", "ComputeScopes", "FlowsScopes", - "SpecificFlowScopeBuilder", - "GCSEndpointScopeBuilder", - "GCSCollectionScopeBuilder", + "SpecificFlowScopes", + "GCSEndpointScopes", + "GCSCollectionScopes", "GroupsScopes", "NexusScopes", "SearchScopes", diff --git a/src/globus_sdk/scopes/data/auth.py b/src/globus_sdk/scopes/data/auth.py index 6386f75f5..fc2afb0a9 100644 --- a/src/globus_sdk/scopes/data/auth.py +++ b/src/globus_sdk/scopes/data/auth.py @@ -1,23 +1,21 @@ -from ..builder import ScopeBuilder +from ..collection import StaticScopeCollection, _urn_scope +from ..representation import Scope -class _AuthScopesBuilder(ScopeBuilder): - _classattr_scope_names = ["openid", "email", "profile"] +class _AuthScopes(StaticScopeCollection): + resource_server = "auth.globus.org" - openid: str = "openid" - email: str = "email" - profile: str = "profile" + openid = Scope("openid") + email = Scope("email") + profile = Scope("profile") + manage_projects = _urn_scope(resource_server, "manage_projects") + view_authentications = _urn_scope(resource_server, "view_authentications") + view_clients = _urn_scope(resource_server, "view_clients") + view_clients_and_scopes = _urn_scope(resource_server, "view_clients_and_scopes") + view_consents = _urn_scope(resource_server, "view_consents") + view_identities = _urn_scope(resource_server, "view_identities") + view_identity_set = _urn_scope(resource_server, "view_identity_set") -AuthScopes = _AuthScopesBuilder( - "auth.globus.org", - known_scopes=[ - "manage_projects", - "view_authentications", - "view_clients", - "view_clients_and_scopes", - "view_consents", - "view_identities", - "view_identity_set", - ], -) + +AuthScopes = _AuthScopes() diff --git a/src/globus_sdk/scopes/data/compute.py b/src/globus_sdk/scopes/data/compute.py index 4f08ed0fe..9908c4534 100644 --- a/src/globus_sdk/scopes/data/compute.py +++ b/src/globus_sdk/scopes/data/compute.py @@ -1,36 +1,13 @@ -from __future__ import annotations +from ..collection import StaticScopeCollection, _url_scope -from ..builder import ScopeBuilder, ScopeBuilderScopes +class _ComputeScopes(StaticScopeCollection): + # The Compute service breaks the scopes/resource server convention: its resource + # server is a service name and its scopes are built around the client ID. + resource_server = "funcx_service" + client_id = "facd7ccc-c5f4-42aa-916b-a0e270e2c2a9" -class _ComputeScopeBuilder(ScopeBuilder): - """The Compute service breaks the scopes/resource server convention: its resource - server is a service name and its scopes are built around the client ID. - """ + all = _url_scope(client_id, "all") - def __init__( - self, - resource_server: str, - client_id: str, - known_scopes: ScopeBuilderScopes = None, - known_url_scopes: ScopeBuilderScopes = None, - ) -> None: - self._client_id = client_id - super().__init__( - resource_server, - known_scopes=known_scopes, - known_url_scopes=known_url_scopes, - ) - def urn_scope_string(self, scope_name: str) -> str: - return f"urn:globus:auth:scope:{self._client_id}:{scope_name}" - - def url_scope_string(self, scope_name: str) -> str: - return f"https://auth.globus.org/scopes/{self._client_id}/{scope_name}" - - -ComputeScopes = _ComputeScopeBuilder( - "funcx_service", - "facd7ccc-c5f4-42aa-916b-a0e270e2c2a9", - known_url_scopes=["all"], -) +ComputeScopes = _ComputeScopes() diff --git a/src/globus_sdk/scopes/data/flows.py b/src/globus_sdk/scopes/data/flows.py index c5ef35585..c0f8dc9f8 100644 --- a/src/globus_sdk/scopes/data/flows.py +++ b/src/globus_sdk/scopes/data/flows.py @@ -3,53 +3,68 @@ import typing as t import uuid -from ..builder import ScopeBuilder, ScopeBuilderScopes +from ..collection import ( + DynamicScopeCollection, + StaticScopeCollection, + _url_scope, +) +from ..representation import Scope -class _FlowsScopeBuilder(ScopeBuilder): - """ - The Flows service breaks the scopes/resource server convention: its resource server - is a domain name but its scopes are built around the client ID. +class _FlowsScopes(StaticScopeCollection): + # The Flows service breaks the scopes/resource server convention: its + # resource server is a domain name but its scopes are built around the + # client ID. + resource_server = "flows.globus.org" + client_id = "eec9b274-0c81-4334-bdc2-54e90e689b9a" + + all = _url_scope(client_id, "all") + manage_flows = _url_scope(client_id, "manage_flows") + view_flows = _url_scope(client_id, "view_flows") + run = _url_scope(client_id, "run") + run_status = _url_scope(client_id, "run_status") + run_manage = _url_scope(client_id, "run_manage") + + +FlowsScopes = _FlowsScopes() - Given that there isn't a simple way to support this more generally (and we - shouldn't encourage supporting this more generally), this class serves to - build out the scopes accurately specifically for Flows. + +class SpecificFlowScopes(DynamicScopeCollection): """ + This defines the scopes for a single flow (as distinct from the Flows service). - def __init__( - self, - domain_name: str, - client_id: str, - known_scopes: ScopeBuilderScopes = None, - known_url_scopes: ScopeBuilderScopes = None, - ) -> None: - self._client_id = client_id - super().__init__( - domain_name, known_scopes=known_scopes, known_url_scopes=known_url_scopes - ) + It primarily provides the `user` scope which is typically needed to start a run of + a flow. - def urn_scope_string(self, scope_name: str) -> str: - return f"urn:globus:auth:scope:{self._client_id}:{scope_name}" + Example usage: - def url_scope_string(self, scope_name: str) -> str: - return f"https://auth.globus.org/scopes/{self._client_id}/{scope_name}" + .. code-block:: python + sc = SpecificFlowScopes("my-flow-id-here") + flow_scope = sc.user + """ -FlowsScopes = _FlowsScopeBuilder( - "flows.globus.org", - "eec9b274-0c81-4334-bdc2-54e90e689b9a", - known_url_scopes=[ - "all", - "manage_flows", - "view_flows", - "run", - "run_status", - "run_manage", - ], -) + _scope_names = ("user",) + + def __init__(self, flow_id: uuid.UUID | str) -> None: + _flow_id = str(flow_id) + super().__init__(_flow_id) + + self.user: Scope = _url_scope( + _flow_id, f"flow_{_flow_id.replace('-', '_')}_user" + ) + @classmethod + def _build_class_stub(cls) -> SpecificFlowScopes: + """ + This internal helper builds a "stub" object so that + ``SpecificFlowClient.scopes`` is typed as ``SpecificFlowScopes`` but + raises appropriate errors at runtime access via the class. + """ + return _SpecificFlowScopesClassStub() -class _SpecificFlowScopesClassStub(ScopeBuilder): + +class _SpecificFlowScopesClassStub(SpecificFlowScopes): """ This stub object ensures that the type deductions for type checkers (e.g. mypy) on SpecificFlowClient.scopes are correct. @@ -62,17 +77,12 @@ class _SpecificFlowScopesClassStub(ScopeBuilder): instance-var access. """ - def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: + def __init__(self) -> None: super().__init__("") - def __getattr__(self, name: str) -> t.Any: + def __getattribute__(self, name: str) -> t.Any: if name == "user": _raise_attr_error("scopes") - elif name == "resource_server": - _raise_attr_error("resource_server") - return super().__getattr__(name) - - def __getattribute__(self, name: str) -> t.Any: if name == "resource_server": _raise_attr_error("resource_server") return object.__getattribute__(self, name) @@ -85,29 +95,3 @@ def _raise_attr_error(name: str) -> t.NoReturn: f"Instead, instantiate a SpecificFlowClient and access the '{name}' attribute " "from that instance." ) - - -class SpecificFlowScopeBuilder(ScopeBuilder): - """ - This defines the scopes for a single flow (as distinct from the Flows service). - - It primarily provides the `user` scope which is typically needed to start a run of - a flow. - - Example usage: - - .. code-block:: python - - sb = SpecificFlowScopeBuilder("my-flow-id-here") - flow_scope = sb.user - """ - - _CLASS_STUB = _SpecificFlowScopesClassStub() - - def __init__(self, flow_id: uuid.UUID | str) -> None: - self._flow_id = flow_id - str_flow_id = str(flow_id) - super().__init__( - resource_server=str_flow_id, - known_url_scopes=[("user", f"flow_{str_flow_id.replace('-', '_')}_user")], - ) diff --git a/src/globus_sdk/scopes/data/gcs.py b/src/globus_sdk/scopes/data/gcs.py index ea8cba698..6137d404f 100644 --- a/src/globus_sdk/scopes/data/gcs.py +++ b/src/globus_sdk/scopes/data/gcs.py @@ -1,44 +1,47 @@ -from ..builder import ScopeBuilder +from functools import cached_property +from ..collection import DynamicScopeCollection, _url_scope, _urn_scope +from ..representation import Scope -class GCSEndpointScopeBuilder(ScopeBuilder): + +class GCSEndpointScopes(DynamicScopeCollection): """ - A ScopeBuilder with a named property for the GCS manage_collections scope. - "manage_collections" is a scope on GCS Endpoints. The resource_server string should - be the GCS Endpoint ID. + A dynamic ScopeCollection with a named property for the GCS + manage_collections scope. "manage_collections" is a scope on GCS Endpoints. + The resource_server string should be the GCS Endpoint ID. **Examples** - >>> sb = GCSEndpointScopeBuilder("xyz") + >>> sc = GCSEndpointScopes("xyz") >>> mc_scope = sb.manage_collections """ - _classattr_scope_names = ["manage_collections"] + _scope_names = ("manage_collections",) - @property - def manage_collections(self) -> str: - return self.urn_scope_string("manage_collections") + @cached_property + def manage_collections(self) -> Scope: + return _urn_scope(self.resource_server, "manage_collections") -class GCSCollectionScopeBuilder(ScopeBuilder): +class GCSCollectionScopes(DynamicScopeCollection): """ - A ScopeBuilder with a named property for the GCS data_access scope. + A dynamic ScopeCollection with a named property for the GCS data_access scope. "data_access" is a scope on GCS Collections. The resource_server string should be the GCS Collection ID. **Examples** - >>> sb = GCSCollectionScopeBuilder("xyz") - >>> da_scope = sb.data_access - >>> https_scope = sb.https + >>> sc = GCSCollectionScopes("xyz") + >>> da_scope = sc.data_access + >>> https_scope = sc.https """ - _classattr_scope_names = ["data_access", "https"] + _scope_names = ("data_access", "https") - @property - def data_access(self) -> str: - return self.url_scope_string("data_access") + @cached_property + def data_access(self) -> Scope: + return _url_scope(self.resource_server, "data_access") - @property - def https(self) -> str: - return self.url_scope_string("https") + @cached_property + def https(self) -> Scope: + return _url_scope(self.resource_server, "https") diff --git a/src/globus_sdk/scopes/data/groups.py b/src/globus_sdk/scopes/data/groups.py index 89fdc6088..cde841b39 100644 --- a/src/globus_sdk/scopes/data/groups.py +++ b/src/globus_sdk/scopes/data/groups.py @@ -1,17 +1,20 @@ -from ..builder import ScopeBuilder - -GroupsScopes = ScopeBuilder( - "groups.api.globus.org", - known_scopes=[ - "all", - "view_my_groups_and_memberships", - ], -) - - -NexusScopes = ScopeBuilder( - "nexus.api.globus.org", - known_scopes=[ - "groups", - ], -) +from ..collection import StaticScopeCollection, _urn_scope + + +class _GroupsScopes(StaticScopeCollection): + resource_server = "groups.api.globus.org" + + all = _urn_scope(resource_server, "all") + view_my_groups_and_memberships = _urn_scope( + resource_server, "view_my_groups_and_memberships" + ) + + +class _NexusScopes(StaticScopeCollection): + resource_server = "nexus.api.globus.org" + + groups = _urn_scope(resource_server, "groups") + + +GroupsScopes = _GroupsScopes() +NexusScopes = _NexusScopes() diff --git a/src/globus_sdk/scopes/data/search.py b/src/globus_sdk/scopes/data/search.py index 2bc540fb3..6a88115ec 100644 --- a/src/globus_sdk/scopes/data/search.py +++ b/src/globus_sdk/scopes/data/search.py @@ -1,11 +1,13 @@ -from ..builder import ScopeBuilder - -SearchScopes = ScopeBuilder( - "search.api.globus.org", - known_scopes=[ - "all", - "globus_connect_server", - "ingest", - "search", - ], -) +from ..collection import StaticScopeCollection, _urn_scope + + +class _SearchScopes(StaticScopeCollection): + resource_server = "search.api.globus.org" + + all = _urn_scope(resource_server, "all") + globus_connect_server = _urn_scope(resource_server, "globus_connect_server") + ingest = _urn_scope(resource_server, "ingest") + search = _urn_scope(resource_server, "search") + + +SearchScopes = _SearchScopes() diff --git a/src/globus_sdk/scopes/data/timers.py b/src/globus_sdk/scopes/data/timers.py index 62b97d192..34f4e4acc 100644 --- a/src/globus_sdk/scopes/data/timers.py +++ b/src/globus_sdk/scopes/data/timers.py @@ -1,8 +1,10 @@ -from ..builder import ScopeBuilder - -TimersScopes = ScopeBuilder( - "524230d7-ea86-4a52-8312-86065a9e0417", - known_url_scopes=[ - "timer", - ], -) +from ..collection import StaticScopeCollection, _url_scope + + +class _TimersScopes(StaticScopeCollection): + resource_server = "524230d7-ea86-4a52-8312-86065a9e0417" + + timer = _url_scope(resource_server, "timer") + + +TimersScopes = _TimersScopes() diff --git a/src/globus_sdk/scopes/data/transfer.py b/src/globus_sdk/scopes/data/transfer.py index 967262eb3..f6e23a43d 100644 --- a/src/globus_sdk/scopes/data/transfer.py +++ b/src/globus_sdk/scopes/data/transfer.py @@ -1,9 +1,11 @@ -from ..builder import ScopeBuilder - -TransferScopes = ScopeBuilder( - "transfer.api.globus.org", - known_scopes=[ - "all", - "gcp_install", - ], -) +from ..collection import StaticScopeCollection, _urn_scope + + +class _TransferScopes(StaticScopeCollection): + resource_server = "transfer.api.globus.org" + + all = _urn_scope(resource_server, "all") + gcp_install = _urn_scope(resource_server, "gcp_install") + + +TransferScopes = _TransferScopes() diff --git a/src/globus_sdk/scopes/parser.py b/src/globus_sdk/scopes/parser.py new file mode 100644 index 000000000..c95d5e996 --- /dev/null +++ b/src/globus_sdk/scopes/parser.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import typing as t + +from globus_sdk import exc + +from ._graph_parser import ScopeGraph +from .representation import Scope + + +class ScopeParser: + """ + The ``ScopeParser`` handles the conversion of strings to scopes. + + Most interfaces are classmethods, meaning users should prefer usage like + ``ScopeParser.parse("foo")`` + """ + + @classmethod + def parse(cls, scope_string: str) -> list[Scope]: + """ + Parse an arbitrary scope string to a list of scopes. + + Zero or more than one scope may be returned, as in the case of an empty string + or space-delimited scopes. + + .. warning:: + + Parsing passes through an intermediary representation which treats scopes + as a graph. This ensures that the behavior of parses matches the treatment + of scope strings in Globus Auth authorization flows. + However, this also means that the parsing does not allow for strings which + represent consent trees with structures in which the same scope appears in + multiple parts of the tree. + + :param scope_string: The string to parse + """ + # build the graph intermediate representation + scope_graph = ScopeGraph.parse(scope_string) + + # traverse the graph in a reversed BFS scan + # + # this means we'll handle leaf nodes first, and we'll never reach a + # node before its descendants (dependencies) + # + # as we work, build a lookup table for built Scope objects so that we can + # quickly retrieve elements + built_scopes: dict[tuple[str, bool], Scope] = {} + + for name, optionality in list(scope_graph.breadth_first_walk())[::-1]: + dependencies: tuple[Scope, ...] = tuple( + # the lookup in built_scopes here is safe because of the + # reversed BFS ordering + built_scopes[(dep_name, dep_optional)] + for _, dep_name, dep_optional in scope_graph.adjacency_matrix[name] + ) + + built_scopes[(name, optionality)] = Scope( + name, optional=optionality, dependencies=dependencies + ) + + # only return the top-level elements from that build process + # (the roots of the forest-shaped graph) + return [built_scopes[key] for key in scope_graph.top_level_scopes] + + @classmethod + def merge_scopes(cls, scopes_a: list[Scope], scopes_b: list[Scope]) -> list[Scope]: + """ + Given two lists of Scopes, merge them into one list of Scopes by parsing + them as one combined scope string. + + :param scopes_a: list of Scopes to be merged with scopes_b + :param scopes_b: list of Scopes to be merged with scopes_a + """ + # dict of base scope_string: list of scopes with that base scope_string + return cls.parse( + " ".join([str(s) for s in scopes_a] + [str(s) for s in scopes_b]) + ) + + @classmethod + def serialize( + cls, scopes: str | Scope | t.Iterable[str | Scope], *, reject_empty: bool = True + ) -> str: + """ + Normalize scopes to a space-separated scope string. + + The results of this method are suitable for sending to Globus Auth. + Scopes are not parsed, merged, or normalized by this method. + + :param scopes: A scope string, scope object, or an iterable of scope strings + and scope objects. + :param reject_empty: When true (the default), raise an error if the + scopes serialize to the empty string. + :returns: A space-separated scope string. + + Example usage: + + .. code-block:: pycon + + >>> ScopeParser.serialize([Scope("foo"), "bar", Scope("qux")]) + 'foo bar qux' + """ + scope_iter: t.Iterable[str | Scope] + if isinstance(scopes, (str, Scope)): + scope_iter = (scopes,) + else: + scope_iter = scopes + + result = " ".join(str(scope) for scope in scope_iter) + if reject_empty and result == "": + raise exc.GlobusSDKUsageError( + "'scopes' cannot be the empty string or empty collection." + ) + return result diff --git a/src/globus_sdk/scopes/representation.py b/src/globus_sdk/scopes/representation.py index 7aabdc168..bb031ad82 100644 --- a/src/globus_sdk/scopes/representation.py +++ b/src/globus_sdk/scopes/representation.py @@ -1,93 +1,53 @@ from __future__ import annotations -import warnings +import dataclasses +import sys +import typing as t -from ._parser import parse_scope_graph +# pass slots=True on 3.10+ +# it's not strictly necessary, but it improves performance +if sys.version_info >= (3, 10): + _add_dataclass_kwargs: dict[str, bool] = {"slots": True} +else: + _add_dataclass_kwargs: dict[str, bool] = {} +@dataclasses.dataclass(frozen=True, repr=False, **_add_dataclass_kwargs) class Scope: """ - A scope object is a representation of a scope which allows modifications to be - made. In particular, it supports handling scope dependencies via - ``add_dependency``. + A scope object is a representation of a scope and its dynamic dependencies + (other scopes). - `str(Scope(...))` produces a valid scope string for use in various methods. + A scope may be optional (also referred to as "atomically revocable"). + An optional scope can be revoked without revoking consent for other scopes + which were granted at the same time. + + Scopes are immutable, and provide several evolver methods which produce new + Scopes. In particular, ``with_dependency`` and ``with_dependencies`` create + new scopes with added dependencies. + + ``str(Scope(...))`` produces a valid scope string for use in various methods. :param scope_string: The string which will be used as the basis for this Scope :param optional: The scope may be marked as optional. This means that the scope can - be declined by the user without declining consent for other scopes + be declined by the user without declining consent for other scopes. """ - def __init__( + scope_string: str + optional: bool = dataclasses.field(default=False) + dependencies: tuple[Scope, ...] = dataclasses.field(default=()) + + def __post_init__( self, - scope_string: str, - *, - optional: bool = False, - dependencies: list[Scope] | None = None, ) -> None: - if any(c in scope_string for c in "[]* "): + if any(c in self.scope_string for c in "[]* "): raise ValueError( "Scope instances may not contain the special characters '[]* '. " - "Use either Scope.deserialize or Scope.parse instead" + "Use Scope.parse instead." ) - self.scope_string = scope_string - self.optional = optional - self.dependencies: list[Scope] = [] if dependencies is None else dependencies - - @staticmethod - def parse(scope_string: str) -> list[Scope]: - """ - Parse an arbitrary scope string to a list of scopes. - - Zero or more than one scope may be returned, as in the case of an empty string - or space-delimited scopes. - - .. warning:: - - Parsing passes through an intermediary representation which treats scopes - as a graph. This ensures that the behavior of parses matches the treatment - of scope strings in Globus Auth authorization flows. - However, this also means that the parsing does not allow for strings which - represent consent trees with structures in which the same scope appears in - multiple parts of the tree. - - :param scope_string: The string to parse - """ - scope_graph = parse_scope_graph(scope_string) - - # initialize BFS traversals (one per root node) and copy that data - # to setup the result data - bfs_additions: list[Scope] = [ - Scope(s, optional=optional) for s, optional in scope_graph.top_level_scopes - ] - results: list[Scope] = list(bfs_additions) - - while bfs_additions: - current_scope = bfs_additions.pop(0) - edges = scope_graph.adjacency_matrix[current_scope.scope_string] - for _, dest, optional in edges: - dest_scope = Scope(dest, optional=optional) - current_scope.add_dependency(dest_scope) - bfs_additions.append(dest_scope) - - return results - - @staticmethod - def merge_scopes(scopes_a: list[Scope], scopes_b: list[Scope]) -> list[Scope]: - """ - Given two lists of Scopes, merge them into one list of Scopes by parsing - them as one combined scope string. - - :param scopes_a: list of Scopes to be merged with scopes_b - :param scopes_b: list of Scopes to be merged with scopes_a - """ - # dict of base scope_string: list of scopes with that base scope_string - return Scope.parse( - " ".join([str(s) for s in scopes_a] + [str(s) for s in scopes_b]) - ) @classmethod - def deserialize(cls, scope_string: str) -> Scope: + def parse(cls, scope_string: str) -> Scope: """ Deserialize a scope string to a scope object. @@ -97,54 +57,62 @@ def deserialize(cls, scope_string: str) -> Scope: :param scope_string: The string to parse """ - data = Scope.parse(scope_string) + # deferred import because ScopeParser depends on Scope, but Scope.parse + # is a wrapper over ScopeParser.parse() + from .parser import ScopeParser + + data = ScopeParser.parse(scope_string) if len(data) != 1: raise ValueError( - "Deserializing a scope from string did not get exactly one scope. " + "`Scope.parse()` did not get exactly one scope. " f"Instead got data={data}" ) return data[0] - def serialize(self) -> str: - base_scope = ("*" if self.optional else "") + self.scope_string - if not self.dependencies: - return base_scope - return ( - base_scope + "[" + " ".join(c.serialize() for c in self.dependencies) + "]" + def with_dependency(self, other_scope: Scope) -> Scope: + """ + Create a new scope with a dependency. + The dependent scope relationship will be stored in the Scope and will + be evident in its string representation. + + :param other_scope: The scope upon which the current scope depends. + """ + if not isinstance(other_scope, Scope): + raise TypeError( + "Scope.with_dependency() takes a Scope as its input. " + f"Got: '{type(other_scope).__qualname__}'" + ) + return dataclasses.replace( + self, dependencies=self.dependencies + (other_scope,) ) - def add_dependency( - self, scope: str | Scope, *, optional: bool | None = None - ) -> Scope: + def with_dependencies(self, other_scopes: t.Iterable[Scope]) -> Scope: """ - Add a scope dependency. The dependent scope relationship will be stored in the - Scope and will be evident in its string representation. + Create a new scope with added dependencies. + The dependent scope relationships will be stored in the Scope and will + be evident in its string representation. - :param scope: The scope upon which the current scope depends - :param optional: Mark the dependency an optional one. By default it is not. An - optional scope dependency can be declined by the user without declining - consent for the primary scope + :param other_scopes: The scopes upon which the current scope depends. """ - if optional is not None: - if isinstance(scope, Scope): - raise ValueError( - "cannot use optional=... with a Scope object as the argument to " - "add_dependency" + other_scopes_tuple = tuple(other_scopes) + for i, item in enumerate(other_scopes_tuple): + if not isinstance(item, Scope): + raise TypeError( + "Scope.with_dependencies() takes " + "an iterable of Scopes as its input. " + f"At position {i}, got: '{type(item).__qualname__}'" ) - warnings.warn( - "Passing 'optional' to add_dependency is deprecated. " - "Construct an optional Scope object instead.", - DeprecationWarning, - stacklevel=2, - ) - scopeobj = Scope(scope, optional=optional) - else: - if isinstance(scope, str): - scopeobj = Scope.deserialize(scope) - else: - scopeobj = scope - self.dependencies.append(scopeobj) - return self + return dataclasses.replace( + self, dependencies=self.dependencies + other_scopes_tuple + ) + + def with_optional(self, optional: bool) -> Scope: + """ + Create a new scope with a different 'optional' value. + + :param optional: Whether or not the scope is optional. + """ + return dataclasses.replace(self, optional=optional) def __repr__(self) -> str: parts: list[str] = [f"'{self.scope_string}'"] @@ -155,4 +123,7 @@ def __repr__(self) -> str: return "Scope(" + ", ".join(parts) + ")" def __str__(self) -> str: - return self.serialize() + base_scope = ("*" if self.optional else "") + self.scope_string + if not self.dependencies: + return base_scope + return base_scope + "[" + " ".join(str(c) for c in self.dependencies) + "]" diff --git a/src/globus_sdk/scopes/scope_definition.py b/src/globus_sdk/scopes/scope_definition.py deleted file mode 100644 index 7d9d677c9..000000000 --- a/src/globus_sdk/scopes/scope_definition.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -THIS IS A LEGACY MODULE - -This module defines a legacy scope object and parser called `MutableScope`. -It is maintained for backwards compatibility. - -For new code, use the `globus_sdk.Scope` object. -""" - -from __future__ import annotations - -import typing as t -import warnings - -if t.TYPE_CHECKING: - from globus_sdk._types import ScopeCollectionType - - -class MutableScope: - """ - A scope object is a representation of a scope which allows modifications to be - made. In particular, it supports handling scope dependencies via - ``add_dependency``. - - `str(MutableScope(...))` produces a valid scope string for use in various methods. - - :param scope_string: The string which will be used as the basis for this Scope - :param optional: The scope may be marked as optional. This means that the scope can - be declined by the user without declining consent for other scopes - """ - - def __init__( - self, - scope_string: str, - *, - optional: bool = False, - dependencies: list[MutableScope] | None = None, - ) -> None: - if any(c in scope_string for c in "[]* "): - raise ValueError( - "MutableScope instances may not contain the special characters '[]* '." - ) - self.scope_string = scope_string - self.optional = optional - self.dependencies: list[MutableScope] = ( - [] if dependencies is None else dependencies - ) - - def serialize(self) -> str: - base_scope = ("*" if self.optional else "") + self.scope_string - if not self.dependencies: - return base_scope - return ( - base_scope + "[" + " ".join(c.serialize() for c in self.dependencies) + "]" - ) - - def add_dependency( - self, - scope: str | MutableScope, - *, - optional: bool | None = None, - ) -> MutableScope: - """ - Add a scope dependency. The dependent scope relationship will be stored in the - Scope and will be evident in its string representation. - - :param scope: The scope upon which the current scope depends - :param optional: Mark the dependency an optional one. By default it is not. An - optional scope dependency can be declined by the user without declining - consent for the primary scope - """ - if optional is not None: - if isinstance(scope, MutableScope): - raise ValueError( - "cannot use optional=... with a MutableScope object as the " - "argument to add_dependency" - ) - warnings.warn( - "Passing 'optional' to add_dependency is deprecated. " - "Construct an optional MutableScope object instead.", - DeprecationWarning, - stacklevel=2, - ) - scopeobj = MutableScope(scope, optional=optional) - else: - if isinstance(scope, str): - scopeobj = MutableScope(scope) - else: - scopeobj = scope - self.dependencies.append(scopeobj) - return self - - def __repr__(self) -> str: - parts: list[str] = [f"'{self.scope_string}'"] - if self.optional: - parts.append("optional=True") - if self.dependencies: - parts.append(f"dependencies={self.dependencies!r}") - return "MutableScope(" + ", ".join(parts) + ")" - - def __str__(self) -> str: - return self.serialize() - - @staticmethod - def scopes2str(obj: ScopeCollectionType) -> str: - """ - .. warning:: - - Deprecated. Prefer ``globus_sdk.scopes.scopes_to_str``. - - Given a scope string, a collection of scope strings, a MutableScope object, a - collection of MutableScope objects, or a mixed collection of strings and - Scopes, convert to a string which can be used in a request. - - :param obj: The object or collection to convert to a string - """ - from ._normalize import scopes_to_str - - return scopes_to_str(obj) diff --git a/src/globus_sdk/services/auth/_common.py b/src/globus_sdk/services/auth/_common.py index 7cbec0610..c6144ff50 100644 --- a/src/globus_sdk/services/auth/_common.py +++ b/src/globus_sdk/services/auth/_common.py @@ -7,39 +7,11 @@ import jwt from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey -from globus_sdk._types import ScopeCollectionType -from globus_sdk.exc import GlobusSDKUsageError -from globus_sdk.exc.warnings import warn_deprecated +from globus_sdk._missing import MISSING, MissingType from globus_sdk.response import GlobusHTTPResponse -from globus_sdk.scopes import AuthScopes, TransferScopes, scopes_to_str log = logging.getLogger(__name__) -_DEFAULT_REQUESTED_SCOPES = ( - AuthScopes.openid, - AuthScopes.profile, - AuthScopes.email, - TransferScopes.all, -) - - -def stringify_requested_scopes(requested_scopes: ScopeCollectionType | None) -> str: - if requested_scopes is None: - warn_deprecated( - "`requested_scopes` was not specified or was given as `None`. " - "A default set of scopes will be used, but this behavior is deprecated. " - "Specify an explicit set of scopes instead.", - stacklevel=3, - ) - requested_scopes = _DEFAULT_REQUESTED_SCOPES - - requested_scopes_string: str = scopes_to_str(requested_scopes) - if requested_scopes_string == "": - raise GlobusSDKUsageError( - "requested_scopes cannot be the empty string or empty collection" - ) - return requested_scopes_string - class _JWKGetCallbackProto(t.Protocol): def __call__( @@ -94,7 +66,7 @@ def get_openid_configuration(self) -> GlobusHTTPResponse: ... @t.overload def get_jwk( self, - openid_configuration: None | GlobusHTTPResponse | dict[str, t.Any], + openid_configuration: GlobusHTTPResponse | dict[str, t.Any] | MissingType, *, as_pem: t.Literal[True], ) -> RSAPublicKey: ... @@ -102,14 +74,16 @@ def get_jwk( @t.overload def get_jwk( self, - openid_configuration: None | GlobusHTTPResponse | dict[str, t.Any], + openid_configuration: GlobusHTTPResponse | dict[str, t.Any] | MissingType, *, as_pem: t.Literal[False], ) -> dict[str, t.Any]: ... def get_jwk( self, - openid_configuration: None | GlobusHTTPResponse | dict[str, t.Any] = None, + openid_configuration: ( + GlobusHTTPResponse | dict[str, t.Any] | MissingType + ) = MISSING, *, as_pem: bool = False, ) -> RSAPublicKey | dict[str, t.Any]: ... diff --git a/src/globus_sdk/services/auth/client/base_login_client.py b/src/globus_sdk/services/auth/client/base_login_client.py index afa2bf3bd..34ce44b1a 100644 --- a/src/globus_sdk/services/auth/client/base_login_client.py +++ b/src/globus_sdk/services/auth/client/base_login_client.py @@ -6,10 +6,13 @@ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey -from globus_sdk import _guards, client, exc, utils +from globus_sdk import client, exc +from globus_sdk._internal.remarshal import commajoin +from globus_sdk._missing import MISSING, MissingType from globus_sdk.authorizers import GlobusAuthorizer, NullAuthorizer from globus_sdk.response import GlobusHTTPResponse from globus_sdk.scopes import AuthScopes, Scope +from globus_sdk.transport import RequestsTransport, RetryConfig from .._common import get_jwk_data, pem_decode_jwk_data from ..errors import AuthAPIError @@ -50,14 +53,16 @@ def __init__( base_url: str | None = None, authorizer: GlobusAuthorizer | None = None, app_name: str | None = None, - transport_params: dict[str, t.Any] | None = None, + transport: RequestsTransport | None = None, + retry_config: RetryConfig | None = None, ) -> None: super().__init__( environment=environment, base_url=base_url, authorizer=authorizer, app_name=app_name, - transport_params=transport_params, + transport=transport, + retry_config=retry_config, ) self.client_id: str | None = str(client_id) if client_id is not None else None # an AuthClient may contain a GlobusOAuth2FlowManager in order to @@ -95,7 +100,7 @@ def get_openid_configuration(self) -> GlobusHTTPResponse: @t.overload def get_jwk( self, - openid_configuration: None | GlobusHTTPResponse | dict[str, t.Any], + openid_configuration: GlobusHTTPResponse | dict[str, t.Any] | MissingType, *, as_pem: t.Literal[True], ) -> RSAPublicKey: ... @@ -103,7 +108,7 @@ def get_jwk( @t.overload def get_jwk( self, - openid_configuration: None | GlobusHTTPResponse | dict[str, t.Any], + openid_configuration: GlobusHTTPResponse | dict[str, t.Any] | MissingType, *, as_pem: t.Literal[False], ) -> dict[str, t.Any]: ... @@ -117,7 +122,9 @@ def get_jwk( # an AuthClient which it uses def get_jwk( self, - openid_configuration: None | GlobusHTTPResponse | dict[str, t.Any] = None, + openid_configuration: ( + GlobusHTTPResponse | dict[str, t.Any] | MissingType + ) = MISSING, *, as_pem: bool = False, ) -> RSAPublicKey | dict[str, t.Any]: @@ -130,7 +137,7 @@ def get_jwk( When not provided, it will be fetched automatically. :param as_pem: Decode the JWK to an RSA PEM key, typically for JWT decoding """ - if openid_configuration is None: + if openid_configuration is MISSING: log.debug("No OIDC Config provided, autofetching...") openid_configuration = self.get_openid_configuration() jwk_data = get_jwk_data( @@ -142,15 +149,15 @@ def oauth2_get_authorize_url( self, *, session_required_identities: ( - uuid.UUID | str | t.Iterable[uuid.UUID | str] | None - ) = None, - session_required_single_domain: str | t.Iterable[str] | None = None, + uuid.UUID | str | t.Iterable[uuid.UUID | str] | MissingType + ) = MISSING, + session_required_single_domain: str | t.Iterable[str] | MissingType = MISSING, session_required_policies: ( - uuid.UUID | str | t.Iterable[uuid.UUID | str] | None - ) = None, - session_required_mfa: bool | None = None, - session_message: str | None = None, - prompt: t.Literal["login"] | None = None, + uuid.UUID | str | t.Iterable[uuid.UUID | str] | MissingType + ) = MISSING, + session_required_mfa: bool | MissingType = MISSING, + session_message: str | MissingType = MISSING, + prompt: t.Literal["login"] | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> str: """ @@ -183,26 +190,15 @@ def oauth2_get_authorize_url( "Call the oauth2_start_flow() method on this " "AuthClient to resolve" ) - if query_params is None: - query_params = {} - if session_required_identities is not None: - query_params["session_required_identities"] = utils.commajoin( - session_required_identities - ) - if session_required_single_domain is not None: - query_params["session_required_single_domain"] = utils.commajoin( - session_required_single_domain - ) - if session_required_policies is not None: - query_params["session_required_policies"] = utils.commajoin( - session_required_policies - ) - if session_required_mfa is not None: - query_params["session_required_mfa"] = session_required_mfa - if session_message is not None: - query_params["session_message"] = session_message - if prompt is not None: - query_params["prompt"] = prompt + query_params = { + "session_required_identities": commajoin(session_required_identities), + "session_required_single_domain": commajoin(session_required_single_domain), + "session_required_policies": commajoin(session_required_policies), + "session_required_mfa": session_required_mfa, + "session_message": session_message, + "prompt": prompt, + **(query_params or {}), + } auth_url = self.current_oauth2_flow_manager.get_authorize_url( query_params=query_params ) @@ -263,43 +259,6 @@ def oauth2_refresh_token( form_data, body_params=body_params, response_class=OAuthRefreshTokenResponse ) - def oauth2_validate_token( - self, - token: str, - *, - body_params: dict[str, t.Any] | None = None, - ) -> GlobusHTTPResponse: - """ - Deprecated. Because the validity of a token may be dependent on policies - enforced both by Globus Auth and the resource server, this method is not - considered a reliable way to check token validity. - Users are encouraged to treat tokens as valid until proven otherwise instead. - - :param token: The token which should be validated. Can be a refresh token or an - access token - :param body_params: Additional parameters to include in the validation - body. Primarily for internal use - """ - exc.warn_deprecated( - f"{self.__class__.__name__}.oauth2_validate_token() is deprecated. " - "This validation method gives non-definitive results. " - "Tokens should be treated as valid until they are used and their " - "validity can be assessed." - ) - log.debug("Validating token") - body = {"token": token} - - # if this client has no way of authenticating itself but - # it does have a client_id, we'll send that in the request - no_authentication = _guards.is_optional(self.authorizer, NullAuthorizer) - if no_authentication and self.client_id: - log.debug("Validating token with unauthenticated client") - body.update({"client_id": self.client_id}) - - if body_params: - body.update(body_params) - return self.post("/v2/oauth2/token/validate", data=body, encoding="form") - def oauth2_revoke_token( self, token: str, @@ -340,21 +299,19 @@ def oauth2_revoke_token( if no_authentication and self.client_id: log.debug("Revoking token with unauthenticated client") body.update({"client_id": self.client_id}) - - if body_params: - body.update(body_params) + body.update(body_params or {}) return self.post("/v2/oauth2/token/revoke", data=body, encoding="form") @t.overload def oauth2_token( self, - form_data: dict[str, t.Any] | utils.PayloadWrapper, + form_data: dict[str, t.Any], ) -> OAuthTokenResponse: ... @t.overload def oauth2_token( self, - form_data: dict[str, t.Any] | utils.PayloadWrapper, + form_data: dict[str, t.Any], *, body_params: dict[str, t.Any] | None, ) -> OAuthTokenResponse: ... @@ -362,7 +319,7 @@ def oauth2_token( @t.overload def oauth2_token( self, - form_data: dict[str, t.Any] | utils.PayloadWrapper, + form_data: dict[str, t.Any], *, response_class: type[RT], ) -> RT: ... @@ -370,7 +327,7 @@ def oauth2_token( @t.overload def oauth2_token( self, - form_data: dict[str, t.Any] | utils.PayloadWrapper, + form_data: dict[str, t.Any], *, body_params: dict[str, t.Any] | None, response_class: type[RT], @@ -378,7 +335,7 @@ def oauth2_token( def oauth2_token( self, - form_data: dict[str, t.Any] | utils.PayloadWrapper, + form_data: dict[str, t.Any], *, body_params: dict[str, t.Any] | None = None, response_class: type[OAuthTokenResponse] | type[RT] = OAuthTokenResponse, @@ -406,9 +363,7 @@ def oauth2_token( log.debug("Fetching new token from Globus Auth") # use the fact that requests implicitly encodes the `data` parameter as # a form POST - data = dict(form_data) - if body_params: - data.update(body_params) + data = {**form_data, **(body_params or {})} return response_class( self.post( "/v2/oauth2/token", diff --git a/src/globus_sdk/services/auth/client/confidential_client.py b/src/globus_sdk/services/auth/client/confidential_client.py index 8a84d1fe8..c3f160986 100644 --- a/src/globus_sdk/services/auth/client/confidential_client.py +++ b/src/globus_sdk/services/auth/client/confidential_client.py @@ -4,18 +4,16 @@ import typing as t import uuid -from globus_sdk import exc, utils -from globus_sdk._types import ScopeCollectionType +from globus_sdk import exc +from globus_sdk._internal.remarshal import strseq_iter, strseq_listify +from globus_sdk._missing import MISSING, MissingType from globus_sdk.authorizers import BasicAuthorizer from globus_sdk.response import GlobusHTTPResponse +from globus_sdk.scopes import Scope, ScopeParser +from globus_sdk.transport import RequestsTransport, RetryConfig -from .._common import stringify_requested_scopes from ..flow_managers import GlobusAuthorizationCodeFlowManager -from ..response import ( - GetIdentitiesResponse, - OAuthClientCredentialsResponse, - OAuthDependentTokenResponse, -) +from ..response import OAuthClientCredentialsResponse, OAuthDependentTokenResponse from .base_login_client import AuthLoginClient log = logging.getLogger(__name__) @@ -50,7 +48,8 @@ def __init__( environment: str | None = None, base_url: str | None = None, app_name: str | None = None, - transport_params: dict[str, t.Any] | None = None, + transport: RequestsTransport | None = None, + retry_config: RetryConfig | None = None, ) -> None: super().__init__( client_id=client_id, @@ -58,58 +57,12 @@ def __init__( environment=environment, base_url=base_url, app_name=app_name, - transport_params=transport_params, - ) - - def get_identities( - self, - *, - usernames: t.Iterable[str] | str | None = None, - ids: t.Iterable[uuid.UUID | str] | uuid.UUID | str | None = None, - provision: bool = False, - query_params: dict[str, t.Any] | None = None, - ) -> GetIdentitiesResponse: - """ - Perform a call to the Get Identities API using the direct client - credentials of this client. - - This method is considered deprecated -- callers should instead use client - credentials to get a token and then use that token to call the API via a - :class:`~.AuthClient`. - - :param usernames: A username or list of usernames to lookup. Mutually exclusive - with ``ids`` - :param ids: An identity ID or list of IDs to lookup. Mutually exclusive - with ``usernames`` - :param provision: Create identities if they do not exist, allowing clients to - get username-to-identity mappings prior to the identity being used - :param query_params: Any additional parameters to be passed through - as query params. - """ - exc.warn_deprecated( - "ConfidentialAuthClient.get_identities() is deprecated. " - "Get a token via `oauth2_client_credentials_tokens` " - "and use that to call the API instead." - ) - - if query_params is None: - query_params = {} - - if usernames is not None: - query_params["usernames"] = utils.commajoin(usernames) - query_params["provision"] = ( - "false" if str(provision).lower() == "false" else "true" - ) - if ids is not None: - query_params["ids"] = utils.commajoin(ids) - - return GetIdentitiesResponse( - self.get("/v2/api/identities", query_params=query_params) + transport=transport, + retry_config=retry_config, ) def oauth2_client_credentials_tokens( - self, - requested_scopes: ScopeCollectionType | None = None, + self, requested_scopes: str | Scope | t.Iterable[str | Scope] ) -> OAuthClientCredentialsResponse: r""" Perform an OAuth2 Client Credentials Grant to get access tokens which @@ -118,20 +71,25 @@ def oauth2_client_credentials_tokens( This method does not use a ``GlobusOAuthFlowManager`` because it is not at all necessary to do so. - :param requested_scopes: The scopes on the token(s) being requested. Defaults to - ``openid profile email urn:globus:auth:scope:transfer.api.globus.org:all`` + :param requested_scopes: The scopes on the token(s) being requested. For example, with a Client ID of "CID1001" and a Client Secret of "RAND2002", you could use this grant type like so: - >>> client = ConfidentialAppAuthClient("CID1001", "RAND2002") - >>> tokens = client.oauth2_client_credentials_tokens() - >>> transfer_token_info = ( - ... tokens.by_resource_server["transfer.api.globus.org"]) - >>> transfer_token = transfer_token_info["access_token"] - """ - log.debug("Fetching token(s) using client credentials") - requested_scopes_string = stringify_requested_scopes(requested_scopes) + .. code-block:: pycon + + >>> client = ConfidentialAppAuthClient("CID1001", "RAND2002") + >>> tokens = client.oauth2_client_credentials_tokens( + ... "urn:globus:auth:scope:transfer.api.globus.org:all" + ... ) + >>> transfer_token_info = tokens.by_resource_server["transfer.api.globus.org"] + >>> transfer_token = transfer_token_info["access_token"] + """ # noqa: E501 + requested_scopes_string = ScopeParser.serialize(requested_scopes) + log.debug( + "Fetching token(s) using client credentials, " + f"scope={requested_scopes_string}" + ) return self.oauth2_token( {"grant_type": "client_credentials", "scope": requested_scopes_string}, response_class=OAuthClientCredentialsResponse, @@ -140,7 +98,7 @@ def oauth2_client_credentials_tokens( def oauth2_start_flow( self, redirect_uri: str, - requested_scopes: ScopeCollectionType | None = None, + requested_scopes: str | Scope | t.Iterable[str | Scope], *, state: str = "_default", refresh_tokens: bool = False, @@ -191,7 +149,7 @@ def oauth2_get_dependent_tokens( token: str, *, refresh_tokens: bool = False, - scope: str | t.Iterable[str] | utils.MissingType = utils.MISSING, + scope: str | t.Iterable[str] | MissingType = MISSING, additional_params: dict[str, t.Any] | None = None, ) -> OAuthDependentTokenResponse: """ @@ -259,24 +217,20 @@ def oauth2_get_dependent_tokens( form_data = { "grant_type": "urn:globus:auth:grant_type:dependent_token", "token": token, + # the internal parameter is 'access_type', but using the name + # 'refresh_tokens' is consistent with the rest of the SDK and better + # communicates expectations back to the user than the OAuth2 spec wording + "access_type": "offline" if refresh_tokens else MISSING, + "scope": (" ".join(strseq_iter(scope)) if scope is not MISSING else scope), + **(additional_params or {}), } - # the internal parameter is 'access_type', but using the name 'refresh_tokens' - # is consistent with the rest of the SDK and better communicates expectations - # back to the user than the OAuth2 spec wording - if refresh_tokens: - form_data["access_type"] = "offline" - if not isinstance(scope, utils.MissingType): - form_data["scope"] = " ".join(utils.safe_strseq_iter(scope)) - if additional_params: - form_data.update(additional_params) - return self.oauth2_token(form_data, response_class=OAuthDependentTokenResponse) def oauth2_token_introspect( self, token: str, *, - include: str | None = None, + include: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ @@ -315,9 +269,10 @@ def oauth2_token_introspect( :ref: auth/reference/#token_introspection_post_v2_oauth2_token_introspect """ # noqa: E501 log.debug("Checking token validity (introspect)") - body = {"token": token} - if include is not None: - body["include"] = include + body = { + "token": token, + "include": include, + } return self.post( "/v2/oauth2/token/introspect", data=body, @@ -329,7 +284,7 @@ def create_child_client( self, name: str, *, - public_client: bool | utils.MissingType = utils.MISSING, + public_client: bool | MissingType = MISSING, client_type: ( t.Literal[ "client_identity", @@ -339,15 +294,15 @@ def create_child_client( "hybrid_confidential_client_resource_server", "resource_server", ] - | utils.MissingType - ) = utils.MISSING, - visibility: t.Literal["public", "private"] | utils.MissingType = utils.MISSING, - redirect_uris: t.Iterable[str] | utils.MissingType = utils.MISSING, - terms_and_conditions: str | utils.MissingType = utils.MISSING, - privacy_policy: str | utils.MissingType = utils.MISSING, - required_idp: uuid.UUID | str | utils.MissingType = utils.MISSING, - preselect_idp: uuid.UUID | str | utils.MissingType = utils.MISSING, - additional_fields: dict[str, t.Any] | utils.MissingType = utils.MISSING, + | MissingType + ) = MISSING, + visibility: t.Literal["public", "private"] | MissingType = MISSING, + redirect_uris: t.Iterable[str] | MissingType = MISSING, + terms_and_conditions: str | MissingType = MISSING, + privacy_policy: str | MissingType = MISSING, + required_idp: uuid.UUID | str | MissingType = MISSING, + preselect_idp: uuid.UUID | str | MissingType = MISSING, + additional_fields: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ Create a new client. Requires the ``manage_projects`` scope. @@ -432,16 +387,27 @@ def create_child_client( :ref: auth/reference/#create_client """ # Must specify exactly one of public_client or client_type - if public_client is not utils.MISSING and client_type is not utils.MISSING: + if public_client is not MISSING and client_type is not MISSING: raise exc.GlobusSDKUsageError( "AuthClient.create_client does not take both " "'public_client' and 'client_type'. These are mutually exclusive." ) - if public_client is utils.MISSING and client_type is utils.MISSING: + if public_client is MISSING and client_type is MISSING: raise exc.GlobusSDKUsageError( "AuthClient.create_client requires either 'public_client' or " "'client_type'." ) + # terms_and_conditions and privacy_policy must both be set or unset + if bool(terms_and_conditions) ^ bool(privacy_policy): + raise exc.GlobusSDKUsageError( + "terms_and_conditions and privacy_policy must both be set or unset" + ) + links: dict[str, str | MissingType] | MissingType = MISSING + if terms_and_conditions and privacy_policy: + links = { + "terms_and_conditions": terms_and_conditions, + "privacy_policy": privacy_policy, + } body: dict[str, t.Any] = { "name": name, @@ -450,23 +416,9 @@ def create_child_client( "preselect_idp": preselect_idp, "public_client": public_client, "client_type": client_type, + "redirect_uris": strseq_listify(redirect_uris), + "links": links, + **(additional_fields or {}), } - if not isinstance(redirect_uris, utils.MissingType): - body["redirect_uris"] = list(utils.safe_strseq_iter(redirect_uris)) - - # terms_and_conditions and privacy_policy must both be set or unset - if bool(terms_and_conditions) ^ bool(privacy_policy): - raise exc.GlobusSDKUsageError( - "terms_and_conditions and privacy_policy must both be set or unset" - ) - links: dict[str, str | utils.MissingType] = { - "terms_and_conditions": terms_and_conditions, - "privacy_policy": privacy_policy, - } - if terms_and_conditions or privacy_policy: - body["links"] = links - - if not isinstance(additional_fields, utils.MissingType): - body.update(additional_fields) return self.post("/v2/api/clients", data={"client": body}) diff --git a/src/globus_sdk/services/auth/client/native_client.py b/src/globus_sdk/services/auth/client/native_client.py index eea469494..3c23d0a22 100644 --- a/src/globus_sdk/services/auth/client/native_client.py +++ b/src/globus_sdk/services/auth/client/native_client.py @@ -4,9 +4,11 @@ import typing as t import uuid -from globus_sdk._types import ScopeCollectionType +from globus_sdk._missing import MISSING, MissingType from globus_sdk.authorizers import NullAuthorizer from globus_sdk.response import GlobusHTTPResponse +from globus_sdk.scopes import Scope +from globus_sdk.transport import RequestsTransport, RetryConfig from ..flow_managers import GlobusNativeAppFlowManager from ..response import OAuthRefreshTokenResponse @@ -38,7 +40,8 @@ def __init__( environment: str | None = None, base_url: str | None = None, app_name: str | None = None, - transport_params: dict[str, t.Any] | None = None, + transport: RequestsTransport | None = None, + retry_config: RetryConfig | None = None, ) -> None: super().__init__( client_id=client_id, @@ -46,18 +49,19 @@ def __init__( environment=environment, base_url=base_url, app_name=app_name, - transport_params=transport_params, + transport=transport, + retry_config=retry_config, ) def oauth2_start_flow( self, - requested_scopes: ScopeCollectionType | None = None, + requested_scopes: str | Scope | t.Iterable[str | Scope], *, - redirect_uri: str | None = None, + redirect_uri: str | MissingType = MISSING, state: str = "_default", - verifier: str | None = None, + verifier: str | MissingType = MISSING, refresh_tokens: bool = False, - prefill_named_grant: str | None = None, + prefill_named_grant: str | MissingType = MISSING, ) -> GlobusNativeAppFlowManager: """ Starts a Native App OAuth2 flow. diff --git a/src/globus_sdk/services/auth/client/service_client.py b/src/globus_sdk/services/auth/client/service_client.py index fedb83a74..7e4e8d77d 100644 --- a/src/globus_sdk/services/auth/client/service_client.py +++ b/src/globus_sdk/services/auth/client/service_client.py @@ -1,16 +1,18 @@ from __future__ import annotations -import functools import logging import typing as t import uuid from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey -from globus_sdk import client, exc, utils +from globus_sdk import client, exc +from globus_sdk._internal.remarshal import commajoin, strseq_listify +from globus_sdk._missing import MISSING, MissingType from globus_sdk.authorizers import GlobusAuthorizer from globus_sdk.response import GlobusHTTPResponse, IterableResponse from globus_sdk.scopes import AuthScopes, Scope +from globus_sdk.transport import RequestsTransport, RetryConfig if t.TYPE_CHECKING: from globus_sdk.globus_app import GlobusApp @@ -34,44 +36,6 @@ F = t.TypeVar("F", bound=t.Callable[..., GlobusHTTPResponse]) -def _create_policy_compat(f: F) -> F: - @functools.wraps(f) - def wrapper(self: t.Any, *args: t.Any, **kwargs: t.Any) -> t.Any: - if args: - if len(args) > 5: - raise TypeError( - "create_policy() takes 5 positional arguments " - f"but {len(args)} were given" - ) - - exc.warn_deprecated( - "'AuthClient.create_policy' received positional arguments. " - "Use only keyword arguments instead." - ) - - for argname, argvalue in zip( - ( - "project_id", - "high_assurance", - "authentication_assurance_timeout", - "required_mfa", - "display_name", - "description", - ), - args, - ): - if argname in kwargs: - raise TypeError( - f"create_policy() got multiple values for argument '{argname}'" - ) - else: - kwargs[argname] = argvalue - - return f(self, **kwargs) - - return t.cast(F, wrapper) - - class AuthClient(client.BaseClient): """ A client for using the @@ -102,21 +66,21 @@ class AuthClient(client.BaseClient): error_class = AuthAPIError scopes = AuthScopes default_scope_requirements = [ - Scope(AuthScopes.openid), - Scope(AuthScopes.profile), - Scope(AuthScopes.email), + AuthScopes.openid, + AuthScopes.profile, + AuthScopes.email, ] def __init__( self, - client_id: uuid.UUID | str | None = None, environment: str | None = None, base_url: str | None = None, app: GlobusApp | None = None, app_scopes: list[Scope] | None = None, authorizer: GlobusAuthorizer | None = None, app_name: str | None = None, - transport_params: dict[str, t.Any] | None = None, + transport: RequestsTransport | None = None, + retry_config: RetryConfig | None = None, ) -> None: super().__init__( environment=environment, @@ -125,40 +89,10 @@ def __init__( app_scopes=app_scopes, authorizer=authorizer, app_name=app_name, - transport_params=transport_params, + transport=transport, + retry_config=retry_config, ) - self._client_id = str(client_id) if client_id is not None else None - if client_id is not None: - exc.warn_deprecated( - "The client_id parameter is no longer accepted by `AuthClient` / " - "`AuthClient`. When creating a client which represents an " - "application, use `NativeAppAuthClient` or " - "`ConfidentialAppAuthClient` instead." - ) - - # this attribute is preserved for compatibility, but will be removed in a - # future release - @property - def client_id(self) -> str | None: - exc.warn_deprecated( - "The client_id attribute on `AuthClient` / " - "`AuthClient` is deprecated. " - "For clients with client IDs, use `NativeAppAuthClient` or " - "`ConfidentialAppAuthClient` instead." - ) - return self._client_id - - @client_id.setter - def client_id(self, value: uuid.UUID | str) -> None: - exc.warn_deprecated( - "The client_id attribute on `AuthClient` / " - "`AuthClient` is deprecated. " - "For clients with client IDs, use `NativeAppAuthClient` or " - "`ConfidentialAppAuthClient` instead." - ) - self._client_id = str(value) if value is not None else None - # FYI: this get_openid_configuration method is duplicated in AuthLoginBaseClient # if this code is modified, please update that copy as well # this will ideally be resolved in a future SDK version by making this the only copy @@ -173,7 +107,7 @@ def get_openid_configuration(self) -> GlobusHTTPResponse: @t.overload def get_jwk( self, - openid_configuration: None | GlobusHTTPResponse | dict[str, t.Any], + openid_configuration: GlobusHTTPResponse | dict[str, t.Any] | MissingType, *, as_pem: t.Literal[True], ) -> RSAPublicKey: ... @@ -181,7 +115,7 @@ def get_jwk( @t.overload def get_jwk( self, - openid_configuration: None | GlobusHTTPResponse | dict[str, t.Any], + openid_configuration: GlobusHTTPResponse | dict[str, t.Any] | MissingType, *, as_pem: t.Literal[False], ) -> dict[str, t.Any]: ... @@ -191,7 +125,9 @@ def get_jwk( # this will ideally be resolved in a future SDK version by making this the only copy def get_jwk( self, - openid_configuration: None | GlobusHTTPResponse | dict[str, t.Any] = None, + openid_configuration: ( + GlobusHTTPResponse | dict[str, t.Any] | MissingType + ) = MISSING, *, as_pem: bool = False, ) -> RSAPublicKey | dict[str, t.Any]: @@ -206,7 +142,7 @@ def get_jwk( :param as_pem: Decode the JWK to an RSA PEM key, typically for JWT decoding :type as_pem: bool """ - if openid_configuration is None: + if openid_configuration is MISSING: log.debug("No OIDC Config provided, autofetching...") openid_configuration = self.get_openid_configuration() jwk_data = get_jwk_data( @@ -248,20 +184,11 @@ def userinfo(self) -> GlobusHTTPResponse: log.debug("Looking up OIDC-style Userinfo from Globus Auth") return self.get("/v2/oauth2/userinfo") - def oauth2_userinfo(self) -> GlobusHTTPResponse: - """ - A deprecated alias for ``userinfo``. - """ - exc.warn_deprecated( - "The method `oauth2_userinfo` is deprecated. Use `userinfo` instead." - ) - return self.userinfo() - def get_identities( self, *, - usernames: t.Iterable[str] | str | None = None, - ids: t.Iterable[uuid.UUID | str] | uuid.UUID | str | None = None, + usernames: t.Iterable[str] | str | MissingType = MISSING, + ids: t.Iterable[uuid.UUID | str] | uuid.UUID | str | MissingType = MISSING, provision: bool = False, query_params: dict[str, t.Any] | None = None, ) -> GetIdentitiesResponse: @@ -346,26 +273,27 @@ def get_identities( log.debug("Looking up Globus Auth Identities") - if query_params is None: - query_params = {} + query_params = { + "usernames": commajoin(usernames), + "ids": commajoin(ids), + # only specify `provision` if `usernames` is given + "provision": ( + str(provision).lower() if usernames is not MISSING else MISSING + ), + **(query_params or {}), + } - # if either of these params has a truthy value, stringify it - if usernames: - query_params["usernames"] = utils.commajoin(usernames) - query_params["provision"] = ( - "false" if str(provision).lower() == "false" else "true" + if ( + query_params["usernames"] is not MISSING + and query_params["ids"] is not MISSING + ): + log.warning( + "get_identities called with both usernames and " + "identities set! Expecting an error." ) - if ids: - query_params["ids"] = utils.commajoin(ids) log.debug(f"query_params={query_params}") - if "usernames" in query_params and "ids" in query_params: - log.warning( - "get_identities call with both usernames and " - "identities set! Expected to result in errors" - ) - return GetIdentitiesResponse( self.get("/v2/api/identities", query_params=query_params) ) @@ -373,8 +301,8 @@ def get_identities( def get_identity_providers( self, *, - domains: t.Iterable[str] | str | None = None, - ids: t.Iterable[uuid.UUID | str] | uuid.UUID | str | None = None, + domains: t.Iterable[str] | str | MissingType = MISSING, + ids: t.Iterable[uuid.UUID | str] | uuid.UUID | str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> GetIdentityProvidersResponse: r""" @@ -439,28 +367,23 @@ def get_identity_providers( log.debug("Looking up Globus Auth Identity Providers") - if query_params is None: - query_params = {} - - if domains is not None and ids is not None: + if domains is not MISSING and ids is not MISSING: raise exc.GlobusSDKUsageError( "AuthClient.get_identity_providers does not take both " "'domains' and 'ids'. These are mutually exclusive." ) - # if either of these params has a truthy value, stringify it - # this handles lists of values as well as individual values gracefully - # letting us consume args whose `__str__` methods produce "the right - # thing" - elif domains is not None: - query_params["domains"] = utils.commajoin(domains) - elif ids is not None: - query_params["ids"] = utils.commajoin(ids) - else: + elif domains is MISSING and ids is MISSING: log.warning( - "neither 'domains' nor 'ids' provided to get_identity_providers(). " + "Neither 'domains' nor 'ids' provided to get_identity_providers(). " "This can only succeed if 'query_params' were given." ) + query_params = { + "domains": commajoin(domains), + "ids": commajoin(ids), + **(query_params or {}), + } + log.debug(f"query_params={query_params}") return GetIdentityProvidersResponse( self.get("/v2/api/identity_providers", query_params=query_params) @@ -571,8 +494,12 @@ def create_project( display_name: str, contact_email: str, *, - admin_ids: uuid.UUID | str | t.Iterable[uuid.UUID | str] | None = None, - admin_group_ids: uuid.UUID | str | t.Iterable[uuid.UUID | str] | None = None, + admin_ids: ( + uuid.UUID | str | t.Iterable[uuid.UUID | str] | MissingType + ) = MISSING, + admin_group_ids: ( + uuid.UUID | str | t.Iterable[uuid.UUID | str] | MissingType + ) = MISSING, ) -> GlobusHTTPResponse: """ Create a new project. Requires the ``manage_projects`` scope. @@ -596,7 +523,7 @@ def create_project( .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) - >>> userinfo = ac.oauth2_userinfo() + >>> userinfo = ac.userinfo() >>> identity_id = userinfo["sub"] >>> email = userinfo["email"] >>> r = ac.create_project( @@ -617,24 +544,26 @@ def create_project( .. extdoclink:: Create Project :ref: auth/reference/#create_project """ - body: dict[str, t.Any] = { + body = { "display_name": display_name, "contact_email": contact_email, + "admin_ids": strseq_listify(admin_ids), + "admin_group_ids": strseq_listify(admin_group_ids), } - if admin_ids is not None: - body["admin_ids"] = list(utils.safe_strseq_iter(admin_ids)) - if admin_group_ids is not None: - body["admin_group_ids"] = list(utils.safe_strseq_iter(admin_group_ids)) return self.post("/v2/api/projects", data={"project": body}) def update_project( self, project_id: uuid.UUID | str, *, - display_name: str | None = None, - contact_email: str | None = None, - admin_ids: uuid.UUID | str | t.Iterable[uuid.UUID | str] | None = None, - admin_group_ids: uuid.UUID | str | t.Iterable[uuid.UUID | str] | None = None, + display_name: str | MissingType = MISSING, + contact_email: str | MissingType = MISSING, + admin_ids: ( + uuid.UUID | str | t.Iterable[uuid.UUID | str] | MissingType + ) = MISSING, + admin_group_ids: ( + uuid.UUID | str | t.Iterable[uuid.UUID | str] | MissingType + ) = MISSING, ) -> GlobusHTTPResponse: """ Update a project. Requires the ``manage_projects`` scope. @@ -657,7 +586,7 @@ def update_project( >>> ac = globus_sdk.AuthClient(...) >>> project_id = ... - >>> userinfo = ac.oauth2_userinfo() + >>> userinfo = ac.userinfo() >>> email = userinfo["email"] >>> r = ac.update_project(project_id, contact_email=email) @@ -672,15 +601,12 @@ def update_project( .. extdoclink:: Update Project :ref: auth/reference/#update_project """ - body: dict[str, t.Any] = {} - if display_name is not None: - body["display_name"] = display_name - if contact_email is not None: - body["contact_email"] = contact_email - if admin_ids is not None: - body["admin_ids"] = list(utils.safe_strseq_iter(admin_ids)) - if admin_group_ids is not None: - body["admin_group_ids"] = list(utils.safe_strseq_iter(admin_group_ids)) + body = { + "display_name": display_name, + "contact_email": contact_email, + "admin_ids": strseq_listify(admin_ids), + "admin_group_ids": strseq_listify(admin_group_ids), + } return self.put(f"/v2/api/projects/{project_id}", data={"project": body}) def delete_project(self, project_id: uuid.UUID | str) -> GlobusHTTPResponse: @@ -808,36 +734,31 @@ def get_policies(self) -> IterableResponse: """ return GetPoliciesResponse(self.get("/v2/api/policies")) - @_create_policy_compat - def create_policy( # pylint: disable=missing-param-doc + def create_policy( self, *, project_id: uuid.UUID | str, display_name: str, description: str, - high_assurance: bool | utils.MissingType = utils.MISSING, - authentication_assurance_timeout: int | utils.MissingType = utils.MISSING, - required_mfa: bool | utils.MissingType = utils.MISSING, - domain_constraints_include: ( - t.Iterable[str] | None | utils.MissingType - ) = utils.MISSING, - domain_constraints_exclude: ( - t.Iterable[str] | None | utils.MissingType - ) = utils.MISSING, + high_assurance: bool | MissingType = MISSING, + authentication_assurance_timeout: int | MissingType = MISSING, + required_mfa: bool | MissingType = MISSING, + domain_constraints_include: t.Iterable[str] | None | MissingType = MISSING, + domain_constraints_exclude: t.Iterable[str] | None | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Create a new Auth policy. Requires the ``manage_projects`` scope. :param project_id: ID of the project for the new policy + :param display_name: A user-friendly name for the policy + :param description: A user-friendly description to explain the purpose of the + policy :param high_assurance: Whether or not this policy is applied to sessions. :param authentication_assurance_timeout: Number of seconds within which someone must have authenticated to satisfy the policy :param required_mfa: If True, then multi-factor authentication is required. This can only be set to True for high-assurance policies. The default is False. - :param display_name: A user-friendly name for the policy - :param description: A user-friendly description to explain the purpose of the - policy :param domain_constraints_include: A list of domains that can satisfy the policy :param domain_constraints_exclude: A list of domains that cannot satisfy the policy @@ -857,7 +778,6 @@ def create_policy( # pylint: disable=missing-param-doc .. code-block:: pycon >>> ac = globus_sdk.AuthClient(...) - >>> client_id = ... >>> r = ac.create_policy( ... project_id="da84e531-1afb-43cb-8c87-135ab580516a", ... high_assurance=True, @@ -887,8 +807,8 @@ def create_policy( # pylint: disable=missing-param-doc "required_mfa": required_mfa, "display_name": display_name, "description": description, - "domain_constraints_include": domain_constraints_include, - "domain_constraints_exclude": domain_constraints_exclude, + "domain_constraints_include": strseq_listify(domain_constraints_include), + "domain_constraints_exclude": strseq_listify(domain_constraints_exclude), } return self.post("/v2/api/policies", data={"policy": body}) @@ -897,17 +817,13 @@ def update_policy( self, policy_id: uuid.UUID | str, *, - project_id: uuid.UUID | str | utils.MissingType = utils.MISSING, - authentication_assurance_timeout: int | utils.MissingType = utils.MISSING, - required_mfa: bool | utils.MissingType = utils.MISSING, - display_name: str | utils.MissingType = utils.MISSING, - description: str | utils.MissingType = utils.MISSING, - domain_constraints_include: ( - t.Iterable[str] | None | utils.MissingType - ) = utils.MISSING, - domain_constraints_exclude: ( - t.Iterable[str] | None | utils.MissingType - ) = utils.MISSING, + project_id: uuid.UUID | str | MissingType = MISSING, + authentication_assurance_timeout: int | MissingType = MISSING, + required_mfa: bool | MissingType = MISSING, + display_name: str | MissingType = MISSING, + description: str | MissingType = MISSING, + domain_constraints_include: t.Iterable[str] | None | MissingType = MISSING, + domain_constraints_exclude: t.Iterable[str] | None | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Update a policy. Requires the ``manage_projects`` scope. @@ -952,8 +868,8 @@ def update_policy( "required_mfa": required_mfa, "display_name": display_name, "description": description, - "domain_constraints_include": domain_constraints_include, - "domain_constraints_exclude": domain_constraints_exclude, + "domain_constraints_include": strseq_listify(domain_constraints_include), + "domain_constraints_exclude": strseq_listify(domain_constraints_exclude), "project_id": project_id, } return self.put(f"/v2/api/policies/{policy_id}", data={"policy": body}) @@ -990,8 +906,8 @@ def delete_policy(self, policy_id: uuid.UUID | str) -> GlobusHTTPResponse: def get_client( self, *, - client_id: uuid.UUID | str | utils.MissingType = utils.MISSING, - fqdn: str | utils.MissingType = utils.MISSING, + client_id: uuid.UUID | str | MissingType = MISSING, + fqdn: str | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Look up a client by ``client_id`` or (exclusive) by ``fqdn``. @@ -1053,18 +969,18 @@ def get_client( .. extdoclink:: Get Clients :ref: auth/reference/#get_clients """ # noqa: E501 - if client_id is not utils.MISSING and fqdn is not utils.MISSING: + if client_id is not MISSING and fqdn is not MISSING: raise exc.GlobusSDKUsageError( "AuthClient.get_client does not take both " "'client_id' and 'fqdn'. These are mutually exclusive." ) - if client_id is utils.MISSING and fqdn is utils.MISSING: + if client_id is MISSING and fqdn is MISSING: raise exc.GlobusSDKUsageError( "AuthClient.get_client requires either 'client_id' or 'fqdn'." ) - if client_id is not utils.MISSING: + if client_id is not MISSING: return self.get(f"/v2/api/clients/{client_id}") return self.get("/v2/api/clients", query_params={"fqdn": fqdn}) @@ -1141,9 +1057,9 @@ def create_client( name: str, project: uuid.UUID | str, *, - public_client: bool | utils.MissingType = utils.MISSING, + public_client: bool | MissingType = MISSING, client_type: ( - utils.MissingType + MissingType | t.Literal[ "client_identity", "confidential_client", @@ -1152,14 +1068,14 @@ def create_client( "hybrid_confidential_client_resource_server", "resource_server", ] - ) = utils.MISSING, - visibility: utils.MissingType | t.Literal["public", "private"] = utils.MISSING, - redirect_uris: t.Iterable[str] | utils.MissingType = utils.MISSING, - terms_and_conditions: str | utils.MissingType = utils.MISSING, - privacy_policy: str | utils.MissingType = utils.MISSING, - required_idp: uuid.UUID | str | utils.MissingType = utils.MISSING, - preselect_idp: uuid.UUID | str | utils.MissingType = utils.MISSING, - additional_fields: dict[str, t.Any] | utils.MissingType = utils.MISSING, + ) = MISSING, + visibility: MissingType | t.Literal["public", "private"] = MISSING, + redirect_uris: t.Iterable[str] | MissingType = MISSING, + terms_and_conditions: str | MissingType = MISSING, + privacy_policy: str | MissingType = MISSING, + required_idp: uuid.UUID | str | MissingType = MISSING, + preselect_idp: uuid.UUID | str | MissingType = MISSING, + additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Create a new client. Requires the ``manage_projects`` scope. @@ -1247,12 +1163,12 @@ def create_client( :ref: auth/reference/#create_client """ # Must specify exactly one of public_client or client_type - if public_client is not utils.MISSING and client_type is not utils.MISSING: + if public_client is not MISSING and client_type is not MISSING: raise exc.GlobusSDKUsageError( "AuthClient.create_client does not take both " "'public_client' and 'client_type'. These are mutually exclusive." ) - if public_client is utils.MISSING and client_type is utils.MISSING: + if public_client is MISSING and client_type is MISSING: raise exc.GlobusSDKUsageError( "AuthClient.create_client requires either 'public_client' or " "'client_type'." @@ -1273,14 +1189,14 @@ def create_client( raise exc.GlobusSDKUsageError( "terms_and_conditions and privacy_policy must both be set or unset" ) - links: dict[str, str | utils.MissingType] = { + links: dict[str, str | MissingType] = { "terms_and_conditions": terms_and_conditions, "privacy_policy": privacy_policy, } if terms_and_conditions or privacy_policy: body["links"] = links - if not isinstance(additional_fields, utils.MissingType): + if additional_fields is not MISSING: body.update(additional_fields) return self.post("/v2/api/clients", data={"client": body}) @@ -1289,14 +1205,14 @@ def update_client( self, client_id: uuid.UUID | str, *, - name: str | utils.MissingType = utils.MISSING, - visibility: utils.MissingType | t.Literal["public", "private"] = utils.MISSING, - redirect_uris: t.Iterable[str] | utils.MissingType = utils.MISSING, - terms_and_conditions: str | None | utils.MissingType = utils.MISSING, - privacy_policy: str | None | utils.MissingType = utils.MISSING, - required_idp: uuid.UUID | str | None | utils.MissingType = utils.MISSING, - preselect_idp: uuid.UUID | str | None | utils.MissingType = utils.MISSING, - additional_fields: dict[str, t.Any] | utils.MissingType = utils.MISSING, + name: str | MissingType = MISSING, + visibility: MissingType | t.Literal["public", "private"] = MISSING, + redirect_uris: t.Iterable[str] | MissingType = MISSING, + terms_and_conditions: str | None | MissingType = MISSING, + privacy_policy: str | None | MissingType = MISSING, + required_idp: uuid.UUID | str | None | MissingType = MISSING, + preselect_idp: uuid.UUID | str | None | MissingType = MISSING, + additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Update a client. Requires the ``manage_projects`` scope. @@ -1356,17 +1272,14 @@ def update_client( raise exc.GlobusSDKUsageError( "terms_and_conditions and privacy_policy must both be set or unset" ) - links: dict[str, str | None | utils.MissingType] = { + links: dict[str, str | None | MissingType] = { "terms_and_conditions": terms_and_conditions, "privacy_policy": privacy_policy, } - if ( - terms_and_conditions is not utils.MISSING - or privacy_policy is not utils.MISSING - ): + if terms_and_conditions is not MISSING or privacy_policy is not MISSING: body["links"] = links - if not isinstance(additional_fields, utils.MissingType): + if additional_fields is not MISSING: body.update(additional_fields) return self.put(f"/v2/api/clients/{client_id}", data={"client": body}) @@ -1570,11 +1483,9 @@ def get_scope(self, scope_id: uuid.UUID | str) -> GlobusHTTPResponse: def get_scopes( self, *, - scope_strings: t.Iterable[str] | str | utils.MissingType = utils.MISSING, - ids: ( - t.Iterable[uuid.UUID | str] | uuid.UUID | str | utils.MissingType - ) = utils.MISSING, - query_params: dict[str, t.Any] | utils.MissingType = utils.MISSING, + scope_strings: t.Iterable[str] | str | MissingType = MISSING, + ids: t.Iterable[uuid.UUID | str] | uuid.UUID | str | MissingType = MISSING, + query_params: dict[str, t.Any] | MissingType = MISSING, ) -> IterableResponse: """ Look up scopes in projects on which the authenticated user is an admin. @@ -1644,19 +1555,19 @@ def get_scopes( .. extdoclink:: Get Scopes :ref: auth/reference/#get_scopes """ # noqa: E501 - if scope_strings is not utils.MISSING and ids is not utils.MISSING: + if scope_strings is not MISSING and ids is not MISSING: raise exc.GlobusSDKUsageError( "AuthClient.get_scopes does not take both " "'scopes_strings' and 'ids'. These are mutually exclusive." ) - if isinstance(query_params, utils.MissingType): + if query_params is MISSING: query_params = {} - if not isinstance(scope_strings, utils.MissingType): - query_params["scope_strings"] = utils.commajoin(scope_strings) - if not isinstance(ids, utils.MissingType): - query_params["ids"] = utils.commajoin(ids) + if scope_strings is not MISSING: + query_params["scope_strings"] = commajoin(scope_strings) + if ids is not MISSING: + query_params["ids"] = commajoin(ids) return GetScopesResponse(self.get("/v2/api/scopes", query_params=query_params)) @@ -1667,12 +1578,10 @@ def create_scope( description: str, scope_suffix: str, *, - required_domains: t.Iterable[str] | utils.MissingType = utils.MISSING, - dependent_scopes: ( - t.Iterable[DependentScopeSpec] | utils.MissingType - ) = utils.MISSING, - advertised: bool | utils.MissingType = utils.MISSING, - allows_refresh_token: bool | utils.MissingType = utils.MISSING, + required_domains: t.Iterable[str] | MissingType = MISSING, + dependent_scopes: t.Iterable[DependentScopeSpec] | MissingType = MISSING, + advertised: bool | MissingType = MISSING, + allows_refresh_token: bool | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Create a new scope. Requires the ``manage_projects`` scope. @@ -1735,15 +1644,13 @@ def update_scope( self, scope_id: uuid.UUID | str, *, - name: str | utils.MissingType = utils.MISSING, - description: str | utils.MissingType = utils.MISSING, - scope_suffix: str | utils.MissingType = utils.MISSING, - required_domains: t.Iterable[str] | utils.MissingType = utils.MISSING, - dependent_scopes: ( - t.Iterable[DependentScopeSpec] | utils.MissingType - ) = utils.MISSING, - advertised: bool | utils.MissingType = utils.MISSING, - allows_refresh_token: bool | utils.MissingType = utils.MISSING, + name: str | MissingType = MISSING, + description: str | MissingType = MISSING, + scope_suffix: str | MissingType = MISSING, + required_domains: t.Iterable[str] | MissingType = MISSING, + dependent_scopes: t.Iterable[DependentScopeSpec] | MissingType = MISSING, + advertised: bool | MissingType = MISSING, + allows_refresh_token: bool | MissingType = MISSING, ) -> GlobusHTTPResponse: """ Update a scope. Requires the ``manage_projects`` scope. diff --git a/src/globus_sdk/services/auth/data.py b/src/globus_sdk/services/auth/data.py index ad46aeff0..4863cce71 100644 --- a/src/globus_sdk/services/auth/data.py +++ b/src/globus_sdk/services/auth/data.py @@ -2,10 +2,10 @@ import uuid -from globus_sdk import utils +from globus_sdk._payload import GlobusPayload -class DependentScopeSpec(utils.PayloadWrapper): +class DependentScopeSpec(GlobusPayload): """ Utility class for creating dependent scope values as parameters to :meth:`AuthClient.create_scope ` diff --git a/src/globus_sdk/services/auth/flow_managers/authorization_code.py b/src/globus_sdk/services/auth/flow_managers/authorization_code.py index 939622494..3c09e636f 100644 --- a/src/globus_sdk/services/auth/flow_managers/authorization_code.py +++ b/src/globus_sdk/services/auth/flow_managers/authorization_code.py @@ -4,10 +4,10 @@ import typing as t import urllib.parse -from globus_sdk import utils -from globus_sdk._types import ScopeCollectionType +from globus_sdk._internal.utils import slash_join +from globus_sdk._missing import filter_missing +from globus_sdk.scopes import Scope, ScopeParser -from .._common import stringify_requested_scopes from ..response import OAuthAuthorizationCodeResponse from .base import GlobusOAuthFlowManager @@ -36,8 +36,7 @@ class GlobusAuthorizationCodeFlowManager(GlobusOAuthFlowManager): and also to make calls to the Auth service. :param redirect_uri: The page that users should be directed to after authenticating at the authorize URL. - :param requested_scopes: The scopes on the token(s) being requested. Defaults to - ``openid profile email urn:globus:auth:scope:transfer.api.globus.org:all`` + :param requested_scopes: The scopes on the token(s) being requested. :param state: This string allows an application to pass information back to itself in the course of the OAuth flow. Because the user will navigate away from the application to complete the flow, this parameter lets the app pass an arbitrary @@ -50,13 +49,13 @@ def __init__( self, auth_client: globus_sdk.ConfidentialAppAuthClient, redirect_uri: str, - requested_scopes: ScopeCollectionType | None = None, + requested_scopes: str | Scope | t.Iterable[str | Scope], state: str = "_default", refresh_tokens: bool = False, ) -> None: # convert a scope object or iterable to string immediately on load # and default to the default requested scopes - self.requested_scopes: str = stringify_requested_scopes(requested_scopes) + self.requested_scopes: str = ScopeParser.serialize(requested_scopes) # store the remaining parameters directly, with no transformation self.client_id = auth_client.client_id @@ -87,7 +86,7 @@ def get_authorize_url(self, query_params: dict[str, t.Any] | None = None) -> str either to your provided ``redirect_uri`` or to the default location, with the ``auth_code`` embedded in a query parameter. """ - authorize_base_url = utils.slash_join( + authorize_base_url = slash_join( self.auth_client.base_url, "/v2/oauth2/authorize" ) log.debug(f"Building authorization URI. Base URL: {authorize_base_url}") @@ -100,10 +99,9 @@ def get_authorize_url(self, query_params: dict[str, t.Any] | None = None) -> str "state": self.state, "response_type": "code", "access_type": (self.refresh_tokens and "offline") or "online", + **(query_params or {}), } - if query_params: - params.update(query_params) - + params = filter_missing(params) encoded_params = urllib.parse.urlencode(params) return f"{authorize_base_url}?{encoded_params}" diff --git a/src/globus_sdk/services/auth/flow_managers/native_app.py b/src/globus_sdk/services/auth/flow_managers/native_app.py index 0df0b0552..39e09d6ef 100644 --- a/src/globus_sdk/services/auth/flow_managers/native_app.py +++ b/src/globus_sdk/services/auth/flow_managers/native_app.py @@ -8,11 +8,11 @@ import typing as t import urllib.parse -from globus_sdk import utils -from globus_sdk._types import ScopeCollectionType +from globus_sdk._internal.utils import slash_join +from globus_sdk._missing import MISSING, MissingType, filter_missing from globus_sdk.exc import GlobusSDKUsageError +from globus_sdk.scopes import Scope, ScopeParser -from .._common import stringify_requested_scopes from ..response import OAuthAuthorizationCodeResponse from .base import GlobusOAuthFlowManager @@ -22,8 +22,8 @@ log = logging.getLogger(__name__) -def make_native_app_challenge( - verifier: str | None = None, +def _make_native_app_challenge( + verifier: str | MissingType = MISSING, ) -> tuple[str, str]: """ Produce a challenge and verifier for the Native App flow. @@ -41,22 +41,24 @@ def make_native_app_challenge( contain the following characters: [a-zA-Z0-9~_.-]. """ - if verifier: + if isinstance(verifier, str): if not 43 <= len(verifier) <= 128: raise GlobusSDKUsageError( f"verifier must be 43-128 characters long: {len(verifier)}" ) if bool(re.search(r"[^a-zA-Z0-9~_.-]", verifier)): raise GlobusSDKUsageError("verifier contained invalid characters") + + code_verifier: str = verifier else: log.debug( "Autogenerating verifier secret. On low-entropy systems " "this may be insecure" ) + code_verifier = ( + base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8").rstrip("=") + ) - code_verifier = verifier or base64.urlsafe_b64encode(os.urandom(32)).decode( - "utf-8" - ).rstrip("=") # hash it, pull out a digest hashed_verifier = hashlib.sha256(code_verifier.encode("utf-8")).digest() # urlsafe base64 encode that hash and strip the padding @@ -80,8 +82,7 @@ class GlobusNativeAppFlowManager(GlobusOAuthFlowManager): :param auth_client: The client object on which this flow is based. It is used to extract default values for the flow, and also to make calls to the Auth service. - :param requested_scopes: The scopes on the token(s) being requested. Defaults to - ``openid profile email urn:globus:auth:scope:transfer.api.globus.org:all`` + :param requested_scopes: The scopes on the token(s) being requested. :param redirect_uri: The page that users should be directed to after authenticating at the authorize URL. Defaults to 'https://auth.globus.org/v2/web/auth-code', which displays the resulting ``auth_code`` for users to copy-paste back into @@ -101,12 +102,12 @@ class GlobusNativeAppFlowManager(GlobusOAuthFlowManager): def __init__( self, auth_client: globus_sdk.NativeAppAuthClient, - requested_scopes: ScopeCollectionType | None = None, - redirect_uri: str | None = None, + requested_scopes: str | Scope | t.Iterable[str | Scope], + redirect_uri: str | MissingType = MISSING, state: str = "_default", - verifier: str | None = None, + verifier: str | MissingType = MISSING, refresh_tokens: bool = False, - prefill_named_grant: str | None = None, + prefill_named_grant: str | MissingType = MISSING, ) -> None: self.auth_client = auth_client @@ -123,19 +124,18 @@ def __init__( ) # convert scopes iterable to string immediately on load - # and default to the default requested scopes - self.requested_scopes = stringify_requested_scopes(requested_scopes) + self.requested_scopes = ScopeParser.serialize(requested_scopes) # default to `/v2/web/auth-code` on whatever environment we're looking # at -- most typically it will be `https://auth.globus.org/` self.redirect_uri = redirect_uri or ( - utils.slash_join(auth_client.base_url, "/v2/web/auth-code") + slash_join(auth_client.base_url, "/v2/web/auth-code") ) # make a challenge and secret to keep # if the verifier is provided, it will just be passed back to us, and # if not, one will be generated - self.verifier, self.challenge = make_native_app_challenge(verifier) + self.verifier, self.challenge = _make_native_app_challenge(verifier) # store the remaining parameters directly, with no transformation self.refresh_tokens = refresh_tokens @@ -152,7 +152,7 @@ def __init__( f"verifier=,challenge={self.challenge}" ) - if prefill_named_grant is not None: + if prefill_named_grant is not MISSING: log.debug(f"prefill_named_grant={self.prefill_named_grant}") def get_authorize_url(self, query_params: dict[str, t.Any] | None = None) -> str: @@ -168,7 +168,7 @@ def get_authorize_url(self, query_params: dict[str, t.Any] | None = None) -> str either to your provided ``redirect_uri`` or to the default location, with the ``auth_code`` embedded in a query parameter. """ - authorize_base_url = utils.slash_join( + authorize_base_url = slash_join( self.auth_client.base_url, "/v2/oauth2/authorize" ) log.debug(f"Building authorization URI. Base URL: {authorize_base_url}") @@ -183,11 +183,10 @@ def get_authorize_url(self, query_params: dict[str, t.Any] | None = None) -> str "code_challenge": self.challenge, "code_challenge_method": "S256", "access_type": (self.refresh_tokens and "offline") or "online", + "prefill_named_grant": self.prefill_named_grant, + **(query_params or {}), } - if self.prefill_named_grant is not None: - params["prefill_named_grant"] = self.prefill_named_grant - if query_params: - params.update(query_params) + params = filter_missing(params) encoded_params = urllib.parse.urlencode(params) return f"{authorize_base_url}?{encoded_params}" diff --git a/src/globus_sdk/services/auth/identity_map.py b/src/globus_sdk/services/auth/identity_map.py index fe8fe5c52..0e939541b 100644 --- a/src/globus_sdk/services/auth/identity_map.py +++ b/src/globus_sdk/services/auth/identity_map.py @@ -3,7 +3,7 @@ import typing as t import uuid -from .client import AuthClient, ConfidentialAppAuthClient +from .client import AuthClient def is_username(val: str) -> bool: @@ -131,7 +131,7 @@ class IdentityMap: def __init__( self, - auth_client: AuthClient | ConfidentialAppAuthClient, + auth_client: AuthClient, identity_ids: t.Iterable[str] | None = None, *, id_batch_size: int | None = None, diff --git a/src/globus_sdk/services/compute/__init__.py b/src/globus_sdk/services/compute/__init__.py index dd36e7d59..f8fc864af 100644 --- a/src/globus_sdk/services/compute/__init__.py +++ b/src/globus_sdk/services/compute/__init__.py @@ -1,11 +1,8 @@ from .client import ComputeClientV2, ComputeClientV3 -from .data import ComputeFunctionDocument, ComputeFunctionMetadata from .errors import ComputeAPIError __all__ = ( "ComputeAPIError", "ComputeClientV2", "ComputeClientV3", - "ComputeFunctionDocument", - "ComputeFunctionMetadata", ) diff --git a/src/globus_sdk/services/compute/client.py b/src/globus_sdk/services/compute/client.py index 76026472d..d67916e3e 100644 --- a/src/globus_sdk/services/compute/client.py +++ b/src/globus_sdk/services/compute/client.py @@ -4,8 +4,10 @@ import typing as t import uuid -from globus_sdk import MISSING, GlobusHTTPResponse, MissingType, client, utils -from globus_sdk.scopes import ComputeScopes, Scope +from globus_sdk import GlobusHTTPResponse, client +from globus_sdk._internal.remarshal import strseq_listify +from globus_sdk._missing import MISSING, MissingType +from globus_sdk.scopes import ComputeScopes from .errors import ComputeAPIError @@ -24,9 +26,9 @@ class ComputeClientV2(client.BaseClient): error_class = ComputeAPIError service_name = "compute" scopes = ComputeScopes - default_scope_requirements = [Scope(ComputeScopes.all)] + default_scope_requirements = [ComputeScopes.all] - def get_version(self, service: str | None = None) -> GlobusHTTPResponse: + def get_version(self, service: str | MissingType = MISSING) -> GlobusHTTPResponse: """Get the current version of the API and other services. :param service: Service for which to get version information. @@ -39,7 +41,7 @@ def get_version(self, service: str | None = None) -> GlobusHTTPResponse: :service: compute :ref: Root/operation/get_version_v2_version_get """ - query_params = {"service": service} if service else None + query_params = {"service": service} return self.get("/v2/version", query_params=query_params) def get_result_amqp_url(self) -> GlobusHTTPResponse: @@ -147,13 +149,10 @@ def lock_endpoint(self, endpoint_id: uuid.UUID | str) -> GlobusHTTPResponse: """ # noqa: E501 return self.post(f"/v2/endpoints/{endpoint_id}/lock") - def register_function( - self, - function_data: dict[str, t.Any], - ) -> GlobusHTTPResponse: + def register_function(self, data: dict[str, t.Any]) -> GlobusHTTPResponse: """Register a new function. - :param function_data: A function registration document. + :param data: A function registration document. .. tab-set:: @@ -163,7 +162,7 @@ def register_function( :service: compute :ref: Functions/operation/register_function_v2_functions_post """ # noqa: E501 - return self.post("/v2/functions", data=function_data) + return self.post("/v2/functions", data=data) def get_function(self, function_id: uuid.UUID | str) -> GlobusHTTPResponse: """Get information about a registered function. @@ -225,8 +224,9 @@ def get_task_batch( :service: compute :ref: Root/operation/get_batch_status_v2_batch_status_post """ - task_ids = list(utils.safe_strseq_iter(task_ids)) - return self.post("/v2/batch_status", data={"task_ids": task_ids}) + return self.post( + "/v2/batch_status", data={"task_ids": strseq_listify(task_ids)} + ) def get_task_group(self, task_group_id: uuid.UUID | str) -> GlobusHTTPResponse: """Get a list of task IDs associated with a task group. @@ -269,7 +269,7 @@ class ComputeClientV3(client.BaseClient): error_class = ComputeAPIError service_name = "compute" scopes = ComputeScopes - default_scope_requirements = [Scope(ComputeScopes.all)] + default_scope_requirements = [ComputeScopes.all] def register_endpoint(self, data: dict[str, t.Any]) -> GlobusHTTPResponse: """Register a new endpoint. diff --git a/src/globus_sdk/services/compute/data.py b/src/globus_sdk/services/compute/data.py deleted file mode 100644 index d6d50a643..000000000 --- a/src/globus_sdk/services/compute/data.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -import uuid - -from globus_sdk import utils -from globus_sdk.exc import warn_deprecated -from globus_sdk.utils import MISSING, MissingType - - -class ComputeFunctionMetadata(utils.PayloadWrapper): - """ - .. warning:: - - This class is deprecated. - - A wrapper for function metadata. - - :param python_version: The Python version used to serialize the function. - :param sdk_version: The Globus Compute SDK version used to serialize the function. - """ - - def __init__( - self, - *, - python_version: str | MissingType = MISSING, - sdk_version: str | MissingType = MISSING, - ) -> None: - warn_deprecated("ComputeFunctionMetadata is deprecated.") - super().__init__() - self["python_version"] = python_version - self["sdk_version"] = sdk_version - - -class ComputeFunctionDocument(utils.PayloadWrapper): - """ - .. warning:: - - This class is deprecated. - - A function registration document. - - :param function_name: The name of the function. - :param function_code: The serialized function source code. - :param description: The description of the function. - :param metadata: The metadata of the function. - :param group: Restrict function access to members of this Globus group. - :param public: Indicates whether the function is public. - """ - - def __init__( - self, - *, - function_name: str, - function_code: str, - description: str | MissingType = MISSING, - metadata: ComputeFunctionMetadata | MissingType = MISSING, - group: uuid.UUID | str | MissingType = MISSING, - public: bool = False, - ) -> None: - warn_deprecated("ComputeFunctionDocument is deprecated.") - super().__init__() - self["function_name"] = function_name - self["function_code"] = function_code - self["description"] = description - self["metadata"] = metadata - self["group"] = group - self["public"] = public diff --git a/src/globus_sdk/services/compute/deprecated_client.py b/src/globus_sdk/services/compute/deprecated_client.py deleted file mode 100644 index 9c2d6f7ad..000000000 --- a/src/globus_sdk/services/compute/deprecated_client.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -import sys -import typing as t - -from globus_sdk.exc import warn_deprecated - -from .client import ComputeClientV2 - -__all__ = ("ComputeClient",) - -if t.TYPE_CHECKING: - - class ComputeClient(ComputeClientV2): - pass - -else: - - def __getattr__(name: str) -> t.Any: - if name == "ComputeClient": - warn_deprecated( - "'globus_sdk.ComputeClient' is deprecated and will be removed " - "in the future. Prefer 'globus_sdk.ComputeClientV2'." - ) - - class ComputeClient(ComputeClientV2): - """A deprecated alias for 'globus_sdk.ComputeClientV2'.""" - - setattr(sys.modules[__name__], name, ComputeClient) - return ComputeClient - - raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/src/globus_sdk/services/flows/client.py b/src/globus_sdk/services/flows/client.py index 2e58e8406..2dbead5d1 100644 --- a/src/globus_sdk/services/flows/client.py +++ b/src/globus_sdk/services/flows/client.py @@ -1,21 +1,24 @@ from __future__ import annotations import logging +import sys import typing as t import uuid -from globus_sdk import ( - GlobusHTTPResponse, - GlobusSDKUsageError, - client, - exc, - paging, - utils, -) +from globus_sdk import GlobusHTTPResponse, client, paging +from globus_sdk._internal import guards +from globus_sdk._internal.remarshal import commajoin +from globus_sdk._missing import MISSING, MissingType from globus_sdk.authorizers import GlobusAuthorizer from globus_sdk.globus_app import GlobusApp -from globus_sdk.scopes import FlowsScopes, Scope, ScopeBuilder, SpecificFlowScopeBuilder -from globus_sdk.utils import MISSING, MissingType +from globus_sdk.scopes import ( + FlowsScopes, + GCSCollectionScopes, + Scope, + SpecificFlowScopes, + TransferScopes, +) +from globus_sdk.transport import RequestsTransport, RetryConfig from .data import RunActivityNotificationPolicy from .errors import FlowsAPIError @@ -25,6 +28,11 @@ IterableRunsResponse, ) +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + log = logging.getLogger(__name__) @@ -40,22 +48,22 @@ class FlowsClient(client.BaseClient): error_class = FlowsAPIError service_name = "flows" scopes = FlowsScopes - default_scope_requirements = [Scope(FlowsScopes.all)] + default_scope_requirements = [FlowsScopes.all] def create_flow( self, title: str, definition: dict[str, t.Any], input_schema: dict[str, t.Any], - subtitle: str | None = None, - description: str | None = None, - flow_viewers: list[str] | None = None, - flow_starters: list[str] | None = None, - flow_administrators: list[str] | None = None, - run_managers: list[str] | None = None, - run_monitors: list[str] | None = None, - keywords: list[str] | None = None, - subscription_id: uuid.UUID | str | None = None, + subtitle: str | MissingType = MISSING, + description: str | MissingType = MISSING, + flow_viewers: list[str] | MissingType = MISSING, + flow_starters: list[str] | MissingType = MISSING, + flow_administrators: list[str] | MissingType = MISSING, + run_managers: list[str] | MissingType = MISSING, + run_monitors: list[str] | MissingType = MISSING, + keywords: list[str] | MissingType = MISSING, + subscription_id: uuid.UUID | str | None | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ @@ -186,25 +194,20 @@ def create_flow( """ # noqa E501 data = { - k: v - for k, v in { - "title": title, - "definition": definition, - "input_schema": input_schema, - "subtitle": subtitle, - "description": description, - "flow_viewers": flow_viewers, - "flow_starters": flow_starters, - "flow_administrators": flow_administrators, - "run_managers": run_managers, - "run_monitors": run_monitors, - "keywords": keywords, - "subscription_id": subscription_id, - }.items() - if v is not None + "title": title, + "definition": definition, + "input_schema": input_schema, + "subtitle": subtitle, + "description": description, + "flow_viewers": flow_viewers, + "flow_starters": flow_starters, + "flow_administrators": flow_administrators, + "run_managers": run_managers, + "run_monitors": run_monitors, + "keywords": keywords, + "subscription_id": subscription_id, + **(additional_fields or {}), } - data.update(additional_fields or {}) - return self.post("/flows", data=data) def get_flow( @@ -227,32 +230,23 @@ def get_flow( :service: flows :ref: Flows/paths/~1flows~1{flow_id}/get """ - - if query_params is None: - query_params = {} - return self.get(f"/flows/{flow_id}", query_params=query_params) @paging.has_paginator(paging.MarkerPaginator, items_key="flows") def list_flows( self, *, - filter_role: str | None = None, - filter_roles: str | t.Iterable[str] | None = None, - filter_fulltext: str | None = None, - orderby: str | t.Iterable[str] | None = None, - marker: str | None = None, + filter_roles: str | t.Iterable[str] | MissingType = MISSING, + filter_fulltext: str | MissingType = MISSING, + orderby: str | t.Iterable[str] | MissingType = MISSING, + marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableFlowsResponse: """ List deployed flows - :param filter_role: (deprecated) A role name specifying the minimum permissions - required for a flow to be included in the response. Mutually exclusive with - **filter_roles**. :param filter_roles: A list of role names specifying the roles the user must - have for a flow to be included in the response. Mutually exclusive with - **filter_role**. + have for a flow to be included in the response. :param filter_fulltext: A string to use in a full-text search to filter results :param orderby: A criterion for ordering flows in the listing :param marker: A marker for pagination @@ -273,15 +267,6 @@ def list_flows( - ``run_monitor`` - ``run_manager`` - .. note:: - - The deprecated ``filter_role`` parameter has similar behavior. - - ``filter_role`` accepts exactly one role name, and filters to flows - where the caller has the specified role or a strictly weaker role. - For example, ``filter_role="flow_administrator"`` will include flows - where the caller has the ``flow_starter`` role. - **OrderBy Values** Values for ``orderby`` consist of a field name, a space, and an @@ -314,7 +299,7 @@ def list_flows( flows = FlowsClient(...) my_frobulate_flows = flows.list_flows( - filter_role="flow_owner", + filter_roles="flow_owner", filter_fulltext="frobulate", orderby=("title ASC", "updated_at DESC"), ) @@ -344,53 +329,35 @@ def list_flows( :service: flows :ref: Flows/paths/~1flows/get """ - - if query_params is None: - query_params = {} - if filter_role is not None: - exc.warn_deprecated( - "The `filter_role` parameter is deprecated. Use `filter_roles` instead." - ) - query_params["filter_role"] = filter_role - if filter_roles is not None: - query_params["filter_roles"] = utils.commajoin(filter_roles) - if filter_fulltext is not None: - query_params["filter_fulltext"] = filter_fulltext - - if filter_role is not None and filter_roles is not None: - msg = "Mutually exclusive parameters: filter_role and filter_roles." - raise GlobusSDKUsageError(msg) - - if orderby is not None: - if isinstance(orderby, str): - query_params["orderby"] = orderby - else: - # copy any input sequence to force the type to `list` which is known to - # behave well - # this also ensures that we will consume non-sequence iterables - # (e.g. generator expressions) in a well-defined way - query_params["orderby"] = list(orderby) - if marker is not None: - query_params["marker"] = marker - + query_params = { + "filter_roles": commajoin(filter_roles), + "filter_fulltext": filter_fulltext, + # if `orderby` is an iterable (e.g., generator expression), it gets + # converted to a list in this step + "orderby": ( + orderby if isinstance(orderby, (str, MissingType)) else list(orderby) + ), + "marker": marker, + **(query_params or {}), + } return IterableFlowsResponse(self.get("/flows", query_params=query_params)) def update_flow( self, flow_id: uuid.UUID | str, *, - title: str | None = None, - definition: dict[str, t.Any] | None = None, - input_schema: dict[str, t.Any] | None = None, - subtitle: str | None = None, - description: str | None = None, - flow_owner: str | None = None, - flow_viewers: list[str] | None = None, - flow_starters: list[str] | None = None, - flow_administrators: list[str] | None = None, - run_managers: list[str] | None = None, - run_monitors: list[str] | None = None, - keywords: list[str] | None = None, + title: str | MissingType = MISSING, + definition: dict[str, t.Any] | MissingType = MISSING, + input_schema: dict[str, t.Any] | MissingType = MISSING, + subtitle: str | MissingType = MISSING, + description: str | MissingType = MISSING, + flow_owner: str | MissingType = MISSING, + flow_viewers: list[str] | MissingType = MISSING, + flow_starters: list[str] | MissingType = MISSING, + flow_administrators: list[str] | MissingType = MISSING, + run_managers: list[str] | MissingType = MISSING, + run_monitors: list[str] | MissingType = MISSING, + keywords: list[str] | MissingType = MISSING, subscription_id: uuid.UUID | str | t.Literal["DEFAULT"] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: @@ -514,26 +481,21 @@ def update_flow( """ # noqa E501 data = { - k: v - for k, v in { - "title": title, - "definition": definition, - "input_schema": input_schema, - "subtitle": subtitle, - "description": description, - "flow_owner": flow_owner, - "flow_viewers": flow_viewers, - "flow_starters": flow_starters, - "flow_administrators": flow_administrators, - "run_managers": run_managers, - "run_monitors": run_monitors, - "keywords": keywords, - "subscription_id": subscription_id, - }.items() - if v is not None and v is not MISSING + "title": title, + "definition": definition, + "input_schema": input_schema, + "subtitle": subtitle, + "description": description, + "flow_owner": flow_owner, + "flow_viewers": flow_viewers, + "flow_starters": flow_starters, + "flow_administrators": flow_administrators, + "run_managers": run_managers, + "run_monitors": run_monitors, + "keywords": keywords, + "subscription_id": subscription_id, + **(additional_fields or {}), } - data.update(additional_fields or {}) - return self.put(f"/flows/{flow_id}", data=data) def delete_flow( @@ -556,10 +518,6 @@ def delete_flow( :service: flows :ref: Flows/paths/~1flows~1{flow_id}/delete """ - - if query_params is None: - query_params = {} - return self.delete(f"/flows/{flow_id}", query_params=query_params) def validate_flow( @@ -611,16 +569,21 @@ def validate_flow( :ref: Flows/paths/~1flows~1validate/post """ # noqa E501 - data = {"definition": definition, "input_schema": input_schema} + data = { + "definition": definition, + "input_schema": input_schema, + } return self.post("/flows/validate", data=data) @paging.has_paginator(paging.MarkerPaginator, items_key="runs") def list_runs( self, *, - filter_flow_id: t.Iterable[uuid.UUID | str] | uuid.UUID | str | None = None, - filter_roles: str | t.Iterable[str] | None = None, - marker: str | None = None, + filter_flow_id: ( + t.Iterable[uuid.UUID | str] | uuid.UUID | str | MissingType + ) = MISSING, + filter_roles: str | t.Iterable[str] | MissingType = MISSING, + marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableRunsResponse: """ @@ -661,16 +624,12 @@ def list_runs( :service: flows :ref: Runs/paths/~1runs/get """ - if query_params is None: - query_params = {} - if filter_flow_id is not None: - query_params["filter_flow_id"] = ",".join( - utils.safe_strseq_iter(filter_flow_id) - ) - if filter_roles: - query_params["filter_roles"] = utils.commajoin(filter_roles) - if marker is not None: - query_params["marker"] = marker + query_params = { + "filter_flow_id": commajoin(filter_flow_id), + "filter_roles": commajoin(filter_roles), + "marker": marker, + **(query_params or {}), + } return IterableRunsResponse(self.get("/runs", query_params=query_params)) @paging.has_paginator(paging.MarkerPaginator, items_key="entries") @@ -678,9 +637,9 @@ def get_run_logs( self, run_id: uuid.UUID | str, *, - limit: int | None = None, - reverse_order: bool | None = None, - marker: str | None = None, + limit: int | MissingType = MISSING, + reverse_order: bool | MissingType = MISSING, + marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableRunLogsResponse: """ @@ -719,8 +678,6 @@ def get_run_logs( "marker": marker, **(query_params or {}), } - # Filter out request keys with None values to allow server defaults - query_params = {k: v for k, v in query_params.items() if v is not None} return IterableRunLogsResponse( self.get(f"/runs/{run_id}/log", query_params=query_params) ) @@ -729,7 +686,7 @@ def get_run( self, run_id: uuid.UUID | str, *, - include_flow_description: bool | None = None, + include_flow_description: bool | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ @@ -763,11 +720,10 @@ def get_run( :service: flows :ref: Flows/paths/~1runs~1{run_id}/get """ - - query_params = query_params or {} - if include_flow_description is not None: - query_params["include_flow_description"] = include_flow_description - + query_params = { + "include_flow_description": include_flow_description, + **(query_params or {}), + } return self.get(f"/runs/{run_id}", query_params=query_params) def get_run_definition( @@ -838,10 +794,10 @@ def update_run( self, run_id: uuid.UUID | str, *, - label: str | None = None, - tags: list[str] | None = None, - run_monitors: list[str] | None = None, - run_managers: list[str] | None = None, + label: str | MissingType = MISSING, + tags: list[str] | MissingType = MISSING, + run_monitors: list[str] | MissingType = MISSING, + run_managers: list[str] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ @@ -886,17 +842,12 @@ def update_run( """ data = { - k: v - for k, v in { - "tags": tags, - "label": label, - "run_monitors": run_monitors, - "run_managers": run_managers, - }.items() - if v is not None + "tags": tags, + "label": label, + "run_monitors": run_monitors, + "run_managers": run_managers, + **(additional_fields or {}), } - data.update(additional_fields or {}) - return self.put(f"/runs/{run_id}", data=data) def delete_run(self, run_id: uuid.UUID | str) -> GlobusHTTPResponse: @@ -947,7 +898,7 @@ class SpecificFlowClient(client.BaseClient): error_class = FlowsAPIError service_name = "flows" - scopes: ScopeBuilder = SpecificFlowScopeBuilder._CLASS_STUB + scopes: SpecificFlowScopes = SpecificFlowScopes._build_class_stub() def __init__( self, @@ -958,34 +909,96 @@ def __init__( app_scopes: list[Scope] | None = None, authorizer: GlobusAuthorizer | None = None, app_name: str | None = None, - transport_params: dict[str, t.Any] | None = None, + transport: RequestsTransport | None = None, + retry_config: RetryConfig | None = None, ) -> None: self._flow_id = flow_id - self.scopes = SpecificFlowScopeBuilder(flow_id) + self.scopes = SpecificFlowScopes(flow_id) super().__init__( app=app, app_scopes=app_scopes, environment=environment, authorizer=authorizer, app_name=app_name, - transport_params=transport_params, + transport=transport, + retry_config=retry_config, ) @property def default_scope_requirements(self) -> list[Scope]: - return [Scope(self.scopes.user)] + return [self.scopes.user] + + def add_app_transfer_data_access_scope( + self, collection_ids: uuid.UUID | str | t.Iterable[uuid.UUID | str] + ) -> Self: + """ + Add a dependent ``data_access`` scope for one or more given ``collection_ids`` + to this client's ``GlobusApp``, under the Transfer ``all`` scope. + Useful for preventing ``ConsentRequired`` errors when starting or resuming runs + of flows that use Globus Connect Server mapped collection(s). + + .. warning:: + + This method must only be used on ``collection_ids`` for non-High-Assurance + GCS Mapped Collections. + + Use on other collection types, e.g., on GCP Mapped Collections or any form + of Guest Collection, will result in "Unknown Scope" errors during the login + flow. + + Returns ``self`` for chaining. + + Raises ``GlobusSDKUsageError`` if this client was not initialized with an app. + + :param collection_ids: a collection ID or an iterable of IDs. + + .. tab-set:: + + .. tab-item:: Example Usage + + .. code-block:: python + + flow_id = ... + COLLECTION_ID = ... + app = UserApp("myapp", client_id=NATIVE_APP_CLIENT_ID) + client = SpecificFlowClient(FLOW_ID, app=app).add_app_transfer_data_access_scope( + COLLECTION_ID + ) + + client.run_flow({"collection": COLLECTION_ID}) + """ # noqa: E501 + if isinstance(collection_ids, (str, uuid.UUID)): + guards.validators.uuidlike("collection_ids", collection_ids) + # wrap the collection_ids input in a list for consistent iteration below + collection_ids_ = [collection_ids] + else: + # copy to a list so that ephemeral iterables can be iterated multiple times + collection_ids_ = list(collection_ids) + for i, c in enumerate(collection_ids_): + guards.validators.uuidlike(f"collection_ids[{i}]", c) + + transfer_scope = TransferScopes.all.with_optional(True) + for coll_id in collection_ids_: + data_access_scope = GCSCollectionScopes( + str(coll_id) + ).data_access.with_optional(True) + transfer_scope = transfer_scope.with_dependency(data_access_scope) + + specific_flow_scope = self.scopes.user.with_dependency(transfer_scope) + self.add_app_scope(specific_flow_scope) + return self def run_flow( self, body: dict[str, t.Any], *, - label: str | None = None, - tags: list[str] | None = None, + label: str | MissingType = MISSING, + tags: list[str] | MissingType = MISSING, activity_notification_policy: ( - dict[str, t.Any] | RunActivityNotificationPolicy | None - ) = None, - run_monitors: list[str] | None = None, - run_managers: list[str] | None = None, + dict[str, t.Any] | RunActivityNotificationPolicy | MissingType + ) = MISSING, + run_monitors: list[str] | MissingType = MISSING, + run_managers: list[str] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> GlobusHTTPResponse: """ @@ -1014,19 +1027,14 @@ def run_flow( :ref: ~1flows~1{flow_id}~1run/post """ data = { - k: v - for k, v in { - "body": body, - "tags": tags, - "label": label, - "activity_notification_policy": activity_notification_policy, - "run_monitors": run_monitors, - "run_managers": run_managers, - }.items() - if v is not None + "body": body, + "tags": tags, + "label": label, + "activity_notification_policy": activity_notification_policy, + "run_monitors": run_monitors, + "run_managers": run_managers, + **(additional_fields or {}), } - data.update(additional_fields or {}) - return self.post(f"/flows/{self._flow_id}/run", data=data) def resume_run(self, run_id: uuid.UUID | str) -> GlobusHTTPResponse: diff --git a/src/globus_sdk/services/flows/data.py b/src/globus_sdk/services/flows/data.py index 8319eee5a..3f128a221 100644 --- a/src/globus_sdk/services/flows/data.py +++ b/src/globus_sdk/services/flows/data.py @@ -3,12 +3,13 @@ import logging import typing as t -from globus_sdk.utils import MISSING, MissingType, PayloadWrapper +from globus_sdk._missing import MISSING, MissingType +from globus_sdk._payload import GlobusPayload log = logging.getLogger(__name__) -class RunActivityNotificationPolicy(PayloadWrapper): +class RunActivityNotificationPolicy(GlobusPayload): """ A notification policy for a run, determining when emails will be sent. diff --git a/src/globus_sdk/services/flows/errors.py b/src/globus_sdk/services/flows/errors.py index 270a5adf0..e38031881 100644 --- a/src/globus_sdk/services/flows/errors.py +++ b/src/globus_sdk/services/flows/errors.py @@ -1,6 +1,6 @@ from __future__ import annotations -from globus_sdk import _guards +from globus_sdk._internal import guards from globus_sdk.exc import ErrorSubdocument, GlobusAPIError @@ -28,7 +28,7 @@ def _parse_undefined_error_format(self) -> bool: self.code = self._extract_code_from_error_array(self.errors) details = self._dict_data["error"].get("detail") - if _guards.is_list_of(details, dict): + if guards.is_list_of(details, dict): self.messages = [ error_detail["msg"] for error_detail in details diff --git a/src/globus_sdk/services/gcs/client.py b/src/globus_sdk/services/gcs/client.py index ef8143700..e1ea8a6c1 100644 --- a/src/globus_sdk/services/gcs/client.py +++ b/src/globus_sdk/services/gcs/client.py @@ -3,12 +3,16 @@ import typing as t import uuid -from globus_sdk import client, exc, paging, response, scopes, utils +from globus_sdk import client, paging, response +from globus_sdk._internal.classprop import classproperty +from globus_sdk._internal.remarshal import commajoin +from globus_sdk._internal.utils import slash_join +from globus_sdk._missing import MISSING, MissingType from globus_sdk.authorizers import GlobusAuthorizer from globus_sdk.globus_app import GlobusApp -from globus_sdk.scopes import Scope +from globus_sdk.scopes import GCSCollectionScopes, GCSEndpointScopes, Scope +from globus_sdk.transport import RequestsTransport, RetryConfig -from .connector_table import ConnectorTable from .data import ( CollectionDocument, EndpointDocument, @@ -53,7 +57,8 @@ def __init__( environment: str | None = None, authorizer: GlobusAuthorizer | None = None, app_name: str | None = None, - transport_params: dict[str, t.Any] | None = None, + transport: RequestsTransport | None = None, + retry_config: RetryConfig | None = None, ) -> None: # check if the provided address was a DNS name or an HTTPS URL if not gcs_address.startswith("https://"): @@ -62,7 +67,7 @@ def __init__( # if it was an HTTPS URL, check that it ends with /api/ elif not gcs_address.endswith(("/api/", "/api")): # if it doesn't, add it - gcs_address = utils.slash_join(gcs_address, "/api/") + gcs_address = slash_join(gcs_address, "/api/") self._endpoint_client_id: str | None = None @@ -73,76 +78,47 @@ def __init__( app_scopes=app_scopes, authorizer=authorizer, app_name=app_name, - transport_params=transport_params, + transport=transport, + retry_config=retry_config, ) @staticmethod def get_gcs_endpoint_scopes( endpoint_id: uuid.UUID | str, - ) -> scopes.GCSEndpointScopeBuilder: + ) -> GCSEndpointScopes: """Given a GCS Endpoint ID, this helper constructs an object containing the scopes for that Endpoint. :param endpoint_id: The ID of the Endpoint - See documentation for :class:`globus_sdk.scopes.GCSEndpointScopeBuilder` for + See documentation for :class:`globus_sdk.scopes.GCSEndpointScopes` for more information. """ - return scopes.GCSEndpointScopeBuilder(str(endpoint_id)) + return GCSEndpointScopes(str(endpoint_id)) @staticmethod def get_gcs_collection_scopes( collection_id: uuid.UUID | str, - ) -> scopes.GCSCollectionScopeBuilder: + ) -> GCSCollectionScopes: """Given a GCS Collection ID, this helper constructs an object containing the scopes for that Collection. :param collection_id: The ID of the Collection - See documentation for :class:`globus_sdk.scopes.GCSCollectionScopeBuilder` for + See documentation for :class:`globus_sdk.scopes.GCSCollectionScopes` for more information. """ - return scopes.GCSCollectionScopeBuilder(str(collection_id)) - - @staticmethod - def connector_id_to_name(connector_id: uuid.UUID | str) -> str | None: - """ - .. warning:: - - 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 - """ - 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 + return GCSCollectionScopes(str(collection_id)) @property def default_scope_requirements(self) -> list[Scope]: return [ - Scope( - GCSClient.get_gcs_endpoint_scopes( - self.endpoint_client_id - ).manage_collections - ) + GCSClient.get_gcs_endpoint_scopes( + self.endpoint_client_id + ).manage_collections ] - @utils.classproperty + @classproperty def resource_server( # pylint: disable=missing-param-doc self_or_cls: client.BaseClient | type[client.BaseClient], ) -> str | None: @@ -233,8 +209,8 @@ def update_endpoint( endpoint_data: dict[str, t.Any] | EndpointDocument, *, include: ( - t.Iterable[t.Literal["endpoint"]] | t.Literal["endpoint"] | None - ) = None, + t.Iterable[t.Literal["endpoint"]] | t.Literal["endpoint"] | MissingType + ) = MISSING, query_params: dict[str, t.Any] | None = None, ) -> UnpackingGCSResponse: """ @@ -255,10 +231,7 @@ def update_endpoint( :ref: openapi_Endpoint/#patchEndpoint :service: gcs """ - query_params = query_params or {} - if include is not None: - query_params["include"] = utils.commajoin(include) - + query_params = {"include": commajoin(include), **(query_params or {})} return UnpackingGCSResponse( self.patch( "/endpoint", @@ -279,13 +252,13 @@ def update_endpoint( def get_collection_list( self, *, - mapped_collection_id: uuid.UUID | str | None = None, + mapped_collection_id: uuid.UUID | str | MissingType = MISSING, filter: ( # pylint: disable=redefined-builtin - str | t.Iterable[str] | None - ) = None, - include: str | t.Iterable[str] | None = None, - page_size: int | None = None, - marker: str | None = None, + str | t.Iterable[str] | MissingType + ) = MISSING, + include: str | t.Iterable[str] | MissingType = MISSING, + page_size: int | MissingType = MISSING, + marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableGCSResponse: """ @@ -312,20 +285,14 @@ def get_collection_list( :ref: openapi_Collections/#ListCollections :service: gcs """ - if query_params is None: - query_params = {} - if include is not None: - query_params["include"] = ",".join(utils.safe_strseq_iter(include)) - if page_size is not None: - query_params["page_size"] = page_size - if marker is not None: - query_params["marker"] = marker - if mapped_collection_id is not None: - query_params["mapped_collection_id"] = mapped_collection_id - if filter is not None: - if isinstance(filter, str): - filter = [filter] - query_params["filter"] = ",".join(filter) + query_params = { + "include": commajoin(include), + "page_size": page_size, + "marker": marker, + "mapped_collection_id": mapped_collection_id, + "filter": commajoin(filter), + **(query_params or {}), + } return IterableGCSResponse(self.get("collections", query_params=query_params)) def get_collection( @@ -454,9 +421,9 @@ def delete_collection( def get_storage_gateway_list( self, *, - include: None | str | t.Iterable[str] = None, - page_size: int | None = None, - marker: str | None = None, + include: str | t.Iterable[str] | MissingType = MISSING, + page_size: int | MissingType = MISSING, + marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableGCSResponse: """ @@ -485,14 +452,12 @@ def get_storage_gateway_list( :ref: openapi_Storage_Gateways/#getStorageGateways :service: gcs """ - if query_params is None: - query_params = {} - if include is not None: - query_params["include"] = ",".join(utils.safe_strseq_iter(include)) - if page_size is not None: - query_params["page_size"] = page_size - if marker is not None: - query_params["marker"] = marker + query_params = { + "include": commajoin(include), + "page_size": page_size, + "marker": marker, + **(query_params or {}), + } return IterableGCSResponse( self.get("/storage_gateways", query_params=query_params) ) @@ -529,7 +494,7 @@ def get_storage_gateway( self, storage_gateway_id: uuid.UUID | str, *, - include: None | str | t.Iterable[str] = None, + include: str | t.Iterable[str] | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> UnpackingGCSResponse: """ @@ -552,11 +517,7 @@ def get_storage_gateway( :ref: openapi_Storage_Gateways/#getStorageGateway :service: gcs """ - if query_params is None: - query_params = {} - if include is not None: - query_params["include"] = ",".join(utils.safe_strseq_iter(include)) - + query_params = {"include": commajoin(include), **(query_params or {})} return UnpackingGCSResponse( self.get( f"/storage_gateways/{storage_gateway_id}", @@ -632,10 +593,10 @@ def delete_storage_gateway( ) def get_role_list( self, - collection_id: uuid.UUID | str | None = None, - include: str | None = None, - page_size: int | None = None, - marker: str | None = None, + collection_id: uuid.UUID | str | MissingType = MISSING, + include: str | MissingType = MISSING, + page_size: int | MissingType = MISSING, + marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableGCSResponse: """ @@ -662,17 +623,13 @@ def get_role_list( :ref: openapi_Roles/#listRoles :service: gcs """ - if query_params is None: - query_params = {} - if include is not None: - query_params["include"] = include - if page_size is not None: - query_params["page_size"] = page_size - if marker is not None: - query_params["marker"] = marker - if collection_id is not None: - query_params["collection_id"] = collection_id - + query_params = { + "include": include, + "page_size": page_size, + "marker": marker, + "collection_id": collection_id, + **(query_params or {}), + } path = "/roles" return IterableGCSResponse(self.get(path, query_params=query_params)) @@ -758,10 +715,10 @@ def delete_role( ) def get_user_credential_list( self, - storage_gateway: uuid.UUID | str | None = None, + storage_gateway: uuid.UUID | str | MissingType = MISSING, + page_size: int | MissingType = MISSING, + marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, - page_size: int | None = None, - marker: str | None = None, ) -> IterableGCSResponse: """ List User Credentials @@ -782,15 +739,12 @@ def get_user_credential_list( :ref: openapi_User_Credentials/#getUserCredentials :service: gcs """ - if query_params is None: - query_params = {} - if storage_gateway is not None: - query_params["storage_gateway"] = storage_gateway - if page_size is not None: - query_params["page_size"] = page_size - if marker is not None: - query_params["marker"] = marker - + query_params = { + "storage_gateway": storage_gateway, + "page_size": page_size, + "marker": marker, + **(query_params or {}), + } path = "/user_credentials" return IterableGCSResponse(self.get(path, query_params=query_params)) diff --git a/src/globus_sdk/services/gcs/data/_common.py b/src/globus_sdk/services/gcs/data/_common.py index 1b4325093..8c732de52 100644 --- a/src/globus_sdk/services/gcs/data/_common.py +++ b/src/globus_sdk/services/gcs/data/_common.py @@ -2,7 +2,7 @@ import typing as t -from globus_sdk.utils import MISSING +from globus_sdk._missing import MISSING VersionTuple = t.Tuple[int, int, int] diff --git a/src/globus_sdk/services/gcs/data/collection.py b/src/globus_sdk/services/gcs/data/collection.py index f73bf0b56..3a993eac4 100644 --- a/src/globus_sdk/services/gcs/data/collection.py +++ b/src/globus_sdk/services/gcs/data/collection.py @@ -4,7 +4,9 @@ import typing as t import uuid -from globus_sdk import utils +from globus_sdk._internal.remarshal import strseq_listify +from globus_sdk._missing import MISSING, MissingType +from globus_sdk._payload import AbstractGlobusPayload from ._common import ( DatatypeCallback, @@ -57,7 +59,9 @@ def _user_message_length_callback( return None -class CollectionDocument(utils.PayloadWrapper, abc.ABC): +# Declare a metaclass of ABCMeta even though inheriting from `Payload` renders it +# inert. This will let type checkers understand that this class is abstract. +class CollectionDocument(AbstractGlobusPayload): """ This is the base class for :class:`~.MappedCollectionDocument` and :class:`~.GuestCollectionDocument`. @@ -144,69 +148,66 @@ def __init__( self, *, # data_type - data_type: str | None = None, + data_type: str | MissingType = MISSING, # strs - collection_base_path: str | None = None, - contact_email: str | None = None, - contact_info: str | None = None, - default_directory: str | None = None, - department: str | None = None, - description: str | None = None, - display_name: str | None = None, - identity_id: uuid.UUID | str | None = None, - info_link: str | None = None, - organization: str | None = None, + collection_base_path: str | MissingType = MISSING, + contact_email: str | None | MissingType = MISSING, + contact_info: str | None | MissingType = MISSING, + default_directory: str | MissingType = MISSING, + department: str | None | MissingType = MISSING, + description: str | None | MissingType = MISSING, + display_name: str | MissingType = MISSING, + identity_id: uuid.UUID | str | MissingType = MISSING, + info_link: str | None | MissingType = MISSING, + organization: str | MissingType = MISSING, restrict_transfers_to_high_assurance: ( - t.Literal["inbound", "outbound", "all"] | None - ) = None, - user_message: str | None = None, - user_message_link: str | None = None, + t.Literal["inbound", "outbound", "all"] | MissingType + ) = MISSING, + user_message: str | None | MissingType = MISSING, + user_message_link: str | None | MissingType = MISSING, # str lists - keywords: t.Iterable[str] | None = None, + keywords: t.Iterable[str] | MissingType = MISSING, # bools - disable_verify: bool | None = None, - enable_https: bool | None = None, - force_encryption: bool | None = None, - force_verify: bool | None = None, - public: bool | None = None, + disable_verify: bool | MissingType = MISSING, + enable_https: bool | MissingType = MISSING, + force_encryption: bool | MissingType = MISSING, + force_verify: bool | MissingType = MISSING, + public: bool | MissingType = MISSING, # ints - acl_expiration_mins: int | None = None, + acl_expiration_mins: int | MissingType = MISSING, # dicts - associated_flow_policy: dict[str, t.Any] | None = None, + associated_flow_policy: dict[str, t.Any] | MissingType = MISSING, # additional fields - additional_fields: dict[str, t.Any] | None = None, + additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> None: super().__init__() self["collection_type"] = self.collection_type - self._set_optstrs( - DATA_TYPE=data_type, - collection_base_path=collection_base_path, - contact_email=contact_email, - contact_info=contact_info, - default_directory=default_directory, - department=department, - description=description, - display_name=display_name, - identity_id=identity_id, - info_link=info_link, - organization=organization, - restrict_transfers_to_high_assurance=restrict_transfers_to_high_assurance, - user_message=user_message, - user_message_link=user_message_link, - ) - self._set_optstrlists( - keywords=keywords, - ) - self._set_optbools( - disable_verify=disable_verify, - enable_https=enable_https, - force_encryption=force_encryption, - force_verify=force_verify, - public=public, + self["DATA_TYPE"] = data_type + self["collection_base_path"] = collection_base_path + self["contact_email"] = contact_email + self["contact_info"] = contact_info + self["default_directory"] = default_directory + self["department"] = department + self["description"] = description + self["display_name"] = display_name + self["identity_id"] = identity_id + self["info_link"] = info_link + self["organization"] = organization + self["restrict_transfers_to_high_assurance"] = ( + restrict_transfers_to_high_assurance ) - self._set_optints(acl_expiration_mins=acl_expiration_mins) - self._set_value("associated_flow_policy", associated_flow_policy) - if additional_fields is not None: + self["user_message"] = user_message + self["user_message_link"] = user_message_link + self["keywords"] = strseq_listify(keywords) + self["disable_verify"] = disable_verify + self["enable_https"] = enable_https + self["force_encryption"] = force_encryption + self["force_verify"] = force_verify + self["public"] = public + self["acl_expiration_mins"] = acl_expiration_mins + self["associated_flow_policy"] = associated_flow_policy + + if additional_fields is not MISSING: self.update(additional_fields) @property @@ -266,56 +267,56 @@ def __init__( self, *, # data type - data_type: str | None = None, + data_type: str | MissingType = MISSING, # > common args start < # strs - collection_base_path: str | None = None, - contact_email: str | None = None, - contact_info: str | None = None, - default_directory: str | None = None, - department: str | None = None, - description: str | None = None, - display_name: str | None = None, - identity_id: uuid.UUID | str | None = None, - info_link: str | None = None, - organization: str | None = None, + collection_base_path: str | MissingType = MISSING, + contact_email: str | None | MissingType = MISSING, + contact_info: str | None | MissingType = MISSING, + default_directory: str | MissingType = MISSING, + department: str | None | MissingType = MISSING, + description: str | None | MissingType = MISSING, + display_name: str | MissingType = MISSING, + identity_id: uuid.UUID | str | MissingType = MISSING, + info_link: str | None | MissingType = MISSING, + organization: str | MissingType = MISSING, restrict_transfers_to_high_assurance: ( - t.Literal["inbound", "outbound", "all"] | None - ) = None, - user_message: str | None = None, - user_message_link: str | None = None, + t.Literal["inbound", "outbound", "all"] | MissingType + ) = MISSING, + user_message: str | None | MissingType = MISSING, + user_message_link: str | None | MissingType = MISSING, # str lists - keywords: t.Iterable[str] | None = None, + keywords: t.Iterable[str] | MissingType = MISSING, # bools - disable_verify: bool | None = None, - enable_https: bool | None = None, - force_encryption: bool | None = None, - force_verify: bool | None = None, - public: bool | None = None, + disable_verify: bool | MissingType = MISSING, + enable_https: bool | MissingType = MISSING, + force_encryption: bool | MissingType = MISSING, + force_verify: bool | MissingType = MISSING, + public: bool | MissingType = MISSING, # ints - acl_expiration_mins: int | None = None, + acl_expiration_mins: int | MissingType = MISSING, # > common args end < # > specific args start < # strs - domain_name: str | None = None, - guest_auth_policy_id: uuid.UUID | str | None = None, - storage_gateway_id: uuid.UUID | str | None = None, + domain_name: str | MissingType = MISSING, + guest_auth_policy_id: uuid.UUID | str | None | MissingType = MISSING, + storage_gateway_id: uuid.UUID | str | MissingType = MISSING, # str lists - sharing_users_allow: t.Iterable[str] | None = None, - sharing_users_deny: t.Iterable[str] | None = None, - sharing_restrict_paths: dict[str, t.Any] | None = None, + sharing_users_allow: t.Iterable[str] | None | MissingType = MISSING, + sharing_users_deny: t.Iterable[str] | None | MissingType = MISSING, + sharing_restrict_paths: dict[str, t.Any] | None | MissingType = MISSING, # bools - delete_protected: bool | None = None, - allow_guest_collections: bool | None = None, - disable_anonymous_writes: bool | None = None, + delete_protected: bool | MissingType = MISSING, + allow_guest_collections: bool | MissingType = MISSING, + disable_anonymous_writes: bool | MissingType = MISSING, # ints - auto_delete_timeout: int | None = None, + auto_delete_timeout: int | MissingType = MISSING, # dicts - associated_flow_policy: dict[str, t.Any] | None = None, - policies: CollectionPolicies | dict[str, t.Any] | None = None, + associated_flow_policy: dict[str, t.Any] | MissingType = MISSING, + policies: CollectionPolicies | dict[str, t.Any] | MissingType = MISSING, # > specific args end < # additional fields - additional_fields: dict[str, t.Any] | None = None, + additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> None: super().__init__( # data type @@ -349,26 +350,22 @@ def __init__( # additional fields additional_fields=additional_fields, ) - self._set_optstrs( - domain_name=domain_name, - restrict_transfers_to_high_assurance=restrict_transfers_to_high_assurance, - guest_auth_policy_id=guest_auth_policy_id, - storage_gateway_id=storage_gateway_id, - ) - self._set_optstrlists( - sharing_users_allow=sharing_users_allow, - sharing_users_deny=sharing_users_deny, - ) - self._set_optbools( - delete_protected=delete_protected, - allow_guest_collections=allow_guest_collections, - disable_anonymous_writes=disable_anonymous_writes, + self["domain_name"] = domain_name + self["restrict_transfers_to_high_assurance"] = ( + restrict_transfers_to_high_assurance ) - self._set_optints( - auto_delete_timeout=auto_delete_timeout, - ) - self._set_value("sharing_restrict_paths", sharing_restrict_paths) - self._set_value("policies", policies) + self["guest_auth_policy_id"] = guest_auth_policy_id + self["storage_gateway_id"] = storage_gateway_id + + self["sharing_users_allow"] = strseq_listify(sharing_users_allow) + self["sharing_users_deny"] = strseq_listify(sharing_users_deny) + + self["delete_protected"] = delete_protected + self["allow_guest_collections"] = allow_guest_collections + self["disable_anonymous_writes"] = disable_anonymous_writes + self["auto_delete_timeout"] = auto_delete_timeout + self["sharing_restrict_paths"] = sharing_restrict_paths + self["policies"] = policies ensure_datatype(self) @@ -410,45 +407,45 @@ def __init__( self, *, # data type - data_type: str | None = None, + data_type: str | MissingType = MISSING, # > common args start < # strs - collection_base_path: str | None = None, - contact_email: str | None = None, - contact_info: str | None = None, - default_directory: str | None = None, - department: str | None = None, - description: str | None = None, - display_name: str | None = None, - identity_id: uuid.UUID | str | None = None, - info_link: str | None = None, - organization: str | None = None, + collection_base_path: str | MissingType = MISSING, + contact_email: str | None | MissingType = MISSING, + contact_info: str | None | MissingType = MISSING, + default_directory: str | MissingType = MISSING, + department: str | None | MissingType = MISSING, + description: str | None | MissingType = MISSING, + display_name: str | MissingType = MISSING, + identity_id: uuid.UUID | str | MissingType = MISSING, + info_link: str | None | MissingType = MISSING, + organization: str | MissingType = MISSING, restrict_transfers_to_high_assurance: ( - t.Literal["inbound", "outbound", "all"] | None - ) = None, - user_message: str | None = None, - user_message_link: str | None = None, + t.Literal["inbound", "outbound", "all"] | MissingType + ) = MISSING, + user_message: str | None | MissingType = MISSING, + user_message_link: str | None | MissingType = MISSING, # str lists - keywords: t.Iterable[str] | None = None, + keywords: t.Iterable[str] | MissingType = MISSING, # bools - disable_verify: bool | None = None, - enable_https: bool | None = None, - force_encryption: bool | None = None, - force_verify: bool | None = None, - public: bool | None = None, + disable_verify: bool | MissingType = MISSING, + enable_https: bool | MissingType = MISSING, + force_encryption: bool | MissingType = MISSING, + force_verify: bool | MissingType = MISSING, + public: bool | MissingType = MISSING, # ints - acl_expiration_mins: int | None = None, + acl_expiration_mins: int | MissingType = MISSING, # dicts - associated_flow_policy: dict[str, t.Any] | None = None, + associated_flow_policy: dict[str, t.Any] | MissingType = MISSING, # > common args end < # > specific args start < - mapped_collection_id: uuid.UUID | str | None = None, - user_credential_id: uuid.UUID | str | None = None, - skip_auto_delete: bool | None = None, - activity_notification_policy: dict[str, list[str]] | None = None, + mapped_collection_id: uuid.UUID | str | MissingType = MISSING, + user_credential_id: uuid.UUID | str | MissingType = MISSING, + skip_auto_delete: bool | MissingType = MISSING, + activity_notification_policy: dict[str, list[str]] | MissingType = MISSING, # > specific args end < # additional fields - additional_fields: dict[str, t.Any] | None = None, + additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> None: super().__init__( # data type @@ -482,19 +479,15 @@ def __init__( # additional fields additional_fields=additional_fields, ) - self._set_optstrs( - mapped_collection_id=mapped_collection_id, - user_credential_id=user_credential_id, - ) - self._set_optbools( - skip_auto_delete=skip_auto_delete, - ) - self._set_value("activity_notification_policy", activity_notification_policy) + self["mapped_collection_id"] = mapped_collection_id + self["user_credential_id"] = user_credential_id + self["skip_auto_delete"] = skip_auto_delete + self["activity_notification_policy"] = activity_notification_policy ensure_datatype(self) -class CollectionPolicies(utils.PayloadWrapper, abc.ABC): +class CollectionPolicies(AbstractGlobusPayload): """ This is the abstract base type for Collection Policies documents to use as the ``policies`` parameter when creating a MappedCollectionDocument. @@ -519,17 +512,17 @@ class POSIXCollectionPolicies(CollectionPolicies): def __init__( self, DATA_TYPE: str = "posix_collection_policies#1.0.0", - sharing_groups_allow: None | str | t.Iterable[str] = None, - sharing_groups_deny: None | str | t.Iterable[str] = None, - additional_fields: dict[str, t.Any] | None = None, + sharing_groups_allow: str | t.Iterable[str] | None | MissingType = MISSING, + sharing_groups_deny: str | t.Iterable[str] | None | MissingType = MISSING, + additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> None: super().__init__() - self._set_optstrs(DATA_TYPE=DATA_TYPE) - self._set_optstrlists( - sharing_groups_allow=sharing_groups_allow, - sharing_groups_deny=sharing_groups_deny, - ) - if additional_fields is not None: + self["DATA_TYPE"] = DATA_TYPE + + self["sharing_groups_allow"] = strseq_listify(sharing_groups_allow) + self["sharing_groups_deny"] = strseq_listify(sharing_groups_deny) + + if additional_fields is not MISSING: self.update(additional_fields) @@ -551,17 +544,16 @@ class POSIXStagingCollectionPolicies(CollectionPolicies): def __init__( self, DATA_TYPE: str = "posix_staging_collection_policies#1.0.0", - sharing_groups_allow: None | str | t.Iterable[str] = None, - sharing_groups_deny: None | str | t.Iterable[str] = None, - additional_fields: dict[str, t.Any] | None = None, + sharing_groups_allow: str | t.Iterable[str] | None | MissingType = MISSING, + sharing_groups_deny: str | t.Iterable[str] | None | MissingType = MISSING, + additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> None: super().__init__() - self._set_optstrs(DATA_TYPE=DATA_TYPE) - self._set_optstrlists( - sharing_groups_allow=sharing_groups_allow, - sharing_groups_deny=sharing_groups_deny, - ) - if additional_fields is not None: + self["DATA_TYPE"] = DATA_TYPE + self["sharing_groups_allow"] = strseq_listify(sharing_groups_allow) + self["sharing_groups_deny"] = strseq_listify(sharing_groups_deny) + + if additional_fields is not MISSING: self.update(additional_fields) @@ -579,10 +571,11 @@ class GoogleCloudStorageCollectionPolicies(CollectionPolicies): def __init__( self, DATA_TYPE: str = "google_cloud_storage_collection_policies#1.0.0", - project: str | None = None, - additional_fields: dict[str, t.Any] | None = None, + project: str | MissingType = MISSING, + additional_fields: dict[str, t.Any] | MissingType = MISSING, ) -> None: super().__init__() - self._set_optstrs(DATA_TYPE=DATA_TYPE, project=project) - if additional_fields is not None: + self["DATA_TYPE"] = DATA_TYPE + self["project"] = project + if additional_fields is not MISSING: self.update(additional_fields) diff --git a/src/globus_sdk/services/gcs/data/endpoint.py b/src/globus_sdk/services/gcs/data/endpoint.py index 07aa6e407..5f0dafa6a 100644 --- a/src/globus_sdk/services/gcs/data/endpoint.py +++ b/src/globus_sdk/services/gcs/data/endpoint.py @@ -2,12 +2,13 @@ import typing as t -from globus_sdk import utils +from globus_sdk._internal.remarshal import strseq_listify +from globus_sdk._missing import MISSING, MissingType +from globus_sdk._payload import GlobusPayload from globus_sdk.services.gcs.data._common import DatatypeCallback, ensure_datatype -from globus_sdk.utils import MISSING, MissingType -class EndpointDocument(utils.PayloadWrapper): +class EndpointDocument(GlobusPayload): r""" :param data_type: Explicitly set the ``DATA_TYPE`` value for this endpoint document. @@ -126,11 +127,7 @@ def __init__( self["info_link"] = info_link self["network_use"] = network_use self["organization"] = organization - self["keywords"] = ( - keywords - if isinstance(keywords, MissingType) - else list(utils.safe_strseq_iter(keywords)) - ) + self["keywords"] = strseq_listify(keywords) self["allow_udt"] = allow_udt self["public"] = public self["max_concurrency"] = max_concurrency @@ -140,6 +137,6 @@ def __init__( self["subscription_id"] = subscription_id self["gridftp_control_channel_port"] = gridftp_control_channel_port - if not isinstance(additional_fields, MissingType): + if additional_fields is not MISSING: self.update(additional_fields) ensure_datatype(self) diff --git a/src/globus_sdk/services/gcs/data/role.py b/src/globus_sdk/services/gcs/data/role.py index fb8d7dc46..44e59b76b 100644 --- a/src/globus_sdk/services/gcs/data/role.py +++ b/src/globus_sdk/services/gcs/data/role.py @@ -3,10 +3,11 @@ import typing as t import uuid -from globus_sdk import utils +from globus_sdk._missing import MISSING, MissingType +from globus_sdk._payload import GlobusPayload -class GCSRoleDocument(utils.PayloadWrapper): +class GCSRoleDocument(GlobusPayload): """ Convenience class for constructing a Role document to use as the `data` parameter to `create_role` @@ -24,17 +25,14 @@ class GCSRoleDocument(utils.PayloadWrapper): def __init__( self, DATA_TYPE: str = "role#1.0.0", - collection: uuid.UUID | str | None = None, - principal: str | None = None, - role: str | None = None, + collection: uuid.UUID | str | MissingType = MISSING, + principal: str | MissingType = MISSING, + role: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs( - DATA_TYPE=DATA_TYPE, - collection=collection, - principal=principal, - role=role, - ) - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["collection"] = collection + self["principal"] = principal + self["role"] = role + self.update(additional_fields or {}) diff --git a/src/globus_sdk/services/gcs/data/storage_gateway.py b/src/globus_sdk/services/gcs/data/storage_gateway.py index 1f350ab01..fd1203ef5 100644 --- a/src/globus_sdk/services/gcs/data/storage_gateway.py +++ b/src/globus_sdk/services/gcs/data/storage_gateway.py @@ -1,15 +1,17 @@ from __future__ import annotations -import abc +import copy import typing as t import uuid -from globus_sdk import utils +from globus_sdk._internal.remarshal import list_map, listify, strseq_listify +from globus_sdk._missing import MISSING, MissingType +from globus_sdk._payload import AbstractGlobusPayload, GlobusPayload from ._common import DatatypeCallback, ensure_datatype -class StorageGatewayDocument(utils.PayloadWrapper): +class StorageGatewayDocument(GlobusPayload): """ Convenience class for constructing a Storage Gateway document to use as the `data` parameter to ``create_storage_gateway`` or @@ -56,42 +58,38 @@ class StorageGatewayDocument(utils.PayloadWrapper): def __init__( self, - DATA_TYPE: str | None = None, - display_name: str | None = None, - connector_id: uuid.UUID | str | None = None, - root: str | None = None, - identity_mappings: None | t.Iterable[dict[str, t.Any]] = None, - policies: StorageGatewayPolicies | dict[str, t.Any] | None = None, - allowed_domains: t.Iterable[str] | None = None, - high_assurance: bool | None = None, - require_mfa: bool | None = None, - authentication_timeout_mins: int | None = None, - users_allow: t.Iterable[str] | None = None, - users_deny: t.Iterable[str] | None = None, + DATA_TYPE: str | MissingType = MISSING, + display_name: str | MissingType = MISSING, + connector_id: uuid.UUID | str | MissingType = MISSING, + root: str | MissingType = MISSING, + identity_mappings: t.Iterable[dict[str, t.Any]] | MissingType = MISSING, + policies: StorageGatewayPolicies | dict[str, t.Any] | MissingType = MISSING, + allowed_domains: t.Iterable[str] | MissingType = MISSING, + high_assurance: bool | MissingType = MISSING, + require_mfa: bool | MissingType = MISSING, + authentication_timeout_mins: int | MissingType = MISSING, + users_allow: t.Iterable[str] | MissingType = MISSING, + users_deny: t.Iterable[str] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs( - DATA_TYPE=DATA_TYPE, - display_name=display_name, - connector_id=connector_id, - root=root, - ) - self._set_optstrlists( - allowed_domains=allowed_domains, - users_allow=users_allow, - users_deny=users_deny, - ) - self._set_optbools(high_assurance=high_assurance, require_mfa=require_mfa) - self._set_optints(authentication_timeout_mins=authentication_timeout_mins) - self._set_value("identity_mappings", identity_mappings, callback=list) - self._set_value("policies", policies) - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["display_name"] = display_name + self["connector_id"] = connector_id + self["root"] = root + self["allowed_domains"] = strseq_listify(allowed_domains) + self["users_allow"] = strseq_listify(users_allow) + self["users_deny"] = strseq_listify(users_deny) + self["high_assurance"] = high_assurance + self["require_mfa"] = require_mfa + self["authentication_timeout_mins"] = authentication_timeout_mins + self["identity_mappings"] = listify(identity_mappings) + self["policies"] = policies + self.update(additional_fields or {}) ensure_datatype(self) -class StorageGatewayPolicies(utils.PayloadWrapper, abc.ABC): +class StorageGatewayPolicies(AbstractGlobusPayload): """ This is the abstract base type for Storage Policies documents to use as the ``policies`` parameter when creating a StorageGatewayDocument. @@ -118,15 +116,15 @@ class POSIXStoragePolicies(StorageGatewayPolicies): def __init__( self, DATA_TYPE: str = "posix_storage_policies#1.0.0", - groups_allow: t.Iterable[str] | None = None, - groups_deny: t.Iterable[str] | None = None, + groups_allow: t.Iterable[str] | MissingType = MISSING, + groups_deny: t.Iterable[str] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs(DATA_TYPE=DATA_TYPE) - self._set_optstrlists(groups_allow=groups_allow, groups_deny=groups_deny) - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["groups_allow"] = strseq_listify(groups_allow) + self["groups_deny"] = strseq_listify(groups_deny) + self.update(additional_fields or {}) class POSIXStagingStoragePolicies(StorageGatewayPolicies): @@ -149,22 +147,20 @@ class POSIXStagingStoragePolicies(StorageGatewayPolicies): def __init__( self, DATA_TYPE: str = "posix_staging_storage_policies#1.0.0", - groups_allow: t.Iterable[str] | None = None, - groups_deny: t.Iterable[str] | None = None, - stage_app: str | None = None, - environment: t.Iterable[dict[str, str]] | None = None, + groups_allow: t.Iterable[str] | MissingType = MISSING, + groups_deny: t.Iterable[str] | MissingType = MISSING, + stage_app: str | MissingType = MISSING, + environment: t.Iterable[dict[str, str]] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs(DATA_TYPE=DATA_TYPE, stage_app=stage_app) - self._set_optstrlists(groups_allow=groups_allow, groups_deny=groups_deny) - self._set_value( - "environment", - environment, - callback=lambda env_iter: [{**e} for e in env_iter], - ) - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["stage_app"] = stage_app + self["groups_allow"] = strseq_listify(groups_allow) + self["groups_deny"] = strseq_listify(groups_deny) + # make shallow copies of all the dicts passed + self["environment"] = list_map(environment, copy.copy) + self.update(additional_fields or {}) class BlackPearlStoragePolicies(StorageGatewayPolicies): @@ -185,18 +181,15 @@ class BlackPearlStoragePolicies(StorageGatewayPolicies): def __init__( self, DATA_TYPE: str = "blackpearl_storage_policies#1.0.0", - s3_endpoint: str | None = None, - bp_access_id_file: str | None = None, + s3_endpoint: str | MissingType = MISSING, + bp_access_id_file: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs( - DATA_TYPE=DATA_TYPE, - s3_endpoint=s3_endpoint, - bp_access_id_file=bp_access_id_file, - ) - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["s3_endpoint"] = s3_endpoint + self["bp_access_id_file"] = bp_access_id_file + self.update(additional_fields or {}) class BoxStoragePolicies(StorageGatewayPolicies): @@ -216,15 +209,15 @@ class BoxStoragePolicies(StorageGatewayPolicies): def __init__( self, DATA_TYPE: str = "box_storage_policies#1.0.0", - enterpriseID: str | None = None, - boxAppSettings: dict[str, t.Any] | None = None, + enterpriseID: str | MissingType = MISSING, + boxAppSettings: dict[str, t.Any] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs(DATA_TYPE=DATA_TYPE, enterpriseID=enterpriseID) - self._set_value("boxAppSettings", boxAppSettings) - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["enterpriseID"] = enterpriseID + self["boxAppSettings"] = boxAppSettings + self.update(additional_fields or {}) class CephStoragePolicies(StorageGatewayPolicies): @@ -247,22 +240,19 @@ class CephStoragePolicies(StorageGatewayPolicies): def __init__( self, DATA_TYPE: str = "ceph_storage_policies#1.0.0", - s3_endpoint: str | None = None, - s3_buckets: t.Iterable[str] | None = None, - ceph_admin_key_id: str | None = None, - ceph_admin_secret_key: str | None = None, + s3_endpoint: str | MissingType = MISSING, + s3_buckets: t.Iterable[str] | MissingType = MISSING, + ceph_admin_key_id: str | MissingType = MISSING, + ceph_admin_secret_key: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs( - DATA_TYPE=DATA_TYPE, - s3_endpoint=s3_endpoint, - ceph_admin_key_id=ceph_admin_key_id, - ceph_admin_secret_key=ceph_admin_secret_key, - ) - self._set_optstrlists(s3_buckets=s3_buckets) - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["s3_endpoint"] = s3_endpoint + self["ceph_admin_key_id"] = ceph_admin_key_id + self["ceph_admin_secret_key"] = ceph_admin_secret_key + self["s3_buckets"] = strseq_listify(s3_buckets) + self.update(additional_fields or {}) class GoogleDriveStoragePolicies(StorageGatewayPolicies): @@ -283,16 +273,17 @@ class GoogleDriveStoragePolicies(StorageGatewayPolicies): def __init__( self, DATA_TYPE: str = "google_drive_storage_policies#1.0.0", - client_id: str | None = None, - secret: str | None = None, - user_api_rate_quota: int | None = None, + client_id: str | MissingType = MISSING, + secret: str | MissingType = MISSING, + user_api_rate_quota: int | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs(DATA_TYPE=DATA_TYPE, client_id=client_id, secret=secret) - self._set_optints(user_api_rate_quota=user_api_rate_quota) - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["client_id"] = client_id + self["secret"] = secret + self["user_api_rate_quota"] = user_api_rate_quota + self.update(additional_fields or {}) class GoogleCloudStoragePolicies(StorageGatewayPolicies): @@ -323,19 +314,21 @@ class GoogleCloudStoragePolicies(StorageGatewayPolicies): def __init__( self, DATA_TYPE: str = "google_cloud_storage_policies#1.0.0", - client_id: str | None = None, - secret: str | None = None, - service_account_key: dict[str, t.Any] | None = None, - buckets: t.Iterable[str] | None = None, - projects: t.Iterable[str] | None = None, + client_id: str | MissingType = MISSING, + secret: str | MissingType = MISSING, + service_account_key: dict[str, t.Any] | MissingType = MISSING, + buckets: t.Iterable[str] | MissingType = MISSING, + projects: t.Iterable[str] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs(DATA_TYPE=DATA_TYPE, client_id=client_id, secret=secret) - self._set_optstrlists(buckets=buckets, projects=projects) - self._set_value("service_account_key", service_account_key) - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["client_id"] = client_id + self["secret"] = secret + self["buckets"] = strseq_listify(buckets) + self["projects"] = strseq_listify(projects) + self["service_account_key"] = service_account_key + self.update(additional_fields or {}) class OneDriveStoragePolicies(StorageGatewayPolicies): @@ -357,19 +350,19 @@ class OneDriveStoragePolicies(StorageGatewayPolicies): def __init__( self, DATA_TYPE: str = "onedrive_storage_policies#1.0.0", - client_id: str | None = None, - secret: str | None = None, - tenant: str | None = None, - user_api_rate_limit: int | None = None, + client_id: str | MissingType = MISSING, + secret: str | MissingType = MISSING, + tenant: str | MissingType = MISSING, + user_api_rate_limit: int | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs( - DATA_TYPE=DATA_TYPE, client_id=client_id, secret=secret, tenant=tenant - ) - self._set_optints(user_api_rate_limit=user_api_rate_limit) - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["client_id"] = client_id + self["secret"] = secret + self["tenant"] = tenant + self["user_api_rate_limit"] = user_api_rate_limit + self.update(additional_fields or {}) class AzureBlobStoragePolicies(StorageGatewayPolicies): @@ -393,26 +386,23 @@ class AzureBlobStoragePolicies(StorageGatewayPolicies): def __init__( self, DATA_TYPE: str = "azure_blob_storage_policies#1.0.0", - client_id: str | None = None, - secret: str | None = None, - tenant: str | None = None, - account: str | None = None, - auth_type: str | None = None, - adls: bool | None = None, + client_id: str | MissingType = MISSING, + secret: str | MissingType = MISSING, + tenant: str | MissingType = MISSING, + account: str | MissingType = MISSING, + auth_type: str | MissingType = MISSING, + adls: bool | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs( - DATA_TYPE=DATA_TYPE, - client_id=client_id, - secret=secret, - tenant=tenant, - account=account, - auth_type=auth_type, - ) - self._set_optbools(adls=adls) - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["client_id"] = client_id + self["secret"] = secret + self["tenant"] = tenant + self["account"] = account + self["auth_type"] = auth_type + self["adls"] = adls + self.update(additional_fields or {}) class S3StoragePolicies(StorageGatewayPolicies): @@ -435,17 +425,17 @@ class S3StoragePolicies(StorageGatewayPolicies): def __init__( self, DATA_TYPE: str = "s3_storage_policies#1.0.0", - s3_endpoint: str | None = None, - s3_buckets: t.Iterable[str] | None = None, - s3_user_credential_required: bool | None = None, + s3_endpoint: str | MissingType = MISSING, + s3_buckets: t.Iterable[str] | MissingType = MISSING, + s3_user_credential_required: bool | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs(DATA_TYPE=DATA_TYPE, s3_endpoint=s3_endpoint) - self._set_optbools(s3_user_credential_required=s3_user_credential_required) - self._set_optstrlists(s3_buckets=s3_buckets) - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["s3_endpoint"] = s3_endpoint + self["s3_user_credential_required"] = s3_user_credential_required + self["s3_buckets"] = strseq_listify(s3_buckets) + self.update(additional_fields or {}) class ActiveScaleStoragePolicies(S3StoragePolicies): @@ -470,18 +460,15 @@ class IrodsStoragePolicies(StorageGatewayPolicies): def __init__( self, DATA_TYPE: str = "irods_storage_policies#1.0.0", - irods_environment_file: str | None = None, - irods_authentication_file: str | None = None, + irods_environment_file: str | MissingType = MISSING, + irods_authentication_file: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs( - DATA_TYPE=DATA_TYPE, - irods_environment_file=irods_environment_file, - irods_authentication_file=irods_authentication_file, - ) - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["irods_environment_file"] = irods_environment_file + self["irods_authentication_file"] = irods_authentication_file + self.update(additional_fields or {}) class HPSSStoragePolicies(StorageGatewayPolicies): @@ -501,17 +488,14 @@ class HPSSStoragePolicies(StorageGatewayPolicies): def __init__( self, DATA_TYPE: str = "hpss_storage_policies#1.0.0", - authentication_mech: str | None = None, - authenticator: str | None = None, - uda_checksum_support: bool | None = None, + authentication_mech: str | MissingType = MISSING, + authenticator: str | MissingType = MISSING, + uda_checksum_support: bool | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs( - DATA_TYPE=DATA_TYPE, - authentication_mech=authentication_mech, - authenticator=authenticator, - ) - self._set_optbools(uda_checksum_support=uda_checksum_support) - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["authentication_mech"] = authentication_mech + self["authenticator"] = authenticator + self["uda_checksum_support"] = uda_checksum_support + self.update(additional_fields or {}) diff --git a/src/globus_sdk/services/gcs/data/user_credential.py b/src/globus_sdk/services/gcs/data/user_credential.py index 0551d1c17..fc467a518 100644 --- a/src/globus_sdk/services/gcs/data/user_credential.py +++ b/src/globus_sdk/services/gcs/data/user_credential.py @@ -3,10 +3,11 @@ import typing as t import uuid -from globus_sdk import utils +from globus_sdk._missing import MISSING, MissingType +from globus_sdk._payload import GlobusPayload -class UserCredentialDocument(utils.PayloadWrapper): +class UserCredentialDocument(GlobusPayload): """ Convenience class for constructing a UserCredential document to use as the `data` parameter to `create_user_credential` and @@ -27,24 +28,20 @@ class UserCredentialDocument(utils.PayloadWrapper): def __init__( self, DATA_TYPE: str = "user_credential#1.0.0", - identity_id: uuid.UUID | str | None = None, - connector_id: uuid.UUID | str | None = None, - username: str | None = None, - display_name: str | None = None, - storage_gateway_id: uuid.UUID | str | None = None, - policies: dict[str, t.Any] | None = None, + identity_id: uuid.UUID | str | MissingType = MISSING, + connector_id: uuid.UUID | str | MissingType = MISSING, + username: str | MissingType = MISSING, + display_name: str | MissingType = MISSING, + storage_gateway_id: uuid.UUID | str | MissingType = MISSING, + policies: dict[str, t.Any] | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - self._set_optstrs( - DATA_TYPE=DATA_TYPE, - identity_id=identity_id, - connector_id=connector_id, - username=username, - display_name=display_name, - storage_gateway_id=storage_gateway_id, - ) - self._set_value("policies", policies) - - if additional_fields is not None: - self.update(additional_fields) + self["DATA_TYPE"] = DATA_TYPE + self["identity_id"] = identity_id + self["connector_id"] = connector_id + self["username"] = username + self["display_name"] = display_name + self["storage_gateway_id"] = storage_gateway_id + self["policies"] = policies + self.update(additional_fields or {}) diff --git a/src/globus_sdk/services/groups/client.py b/src/globus_sdk/services/groups/client.py index a9e94baff..29c7b1ae0 100644 --- a/src/globus_sdk/services/groups/client.py +++ b/src/globus_sdk/services/groups/client.py @@ -4,7 +4,9 @@ import typing as t import uuid -from globus_sdk import client, exc, response, utils +from globus_sdk import client, response +from globus_sdk._internal.remarshal import commajoin +from globus_sdk._missing import MISSING, MissingType from globus_sdk.scopes import GroupsScopes, Scope from .data import BatchMembershipActions, GroupPolicies @@ -40,21 +42,20 @@ class GroupsClient(client.BaseClient): .. automethodlist:: globus_sdk.GroupsClient """ - # NOTE: setting base_path is no longer considered good practice - # see the BaseClient source for details - base_path = "/v2/" error_class = GroupsAPIError service_name = "groups" scopes = GroupsScopes @property def default_scope_requirements(self) -> list[Scope]: - return [Scope(GroupsScopes.view_my_groups_and_memberships)] + return [GroupsScopes.view_my_groups_and_memberships] def get_my_groups( self, *, - statuses: _VALID_STATUSES_T | t.Iterable[_VALID_STATUSES_T] | None = None, + statuses: ( + _VALID_STATUSES_T | t.Iterable[_VALID_STATUSES_T] | MissingType + ) = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.ArrayResponse: """ @@ -77,19 +78,16 @@ def get_my_groups( :service: groups :ref: get_my_groups_and_memberships_v2_groups_my_groups_get """ - if query_params is None: - query_params = {} - if statuses is not None: - query_params["statuses"] = ",".join(utils.safe_strseq_iter(statuses)) + query_params = {"statuses": commajoin(statuses), **(query_params or {})} return response.ArrayResponse( - self.get("/groups/my_groups", query_params=query_params) + self.get("/v2/groups/my_groups", query_params=query_params) ) def get_group( self, group_id: uuid.UUID | str, *, - include: None | str | t.Iterable[str] = None, + include: str | t.Iterable[str] | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ @@ -111,11 +109,8 @@ def get_group( :service: groups :ref: get_group_v2_groups__group_id__get """ - if query_params is None: - query_params = {} - if include is not None: - query_params["include"] = ",".join(utils.safe_strseq_iter(include)) - return self.get(f"/groups/{group_id}", query_params=query_params) + query_params = {"include": commajoin(include), **(query_params or {})} + return self.get(f"/v2/groups/{group_id}", query_params=query_params) def get_group_by_subscription_id( self, subscription_id: uuid.UUID | str @@ -148,7 +143,7 @@ def get_group_by_subscription_id( :service: groups :ref: get_group_by_subscription_id_v2_subscription_info__subscription_id__get """ # noqa: E501 - return self.get(f"/subscription_info/{subscription_id}") + return self.get(f"/v2/subscription_info/{subscription_id}") def delete_group( self, @@ -172,7 +167,7 @@ def delete_group( :service: groups :ref: delete_group_v2_groups__group_id__delete """ - return self.delete(f"/groups/{group_id}", query_params=query_params) + return self.delete(f"/v2/groups/{group_id}", query_params=query_params) def create_group( self, @@ -196,7 +191,7 @@ def create_group( :service: groups :ref: create_group_v2_groups_post """ - return self.post("/groups", data=data, query_params=query_params) + return self.post("/v2/groups", data=data, query_params=query_params) def update_group( self, @@ -222,7 +217,7 @@ def update_group( :service: groups :ref: update_group_v2_groups__group_id__put """ - return self.put(f"/groups/{group_id}", data=data, query_params=query_params) + return self.put(f"/v2/groups/{group_id}", data=data, query_params=query_params) def get_group_policies( self, @@ -246,7 +241,7 @@ def get_group_policies( :service: groups :ref: get_policies_v2_groups__group_id__policies_get """ - return self.get(f"/groups/{group_id}/policies", query_params=query_params) + return self.get(f"/v2/groups/{group_id}/policies", query_params=query_params) def set_group_policies( self, @@ -273,7 +268,7 @@ def set_group_policies( :ref: update_policies_v2_groups__group_id__policies_put """ return self.put( - f"/groups/{group_id}/policies", data=data, query_params=query_params + f"/v2/groups/{group_id}/policies", data=data, query_params=query_params ) def get_identity_preferences( @@ -295,7 +290,7 @@ def get_identity_preferences( :service: groups :ref: get_identity_set_preferences_v2_preferences_get """ - return self.get("/preferences", query_params=query_params) + return self.get("/v2/preferences", query_params=query_params) def set_identity_preferences( self, @@ -327,7 +322,7 @@ def set_identity_preferences( :service: groups :ref: put_identity_set_preferences_v2_preferences_put """ - return self.put("/preferences", data=data, query_params=query_params) + return self.put("/v2/preferences", data=data, query_params=query_params) def get_membership_fields( self, @@ -352,7 +347,7 @@ def get_membership_fields( :ref: get_membership_fields_v2_groups__group_id__membership_fields_get """ # noqa: E501 return self.get( - f"/groups/{group_id}/membership_fields", query_params=query_params + f"/v2/groups/{group_id}/membership_fields", query_params=query_params ) def set_membership_fields( @@ -380,7 +375,7 @@ def set_membership_fields( :ref: put_membership_fields_v2_groups__group_id__membership_fields_put """ # noqa: E501 return self.put( - f"/groups/{group_id}/membership_fields", + f"/v2/groups/{group_id}/membership_fields", data=data, query_params=query_params, ) @@ -421,29 +416,8 @@ def batch_membership_action( :service: groups :ref: group_membership_post_actions_v2_groups__group_id__post """ - return self.post(f"/groups/{group_id}", data=actions, query_params=query_params) - - def set_subscription_admin_verified_id( - self, - group_id: uuid.UUID | str, - subscription_id: uuid.UUID | str | None, - *, - query_params: dict[str, t.Any] | None = None, - ) -> response.GlobusHTTPResponse: - """ - Deprecated alias for :meth:`set_subscription_admin_verified`. - - :param group_id: the ID of the group - :param subscription_id: the ID of the subscription to which the group belongs, - or ``None`` to disassociate the group from a subscription - :param query_params: additional passthrough query parameters - """ - exc.warn_deprecated( - "`GroupsClient.set_subscription_admin_verified_id()` has been renamed to " - "`GroupsClient.set_subscription_admin_verified()`." - ) - return self.set_subscription_admin_verified( - group_id, subscription_id, query_params=query_params + return self.post( + f"/v2/groups/{group_id}", data=actions, query_params=query_params ) def set_subscription_admin_verified( @@ -474,7 +448,7 @@ def set_subscription_admin_verified( subscription_admin_verified_put """ return self.put( - f"/groups/{group_id}/subscription_admin_verified", + f"/v2/groups/{group_id}/subscription_admin_verified", data={"subscription_admin_verified_id": subscription_id}, query_params=query_params, ) diff --git a/src/globus_sdk/services/groups/data.py b/src/globus_sdk/services/groups/data.py index 7be976c70..315976743 100644 --- a/src/globus_sdk/services/groups/data.py +++ b/src/globus_sdk/services/groups/data.py @@ -4,7 +4,9 @@ import typing as t import uuid -from globus_sdk import utils +from globus_sdk._internal.remarshal import strseq_iter +from globus_sdk._missing import MISSING, MissingType +from globus_sdk._payload import GlobusPayload T = t.TypeVar("T") @@ -97,7 +99,7 @@ def _docstring_fixer(cls: type[T]) -> type[T]: return cls -class BatchMembershipActions(utils.PayloadWrapper): +class BatchMembershipActions(GlobusPayload): """ An object used to represent a batch action on memberships of a group. `Perform actions on group members @@ -114,8 +116,7 @@ def accept_invites( :param identity_ids: The identities for whom to accept invites """ self.setdefault("accept", []).extend( - {"identity_id": identity_id} - for identity_id in utils.safe_strseq_iter(identity_ids) + {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self @@ -133,7 +134,7 @@ def add_members( """ self.setdefault("add", []).extend( {"identity_id": identity_id, "role": role} - for identity_id in utils.safe_strseq_iter(identity_ids) + for identity_id in strseq_iter(identity_ids) ) return self @@ -146,8 +147,7 @@ def approve_pending( :param identity_ids: The identities to approve as members of the group """ self.setdefault("approve", []).extend( - {"identity_id": identity_id} - for identity_id in utils.safe_strseq_iter(identity_ids) + {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self @@ -176,8 +176,7 @@ def decline_invites( :param identity_ids: The identities for whom invitations should be declined """ self.setdefault("decline", []).extend( - {"identity_id": identity_id} - for identity_id in utils.safe_strseq_iter(identity_ids) + {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self @@ -195,7 +194,7 @@ def invite_members( """ self.setdefault("invite", []).extend( {"identity_id": identity_id, "role": role} - for identity_id in utils.safe_strseq_iter(identity_ids) + for identity_id in strseq_iter(identity_ids) ) return self @@ -207,8 +206,7 @@ def join(self, identity_ids: t.Iterable[uuid.UUID | str]) -> BatchMembershipActi :param identity_ids: The identities to use to join the group """ self.setdefault("join", []).extend( - {"identity_id": identity_id} - for identity_id in utils.safe_strseq_iter(identity_ids) + {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self @@ -222,8 +220,7 @@ def leave( :param identity_ids: The identities to remove from the group """ self.setdefault("leave", []).extend( - {"identity_id": identity_id} - for identity_id in utils.safe_strseq_iter(identity_ids) + {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self @@ -236,8 +233,7 @@ def reject_join_requests( :param identity_ids: The identities to reject from the group """ self.setdefault("reject", []).extend( - {"identity_id": identity_id} - for identity_id in utils.safe_strseq_iter(identity_ids) + {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self @@ -251,8 +247,7 @@ def remove_members( :param identity_ids: The identities to remove from the group """ self.setdefault("remove", []).extend( - {"identity_id": identity_id} - for identity_id in utils.safe_strseq_iter(identity_ids) + {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self @@ -265,14 +260,13 @@ def request_join( :param identity_ids: The identities to use to request membership in the group """ self.setdefault("request_join", []).extend( - {"identity_id": identity_id} - for identity_id in utils.safe_strseq_iter(identity_ids) + {"identity_id": identity_id} for identity_id in strseq_iter(identity_ids) ) return self @_docstring_fixer -class GroupPolicies(utils.PayloadWrapper): +class GroupPolicies(GlobusPayload): """ An object used to represent the policy settings of a group. This may be used to set or modify group settings. @@ -301,9 +295,7 @@ def __init__( group_members_visibility: _GROUP_MEMBER_VISIBILITY_T, join_requests: bool, signup_fields: t.Iterable[_GROUP_REQUIRED_SIGNUP_FIELDS_T], - authentication_assurance_timeout: ( - int | None | utils.MissingType - ) = utils.MISSING, + authentication_assurance_timeout: int | None | MissingType = MISSING, ) -> None: super().__init__() self["is_high_assurance"] = is_high_assurance diff --git a/src/globus_sdk/services/search/__init__.py b/src/globus_sdk/services/search/__init__.py index 1084a4948..6f2eb2b90 100644 --- a/src/globus_sdk/services/search/__init__.py +++ b/src/globus_sdk/services/search/__init__.py @@ -1,10 +1,9 @@ from .client import SearchClient -from .data import SearchQuery, SearchQueryV1, SearchScrollQuery +from .data import SearchQueryV1, SearchScrollQuery from .errors import SearchAPIError __all__ = ( "SearchClient", - "SearchQuery", "SearchQueryV1", "SearchScrollQuery", "SearchAPIError", diff --git a/src/globus_sdk/services/search/client.py b/src/globus_sdk/services/search/client.py index fa1273d86..88d1a49ea 100644 --- a/src/globus_sdk/services/search/client.py +++ b/src/globus_sdk/services/search/client.py @@ -4,12 +4,12 @@ import typing as t import uuid -from globus_sdk import client, paging, response, utils -from globus_sdk.exc.warnings import warn_deprecated -from globus_sdk.scopes import Scope, SearchScopes -from globus_sdk.utils import MISSING, MissingType +from globus_sdk import client, paging, response +from globus_sdk._internal.remarshal import strseq_listify +from globus_sdk._missing import MISSING, MissingType +from globus_sdk.scopes import SearchScopes -from .data import SearchQuery, SearchScrollQuery +from .data import SearchQueryV1, SearchScrollQuery from .errors import SearchAPIError from .response import IndexListResponse @@ -32,15 +32,12 @@ class SearchClient(client.BaseClient): error_class = SearchAPIError service_name = "search" scopes = SearchScopes + default_scope_requirements = [SearchScopes.search] # # Index Management # - @property - def default_scope_requirements(self) -> list[Scope]: - return [Scope(SearchScopes.search)] - def create_index( self, display_name: str, description: str ) -> response.GlobusHTTPResponse: @@ -281,9 +278,9 @@ def search( index_id: uuid.UUID | str, q: str, *, - offset: int = 0, - limit: int = 10, - advanced: bool = False, + offset: int | MissingType = MISSING, + limit: int | MissingType = MISSING, + advanced: bool | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ @@ -325,17 +322,13 @@ def search( .. expandtestfixture:: search.search """ # noqa: E501 - if query_params is None: - query_params = {} - query_params.update( - { - "q": q, - "offset": offset, - "limit": limit, - "advanced": advanced, - } - ) - + query_params = { + "q": q, + "offset": offset, + "limit": limit, + "advanced": advanced, + **(query_params or {}), + } log.debug(f"SearchClient.search({index_id}, ...)") return self.get(f"/v1/index/{index_id}/search", query_params=query_params) @@ -349,10 +342,10 @@ def search( def post_search( self, index_id: uuid.UUID | str, - data: dict[str, t.Any] | SearchQuery, + data: dict[str, t.Any] | SearchQueryV1, *, - offset: int | None = None, - limit: int | None = None, + offset: int | MissingType = MISSING, + limit: int | MissingType = MISSING, ) -> response.GlobusHTTPResponse: """ Execute a complex Search Query, using a query document to express filters, @@ -407,12 +400,11 @@ def post_search( """ log.debug(f"SearchClient.post_search({index_id}, ...)") add_kwargs = {} - if offset is not None: + if offset is not MISSING: add_kwargs["offset"] = offset - if limit is not None: + if limit is not MISSING: add_kwargs["limit"] = limit - if add_kwargs: - data = {**data, **add_kwargs} + data = {**data, **add_kwargs} return self.post(f"v1/index/{index_id}/search", data=data) @paging.has_paginator(paging.MarkerPaginator, items_key="gmeta") @@ -421,7 +413,7 @@ def scroll( index_id: uuid.UUID | str, data: dict[str, t.Any] | SearchScrollQuery, *, - marker: str | None = None, + marker: str | MissingType = MISSING, ) -> response.GlobusHTTPResponse: """ Scroll all data in a Search index. The paginated version of this API should @@ -459,10 +451,9 @@ def scroll( """ log.debug(f"SearchClient.scroll({index_id}, ...)") add_kwargs = {} - if marker is not None: + if marker is not MISSING: add_kwargs["marker"] = marker - if add_kwargs: - data = {**data, **add_kwargs} + data = {**data, **add_kwargs} return self.post(f"v1/index/{index_id}/scroll", data=data) # @@ -626,9 +617,10 @@ def batch_delete_by_subject( # convert the provided subjects to a list and use the "safe iter" helper to # ensure that a single string is *not* treated as an iterable of strings, # which is usually not intentional - body = {"subjects": list(utils.safe_strseq_iter(subjects))} - if additional_params: - body.update(additional_params) + body = { + "subjects": strseq_listify(subjects), + **(additional_params or {}), + } return self.post(f"/v1/index/{index_id}/batch_delete_by_subject", data=body) # @@ -668,10 +660,11 @@ def get_subject( .. extdoclink:: Get By Subject :ref: search/reference/get_subject/ """ - if query_params is None: - query_params = {} - query_params["subject"] = subject log.debug(f"SearchClient.get_subject({index_id}, {subject}, ...)") + query_params = { + "subject": subject, + **(query_params or {}), + } return self.get(f"/v1/index/{index_id}/subject", query_params=query_params) def delete_subject( @@ -711,11 +704,11 @@ def delete_subject( .. extdoclink:: Delete By Subject :ref: search/reference/delete_subject/ """ - if query_params is None: - query_params = {} - query_params["subject"] = subject - log.debug(f"SearchClient.delete_subject({index_id}, {subject}, ...)") + query_params = { + "subject": subject, + **(query_params or {}), + } return self.delete(f"/v1/index/{index_id}/subject", query_params=query_params) # @@ -727,7 +720,7 @@ def get_entry( index_id: uuid.UUID | str, subject: str, *, - entry_id: str | None = None, + entry_id: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ @@ -767,136 +760,24 @@ def get_entry( .. extdoclink:: Get Entry :ref: search/reference/get_entry/ """ # noqa: E501 - if query_params is None: - query_params = {} - query_params["subject"] = subject - if entry_id is not None: - query_params["entry_id"] = entry_id - log.debug( "SearchClient.get_entry({}, {}, {}, ...)".format( index_id, subject, entry_id ) ) + query_params = { + "entry_id": entry_id, + "subject": subject, + **(query_params or {}), + } return self.get(f"/v1/index/{index_id}/entry", query_params=query_params) - def create_entry( - self, index_id: uuid.UUID | str, data: dict[str, t.Any] - ) -> response.GlobusHTTPResponse: - """ - This API method is in effect an alias of ingest and is deprecated. - Users are recommended to use :meth:`~.ingest` instead. - - Create or update one Entry document in Search. - - The API does not enforce that the document does not exist, and will overwrite - any existing data. - - :param index_id: the index containing this Entry - :param data: the entry document to write - - .. tab-set:: - - .. tab-item:: Example Usage - - Create an entry with a subject of ``https://example.com/foo/bar`` and - a null entry_id: - - .. code-block:: python - - sc = globus_sdk.SearchClient(...) - sc.create_entry( - index_id, - { - "subject": "https://example.com/foo/bar", - "visible_to": ["public"], - "content": {"foo/bar": "some val"}, - }, - ) - - Create an entry with a subject of ``https://example.com/foo/bar`` and - an entry_id of ``foo/bar``: - - .. code-block:: python - - sc = globus_sdk.SearchClient(...) - sc.create_entry( - index_id, - { - "subject": "https://example.com/foo/bar", - "visible_to": ["public"], - "id": "foo/bar", - "content": {"foo/bar": "some val"}, - }, - ) - - .. tab-item:: API Info - - ``POST /v1/index//entry`` - - .. extdoclink:: Create Entry - :ref: search/reference/create_or_update_entry/ - """ - warn_deprecated( - "SearchClient.create_entry is deprecated. " - "Users should prefer using `SearchClient.ingest`" - ) - log.debug(f"SearchClient.create_entry({index_id}, ...)") - return self.post(f"/v1/index/{index_id}/entry", data=data) - - def update_entry( - self, index_id: uuid.UUID | str, data: dict[str, t.Any] - ) -> response.GlobusHTTPResponse: - """ - This API method is in effect an alias of ingest and is deprecated. - Users are recommended to use :meth:`~.ingest` instead. - - Create or update one Entry document in Search. - - This does not do a partial update, but replaces the existing document. - - :param index_id: the index containing this Entry - :param data: the entry document to write - - .. tab-set:: - - .. tab-item:: Example Usage - - Update an entry with a subject of ``https://example.com/foo/bar`` and - a null entry_id: - - .. code-block:: python - - sc = globus_sdk.SearchClient(...) - sc.update_entry( - index_id, - { - "subject": "https://example.com/foo/bar", - "visible_to": ["public"], - "content": {"foo/bar": "some val"}, - }, - ) - - .. tab-item:: API Info - - ``PUT /v1/index//entry`` - - .. extdoclink:: Update Entry - :ref: search/reference/create_or_update_entry/ - """ - warn_deprecated( - "SearchClient.update_entry is deprecated. " - "Users should prefer using `SearchClient.ingest`" - ) - log.debug(f"SearchClient.update_entry({index_id}, ...)") - return self.put(f"/v1/index/{index_id}/entry", data=data) - def delete_entry( self, index_id: uuid.UUID | str, subject: str, *, - entry_id: str | None = None, + entry_id: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ @@ -936,16 +817,16 @@ def delete_entry( .. extdoclink:: Delete Entry :ref: search/reference/delete_entry/ """ # noqa: E501 - if query_params is None: - query_params = {} - query_params["subject"] = subject - if entry_id is not None: - query_params["entry_id"] = entry_id log.debug( "SearchClient.delete_entry({}, {}, {}, ...)".format( index_id, subject, entry_id ) ) + query_params = { + "entry_id": entry_id, + "subject": subject, + **(query_params or {}), + } return self.delete(f"/v1/index/{index_id}/entry", query_params=query_params) # diff --git a/src/globus_sdk/services/search/data.py b/src/globus_sdk/services/search/data.py index dd593f8d1..839b0df25 100644 --- a/src/globus_sdk/services/search/data.py +++ b/src/globus_sdk/services/search/data.py @@ -2,231 +2,15 @@ import typing as t -from globus_sdk import exc, utils +from globus_sdk._missing import MISSING, MissingType +from globus_sdk._payload import GlobusPayload -# workaround for absence of Self type -# for the workaround and some background, see: -# https://github.com/python/mypy/issues/11871 -SearchQueryT = t.TypeVar("SearchQueryT", bound="SearchQueryBase") - -# an internal class for declaring multiple related types with shared methods -class SearchQueryBase(utils.PayloadWrapper): - """ - The base class for all Search query helpers. - - Search has multiple types of query documents. Not all of their supported attributes - are shared, and they therefore do not inherit from one another. - This class implements common methods to all of them. - - Query objects have a chainable API, in which methods return the query object after - modification. This allows usage like - - >>> query = ... - >>> query = query.set_limit(10).set_advanced(False) - """ - - def set_query(self: SearchQueryT, query: str) -> SearchQueryT: - """ - Set the query string for the query document. - - :param query: the new query string - """ - self["q"] = query - return self - - def set_limit(self: SearchQueryT, limit: int) -> SearchQueryT: - """ - Set the limit for the query document. - - :param limit: a limit on the number of results returned in a single page - """ - self["limit"] = limit - return self - - def set_advanced(self: SearchQueryT, advanced: bool) -> SearchQueryT: - """ - Enable or disable advanced query string processing. - - :param advanced: whether to enable (``True``) or not (``False``) - """ - self["advanced"] = advanced - return self - - def add_filter( - self: SearchQueryT, - field_name: str, - values: list[str], - *, - # pylint: disable=redefined-builtin - type: str = "match_all", - additional_fields: dict[str, t.Any] | None = None, - ) -> SearchQueryT: - """ - Add a filter subdocument to the query. - - :param field_name: the field on which to filter - :param values: the values to use in the filter - :param type: the type of filter to apply, defaults to "match_all" - :param additional_fields: additional data to include in the filter document - """ - self["filters"] = self.get("filters", []) - new_filter = { - "field_name": field_name, - "values": values, - "type": type, - **(additional_fields or {}), - } - self["filters"].append(new_filter) - return self - - -class SearchQuery(SearchQueryBase): +class SearchQueryV1(GlobusPayload): """ A specialized dict which has helpers for creating and modifying a Search Query document. - :param q: The query string. Required unless filters are used. - :param limit: A limit on the number of results returned in a single page - :param offset: An offset into the set of all results for the query - :param advanced: Whether to enable (``True``) or not to enable (``False``) advanced - parsing of query strings. The default of ``False`` is robust and guarantees that - the query will not error with "bad query string" errors - :param additional_fields: additional data to include in the query document - - Example usage: - - >>> from globus_sdk import SearchClient, SearchQuery - >>> sc = SearchClient(...) - >>> index_id = ... - >>> query = (SearchQuery(q='example query') - >>> .set_limit(100).set_offset(10) - >>> .add_filter('path.to.field1', ['foo', 'bar'])) - >>> result = sc.post_search(index_id, query) - """ - - def __init__( - self, - q: str | None = None, - *, - limit: int | None = None, - offset: int | None = None, - advanced: bool | None = None, - additional_fields: dict[str, t.Any] | None = None, - ) -> None: - super().__init__() - exc.warn_deprecated("'SearchQuery' is deprecated. Use 'SearchQueryV1' instead.") - if q is not None: - self["q"] = q - if limit is not None: - self["limit"] = limit - if offset is not None: - self["offset"] = offset - if advanced is not None: - self["advanced"] = advanced - if additional_fields is not None: - self.update(additional_fields) - - def set_offset(self, offset: int) -> SearchQuery: - """ - Set the offset for the query document. - - :param offset: an offset into the set of all results for the query - """ - self["offset"] = offset - return self - - def add_facet( - self, - name: str, - field_name: str, - *, - # pylint: disable=redefined-builtin - type: str = "terms", - size: int | None = None, - date_interval: str | None = None, - histogram_range: tuple[t.Any, t.Any] | None = None, - additional_fields: dict[str, t.Any] | None = None, - ) -> SearchQuery: - """ - Add a facet subdocument to the query. - - :param name: the name for the facet in the result - :param field_name: the field on which to build the facet - :param type: the type of facet to apply, defaults to "terms" - :param size: the size parameter for the facet - :param date_interval: the date interval for a date histogram facet - :param histogram_range: a low and high bound for a numeric histogram facet - :param additional_fields: additional data to include in the facet document - """ - self["facets"] = self.get("facets", []) - facet: dict[str, t.Any] = { - "name": name, - "field_name": field_name, - "type": type, - **(additional_fields or {}), - } - if size is not None: - facet["size"] = size - if date_interval is not None: - facet["date_interval"] = date_interval - if histogram_range is not None: - low, high = histogram_range - facet["histogram_range"] = {"low": low, "high": high} - self["facets"].append(facet) - return self - - def add_boost( - self, - field_name: str, - factor: str | int | float, - *, - additional_fields: dict[str, t.Any] | None = None, - ) -> SearchQuery: - """ - Add a boost subdocument to the query. - - :param field_name: the field to boost in result weighting - :param factor: the factor by which to adjust the field weight (where ``1.0`` is - the default weight) - :param additional_fields: additional data to include in the boost document - """ - self["boosts"] = self.get("boosts", []) - boost = { - "field_name": field_name, - "factor": factor, - **(additional_fields or {}), - } - self["boosts"].append(boost) - return self - - def add_sort( - self, - field_name: str, - *, - order: str | None = None, - additional_fields: dict[str, t.Any] | None = None, - ) -> SearchQuery: - """ - Add a sort subdocument to the query. - - :param field_name: the field on which to sort - :param order: ascending or descending order, given as ``"asc"`` or ``"desc"`` - :param additional_fields: additional data to include in the sort document - """ - self["sort"] = self.get("sort", []) - sort = {"field_name": field_name, **(additional_fields or {})} - if order is not None: - sort["order"] = order - self["sort"].append(sort) - return self - - -class SearchQueryV1(utils.PayloadWrapper): - """ - A specialized dict which has helpers for creating and modifying a Search - Query document. Replaces the usage of ``SearchQuery``. - :param q: The query string. Required unless filters are used. :param limit: A limit on the number of results returned in a single page :param offset: An offset into the set of all results for the query @@ -245,16 +29,16 @@ class SearchQueryV1(utils.PayloadWrapper): def __init__( self, *, - q: str | utils.MissingType = utils.MISSING, - limit: int | utils.MissingType = utils.MISSING, - offset: int | utils.MissingType = utils.MISSING, - advanced: bool | utils.MissingType = utils.MISSING, - filters: list[dict[str, t.Any]] | utils.MissingType = utils.MISSING, - facets: list[dict[str, t.Any]] | utils.MissingType = utils.MISSING, - post_facet_filters: list[dict[str, t.Any]] | utils.MissingType = utils.MISSING, - boosts: list[dict[str, t.Any]] | utils.MissingType = utils.MISSING, - sort: list[dict[str, t.Any]] | utils.MissingType = utils.MISSING, - additional_fields: dict[str, t.Any] | utils.MissingType = utils.MISSING, + q: str | MissingType = MISSING, + limit: int | MissingType = MISSING, + offset: int | MissingType = MISSING, + advanced: bool | MissingType = MISSING, + filters: list[dict[str, t.Any]] | MissingType = MISSING, + facets: list[dict[str, t.Any]] | MissingType = MISSING, + post_facet_filters: list[dict[str, t.Any]] | MissingType = MISSING, + boosts: list[dict[str, t.Any]] | MissingType = MISSING, + sort: list[dict[str, t.Any]] | MissingType = MISSING, + additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() self["@version"] = "query#1.0.0" @@ -268,12 +52,10 @@ def __init__( self["post_facet_filters"] = post_facet_filters self["boosts"] = boosts self["sort"] = sort + self.update(additional_fields or {}) - if not isinstance(additional_fields, utils.MissingType): - self.update(additional_fields) - -class SearchScrollQuery(SearchQueryBase): +class SearchScrollQuery(GlobusPayload): """ A scrolling query type, for scrolling the full result set for an index. @@ -295,30 +77,16 @@ class SearchScrollQuery(SearchQueryBase): def __init__( self, - q: str | None = None, + q: str | MissingType = MISSING, *, - limit: int | None = None, - advanced: bool | None = None, - marker: str | None = None, + limit: int | MissingType = MISSING, + advanced: bool | MissingType = MISSING, + marker: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - if q is not None: - self["q"] = q - if limit is not None: - self["limit"] = limit - if advanced is not None: - self["advanced"] = advanced - if marker is not None: - self["marker"] = marker - if additional_fields is not None: - self.update(additional_fields) - - def set_marker(self, marker: str) -> SearchScrollQuery: - """ - Set the marker on a scroll query. - - :param marker: the marker value - """ + self["q"] = q + self["limit"] = limit + self["advanced"] = advanced self["marker"] = marker - return self + self.update(additional_fields or {}) diff --git a/src/globus_sdk/services/timer.py b/src/globus_sdk/services/timer.py deleted file mode 100644 index 9fc85567d..000000000 --- a/src/globus_sdk/services/timer.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -import sys -import typing as t - -from .timers import TimersAPIError, TimersClient - -__all__ = ( - "TimerAPIError", - "TimerClient", -) - -# legacy aliases -# (when accessed, these will emit deprecation warnings in a future release) -if t.TYPE_CHECKING: - TimerClient = TimersClient - TimerAPIError = TimersAPIError -else: - _RENAMES: dict[str, tuple[str, type]] = { - "TimerClient": ("TimersClient", TimersClient), - "TimerAPIError": ("TimersAPIError", TimersAPIError), - } - - def __getattr__(name: str) -> t.Any: - # In the future, add the following snippet or similar to emit - # deprecation warnings: - # - # from globus_sdk.exc import warn_deprecated - # - # if name in _RENAMES: - # new_name = _RENAMES[name][0] - # warn_deprecated( - # f"'globus_sdk.{name}' has been renamed to 'globus_sdk.{new_name}'. " - # "'{name}' is supported as an alias for now." - # ) - if name in _RENAMES: - value = _RENAMES[name][1] - setattr(sys.modules[__name__], name, value) - return value - - raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/src/globus_sdk/services/timers/client.py b/src/globus_sdk/services/timers/client.py index 2bb55a8fe..a0a0eddf1 100644 --- a/src/globus_sdk/services/timers/client.py +++ b/src/globus_sdk/services/timers/client.py @@ -4,9 +4,10 @@ import typing as t import uuid -from globus_sdk import _guards, client, exc, response +from globus_sdk import client, exc, response +from globus_sdk._internal import guards from globus_sdk.scopes import ( - GCSCollectionScopeBuilder, + GCSCollectionScopes, Scope, TimersScopes, TransferScopes, @@ -30,7 +31,7 @@ class TimersClient(client.BaseClient): error_class = TimersAPIError service_name = "timer" scopes = TimersScopes - default_scope_requirements = [Scope(TimersScopes.timer)] + default_scope_requirements = [TimersScopes.timer] def add_app_transfer_data_access_scope( self, collection_ids: uuid.UUID | str | t.Iterable[uuid.UUID | str] @@ -66,9 +67,7 @@ def add_app_transfer_data_access_scope( app = UserApp("myapp", client_id=NATIVE_APP_CLIENT_ID) client = TimersClient(app=app).add_app_transfer_data_access_scope(COLLECTION_ID) - transfer_data = TransferData( - source_endpoint=COLLECTION_ID, destination_endpoint=COLLECTION_ID - ) + transfer_data = TransferData(COLLECTION_ID, COLLECTION_ID) transfer_data.add_item("/staging/", "/active/") daily_timer = TransferTimer( @@ -78,25 +77,24 @@ def add_app_transfer_data_access_scope( client.create_timer(daily_timer) """ # noqa: E501 if isinstance(collection_ids, (str, uuid.UUID)): - _guards.validators.uuidlike("collection_ids", collection_ids) + guards.validators.uuidlike("collection_ids", collection_ids) # wrap the collection_ids input in a list for consistent iteration below collection_ids_ = [collection_ids] else: # copy to a list so that ephemeral iterables can be iterated multiple times collection_ids_ = list(collection_ids) for i, c in enumerate(collection_ids_): - _guards.validators.uuidlike(f"collection_ids[{i}]", c) + guards.validators.uuidlike(f"collection_ids[{i}]", c) - transfer_scope = Scope(TransferScopes.all) + dependencies: list[Scope] = [] for coll_id in collection_ids_: - data_access_scope = Scope( - GCSCollectionScopeBuilder(str(coll_id)).data_access, - optional=True, - ) - transfer_scope.add_dependency(data_access_scope) + data_access_scope = GCSCollectionScopes( + str(coll_id) + ).data_access.with_optional(True) + dependencies.append(data_access_scope) + transfer_scope = TransferScopes.all.with_dependencies(dependencies) - timers_scope = Scope(TimersScopes.timer) - timers_scope.add_dependency(transfer_scope) + timers_scope = TimersScopes.timer.with_dependency(transfer_scope) self.add_app_scope(timers_scope) return self @@ -154,15 +152,14 @@ def create_timer( .. code-block:: pycon - >>> transfer_client = TransferClient(...) - >>> transfer_data = TransferData(transfer_client, ...) - >>> timer_client = globus_sdk.TimersClient(...) + >>> transfer_data = TransferData(...) + >>> timers_client = globus_sdk.TimersClient(...) >>> create_doc = globus_sdk.TransferTimer( ... name="my-timer", ... schedule={"type": "recurring", "interval": 1800}, ... body=transfer_data, ... ) - >>> response = timer_client.create_timer(timer=create_doc) + >>> response = timers_client.create_timer(timer=create_doc) .. tab-item:: Example Response Data @@ -191,16 +188,17 @@ def create_job( **Examples** >>> from datetime import datetime, timedelta, timezone - >>> transfer_client = TransferClient(...) - >>> transfer_data = TransferData(transfer_client, ...) + >>> callback_url = ... + >>> data = ... >>> timer_client = globus_sdk.TimersClient(...) - >>> job = TimerJob.from_transfer_data( - ... transfer_data, + >>> job = TimerJob( + ... callback_url, + ... data, ... datetime.now(tz=timezone.utc).replace(tzinfo=None), ... timedelta(days=14), ... name="my-timer-job" ... ) - >>> timer_result = timer_client.create_job(job) + >>> timer_result = timers_client.create_job(job) """ if isinstance(data, TransferTimer): raise exc.GlobusSDKUsageError( diff --git a/src/globus_sdk/services/timers/data.py b/src/globus_sdk/services/timers/data.py index d4c3c66c4..1fd9d048a 100644 --- a/src/globus_sdk/services/timers/data.py +++ b/src/globus_sdk/services/timers/data.py @@ -7,15 +7,14 @@ import typing as t import uuid -from globus_sdk.config import get_service_url -from globus_sdk.exc import warn_deprecated +from globus_sdk._missing import MISSING, MissingType +from globus_sdk._payload import GlobusPayload from globus_sdk.services.transfer import TransferData -from globus_sdk.utils import MISSING, MissingType, PayloadWrapper, slash_join log = logging.getLogger(__name__) -class TransferTimer(PayloadWrapper): +class TransferTimer(GlobusPayload): """ A helper for defining a payload for Transfer Timer creation. Use this along with :meth:`create_timer ` to @@ -124,7 +123,7 @@ def _preprocess_body( return new_body -class FlowTimer(PayloadWrapper): +class FlowTimer(GlobusPayload): """ A helper for defining a payload for Flow Timer creation. Use this along with :meth:`create_timer ` to @@ -232,7 +231,7 @@ def _preprocess_body(self, body: dict[str, t.Any]) -> dict[str, t.Any]: return body.copy() -class RecurringTimerSchedule(PayloadWrapper): +class RecurringTimerSchedule(GlobusPayload): """ A helper used as part of a *timer* to define when the *timer* will run. @@ -290,7 +289,7 @@ def __init__( } -class OnceTimerSchedule(PayloadWrapper): +class OnceTimerSchedule(GlobusPayload): """ A helper used as part of a *timer* to define when the *timer* will run. @@ -310,7 +309,7 @@ def __init__( self["datetime"] = _format_date(datetime) -class TimerJob(PayloadWrapper): +class TimerJob(GlobusPayload): r""" .. warning:: @@ -373,69 +372,6 @@ def __init__( if scope is not None: self["scope"] = scope - @classmethod - def from_transfer_data( - cls, - transfer_data: TransferData | dict[str, t.Any], - start: dt.datetime | str, - interval: dt.timedelta | int | None, - *, - name: str | None = None, - stop_after: dt.datetime | None = None, - stop_after_n: int | None = None, - scope: str | None = None, - environment: str | None = None, - ) -> TimerJob: - r""" - Specify data to create a Timers job using the parameters for a transfer. Timers - will use those parameters to run the defined transfer operation, recurring at - the given interval. - - :param transfer_data: A :class:`TransferData ` object. - Construct this object exactly as you would normally; Timers will use this to - run the recurring transfer. - :param start: The datetime at which to start the Timers job. - :param interval: The interval at which the Timers job should recur. Interpreted - as seconds if specified as an integer. If ``stop_after_n == 1``, i.e. the - job is set to run only a single time, then interval *must* be None. - :param name: A (not necessarily unique) name to identify this job in Timers - :param stop_after: A date after which the Timers job will stop running - :param stop_after_n: A number of executions after which the Timers job will stop - :param scope: Timers defaults to the Transfer 'all' scope. Use this parameter to - change the scope used by Timers when calling the Transfer Action Provider. - :param environment: For internal use: because this method needs to generate a - URL for the Transfer Action Provider, this argument can control which - environment the Timers job is sent to. - """ - warn_deprecated( - "TimerJob.from_transfer_data(X, ...) is deprecated. " - "Prefer TransferTimer(body=X, ...) instead." - ) - - transfer_action_url = slash_join( - get_service_url("actions", environment=environment), "transfer/transfer/run" - ) - log.debug( - "Creating TimerJob from TransferData, action_url=%s", transfer_action_url - ) - for key in ("submission_id", "skip_activation_check"): - if key in transfer_data: - raise ValueError( - f"cannot create TimerJob from TransferData which has {key} set" - ) - # dict will either convert a `TransferData` object or leave us with a dict here - callback_body = {"body": dict(transfer_data)} - return cls( - transfer_action_url, - callback_body, - start, - interval, - name=name, - stop_after=stop_after, - stop_after_n=stop_after_n, - scope=scope, - ) - def _format_date(date: str | dt.datetime | MissingType) -> str | MissingType: if isinstance(date, dt.datetime): diff --git a/src/globus_sdk/services/timers/errors.py b/src/globus_sdk/services/timers/errors.py index f3f6f4008..c03d1f2ed 100644 --- a/src/globus_sdk/services/timers/errors.py +++ b/src/globus_sdk/services/timers/errors.py @@ -2,7 +2,7 @@ import typing as t -from globus_sdk import _guards +from globus_sdk._internal import guards from globus_sdk.exc import ErrorSubdocument, GlobusAPIError @@ -10,9 +10,8 @@ class TimersAPIError(GlobusAPIError): """ Error class to represent error responses from Timers. - Has no particular additions to the base ``GlobusAPIError``, but implements a - different method for parsing error responses from Timers due to the differences - between various error formats used. + Implements a dedicated method for parsing error responses from Timers due + to the differences between various error formats used. """ def _parse_undefined_error_format(self) -> bool: @@ -48,42 +47,38 @@ def _parse_undefined_error_format(self) -> bool: # but before that fallback, try the two relevant branches # if 'error' is present, use it to populate the errors array - # extract 'code' from it - # and extract 'messages' from it + # extract 'code' and 'messages' from it if isinstance(self._dict_data.get("error"), dict): self.errors = [ErrorSubdocument(self._dict_data["error"])] self.code = self._extract_code_from_error_array(self.errors) self.messages = self._extract_messages_from_error_array(self.errors) return True - elif _guards.is_list_of(self._dict_data.get("detail"), dict): - # FIXME: - # the 'code' is currently being set explicitly by the - # SDK in this case even though none was provided by - # the service - # in a future version of the SDK, the code should be `None` - self.code = "Validation Error" - + elif guards.is_list_of(self._dict_data.get("detail"), dict): # collect the errors array from details - self.errors = [ErrorSubdocument(d) for d in self._dict_data["detail"]] + self.errors = [ + ErrorSubdocument(d, message_fields=("msg",)) + for d in self._dict_data["detail"] + ] + # extract a 'code' if there is one + self.code = self._extract_code_from_error_array(self.errors) - # drop error objects which don't have the relevant fields - # and then build custom 'messages' for Globus Timers errors - details = list(_details_from_errors(self.errors)) + # build custom 'messages' for this case self.messages = [ - f"{e['msg']}: {'.'.join(k for k in e['loc'])}" for e in details + f"{message}: {loc}" + for (message, loc) in _parse_detail_docs(self.errors) ] return True else: return super()._parse_undefined_error_format() -def _details_from_errors( +def _parse_detail_docs( errors: list[ErrorSubdocument], -) -> t.Iterator[dict[str, t.Any]]: +) -> t.Iterator[tuple[str, str]]: for d in errors: - if not isinstance(d.get("msg"), str): + if d.message is None: continue loc_list = d.get("loc") - if not _guards.is_list_of(loc_list, str): + if not guards.is_list_of(loc_list, str): continue - yield d.raw + yield (d.message, ".".join(loc_list)) diff --git a/src/globus_sdk/services/transfer/__init__.py b/src/globus_sdk/services/transfer/__init__.py index 8547712b4..2fa33d11a 100644 --- a/src/globus_sdk/services/transfer/__init__.py +++ b/src/globus_sdk/services/transfer/__init__.py @@ -1,13 +1,12 @@ from .client import TransferClient from .data import DeleteData, TransferData from .errors import TransferAPIError -from .response import ActivationRequirementsResponse, IterableTransferResponse +from .response import IterableTransferResponse __all__ = ( "TransferClient", "TransferData", "DeleteData", "TransferAPIError", - "ActivationRequirementsResponse", "IterableTransferResponse", ) diff --git a/src/globus_sdk/services/transfer/client.py b/src/globus_sdk/services/transfer/client.py index 3ae871563..36300ee14 100644 --- a/src/globus_sdk/services/transfer/client.py +++ b/src/globus_sdk/services/transfer/client.py @@ -5,14 +5,18 @@ import typing as t import uuid -from globus_sdk import _guards, client, exc, paging, response, utils -from globus_sdk._types import DateLike, IntLike -from globus_sdk.scopes import GCSCollectionScopeBuilder, Scope, TransferScopes +from globus_sdk import client, exc, paging, response +from globus_sdk._internal import guards +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.scopes import GCSCollectionScopes, Scope, TransferScopes +from globus_sdk.transport import RetryConfig from .data import DeleteData, TransferData from .errors import TransferAPIError -from .response import ActivationRequirementsResponse, IterableTransferResponse -from .transport import TransferRequestsTransport +from .response import IterableTransferResponse +from .transport import TRANSFER_DEFAULT_RETRY_CHECKS log = logging.getLogger(__name__) @@ -23,15 +27,38 @@ def _datelike_to_str(x: DateLike) -> str: return x if isinstance(x, str) else x.isoformat(timespec="seconds") -def _format_filter_item(x: str | TransferFilterDict) -> str: - if isinstance(x, str): +def _format_completion_time( + x: str | tuple[DateLike, DateLike] | MissingType, +) -> str | MissingType: + if x is MISSING: + return MISSING + elif isinstance(x, str): return x - return "/".join(f"{k}:{utils.commajoin(v)}" for k, v in x.items()) + else: + start_t, end_t = x + start_t, end_t = _datelike_to_str(start_t), _datelike_to_str(end_t) + return f"{start_t},{end_t}" + + +@t.overload +def _format_filter_item(x: str | TransferFilterDict) -> str: ... + + +@t.overload +def _format_filter_item(x: MissingType) -> MissingType: ... + + +def _format_filter_item(x: str | TransferFilterDict | MissingType) -> str | MissingType: + if x is MISSING: + return MISSING + elif isinstance(x, str): + return x + return "/".join(f"{k}:{commajoin(v)}" for k, v in x.items()) def _format_filter( - x: str | TransferFilterDict | list[str | TransferFilterDict], -) -> str | list[str]: + x: str | TransferFilterDict | list[str | TransferFilterDict] | MissingType, +) -> str | list[str] | MissingType: if isinstance(x, list): return [_format_filter_item(y) for y in x] return _format_filter_item(x) @@ -103,13 +130,13 @@ class TransferClient(client.BaseClient): """ service_name = "transfer" - # NOTE: setting base_path is no longer considered good practice - # see the BaseClient source for details - base_path = "/v0.10/" - transport_class: type[TransferRequestsTransport] = TransferRequestsTransport error_class = TransferAPIError scopes = TransferScopes - default_scope_requirements = [Scope(TransferScopes.all)] + 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_DEFAULT_RETRY_CHECKS) def add_app_data_access_scope( self, collection_ids: uuid.UUID | str | t.Iterable[uuid.UUID | str] @@ -167,23 +194,24 @@ def add_app_data_access_scope( res = client.submit_transfer({}) """ # noqa: E501 if isinstance(collection_ids, (str, uuid.UUID)): - _guards.validators.uuidlike("collection_ids", collection_ids) + guards.validators.uuidlike("collection_ids", collection_ids) # wrap the collection_ids input in a list for consistent iteration below collection_ids_ = [collection_ids] else: # copy to a list so that ephemeral iterables can be iterated multiple times collection_ids_ = list(collection_ids) for i, c in enumerate(collection_ids_): - _guards.validators.uuidlike(f"collection_ids[{i}]", c) + guards.validators.uuidlike(f"collection_ids[{i}]", c) - base_scope = Scope(TransferScopes.all) + scope = TransferScopes.all + dependencies: list[Scope] = [] for coll_id in collection_ids_: - data_access_scope = Scope( - GCSCollectionScopeBuilder(str(coll_id)).data_access, - optional=True, - ) - base_scope.add_dependency(data_access_scope) - self.add_app_scope(base_scope) + data_access_scope = GCSCollectionScopes( + str(coll_id) + ).data_access.with_optional(True) + dependencies.append(data_access_scope) + scope = scope.with_dependencies(dependencies) + self.add_app_scope(scope) return self # Convenience methods, providing more pythonic access to common REST @@ -222,7 +250,7 @@ def get_endpoint( :ref: transfer/endpoints_and_collections/#get_endpoint_or_collection_by_id """ # noqa: E501 log.debug(f"TransferClient.get_endpoint({endpoint_id})") - return self.get(f"endpoint/{endpoint_id}", query_params=query_params) + return self.get(f"/v0.10/endpoint/{endpoint_id}", query_params=query_params) def update_endpoint( self, @@ -257,20 +285,10 @@ def update_endpoint( .. extdoclink:: Update Globus Connect Personal collection by id :ref: transfer/gcp_management/#update_collection_by_id """ # noqa: E501 - if data.get("myproxy_server"): - if data.get("oauth_server"): - raise exc.GlobusSDKUsageError( - "an endpoint cannot be reconfigured to use multiple " - "identity providers for activation; specify either " - "MyProxy or OAuth, not both" - ) - else: - data["oauth_server"] = None - elif data.get("oauth_server"): - data["myproxy_server"] = None - log.debug(f"TransferClient.update_endpoint({endpoint_id}, ...)") - return self.put(f"endpoint/{endpoint_id}", data=data, query_params=query_params) + return self.put( + f"/v0.10/endpoint/{endpoint_id}", data=data, query_params=query_params + ) def set_subscription_id( self, @@ -327,7 +345,7 @@ def set_subscription_id( :ref: transfer/gcp_management/#associate_collection_subscription """ # noqa: E501 return self.put( - f"/endpoint/{collection_id}/subscription", + f"/v0.10/endpoint/{collection_id}/subscription", data={"subscription_id": subscription_id}, ) @@ -385,29 +403,6 @@ def set_subscription_admin_verified( data={"subscription_admin_verified": subscription_admin_verified}, ) - def create_endpoint(self, data: dict[str, t.Any]) -> response.GlobusHTTPResponse: - """ - .. warning:: - - This method is deprecated with the end of Globus Connect Server v4 - support and may no longer function with the Transfer API. - - :param data: An endpoint document with fields for the new endpoint - """ - exc.warn_deprecated( - "create_endpoint is specific to Globus Connect Server v4, " - "which is no longer supported by the Transfer API." - ) - if data.get("myproxy_server") and data.get("oauth_server"): - raise exc.GlobusSDKUsageError( - "an endpoint cannot be created using multiple identity " - "providers for activation; specify either MyProxy or OAuth, " - "not both" - ) - - log.debug("TransferClient.create_endpoint(...)") - return self.post("endpoint", data=data) - def delete_endpoint( self, endpoint_id: uuid.UUID | str ) -> response.GlobusHTTPResponse: @@ -431,7 +426,7 @@ def delete_endpoint( :ref: transfer/gcp_management/#delete_collection_by_id """ log.debug(f"TransferClient.delete_endpoint({endpoint_id})") - return self.delete(f"endpoint/{endpoint_id}") + return self.delete(f"/v0.10/endpoint/{endpoint_id}") @paging.has_paginator( paging.HasNextPaginator, @@ -442,12 +437,12 @@ def delete_endpoint( ) def endpoint_search( self, - filter_fulltext: str | None = None, + filter_fulltext: str | MissingType = MISSING, *, - filter_scope: str | None = None, - filter_owner_id: str | None = None, - filter_host_endpoint: uuid.UUID | str | None = None, - filter_non_functional: bool | None = None, + filter_scope: str | MissingType = MISSING, + filter_owner_id: str | MissingType = MISSING, + filter_host_endpoint: uuid.UUID | str | MissingType = MISSING, + filter_non_functional: bool | MissingType = MISSING, filter_entity_type: ( t.Literal[ "GCP_mapped_collection", @@ -456,10 +451,10 @@ def endpoint_search( "GCSv5_mapped_collection", "GCSv5_guest_collection", ] - | None - ) = None, - limit: int | None = None, - offset: int | None = None, + | MissingType + ) = MISSING, + limit: int | MissingType = MISSING, + offset: int | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: r""" @@ -522,147 +517,28 @@ def endpoint_search( .. extdoclink:: Endpoint and Collection Search :ref: transfer/endpoint_and_collection_search """ # noqa: E501 - if query_params is None: - query_params = {} - if filter_scope is not None: - query_params["filter_scope"] = filter_scope - if filter_fulltext is not None: - query_params["filter_fulltext"] = filter_fulltext - if filter_owner_id is not None: - query_params["filter_owner_id"] = filter_owner_id - if filter_host_endpoint is not None: - query_params["filter_host_endpoint"] = filter_host_endpoint - if filter_non_functional is not None: # convert to int (expect bool input) - query_params["filter_non_functional"] = 1 if filter_non_functional else 0 - if filter_entity_type is not None: - query_params["filter_entity_type"] = filter_entity_type - if limit is not None: - query_params["limit"] = limit - if offset is not None: - query_params["offset"] = offset + query_params = { + "filter_scope": filter_scope, + "filter_fulltext": filter_fulltext, + "filter_owner_id": filter_owner_id, + "filter_host_endpoint": filter_host_endpoint, + "filter_non_functional": ( + 1 + if filter_non_functional + else ( + 0 + if isinstance(filter_non_functional, bool) + else filter_non_functional + ) + ), + "filter_entity_type": filter_entity_type, + "limit": limit, + "offset": offset, + **(query_params or {}), + } log.debug(f"TransferClient.endpoint_search({query_params})") return IterableTransferResponse( - self.get("endpoint_search", query_params=query_params) - ) - - def endpoint_autoactivate( - self, - endpoint_id: uuid.UUID | str, - *, - if_expires_in: int | None = None, - query_params: dict[str, t.Any] | None = None, - ) -> response.GlobusHTTPResponse: - r""" - .. warning:: - - This method is deprecated with the end of Globus Connect Server v4 - support and may no longer function with the Transfer API. - - :param endpoint_id: The ID of the endpoint to autoactivate - :param if_expires_in: A number of seconds. Autoactivation will only be attempted - if the current activation expires within this timeframe. Otherwise, - autoactivation will succeed with a code of 'AlreadyActivated' - :param query_params: Any additional parameters will be passed through - as query params. - """ # noqa: E501 - exc.warn_deprecated( - "endpoint_autoactivate is specific to Globus Connect Server v4, " - "which is no longer supported by the Transfer API." - ) - - if query_params is None: - query_params = {} - if if_expires_in is not None: - query_params["if_expires_in"] = if_expires_in - log.debug(f"TransferClient.endpoint_autoactivate({endpoint_id})") - return self.post( - f"endpoint/{endpoint_id}/autoactivate", query_params=query_params - ) - - def endpoint_deactivate( - self, - endpoint_id: uuid.UUID | str, - *, - query_params: dict[str, t.Any] | None = None, - ) -> response.GlobusHTTPResponse: - """ - .. warning:: - - This method is deprecated with the end of Globus Connect Server v4 - support and may no longer function with the Transfer API. - - :param endpoint_id: The ID of the endpoint to deactivate - :param query_params: Any additional parameters will be passed through - as query params. - """ - exc.warn_deprecated( - "endpoint_deactivate is specific to Globus Connect Server v4, " - "which is no longer supported by the Transfer API." - ) - log.debug(f"TransferClient.endpoint_deactivate({endpoint_id})") - return self.post( - f"endpoint/{endpoint_id}/deactivate", query_params=query_params - ) - - def endpoint_activate( - self, - endpoint_id: uuid.UUID | str, - *, - requirements_data: dict[str, t.Any] | None, - query_params: dict[str, t.Any] | None = None, - ) -> response.GlobusHTTPResponse: - """ - .. warning:: - - This method is deprecated with the end of Globus Connect Server v4 - support and may no longer function with the Transfer API. - - :param endpoint_id: The ID of the endpoint to activate - :pram requirements_data: Filled in activation requirements data, as can be - fetched from :meth:`~endpoint_get_activation_requirements`. Only the fields - for the activation type being used need to be filled in. - :param requirements_data: An optional body for the request - :param query_params: Any additional parameters will be passed through - as query params. - """ - exc.warn_deprecated( - "endpoint_activate is specific to Globus Connect Server v4, " - "which is no longer supported by the Transfer API." - ) - log.debug(f"TransferClient.endpoint_activate({endpoint_id})") - return self.post( - f"endpoint/{endpoint_id}/activate", - data=requirements_data, - query_params=query_params, - ) - - def endpoint_get_activation_requirements( - self, - endpoint_id: uuid.UUID | str, - *, - query_params: dict[str, t.Any] | None = None, - ) -> ActivationRequirementsResponse: - """ - .. warning:: - - This method is deprecated with the end of Globus Connect Server v4 - support and may no longer function with the Transfer API. - - :param endpoint_id: The ID of the endpoint whose activation requirements data is - being looked up - :param query_params: Any additional parameters will be passed through - as query params. - """ - exc.warn_deprecated( - "endpoint_get_activation_requirements is specific to " - "Globus Connect Server v4, " - "which is no longer supported by the Transfer API." - ) - return ActivationRequirementsResponse( - self.get( - f"endpoint/{endpoint_id}/activation_requirements", - query_params=query_params, - ) + self.get("/v0.10/endpoint_search", query_params=query_params) ) def my_effective_pause_rule_list( @@ -688,7 +564,7 @@ def my_effective_pause_rule_list( log.debug(f"TransferClient.my_effective_pause_rule_list({endpoint_id}, ...)") return IterableTransferResponse( self.get( - f"endpoint/{endpoint_id}/my_effective_pause_rule_list", + f"/v0.10/endpoint/{endpoint_id}/my_effective_pause_rule_list", query_params=query_params, ) ) @@ -720,7 +596,7 @@ def my_shared_endpoint_list( log.debug(f"TransferClient.my_shared_endpoint_list({endpoint_id}, ...)") return IterableTransferResponse( self.get( - f"endpoint/{endpoint_id}/my_shared_endpoint_list", + f"/v0.10/endpoint/{endpoint_id}/my_shared_endpoint_list", query_params=query_params, ) ) @@ -730,8 +606,8 @@ def get_shared_endpoint_list( self, endpoint_id: uuid.UUID | str, *, - max_results: int | None = None, - next_token: str | None = None, + max_results: int | MissingType = MISSING, + next_token: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ @@ -757,15 +633,16 @@ def get_shared_endpoint_list( :ref: transfer/endpoints_and_collections/get_guest_collection_list """ log.debug(f"TransferClient.get_shared_endpoint_list({endpoint_id}, ...)") - if query_params is None: - query_params = {} - if max_results is not None: - query_params["max_results"] = str(max_results) - if next_token is not None: - query_params["next_token"] = next_token + query_params = { + "max_results": ( + str(max_results) if isinstance(max_results, int) else max_results + ), + "next_token": next_token, + **(query_params or {}), + } return IterableTransferResponse( self.get( - f"endpoint/{endpoint_id}/shared_endpoint_list", + f"/v0.10/endpoint/{endpoint_id}/shared_endpoint_list", query_params=query_params, ), iter_key="shared_endpoints", @@ -803,7 +680,7 @@ def create_shared_endpoint( :ref: transfer/gcp_management/#create_guest_collection """ log.debug("TransferClient.create_shared_endpoint(...)") - return self.post("shared_endpoint", data=data) + return self.post("/v0.10/shared_endpoint", data=data) # Endpoint servers @@ -829,7 +706,9 @@ def endpoint_server_list( """ # noqa: E501 log.debug(f"TransferClient.endpoint_server_list({endpoint_id}, ...)") return IterableTransferResponse( - self.get(f"endpoint/{endpoint_id}/server_list", query_params=query_params) + self.get( + f"/v0.10/endpoint/{endpoint_id}/server_list", query_params=query_params + ) ) def get_endpoint_server( @@ -857,75 +736,9 @@ def get_endpoint_server( "TransferClient.get_endpoint_server(%s, %s, ...)", endpoint_id, server_id ) return self.get( - f"endpoint/{endpoint_id}/server/{server_id}", query_params=query_params - ) - - def add_endpoint_server( - self, endpoint_id: uuid.UUID | str, server_data: dict[str, t.Any] - ) -> response.GlobusHTTPResponse: - """ - .. warning:: - - This method is deprecated with the end of Globus Connect Server v4 - support and may no longer function with the Transfer API. - - :param endpoint_id: The endpoint under which the server is being registered - :param server_data: Fields for the new server, as a server document - """ - exc.warn_deprecated( - "add_endpoint_server is specific to Globus Connect Server v4, " - "which is no longer supported by the Transfer API." - ) - log.debug(f"TransferClient.add_endpoint_server({endpoint_id}, ...)") - return self.post(f"endpoint/{endpoint_id}/server", data=server_data) - - def update_endpoint_server( - self, - endpoint_id: uuid.UUID | str, - server_id: IntLike, - server_data: dict[str, t.Any], - ) -> response.GlobusHTTPResponse: - """ - .. warning:: - - This method is deprecated with the end of Globus Connect Server v4 - support and may no longer function with the Transfer API. - - :param endpoint_id: The endpoint under which the server is registered - :param server_id: The ID of the server to update - :param server_data: Fields on the server to update, as a partial server document - """ - exc.warn_deprecated( - "update_endpoint_server is specific to Globus Connect Server v4, " - "which is no longer supported by the Transfer API." - ) - log.debug( - "TransferClient.update_endpoint_server(%s, %s, ...)", - endpoint_id, - server_id, - ) - return self.put(f"endpoint/{endpoint_id}/server/{server_id}", data=server_data) - - def delete_endpoint_server( - self, endpoint_id: uuid.UUID | str, server_id: IntLike - ) -> response.GlobusHTTPResponse: - """ - .. warning:: - - This method is deprecated with the end of Globus Connect Server v4 - support and may no longer function with the Transfer API. - - :param endpoint_id: The endpoint under which the server is registered - :param server_id: The ID of the server to delete - """ - exc.warn_deprecated( - "delete_endpoint_server is specific to Globus Connect Server v4, " - "which is no longer supported by the Transfer API." - ) - log.debug( - "TransferClient.delete_endpoint_server(%s, %s)", endpoint_id, server_id + f"/v0.10/endpoint/{endpoint_id}/server/{server_id}", + query_params=query_params, ) - return self.delete(f"endpoint/{endpoint_id}/server/{server_id}") # # Roles @@ -953,7 +766,9 @@ def endpoint_role_list( """ log.debug(f"TransferClient.endpoint_role_list({endpoint_id}, ...)") return IterableTransferResponse( - self.get(f"endpoint/{endpoint_id}/role_list", query_params=query_params) + self.get( + f"/v0.10/endpoint/{endpoint_id}/role_list", query_params=query_params + ) ) def add_endpoint_role( @@ -973,7 +788,7 @@ def add_endpoint_role( :ref: transfer/roles/#create_role """ log.debug(f"TransferClient.add_endpoint_role({endpoint_id}, ...)") - return self.post(f"endpoint/{endpoint_id}/role", data=role_data) + return self.post(f"/v0.10/endpoint/{endpoint_id}/role", data=role_data) def get_endpoint_role( self, @@ -998,7 +813,7 @@ def get_endpoint_role( """ log.debug(f"TransferClient.get_endpoint_role({endpoint_id}, {role_id}, ...)") return self.get( - f"endpoint/{endpoint_id}/role/{role_id}", query_params=query_params + f"/v0.10/endpoint/{endpoint_id}/role/{role_id}", query_params=query_params ) def delete_endpoint_role( @@ -1018,7 +833,7 @@ def delete_endpoint_role( :ref: transfer/roles/#delete_role_by_id """ log.debug(f"TransferClient.delete_endpoint_role({endpoint_id}, {role_id})") - return self.delete(f"endpoint/{endpoint_id}/role/{role_id}") + return self.delete(f"/v0.10/endpoint/{endpoint_id}/role/{role_id}") # # ACLs @@ -1045,7 +860,9 @@ def endpoint_acl_list( """ log.debug(f"TransferClient.endpoint_acl_list({endpoint_id}, ...)") return IterableTransferResponse( - self.get(f"endpoint/{endpoint_id}/access_list", query_params=query_params) + self.get( + f"/v0.10/endpoint/{endpoint_id}/access_list", query_params=query_params + ) ) def get_endpoint_acl_rule( @@ -1073,7 +890,7 @@ def get_endpoint_acl_rule( "TransferClient.get_endpoint_acl_rule(%s, %s, ...)", endpoint_id, rule_id ) return self.get( - f"endpoint/{endpoint_id}/access/{rule_id}", query_params=query_params + f"/v0.10/endpoint/{endpoint_id}/access/{rule_id}", query_params=query_params ) def add_endpoint_acl_rule( @@ -1111,7 +928,7 @@ def add_endpoint_acl_rule( :ref: transfer/acl/#rest_access_create """ log.debug(f"TransferClient.add_endpoint_acl_rule({endpoint_id}, ...)") - return self.post(f"endpoint/{endpoint_id}/access", data=rule_data) + return self.post(f"/v0.10/endpoint/{endpoint_id}/access", data=rule_data) def update_endpoint_acl_rule( self, @@ -1138,7 +955,9 @@ def update_endpoint_acl_rule( endpoint_id, rule_id, ) - return self.put(f"endpoint/{endpoint_id}/access/{rule_id}", data=rule_data) + return self.put( + f"/v0.10/endpoint/{endpoint_id}/access/{rule_id}", data=rule_data + ) def delete_endpoint_acl_rule( self, endpoint_id: uuid.UUID | str, rule_id: str @@ -1159,7 +978,7 @@ def delete_endpoint_acl_rule( log.debug( "TransferClient.delete_endpoint_acl_rule(%s, %s)", endpoint_id, rule_id ) - return self.delete(f"endpoint/{endpoint_id}/access/{rule_id}") + return self.delete(f"/v0.10/endpoint/{endpoint_id}/access/{rule_id}") # # Bookmarks @@ -1182,7 +1001,7 @@ def bookmark_list( """ log.debug(f"TransferClient.bookmark_list({query_params})") return IterableTransferResponse( - self.get("bookmark_list", query_params=query_params) + self.get("/v0.10/bookmark_list", query_params=query_params) ) def create_bookmark( @@ -1201,7 +1020,7 @@ def create_bookmark( :ref: transfer/collection_bookmarks/#create_bookmark """ log.debug(f"TransferClient.create_bookmark({bookmark_data})") - return self.post("bookmark", data=bookmark_data) + return self.post("/v0.10/bookmark", data=bookmark_data) def get_bookmark( self, @@ -1223,7 +1042,7 @@ def get_bookmark( :ref: transfer/collection_bookmarks/#get_bookmark_by_id """ log.debug(f"TransferClient.get_bookmark({bookmark_id})") - return self.get(f"bookmark/{bookmark_id}", query_params=query_params) + return self.get(f"/v0.10/bookmark/{bookmark_id}", query_params=query_params) def update_bookmark( self, bookmark_id: uuid.UUID | str, bookmark_data: dict[str, t.Any] @@ -1242,7 +1061,7 @@ def update_bookmark( :ref: transfer/collection_bookmarks/#update_bookmark """ log.debug(f"TransferClient.update_bookmark({bookmark_id})") - return self.put(f"bookmark/{bookmark_id}", data=bookmark_data) + return self.put(f"/v0.10/bookmark/{bookmark_id}", data=bookmark_data) def delete_bookmark( self, bookmark_id: uuid.UUID | str @@ -1260,7 +1079,7 @@ def delete_bookmark( :ref: transfer/collection_bookmarks/#delete_bookmark_by_id """ log.debug(f"TransferClient.delete_bookmark({bookmark_id})") - return self.delete(f"bookmark/{bookmark_id}") + return self.delete(f"/v0.10/bookmark/{bookmark_id}") # # Synchronous Filesys Operations @@ -1269,16 +1088,18 @@ def delete_bookmark( def operation_ls( self, endpoint_id: uuid.UUID | str, - path: str | None = None, + path: str | MissingType = MISSING, *, - show_hidden: bool | None = None, - orderby: str | list[str] | None = None, - limit: int | None = None, - offset: int | None = None, + show_hidden: bool | MissingType = MISSING, + orderby: str | list[str] | MissingType = MISSING, + limit: int | MissingType = MISSING, + offset: int | MissingType = MISSING, # note: filter is a soft keyword in python, so using this name is okay # pylint: disable=redefined-builtin - filter: str | TransferFilterDict | list[str | TransferFilterDict] | None = None, - local_user: str | None = None, + filter: ( + str | TransferFilterDict | list[str | TransferFilterDict] | MissingType + ) = MISSING, + local_user: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ @@ -1353,29 +1174,25 @@ def operation_ls( .. extdoclink:: List Directory Contents :ref: transfer/file_operations/#list_directory_contents """ # noqa: E501 - if query_params is None: - query_params = {} - if path is not None: - query_params["path"] = path - if show_hidden is not None: - query_params["show_hidden"] = 1 if show_hidden else 0 - if limit is not None: - query_params["limit"] = limit - if offset is not None: - query_params["offset"] = offset - if orderby is not None: - if isinstance(orderby, str): - query_params["orderby"] = orderby - else: - query_params["orderby"] = ",".join(orderby) - if filter is not None: - query_params["filter"] = _format_filter(filter) - if local_user is not None: - query_params["local_user"] = local_user - + query_params = { + "path": path, + "limit": limit, + "offset": offset, + "show_hidden": ( + 1 + if show_hidden + else 0 if isinstance(show_hidden, bool) else show_hidden + ), + "orderby": commajoin(orderby), + "filter": _format_filter(filter), + "local_user": local_user, + **(query_params or {}), + } log.debug(f"TransferClient.operation_ls({endpoint_id}, {query_params})") return IterableTransferResponse( - self.get(f"operation/endpoint/{endpoint_id}/ls", query_params=query_params) + self.get( + f"/v0.10/operation/endpoint/{endpoint_id}/ls", query_params=query_params + ) ) def operation_mkdir( @@ -1383,7 +1200,7 @@ def operation_mkdir( endpoint_id: uuid.UUID | str, path: str, *, - local_user: str | None = None, + local_user: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ @@ -1415,11 +1232,13 @@ def operation_mkdir( endpoint_id, path, query_params ) ) - json_body = {"DATA_TYPE": "mkdir", "path": path} - if local_user is not None: - json_body["local_user"] = local_user + json_body = { + "DATA_TYPE": "mkdir", + "path": path, + "local_user": local_user, + } return self.post( - f"operation/endpoint/{endpoint_id}/mkdir", + f"/v0.10/operation/endpoint/{endpoint_id}/mkdir", data=json_body, query_params=query_params, ) @@ -1430,7 +1249,7 @@ def operation_rename( oldpath: str, newpath: str, *, - local_user: str | None = None, + local_user: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ @@ -1467,11 +1286,10 @@ def operation_rename( "DATA_TYPE": "rename", "old_path": oldpath, "new_path": newpath, + "local_user": local_user, } - if local_user is not None: - json_body["local_user"] = local_user return self.post( - f"operation/endpoint/{endpoint_id}/rename", + f"/v0.10/operation/endpoint/{endpoint_id}/rename", data=json_body, query_params=query_params, ) @@ -1479,9 +1297,9 @@ def operation_rename( def operation_stat( self, endpoint_id: uuid.UUID | str, - path: str | None = None, + path: str | MissingType = MISSING, *, - local_user: str | None = None, + local_user: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> response.GlobusHTTPResponse: """ @@ -1512,54 +1330,14 @@ def operation_stat( .. extdoclink:: Get File or Directory Status :ref: transfer/file_operations/#stat """ - if query_params is None: - query_params = {} - if path is not None: - query_params["path"] = path - if local_user is not None: - query_params["local_user"] = local_user - - log.debug(f"TransferClient.operation_stat({endpoint_id}, {query_params})") - return self.get( - f"operation/endpoint/{endpoint_id}/stat", query_params=query_params - ) - - def operation_symlink( - self, - endpoint_id: uuid.UUID | str, - symlink_target: str, - path: str, - *, - query_params: dict[str, t.Any] | None = None, - ) -> response.GlobusHTTPResponse: - """ - :param endpoint_id: The ID of the endpoint on which to create a symlink - :param symlink_target: The path referenced by the new symlink - :param path: The name of (path to) the new symlink - :param query_params: Additional passthrough query parameters - - .. warning:: - - This method is not currently supported by any collections. - """ # noqa: E501 - exc.warn_deprecated( - "operation_symlink is not currently supported by any collections. " - "To reduce confusion, this method will be removed." - ) - log.debug( - "TransferClient.operation_symlink({}, {}, {}, {})".format( - endpoint_id, symlink_target, path, query_params - ) - ) - data = { - "DATA_TYPE": "symlink", - "symlink_target": symlink_target, + query_params = { "path": path, + "local_user": local_user, + **(query_params or {}), } - return self.post( - f"operation/endpoint/{endpoint_id}/symlink", - data=data, - query_params=query_params, + log.debug(f"TransferClient.operation_stat({endpoint_id}, {query_params})") + return self.get( + f"/v0.10/operation/endpoint/{endpoint_id}/stat", query_params=query_params ) # @@ -1594,7 +1372,7 @@ def get_submission_id( .. expandtestfixture:: transfer.get_submission_id """ log.debug(f"TransferClient.get_submission_id({query_params})") - return self.get("submission_id", query_params=query_params) + return self.get("/v0.10/submission_id", query_params=query_params) def submit_transfer( self, data: dict[str, t.Any] | TransferData @@ -1616,16 +1394,16 @@ def submit_transfer( .. code-block:: python - tc = globus_sdk.TransferClient(...) tdata = globus_sdk.TransferData( - tc, source_endpoint_id, destination_endpoint_id, label="SDK example", sync_level="checksum", ) - tdata.add_item("/source/path/dir/", "/dest/path/dir/", recursive=True) + tdata.add_item("/source/path/dir/", "/dest/path/dir/") tdata.add_item("/source/path/file.txt", "/dest/path/file.txt") + + tc = globus_sdk.TransferClient(...) transfer_result = tc.submit_transfer(tdata) print("task_id =", transfer_result["task_id"]) @@ -1640,10 +1418,10 @@ def submit_transfer( :ref: transfer/task_submit/#submit_transfer_task """ # noqa: E501 log.debug("TransferClient.submit_transfer(...)") - if "submission_id" not in data: + if "submission_id" not in data or data["submission_id"] is MISSING: log.debug("submit_transfer autofetching submission_id") data["submission_id"] = self.get_submission_id()["value"] - return self.post("/transfer", data=data) + return self.post("/v0.10/transfer", data=data) def submit_delete( self, data: dict[str, t.Any] | DeleteData @@ -1665,10 +1443,11 @@ def submit_delete( .. code-block:: python - tc = globus_sdk.TransferClient(...) - ddata = globus_sdk.DeleteData(tc, endpoint_id, recursive=True) + ddata = globus_sdk.DeleteData(endpoint_id, recursive=True) ddata.add_item("/dir/to/delete/") ddata.add_item("/file/to/delete/file.txt") + + tc = globus_sdk.TransferClient(...) delete_result = tc.submit_delete(ddata) print("task_id =", delete_result["task_id"]) @@ -1683,10 +1462,10 @@ def submit_delete( :ref: transfer/task_submit/#submit_delete_task """ log.debug("TransferClient.submit_delete(...)") - if "submission_id" not in data: + if "submission_id" not in data or data["submission_id"] is MISSING: log.debug("submit_delete autofetching submission_id") data["submission_id"] = self.get_submission_id()["value"] - return self.post("/delete", data=data) + return self.post("/v0.10/delete", data=data) # # Task inspection and management @@ -1702,11 +1481,11 @@ def submit_delete( def task_list( self, *, - limit: int | None = None, - offset: int | None = None, - orderby: str | list[str] | None = None, + limit: int | MissingType = MISSING, + offset: int | MissingType = MISSING, + orderby: str | list[str] | MissingType = MISSING, # pylint: disable=redefined-builtin - filter: str | TransferFilterDict | None = None, + filter: str | TransferFilterDict | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ @@ -1782,21 +1561,15 @@ def task_list( :ref: transfer/task/#get_task_list """ # noqa: E501 log.debug("TransferClient.task_list(...)") - if query_params is None: - query_params = {} - if limit is not None: - query_params["limit"] = limit - if offset is not None: - query_params["offset"] = offset - if orderby is not None: - if isinstance(orderby, str): - query_params["orderby"] = orderby - else: - query_params["orderby"] = ",".join(orderby) - if filter is not None: - query_params["filter"] = _format_filter_item(filter) + query_params = { + "limit": limit, + "offset": offset, + "orderby": commajoin(orderby), + "filter": _format_filter_item(filter), + **(query_params or {}), + } return IterableTransferResponse( - self.get("task_list", query_params=query_params) + self.get("/v0.10/task_list", query_params=query_params) ) @paging.has_paginator( @@ -1810,8 +1583,8 @@ def task_event_list( self, task_id: uuid.UUID | str, *, - limit: int | None = None, - offset: int | None = None, + limit: int | MissingType = MISSING, + offset: int | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: r""" @@ -1847,14 +1620,13 @@ def task_event_list( :ref: transfer/task/#get_event_list """ # noqa: E501 log.debug(f"TransferClient.task_event_list({task_id}, ...)") - if query_params is None: - query_params = {} - if limit is not None: - query_params["limit"] = limit - if offset is not None: - query_params["offset"] = offset + query_params = { + "limit": limit, + "offset": offset, + **(query_params or {}), + } return IterableTransferResponse( - self.get(f"task/{task_id}/event_list", query_params=query_params) + self.get(f"/v0.10/task/{task_id}/event_list", query_params=query_params) ) def get_task( @@ -1877,7 +1649,7 @@ def get_task( :ref: transfer/task/#get_task_by_id """ log.debug(f"TransferClient.get_task({task_id}, ...)") - return self.get(f"task/{task_id}", query_params=query_params) + return self.get(f"/v0.10/task/{task_id}", query_params=query_params) def update_task( self, @@ -1904,7 +1676,7 @@ def update_task( :ref: transfer/task/#update_task_by_id """ log.debug(f"TransferClient.update_task({task_id}, ...)") - return self.put(f"task/{task_id}", data=data, query_params=query_params) + return self.put(f"/v0.10/task/{task_id}", data=data, query_params=query_params) def cancel_task(self, task_id: uuid.UUID | str) -> response.GlobusHTTPResponse: """ @@ -1922,7 +1694,7 @@ def cancel_task(self, task_id: uuid.UUID | str) -> response.GlobusHTTPResponse: :ref: transfer/task/#cancel_task_by_id """ log.debug(f"TransferClient.cancel_task({task_id})") - return self.post(f"task/{task_id}/cancel") + return self.post(f"/v0.10/task/{task_id}/cancel") def task_wait( self, task_id: uuid.UUID | str, *, timeout: int = 10, polling_interval: int = 10 @@ -2049,7 +1821,7 @@ def task_pause_info( :ref: transfer/task/#get_task_pause_info """ log.debug(f"TransferClient.task_pause_info({task_id}, ...)") - return self.get(f"task/{task_id}/pause_info", query_params=query_params) + return self.get(f"/v0.10/task/{task_id}/pause_info", query_params=query_params) @paging.has_paginator( paging.NullableMarkerPaginator, items_key="DATA", marker_key="next_marker" @@ -2058,7 +1830,7 @@ def task_successful_transfers( self, task_id: uuid.UUID | str, *, - marker: str | None = None, + marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ @@ -2100,12 +1872,14 @@ def task_successful_transfers( :ref: transfer/task/#get_task_successful_transfers """ # noqa: E501 log.debug(f"TransferClient.task_successful_transfers({task_id}, ...)") - if query_params is None: - query_params = {} - if marker is not None: - query_params["marker"] = marker + query_params = { + "marker": marker, + **(query_params or {}), + } return IterableTransferResponse( - self.get(f"task/{task_id}/successful_transfers", query_params=query_params) + self.get( + f"/v0.10/task/{task_id}/successful_transfers", query_params=query_params + ) ) @paging.has_paginator( @@ -2115,7 +1889,7 @@ def task_skipped_errors( self, task_id: uuid.UUID | str, *, - marker: str | None = None, + marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ @@ -2151,12 +1925,12 @@ def task_skipped_errors( :ref: transfer/task/#get_task_skipped_errors """ # noqa: E501 log.debug("TransferClient.task_skipped_errors(%s, ...)", task_id) - if query_params is None: - query_params = {} - if marker is not None: - query_params["marker"] = marker + query_params = { + "marker": marker, + **(query_params or {}), + } return IterableTransferResponse( - self.get(f"task/{task_id}/skipped_errors", query_params=query_params) + self.get(f"/v0.10/task/{task_id}/skipped_errors", query_params=query_params) ) # @@ -2184,7 +1958,9 @@ def endpoint_manager_monitored_endpoints( f"TransferClient.endpoint_manager_monitored_endpoints({query_params})" ) return IterableTransferResponse( - self.get("endpoint_manager/monitored_endpoints", query_params=query_params) + self.get( + "/v0.10/endpoint_manager/monitored_endpoints", query_params=query_params + ) ) def endpoint_manager_hosted_endpoint_list( @@ -2213,7 +1989,7 @@ def endpoint_manager_hosted_endpoint_list( ) return IterableTransferResponse( self.get( - f"endpoint_manager/endpoint/{endpoint_id}/hosted_endpoint_list", + f"/v0.10/endpoint_manager/endpoint/{endpoint_id}/hosted_endpoint_list", query_params=query_params, ) ) @@ -2241,7 +2017,7 @@ def endpoint_manager_get_endpoint( """ # noqa: E501 log.debug(f"TransferClient.endpoint_manager_get_endpoint({endpoint_id})") return self.get( - f"endpoint_manager/endpoint/{endpoint_id}", query_params=query_params + f"/v0.10/endpoint_manager/endpoint/{endpoint_id}", query_params=query_params ) def endpoint_manager_acl_list( @@ -2270,7 +2046,7 @@ def endpoint_manager_acl_list( ) return IterableTransferResponse( self.get( - f"endpoint_manager/endpoint/{endpoint_id}/access_list", + f"/v0.10/endpoint_manager/endpoint/{endpoint_id}/access_list", query_params=query_params, ) ) @@ -2283,16 +2059,18 @@ def endpoint_manager_acl_list( def endpoint_manager_task_list( self, *, - filter_status: None | str | t.Iterable[str] = None, - filter_task_id: None | uuid.UUID | str | t.Iterable[uuid.UUID | str] = None, - filter_owner_id: uuid.UUID | str | None = None, - filter_endpoint: uuid.UUID | str | None = None, - filter_endpoint_use: t.Literal["source", "destination"] | None = None, - filter_is_paused: bool | None = None, - filter_completion_time: None | str | tuple[DateLike, DateLike] = None, - filter_min_faults: int | None = None, - filter_local_user: str | None = None, - last_key: str | None = None, + filter_status: str | t.Iterable[str] | MissingType = MISSING, + filter_task_id: ( + uuid.UUID | str | t.Iterable[uuid.UUID | str] | MissingType + ) = MISSING, + filter_owner_id: uuid.UUID | str | MissingType = MISSING, + filter_endpoint: uuid.UUID | str | MissingType = MISSING, + filter_endpoint_use: t.Literal["source", "destination"] | MissingType = MISSING, + filter_is_paused: bool | MissingType = MISSING, + filter_completion_time: str | tuple[DateLike, DateLike] | MissingType = MISSING, + filter_min_faults: int | MissingType = MISSING, + filter_local_user: str | MissingType = MISSING, + last_key: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: r""" @@ -2400,41 +2178,26 @@ def endpoint_manager_task_list( :ref: transfer/advanced_collection_management/#get_tasks """ # noqa: E501 log.debug("TransferClient.endpoint_manager_task_list(...)") - if filter_endpoint is None and filter_endpoint_use is not None: + if filter_endpoint is MISSING and filter_endpoint_use is not MISSING: raise exc.GlobusSDKUsageError( "`filter_endpoint_use` is only valid when `filter_endpoint` is " "also supplied." ) - - if query_params is None: - query_params = {} - if filter_status is not None: - query_params["filter_status"] = utils.commajoin(filter_status) - if filter_task_id is not None: - query_params["filter_task_id"] = utils.commajoin(filter_task_id) - if filter_owner_id is not None: - query_params["filter_owner_id"] = filter_owner_id - if filter_endpoint is not None: - query_params["filter_endpoint"] = filter_endpoint - if filter_endpoint_use is not None: - query_params["filter_endpoint_use"] = filter_endpoint_use - if filter_is_paused is not None: - query_params["filter_is_paused"] = filter_is_paused - if filter_completion_time is not None: - if isinstance(filter_completion_time, str): - query_params["filter_completion_time"] = filter_completion_time - else: - start_t, end_t = filter_completion_time - start_t, end_t = _datelike_to_str(start_t), _datelike_to_str(end_t) - query_params["filter_completion_time"] = f"{start_t},{end_t}" - if filter_min_faults is not None: - query_params["filter_min_faults"] = filter_min_faults - if filter_local_user is not None: - query_params["filter_local_user"] = filter_local_user - if last_key is not None: - query_params["last_key"] = last_key + query_params = { + "filter_status": commajoin(filter_status), + "filter_task_id": commajoin(filter_task_id), + "filter_owner_id": filter_owner_id, + "filter_endpoint": filter_endpoint, + "filter_endpoint_use": filter_endpoint_use, + "filter_is_paused": filter_is_paused, + "filter_completion_time": _format_completion_time(filter_completion_time), + "filter_min_faults": filter_min_faults, + "filter_local_user": filter_local_user, + "last_key": last_key, + **(query_params or {}), + } return IterableTransferResponse( - self.get("endpoint_manager/task_list", query_params=query_params) + self.get("/v0.10/endpoint_manager/task_list", query_params=query_params) ) def endpoint_manager_get_task( @@ -2460,7 +2223,9 @@ def endpoint_manager_get_task( :ref: transfer/advanced_collection_management/#get_task """ log.debug(f"TransferClient.endpoint_manager_get_task({task_id}, ...)") - return self.get(f"endpoint_manager/task/{task_id}", query_params=query_params) + return self.get( + f"/v0.10/endpoint_manager/task/{task_id}", query_params=query_params + ) @paging.has_paginator( paging.LimitOffsetTotalPaginator, @@ -2473,9 +2238,9 @@ def endpoint_manager_task_event_list( self, task_id: uuid.UUID | str, *, - limit: int | None = None, - offset: int | None = None, - filter_is_error: bool | None = None, + limit: int | MissingType = MISSING, + offset: int | MissingType = MISSING, + filter_is_error: bool | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ @@ -2505,17 +2270,20 @@ def endpoint_manager_task_event_list( :ref: transfer/advanced_collection_management/#get_task_events """ log.debug(f"TransferClient.endpoint_manager_task_event_list({task_id}, ...)") - if query_params is None: - query_params = {} - if limit is not None: - query_params["limit"] = limit - if offset is not None: - query_params["offset"] = offset - if filter_is_error is not None: - query_params["filter_is_error"] = 1 if filter_is_error else 0 + query_params = { + "limit": limit, + "offset": offset, + "filter_is_error": ( + 1 + if filter_is_error + else 0 if isinstance(filter_is_error, bool) else filter_is_error + ), + **(query_params or {}), + } return IterableTransferResponse( self.get( - f"endpoint_manager/task/{task_id}/event_list", query_params=query_params + f"/v0.10/endpoint_manager/task/{task_id}/event_list", + query_params=query_params, ) ) @@ -2543,7 +2311,8 @@ def endpoint_manager_task_pause_info( """ # noqa: E501 log.debug(f"TransferClient.endpoint_manager_task_pause_info({task_id}, ...)") return self.get( - f"endpoint_manager/task/{task_id}/pause_info", query_params=query_params + f"/v0.10/endpoint_manager/task/{task_id}/pause_info", + query_params=query_params, ) @paging.has_paginator( @@ -2553,7 +2322,7 @@ def endpoint_manager_task_successful_transfers( self, task_id: uuid.UUID | str, *, - marker: str | None = None, + marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: r""" @@ -2581,13 +2350,13 @@ def endpoint_manager_task_successful_transfers( "TransferClient.endpoint_manager_task_successful_transfers(%s, ...)", task_id, ) - if query_params is None: - query_params = {} - if marker is not None: - query_params["marker"] = marker + query_params = { + "marker": marker, + **(query_params or {}), + } return IterableTransferResponse( self.get( - f"endpoint_manager/task/{task_id}/successful_transfers", + f"/v0.10/endpoint_manager/task/{task_id}/successful_transfers", query_params=query_params, ) ) @@ -2599,7 +2368,7 @@ def endpoint_manager_task_skipped_errors( self, task_id: uuid.UUID | str, *, - marker: str | None = None, + marker: str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: r""" @@ -2626,13 +2395,13 @@ def endpoint_manager_task_skipped_errors( log.debug( f"TransferClient.endpoint_manager_task_skipped_errors({task_id}, ...)" ) - if query_params is None: - query_params = {} - if marker is not None: - query_params["marker"] = marker + query_params = { + "marker": marker, + **(query_params or {}), + } return IterableTransferResponse( self.get( - f"endpoint_manager/task/{task_id}/skipped_errors", + f"/v0.10/endpoint_manager/task/{task_id}/skipped_errors", query_params=query_params, ) ) @@ -2667,7 +2436,7 @@ def endpoint_manager_cancel_tasks( ) data = {"message": message, "task_id_list": str_task_ids} return self.post( - "endpoint_manager/admin_cancel", data=data, query_params=query_params + "/v0.10/endpoint_manager/admin_cancel", data=data, query_params=query_params ) def endpoint_manager_cancel_status( @@ -2694,7 +2463,7 @@ def endpoint_manager_cancel_status( """ # noqa: E501 log.debug(f"TransferClient.endpoint_manager_cancel_status({admin_cancel_id})") return self.get( - f"endpoint_manager/admin_cancel/{admin_cancel_id}", + f"/v0.10/endpoint_manager/admin_cancel/{admin_cancel_id}", query_params=query_params, ) @@ -2728,7 +2497,7 @@ def endpoint_manager_pause_tasks( ) data = {"message": message, "task_id_list": str_task_ids} return self.post( - "endpoint_manager/admin_pause", data=data, query_params=query_params + "/v0.10/endpoint_manager/admin_pause", data=data, query_params=query_params ) def endpoint_manager_resume_tasks( @@ -2757,7 +2526,7 @@ def endpoint_manager_resume_tasks( log.debug(f"TransferClient.endpoint_manager_resume_tasks({str_task_ids})") data = {"task_id_list": str_task_ids} return self.post( - "endpoint_manager/admin_resume", data=data, query_params=query_params + "/v0.10/endpoint_manager/admin_resume", data=data, query_params=query_params ) # @@ -2767,7 +2536,7 @@ def endpoint_manager_resume_tasks( def endpoint_manager_pause_rule_list( self, *, - filter_endpoint: uuid.UUID | str | None = None, + filter_endpoint: uuid.UUID | str | MissingType = MISSING, query_params: dict[str, t.Any] | None = None, ) -> IterableTransferResponse: """ @@ -2789,12 +2558,14 @@ def endpoint_manager_pause_rule_list( :ref: transfer/advanced_collection_management/#get_pause_rules """ log.debug("TransferClient.endpoint_manager_pause_rule_list(...)") - if query_params is None: - query_params = {} - if filter_endpoint is not None: - query_params["filter_endpoint"] = filter_endpoint + query_params = { + "filter_endpoint": filter_endpoint, + **(query_params or {}), + } return IterableTransferResponse( - self.get("endpoint_manager/pause_rule_list", query_params=query_params) + self.get( + "/v0.10/endpoint_manager/pause_rule_list", query_params=query_params + ) ) def endpoint_manager_create_pause_rule( @@ -2831,7 +2602,7 @@ def endpoint_manager_create_pause_rule( :ref: transfer/advanced_collection_management/#create_pause_rule """ log.debug("TransferClient.endpoint_manager_create_pause_rule(...)") - return self.post("endpoint_manager/pause_rule", data=data) + return self.post("/v0.10/endpoint_manager/pause_rule", data=data) def endpoint_manager_get_pause_rule( self, @@ -2857,7 +2628,8 @@ def endpoint_manager_get_pause_rule( """ log.debug(f"TransferClient.endpoint_manager_get_pause_rule({pause_rule_id})") return self.get( - f"endpoint_manager/pause_rule/{pause_rule_id}", query_params=query_params + f"/v0.10/endpoint_manager/pause_rule/{pause_rule_id}", + query_params=query_params, ) def endpoint_manager_update_pause_rule( @@ -2895,7 +2667,9 @@ def endpoint_manager_update_pause_rule( :ref: transfer/advanced_collection_management/#update_pause_rule """ log.debug(f"TransferClient.endpoint_manager_update_pause_rule({pause_rule_id})") - return self.put(f"endpoint_manager/pause_rule/{pause_rule_id}", data=data) + return self.put( + f"/v0.10/endpoint_manager/pause_rule/{pause_rule_id}", data=data + ) def endpoint_manager_delete_pause_rule( self, @@ -2922,5 +2696,6 @@ def endpoint_manager_delete_pause_rule( """ log.debug(f"TransferClient.endpoint_manager_delete_pause_rule({pause_rule_id})") return self.delete( - f"endpoint_manager/pause_rule/{pause_rule_id}", query_params=query_params + f"/v0.10/endpoint_manager/pause_rule/{pause_rule_id}", + query_params=query_params, ) diff --git a/src/globus_sdk/services/transfer/data/__init__.py b/src/globus_sdk/services/transfer/data/__init__.py index 92b79a37c..b5904588a 100644 --- a/src/globus_sdk/services/transfer/data/__init__.py +++ b/src/globus_sdk/services/transfer/data/__init__.py @@ -1,8 +1,7 @@ """ Data helper classes for constructing Transfer API documents. All classes should -be PayloadWrapper types, so they can be passed seamlessly to -:class:`TransferClient ` methods without -conversion. +be Payload types, so they can be passed seamlessly to +:class:`TransferClient ` methods without conversion. """ from .delete_data import DeleteData diff --git a/src/globus_sdk/services/transfer/data/delete_data.py b/src/globus_sdk/services/transfer/data/delete_data.py index a4875b408..adba50220 100644 --- a/src/globus_sdk/services/transfer/data/delete_data.py +++ b/src/globus_sdk/services/transfer/data/delete_data.py @@ -5,15 +5,14 @@ import typing as t import uuid -from globus_sdk import exc, utils - -if t.TYPE_CHECKING: - import globus_sdk +from globus_sdk._internal.remarshal import stringify +from globus_sdk._missing import MISSING, MissingType +from globus_sdk._payload import GlobusPayload log = logging.getLogger(__name__) -class DeleteData(utils.PayloadWrapper): +class DeleteData(GlobusPayload): r""" Convenience class for constructing a delete document, to use as the `data` parameter to @@ -22,20 +21,12 @@ class DeleteData(utils.PayloadWrapper): At least one item must be added using :meth:`add_item `. - If ``submission_id`` isn't passed, one will be fetched automatically. The - submission ID can be pulled out of here to inspect, but the document - can be used as-is multiple times over to retry a potential submission - failure (so there shouldn't be any need to inspect it). - - :param transfer_client: A ``TransferClient`` instance which will be used to get a - submission ID if one is not supplied. Should be the same instance that is used - to submit the deletion. :param endpoint: The endpoint ID which is targeted by this deletion Task :param label: A string label for the Task - :param submission_id: A submission ID value fetched via - :meth:`get_submission_id `. - Defaults to using ``transfer_client.get_submission_id`` if a ``transfer_client`` - is provided + :param submission_id: A submission ID value fetched via :meth:`get_submission_id + `. By default, the SDK + will fetch and populate this field when :meth:`submit_delete + ` is called. :param recursive: Recursively delete subdirectories on the target endpoint [default: ``False``] :param ignore_missing: Ignore nonexistent files and directories instead of treating @@ -49,8 +40,6 @@ class DeleteData(utils.PayloadWrapper): timestamp is in UTC to avoid confusion and ambiguity. Examples of ISO-8601 timestamps include ``2017-10-12 09:30Z``, ``2017-10-12 12:33:54+00:00``, and ``2017-10-12`` - :param skip_activation_check: This argument is deprecated, as 'activation' is no - longer supported by Globus Collections. :param notify_on_succeeded: Send a notification email when the delete task completes with a status of SUCCEEDED. [default: ``True``] @@ -74,10 +63,10 @@ class DeleteData(utils.PayloadWrapper): **External Documentation** See the - `Task document definition \ + `Task document definition `_ and - `Delete specific fields \ + `Delete specific fields `_ in the REST documentation for more details on Delete Task documents. @@ -86,57 +75,34 @@ class DeleteData(utils.PayloadWrapper): def __init__( self, - transfer_client: globus_sdk.TransferClient | None = None, - endpoint: uuid.UUID | str | None = None, + endpoint: uuid.UUID | str, *, - label: str | None = None, - submission_id: uuid.UUID | str | None = None, - recursive: bool = False, - ignore_missing: bool = False, - interpret_globs: bool = False, - deadline: str | datetime.datetime | None = None, - skip_activation_check: bool | None = None, - notify_on_succeeded: bool = True, - notify_on_failed: bool = True, - notify_on_inactive: bool = True, - local_user: str | None = None, + label: str | MissingType = MISSING, + submission_id: uuid.UUID | str | MissingType = MISSING, + recursive: bool | MissingType = MISSING, + ignore_missing: bool | MissingType = MISSING, + interpret_globs: bool | MissingType = MISSING, + deadline: str | datetime.datetime | MissingType = MISSING, + notify_on_succeeded: bool | MissingType = MISSING, + notify_on_failed: bool | MissingType = MISSING, + notify_on_inactive: bool | MissingType = MISSING, + local_user: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - # this must be checked explicitly to handle the fact that `transfer_client` is - # the first arg - if endpoint is None: - raise exc.GlobusSDKUsageError("endpoint is required") - - if skip_activation_check is not None: - exc.warn_deprecated( - "`skip_activation_check` is no longer supported by Globus Collections, " - "and has no effect when set." - ) - self["DATA_TYPE"] = "delete" self["DATA"] = [] - self._set_optstrs( - endpoint=endpoint, - label=label, - submission_id=submission_id - or ( - transfer_client.get_submission_id()["value"] - if transfer_client - else None - ), - deadline=deadline, - local_user=local_user, - ) - self._set_optbools( - recursive=recursive, - ignore_missing=ignore_missing, - interpret_globs=interpret_globs, - skip_activation_check=skip_activation_check, - notify_on_succeeded=notify_on_succeeded, - notify_on_failed=notify_on_failed, - notify_on_inactive=notify_on_inactive, - ) + self["endpoint"] = endpoint + self["label"] = label + self["submission_id"] = submission_id + self["deadline"] = stringify(deadline) + self["local_user"] = local_user + self["recursive"] = recursive + self["ignore_missing"] = ignore_missing + self["interpret_globs"] = interpret_globs + self["notify_on_succeeded"] = notify_on_succeeded + self["notify_on_failed"] = notify_on_failed + self["notify_on_inactive"] = notify_on_inactive for k, v in self.items(): log.debug("DeleteData.%s = %s", k, v) @@ -166,9 +132,11 @@ def add_item( :param path: Path to the directory or file to be deleted :param additional_fields: additional fields to be added to the delete item """ - item_data = {"DATA_TYPE": "delete_item", "path": path} - if additional_fields is not None: - item_data.update(additional_fields) + item_data = { + "DATA_TYPE": "delete_item", + "path": path, + **(additional_fields or {}), + } log.debug('DeleteData[{}].add_item: "{}"'.format(self["endpoint"], path)) self["DATA"].append(item_data) diff --git a/src/globus_sdk/services/transfer/data/transfer_data.py b/src/globus_sdk/services/transfer/data/transfer_data.py index 737c72050..c15c27466 100644 --- a/src/globus_sdk/services/transfer/data/transfer_data.py +++ b/src/globus_sdk/services/transfer/data/transfer_data.py @@ -5,10 +5,8 @@ import typing as t import uuid -from globus_sdk import exc, utils - -if t.TYPE_CHECKING: - import globus_sdk +from globus_sdk._missing import MISSING, MissingType +from globus_sdk._payload import GlobusPayload log = logging.getLogger(__name__) _sync_level_dict: dict[t.Literal["exists", "size", "mtime", "checksum"], int] = { @@ -20,8 +18,8 @@ def _parse_sync_level( - sync_level: t.Literal["exists", "size", "mtime", "checksum"] | int, -) -> int: + sync_level: t.Literal["exists", "size", "mtime", "checksum"] | int | MissingType, +) -> int | MissingType: """ Map sync_level strings to known int values @@ -35,7 +33,7 @@ def _parse_sync_level( return sync_level -class TransferData(utils.PayloadWrapper): +class TransferData(GlobusPayload): r""" Convenience class for constructing a transfer document, to use as the ``data`` parameter to @@ -44,20 +42,13 @@ class TransferData(utils.PayloadWrapper): At least one item must be added using :meth:`add_item `. - If ``submission_id`` isn't passed, one will be fetched automatically. The - submission ID can be pulled out of here to inspect, but the document - can be used as-is multiple times over to retry a potential submission - failure (so there shouldn't be any need to inspect it). - - :param transfer_client: A ``TransferClient`` instance which will be used to get a - submission ID if one is not supplied. Should be the same instance that is used - to submit the transfer. :param source_endpoint: The endpoint ID of the source endpoint :param destination_endpoint: The endpoint ID of the destination endpoint :param label: A string label for the Task :param submission_id: A submission ID value fetched via :meth:`get_submission_id \ - `. Defaults to using - ``transfer_client.get_submission_id`` + `. By default, the SDK + will fetch and populate this field when :meth:`submit_transfer \ + ` is called. :param sync_level: The method used to compare items between the source and destination. One of ``"exists"``, ``"size"``, ``"mtime"``, or ``"checksum"`` See the section below on sync-level for an explanation of values. @@ -77,10 +68,6 @@ class TransferData(utils.PayloadWrapper): timestamp is in UTC to avoid confusion and ambiguity. Examples of ISO-8601 timestamps include ``2017-10-12 09:30Z``, ``2017-10-12 12:33:54+00:00``, and ``2017-10-12`` - :param recursive_symlinks: This keyword argument is deprecated as not collections - support it. - :param skip_activation_check: This argument is deprecated, as 'activation' is no - longer supported by Globus Collections. :param skip_source_errors: When true, source permission denied and file not found errors from the source endpoint will cause the offending path to be skipped. @@ -159,82 +146,49 @@ class TransferData(utils.PayloadWrapper): def __init__( self, - transfer_client: globus_sdk.TransferClient | None = None, - source_endpoint: uuid.UUID | str | None = None, - destination_endpoint: uuid.UUID | str | None = None, + source_endpoint: uuid.UUID | str, + destination_endpoint: uuid.UUID | str, *, - label: str | None = None, - submission_id: uuid.UUID | str | None = None, + label: str | MissingType = MISSING, + submission_id: uuid.UUID | str | MissingType = MISSING, sync_level: ( - int | None | t.Literal["exists", "size", "mtime", "checksum"] - ) = None, - verify_checksum: bool = False, - preserve_timestamp: bool = False, - encrypt_data: bool = False, - deadline: datetime.datetime | str | None = None, - skip_activation_check: bool | None = None, - skip_source_errors: bool = False, - fail_on_quota_errors: bool = False, - recursive_symlinks: str | None = None, - delete_destination_extra: bool = False, - notify_on_succeeded: bool = True, - notify_on_failed: bool = True, - notify_on_inactive: bool = True, - source_local_user: str | None = None, - destination_local_user: str | None = None, + int | t.Literal["exists", "size", "mtime", "checksum"] | MissingType + ) = MISSING, + verify_checksum: bool | MissingType = MISSING, + preserve_timestamp: bool | MissingType = MISSING, + encrypt_data: bool | MissingType = MISSING, + deadline: datetime.datetime | str | MissingType = MISSING, + skip_source_errors: bool | MissingType = MISSING, + fail_on_quota_errors: bool | MissingType = MISSING, + delete_destination_extra: bool | MissingType = MISSING, + notify_on_succeeded: bool | MissingType = MISSING, + notify_on_failed: bool | MissingType = MISSING, + notify_on_inactive: bool | MissingType = MISSING, + source_local_user: str | MissingType = MISSING, + destination_local_user: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: super().__init__() - # these must be checked explicitly to handle the fact that `transfer_client` is - # the first arg - if source_endpoint is None: - raise exc.GlobusSDKUsageError("source_endpoint is required") - if destination_endpoint is None: - raise exc.GlobusSDKUsageError("destination_endpoint is required") - - if recursive_symlinks: - exc.warn_deprecated( - "`recursive_symlinks` is not currently supported by any collections. " - "To reduce confusion, this keyword argument will be removed." - ) - - if skip_activation_check is not None: - exc.warn_deprecated( - "`skip_activation_check` is no longer supported by Globus Collections, " - "and has no effect when set." - ) - log.debug("Creating a new TransferData object") self["DATA_TYPE"] = "transfer" self["DATA"] = [] - self._set_optstrs( - source_endpoint=source_endpoint, - destination_endpoint=destination_endpoint, - label=label, - submission_id=submission_id - or ( - transfer_client.get_submission_id()["value"] - if transfer_client - else None - ), - recursive_symlinks=recursive_symlinks, - deadline=deadline, - source_local_user=source_local_user, - destination_local_user=destination_local_user, - ) - self._set_optbools( - verify_checksum=verify_checksum, - preserve_timestamp=preserve_timestamp, - encrypt_data=encrypt_data, - skip_activation_check=skip_activation_check, - skip_source_errors=skip_source_errors, - fail_on_quota_errors=fail_on_quota_errors, - delete_destination_extra=delete_destination_extra, - notify_on_succeeded=notify_on_succeeded, - notify_on_failed=notify_on_failed, - notify_on_inactive=notify_on_inactive, - ) - self._set_value("sync_level", sync_level, callback=_parse_sync_level) + self["source_endpoint"] = source_endpoint + self["destination_endpoint"] = destination_endpoint + self["label"] = label + self["submission_id"] = submission_id + self["deadline"] = deadline + self["source_local_user"] = source_local_user + self["destination_local_user"] = destination_local_user + self["verify_checksum"] = verify_checksum + self["preserve_timestamp"] = preserve_timestamp + self["encrypt_data"] = encrypt_data + self["skip_source_errors"] = skip_source_errors + self["fail_on_quota_errors"] = fail_on_quota_errors + self["delete_destination_extra"] = delete_destination_extra + self["notify_on_succeeded"] = notify_on_succeeded + self["notify_on_failed"] = notify_on_failed + self["notify_on_inactive"] = notify_on_inactive + self["sync_level"] = _parse_sync_level(sync_level) for k, v in self.items(): log.debug("TransferData.%s = %s", k, v) @@ -252,9 +206,9 @@ def add_item( source_path: str, destination_path: str, *, - recursive: bool | None = None, - external_checksum: str | None = None, - checksum_algorithm: str | None = None, + recursive: bool | MissingType = MISSING, + external_checksum: str | MissingType = MISSING, + checksum_algorithm: str | MissingType = MISSING, additional_fields: dict[str, t.Any] | None = None, ) -> None: """ @@ -288,16 +242,11 @@ def add_item( "DATA_TYPE": "transfer_item", "source_path": source_path, "destination_path": destination_path, + "recursive": recursive, + "external_checksum": external_checksum, + "checksum_algorithm": checksum_algorithm, + **(additional_fields or {}), } - if recursive is not None: - item_data["recursive"] = recursive - if external_checksum is not None: - item_data["external_checksum"] = external_checksum - if checksum_algorithm is not None: - item_data["checksum_algorithm"] = checksum_algorithm - if additional_fields is not None: - item_data.update(additional_fields) - log.debug( 'TransferData[{}, {}].add_item: "{}"->"{}"'.format( self["source_endpoint"], @@ -308,48 +257,14 @@ def add_item( ) self["DATA"].append(item_data) - def add_symlink_item(self, source_path: str, destination_path: str) -> None: - """ - .. warning:: - - This method is not currently supported by any collections. - - Add a symlink to be transferred as a symlink rather than as the - target of the symlink. - - Appends a transfer_symlink_item document to the DATA key of the - transfer document. - - :param source_path: Path to the source symlink - :param destination_path: Path to which the source symlink will be transferred - """ - exc.warn_deprecated( - "add_symlink_item is not currently supported by any collections. " - "To reduce confusion, this method will be removed." - ) - item_data = { - "DATA_TYPE": "transfer_symlink_item", - "source_path": source_path, - "destination_path": destination_path, - } - log.debug( - 'TransferData[{}, {}].add_symlink_item: "{}"->"{}"'.format( - self["source_endpoint"], - self["destination_endpoint"], - source_path, - destination_path, - ) - ) - self["DATA"].append(item_data) - def add_filter_rule( self, name: str, *, method: t.Literal["include", "exclude"] = "exclude", type: ( # pylint: disable=redefined-builtin - None | t.Literal["file", "dir"] - ) = None, + t.Literal["file", "dir"] | MissingType + ) = MISSING, ) -> None: """ Add a filter rule to the transfer document. @@ -391,15 +306,14 @@ def add_filter_rule( ``tdata`` now describes a transfer which will only transfer files with the ``.txt`` extension. """ - if "filter_rules" not in self: + if self.get("filter_rules", MISSING) is MISSING: self["filter_rules"] = [] rule = { "DATA_TYPE": "filter_rule", "method": method, "name": name, + "type": type, } - if type is not None: - rule["type"] = type self["filter_rules"].append(rule) def iter_items(self) -> t.Iterator[dict[str, t.Any]]: diff --git a/src/globus_sdk/services/transfer/response/__init__.py b/src/globus_sdk/services/transfer/response/__init__.py index 5c0ea61b9..27a012cf2 100644 --- a/src/globus_sdk/services/transfer/response/__init__.py +++ b/src/globus_sdk/services/transfer/response/__init__.py @@ -1,4 +1,3 @@ -from .activation import ActivationRequirementsResponse from .iterable import IterableTransferResponse -__all__ = ("IterableTransferResponse", "ActivationRequirementsResponse") +__all__ = ("IterableTransferResponse",) diff --git a/src/globus_sdk/services/transfer/response/activation.py b/src/globus_sdk/services/transfer/response/activation.py deleted file mode 100644 index 107a8b2a3..000000000 --- a/src/globus_sdk/services/transfer/response/activation.py +++ /dev/null @@ -1,148 +0,0 @@ -from __future__ import annotations - -import time -import typing as t - -from globus_sdk.response import GlobusHTTPResponse - - -class ActivationRequirementsResponse(GlobusHTTPResponse): - """ - Response class for Activation Requirements responses. - - All Activation Requirements documents refer to a specific Endpoint, from - whence they were acquired. References to "the Endpoint" implicitly refer to - that originating Endpoint, and not to some other Endpoint. - - **External Documentation** - - See - `Activation Requirements Document\ - `_ - in the API documentation for details. - """ - - def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: - super().__init__(*args, **kwargs) - - # at initialization time, capture expires_in and convert to an absolute - # timestamp -- otherwise, the time between receiving the response and - # querying its status will start to matter - if self["expires_in"] == -1: - # expires_in=-1 is the "infinite lifetime" case - self.expires_at: int | None = None - else: - self.expires_at = int(time.time() + self["expires_in"]) - - @property - def supports_auto_activation(self) -> bool: - r""" - Check if the document lists Auto-Activation as an available type of - activation. - Typically good to use when you need to catch endpoints that require web - activation before proceeding. - - >>> endpoint_id = "..." - >>> tc = TransferClient(...) - >>> reqs_doc = tc.endpoint_get_activation_requirements(endpoint_id) - >>> if not reqs_doc.supports_auto_activation: - >>> # use `from __future__ import print_function` in py2 - >>> print(("This endpoint requires web activation. " - >>> "Please login and activate the endpoint here:\n" - >>> "https://app.globus.org/file-manager?origin_id={}") - >>> .format(endpoint_id), file=sys.stderr) - >>> # py3 calls it `input()` in py2, use `raw_input()` - >>> input("Please Hit Enter When You Are Done") - """ - return t.cast(bool, self["auto_activation_supported"]) - - @property - def supports_web_activation(self) -> bool: - """ - Check if the document lists known types of activation that can be done - through the web. If this returns ``False``, it means that the endpoint - is of a highly unusual type, and you should directly inspect the - response's ``data`` attribute to see what is required. Sending users to - the web page for activation is also a fairly safe action to take. - Note that ``ActivationRequirementsResponse.supports_auto_activation`` - directly implies - ``ActivationRequirementsResponse.supports_web_activation``, so these - are *not* exclusive. - - For example, - - >>> tc = TransferClient(...) - >>> reqs_doc = tc.endpoint_get_activation_requirements(...) - >>> if not reqs_doc.supports_web_activation: - >>> # use `from __future__ import print_function` in py2 - >>> print("Highly unusual endpoint. " + - >>> "Cannot webactivate. Raw doc: " + - >>> str(reqs_doc), file=sys.stderr) - >>> print("Sending user to web anyway, just in case.", - >>> file=sys.stderr) - >>> ... - """ - return ( - self.supports_auto_activation - or self["oauth_server"] is not None - or any( - x for x in self["DATA"] if x["type"] in ("myproxy", "delegate_myproxy") - ) - ) - - def active_until(self, time_seconds: int, relative_time: bool = True) -> bool: - """ - Check if the Endpoint will be active until some time in the future, - given as an integer number of seconds. - When ``relative_time=False``, the ``time_seconds`` is interpreted as a - POSIX timestamp. - - This supports queries using both relative and absolute timestamps to - better support a wide range of use cases. For example, if I have a task - that I know will typically take N seconds, and I want an M second - safety margin: - - >>> num_secs_allowed = N + M - >>> tc = TransferClient(...) - >>> reqs_doc = tc.endpoint_get_activation_requirements(...) - >>> if not reqs_doc.active_until(num_secs_allowed): - >>> raise Exception("Endpoint won't be active long enough") - >>> ... - - or, alternatively, if I know that the endpoint must be active until - October 18th, 2016 for my tasks to complete: - - >>> oct18_2016 = 1476803436 - >>> tc = TransferClient(...) - >>> reqs_doc = tc.endpoint_get_activation_requirements(...) - >>> if not reqs_doc.active_until(oct18_2016, relative_time=False): - >>> raise Exception("Endpoint won't be active long enough") - >>> ... - - :param time_seconds: Number of seconds into the future. - :param relative_time: Defaults to True. When False, ``time_seconds`` is treated - as a POSIX timestamp (i.e. seconds since epoch as an integer) instead of - its ordinary behavior. - - - :return: True if the Endpoint will be active until the deadline, False otherwise - """ - # inactive endpoint - if not self["activated"]: - return False - # infinite activation period - if self.expires_at is None: - return True - - if relative_time: - return (time.time() + time_seconds) < self.expires_at - else: - return time_seconds < self.expires_at - - @property - def always_activated(self) -> bool: - """ - Returns True if the endpoint activation never expires - (e.g. shared endpoints, globus connect personal endpoints). - """ - return t.cast(int, self["expires_in"]) == -1 diff --git a/src/globus_sdk/services/transfer/transport.py b/src/globus_sdk/services/transfer/transport.py index d4688ec10..4ab2c6888 100644 --- a/src/globus_sdk/services/transfer/transport.py +++ b/src/globus_sdk/services/transfer/transport.py @@ -1,33 +1,46 @@ """ -Custom Transport class for the TransferClient that overrides -default_check_transient_error +Custom retry check collection for the TransferClient that overrides +the default check_transient_error """ -from globus_sdk.transport import RequestsTransport, RetryCheckResult, RetryContext +from __future__ import annotations +from globus_sdk.transport import RetryCheck, RetryCheckResult, RetryContext +from globus_sdk.transport.default_retry_checks import ( + DEFAULT_RETRY_CHECKS, + check_transient_error, +) -class TransferRequestsTransport(RequestsTransport): - def default_check_transient_error(self, 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 - """ - if ctx.response is not None and ( - ctx.response.status_code in self.TRANSIENT_ERROR_STATUS_CODES - ): - try: - code = ctx.response.json()["code"] - except (ValueError, KeyError): - code = "" +def check_transfer_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. - for non_retry_code in ("ExternalError", "EndpointError"): - if non_retry_code in code: - return RetryCheckResult.no_decision + :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 + ): + try: + code = ctx.response.json()["code"] + except (ValueError, KeyError): + code = "" - return RetryCheckResult.do_retry + for non_retry_code in ("ExternalError", "EndpointError"): + if non_retry_code in code: + return RetryCheckResult.no_decision - 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_DEFAULT_RETRY_CHECKS: tuple[RetryCheck, ...] = tuple( + check_transfer_transient_error if check is check_transient_error else check + for check in DEFAULT_RETRY_CHECKS +) diff --git a/src/globus_sdk/_testing/__init__.py b/src/globus_sdk/testing/__init__.py similarity index 100% rename from src/globus_sdk/_testing/__init__.py rename to src/globus_sdk/testing/__init__.py diff --git a/src/globus_sdk/_testing/data/compute/__init__.py b/src/globus_sdk/testing/data/__init__.py similarity index 100% rename from src/globus_sdk/_testing/data/compute/__init__.py rename to src/globus_sdk/testing/data/__init__.py diff --git a/src/globus_sdk/_testing/data/compute/v2/__init__.py b/src/globus_sdk/testing/data/auth/__init__.py similarity index 100% rename from src/globus_sdk/_testing/data/compute/v2/__init__.py rename to src/globus_sdk/testing/data/auth/__init__.py diff --git a/src/globus_sdk/_testing/data/auth/_common.py b/src/globus_sdk/testing/data/auth/_common.py similarity index 100% rename from src/globus_sdk/_testing/data/auth/_common.py rename to src/globus_sdk/testing/data/auth/_common.py diff --git a/src/globus_sdk/_testing/data/auth/create_child_client.py b/src/globus_sdk/testing/data/auth/create_child_client.py similarity index 98% rename from src/globus_sdk/_testing/data/auth/create_child_client.py rename to src/globus_sdk/testing/data/auth/create_child_client.py index 8a38df251..e3f89d124 100644 --- a/src/globus_sdk/_testing/data/auth/create_child_client.py +++ b/src/globus_sdk/testing/data/auth/create_child_client.py @@ -3,7 +3,7 @@ from responses.matchers import json_params_matcher -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet _COMMON_RESPONSE_RECORD = { "fqdns": [], diff --git a/src/globus_sdk/_testing/data/auth/create_client.py b/src/globus_sdk/testing/data/auth/create_client.py similarity index 98% rename from src/globus_sdk/_testing/data/auth/create_client.py rename to src/globus_sdk/testing/data/auth/create_client.py index 64274c70d..fcdd4e9ce 100644 --- a/src/globus_sdk/_testing/data/auth/create_client.py +++ b/src/globus_sdk/testing/data/auth/create_client.py @@ -3,7 +3,7 @@ from responses.matchers import json_params_matcher -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet _COMMON_RESPONSE_RECORD = { "fqdns": [], diff --git a/src/globus_sdk/_testing/data/auth/create_client_credential.py b/src/globus_sdk/testing/data/auth/create_client_credential.py similarity index 93% rename from src/globus_sdk/_testing/data/auth/create_client_credential.py rename to src/globus_sdk/testing/data/auth/create_client_credential.py index 19f43fd5b..29ff4344d 100644 --- a/src/globus_sdk/_testing/data/auth/create_client_credential.py +++ b/src/globus_sdk/testing/data/auth/create_client_credential.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet NEW_CREDENTIAL_NAME = str(uuid.uuid4()).replace("-", "") diff --git a/src/globus_sdk/_testing/data/auth/create_native_app_instance.py b/src/globus_sdk/testing/data/auth/create_native_app_instance.py similarity index 97% rename from src/globus_sdk/_testing/data/auth/create_native_app_instance.py rename to src/globus_sdk/testing/data/auth/create_native_app_instance.py index cc9637ac2..d50a31d5c 100644 --- a/src/globus_sdk/_testing/data/auth/create_native_app_instance.py +++ b/src/globus_sdk/testing/data/auth/create_native_app_instance.py @@ -3,7 +3,7 @@ from responses.matchers import json_params_matcher -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet APP_REQUEST_ARGS = { "template_id": str(uuid.uuid1()), diff --git a/src/globus_sdk/_testing/data/auth/create_policy.py b/src/globus_sdk/testing/data/auth/create_policy.py similarity index 98% rename from src/globus_sdk/_testing/data/auth/create_policy.py rename to src/globus_sdk/testing/data/auth/create_policy.py index c1934ecf1..a97fe5d1c 100644 --- a/src/globus_sdk/_testing/data/auth/create_policy.py +++ b/src/globus_sdk/testing/data/auth/create_policy.py @@ -3,7 +3,7 @@ from responses.matchers import json_params_matcher -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet POLICY_REQUEST_ARGS = { "project_id": str(uuid.uuid1()), diff --git a/src/globus_sdk/_testing/data/auth/create_project.py b/src/globus_sdk/testing/data/auth/create_project.py similarity index 96% rename from src/globus_sdk/_testing/data/auth/create_project.py rename to src/globus_sdk/testing/data/auth/create_project.py index 5e2a2b0f0..3d2ac5de7 100644 --- a/src/globus_sdk/_testing/data/auth/create_project.py +++ b/src/globus_sdk/testing/data/auth/create_project.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet project_id = str(uuid.uuid1()) star_lord = { diff --git a/src/globus_sdk/_testing/data/auth/create_scope.py b/src/globus_sdk/testing/data/auth/create_scope.py similarity index 98% rename from src/globus_sdk/_testing/data/auth/create_scope.py rename to src/globus_sdk/testing/data/auth/create_scope.py index 9f217709b..95ba4958d 100644 --- a/src/globus_sdk/_testing/data/auth/create_scope.py +++ b/src/globus_sdk/testing/data/auth/create_scope.py @@ -4,7 +4,7 @@ from responses.matchers import json_params_matcher from globus_sdk import DependentScopeSpec -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet SCOPE_REQUEST_ARGS = { "client_id": str(uuid.uuid1()), diff --git a/src/globus_sdk/_testing/data/auth/delete_client.py b/src/globus_sdk/testing/data/auth/delete_client.py similarity index 92% rename from src/globus_sdk/_testing/data/auth/delete_client.py rename to src/globus_sdk/testing/data/auth/delete_client.py index f5916df0a..499222aff 100644 --- a/src/globus_sdk/_testing/data/auth/delete_client.py +++ b/src/globus_sdk/testing/data/auth/delete_client.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet CLIENT = { "required_idp": None, diff --git a/src/globus_sdk/_testing/data/auth/delete_client_credential.py b/src/globus_sdk/testing/data/auth/delete_client_credential.py similarity index 88% rename from src/globus_sdk/_testing/data/auth/delete_client_credential.py rename to src/globus_sdk/testing/data/auth/delete_client_credential.py index ac407af21..68a1bae7d 100644 --- a/src/globus_sdk/_testing/data/auth/delete_client_credential.py +++ b/src/globus_sdk/testing/data/auth/delete_client_credential.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet CREDENTIAL = { "name": "foo", diff --git a/src/globus_sdk/_testing/data/auth/delete_policy.py b/src/globus_sdk/testing/data/auth/delete_policy.py similarity index 90% rename from src/globus_sdk/_testing/data/auth/delete_policy.py rename to src/globus_sdk/testing/data/auth/delete_policy.py index 6aa91ab50..f0924fbab 100644 --- a/src/globus_sdk/_testing/data/auth/delete_policy.py +++ b/src/globus_sdk/testing/data/auth/delete_policy.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet POLICY = { "high_assurance": False, diff --git a/src/globus_sdk/_testing/data/auth/delete_project.py b/src/globus_sdk/testing/data/auth/delete_project.py similarity index 93% rename from src/globus_sdk/_testing/data/auth/delete_project.py rename to src/globus_sdk/testing/data/auth/delete_project.py index fb152e510..12d570f11 100644 --- a/src/globus_sdk/_testing/data/auth/delete_project.py +++ b/src/globus_sdk/testing/data/auth/delete_project.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet project_id = str(uuid.uuid1()) star_lord = { diff --git a/src/globus_sdk/_testing/data/auth/delete_scope.py b/src/globus_sdk/testing/data/auth/delete_scope.py similarity index 90% rename from src/globus_sdk/_testing/data/auth/delete_scope.py rename to src/globus_sdk/testing/data/auth/delete_scope.py index 67aaee1da..f7afd4e57 100644 --- a/src/globus_sdk/_testing/data/auth/delete_scope.py +++ b/src/globus_sdk/testing/data/auth/delete_scope.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet SCOPE = { "scope_string": "https://auth.globus.org/scopes/3f33d83f-ec0a-4190-887d-0622e7c4ee9a/manager", # noqa: E501 diff --git a/src/globus_sdk/_testing/data/auth/get_client.py b/src/globus_sdk/testing/data/auth/get_client.py similarity index 93% rename from src/globus_sdk/_testing/data/auth/get_client.py rename to src/globus_sdk/testing/data/auth/get_client.py index d422b7f06..046823d26 100644 --- a/src/globus_sdk/_testing/data/auth/get_client.py +++ b/src/globus_sdk/testing/data/auth/get_client.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet FQDN = "globus.org" diff --git a/src/globus_sdk/_testing/data/auth/get_client_credentials.py b/src/globus_sdk/testing/data/auth/get_client_credentials.py similarity index 88% rename from src/globus_sdk/_testing/data/auth/get_client_credentials.py rename to src/globus_sdk/testing/data/auth/get_client_credentials.py index a86bffb49..706018fb7 100644 --- a/src/globus_sdk/_testing/data/auth/get_client_credentials.py +++ b/src/globus_sdk/testing/data/auth/get_client_credentials.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet CREDENTIAL = { "name": "foo", diff --git a/src/globus_sdk/_testing/data/auth/get_clients.py b/src/globus_sdk/testing/data/auth/get_clients.py similarity index 95% rename from src/globus_sdk/_testing/data/auth/get_clients.py rename to src/globus_sdk/testing/data/auth/get_clients.py index 8599015ab..746217e49 100644 --- a/src/globus_sdk/_testing/data/auth/get_clients.py +++ b/src/globus_sdk/testing/data/auth/get_clients.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet FOO_CLIENT = { "required_idp": None, diff --git a/src/globus_sdk/_testing/data/auth/get_consents.py b/src/globus_sdk/testing/data/auth/get_consents.py similarity index 96% rename from src/globus_sdk/_testing/data/auth/get_consents.py rename to src/globus_sdk/testing/data/auth/get_consents.py index 3270cb528..1068f8f5d 100644 --- a/src/globus_sdk/_testing/data/auth/get_consents.py +++ b/src/globus_sdk/testing/data/auth/get_consents.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet _DATA_ACCESS = ( "https://auth.globus.org/scopes/542a86fc-1766-450d-841f-065488a2ec01/data_access" diff --git a/src/globus_sdk/_testing/data/auth/get_identities.py b/src/globus_sdk/testing/data/auth/get_identities.py similarity index 97% rename from src/globus_sdk/_testing/data/auth/get_identities.py rename to src/globus_sdk/testing/data/auth/get_identities.py index f7b530882..1c281f790 100644 --- a/src/globus_sdk/_testing/data/auth/get_identities.py +++ b/src/globus_sdk/testing/data/auth/get_identities.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import UNAUTHORIZED_AUTH_RESPONSE diff --git a/src/globus_sdk/_testing/data/auth/get_identity_providers.py b/src/globus_sdk/testing/data/auth/get_identity_providers.py similarity index 94% rename from src/globus_sdk/_testing/data/auth/get_identity_providers.py rename to src/globus_sdk/testing/data/auth/get_identity_providers.py index 7e632f7c0..82043b12b 100644 --- a/src/globus_sdk/_testing/data/auth/get_identity_providers.py +++ b/src/globus_sdk/testing/data/auth/get_identity_providers.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet globusid_idp = { "short_name": "globusid", diff --git a/src/globus_sdk/_testing/data/auth/get_policies.py b/src/globus_sdk/testing/data/auth/get_policies.py similarity index 94% rename from src/globus_sdk/_testing/data/auth/get_policies.py rename to src/globus_sdk/testing/data/auth/get_policies.py index 086d2ce55..25d76bb27 100644 --- a/src/globus_sdk/_testing/data/auth/get_policies.py +++ b/src/globus_sdk/testing/data/auth/get_policies.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet GREEN_LIGHT_POLICY = { "high_assurance": False, diff --git a/src/globus_sdk/_testing/data/auth/get_policy.py b/src/globus_sdk/testing/data/auth/get_policy.py similarity index 90% rename from src/globus_sdk/_testing/data/auth/get_policy.py rename to src/globus_sdk/testing/data/auth/get_policy.py index 916da351a..ab36fa5d2 100644 --- a/src/globus_sdk/_testing/data/auth/get_policy.py +++ b/src/globus_sdk/testing/data/auth/get_policy.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet POLICY = { "high_assurance": False, diff --git a/src/globus_sdk/_testing/data/auth/get_project.py b/src/globus_sdk/testing/data/auth/get_project.py similarity index 95% rename from src/globus_sdk/_testing/data/auth/get_project.py rename to src/globus_sdk/testing/data/auth/get_project.py index 0306d9baa..2471df49a 100644 --- a/src/globus_sdk/_testing/data/auth/get_project.py +++ b/src/globus_sdk/testing/data/auth/get_project.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet GUARDIANS_IDP_ID = str(uuid.uuid1()) STAR_LORD = { diff --git a/src/globus_sdk/_testing/data/auth/get_projects.py b/src/globus_sdk/testing/data/auth/get_projects.py similarity index 96% rename from src/globus_sdk/_testing/data/auth/get_projects.py rename to src/globus_sdk/testing/data/auth/get_projects.py index a81f65d5a..1d2f8907b 100644 --- a/src/globus_sdk/_testing/data/auth/get_projects.py +++ b/src/globus_sdk/testing/data/auth/get_projects.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet GUARDIANS_IDP_ID = str(uuid.uuid1()) STAR_LORD = { diff --git a/src/globus_sdk/_testing/data/auth/get_scope.py b/src/globus_sdk/testing/data/auth/get_scope.py similarity index 90% rename from src/globus_sdk/_testing/data/auth/get_scope.py rename to src/globus_sdk/testing/data/auth/get_scope.py index e7bd40b2b..a40d13945 100644 --- a/src/globus_sdk/_testing/data/auth/get_scope.py +++ b/src/globus_sdk/testing/data/auth/get_scope.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet SCOPE = { "scope_string": "https://auth.globus.org/scopes/3f33d83f-ec0a-4190-887d-0622e7c4ee9a/manager", # noqa: E501 diff --git a/src/globus_sdk/_testing/data/auth/get_scopes.py b/src/globus_sdk/testing/data/auth/get_scopes.py similarity index 95% rename from src/globus_sdk/_testing/data/auth/get_scopes.py rename to src/globus_sdk/testing/data/auth/get_scopes.py index 0b4e195a8..d8c0431e0 100644 --- a/src/globus_sdk/_testing/data/auth/get_scopes.py +++ b/src/globus_sdk/testing/data/auth/get_scopes.py @@ -2,7 +2,7 @@ from responses.matchers import query_param_matcher -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet SCOPE1 = { "scope_string": "https://auth.globus.org/scopes/3f33d83f-ec0a-4190-887d-0622e7c4ee9a/manage", # noqa: E501 diff --git a/src/globus_sdk/_testing/data/auth/oauth2_client_credentials_tokens.py b/src/globus_sdk/testing/data/auth/oauth2_client_credentials_tokens.py similarity index 94% rename from src/globus_sdk/_testing/data/auth/oauth2_client_credentials_tokens.py rename to src/globus_sdk/testing/data/auth/oauth2_client_credentials_tokens.py index be0f416ab..e0d046d38 100644 --- a/src/globus_sdk/_testing/data/auth/oauth2_client_credentials_tokens.py +++ b/src/globus_sdk/testing/data/auth/oauth2_client_credentials_tokens.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet _token = "DUMMY_TRANSFER_TOKEN_FROM_THE_INTERTUBES" _scope = "urn:globus:auth:scope:transfer.api.globus.org:all" diff --git a/src/globus_sdk/_testing/data/auth/oauth2_exchange_code_for_tokens.py b/src/globus_sdk/testing/data/auth/oauth2_exchange_code_for_tokens.py similarity index 94% rename from src/globus_sdk/_testing/data/auth/oauth2_exchange_code_for_tokens.py rename to src/globus_sdk/testing/data/auth/oauth2_exchange_code_for_tokens.py index e691e087f..e7b8bbce6 100644 --- a/src/globus_sdk/_testing/data/auth/oauth2_exchange_code_for_tokens.py +++ b/src/globus_sdk/testing/data/auth/oauth2_exchange_code_for_tokens.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet RESPONSES = ResponseSet( default=RegisteredResponse( diff --git a/src/globus_sdk/_testing/data/auth/oauth2_get_dependent_tokens.py b/src/globus_sdk/testing/data/auth/oauth2_get_dependent_tokens.py similarity index 96% rename from src/globus_sdk/_testing/data/auth/oauth2_get_dependent_tokens.py rename to src/globus_sdk/testing/data/auth/oauth2_get_dependent_tokens.py index b9c06d250..994d36741 100644 --- a/src/globus_sdk/_testing/data/auth/oauth2_get_dependent_tokens.py +++ b/src/globus_sdk/testing/data/auth/oauth2_get_dependent_tokens.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet RESPONSES = ResponseSet( groups=RegisteredResponse( diff --git a/src/globus_sdk/_testing/data/auth/oauth2_revoke_token.py b/src/globus_sdk/testing/data/auth/oauth2_revoke_token.py similarity index 72% rename from src/globus_sdk/_testing/data/auth/oauth2_revoke_token.py rename to src/globus_sdk/testing/data/auth/oauth2_revoke_token.py index 78005c482..f66e4439b 100644 --- a/src/globus_sdk/_testing/data/auth/oauth2_revoke_token.py +++ b/src/globus_sdk/testing/data/auth/oauth2_revoke_token.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet RESPONSES = ResponseSet( default=RegisteredResponse( diff --git a/src/globus_sdk/_testing/data/auth/oauth2_token_introspect.py b/src/globus_sdk/testing/data/auth/oauth2_token_introspect.py similarity index 94% rename from src/globus_sdk/_testing/data/auth/oauth2_token_introspect.py rename to src/globus_sdk/testing/data/auth/oauth2_token_introspect.py index e05749c59..5927b6883 100644 --- a/src/globus_sdk/_testing/data/auth/oauth2_token_introspect.py +++ b/src/globus_sdk/testing/data/auth/oauth2_token_introspect.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet _kingfish = { "username": "kingfish@globus.org", diff --git a/src/globus_sdk/_testing/data/auth/update_client.py b/src/globus_sdk/testing/data/auth/update_client.py similarity index 97% rename from src/globus_sdk/_testing/data/auth/update_client.py rename to src/globus_sdk/testing/data/auth/update_client.py index c122eed1f..7fa7c203a 100644 --- a/src/globus_sdk/_testing/data/auth/update_client.py +++ b/src/globus_sdk/testing/data/auth/update_client.py @@ -3,7 +3,7 @@ from responses.matchers import json_params_matcher -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet _COMMON_RESPONSE_RECORD = { "fqdns": [], diff --git a/src/globus_sdk/_testing/data/auth/update_policy.py b/src/globus_sdk/testing/data/auth/update_policy.py similarity index 98% rename from src/globus_sdk/_testing/data/auth/update_policy.py rename to src/globus_sdk/testing/data/auth/update_policy.py index 83925d73d..e198383be 100644 --- a/src/globus_sdk/_testing/data/auth/update_policy.py +++ b/src/globus_sdk/testing/data/auth/update_policy.py @@ -3,7 +3,7 @@ from responses.matchers import json_params_matcher -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet POLICY_REQUEST_ARGS = { "policy_id": str(uuid.uuid1()), diff --git a/src/globus_sdk/_testing/data/auth/update_project.py b/src/globus_sdk/testing/data/auth/update_project.py similarity index 96% rename from src/globus_sdk/_testing/data/auth/update_project.py rename to src/globus_sdk/testing/data/auth/update_project.py index c32959a30..67a3f5cff 100644 --- a/src/globus_sdk/_testing/data/auth/update_project.py +++ b/src/globus_sdk/testing/data/auth/update_project.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet project_id = str(uuid.uuid1()) star_lord = { diff --git a/src/globus_sdk/_testing/data/auth/update_scope.py b/src/globus_sdk/testing/data/auth/update_scope.py similarity index 98% rename from src/globus_sdk/_testing/data/auth/update_scope.py rename to src/globus_sdk/testing/data/auth/update_scope.py index 3d5555013..4bb62ea49 100644 --- a/src/globus_sdk/_testing/data/auth/update_scope.py +++ b/src/globus_sdk/testing/data/auth/update_scope.py @@ -4,7 +4,7 @@ from responses.matchers import json_params_matcher from globus_sdk import DependentScopeSpec -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet SCOPE_REQUEST_ARGS = { "scope_id": str(uuid.uuid1()), diff --git a/src/globus_sdk/_testing/data/auth/userinfo.py b/src/globus_sdk/testing/data/auth/userinfo.py similarity index 91% rename from src/globus_sdk/_testing/data/auth/userinfo.py rename to src/globus_sdk/testing/data/auth/userinfo.py index 2a72fb918..fe3f6adc8 100644 --- a/src/globus_sdk/_testing/data/auth/userinfo.py +++ b/src/globus_sdk/testing/data/auth/userinfo.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import FORBIDDEN_AUTH_RESPONSE, UNAUTHORIZED_AUTH_RESPONSE diff --git a/src/globus_sdk/_testing/data/compute/v3/__init__.py b/src/globus_sdk/testing/data/compute/__init__.py similarity index 100% rename from src/globus_sdk/_testing/data/compute/v3/__init__.py rename to src/globus_sdk/testing/data/compute/__init__.py diff --git a/src/globus_sdk/_testing/data/compute/_common.py b/src/globus_sdk/testing/data/compute/_common.py similarity index 100% rename from src/globus_sdk/_testing/data/compute/_common.py rename to src/globus_sdk/testing/data/compute/_common.py diff --git a/src/globus_sdk/_testing/data/flows/__init__.py b/src/globus_sdk/testing/data/compute/v2/__init__.py similarity index 100% rename from src/globus_sdk/_testing/data/flows/__init__.py rename to src/globus_sdk/testing/data/compute/v2/__init__.py diff --git a/src/globus_sdk/_testing/data/compute/v2/delete_endpoint.py b/src/globus_sdk/testing/data/compute/v2/delete_endpoint.py similarity index 79% rename from src/globus_sdk/_testing/data/compute/v2/delete_endpoint.py rename to src/globus_sdk/testing/data/compute/v2/delete_endpoint.py index e297ebc1d..fda19b179 100644 --- a/src/globus_sdk/_testing/data/compute/v2/delete_endpoint.py +++ b/src/globus_sdk/testing/data/compute/v2/delete_endpoint.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID diff --git a/src/globus_sdk/_testing/data/compute/v2/delete_function.py b/src/globus_sdk/testing/data/compute/v2/delete_function.py similarity index 79% rename from src/globus_sdk/_testing/data/compute/v2/delete_function.py rename to src/globus_sdk/testing/data/compute/v2/delete_function.py index 92a65c4b4..ef327c0f5 100644 --- a/src/globus_sdk/_testing/data/compute/v2/delete_function.py +++ b/src/globus_sdk/testing/data/compute/v2/delete_function.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import FUNCTION_ID diff --git a/src/globus_sdk/_testing/data/compute/v2/get_endpoint.py b/src/globus_sdk/testing/data/compute/v2/get_endpoint.py similarity index 92% rename from src/globus_sdk/_testing/data/compute/v2/get_endpoint.py rename to src/globus_sdk/testing/data/compute/v2/get_endpoint.py index 8895fb25d..938224bc7 100644 --- a/src/globus_sdk/_testing/data/compute/v2/get_endpoint.py +++ b/src/globus_sdk/testing/data/compute/v2/get_endpoint.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID, SUBSCRIPTION_ID diff --git a/src/globus_sdk/_testing/data/compute/v2/get_endpoint_status.py b/src/globus_sdk/testing/data/compute/v2/get_endpoint_status.py similarity index 87% rename from src/globus_sdk/_testing/data/compute/v2/get_endpoint_status.py rename to src/globus_sdk/testing/data/compute/v2/get_endpoint_status.py index 551b32d83..2307e1af8 100644 --- a/src/globus_sdk/_testing/data/compute/v2/get_endpoint_status.py +++ b/src/globus_sdk/testing/data/compute/v2/get_endpoint_status.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID diff --git a/src/globus_sdk/_testing/data/compute/v2/get_endpoints.py b/src/globus_sdk/testing/data/compute/v2/get_endpoints.py similarity index 95% rename from src/globus_sdk/_testing/data/compute/v2/get_endpoints.py rename to src/globus_sdk/testing/data/compute/v2/get_endpoints.py index 16c3f622a..cbf934a5b 100644 --- a/src/globus_sdk/_testing/data/compute/v2/get_endpoints.py +++ b/src/globus_sdk/testing/data/compute/v2/get_endpoints.py @@ -1,6 +1,6 @@ from responses.matchers import query_param_matcher -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID, ENDPOINT_ID_2, ENDPOINT_ID_3, NON_USER_ID, USER_ID diff --git a/src/globus_sdk/_testing/data/compute/v2/get_function.py b/src/globus_sdk/testing/data/compute/v2/get_function.py similarity index 89% rename from src/globus_sdk/_testing/data/compute/v2/get_function.py rename to src/globus_sdk/testing/data/compute/v2/get_function.py index 21339526e..c55bda46c 100644 --- a/src/globus_sdk/_testing/data/compute/v2/get_function.py +++ b/src/globus_sdk/testing/data/compute/v2/get_function.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import FUNCTION_CODE, FUNCTION_ID, FUNCTION_NAME diff --git a/src/globus_sdk/_testing/data/compute/v2/get_result_amqp_url.py b/src/globus_sdk/testing/data/compute/v2/get_result_amqp_url.py similarity index 81% rename from src/globus_sdk/_testing/data/compute/v2/get_result_amqp_url.py rename to src/globus_sdk/testing/data/compute/v2/get_result_amqp_url.py index db8cafaa0..9e4734d40 100644 --- a/src/globus_sdk/_testing/data/compute/v2/get_result_amqp_url.py +++ b/src/globus_sdk/testing/data/compute/v2/get_result_amqp_url.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet DEFAULT_RESPONSE_DOC = { "queue_prefix": "some_prefix", diff --git a/src/globus_sdk/_testing/data/compute/v2/get_task.py b/src/globus_sdk/testing/data/compute/v2/get_task.py similarity index 78% rename from src/globus_sdk/_testing/data/compute/v2/get_task.py rename to src/globus_sdk/testing/data/compute/v2/get_task.py index 933be971f..c877b553e 100644 --- a/src/globus_sdk/_testing/data/compute/v2/get_task.py +++ b/src/globus_sdk/testing/data/compute/v2/get_task.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import TASK_DOC, TASK_ID diff --git a/src/globus_sdk/_testing/data/compute/v2/get_task_batch.py b/src/globus_sdk/testing/data/compute/v2/get_task_batch.py similarity index 87% rename from src/globus_sdk/_testing/data/compute/v2/get_task_batch.py rename to src/globus_sdk/testing/data/compute/v2/get_task_batch.py index 451c2e8ba..7fb485867 100644 --- a/src/globus_sdk/_testing/data/compute/v2/get_task_batch.py +++ b/src/globus_sdk/testing/data/compute/v2/get_task_batch.py @@ -1,6 +1,6 @@ from responses.matchers import json_params_matcher -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import TASK_DOC, TASK_ID diff --git a/src/globus_sdk/_testing/data/compute/v2/get_task_group.py b/src/globus_sdk/testing/data/compute/v2/get_task_group.py similarity index 89% rename from src/globus_sdk/_testing/data/compute/v2/get_task_group.py rename to src/globus_sdk/testing/data/compute/v2/get_task_group.py index 058687cd2..90e848887 100644 --- a/src/globus_sdk/_testing/data/compute/v2/get_task_group.py +++ b/src/globus_sdk/testing/data/compute/v2/get_task_group.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import TASK_GROUP_ID, TASK_ID, TASK_ID_2 diff --git a/src/globus_sdk/_testing/data/compute/v2/get_version.py b/src/globus_sdk/testing/data/compute/v2/get_version.py similarity index 90% rename from src/globus_sdk/_testing/data/compute/v2/get_version.py rename to src/globus_sdk/testing/data/compute/v2/get_version.py index 6706de913..3906a1f08 100644 --- a/src/globus_sdk/_testing/data/compute/v2/get_version.py +++ b/src/globus_sdk/testing/data/compute/v2/get_version.py @@ -1,6 +1,6 @@ from responses.matchers import query_param_matcher -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet API_VERSION = "1.23.0" ALL_RESPONSE_DOC = { diff --git a/src/globus_sdk/_testing/data/compute/v2/lock_endpoint.py b/src/globus_sdk/testing/data/compute/v2/lock_endpoint.py similarity index 85% rename from src/globus_sdk/_testing/data/compute/v2/lock_endpoint.py rename to src/globus_sdk/testing/data/compute/v2/lock_endpoint.py index acb12e33d..b22513778 100644 --- a/src/globus_sdk/_testing/data/compute/v2/lock_endpoint.py +++ b/src/globus_sdk/testing/data/compute/v2/lock_endpoint.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID diff --git a/src/globus_sdk/_testing/data/compute/v2/register_endpoint.py b/src/globus_sdk/testing/data/compute/v2/register_endpoint.py similarity index 90% rename from src/globus_sdk/_testing/data/compute/v2/register_endpoint.py rename to src/globus_sdk/testing/data/compute/v2/register_endpoint.py index a6d73dc28..8cf66b460 100644 --- a/src/globus_sdk/_testing/data/compute/v2/register_endpoint.py +++ b/src/globus_sdk/testing/data/compute/v2/register_endpoint.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID diff --git a/src/globus_sdk/_testing/data/compute/v2/register_function.py b/src/globus_sdk/testing/data/compute/v2/register_function.py similarity index 84% rename from src/globus_sdk/_testing/data/compute/v2/register_function.py rename to src/globus_sdk/testing/data/compute/v2/register_function.py index b0c68bd72..875ebea07 100644 --- a/src/globus_sdk/_testing/data/compute/v2/register_function.py +++ b/src/globus_sdk/testing/data/compute/v2/register_function.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import FUNCTION_CODE, FUNCTION_ID, FUNCTION_NAME diff --git a/src/globus_sdk/_testing/data/compute/v2/submit.py b/src/globus_sdk/testing/data/compute/v2/submit.py similarity index 91% rename from src/globus_sdk/_testing/data/compute/v2/submit.py rename to src/globus_sdk/testing/data/compute/v2/submit.py index 457deb065..faf557520 100644 --- a/src/globus_sdk/_testing/data/compute/v2/submit.py +++ b/src/globus_sdk/testing/data/compute/v2/submit.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import TASK_GROUP_ID, TASK_ID, TASK_ID_2 diff --git a/src/globus_sdk/_testing/data/globus_connect_server/__init__.py b/src/globus_sdk/testing/data/compute/v3/__init__.py similarity index 100% rename from src/globus_sdk/_testing/data/globus_connect_server/__init__.py rename to src/globus_sdk/testing/data/compute/v3/__init__.py diff --git a/src/globus_sdk/_testing/data/compute/v3/get_endpoint_allowlist.py b/src/globus_sdk/testing/data/compute/v3/get_endpoint_allowlist.py similarity index 86% rename from src/globus_sdk/_testing/data/compute/v3/get_endpoint_allowlist.py rename to src/globus_sdk/testing/data/compute/v3/get_endpoint_allowlist.py index 50063d58f..05fdfca48 100644 --- a/src/globus_sdk/_testing/data/compute/v3/get_endpoint_allowlist.py +++ b/src/globus_sdk/testing/data/compute/v3/get_endpoint_allowlist.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID, FUNCTION_ID, FUNCTION_ID_2 diff --git a/src/globus_sdk/_testing/data/compute/v3/lock_endpoint.py b/src/globus_sdk/testing/data/compute/v3/lock_endpoint.py similarity index 85% rename from src/globus_sdk/_testing/data/compute/v3/lock_endpoint.py rename to src/globus_sdk/testing/data/compute/v3/lock_endpoint.py index 29a669e14..1cdc66e6d 100644 --- a/src/globus_sdk/_testing/data/compute/v3/lock_endpoint.py +++ b/src/globus_sdk/testing/data/compute/v3/lock_endpoint.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID diff --git a/src/globus_sdk/_testing/data/compute/v3/register_endpoint.py b/src/globus_sdk/testing/data/compute/v3/register_endpoint.py similarity index 89% rename from src/globus_sdk/_testing/data/compute/v3/register_endpoint.py rename to src/globus_sdk/testing/data/compute/v3/register_endpoint.py index 5e62f8147..f6e1acfb3 100644 --- a/src/globus_sdk/_testing/data/compute/v3/register_endpoint.py +++ b/src/globus_sdk/testing/data/compute/v3/register_endpoint.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID diff --git a/src/globus_sdk/_testing/data/compute/v3/register_function.py b/src/globus_sdk/testing/data/compute/v3/register_function.py similarity index 86% rename from src/globus_sdk/_testing/data/compute/v3/register_function.py rename to src/globus_sdk/testing/data/compute/v3/register_function.py index c79a4fa78..b0e323153 100644 --- a/src/globus_sdk/_testing/data/compute/v3/register_function.py +++ b/src/globus_sdk/testing/data/compute/v3/register_function.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import FUNCTION_CODE, FUNCTION_ID, FUNCTION_NAME diff --git a/src/globus_sdk/_testing/data/compute/v3/submit.py b/src/globus_sdk/testing/data/compute/v3/submit.py similarity index 91% rename from src/globus_sdk/_testing/data/compute/v3/submit.py rename to src/globus_sdk/testing/data/compute/v3/submit.py index 5402cd32d..96db426f6 100644 --- a/src/globus_sdk/_testing/data/compute/v3/submit.py +++ b/src/globus_sdk/testing/data/compute/v3/submit.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID, FUNCTION_ID, TASK_GROUP_ID, TASK_ID, TASK_ID_2 diff --git a/src/globus_sdk/_testing/data/compute/v3/update_endpoint.py b/src/globus_sdk/testing/data/compute/v3/update_endpoint.py similarity index 90% rename from src/globus_sdk/_testing/data/compute/v3/update_endpoint.py rename to src/globus_sdk/testing/data/compute/v3/update_endpoint.py index e18e1271e..cc76c1a41 100644 --- a/src/globus_sdk/_testing/data/compute/v3/update_endpoint.py +++ b/src/globus_sdk/testing/data/compute/v3/update_endpoint.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from .._common import ENDPOINT_ID diff --git a/src/globus_sdk/_testing/data/groups/__init__.py b/src/globus_sdk/testing/data/flows/__init__.py similarity index 100% rename from src/globus_sdk/_testing/data/groups/__init__.py rename to src/globus_sdk/testing/data/flows/__init__.py diff --git a/src/globus_sdk/_testing/data/flows/_common.py b/src/globus_sdk/testing/data/flows/_common.py similarity index 100% rename from src/globus_sdk/_testing/data/flows/_common.py rename to src/globus_sdk/testing/data/flows/_common.py diff --git a/src/globus_sdk/_testing/data/flows/cancel_run.py b/src/globus_sdk/testing/data/flows/cancel_run.py similarity index 91% rename from src/globus_sdk/_testing/data/flows/cancel_run.py rename to src/globus_sdk/testing/data/flows/cancel_run.py index be0b8d3f6..ea59338b6 100644 --- a/src/globus_sdk/_testing/data/flows/cancel_run.py +++ b/src/globus_sdk/testing/data/flows/cancel_run.py @@ -1,6 +1,6 @@ import copy -from globus_sdk._testing import RegisteredResponse, ResponseSet +from globus_sdk.testing import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_RUN diff --git a/src/globus_sdk/_testing/data/flows/create_flow.py b/src/globus_sdk/testing/data/flows/create_flow.py similarity index 96% rename from src/globus_sdk/_testing/data/flows/create_flow.py rename to src/globus_sdk/testing/data/flows/create_flow.py index 40b53c01d..40297c02d 100644 --- a/src/globus_sdk/_testing/data/flows/create_flow.py +++ b/src/globus_sdk/testing/data/flows/create_flow.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_FLOW_DOC diff --git a/src/globus_sdk/_testing/data/flows/delete_flow.py b/src/globus_sdk/testing/data/flows/delete_flow.py similarity index 95% rename from src/globus_sdk/_testing/data/flows/delete_flow.py rename to src/globus_sdk/testing/data/flows/delete_flow.py index 93f9071d3..d7d24772b 100644 --- a/src/globus_sdk/_testing/data/flows/delete_flow.py +++ b/src/globus_sdk/testing/data/flows/delete_flow.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_FLOW_DOC, TWO_HOP_TRANSFER_FLOW_ID diff --git a/src/globus_sdk/_testing/data/flows/delete_run.py b/src/globus_sdk/testing/data/flows/delete_run.py similarity index 94% rename from src/globus_sdk/_testing/data/flows/delete_run.py rename to src/globus_sdk/testing/data/flows/delete_run.py index c8d7f63b2..bce819a9f 100644 --- a/src/globus_sdk/_testing/data/flows/delete_run.py +++ b/src/globus_sdk/testing/data/flows/delete_run.py @@ -1,6 +1,6 @@ import copy -from globus_sdk._testing import RegisteredResponse, ResponseSet +from globus_sdk.testing import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_RUN diff --git a/src/globus_sdk/_testing/data/flows/get_flow.py b/src/globus_sdk/testing/data/flows/get_flow.py similarity index 84% rename from src/globus_sdk/_testing/data/flows/get_flow.py rename to src/globus_sdk/testing/data/flows/get_flow.py index 916a92470..a27e7276e 100644 --- a/src/globus_sdk/_testing/data/flows/get_flow.py +++ b/src/globus_sdk/testing/data/flows/get_flow.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_FLOW_DOC, TWO_HOP_TRANSFER_FLOW_ID diff --git a/src/globus_sdk/_testing/data/flows/get_run.py b/src/globus_sdk/testing/data/flows/get_run.py similarity index 92% rename from src/globus_sdk/_testing/data/flows/get_run.py rename to src/globus_sdk/testing/data/flows/get_run.py index ffe6b16b3..bb8e43216 100644 --- a/src/globus_sdk/_testing/data/flows/get_run.py +++ b/src/globus_sdk/testing/data/flows/get_run.py @@ -2,7 +2,7 @@ from responses.matchers import query_param_matcher -from globus_sdk._testing import RegisteredResponse, ResponseList, ResponseSet +from globus_sdk.testing import RegisteredResponse, ResponseList, ResponseSet from ._common import FLOW_DESCRIPTION, RUN, RUN_ID diff --git a/src/globus_sdk/_testing/data/flows/get_run_definition.py b/src/globus_sdk/testing/data/flows/get_run_definition.py similarity index 86% rename from src/globus_sdk/_testing/data/flows/get_run_definition.py rename to src/globus_sdk/testing/data/flows/get_run_definition.py index 3692b8fac..78aec4424 100644 --- a/src/globus_sdk/_testing/data/flows/get_run_definition.py +++ b/src/globus_sdk/testing/data/flows/get_run_definition.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing import RegisteredResponse, ResponseList, ResponseSet +from globus_sdk.testing import RegisteredResponse, ResponseList, ResponseSet from ._common import RUN_ID diff --git a/src/globus_sdk/_testing/data/flows/get_run_logs.py b/src/globus_sdk/testing/data/flows/get_run_logs.py similarity index 99% rename from src/globus_sdk/_testing/data/flows/get_run_logs.py rename to src/globus_sdk/testing/data/flows/get_run_logs.py index 0e67f3513..5fe302d3f 100644 --- a/src/globus_sdk/_testing/data/flows/get_run_logs.py +++ b/src/globus_sdk/testing/data/flows/get_run_logs.py @@ -1,6 +1,6 @@ from responses.matchers import query_param_matcher -from globus_sdk._testing import RegisteredResponse, ResponseList, ResponseSet +from globus_sdk.testing import RegisteredResponse, ResponseList, ResponseSet RUN_ID = "cfdaf0a4-0931-40af-b974-b619ce69f401" OWNER_URN = "urn:globus:auth:identity:944cfbe8-60f8-474d-a634-a0c1ad543a54" diff --git a/src/globus_sdk/_testing/data/flows/list_flows.py b/src/globus_sdk/testing/data/flows/list_flows.py similarity index 98% rename from src/globus_sdk/_testing/data/flows/list_flows.py rename to src/globus_sdk/testing/data/flows/list_flows.py index b2b3caf15..97abb5a89 100644 --- a/src/globus_sdk/_testing/data/flows/list_flows.py +++ b/src/globus_sdk/testing/data/flows/list_flows.py @@ -6,7 +6,7 @@ from responses import matchers -from globus_sdk._testing.models import RegisteredResponse, ResponseList, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseList, ResponseSet from ._common import TWO_HOP_TRANSFER_FLOW_DOC, TWO_HOP_TRANSFER_FLOW_ID diff --git a/src/globus_sdk/_testing/data/flows/list_runs.py b/src/globus_sdk/testing/data/flows/list_runs.py similarity index 98% rename from src/globus_sdk/_testing/data/flows/list_runs.py rename to src/globus_sdk/testing/data/flows/list_runs.py index c967afe0c..ab060058b 100644 --- a/src/globus_sdk/_testing/data/flows/list_runs.py +++ b/src/globus_sdk/testing/data/flows/list_runs.py @@ -7,7 +7,7 @@ from responses import matchers -from globus_sdk._testing import RegisteredResponse, ResponseList, ResponseSet +from globus_sdk.testing import RegisteredResponse, ResponseList, ResponseSet from ._common import RUN, RUN_ID, USER1 diff --git a/src/globus_sdk/_testing/data/flows/resume_run.py b/src/globus_sdk/testing/data/flows/resume_run.py similarity index 85% rename from src/globus_sdk/_testing/data/flows/resume_run.py rename to src/globus_sdk/testing/data/flows/resume_run.py index d06693248..5c0f11ee3 100644 --- a/src/globus_sdk/_testing/data/flows/resume_run.py +++ b/src/globus_sdk/testing/data/flows/resume_run.py @@ -1,4 +1,4 @@ -from globus_sdk._testing import RegisteredResponse, ResponseSet +from globus_sdk.testing import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_RUN diff --git a/src/globus_sdk/_testing/data/flows/run_flow.py b/src/globus_sdk/testing/data/flows/run_flow.py similarity index 93% rename from src/globus_sdk/_testing/data/flows/run_flow.py rename to src/globus_sdk/testing/data/flows/run_flow.py index d3756993a..32b8fa382 100644 --- a/src/globus_sdk/_testing/data/flows/run_flow.py +++ b/src/globus_sdk/testing/data/flows/run_flow.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_FLOW_ID, TWO_HOP_TRANSFER_RUN diff --git a/src/globus_sdk/_testing/data/flows/update_flow.py b/src/globus_sdk/testing/data/flows/update_flow.py similarity index 92% rename from src/globus_sdk/_testing/data/flows/update_flow.py rename to src/globus_sdk/testing/data/flows/update_flow.py index 79e5087d3..3988b7be6 100644 --- a/src/globus_sdk/_testing/data/flows/update_flow.py +++ b/src/globus_sdk/testing/data/flows/update_flow.py @@ -1,6 +1,6 @@ from copy import deepcopy -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_FLOW_DOC, TWO_HOP_TRANSFER_FLOW_ID diff --git a/src/globus_sdk/_testing/data/flows/update_run.py b/src/globus_sdk/testing/data/flows/update_run.py similarity index 84% rename from src/globus_sdk/_testing/data/flows/update_run.py rename to src/globus_sdk/testing/data/flows/update_run.py index 716f5b807..ef69da007 100644 --- a/src/globus_sdk/_testing/data/flows/update_run.py +++ b/src/globus_sdk/testing/data/flows/update_run.py @@ -1,4 +1,4 @@ -from globus_sdk._testing import RegisteredResponse, ResponseSet +from globus_sdk.testing import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_RUN diff --git a/src/globus_sdk/_testing/data/flows/validate_flow.py b/src/globus_sdk/testing/data/flows/validate_flow.py similarity index 97% rename from src/globus_sdk/_testing/data/flows/validate_flow.py rename to src/globus_sdk/testing/data/flows/validate_flow.py index 593533dfa..c50f9e769 100644 --- a/src/globus_sdk/_testing/data/flows/validate_flow.py +++ b/src/globus_sdk/testing/data/flows/validate_flow.py @@ -1,6 +1,6 @@ from responses import matchers -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet VALIDATE_SIMPLE_FLOW_DEFINITION = { "Comment": "Simple flow", diff --git a/src/globus_sdk/_testing/data/flows/validate_run.py b/src/globus_sdk/testing/data/flows/validate_run.py similarity index 97% rename from src/globus_sdk/_testing/data/flows/validate_run.py rename to src/globus_sdk/testing/data/flows/validate_run.py index 42e82a7ce..3a609a28c 100644 --- a/src/globus_sdk/_testing/data/flows/validate_run.py +++ b/src/globus_sdk/testing/data/flows/validate_run.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TWO_HOP_TRANSFER_FLOW_ID diff --git a/src/globus_sdk/_testing/data/search/__init__.py b/src/globus_sdk/testing/data/globus_connect_server/__init__.py similarity index 100% rename from src/globus_sdk/_testing/data/search/__init__.py rename to src/globus_sdk/testing/data/globus_connect_server/__init__.py diff --git a/src/globus_sdk/_testing/data/globus_connect_server/create_storage_gateway.py b/src/globus_sdk/testing/data/globus_connect_server/create_storage_gateway.py similarity index 96% rename from src/globus_sdk/_testing/data/globus_connect_server/create_storage_gateway.py rename to src/globus_sdk/testing/data/globus_connect_server/create_storage_gateway.py index e8df287a6..8feaa7625 100644 --- a/src/globus_sdk/_testing/data/globus_connect_server/create_storage_gateway.py +++ b/src/globus_sdk/testing/data/globus_connect_server/create_storage_gateway.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet metadata = { "id": "daa09846-eb92-11e9-b89c-9cb6d0d9fd63", diff --git a/src/globus_sdk/_testing/data/globus_connect_server/create_user_credential.py b/src/globus_sdk/testing/data/globus_connect_server/create_user_credential.py similarity index 94% rename from src/globus_sdk/_testing/data/globus_connect_server/create_user_credential.py rename to src/globus_sdk/testing/data/globus_connect_server/create_user_credential.py index cdacc1c3e..cac523b5a 100644 --- a/src/globus_sdk/_testing/data/globus_connect_server/create_user_credential.py +++ b/src/globus_sdk/testing/data/globus_connect_server/create_user_credential.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet CREDENTIAL_ID = "af43d884-64a1-4414-897a-680c32374439" diff --git a/src/globus_sdk/_testing/data/globus_connect_server/delete_storage_gateway.py b/src/globus_sdk/testing/data/globus_connect_server/delete_storage_gateway.py similarity index 93% rename from src/globus_sdk/_testing/data/globus_connect_server/delete_storage_gateway.py rename to src/globus_sdk/testing/data/globus_connect_server/delete_storage_gateway.py index b1ee7ae38..62b11dc9f 100644 --- a/src/globus_sdk/_testing/data/globus_connect_server/delete_storage_gateway.py +++ b/src/globus_sdk/testing/data/globus_connect_server/delete_storage_gateway.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet metadata = { "id": "daa09846-eb92-11e9-b89c-9cb6d0d9fd63", diff --git a/src/globus_sdk/_testing/data/globus_connect_server/delete_user_credential.py b/src/globus_sdk/testing/data/globus_connect_server/delete_user_credential.py similarity index 88% rename from src/globus_sdk/_testing/data/globus_connect_server/delete_user_credential.py rename to src/globus_sdk/testing/data/globus_connect_server/delete_user_credential.py index 6a85ba8a6..4b82b5fb3 100644 --- a/src/globus_sdk/_testing/data/globus_connect_server/delete_user_credential.py +++ b/src/globus_sdk/testing/data/globus_connect_server/delete_user_credential.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet CREDENTIAL_ID = "af43d884-64a1-4414-897a-680c32374439" diff --git a/src/globus_sdk/_testing/data/globus_connect_server/get_collection_list.py b/src/globus_sdk/testing/data/globus_connect_server/get_collection_list.py similarity index 97% rename from src/globus_sdk/_testing/data/globus_connect_server/get_collection_list.py rename to src/globus_sdk/testing/data/globus_connect_server/get_collection_list.py index 61dc6cf99..30fd6e651 100644 --- a/src/globus_sdk/_testing/data/globus_connect_server/get_collection_list.py +++ b/src/globus_sdk/testing/data/globus_connect_server/get_collection_list.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet identity_id = str(uuid.uuid4()) collection_ids = [str(uuid.uuid4()), str(uuid.uuid4())] diff --git a/src/globus_sdk/_testing/data/globus_connect_server/get_endpoint.py b/src/globus_sdk/testing/data/globus_connect_server/get_endpoint.py similarity index 94% rename from src/globus_sdk/_testing/data/globus_connect_server/get_endpoint.py rename to src/globus_sdk/testing/data/globus_connect_server/get_endpoint.py index 82f85e355..523fcea4d 100644 --- a/src/globus_sdk/_testing/data/globus_connect_server/get_endpoint.py +++ b/src/globus_sdk/testing/data/globus_connect_server/get_endpoint.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet endpoint_id = str(uuid.uuid4()) gcs_manager_url = RegisteredResponse._url_map["gcs"] diff --git a/src/globus_sdk/_testing/data/globus_connect_server/get_gcs_info.py b/src/globus_sdk/testing/data/globus_connect_server/get_gcs_info.py similarity index 93% rename from src/globus_sdk/_testing/data/globus_connect_server/get_gcs_info.py rename to src/globus_sdk/testing/data/globus_connect_server/get_gcs_info.py index a0d1a6022..f93d1a450 100644 --- a/src/globus_sdk/_testing/data/globus_connect_server/get_gcs_info.py +++ b/src/globus_sdk/testing/data/globus_connect_server/get_gcs_info.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet endpoint_client_id = str(uuid.uuid4()) domain_name = "abc.xyz.data.globus.org" diff --git a/src/globus_sdk/_testing/data/globus_connect_server/get_storage_gateway.py b/src/globus_sdk/testing/data/globus_connect_server/get_storage_gateway.py similarity index 95% rename from src/globus_sdk/_testing/data/globus_connect_server/get_storage_gateway.py rename to src/globus_sdk/testing/data/globus_connect_server/get_storage_gateway.py index 8374c27a7..9578e0bf5 100644 --- a/src/globus_sdk/_testing/data/globus_connect_server/get_storage_gateway.py +++ b/src/globus_sdk/testing/data/globus_connect_server/get_storage_gateway.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet metadata = { "id": "daa09846-eb92-11e9-b89c-9cb6d0d9fd63", diff --git a/src/globus_sdk/_testing/data/globus_connect_server/get_storage_gateway_list.py b/src/globus_sdk/testing/data/globus_connect_server/get_storage_gateway_list.py similarity index 97% rename from src/globus_sdk/_testing/data/globus_connect_server/get_storage_gateway_list.py rename to src/globus_sdk/testing/data/globus_connect_server/get_storage_gateway_list.py index c9c268b15..7ba16adfb 100644 --- a/src/globus_sdk/_testing/data/globus_connect_server/get_storage_gateway_list.py +++ b/src/globus_sdk/testing/data/globus_connect_server/get_storage_gateway_list.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet GATEWAY_IDS = [ "a0cbde58-0183-11ea-92bd-9cb6d0d9fd63", diff --git a/src/globus_sdk/_testing/data/globus_connect_server/get_user_credential.py b/src/globus_sdk/testing/data/globus_connect_server/get_user_credential.py similarity index 94% rename from src/globus_sdk/_testing/data/globus_connect_server/get_user_credential.py rename to src/globus_sdk/testing/data/globus_connect_server/get_user_credential.py index c13f925de..9dce5fbc4 100644 --- a/src/globus_sdk/_testing/data/globus_connect_server/get_user_credential.py +++ b/src/globus_sdk/testing/data/globus_connect_server/get_user_credential.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet CREDENTIAL_ID = "af43d884-64a1-4414-897a-680c32374439" diff --git a/src/globus_sdk/_testing/data/globus_connect_server/get_user_credential_list.py b/src/globus_sdk/testing/data/globus_connect_server/get_user_credential_list.py similarity index 96% rename from src/globus_sdk/_testing/data/globus_connect_server/get_user_credential_list.py rename to src/globus_sdk/testing/data/globus_connect_server/get_user_credential_list.py index aeb594f50..cd951a913 100644 --- a/src/globus_sdk/_testing/data/globus_connect_server/get_user_credential_list.py +++ b/src/globus_sdk/testing/data/globus_connect_server/get_user_credential_list.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet CREDENTIAL_IDS = [ "af43d884-64a1-4414-897a-680c32374439", diff --git a/src/globus_sdk/_testing/data/globus_connect_server/update_endpoint.py b/src/globus_sdk/testing/data/globus_connect_server/update_endpoint.py similarity index 95% rename from src/globus_sdk/_testing/data/globus_connect_server/update_endpoint.py rename to src/globus_sdk/testing/data/globus_connect_server/update_endpoint.py index 32ae0bffb..fc741130f 100644 --- a/src/globus_sdk/_testing/data/globus_connect_server/update_endpoint.py +++ b/src/globus_sdk/testing/data/globus_connect_server/update_endpoint.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet endpoint_id = str(uuid.uuid4()) gcs_manager_url = RegisteredResponse._url_map["gcs"] diff --git a/src/globus_sdk/_testing/data/globus_connect_server/update_storage_gateway.py b/src/globus_sdk/testing/data/globus_connect_server/update_storage_gateway.py similarity index 95% rename from src/globus_sdk/_testing/data/globus_connect_server/update_storage_gateway.py rename to src/globus_sdk/testing/data/globus_connect_server/update_storage_gateway.py index d43b84bf7..d6c34c3ad 100644 --- a/src/globus_sdk/_testing/data/globus_connect_server/update_storage_gateway.py +++ b/src/globus_sdk/testing/data/globus_connect_server/update_storage_gateway.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet metadata = { "id": "daa09846-eb92-11e9-b89c-9cb6d0d9fd63", diff --git a/src/globus_sdk/_testing/data/globus_connect_server/update_user_credential.py b/src/globus_sdk/testing/data/globus_connect_server/update_user_credential.py similarity index 94% rename from src/globus_sdk/_testing/data/globus_connect_server/update_user_credential.py rename to src/globus_sdk/testing/data/globus_connect_server/update_user_credential.py index f31551138..d048c86ff 100644 --- a/src/globus_sdk/_testing/data/globus_connect_server/update_user_credential.py +++ b/src/globus_sdk/testing/data/globus_connect_server/update_user_credential.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet CREDENTIAL_ID = "af43d884-64a1-4414-897a-680c32374439" diff --git a/src/globus_sdk/_testing/data/timer/__init__.py b/src/globus_sdk/testing/data/groups/__init__.py similarity index 100% rename from src/globus_sdk/_testing/data/timer/__init__.py rename to src/globus_sdk/testing/data/groups/__init__.py diff --git a/src/globus_sdk/_testing/data/groups/_common.py b/src/globus_sdk/testing/data/groups/_common.py similarity index 100% rename from src/globus_sdk/_testing/data/groups/_common.py rename to src/globus_sdk/testing/data/groups/_common.py diff --git a/src/globus_sdk/_testing/data/groups/create_group.py b/src/globus_sdk/testing/data/groups/create_group.py similarity index 70% rename from src/globus_sdk/_testing/data/groups/create_group.py rename to src/globus_sdk/testing/data/groups/create_group.py index 2566fbad2..083bd4a7a 100644 --- a/src/globus_sdk/_testing/data/groups/create_group.py +++ b/src/globus_sdk/testing/data/groups/create_group.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import BASE_GROUP_DOC, GROUP_ID @@ -6,7 +6,7 @@ metadata={"group_id": GROUP_ID}, default=RegisteredResponse( service="groups", - path="/groups", + path="/v2/groups", method="POST", json=BASE_GROUP_DOC, ), diff --git a/src/globus_sdk/_testing/data/groups/delete_group.py b/src/globus_sdk/testing/data/groups/delete_group.py similarity index 67% rename from src/globus_sdk/_testing/data/groups/delete_group.py rename to src/globus_sdk/testing/data/groups/delete_group.py index 35fcee46f..2d748cd4a 100644 --- a/src/globus_sdk/_testing/data/groups/delete_group.py +++ b/src/globus_sdk/testing/data/groups/delete_group.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import BASE_GROUP_DOC, GROUP_ID @@ -6,7 +6,7 @@ metadata={"group_id": GROUP_ID}, default=RegisteredResponse( service="groups", - path=f"/groups/{GROUP_ID}", + path=f"/v2/groups/{GROUP_ID}", method="DELETE", json=BASE_GROUP_DOC, ), diff --git a/src/globus_sdk/_testing/data/groups/get_group.py b/src/globus_sdk/testing/data/groups/get_group.py similarity index 76% rename from src/globus_sdk/_testing/data/groups/get_group.py rename to src/globus_sdk/testing/data/groups/get_group.py index 98d275180..81a747011 100644 --- a/src/globus_sdk/_testing/data/groups/get_group.py +++ b/src/globus_sdk/testing/data/groups/get_group.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ( BASE_GROUP_DOC, @@ -12,12 +12,12 @@ metadata={"group_id": GROUP_ID}, default=RegisteredResponse( service="groups", - path=f"/groups/{GROUP_ID}", + path=f"/v2/groups/{GROUP_ID}", json=BASE_GROUP_DOC, ), subscription=RegisteredResponse( service="groups", - path=f"/groups/{SUBSCRIPTION_GROUP_ID}", + path=f"/v2/groups/{SUBSCRIPTION_GROUP_ID}", json=SUBSCRIPTION_GROUP_DOC, metadata={ "group_id": SUBSCRIPTION_GROUP_ID, diff --git a/src/globus_sdk/_testing/data/groups/get_group_by_subscription_id.py b/src/globus_sdk/testing/data/groups/get_group_by_subscription_id.py similarity index 83% rename from src/globus_sdk/_testing/data/groups/get_group_by_subscription_id.py rename to src/globus_sdk/testing/data/groups/get_group_by_subscription_id.py index 12147be60..f44e80875 100644 --- a/src/globus_sdk/_testing/data/groups/get_group_by_subscription_id.py +++ b/src/globus_sdk/testing/data/groups/get_group_by_subscription_id.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import SUBSCRIPTION_GROUP_ID, SUBSCRIPTION_ID, SUBSCRIPTION_INFO @@ -6,7 +6,7 @@ metadata={"group_id": SUBSCRIPTION_GROUP_ID, "subscription_id": SUBSCRIPTION_ID}, default=RegisteredResponse( service="groups", - path=f"/subscription_info/{SUBSCRIPTION_ID}", + path=f"/v2/subscription_info/{SUBSCRIPTION_ID}", json={ "group_id": SUBSCRIPTION_GROUP_ID, "subscription_id": SUBSCRIPTION_ID, diff --git a/src/globus_sdk/_testing/data/groups/get_my_groups.py b/src/globus_sdk/testing/data/groups/get_my_groups.py similarity index 98% rename from src/globus_sdk/_testing/data/groups/get_my_groups.py rename to src/globus_sdk/testing/data/groups/get_my_groups.py index c25f1a545..670989665 100644 --- a/src/globus_sdk/_testing/data/groups/get_my_groups.py +++ b/src/globus_sdk/testing/data/groups/get_my_groups.py @@ -2,7 +2,7 @@ import typing as t -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet raw_data: list[dict[str, t.Any]] = [ { @@ -175,7 +175,7 @@ }, default=RegisteredResponse( service="groups", - path="/groups/my_groups", + path="/v2/groups/my_groups", json=raw_data, ), ) diff --git a/src/globus_sdk/_testing/data/groups/set_group_policies.py b/src/globus_sdk/testing/data/groups/set_group_policies.py similarity index 80% rename from src/globus_sdk/_testing/data/groups/set_group_policies.py rename to src/globus_sdk/testing/data/groups/set_group_policies.py index 7fc701161..22cc78164 100644 --- a/src/globus_sdk/_testing/data/groups/set_group_policies.py +++ b/src/globus_sdk/testing/data/groups/set_group_policies.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import GROUP_ID @@ -6,7 +6,7 @@ metadata={"group_id": GROUP_ID}, default=RegisteredResponse( service="groups", - path=f"/groups/{GROUP_ID}/policies", + path=f"/v2/groups/{GROUP_ID}/policies", method="PUT", json={ "is_high_assurance": False, diff --git a/src/globus_sdk/_testing/data/groups/set_subscription_admin_verified.py b/src/globus_sdk/testing/data/groups/set_subscription_admin_verified.py similarity index 72% rename from src/globus_sdk/_testing/data/groups/set_subscription_admin_verified.py rename to src/globus_sdk/testing/data/groups/set_subscription_admin_verified.py index e86407853..9a528ccd1 100644 --- a/src/globus_sdk/_testing/data/groups/set_subscription_admin_verified.py +++ b/src/globus_sdk/testing/data/groups/set_subscription_admin_verified.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import GROUP_ID, SUBSCRIPTION_ID @@ -6,7 +6,7 @@ metadata={"group_id": GROUP_ID, "subscription_id": SUBSCRIPTION_ID}, default=RegisteredResponse( service="groups", - path=f"/groups/{GROUP_ID}/subscription_admin_verified", + path=f"/v2/groups/{GROUP_ID}/subscription_admin_verified", method="PUT", json={ "group_id": GROUP_ID, diff --git a/src/globus_sdk/_testing/data/transfer/__init__.py b/src/globus_sdk/testing/data/search/__init__.py similarity index 100% rename from src/globus_sdk/_testing/data/transfer/__init__.py rename to src/globus_sdk/testing/data/search/__init__.py diff --git a/src/globus_sdk/_testing/data/search/batch_delete_by_subject.py b/src/globus_sdk/testing/data/search/batch_delete_by_subject.py similarity index 82% rename from src/globus_sdk/_testing/data/search/batch_delete_by_subject.py rename to src/globus_sdk/testing/data/search/batch_delete_by_subject.py index d66423d26..acdca126f 100644 --- a/src/globus_sdk/_testing/data/search/batch_delete_by_subject.py +++ b/src/globus_sdk/testing/data/search/batch_delete_by_subject.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = str(uuid.uuid1()) TASK_ID = str(uuid.uuid1()) diff --git a/src/globus_sdk/_testing/data/search/create_index.py b/src/globus_sdk/testing/data/search/create_index.py similarity index 95% rename from src/globus_sdk/_testing/data/search/create_index.py rename to src/globus_sdk/testing/data/search/create_index.py index 428677bb0..224edc8cc 100644 --- a/src/globus_sdk/_testing/data/search/create_index.py +++ b/src/globus_sdk/testing/data/search/create_index.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = str(uuid.uuid4()) diff --git a/src/globus_sdk/_testing/data/search/create_role.py b/src/globus_sdk/testing/data/search/create_role.py similarity index 92% rename from src/globus_sdk/_testing/data/search/create_role.py rename to src/globus_sdk/testing/data/search/create_role.py index 41f5dfeee..e148dd065 100644 --- a/src/globus_sdk/_testing/data/search/create_role.py +++ b/src/globus_sdk/testing/data/search/create_role.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = "60d1160b-f016-40b0-8545-99619865873d" IDENTITY_ID = "46bd0f56-e24f-11e5-a510-131bef46955c" diff --git a/src/globus_sdk/_testing/data/search/delete_index.py b/src/globus_sdk/testing/data/search/delete_index.py similarity index 92% rename from src/globus_sdk/_testing/data/search/delete_index.py rename to src/globus_sdk/testing/data/search/delete_index.py index 762fe3d53..697923837 100644 --- a/src/globus_sdk/_testing/data/search/delete_index.py +++ b/src/globus_sdk/testing/data/search/delete_index.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = str(uuid.uuid4()) diff --git a/src/globus_sdk/_testing/data/search/delete_role.py b/src/globus_sdk/testing/data/search/delete_role.py similarity index 91% rename from src/globus_sdk/_testing/data/search/delete_role.py rename to src/globus_sdk/testing/data/search/delete_role.py index 6f5832ea0..591aa7df9 100644 --- a/src/globus_sdk/_testing/data/search/delete_role.py +++ b/src/globus_sdk/testing/data/search/delete_role.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = "60d1160b-f016-40b0-8545-99619865873d" IDENTITY_ID = "46bd0f56-e24f-11e5-a510-131bef46955c" diff --git a/src/globus_sdk/_testing/data/search/get_role_list.py b/src/globus_sdk/testing/data/search/get_role_list.py similarity index 94% rename from src/globus_sdk/_testing/data/search/get_role_list.py rename to src/globus_sdk/testing/data/search/get_role_list.py index b7e12f4d8..77aa041d0 100644 --- a/src/globus_sdk/_testing/data/search/get_role_list.py +++ b/src/globus_sdk/testing/data/search/get_role_list.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = "60d1160b-f016-40b0-8545-99619865873d" IDENTITY_IDS = [ diff --git a/src/globus_sdk/_testing/data/search/index_list.py b/src/globus_sdk/testing/data/search/index_list.py similarity index 95% rename from src/globus_sdk/_testing/data/search/index_list.py rename to src/globus_sdk/testing/data/search/index_list.py index d7bc3e40c..85214582f 100644 --- a/src/globus_sdk/_testing/data/search/index_list.py +++ b/src/globus_sdk/testing/data/search/index_list.py @@ -3,7 +3,7 @@ import typing as t import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_IDS = [str(uuid.uuid1()), str(uuid.uuid1())] INDEX_ATTRIBUTES: dict[str, dict[str, t.Any]] = { diff --git a/src/globus_sdk/_testing/data/search/post_search.py b/src/globus_sdk/testing/data/search/post_search.py similarity index 92% rename from src/globus_sdk/_testing/data/search/post_search.py rename to src/globus_sdk/testing/data/search/post_search.py index 0cc56b4d5..3a5cfc9f4 100644 --- a/src/globus_sdk/_testing/data/search/post_search.py +++ b/src/globus_sdk/testing/data/search/post_search.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = "60d1160b-f016-40b0-8545-99619865873d" diff --git a/src/globus_sdk/_testing/data/search/reopen_index.py b/src/globus_sdk/testing/data/search/reopen_index.py similarity index 93% rename from src/globus_sdk/_testing/data/search/reopen_index.py rename to src/globus_sdk/testing/data/search/reopen_index.py index 069d67806..2f8069b3a 100644 --- a/src/globus_sdk/_testing/data/search/reopen_index.py +++ b/src/globus_sdk/testing/data/search/reopen_index.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = str(uuid.uuid4()) diff --git a/src/globus_sdk/_testing/data/search/search.py b/src/globus_sdk/testing/data/search/search.py similarity index 92% rename from src/globus_sdk/_testing/data/search/search.py rename to src/globus_sdk/testing/data/search/search.py index f2239532f..5ef1db55a 100644 --- a/src/globus_sdk/_testing/data/search/search.py +++ b/src/globus_sdk/testing/data/search/search.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = "60d1160b-f016-40b0-8545-99619865873d" diff --git a/src/globus_sdk/_testing/data/search/update_index.py b/src/globus_sdk/testing/data/search/update_index.py similarity index 96% rename from src/globus_sdk/_testing/data/search/update_index.py rename to src/globus_sdk/testing/data/search/update_index.py index f43ccf7bf..51e4e4d28 100644 --- a/src/globus_sdk/_testing/data/search/update_index.py +++ b/src/globus_sdk/testing/data/search/update_index.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet INDEX_ID = str(uuid.uuid4()) diff --git a/src/globus_sdk/testing/data/timer/__init__.py b/src/globus_sdk/testing/data/timer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/globus_sdk/_testing/data/timer/_common.py b/src/globus_sdk/testing/data/timer/_common.py similarity index 100% rename from src/globus_sdk/_testing/data/timer/_common.py rename to src/globus_sdk/testing/data/timer/_common.py diff --git a/src/globus_sdk/_testing/data/timer/create_job.py b/src/globus_sdk/testing/data/timer/create_job.py similarity index 93% rename from src/globus_sdk/_testing/data/timer/create_job.py rename to src/globus_sdk/testing/data/timer/create_job.py index bad50aea4..1a8e54867 100644 --- a/src/globus_sdk/_testing/data/timer/create_job.py +++ b/src/globus_sdk/testing/data/timer/create_job.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TIMER_ID, V1_TIMER diff --git a/src/globus_sdk/_testing/data/timer/create_timer.py b/src/globus_sdk/testing/data/timer/create_timer.py similarity index 92% rename from src/globus_sdk/_testing/data/timer/create_timer.py rename to src/globus_sdk/testing/data/timer/create_timer.py index 89854d625..2c9f2b598 100644 --- a/src/globus_sdk/_testing/data/timer/create_timer.py +++ b/src/globus_sdk/testing/data/timer/create_timer.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ( DEST_EP_ID, diff --git a/src/globus_sdk/_testing/data/timer/delete_job.py b/src/globus_sdk/testing/data/timer/delete_job.py similarity index 77% rename from src/globus_sdk/_testing/data/timer/delete_job.py rename to src/globus_sdk/testing/data/timer/delete_job.py index 3288f1bcb..f501b56f2 100644 --- a/src/globus_sdk/_testing/data/timer/delete_job.py +++ b/src/globus_sdk/testing/data/timer/delete_job.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TIMER_ID, V1_TIMER diff --git a/src/globus_sdk/_testing/data/timer/get_job.py b/src/globus_sdk/testing/data/timer/get_job.py similarity index 96% rename from src/globus_sdk/_testing/data/timer/get_job.py rename to src/globus_sdk/testing/data/timer/get_job.py index 0ccb489ee..fa587bfc9 100644 --- a/src/globus_sdk/_testing/data/timer/get_job.py +++ b/src/globus_sdk/testing/data/timer/get_job.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TIMER_ID, V1_TIMER diff --git a/src/globus_sdk/_testing/data/timer/list_jobs.py b/src/globus_sdk/testing/data/timer/list_jobs.py similarity index 77% rename from src/globus_sdk/_testing/data/timer/list_jobs.py rename to src/globus_sdk/testing/data/timer/list_jobs.py index ce9014465..18d9f3781 100644 --- a/src/globus_sdk/_testing/data/timer/list_jobs.py +++ b/src/globus_sdk/testing/data/timer/list_jobs.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TIMER_ID, V1_TIMER diff --git a/src/globus_sdk/_testing/data/timer/pause_job.py b/src/globus_sdk/testing/data/timer/pause_job.py similarity index 80% rename from src/globus_sdk/_testing/data/timer/pause_job.py rename to src/globus_sdk/testing/data/timer/pause_job.py index cb72ccb46..79e2c61a8 100644 --- a/src/globus_sdk/_testing/data/timer/pause_job.py +++ b/src/globus_sdk/testing/data/timer/pause_job.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TIMER_ID diff --git a/src/globus_sdk/_testing/data/timer/resume_job.py b/src/globus_sdk/testing/data/timer/resume_job.py similarity index 80% rename from src/globus_sdk/_testing/data/timer/resume_job.py rename to src/globus_sdk/testing/data/timer/resume_job.py index 971d863a6..51056e6d2 100644 --- a/src/globus_sdk/_testing/data/timer/resume_job.py +++ b/src/globus_sdk/testing/data/timer/resume_job.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TIMER_ID diff --git a/src/globus_sdk/_testing/data/timer/update_job.py b/src/globus_sdk/testing/data/timer/update_job.py similarity index 85% rename from src/globus_sdk/_testing/data/timer/update_job.py rename to src/globus_sdk/testing/data/timer/update_job.py index 0be527a80..ad89200b5 100644 --- a/src/globus_sdk/_testing/data/timer/update_job.py +++ b/src/globus_sdk/testing/data/timer/update_job.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TIMER_ID, V1_TIMER diff --git a/src/globus_sdk/testing/data/transfer/__init__.py b/src/globus_sdk/testing/data/transfer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/globus_sdk/_testing/data/transfer/_common.py b/src/globus_sdk/testing/data/transfer/_common.py similarity index 100% rename from src/globus_sdk/_testing/data/transfer/_common.py rename to src/globus_sdk/testing/data/transfer/_common.py diff --git a/src/globus_sdk/_testing/data/transfer/endpoint_manager_task_list.py b/src/globus_sdk/testing/data/transfer/endpoint_manager_task_list.py similarity index 97% rename from src/globus_sdk/_testing/data/transfer/endpoint_manager_task_list.py rename to src/globus_sdk/testing/data/transfer/endpoint_manager_task_list.py index 1bad1ccc4..49c41dc8d 100644 --- a/src/globus_sdk/_testing/data/transfer/endpoint_manager_task_list.py +++ b/src/globus_sdk/testing/data/transfer/endpoint_manager_task_list.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID as SRC_ENDPOINT_ID from ._common import TASK_ID @@ -13,7 +13,7 @@ default=RegisteredResponse( service="transfer", method="GET", - path="/endpoint_manager/task_list", + path="/v0.10/endpoint_manager/task_list", metadata={ "task_id": TASK_ID, "source": SRC_ENDPOINT_ID, diff --git a/src/globus_sdk/_testing/data/transfer/endpoint_manager_task_successful_transfers.py b/src/globus_sdk/testing/data/transfer/endpoint_manager_task_successful_transfers.py similarity index 79% rename from src/globus_sdk/_testing/data/transfer/endpoint_manager_task_successful_transfers.py rename to src/globus_sdk/testing/data/transfer/endpoint_manager_task_successful_transfers.py index 2610ddb36..7d5af62a2 100644 --- a/src/globus_sdk/_testing/data/transfer/endpoint_manager_task_successful_transfers.py +++ b/src/globus_sdk/testing/data/transfer/endpoint_manager_task_successful_transfers.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import TASK_ID @@ -7,7 +7,7 @@ default=RegisteredResponse( service="transfer", method="GET", - path=f"/endpoint_manager/task/{TASK_ID}/successful_transfers", + path=f"/v0.10/endpoint_manager/task/{TASK_ID}/successful_transfers", json={ "DATA_TYPE": "successful_transfers", "marker": 0, diff --git a/src/globus_sdk/_testing/data/transfer/get_endpoint.py b/src/globus_sdk/testing/data/transfer/get_endpoint.py similarity index 93% rename from src/globus_sdk/_testing/data/transfer/get_endpoint.py rename to src/globus_sdk/testing/data/transfer/get_endpoint.py index 68c8fd5a5..13e18cedf 100644 --- a/src/globus_sdk/_testing/data/transfer/get_endpoint.py +++ b/src/globus_sdk/testing/data/transfer/get_endpoint.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID @@ -57,7 +57,7 @@ metadata={"endpoint_id": ENDPOINT_ID}, default=RegisteredResponse( service="transfer", - path=f"/endpoint/{ENDPOINT_ID}", + path=f"/v0.10/endpoint/{ENDPOINT_ID}", json=ENDPOINT_DOC, ), ) diff --git a/src/globus_sdk/_testing/data/transfer/get_submission_id.py b/src/globus_sdk/testing/data/transfer/get_submission_id.py similarity index 66% rename from src/globus_sdk/_testing/data/transfer/get_submission_id.py rename to src/globus_sdk/testing/data/transfer/get_submission_id.py index 820f87858..4a4eb925c 100644 --- a/src/globus_sdk/_testing/data/transfer/get_submission_id.py +++ b/src/globus_sdk/testing/data/transfer/get_submission_id.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import SUBMISSION_ID @@ -6,7 +6,7 @@ metadata={"submission_id": SUBMISSION_ID}, default=RegisteredResponse( service="transfer", - path="/submission_id", + path="/v0.10/submission_id", json={"value": SUBMISSION_ID}, ), ) diff --git a/src/globus_sdk/_testing/data/transfer/operation_mkdir.py b/src/globus_sdk/testing/data/transfer/operation_mkdir.py similarity index 66% rename from src/globus_sdk/_testing/data/transfer/operation_mkdir.py rename to src/globus_sdk/testing/data/transfer/operation_mkdir.py index 9fa58b2b3..0e64fd036 100644 --- a/src/globus_sdk/_testing/data/transfer/operation_mkdir.py +++ b/src/globus_sdk/testing/data/transfer/operation_mkdir.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID @@ -7,13 +7,13 @@ default=RegisteredResponse( service="transfer", method="POST", - path=f"/operation/endpoint/{ENDPOINT_ID}/mkdir", + path=f"/v0.10/operation/endpoint/{ENDPOINT_ID}/mkdir", json={ "DATA_TYPE": "mkdir_result", "code": "DirectoryCreated", "message": "The directory was created successfully", "request_id": "ShbIUzrWT", - "resource": f"/operation/endpoint/{ENDPOINT_ID}/mkdir", + "resource": f"/v0.10/operation/endpoint/{ENDPOINT_ID}/mkdir", }, ), ) diff --git a/src/globus_sdk/_testing/data/transfer/operation_rename.py b/src/globus_sdk/testing/data/transfer/operation_rename.py similarity index 65% rename from src/globus_sdk/_testing/data/transfer/operation_rename.py rename to src/globus_sdk/testing/data/transfer/operation_rename.py index 4c44e4d79..d49b5790d 100644 --- a/src/globus_sdk/_testing/data/transfer/operation_rename.py +++ b/src/globus_sdk/testing/data/transfer/operation_rename.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID @@ -7,13 +7,13 @@ default=RegisteredResponse( service="transfer", method="POST", - path=f"/operation/endpoint/{ENDPOINT_ID}/rename", + path=f"/v0.10/operation/endpoint/{ENDPOINT_ID}/rename", json={ "DATA_TYPE": "result", "code": "FileRenamed", "message": "File or directory renamed successfully", "request_id": "ShbIUzrWT", - "resource": f"/operation/endpoint/{ENDPOINT_ID}/rename", + "resource": f"/v0.10/operation/endpoint/{ENDPOINT_ID}/rename", }, ), ) diff --git a/src/globus_sdk/_testing/data/transfer/operation_stat.py b/src/globus_sdk/testing/data/transfer/operation_stat.py similarity index 81% rename from src/globus_sdk/_testing/data/transfer/operation_stat.py rename to src/globus_sdk/testing/data/transfer/operation_stat.py index 312d5ddda..4121557eb 100644 --- a/src/globus_sdk/_testing/data/transfer/operation_stat.py +++ b/src/globus_sdk/testing/data/transfer/operation_stat.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID @@ -7,7 +7,7 @@ default=RegisteredResponse( service="transfer", method="GET", - path=f"/operation/endpoint/{ENDPOINT_ID}/stat", + path=f"/v0.10/operation/endpoint/{ENDPOINT_ID}/stat", json={ "DATA_TYPE": "file", "group": "tutorial", @@ -27,25 +27,25 @@ not_found=RegisteredResponse( service="transfer", method="GET", - path=f"/operation/endpoint/{ENDPOINT_ID}/stat", + path=f"/v0.10/operation/endpoint/{ENDPOINT_ID}/stat", status=404, json={ "code": "NotFound", "message": f"Path not found, Error (list)\nEndpoint: Globus Tutorial Collection 1 ({ENDPOINT_ID})\nServer: 100.26.231.26:443\nMessage: No such file or directory\n---\nDetails: Error: '~/foo' not found\\r\\n550-GlobusError: v=1 c=PATH_NOT_FOUND\\r\\n550-GridFTP-Errno: 2\\r\\n550-GridFTP-Reason: System error in stat\\r\\n550-GridFTP-Error-String: No such file or directory\\r\\n550 End.\\r\\n\n", # noqa 501 "request_id": "aaabbbccc", - "resource": f"/operation/endpoint/{ENDPOINT_ID}/stat", + "resource": f"/v0.10/operation/endpoint/{ENDPOINT_ID}/stat", }, ), permission_denied=RegisteredResponse( service="transfer", method="GET", - path=f"/operation/endpoint/{ENDPOINT_ID}/stat", + path=f"/v0.10/operation/endpoint/{ENDPOINT_ID}/stat", status=403, json={ "code": "EndpointPermissionDenied", "message": f"Denied by endpoint, Error (list)\nEndpoint: Globus Tutorial Collection 1 ({ENDPOINT_ID})\nServer: 100.26.231.26:443\nCommand: MLST /foo\nMessage: Fatal FTP Response\n---\nDetails: 500 Command failed : Path not allowed.\\r\\n\n", # noqa 501 "request_id": "aaabbbccc", - "resource": f"/operation/endpoint/{ENDPOINT_ID}/stat", + "resource": f"/v0.10/operation/endpoint/{ENDPOINT_ID}/stat", }, ), ) diff --git a/src/globus_sdk/_testing/data/transfer/set_subscription_admin_verified.py b/src/globus_sdk/testing/data/transfer/set_subscription_admin_verified.py similarity index 97% rename from src/globus_sdk/_testing/data/transfer/set_subscription_admin_verified.py rename to src/globus_sdk/testing/data/transfer/set_subscription_admin_verified.py index 3a557a4ed..45fd3749c 100644 --- a/src/globus_sdk/_testing/data/transfer/set_subscription_admin_verified.py +++ b/src/globus_sdk/testing/data/transfer/set_subscription_admin_verified.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID, SUBSCRIPTION_ID diff --git a/src/globus_sdk/_testing/data/transfer/set_subscription_id.py b/src/globus_sdk/testing/data/transfer/set_subscription_id.py similarity index 74% rename from src/globus_sdk/_testing/data/transfer/set_subscription_id.py rename to src/globus_sdk/testing/data/transfer/set_subscription_id.py index 57cd9b223..ac087a641 100644 --- a/src/globus_sdk/_testing/data/transfer/set_subscription_id.py +++ b/src/globus_sdk/testing/data/transfer/set_subscription_id.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID, SUBSCRIPTION_ID @@ -11,31 +11,31 @@ default=RegisteredResponse( service="transfer", method="PUT", - path=f"/endpoint/{ENDPOINT_ID}/subscription", + path=f"/v0.10/endpoint/{ENDPOINT_ID}/subscription", json={ "DATA_TYPE": "result", "code": "Updated", "message": "Endpoint updated successfully", "request_id": "dWTZZe17L", - "resource": f"/endpoint/{ENDPOINT_ID}/subscription", + "resource": f"/v0.10/endpoint/{ENDPOINT_ID}/subscription", }, ), not_found=RegisteredResponse( service="transfer", method="PUT", - path=f"/endpoint/{ENDPOINT_ID}/subscription", + path=f"/v0.10/endpoint/{ENDPOINT_ID}/subscription", status=404, json={ "code": "EndpointNotFound", "message": f"No such endpoint '{ENDPOINT_ID}'", "request_id": "BHI2BHt8N", - "resource": f"/endpoint/{ENDPOINT_ID}/subscription", + "resource": f"/v0.10/endpoint/{ENDPOINT_ID}/subscription", }, ), multi_subscriber_cannot_use_default=RegisteredResponse( service="transfer", method="PUT", - path=f"/endpoint/{ENDPOINT_ID}/subscription", + path=f"/v0.10/endpoint/{ENDPOINT_ID}/subscription", status=400, json={ "code": "BadRequest", @@ -45,7 +45,7 @@ f"{SUBSCRIPTION_ID}, {OTHER_SUBSCRIPTION_ID}" ), "request_id": "H1dFNg6QB", - "resource": f"/endpoint/{ENDPOINT_ID}/subscription", + "resource": f"/v0.10/endpoint/{ENDPOINT_ID}/subscription", }, metadata={ "endpoint_id": ENDPOINT_ID, diff --git a/src/globus_sdk/_testing/data/transfer/submit_delete.py b/src/globus_sdk/testing/data/transfer/submit_delete.py similarity index 79% rename from src/globus_sdk/_testing/data/transfer/submit_delete.py rename to src/globus_sdk/testing/data/transfer/submit_delete.py index acc4d4466..ba682034c 100644 --- a/src/globus_sdk/_testing/data/transfer/submit_delete.py +++ b/src/globus_sdk/testing/data/transfer/submit_delete.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import SUBMISSION_ID, TASK_ID @@ -7,7 +7,7 @@ default=RegisteredResponse( service="transfer", method="POST", - path="/delete", + path="/v0.10/delete", json={ "DATA_TYPE": "delete_result", "code": "Accepted", @@ -16,12 +16,12 @@ "and queued for execution" ), "request_id": "NS2QXhLZ7", - "resource": "/delete", + "resource": "/v0.10/delete", "submission_id": SUBMISSION_ID, "task_id": TASK_ID, "task_link": { "DATA_TYPE": "link", - "href": f"task/{TASK_ID}?format=json", + "href": f"/v0.10/task/{TASK_ID}?format=json", "rel": "related", "resource": "task", "title": "related task", diff --git a/src/globus_sdk/_testing/data/transfer/submit_transfer.py b/src/globus_sdk/testing/data/transfer/submit_transfer.py similarity index 80% rename from src/globus_sdk/_testing/data/transfer/submit_transfer.py rename to src/globus_sdk/testing/data/transfer/submit_transfer.py index 00c32ced1..f204aa1a0 100644 --- a/src/globus_sdk/_testing/data/transfer/submit_transfer.py +++ b/src/globus_sdk/testing/data/transfer/submit_transfer.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import SUBMISSION_ID, TASK_ID @@ -7,7 +7,7 @@ default=RegisteredResponse( service="transfer", method="POST", - path="/transfer", + path="/v0.10/transfer", json={ "DATA_TYPE": "transfer_result", "code": "Accepted", @@ -16,12 +16,12 @@ "and queued for execution" ), "request_id": "7HgMVYazI", - "resource": "/transfer", + "resource": "/v0.10/transfer", "submission_id": SUBMISSION_ID, "task_id": TASK_ID, "task_link": { "DATA_TYPE": "link", - "href": f"task/{TASK_ID}?format=json", + "href": f"/v0.10/task/{TASK_ID}?format=json", "rel": "related", "resource": "task", "title": "related task", @@ -31,12 +31,12 @@ failure=RegisteredResponse( service="transfer", method="POST", - path="/transfer", + path="/v0.10/transfer", json={ "code": "ClientError.BadRequest.NoTransferItems", "message": "A transfer requires at least one item", "request_id": "oUAA6Sq2P", - "resource": "/transfer", + "resource": "/v0.10/transfer", }, status=400, metadata={ diff --git a/src/globus_sdk/_testing/data/transfer/task_list.py b/src/globus_sdk/testing/data/transfer/task_list.py similarity index 96% rename from src/globus_sdk/_testing/data/transfer/task_list.py rename to src/globus_sdk/testing/data/transfer/task_list.py index 26684b8de..76b115c38 100644 --- a/src/globus_sdk/_testing/data/transfer/task_list.py +++ b/src/globus_sdk/testing/data/transfer/task_list.py @@ -1,6 +1,6 @@ import uuid -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet source_id = str(uuid.uuid4()) destination_id = str(uuid.uuid4()) @@ -85,7 +85,7 @@ }, default=RegisteredResponse( service="transfer", - path="/task_list", + path="/v0.10/task_list", json=TASK_LIST_DOC, ), ) diff --git a/src/globus_sdk/_testing/data/transfer/update_endpoint.py b/src/globus_sdk/testing/data/transfer/update_endpoint.py similarity index 68% rename from src/globus_sdk/_testing/data/transfer/update_endpoint.py rename to src/globus_sdk/testing/data/transfer/update_endpoint.py index 767b0b24b..a3185a61a 100644 --- a/src/globus_sdk/_testing/data/transfer/update_endpoint.py +++ b/src/globus_sdk/testing/data/transfer/update_endpoint.py @@ -1,4 +1,4 @@ -from globus_sdk._testing.models import RegisteredResponse, ResponseSet +from globus_sdk.testing.models import RegisteredResponse, ResponseSet from ._common import ENDPOINT_ID @@ -7,13 +7,13 @@ default=RegisteredResponse( service="transfer", method="PUT", - path=f"/endpoint/{ENDPOINT_ID}", + path=f"/v0.10/endpoint/{ENDPOINT_ID}", json={ "DATA_TYPE": "result", "code": "Updated", "message": "Endpoint updated successfully", "request_id": "6aZjzldyM", - "resource": f"/endpoint/{ENDPOINT_ID}", + "resource": f"/v0.10/endpoint/{ENDPOINT_ID}", }, ), ) diff --git a/src/globus_sdk/_testing/helpers.py b/src/globus_sdk/testing/helpers.py similarity index 100% rename from src/globus_sdk/_testing/helpers.py rename to src/globus_sdk/testing/helpers.py diff --git a/src/globus_sdk/_testing/models.py b/src/globus_sdk/testing/models.py similarity index 93% rename from src/globus_sdk/_testing/models.py rename to src/globus_sdk/testing/models.py index 1bfe45938..0bef17111 100644 --- a/src/globus_sdk/_testing/models.py +++ b/src/globus_sdk/testing/models.py @@ -5,7 +5,7 @@ import responses -from ..utils import slash_join +from globus_sdk._internal.utils import slash_join class RegisteredResponse: @@ -37,18 +37,14 @@ class RegisteredResponse: _url_map = { "auth": "https://auth.globus.org/", "nexus": "https://nexus.api.globusonline.org/", - "transfer": "https://transfer.api.globus.org/v0.10", + "transfer": "https://transfer.api.globus.org/", "search": "https://search.api.globus.org/", - "gcs": "https://abc.xyz.data.globus.org/api", - "groups": "https://groups.api.globus.org/v2/", + "gcs": "https://abc.xyz.data.globus.org/api/", + "groups": "https://groups.api.globus.org/", "timer": "https://timer.automate.globus.org/", "flows": "https://flows.automate.globus.org/", "compute": "https://compute.api.globus.org/", } - _base_path_map = { - "transfer": "/v0.10/", - "groups": "/v2/", - } def __init__( self, @@ -105,13 +101,6 @@ def __init__( self.service = service if service: - # strip base_paths to match the behavior of clients - # this allows a registered response to use a path like `/v2/groups` with - # the GroupsClient, rather than *requiring* that it use `/groups` - base_path = self._base_path_map.get(service) - if base_path and path.startswith(base_path): - path = path[len(base_path) :] - self.full_url = slash_join(self._url_map[service], path) else: self.full_url = path diff --git a/src/globus_sdk/_testing/registry.py b/src/globus_sdk/testing/registry.py similarity index 96% rename from src/globus_sdk/_testing/registry.py rename to src/globus_sdk/testing/registry.py index 0f9c3cee9..b575f1e0e 100644 --- a/src/globus_sdk/_testing/registry.py +++ b/src/globus_sdk/testing/registry.py @@ -85,17 +85,17 @@ def get_response_set(set_id: t.Any) -> ResponseSet: # if ID is a string, it's the (optionally dotted) name of a module if isinstance(set_id, str): - module_name = f"globus_sdk._testing.data.{set_id}" + module_name = f"globus_sdk.testing.data.{set_id}" else: assert hasattr( set_id, "__qualname__" ), f"cannot load response set from {type(set_id)}" # support modules like - # globus_sdk/_testing/data/auth/get_identities.py + # globus_sdk/testing/data/auth/get_identities.py # for lookups like # get_response_set(AuthClient.get_identities) module_name = ( - f"globus_sdk._testing.data.{_resolve_qualname(set_id.__qualname__)}" + f"globus_sdk.testing.data.{_resolve_qualname(set_id.__qualname__)}" ) # after that, check the built-in "registry" built from modules diff --git a/src/globus_sdk/tokenstorage/v2/__init__.py b/src/globus_sdk/token_storage/__init__.py similarity index 100% rename from src/globus_sdk/tokenstorage/v2/__init__.py rename to src/globus_sdk/token_storage/__init__.py diff --git a/src/globus_sdk/tokenstorage/v2/base.py b/src/globus_sdk/token_storage/base.py similarity index 100% rename from src/globus_sdk/tokenstorage/v2/base.py rename to src/globus_sdk/token_storage/base.py diff --git a/src/globus_sdk/tokenstorage/v2/json.py b/src/globus_sdk/token_storage/json.py similarity index 99% rename from src/globus_sdk/tokenstorage/v2/json.py rename to src/globus_sdk/token_storage/json.py index 122d8fb8c..0d559ab1e 100644 --- a/src/globus_sdk/tokenstorage/v2/json.py +++ b/src/globus_sdk/token_storage/json.py @@ -3,7 +3,7 @@ import json import typing as t -from globus_sdk.version import __version__ +from globus_sdk import __version__ from .base import FileTokenStorage from .token_data import TokenStorageData diff --git a/src/globus_sdk/tokenstorage/v1/__init__.py b/src/globus_sdk/token_storage/legacy/__init__.py similarity index 100% rename from src/globus_sdk/tokenstorage/v1/__init__.py rename to src/globus_sdk/token_storage/legacy/__init__.py diff --git a/src/globus_sdk/tokenstorage/v1/base.py b/src/globus_sdk/token_storage/legacy/base.py similarity index 100% rename from src/globus_sdk/tokenstorage/v1/base.py rename to src/globus_sdk/token_storage/legacy/base.py diff --git a/src/globus_sdk/tokenstorage/v1/file_adapters.py b/src/globus_sdk/token_storage/legacy/file_adapters.py similarity index 98% rename from src/globus_sdk/tokenstorage/v1/file_adapters.py rename to src/globus_sdk/token_storage/legacy/file_adapters.py index 909817143..f0113dfd1 100644 --- a/src/globus_sdk/tokenstorage/v1/file_adapters.py +++ b/src/globus_sdk/token_storage/legacy/file_adapters.py @@ -5,7 +5,7 @@ import typing as t import globus_sdk -from globus_sdk.version import __version__ +from globus_sdk import __version__ from .base import FileAdapter @@ -55,7 +55,7 @@ def _raw_load(self) -> dict[str, t.Any]: return val def _handle_formats(self, read_data: dict[str, t.Any]) -> _JSONFileData: - """Handle older data formats supported by globus_sdk.tokenstorage + """Handle older data formats supported by globus_sdk.token_storage if the data is not in a known/recognized format, this will error otherwise, reshape the data to the current supported format and return it @@ -110,7 +110,7 @@ def store(self, token_response: globus_sdk.OAuthTokenResponse) -> None: Given a token response, extract all the token data and write it to ``self.filename`` as JSON data. - Additionally will write the version of ``globus_sdk.tokenstorage`` + Additionally will write the version of ``globus_sdk.token_storage`` which was in use. Under the assumption that this may be running on a system with multiple diff --git a/src/globus_sdk/tokenstorage/v1/memory_adapter.py b/src/globus_sdk/token_storage/legacy/memory_adapter.py similarity index 100% rename from src/globus_sdk/tokenstorage/v1/memory_adapter.py rename to src/globus_sdk/token_storage/legacy/memory_adapter.py diff --git a/src/globus_sdk/tokenstorage/v1/sqlite_adapter.py b/src/globus_sdk/token_storage/legacy/sqlite_adapter.py similarity index 99% rename from src/globus_sdk/tokenstorage/v1/sqlite_adapter.py rename to src/globus_sdk/token_storage/legacy/sqlite_adapter.py index 4e441c2ce..28f08bf6e 100644 --- a/src/globus_sdk/tokenstorage/v1/sqlite_adapter.py +++ b/src/globus_sdk/token_storage/legacy/sqlite_adapter.py @@ -6,7 +6,7 @@ import typing as t import globus_sdk -from globus_sdk.version import __version__ +from globus_sdk import __version__ from .base import FileAdapter diff --git a/src/globus_sdk/tokenstorage/v2/memory.py b/src/globus_sdk/token_storage/memory.py similarity index 100% rename from src/globus_sdk/tokenstorage/v2/memory.py rename to src/globus_sdk/token_storage/memory.py diff --git a/src/globus_sdk/tokenstorage/v2/sqlite.py b/src/globus_sdk/token_storage/sqlite.py similarity index 98% rename from src/globus_sdk/tokenstorage/v2/sqlite.py rename to src/globus_sdk/token_storage/sqlite.py index 2f4b7c8db..cbd0fb7b0 100644 --- a/src/globus_sdk/tokenstorage/v2/sqlite.py +++ b/src/globus_sdk/token_storage/sqlite.py @@ -6,8 +6,7 @@ import textwrap import typing as t -from globus_sdk import exc -from globus_sdk.version import __version__ +from globus_sdk import __version__, exc from .base import FileTokenStorage from .token_data import TokenStorageData diff --git a/src/globus_sdk/tokenstorage/v2/token_data.py b/src/globus_sdk/token_storage/token_data.py similarity index 95% rename from src/globus_sdk/tokenstorage/v2/token_data.py rename to src/globus_sdk/token_storage/token_data.py index 1ddf106da..a428eb8d2 100644 --- a/src/globus_sdk/tokenstorage/v2/token_data.py +++ b/src/globus_sdk/token_storage/token_data.py @@ -2,8 +2,8 @@ import typing as t -from globus_sdk._guards import validators -from globus_sdk._serializable import Serializable +from globus_sdk._internal.guards import validators +from globus_sdk._internal.serializable import Serializable class TokenStorageData(Serializable): diff --git a/src/globus_sdk/tokenstorage/v2/validating_token_storage/__init__.py b/src/globus_sdk/token_storage/validating_token_storage/__init__.py similarity index 100% rename from src/globus_sdk/tokenstorage/v2/validating_token_storage/__init__.py rename to src/globus_sdk/token_storage/validating_token_storage/__init__.py diff --git a/src/globus_sdk/tokenstorage/v2/validating_token_storage/context.py b/src/globus_sdk/token_storage/validating_token_storage/context.py similarity index 100% rename from src/globus_sdk/tokenstorage/v2/validating_token_storage/context.py rename to src/globus_sdk/token_storage/validating_token_storage/context.py diff --git a/src/globus_sdk/tokenstorage/v2/validating_token_storage/errors.py b/src/globus_sdk/token_storage/validating_token_storage/errors.py similarity index 100% rename from src/globus_sdk/tokenstorage/v2/validating_token_storage/errors.py rename to src/globus_sdk/token_storage/validating_token_storage/errors.py diff --git a/src/globus_sdk/tokenstorage/v2/validating_token_storage/storage.py b/src/globus_sdk/token_storage/validating_token_storage/storage.py similarity index 100% rename from src/globus_sdk/tokenstorage/v2/validating_token_storage/storage.py rename to src/globus_sdk/token_storage/validating_token_storage/storage.py diff --git a/src/globus_sdk/tokenstorage/v2/validating_token_storage/validators.py b/src/globus_sdk/token_storage/validating_token_storage/validators.py similarity index 100% rename from src/globus_sdk/tokenstorage/v2/validating_token_storage/validators.py rename to src/globus_sdk/token_storage/validating_token_storage/validators.py diff --git a/src/globus_sdk/tokenstorage/__init__.py b/src/globus_sdk/tokenstorage/__init__.py deleted file mode 100644 index 2f6a81e07..000000000 --- a/src/globus_sdk/tokenstorage/__init__.py +++ /dev/null @@ -1,48 +0,0 @@ -from .v1 import ( - FileAdapter, - MemoryAdapter, - SimpleJSONFileAdapter, - SQLiteAdapter, - StorageAdapter, -) -from .v2 import ( - FileTokenStorage, - HasRefreshTokensValidator, - JSONTokenStorage, - MemoryTokenStorage, - NotExpiredValidator, - ScopeRequirementsValidator, - SQLiteTokenStorage, - TokenDataValidator, - TokenStorage, - TokenStorageData, - TokenValidationContext, - TokenValidationError, - UnchangingIdentityIDValidator, - ValidatingTokenStorage, -) - -__all__ = ( - # [v1] "StorageAdapter" Constructs - "StorageAdapter", - "FileAdapter", - "SimpleJSONFileAdapter", - "SQLiteAdapter", - "MemoryAdapter", - # [v2] "TokenStorage" Constructs - "TokenStorage", - "TokenStorageData", - "FileTokenStorage", - "JSONTokenStorage", - "SQLiteTokenStorage", - "MemoryTokenStorage", - # [v2] "ValidatingTokenStorage" Constructs - "ValidatingTokenStorage", - "TokenValidationContext", - "TokenDataValidator", - "TokenValidationError", - "HasRefreshTokensValidator", - "NotExpiredValidator", - "ScopeRequirementsValidator", - "UnchangingIdentityIDValidator", -) diff --git a/src/globus_sdk/transport/__init__.py b/src/globus_sdk/transport/__init__.py index 6d2c77e6a..d0cdd6bba 100644 --- a/src/globus_sdk/transport/__init__.py +++ b/src/globus_sdk/transport/__init__.py @@ -1,23 +1,29 @@ from ._clientinfo import GlobusClientInfo +from .caller_info import RequestCallerInfo from .encoders import FormRequestEncoder, JSONRequestEncoder, RequestEncoder from .requests import RequestsTransport from .retry import ( RetryCheck, + RetryCheckCollection, RetryCheckFlags, RetryCheckResult, - RetryCheckRunner, RetryContext, set_retry_check_flags, ) +from .retry_check_runner import RetryCheckRunner +from .retry_config import RetryConfig __all__ = ( "RequestsTransport", + "RequestCallerInfo", "RetryCheck", + "RetryCheckCollection", "RetryCheckFlags", "RetryCheckResult", "RetryCheckRunner", "set_retry_check_flags", "RetryContext", + "RetryConfig", "RequestEncoder", "JSONRequestEncoder", "FormRequestEncoder", diff --git a/src/globus_sdk/transport/_clientinfo.py b/src/globus_sdk/transport/_clientinfo.py index c7d9078f2..8a0383862 100644 --- a/src/globus_sdk/transport/_clientinfo.py +++ b/src/globus_sdk/transport/_clientinfo.py @@ -10,8 +10,7 @@ import typing as t -from globus_sdk import exc -from globus_sdk.version import __version__ +from globus_sdk import __version__, exc _RESERVED_CHARS = ";,=" diff --git a/src/globus_sdk/transport/caller_info.py b/src/globus_sdk/transport/caller_info.py new file mode 100644 index 000000000..b48bc573e --- /dev/null +++ b/src/globus_sdk/transport/caller_info.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from globus_sdk.authorizers import GlobusAuthorizer + +from .retry_config import RetryConfig + + +class RequestCallerInfo: + """ + Data object that holds contextual information about the caller of a request. + + :param retry_config: The configuration of retry checks for the call + :param authorizer: The authorizer object from the client making the request + """ + + def __init__( + self, + *, + retry_config: RetryConfig, + authorizer: GlobusAuthorizer | None = None, + ) -> None: + self.authorizer = authorizer + self.retry_config = retry_config diff --git a/src/globus_sdk/transport/default_retry_checks.py b/src/globus_sdk/transport/default_retry_checks.py new file mode 100644 index 000000000..b9d1129f6 --- /dev/null +++ b/src/globus_sdk/transport/default_retry_checks.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import requests + +from .retry import ( + RetryCheck, + RetryCheckFlags, + RetryCheckResult, + RetryContext, + set_retry_check_flags, +) + + +def check_request_exception(ctx: RetryContext) -> RetryCheckResult: + """ + Check if a network error was encountered + + :param ctx: The context object which describes the state of the request and the + retries which may already have been attempted. + """ + if ctx.exception and isinstance(ctx.exception, requests.RequestException): + return RetryCheckResult.do_retry + return RetryCheckResult.no_decision + + +def check_retry_after_header(ctx: RetryContext) -> RetryCheckResult: + """ + Check for a retry-after header if the response had a matching status + + :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 None or ( + ctx.response.status_code not in retry_config.retry_after_status_codes + ): + return RetryCheckResult.no_decision + retry_after = _parse_retry_after(ctx.response) + if retry_after: + ctx.backoff = float(retry_after) + return RetryCheckResult.do_retry + + +def check_transient_error(ctx: RetryContext) -> RetryCheckResult: + """ + Check for transient error status codes which could be resolved by retrying + the request + + :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 + ): + return RetryCheckResult.do_retry + return RetryCheckResult.no_decision + + +@set_retry_check_flags(RetryCheckFlags.RUN_ONCE) +def check_expired_authorization(ctx: RetryContext) -> RetryCheckResult: + """ + This check evaluates whether or not there is invalid or expired authorization + information which could be updated with some action -- most typically a token + refresh for an expired access token. + + The check is flagged to only run once per request. + + :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 ( # is the current check applicable? + ctx.response is None + or ctx.caller_info is None + or ctx.caller_info.authorizer is None + or ( + ctx.response.status_code + not in retry_config.expired_authorization_status_codes + ) + ): + return RetryCheckResult.no_decision + + # run the authorizer's handler, and 'do_retry' if the handler indicated + # that it was able to make a change which should make the request retryable + if ctx.caller_info.authorizer.handle_missing_authorization(): + return RetryCheckResult.do_retry + return RetryCheckResult.no_decision + + +def _parse_retry_after(response: requests.Response) -> int | None: + """ + Get the 'Retry-After' header as an int. + + :param response: The response to parse. + """ + val = response.headers.get("Retry-After") + if not val: + return None + try: + return int(val) + except ValueError: + return None + + +DEFAULT_RETRY_CHECKS: tuple[RetryCheck, ...] = ( + check_expired_authorization, + check_request_exception, + check_retry_after_header, + check_transient_error, +) diff --git a/src/globus_sdk/transport/encoders.py b/src/globus_sdk/transport/encoders.py index 95b331458..5052f47e9 100644 --- a/src/globus_sdk/transport/encoders.py +++ b/src/globus_sdk/transport/encoders.py @@ -6,7 +6,7 @@ import requests -from globus_sdk import utils +from globus_sdk._missing import MISSING, filter_missing class RequestEncoder: @@ -66,9 +66,7 @@ def _prepare_params( """ if params is None: return None - return utils.filter_missing( - {k: self._format_primitive(v) for k, v in params.items()} - ) + return filter_missing({k: self._format_primitive(v) for k, v in params.items()}) def _prepare_headers( self, headers: dict[str, t.Any] | None @@ -80,7 +78,7 @@ def _prepare_headers( """ if headers is None: return None - return utils.filter_missing( + return filter_missing( {k: self._format_primitive(v) for k, v in headers.items()} ) @@ -88,17 +86,15 @@ def _prepare_data(self, data: t.Any) -> t.Any: """ Prepare the data (body) for a request. - If the body is a dict or PayloadWrapper, it will be recursively processed to + If the body is a dict, list, or tuple, it will be recursively processed to filter out MISSING and format primitives. Otherwise, it is returned as-is. """ - if isinstance(data, (dict, utils.PayloadWrapper)): - return utils.filter_missing( - {k: self._prepare_data(v) for k, v in data.items()} - ) + if isinstance(data, dict): + return filter_missing({k: self._prepare_data(v) for k, v in data.items()}) elif isinstance(data, (list, tuple)): - return [self._prepare_data(x) for x in data if x is not utils.MISSING] + return [self._prepare_data(x) for x in data if x is not MISSING] else: return self._format_primitive(data) @@ -142,7 +138,7 @@ def encode( data: t.Any, headers: dict[str, str], ) -> requests.Request: - if not isinstance(data, (dict, utils.PayloadWrapper)): + if not isinstance(data, dict): raise TypeError("FormRequestEncoder cannot encode non-dict data") return requests.Request( method, diff --git a/src/globus_sdk/transport/requests.py b/src/globus_sdk/transport/requests.py index 3712ae6cd..986bf9b3d 100644 --- a/src/globus_sdk/transport/requests.py +++ b/src/globus_sdk/transport/requests.py @@ -3,52 +3,28 @@ import contextlib import logging import pathlib -import random import time import typing as t import requests -from globus_sdk import config, exc, utils +from globus_sdk import __version__, config, exc from globus_sdk.authorizers import GlobusAuthorizer from globus_sdk.transport.encoders import ( FormRequestEncoder, JSONRequestEncoder, RequestEncoder, ) -from globus_sdk.version import __version__ from ._clientinfo import GlobusClientInfo -from .retry import ( - RetryCheck, - RetryCheckFlags, - RetryCheckResult, - RetryCheckRunner, - RetryContext, - set_retry_check_flags, -) +from .caller_info import RequestCallerInfo +from .retry import RetryContext +from .retry_check_runner import RetryCheckRunner +from .retry_config import RetryConfig log = logging.getLogger(__name__) -def _parse_retry_after(response: requests.Response) -> int | None: - val = response.headers.get("Retry-After") - if not val: - return None - try: - return int(val) - except ValueError: - return None - - -def _exponential_backoff(ctx: RetryContext) -> float: - # respect any explicit backoff set on the context - if ctx.backoff is not None: - return ctx.backoff - # exponential backoff with jitter - return t.cast(float, (0.25 + 0.5 * random.random()) * (2**ctx.attempt)) - - class RequestsTransport: """ The RequestsTransport handles HTTP request sending and retries. @@ -56,13 +32,9 @@ class RequestsTransport: It receives raw request information from a client class, and then performs the following steps - encode the data in a prepared request - - repeatedly send the request until no retry is requested + - repeatedly send the request until no retry is requested by the configured hooks - return the last response or reraise the last exception - Retry checks are registered as hooks on the Transport. Additional hooks can be - passed to the constructor via `retry_checks`. Or hooks can be added to an existing - transport via a decorator. - If the maximum number of retries is reached, the final response or exception will be returned or raised. @@ -72,15 +44,6 @@ class RequestsTransport: defaults to 60s but can be set via the ``GLOBUS_SDK_HTTP_TIMEOUT`` environment variable. Any value set via this parameter takes precedence over the environment variable. - :param retry_backoff: A function which determines how long to sleep between calls - based on the RetryContext. Defaults to exponential backoff with jitter based on - the context ``attempt`` number. - :param retry_checks: A list of initial retry checks. Any hooks registered, - including the default hooks, will run after these checks. - :param max_sleep: The maximum sleep time between retries (in seconds). If the - computed sleep time or the backoff requested by a retry check exceeds this - value, this amount of time will be used instead - :param max_retries: The maximum number of retries allowed by this transport :ivar dict[str, str] headers: The headers which are sent on every request. These may be augmented by the transport when sending requests. @@ -89,13 +52,6 @@ class RequestsTransport: #: default maximum number of retries DEFAULT_MAX_RETRIES = 5 - #: status codes for responses which may have a Retry-After header - RETRY_AFTER_STATUS_CODES: tuple[int, ...] = (429, 503) - #: status codes for error responses which should generally be retried - TRANSIENT_ERROR_STATUS_CODES: tuple[int, ...] = (429, 500, 502, 503, 504) - #: status codes indicating that authorization info was missing or expired - EXPIRED_AUTHORIZATION_STATUS_CODES: tuple[int, ...] = (401,) - #: the encoders are a mapping of encoding names to encoder objects encoders: dict[str, RequestEncoder] = { "text": RequestEncoder(), @@ -109,10 +65,6 @@ def __init__( self, verify_ssl: bool | str | pathlib.Path | None = None, http_timeout: float | None = None, - retry_backoff: t.Callable[[RetryContext], float] = _exponential_backoff, - retry_checks: list[RetryCheck] | None = None, - max_sleep: float | int = 10, - max_retries: int | None = None, ) -> None: self.session = requests.Session() self.verify_ssl = config.get_ssl_verify(verify_ssl) @@ -127,16 +79,6 @@ def __init__( "X-Globus-Client-Info": self.globus_client_info.format(), } - # retry parameters - self.retry_backoff = retry_backoff - self.max_sleep = max_sleep - self.max_retries = ( - max_retries if max_retries is not None else self.DEFAULT_MAX_RETRIES - ) - self.retry_checks = list(retry_checks if retry_checks else []) # copy - # register internal checks - self.register_default_retry_checks() - def close(self) -> None: """ Closes all resources owned by the transport, primarily the underlying @@ -181,29 +123,17 @@ def tune( *, verify_ssl: bool | str | pathlib.Path | None = None, http_timeout: float | None = None, - retry_backoff: t.Callable[[RetryContext], float] | None = None, - max_sleep: float | int | None = None, - max_retries: int | None = None, ) -> t.Iterator[None]: """ Temporarily adjust some of the request sending settings of the transport. This method works as a context manager, and will reset settings to their original values after it exits. - In particular, this can be used to temporarily adjust request-sending minutiae - like the ``http_timeout`` used. - :param verify_ssl: Explicitly enable or disable SSL verification, or configure the path to a CA certificate bundle to use for SSL verification :param http_timeout: Explicitly set an HTTP timeout value in seconds - :param retry_backoff: A function which determines how long to sleep between - calls based on the RetryContext - :param max_sleep: The maximum sleep time between retries (in seconds). If the - computed sleep time or the backoff requested by a retry check exceeds this - value, this amount of time will be used instead - :param max_retries: The maximum number of retries allowed by this transport - **Examples** + **Example Usage** This can be used with any client class to temporarily set values in the context of one or more HTTP requests. To increase the HTTP request timeout from the @@ -213,19 +143,11 @@ def tune( >>> with client.transport.tune(http_timeout=120): >>> foo = client.get_foo() - or to disable retries (note that this also disables the retry on - expired-and-refreshed credentials): - - >>> client = ... # any client class - >>> with client.transport.tune(max_retries=0): - >>> foo = client.get_foo() + See also: :meth:`RetryConfig.tune`. """ saved_settings = ( self.verify_ssl, self.http_timeout, - self.retry_backoff, - self.max_sleep, - self.max_retries, ) if verify_ssl is not None: if isinstance(verify_ssl, bool): @@ -234,19 +156,10 @@ def tune( self.verify_ssl = str(verify_ssl) if http_timeout is not None: self.http_timeout = http_timeout - if retry_backoff is not None: - self.retry_backoff = retry_backoff - if max_sleep is not None: - self.max_sleep = max_sleep - if max_retries is not None: - self.max_retries = max_retries yield ( self.verify_ssl, self.http_timeout, - self.retry_backoff, - self.max_sleep, - self.max_retries, ) = saved_settings def _encode( @@ -254,9 +167,7 @@ def _encode( method: str, url: str, query_params: dict[str, t.Any] | None = None, - data: ( - dict[str, t.Any] | list[t.Any] | utils.PayloadWrapper | str | bytes | None - ) = None, + data: dict[str, t.Any] | list[t.Any] | str | bytes | None = None, headers: dict[str, str] | None = None, encoding: str | None = None, ) -> requests.Request: @@ -288,7 +199,7 @@ def _set_authz_header( else: req.headers.pop("Authorization", None) # remove any possible value - def _retry_sleep(self, ctx: RetryContext) -> None: + def _retry_sleep(self, retry_config: RetryConfig, ctx: RetryContext) -> None: """ Given a retry context, compute the amount of time to sleep and sleep that much This is always the minimum of the backoff (run on the context) and the @@ -297,21 +208,24 @@ def _retry_sleep(self, ctx: RetryContext) -> None: :param ctx: The context object which describes the state of the request and the retries which may already have been attempted. """ - sleep_period = min(self.retry_backoff(ctx), self.max_sleep) - log.debug("request retry_sleep(%s) [max=%s]", sleep_period, self.max_sleep) + sleep_period = min(retry_config.backoff(ctx), retry_config.max_sleep) + log.debug( + "request retry_sleep(%s) [max=%s]", + sleep_period, + retry_config.max_sleep, + ) time.sleep(sleep_period) def request( self, method: str, url: str, + *, + caller_info: RequestCallerInfo, query_params: dict[str, t.Any] | None = None, - data: ( - dict[str, t.Any] | list[t.Any] | utils.PayloadWrapper | str | bytes | None - ) = None, + data: dict[str, t.Any] | list[t.Any] | str | bytes | None = None, headers: dict[str, str] | None = None, encoding: str | None = None, - authorizer: GlobusAuthorizer | None = None, allow_redirects: bool = True, stream: bool = False, ) -> requests.Response: @@ -320,6 +234,8 @@ def request( :param url: URL for the request :param method: HTTP request method, as an all caps string + :param caller_info: Contextual information about the caller of the request, + including the authorizer and retry configuration. :param query_params: Parameters to be encoded as a query string :param headers: HTTP headers to add to the request :param data: Data to send as the request body. May pass through encoding. @@ -327,8 +243,6 @@ def request( are all valid values. Custom encodings can be used only if they are registered with the transport. By default, strings get "text" behavior and all other objects get "json". - :param authorizer: The authorizer which is used to get or update authorization - information for the request :param allow_redirects: Follow Location headers on redirect response automatically. Defaults to ``True`` :param stream: Do not immediately download the response content. Defaults to @@ -339,16 +253,18 @@ def request( log.debug("starting request for %s", url) resp: requests.Response | None = None req = self._encode(method, url, query_params, data, headers, encoding) - checker = RetryCheckRunner(self.retry_checks) + retry_config = caller_info.retry_config + checker = RetryCheckRunner(caller_info.retry_config.checks) + log.debug("transport request state initialized") - for attempt in range(self.max_retries + 1): + for attempt in range(retry_config.max_retries + 1): log.debug("transport request retry cycle. attempt=%d", attempt) # add Authorization header, or (if it's a NullAuthorizer) possibly # explicitly remove the Authorization header # done fresh for each request, to handle potential for refreshed credentials - self._set_authz_header(authorizer, req) + self._set_authz_header(caller_info.authorizer, req) - ctx = RetryContext(attempt, authorizer=authorizer) + ctx = RetryContext(attempt, caller_info=caller_info) try: log.debug("request about to send") resp = ctx.response = self.session.send( @@ -361,7 +277,7 @@ def request( except requests.RequestException as err: log.debug("request hit error (RequestException)") ctx.exception = err - if attempt >= self.max_retries or not checker.should_retry(ctx): + if attempt >= retry_config.max_retries or not checker.should_retry(ctx): log.warning("request done (fail, error)") raise exc.convert_request_exception(err) log.debug("request may retry (should-retry=true)") @@ -373,113 +289,10 @@ def request( log.debug("request may retry, will check attempts") # the request will be retried, so sleep... - if attempt < self.max_retries: + if attempt < retry_config.max_retries: log.debug("under attempt limit, will sleep") - self._retry_sleep(ctx) + self._retry_sleep(retry_config, ctx) if resp is None: raise ValueError("Somehow, retries ended without a response") log.warning("request reached max retries, done (fail, response)") return resp - - # decorator which lets you add a check to a retry policy - def register_retry_check(self, func: RetryCheck) -> RetryCheck: - """ - Register a retry check with this transport. - - A retry checker is a callable responsible for implementing - `check(RetryContext) -> RetryCheckResult` - - `check` should *not* perform any sleeps or delays. - Multiple checks should be chainable and callable in any order. - - :param func: The function or other callable to register as a retry check - """ - self.retry_checks.append(func) - return func - - def register_default_retry_checks(self) -> None: - """ - This hook is called during transport initialization. By default, it registers - the following hooks: - - - default_check_expired_authorization - - default_check_request_exception - - default_check_retry_after_header - - default_check_transient_error - - It can be overridden to register additional hooks or to remove the default - hooks. - """ - self.register_retry_check(self.default_check_expired_authorization) - self.register_retry_check(self.default_check_request_exception) - self.register_retry_check(self.default_check_retry_after_header) - self.register_retry_check(self.default_check_transient_error) - - def default_check_request_exception(self, ctx: RetryContext) -> RetryCheckResult: - """ - Check if a network error was encountered - - :param ctx: The context object which describes the state of the request and the - retries which may already have been attempted. - """ - if ctx.exception and isinstance(ctx.exception, requests.RequestException): - return RetryCheckResult.do_retry - return RetryCheckResult.no_decision - - def default_check_retry_after_header(self, ctx: RetryContext) -> RetryCheckResult: - """ - Check for a retry-after header if the response had a matching status - - :param ctx: The context object which describes the state of the request and the - retries which may already have been attempted. - """ - if ( - ctx.response is None - or ctx.response.status_code not in self.RETRY_AFTER_STATUS_CODES - ): - return RetryCheckResult.no_decision - retry_after = _parse_retry_after(ctx.response) - if retry_after: - ctx.backoff = float(retry_after) - return RetryCheckResult.do_retry - - def default_check_transient_error(self, ctx: RetryContext) -> RetryCheckResult: - """ - Check for transient error status codes which could be resolved by retrying - the request - - :param ctx: The context object which describes the state of the request and the - retries which may already have been attempted. - """ - if ctx.response is not None and ( - ctx.response.status_code in self.TRANSIENT_ERROR_STATUS_CODES - ): - return RetryCheckResult.do_retry - return RetryCheckResult.no_decision - - @set_retry_check_flags(RetryCheckFlags.RUN_ONCE) - def default_check_expired_authorization( - self, ctx: RetryContext - ) -> RetryCheckResult: - """ - This check evaluates whether or not there is invalid or expired authorization - information which could be updated with some action -- most typically a token - refresh for an expired access token. - - The check is flagged to only run once per request. - - :param ctx: The context object which describes the state of the request and the - retries which may already have been attempted. - """ - if ( # is the current check applicable? - ctx.response is None - or ctx.authorizer is None - or ctx.response.status_code not in self.EXPIRED_AUTHORIZATION_STATUS_CODES - ): - return RetryCheckResult.no_decision - - # run the authorizer's handler, and 'do_retry' if the handler indicated - # that it was able to make a change which should make the request retryable - if ctx.authorizer.handle_missing_authorization(): - return RetryCheckResult.do_retry - return RetryCheckResult.no_decision diff --git a/src/globus_sdk/transport/retry.py b/src/globus_sdk/transport/retry.py index fc2a39523..08d46e70d 100644 --- a/src/globus_sdk/transport/retry.py +++ b/src/globus_sdk/transport/retry.py @@ -1,18 +1,20 @@ from __future__ import annotations import enum -import logging import typing as t import requests -from globus_sdk.authorizers import GlobusAuthorizer - -log = logging.getLogger(__name__) +if t.TYPE_CHECKING: + from .caller_info import RequestCallerInfo C = t.TypeVar("C", bound=t.Callable[..., t.Any]) +# alias useful for declaring retry-related types +RetryCheck = t.Callable[["RetryContext"], "RetryCheckResult"] + + class RetryContext: """ The RetryContext is an object passed to retry checks in order to determine whether @@ -24,23 +26,23 @@ class RetryContext: or ``exception`` will be present. :param attempt: The request attempt number, starting at 0. + :param caller_info: Contextual information about the caller, including authorizer :param response: The response on a successful request :param exception: The error raised when trying to send the request - :param authorizer: The authorizer object from the client making the request """ def __init__( self, attempt: int, *, - authorizer: GlobusAuthorizer | None = None, + caller_info: RequestCallerInfo, response: requests.Response | None = None, exception: Exception | None = None, ) -> None: # retry attempt number self.attempt = attempt - # if there is an authorizer for the request, it will be available in the context - self.authorizer = authorizer + # caller info provides contextual information about the request + self.caller_info = caller_info # the response or exception from a request # we expect exactly one of these to be non-null self.response = response @@ -89,60 +91,52 @@ def decorator(func: C) -> C: return decorator -# types useful for declaring RetryCheckRunner and related types -RetryCheck = t.Callable[[RetryContext], RetryCheckResult] +class RetryCheckCollection: + """ + A RetryCheckCollection is an ordered collection of retry checks which are + used to determine whether or not a request should be retried. + + Checks are stored in registration order. + Notably, the collection does not decide + - how many times a request should retry + - how or how long the call should wait between attempts + (except via the backoff which may be set) + - what kinds of request parameters (e.g., timeouts) are used -class RetryCheckRunner: + It *only* contains ``RetryCheck`` functions which can look at a response or + error and decide whether or not to retry. """ - A RetryCheckRunner is an object responsible for running retry checks over the - lifetime of a request. Unlike the checks or the retry context, the runner persists - between retries. It can therefore implement special logic for checks like "only try - this check once". - Its primary responsibility is to answer the question "should_retry(context)?" with a - boolean. + def __init__(self) -> None: + self._data: list[RetryCheck] = [] - It takes as its input a list of checks. Checks may be paired with flags to indicate - their configuration options. When not paired with flags, the flags are taken to be - "NONE". + def register_check(self, func: RetryCheck) -> RetryCheck: + """ + Register a retry check with this policy. - Supported flags: + A retry checker is a callable responsible for implementing + `check(RetryContext) -> RetryCheckResult` - ``RUN_ONCE`` - The check will run at most once for a given request. Once it has run, it is - recorded as "has_run" and will not be run again on that request. - """ + `check` should *not* perform any sleeps or delays. + Multiple checks should be chainable and callable in any order. + + :param func: The function or other callable to register as a retry check + """ + self._data.append(func) + return func + + def register_many_checks(self, funcs: t.Iterable[RetryCheck]) -> None: + """ + Register all checks in a collection of checks. + + :param funcs: An iterable collection of retry check callables + """ + for f in funcs: + self.register_check(f) + + def __iter__(self) -> t.Iterator[RetryCheck]: + yield from self._data - # check configs: a list of pairs, (check, flags) - # a check without flags is assumed to have flags=NONE - def __init__(self, checks: list[RetryCheck]) -> None: - self._checks: list[RetryCheck] = [] - self._check_data: dict[RetryCheck, dict[str, t.Any]] = {} - for check in checks: - self._checks.append(check) - self._check_data[check] = {} - - def should_retry(self, context: RetryContext) -> bool: - for check in self._checks: - flags = getattr(check, "_retry_check_flags", RetryCheckFlags.NONE) - - if flags & RetryCheckFlags.RUN_ONCE: - if self._check_data[check].get("has_run"): - continue - else: - self._check_data[check]["has_run"] = True - - result = check(context) - log.debug( # try to get name but don't fail if it's not a function... - "ran retry check (%s) => %s", getattr(check, "__name__", check), result - ) - if result is RetryCheckResult.no_decision: - continue - elif result is RetryCheckResult.do_not_retry: - return False - else: - return True - - # fallthrough: don't retry any request which isn't marked for retry - return False + def __len__(self) -> int: + return len(self._data) diff --git a/src/globus_sdk/transport/retry_check_runner.py b/src/globus_sdk/transport/retry_check_runner.py new file mode 100644 index 000000000..4d59edc58 --- /dev/null +++ b/src/globus_sdk/transport/retry_check_runner.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import logging +import typing as t + +from .retry import RetryCheck, RetryCheckFlags, RetryCheckResult, RetryContext + +log = logging.getLogger(__name__) + + +class RetryCheckRunner: + """ + A RetryCheckRunner is an object responsible for running retry checks over the + lifetime of a request. Unlike the checks or the retry context, the runner persists + between retries. It can therefore implement special logic for checks like "only try + this check once". + + Its primary responsibility is to answer the question "should_retry(context)?" with a + boolean. + + It takes as its input a list of checks. Checks may be paired with flags to indicate + their configuration options. When not paired with flags, the flags are taken to be + "NONE". + + Supported flags: + + ``RUN_ONCE`` + The check will run at most once for a given request. Once it has run, it is + recorded as "has_run" and will not be run again on that request. + """ + + # check configs: a list of pairs, (check, flags) + # a check without flags is assumed to have flags=NONE + def __init__(self, checks: t.Iterable[RetryCheck]) -> None: + self._checks: list[RetryCheck] = [] + self._check_data: dict[RetryCheck, dict[str, t.Any]] = {} + for check in checks: + self._checks.append(check) + self._check_data[check] = {} + + def should_retry(self, context: RetryContext) -> bool: + for check in self._checks: + flags = getattr(check, "_retry_check_flags", RetryCheckFlags.NONE) + + if flags & RetryCheckFlags.RUN_ONCE: + if self._check_data[check].get("has_run"): + continue + else: + self._check_data[check]["has_run"] = True + + result = check(context) + log.debug( # try to get name but don't fail if it's not a function... + "ran retry check (%s) => %s", getattr(check, "__name__", check), result + ) + if result is RetryCheckResult.no_decision: + continue + elif result is RetryCheckResult.do_not_retry: + return False + else: + return True + + # fallthrough: don't retry any request which isn't marked for retry + return False diff --git a/src/globus_sdk/transport/retry_config.py b/src/globus_sdk/transport/retry_config.py new file mode 100644 index 000000000..ff458865b --- /dev/null +++ b/src/globus_sdk/transport/retry_config.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import contextlib +import dataclasses +import random +import typing as t + +from .retry import RetryCheckCollection, RetryContext + + +def _exponential_backoff(ctx: RetryContext) -> float: + # respect any explicit backoff set on the context + if ctx.backoff is not None: + return ctx.backoff + # exponential backoff with jitter + return t.cast(float, (0.25 + 0.5 * random.random()) * (2**ctx.attempt)) + + +@dataclasses.dataclass +class RetryConfig: + """ + Configuration for a client which is going to retry requests. + + :param max_retries: The maximum number of retries allowed. + :param max_sleep: The maximum sleep time between retries (in seconds). If the + computed sleep time or the backoff requested by a retry check exceeds this + value, this amount of time will be used instead. + :param backoff: A function which determines how long to sleep between calls + based on the RetryContext. Defaults to exponential backoff with jitter based on + the context ``attempt`` number. + :param retry_after_status_codes: HTTP status codes for responses which may have + a Retry-After header. + :param transient_error_status_codes: HTTP status codes for error responses which + should generally be retried. + :param expired_authorization_status_codes: HTTP status codes indicating that + authorization info was missing or expired. + :param checks: The check callbacks which will run in order to evaluate + responses and exceptions, as a ``RetryCheckCollection``. + """ + + max_retries: int = 5 + max_sleep: float | int = 10 + backoff: t.Callable[[RetryContext], float] = _exponential_backoff + retry_after_status_codes: tuple[int, ...] = (429, 503) + transient_error_status_codes: tuple[int, ...] = (429, 500, 502, 503, 504) + expired_authorization_status_codes: tuple[int, ...] = (401,) + + checks: RetryCheckCollection = dataclasses.field( + default_factory=RetryCheckCollection + ) + + @contextlib.contextmanager + def tune( + self, + *, + backoff: t.Callable[[RetryContext], float] | None = None, + max_sleep: float | int | None = None, + max_retries: int | None = None, + ) -> t.Iterator[None]: + """ + Temporarily adjust some of the request retry settings. + This method works as a context manager, and will reset settings to their + original values after it exits. + + :param backoff: A function which determines how long to sleep between + calls based on the RetryContext + :param max_sleep: The maximum sleep time between retries (in seconds). If the + computed sleep time or the backoff requested by a retry check exceeds this + value, this amount of time will be used instead + :param max_retries: The maximum number of retries allowed by this transport + + **Example Usage** + + This can be used with any client class to temporarily set values in the context + of one or more HTTP requests. For example, to disable retries: + + >>> client = ... # any client class + >>> with client.retry_config.tune(max_retries=0): + >>> foo = client.get_foo() + + See also: :meth:`RequestsTransport.tune`. + """ + saved_settings = ( + self.backoff, + self.max_sleep, + self.max_retries, + ) + if backoff is not None: + self.backoff = backoff + if max_sleep is not None: + self.max_sleep = max_sleep + if max_retries is not None: + self.max_retries = max_retries + yield + ( + self.backoff, + self.max_sleep, + self.max_retries, + ) = saved_settings diff --git a/src/globus_sdk/utils.py b/src/globus_sdk/utils.py deleted file mode 100644 index b87b151b3..000000000 --- a/src/globus_sdk/utils.py +++ /dev/null @@ -1,276 +0,0 @@ -from __future__ import annotations - -import collections -import collections.abc -import hashlib -import os -import platform -import sys -import typing as t -import uuid -from base64 import b64encode - -T = t.TypeVar("T") -R = t.TypeVar("R") - -if t.TYPE_CHECKING: - # pylint: disable=unsubscriptable-object - PayloadWrapperBase = collections.UserDict[str, t.Any] -else: - PayloadWrapperBase = collections.UserDict - - -class MissingType: - def __init__(self) -> None: - # disable instantiation, but gated to be able to run once - # when this module is imported - if "MISSING" in globals(): - raise TypeError("MissingType should not be instantiated") - - def __bool__(self) -> bool: - return False - - def __copy__(self) -> MissingType: - return self - - def __deepcopy__(self, memo: dict[int, t.Any]) -> MissingType: - return self - - # unpickling a MissingType should always return the "MISSING" sentinel - def __reduce__(self) -> str: - return "MISSING" - - def __repr__(self) -> str: - return "" - - -# a sentinel value for "missing" values which are distinguished from `None` (null) -# this is the default used to indicate that a parameter was not passed, so that -# method calls passing `None` can be distinguished from those which did not pass any -# value -# users should typically not use this value directly, but it is part of the public SDK -# interfaces along with its type for annotation purposes -# -# *new in version 3.30.0* -MISSING = MissingType() - - -def filter_missing(data: dict[str, t.Any] | None) -> dict[str, t.Any] | None: - if data is None: - return None - return {k: v for k, v in data.items() if v is not MISSING} - - -def sha256_string(s: str) -> str: - return hashlib.sha256(s.encode("utf-8")).hexdigest() - - -def b64str(s: str) -> str: - return b64encode(s.encode("utf-8")).decode("utf-8") - - -def get_nice_hostname() -> str | None: - """ - Get the current hostname, with the following added behavior: - - - if it ends in '.local', strip that suffix, as this is a frequent macOS behavior - 'DereksCoolMacbook.local' -> 'DereksCoolMacbook' - - - if the hostname is undiscoverable, return None - """ - name = platform.node() - if name.endswith(".local"): - return name[: -len(".local")] - return name or None - - -def slash_join(a: str, b: str | None) -> str: - """ - Join a and b with a single slash, regardless of whether they already - contain a trailing/leading slash or neither. - - :param a: the first path component - :param b: the second path component - """ - if not b: # "" or None, don't append a slash - return a - if a.endswith("/"): - if b.startswith("/"): - return a[:-1] + b - return a + b - if b.startswith("/"): - return a + b - return a + "/" + b - - -def safe_strseq_iter( - value: t.Iterable[t.Any] | str | uuid.UUID, -) -> t.Iterator[str]: - """ - Given an Iterable (typically of strings), produce an iterator over it of strings. - - :param value: The stringifiable object or objects to iterate over - - This is a passthrough with some caveats: - - if the value is a solitary string, yield only that value - - if the value is a solitary UUID, yield only that value (as a string) - - str values in the iterable which are not strings - - This helps handle cases where a string is passed to a function expecting an iterable - of strings, as well as cases where an iterable of UUID objects is accepted for a - list of IDs, or something similar. - """ - if isinstance(value, str): - yield value - elif isinstance(value, uuid.UUID): - yield str(value) - else: - for x in value: - yield str(x) - - -def commajoin(val: uuid.UUID | str | t.Iterable[uuid.UUID | str]) -> str: - # note that this explicit handling of Iterable allows for string-like objects to be - # passed to this function and be stringified by the `str()` call - if isinstance(val, collections.abc.Iterable): - return ",".join(safe_strseq_iter(val)) - return str(val) - - -class PayloadWrapper(PayloadWrapperBase): - """ - A class for defining helper objects which wrap some kind of "payload" dict. - Typical for helper objects which formulate a request payload, e.g. as JSON. - - Payload types inheriting from this class can be passed directly to the client - ``post()``, ``put()``, and ``patch()`` methods instead of a dict. These methods will - recognize a ``PayloadWrapper`` and convert it to a dict for serialization with the - requested encoder (e.g. as a JSON request body). - """ - - # use UserDict rather than subclassing dict so that our API is always consistent - # e.g. `dict.pop` does not invoke `dict.__delitem__`. Overriding `__delitem__` on a - # dict subclass can lead to inconsistent behavior between usages like these: - # x = d["k"]; del d["k"] - # x = d.pop("k") - # - # UserDict inherits from MutableMapping and only defines the dunder methods, so - # changing its behavior safely/consistently is simpler - - # - # internal helpers for setting non-null values - # - - def _set_value( - self, - key: str, - val: t.Any, - callback: t.Callable[[t.Any], t.Any] | None = None, - ) -> None: - """ - Internal helper for setting an omittable value on the payload. - - If the value is non-None, it will be set and the callback (if provided) will be - invoked on it. - Otherwise, it will be ignored and the callback will not be invoked. - - :param key: The key to set. - :param val: The value to set. - :param callback: An optional callback to apply to the value immediately - before it is set. - """ - if val is not None: - self[key] = callback(val) if callback else val - - def _set_optstrs(self, **kwargs: t.Any) -> None: - """ - Convenience function for setting a collection of omittable string values. - - Values are converted to strings prior to assignment. - """ - for k, v in kwargs.items(): - self._set_value(k, v, callback=str) - - def _set_optstrlists(self, **kwargs: t.Iterable[t.Any] | None) -> None: - """ - Convenience function for setting a collection of omittable string list values. - - Values are converted to lists of strings prior to assignment. - """ - for k, v in kwargs.items(): - self._set_value(k, v, callback=lambda x: list(safe_strseq_iter(x))) - - def _set_optbools(self, **kwargs: bool | None) -> None: - """ - Convenience function for setting a collection of omittable bool values. - - Values are converted to bools prior to assignment. - """ - for k, v in kwargs.items(): - self._set_value(k, v, callback=bool) - - def _set_optints(self, **kwargs: t.Any) -> None: - """ - Convenience function for setting a collection of omittable int values. - - Values are converted to ints prior to assignment. - """ - for k, v in kwargs.items(): - self._set_value(k, v, callback=int) - - -def in_sphinx_build() -> bool: # pragma: no cover - # check if `sphinx-build` was used to invoke - return os.path.basename(sys.argv[0]) in ["sphinx-build", "sphinx-build.exe"] - - -class _classproperty(t.Generic[T, R]): - """ - WARNING: for internal use only. - Everything in `globus_sdk.utils` is meant to be internal only, but that holds - for this class **in particular**. - - This is a well-typed Generic Descriptor which can be used to wrap decorated - functions. Usage should be: - - @utils.classproperty - def foo(self_or_cls): ... - - Note that this descriptor will pass an instance (self) if possible, and the - class (cls) only if there is no instance. This is unlike ``classmethod``. - - For more guidance on how this works, see the python3 descriptor guide: - https://docs.python.org/3/howto/descriptor.html#properties - """ - - def __init__(self, func: t.Callable[[type[T]], R]) -> None: - self.func = func - - def __get__(self, obj: t.Any, cls: type[T]) -> R: - # NOTE: our __get__ here prefers the object over the class when possible - # although well-defined behavior for a descriptor, this contradicts the - # expectation that developers may have from `classmethod` - if obj is None: - return self.func(cls) - return self.func(obj) - - -# if running under sphinx, define this as the stacked classmethod(property(...)) -# decoration, so that proper autodoc generation happens -# this is based on the python3.9 behavior which supported stacking these decorators -# however, that support was pulled in 3.10 and is not going to be reintroduced at -# present -# therefore, this sphinx behavior may not be stable in the long term -if in_sphinx_build(): # pragma: no cover - - def classproperty(func: t.Callable[[T], R]) -> _classproperty[T, R]: - # type ignore this because - # - it doesn't match the return type - # - mypy doesn't understand classmethod(property(...)) on older pythons - return classmethod(property(func)) # type: ignore - -else: - - def classproperty(func: t.Callable[[T], R]) -> _classproperty[T, R]: - # type cast to convert instance method to class method - return _classproperty(t.cast(t.Callable[[t.Type[T]], R], func)) diff --git a/src/globus_sdk/version.py b/src/globus_sdk/version.py deleted file mode 100644 index 7469d9a49..000000000 --- a/src/globus_sdk/version.py +++ /dev/null @@ -1,3 +0,0 @@ -# single source of truth for package version, -# see https://packaging.python.org/en/latest/single_source_version/ -__version__ = "3.65.0" diff --git a/tests/benchmark/__init__.py b/tests/benchmark/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/benchmark/test_scope_parser.py b/tests/benchmark/test_scope_parser.py new file mode 100644 index 000000000..fdcc18f63 --- /dev/null +++ b/tests/benchmark/test_scope_parser.py @@ -0,0 +1,32 @@ +import pytest + +from globus_sdk.scopes import ScopeParser + + +def _make_deep_scope(depth): + big_scope = "" + for i in range(depth): + big_scope += f"foo{i}[" + big_scope += "bar" + for _ in range(depth): + big_scope += "]" + return big_scope + + +def _make_wide_scope(width): + big_scope = "" + for i in range(width): + big_scope += f"foo{i} " + return big_scope + + +@pytest.mark.parametrize("depth", (10, 100, 1000, 2000, 3000, 4000, 5000)) +def test_deep_scope_parsing(benchmark, depth): + scope_string = _make_deep_scope(depth) + benchmark(ScopeParser.parse, scope_string) + + +@pytest.mark.parametrize("width", (5000, 10000)) +def test_wide_scope_parsing(benchmark, width): + scope_string = _make_wide_scope(width) + benchmark(ScopeParser.parse, scope_string) diff --git a/tests/common/consents.py b/tests/common/consents.py index 65d3b24cc..c1e7920c2 100644 --- a/tests/common/consents.py +++ b/tests/common/consents.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta -from globus_sdk import Scope +from globus_sdk.scopes import Scope, ScopeParser from globus_sdk.scopes.consents import Consent, ConsentForest ScopeRepr = namedtuple("Scope", ["id", "name"]) @@ -78,7 +78,7 @@ def _normalize_scopes(scopes: list[str | Scope] | str | Scope) -> list[Scope]: if isinstance(scopes, Scope): return [scopes] elif isinstance(scopes, str): - return Scope.parse(scopes) + return ScopeParser.parse(scopes) else: to_return = [] for scope in scopes: diff --git a/tests/common/globus_responses.py b/tests/common/globus_responses.py index 078211143..e8e7bfd37 100644 --- a/tests/common/globus_responses.py +++ b/tests/common/globus_responses.py @@ -3,7 +3,7 @@ import responses -from globus_sdk import utils +from globus_sdk._internal.utils import slash_join def register_api_route_fixture_file(service, path, filename, **kwargs): @@ -54,7 +54,7 @@ def register_api_route( } assert service in base_url_map base_url = base_url_map.get(service) - full_url = utils.slash_join(base_url, path) + full_url = slash_join(base_url, path) # can set it to `{}` explicitly to clear the default if adding_headers is None: diff --git a/tests/conftest.py b/tests/conftest.py index 44b5fd6cc..33c0a4be1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,6 @@ import responses import globus_sdk -from globus_sdk.transport import RequestsTransport @pytest.fixture(autouse=True) @@ -13,14 +12,6 @@ def mocksleep(): yield m -@pytest.fixture -def no_retry_transport(): - class NoRetryTransport(RequestsTransport): - DEFAULT_MAX_RETRIES = 0 - - return NoRetryTransport - - @pytest.fixture(autouse=True) def mocked_responses(): """ diff --git a/tests/functional/base_client/conftest.py b/tests/functional/base_client/conftest.py index d02df505d..f1204f3ac 100644 --- a/tests/functional/base_client/conftest.py +++ b/tests/functional/base_client/conftest.py @@ -4,8 +4,13 @@ @pytest.fixture -def client(): +def client_class(): class CustomClient(globus_sdk.BaseClient): service_name = "foo" - return CustomClient() + return CustomClient + + +@pytest.fixture +def client(client_class): + return client_class() diff --git a/tests/functional/base_client/test_advanced_http_options.py b/tests/functional/base_client/test_advanced_http_options.py index b3d1e91d2..61c3640d9 100644 --- a/tests/functional/base_client/test_advanced_http_options.py +++ b/tests/functional/base_client/test_advanced_http_options.py @@ -1,4 +1,4 @@ -from globus_sdk._testing import RegisteredResponse, load_response +from globus_sdk.testing import RegisteredResponse, load_response def test_allow_redirects_false(client): diff --git a/tests/functional/base_client/test_default_headers.py b/tests/functional/base_client/test_default_headers.py index 8629d7a3a..258f5389f 100644 --- a/tests/functional/base_client/test_default_headers.py +++ b/tests/functional/base_client/test_default_headers.py @@ -1,5 +1,5 @@ from globus_sdk import __version__ -from globus_sdk._testing import RegisteredResponse, get_last_request +from globus_sdk.testing import RegisteredResponse, get_last_request def test_clientinfo_header_default(client): diff --git a/tests/functional/base_client/test_filter_missing.py b/tests/functional/base_client/test_filter_missing.py index a90b828d6..f1b10c422 100644 --- a/tests/functional/base_client/test_filter_missing.py +++ b/tests/functional/base_client/test_filter_missing.py @@ -3,8 +3,8 @@ import pytest -from globus_sdk import utils -from globus_sdk._testing import RegisteredResponse, get_last_request, load_response +from globus_sdk import MISSING +from globus_sdk.testing import RegisteredResponse, get_last_request, load_response @pytest.fixture(autouse=True) @@ -25,14 +25,14 @@ def setup_mock_responses(): def test_query_params_can_filter_missing(client): - res = client.get("/bar", query_params={"foo": "bar", "baz": utils.MISSING}) + res = client.get("/bar", query_params={"foo": "bar", "baz": MISSING}) assert res.http_status == 200 req = get_last_request() assert req.params == {"foo": "bar"} def test_headers_can_filter_missing(client): - res = client.get("/bar", headers={"foo": "bar", "baz": utils.MISSING}) + res = client.get("/bar", headers={"foo": "bar", "baz": MISSING}) assert res.http_status == 200 req = get_last_request() assert req.headers["foo"] == "bar" @@ -40,7 +40,7 @@ def test_headers_can_filter_missing(client): def test_json_body_can_filter_missing(client): - res = client.post("/bar", data={"foo": "bar", "baz": utils.MISSING}) + res = client.post("/bar", data={"foo": "bar", "baz": MISSING}) assert res.http_status == 200 req = get_last_request() sent = json.loads(req.body) @@ -48,9 +48,7 @@ def test_json_body_can_filter_missing(client): def test_form_body_can_filter_missing(client): - res = client.post( - "/bar", data={"foo": "bar", "baz": utils.MISSING}, encoding="form" - ) + res = client.post("/bar", data={"foo": "bar", "baz": MISSING}, encoding="form") assert res.http_status == 200 req = get_last_request() sent = urllib.parse.parse_qs(req.body) diff --git a/tests/functional/base_client/test_retry_behavior.py b/tests/functional/base_client/test_retry_behavior.py index 8c8e67ac9..2839fbeb9 100644 --- a/tests/functional/base_client/test_retry_behavior.py +++ b/tests/functional/base_client/test_retry_behavior.py @@ -2,19 +2,16 @@ import requests import globus_sdk -from globus_sdk._testing import RegisteredResponse, load_response +from globus_sdk.testing import RegisteredResponse +from globus_sdk.transport import RequestCallerInfo, RetryConfig @pytest.mark.parametrize("error_status", [500, 429, 502, 503, 504]) def test_retry_on_transient_error(client, mocksleep, error_status): - load_response( - RegisteredResponse( - path="https://foo.api.globus.org/bar", status=error_status, body="Uh-oh!" - ) - ) - load_response( - RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}) - ) + RegisteredResponse( + path="https://foo.api.globus.org/bar", status=error_status, body="Uh-oh!" + ).add() + RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() # no sign of an error in the client res = client.get("/bar") @@ -26,37 +23,62 @@ def test_retry_on_transient_error(client, mocksleep, error_status): def test_retry_disabled_via_tune(client, mocksleep): - load_response( - RegisteredResponse( - path="https://foo.api.globus.org/bar", status=500, body="Uh-oh!" - ) - ) - load_response( - RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}) - ) + RegisteredResponse( + path="https://foo.api.globus.org/bar", status=500, body="Uh-oh!" + ).add() + RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() # the error is seen by the client (automatic retry does not hide it) with pytest.raises(globus_sdk.GlobusAPIError) as excinfo: - with client.transport.tune(max_retries=0): + with client.retry_config.tune(max_retries=0): client.get("/bar") assert excinfo.value.http_status == 500 - # there was a no sleep (retry was not triggered) + # there was no sleep (retry was not triggered) mocksleep.assert_not_called() +def test_retry_disabled_via_init_param(client_class, mocksleep): + RegisteredResponse( + path="https://foo.api.globus.org/bar", status=500, body="Uh-oh!" + ).add() + RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() + client = client_class(retry_config=RetryConfig(max_retries=0)) + + # the error is seen by the client (automatic retry does not hide it) + with pytest.raises(globus_sdk.GlobusAPIError) as excinfo: + client.get("/bar") + assert excinfo.value.http_status == 500 + + # there was no sleep (retry was not triggered) + mocksleep.assert_not_called() + + +def test_retry_disabled_via_init_param_but_enabled_via_tune(client_class, mocksleep): + RegisteredResponse( + path="https://foo.api.globus.org/bar", status=500, body="Uh-oh!" + ).add() + RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() + client = client_class(retry_config=RetryConfig(max_retries=0)) + + # no sign of an error in the client if we "turn it back on" + with client.retry_config.tune(max_retries=1): + res = client.get("/bar") + assert res.http_status == 200 + assert res["baz"] == 1 + + # there was a sleep (retry was triggered) + mocksleep.assert_called_once() + + def test_retry_on_network_error(client, mocksleep): # set the response to be a requests NetworkError -- responses will raise the # exception when the call is made - load_response( - RegisteredResponse( - path="https://foo.api.globus.org/bar", - body=requests.ConnectionError("foo-err"), - ) - ) - load_response( - RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}) - ) + RegisteredResponse( + path="https://foo.api.globus.org/bar", + body=requests.ConnectionError("foo-err"), + ).add() + RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() # no sign of an error in the client res = client.get("/bar") @@ -71,14 +93,10 @@ def test_retry_on_network_error(client, mocksleep): def test_retry_limit(client, mocksleep, num_errors, expect_err): # N errors followed by a success for _i in range(num_errors): - load_response( - RegisteredResponse( - path="https://foo.api.globus.org/bar", status=500, body="Uh-oh!" - ) - ) - load_response( - RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}) - ) + RegisteredResponse( + path="https://foo.api.globus.org/bar", status=500, body="Uh-oh!" + ).add() + RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() if expect_err: with pytest.raises(globus_sdk.GlobusAPIError): @@ -95,17 +113,13 @@ def test_retry_limit(client, mocksleep, num_errors, expect_err): def test_transport_retry_limit(client, mocksleep): # this limit is a safety to protect against a bad policy causing infinite retries - client.transport.max_retries = 2 + client.retry_config.max_retries = 2 for _i in range(3): - load_response( - RegisteredResponse( - path="https://foo.api.globus.org/bar", status=500, body="Uh-oh!" - ) - ) - load_response( - RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}) - ) + RegisteredResponse( + path="https://foo.api.globus.org/bar", status=500, body="Uh-oh!" + ).add() + RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() with pytest.raises(globus_sdk.GlobusAPIError): client.get("/bar") @@ -114,11 +128,11 @@ def test_transport_retry_limit(client, mocksleep): def test_bad_max_retries_causes_error(client): - # this test exploits the fact that we loop to (transport.max_retries + 1) in order + # this test exploits the fact that we loop to (max_retries + 1) in order # to ensure that no requests are ever sent # the transport should throw an error in this case, since it doesn't have a response # value to return - client.transport.max_retries = -1 + client.retry_config.max_retries = -1 with pytest.raises(ValueError): client.get("/bar") @@ -126,29 +140,21 @@ def test_bad_max_retries_causes_error(client): def test_persistent_connection_error(client): for _i in range(6): - load_response( - RegisteredResponse( - path="https://foo.api.globus.org/bar", - body=requests.ConnectionError("foo-err"), - ) - ) - load_response( - RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}) - ) + RegisteredResponse( + path="https://foo.api.globus.org/bar", + body=requests.ConnectionError("foo-err"), + ).add() + RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() with pytest.raises(globus_sdk.GlobusConnectionError): client.get("/bar") def test_no_retry_401_no_authorizer(client): - load_response( - RegisteredResponse( - path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" - ) - ) - load_response( - RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}) - ) + RegisteredResponse( + path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" + ).add() + RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() # error gets raised in client (no retry) with pytest.raises(globus_sdk.GlobusAPIError) as excinfo: @@ -157,14 +163,10 @@ def test_no_retry_401_no_authorizer(client): def test_retry_with_authorizer(client): - load_response( - RegisteredResponse( - path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" - ) - ) - load_response( - RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}) - ) + RegisteredResponse( + path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" + ).add() + RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() # an authorizer class which does nothing but claims to support handling of # unauthorized errors @@ -193,14 +195,10 @@ def handle_missing_authorization(self): def test_no_retry_with_authorizer_no_handler(client): - load_response( - RegisteredResponse( - path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" - ) - ) - load_response( - RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}) - ) + RegisteredResponse( + path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" + ).add() + RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() # an authorizer class which does nothing and does not claim to handle # unauthorized errors @@ -228,19 +226,13 @@ def handle_missing_authorization(self): def test_retry_with_authorizer_persistent_401(client): - load_response( - RegisteredResponse( - path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" - ) - ) - load_response( - RegisteredResponse( - path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" - ) - ) - load_response( - RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}) - ) + RegisteredResponse( + path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" + ).add() + RegisteredResponse( + path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" + ).add() + RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() # an authorizer class which does nothing but claims to support handling of # unauthorized errors @@ -268,3 +260,35 @@ def handle_missing_authorization(self): # and that between the two calls, handle_missing_authorization was called once # but the handler should not be called a second time because the 401 repeated assert dummy_authz_calls == ["set_authz", "handle_missing", "set_authz"] + + +def test_transport_caller_info_with_retry(client): + RegisteredResponse( + path="https://foo.api.globus.org/bar", status=401, body="Unauthorized" + ).add() + RegisteredResponse(path="https://foo.api.globus.org/bar", json={"baz": 1}).add() + + dummy_authz_calls = [] + + class DummyAuthorizer(globus_sdk.authorizers.GlobusAuthorizer): + def get_authorization_header(self): + dummy_authz_calls.append("set_authz") + return "foo" + + def handle_missing_authorization(self): + dummy_authz_calls.append("handle_missing") + return True + + authorizer = DummyAuthorizer() + caller_info = RequestCallerInfo( + retry_config=client.retry_config, authorizer=authorizer + ) + + # Test direct transport usage with caller_info + response = client.transport.request( + "GET", "https://foo.api.globus.org/bar", caller_info=caller_info + ) + + assert response.status_code == 200 + # Verify that the authorizer was used for both authorization and retry handling + assert dummy_authz_calls == ["set_authz", "handle_missing", "set_authz"] diff --git a/tests/functional/globus_app/test_globus_app_token_handling.py b/tests/functional/globus_app/test_globus_app_token_handling.py index ab5b979f6..4c2188643 100644 --- a/tests/functional/globus_app/test_globus_app_token_handling.py +++ b/tests/functional/globus_app/test_globus_app_token_handling.py @@ -4,8 +4,8 @@ import responses import globus_sdk -import globus_sdk.tokenstorage -from globus_sdk._testing import RegisteredResponse, load_response +import globus_sdk.token_storage +from globus_sdk.testing import RegisteredResponse, load_response # the JWT will have a client ID in its audience claim # make sure to use that value when trying to decode it @@ -124,7 +124,7 @@ def _count_jwk_calls(): calls = [c for c in calls if c.url == "https://auth.globus.org/jwk.json"] return len(calls) - memory_storage = globus_sdk.tokenstorage.MemoryTokenStorage() + memory_storage = globus_sdk.token_storage.MemoryTokenStorage() config = globus_sdk.GlobusAppConfig( token_storage=memory_storage, id_token_decoder=InfiniteLeewayDecoder, @@ -164,7 +164,7 @@ def __init__(self, *args, **kwargs) -> None: nonlocal init_counter init_counter += 1 - memory_storage = globus_sdk.tokenstorage.MemoryTokenStorage() + memory_storage = globus_sdk.token_storage.MemoryTokenStorage() config = globus_sdk.GlobusAppConfig( token_storage=memory_storage, id_token_decoder=CustomDecoder, @@ -195,7 +195,7 @@ def decode(self, *args, **kwargs) -> None: return super().decode(*args, **kwargs) login_client = globus_sdk.NativeAppAuthClient(client_id=CLIENT_ID_FROM_JWT) - memory_storage = globus_sdk.tokenstorage.MemoryTokenStorage() + memory_storage = globus_sdk.token_storage.MemoryTokenStorage() config = globus_sdk.GlobusAppConfig( token_storage=memory_storage, id_token_decoder=CustomDecoder(login_client) ) @@ -222,7 +222,7 @@ def test_globus_app_custom_id_token_decoder_instance_can_overload_jwt_leeway( load_response(globus_sdk.NativeAppAuthClient.oauth2_revoke_token) login_client = globus_sdk.NativeAppAuthClient(client_id=CLIENT_ID_FROM_JWT) - memory_storage = globus_sdk.tokenstorage.MemoryTokenStorage() + memory_storage = globus_sdk.token_storage.MemoryTokenStorage() config = globus_sdk.GlobusAppConfig( token_storage=memory_storage, id_token_decoder=globus_sdk.IDTokenDecoder( diff --git a/tests/functional/local_endpoint/test_personal.py b/tests/functional/local_endpoint/test_personal.py index 027e2a4c6..3f6dad88d 100644 --- a/tests/functional/local_endpoint/test_personal.py +++ b/tests/functional/local_endpoint/test_personal.py @@ -6,7 +6,7 @@ import pytest import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response _IS_WINDOWS = os.name == "nt" diff --git a/tests/functional/login_flows/test_login_flow_manager.py b/tests/functional/login_flows/test_login_flow_manager.py index 4b951c687..5b0773bf8 100644 --- a/tests/functional/login_flows/test_login_flow_manager.py +++ b/tests/functional/login_flows/test_login_flow_manager.py @@ -3,7 +3,6 @@ import pytest from globus_sdk import ConfidentialAppAuthClient, NativeAppAuthClient -from globus_sdk._testing import load_response from globus_sdk.gare import GlobusAuthorizationParameters from globus_sdk.login_flows import ( CommandLineLoginFlowManager, @@ -12,6 +11,7 @@ from globus_sdk.login_flows.command_line_login_flow_manager import ( CommandLineLoginFlowEOFError, ) +from globus_sdk.testing import load_response def _mock_input(s): diff --git a/tests/functional/scopes/test_scope_data_behaviors.py b/tests/functional/scopes/test_scope_data_behaviors.py new file mode 100644 index 000000000..e3b96f26a --- /dev/null +++ b/tests/functional/scopes/test_scope_data_behaviors.py @@ -0,0 +1,64 @@ +import uuid + +import pytest + +from globus_sdk.scopes import ( + AuthScopes, + ComputeScopes, + FlowsScopes, + GCSCollectionScopes, + GCSEndpointScopes, + GroupsScopes, + NexusScopes, + Scope, + SearchScopes, + SpecificFlowScopes, + TimersScopes, + TransferScopes, +) + + +@pytest.mark.parametrize( + "collection, expect_resource_server", + ( + (AuthScopes, "auth.globus.org"), + (ComputeScopes, "funcx_service"), + (FlowsScopes, "flows.globus.org"), + (GroupsScopes, "groups.api.globus.org"), + (NexusScopes, "nexus.api.globus.org"), + (SearchScopes, "search.api.globus.org"), + (TimersScopes, "524230d7-ea86-4a52-8312-86065a9e0417"), + (TransferScopes, "transfer.api.globus.org"), + ), +) +def test_static_resource_server_attributes(collection, expect_resource_server): + assert collection.resource_server == expect_resource_server + + +@pytest.mark.parametrize( + "collection_cls", (GCSEndpointScopes, GCSCollectionScopes, SpecificFlowScopes) +) +def test_dynamic_resource_server_attributes(collection_cls): + some_id = str(uuid.UUID(int=1)) + coll = collection_cls(some_id) + assert coll.resource_server == some_id + + +def test_oidc_scope_formatting(): + assert str(AuthScopes.openid) == "openid" + assert str(AuthScopes.email) == "email" + assert str(AuthScopes.profile) == "profile" + + +def test_non_oidc_auth_scope_formatting(): + non_oidc_scopes = set(AuthScopes).difference( + (AuthScopes.openid, AuthScopes.email, AuthScopes.profile) + ) + + assert len(non_oidc_scopes) > 0 + assert all(isinstance(x, Scope) for x in non_oidc_scopes) + + scope_strs = [str(s) for s in non_oidc_scopes] + assert all( + s.startswith("urn:globus:auth:scope:auth.globus.org:") for s in scope_strs + ) diff --git a/tests/functional/services/auth/base/test_oauth2_revoke_token.py b/tests/functional/services/auth/base/test_oauth2_revoke_token.py index 725fae17e..df31d6614 100644 --- a/tests/functional/services/auth/base/test_oauth2_revoke_token.py +++ b/tests/functional/services/auth/base/test_oauth2_revoke_token.py @@ -4,7 +4,7 @@ import pytest import globus_sdk -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response @pytest.fixture diff --git a/tests/functional/services/auth/base/test_oauth2_validate_token.py b/tests/functional/services/auth/base/test_oauth2_validate_token.py deleted file mode 100644 index 1e67a8d91..000000000 --- a/tests/functional/services/auth/base/test_oauth2_validate_token.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -import globus_sdk -from globus_sdk._testing import RegisteredResponse, load_response - - -def test_oauth2_validate_token_emits_deprecation_warning(): - nc = globus_sdk.NativeAppAuthClient("dummy_client_id") - load_response( - RegisteredResponse( - service="auth", - path="/v2/oauth2/token/validate", - method="POST", - json={"foo": "bar"}, - ) - ) - with pytest.warns(globus_sdk.RemovedInV4Warning): - nc.oauth2_validate_token("dummy_token") diff --git a/tests/functional/services/auth/confidential_client/conftest.py b/tests/functional/services/auth/confidential_client/conftest.py index c59a40f1a..d680b49d6 100644 --- a/tests/functional/services/auth/confidential_client/conftest.py +++ b/tests/functional/services/auth/confidential_client/conftest.py @@ -4,8 +4,9 @@ @pytest.fixture -def auth_client(no_retry_transport): - class CustomAuthClient(globus_sdk.ConfidentialAppAuthClient): - transport_class = no_retry_transport - - return CustomAuthClient("dummy_client_id", "dummy_client_secret") +def auth_client(): + client = globus_sdk.ConfidentialAppAuthClient( + "dummy_client_id", "dummy_client_secret" + ) + with client.retry_config.tune(max_retries=0): + yield client diff --git a/tests/functional/services/auth/confidential_client/test_create_child_client.py b/tests/functional/services/auth/confidential_client/test_create_child_client.py index 340247213..e8371ccd7 100644 --- a/tests/functional/services/auth/confidential_client/test_create_child_client.py +++ b/tests/functional/services/auth/confidential_client/test_create_child_client.py @@ -3,7 +3,7 @@ import pytest from globus_sdk import GlobusSDKUsageError -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/confidential_client/test_oauth2_client_credentials_tokens.py b/tests/functional/services/auth/confidential_client/test_oauth2_client_credentials_tokens.py index dbdac64f2..9aef09995 100644 --- a/tests/functional/services/auth/confidential_client/test_oauth2_client_credentials_tokens.py +++ b/tests/functional/services/auth/confidential_client/test_oauth2_client_credentials_tokens.py @@ -1,7 +1,7 @@ import urllib.parse -from globus_sdk._testing import get_last_request, load_response -from globus_sdk.scopes import MutableScope +from globus_sdk.scopes import Scope +from globus_sdk.testing import get_last_request, load_response def test_oauth2_client_credentials_tokens(auth_client): @@ -14,10 +14,10 @@ def test_oauth2_client_credentials_tokens(auth_client): ) -def test_oauth2_client_credentials_tokens_can_accept_mutable_scope_object(auth_client): +def test_oauth2_client_credentials_tokens_can_accept_scope_object(auth_client): meta = load_response(auth_client.oauth2_client_credentials_tokens).metadata - response = auth_client.oauth2_client_credentials_tokens(MutableScope(meta["scope"])) + response = auth_client.oauth2_client_credentials_tokens(Scope(meta["scope"])) assert ( response.by_resource_server[meta["resource_server"]]["access_token"] == meta["access_token"] diff --git a/tests/functional/services/auth/confidential_client/test_oauth2_get_dependent_tokens.py b/tests/functional/services/auth/confidential_client/test_oauth2_get_dependent_tokens.py index 67efc5c21..c6570152e 100644 --- a/tests/functional/services/auth/confidential_client/test_oauth2_get_dependent_tokens.py +++ b/tests/functional/services/auth/confidential_client/test_oauth2_get_dependent_tokens.py @@ -2,7 +2,7 @@ import pytest -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response def test_oauth2_get_dependent_tokens(auth_client): diff --git a/tests/functional/services/auth/confidential_client/test_oauth2_token_introspect.py b/tests/functional/services/auth/confidential_client/test_oauth2_token_introspect.py index f31a1939f..8ed54f812 100644 --- a/tests/functional/services/auth/confidential_client/test_oauth2_token_introspect.py +++ b/tests/functional/services/auth/confidential_client/test_oauth2_token_introspect.py @@ -1,4 +1,4 @@ -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response def test_oauth2_token_introspect(auth_client): diff --git a/tests/functional/services/auth/conftest.py b/tests/functional/services/auth/conftest.py index 1153551ea..4d72616c0 100644 --- a/tests/functional/services/auth/conftest.py +++ b/tests/functional/services/auth/conftest.py @@ -4,16 +4,14 @@ @pytest.fixture -def login_client(no_retry_transport): - class CustomAuthClient(globus_sdk.AuthLoginClient): - transport_class = no_retry_transport - - return CustomAuthClient() +def login_client(): + client = globus_sdk.AuthLoginClient() + with client.retry_config.tune(max_retries=0): + yield client @pytest.fixture -def service_client(no_retry_transport): - class CustomAuthClient(globus_sdk.AuthClient): - transport_class = no_retry_transport - - return CustomAuthClient() +def service_client(): + client = globus_sdk.AuthClient() + with client.retry_config.tune(max_retries=0): + yield client diff --git a/tests/functional/services/auth/native_client/conftest.py b/tests/functional/services/auth/native_client/conftest.py index 78c4d6417..5786aef29 100644 --- a/tests/functional/services/auth/native_client/conftest.py +++ b/tests/functional/services/auth/native_client/conftest.py @@ -4,8 +4,7 @@ @pytest.fixture -def auth_client(no_retry_transport): - class CustomAuthClient(globus_sdk.NativeAppAuthClient): - transport_class = no_retry_transport - - return CustomAuthClient("dummy_client_id") +def auth_client(): + client = globus_sdk.NativeAppAuthClient("dummy_client_id") + with client.retry_config.tune(max_retries=0): + yield client diff --git a/tests/functional/services/auth/native_client/test_create_native_app_instance.py b/tests/functional/services/auth/native_client/test_create_native_app_instance.py index dd52a6d9d..2b9b07cba 100644 --- a/tests/functional/services/auth/native_client/test_create_native_app_instance.py +++ b/tests/functional/services/auth/native_client/test_create_native_app_instance.py @@ -2,7 +2,7 @@ import pytest -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_create_client.py b/tests/functional/services/auth/service_client/test_create_client.py index 7643c99d5..1fa5b6432 100644 --- a/tests/functional/services/auth/service_client/test_create_client.py +++ b/tests/functional/services/auth/service_client/test_create_client.py @@ -5,7 +5,7 @@ import pytest from globus_sdk import GlobusSDKUsageError -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_create_client_credential.py b/tests/functional/services/auth/service_client/test_create_client_credential.py index 6096acb58..a718b65e7 100644 --- a/tests/functional/services/auth/service_client/test_create_client_credential.py +++ b/tests/functional/services/auth/service_client/test_create_client_credential.py @@ -4,7 +4,7 @@ import pytest -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize("uuid_type", (str, uuid.UUID)) diff --git a/tests/functional/services/auth/service_client/test_create_policy.py b/tests/functional/services/auth/service_client/test_create_policy.py index 5c204ea22..125d5cc2a 100644 --- a/tests/functional/services/auth/service_client/test_create_policy.py +++ b/tests/functional/services/auth/service_client/test_create_policy.py @@ -1,11 +1,8 @@ from __future__ import annotations -import json - import pytest -from globus_sdk import exc -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( @@ -36,49 +33,3 @@ def test_create_policy( res = service_client.create_policy(**meta["args"]) for k, v in meta["response"].items(): assert res["policy"][k] == v - - -def test_compatible_create_policy_usage_rejects_too_many_positionals(service_client): - load_response(service_client.create_policy) - with pytest.raises( - TypeError, - match=r"create_policy\(\) takes 5 positional arguments but 6 were given", - ): - service_client.create_policy(1, 2, 3, 4, 5, 6) - - -def test_valid_compatible_policy_usage_emits_warning(service_client): - load_response(service_client.create_policy) - with pytest.warns( - exc.RemovedInV4Warning, - match=r"'AuthClient\.create_policy' received positional arguments", - ): - service_client.create_policy( - "my_project_id", - True, - 101, - display_name="my_display_name", - description="my_description", - ) - - lastreq = get_last_request() - sent_data = json.loads(lastreq.body) - assert sent_data["policy"] == { - "project_id": "my_project_id", - "high_assurance": True, - "authentication_assurance_timeout": 101, - "display_name": "my_display_name", - "description": "my_description", - } - - -def test_policy_usage_warns_and_errors_when_argument_is_supplied_twice(service_client): - with pytest.raises( - TypeError, - match="create_policy\\(\\) got multiple values for argument 'project_id'", - ): - with pytest.warns( - exc.RemovedInV4Warning, - match="'AuthClient.create_policy()' received positional arguments", - ): - service_client.create_policy("my_project_id", project_id="my_project_id2") diff --git a/tests/functional/services/auth/service_client/test_create_project.py b/tests/functional/services/auth/service_client/test_create_project.py index 0caba212a..c649945b3 100644 --- a/tests/functional/services/auth/service_client/test_create_project.py +++ b/tests/functional/services/auth/service_client/test_create_project.py @@ -3,7 +3,7 @@ import pytest -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_create_scope.py b/tests/functional/services/auth/service_client/test_create_scope.py index c7ea900f0..ae0b6e5a8 100644 --- a/tests/functional/services/auth/service_client/test_create_scope.py +++ b/tests/functional/services/auth/service_client/test_create_scope.py @@ -2,7 +2,7 @@ import pytest -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_delete_client.py b/tests/functional/services/auth/service_client/test_delete_client.py index af4c20fa3..b03aff940 100644 --- a/tests/functional/services/auth/service_client/test_delete_client.py +++ b/tests/functional/services/auth/service_client/test_delete_client.py @@ -4,7 +4,7 @@ import pytest -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_delete_client_credential.py b/tests/functional/services/auth/service_client/test_delete_client_credential.py index 01cf8ff1a..22dd6da43 100644 --- a/tests/functional/services/auth/service_client/test_delete_client_credential.py +++ b/tests/functional/services/auth/service_client/test_delete_client_credential.py @@ -4,7 +4,7 @@ import pytest -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_delete_policy.py b/tests/functional/services/auth/service_client/test_delete_policy.py index 538554c42..39c0a3d7f 100644 --- a/tests/functional/services/auth/service_client/test_delete_policy.py +++ b/tests/functional/services/auth/service_client/test_delete_policy.py @@ -4,7 +4,7 @@ import pytest -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_delete_project.py b/tests/functional/services/auth/service_client/test_delete_project.py index b4c55b42a..f0946cb2b 100644 --- a/tests/functional/services/auth/service_client/test_delete_project.py +++ b/tests/functional/services/auth/service_client/test_delete_project.py @@ -1,4 +1,4 @@ -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_delete_project(service_client): diff --git a/tests/functional/services/auth/service_client/test_delete_scope.py b/tests/functional/services/auth/service_client/test_delete_scope.py index bd672f272..48e398b34 100644 --- a/tests/functional/services/auth/service_client/test_delete_scope.py +++ b/tests/functional/services/auth/service_client/test_delete_scope.py @@ -4,7 +4,7 @@ import pytest -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_get_client.py b/tests/functional/services/auth/service_client/test_get_client.py index 478f55ef8..5ed5717fb 100644 --- a/tests/functional/services/auth/service_client/test_get_client.py +++ b/tests/functional/services/auth/service_client/test_get_client.py @@ -5,7 +5,7 @@ import pytest from globus_sdk import GlobusSDKUsageError -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_get_client_credentials.py b/tests/functional/services/auth/service_client/test_get_client_credentials.py index 9cbc97992..868669805 100644 --- a/tests/functional/services/auth/service_client/test_get_client_credentials.py +++ b/tests/functional/services/auth/service_client/test_get_client_credentials.py @@ -4,7 +4,7 @@ import pytest -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_get_clients.py b/tests/functional/services/auth/service_client/test_get_clients.py index 6565fe22d..5d670ce90 100644 --- a/tests/functional/services/auth/service_client/test_get_clients.py +++ b/tests/functional/services/auth/service_client/test_get_clients.py @@ -1,4 +1,4 @@ -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_clients(service_client): diff --git a/tests/functional/services/auth/service_client/test_get_consents.py b/tests/functional/services/auth/service_client/test_get_consents.py index 792b47d0b..c7cf2dea6 100644 --- a/tests/functional/services/auth/service_client/test_get_consents.py +++ b/tests/functional/services/auth/service_client/test_get_consents.py @@ -1,4 +1,4 @@ -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_consents(service_client): diff --git a/tests/functional/services/auth/service_client/test_get_identities.py b/tests/functional/services/auth/service_client/test_get_identities.py index 1ecb47b2b..453643ad2 100644 --- a/tests/functional/services/auth/service_client/test_get_identities.py +++ b/tests/functional/services/auth/service_client/test_get_identities.py @@ -3,7 +3,7 @@ import pytest import globus_sdk -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response class StringWrapper: @@ -50,18 +50,16 @@ def test_get_identities_success(usernames, service_client): } -@pytest.mark.parametrize( - "inval, outval", - [ - (True, "true"), - (False, "false"), - (1, "true"), - (0, "true"), - ("fALSe", "false"), - ("true", "true"), - ], -) -def test_get_identities_provision(inval, outval, service_client): +def test_get_identities_provision_flag_defaults_to_false(service_client): + load_response(service_client.get_identities) + service_client.get_identities(usernames="globus@globus.org") + lastreq = get_last_request() + assert "provision" in lastreq.params + assert lastreq.params["provision"] == "false" + + +@pytest.mark.parametrize("inval, outval", [(True, "true"), (False, "false")]) +def test_get_identities_provision_flag_formatting(inval, outval, service_client): load_response(service_client.get_identities) service_client.get_identities(usernames="globus@globus.org", provision=inval) lastreq = get_last_request() diff --git a/tests/functional/services/auth/service_client/test_get_identity_providers.py b/tests/functional/services/auth/service_client/test_get_identity_providers.py index e7e3b7e49..3c7d041a3 100644 --- a/tests/functional/services/auth/service_client/test_get_identity_providers.py +++ b/tests/functional/services/auth/service_client/test_get_identity_providers.py @@ -1,7 +1,7 @@ import pytest import globus_sdk -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response def test_get_identity_providers_by_domains(service_client): diff --git a/tests/functional/services/auth/service_client/test_get_policies.py b/tests/functional/services/auth/service_client/test_get_policies.py index 69f8ae4d5..38579ccd4 100644 --- a/tests/functional/services/auth/service_client/test_get_policies.py +++ b/tests/functional/services/auth/service_client/test_get_policies.py @@ -1,4 +1,4 @@ -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_policies(service_client): diff --git a/tests/functional/services/auth/service_client/test_get_policy.py b/tests/functional/services/auth/service_client/test_get_policy.py index 1a06e5004..9f4df5da9 100644 --- a/tests/functional/services/auth/service_client/test_get_policy.py +++ b/tests/functional/services/auth/service_client/test_get_policy.py @@ -4,7 +4,7 @@ import pytest -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_get_project.py b/tests/functional/services/auth/service_client/test_get_project.py index f809a3d41..d817fadf6 100644 --- a/tests/functional/services/auth/service_client/test_get_project.py +++ b/tests/functional/services/auth/service_client/test_get_project.py @@ -4,7 +4,7 @@ import pytest -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_get_projects.py b/tests/functional/services/auth/service_client/test_get_projects.py index 35f15df47..e6336c42a 100644 --- a/tests/functional/services/auth/service_client/test_get_projects.py +++ b/tests/functional/services/auth/service_client/test_get_projects.py @@ -1,4 +1,4 @@ -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_projects(service_client): diff --git a/tests/functional/services/auth/service_client/test_get_scope.py b/tests/functional/services/auth/service_client/test_get_scope.py index bf13cac7d..249bd62e0 100644 --- a/tests/functional/services/auth/service_client/test_get_scope.py +++ b/tests/functional/services/auth/service_client/test_get_scope.py @@ -4,7 +4,7 @@ import pytest -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_get_scopes.py b/tests/functional/services/auth/service_client/test_get_scopes.py index 760cea606..2f4354257 100644 --- a/tests/functional/services/auth/service_client/test_get_scopes.py +++ b/tests/functional/services/auth/service_client/test_get_scopes.py @@ -1,7 +1,7 @@ import pytest from globus_sdk import GlobusSDKUsageError -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_scopes(service_client): diff --git a/tests/functional/services/auth/service_client/test_update_client.py b/tests/functional/services/auth/service_client/test_update_client.py index 687a42582..efd25c64c 100644 --- a/tests/functional/services/auth/service_client/test_update_client.py +++ b/tests/functional/services/auth/service_client/test_update_client.py @@ -5,7 +5,7 @@ import pytest from globus_sdk import GlobusSDKUsageError -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_update_policy.py b/tests/functional/services/auth/service_client/test_update_policy.py index b68350e18..ca981807f 100644 --- a/tests/functional/services/auth/service_client/test_update_policy.py +++ b/tests/functional/services/auth/service_client/test_update_policy.py @@ -2,7 +2,7 @@ import pytest -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_update_project.py b/tests/functional/services/auth/service_client/test_update_project.py index 129bcf6cb..185b7a6ff 100644 --- a/tests/functional/services/auth/service_client/test_update_project.py +++ b/tests/functional/services/auth/service_client/test_update_project.py @@ -3,17 +3,18 @@ import pytest -from globus_sdk._testing import get_last_request, load_response +from globus_sdk._missing import MISSING, filter_missing +from globus_sdk.testing import get_last_request, load_response @pytest.mark.parametrize( - "admin_id_style", ("none", "string", "list", "set", "uuid", "uuid_list") + "admin_id_style", ("missing", "string", "list", "set", "uuid", "uuid_list") ) def test_update_project_admin_id_styles(service_client, admin_id_style): meta = load_response(service_client.update_project).metadata - if admin_id_style == "none": - admin_ids = None + if admin_id_style == "missing": + admin_ids = MISSING elif admin_id_style == "string": admin_ids = meta["admin_id"] elif admin_id_style == "list": @@ -37,8 +38,8 @@ def test_update_project_admin_id_styles(service_client, admin_id_style): last_req = get_last_request() data = json.loads(last_req.body) assert list(data) == ["project"], data # 'project' is the only key - if admin_id_style == "none": - assert data["project"] == {"display_name": "My Project"} + if admin_id_style == "missing": + assert filter_missing(data["project"]) == {"display_name": "My Project"} else: assert data["project"] == { "display_name": "My Project", diff --git a/tests/functional/services/auth/service_client/test_update_scope.py b/tests/functional/services/auth/service_client/test_update_scope.py index 956325222..e3bed2cdc 100644 --- a/tests/functional/services/auth/service_client/test_update_scope.py +++ b/tests/functional/services/auth/service_client/test_update_scope.py @@ -2,7 +2,7 @@ import pytest -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/auth/service_client/test_userinfo.py b/tests/functional/services/auth/service_client/test_userinfo.py index 3ecd1dee7..c6dfb9e5a 100644 --- a/tests/functional/services/auth/service_client/test_userinfo.py +++ b/tests/functional/services/auth/service_client/test_userinfo.py @@ -1,7 +1,7 @@ import pytest import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response # TODO: add data for the success case and test it @@ -12,7 +12,7 @@ def test_userinfo(): @pytest.mark.parametrize("casename", ("unauthorized", "forbidden")) def test_userinfo_error_handling(service_client, casename): - meta = load_response(service_client.oauth2_userinfo, case=casename).metadata + meta = load_response(service_client.userinfo, case=casename).metadata with pytest.raises(globus_sdk.AuthAPIError) as excinfo: service_client.userinfo() @@ -21,16 +21,3 @@ def test_userinfo_error_handling(service_client, casename): assert err.http_status == meta["http_status"] assert err.code == meta["code"] assert err.request_id == meta["error_id"] - - -def test_oauth2_userinfo_warns(service_client): - # TODO: - # if the above success case is added, this test can be changed to use it - # that would let us get rid of the try-except guard below - load_response(service_client.oauth2_userinfo, case="unauthorized") - - with pytest.warns(globus_sdk.RemovedInV4Warning, match="Use `userinfo` instead."): - try: - service_client.oauth2_userinfo() - except globus_sdk.AuthAPIError: - pass diff --git a/tests/functional/services/auth/test_auth_client_flow.py b/tests/functional/services/auth/test_auth_client_flow.py index 7a37c19b8..75ff8695e 100644 --- a/tests/functional/services/auth/test_auth_client_flow.py +++ b/tests/functional/services/auth/test_auth_client_flow.py @@ -4,29 +4,28 @@ import pytest import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk._missing import MISSING from globus_sdk.scopes import TransferScopes -from globus_sdk.services.auth.flow_managers.native_app import make_native_app_challenge +from globus_sdk.services.auth.flow_managers.native_app import _make_native_app_challenge +from globus_sdk.testing import load_response CLIENT_ID = "d0f1d9b0-bd81-4108-be74-ea981664453a" @pytest.fixture -def native_client(no_retry_transport): - class CustomAuthClient(globus_sdk.NativeAppAuthClient): - transport_class = no_retry_transport - - return CustomAuthClient(client_id=CLIENT_ID) +def native_client(): + client = globus_sdk.NativeAppAuthClient(client_id=CLIENT_ID) + with client.retry_config.tune(max_retries=0): + yield client @pytest.fixture -def confidential_client(no_retry_transport): - class CustomAuthClient(globus_sdk.ConfidentialAppAuthClient): - transport_class = no_retry_transport - - return CustomAuthClient( +def confidential_client(): + client = globus_sdk.ConfidentialAppAuthClient( client_id=CLIENT_ID, client_secret="SECRET_SECRET_HES_GOT_A_SECRET" ) + with client.retry_config.tune(max_retries=0): + yield client # build a nearly-diagonal matrix over @@ -62,7 +61,7 @@ class CustomAuthClient(globus_sdk.ConfidentialAppAuthClient): prompt_options = ("login",) # Seed an all-`None` option test, then use a loop to fill in the rest. # The number of parameters here must match the test parameters: -_ALL_SESSION_PARAM_COMBINATIONS = [(None,) * 6] +_ALL_SESSION_PARAM_COMBINATIONS = [(MISSING,) * 6] for idx, options in enumerate( ( domain_options, @@ -74,7 +73,7 @@ class CustomAuthClient(globus_sdk.ConfidentialAppAuthClient): ) ): for option in options: - parameters = [None] * 6 + parameters = [MISSING] * 6 parameters[idx] = option _ALL_SESSION_PARAM_COMBINATIONS.append(tuple(parameters)) @@ -130,7 +129,7 @@ def test_oauth2_get_authorize_url_supports_session_params( "session_required_single_domain" if domain_option else None, "session_required_identities" if identity_option else None, "session_required_policies" if policy_option else None, - "session_required_mfa" if mfa_option is not None else None, + "session_required_mfa" if mfa_option is not MISSING else None, "prompt" if prompt_option else None, } expected_params_keys.discard(None) @@ -147,7 +146,7 @@ def test_oauth2_get_authorize_url_supports_session_params( assert expected_params_keys <= parsed_params_keys assert (unexpected_query_params - parsed_params_keys) == unexpected_query_params - if domain_option is not None: + if domain_option is not MISSING: strized_option = ( ",".join(str(x) for x in domain_option) if isinstance(domain_option, list) @@ -155,7 +154,7 @@ def test_oauth2_get_authorize_url_supports_session_params( ) assert parsed_params["session_required_single_domain"] == [strized_option] - if identity_option is not None: + if identity_option is not MISSING: strized_option = ( ",".join(str(x) for x in identity_option) if isinstance(identity_option, list) @@ -163,7 +162,7 @@ def test_oauth2_get_authorize_url_supports_session_params( ) assert parsed_params["session_required_identities"] == [strized_option] - if policy_option is not None: + if policy_option is not MISSING: strized_option = ( ",".join(str(x) for x in policy_option) if isinstance(policy_option, list) @@ -171,21 +170,19 @@ def test_oauth2_get_authorize_url_supports_session_params( ) assert parsed_params["session_required_policies"] == [strized_option] - if mfa_option is not None: + if mfa_option is not MISSING: strized_option = "True" if mfa_option else "False" assert parsed_params["session_required_mfa"] == [strized_option] - if prompt_option is not None: + if prompt_option is not MISSING: assert parsed_params["prompt"] == [prompt_option] def test_oauth2_get_authorize_url_native_defaults(native_client): - # default parameters for starting auth flow - # should warn because scopes were not specified - with pytest.warns(globus_sdk.RemovedInV4Warning): - flow_manager = globus_sdk.services.auth.GlobusNativeAppFlowManager( - native_client - ) + flow_manager = globus_sdk.services.auth.GlobusNativeAppFlowManager( + native_client, + TransferScopes.all, + ) native_client.current_oauth2_flow_manager = flow_manager # get url and validate results @@ -197,7 +194,7 @@ def test_oauth2_get_authorize_url_native_defaults(native_client): assert parsed_params == { "client_id": [native_client.client_id], "redirect_uri": [native_client.base_url + "v2/web/auth-code"], - "scope": [f"openid profile email {TransferScopes.all}"], + "scope": [str(TransferScopes.all)], "state": ["_default"], "response_type": ["code"], "code_challenge": [flow_manager.challenge], @@ -220,7 +217,7 @@ def test_oauth2_get_authorize_url_native_custom_params(native_client): # get url_and validate results url_res = native_client.oauth2_get_authorize_url() - verifier, remade_challenge = make_native_app_challenge("a" * 43) + verifier, remade_challenge = _make_native_app_challenge("a" * 43) parsed_url = urllib.parse.urlparse(url_res) assert f"https://{parsed_url.netloc}/" == native_client.base_url assert parsed_url.path == "/v2/oauth2/authorize" @@ -238,12 +235,9 @@ def test_oauth2_get_authorize_url_native_custom_params(native_client): def test_oauth2_get_authorize_url_confidential_defaults(confidential_client): - # default parameters for starting auth flow - # warns because no requested_scopes was passed - with pytest.warns(globus_sdk.RemovedInV4Warning): - flow_manager = globus_sdk.services.auth.GlobusAuthorizationCodeFlowManager( - confidential_client, "uri" - ) + flow_manager = globus_sdk.services.auth.GlobusAuthorizationCodeFlowManager( + confidential_client, "uri", TransferScopes.all + ) confidential_client.current_oauth2_flow_manager = flow_manager # get url_and validate results @@ -255,7 +249,7 @@ def test_oauth2_get_authorize_url_confidential_defaults(confidential_client): assert parsed_params == { "client_id": [confidential_client.client_id], "redirect_uri": ["uri"], - "scope": [f"openid profile email {TransferScopes.all}"], + "scope": [str(TransferScopes.all)], "state": ["_default"], "response_type": ["code"], "access_type": ["online"], @@ -304,7 +298,7 @@ def test_oauth2_exchange_code_for_tokens_native(native_client): with pytest.raises(globus_sdk.AuthAPIError) as excinfo: native_client.oauth2_exchange_code_for_tokens("invalid_code") assert excinfo.value.http_status == 401 - assert excinfo.value.code == "Error" + assert excinfo.value.code is None def test_oauth2_exchange_code_for_tokens_confidential(confidential_client): @@ -324,4 +318,4 @@ def test_oauth2_exchange_code_for_tokens_confidential(confidential_client): with pytest.raises(globus_sdk.AuthAPIError) as excinfo: confidential_client.oauth2_exchange_code_for_tokens("invalid_code") assert excinfo.value.http_status == 401 - assert excinfo.value.code == "Error" + assert excinfo.value.code is None diff --git a/tests/functional/services/auth/test_identity_map.py b/tests/functional/services/auth/test_identity_map.py index 8c944273c..46b18eef3 100644 --- a/tests/functional/services/auth/test_identity_map.py +++ b/tests/functional/services/auth/test_identity_map.py @@ -2,7 +2,7 @@ import responses import globus_sdk -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response IDENTITIES_MULTIPLE_RESPONSE = { "identities": [ diff --git a/tests/functional/services/compute/conftest.py b/tests/functional/services/compute/conftest.py index 8264cd0dc..563cd27af 100644 --- a/tests/functional/services/compute/conftest.py +++ b/tests/functional/services/compute/conftest.py @@ -4,16 +4,14 @@ @pytest.fixture -def compute_client_v2(no_retry_transport): - class CustomComputeClientV2(globus_sdk.ComputeClientV2): - transport_class = no_retry_transport - - return CustomComputeClientV2() +def compute_client_v2(): + client = globus_sdk.ComputeClientV2() + with client.retry_config.tune(max_retries=0): + yield client @pytest.fixture -def compute_client_v3(no_retry_transport): - class CustomComputeClientV3(globus_sdk.ComputeClientV3): - transport_class = no_retry_transport - - return CustomComputeClientV3() +def compute_client_v3(): + client = globus_sdk.ComputeClientV3() + with client.retry_config.tune(max_retries=0): + yield client diff --git a/tests/functional/services/compute/v2/test_delete_endpoint.py b/tests/functional/services/compute/v2/test_delete_endpoint.py index 0ab619080..a3043c45c 100644 --- a/tests/functional/services/compute/v2/test_delete_endpoint.py +++ b/tests/functional/services/compute/v2/test_delete_endpoint.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_delete_endpoint(compute_client_v2: globus_sdk.ComputeClientV2): diff --git a/tests/functional/services/compute/v2/test_delete_function.py b/tests/functional/services/compute/v2/test_delete_function.py index eab3f08b4..d10d9377a 100644 --- a/tests/functional/services/compute/v2/test_delete_function.py +++ b/tests/functional/services/compute/v2/test_delete_function.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_delete_function(compute_client_v2: globus_sdk.ComputeClientV2): diff --git a/tests/functional/services/compute/v2/test_get_endpoint.py b/tests/functional/services/compute/v2/test_get_endpoint.py index 58dd98019..0a8366e5c 100644 --- a/tests/functional/services/compute/v2/test_get_endpoint.py +++ b/tests/functional/services/compute/v2/test_get_endpoint.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_endpoint(compute_client_v2: globus_sdk.ComputeClientV2): diff --git a/tests/functional/services/compute/v2/test_get_endpoint_status.py b/tests/functional/services/compute/v2/test_get_endpoint_status.py index 487530e63..b8a00b992 100644 --- a/tests/functional/services/compute/v2/test_get_endpoint_status.py +++ b/tests/functional/services/compute/v2/test_get_endpoint_status.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_endpoint_status(compute_client_v2: globus_sdk.ComputeClientV2): diff --git a/tests/functional/services/compute/v2/test_get_endpoints.py b/tests/functional/services/compute/v2/test_get_endpoints.py index 4c8e07eb5..268fa29db 100644 --- a/tests/functional/services/compute/v2/test_get_endpoints.py +++ b/tests/functional/services/compute/v2/test_get_endpoints.py @@ -1,7 +1,7 @@ import urllib.parse import globus_sdk -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response def test_get_endpoints(compute_client_v2: globus_sdk.ComputeClientV2): diff --git a/tests/functional/services/compute/v2/test_get_function.py b/tests/functional/services/compute/v2/test_get_function.py index 1da30dbea..c5867b218 100644 --- a/tests/functional/services/compute/v2/test_get_function.py +++ b/tests/functional/services/compute/v2/test_get_function.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_function(compute_client_v2: globus_sdk.ComputeClientV2): diff --git a/tests/functional/services/compute/v2/test_get_result_amqp_url.py b/tests/functional/services/compute/v2/test_get_result_amqp_url.py index 6f148bac6..6c1edd2dc 100644 --- a/tests/functional/services/compute/v2/test_get_result_amqp_url.py +++ b/tests/functional/services/compute/v2/test_get_result_amqp_url.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_result_amqp_url(compute_client_v2: globus_sdk.ComputeClientV2): diff --git a/tests/functional/services/compute/v2/test_get_task_batch.py b/tests/functional/services/compute/v2/test_get_task_batch.py index 4bdcaafad..190bd2e0f 100644 --- a/tests/functional/services/compute/v2/test_get_task_batch.py +++ b/tests/functional/services/compute/v2/test_get_task_batch.py @@ -4,7 +4,7 @@ import pytest import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response @pytest.mark.parametrize( diff --git a/tests/functional/services/compute/v2/test_get_task_group.py b/tests/functional/services/compute/v2/test_get_task_group.py index dc2368d39..34a2c7660 100644 --- a/tests/functional/services/compute/v2/test_get_task_group.py +++ b/tests/functional/services/compute/v2/test_get_task_group.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_task_group(compute_client_v2: globus_sdk.ComputeClientV2): diff --git a/tests/functional/services/compute/v2/test_get_task_info.py b/tests/functional/services/compute/v2/test_get_task_info.py index aeadccf10..2394a9af2 100644 --- a/tests/functional/services/compute/v2/test_get_task_info.py +++ b/tests/functional/services/compute/v2/test_get_task_info.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_task(compute_client_v2: globus_sdk.ComputeClientV2): diff --git a/tests/functional/services/compute/v2/test_get_version.py b/tests/functional/services/compute/v2/test_get_version.py index 6b2b8594e..3b287b6dd 100644 --- a/tests/functional/services/compute/v2/test_get_version.py +++ b/tests/functional/services/compute/v2/test_get_version.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_version(compute_client_v2: globus_sdk.ComputeClientV2): diff --git a/tests/functional/services/compute/v2/test_lock_endpoint.py b/tests/functional/services/compute/v2/test_lock_endpoint.py index 1848212a6..2f3736c02 100644 --- a/tests/functional/services/compute/v2/test_lock_endpoint.py +++ b/tests/functional/services/compute/v2/test_lock_endpoint.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_lock_endpoint(compute_client_v2: globus_sdk.ComputeClientV2): diff --git a/tests/functional/services/compute/v2/test_register_endpoint.py b/tests/functional/services/compute/v2/test_register_endpoint.py index 6d9d094f0..66310dabe 100644 --- a/tests/functional/services/compute/v2/test_register_endpoint.py +++ b/tests/functional/services/compute/v2/test_register_endpoint.py @@ -1,7 +1,7 @@ import uuid import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response ENDPOINT_CONFIG = """ display_name: My Endpoint diff --git a/tests/functional/services/compute/v2/test_register_function.py b/tests/functional/services/compute/v2/test_register_function.py index 20b0e966d..55390bf6a 100644 --- a/tests/functional/services/compute/v2/test_register_function.py +++ b/tests/functional/services/compute/v2/test_register_function.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_register_function(compute_client_v2: globus_sdk.ComputeClientV2): @@ -8,6 +8,6 @@ def test_register_function(compute_client_v2: globus_sdk.ComputeClientV2): "function_name": meta["function_name"], "function_code": meta["function_code"], } - res = compute_client_v2.register_function(function_data=registration_doc) + res = compute_client_v2.register_function(data=registration_doc) assert res.http_status == 200 assert res.data["function_uuid"] == meta["function_id"] diff --git a/tests/functional/services/compute/v2/test_submit.py b/tests/functional/services/compute/v2/test_submit.py index 90bc307f4..a208f258e 100644 --- a/tests/functional/services/compute/v2/test_submit.py +++ b/tests/functional/services/compute/v2/test_submit.py @@ -1,7 +1,7 @@ import uuid import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_submit(compute_client_v2: globus_sdk.ComputeClientV2): diff --git a/tests/functional/services/compute/v3/test_get_endpoint_allowlist.py b/tests/functional/services/compute/v3/test_get_endpoint_allowlist.py index 5c5ac653a..72ab162ec 100644 --- a/tests/functional/services/compute/v3/test_get_endpoint_allowlist.py +++ b/tests/functional/services/compute/v3/test_get_endpoint_allowlist.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_endpoint_allowlist(compute_client_v3: globus_sdk.ComputeClientV3): diff --git a/tests/functional/services/compute/v3/test_lock_endpoint.py b/tests/functional/services/compute/v3/test_lock_endpoint.py index d2d7558c4..6b91718c8 100644 --- a/tests/functional/services/compute/v3/test_lock_endpoint.py +++ b/tests/functional/services/compute/v3/test_lock_endpoint.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_lock_endpoint(compute_client_v3: globus_sdk.ComputeClientV3): diff --git a/tests/functional/services/compute/v3/test_register_endpoint.py b/tests/functional/services/compute/v3/test_register_endpoint.py index 94abfe5bb..945a9be30 100644 --- a/tests/functional/services/compute/v3/test_register_endpoint.py +++ b/tests/functional/services/compute/v3/test_register_endpoint.py @@ -1,7 +1,7 @@ import uuid import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response ENDPOINT_CONFIG = """ display_name: My Endpoint diff --git a/tests/functional/services/compute/v3/test_register_function.py b/tests/functional/services/compute/v3/test_register_function.py index c16ff0d74..77990b457 100644 --- a/tests/functional/services/compute/v3/test_register_function.py +++ b/tests/functional/services/compute/v3/test_register_function.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_register_function(compute_client_v3: globus_sdk.ComputeClientV3): diff --git a/tests/functional/services/compute/v3/test_submit.py b/tests/functional/services/compute/v3/test_submit.py index 2560938c6..2e5f02f6a 100644 --- a/tests/functional/services/compute/v3/test_submit.py +++ b/tests/functional/services/compute/v3/test_submit.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_submit(compute_client_v3: globus_sdk.ComputeClientV3): diff --git a/tests/functional/services/compute/v3/test_update_endpoint.py b/tests/functional/services/compute/v3/test_update_endpoint.py index 987680e7c..e7830e902 100644 --- a/tests/functional/services/compute/v3/test_update_endpoint.py +++ b/tests/functional/services/compute/v3/test_update_endpoint.py @@ -1,7 +1,7 @@ import uuid import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response ENDPOINT_CONFIG = """ display_name: My Endpoint diff --git a/tests/functional/services/flows/conftest.py b/tests/functional/services/flows/conftest.py index 0bdd788d9..ed321520a 100644 --- a/tests/functional/services/flows/conftest.py +++ b/tests/functional/services/flows/conftest.py @@ -6,18 +6,17 @@ @pytest.fixture -def flows_client(no_retry_transport): - class CustomFlowsClient(globus_sdk.FlowsClient): - transport_class = no_retry_transport - - return CustomFlowsClient() +def flows_client(): + client = globus_sdk.FlowsClient() + with client.retry_config.tune(max_retries=0): + yield client @pytest.fixture -def specific_flow_client_class( - no_retry_transport, -) -> t.Type[globus_sdk.SpecificFlowClient]: +def specific_flow_client_class() -> t.Type[globus_sdk.SpecificFlowClient]: class CustomSpecificFlowClient(globus_sdk.SpecificFlowClient): - transport_class = no_retry_transport + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.retry_config.max_retries = 0 return CustomSpecificFlowClient diff --git a/tests/functional/services/flows/test_flow_crud.py b/tests/functional/services/flows/test_flow_crud.py index e0ebe3c80..df1ef8502 100644 --- a/tests/functional/services/flows/test_flow_crud.py +++ b/tests/functional/services/flows/test_flow_crud.py @@ -3,12 +3,12 @@ import pytest from responses import matchers -from globus_sdk import FlowsAPIError -from globus_sdk._testing import get_last_request, load_response -from globus_sdk._testing.models import RegisteredResponse +from globus_sdk import MISSING, FlowsAPIError +from globus_sdk.testing import get_last_request, load_response +from globus_sdk.testing.models import RegisteredResponse -@pytest.mark.parametrize("subscription_id", [None, "dummy_subscription_id"]) +@pytest.mark.parametrize("subscription_id", [MISSING, None, "dummy_subscription_id"]) def test_create_flow(flows_client, subscription_id): metadata = load_response(flows_client.create_flow).metadata @@ -19,13 +19,13 @@ def test_create_flow(flows_client, subscription_id): last_req = get_last_request() req_body = json.loads(last_req.body) - if subscription_id: + if subscription_id is not MISSING: assert req_body["subscription_id"] == subscription_id else: assert "subscription_id" not in req_body -@pytest.mark.parametrize("value", [None, [], ["dummy_value"]]) +@pytest.mark.parametrize("value", [MISSING, [], ["dummy_value"]]) @pytest.mark.parametrize("key", ["run_managers", "run_monitors"]) def test_create_flow_run_role_serialization(flows_client, key, value): @@ -40,7 +40,8 @@ def test_create_flow_run_role_serialization(flows_client, key, value): "input_schema": {}, } - request_body[key] = value + if value is not MISSING: + request_body[key] = value load_response( RegisteredResponse( @@ -66,7 +67,7 @@ def test_create_flow_run_role_serialization(flows_client, key, value): last_req = get_last_request() req_body = json.loads(last_req.body) - if value is None: + if value is MISSING: assert key not in req_body else: assert req_body[key] == value @@ -108,7 +109,7 @@ def test_update_flow(flows_client): assert resp[k] == v -@pytest.mark.parametrize("value", [None, [], ["dummy_value"]]) +@pytest.mark.parametrize("value", [MISSING, [], ["dummy_value"]]) @pytest.mark.parametrize("key", ["run_managers", "run_monitors"]) def test_update_flow_run_role_serialization(flows_client, key, value): metadata = load_response(flows_client.update_flow).metadata @@ -119,7 +120,7 @@ def test_update_flow_run_role_serialization(flows_client, key, value): last_req = get_last_request() req_body = json.loads(last_req.body) - if value is None: + if value is MISSING: assert key not in req_body else: assert req_body[key] == value diff --git a/tests/functional/services/flows/test_flow_validate.py b/tests/functional/services/flows/test_flow_validate.py index 4ff2a4d97..0497343a2 100644 --- a/tests/functional/services/flows/test_flow_validate.py +++ b/tests/functional/services/flows/test_flow_validate.py @@ -2,17 +2,17 @@ import pytest -from globus_sdk import FlowsAPIError -from globus_sdk._testing import get_last_request, load_response +from globus_sdk import MISSING, FlowsAPIError +from globus_sdk.testing import get_last_request, load_response -@pytest.mark.parametrize("input_schema", [None, {}]) +@pytest.mark.parametrize("input_schema", [MISSING, {}]) def test_validate_flow(flows_client, input_schema): metadata = load_response(flows_client.validate_flow).metadata # Prepare the payload payload = {"definition": metadata["success"]} - if input_schema is not None: + if input_schema is not MISSING: payload["input_schema"] = input_schema resp = flows_client.validate_flow(**payload) diff --git a/tests/functional/services/flows/test_get_run.py b/tests/functional/services/flows/test_get_run.py index 4e745dee5..cf50b3a42 100644 --- a/tests/functional/services/flows/test_get_run.py +++ b/tests/functional/services/flows/test_get_run.py @@ -1,9 +1,10 @@ import pytest -from globus_sdk._testing import get_last_request, load_response +from globus_sdk import MISSING +from globus_sdk.testing import get_last_request, load_response -@pytest.mark.parametrize("include_flow_description", (None, False, True)) +@pytest.mark.parametrize("include_flow_description", (MISSING, False, True)) def test_get_run(flows_client, include_flow_description): metadata = load_response(flows_client.get_run).metadata @@ -14,7 +15,7 @@ def test_get_run(flows_client, include_flow_description): assert response.http_status == 200 request = get_last_request() - if include_flow_description is None: + if include_flow_description is MISSING: assert "flow_description" not in response assert "include_flow_description" not in request.url elif include_flow_description is False: diff --git a/tests/functional/services/flows/test_get_run_logs.py b/tests/functional/services/flows/test_get_run_logs.py index 70961659a..5ff00eb6e 100644 --- a/tests/functional/services/flows/test_get_run_logs.py +++ b/tests/functional/services/flows/test_get_run_logs.py @@ -1,4 +1,4 @@ -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_run_logs(flows_client): diff --git a/tests/functional/services/flows/test_list_flows.py b/tests/functional/services/flows/test_list_flows.py index 8ba22ab4a..9632c52e7 100644 --- a/tests/functional/services/flows/test_list_flows.py +++ b/tests/functional/services/flows/test_list_flows.py @@ -2,32 +2,25 @@ import pytest -from globus_sdk import GlobusSDKUsageError, RemovedInV4Warning -from globus_sdk._testing import get_last_request, load_response +from globus_sdk import MISSING +from globus_sdk.testing import get_last_request, load_response -@pytest.mark.parametrize("filter_fulltext", [None, "foo"]) -@pytest.mark.parametrize("filter_role", [None, "bar"]) -@pytest.mark.parametrize("orderby", [None, "created_at ASC"]) -def test_list_flows_simple(flows_client, filter_fulltext, filter_role, orderby): +@pytest.mark.parametrize("filter_fulltext", [MISSING, "foo"]) +@pytest.mark.parametrize("filter_roles", [MISSING, "bar"]) +@pytest.mark.parametrize("orderby", [MISSING, "created_at ASC"]) +def test_list_flows_simple(flows_client, filter_fulltext, filter_roles, orderby): meta = load_response(flows_client.list_flows).metadata add_kwargs = {} if filter_fulltext: add_kwargs["filter_fulltext"] = filter_fulltext - if filter_role: - add_kwargs["filter_role"] = filter_role + if filter_roles: + add_kwargs["filter_roles"] = filter_roles if orderby: add_kwargs["orderby"] = orderby - if filter_role: - with pytest.warns( - RemovedInV4Warning, - match=r"The `filter_role` parameter is deprecated.*", - ): - res = flows_client.list_flows(**add_kwargs) - else: - res = flows_client.list_flows(**add_kwargs) + res = flows_client.list_flows(**add_kwargs) assert res.http_status == 200 # dict-like indexing @@ -42,10 +35,10 @@ def test_list_flows_simple(flows_client, filter_fulltext, filter_role, orderby): k: [v] for k, v in ( ("filter_fulltext", filter_fulltext), - ("filter_role", filter_role), + ("filter_roles", filter_roles), ("orderby", orderby), ) - if v is not None + if v is not MISSING } assert parsed_qs == expect_query_params @@ -120,35 +113,6 @@ def test_list_flows_orderby_multi(flows_client, orderby_style, orderby_value): assert parsed_qs["orderby"] == expected_orderby_value -@pytest.mark.parametrize( - "filter_role, filter_roles", - [ - # empty string values - ("", ""), - ("", []), - ("", [""]), - # single string values - ("bar", "baz"), - # list values - ("bar", ["baz"]), - ("bar", ["baz", "qux"]), - # comma-separated string - ("bar", "baz,qux"), - # empty list - ("bar", []), - # list containing empty string - ("bar", [""]), - # list containing multiple empty strings - ("bar", ["", ""]), - # list containing mixed values - ("bar", ["baz", "", "qux"]), - ], -) -def test_list_flows_mutually_exclusive_roles(flows_client, filter_role, filter_roles): - with pytest.raises(GlobusSDKUsageError), pytest.warns(RemovedInV4Warning): - flows_client.list_flows(filter_role=filter_role, filter_roles=filter_roles) - - @pytest.mark.parametrize( "filter_roles, expected_filter_roles", [ @@ -157,7 +121,7 @@ def test_list_flows_mutually_exclusive_roles(flows_client, filter_role, filter_r ([], None), ([""], None), (("",), None), - (None, None), + (MISSING, None), # single role as string ("foo", ["foo"]), # single role as list/tuple diff --git a/tests/functional/services/flows/test_list_runs.py b/tests/functional/services/flows/test_list_runs.py index 3be221aa0..350adae1b 100644 --- a/tests/functional/services/flows/test_list_runs.py +++ b/tests/functional/services/flows/test_list_runs.py @@ -3,7 +3,8 @@ import pytest -from globus_sdk._testing import get_last_request, load_response +from globus_sdk import MISSING +from globus_sdk.testing import get_last_request, load_response def test_list_runs_simple(flows_client): @@ -79,7 +80,7 @@ def test_list_runs_filter_flow_id(flows_client, pass_as_uuids): ((), None), ([""], None), (("",), None), - (None, None), + (MISSING, None), # single role as string ("foo", ["foo"]), # single role as list/tuple diff --git a/tests/functional/services/flows/test_resume_run.py b/tests/functional/services/flows/test_resume_run.py index 6ff23307b..bd1bfbc83 100644 --- a/tests/functional/services/flows/test_resume_run.py +++ b/tests/functional/services/flows/test_resume_run.py @@ -1,7 +1,7 @@ import typing as t from globus_sdk import SpecificFlowClient -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_resume_run(specific_flow_client_class: t.Type[SpecificFlowClient]): diff --git a/tests/functional/services/flows/test_run_crud.py b/tests/functional/services/flows/test_run_crud.py index d8596971b..e9c30de53 100644 --- a/tests/functional/services/flows/test_run_crud.py +++ b/tests/functional/services/flows/test_run_crud.py @@ -3,7 +3,7 @@ import pytest from globus_sdk import FlowsAPIError -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response def test_get_run_definition(flows_client): diff --git a/tests/functional/services/flows/test_run_flow.py b/tests/functional/services/flows/test_run_flow.py index c836b5911..116ba3876 100644 --- a/tests/functional/services/flows/test_run_flow.py +++ b/tests/functional/services/flows/test_run_flow.py @@ -6,7 +6,7 @@ import globus_sdk from globus_sdk import FlowsAPIError, SpecificFlowClient -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response def test_run_flow(specific_flow_client_class: type[SpecificFlowClient]): diff --git a/tests/functional/services/flows/test_validate_run.py b/tests/functional/services/flows/test_validate_run.py index 20e3ba1d8..980392c71 100644 --- a/tests/functional/services/flows/test_validate_run.py +++ b/tests/functional/services/flows/test_validate_run.py @@ -3,7 +3,7 @@ import pytest from globus_sdk import FlowsAPIError, SpecificFlowClient -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_validate_run(specific_flow_client_class: type[SpecificFlowClient]): diff --git a/tests/functional/services/gcs/conftest.py b/tests/functional/services/gcs/conftest.py index 8a098e5f6..3533a6cb0 100644 --- a/tests/functional/services/gcs/conftest.py +++ b/tests/functional/services/gcs/conftest.py @@ -1,12 +1,11 @@ import pytest -from globus_sdk import GCSClient +import globus_sdk @pytest.fixture -def client(no_retry_transport): - class CustomGCSClient(GCSClient): - transport_class = no_retry_transport - +def client(): # default fqdn for GCS client testing - return CustomGCSClient("abc.xyz.data.globus.org") + client = globus_sdk.GCSClient("abc.xyz.data.globus.org") + with client.retry_config.tune(max_retries=0): + yield client diff --git a/tests/functional/services/gcs/test_endpoints.py b/tests/functional/services/gcs/test_endpoints.py index 34499ed9c..2c4de1f51 100644 --- a/tests/functional/services/gcs/test_endpoints.py +++ b/tests/functional/services/gcs/test_endpoints.py @@ -1,5 +1,5 @@ import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_endpoint(client): diff --git a/tests/functional/services/gcs/test_get_collection_list.py b/tests/functional/services/gcs/test_get_collection_list.py index 12382aae4..eae2c7ee9 100644 --- a/tests/functional/services/gcs/test_get_collection_list.py +++ b/tests/functional/services/gcs/test_get_collection_list.py @@ -1,7 +1,7 @@ import pytest -from globus_sdk import GCSAPIError -from globus_sdk._testing import get_last_request, load_response +from globus_sdk import MISSING, GCSAPIError +from globus_sdk.testing import get_last_request, load_response def test_get_collection_list(client): @@ -22,7 +22,7 @@ def test_get_collection_list(client): @pytest.mark.parametrize( "include_param, expected", ( - (None, None), + (MISSING, None), ("foo", "foo"), ("foo,bar", "foo,bar"), (("foo", "bar"), "foo,bar"), @@ -32,7 +32,7 @@ def test_get_collection_list_include_param(client, include_param, expected): load_response(client.get_collection_list) client.get_collection_list(include=include_param) req = get_last_request() - if include_param is not None: + if include_param is not MISSING: assert "include" in req.params assert req.params["include"] == expected else: diff --git a/tests/functional/services/gcs/test_get_gcs_info.py b/tests/functional/services/gcs/test_get_gcs_info.py index a20d1256e..8324dd8db 100644 --- a/tests/functional/services/gcs/test_get_gcs_info.py +++ b/tests/functional/services/gcs/test_get_gcs_info.py @@ -1,5 +1,5 @@ -from globus_sdk._testing import get_last_request, load_response from globus_sdk.authorizers import AccessTokenAuthorizer +from globus_sdk.testing import get_last_request, load_response def test_get_gcs_info(client): diff --git a/tests/functional/services/gcs/test_roles.py b/tests/functional/services/gcs/test_roles.py index 2fae98d2c..2bc545f7e 100644 --- a/tests/functional/services/gcs/test_roles.py +++ b/tests/functional/services/gcs/test_roles.py @@ -1,7 +1,7 @@ import json from globus_sdk import GCSRoleDocument -from globus_sdk._testing import get_last_request +from globus_sdk.testing import get_last_request from tests.common import register_api_route_fixture_file diff --git a/tests/functional/services/gcs/test_scope_helpers.py b/tests/functional/services/gcs/test_scope_helpers.py index 747411e32..0441a0755 100644 --- a/tests/functional/services/gcs/test_scope_helpers.py +++ b/tests/functional/services/gcs/test_scope_helpers.py @@ -5,29 +5,28 @@ def test_manage_collections_scope_helper(client): - sb = client.get_gcs_endpoint_scopes(zero_id) + sc = client.get_gcs_endpoint_scopes(zero_id) assert ( - sb.manage_collections == f"urn:globus:auth:scope:{zero_id_s}:manage_collections" + str(sc.manage_collections) + == f"urn:globus:auth:scope:{zero_id_s}:manage_collections" ) # data_access is separated from endpoint scopes - assert not hasattr(sb, "data_access") + assert not hasattr(sc, "data_access") def test_data_access_scope_helper(client): - sb = client.get_gcs_collection_scopes(zero_id) - assert sb.data_access == f"https://auth.globus.org/scopes/{zero_id_s}/data_access" - assert sb.https == f"https://auth.globus.org/scopes/{zero_id_s}/https" + sc = client.get_gcs_collection_scopes(zero_id) + assert ( + str(sc.data_access) == f"https://auth.globus.org/scopes/{zero_id_s}/data_access" + ) + assert str(sc.https) == f"https://auth.globus.org/scopes/{zero_id_s}/https" # manage_collections is separated from collection scopes - assert not hasattr(sb, "manage_collections") - - -def test_str_contains_scope_properties(client): - ep_sb = client.get_gcs_endpoint_scopes(zero_id) + assert not hasattr(sc, "manage_collections") - assert "manage_collections" in str(ep_sb) - assert ep_sb.manage_collections in str(ep_sb) - collection_sb = client.get_gcs_collection_scopes(zero_id) +def test_contains_scope_properties(client): + ep_sc = client.get_gcs_endpoint_scopes(zero_id) + assert ep_sc.manage_collections in list(ep_sc) - assert "data_access" in str(collection_sb) - assert collection_sb.data_access in str(collection_sb) + collection_sc = client.get_gcs_collection_scopes(zero_id) + assert collection_sc.data_access in list(collection_sc) diff --git a/tests/functional/services/gcs/test_storage_gateways.py b/tests/functional/services/gcs/test_storage_gateways.py index 83e3328e5..f4c935ea0 100644 --- a/tests/functional/services/gcs/test_storage_gateways.py +++ b/tests/functional/services/gcs/test_storage_gateways.py @@ -3,12 +3,13 @@ import pytest import globus_sdk -from globus_sdk._testing import get_last_request, load_response +from globus_sdk import MISSING +from globus_sdk.testing import get_last_request, load_response @pytest.mark.parametrize( "include_param", - [None, "private_policies", "private_policies,foo", ("private_policies", "foo")], + [MISSING, "private_policies", "private_policies,foo", ("private_policies", "foo")], ) def test_get_storage_gateway_list(client, include_param): meta = load_response(client.get_storage_gateway_list).metadata @@ -28,7 +29,7 @@ def test_get_storage_gateway_list(client, include_param): req = get_last_request() assert req.body is None parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) - if include_param is None: + if include_param is MISSING: assert parsed_qs == {} elif isinstance(include_param, str): assert parsed_qs == {"include": [include_param]} @@ -65,7 +66,7 @@ def test_create_storage_gateway_validation_error(client): @pytest.mark.parametrize( "include_param", - [None, "private_policies", "private_policies,foo", ("private_policies", "foo")], + [MISSING, "private_policies", "private_policies,foo", ("private_policies", "foo")], ) def test_get_storage_gateway(client, include_param): meta = load_response(client.get_storage_gateway).metadata @@ -80,7 +81,7 @@ def test_get_storage_gateway(client, include_param): req = get_last_request() assert req.body is None parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) - if include_param is None: + if include_param is MISSING: assert parsed_qs == {} elif isinstance(include_param, str): assert parsed_qs == {"include": [include_param]} diff --git a/tests/functional/services/gcs/test_user_credential.py b/tests/functional/services/gcs/test_user_credential.py index b302552ce..61c092d7d 100644 --- a/tests/functional/services/gcs/test_user_credential.py +++ b/tests/functional/services/gcs/test_user_credential.py @@ -1,7 +1,7 @@ import json from globus_sdk import ConnectorTable, UserCredentialDocument -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response def test_get_user_credential_list(client): diff --git a/tests/functional/services/groups/conftest.py b/tests/functional/services/groups/conftest.py index 597dda417..76b5d9459 100644 --- a/tests/functional/services/groups/conftest.py +++ b/tests/functional/services/groups/conftest.py @@ -4,11 +4,10 @@ @pytest.fixture -def groups_client(no_retry_transport): - class CustomGroupsClient(globus_sdk.GroupsClient): - transport_class = no_retry_transport - - return CustomGroupsClient() +def groups_client(): + client = globus_sdk.GroupsClient() + with client.retry_config.tune(max_retries=0): + yield client @pytest.fixture diff --git a/tests/functional/services/groups/test_create_group.py b/tests/functional/services/groups/test_create_group.py index 209a82361..b630a948c 100644 --- a/tests/functional/services/groups/test_create_group.py +++ b/tests/functional/services/groups/test_create_group.py @@ -1,6 +1,6 @@ import json -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response def test_create_group(groups_client): diff --git a/tests/functional/services/groups/test_delete_group.py b/tests/functional/services/groups/test_delete_group.py index a3ff9a679..cf2b86361 100644 --- a/tests/functional/services/groups/test_delete_group.py +++ b/tests/functional/services/groups/test_delete_group.py @@ -1,4 +1,4 @@ -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_delete_group(groups_client): diff --git a/tests/functional/services/groups/test_get_group.py b/tests/functional/services/groups/test_get_group.py index 58ea84575..ed3902f04 100644 --- a/tests/functional/services/groups/test_get_group.py +++ b/tests/functional/services/groups/test_get_group.py @@ -2,7 +2,7 @@ import pytest -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response def test_get_group(groups_client): diff --git a/tests/functional/services/groups/test_get_group_by_subscription_id.py b/tests/functional/services/groups/test_get_group_by_subscription_id.py index 455597503..432c4790f 100644 --- a/tests/functional/services/groups/test_get_group_by_subscription_id.py +++ b/tests/functional/services/groups/test_get_group_by_subscription_id.py @@ -1,4 +1,4 @@ -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_get_group_by_subscription_id(groups_client): diff --git a/tests/functional/services/groups/test_get_my_groups.py b/tests/functional/services/groups/test_get_my_groups.py index 178bbf7b2..3afeaa34e 100644 --- a/tests/functional/services/groups/test_get_my_groups.py +++ b/tests/functional/services/groups/test_get_my_groups.py @@ -1,7 +1,7 @@ import urllib.parse -from globus_sdk._testing import get_last_request, load_response from globus_sdk.response import ArrayResponse +from globus_sdk.testing import get_last_request, load_response def test_get_my_groups(groups_client): diff --git a/tests/functional/services/groups/test_group_memberships.py b/tests/functional/services/groups/test_group_memberships.py index d982af54d..18adf173f 100644 --- a/tests/functional/services/groups/test_group_memberships.py +++ b/tests/functional/services/groups/test_group_memberships.py @@ -4,7 +4,7 @@ import pytest from globus_sdk import BatchMembershipActions, GroupRole -from globus_sdk._testing import RegisteredResponse, get_last_request, load_response +from globus_sdk.testing import RegisteredResponse, get_last_request, load_response from tests.common import register_api_route_fixture_file @@ -63,7 +63,7 @@ def test_batch_action_payload(groups_client, role): group_id = str(uuid.uuid1()) load_response( RegisteredResponse( - service="groups", method="POST", path=f"/groups/{group_id}", json={} + service="groups", method="POST", path=f"/v2/groups/{group_id}", json={} ) ) rolestr = role if isinstance(role, str) else role.value diff --git a/tests/functional/services/groups/test_set_group_policies.py b/tests/functional/services/groups/test_set_group_policies.py index 7406967a2..e181ee1d4 100644 --- a/tests/functional/services/groups/test_set_group_policies.py +++ b/tests/functional/services/groups/test_set_group_policies.py @@ -3,13 +3,13 @@ import pytest from globus_sdk import ( + MISSING, GroupMemberVisibility, GroupPolicies, GroupRequiredSignupFields, GroupVisibility, - utils, ) -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response @pytest.mark.parametrize( @@ -87,7 +87,7 @@ def test_set_group_policies( GroupMemberVisibility.managers, ["address1"], ["address1"], - utils.MISSING, + MISSING, ), ( "private", @@ -147,7 +147,7 @@ def test_set_group_policies_explicit_payload( # check the authentication_assurance_timeout # it should be omitted if it's MISSING - if auth_timeout is utils.MISSING: + if auth_timeout is MISSING: assert "authentication_assurance_timeout" not in req_body else: assert req_body["authentication_assurance_timeout"] == auth_timeout diff --git a/tests/functional/services/groups/test_set_subscription_admin_verified.py b/tests/functional/services/groups/test_set_subscription_admin_verified.py index 5fca7626c..84a07682b 100644 --- a/tests/functional/services/groups/test_set_subscription_admin_verified.py +++ b/tests/functional/services/groups/test_set_subscription_admin_verified.py @@ -1,10 +1,6 @@ import json -import re -import pytest - -from globus_sdk import RemovedInV4Warning -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response def test_set_subscription_admin_verified(groups_client): @@ -21,27 +17,3 @@ def test_set_subscription_admin_verified(groups_client): req = get_last_request() req = json.loads(req.body) assert req == {"subscription_admin_verified_id": meta["subscription_id"]} - - -def test_set_subscription_admin_verified_id(groups_client): - """Test that the deprecated alias warns but is functionally equivalent.""" - meta = load_response(groups_client.set_subscription_admin_verified).metadata - - with pytest.warns( - RemovedInV4Warning, - match=re.escape( - "`GroupsClient.set_subscription_admin_verified_id()` has been renamed to " - "`GroupsClient.set_subscription_admin_verified()`." - ), - ): - res = groups_client.set_subscription_admin_verified_id( - group_id=meta["group_id"], - subscription_id=meta["subscription_id"], - ) - assert res.http_status == 200 - assert res.data["group_id"] == meta["group_id"] - assert res.data["subscription_admin_verified_id"] == meta["subscription_id"] - - req = get_last_request() - req = json.loads(req.body) - assert req == {"subscription_admin_verified_id": meta["subscription_id"]} diff --git a/tests/functional/services/search/conftest.py b/tests/functional/services/search/conftest.py index 6a0a8ac2a..82c2aa1e3 100644 --- a/tests/functional/services/search/conftest.py +++ b/tests/functional/services/search/conftest.py @@ -4,8 +4,7 @@ @pytest.fixture -def client(no_retry_transport): - class CustomSearchClient(globus_sdk.SearchClient): - transport_class = no_retry_transport - - return CustomSearchClient() +def client(): + client = globus_sdk.SearchClient() + with client.retry_config.tune(max_retries=0): + yield client diff --git a/tests/functional/services/search/test_batch_delete_by_subject.py b/tests/functional/services/search/test_batch_delete_by_subject.py index 1814380f1..c3cf2a2c7 100644 --- a/tests/functional/services/search/test_batch_delete_by_subject.py +++ b/tests/functional/services/search/test_batch_delete_by_subject.py @@ -1,6 +1,6 @@ import json -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response def test_batch_delete_by_subject(client): diff --git a/tests/functional/services/search/test_create_entry.py b/tests/functional/services/search/test_create_entry.py deleted file mode 100644 index d00bfa873..000000000 --- a/tests/functional/services/search/test_create_entry.py +++ /dev/null @@ -1,6 +0,0 @@ -import pytest - - -@pytest.mark.xfail -def test_create_entry(): - raise NotImplementedError diff --git a/tests/functional/services/search/test_create_index.py b/tests/functional/services/search/test_create_index.py index e2310d6d7..93ad691d5 100644 --- a/tests/functional/services/search/test_create_index.py +++ b/tests/functional/services/search/test_create_index.py @@ -1,7 +1,7 @@ import pytest import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_create_index(client): diff --git a/tests/functional/services/search/test_delete_index.py b/tests/functional/services/search/test_delete_index.py index 835c62b63..45d8f0fc3 100644 --- a/tests/functional/services/search/test_delete_index.py +++ b/tests/functional/services/search/test_delete_index.py @@ -1,7 +1,7 @@ import pytest import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_delete_index(client): diff --git a/tests/functional/services/search/test_index_list.py b/tests/functional/services/search/test_index_list.py index b66020272..be2d2b494 100644 --- a/tests/functional/services/search/test_index_list.py +++ b/tests/functional/services/search/test_index_list.py @@ -1,4 +1,4 @@ -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_search_index_list(client): diff --git a/tests/functional/services/search/test_reopen_index.py b/tests/functional/services/search/test_reopen_index.py index b5ef9a05a..8f6a034b9 100644 --- a/tests/functional/services/search/test_reopen_index.py +++ b/tests/functional/services/search/test_reopen_index.py @@ -1,7 +1,7 @@ import pytest import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_reopen_index(client): diff --git a/tests/functional/services/search/test_search.py b/tests/functional/services/search/test_search.py index 97c17b29d..442bffd63 100644 --- a/tests/functional/services/search/test_search.py +++ b/tests/functional/services/search/test_search.py @@ -6,16 +6,16 @@ import responses import globus_sdk -from globus_sdk._testing import get_last_request, load_response +from globus_sdk._missing import filter_missing +from globus_sdk.testing import get_last_request, load_response from tests.common import register_api_route_fixture_file @pytest.fixture -def search_client(no_retry_transport): - class CustomSearchClient(globus_sdk.SearchClient): - transport_class = no_retry_transport - - return CustomSearchClient() +def search_client(): + client = globus_sdk.SearchClient() + with client.retry_config.tune(max_retries=0): + yield client def test_search_query_simple(search_client): @@ -31,12 +31,7 @@ def test_search_query_simple(search_client): req = get_last_request() assert req.body is None parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) - assert parsed_qs == { - "q": ["foo"], - "advanced": ["False"], - "limit": ["10"], - "offset": ["0"], - } + assert parsed_qs == {"q": ["foo"]} @pytest.mark.parametrize("query_doc", [{"q": "foo"}, {"q": "foo", "limit": 10}]) @@ -56,26 +51,6 @@ def test_search_post_query_simple(search_client, query_doc): assert req_body == dict(query_doc) -def test_search_post_query_with_legacy_helper(search_client): - meta = load_response(search_client.post_search).metadata - with pytest.warns( - globus_sdk.RemovedInV4Warning, match="'SearchQuery' is deprecated" - ): - query_doc = globus_sdk.SearchQuery("foo") - - res = search_client.post_search(meta["index_id"], query_doc) - assert res.http_status == 200 - - data = res.data - assert isinstance(data, dict) - assert data["gmeta"][0]["entries"][0]["content"]["foo"] == "bar" - - req = get_last_request() - assert req.body is not None - req_body = json.loads(req.body) - assert req_body == dict(query_doc) - - def test_search_post_query_simple_with_v1_helper(search_client): query_doc = globus_sdk.SearchQueryV1(q="foo") meta = load_response(search_client.post_search).metadata @@ -93,36 +68,16 @@ def test_search_post_query_simple_with_v1_helper(search_client): assert req_body == {"@version": "query#1.0.0", "q": "foo"} -def test_search_post_query_arg_overrides(search_client): - meta = load_response(search_client.post_search).metadata - - query_doc = {"q": "foo", "limit": 10, "offset": 0} - res = search_client.post_search(meta["index_id"], query_doc, limit=100, offset=150) - assert res.http_status == 200 - - data = res.data - assert isinstance(data, dict) - assert data["gmeta"][0]["entries"][0]["content"]["foo"] == "bar" - - req = get_last_request() - assert req.body is not None - req_body = json.loads(req.body) - assert req_body != dict(query_doc) - assert req_body["q"] == query_doc["q"] - assert req_body["limit"] == 100 - assert req_body["offset"] == 150 - # important! these should be unchanged (no side-effects) - assert query_doc["limit"] == 10 - assert query_doc["offset"] == 0 - - -def test_search_post_query_arg_overrides_with_legacy_helper(search_client): +@pytest.mark.parametrize("doc_type", ("dict", "helper")) +def test_search_post_query_arg_overrides(search_client, doc_type): meta = load_response(search_client.post_search).metadata - with pytest.warns( - globus_sdk.RemovedInV4Warning, match="'SearchQuery' is deprecated" - ): - query_doc = globus_sdk.SearchQuery("foo", limit=10, offset=0) + if doc_type == "dict": + query_doc = {"q": "foo", "limit": 10, "offset": 0} + elif doc_type == "helper": + query_doc = globus_sdk.SearchQueryV1(q="foo", limit=10, offset=0) + else: + raise NotImplementedError(doc_type) res = search_client.post_search(meta["index_id"], query_doc, limit=100, offset=150) assert res.http_status == 200 @@ -181,4 +136,4 @@ def test_search_paginated_scroll_query(search_client, query_doc): assert data[1]["entries"][0]["content"]["foo"] == "baz" # confirm that pagination was not side-effecting - assert "marker" not in query_doc + assert "marker" not in filter_missing(query_doc) diff --git a/tests/functional/services/search/test_search_roles.py b/tests/functional/services/search/test_search_roles.py index 55b151036..afd77b930 100644 --- a/tests/functional/services/search/test_search_roles.py +++ b/tests/functional/services/search/test_search_roles.py @@ -3,15 +3,14 @@ import pytest import globus_sdk -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response @pytest.fixture -def search_client(no_retry_transport): - class CustomSearchClient(globus_sdk.SearchClient): - transport_class = no_retry_transport - - return CustomSearchClient() +def search_client(): + client = globus_sdk.SearchClient() + with client.retry_config.tune(max_retries=0): + yield client def test_search_role_create(search_client): diff --git a/tests/functional/services/search/test_update_entry.py b/tests/functional/services/search/test_update_entry.py deleted file mode 100644 index a6570cbc5..000000000 --- a/tests/functional/services/search/test_update_entry.py +++ /dev/null @@ -1,6 +0,0 @@ -import pytest - - -@pytest.mark.xfail -def test_update_entry(): - raise NotImplementedError diff --git a/tests/functional/services/search/test_update_index.py b/tests/functional/services/search/test_update_index.py index 49e1acbab..27f798af6 100644 --- a/tests/functional/services/search/test_update_index.py +++ b/tests/functional/services/search/test_update_index.py @@ -1,7 +1,7 @@ import pytest import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_update_index(client): diff --git a/tests/functional/services/timers/test_create_timer.py b/tests/functional/services/timers/test_create_timer.py index fc6301658..13faf1887 100644 --- a/tests/functional/services/timers/test_create_timer.py +++ b/tests/functional/services/timers/test_create_timer.py @@ -1,7 +1,8 @@ import json import globus_sdk -from globus_sdk._testing import get_last_request, load_response +from globus_sdk._missing import filter_missing +from globus_sdk.testing import get_last_request, load_response def test_dummy_timer_creation(client): @@ -44,7 +45,9 @@ def test_transfer_timer_creation(client): "end": {"condition": "iterations", "iterations": 3}, } assert sent["timer"]["body"] == { - k: v for k, v in body.items() if k != "skip_activation_check" + k: [filter_missing(data_val) for data_val in v] if k == "DATA" else v + for k, v in filter_missing(body).items() + if k != "skip_activation_check" } diff --git a/tests/functional/services/timers/test_jobs.py b/tests/functional/services/timers/test_jobs.py index 1a3fcc28a..a69e37c9a 100644 --- a/tests/functional/services/timers/test_jobs.py +++ b/tests/functional/services/timers/test_jobs.py @@ -3,9 +3,8 @@ import pytest -from globus_sdk import TimerJob, TimersAPIError, TransferData, config, exc, utils -from globus_sdk._testing import get_last_request, load_response -from tests.common import GO_EP1_ID, GO_EP2_ID +from globus_sdk import TimerJob, TimersAPIError +from globus_sdk.testing import get_last_request, load_response def test_list_jobs(client): @@ -38,19 +37,14 @@ def test_get_job_errors(client): ) def test_create_job(client, start, interval): meta = load_response(client.create_job).metadata - transfer_data = TransferData( - source_endpoint=GO_EP1_ID, destination_endpoint=GO_EP2_ID + timer_job = TimerJob( + "https://example.bogus/bogus-callback", {"bogus": "bogus_body"}, start, interval ) - with pytest.warns(exc.RemovedInV4Warning, match="Prefer TransferTimer"): - timer_job = TimerJob.from_transfer_data(transfer_data, start, interval) - response = client.create_job(timer_job) - assert response.http_status == 201 - assert response.data["job_id"] == meta["job_id"] - with pytest.warns(exc.RemovedInV4Warning, match="Prefer TransferTimer"): - timer_job = TimerJob.from_transfer_data(dict(transfer_data), start, interval) + response = client.create_job(timer_job) assert response.http_status == 201 assert response.data["job_id"] == meta["job_id"] + req_body = json.loads(get_last_request().body) if isinstance(start, datetime.datetime): assert req_body["start"] == start.isoformat() @@ -60,27 +54,24 @@ def test_create_job(client, start, interval): assert req_body["interval"] == interval.total_seconds() else: assert req_body["interval"] == interval - assert req_body["callback_url"] == utils.slash_join( - config.get_service_url("actions"), "/transfer/transfer/run" - ) + assert req_body["callback_url"] == "https://example.bogus/bogus-callback" def test_create_job_validation_error(client): meta = load_response(client.create_job, case="validation_error").metadata - transfer_data = TransferData( - source_endpoint=GO_EP1_ID, destination_endpoint=GO_EP2_ID + timer_job = TimerJob( + "https://example.bogus/bogus-callback", + {"bogus": "bogus_body"}, + "2022-04-05T06:00:00", + 1800, ) - with pytest.warns(exc.RemovedInV4Warning, match="Prefer TransferTimer"): - timer_job = TimerJob.from_transfer_data( - transfer_data, "2022-04-05T06:00:00", 1800 - ) with pytest.raises(TimersAPIError) as excinfo: client.create_job(timer_job) err = excinfo.value assert err.http_status == 422 - assert err.code == "Validation Error" + assert err.code is None assert err.messages == meta["expect_messages"] diff --git a/tests/functional/services/transfer/conftest.py b/tests/functional/services/transfer/conftest.py index 6657c1ad2..a8397648d 100644 --- a/tests/functional/services/transfer/conftest.py +++ b/tests/functional/services/transfer/conftest.py @@ -4,8 +4,7 @@ @pytest.fixture -def client(no_retry_transport): - class CustomTransferClient(globus_sdk.TransferClient): - transport_class = no_retry_transport - - return CustomTransferClient() +def client(): + client = globus_sdk.TransferClient() + with client.retry_config.tune(max_retries=0): + yield client diff --git a/tests/functional/services/transfer/endpoint_manager/test_endpoint_manager_task_successful_transfers.py b/tests/functional/services/transfer/endpoint_manager/test_endpoint_manager_task_successful_transfers.py index 528a2977c..113a7727e 100644 --- a/tests/functional/services/transfer/endpoint_manager/test_endpoint_manager_task_successful_transfers.py +++ b/tests/functional/services/transfer/endpoint_manager/test_endpoint_manager_task_successful_transfers.py @@ -1,4 +1,4 @@ -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_endpoint_manager_task_successful_transfers(client): diff --git a/tests/functional/services/transfer/endpoint_manager/test_task_event_list.py b/tests/functional/services/transfer/endpoint_manager/test_task_event_list.py index d6042cd6e..a4158f4db 100644 --- a/tests/functional/services/transfer/endpoint_manager/test_task_event_list.py +++ b/tests/functional/services/transfer/endpoint_manager/test_task_event_list.py @@ -2,7 +2,7 @@ import pytest -from globus_sdk._testing import get_last_request +from globus_sdk.testing import get_last_request from tests.common import register_api_route ZERO_ID = uuid.UUID(int=0) diff --git a/tests/functional/services/transfer/endpoint_manager/test_task_list.py b/tests/functional/services/transfer/endpoint_manager/test_task_list.py index f86a46ddb..fb458ae7d 100644 --- a/tests/functional/services/transfer/endpoint_manager/test_task_list.py +++ b/tests/functional/services/transfer/endpoint_manager/test_task_list.py @@ -4,7 +4,7 @@ import pytest import globus_sdk -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response ZERO_ID = uuid.UUID(int=0) diff --git a/tests/functional/services/transfer/fixture_data/activation_already_activated_stub.json b/tests/functional/services/transfer/fixture_data/activation_already_activated_stub.json deleted file mode 100644 index 33983b9aa..000000000 --- a/tests/functional/services/transfer/fixture_data/activation_already_activated_stub.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "code": "AlreadyActivated" -} diff --git a/tests/functional/services/transfer/fixture_data/activation_stub.json b/tests/functional/services/transfer/fixture_data/activation_stub.json deleted file mode 100644 index 3ebd407a6..000000000 --- a/tests/functional/services/transfer/fixture_data/activation_stub.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "code": "AutoActivated.CachedCredential" -} diff --git a/tests/functional/services/transfer/test_custom_retry_behavior.py b/tests/functional/services/transfer/test_custom_retry_behavior.py new file mode 100644 index 000000000..344d3b5f7 --- /dev/null +++ b/tests/functional/services/transfer/test_custom_retry_behavior.py @@ -0,0 +1,49 @@ +import pytest + +import globus_sdk +from globus_sdk.testing import RegisteredResponse + + +def test_transfer_client_will_retry_ordinary_502(client, mocksleep): + # turn on retries (fixture defaults off) + client.retry_config.max_retries = 1 + + RegisteredResponse(service="transfer", path="/foo", status=502, body="Uh-oh!").add() + RegisteredResponse(service="transfer", path="/foo", json={"status": "ok"}).add() + + # no sign of an error in the client + res = client.get("/foo") + assert res.http_status == 200 + assert res["status"] == "ok" + + # there was a sleep (retry was triggered) + mocksleep.assert_called_once() + + +def test_transfer_client_will_not_retry_endpoint_error(client, mocksleep): + # turn on retries (fixture defaults off) + client.retry_config.max_retries = 1 + + RegisteredResponse( + service="transfer", + path="/do_a_gcp_thing", + status=502, + json={ + "HTTP status": "502", + "code": "ExternalError.DirListingFailed.GCDisconnected", + "error_name": "Transfer API Error", + "message": "The GCP endpoint is not currently connected to Globus", + "request_id": "rhvcR0aHX", + }, + ).add() + RegisteredResponse( + service="transfer", path="/do_a_gcp_thing", json={"status": "ok"} + ).add() + + # no sign of an error in the client + with pytest.raises(globus_sdk.TransferAPIError) as excinfo: + client.get("/do_a_gcp_thing") + assert excinfo.value.http_status == 502 + + # there was no sleep (retry was not triggered) + mocksleep.assert_not_called() diff --git a/tests/functional/services/transfer/test_operation_ls.py b/tests/functional/services/transfer/test_operation_ls.py index f4c8438d6..d9ec532a9 100644 --- a/tests/functional/services/transfer/test_operation_ls.py +++ b/tests/functional/services/transfer/test_operation_ls.py @@ -2,7 +2,7 @@ import pytest -from globus_sdk._testing import RegisteredResponse, get_last_request, load_response +from globus_sdk.testing import RegisteredResponse, get_last_request, load_response from tests.common import GO_EP1_ID @@ -40,7 +40,7 @@ def _setup_ls_response(): load_response( RegisteredResponse( service="transfer", - path=f"/operation/endpoint/{GO_EP1_ID}/ls", + path=f"/v0.10/operation/endpoint/{GO_EP1_ID}/ls", json=_mk_ls_data(), ), ) diff --git a/tests/functional/services/transfer/test_operation_mkdir.py b/tests/functional/services/transfer/test_operation_mkdir.py index 82d812d3a..e9111acb8 100644 --- a/tests/functional/services/transfer/test_operation_mkdir.py +++ b/tests/functional/services/transfer/test_operation_mkdir.py @@ -3,12 +3,13 @@ import pytest -from globus_sdk._testing import get_last_request, load_response +from globus_sdk import MISSING +from globus_sdk.testing import get_last_request, load_response _OMIT = object() -@pytest.mark.parametrize("local_user", ("my-user", None, _OMIT)) +@pytest.mark.parametrize("local_user", ("my-user", MISSING, _OMIT)) def test_operation_mkdir(client, local_user): meta = load_response(client.operation_mkdir).metadata endpoint_id = meta["endpoint_id"] @@ -32,7 +33,7 @@ def test_operation_mkdir(client, local_user): req = get_last_request() body = json.loads(req.body) assert body["path"] == "~/dir/" - if local_user not in (_OMIT, None): + if local_user not in (_OMIT, MISSING): assert body["local_user"] == local_user else: assert "local_user" not in body diff --git a/tests/functional/services/transfer/test_operation_rename.py b/tests/functional/services/transfer/test_operation_rename.py index 53a7a2866..e4e59e0d4 100644 --- a/tests/functional/services/transfer/test_operation_rename.py +++ b/tests/functional/services/transfer/test_operation_rename.py @@ -3,12 +3,13 @@ import pytest -from globus_sdk._testing import get_last_request, load_response +from globus_sdk import MISSING +from globus_sdk.testing import get_last_request, load_response _OMIT = object() -@pytest.mark.parametrize("local_user", ("my-user", None, _OMIT)) +@pytest.mark.parametrize("local_user", ("my-user", MISSING, _OMIT)) def test_operation_rename(client, local_user): meta = load_response(client.operation_rename).metadata endpoint_id = meta["endpoint_id"] @@ -35,7 +36,7 @@ def test_operation_rename(client, local_user): body = json.loads(req.body) assert body["old_path"] == "~/old-name" assert body["new_path"] == "~/new-name" - if local_user not in (_OMIT, None): + if local_user not in (_OMIT, MISSING): assert body["local_user"] == local_user else: assert "local_user" not in body diff --git a/tests/functional/services/transfer/test_operation_stat.py b/tests/functional/services/transfer/test_operation_stat.py index 479a7af1a..3a0588a3d 100644 --- a/tests/functional/services/transfer/test_operation_stat.py +++ b/tests/functional/services/transfer/test_operation_stat.py @@ -4,7 +4,7 @@ import urllib.parse -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response def test_operation_stat(client): diff --git a/tests/functional/services/transfer/test_operation_symlink.py b/tests/functional/services/transfer/test_operation_symlink.py deleted file mode 100644 index 01eaaf03e..000000000 --- a/tests/functional/services/transfer/test_operation_symlink.py +++ /dev/null @@ -1,29 +0,0 @@ -import uuid - -import pytest - -from globus_sdk import exc -from globus_sdk._testing import RegisteredResponse - - -@pytest.fixture -def symlink_endpoint_id(): - return str(uuid.uuid1()) - - -@pytest.fixture(autouse=True) -def _setup_symlink_response(symlink_endpoint_id): - RegisteredResponse( - service="transfer", - method="POST", - path=f"/operation/endpoint/{symlink_endpoint_id}/symlink", - json={}, - ).add() - - -def test_operation_symlink_warns(client, symlink_endpoint_id): - with pytest.warns( - exc.RemovedInV4Warning, - match="operation_symlink is not currently supported by any collections", - ): - client.operation_symlink(symlink_endpoint_id, "some_link_target", "/some/path") diff --git a/tests/functional/services/transfer/test_set_subscription_admin_verified.py b/tests/functional/services/transfer/test_set_subscription_admin_verified.py index 033f86071..5e741e67f 100644 --- a/tests/functional/services/transfer/test_set_subscription_admin_verified.py +++ b/tests/functional/services/transfer/test_set_subscription_admin_verified.py @@ -1,7 +1,7 @@ import pytest import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_set_subscription_admin_verified(client): diff --git a/tests/functional/services/transfer/test_set_subscription_id.py b/tests/functional/services/transfer/test_set_subscription_id.py index 2676baa82..201431a49 100644 --- a/tests/functional/services/transfer/test_set_subscription_id.py +++ b/tests/functional/services/transfer/test_set_subscription_id.py @@ -1,7 +1,7 @@ import pytest import globus_sdk -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_set_subscription_id(client): diff --git a/tests/functional/services/transfer/test_simple.py b/tests/functional/services/transfer/test_simple.py index 880e0f769..1e03b88be 100644 --- a/tests/functional/services/transfer/test_simple.py +++ b/tests/functional/services/transfer/test_simple.py @@ -1,13 +1,9 @@ import json -import urllib.parse import uuid import pytest -import globus_sdk -import globus_sdk.exc -from globus_sdk._testing import get_last_request, load_response -from tests.common import GO_EP1_ID, register_api_route_fixture_file +from globus_sdk.testing import get_last_request, load_response def test_get_endpoint(client): @@ -43,102 +39,3 @@ def test_update_endpoint(epid_type, client): req = get_last_request() assert json.loads(req.body) == update_data - - -def test_update_endpoint_rewrites_activation_servers(client): - """ - Update endpoint, validate results - """ - meta = load_response(client.update_endpoint).metadata - epid = meta["endpoint_id"] - - # sending myproxy_server implicitly adds oauth_server=null - update_data = {"myproxy_server": "foo"} - client.update_endpoint(epid, update_data.copy()) - req = get_last_request() - assert json.loads(req.body) != update_data - update_data["oauth_server"] = None - assert json.loads(req.body) == update_data - - # sending oauth_server implicitly adds myproxy_server=null - update_data = {"oauth_server": "foo"} - client.update_endpoint(epid, update_data.copy()) - req = get_last_request() - assert json.loads(req.body) != update_data - update_data["myproxy_server"] = None - assert json.loads(req.body) == update_data - - -def test_update_endpoint_invalid_activation_servers(client): - epid = "example-id" - update_data = {"oauth_server": "foo", "myproxy_server": "bar"} - with pytest.raises(globus_sdk.GlobusSDKUsageError) as excinfo: - client.update_endpoint(epid, update_data) - - assert "either MyProxy or OAuth, not both" in str(excinfo.value) - - -def test_create_endpoint(client): - load_response(client.create_endpoint) - - create_data = {"display_name": "Name", "description": "desc"} - with pytest.warns(globus_sdk.exc.RemovedInV4Warning): - create_doc = client.create_endpoint(create_data) - - # make sure response is a successful update - assert create_doc["DATA_TYPE"] == "endpoint_create_result" - assert create_doc["code"] == "Created" - assert create_doc["message"] == "Endpoint created successfully" - - req = get_last_request() - assert json.loads(req.body) == create_data - - -def test_create_endpoint_invalid_activation_servers(client): - create_data = {"oauth_server": "foo", "myproxy_server": "bar"} - with pytest.raises(globus_sdk.GlobusSDKUsageError) as excinfo: - with pytest.warns(globus_sdk.exc.RemovedInV4Warning): - client.create_endpoint(create_data) - - assert "either MyProxy or OAuth, not both" in str(excinfo.value) - - -def test_autoactivation(client): - """ - Do `autoactivate` on go#ep1, validate results, and check that `if_expires_in` can be - passed correctly. - """ - # register get_endpoint mock data - register_api_route_fixture_file( - "transfer", - f"/endpoint/{GO_EP1_ID}/autoactivate", - "activation_stub.json", - method="POST", - ) - - # load and check the activation doc - with pytest.warns(globus_sdk.exc.RemovedInV4Warning): - res = client.endpoint_autoactivate(GO_EP1_ID) - assert res["code"] == "AutoActivated.CachedCredential" - - # check the formatted url for the request - req = get_last_request() - assert ( - req.url - == f"https://transfer.api.globus.org/v0.10/endpoint/{GO_EP1_ID}/autoactivate" - ) - - register_api_route_fixture_file( - "transfer", - f"/endpoint/{GO_EP1_ID}/autoactivate", - "activation_already_activated_stub.json", - method="POST", - replace=True, - ) - with pytest.warns(globus_sdk.exc.RemovedInV4Warning): - res = client.endpoint_autoactivate(GO_EP1_ID, if_expires_in=300) - assert res["code"] == "AlreadyActivated" - - req = get_last_request() - parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(req.url).query) - assert parsed_qs == {"if_expires_in": ["300"]} diff --git a/tests/functional/services/transfer/test_task_list.py b/tests/functional/services/transfer/test_task_list.py index 4d4d3378a..09d9fdaa4 100644 --- a/tests/functional/services/transfer/test_task_list.py +++ b/tests/functional/services/transfer/test_task_list.py @@ -2,7 +2,7 @@ import pytest -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response @pytest.mark.parametrize( @@ -12,7 +12,7 @@ ({"query_params": {"foo": "bar"}}, {"foo": "bar"}), ({"filter": "foo"}, {"filter": "foo"}), ({"limit": 10, "offset": 100}, {"limit": "10", "offset": "100"}), - ({"limit": 10, "query_params": {"limit": 100}}, {"limit": "10"}), + ({"limit": 10, "query_params": {"limit": 100}}, {"limit": "100"}), ({"filter": "foo:bar:baz"}, {"filter": "foo:bar:baz"}), ({"filter": {"foo": "bar", "bar": "baz"}}, {"filter": "foo:bar/bar:baz"}), ({"filter": {"foo": ["bar", "baz"]}}, {"filter": "foo:bar,baz"}), diff --git a/tests/functional/services/transfer/test_task_submit.py b/tests/functional/services/transfer/test_task_submit.py index 328714eaf..c2953fc42 100644 --- a/tests/functional/services/transfer/test_task_submit.py +++ b/tests/functional/services/transfer/test_task_submit.py @@ -7,7 +7,7 @@ import pytest from globus_sdk import DeleteData, TransferAPIError, TransferData -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response from tests.common import GO_EP1_ID, GO_EP2_ID @@ -16,9 +16,7 @@ def test_transfer_submit_failure(client): meta = load_response(client.submit_transfer, case="failure").metadata with pytest.raises(TransferAPIError) as excinfo: - client.submit_transfer( - TransferData(source_endpoint=GO_EP1_ID, destination_endpoint=GO_EP2_ID) - ) + client.submit_transfer(TransferData(GO_EP1_ID, GO_EP2_ID)) assert excinfo.value.http_status == 400 assert excinfo.value.request_id == meta["request_id"] @@ -30,8 +28,8 @@ def test_transfer_submit_success(client): meta = load_response(client.submit_transfer).metadata tdata = TransferData( - source_endpoint=GO_EP1_ID, - destination_endpoint=GO_EP2_ID, + GO_EP1_ID, + GO_EP2_ID, label="mytask", sync_level="exists", deadline="2018-06-01", diff --git a/tests/functional/services/transfer/test_task_wait.py b/tests/functional/services/transfer/test_task_wait.py index 61099a4c4..05b9d0045 100644 --- a/tests/functional/services/transfer/test_task_wait.py +++ b/tests/functional/services/transfer/test_task_wait.py @@ -4,7 +4,7 @@ import responses import globus_sdk -from globus_sdk._testing import get_last_request +from globus_sdk.testing import get_last_request from tests.common import register_api_route_fixture_file TASK1_ID = "b8872740-7edc-11ec-9f33-ed182a728dff" diff --git a/tests/functional/_testing/test_non_default_mock.py b/tests/functional/testing/test_non_default_mock.py similarity index 91% rename from tests/functional/_testing/test_non_default_mock.py rename to tests/functional/testing/test_non_default_mock.py index 7267e2c48..52a527e5a 100644 --- a/tests/functional/_testing/test_non_default_mock.py +++ b/tests/functional/testing/test_non_default_mock.py @@ -1,5 +1,5 @@ """ -Test that globus_sdk._testing can accept a non-default requests mock +Test that globus_sdk.testing can accept a non-default requests mock """ import sys @@ -9,7 +9,7 @@ import responses from globus_sdk import GlobusHTTPResponse, GroupsClient -from globus_sdk._testing import get_last_request, load_response +from globus_sdk.testing import get_last_request, load_response @pytest.fixture diff --git a/tests/functional/tokenstorage/v1/test_simplejson_file.py b/tests/functional/tokenstorage/v1/test_simplejson_file.py index 7385a9d70..b2eb8730c 100644 --- a/tests/functional/tokenstorage/v1/test_simplejson_file.py +++ b/tests/functional/tokenstorage/v1/test_simplejson_file.py @@ -3,8 +3,8 @@ import pytest -from globus_sdk.tokenstorage import SimpleJSONFileAdapter -from globus_sdk.version import __version__ +from globus_sdk import __version__ +from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter IS_WINDOWS = os.name == "nt" diff --git a/tests/functional/tokenstorage/v1/test_sqlite.py b/tests/functional/tokenstorage/v1/test_sqlite.py index 176a4d3ac..ebcaca67e 100644 --- a/tests/functional/tokenstorage/v1/test_sqlite.py +++ b/tests/functional/tokenstorage/v1/test_sqlite.py @@ -1,6 +1,6 @@ import pytest -from globus_sdk.tokenstorage import SQLiteAdapter +from globus_sdk.token_storage.legacy import SQLiteAdapter @pytest.fixture diff --git a/tests/functional/tokenstorage/v2/conftest.py b/tests/functional/tokenstorage/v2/conftest.py index 2bbe171e0..bc6c37620 100644 --- a/tests/functional/tokenstorage/v2/conftest.py +++ b/tests/functional/tokenstorage/v2/conftest.py @@ -5,8 +5,8 @@ import pytest import globus_sdk -from globus_sdk._testing import RegisteredResponse -from globus_sdk.tokenstorage import TokenStorageData +from globus_sdk.testing import RegisteredResponse +from globus_sdk.token_storage import TokenStorageData @pytest.fixture @@ -15,11 +15,10 @@ def id_token_sub(): @pytest.fixture -def cc_auth_client(no_retry_transport): - class CustomAuthClient(globus_sdk.ConfidentialAppAuthClient): - transport_class = no_retry_transport - - return CustomAuthClient("dummy_id", "dummy_secret") +def cc_auth_client(): + client = globus_sdk.ConfidentialAppAuthClient("dummy_id", "dummy_secret") + with client.retry_config.tune(max_retries=0): + yield client @pytest.fixture diff --git a/tests/functional/tokenstorage/v2/test_common_tokenstorage.py b/tests/functional/tokenstorage/v2/test_common_tokenstorage.py index 1162b20f3..f732710df 100644 --- a/tests/functional/tokenstorage/v2/test_common_tokenstorage.py +++ b/tests/functional/tokenstorage/v2/test_common_tokenstorage.py @@ -1,6 +1,6 @@ import pytest -from globus_sdk.tokenstorage import ( +from globus_sdk.token_storage import ( JSONTokenStorage, MemoryTokenStorage, SQLiteTokenStorage, diff --git a/tests/functional/tokenstorage/v2/test_json_tokenstorage.py b/tests/functional/tokenstorage/v2/test_json_tokenstorage.py index 606fab039..dc78b4f6b 100644 --- a/tests/functional/tokenstorage/v2/test_json_tokenstorage.py +++ b/tests/functional/tokenstorage/v2/test_json_tokenstorage.py @@ -3,8 +3,9 @@ import pytest -from globus_sdk.tokenstorage import JSONTokenStorage, SimpleJSONFileAdapter -from globus_sdk.version import __version__ +from globus_sdk import __version__ +from globus_sdk.token_storage import JSONTokenStorage +from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter IS_WINDOWS = os.name == "nt" diff --git a/tests/functional/tokenstorage/v2/test_memory_tokenstorage.py b/tests/functional/tokenstorage/v2/test_memory_tokenstorage.py index 9783e0b7b..f2631144a 100644 --- a/tests/functional/tokenstorage/v2/test_memory_tokenstorage.py +++ b/tests/functional/tokenstorage/v2/test_memory_tokenstorage.py @@ -1,4 +1,4 @@ -from globus_sdk.tokenstorage import MemoryTokenStorage +from globus_sdk.token_storage import MemoryTokenStorage def test_store_and_get_token_data_by_resource_server( diff --git a/tests/functional/tokenstorage/v2/test_sqlite_tokenstorage.py b/tests/functional/tokenstorage/v2/test_sqlite_tokenstorage.py index 5662afc57..21c505efb 100644 --- a/tests/functional/tokenstorage/v2/test_sqlite_tokenstorage.py +++ b/tests/functional/tokenstorage/v2/test_sqlite_tokenstorage.py @@ -1,7 +1,8 @@ import pytest from globus_sdk import exc -from globus_sdk.tokenstorage import SQLiteAdapter, SQLiteTokenStorage +from globus_sdk.token_storage import SQLiteTokenStorage +from globus_sdk.token_storage.legacy import SQLiteAdapter @pytest.fixture diff --git a/tests/non-pytest/lazy-imports/test_for_import_cycles.py b/tests/non-pytest/lazy-imports/test_for_import_cycles.py index f34ff634f..38cdb07a8 100644 --- a/tests/non-pytest/lazy-imports/test_for_import_cycles.py +++ b/tests/non-pytest/lazy-imports/test_for_import_cycles.py @@ -16,7 +16,7 @@ import pytest import globus_sdk -from globus_sdk._lazy_import import find_source_module +from globus_sdk._internal.lazy_import import find_source_module PYTHON_BINARY = os.environ.get("GLOBUS_TEST_PY", sys.executable) diff --git a/tests/non-pytest/lazy-imports/test_modules_do_not_require_requests.py b/tests/non-pytest/lazy-imports/test_modules_do_not_require_requests.py index d28731557..a006deb09 100644 --- a/tests/non-pytest/lazy-imports/test_modules_do_not_require_requests.py +++ b/tests/non-pytest/lazy-imports/test_modules_do_not_require_requests.py @@ -28,18 +28,20 @@ "paging", "response", "scopes", - "tokenstorage", + "token_storage", # the top-level of the 'exc' subpackage (but not necessarily its contents) # should similarly be standalone, for exception handlers "exc", # internal components and utilities are a special case: # failing to ensure that these avoid 'requests' can make it more difficult # to ensure that the main parts (above) do not transitively pick it up - "_guards", - "_serializable", - "_types", - "utils", - "version", + "_internal.classprop", + "_internal.guards", + "_internal.remarshal", + "_internal.serializable", + "_internal.utils", + "_internal.type_definitions", + "_missing", ), ) def test_module_does_not_require_requests(module_name): diff --git a/tests/non-pytest/mypy-ignore-tests/app_scope_requirements.py b/tests/non-pytest/mypy-ignore-tests/app_scope_requirements.py index ed643abce..b49b214c5 100644 --- a/tests/non-pytest/mypy-ignore-tests/app_scope_requirements.py +++ b/tests/non-pytest/mypy-ignore-tests/app_scope_requirements.py @@ -1,12 +1,13 @@ from globus_sdk import UserApp -# declare scope data in the form of a subtype of the ScopeCollectionType (`list[str]`) -# indexed in a dict, this is meant to be a subtype of the requirements data accepted -# by `GlobusApp.add_scope_requirements` +# declare scope data in the form of a subtype of the +# `str | Scope | t.Iterable[str | Scope]` (`list[str]`) indexed in a dict, +# this is meant to be a subtype of the requirements data accepted by +# `GlobusApp.add_scope_requirements` # -# this is a regression test for that being annotated as `dict[str, ScopeCollectionType]` -# which will reject the input type because `dict` is a mutable container, and therefore -# invariant +# this is a regression test for that being annotated as +# `dict[str, str | Scope | t.Iterable[str | Scope]]` which will reject the input +# type because `dict` is a mutable container, and therefore invariant scopes: dict[str, list[str]] = {"foo": ["bar"]} my_app = UserApp("...", client_id="...") my_app.add_scope_requirements(scopes) diff --git a/tests/non-pytest/mypy-ignore-tests/auth_client_initialization.py b/tests/non-pytest/mypy-ignore-tests/auth_client_initialization.py index 2215aa2e7..59adbc305 100644 --- a/tests/non-pytest/mypy-ignore-tests/auth_client_initialization.py +++ b/tests/non-pytest/mypy-ignore-tests/auth_client_initialization.py @@ -16,3 +16,10 @@ cc = globus_sdk.ConfidentialAppAuthClient( # type: ignore[call-arg] "foo_client_id", "foo_client_secret", authorizer=authorizer ) + +# the login clients allow a client_id kwarg, but AuthClient does not +globus_sdk.NativeAppAuthClient(client_id="foo_client_id") +globus_sdk.ConfidentialAppAuthClient( + client_id="foo_client_id", client_secret="foo_client_secret" +) +globus_sdk.AuthClient(client_id="foo_client_id") # type: ignore[call-arg] diff --git a/tests/non-pytest/mypy-ignore-tests/auth_client_jwk_methods.py b/tests/non-pytest/mypy-ignore-tests/auth_client_jwk_methods.py index f5456b062..f2df2f570 100644 --- a/tests/non-pytest/mypy-ignore-tests/auth_client_jwk_methods.py +++ b/tests/non-pytest/mypy-ignore-tests/auth_client_jwk_methods.py @@ -2,12 +2,10 @@ from globus_sdk.services.auth._common import SupportsJWKMethods # setup clients -ac = globus_sdk.AuthClient() nc = globus_sdk.NativeAppAuthClient("foo_client_id") cc = globus_sdk.ConfidentialAppAuthClient("foo_client_id", "foo_client_secret") # check that each one supports the JWK methods x: SupportsJWKMethods -x = ac x = nc x = cc diff --git a/tests/non-pytest/mypy-ignore-tests/identity_map.py b/tests/non-pytest/mypy-ignore-tests/identity_map.py index a64f1d865..ac5fed610 100644 --- a/tests/non-pytest/mypy-ignore-tests/identity_map.py +++ b/tests/non-pytest/mypy-ignore-tests/identity_map.py @@ -6,9 +6,9 @@ nc = globus_sdk.NativeAppAuthClient("foo_client_id") cc = globus_sdk.ConfidentialAppAuthClient("foo_client_id", "foo_client_secret") -# check init allows CC, but not NC +# check init allows the service client but not the login clients im = globus_sdk.IdentityMap(ac) -im = globus_sdk.IdentityMap(cc) +im = globus_sdk.IdentityMap(cc) # type: ignore[arg-type] im = globus_sdk.IdentityMap(nc) # type: ignore[arg-type] # getitem and delitem work, but setitem and contains do not diff --git a/tests/non-pytest/mypy-ignore-tests/missing_type.py b/tests/non-pytest/mypy-ignore-tests/missing_type.py new file mode 100644 index 000000000..181e0d3cf --- /dev/null +++ b/tests/non-pytest/mypy-ignore-tests/missing_type.py @@ -0,0 +1,30 @@ +import typing as t + +from globus_sdk import MISSING, MissingType + +# first, the type of `MISSING` must be `MissingType` +t.assert_type(MISSING, MissingType) + +# second, a variable annotated as `int | MissingType` is assignable with `MISSING` +x: int | MissingType = MISSING + +# and `MissingType` is not the same as None, Ellipsis, False, 0, or other weirdness +# these error! +y: MissingType +y = None # type: ignore[assignment] +y = Ellipsis # type: ignore[assignment] +y = ... # type: ignore[assignment] +y = False # type: ignore[assignment] +y = 0 # type: ignore[assignment] + +# given that x is int|MissingType, `x is not MISSING` should narrow to `int` +if x is not MISSING: + t.assert_type(x, int) +else: + # don't do this: + # t.assert_type(x, MissingType) + # although that looks right, `MissingType` != `Literal[MISSING]`, so it fails + # (at least on some mypy versions) + # instead, confirm that `not isinstance(x, MissingType)` narrows to a Never + if not isinstance(x, MissingType): # noqa: SDK003 + t.assert_never(x) diff --git a/tests/non-pytest/mypy-ignore-tests/responselike_protocol.py b/tests/non-pytest/mypy-ignore-tests/responselike_protocol.py index 0c90df3ba..136c9ba0a 100644 --- a/tests/non-pytest/mypy-ignore-tests/responselike_protocol.py +++ b/tests/non-pytest/mypy-ignore-tests/responselike_protocol.py @@ -7,7 +7,7 @@ import requests from globus_sdk import GlobusAPIError, GlobusHTTPResponse -from globus_sdk._types import ResponseLike +from globus_sdk._internal.type_definitions import ResponseLike if sys.version_info < (3, 11): from typing_extensions import assert_type diff --git a/tests/non-pytest/mypy-ignore-tests/scope_collection_type.py b/tests/non-pytest/mypy-ignore-tests/scope_collection_type.py index f925e6447..f40f66e88 100644 --- a/tests/non-pytest/mypy-ignore-tests/scope_collection_type.py +++ b/tests/non-pytest/mypy-ignore-tests/scope_collection_type.py @@ -1,6 +1,7 @@ +import typing as t + import globus_sdk -from globus_sdk._types import ScopeCollectionType -from globus_sdk.scopes import MutableScope, scopes_to_str +from globus_sdk.scopes import Scope, ScopeParser from globus_sdk.services.auth import ( GlobusAuthorizationCodeFlowManager, GlobusNativeAppFlowManager, @@ -21,20 +22,16 @@ ) -# these functions should type-check okay -def foo(x: ScopeCollectionType) -> str: - return MutableScope.scopes2str(x) - - -def foo2(x: ScopeCollectionType) -> str: - return scopes_to_str(x) +# this function should type-check okay +def foo(x: str | Scope | t.Iterable[str | Scope]) -> str: + return ScopeParser.serialize(x) foo("somestring") foo(["somestring", "otherstring"]) -foo(MutableScope("bar")) -foo((MutableScope("bar"),)) -foo({MutableScope("bar"), "baz"}) +foo(Scope("bar")) +foo((Scope("bar"),)) +foo({Scope("bar"), "baz"}) # bad usages foo(1) # type: ignore[arg-type] foo((False,)) # type: ignore[arg-type] @@ -62,29 +59,29 @@ def foo2(x: ScopeCollectionType) -> str: GlobusAuthorizationCodeFlowManager( cc_client, "https://example.org/redirect-uri", - requested_scopes=MutableScope("foo"), + requested_scopes=Scope("foo"), ) GlobusNativeAppFlowManager( native_client, - requested_scopes=MutableScope("foo"), + requested_scopes=Scope("foo"), ) GlobusAuthorizationCodeFlowManager( cc_client, "https://example.org/redirect-uri", - requested_scopes=[MutableScope("foo")], + requested_scopes=[Scope("foo")], ) GlobusNativeAppFlowManager( native_client, - requested_scopes=[MutableScope("foo")], + requested_scopes=[Scope("foo")], ) GlobusAuthorizationCodeFlowManager( cc_client, "https://example.org/redirect-uri", - requested_scopes=[MutableScope("foo"), "bar"], + requested_scopes=[Scope("foo"), "bar"], ) GlobusNativeAppFlowManager( native_client, - requested_scopes=[MutableScope("foo"), "bar"], + requested_scopes=[Scope("foo"), "bar"], ) # bad usages GlobusAuthorizationCodeFlowManager( @@ -120,19 +117,17 @@ def foo2(x: ScopeCollectionType) -> str: cc_client.oauth2_start_flow( "https://example.org/redirect-uri", requested_scopes=("foo", "bar") ) -native_client.oauth2_start_flow(MutableScope("foo")) -cc_client.oauth2_start_flow("https://example.org/redirect-uri", MutableScope("foo")) -native_client.oauth2_start_flow(requested_scopes=MutableScope("foo")) +native_client.oauth2_start_flow(Scope("foo")) +cc_client.oauth2_start_flow("https://example.org/redirect-uri", Scope("foo")) +native_client.oauth2_start_flow(requested_scopes=Scope("foo")) cc_client.oauth2_start_flow( - "https://example.org/redirect-uri", requested_scopes=MutableScope("foo") + "https://example.org/redirect-uri", requested_scopes=Scope("foo") ) -native_client.oauth2_start_flow([MutableScope("foo"), "bar"]) +native_client.oauth2_start_flow([Scope("foo"), "bar"]) +cc_client.oauth2_start_flow("https://example.org/redirect-uri", [Scope("foo"), "bar"]) +native_client.oauth2_start_flow(requested_scopes=[Scope("foo"), "bar"]) cc_client.oauth2_start_flow( - "https://example.org/redirect-uri", [MutableScope("foo"), "bar"] -) -native_client.oauth2_start_flow(requested_scopes=[MutableScope("foo"), "bar"]) -cc_client.oauth2_start_flow( - "https://example.org/redirect-uri", requested_scopes=[MutableScope("foo"), "bar"] + "https://example.org/redirect-uri", requested_scopes=[Scope("foo"), "bar"] ) # bad usages native_client.oauth2_start_flow(1) # type: ignore[arg-type] @@ -153,12 +148,10 @@ def foo2(x: ScopeCollectionType) -> str: cc_client.oauth2_client_credentials_tokens(requested_scopes="foo") cc_client.oauth2_client_credentials_tokens(("foo", "bar")) cc_client.oauth2_client_credentials_tokens(requested_scopes=("foo", "bar")) -cc_client.oauth2_client_credentials_tokens(MutableScope("foo")) -cc_client.oauth2_client_credentials_tokens(requested_scopes=MutableScope("foo")) -cc_client.oauth2_client_credentials_tokens([MutableScope("foo"), "bar"]) -cc_client.oauth2_client_credentials_tokens( - requested_scopes=[MutableScope("foo"), "bar"] -) +cc_client.oauth2_client_credentials_tokens(Scope("foo")) +cc_client.oauth2_client_credentials_tokens(requested_scopes=Scope("foo")) +cc_client.oauth2_client_credentials_tokens([Scope("foo"), "bar"]) +cc_client.oauth2_client_credentials_tokens(requested_scopes=[Scope("foo"), "bar"]) cc_client.oauth2_client_credentials_tokens(1) # type: ignore[arg-type] cc_client.oauth2_client_credentials_tokens( requested_scopes=none_list, # type: ignore[arg-type] diff --git a/tests/non-pytest/mypy-ignore-tests/specific_flow_scopes.py b/tests/non-pytest/mypy-ignore-tests/specific_flow_scopes.py index 2042c6d0e..f2cc07008 100644 --- a/tests/non-pytest/mypy-ignore-tests/specific_flow_scopes.py +++ b/tests/non-pytest/mypy-ignore-tests/specific_flow_scopes.py @@ -8,8 +8,8 @@ specific_flow_client = globus_sdk.SpecificFlowClient(flow_id) scopes_object = specific_flow_client.scopes -t.assert_type(scopes_object, globus_sdk.scopes.ScopeBuilder) +t.assert_type(scopes_object, globus_sdk.scopes.SpecificFlowScopes) -scope: str = scopes_object.user +scope: globus_sdk.Scope = scopes_object.user x: int = scopes_object.user # type: ignore[assignment] resource_server: str = specific_flow_client.scopes.resource_server diff --git a/tests/non-pytest/mypy-ignore-tests/test_consents_usage.py b/tests/non-pytest/mypy-ignore-tests/test_consents_usage.py index 177eeccf4..46429dd9c 100644 --- a/tests/non-pytest/mypy-ignore-tests/test_consents_usage.py +++ b/tests/non-pytest/mypy-ignore-tests/test_consents_usage.py @@ -1,6 +1,7 @@ import uuid from globus_sdk import AuthClient, Scope +from globus_sdk.scopes import ScopeParser # setup: get a consent forest ac = AuthClient() @@ -11,7 +12,8 @@ # create some variant types xfer_str: str = "urn:globus:auth:scope:transfer.api.globus.org:all" strlist: list[str] = [xfer_str] -scopelist: list[Scope] = Scope.parse(xfer_str) +scopelist: list[Scope] = ScopeParser.parse(xfer_str) +scopeobj: Scope = Scope.parse(xfer_str) # all should be allowed b: bool diff --git a/tests/non-pytest/mypy-ignore-tests/test_guards.py b/tests/non-pytest/mypy-ignore-tests/test_guards.py index 0dc761533..bd7b65b27 100644 --- a/tests/non-pytest/mypy-ignore-tests/test_guards.py +++ b/tests/non-pytest/mypy-ignore-tests/test_guards.py @@ -1,7 +1,7 @@ -# test that the internal _guards module provides valid and well-formed type-guards +# test that the internal guards module provides valid and well-formed type-guards import typing as t -from globus_sdk import _guards +from globus_sdk._internal import guards def get_any() -> t.Any: @@ -12,20 +12,20 @@ def get_any() -> t.Any: t.assert_type(x, t.Any) # test is_list_of -if _guards.is_list_of(x, str): +if guards.is_list_of(x, str): t.assert_type(x, list[str]) -elif _guards.is_list_of(x, int): +elif guards.is_list_of(x, int): t.assert_type(x, list[int]) # test is_optional -if _guards.is_optional(x, float): +if guards.is_optional(x, float): t.assert_type(x, float | None) -elif _guards.is_optional(x, bytes): +elif guards.is_optional(x, bytes): t.assert_type(x, bytes | None) # test is_optional_list_of -if _guards.is_optional_list_of(x, type(None)): +if guards.is_optional_list_of(x, type(None)): t.assert_type(x, list[None] | None) -elif _guards.is_optional_list_of(x, dict): +elif guards.is_optional_list_of(x, dict): t.assert_type(x, list[dict[t.Any, t.Any]] | None) diff --git a/tests/non-pytest/mypy-ignore-tests/timer_client_aliasing.py b/tests/non-pytest/mypy-ignore-tests/timer_client_aliasing.py deleted file mode 100644 index bfef9b6a0..000000000 --- a/tests/non-pytest/mypy-ignore-tests/timer_client_aliasing.py +++ /dev/null @@ -1,12 +0,0 @@ -import globus_sdk - -c = globus_sdk.TimersClient() -c_legacy = globus_sdk.TimerClient() - -# both can call create_timer() -c.create_timer({"foo": "bar"}) -c_legacy.create_timer({"foo": "bar"}) - -# both reject create_timer() -c.create_timer(object()) # type: ignore[arg-type] -c_legacy.create_timer(object()) # type: ignore[arg-type] diff --git a/tests/non-pytest/mypy-ignore-tests/transfer_data.py b/tests/non-pytest/mypy-ignore-tests/transfer_data.py index 963a1393a..fa6e1d1e9 100644 --- a/tests/non-pytest/mypy-ignore-tests/transfer_data.py +++ b/tests/non-pytest/mypy-ignore-tests/transfer_data.py @@ -1,21 +1,20 @@ import uuid -from globus_sdk import TransferClient, TransferData +from globus_sdk import TransferData # simple usage, ok -tc = TransferClient() -TransferData(tc, "srcep", "destep") +TransferData("srcep", "destep") # can set sync level -TransferData(tc, "srcep", "destep", sync_level=1) -TransferData(tc, "srcep", "destep", sync_level="exists") +TransferData("srcep", "destep", sync_level=1) +TransferData("srcep", "destep", sync_level="exists") # unknown int values are allowed -TransferData(tc, "srcep", "destep", sync_level=100) +TransferData("srcep", "destep", sync_level=100) # unknown str values are rejected (Literal) -TransferData(tc, "srcep", "destep", sync_level="sizes") # type: ignore[arg-type] +TransferData("srcep", "destep", sync_level="sizes") # type: ignore[arg-type] # TransferData.add_filter_rule -tdata = TransferData(tc, uuid.UUID(), uuid.UUID()) +tdata = TransferData(uuid.UUID(), uuid.UUID()) tdata.add_filter_rule("*.tgz") tdata.add_filter_rule("*.tgz", method="exclude") tdata.add_filter_rule("*.tgz", type="file") diff --git a/tests/non-pytest/performance/parser_benchmark.py b/tests/non-pytest/performance/parser_benchmark.py deleted file mode 100644 index 4ee543434..000000000 --- a/tests/non-pytest/performance/parser_benchmark.py +++ /dev/null @@ -1,93 +0,0 @@ -from __future__ import annotations - -import sys -import timeit - -from globus_sdk.scopes._parser import parse_scope_graph - - -def timeit_test() -> None: - for size, num_iterations, style in ( - (10, 1000, "deep"), - (100, 1000, "deep"), - (1000, 1000, "deep"), - (2000, 100, "deep"), - (3000, 100, "deep"), - (4000, 100, "deep"), - (5000, 100, "deep"), - (5000, 1000, "wide"), - (10000, 1000, "wide"), - ): - if style == "deep": - setup = f"""\ -from globus_sdk.experimental.scope_parser import Scope -big_scope = "" -for i in range({size}): - big_scope += f"foo{{i}}[" -big_scope += "bar" -for _ in range({size}): - big_scope += "]" -""" - elif style == "wide": - setup = f"""\ -from globus_sdk.experimental.scope_parser import Scope -big_scope = "" -for i in range({size}): - big_scope += f"foo{{i}} " -""" - else: - raise NotImplementedError - - timer = timeit.Timer("Scope.parse(big_scope)", setup=setup) - - raw_timings = timer.repeat(repeat=5, number=num_iterations) - best, worst, average, variance = _stats(raw_timings) - if style == "deep": - print(f"{num_iterations} runs on a deep scope, depth={size}") - elif style == "wide": - print(f"{num_iterations} runs on a wide scope, width={size}") - else: - raise NotImplementedError - print(f" best={best} worst={worst} average={average} variance={variance}") - print(f" normalized best={best / num_iterations}") - print() - print("The most informative stat over these timings is the min timing (best).") - print("Normed best is best/iterations.") - print( - "Max timing (worst) and dispersion (variance vis-a-vis average) indicate " - "how consistent the results are, but are not a report of speed." - ) - - -def _stats(timing_data: list[float]) -> tuple[float, float, float, float]: - best = min(timing_data) - worst = max(timing_data) - average = sum(timing_data) / len(timing_data) - variance = sum((x - average) ** 2 for x in timing_data) / len(timing_data) - return best, worst, average, variance - - -def parse_test(scope_string: str) -> None: - parsed_graph = parse_scope_graph(scope_string) - print( - "top level scopes:", - ", ".join([name for name, _optional in parsed_graph.top_level_scopes]), - ) - print(parsed_graph) - - -def main() -> None: - if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"): - print("This script supports two usage patterns:") - print(" python -m globus_sdk.experimental.scope_parser SCOPE_STRING") - print(" python -m globus_sdk.experimental.scope_parser --timeit") - sys.exit(0) - - print() - if sys.argv[1] == "--timeit": - timeit_test() - else: - parse_test(sys.argv[1]) - - -main() diff --git a/tests/unit/authorizers/test_basic_authorizer.py b/tests/unit/authorizers/test_basic_authorizer.py index fbf2c9383..31ae3104e 100644 --- a/tests/unit/authorizers/test_basic_authorizer.py +++ b/tests/unit/authorizers/test_basic_authorizer.py @@ -19,6 +19,7 @@ def test_get_authorization_header(authorizer): """ header_val = authorizer.get_authorization_header() assert header_val[:6] == "Basic " + assert header_val[6:] == "dGVzdFVzZXI6UEFTU1dPUkQ=" decoded = base64.b64decode(header_val[6:].encode("utf-8")).decode("utf-8") assert decoded == f"{USERNAME}:{PASSWORD}" @@ -31,9 +32,14 @@ def test_handle_missing_authorization(authorizer): @pytest.mark.parametrize( - "username, password", [("user", "テスト"), ("дум", "pass"), ("テスト", "дум")] + "username, password, encoded_value", + [ + ("user", "テスト", "dXNlcjrjg4bjgrnjg4g="), + ("дум", "pass", "0LTRg9C8OnBhc3M="), + ("テスト", "дум", "44OG44K544OIOtC00YPQvA=="), + ], ) -def test_unicode_handling(username, password): +def test_unicode_handling(username, password, encoded_value): """ With a unicode string for the password, set and verify the Authorization header. @@ -42,5 +48,6 @@ def test_unicode_handling(username, password): header_val = authorizer.get_authorization_header() assert header_val[:6] == "Basic " + assert header_val[6:] == encoded_value decoded = base64.b64decode(header_val[6:].encode("utf-8")).decode("utf-8") assert decoded == f"{username}:{password}" diff --git a/tests/unit/authorizers/test_client_credentials_authorizer.py b/tests/unit/authorizers/test_client_credentials_authorizer.py index 528e8cca6..d2019838c 100644 --- a/tests/unit/authorizers/test_client_credentials_authorizer.py +++ b/tests/unit/authorizers/test_client_credentials_authorizer.py @@ -3,7 +3,7 @@ import pytest from globus_sdk.authorizers import ClientCredentialsAuthorizer -from globus_sdk.scopes import MutableScope +from globus_sdk.scopes import Scope ACCESS_TOKEN = "access_token_1" EXPIRES_AT = -1 @@ -62,11 +62,11 @@ def test_multiple_resource_servers(authorizer, response): assert SCOPES in str(excinfo.value) -def test_can_create_authorizer_from_mutable_scopes(client): - a1 = ClientCredentialsAuthorizer(client, MutableScope("foo")) +def test_can_create_authorizer_from_scope_objects(client): + a1 = ClientCredentialsAuthorizer(client, Scope("foo")) assert a1.scopes == "foo" a2 = ClientCredentialsAuthorizer( - client, [MutableScope("foo"), "bar", MutableScope("baz").add_dependency("buzz")] + client, [Scope("foo"), "bar", Scope("baz").with_dependency(Scope("buzz"))] ) assert a2.scopes == "foo bar baz[buzz]" diff --git a/tests/unit/errors/test_auth_errors.py b/tests/unit/errors/test_auth_errors.py index 6178e60f9..7a8ae060f 100644 --- a/tests/unit/errors/test_auth_errors.py +++ b/tests/unit/errors/test_auth_errors.py @@ -1,7 +1,7 @@ import pytest from globus_sdk import AuthAPIError -from globus_sdk._testing import construct_error +from globus_sdk.testing import construct_error def test_auth_error_get_args_simple(): @@ -17,7 +17,7 @@ def test_auth_error_get_args_simple(): req.url, None, 404, - "Error", + None, "simple auth error message", ] @@ -38,7 +38,7 @@ def test_nested_auth_error_message_and_code(): ) assert err.message == "nested auth error message; some secondary error" - assert err.code == "Error" + assert err.code is None @pytest.mark.parametrize( diff --git a/tests/unit/errors/test_common_functionality.py b/tests/unit/errors/test_common_functionality.py index 72a934abc..8b54df656 100644 --- a/tests/unit/errors/test_common_functionality.py +++ b/tests/unit/errors/test_common_functionality.py @@ -1,10 +1,12 @@ import itertools +import sys +import uuid import pytest import requests -from globus_sdk import ErrorSubdocument, GlobusAPIError, RemovedInV4Warning, exc -from globus_sdk._testing import construct_error +from globus_sdk import ErrorSubdocument, GlobusAPIError, exc +from globus_sdk.testing import construct_error def _strmatch_any_order(inputstr, prefix, midfixes, suffix, sep=", "): @@ -52,47 +54,27 @@ def test_binary_content_property(): assert err.binary_content == body_text.encode("utf-8") -def test_raw_text_property_warns(): - body_text = "some data" - err = construct_error(body=body_text, http_status=400) - with pytest.warns( - RemovedInV4Warning, - match=( - r"The 'raw_text' property of GlobusAPIError objects is deprecated\. " - r"Use the 'text' property instead\." - ), - ): - assert err.raw_text == body_text - - -def test_imperative_message_setting_warns(): - err = construct_error( - body={"code": "FooCode", "message": "FooMessage"}, http_status=400 - ) - assert err.message == "FooMessage" - - with pytest.warns( - RemovedInV4Warning, - match=( - r"Setting a message on GlobusAPIError objects is deprecated\. " - r"This overwrites any parsed messages\. Append to 'messages' instead\." - ), - ): - err.message = "BarMessage" - - assert err.message == "BarMessage" +# `add_note()` and `__notes__` are new in Python 3.11 +@pytest.mark.skipif( + sys.version_info < (3, 11), reason="Exception.add_note() is new in Python 3.11" +) +def test_notes_are_populated_with_text(): + text_body = f"some error: {uuid.uuid4()}" + err = construct_error(body=text_body, http_status=400) + assert err.text == text_body + assert any(text_body in note for note in err.__notes__) @pytest.mark.parametrize( "body, response_headers, http_status, expect_code, expect_message", ( - ("text_data", {}, 401, "Error", "Unauthorized"), # text + ("text_data", {}, 401, None, "Unauthorized"), # text # JSON with unrecognized contents ( {"foo": "bar"}, {"Content-Type": "application/json"}, 403, - "Error", + None, "Forbidden", ), # JSON with well-known contents @@ -108,7 +90,7 @@ def test_imperative_message_setting_warns(): "[]", {"Content-Type": "application/json"}, 403, - "Error", + None, "Forbidden", ), # invalid JSON @@ -116,7 +98,7 @@ def test_imperative_message_setting_warns(): "{", {"Content-Type": "application/json"}, 400, - "Error", + None, "Bad Request", ), ), @@ -544,7 +526,8 @@ def test_error_repr_has_expected_info( if error_code is not None: assert error_code in stringified else: - assert "'Error'" in stringified + # several things could be 'None', but at least one of them is 'code' + assert "None" in stringified if error_message is None: assert "otherdata" in stringified else: @@ -584,16 +567,10 @@ def test_loads_jsonapi_error_subdocuments(content_type): ) # code is not taken from any of the subdocuments (inherently too ambiguous) - # behavior will depend on which parsing path was taken - if content_type.endswith("vnd.api+json"): - # code becomes None because we saw "true" JSON:API and can opt-in to - # better behavior - assert err.code is None - else: - # code remains 'Error' for backwards compatibility in the non-JSON:API case - assert err.code == "Error" + # this holds regardless of which parsing path was taken + assert err.code is None - # but messages can be extracted, and they prefer detail to title + # messages can be extracted, and they prefer detail to title assert err.messages == [ "password was only 3 chars long, must be at least 8", "password must have non-alphanumeric characters", @@ -649,22 +626,19 @@ def test_loads_jsonapi_error_messages_from_various_fields(content_type): body=body, http_status=422, response_headers={"Content-Type": content_type} ) + # no code was found + assert err.code is None + # messages are extracted, and they use whichever field is appropriate for # each sub-error # note that 'message' will *not* be extracted if the Content-Type indicated JSON:API # because JSON:API does not define such a field if content_type.endswith("vnd.api+json"): - # code becomes None because we saw "true" JSON:API and can opt-in to - # better behavior - assert err.code is None assert err.messages == [ "Must contain capital letter", "password must have non-alphanumeric characters", ] else: - # code remains 'Error' for backwards compatibility in the non-JSON:API case - assert err.code == "Error" - assert err.messages == [ "invalid password value", "Must contain capital letter", diff --git a/tests/unit/errors/test_timers_errors.py b/tests/unit/errors/test_timers_errors.py index 8cf05246b..99b0e1ca8 100644 --- a/tests/unit/errors/test_timers_errors.py +++ b/tests/unit/errors/test_timers_errors.py @@ -1,5 +1,5 @@ from globus_sdk import TimersAPIError -from globus_sdk._testing import construct_error +from globus_sdk.testing import construct_error def test_timer_error_load_simple(): @@ -33,11 +33,11 @@ def test_timer_error_load_nested(): http_status=422, ) - assert err.code == "Validation Error" + assert err.code is None assert err.message == "field required: body.start; field required: body.end" def test_timer_error_load_unrecognized_format(): err = construct_error(error_class=TimersAPIError, body={}, http_status=400) - assert err.code == "Error" + assert err.code is None assert err.message is None diff --git a/tests/unit/errors/test_transfer_errors.py b/tests/unit/errors/test_transfer_errors.py index b88291d2a..5e621acfa 100644 --- a/tests/unit/errors/test_transfer_errors.py +++ b/tests/unit/errors/test_transfer_errors.py @@ -1,5 +1,5 @@ from globus_sdk import TransferAPIError -from globus_sdk._testing import construct_error +from globus_sdk.testing import construct_error def test_transfer_response_get_args(): diff --git a/tests/unit/experimental/test_legacy_support.py b/tests/unit/experimental/test_legacy_support.py deleted file mode 100644 index cbe4dc76a..000000000 --- a/tests/unit/experimental/test_legacy_support.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -Constructs which are added to `experimental` ultimately (hopefully) get ported over to - the main `globus_sdk` namespace. - -The tests in this module verify that those constructs are still available from the - `globus_sdk.experimental` namespace (for backwards compatibility). - -Eventually these constructs do get deprecated at which point the tests in this module - can be deleted. -""" - -import pytest - -from globus_sdk import RemovedInV4Warning -from globus_sdk.gare import ( - GARE, - GlobusAuthorizationParameters, - has_gares, - is_gare, - to_gare, - to_gares, -) - - -def test_scope_importable_from_experimental(): - with pytest.warns(RemovedInV4Warning): - from globus_sdk.experimental.scope_parser import ( # noqa: F401 - Scope, - ScopeCycleError, - ScopeParseError, - ) - - -@pytest.mark.parametrize( - "alias, value", - ( - ("GlobusAuthorizationParameters", GlobusAuthorizationParameters), - ("GlobusAuthRequirementsError", GARE), - ("to_auth_requirements_error", to_gare), - ("to_auth_requirements_errors", to_gares), - ("has_auth_requirements_errors", has_gares), - ("is_auth_requirements_error", is_gare), - ), -) -def test_deprecated_experimental_alias(alias, value): - with pytest.warns(RemovedInV4Warning): - from globus_sdk.experimental import ( - auth_requirements_error as experimental_module, - ) - - aliased_value = getattr(experimental_module, alias) - assert aliased_value is value - - -def test_login_flow_manager_importable_from_experimental(): - with pytest.warns(RemovedInV4Warning): - from globus_sdk.experimental.login_flow_manager import ( # noqa: F401 - CommandLineLoginFlowManager, - LocalServerLoginFlowManager, - LoginFlowManager, - ) - - -def test_tokenstorage_importable_from_experimental(): - with pytest.warns(RemovedInV4Warning): - from globus_sdk.experimental.tokenstorage import ( # noqa: F401 - JSONTokenStorage, - MemoryTokenStorage, - SQLiteTokenStorage, - ) - - -def test_globus_app_importable_from_experimental(): - # This construct should be imported from `globus_sdk.globus_app`. - with pytest.warns(RemovedInV4Warning, match=r"globus_sdk\.globus_app\."): - from globus_sdk.experimental.globus_app import ( # noqa: F401 - TokenValidationErrorHandler, - ) - - # Each of these constructs should be imported from `globus_sdk`. - # This regex ensures we didn't include `globus_sdk.globus_app.` in any warning - with pytest.warns(RemovedInV4Warning, match=r"^(?!globus_sdk\.globus_app\.).*$"): - from globus_sdk.experimental.globus_app import ( # noqa: F401 - ClientApp, - GlobusApp, - GlobusAppConfig, - UserApp, - ) diff --git a/tests/unit/globus_app/test_authorizer_factory.py b/tests/unit/globus_app/test_authorizer_factory.py index 111ce963e..3982d0f51 100644 --- a/tests/unit/globus_app/test_authorizer_factory.py +++ b/tests/unit/globus_app/test_authorizer_factory.py @@ -8,12 +8,12 @@ ClientCredentialsAuthorizerFactory, RefreshTokenAuthorizerFactory, ) -from globus_sdk.tokenstorage import ( +from globus_sdk.token_storage import ( HasRefreshTokensValidator, MemoryTokenStorage, NotExpiredValidator, ) -from globus_sdk.tokenstorage.v2.validating_token_storage import ( +from globus_sdk.token_storage.validating_token_storage import ( ExpiredTokenError, MissingTokenError, ValidatingTokenStorage, diff --git a/tests/unit/globus_app/test_client_integration.py b/tests/unit/globus_app/test_client_integration.py index 06998b00b..236713c4d 100644 --- a/tests/unit/globus_app/test_client_integration.py +++ b/tests/unit/globus_app/test_client_integration.py @@ -4,8 +4,8 @@ import globus_sdk from globus_sdk import GlobusApp, GlobusAppConfig, UserApp -from globus_sdk._testing import load_response -from globus_sdk.tokenstorage import MemoryTokenStorage +from globus_sdk.testing import load_response +from globus_sdk.token_storage import MemoryTokenStorage @pytest.fixture @@ -64,6 +64,17 @@ def test_timers_client_add_app_data_access_scope(app): assert expected in str_list +def test_specific_flow_client_add_app_data_access_scope(app): + flow_id = str(uuid.UUID(int=1)) + client = globus_sdk.SpecificFlowClient(flow_id, app=app) + + collection_id = str(uuid.UUID(int=0)) + client.add_app_transfer_data_access_scope(collection_id) + str_list = [str(s) for s in app.scope_requirements[client.resource_server]] + expected = f"{client.scopes.user}[*urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/{collection_id}/data_access]]" # noqa: E501 + assert expected in str_list + + def test_transfer_client_add_app_data_access_scope_chaining(app): collection_id_1 = str(uuid.UUID(int=1)) collection_id_2 = str(uuid.UUID(int=2)) @@ -92,7 +103,7 @@ def test_transfer_client_add_app_data_access_scope_in_iterable(app): transfer_dependencies = [] for scope in app.scope_requirements["transfer.api.globus.org"]: - if scope.scope_string != globus_sdk.TransferClient.scopes.all: + if scope.scope_string != str(globus_sdk.TransferClient.scopes.all): continue for dep in scope.dependencies: transfer_dependencies.append((dep.scope_string, dep.optional)) @@ -113,10 +124,10 @@ def test_timers_client_add_app_data_access_scope_in_iterable(app): transfer_dependencies = [] for scope in app.scope_requirements[globus_sdk.TimersClient.resource_server]: - if scope.scope_string != globus_sdk.TimersClient.scopes.timer: + if scope.scope_string != str(globus_sdk.TimersClient.scopes.timer): continue for dep in scope.dependencies: - if dep.scope_string != globus_sdk.TransferClient.scopes.all: + if dep.scope_string != str(globus_sdk.TransferClient.scopes.all): continue for subdep in dep.dependencies: transfer_dependencies.append((subdep.scope_string, subdep.optional)) diff --git a/tests/unit/globus_app/test_globus_app.py b/tests/unit/globus_app/test_globus_app.py index 25cea7cb2..db0672feb 100644 --- a/tests/unit/globus_app/test_globus_app.py +++ b/tests/unit/globus_app/test_globus_app.py @@ -19,7 +19,6 @@ TransferClient, UserApp, ) -from globus_sdk._testing import load_response from globus_sdk.exc import GlobusSDKUsageError from globus_sdk.gare import GlobusAuthorizationParameters from globus_sdk.globus_app.authorizer_factory import ( @@ -33,7 +32,8 @@ LoginFlowManager, ) from globus_sdk.scopes import AuthScopes, Scope -from globus_sdk.tokenstorage import ( +from globus_sdk.testing import load_response +from globus_sdk.token_storage import ( HasRefreshTokensValidator, JSONTokenStorage, MemoryTokenStorage, @@ -291,7 +291,7 @@ def test_add_scope_requirements_and_auth_params_with_required_scopes(): # adding a requirement with a dependency user_app.add_scope_requirements( - {"foo": [Scope("foo:all").add_dependency(Scope("bar:all"))]} + {"foo": [Scope("foo:all").with_dependency(Scope("bar:all"))]} ) params = user_app._auth_params_with_required_scopes() assert sorted(params.required_scopes) == [ @@ -303,7 +303,7 @@ def test_add_scope_requirements_and_auth_params_with_required_scopes(): # re-adding a requirement with a new dependency, dependencies should be combined user_app.add_scope_requirements( - {"foo": [Scope("foo:all").add_dependency(Scope("baz:all"))]} + {"foo": [Scope("foo:all").with_dependency(Scope("baz:all"))]} ) params = user_app._auth_params_with_required_scopes() # order of dependencies is not guaranteed @@ -345,7 +345,7 @@ def test_constructor_scope_requirements_accepts_different_scope_types(scope_coll def test_scope_requirements_returns_copies_scopes(): user_app = UserApp("test-app", client_id="mock_client_id") - foo_scope = Scope("foo:all").add_dependency(Scope("bar:all")) + foo_scope = Scope("foo:all").with_dependency(Scope("bar:all")) user_app.add_scope_requirements({"foo": [foo_scope]}) real_requirements = user_app._scope_requirements diff --git a/tests/unit/globus_app/test_scope_normalization.py b/tests/unit/globus_app/test_scope_normalization.py new file mode 100644 index 000000000..c4369691a --- /dev/null +++ b/tests/unit/globus_app/test_scope_normalization.py @@ -0,0 +1,59 @@ +import pytest + +import globus_sdk +from globus_sdk.scopes import Scope + + +@pytest.fixture +def user_app(): + client_id = "mock_client_id" + return globus_sdk.UserApp("test-app", client_id=client_id) + + +@pytest.mark.parametrize( + "scope_collection", + ([Scope("scope1")], Scope("scope1"), "scope1", ["scope1"]), +) +def test_iter_scopes_simple(user_app, scope_collection): + actual_list = list(user_app._iter_scopes(scope_collection)) + + assert len(actual_list) == 1 + assert isinstance(actual_list[0], Scope) + assert str(actual_list[0]) == "scope1" + + +@pytest.mark.parametrize( + "scope_collection, expect_str", + ( + (("scope1", "scope2"), "scope1 scope2"), + (("scope1", Scope("scope2")), "scope1 scope2"), + ((Scope("scope1"), Scope("scope2")), "scope1 scope2"), + ((Scope("scope1"), Scope("scope2"), "scope3"), "scope1 scope2 scope3"), + ( + (Scope("scope1"), Scope("scope2"), "scope3 scope4"), + "scope1 scope2 scope3 scope4", + ), + ([Scope("scope1"), "scope2", "scope3 scope4"], "scope1 scope2 scope3 scope4"), + ), +) +def test_iter_scopes_handles_mixed_data(user_app, scope_collection, expect_str): + actual_list = list(user_app._iter_scopes(scope_collection)) + + assert all(isinstance(scope, Scope) for scope in actual_list) + assert _as_sorted_string(actual_list) == expect_str + + +def test_iter_scopes_handles_dependent_scopes(user_app): + scope_collection = "scope1 scope2[scope3 scope4]" + actual_list = list(user_app._iter_scopes(scope_collection)) + + actual_sorted_str = _as_sorted_string(actual_list) + # Dependent scope ordering is not guaranteed + assert ( + actual_sorted_str == "scope1 scope2[scope3 scope4]" + or actual_sorted_str == "scope1 scope2[scope4 scope3]" + ) + + +def _as_sorted_string(scope_list) -> str: + return " ".join(sorted(str(scope) for scope in scope_list)) diff --git a/tests/unit/helpers/gcs/test_collections.py b/tests/unit/helpers/gcs/test_collections.py index 863387cb9..9a3f77f06 100644 --- a/tests/unit/helpers/gcs/test_collections.py +++ b/tests/unit/helpers/gcs/test_collections.py @@ -1,4 +1,5 @@ import inspect +import typing as t import uuid import pytest @@ -11,6 +12,7 @@ POSIXCollectionPolicies, POSIXStagingCollectionPolicies, ) +from globus_sdk._missing import MISSING, MissingType, filter_missing from globus_sdk.transport import JSONRequestEncoder STUB_SG_ID = uuid.uuid1() # storage gateway @@ -18,8 +20,8 @@ STUB_UC_ID = uuid.uuid1() # user credential -MappedCollectionSignature = inspect.signature(MappedCollectionDocument) -GuestCollectionSignature = inspect.signature(GuestCollectionDocument) +MappedCollectionSignature = inspect.signature(MappedCollectionDocument.__init__) +GuestCollectionSignature = inspect.signature(GuestCollectionDocument.__init__) def test_collection_base_abstract(): @@ -212,15 +214,28 @@ def test_settings_which_are_only_supported_in_guest_collections(fieldname): @pytest.mark.parametrize( "fieldname", ( - "allow_guest_collections", + "disable_verify", + "enable_https", + "force_encryption", + "force_verify", + "public", "delete_protected", + "allow_guest_collections", "disable_anonymous_writes", ), ) @pytest.mark.parametrize("value", (True, False, None)) def test_mapped_collection_opt_bool(fieldname, value): - doc = MappedCollectionDocument( - storage_gateway_id=STUB_SG_ID, collection_base_path="/", **{fieldname: value} + data = {} + if value is not None: + data[fieldname] = value + + doc = filter_missing( + MappedCollectionDocument( + storage_gateway_id=STUB_SG_ID, + collection_base_path="/", + **data, + ) ) if value is not None: assert fieldname in doc @@ -229,6 +244,115 @@ def test_mapped_collection_opt_bool(fieldname, value): assert fieldname not in doc +common_collection_fields = [ + ("collection_base_path", (str, MissingType)), + ("contact_email", (str, None, MissingType)), + ("contact_info", (str, None, MissingType)), + ("default_directory", (str, MissingType)), + ("department", (str, None, MissingType)), + ("description", (str, None, MissingType)), + ("display_name", (str, MissingType)), + ("identity_id", (t.Union[uuid.UUID, str], MissingType)), + ("info_link", (str, None, MissingType)), + ("organization", (str, MissingType)), + ("user_message", (str, None, MissingType)), + ("user_message_link", (str, None, MissingType)), + ("keywords", (t.Iterable[str], MissingType)), + ("disable_verify", (bool, MissingType)), + ("enable_https", (bool, MissingType)), + ("force_encryption", (bool, MissingType)), + ("force_verify", (bool, MissingType)), + ("public", (bool, MissingType)), +] + + +mapped_collection_fields = [ + *common_collection_fields, + ("domain_name", (str, MissingType)), + ("guest_auth_policy_id", (t.Union[uuid.UUID, str], None, MissingType)), + ("disable_anonymous_writes", (bool, MissingType)), + ("policies", (t.Dict[str, t.Any], MissingType)), +] + + +guest_collection_fields = [ + *common_collection_fields, + ("mapped_collection_id", (t.Union[uuid.UUID, str], MissingType)), + ("user_credential_id", (t.Union[uuid.UUID, str], MissingType)), + ("activity_notification_policy", (t.Dict[str, t.List[str]], MissingType)), +] + + +def expand_collection_fields(fields): + """ + Expand each collection field into (field, valid_value) + """ + return [ + (param, value) + for param, types in fields + for _type in types + for value in _gen_value(_type) + ] + + +def _gen_value(_type): + """ + Return a list of valid values for type _type. + """ + if _type is MissingType: + return [MISSING] + if _type is None: + return [None] + if _type is str: + return ["STRING"] + if _type is bool: + return [True, False] + if _type is t.Union[uuid.UUID, str]: + return [str(uuid.uuid1()), uuid.uuid1()] + if _type is t.Iterable[str]: + return [[], ["a", "b", "c"]] + if _type is t.Dict[str, t.Any]: + return [{"A": 1}] + if _type is t.Dict[str, t.List[str]]: + return [{"a": ["b", "c"]}] + + raise AssertionError(f"Unexpected Type: {_type}") + + +@pytest.mark.parametrize( + "fieldname,value", + expand_collection_fields(mapped_collection_fields), +) +def test_mapped_collection_fields(fieldname, value): + """ + Verify that each field in the mapped collection document can be set to a valid + value. + """ + data = {} + if value != MISSING: + data[fieldname] = value + + doc = MappedCollectionDocument(**data) + assert doc[fieldname] == value + + +@pytest.mark.parametrize( + "fieldname,value", + expand_collection_fields(guest_collection_fields), +) +def test_guest_collection_fields(fieldname, value): + """ + Verify that each field in the guest collection document can be set to a valid + value. + """ + data = {} + if value != MISSING: + data[fieldname] = value + + doc = GuestCollectionDocument(**data) + assert doc[fieldname] == value + + # regression test for a typo which caused this to be set improperly to the wrong key @pytest.mark.parametrize("value", ("inbound", "outbound", "all")) @pytest.mark.parametrize("collection_type", ("mapped", "guest")) diff --git a/tests/unit/helpers/gcs/test_connector_table.py b/tests/unit/helpers/gcs/test_connector_table.py index 3deaf8a48..9f8e16a8c 100644 --- a/tests/unit/helpers/gcs/test_connector_table.py +++ b/tests/unit/helpers/gcs/test_connector_table.py @@ -6,13 +6,7 @@ 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 +from globus_sdk import ConnectorTable, GlobusConnectServerConnector @pytest.mark.parametrize("connector_data", ConnectorTable._connectors) diff --git a/tests/unit/helpers/test_auth_flow_managers.py b/tests/unit/helpers/test_auth_flow_managers.py index 477584f63..b8963f3bf 100644 --- a/tests/unit/helpers/test_auth_flow_managers.py +++ b/tests/unit/helpers/test_auth_flow_managers.py @@ -10,7 +10,7 @@ from globus_sdk.services.auth.flow_managers.authorization_code import ( GlobusAuthorizationCodeFlowManager, ) -from globus_sdk.services.auth.flow_managers.native_app import make_native_app_challenge +from globus_sdk.services.auth.flow_managers.native_app import _make_native_app_challenge @pytest.mark.parametrize( @@ -23,7 +23,7 @@ ) def test_invalid_native_app_challenge(verifier): with pytest.raises(globus_sdk.GlobusSDKUsageError): - make_native_app_challenge(verifier) + _make_native_app_challenge(verifier) def test_simple_input_native_app_challenge(): @@ -33,7 +33,7 @@ def test_simple_input_native_app_challenge(): .rstrip(b"=") .decode("utf-8") ) - res_verifier, res_challenge = make_native_app_challenge(verifier) + res_verifier, res_challenge = _make_native_app_challenge(verifier) assert res_verifier == verifier assert res_challenge == challenge @@ -51,7 +51,7 @@ def mock_b64encode(b: bytes): monkeypatch.setattr(os, "urandom", mock_urandom) monkeypatch.setattr(base64, "urlsafe_b64encode", mock_b64encode) - verifier, challenge = make_native_app_challenge() + verifier, challenge = _make_native_app_challenge() assert verifier == "abc123" assert challenge == "abc123" diff --git a/tests/unit/helpers/test_auth_scope_stringify.py b/tests/unit/helpers/test_auth_scope_stringify.py deleted file mode 100644 index ace749620..000000000 --- a/tests/unit/helpers/test_auth_scope_stringify.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest - -from globus_sdk import GlobusSDKUsageError, RemovedInV4Warning -from globus_sdk.scopes import MutableScope -from globus_sdk.services.auth._common import stringify_requested_scopes - - -def test_scope_stringify_roundtrips_string(): - assert stringify_requested_scopes("foo") == "foo" - - -def test_scope_stringify_matches_str_of_mutable_scope(): - foo_scope = MutableScope("foo") - # these asserts are nearly equivalent, but not quite the same - # MutableScope.__str__ could -- at least, in theory -- change in the future - assert stringify_requested_scopes(foo_scope) == str(foo_scope) - assert stringify_requested_scopes(foo_scope) == "foo" - - -def test_scope_stringify_rejects_empty_string(): - with pytest.raises( - GlobusSDKUsageError, - match="requested_scopes cannot be the empty string or empty collection", - ): - stringify_requested_scopes("") - - -@pytest.mark.parametrize("collection_obj", ([], set(), ())) -def test_scope_stringify_rejects_empty_collection(collection_obj): - with pytest.raises( - GlobusSDKUsageError, - match="requested_scopes cannot be the empty string or empty collection", - ): - stringify_requested_scopes(collection_obj) - - -def test_scope_stringify_handles_none_with_default(): - with pytest.warns(RemovedInV4Warning, match="Specify an explicit set of scopes"): - scope_string = stringify_requested_scopes(None) - assert ( - scope_string - == "openid profile email urn:globus:auth:scope:transfer.api.globus.org:all" - ) diff --git a/tests/unit/helpers/test_search.py b/tests/unit/helpers/test_search.py index d44a64f56..b46409629 100644 --- a/tests/unit/helpers/test_search.py +++ b/tests/unit/helpers/test_search.py @@ -1,41 +1,8 @@ """ -Unit tests for globus_sdk.SearchQuery +Unit tests for globus_sdk.SearchQueryV1 """ -import pytest - -from globus_sdk import RemovedInV4Warning, SearchQuery, SearchQueryV1, utils - - -def test_init_legacy(): - params = {"q": "foo", "limit": 10, "offset": 0, "advanced": False} - with pytest.warns(RemovedInV4Warning, match="'SearchQuery' is deprecated"): - param_query = SearchQuery(**params) - for par in params: - assert param_query[par] == params[par] - - -def test_init_legacy_no_args(): - with pytest.warns(RemovedInV4Warning, match="'SearchQuery' is deprecated"): - query = SearchQuery() - - assert len(query) == 0 - - -def test_init_legacy_additional_fields(): - add_params = {"param1": "value1", "param2": "value2"} - with pytest.warns(RemovedInV4Warning, match="'SearchQuery' is deprecated"): - param_query = SearchQuery(additional_fields=add_params) - for par in add_params: - assert param_query[par] == add_params[par] - - -def test_init_legacy_deprecation_warning(): - with pytest.warns( - RemovedInV4Warning, - match="'SearchQuery' is deprecated. Use 'SearchQueryV1' instead.", - ): - SearchQuery() +from globus_sdk import MISSING, SearchQueryV1 def test_init_v1(): @@ -46,7 +13,7 @@ def test_init_v1(): # ensure key attributes initialize to empty lists for attribute in ["facets", "filters", "post_facet_filters", "sort", "boosts"]: - assert query[attribute] == utils.MISSING + assert query[attribute] == MISSING # init with supported fields params = {"q": "foo", "limit": 10, "offset": 0, "advanced": False} @@ -59,162 +26,3 @@ def test_init_v1(): param_query = SearchQueryV1(additional_fields=add_params) for par in add_params: assert param_query[par] == add_params[par] - - -@pytest.mark.parametrize("attrname", ["q", "limit", "offset", "advanced"]) -def test_set_method(attrname): - with pytest.warns(RemovedInV4Warning, match="'SearchQuery' is deprecated"): - query = SearchQuery() - method = getattr(query, "set_{}".format("query" if attrname == "q" else attrname)) - # start absent - assert attrname not in query - # returns self - assert method("foo") is query - # sets value - assert query[attrname] == "foo" - - -def test_add_facet(): - with pytest.warns(RemovedInV4Warning, match="'SearchQuery' is deprecated"): - query = SearchQuery() - assert "facets" not in query - - # simple terms facet - # returns self - assert query.add_facet("facetname", "fieldname") is query - assert query["facets"] - assert len(query["facets"]) == 1 - assert query["facets"][0] == { - "type": "terms", - "name": "facetname", - "field_name": "fieldname", - } - - # terms with size - query.add_facet("n", "f", size=5) - assert len(query["facets"]) == 2 - assert query["facets"][1] == { - "type": "terms", - "name": "n", - "field_name": "f", - "size": 5, - } - - # date histogram - query.add_facet( - "n", - "f", - type="date_histogram", - date_interval="year", - histogram_range=(1870, 1880), - ) - assert len(query["facets"]) == 3 - assert query["facets"][2] == { - "type": "date_histogram", - "name": "n", - "field_name": "f", - "date_interval": "year", - "histogram_range": {"low": 1870, "high": 1880}, - } - - # unknown param - query.add_facet( - "facetname", "fieldname", additional_fields={"nonexistentparam": "value1"} - ) - assert len(query["facets"]) == 4 - assert query["facets"][3] == { - "type": "terms", - "name": "facetname", - "field_name": "fieldname", - "nonexistentparam": "value1", - } - - -def test_add_filter(): - with pytest.warns(RemovedInV4Warning, match="'SearchQuery' is deprecated"): - query = SearchQuery() - assert "filters" not in query - - # returns self - assert query.add_filter("f", [1, 2, 3]) is query - - assert query["filters"] - assert len(query["filters"]) == 1 - assert query["filters"][0] == { - "field_name": "f", - "type": "match_all", - "values": [1, 2, 3], - } - - # match_any + custom param - query.add_filter( - "f", - [1, 2, 3], - type="match_any", - additional_fields={"nonexistentparam": "val1"}, - ) - assert len(query["filters"]) == 2 - assert query["filters"][1] == { - "field_name": "f", - "type": "match_any", - "values": [1, 2, 3], - "nonexistentparam": "val1", - } - - # range - query.add_filter("f", [{"from": 1, "to": 100}], type="range") - assert len(query["filters"]) == 3 - assert query["filters"][2] == { - "field_name": "f", - "type": "range", - "values": [{"from": 1, "to": 100}], - } - - -def test_add_boost(): - with pytest.warns(RemovedInV4Warning, match="'SearchQuery' is deprecated"): - query = SearchQuery() - assert "boosts" not in query - - # returns self - assert query.add_boost("f", 2) == query - - assert query["boosts"] - assert len(query["boosts"]) == 1 - assert query["boosts"][0] == {"field_name": "f", "factor": 2} - - # custom param - query.add_boost("f", 1.1, additional_fields={"nonexistentparam": "value1"}) - assert len(query["boosts"]) == 2 - assert query["boosts"][1] == { - "field_name": "f", - "factor": 1.1, - "nonexistentparam": "value1", - } - - -def test_add_sort(): - with pytest.warns(RemovedInV4Warning, match="'SearchQuery' is deprecated"): - query = SearchQuery() - assert "sort" not in query - - # returns self - assert query.add_sort("f") is query - - assert query["sort"] - assert len(query["sort"]) == 1 - assert query["sort"][0] == {"field_name": "f"} - - # with order - query.add_sort("f", order="asc") - assert len(query["sort"]) == 2 - assert query["sort"][1] == {"field_name": "f", "order": "asc"} - - # custom param - query.add_sort("f", order="asc", additional_fields={"nonexistentparam": "value1"}) - assert len(query["sort"]) == 3 - assert query["sort"][2] == { - "field_name": "f", - "order": "asc", - "nonexistentparam": "value1", - } diff --git a/tests/unit/helpers/test_timer.py b/tests/unit/helpers/test_timer.py index cab604f1e..7439df560 100644 --- a/tests/unit/helpers/test_timer.py +++ b/tests/unit/helpers/test_timer.py @@ -1,4 +1,3 @@ -import contextlib import datetime import pytest @@ -6,48 +5,15 @@ from globus_sdk import ( OnceTimerSchedule, RecurringTimerSchedule, - TimerJob, TransferData, TransferTimer, - exc, - utils, ) +from globus_sdk._missing import filter_missing from tests.common import GO_EP1_ID, GO_EP2_ID -def test_timer_from_transfer_data_ok(): - tdata = TransferData(None, GO_EP1_ID, GO_EP2_ID) - with pytest.warns(exc.RemovedInV4Warning, match="Prefer TransferTimer"): - job = TimerJob.from_transfer_data(tdata, "2022-01-01T00:00:00Z", 600) - assert "callback_body" in job - assert "body" in job["callback_body"] - assert "source_endpoint" in job["callback_body"]["body"] - assert "destination_endpoint" in job["callback_body"]["body"] - assert job["callback_body"]["body"]["source_endpoint"] == GO_EP1_ID - assert job["callback_body"]["body"]["destination_endpoint"] == GO_EP2_ID - - -@pytest.mark.parametrize( - "badkey, value", (("submission_id", "foo"), ("skip_activation_check", True)) -) -def test_timer_from_transfer_data_rejects_forbidden_keys(badkey, value): - if badkey == "skip_activation_check": - ctx = pytest.warns( - exc.RemovedInV4Warning, - match="`skip_activation_check` is no longer supported", - ) - else: - ctx = contextlib.nullcontext() - - with ctx: - tdata = TransferData(None, GO_EP1_ID, GO_EP2_ID, **{badkey: value}) - with pytest.raises(ValueError): - with pytest.warns(exc.RemovedInV4Warning, match="Prefer TransferTimer"): - TimerJob.from_transfer_data(tdata, "2022-01-01T00:00:00Z", 600) - - def test_transfer_timer_ok(): - tdata = TransferData(source_endpoint=GO_EP1_ID, destination_endpoint=GO_EP2_ID) + tdata = TransferData(GO_EP1_ID, GO_EP2_ID) timer = TransferTimer(body=tdata, name="foo timer", schedule={"type": "once"}) assert timer["name"] == "foo timer" assert timer["schedule"]["type"] == "once" @@ -86,12 +52,12 @@ def test_transfer_timer_removes_disallowed_fields(): ) def test_once_timer_schedule_formats_datetime(input_time, expected): schedule = OnceTimerSchedule(datetime=input_time) - assert schedule.data == {"type": "once", "datetime": expected} + assert dict(schedule) == {"type": "once", "datetime": expected} def test_recurring_timer_schedule_interval_only(): schedule = RecurringTimerSchedule(interval_seconds=600) - assert utils.filter_missing(schedule) == { + assert filter_missing(schedule) == { "type": "recurring", "interval_seconds": 600, } @@ -109,7 +75,7 @@ def test_recurring_timer_schedule_interval_only(): ) def test_recurring_timer_schedule_formats_start(input_time, expected): schedule = RecurringTimerSchedule(interval_seconds=600, start=input_time) - assert utils.filter_missing(schedule) == { + assert filter_missing(schedule) == { "type": "recurring", "interval_seconds": 600, "start": expected, @@ -122,7 +88,7 @@ def test_recurring_timer_schedule_formats_datetime_for_end(): schedule = RecurringTimerSchedule( interval_seconds=600, end={"condition": "time", "datetime": end_time} ) - assert utils.filter_missing(schedule) == { + assert filter_missing(schedule) == { "type": "recurring", "interval_seconds": 600, "end": {"condition": "time", "datetime": "2023-10-27T05:38:49+00:00"}, diff --git a/tests/unit/helpers/test_transfer.py b/tests/unit/helpers/test_transfer.py index 51dc94f5b..c31dc9b5b 100644 --- a/tests/unit/helpers/test_transfer.py +++ b/tests/unit/helpers/test_transfer.py @@ -1,42 +1,30 @@ import pytest -from globus_sdk import ( - DeleteData, - GlobusSDKUsageError, - TransferClient, - TransferData, - exc, -) -from globus_sdk._testing import load_response +from globus_sdk import MISSING, DeleteData, TransferData from globus_sdk.services.transfer.client import _format_filter from tests.common import GO_EP1_ID, GO_EP2_ID -def test_transfer_init_simple(): +def test_transfer_init_no_params(): """ - Creates TransferData objects with and without parameters, - Verifies TransferData field initialization + Creates a TransferData object without optional parameters and + verifies field initialization. """ - tc = TransferClient() - meta = load_response(tc.get_submission_id).metadata # default init - tdata = TransferData(tc, GO_EP1_ID, GO_EP2_ID) + tdata = TransferData(GO_EP1_ID, GO_EP2_ID) assert tdata["DATA_TYPE"] == "transfer" assert tdata["source_endpoint"] == GO_EP1_ID assert tdata["destination_endpoint"] == GO_EP2_ID - assert tdata["submission_id"] == meta["submission_id"] + assert tdata["submission_id"] is MISSING assert "DATA" in tdata assert len(tdata["DATA"]) == 0 def test_transfer_init_w_params(): - tc = TransferClient() - meta = load_response(tc.get_submission_id).metadata # init with params label = "label" params = {"param1": "value1", "param2": "value2"} tdata = TransferData( - tc, GO_EP1_ID, GO_EP2_ID, label=label, @@ -44,48 +32,18 @@ def test_transfer_init_w_params(): additional_fields=params, ) assert tdata["label"] == label - assert tdata["submission_id"] == meta["submission_id"] + assert tdata["submission_id"] is MISSING # sync_level of "exists" should be converted to 0 assert tdata["sync_level"] == 0 for par in params: assert tdata[par] == params[par] -def test_transfer_init_no_client(): - tdata1 = TransferData(None, GO_EP1_ID, GO_EP2_ID) - tdata2 = TransferData(source_endpoint=GO_EP1_ID, destination_endpoint=GO_EP2_ID) - tdata3 = TransferData( - source_endpoint=GO_EP1_ID, destination_endpoint=GO_EP2_ID, transfer_client=None - ) - for tdata in (tdata1, tdata2, tdata3): - assert tdata["DATA_TYPE"] == "transfer" - assert tdata["source_endpoint"] == GO_EP1_ID - assert tdata["destination_endpoint"] == GO_EP2_ID - assert "submission_id" not in tdata - assert "DATA" in tdata - assert len(tdata["DATA"]) == 0 - - -@pytest.mark.parametrize( - "tdata_args", - [ - (), - (GO_EP1_ID, GO_EP2_ID), - (None, None, None), - (None, GO_EP1_ID, None), - (None, None, GO_EP2_ID), - ], -) -def test_transfer_init_rejects_bad_usage(tdata_args): - with pytest.raises(GlobusSDKUsageError): - TransferData(*tdata_args) - - def test_transfer_add_item(): """ Adds items to TransferData, verifies results """ - tdata = TransferData(source_endpoint=GO_EP1_ID, destination_endpoint=GO_EP2_ID) + tdata = TransferData(GO_EP1_ID, GO_EP2_ID) # add item source_path = "source/path/" dest_path = "dest/path/" @@ -96,9 +54,9 @@ def test_transfer_add_item(): assert data["DATA_TYPE"] == "transfer_item" assert data["source_path"] == source_path assert data["destination_path"] == dest_path - assert "recursive" not in data - assert "external_checksum" not in data - assert "checksum_algorithm" not in data + assert data["recursive"] == MISSING + assert data["external_checksum"] == MISSING + assert data["checksum_algorithm"] == MISSING # add recursive item tdata.add_item(source_path, dest_path, recursive=True) @@ -109,8 +67,8 @@ def test_transfer_add_item(): assert r_data["source_path"] == source_path assert r_data["destination_path"] == dest_path assert r_data["recursive"] - assert "external_checksum" not in data - assert "checksum_algorithm" not in data + assert data["external_checksum"] == MISSING + assert data["checksum_algorithm"] == MISSING # item with checksum checksum = "d577273ff885c3f84dadb8578bb41399" @@ -123,7 +81,7 @@ def test_transfer_add_item(): assert c_data["DATA_TYPE"] == "transfer_item" assert c_data["source_path"] == source_path assert c_data["destination_path"] == dest_path - assert "recursive" not in c_data + assert c_data["recursive"] == MISSING assert c_data["external_checksum"] == checksum assert c_data["checksum_algorithm"] == algorithm @@ -135,20 +93,24 @@ def test_transfer_add_item(): assert fields_data["DATA_TYPE"] == "transfer_item" assert fields_data["source_path"] == source_path assert fields_data["destination_path"] == dest_path - assert "recursive" not in fields_data + assert fields_data["recursive"] == MISSING assert all(fields_data[k] == v for k, v in addfields.items()) -def test_delete_init_with_client(): - """ - Verifies DeleteData field initialization - """ - tc = TransferClient() - load_response(tc.get_submission_id) - ddata = DeleteData(tc, GO_EP1_ID) +@pytest.mark.parametrize( + "args, kwargs", + ( + ((GO_EP1_ID,), {}), + ((), {"endpoint": GO_EP1_ID}), + ), +) +def test_delete_data_noparams_init(args, kwargs): + # the minimal, required argument is the endpoint ID -- less than that results + # in a TypeError because the signature is not obeyed + ddata = DeleteData(*args, **kwargs) assert ddata["DATA_TYPE"] == "delete" assert ddata["endpoint"] == GO_EP1_ID - assert "submission_id" in ddata + assert ddata["submission_id"] is MISSING assert "DATA" in ddata assert len(ddata["DATA"]) == 0 @@ -163,48 +125,23 @@ def test_delete_init_with_client(): ), ) def test_delete_init_with_supported_parameters(add_kwargs): - ddata = DeleteData(endpoint=GO_EP1_ID, **add_kwargs) + ddata = DeleteData(GO_EP1_ID, **add_kwargs) for k, v in add_kwargs.items(): assert ddata[k] == v def test_delete_init_with_additional_fields(): params = {"param1": "value1", "param2": "value2"} - ddata = DeleteData(endpoint=GO_EP1_ID, additional_fields=params) + ddata = DeleteData(GO_EP1_ID, additional_fields=params) assert ddata["param1"] == "value1" assert ddata["param2"] == "value2" -@pytest.mark.parametrize( - "args, kwargs", - ( - ((None, GO_EP1_ID), {}), - ((), {"endpoint": GO_EP1_ID}), - ((), {"endpoint": GO_EP1_ID, "transfer_client": None}), - ), -) -def test_delete_init_no_client(args, kwargs): - ddata = DeleteData(*args, **kwargs) - assert ddata["DATA_TYPE"] == "delete" - assert ddata["endpoint"] == GO_EP1_ID - assert "submission_id" not in ddata - assert "DATA" in ddata - assert len(ddata["DATA"]) == 0 - - -@pytest.mark.parametrize( - "ddata_args", [(), (GO_EP1_ID,), (None, None), (GO_EP1_ID, None)] -) -def test_delete_init_rejects_bad_usage(ddata_args): - with pytest.raises(GlobusSDKUsageError): - DeleteData(*ddata_args) - - def test_delete_add_item(): """ Adds items to DeleteData, verifies results """ - ddata = DeleteData(endpoint=GO_EP1_ID) + ddata = DeleteData(GO_EP1_ID) # add normal item path = "source/path/" @@ -225,7 +162,7 @@ def test_delete_add_item(): def test_delete_iter_items(): - ddata = DeleteData(endpoint=GO_EP1_ID) + ddata = DeleteData(GO_EP1_ID) # add item ddata.add_item("abc/") ddata.add_item("def/") @@ -244,7 +181,7 @@ def check_item(x, path): def test_transfer_iter_items(): - tdata = TransferData(source_endpoint=GO_EP1_ID, destination_endpoint=GO_EP2_ID) + tdata = TransferData(GO_EP1_ID, GO_EP2_ID) tdata.add_item("source/abc.txt", "dest/abc.txt") tdata.add_item("source/def/", "dest/def/", recursive=True) @@ -277,10 +214,8 @@ def test_notification_options(n_succeeded, n_failed, n_inactive): if n_inactive is not None: notify_kwargs["notify_on_inactive"] = n_inactive - ddata = DeleteData(endpoint=GO_EP1_ID, **notify_kwargs) - tdata = TransferData( - source_endpoint=GO_EP1_ID, destination_endpoint=GO_EP2_ID, **notify_kwargs - ) + ddata = DeleteData(GO_EP1_ID, **notify_kwargs) + tdata = TransferData(GO_EP1_ID, GO_EP2_ID, **notify_kwargs) def _default(x): return x if x is not None else True @@ -291,8 +226,12 @@ def _default(x): "notify_on_inactive": _default(n_inactive), } for k, v in expect.items(): - assert tdata[k] is v - assert ddata[k] is v + if k in notify_kwargs: + assert tdata[k] is v + assert ddata[k] is v + else: + assert tdata[k] is MISSING + assert ddata[k] is MISSING @pytest.mark.parametrize( @@ -314,54 +253,14 @@ def _default(x): def test_transfer_sync_levels_result(sync_level, result): if isinstance(result, type) and issubclass(result, Exception): with pytest.raises(result): - TransferData( - source_endpoint=GO_EP1_ID, - destination_endpoint=GO_EP2_ID, - sync_level=sync_level, - ) + TransferData(GO_EP1_ID, GO_EP2_ID, sync_level=sync_level) else: - tdata = TransferData( - source_endpoint=GO_EP1_ID, - destination_endpoint=GO_EP2_ID, - sync_level=sync_level, - ) + tdata = TransferData(GO_EP1_ID, GO_EP2_ID, sync_level=sync_level) assert tdata["sync_level"] == result -@pytest.mark.parametrize("datatype", ("transfer", "delete")) -@pytest.mark.parametrize("value", (None, True, False)) -def test_skip_activation_check_supported(datatype, value): - def create(**kwargs): - if datatype == "transfer": - return TransferData( - source_endpoint=GO_EP1_ID, destination_endpoint=GO_EP2_ID, **kwargs - ) - else: - return DeleteData(endpoint=GO_EP1_ID, **kwargs) - - if value is None: - # not present if not provided as a param or provided as explicit None - assert "skip_activation_check" not in create() - elif value: - with pytest.warns( - exc.RemovedInV4Warning, - match="`skip_activation_check` is no longer supported", - ): - data = create(skip_activation_check=True) - assert "skip_activation_check" in data - assert data["skip_activation_check"] is True - else: - with pytest.warns( - exc.RemovedInV4Warning, - match="`skip_activation_check` is no longer supported", - ): - data = create(skip_activation_check=False) - assert "skip_activation_check" in data - assert data["skip_activation_check"] is False - - def test_add_filter_rule(): - tdata = TransferData(source_endpoint=GO_EP1_ID, destination_endpoint=GO_EP2_ID) + tdata = TransferData(GO_EP1_ID, GO_EP2_ID) assert "filter_rules" not in tdata tdata.add_filter_rule("*.tgz", type="file") @@ -378,7 +277,7 @@ def test_add_filter_rule(): assert tdata["filter_rules"][1]["DATA_TYPE"] == "filter_rule" assert tdata["filter_rules"][1]["method"] == "exclude" assert tdata["filter_rules"][1]["name"] == "tmp" - assert "type" not in tdata["filter_rules"][1] + assert tdata["filter_rules"][1]["type"] == MISSING @pytest.mark.parametrize( diff --git a/tests/unit/responses/test_activation_response.py b/tests/unit/responses/test_activation_response.py deleted file mode 100644 index 9beb5fa00..000000000 --- a/tests/unit/responses/test_activation_response.py +++ /dev/null @@ -1,117 +0,0 @@ -import json -import time -from unittest import mock - -import pytest -import requests - -from globus_sdk.response import GlobusHTTPResponse -from globus_sdk.services.transfer.response import ActivationRequirementsResponse - - -def make_response( - activated=True, - expires_in=0, - auto_activation_supported=True, - oauth_server=None, - DATA=None, -): - """ - Helper for making ActivationRequirementsResponses with known fields - """ - DATA = DATA or [] - data = { - "activated": activated, - "expires_in": expires_in, - "oauth_server": oauth_server, - "DATA": DATA, - "auto_activation_supported": auto_activation_supported, - } - response = requests.Response() - response.headers["Content-Type"] = "application/json" - response._content = json.dumps(data).encode("utf-8") - return ActivationRequirementsResponse( - GlobusHTTPResponse(response, client=mock.Mock()) - ) - - -def test_expires_at(): - """ - Confirms expires_at is set properly by __init__ - """ - for seconds in [0, 10, 100, 1000, -10]: - response = make_response(expires_in=seconds) - expected = int(time.time()) + seconds - # make sure within a 1 second range of expected value - assert response.expires_at in (expected - 1, expected, expected + 1) - - # -1 marks no expiration - response = make_response(expires_in=-1) - assert response.expires_at is None - - -@pytest.mark.parametrize("value", (True, False)) -def test_supports_auto_activation(value): - """ - Gets supports_auto_activation from made responses, validates results - """ - response = make_response(auto_activation_supported=value) - assert response.supports_auto_activation == value - - -def test_supports_web_activation(): - """ - Gets supports_web_activation from made responses, validates results - """ - # true if auto_activatable, - response = make_response(auto_activation_supported=True) - assert response.supports_web_activation - # has an oauth server, - response = make_response(auto_activation_supported=False, oauth_server="server") - assert response.supports_web_activation - # or one of the other documents is myproxy or delegate_myproxy, - response = make_response( - auto_activation_supported=False, DATA=[{"type": "myproxy"}] - ) - assert response.supports_web_activation - response = make_response( - auto_activation_supported=False, DATA=[{"type": "delegate_myproxy"}] - ) - assert response.supports_web_activation - - # otherwise false - response = make_response(auto_activation_supported=False) - assert not response.supports_web_activation - - -def test_active_until(): - """ - Calls active_until on made responses, validates results - """ - # not active at all - response = make_response(activated=False) - assert not response.active_until(0) - - # always active - response = make_response(expires_in=-1) - assert response.active_until(0) - - response = make_response(expires_in=10) - # relative time - assert response.active_until(5) - assert not response.active_until(15) - # absolute time - now = int(time.time()) - assert response.active_until(now + 5, relative_time=False) - assert not response.active_until(now + 15, relative_time=False) - - -def test_always_activated(): - """ - Gets always_activated property from made responses, validates results - """ - response = make_response(expires_in=-1) - assert response.always_activated - - response = make_response(expires_in=0) - assert not response.always_activated diff --git a/tests/unit/scopes/test_merge_scopes.py b/tests/unit/scopes/test_merge_scopes.py index 03dad7ce6..5fe2e1c23 100644 --- a/tests/unit/scopes/test_merge_scopes.py +++ b/tests/unit/scopes/test_merge_scopes.py @@ -1,13 +1,13 @@ -from globus_sdk.scopes import Scope +from globus_sdk.scopes import Scope, ScopeParser def test_base_scope_strings(): s1 = [Scope("foo"), Scope("bar")] s2 = [Scope("foo"), Scope("baz")] - merged = Scope.merge_scopes(s1, s2) + merged = ScopeParser.merge_scopes(s1, s2) assert len(merged) == 3 - str_list = [s.serialize() for s in merged] + str_list = [str(s) for s in merged] assert "foo" in str_list assert "bar" in str_list assert "baz" in str_list @@ -16,47 +16,47 @@ def test_base_scope_strings(): def test_mixed_optional_dependencies(): s1 = [Scope("foo", optional=True)] s2 = [Scope("foo", optional=False)] - merged = Scope.merge_scopes(s1, s2) + merged = ScopeParser.merge_scopes(s1, s2) assert len(merged) == 2 - str_list = [s.serialize() for s in merged] + str_list = [str(s) for s in merged] assert "foo" in str_list assert "*foo" in str_list def test_different_dependencies(): - s1 = [Scope("foo").add_dependency("bar")] - s2 = [Scope("foo").add_dependency("baz")] - merged = Scope.merge_scopes(s1, s2) + s1 = [Scope("foo").with_dependency(Scope("bar"))] + s2 = [Scope("foo").with_dependency(Scope("baz"))] + merged = ScopeParser.merge_scopes(s1, s2) assert len(merged) == 1 assert merged[0].scope_string == "foo" - dependency_str_list = [s.serialize() for s in merged[0].dependencies] + dependency_str_list = [str(s) for s in merged[0].dependencies] assert len(dependency_str_list) == 2 assert "bar" in dependency_str_list assert "baz" in dependency_str_list def test_optional_dependencies(): - s1 = [Scope("foo").add_dependency("bar")] - s2 = [Scope("foo").add_dependency("*bar")] - merged = Scope.merge_scopes(s1, s2) + s1 = [Scope("foo").with_dependency(Scope("bar"))] + s2 = [Scope("foo").with_dependency(Scope("bar", optional=True))] + merged = ScopeParser.merge_scopes(s1, s2) assert len(merged) == 1 assert merged[0].scope_string == "foo" - dependency_str_list = [s.serialize() for s in merged[0].dependencies] + dependency_str_list = [str(s) for s in merged[0].dependencies] assert len(dependency_str_list) == 1 assert "bar" in dependency_str_list def test_different_dependencies_on_mixed_optional_base(): - s1 = [Scope("foo").add_dependency("bar")] - s2 = [Scope("foo", optional=True).add_dependency("baz")] - merged = Scope.merge_scopes(s1, s2) + s1 = [Scope("foo").with_dependency(Scope("bar"))] + s2 = [Scope("foo", optional=True).with_dependency(Scope("baz"))] + merged = ScopeParser.merge_scopes(s1, s2) assert len(merged) == 2 for scope in merged: - dependency_str_list = [s.serialize() for s in scope.dependencies] + dependency_str_list = [str(s) for s in scope.dependencies] assert len(dependency_str_list) == 2 assert "bar" in dependency_str_list assert "baz" in dependency_str_list diff --git a/tests/unit/scopes/test_mutable_scope.py b/tests/unit/scopes/test_mutable_scope.py deleted file mode 100644 index 4e4e640bc..000000000 --- a/tests/unit/scopes/test_mutable_scope.py +++ /dev/null @@ -1,48 +0,0 @@ -import pytest - -from globus_sdk.scopes import MutableScope - - -def test_scope_str_and_repr_simple(): - s = MutableScope("simple") - assert str(s) == "simple" - assert repr(s) == "MutableScope('simple')" - - -def test_scope_str_and_repr_optional(): - s = MutableScope("simple", optional=True) - assert str(s) == "*simple" - assert repr(s) == "MutableScope('simple', optional=True)" - - -def test_scope_str_and_repr_with_dependencies(): - s = MutableScope("top") - s.add_dependency("foo") - assert str(s) == "top[foo]" - s.add_dependency("bar") - assert str(s) == "top[foo bar]" - assert ( - repr(s) == "MutableScope('top', " - "dependencies=[MutableScope('foo'), MutableScope('bar')])" - ) - - -def test_add_dependency_warns_on_optional_but_still_has_good_str_and_repr(): - s = MutableScope("top") - # this should warn, the use of `optional=...` rather than adding a Scope object - # when optional dependencies are wanted is deprecated - with pytest.warns(DeprecationWarning): - s.add_dependency("foo", optional=True) - - # confirm the str representation and repr for good measure - assert str(s) == "top[*foo]" - assert ( - repr(s) - == "MutableScope('top', dependencies=[MutableScope('foo', optional=True)])" - ) - - -@pytest.mark.parametrize("scope_str", ("*foo", "foo[bar]", "foo[", "foo]", "foo bar")) -def test_scope_init_forbids_special_chars(scope_str): - with pytest.raises(ValueError): - MutableScope(scope_str) diff --git a/tests/unit/scopes/test_scope_builder.py b/tests/unit/scopes/test_scope_builder.py deleted file mode 100644 index b9a37177d..000000000 --- a/tests/unit/scopes/test_scope_builder.py +++ /dev/null @@ -1,118 +0,0 @@ -import uuid - -from globus_sdk.scopes import ComputeScopes, FlowsScopes, ScopeBuilder - - -def test_url_scope_string(): - sb = ScopeBuilder(str(uuid.UUID(int=0))) - assert sb.url_scope_string("data_access") == ( - "https://auth.globus.org/scopes/00000000-0000-0000-0000-000000000000" - "/data_access" - ) - - -def test_urn_scope_string(): - sb = ScopeBuilder("example.globus.org") - assert ( - sb.urn_scope_string("scope") == "urn:globus:auth:scope:example.globus.org:scope" - ) - - -def test_known_scopes(): - sb = ScopeBuilder(str(uuid.UUID(int=0)), known_scopes="foo") - assert sb.foo == "urn:globus:auth:scope:00000000-0000-0000-0000-000000000000:foo" - - -def test_known_url_scopes(): - sb = ScopeBuilder(str(uuid.UUID(int=0)), known_url_scopes="foo") - assert sb.foo == ( - "https://auth.globus.org/scopes/00000000-0000-0000-0000-000000000000/foo" - ) - - -def test_scopebuilder_str(): - sb = ScopeBuilder(str(uuid.UUID(int=0)), known_scopes="foo", known_url_scopes="bar") - rs, foo_scope, bar_scope = sb.resource_server, sb.foo, sb.bar - - stringified = str(sb) - assert rs in stringified - assert foo_scope in stringified - assert bar_scope in stringified - - -def test_uniquely_named_scopes(): - rs = str(uuid.uuid4()) - scope_1 = str(uuid.uuid4()) - scope_2 = str(uuid.uuid4()) - sb = ScopeBuilder( - rs, - known_scopes=[("my_urn_scope", scope_1), "foo"], - known_url_scopes=[("my_url_scope", scope_2), "bar"], - ) - - assert sb.my_urn_scope == f"urn:globus:auth:scope:{rs}:{scope_1}" - assert sb.foo == f"urn:globus:auth:scope:{rs}:foo" - assert sb.my_url_scope == f"https://auth.globus.org/scopes/{rs}/{scope_2}" - assert sb.bar == f"https://auth.globus.org/scopes/{rs}/bar" - - -def test_sb_allowed_inputs_types(): - rs = str(uuid.uuid4()) - scope_1 = "do_a_thing" - scope_1_urn = f"urn:globus:auth:scope:{rs}:{scope_1}" - - none_sb = ScopeBuilder(rs, known_scopes=None) - str_sb = ScopeBuilder(rs, known_scopes=scope_1) - tuple_sb = ScopeBuilder(rs, known_scopes=("scope_1", scope_1)) - list_sb = ScopeBuilder(rs, known_scopes=[scope_1, ("scope_1", scope_1)]) - - assert none_sb.scope_names == [] - assert scope_1 in str_sb.scope_names - assert str_sb.do_a_thing == scope_1_urn - assert "scope_1" in tuple_sb.scope_names - assert tuple_sb.scope_1 == scope_1_urn - assert scope_1 in list_sb.scope_names - assert "scope_1" in list_sb.scope_names - assert list_sb.scope_1 == scope_1_urn - assert list_sb.do_a_thing == scope_1_urn - - -def test_scopebuilder_make_mutable_produces_same_strings(): - sb = ScopeBuilder(str(uuid.UUID(int=0)), known_scopes="foo", known_url_scopes="bar") - assert str(sb.make_mutable("foo")) == sb.foo - assert str(sb.make_mutable("bar")) == sb.bar - - -def test_scopebuilder_make_mutable_can_be_optional(): - sb = ScopeBuilder(str(uuid.UUID(int=0)), known_scopes="foo") - assert str(sb.make_mutable("foo", optional=True)) == "*" + sb.foo - - -def test_flows_scopes_creation(): - assert FlowsScopes.resource_server == "flows.globus.org" - assert ( - FlowsScopes.run - == "https://auth.globus.org/scopes/eec9b274-0c81-4334-bdc2-54e90e689b9a/run" - ) - - -def test_compute_scopes_creation(): - assert ComputeScopes.resource_server == "funcx_service" - assert ( - ComputeScopes.all - == "https://auth.globus.org/scopes/facd7ccc-c5f4-42aa-916b-a0e270e2c2a9/all" - ) - - -def test_stringify_scope_builder(): - class MyScopeBuilder(ScopeBuilder): - pass - - sb = MyScopeBuilder("foo", known_scopes=["sc1"]) - assert ( - str(sb) - == """\ -MyScopeBuilder[foo] - sc1: - urn:globus:auth:scope:foo:sc1""" - ) diff --git a/tests/unit/scopes/test_scope_collections.py b/tests/unit/scopes/test_scope_collections.py new file mode 100644 index 000000000..784d1ed27 --- /dev/null +++ b/tests/unit/scopes/test_scope_collections.py @@ -0,0 +1,78 @@ +import uuid + +from globus_sdk.scopes import ComputeScopes, FlowsScopes, Scope +from globus_sdk.scopes.collection import ( + DynamicScopeCollection, + StaticScopeCollection, + _url_scope, + _urn_scope, +) + + +def test_url_scope_string(): + resource_server = str(uuid.UUID(int=0)) + s = _url_scope(resource_server, "data_access") + assert isinstance(s, Scope) + assert str(s) == ( + "https://auth.globus.org/scopes/00000000-0000-0000-0000-000000000000" + "/data_access" + ) + + +def test_urn_scope_string(): + resource_server = "example.globus.org" + s = _urn_scope(resource_server, "myscope") + assert isinstance(s, Scope) + assert str(s) == "urn:globus:auth:scope:example.globus.org:myscope" + + +def test_static_scope_collection_iter_contains_expected_values(): + class _MyScopes(StaticScopeCollection): + resource_server = str(uuid.UUID(int=0)) + + foo = _urn_scope(resource_server, "foo") + bar = _url_scope(resource_server, "bar") + + MyScopes = _MyScopes() + + listified = list(MyScopes) + as_set = set(listified) + assert len(listified) == len(as_set) + assert as_set == {MyScopes.foo, MyScopes.bar} + + +def test_dynamic_scope_collection_contains_expected_values(): + class MyScopes(DynamicScopeCollection): + _scope_names = ("foo", "bar") + + @property + def foo(self): + return _urn_scope(self.resource_server, "foo") + + @property + def bar(self): + return _url_scope(self.resource_server, "bar") + + resource_server = str(uuid.UUID(int=10)) + scope_collection = MyScopes(resource_server) + assert scope_collection.resource_server == resource_server + + listified = list(scope_collection) + assert scope_collection.foo in listified + assert scope_collection.bar in listified + + +def test_flows_scopes_creation(): + assert FlowsScopes.resource_server == "flows.globus.org" + assert ( + str(FlowsScopes.run) + == "https://auth.globus.org/scopes/eec9b274-0c81-4334-bdc2-54e90e689b9a/run" + ) + + +def test_compute_scopes_creation(): + assert ComputeScopes.resource_server == "funcx_service" + assert ( + str(ComputeScopes.all) + == "https://auth.globus.org/scopes/facd7ccc-c5f4-42aa-916b-a0e270e2c2a9/all" + ) diff --git a/tests/unit/scopes/test_scope_model.py b/tests/unit/scopes/test_scope_model.py new file mode 100644 index 000000000..07e9c7465 --- /dev/null +++ b/tests/unit/scopes/test_scope_model.py @@ -0,0 +1,74 @@ +import uuid + +import pytest + +from globus_sdk.scopes import Scope + + +def test_scope_with_dependency_leaves_original_unchanged(): + s1 = Scope(uuid.uuid1().hex) + s2 = Scope("s2") + s3 = s1.with_dependency(s2) + + assert s1.scope_string == s3.scope_string + assert len(s1.dependencies) == 0 + assert len(s3.dependencies) == 1 + + +def test_scope_with_dependencies_leaves_original_unchanged(): + s1 = Scope(uuid.uuid1().hex) + s2 = Scope("s2") + s3 = Scope("s3") + s4 = s1.with_dependencies((s2, s3)) + + assert s1.scope_string == s4.scope_string + assert len(s1.dependencies) == 0 + assert len(s4.dependencies) == 2 + + +def test_scope_with_optional_leaves_original_unchanged(): + s1 = Scope(uuid.uuid1().hex) + s2 = s1.with_optional(True) + s3 = s2.with_optional(False) + + assert s1.scope_string == s2.scope_string == s3.scope_string + assert len(s1.dependencies) == 0 + assert len(s2.dependencies) == 0 + assert len(s3.dependencies) == 0 + + assert not s1.optional + assert s2.optional + assert not s3.optional + + +def test_scope_with_string_dependency_gets_typeerror(): + s = Scope("x") + with pytest.raises( + TypeError, + match=r"Scope\.with_dependency\(\) takes a Scope as its input\. Got: 'str'", + ): + s.with_dependency("y") + + +def test_scope_with_string_dependencies_gets_typeerror(): + s = Scope("x") + with pytest.raises( + TypeError, + match=( + r"Scope\.with_dependencies\(\) takes an iterable of Scopes as its input\. " + "At position 0, got: 'str'" + ), + ): + s.with_dependencies(["y"]) + + +def test_scope_with_mixed_dependencies_gets_typeerror(): + s = Scope("x") + with pytest.raises( + TypeError, + match=( + r"Scope\.with_dependencies\(\) takes an iterable of Scopes as its input\. " + "At position 1, got: 'str'" + ), + ): + s.with_dependencies([Scope("y"), "z"]) diff --git a/tests/unit/scopes/test_scope_normalization.py b/tests/unit/scopes/test_scope_normalization.py deleted file mode 100644 index 6f697f33b..000000000 --- a/tests/unit/scopes/test_scope_normalization.py +++ /dev/null @@ -1,91 +0,0 @@ -import pytest - -from globus_sdk.scopes import MutableScope, Scope, scopes_to_scope_list, scopes_to_str - - -def test_scopes_to_str_roundtrip_simple_str(): - assert scopes_to_str("scope1") == "scope1" - - -def test_scopes_to_str_stringifies_single_scope(): - assert scopes_to_str(Scope("scope1")) == "scope1" - - -@pytest.mark.parametrize( - "scope_collection", - ( - ("scope1",), - ["scope1"], - {"scope1"}, - (s for s in ["scope1"]), - ), -) -def test_scopes_to_str_roundtrip_simple_str_in_collection(scope_collection): - assert scopes_to_str(scope_collection) == "scope1" - - -@pytest.mark.parametrize( - "scope_collection, expect_str", - ( - (("scope1", Scope("scope2")), "scope1 scope2"), - (("scope1", MutableScope("scope2")), "scope1 scope2"), - ((Scope("scope1"), MutableScope("scope2")), "scope1 scope2"), - ((Scope("scope1"), MutableScope("scope2"), "scope3"), "scope1 scope2 scope3"), - ( - ((Scope("scope1"), Scope("scope2")), "scope3 scope4"), - "scope1 scope2 scope3 scope4", - ), - (([[["bar"]]],), "bar"), - ), -) -def test_scopes_to_str_handles_mixed_data(scope_collection, expect_str): - assert scopes_to_str(scope_collection) == expect_str - - -@pytest.mark.parametrize( - "scope_collection", - ([Scope("scope1")], Scope("scope1"), "scope1", MutableScope("scope1")), -) -def test_scopes_to_scope_list_simple(scope_collection): - actual_list = scopes_to_scope_list(scope_collection) - - assert len(actual_list) == 1 - assert isinstance(actual_list[0], Scope) - assert str(actual_list[0]) == "scope1" - - -@pytest.mark.parametrize( - "scope_collection, expect_str", - ( - (("scope1", "scope2"), "scope1 scope2"), - (("scope1", MutableScope("scope2")), "scope1 scope2"), - ((Scope("scope1"), MutableScope("scope2")), "scope1 scope2"), - ((Scope("scope1"), MutableScope("scope2"), "scope3"), "scope1 scope2 scope3"), - ( - ((Scope("scope1"), Scope("scope2")), "scope3 scope4"), - "scope1 scope2 scope3 scope4", - ), - (([[["bar"]]],), "bar"), - ), -) -def test_scopes_to_scope_list_handles_mixed_data(scope_collection, expect_str): - actual_list = scopes_to_scope_list(scope_collection) - - assert all(isinstance(scope, Scope) for scope in actual_list) - assert _as_sorted_string(actual_list) == expect_str - - -def test_scopes_to_scope_list_handles_dependent_scopes(): - scope_collection = "scope1 scope2[scope3 scope4]" - actual_list = scopes_to_scope_list(scope_collection) - - actual_sorted_str = _as_sorted_string(actual_list) - # Dependent scope ordering is not guaranteed - assert ( - actual_sorted_str == "scope1 scope2[scope3 scope4]" - or actual_sorted_str == "scope1 scope2[scope4 scope3]" - ) - - -def _as_sorted_string(scope_list) -> str: - return " ".join(sorted(str(scope) for scope in scope_list)) diff --git a/tests/unit/scopes/test_scope_parser.py b/tests/unit/scopes/test_scope_parser.py index 6cb8b9bcd..0284685c1 100644 --- a/tests/unit/scopes/test_scope_parser.py +++ b/tests/unit/scopes/test_scope_parser.py @@ -2,7 +2,8 @@ import pytest -from globus_sdk import Scope, ScopeCycleError, ScopeParseError +from globus_sdk import exc +from globus_sdk.scopes import Scope, ScopeCycleError, ScopeParseError, ScopeParser def test_scope_str_and_repr_simple(): @@ -19,53 +20,32 @@ def test_scope_str_and_repr_optional(): def test_scope_str_and_repr_with_dependencies(): s = Scope("top") - s.add_dependency("foo") + s = s.with_dependency(Scope("foo")) assert str(s) == "top[foo]" - s.add_dependency("bar") + s = s.with_dependency(Scope("bar")) assert str(s) == "top[foo bar]" - assert repr(s) == "Scope('top', dependencies=[Scope('foo'), Scope('bar')])" - - -def test_add_dependency_warns_on_optional_but_still_has_good_str_and_repr(): - s = Scope("top") - # this should warn, the use of `optional=...` rather than adding a Scope object - # when optional dependencies are wanted is deprecated - with pytest.warns(DeprecationWarning): - s.add_dependency("foo", optional=True) - - # confirm the str representation and repr for good measure - assert str(s) == "top[*foo]" - assert repr(s) == "Scope('top', dependencies=[Scope('foo', optional=True)])" - - -@pytest.mark.parametrize("optional_arg", (True, False)) -def test_add_dependency_fails_if_optional_is_combined_with_scope(optional_arg): - s = Scope("top") - s2 = Scope("bottom") - with pytest.raises(ValueError): - s.add_dependency(s2, optional=optional_arg) + assert repr(s) == "Scope('top', dependencies=(Scope('foo'), Scope('bar')))" def test_scope_str_nested(): - top = Scope("top") - mid = Scope("mid") bottom = Scope("bottom") - mid.add_dependency(bottom) - top.add_dependency(mid) + mid = Scope("mid", dependencies=(bottom,)) + top = Scope("top", dependencies=(mid,)) assert str(bottom) == "bottom" assert str(mid) == "mid[bottom]" assert str(top) == "top[mid[bottom]]" -def test_add_dependency_parses_scope_with_optional_marker(): +def test_scope_with_optional_dependency_stringifies(): s = Scope("top") - s.add_dependency("*subscope") + s = s.with_dependency(Scope("subscope", optional=True)) assert str(s) == "top[*subscope]" - assert repr(s) == "Scope('top', dependencies=[Scope('subscope', optional=True)])" + subscope_repr = "Scope('subscope', optional=True)" + assert repr(s) == f"Scope('top', dependencies=({subscope_repr},))" def test_scope_parsing_allows_empty_string(): - scopes = Scope.parse("") + scopes = ScopeParser.parse("") assert scopes == [] @@ -78,8 +58,8 @@ def test_scope_parsing_allows_empty_string(): ], ) def test_scope_parsing_ignores_non_semantic_whitespace(scope_string1, scope_string2): - list1 = Scope.parse(scope_string1) - list2 = Scope.parse(scope_string2) + list1 = ScopeParser.parse(scope_string1) + list2 = ScopeParser.parse(scope_string2) assert len(list1) == len(list2) == 1 s1, s2 = list1[0], list2[0] # Scope.__eq__ is not defined, so equivalence checking is manual (and somewhat error @@ -125,7 +105,7 @@ def test_scope_parsing_ignores_non_semantic_whitespace(scope_string1, scope_stri ) def test_scope_parsing_rejects_bad_inputs(scopestring): with pytest.raises(ScopeParseError): - Scope.parse(scopestring) + ScopeParser.parse(scopestring) @pytest.mark.parametrize( @@ -141,7 +121,7 @@ def test_scope_parsing_rejects_bad_inputs(scopestring): ) def test_scope_parsing_catches_and_rejects_cycles(scopestring): with pytest.raises(ScopeCycleError): - Scope.parse(scopestring) + ScopeParser.parse(scopestring) @pytest.mark.flaky @@ -166,7 +146,7 @@ def test_scope_parsing_catches_and_rejects_very_large_cycles_quickly(): t0 = time.time() with pytest.raises(ScopeCycleError): - Scope.parse(scope_string) + ScopeParser.parse(scope_string) t1 = time.time() assert t1 - t0 < 0.1 @@ -177,31 +157,31 @@ def test_scope_parsing_catches_and_rejects_very_large_cycles_quickly(): ) def test_scope_parsing_accepts_valid_inputs(scopestring): # test *only* that parsing does not error and returns a non-empty list of scopes - scopes = Scope.parse(scopestring) + scopes = ScopeParser.parse(scopestring) assert isinstance(scopes, list) assert len(scopes) > 0 assert isinstance(scopes[0], Scope) def test_scope_deserialize_simple(): - scope = Scope.deserialize("foo") + scope = Scope.parse("foo") assert str(scope) == "foo" def test_scope_deserialize_with_dependencies(): # oh, while we're here, let's also check that our whitespace insensitivity works - scope = Scope.deserialize("foo[ bar *baz ]") + scope = Scope.parse("foo[ bar *baz ]") assert str(scope) in ("foo[bar *baz]", "foo[*baz bar]") def test_scope_deserialize_fails_on_empty(): with pytest.raises(ValueError): - Scope.deserialize(" ") + Scope.parse(" ") def test_scope_deserialize_fails_on_multiple_top_level_scopes(): with pytest.raises(ValueError): - Scope.deserialize("foo bar") + Scope.parse("foo bar") @pytest.mark.parametrize("scope_str", ("*foo", "foo[bar]", "foo[", "foo]", "foo bar")) @@ -220,4 +200,66 @@ def test_scope_init_forbids_special_chars(scope_str): ], ) def test_scope_parsing_normalizes_optionals(original, reserialized): - assert {s.serialize() for s in Scope.parse(original)} == reserialized + assert {str(s) for s in ScopeParser.parse(original)} == reserialized + + +@pytest.mark.parametrize( + "scope_str", + ( + "foo", + "*foo", + "foo[bar] baz", + " foo ", + "foo[bar] bar[foo]", + ), +) +def test_serialize_of_scope_string_is_exact(scope_str): + assert ScopeParser.serialize(scope_str) == scope_str + + +def test_serialize_of_scope_object(): + assert ScopeParser.serialize(Scope("scope1")) == "scope1" + + +@pytest.mark.parametrize( + "scope_collection", + ( + ("scope1",), + ["scope1"], + {"scope1"}, + (s for s in ["scope1"]), + ), +) +def test_serialize_of_simple_collection_of_strings(scope_collection): + assert ScopeParser.serialize(scope_collection) == "scope1" + + +@pytest.mark.parametrize( + "scope_collection, expect_str", + ( + (("scope1", Scope("scope2")), "scope1 scope2"), + ((Scope("scope1"), Scope("scope2")), "scope1 scope2"), + ((Scope("scope1"), Scope("scope2"), "scope3"), "scope1 scope2 scope3"), + ( + (Scope("scope1"), Scope("scope2"), "scope3 scope4"), + "scope1 scope2 scope3 scope4", + ), + ([Scope("scope1"), "scope2", "scope3 scope4"], "scope1 scope2 scope3 scope4"), + ), +) +def test_serialize_handles_mixed_data(scope_collection, expect_str): + assert ScopeParser.serialize(scope_collection) == expect_str + + +@pytest.mark.parametrize("input_obj", ("", [], set(), ())) +def test_serialize_rejects_empty_by_default(input_obj): + with pytest.raises( + exc.GlobusSDKUsageError, + match="'scopes' cannot be the empty string or empty collection", + ): + ScopeParser.serialize(input_obj) + + +@pytest.mark.parametrize("input_obj", ("", [], set(), ())) +def test_serialize_allows_empty_string_with_flag(input_obj): + assert ScopeParser.serialize(input_obj, reject_empty=False) == "" diff --git a/tests/unit/scopes/test_scope_parser_intermediate_representations.py b/tests/unit/scopes/test_scope_parser_intermediate_representations.py index 06a3d89d9..7db32f6eb 100644 --- a/tests/unit/scopes/test_scope_parser_intermediate_representations.py +++ b/tests/unit/scopes/test_scope_parser_intermediate_representations.py @@ -1,8 +1,8 @@ -from globus_sdk.scopes._parser import ScopeTreeNode, parse_scope_graph +from globus_sdk.scopes._graph_parser import ScopeGraph def test_graph_str_single_node(): - g = parse_scope_graph("foo") + g = ScopeGraph.parse("foo") clean_str = _blank_lines_removed(str(g)) assert ( clean_str @@ -15,7 +15,7 @@ def test_graph_str_single_node(): def test_graph_str_single_optional_node(): - g = parse_scope_graph("*foo") + g = ScopeGraph.parse("*foo") clean_str = _blank_lines_removed(str(g)) assert ( clean_str @@ -28,7 +28,7 @@ def test_graph_str_single_optional_node(): def test_graph_str_single_dependency(): - g = parse_scope_graph("foo[bar]") + g = ScopeGraph.parse("foo[bar]") clean_str = _blank_lines_removed(str(g)) assert ( clean_str @@ -42,7 +42,7 @@ def test_graph_str_single_dependency(): def test_graph_str_optional_dependency(): - g = parse_scope_graph("foo[bar[*baz]]") + g = ScopeGraph.parse("foo[bar[*baz]]") clean_str = _blank_lines_removed(str(g)) assert ( clean_str @@ -56,17 +56,5 @@ def test_graph_str_optional_dependency(): ) -def test_treenode_repr(): - t = ScopeTreeNode("foo", optional=False) - assert repr(t) == "ScopeTreeNode('foo')" - - t = ScopeTreeNode("foo", optional=True) - assert repr(t) == "ScopeTreeNode('foo', optional=True)" - - t = ScopeTreeNode("foo", optional=False) - t.dependencies = [ScopeTreeNode("bar", optional=False)] - assert repr(t) == "ScopeTreeNode('foo', dependencies=[ScopeTreeNode('bar')])" - - def _blank_lines_removed(s: str) -> str: return "\n".join(line for line in s.split("\n") if line.strip() != "") diff --git a/tests/unit/services/compute/test_deprecated_client_alias.py b/tests/unit/services/compute/test_deprecated_client_alias.py deleted file mode 100644 index 56a3e4995..000000000 --- a/tests/unit/services/compute/test_deprecated_client_alias.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest - -import globus_sdk -from globus_sdk import ComputeClientV2, RemovedInV4Warning - - -def test_legacy_client_warns_on_import(): - from globus_sdk.services.compute import ( - deprecated_client as deprecated_client_module, - ) - - # first, remove the object from the module's `__dict__` if it was there - # ensures that access will run `__getattr__` - if "ComputeClient" in deprecated_client_module.__dict__: - del deprecated_client_module.__dict__["ComputeClient"] - # and, similarly, remove it from 'globus_sdk' - if "ComputeClient" in globus_sdk.__dict__: - del globus_sdk.__dict__["ComputeClient"] - - with pytest.warns(RemovedInV4Warning, match="deprecated"): - from globus_sdk import ComputeClient # noqa: F401 - - -@pytest.mark.filterwarnings("ignore::globus_sdk.RemovedInV4Warning") -def test_legacy_client_is_v2(): - client = globus_sdk.ComputeClient() - assert isinstance(client, ComputeClientV2) diff --git a/tests/unit/services/compute/test_deprecated_data.py b/tests/unit/services/compute/test_deprecated_data.py deleted file mode 100644 index 16ed94b87..000000000 --- a/tests/unit/services/compute/test_deprecated_data.py +++ /dev/null @@ -1,21 +0,0 @@ -import pytest - -from globus_sdk.exc import RemovedInV4Warning -from globus_sdk.services.compute.data import ( - ComputeFunctionDocument, - ComputeFunctionMetadata, -) - - -def test_compute_function_metadata_deprecated(): - with pytest.warns( - RemovedInV4Warning, match="ComputeFunctionMetadata is deprecated." - ): - ComputeFunctionMetadata() - - -def test_compute_function_document_deprecated(): - with pytest.warns( - RemovedInV4Warning, match="ComputeFunctionDocument is deprecated." - ): - ComputeFunctionDocument(function_name="foo", function_code="bar") diff --git a/tests/unit/services/timers/test_legacy_names.py b/tests/unit/services/timers/test_legacy_names.py deleted file mode 100644 index f2e4007f1..000000000 --- a/tests/unit/services/timers/test_legacy_names.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -Test that Timers objects are available under their legacy names -(via supported paths only) and that they emit deprecation warnings -as appropriate. -""" - -import pytest - -import globus_sdk -import globus_sdk.scopes - - -def test_importing_legacy_scope_name_works_but_warns(): - # first, remove the object from the module's `__dict__` if it was there - # ensures that access will run `__getattr__` - if "TimerScopes" in globus_sdk.scopes.__dict__: - del globus_sdk.scopes.__dict__["TimerScopes"] - - with pytest.warns( - globus_sdk.exc.RemovedInV4Warning, match="'TimerScopes' is a deprecated name" - ): - from globus_sdk.scopes import TimerScopes - assert TimerScopes is globus_sdk.scopes.TimersScopes - assert hasattr(globus_sdk.scopes, "TimerScopes") - - -def test_bad_access_to_legacy_name_errors(): - # sanity check that by adding module-level 'getattr' we haven't broken the - # AttributeError behaviors of the module - with pytest.raises( - ImportError, match="cannot import name 'FOO' from 'globus_sdk.scopes'" - ): - from globus_sdk.scopes import FOO # noqa: F401 - - with pytest.raises(AttributeError, match="globus_sdk.scopes has no attribute BAR"): - globus_sdk.scopes.BAR - - -def test_importing_legacy_error_name_works(): - # first, remove the object from the module's `__dict__` if it was there - # ensures that access will run `__getattr__` - if "TimerAPIError" in globus_sdk.__dict__: - del globus_sdk.__dict__["TimerAPIError"] - - from globus_sdk import TimerAPIError, TimersAPIError - - assert TimerAPIError is TimersAPIError - assert hasattr(globus_sdk, "TimerAPIError") - - -def test_importing_legacy_client_name_works(): - # first, remove the object from the module's `__dict__` if it was there - # ensures that access will run `__getattr__` - if "TimerClient" in globus_sdk.__dict__: - del globus_sdk.__dict__["TimerClient"] - - from globus_sdk import TimerClient, TimersClient - - assert TimerClient is TimersClient - assert hasattr(globus_sdk, "TimerClient") diff --git a/tests/unit/sphinxext/conftest.py b/tests/unit/sphinxext/conftest.py index e257e0a17..5c984ba36 100644 --- a/tests/unit/sphinxext/conftest.py +++ b/tests/unit/sphinxext/conftest.py @@ -183,9 +183,9 @@ def sphinxext(): """ pytest.importorskip("docutils", reason="testing sphinx extension needs docutils") - import globus_sdk._sphinxext + import globus_sdk._internal.extensions.sphinxext - return globus_sdk._sphinxext + return globus_sdk._internal.extensions.sphinxext @pytest.fixture diff --git a/tests/unit/sphinxext/test_copyparams_directive.py b/tests/unit/sphinxext/test_copyparams_directive.py index 8bddd49a7..01c058d76 100644 --- a/tests/unit/sphinxext/test_copyparams_directive.py +++ b/tests/unit/sphinxext/test_copyparams_directive.py @@ -11,7 +11,8 @@ "authorizer", "app_name", "base_url", - "transport_params", + "transport", + "retry_config", ) diff --git a/tests/unit/sphinxext/test_expand_testing_fixture.py b/tests/unit/sphinxext/test_expand_testing_fixture.py index 15311c9aa..46de311f9 100644 --- a/tests/unit/sphinxext/test_expand_testing_fixture.py +++ b/tests/unit/sphinxext/test_expand_testing_fixture.py @@ -20,7 +20,7 @@ def test_expand_testing_fixture_fails_on_bad_reference(sphinx_runner, capsys): pytest.fail("Didn't find 'ValueError: no fixtures defined' in stderr") assert ( - "no fixtures defined for globus_sdk._testing.data.NO_SUCH_FIXTURE" in test_line + "no fixtures defined for globus_sdk.testing.data.NO_SUCH_FIXTURE" in test_line ) diff --git a/tests/unit/sphinxext/test_list_known_scopes.py b/tests/unit/sphinxext/test_list_known_scopes.py index 2710178fa..bb105bfd9 100644 --- a/tests/unit/sphinxext/test_list_known_scopes.py +++ b/tests/unit/sphinxext/test_list_known_scopes.py @@ -13,18 +13,18 @@ def test_listknownscopes_rejects_wrong_object_type(sphinx_runner, capsys): test_line = None for line in err_lines: - if "ScopeBuilder" in line: + if "scope collection" in line: test_line = line break else: - pytest.fail("Didn't find 'ScopeBuilder' in stderr") + pytest.fail("Didn't find 'scope collection' in stderr") assert re.search( - r"Expected to be a ScopeBuilder", test_line + r"Expected to be a scope collection", test_line ) -# choose an arbitrary scope builder from the SDK and confirm that listknownscopes +# choose an arbitrary scope collection from the SDK and confirm that listknownscopes # will render its list of members # for this case, we're using `TimersScopes` def test_listknownscopes_of_timers(sphinx_runner): diff --git a/tests/unit/test_auth_clients.py b/tests/unit/test_auth_clients.py index 8e78b0056..fc585a584 100644 --- a/tests/unit/test_auth_clients.py +++ b/tests/unit/test_auth_clients.py @@ -9,39 +9,6 @@ CLIENT_ID_STR = str(CLIENT_ID_UUID) -def test_service_client_does_not_require_client_id(): - client = globus_sdk.AuthClient() - # accessing the attribute warns, but provides None - with pytest.warns(globus_sdk.RemovedInV4Warning): - assert client.client_id is None - - -pass_value_params = pytest.mark.parametrize( - "pass_value", (CLIENT_ID_STR, CLIENT_ID_UUID), ids=("str", "uuid") -) - - -@pass_value_params -def test_service_client_allows_client_id_but_warns(pass_value): - # init will warn because a value is being passed - with pytest.warns(globus_sdk.RemovedInV4Warning): - client = globus_sdk.AuthClient(client_id=pass_value) - - # accessing the attribute warns a second time, but provides the stringified value - with pytest.warns(globus_sdk.RemovedInV4Warning): - assert client.client_id == CLIENT_ID_STR - - -@pass_value_params -def test_service_client_allows_client_id_assignment(pass_value): - client = globus_sdk.AuthClient() - with pytest.warns(globus_sdk.RemovedInV4Warning): - client.client_id = pass_value - - with pytest.warns(globus_sdk.RemovedInV4Warning): - assert client.client_id == CLIENT_ID_STR - - @pytest.mark.parametrize( "client_type", ( @@ -50,7 +17,9 @@ def test_service_client_allows_client_id_assignment(pass_value): globus_sdk.NativeAppAuthClient, ), ) -@pass_value_params +@pytest.mark.parametrize( + "pass_value", (CLIENT_ID_STR, CLIENT_ID_UUID), ids=("str", "uuid") +) def test_can_use_uuid_or_str_for_client_id(client_type, pass_value): if client_type in (globus_sdk.AuthLoginClient, globus_sdk.NativeAppAuthClient): client = client_type(client_id=pass_value) diff --git a/tests/unit/test_auth_requirements_error.py b/tests/unit/test_auth_requirements_error.py index 0ec203a92..345f57c9f 100644 --- a/tests/unit/test_auth_requirements_error.py +++ b/tests/unit/test_auth_requirements_error.py @@ -2,7 +2,6 @@ import pytest -from globus_sdk._testing import construct_error from globus_sdk.exc import ErrorSubdocument from globus_sdk.gare import ( GARE, @@ -13,6 +12,7 @@ to_gare, to_gares, ) +from globus_sdk.testing import construct_error @pytest.mark.parametrize( diff --git a/tests/unit/test_base_client.py b/tests/unit/test_base_client.py index b955b24d5..bf5b6da6d 100644 --- a/tests/unit/test_base_client.py +++ b/tests/unit/test_base_client.py @@ -7,10 +7,11 @@ import globus_sdk from globus_sdk import GlobusApp, GlobusAppConfig, GlobusSDKUsageError, UserApp -from globus_sdk._testing import RegisteredResponse, get_last_request from globus_sdk.authorizers import NullAuthorizer from globus_sdk.scopes import Scope, TransferScopes -from globus_sdk.tokenstorage import TokenValidationError +from globus_sdk.testing import RegisteredResponse, get_last_request +from globus_sdk.token_storage import TokenValidationError +from globus_sdk.transport import RequestsTransport @pytest.fixture @@ -19,13 +20,15 @@ def auth_client(): @pytest.fixture -def base_client_class(no_retry_transport): +def base_client_class(): class CustomClient(globus_sdk.BaseClient): - base_path = "/v0.10/" service_name = "transfer" - transport_class = no_retry_transport scopes = TransferScopes - default_scope_requirements = [Scope(TransferScopes.all)] + default_scope_requirements = [TransferScopes.all] + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.retry_config.max_retries = 0 return CustomClient @@ -47,16 +50,13 @@ def test_cannot_instantiate_plain_base_client(): def test_can_instantiate_base_client_with_explicit_url(): - # note how a trailing slash is added due to the default - # base_path of '/' - # this may change in a future major version, to preserve the base_url exactly client = globus_sdk.BaseClient(base_url="https://example.org") - assert client.base_url == "https://example.org/" + assert client.base_url == "https://example.org" def test_can_instantiate_with_base_url_class_attribute(): class MyCoolClient(globus_sdk.BaseClient): - base_url = "https://example.org" + base_url = "https://example.org/" client = MyCoolClient() assert client.base_url == "https://example.org/" @@ -77,8 +77,8 @@ class OnlyServiceClient(globus_sdk.BaseClient): service_name = "service-name" # All 3 are set - assert BothAttributesClient(base_url="init-base").base_url == "init-base/" - assert BothAttributesClient().base_url == "class-base/" + assert BothAttributesClient(base_url="init-base").base_url == "init-base" + assert BothAttributesClient().base_url == "class-base" assert OnlyServiceClient().base_url == "https://service-name.api.globus.org/" @@ -93,10 +93,10 @@ class FooClient(globus_sdk.BaseClient): client = FooClient() assert client.transport.http_timeout == 60.0 - client = FooClient(transport_params={"http_timeout": None}) + client = FooClient(transport=RequestsTransport(http_timeout=None)) assert client.transport.http_timeout == 60.0 - client = FooClient(transport_params={"http_timeout": -1}) + client = FooClient(transport=RequestsTransport(http_timeout=-1)) assert client.transport.http_timeout is None os.environ["GLOBUS_SDK_HTTP_TIMEOUT"] = "120" @@ -142,7 +142,7 @@ def test_http_methods(method, allows_body, base_client): """ methodname = method.upper() resolved_method = getattr(base_client, method) - path = "/madeuppath/objectname" + path = "/v0.10/madeuppath/objectname" RegisteredResponse( service="transfer", path=path, method=methodname, json={"x": "y"} ).add() @@ -195,8 +195,10 @@ def test_http_methods(method, allows_body, base_client): def test_handle_url_unsafe_chars(base_client): # make sure this path (escaped) and the request path (unescaped) match - RegisteredResponse(service="transfer", path="/foo/foo%20bar", json={"x": "y"}).add() - res = base_client.get("foo/foo bar") + RegisteredResponse( + service="transfer", path="/v0.10/foo/foo%20bar", json={"x": "y"} + ).add() + res = base_client.get("/v0.10/foo/foo bar") assert "x" in res assert res["x"] == "y" @@ -211,33 +213,6 @@ def test_access_resource_server_property_via_class(base_client_class): assert base_client_class.resource_server == TransferScopes.resource_server -@pytest.mark.parametrize("leading_slash", (True, False)) -@pytest.mark.parametrize("test_fixture_uses_base_path", (True, False)) -def test_base_path_matching_prefix( - base_client, leading_slash, test_fixture_uses_base_path -): - # self-check/sanity check - base_path = base_client.base_path - assert base_path == "/v0.10/" - - # construct the path and confirm it (sanity check) - req_path = f"{base_client.base_path}foo" - if not leading_slash: - req_path.lstrip("/") - - # register a response under the target path - # this is parametrized so that we are also testing the matching of our - # test fixtures against the same path construction - test_fixture_path = f"{base_path}foo" if test_fixture_uses_base_path else "foo" - RegisteredResponse( - service="transfer", path=test_fixture_path, json={"x": "y"} - ).add() - - # confirm that a "GET" works - res = base_client.get(req_path) - assert res["x"] == "y" - - def test_app_integration(base_client_class): def _reraise_token_error(_: GlobusApp, error: TokenValidationError): raise error @@ -252,7 +227,7 @@ def _reraise_token_error(_: GlobusApp, error: TokenValidationError): # confirm default_required_scopes were automatically added assert [str(s) for s in app.scope_requirements[c.resource_server]] == [ - TransferScopes.all + str(TransferScopes.all) ] # confirm attempt at getting an authorizer from app @@ -279,7 +254,7 @@ def test_add_app_scope(base_client_class): c.add_app_scope("foo") str_list = [str(s) for s in app.scope_requirements[c.resource_server]] assert len(str_list) == 2 - assert TransferScopes.all in str_list + assert str(TransferScopes.all) in str_list assert "foo" in str_list @@ -288,7 +263,7 @@ def test_add_app_scope_chaining(base_client_class): c = base_client_class(app=app).add_app_scope("foo").add_app_scope("bar") str_list = [str(s) for s in app.scope_requirements[c.resource_server]] assert len(str_list) == 3 - assert TransferScopes.all in str_list + assert str(TransferScopes.all) in str_list assert "foo" in str_list assert "bar" in str_list @@ -356,9 +331,8 @@ def test_cannot_attach_app_when_authorizer_was_provided(base_client_class): def test_cannot_attach_app_when_resource_server_is_not_resolvable(): class CustomClient(globus_sdk.BaseClient): - base_path = "/v0.10/" service_name = "transfer" - default_scope_requirements = [Scope(TransferScopes.all)] + default_scope_requirements = [TransferScopes.all] c = CustomClient() app = UserApp("SDK Test", client_id="client_id") diff --git a/tests/unit/test_classproperty.py b/tests/unit/test_classproperty.py new file mode 100644 index 000000000..945ea4483 --- /dev/null +++ b/tests/unit/test_classproperty.py @@ -0,0 +1,27 @@ +from globus_sdk._internal.classprop import classproperty + + +def test_classproperty_simple(): + class Foo: + x = {"x": 1} + + @classproperty + def y(self_or_cls): + return self_or_cls.x["x"] + + assert Foo.y == 1 + + +def test_classproperty_prefers_instance(): + class Foo: + x = {"x": 1} + + def __init__(self) -> None: + self.x = {"x": 2} + + @classproperty + def y(self_or_cls): + return self_or_cls.x["x"] + + assert Foo.y == 1 + assert Foo().y == 2 diff --git a/tests/unit/test_gcs_client.py b/tests/unit/test_gcs_client.py index d4cad03a6..356c9f73c 100644 --- a/tests/unit/test_gcs_client.py +++ b/tests/unit/test_gcs_client.py @@ -1,30 +1,28 @@ from globus_sdk import GCSClient -from globus_sdk._testing import load_response +from globus_sdk.testing import load_response def test_client_address_handling(): # variants of the same location c1 = GCSClient("foo.data.globus.org") c2 = GCSClient("https://foo.data.globus.org") - c3 = GCSClient("https://foo.data.globus.org/api") - c4 = GCSClient("https://foo.data.globus.org/api/") + c3 = GCSClient("https://foo.data.globus.org/api/") # explicit subpath of /api/ - c5 = GCSClient("https://foo.data.globus.org/api/bar") + c4 = GCSClient("https://foo.data.globus.org/api/bar") # explicit construction can point at the root (rather than /api/) - c6 = GCSClient("foo.data.globus.org") - c6.base_url = "https://foo.data.globus.org/" + c5 = GCSClient("foo.data.globus.org") + c5.base_url = "https://foo.data.globus.org/" - # 1, 2, 3, and 4 are all the same + # 1, 2, and 3 are all the same assert c1.base_url == c2.base_url assert c1.base_url == c3.base_url - assert c1.base_url == c4.base_url - # 5 and 6 are different from the rest + # 4 and 5 are different from the rest + assert c4.base_url != c1.base_url assert c5.base_url != c1.base_url - assert c6.base_url != c1.base_url - # 6 is the root of 1 - assert c1.base_url.startswith(c6.base_url) + # 5 is the root of 1 + assert c1.base_url.startswith(c5.base_url) def test_gcs_client_resource_server_and_endpoint_client_id(): diff --git a/tests/unit/test_guards.py b/tests/unit/test_guards.py index 07e833d06..adbf62f43 100644 --- a/tests/unit/test_guards.py +++ b/tests/unit/test_guards.py @@ -2,7 +2,9 @@ import pytest -from globus_sdk import _guards, _serializable, exc +from globus_sdk import exc +from globus_sdk._internal import guards +from globus_sdk._internal.serializable import Serializable @pytest.mark.parametrize( @@ -25,7 +27,7 @@ ], ) def test_list_of_guard(value, typ, ok): - assert _guards.is_list_of(value, typ) == ok + assert guards.is_list_of(value, typ) == ok @pytest.mark.parametrize( @@ -41,7 +43,7 @@ def test_list_of_guard(value, typ, ok): ], ) def test_opt_guard(value, typ, ok): - assert _guards.is_optional(value, typ) == ok + assert guards.is_optional(value, typ) == ok @pytest.mark.parametrize( @@ -62,12 +64,12 @@ def test_opt_guard(value, typ, ok): ], ) def test_opt_list_guard(value, typ, ok): - assert _guards.is_optional_list_of(value, typ) == ok + assert guards.is_optional_list_of(value, typ) == ok @pytest.mark.parametrize("value", (uuid.UUID(int=0), str(uuid.UUID(int=1)))) def test_uuidlike_ok(value): - assert _guards.validators.uuidlike("foo", value) == value + assert guards.validators.uuidlike("foo", value) == value @pytest.mark.parametrize("value", (str(uuid.UUID(int=0))[:-1], "")) @@ -75,7 +77,7 @@ def test_uuidlike_fails_value(value): with pytest.raises( exc.ValidationError, match="'foo' must be a valid UUID" ) as excinfo: - _guards.validators.uuidlike("foo", value) + guards.validators.uuidlike("foo", value) err = excinfo.value assert f"value='{value}'" in str(err) @@ -86,7 +88,7 @@ def test_uuidlike_fails_type(value): with pytest.raises( exc.ValidationError, match="'foo' must be a UUID or str" ) as excinfo: - _guards.validators.uuidlike("foo", value) + guards.validators.uuidlike("foo", value) err = excinfo.value assert f"value='{value}'" in str(err) @@ -95,33 +97,31 @@ def test_uuidlike_fails_type(value): @pytest.mark.parametrize( "validator, value", ( - pytest.param(_guards.validators.str_, "bar", id="str"), - pytest.param(_guards.validators.int_, 0, id="int-0"), - pytest.param(_guards.validators.int_, 1, id="int-1"), - pytest.param(_guards.validators.opt_str, "bar", id="opt_str-str"), - pytest.param(_guards.validators.opt_str, None, id="opt_str-None"), - pytest.param(_guards.validators.opt_bool, True, id="opt_bool-True"), - pytest.param(_guards.validators.opt_bool, False, id="opt_bool-False"), - pytest.param(_guards.validators.opt_bool, None, id="opt_bool-None"), - pytest.param(_guards.validators.str_list, [], id="str_list-empty"), - pytest.param(_guards.validators.str_list, ["foo"], id="str_list-onestr"), - pytest.param(_guards.validators.opt_str_list, [], id="opt_str_list-empty"), + pytest.param(guards.validators.str_, "bar", id="str"), + pytest.param(guards.validators.int_, 0, id="int-0"), + pytest.param(guards.validators.int_, 1, id="int-1"), + pytest.param(guards.validators.opt_str, "bar", id="opt_str-str"), + pytest.param(guards.validators.opt_str, None, id="opt_str-None"), + pytest.param(guards.validators.opt_bool, True, id="opt_bool-True"), + pytest.param(guards.validators.opt_bool, False, id="opt_bool-False"), + pytest.param(guards.validators.opt_bool, None, id="opt_bool-None"), + pytest.param(guards.validators.str_list, [], id="str_list-empty"), + pytest.param(guards.validators.str_list, ["foo"], id="str_list-onestr"), + pytest.param(guards.validators.opt_str_list, [], id="opt_str_list-empty"), + pytest.param(guards.validators.opt_str_list, ["foo"], id="opt_str_list-onestr"), + pytest.param(guards.validators.opt_str_list, None, id="opt_str_list-None"), pytest.param( - _guards.validators.opt_str_list, ["foo"], id="opt_str_list-onestr" - ), - pytest.param(_guards.validators.opt_str_list, None, id="opt_str_list-None"), - pytest.param( - _guards.validators.opt_str_list_or_commasep, + guards.validators.opt_str_list_or_commasep, [], id="opt_str_list_or_commasep-emptylist", ), pytest.param( - _guards.validators.opt_str_list_or_commasep, + guards.validators.opt_str_list_or_commasep, ["foo"], id="opt_str_list_or_commasep-list", ), pytest.param( - _guards.validators.opt_str_list_or_commasep, + guards.validators.opt_str_list_or_commasep, None, id="opt_str_list_or_commasep-None", ), @@ -134,44 +134,42 @@ def test_simple_validator_passing(validator, value): @pytest.mark.parametrize( "validator, value, match_message", ( + pytest.param(guards.validators.str_, 1, "'foo' must be a string", id="str-int"), pytest.param( - _guards.validators.str_, 1, "'foo' must be a string", id="str-int" - ), - pytest.param( - _guards.validators.str_, False, "'foo' must be a string", id="str-bool" + guards.validators.str_, False, "'foo' must be a string", id="str-bool" ), pytest.param( - _guards.validators.str_, None, "'foo' must be a string", id="str-None" + guards.validators.str_, None, "'foo' must be a string", id="str-None" ), pytest.param( - _guards.validators.int_, "bar", "'foo' must be an int", id="int-str" + guards.validators.int_, "bar", "'foo' must be an int", id="int-str" ), pytest.param( - _guards.validators.opt_str, + guards.validators.opt_str, 0, "'foo' must be a string or null", id="opt_str-int", ), pytest.param( - _guards.validators.opt_bool, + guards.validators.opt_bool, 0, "'foo' must be a bool or null", id="opt_bool-int", ), pytest.param( - _guards.validators.str_list, + guards.validators.str_list, "x", "'foo' must be a list of strings", id="str_list-str", ), pytest.param( - _guards.validators.opt_str_list, + guards.validators.opt_str_list, "x", "'foo' must be a list of strings or null", id="opt_str_list-str", ), pytest.param( - _guards.validators.opt_str_list_or_commasep, + guards.validators.opt_str_list_or_commasep, 0, "'foo' must be a list of strings or a comma-delimited string or null", id="opt_str_list_or_commasep-int", @@ -184,35 +182,35 @@ def test_simple_validator_failing(validator, value, match_message): def test_instance_or_dict_validator_failing(): - class MyObj(_serializable.Serializable): + class MyObj(Serializable): def __init__(self, *, extra=None) -> None: pass with pytest.raises( exc.ValidationError, match="'foo' must be a 'MyObj' object or a dictionary" ): - _guards.validators.instance_or_dict("foo", object(), MyObj) + guards.validators.instance_or_dict("foo", object(), MyObj) def test_instance_or_dict_validator_pass_on_simple_instance(): - class MyObj(_serializable.Serializable): + class MyObj(Serializable): def __init__(self, *, extra=None) -> None: pass x = MyObj() - y = _guards.validators.instance_or_dict("foo", x, MyObj) + y = guards.validators.instance_or_dict("foo", x, MyObj) assert x is y def test_instance_or_dict_validator_pass_on_simple_dict(): - class MyObj(_serializable.Serializable): + class MyObj(Serializable): def __init__(self, *, extra=None) -> None: pass - x = _guards.validators.instance_or_dict("foo", {}, MyObj) + x = guards.validators.instance_or_dict("foo", {}, MyObj) assert isinstance(x, MyObj) def test_strlist_or_commasep_splits_str(): - x = _guards.validators.opt_str_list_or_commasep("foo", "foo,bar,baz") + x = guards.validators.opt_str_list_or_commasep("foo", "foo,bar,baz") assert x == ["foo", "bar", "baz"] diff --git a/tests/unit/test_lazy_imports.py b/tests/unit/test_lazy_imports.py index a9564c74f..ec3ddf83e 100644 --- a/tests/unit/test_lazy_imports.py +++ b/tests/unit/test_lazy_imports.py @@ -8,7 +8,6 @@ def test_explicit_dir_func_works(): assert "__all__" in dir(globus_sdk) -@pytest.mark.filterwarnings("ignore::globus_sdk.RemovedInV4Warning") def test_force_eager_imports_can_run(): # this check will not do much, other than ensuring that this does not crash globus_sdk._force_eager_imports() diff --git a/tests/unit/test_missing_type.py b/tests/unit/test_missing_type.py index a0db730a1..d10599a99 100644 --- a/tests/unit/test_missing_type.py +++ b/tests/unit/test_missing_type.py @@ -3,26 +3,26 @@ import pytest -from globus_sdk import utils +from globus_sdk._missing import MISSING, MissingType def test_missing_type_cannot_be_instantiated(): with pytest.raises(TypeError, match="MissingType should not be instantiated"): - utils.MissingType() + MissingType() def test_missing_sentinel_bools_as_false(): - assert bool(utils.MISSING) is False + assert bool(MISSING) is False def test_str_of_missing(): - assert str(utils.MISSING) == "" + assert str(MISSING) == "" def test_copy_of_missing_is_self(): - assert copy.copy(utils.MISSING) is utils.MISSING - assert copy.deepcopy(utils.MISSING) is utils.MISSING + assert copy.copy(MISSING) is MISSING + assert copy.deepcopy(MISSING) is MISSING def test_pickle_of_missing_is_self(): - assert pickle.loads(pickle.dumps(utils.MISSING)) is utils.MISSING + assert pickle.loads(pickle.dumps(MISSING)) is MISSING diff --git a/tests/unit/test_paginator_signature_matching.py b/tests/unit/test_paginator_signature_matching.py index e95d10025..2b1d7e8cf 100644 --- a/tests/unit/test_paginator_signature_matching.py +++ b/tests/unit/test_paginator_signature_matching.py @@ -4,21 +4,18 @@ """ import inspect -import warnings import pytest import globus_sdk _CLIENTS_TO_CHECK = [] -with warnings.catch_warnings(): - warnings.simplefilter("ignore", category=globus_sdk.RemovedInV4Warning) - for attrname in dir(globus_sdk): - obj = getattr(globus_sdk, attrname) - if obj is globus_sdk.BaseClient: - continue - if isinstance(obj, type) and issubclass(obj, globus_sdk.BaseClient): - _CLIENTS_TO_CHECK.append(obj) +for attrname in dir(globus_sdk): + obj = getattr(globus_sdk, attrname) + if obj is globus_sdk.BaseClient: + continue + if isinstance(obj, type) and issubclass(obj, globus_sdk.BaseClient): + _CLIENTS_TO_CHECK.append(obj) _METHODS_TO_CHECK = [] for cls in _CLIENTS_TO_CHECK: diff --git a/tests/unit/test_payload.py b/tests/unit/test_payload.py new file mode 100644 index 000000000..d1adf9c13 --- /dev/null +++ b/tests/unit/test_payload.py @@ -0,0 +1,73 @@ +import abc + +import pytest + +from globus_sdk._payload import AbstractGlobusPayload, GlobusPayload + + +def test_payload_methods(): + # just make sure that PayloadWrapper acts like a dict... + data = GlobusPayload() + assert "foo" not in data + with pytest.raises(KeyError): + data["foo"] + data["foo"] = 1 + assert "foo" in data + assert data["foo"] == 1 + del data["foo"] + assert "foo" not in data + assert len(data) == 0 + assert list(data) == [] + data["foo"] = 1 + data["bar"] = 2 + assert len(data) == 2 + assert data == {"foo": 1, "bar": 2} + data.update({"x": "hello", "y": "world"}) + assert data == {"foo": 1, "bar": 2, "x": "hello", "y": "world"} + + +def test_abstract_payload_detects_abstract_methods(): + # A has no abstract methods so it will instantiate + class A(AbstractGlobusPayload): + pass + + A() + + # B has an abstract method and inherits from AbstractGlobusPayload so it should + # fail to instantiate + class B(A): + @abc.abstractmethod + def f(self): ... + + with pytest.raises( + TypeError, + match=( + "Can't instantiate abstract class B without an " + "implementation for abstract method 'f'" + ), + ): + B() + + # C has two abstract methods, so these should be listed comma separated + class C(B): + @abc.abstractmethod + def g(self): ... + + with pytest.raises( + TypeError, + match=( + "Can't instantiate abstract class C without an " + "implementation for abstract methods ('f', 'g'|'g', 'f')" + ), + ): + C() + + # D should be instantiable because it defines the abstract methods + class D(C): + def f(self): + return 1 + + def g(self): + return 2 + + D() diff --git a/tests/unit/test_remarshal.py b/tests/unit/test_remarshal.py new file mode 100644 index 000000000..86d0eeeb5 --- /dev/null +++ b/tests/unit/test_remarshal.py @@ -0,0 +1,95 @@ +import collections.abc +import uuid + +import pytest + +from globus_sdk import MISSING +from globus_sdk._internal.remarshal import ( + commajoin, + list_map, + listify, + strseq_iter, + strseq_listify, +) + + +@pytest.mark.parametrize( + "value, expected_result", + ( + ("foo", ["foo"]), + ((1, 2, 3), ["1", "2", "3"]), + (uuid.UUID(int=10), [f"{uuid.UUID(int=10)}"]), + (["foo", uuid.UUID(int=5)], ["foo", f"{uuid.UUID(int=5)}"]), + ), +) +def test_strseq_iter(value, expected_result): + iter_ = strseq_iter(value) + assert not isinstance(iter_, list) + assert isinstance(iter_, collections.abc.Iterator) + assert list(iter_) == expected_result + assert list(iter_) == [] + + +@pytest.mark.parametrize( + "value, expected_result", + ( + ("foo", ["foo"]), + ((1, 2, 3), ["1", "2", "3"]), + (uuid.UUID(int=10), [f"{uuid.UUID(int=10)}"]), + (["foo", uuid.UUID(int=5)], ["foo", f"{uuid.UUID(int=5)}"]), + (MISSING, MISSING), + (None, None), + ), +) +def test_strseq_listify(value, expected_result): + list_ = strseq_listify(value) + assert isinstance(list_, list) or list_ in (MISSING, None) + assert list_ == expected_result + + +@pytest.mark.parametrize( + "value, expected_result", + ( + ("foo", "foo"), + (uuid.UUID(int=10), f"{uuid.UUID(int=10)}"), + ((1, 2, 3), "1,2,3"), + (range(5), "0,1,2,3,4"), + (["foo", uuid.UUID(int=5)], f"foo,{uuid.UUID(int=5)}"), + (MISSING, MISSING), + (None, None), + ), +) +def test_commajoin(value, expected_result): + joined = commajoin(value) + assert joined == expected_result + + +@pytest.mark.parametrize( + "value, expected_result", + ( + ("foo", ["f", "o", "o"]), + ((1, 2, 3), [1, 2, 3]), + (range(5), [0, 1, 2, 3, 4]), + (["foo", uuid.UUID(int=5)], ["foo", uuid.UUID(int=5)]), + (MISSING, MISSING), + (None, None), + ), +) +def test_listify(value, expected_result): + converted = listify(value) + assert converted == expected_result + + +@pytest.mark.parametrize( + "value, expected_result", + ( + ("foo", ["ff", "oo", "oo"]), + ((1, 2, 3), [2, 4, 6]), + (range(5), [0, 2, 4, 6, 8]), + (MISSING, MISSING), + (None, None), + ), +) +def test_list_map(value, expected_result): + converted = list_map(value, lambda x: x * 2) + assert converted == expected_result diff --git a/tests/unit/test_specific_flows_client.py b/tests/unit/test_specific_flows_client.py index 531939c6f..d7485c312 100644 --- a/tests/unit/test_specific_flows_client.py +++ b/tests/unit/test_specific_flows_client.py @@ -1,6 +1,7 @@ import pytest import globus_sdk +from globus_sdk.scopes import Scope def test_specific_flow_client_class_errors_on_scope_access(): @@ -22,7 +23,7 @@ def test_specific_flow_client_class_errors_on_scope_access(): scopes.demuddle err = excinfo.value - assert str(err) == "Unrecognized Attribute 'demuddle'" + assert str(err).endswith("has no attribute 'demuddle'") def test_specific_flow_client_class_errors_on_resource_server_access(): @@ -57,15 +58,15 @@ def test_specific_flow_client_instance_supports_scope_access(): # for the 'user' scope, we get a string user_scope = scopes.user - assert isinstance(user_scope, str) - assert user_scope.endswith("flow_foo_user") + assert isinstance(user_scope, Scope) + assert str(user_scope).endswith("flow_foo_user") # but for any other scope we still get the generic attribute error with pytest.raises(AttributeError) as excinfo: scopes.demuddle err = excinfo.value - assert str(err) == "Unrecognized Attribute 'demuddle'" + assert str(err).endswith("has no attribute 'demuddle'") def test_specific_flow_client_instance_supports_resource_server_access(): diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index c00001bd6..19cfb7637 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,29 +1,13 @@ -import uuid - import pytest -from globus_sdk import utils - - -def test_b64str_non_ascii(): - test_string = "ⓤⓢⓔⓡⓝⓐⓜⓔ" - expected_b64 = "4pOk4pOi4pOU4pOh4pOd4pOQ4pOc4pOU" - - assert utils.b64str(test_string) == expected_b64 - - -def test_b64str_ascii(): - test_string = "username" - expected_b64 = "dXNlcm5hbWU=" - - assert utils.b64str(test_string) == expected_b64 +from globus_sdk._internal.utils import get_nice_hostname, sha256_string, slash_join def test_sha256string(): test_string = "foo" expected_sha = "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae" - assert utils.sha256_string(test_string) == expected_sha + assert sha256_string(test_string) == expected_sha @pytest.mark.parametrize( @@ -42,7 +26,7 @@ def test_sha256string(): ) def test_get_nice_hostname(platform_value, result, monkeypatch): monkeypatch.setattr("platform.node", lambda: platform_value) - assert utils.get_nice_hostname() == result + assert get_nice_hostname() == result @pytest.mark.parametrize( @@ -56,64 +40,4 @@ def test_slash_join(a, b): to b's with and without leading "/" Confirms all have the same correct slash_join output """ - assert utils.slash_join(a, b) == "a/b" - - -def test_payload_wrapper_methods(): - # just make sure that PayloadWrapper acts like a dict... - data = utils.PayloadWrapper() - assert "foo" not in data - with pytest.raises(KeyError): - data["foo"] - data["foo"] = 1 - assert "foo" in data - assert data["foo"] == 1 - del data["foo"] - assert "foo" not in data - assert len(data) == 0 - assert list(data) == [] - data["foo"] = 1 - data["bar"] = 2 - assert len(data) == 2 - assert data.data == {"foo": 1, "bar": 2} - data.update({"x": "hello", "y": "world"}) - assert data.data == {"foo": 1, "bar": 2, "x": "hello", "y": "world"} - - -def test_classproperty_simple(): - class Foo: - x = {"x": 1} - - @utils.classproperty - def y(self_or_cls): - return self_or_cls.x["x"] - - assert Foo.y == 1 - - -def test_classproperty_prefers_instance(): - class Foo: - x = {"x": 1} - - def __init__(self) -> None: - self.x = {"x": 2} - - @utils.classproperty - def y(self_or_cls): - return self_or_cls.x["x"] - - assert Foo.y == 1 - assert Foo().y == 2 - - -@pytest.mark.parametrize( - "value, expected_result", - ( - ("foo", ["foo"]), - ((1, 2, 3), ["1", "2", "3"]), - (uuid.UUID(int=10), [f"{uuid.UUID(int=10)}"]), - (["foo", uuid.UUID(int=5)], ["foo", f"{uuid.UUID(int=5)}"]), - ), -) -def test_safe_strseq_iter(value, expected_result): - assert list(utils.safe_strseq_iter(value)) == expected_result + assert slash_join(a, b) == "a/b" diff --git a/tests/unit/_testing/test_construct_error.py b/tests/unit/testing/test_construct_error.py similarity index 97% rename from tests/unit/_testing/test_construct_error.py rename to tests/unit/testing/test_construct_error.py index 9a976936e..203392a3a 100644 --- a/tests/unit/_testing/test_construct_error.py +++ b/tests/unit/testing/test_construct_error.py @@ -1,7 +1,7 @@ import pytest import globus_sdk -from globus_sdk._testing import construct_error +from globus_sdk.testing import construct_error def test_construct_error_defaults_to_base_error_class(): diff --git a/tests/unit/_testing/test_registered_response.py b/tests/unit/testing/test_registered_response.py similarity index 91% rename from tests/unit/_testing/test_registered_response.py rename to tests/unit/testing/test_registered_response.py index 11837e6d9..a0af10cb3 100644 --- a/tests/unit/_testing/test_registered_response.py +++ b/tests/unit/testing/test_registered_response.py @@ -4,7 +4,7 @@ import pytest -from globus_sdk._testing import RegisteredResponse +from globus_sdk.testing import RegisteredResponse @pytest.mark.skipif( diff --git a/tests/unit/tokenstorage/v1/test_memory_adapter.py b/tests/unit/tokenstorage/v1/test_memory_adapter.py index d94ff1adc..9a8bd0390 100644 --- a/tests/unit/tokenstorage/v1/test_memory_adapter.py +++ b/tests/unit/tokenstorage/v1/test_memory_adapter.py @@ -1,7 +1,7 @@ import time from unittest import mock -from globus_sdk.tokenstorage import MemoryAdapter +from globus_sdk.token_storage.legacy import MemoryAdapter def test_memory_adapter_store_overwrites_only_new_data(): diff --git a/tests/unit/tokenstorage/v1/test_simplejson_adapter.py b/tests/unit/tokenstorage/v1/test_simplejson_adapter.py index 321b3b155..fd09b238a 100644 --- a/tests/unit/tokenstorage/v1/test_simplejson_adapter.py +++ b/tests/unit/tokenstorage/v1/test_simplejson_adapter.py @@ -2,8 +2,8 @@ import pytest -from globus_sdk.tokenstorage import SimpleJSONFileAdapter -from globus_sdk.version import __version__ as sdkversion +from globus_sdk import __version__ as sdkversion +from globus_sdk.token_storage.legacy import SimpleJSONFileAdapter def test_simplejson_reading_bad_data(tmp_path): diff --git a/tests/unit/tokenstorage/v1/test_sqlite_adapter.py b/tests/unit/tokenstorage/v1/test_sqlite_adapter.py index f76e7a7c7..be0cb5cb7 100644 --- a/tests/unit/tokenstorage/v1/test_sqlite_adapter.py +++ b/tests/unit/tokenstorage/v1/test_sqlite_adapter.py @@ -1,6 +1,6 @@ import pytest -from globus_sdk.tokenstorage import SQLiteAdapter +from globus_sdk.token_storage.legacy import SQLiteAdapter def test_sqlite_reading_bad_config(): diff --git a/tests/unit/tokenstorage/v2/test_token_storage.py b/tests/unit/tokenstorage/v2/test_token_storage.py index 5f9919436..66f8ab3d3 100644 --- a/tests/unit/tokenstorage/v2/test_token_storage.py +++ b/tests/unit/tokenstorage/v2/test_token_storage.py @@ -1,7 +1,7 @@ import pytest from globus_sdk import GlobusSDKUsageError -from globus_sdk.tokenstorage.v2.base import _slugify_app_name +from globus_sdk.token_storage.base import _slugify_app_name @pytest.mark.parametrize( diff --git a/tests/unit/tokenstorage/v2/test_validating_token_storage.py b/tests/unit/tokenstorage/v2/test_validating_token_storage.py index 8882343a0..d1203e960 100644 --- a/tests/unit/tokenstorage/v2/test_validating_token_storage.py +++ b/tests/unit/tokenstorage/v2/test_validating_token_storage.py @@ -16,13 +16,13 @@ Scope, ) from globus_sdk.scopes.consents import ConsentForest -from globus_sdk.tokenstorage import ( +from globus_sdk.token_storage import ( MemoryTokenStorage, ScopeRequirementsValidator, UnchangingIdentityIDValidator, ValidatingTokenStorage, ) -from globus_sdk.tokenstorage.v2.validating_token_storage import ( +from globus_sdk.token_storage.validating_token_storage import ( IdentityMismatchError, MissingIdentityError, MissingTokenError, @@ -130,7 +130,7 @@ def test_validating_token_storage_evaluates_root_scope_requirements( make_token_response, ): adapter = _make_memstorage_with_scope_validator( - consent_client, {"rs1": [Scope.deserialize("scope1")]} + consent_client, {"rs1": [Scope.parse("scope1")]} ) identity_id = str(uuid.uuid4()) valid_token_response = make_token_response( @@ -154,7 +154,7 @@ def test_storage_with_scope_validator_evaluates_dependent_scope_requirements( make_token_response, consent_client ): adapter = _make_memstorage_with_scope_validator( - consent_client, {"rs1": [Scope.deserialize("scope[subscope]")]} + consent_client, {"rs1": [Scope.parse("scope[subscope]")]} ) token_response = make_token_response(scopes={"rs1": "scope"}) adapter.store_token_response(token_response) diff --git a/tests/unit/transport/test_default_retry_policy.py b/tests/unit/transport/test_default_retry_policy.py index a774fd23c..fd533300e 100644 --- a/tests/unit/transport/test_default_retry_policy.py +++ b/tests/unit/transport/test_default_retry_policy.py @@ -3,71 +3,86 @@ import pytest from globus_sdk.transport import ( + RequestCallerInfo, RequestsTransport, RetryCheckResult, RetryCheckRunner, + RetryConfig, RetryContext, ) +from globus_sdk.transport.default_retry_checks import ( + DEFAULT_RETRY_CHECKS, + check_retry_after_header, + check_transient_error, +) @pytest.mark.parametrize("http_status", (429, 503)) def test_retry_policy_respects_retry_after(mocksleep, http_status): + retry_config = RetryConfig() + retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) transport = RequestsTransport() - checker = RetryCheckRunner(transport.retry_checks) + checker = RetryCheckRunner(retry_config.checks) dummy_response = mock.Mock() dummy_response.headers = {"Retry-After": "5"} dummy_response.status_code = http_status - ctx = RetryContext(1, response=dummy_response) + caller_info = RequestCallerInfo(retry_config=retry_config) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is True mocksleep.assert_not_called() - transport._retry_sleep(ctx) + transport._retry_sleep(retry_config, ctx) mocksleep.assert_called_once_with(5) @pytest.mark.parametrize("http_status", (429, 503)) def test_retry_policy_ignores_retry_after_too_high(mocksleep, http_status): # set explicit max sleep to confirm that the value is capped here - transport = RequestsTransport(max_sleep=5) - checker = RetryCheckRunner(transport.retry_checks) + retry_config = RetryConfig(max_sleep=5) + retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) + transport = RequestsTransport() + checker = RetryCheckRunner(retry_config.checks) dummy_response = mock.Mock() dummy_response.headers = {"Retry-After": "20"} dummy_response.status_code = http_status - ctx = RetryContext(1, response=dummy_response) + caller_info = RequestCallerInfo(retry_config=retry_config) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is True mocksleep.assert_not_called() - transport._retry_sleep(ctx) + transport._retry_sleep(retry_config, ctx) mocksleep.assert_called_once_with(5) @pytest.mark.parametrize("http_status", (429, 503)) def test_retry_policy_ignores_malformed_retry_after(mocksleep, http_status): + retry_config = RetryConfig() + retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) transport = RequestsTransport() - checker = RetryCheckRunner(transport.retry_checks) + checker = RetryCheckRunner(retry_config.checks) dummy_response = mock.Mock() dummy_response.headers = {"Retry-After": "not-an-integer"} dummy_response.status_code = http_status - ctx = RetryContext(1, response=dummy_response) + caller_info = RequestCallerInfo(retry_config=retry_config) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is True mocksleep.assert_not_called() - transport._retry_sleep(ctx) + transport._retry_sleep(retry_config, ctx) mocksleep.assert_called_once() @pytest.mark.parametrize( - "checkname", - [ - "default_check_retry_after_header", - "default_check_transient_error", - ], + "check_method", + [check_retry_after_header, check_transient_error], + ids=lambda f: f.__name__, ) -def test_default_retry_check_noop_on_exception(checkname, mocksleep): - transport = RequestsTransport() - method = getattr(transport, checkname) - ctx = RetryContext(1, exception=Exception("foo")) - assert method(ctx) is RetryCheckResult.no_decision +def test_default_retry_check_noop_on_exception(check_method, mocksleep): + retry_config = RetryConfig() + retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) + caller_info = RequestCallerInfo(retry_config=retry_config) + ctx = RetryContext(1, caller_info=caller_info, exception=Exception("foo")) + assert check_method(ctx) is RetryCheckResult.no_decision diff --git a/tests/unit/transport/test_retry_check_runner.py b/tests/unit/transport/test_retry_check_runner.py index 7a0e759b7..c4105d456 100644 --- a/tests/unit/transport/test_retry_check_runner.py +++ b/tests/unit/transport/test_retry_check_runner.py @@ -1,17 +1,27 @@ from unittest import mock -from globus_sdk.transport import RetryCheckResult, RetryCheckRunner, RetryContext +from globus_sdk.transport import ( + RequestCallerInfo, + RetryCheckResult, + RetryCheckRunner, + RetryConfig, + RetryContext, +) +from globus_sdk.transport.default_retry_checks import DEFAULT_RETRY_CHECKS def _make_test_retry_context(*, status=200, exception=None, response=None): + retry_config = RetryConfig() + retry_config.checks.register_many_checks(DEFAULT_RETRY_CHECKS) + caller_info = RequestCallerInfo(retry_config=retry_config) if exception: - return RetryContext(1, exception=exception) + return RetryContext(1, caller_info=caller_info, exception=exception) elif response: - return RetryContext(1, response=response) + return RetryContext(1, caller_info=caller_info, response=response) dummy_response = mock.Mock() dummy_response.status_code = 200 - return RetryContext(1, response=dummy_response) + return RetryContext(1, caller_info=caller_info, response=dummy_response) def test_retry_check_runner_should_retry_explicit_on_first_check(): diff --git a/tests/unit/transport/test_transfer_transport.py b/tests/unit/transport/test_transfer_transport.py index f806b6d9f..cee273260 100644 --- a/tests/unit/transport/test_transfer_transport.py +++ b/tests/unit/transport/test_transfer_transport.py @@ -1,12 +1,36 @@ from unittest import mock -from globus_sdk.services.transfer.transport import TransferRequestsTransport -from globus_sdk.transport import RetryCheckRunner, RetryContext +from globus_sdk.services.transfer.transport import TRANSFER_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_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_DEFAULT_RETRY_CHECKS) + + assert len(default_variant) == len(transfer_variant) def test_transfer_does_not_retry_external(): - transport = TransferRequestsTransport() - checker = RetryCheckRunner(transport.retry_checks) + retry_config = RetryConfig() + retry_config.checks.register_many_checks(TRANSFER_DEFAULT_RETRY_CHECKS) + checker = RetryCheckRunner(retry_config.checks) body = { "HTTP status": "502", @@ -19,14 +43,16 @@ def test_transfer_does_not_retry_external(): dummy_response = mock.Mock() dummy_response.json = lambda: body dummy_response.status_code = 502 - ctx = RetryContext(1, response=dummy_response) + 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(): - transport = TransferRequestsTransport() - checker = RetryCheckRunner(transport.retry_checks) + retry_config = RetryConfig() + retry_config.checks.register_many_checks(TRANSFER_DEFAULT_RETRY_CHECKS) + checker = RetryCheckRunner(retry_config.checks) body = { "HTTP status": "502", @@ -42,14 +68,16 @@ def test_transfer_does_not_retry_endpoint_error(): dummy_response = mock.Mock() dummy_response.json = lambda: body dummy_response.status_code = 502 - ctx = RetryContext(1, response=dummy_response) + 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(): - transport = TransferRequestsTransport() - checker = RetryCheckRunner(transport.retry_checks) + retry_config = RetryConfig() + retry_config.checks.register_many_checks(TRANSFER_DEFAULT_RETRY_CHECKS) + checker = RetryCheckRunner(retry_config.checks) def _raise_value_error(): raise ValueError() @@ -57,6 +85,7 @@ def _raise_value_error(): dummy_response = mock.Mock() dummy_response.json = _raise_value_error dummy_response.status_code = 502 - ctx = RetryContext(1, response=dummy_response) + caller_info = RequestCallerInfo(retry_config=retry_config) + ctx = RetryContext(1, caller_info=caller_info, response=dummy_response) assert checker.should_retry(ctx) is True diff --git a/tests/unit/transport/test_transport.py b/tests/unit/transport/test_transport.py index d204acc1e..3a86ec83f 100644 --- a/tests/unit/transport/test_transport.py +++ b/tests/unit/transport/test_transport.py @@ -3,8 +3,8 @@ import pytest -from globus_sdk.transport import RequestsTransport, RetryContext -from globus_sdk.transport.requests import _exponential_backoff +from globus_sdk.transport import RequestsTransport, RetryConfig, RetryContext +from globus_sdk.transport.retry_config import _exponential_backoff def _linear_backoff(ctx: RetryContext) -> float: @@ -30,11 +30,6 @@ def _linear_backoff(ctx: RetryContext) -> float: ("verify_ssl", True, ca_bundle_non_existent), ("verify_ssl", True, str(ca_bundle_non_existent)), ("http_timeout", 60, 120), - ("retry_backoff", _exponential_backoff, _linear_backoff), - ("max_sleep", 10, 10), - ("max_sleep", 10, 1), - ("max_retries", 0, 5), - ("max_retries", 10, 0), ], ) def test_transport_tuning(param_name, init_value, tune_value): @@ -53,6 +48,29 @@ def test_transport_tuning(param_name, init_value, tune_value): assert getattr(transport, param_name) == init_value +@pytest.mark.parametrize( + "param_name, init_value, tune_value", + [ + ("backoff", _exponential_backoff, _linear_backoff), + ("max_sleep", 10, 10), + ("max_sleep", 10, 1), + ("max_retries", 0, 5), + ("max_retries", 10, 0), + ], +) +def test_retry_tuning(param_name, init_value, tune_value): + init_kwargs = {param_name: init_value} + config = RetryConfig(**init_kwargs) + + assert getattr(config, param_name) == init_value + + tune_kwargs = {param_name: tune_value} + with config.tune(**tune_kwargs): + assert getattr(config, param_name) == tune_value + + assert getattr(config, param_name) == init_value + + def test_transport_can_manipulate_user_agent(): transport = RequestsTransport() diff --git a/tests/unit/transport/test_transport_authz_handling.py b/tests/unit/transport/test_transport_authz_handling.py index 0f4eb2251..7f4e506fe 100644 --- a/tests/unit/transport/test_transport_authz_handling.py +++ b/tests/unit/transport/test_transport_authz_handling.py @@ -1,7 +1,9 @@ from unittest import mock +import pytest + from globus_sdk.authorizers import NullAuthorizer -from globus_sdk.transport import RequestsTransport +from globus_sdk.transport import RequestCallerInfo, RequestsTransport, RetryConfig def test_will_not_modify_authz_header_without_authorizer(): @@ -28,3 +30,42 @@ def test_will_null_authz_header_with_null_authorizer(): request.headers["Authorization"] = "foo bar" transport._set_authz_header(NullAuthorizer(), request) assert request.headers == {} + + +def test_requests_transport_accepts_caller_info(): + retry_config = RetryConfig() + transport = RequestsTransport() + mock_authorizer = mock.Mock() + mock_authorizer.get_authorization_header.return_value = "Bearer token" + caller_info = RequestCallerInfo( + retry_config=retry_config, authorizer=mock_authorizer + ) + + with mock.patch.object(transport, "session") as mock_session: + mock_response = mock.Mock(status_code=200) + mock_session.send.return_value = mock_response + + response = transport.request( + "GET", "https://example.com", caller_info=caller_info + ) + + assert response.status_code == 200 + + sent_request = mock_session.send.call_args[0][0] + assert sent_request.headers["Authorization"] == "Bearer token" + + +def test_requests_transport_caller_info_required(): + transport = RequestsTransport() + + with pytest.raises(TypeError): + transport.request("GET", "https://example.com") + + +def test_requests_transport_keyword_only(): + retry_config = RetryConfig() + transport = RequestsTransport() + caller_info = RequestCallerInfo(retry_config=retry_config) + + with pytest.raises(TypeError): + transport.request("GET", "https://example.com", caller_info) diff --git a/tests/unit/transport/test_transport_encoders.py b/tests/unit/transport/test_transport_encoders.py index 0cc373e8e..559227b9b 100644 --- a/tests/unit/transport/test_transport_encoders.py +++ b/tests/unit/transport/test_transport_encoders.py @@ -2,8 +2,9 @@ import pytest +from globus_sdk import MISSING +from globus_sdk._payload import GlobusPayload from globus_sdk.transport import FormRequestEncoder, JSONRequestEncoder, RequestEncoder -from globus_sdk.utils import MISSING, PayloadWrapper @pytest.mark.parametrize("data", ("foo", b"bar")) @@ -70,7 +71,7 @@ def test_all_request_encoders_remove_missing_in_params_and_headers(encoder_class @pytest.mark.parametrize( - "using_payload_wrapper, payload_contents, expected_data", + "using_payload_type, payload_contents, expected_data", [ # basic dicts (False, {"foo": 1}, {"foo": 1}), @@ -84,7 +85,7 @@ def test_all_request_encoders_remove_missing_in_params_and_headers(encoder_class # nested payload wrappers (get dictified / "unwrapped") ( True, - {"bar": PayloadWrapper(foo=1), "baz": [2, PayloadWrapper(foo=1)]}, + {"bar": GlobusPayload(foo=1), "baz": [2, GlobusPayload(foo=1)]}, {"bar": {"foo": 1}, "baz": [2, {"foo": 1}]}, ), # document with UUIDs and tuples buried inside nested structures @@ -96,10 +97,10 @@ def test_all_request_encoders_remove_missing_in_params_and_headers(encoder_class ], ) def test_json_encoder_payload_preparation( - using_payload_wrapper, payload_contents, expected_data + using_payload_type, payload_contents, expected_data ): encoder = JSONRequestEncoder() - x = PayloadWrapper() if using_payload_wrapper else {} + x = GlobusPayload() if using_payload_type else {} for k, v in payload_contents.items(): x[k] = v request = encoder.encode( @@ -130,7 +131,7 @@ def test_json_encoder_is_well_defined_on_array_containing_missing(): @pytest.mark.parametrize( - "using_payload_wrapper, payload_contents, expected_data", + "using_payload_type, payload_contents, expected_data", [ # basic dicts (True, {"foo": 1}, {"foo": 1}), @@ -144,10 +145,10 @@ def test_json_encoder_is_well_defined_on_array_containing_missing(): ], ) def test_form_encoder_payload_preparation( - using_payload_wrapper, payload_contents, expected_data + using_payload_type, payload_contents, expected_data ): encoder = FormRequestEncoder() - x = PayloadWrapper() if using_payload_wrapper else {} + x = GlobusPayload() if using_payload_type else {} for k, v in payload_contents.items(): x[k] = v request = encoder.encode( diff --git a/tox.ini b/tox.ini index bb53941b0..b4d64eb95 100644 --- a/tox.ini +++ b/tox.ini @@ -58,6 +58,12 @@ commands = pytest -n auto tests/non-pytest/lazy-imports/ pytest tests/unit/test_lazy_imports.py +[testenv:benchmark] +deps = + -r requirements/py{py_dot_ver}/test.txt + pytest-benchmark +commands = pytest tests/benchmark/ {posargs} + [testenv:pylint,pylint-{py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}] deps = pylint commands = pylint {posargs:src/} diff --git a/toxfile.py b/toxfile.py index e4fcf746f..592ca65b4 100644 --- a/toxfile.py +++ b/toxfile.py @@ -52,6 +52,15 @@ def tox_add_env_config(env_conf: EnvConfigSet, state: State) -> None: @impl def tox_before_run_commands(tox_env: ToxEnv) -> None: + # determine if it was a parallel invocation by examining the CLI command + parallel_detected = tox_env.options.command in ("p", "run-parallel") + if parallel_detected: + # tox is running parallel, set an indicator env var + # and effectively disable pytest-xdist by setting xdist-workers to 0 + # -- 0 means tests will run in the main process, not even in a worker + setenv = tox_env.conf.load("set_env") + setenv.update({"TOX_PARALLEL": "1", "PYTEST_XDIST_AUTO_NUM_WORKERS": "0"}) + sdk_rmtree = tox_env.conf.load("globus_sdk_rmtree") for name in sdk_rmtree: path = pathlib.Path(name)