From 38d32e826b6ce5688ac54ba753439b4d259bf7df Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 6 Oct 2026 05:38:11 +0000 Subject: [PATCH 1/2] ci: deduplicate quality checks and reuse caches --- .github/workflows/quality_check.yml | 76 +++++++++++++++++++++-------- CONTRIBUTING.md | 6 +++ Makefile | 11 +++-- 3 files changed, 71 insertions(+), 22 deletions(-) diff --git a/.github/workflows/quality_check.yml b/.github/workflows/quality_check.yml index 7901e004856..99d1fb712b5 100644 --- a/.github/workflows/quality_check.yml +++ b/.github/workflows/quality_check.yml @@ -2,13 +2,10 @@ name: Quality check - check code # PROCESS # -# 1. Install all dependencies and spin off containers for all supported Python versions -# 2. Run code formatters and linters (various checks) for code standard -# 3. Run static typing checker for potential bugs -# 4. Run entire test suite for regressions except end-to-end (unit, functional, performance) -# 5. Run static analysis (in addition to CodeQL) for common insecure code practices -# 6. Run complexity baseline to avoid error-prone bugs and keep maintenance lower -# 7. Collect and report on test coverage +# 1. Run formatting, linting, security and complexity checks once +# 2. Run both type checkers and the test suite on every supported Python version +# 3. Validate optional dependencies with Nox on every supported Python version +# 4. Collect and report on test coverage # USAGE # @@ -25,6 +22,8 @@ on: - "mypy.ini" - "Makefile" - "noxfile.py" + - "ruff.toml" + - "bandit.baseline" - ".pre-commit-config.yaml" - ".github/workflows/quality_check.yml" - ".github/workflows/release-v3.yml" @@ -43,6 +42,8 @@ on: - "mypy.ini" - "Makefile" - "noxfile.py" + - "ruff.toml" + - "bandit.baseline" - ".pre-commit-config.yaml" - ".github/workflows/quality_check.yml" - ".github/workflows/release-v3.yml" @@ -55,7 +56,39 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: + static_checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - name: Set up uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.12.19" + enable-cache: true + cache-suffix: static-checks + cache-dependency-glob: | + pyproject.toml + uv.lock + - name: Install dependencies + run: make dev-quality-code + - name: Check formatting + run: make format-check + - name: Lint + run: make lint-check + - name: Security baseline + run: make security-baseline + - name: Complexity baseline + run: make complexity-baseline + quality_check: runs-on: ubuntu-latest strategy: @@ -69,21 +102,30 @@ jobs: contents: read # checkout code only steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Python ${{ matrix.python-version }} + id: python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ matrix.python-version }} - name: Set up uv uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: version: "0.12.19" enable-cache: true - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: ${{ matrix.python-version }} + cache-suffix: quality-${{ matrix.python-version }} + cache-dependency-glob: | + pyproject.toml + uv.lock + noxfile.py - name: Install dependencies run: make dev-quality-code - - name: Checking and enforcing format - run: make format-check - - name: Formatting and Linting - run: make lint + - name: Cache mypy analysis + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .mypy_cache + key: mypy-${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ hashFiles('uv.lock', 'pyproject.toml', 'mypy.ini') }}-${{ github.sha }} + restore-keys: | + mypy-${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ hashFiles('uv.lock', 'pyproject.toml', 'mypy.ini') }}- - name: Static type checking (mypy) run: make mypy - name: Static type checking (ty) @@ -92,10 +134,6 @@ jobs: run: make test - name: Test dependencies with Nox run: make test-dependencies - - name: Security baseline - run: make security-baseline - - name: Complexity baseline - run: make complexity-baseline - name: Upload coverage to Codecov uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # 7.1.1 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10f4019a4bb..9e13e4b1713 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,6 +104,12 @@ Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then run This creates a `.venv` with the locked development dependencies and installs the Git hooks. Run tools with `uv run --locked`, or activate `.venv` when working in your editor. +CI runs formatting, linting, security and complexity checks once. Tests, dependency +checks with Nox, and both type checkers run on every supported Python version. +Run `make format-check lint-check` to check formatting and linting without modifying +files. `make lint` still formats files before linting. CI uses `make dev-quality-code` +to install the same dependencies as `make dev`, without installing Git hooks. + To send us a pull request, please follow these steps: 1. Create a new branch to focus on the specific change you are contributing e.g. `improv/logger-debug-sampling` diff --git a/Makefile b/Makefile index 618669bd22a..398aeb39377 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,15 @@ -.PHONY: target dev format lint test coverage-html pr build build-docs build-docs-website +.PHONY: target dev dev-quality-code format format-check lint lint-check test coverage-html pr build build-docs build-docs-website .PHONY: docs-local security-baseline complexity-baseline release-prod release-test release target: @$(MAKE) pr -dev dev-quality-code: - uv sync --locked --extra all --extra redis --extra datamasking --extra valkey +dev: dev-quality-code uv run --locked pre-commit install +dev-quality-code: + uv sync --locked --extra all --extra redis --extra datamasking --extra valkey + format-check: uv run --locked ruff format aws_lambda_powertools tests examples --check @@ -15,6 +17,9 @@ format: uv run --locked ruff format aws_lambda_powertools tests examples lint: format + $(MAKE) lint-check + +lint-check: uv run --locked ruff check aws_lambda_powertools tests examples lint-docs: From 476f89a7c353370bd7b0666792864e15e78d8299 Mon Sep 17 00:00:00 2001 From: Leandro Damascena Date: Tue, 6 Oct 2026 05:54:20 +0000 Subject: [PATCH 2/2] test: isolate validation dependencies in Nox --- CONTRIBUTING.md | 5 ++ Makefile | 2 +- noxfile.py | 51 +++++++++++++++++-- pyproject.toml | 1 + .../event_handler/_pydantic/conftest.py | 5 +- .../_pydantic/test_bedrock_agent.py | 1 + .../test_openapi_schema_pydantic_v2.py | 2 + .../test_api_middlewares.py | 12 +++-- .../functional/idempotency/_boto3/conftest.py | 3 +- .../idempotency/_boto3/test_idempotency.py | 3 +- .../_fastjsonschema/test_validator.py | 2 + 11 files changed, 75 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e13e4b1713..5402c73fa7c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,6 +110,11 @@ Run `make format-check lint-check` to check formatting and linting without modif files. `make lint` still formats files before linting. CI uses `make dev-quality-code` to install the same dependencies as `make dev`, without installing Git hooks. +`make test-dependencies` recreates the Nox environments so packages left over from +earlier runs cannot hide missing dependencies. Tests marked `requires_validation` +run in dedicated sessions with the Validation extra, keeping the SDK and Parser +sessions independent of it. The full test suite still runs these tests. + To send us a pull request, please follow these steps: 1. Create a new branch to focus on the specific change you are contributing e.g. `improv/logger-debug-sampling` diff --git a/Makefile b/Makefile index 398aeb39377..c3285496ec9 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ test: uv run --locked pytest --cache-clear tests/performance test-dependencies: - uv run --locked nox --error-on-external-run --reuse-venv=yes --non-interactive + uv run --locked nox --error-on-external-run --reuse-venv=no --non-interactive test-pydanticv2: uv run --locked pytest -m "not perf" --ignore tests/e2e diff --git a/noxfile.py b/noxfile.py index 4324fd96eb9..1d17df74687 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,7 +1,7 @@ # Run nox tests # # usage: -# uv run --locked nox --error-on-external-run --reuse-venv=yes --non-interactive +# uv run --locked nox --error-on-external-run --reuse-venv=no --non-interactive # # If you want to target a specific Python version, add -p parameter from __future__ import annotations @@ -14,7 +14,12 @@ PREFIX_TESTS_UNIT = "tests/unit" -def build_and_run_test(session: nox.Session, folders: list, extras: str = "") -> None: +def build_and_run_test( + session: nox.Session, + folders: list, + extras: str = "", + pytest_args: tuple[str, ...] = (), +) -> None: """ This function is responsible for setting up the testing environment and running the test suite for specific feature. @@ -32,20 +37,29 @@ def build_and_run_test(session: nox.Session, folders: list, extras: str = "") -> extras: Optional[str] A string representing additional dependencies that should be installed for the test environment. If not provided, the function will install the project with basic dependencies + pytest_args: tuple[str, ...] + Additional pytest arguments for selecting tests with matching dependencies """ # Required install to execute any test - # Shared fixtures use JSON Schema validation; previously Poetry installed it transitively. - session.install("pytest", "pytest-mock", "pytest_socket", "pytest-asyncio", "fastjsonschema") + session.install("pytest", "pytest-mock", "pytest_socket", "pytest-asyncio") # Powertools project folder is in the root if extras: session.install(f"./[{extras}]") else: session.install("./") + session.run( + "python", + "-I", + "-c", + "from importlib.util import find_spec; " + "assert find_spec('fastjsonschema') is None, " + "'fastjsonschema leaked into the required-dependencies environment'", + ) # Execute test in specific folders - session.run("pytest", *folders) + session.run("pytest", *folders, *pytest_args) @nox.session() @@ -72,6 +86,7 @@ def test_with_only_required_packages(session: nox.Session): f"{PREFIX_TESTS_FUNCTIONAL}/batch/required_dependencies/", f"{PREFIX_TESTS_FUNCTIONAL}/kafka_consumer/required_dependencies/", ], + pytest_args=("-m", "not requires_validation"), ) @@ -121,6 +136,7 @@ def test_with_boto3_sdk_as_required_package(session: nox.Session): f"{PREFIX_TESTS_FUNCTIONAL}/idempotency/_boto3/", ], extras="aws-sdk", + pytest_args=("-m", "not requires_validation"), ) @@ -132,8 +148,10 @@ def test_with_fastjsonschema_as_required_package(session: nox.Session): session, folders=[ f"{PREFIX_TESTS_FUNCTIONAL}/validator/_fastjsonschema/", + f"{PREFIX_TESTS_FUNCTIONAL}/event_handler/required_dependencies/test_api_middlewares.py", ], extras="validation", + pytest_args=("-m", "requires_validation"), ) @@ -170,6 +188,29 @@ def test_with_pydantic_required_package(session: nox.Session): f"{PREFIX_TESTS_UNIT}/event_handler/_pydantic/", ], extras="parser", + pytest_args=("-m", "not requires_validation"), + ) + + +@nox.session() +def test_with_parser_and_validation_required_packages(session: nox.Session): + """Validate generated OpenAPI schemas with Parser and Validation dependencies.""" + build_and_run_test( + session, + folders=[f"{PREFIX_TESTS_FUNCTIONAL}/event_handler/_pydantic/"], + extras="parser,validation", + pytest_args=("-m", "requires_validation"), + ) + + +@nox.session() +def test_with_boto3_and_validation_required_packages(session: nox.Session): + """Test Idempotency combined with the Validation decorator.""" + build_and_run_test( + session, + folders=[f"{PREFIX_TESTS_FUNCTIONAL}/idempotency/_boto3/"], + extras="aws-sdk,validation", + pytest_args=("-m", "requires_validation"), ) diff --git a/pyproject.toml b/pyproject.toml index 2e52e6c02e3..616780afe92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -225,6 +225,7 @@ addopts = "-ra -vv" testpaths = "./tests" markers = [ "perf: marks perf tests to be deselected (deselect with '-m \"not perf\"')", + "requires_validation: tests that also require the validation extra", ] [tool.ty] diff --git a/tests/functional/event_handler/_pydantic/conftest.py b/tests/functional/event_handler/_pydantic/conftest.py index 6ba2b95def9..7dc9b73d7a9 100644 --- a/tests/functional/event_handler/_pydantic/conftest.py +++ b/tests/functional/event_handler/_pydantic/conftest.py @@ -2,7 +2,6 @@ import json -import fastjsonschema import pytest from aws_lambda_powertools.event_handler.openapi.models import APIKey, APIKeyIn @@ -99,6 +98,8 @@ def pydanticv2_only(): def openapi30_schema(): from urllib.request import urlopen + import fastjsonschema + f = urlopen("https://spec.openapis.org/oas/3.0/schema/2021-09-28") data = json.loads(f.read().decode("utf-8")) return fastjsonschema.compile( @@ -111,6 +112,8 @@ def openapi30_schema(): def openapi31_schema(): from urllib.request import urlopen + import fastjsonschema + f = urlopen("https://spec.openapis.org/oas/3.1/schema/2022-10-07") data = json.loads(f.read().decode("utf-8")) return fastjsonschema.compile( diff --git a/tests/functional/event_handler/_pydantic/test_bedrock_agent.py b/tests/functional/event_handler/_pydantic/test_bedrock_agent.py index 90e9e69790b..c4a145240ae 100644 --- a/tests/functional/event_handler/_pydantic/test_bedrock_agent.py +++ b/tests/functional/event_handler/_pydantic/test_bedrock_agent.py @@ -186,6 +186,7 @@ def send_reminders( @pytest.mark.usefixtures("pydanticv2_only") +@pytest.mark.requires_validation def test_openapi_schema_for_pydanticv2(openapi30_schema): # GIVEN BedrockAgentResolver is initialized with enable_validation=True app = BedrockAgentResolver(enable_validation=True) diff --git a/tests/functional/event_handler/_pydantic/test_openapi_schema_pydantic_v2.py b/tests/functional/event_handler/_pydantic/test_openapi_schema_pydantic_v2.py index 6f4b89e47cc..807814f8fde 100644 --- a/tests/functional/event_handler/_pydantic/test_openapi_schema_pydantic_v2.py +++ b/tests/functional/event_handler/_pydantic/test_openapi_schema_pydantic_v2.py @@ -13,6 +13,7 @@ @pytest.mark.usefixtures("pydanticv2_only") +@pytest.mark.requires_validation def test_openapi_3_1_simple_handler(openapi31_schema): # GIVEN APIGatewayRestResolver is initialized with enable_validation=True app = APIGatewayRestResolver(enable_validation=True) @@ -45,6 +46,7 @@ def test_openapi_3_0_with_pydantic_v2(): @pytest.mark.usefixtures("pydanticv2_only") +@pytest.mark.requires_validation def test_openapi_3_1_complex_handler(openapi31_schema): # GIVEN APIGatewayRestResolver is initialized with enable_validation=True app = APIGatewayRestResolver(enable_validation=True) diff --git a/tests/functional/event_handler/required_dependencies/test_api_middlewares.py b/tests/functional/event_handler/required_dependencies/test_api_middlewares.py index 3f19500f4a5..8136a325d20 100644 --- a/tests/functional/event_handler/required_dependencies/test_api_middlewares.py +++ b/tests/functional/event_handler/required_dependencies/test_api_middlewares.py @@ -19,9 +19,6 @@ BaseMiddlewareHandler, NextMiddleware, ) -from aws_lambda_powertools.event_handler.middlewares.schema_validation import ( - SchemaValidationMiddleware, -) from tests.functional.utils import load_event if TYPE_CHECKING: @@ -236,7 +233,10 @@ def get_lambda(my_id: str, name: str) -> Response: ), ], ) +@pytest.mark.requires_validation def test_pass_schema_validation(app: ApiGatewayResolver, event, validation_schema): + from aws_lambda_powertools.event_handler.middlewares.schema_validation import SchemaValidationMiddleware + @app.post("/my/path", middlewares=[SchemaValidationMiddleware(validation_schema)]) def post_lambda() -> Response: return Response(200, content_types.TEXT_HTML, "path") @@ -266,7 +266,10 @@ def post_lambda() -> Response: ), ], ) +@pytest.mark.requires_validation def test_fail_schema_validation(app: ApiGatewayResolver, event, validation_schema): + from aws_lambda_powertools.event_handler.middlewares.schema_validation import SchemaValidationMiddleware + @app.post("/my/path", middlewares=[SchemaValidationMiddleware(validation_schema)]) def post_lambda() -> Response: return Response(200, content_types.TEXT_HTML, "Should not be returned") @@ -300,7 +303,10 @@ def post_lambda() -> Response: ), ], ) +@pytest.mark.requires_validation def test_invalid_schema_validation(app: ApiGatewayResolver, event): + from aws_lambda_powertools.event_handler.middlewares.schema_validation import SchemaValidationMiddleware + @app.post("/my/path", middlewares=[SchemaValidationMiddleware(inbound_schema="schema.json")]) def post_lambda() -> Response: return Response(200, content_types.TEXT_HTML, "Should not be returned") diff --git a/tests/functional/idempotency/_boto3/conftest.py b/tests/functional/idempotency/_boto3/conftest.py index cfc1d994619..27d4c088730 100644 --- a/tests/functional/idempotency/_boto3/conftest.py +++ b/tests/functional/idempotency/_boto3/conftest.py @@ -14,7 +14,6 @@ from aws_lambda_powertools.utilities.idempotency import DynamoDBPersistenceLayer from aws_lambda_powertools.utilities.idempotency.idempotency import IdempotencyConfig from aws_lambda_powertools.utilities.jmespath_utils import query -from aws_lambda_powertools.utilities.validation import envelopes from tests.functional.idempotency.utils import hash_idempotency_key from tests.functional.utils import json_serialize, load_event @@ -198,6 +197,8 @@ def hashed_idempotency_key(request, lambda_apigw_event, default_jmespath, lambda @pytest.fixture def hashed_idempotency_key_with_envelope(request, lambda_apigw_event): + from aws_lambda_powertools.utilities.validation import envelopes + event = query( data=lambda_apigw_event, envelope=envelopes.API_GATEWAY_HTTP, diff --git a/tests/functional/idempotency/_boto3/test_idempotency.py b/tests/functional/idempotency/_boto3/test_idempotency.py index 74ece5c5bab..e6f0758b32f 100644 --- a/tests/functional/idempotency/_boto3/test_idempotency.py +++ b/tests/functional/idempotency/_boto3/test_idempotency.py @@ -52,7 +52,6 @@ DataclassSerializer, ) from aws_lambda_powertools.utilities.typing import DurableContextProtocol -from aws_lambda_powertools.utilities.validation import envelopes, validator from aws_lambda_powertools.warnings import PowertoolsUserWarning from tests.functional.idempotency.utils import ( build_idempotency_put_item_response_stub, @@ -686,6 +685,7 @@ def lambda_handler(event, context): [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True, ) +@pytest.mark.requires_validation def test_idempotent_lambda_with_validator_util( config_without_jmespath: IdempotencyConfig, persistence_store: DynamoDBPersistenceLayer, @@ -701,6 +701,7 @@ def test_idempotent_lambda_with_validator_util( Test idempotent decorator where event with matching event key has already been succesfully processed, using the validator utility to unwrap the event """ + from aws_lambda_powertools.utilities.validation import envelopes, validator stubber = stub.Stubber(persistence_store.client) ddb_response = { diff --git a/tests/functional/validator/_fastjsonschema/test_validator.py b/tests/functional/validator/_fastjsonschema/test_validator.py index e14bc5e01c1..0d86ea2fd29 100644 --- a/tests/functional/validator/_fastjsonschema/test_validator.py +++ b/tests/functional/validator/_fastjsonschema/test_validator.py @@ -13,6 +13,8 @@ validator, ) +pytestmark = pytest.mark.requires_validation + def test_validate_raw_event(schema, raw_event): validate(event=raw_event, schema=schema)