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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ repos:
- id: flake8
name: "Lint python files"
additional_dependencies:
- 'flake8-bugbear==24.12.12'
- 'flake8-bugbear==26.9.9'
- 'flake8-comprehensions==3.16.0'
- 'flake8-typing-as-t==1.0.0'
- repo: https://github.com/PyCQA/isort
Expand Down
8 changes: 7 additions & 1 deletion src/globus_sdk/exc/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ class GlobusAPIError(GlobusError):
MESSAGE_FIELDS = ["message", "detail", "title"]
RECOGNIZED_AUTHZ_SCHEMES = ["bearer", "basic", "globus-goauthtoken"]

def __init__(self, r: requests.Response, *args: t.Any, **kwargs: t.Any) -> None:
# TODO: re-evaluate how exception args are handled by this class.
# For now, ignore flake8-bugbear's B042 rule about exception inheritance.
# Fixing it would likely require somehow adjusting the interface for a
# GlobusAPIError, possibly in a breaking way.
def __init__( # noqa: B042
self, r: requests.Response, *args: t.Any, **kwargs: t.Any
) -> None:
# defer this import to avoid circularity between 'exc' and 'transport'
from globus_sdk.transport import RequestsTransport

Expand Down
11 changes: 8 additions & 3 deletions src/globus_sdk/exc/convert.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from __future__ import annotations

import typing as t

import requests

from .base import GlobusError
Expand All @@ -18,10 +16,17 @@ class NetworkError(GlobusError):
to explain potentially confusing or inconsistent exceptions passed to us
"""

def __init__(self, msg: str, exc: Exception, *args: t.Any, **kwargs: t.Any) -> None:
def __init__(self, msg: str, exc: Exception) -> None:
super().__init__(msg)
self.message = msg
self.underlying_exception = exc

def __str__(self) -> str:
return self.message

def __reduce__(self) -> tuple[type, tuple[str, Exception]]:
return (NetworkError, (self.message, self.underlying_exception))


class GlobusTimeoutError(NetworkError):
"""The REST request timed out."""
Expand Down
8 changes: 4 additions & 4 deletions src/globus_sdk/gare/_variants.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class LegacyDependentConsentRequiredAuthError(Serializable):
The dependent_consent_required error format emitted by the Globus Auth service.
"""

