diff --git a/lambda_handlers/rale_authorizer/handler.py b/lambda_handlers/rale_authorizer/handler.py index 8296f46..ded9f5c 100644 --- a/lambda_handlers/rale_authorizer/handler.py +++ b/lambda_handlers/rale_authorizer/handler.py @@ -236,7 +236,7 @@ def handler(event: dict[str, Any], context: Any) -> dict[str, Any]: # noqa: ARG allowed = service.has_package_grant(project_id=project_id, quilt_uri=quilt_uri) except DataZoneError: return _response(503, {"error": "authorization service unavailable"}) - except ClientError, BotoCoreError: + except (ClientError, BotoCoreError): return _response(503, {"error": "authorization service unavailable"}) if not allowed: @@ -248,7 +248,7 @@ def handler(event: dict[str, Any], context: Any) -> dict[str, Any]: # noqa: ARG if jwt_secret_version: secret_kwargs["VersionId"] = jwt_secret_version jwt_secret = secrets.get_secret_value(**secret_kwargs)["SecretString"] - except ClientError, BotoCoreError, KeyError: + except (ClientError, BotoCoreError, KeyError): return _response(503, {"error": "failed to load jwt secret"}) token_ttl = int(os.environ.get("TOKEN_TTL", "3600")) diff --git a/lambda_handlers/rale_router/handler.py b/lambda_handlers/rale_router/handler.py index 8a79852..516ff57 100644 --- a/lambda_handlers/rale_router/handler.py +++ b/lambda_handlers/rale_router/handler.py @@ -133,7 +133,7 @@ def _proxy_get_or_head( ) except s3_client.exceptions.NoSuchKey: return _response(404, {"error": "object not found"}) - except ClientError, BotoCoreError: + except (ClientError, BotoCoreError): return _response(502, {"error": "failed to fetch object from S3"}) @@ -161,14 +161,14 @@ def handler(event: dict[str, Any], context: Any) -> dict[str, Any]: # noqa: ARG if jwt_secret_version: secret_kwargs["VersionId"] = jwt_secret_version jwt_secret = secrets.get_secret_value(**secret_kwargs)["SecretString"] - except ClientError, BotoCoreError, KeyError: + except (ClientError, BotoCoreError, KeyError): return _response(503, {"error": "failed to load jwt secret"}) try: claims = validate_taj_token(taj, jwt_secret) except TokenExpiredError: return _response(401, {"error": "expired TAJ"}) - except TokenInvalidError, TokenValidationError: + except (TokenInvalidError, TokenValidationError): return _response(401, {"error": "invalid TAJ"}) # For un-pinned USLs the hash comes from the TAJ; for pinned USLs validate diff --git a/pyproject.toml b/pyproject.toml index 78f298c..e5aba22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,8 @@ build-backend = "uv_build" rale = "raja.cli:main" [tool.ruff] -target-version = "py314" +# target-version is inferred from project.requires-python, so the formatter and +# linter never emit syntax that is newer than the interpreters we support. line-length = 100 extend-exclude = ["infra/cdk.out", "infra/cdk.out*"] diff --git a/src/raja/datazone/service.py b/src/raja/datazone/service.py index f3be384..707ef5a 100644 --- a/src/raja/datazone/service.py +++ b/src/raja/datazone/service.py @@ -231,7 +231,7 @@ def _get_asset_external_identifier(self, asset_id: str) -> str: domainIdentifier=self._config.domain_id, identifier=asset_id, ) - except ClientError, BotoCoreError: + except (ClientError, BotoCoreError): return "" if not isinstance(response, dict): return "" @@ -263,7 +263,7 @@ def _get_iam_arn_for_user_id(self, user_id: str) -> str | None: ) arn: str | None = resp.get("details", {}).get("iam", {}).get("arn") return arn - except ClientError, BotoCoreError: + except (ClientError, BotoCoreError): return None def _get_user_id_for_principal(self, principal: str) -> str | None: @@ -276,7 +276,7 @@ def _get_user_id_for_principal(self, principal: str) -> str | None: ) user_id: str | None = resp.get("id") return user_id - except ClientError, BotoCoreError: + except (ClientError, BotoCoreError): return None def _resolve_membership_user_identifier(self, user_identifier: str) -> str: diff --git a/src/raja/scope.py b/src/raja/scope.py index 6f0fb6d..65aab9b 100644 --- a/src/raja/scope.py +++ b/src/raja/scope.py @@ -74,7 +74,7 @@ def _normalize_scopes(scopes: Iterable[Scope | str]) -> set[str]: normalized.add(format_scope(scope.resource_type, scope.resource_id, scope.action)) else: normalized.add(format_scope(**parse_scope(scope).model_dump())) - except ScopeParseError, ScopeValidationError: + except (ScopeParseError, ScopeValidationError): # Re-raise our custom exceptions raise except Exception as exc: diff --git a/tests/unit/test_python_floor.py b/tests/unit/test_python_floor.py new file mode 100644 index 0000000..63f655a --- /dev/null +++ b/tests/unit/test_python_floor.py @@ -0,0 +1,60 @@ +"""Guard the minimum Python version declared in pyproject.toml. + +The published package and the Lambda bundles must parse on the oldest +interpreter ``requires-python`` allows. The CI matrix cannot be relied on for +this: ``uv sync`` resolves the interpreter from ``.python-version``, so every +matrix job runs the same version regardless of ``actions/setup-python``. +""" + +from __future__ import annotations + +import ast +import re +import tomllib +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +SOURCE_ROOTS = (REPO_ROOT / "src", REPO_ROOT / "lambda_handlers") + + +def minimum_python() -> tuple[int, int]: + """Return the lowest (major, minor) accepted by ``requires-python``.""" + pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text()) + requires_python = pyproject["project"]["requires-python"] + match = re.search(r">=\s*(\d+)\.(\d+)", requires_python) + if not match: + pytest.fail(f"cannot read a minimum version from requires-python: {requires_python}") + return int(match.group(1)), int(match.group(2)) + + +def source_files() -> list[Path]: + return sorted(path for root in SOURCE_ROOTS for path in root.rglob("*.py")) + + +def test_sources_parse_on_minimum_python() -> None: + floor = minimum_python() + failures: list[str] = [] + + for path in source_files(): + try: + ast.parse(path.read_text(), filename=str(path), feature_version=floor) + except SyntaxError as exc: + relative = path.relative_to(REPO_ROOT) + failures.append(f"{relative}:{exc.lineno}: {exc.msg}") + + assert not failures, "syntax not supported on Python {}.{}:\n{}".format( + floor[0], floor[1], "\n".join(failures) + ) + + +def test_feature_version_rejects_newer_syntax() -> None: + """Fail loudly if ``feature_version`` stops gating syntax we rely on it for.""" + floor = minimum_python() + if floor >= (3, 14): + pytest.skip("minimum Python already allows unparenthesized except expressions") + + source = "try:\n pass\nexcept ValueError, TypeError:\n pass\n" + with pytest.raises(SyntaxError): + ast.parse(source, feature_version=floor)