Skip to content

Commit

Permalink
Support ScopeCollectionType in GlobusApp
Browse files Browse the repository at this point in the history
  • Loading branch information
derek-globus committed Aug 1, 2024
1 parent 6909c0d commit 0ac85e0
Show file tree
Hide file tree
Showing 8 changed files with 178 additions and 26 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@

Added
~~~~~

- Added support for ``ScopeCollectionType`` to GlobusApp's ``__init__`` and
``add_scope_requirements`` methods. (:pr:`NUMBER`)

Changed
~~~~~~~

- Updated ``ScopeCollectionType`` to be defined recursively. (:pr:`NUMBER`)


Development
~~~~~~~~~~~

- Added a scope normalization function ``globus_sdk.scopes.scopes_to_scope_list`` to
translate from ``ScopeCollectionType`` to a list of ``Scope`` objects.
(:pr:`NUMBER`)

2 changes: 1 addition & 1 deletion src/globus_sdk/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
str,
MutableScope,
Scope,
t.Iterable[t.Union[str, MutableScope, Scope]],
t.Iterable["ScopeCollectionType"],
]


Expand Down
5 changes: 2 additions & 3 deletions src/globus_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
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, scopes_to_str
from globus_sdk.scopes import Scope, ScopeBuilder
from globus_sdk.transport import RequestsTransport

if t.TYPE_CHECKING:
Expand Down Expand Up @@ -209,8 +209,7 @@ def add_app_scope(self, scope_collection: ScopeCollectionType) -> BaseClient:
"Unable to use an 'app' with a client with no "
"'resource_server' defined."
)
scopes = Scope.parse(scopes_to_str(scope_collection))
self._app.add_scope_requirements({self.resource_server: scopes})
self._app.add_scope_requirements({self.resource_server: scope_collection})

return self

Expand Down
36 changes: 23 additions & 13 deletions src/globus_sdk/experimental/globus_app/globus_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
Scope,
)
from globus_sdk import config as sdk_config
from globus_sdk._types import UUIDLike
from globus_sdk._types import ScopeCollectionType, UUIDLike
from globus_sdk.authorizers import GlobusAuthorizer
from globus_sdk.exc import GlobusSDKUsageError
from globus_sdk.experimental.auth_requirements_error import (
Expand All @@ -25,7 +25,7 @@
LoginFlowManager,
)
from globus_sdk.experimental.tokenstorage import JSONTokenStorage, TokenStorage
from globus_sdk.scopes import AuthScopes
from globus_sdk.scopes import AuthScopes, scopes_to_scope_list