def __init__(
def __init__( # noqa: B042
self,
*,
error: t.Literal["dependent_consent_required"],
Expand Down Expand Up @@ -68,7 +68,7 @@ class LegacyConsentRequiredTransferError(Serializable):
The ConsentRequired error format emitted by the Globus Transfer service.
"""

def __init__(
def __init__( # noqa: B042
self,
*,
code: t.Literal["ConsentRequired"],
Expand Down Expand Up @@ -99,7 +99,7 @@ class LegacyConsentRequiredAPError(Serializable):
Action Providers.
"""

def __init__(
def __init__( # noqa: B042
self,
*,
code: t.Literal["ConsentRequired"],
Expand Down Expand Up @@ -195,7 +195,7 @@ class LegacyAuthorizationParametersError(Serializable):

DEFAULT_CODE = "AuthorizationRequired"

def __init__(
def __init__( # noqa: B042
self,
*,
authorization_parameters: dict[str, t.Any] | LegacyAuthorizationParameters,
Expand Down
14 changes: 14 additions & 0 deletions src/globus_sdk/scopes/consents/_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,26 @@ class ConsentParseError(Exception):

def __init__(self, message: str, raw_consent: dict[str, t.Any]) -> None:
super().__init__(message)
self.message = message
self.raw_consent = raw_consent

def __str__(self) -> str:
return self.message

def __reduce__(self) -> tuple[type, tuple[str, dict[str, t.Any]]]:
return (ConsentParseError, (self.message, self.raw_consent))


class ConsentTreeConstructionError(Exception):
"""An error raised if consent tree construction fails."""

def __init__(self, message: str, consents: list[Consent]) -> None:
super().__init__(message)
self.message = message
self.consents = consents

def __str__(self) -> str:
return self.message

def __reduce__(self) -> tuple[type, tuple[str, list[Consent]]]:
return (ConsentTreeConstructionError, (self.message, self.consents))
38 changes: 35 additions & 3 deletions src/globus_sdk/token_storage/validating_token_storage/errors.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import datetime
import uuid
from datetime import datetime

from globus_sdk import GlobusError, Scope

Expand All @@ -28,25 +28,50 @@ def __init__(
self, message: str, stored_id: uuid.UUID | str, new_id: uuid.UUID | str
) -> None:
super().__init__(message)
self.message = message
self.stored_id = stored_id
self.new_id = new_id

def __str__(self) -> str:
return self.message

def __reduce__(self) -> tuple[type, tuple[str, uuid.UUID | str, uuid.UUID | str]]:
return (IdentityMismatchError, (self.message, self.stored_id, self.new_id))


class MissingTokenError(TokenValidationError, LookupError):
"""No token stored for a given resource server."""

def __init__(self, message: str, resource_server: str) -> None:
super().__init__(message)
self.message = message
self.resource_server = resource_server

def __str__(self) -> str:
return self.message

def __reduce__(self) -> tuple[type, tuple[str, str]]:
return (MissingTokenError, (self.message, self.resource_server))


class ExpiredTokenError(TokenValidationError, ValueError):
"""The token stored for a given resource server has expired."""

def __init__(self, expires_at_seconds: int) -> None:
expiration = datetime.fromtimestamp(expires_at_seconds)
super().__init__(f"Token expired at {expiration.isoformat()}")
expiration = datetime.datetime.fromtimestamp(expires_at_seconds)
message = f"Token expired at {expiration.isoformat()}"

super().__init__(message)

self.message = message
self.expiration = expiration
self._expires_at_seconds = expires_at_seconds

def __str__(self) -> str:
return self.message

def __reduce__(self) -> tuple[type, tuple[int]]:
return (ExpiredTokenError, (self._expires_at_seconds,))


class UnmetScopeRequirementsError(TokenValidationError, ValueError):
Expand All @@ -56,6 +81,13 @@ def __init__(
self, message: str, scope_requirements: dict[str, list[Scope]]
) -> None:
super().__init__(message)
self.message = message
# The full set of scope requirements which were evaluated.
# Notably this is not exclusively the unmet scope requirements.
self.scope_requirements = scope_requirements

def __str__(self) -> str:
return self.message

def __reduce__(self) -> tuple[type, tuple[str, dict[str, list[Scope]]]]:
return (UnmetScopeRequirementsError, (self.message, self.scope_requirements))
104 changes: 104 additions & 0 deletions tests/unit/errors/test_error_copy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""
Several errors define `__reduce__` to ensure that they are pickleable.

These tests ensure that we can copy without errors and that the results compare equal
under some definition of "equal" (which may be specific to the error type).
"""

import copy

from globus_sdk.exc import NetworkError
from globus_sdk.scopes.consents import ConsentParseError, ConsentTreeConstructionError
from globus_sdk.token_storage.validating_token_storage import (
ExpiredTokenError,
IdentityMismatchError,
MissingTokenError,
UnmetScopeRequirementsError,
)


def test_copy_of_network_error():
err = NetworkError("bad cxn", Exception("kaboom"))
err_copy = copy.copy(err)
Comment thread
kurtmckee marked this conversation as resolved.
assert id(err) != id(err_copy)
assert str(err_copy.underlying_exception) == "kaboom"


def test_copy_of_identity_mismatch_error():
err = IdentityMismatchError("they didn't match", "a", "b")
err_copy = copy.copy(err)
assert id(err) != id(err_copy)
assert (
err.message,
err.stored_id,
err.new_id,
) == (
err_copy.message,
err_copy.stored_id,
err_copy.new_id,
)


def test_copy_of_missing_token_error():
err = MissingTokenError("it gone", "my_rs")
err_copy = copy.copy(err)
assert id(err) != id(err_copy)
assert (
err.message,
err.resource_server,
) == (
err_copy.message,
err_copy.resource_server,
)


def test_copy_of_expired_token_error():
err = ExpiredTokenError(101)
err_copy = copy.copy(err)
assert id(err) != id(err_copy)
assert (
err.message,
err.expiration,
) == (
err_copy.message,
err_copy.expiration,
)


def test_copy_of_scope_requirements_error():
err = UnmetScopeRequirementsError("needed a token for scope", {"my_rs": []})
err_copy = copy.copy(err)
assert id(err) != id(err_copy)
assert (
err.message,
err.scope_requirements,
) == (
err_copy.message,
err_copy.scope_requirements,
)


def test_copy_of_consent_parse_error():
err = ConsentParseError("it didn't parse", {})
err_copy = copy.copy(err)
assert id(err) != id(err_copy)
assert (
err.message,
err.raw_consent,
) == (
err_copy.message,
err_copy.raw_consent,
)


def test_copy_of_consent_tree_construction_error():
err = ConsentTreeConstructionError("empty", [])
err_copy = copy.copy(err)
assert id(err) != id(err_copy)
assert (
err.message,
err.consents,
) == (
err_copy.message,
err_copy.consents,
)
48 changes: 48 additions & 0 deletions tests/unit/errors/test_error_stringify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""
Several errors define `__str__` to ensure that args are formatted
(or dropped) as desired. These tests ensure that we get the right strings back.
"""

from globus_sdk.exc import NetworkError
from globus_sdk.scopes.consents import ConsentParseError, ConsentTreeConstructionError
from globus_sdk.token_storage.validating_token_storage import (
ExpiredTokenError,
IdentityMismatchError,
MissingTokenError,
UnmetScopeRequirementsError,
)


def test_str_of_network_error():
err = NetworkError("bad cxn", Exception("kaboom"))
assert str(err) == "bad cxn"


def test_str_of_identity_mismatch_error():
err = IdentityMismatchError("they didn't match", "a", "b")
assert str(err) == "they didn't match"


def test_str_of_missing_token_error():
err = MissingTokenError("it gone", "my_rs")
assert str(err) == "it gone"


def test_str_of_expired_token_error():
err = ExpiredTokenError(101)
assert str(err).startswith("Token expired at ")


def test_str_of_scope_requirements_error():
err = UnmetScopeRequirementsError("needed a token for scope", {"my_rs": []})
assert str(err) == "needed a token for scope"


def test_str_of_consent_parse_error():
err = ConsentParseError("it didn't parse", {})
assert str(err) == "it didn't parse"


def test_str_of_consent_tree_construction_error():
err = ConsentTreeConstructionError("empty", [])
assert str(err) == "empty"
4 changes: 2 additions & 2 deletions tests/unit/responses/test_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,14 +196,14 @@ def test_len_array_bad_data(dict_response, json_response_factory):
"Cannot take len() on ArrayResponse data when type is 'NoneType'"
),
):
len(null_array)
len(null_array) # noqa: B018

dict_array = ArrayResponse(dict_response.r)
with pytest.raises(
TypeError,
match=re.escape("Cannot take len() on ArrayResponse data when type is 'dict'"),
):
len(dict_array)
len(dict_array) # noqa: B018


def test_iter_array_bad_data(dict_response, json_response_factory):
Expand Down
Loading