diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 98af715..effc5c6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -26,7 +26,7 @@ jobs: id: "setup-python" uses: "actions/setup-python@v4" with: - python-version: "3.11" + python-version: "3.12" cache: "pip" cache-dependency-path: | .github/workflows/ci.yaml @@ -107,7 +107,12 @@ jobs: os: - name: "Linux" value: "ubuntu-latest" - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" tox-extras: [""] include: @@ -115,26 +120,26 @@ jobs: - os: name: "Windows" value: "windows-latest" - python-version: "3.11" + python-version: "3.12" tox-extras: "" # Test Mac. - os: name: "Mac" value: "macos-latest" - python-version: "3.11" + python-version: "3.12" tox-extras: "" # Test minimum dependencies. - os: name: "Linux" value: "ubuntu-latest" - python-version: "3.7" + python-version: "3.8" tox-extras: "-minimum_flask" - os: name: "Linux" value: "ubuntu-latest" - python-version: "3.11" + python-version: "3.12" tox-extras: "-minimum_flask" steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ce39d12..bdcb3fa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,8 +15,15 @@ repos: hooks: - id: alphabetize-codeowners - - repo: https://github.com/psf/black - rev: 23.7.0 + # Enforce Python 3.8+ idioms. + - repo: https://github.com/asottile/pyupgrade + rev: v3.14.0 + hooks: + - id: pyupgrade + args: [--py38-plus] + + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 23.9.1 hooks: - id: black diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..862ba56 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,13 @@ +version: 2 + +build: + os: "ubuntu-22.04" + tools: + python: "3.11" + +sphinx: + configuration: "docs/source/conf.py" + +python: + install: + - requirements: "docs/requirements.txt" diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fd01dce..8a5e3ce 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,27 @@ Unreleased changes are documented in files in the `changelog.d`_ directory. .. scriv-insert-here +.. _changelog-0.13.0rc2: + +0.13.0rc2 — 2023-10-06 +====================== + +Python support +-------------- + +- Support Python 3.12. +- Drop support for Python 3.7. + +Development +----------- + +- Remove unused dependencies. + +Dependencies +------------ + +- Raise the minimum Flask version to 2.3.0, which dropped support for Python 3.7. + .. _changelog-0.13.0rc1: 0.13.0rc1 — 2023-07-24 diff --git a/docs/requirements.txt b/docs/requirements.txt index 995013c..6317c16 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1 +1,2 @@ -sphinx_material +sphinx==7.2.1 +sphinx_material==0.0.35 diff --git a/globus_action_provider_tools/authentication.py b/globus_action_provider_tools/authentication.py index a2ffd7c..95d3772 100644 --- a/globus_action_provider_tools/authentication.py +++ b/globus_action_provider_tools/authentication.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from time import time from typing import FrozenSet, Iterable, List, Optional, Union, cast @@ -26,7 +28,7 @@ def identity_principal(id_: str) -> str: return f"urn:globus:auth:identity:{id_}" -class AuthState(object): +class AuthState: # Cache for introspection operations, max lifetime: 30 seconds introspect_cache: TTLCache = TTLCache(maxsize=100, ttl=30) @@ -42,7 +44,7 @@ def __init__( auth_client: ConfidentialAppAuthClient, bearer_token: str, expected_scopes: Iterable[str], - expected_audience: Optional[str] = None, + expected_audience: str | None = None, ) -> None: self.auth_client = auth_client self.bearer_token = bearer_token @@ -56,10 +58,10 @@ def __init__( self.expected_audience = auth_client.client_id else: self.expected_audience = expected_audience - self.errors: List[Exception] = [] - self._groups_client: Optional[GroupsClient] = None + self.errors: list[Exception] = [] + self._groups_client: GroupsClient | None = None - def introspect_token(self) -> Optional[GlobusHTTPResponse]: + def introspect_token(self) -> GlobusHTTPResponse | None: # There are cases where a null or empty string bearer token are present as a # placeholder if self.bearer_token is None: @@ -103,7 +105,7 @@ def introspect_token(self) -> Optional[GlobusHTTPResponse]: return resp @property - def effective_identity(self) -> Optional[str]: + def effective_identity(self) -> str | None: tkn_details = self.introspect_token() if tkn_details is None: return None @@ -111,18 +113,18 @@ def effective_identity(self) -> Optional[str]: return effective @property - def identities(self) -> FrozenSet[str]: + def identities(self) -> frozenset[str]: tkn_details = self.introspect_token() if tkn_details is None: return frozenset() return frozenset(map(identity_principal, tkn_details["identity_set"])) @property - def principals(self) -> FrozenSet[str]: + def principals(self) -> frozenset[str]: return self.identities.union(self.groups) @property # type: ignore - def groups(self) -> FrozenSet[str]: + def groups(self) -> frozenset[str]: try: groups_client = self._get_groups_client() except (GlobusAPIError, KeyError, ValueError) as err: @@ -163,7 +165,7 @@ def groups(self) -> FrozenSet[str]: return groups_set @property - def dependent_tokens_cache_id(self) -> Optional[str]: + def dependent_tokens_cache_id(self) -> str | None: tkn_details = self.introspect_token() if tkn_details is None: return None @@ -200,7 +202,7 @@ def get_authorizer_for_scope( scope: str, bypass_dependent_token_cache=False, required_authorizer_expiration_time: int = 60, - ) -> Optional[Union[RefreshTokenAuthorizer, AccessTokenAuthorizer]]: + ) -> RefreshTokenAuthorizer | AccessTokenAuthorizer | None: """Retrieve a Globus SDK authorizer for use in accessing a further Globus Auth registered service / "resource server". This authorizer can be passed to any Globus SDK Client class for use in accessing the Client's service. @@ -353,7 +355,7 @@ def __init__( client_id: str, client_secret: str, expected_scopes: Iterable[str], - expected_audience: Optional[str] = None, + expected_audience: str | None = None, ) -> None: self.auth_client = ConfidentialAppAuthClient(client_id, client_secret) self.default_expected_scopes = frozenset(expected_scopes) @@ -364,7 +366,7 @@ def __init__( self.expected_audience = expected_audience def check_token( - self, access_token: str, expected_scopes: Iterable[str] = None + self, access_token: str, expected_scopes: Iterable[str] | None = None ) -> AuthState: if expected_scopes is None: expected_scopes = self.default_expected_scopes diff --git a/globus_action_provider_tools/authorization.py b/globus_action_provider_tools/authorization.py index 52e7c68..6011716 100644 --- a/globus_action_provider_tools/authorization.py +++ b/globus_action_provider_tools/authorization.py @@ -15,7 +15,7 @@ def authorize_action_access_or_404(status: ActionStatus, auth_state: AuthState) AuthenticationError. """ if status.monitor_by is None: - allowed_set = set([status.creator_id]) + allowed_set = {status.creator_id} else: allowed_set = set(chain([status.creator_id], status.monitor_by)) @@ -40,7 +40,7 @@ def authorize_action_management_or_404( AuthenticationError. """ if status.manage_by is None: - allowed_set = set([status.creator_id]) + allowed_set = {status.creator_id} else: allowed_set = set(chain([status.creator_id], status.manage_by)) diff --git a/globus_action_provider_tools/flask/api_helpers.py b/globus_action_provider_tools/flask/api_helpers.py index ff18739..a1a4b45 100644 --- a/globus_action_provider_tools/flask/api_helpers.py +++ b/globus_action_provider_tools/flask/api_helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from typing import List, Optional @@ -105,15 +107,15 @@ def add_action_routes_to_blueprint( blueprint: flask.Blueprint, client_id: str, client_secret: str, - client_name: Optional[str], + client_name: str | None, provider_description: ActionProviderDescription, action_run_callback: ActionRunCallback, action_status_callback: ActionStatusCallback, action_cancel_callback: ActionCancelCallback, action_release_callback: ActionReleaseCallback, - action_log_callback: Optional[ActionLogCallback] = None, - additional_scopes: Optional[List[str]] = None, - action_enumeration_callback: ActionEnumerationCallback = None, + action_log_callback: ActionLogCallback | None = None, + additional_scopes: list[str] | None = None, + action_enumeration_callback: ActionEnumerationCallback | None = None, ) -> None: """ Add routes to a Flask Blueprint to implement the required operations of the Action @@ -296,7 +298,7 @@ def action_log(action_id: str) -> ViewReturn: def action_enumeration(): auth_state = check_token(request, checker) - valid_statuses = set(e.name.casefold() for e in ActionStatusValue) + valid_statuses = {e.name.casefold() for e in ActionStatusValue} statuses = parse_query_args( request, arg_name="status", diff --git a/globus_action_provider_tools/flask/helpers.py b/globus_action_provider_tools/flask/helpers.py index 26dd1ff..725315e 100644 --- a/globus_action_provider_tools/flask/helpers.py +++ b/globus_action_provider_tools/flask/helpers.py @@ -38,8 +38,8 @@ def parse_query_args( *, arg_name: str, default_value: str = "", - valid_vals: Set[str] = None, -) -> Set[str]: + valid_vals: set[str] | None = None, +) -> set[str]: """ Helper function to parse a query arg "arg_name" and return a validated (according to the values supplied in "valid_vals"), usable set of @@ -188,7 +188,7 @@ def get_input_body_validator( def json_schema_input_validation( - action_input: Dict[str, Any], validator: jsonschema.Validator + action_input: dict[str, Any], validator: jsonschema.Validator ) -> None: """ Use a created JSON Validator to verify the input body of an incoming @@ -201,7 +201,7 @@ def json_schema_input_validation( def pydantic_input_validation( - action_input: Dict[str, Any], validator: Type[BaseModel] + action_input: dict[str, Any], validator: type[BaseModel] ) -> None: """ Validate input using the pydantic model itself. Raises a BadActionRequest @@ -218,7 +218,7 @@ def pydantic_input_validation( except ImportError: # Flask < 2.2: Use the deprecated JSON encoder interface. json_provider_available = False - JsonProvider: Optional["DefaultJSONProvider"] = None + JsonProvider: DefaultJSONProvider | None = None else: # Flask >= 2.2: Use the new JSON provider interface. json_provider_available = True diff --git a/globus_action_provider_tools/validation.py b/globus_action_provider_tools/validation.py index 88a2b3d..638fc14 100644 --- a/globus_action_provider_tools/validation.py +++ b/globus_action_provider_tools/validation.py @@ -11,11 +11,11 @@ "ActionRequest": "action_request.yaml", "ActionStatus": "action_status.yaml", } -_validator_map: Dict[str, jsonschema.Validator] = {} +_validator_map: Dict[str, jsonschema.protocols.Validator] = {} HERE: Path = Path(__file__).parent for schema_name, yaml_file in _schema_to_file_map.items(): - with open(HERE / yaml_file, "r", encoding="utf-8") as specfile: + with open(HERE / yaml_file, encoding="utf-8") as specfile: schema = yaml.safe_load(specfile) validator_cls = jsonschema.validators.validator_for(schema) _validator_map[schema_name] = validator_cls(schema) @@ -43,7 +43,7 @@ def request_validator(request: ValidationRequest) -> ValidationResult: def validate_data( - data: Dict[str, Any], validator: jsonschema.Validator + data: Dict[str, Any], validator: jsonschema.protocols.Validator ) -> ValidationResult: error_messages = [] for error in validator.iter_errors(data): diff --git a/pyproject.toml b/pyproject.toml index f2ef135..49d09b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "globus-action-provider-tools" -version = "0.13.0rc1" +version = "0.13.0rc2" description = "Tools to help developers build services that implement the Action Provider specification." authors = [ "Kurt McKee ", @@ -27,19 +27,19 @@ license = "Apache-2.0" whattimeisit-provider = "examples.flask.whattimeisitrightnow.app.app:main" [tool.poetry.dependencies] -python = "^3.7" +python = ">=3.8" globus-sdk="^3.9.0" jsonschema = "^4.17" pyyaml = "^6" pybase62 = "^0.4.0" pydantic = "^1.7.3" isodate = "^0.6.0" -cachetools = "^4.2.4" -flask = {version = "^2.1.0", optional = true} -pytest = {version = "^7.2.0", optional = true} +cachetools = "^5.0" +flask = {version = "^2.3.0", optional = true} +pytest = {version = "^7", optional = true} freezegun = {version = "^1.2.2", optional = true} -coverage = {extras = ["toml"], version = "^6.5.0", optional = true} -responses = {version = "^0.22.0", optional = true} +coverage = {extras = ["toml"], version = "^7", optional = true} +responses = {version = "^0.23.3", optional = true} [tool.poetry.extras] flask = ["flask"] @@ -51,13 +51,7 @@ testing = [ ] [tool.poetry.group.dev.dependencies] -scriv = {extras = ["toml"], version = "^0.17.0"} -tox = "^3.26.0" -importmagic = "^0" -epc = "^0" -isodate = "^0.6.0" docutils = "^0.16" -rstcheck = "^3.3.1" pygments = "^2.6.1" sphinx = "^5.0.2" sphinx_material = "^0.0.35" @@ -75,6 +69,7 @@ build-backend = "poetry.core.masonry.api" [tool.scriv] categories = [ + "Python support", "Features", "Bugfixes", "Changes", @@ -96,3 +91,14 @@ source = [ [tool.coverage.report] # When the test coverage increases, this bar should also raise. fail_under = 81 + +[tool.pytest.ini_options] +filterwarnings = [ + "error", + + # Minimum Flask versions interact with werkzeug in a now-deprecated manner. + "ignore:The '__version__' attribute is deprecated:DeprecationWarning", + + # dateutil, used by freezegun during testing, has a Python 3.12 compatibility issue. + "ignore:datetime.datetime.utcfromtimestamp\\(\\) is deprecated:DeprecationWarning", +] diff --git a/tests/test_flask_helpers/test_query_helpers.py b/tests/test_flask_helpers/test_query_helpers.py index 918c655..c62d503 100644 --- a/tests/test_flask_helpers/test_query_helpers.py +++ b/tests/test_flask_helpers/test_query_helpers.py @@ -52,7 +52,7 @@ def test_parse_query_args(query_string, expected_statuses, expected_roles): Flask request's string. """ app = Flask(__name__) - valid_statuses = set(e.name.casefold() for e in ActionStatusValue) + valid_statuses = {e.name.casefold() for e in ActionStatusValue} with app.test_request_context(query_string) as req: statuses = parse_query_args( diff --git a/tox.ini b/tox.ini index 8077851..4d0937b 100644 --- a/tox.ini +++ b/tox.ini @@ -5,9 +5,10 @@ isolated_build = true envlist = mypy coverage_erase - py{37, 38, 39, 310, 311} - py{37, 311}-minimum_flask + py{38, 39, 310, 311, 312} + py{38, 311}-minimum_flask coverage_report + docs [testenv:coverage_erase] @@ -20,18 +21,18 @@ commands = coverage erase package = wheel wheel_build_env = build_wheel depends = - py{311, 310, 39, 38, 37}{-minimum_flask,}: coverage_erase + py{312, 311, 310, 39, 38}{-minimum_flask,}: coverage_erase extras = testing !minimum_flask: flask deps = - minimum_flask: flask==2.1.0 + minimum_flask: flask==2.3.0 commands = coverage run -m pytest [testenv:coverage_report] depends = - py{311, 310, 39, 38, 37}{-minimum_flask,} + py{312, 311, 310, 39, 38}{-minimum_flask,} skip_install = true deps = coverage[toml] commands_pre = @@ -43,10 +44,18 @@ commands = coverage report [testenv:mypy] skip_install = true deps = - mypy==0.982 + mypy==1.5.1 types-cachetools types-requests types-pyyaml commands = mypy --ignore-missing-imports globus_action_provider_tools/ tests/ + + +[testenv:docs] +skip_install = true +deps = + -r docs/requirements.txt +commands = + sphinx-build -anEWb html --keep-going docs/source docs/html