from ._validating_token_storage import ValidatingTokenStorage
from .authorizer_factory import (
Expand Down Expand Up @@ -149,7 +149,7 @@ def __init__(
login_client: AuthLoginClient | None = None,
client_id: UUIDLike | None = None,
client_secret: str | None = None,
scope_requirements: dict[str, list[Scope]] | None = None,
scope_requirements: dict[str, ScopeCollectionType] | None = None,
config: GlobusAppConfig = _DEFAULT_CONFIG,
):
"""
Expand All @@ -165,8 +165,9 @@ def __init__(
type of ``GlobusApp``. Mutually exclusive with ``login_client``.
:client_secret: The value of the client secret for ``client_id`` if it uses
secrets. Mutually exclusive with ``login_client``.
:param scope_requirements: A dict of lists of required scopes indexed by
their resource server.
:param scope_requirements: A dictionary of scope requirements indexed by
resource server. The dict value may be a scope, scope string, or list of
scopes or scope strings.
:config: A ``GlobusAppConfig`` used to control various behaviors of this app.
"""
self.app_name = app_name
Expand All @@ -193,7 +194,7 @@ def __init__(
self.client_id = client_id
self._initialize_login_client(client_secret)

self._scope_requirements = scope_requirements or {}
self._scope_requirements = self._setup_scope_requirements(scope_requirements)

# either get config's TokenStorage, or make the default JSONTokenStorage
if self.config.token_storage:
Expand All @@ -220,6 +221,17 @@ def __init__(
consent_client = AuthClient(app=self, app_scopes=[Scope(AuthScopes.openid)])
self._validating_token_storage.set_consent_client(consent_client)

def _setup_scope_requirements(
self, scope_requirements: dict[str, ScopeCollectionType] | None
) -> dict[str, list[Scope]]:
if scope_requirements is None:
return {}

return {
resource_server: scopes_to_scope_list(scopes)
for resource_server, scopes in scope_requirements.items()
}

@abc.abstractmethod
def _initialize_login_client(self, client_secret: str | None) -> None:
"""
Expand Down Expand Up @@ -288,7 +300,7 @@ def get_authorizer(self, resource_server: str) -> GlobusAuthorizer:
raise e

def add_scope_requirements(
self, scope_requirements: dict[str, list[Scope]]
self, scope_requirements: dict[str, ScopeCollectionType]
) -> None:
"""
Add given scope requirements to the app's scope requirements. Any duplicate
Expand All @@ -298,10 +310,8 @@ def add_scope_requirements(
that will be added to this app's scope requirements
"""
for resource_server, scopes in scope_requirements.items():
if resource_server not in self._scope_requirements:
self._scope_requirements[resource_server] = scopes[:]
else:
self._scope_requirements[resource_server].extend(scopes)
curr = self._scope_requirements.setdefault(resource_server, [])
curr.extend(scopes_to_scope_list(scopes))

self._authorizer_factory.clear_cache(*scope_requirements.keys())

Expand Down Expand Up @@ -354,7 +364,7 @@ def __init__(
login_client: AuthLoginClient | None = None,
client_id: UUIDLike | None = None,
client_secret: str | None = None,
scope_requirements: dict[str, list[Scope]] | None = None,
scope_requirements: dict[str, ScopeCollectionType] | None = None,
config: GlobusAppConfig = _DEFAULT_CONFIG,
):
super().__init__(
Expand Down Expand Up @@ -476,7 +486,7 @@ def __init__(
login_client: ConfidentialAppAuthClient | None = None,
client_id: UUIDLike | None = None,
client_secret: str | None = None,
scope_requirements: dict[str, list[Scope]] | None = None,
scope_requirements: dict[str, ScopeCollectionType] | None = None,
config: GlobusAppConfig = _DEFAULT_CONFIG,
):
if config and config.login_flow_manager is not None:
Expand Down
3 changes: 2 additions & 1 deletion src/globus_sdk/scopes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from ._normalize import scopes_to_str
from ._normalize import scopes_to_scope_list, scopes_to_str
from .builder import ScopeBuilder
from .data import (
AuthScopes,
Expand Down Expand Up @@ -31,4 +31,5 @@
"TimerScopes",
"TransferScopes",
"scopes_to_str",
"scopes_to_scope_list",
)
62 changes: 56 additions & 6 deletions src/globus_sdk/scopes/_normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,68 @@

def scopes_to_str(scopes: ScopeCollectionType) -> str:
"""
Convert a scope collection to a space-separated string.
Utility function to normalize a scope collection to a space-separated scope string.
e.g., scopes_to_str(Scope("foo")) -> "foo"
e.g., scopes_to_str(Scope("foo"), "bar", MutableScope("qux")) -> "foo bar qux"
:param scopes: A scope string or object, or an iterable of scope strings or objects.
:returns: A space-separated scope string.
"""
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]:
"""
Utility function to 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.
"""
return " ".join(_iter_scope_collection(scopes))
scope_list: list[Scope] = []
for scope in _iter_scope_collection(scopes, split_root_scopes=True):
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) -> t.Iterator[str]:
def _iter_scope_collection(
obj: ScopeCollectionType,
split_root_scopes: bool,
) -> t.Iterator[str | MutableScope | Scope]:
"""
Convenience function to iterate over a scope collection type.
Collections of scope representations are yielded one at a time.
Individual scope representations are yielded as-is.
:obj: A scope collection or scope representation.
:iter_scope_strings: If True, scope strings with multiple root scopes are split.
This flag allows you to skip a bfs operation if merging can be done purely
with strings.
e.g., _iter_scope_collection("foo bar[baz qux]", True) -> "foo", "bar[baz qux]"
e.g., _iter_scope_collection("foo bar[baz qux]", 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
elif isinstance(obj, (MutableScope, Scope)):
yield str(obj)
else:
for item in obj:
yield str(item)
yield from _iter_scope_collection(item, 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)
37 changes: 36 additions & 1 deletion tests/unit/experimental/globus_app/test_globus_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
MemoryTokenStorage,
TokenData,
)
from globus_sdk.scopes import Scope
from globus_sdk.scopes import AuthScopes, Scope
from globus_sdk.services.auth import OAuthTokenResponse


Expand Down Expand Up @@ -217,6 +217,41 @@ def test_add_scope_requirements_and_auth_params_with_required_scopes():
)


@pytest.mark.parametrize(
"scope_collection",
("email", AuthScopes.email, Scope("email"), [Scope("email")]),
)
def test_add_scope_requirements_accepts_different_scope_types(scope_collection):
client_id = "mock_client_id"
user_app = UserApp("test-app", client_id=client_id)

assert _sorted_auth_scope_str(user_app) == "openid"

# Add a scope scope string
user_app.add_scope_requirements({"auth.globus.org": scope_collection})
assert _sorted_auth_scope_str(user_app) == "email openid"


@pytest.mark.parametrize(
"scope_collection",
("email", AuthScopes.email, Scope("email"), [Scope("email")]),
)
def test_constructor_scope_requirements_accepts_different_scope_types(scope_collection):
client_id = "mock_client_id"
user_app = UserApp(
"test-app",
client_id=client_id,
scope_requirements={"auth.globus.org": scope_collection},
)

assert _sorted_auth_scope_str(user_app) == "email openid"


def _sorted_auth_scope_str(user_app: UserApp) -> str:
scope_list = user_app.get_scope_requirements("auth.globus.org")
return " ".join(sorted(str(scope) for scope in scope_list))


def test_user_app_get_authorizer():
client_id = "mock_client_id"
memory_storage = MemoryTokenStorage()
Expand Down
39 changes: 38 additions & 1 deletion tests/unit/scopes/test_scope_normalization.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest

from globus_sdk.scopes import MutableScope, Scope, scopes_to_str
from globus_sdk.scopes import MutableScope, Scope, scopes_to_scope_list, scopes_to_str


def test_scopes_to_str_roundtrip_simple_str():
Expand Down Expand Up @@ -35,3 +35,40 @@ def test_scopes_to_str_roundtrip_simple_str_in_collection(scope_collection):
)
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"


def test_scopes_to_scope_list_handles_mixed_data():
scope_collection = ["scope1", MutableScope("scope2"), Scope.parse("scope3 scope4")]
actual_list = scopes_to_scope_list(scope_collection)

assert len(actual_list) == 4
assert all(isinstance(scope, Scope) for scope in actual_list)
assert _as_sorted_string(actual_list) == "scope1 scope2 scope3 scope4"


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))

0 comments on commit 0ac85e0

Please sign in to comment.