Repository navigation
ci(deps): bump the actions-minor-patch group with 3 updates #432
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| jobs: | |
| test: | |
| timeout-minutes: 15 | |
| runs-on: ${{ matrix.os }} | |
| # mapped at job level so the upload step can ask whether it has a token before it runs: the secrets | |
| # context is not readable from a step `if`, and step-level env is not in scope for its own condition | |
| env: | |
| CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} | |
| strategy: | |
| matrix: | |
| os: [ubuntu-latest] | |
| python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] | |
| include: | |
| # the Codecov cell also installs every optional integration (pandas/polars/numpy extras plus | |
| # pydantic/attrs/requests), so the tests the other minimal-env cells skip run here for real | |
| - os: ubuntu-latest | |
| python-version: "3.14" | |
| full-deps: true | |
| - os: windows-latest | |
| python-version: "3.14" | |
| - os: macos-latest | |
| python-version: "3.14" | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 | |
| - name: Set up Python ${{ matrix.python-version }} | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| allow-prereleases: true | |
| - name: Install dependencies | |
| if: '!matrix.full-deps' | |
| run: uv sync --locked --extra json | |
| - name: Install dependencies (all integrations) | |
| if: matrix.full-deps | |
| run: uv sync --locked --extra json --extra data --extra inline --group integrations --group docs-examples | |
| - name: Test with coverage | |
| if: '!matrix.full-deps' | |
| run: uv run pytest -v --cov=assertpy2 --cov-report=term-missing --cov-report=xml --ignore=tests/test_docs_examples.py tests | |
| - name: Test with coverage (enforce 100%) | |
| # Only the full-deps cell has every integration installed and 0 skips, so it is | |
| # the only cell that can reach 100%. This is the hard local gate; Codecov's status | |
| # is reporting on top, not the sole guard (it uploads async and can fail open). | |
| # The doc-example guard is excluded here so its runs never pad the coverage number. | |
| if: matrix.full-deps | |
| run: uv run pytest -v --cov=assertpy2 --cov-report=term-missing --cov-report=xml --cov-fail-under=100 --ignore=tests/test_docs_examples.py tests | |
| - name: Guard doc examples | |
| # Runs once (docs are platform-independent) and without coverage, so executing the | |
| # README/guide snippets can't mask a real coverage gap in the test suite. | |
| if: matrix.full-deps | |
| run: uv run pytest tests/test_docs_examples.py | |
| - name: Upload coverage to Codecov | |
| # Only the full-deps cell. The bare 3.14/ubuntu cell skips every integration test and used | |
| # to upload too: Codecov merges reports so the number stayed right, but one of the two was | |
| # always a partial report sent at a 100% target. | |
| # A run without repository secrets gets an empty token, and Codecov rejects an untokened upload | |
| # to a protected branch, so the job went red on the upload while every test passed. The gate is | |
| # the token rather than who opened the pull request: dependabot pushes its branches into this | |
| # repository, so a fork test reads false for it and it still runs without secrets. | |
| if: matrix.full-deps && env.CODECOV_TOKEN != '' | |
| uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 | |
| with: | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| files: coverage.xml | |
| # A failed upload is a broken report, not a passing build. The 100% gate above is the hard | |
| # guard either way, so this cannot turn a real coverage drop into a green run. | |
| fail_ci_if_error: true | |
| declared-floors: | |
| # Every lower bound on a published extra is a promise, and the lockfile only ever resolves the newest | |
| # of each, so the bottom of every range was a claim nobody checked. This job checks them, and each | |
| # widening of it found a real defect: first the data libraries, where the refusal gate keyed on | |
| # numpy's wording for an unusable operand and that wording is different below numpy 2, then | |
| # `asttokens>=2.0` and `behave>=1.2.6`, which were simply wrong, then `starlette>=0.40`, whose test | |
| # client reaches a function deprecated in 3.14. | |
| # | |
| # Scope, said exactly: every extra in `[project.optional-dependencies]`, plus the `dev` and | |
| # `integrations` groups, which is what running the suite needs. The `docs`, `typecheck`, `mutation`, | |
| # `docs-examples` and `benchmark` groups are not here: each drives a tool with its own command rather | |
| # than this suite, and none of them is a promise to anyone installing the package. | |
| # | |
| # Its own job rather than a cell in the matrix above. An `include` entry naming an `os` and a | |
| # `python-version` that already exist is merged into that combination instead of adding one, so a | |
| # cell written that way would have replaced the ordinary runs rather than joining them. | |
| timeout-minutes: 15 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - python-version: "3.10" | |
| extras: "allure,behave,data,inline,json,numpy,pandas,polars" | |
| groups: "--group dev --group integrations" | |
| imports: "typing_extensions, numpy, pandas, polars, asttokens, executing, jsonschema, jsonpath_ng, referencing, allure_commons, behave, attr, attrs, django, flask, httpx, httpx2, pydantic, requests, starlette" | |
| - python-version: "3.11" | |
| extras: "allure,behave,data,inline,json,numpy,pandas,polars" | |
| groups: "--group dev --group integrations" | |
| imports: "numpy, pandas, polars, asttokens, executing, jsonschema, jsonpath_ng, referencing, allure_commons, behave, attr, attrs, django, flask, httpx, httpx2, pydantic, requests, starlette" | |
| - python-version: "3.12" | |
| extras: "allure,behave,data,inline,json,numpy,pandas,polars" | |
| groups: "--group dev --group integrations" | |
| imports: "numpy, pandas, polars, asttokens, executing, jsonschema, jsonpath_ng, referencing, allure_commons, behave, attr, attrs, django, flask, httpx, httpx2, pydantic, requests, starlette" | |
| - python-version: "3.13" | |
| extras: "allure,behave,data,inline,json,numpy,pandas,polars" | |
| groups: "--group dev --group integrations" | |
| imports: "numpy, pandas, polars, asttokens, executing, jsonschema, jsonpath_ng, referencing, allure_commons, behave, attr, attrs, django, flask, httpx, httpx2, pydantic, requests, starlette" | |
| - python-version: "3.14" | |
| extras: "allure,behave,data,inline,json,numpy,pandas,polars" | |
| groups: "--group dev --group integrations" | |
| imports: "numpy, pandas, polars, asttokens, executing, jsonschema, jsonpath_ng, referencing, allure_commons, behave, attr, attrs, django, flask, httpx, httpx2, pydantic, requests, starlette" | |
| # The one dependency every user gets, at its own floor, and it needs a cell to itself: pydantic | |
| # 2.0 requires typing-extensions>=4.6.1, so every cell installing `integrations` resolves it | |
| # above the declared bound. Measured 4.0.0 here against 4.6.1 in each of those. | |
| - python-version: "3.10" | |
| extras: "allure,behave,data,inline,json,numpy,pandas,polars" | |
| groups: "--group dev" | |
| imports: "typing_extensions, numpy, pandas, polars, asttokens, executing, jsonschema, jsonpath_ng, referencing, allure_commons, behave" | |
| reduced: "no integrations, so pydantic cannot lift typing-extensions off its declared floor" | |
| # 3.15 carries what is installable there: no combination of the data libraries publishes a | |
| # wheel for it yet, and pydantic has no stable one either, which takes the whole `integrations` | |
| # group with it. Move this cell up to the others when those wheels land. | |
| - python-version: "3.15" | |
| extras: "allure,behave,inline,json" | |
| groups: "--group dev" | |
| imports: "asttokens, executing, jsonschema, jsonpath_ng, referencing, allure_commons, behave" | |
| reduced: "no data-library wheel for 3.15 yet, and pydantic has no stable one either" | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 | |
| - name: Set up Python ${{ matrix.python-version }} | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| allow-prereleases: true | |
| - name: Install every declared dependency at its lower bound | |
| # `lowest-direct` resolves every direct dependency to the oldest its specifier allows, which is | |
| # what the metadata promises. Hand-pinning `numpy==1.26.0` did the same for three of them and | |
| # covered none of the rest. | |
| # | |
| # `--only-binary` because the oldest allowed release is not installable everywhere: pandas 2.0.0 | |
| # publishes no wheel above 3.11 and building it fails on a missing `pkg_resources`. With it the | |
| # resolver lands on the oldest release that has a wheel for this interpreter, which is the oldest | |
| # a user of it can actually get. Measured: pandas 2.0.0 on 3.10 and 3.11, 2.1.1 on 3.12, 2.2.3 on | |
| # 3.13, 2.3.3 on 3.14. | |
| # | |
| # `uv pip install` and not `uv sync`, which was measured rewriting `uv.lock` wholesale: the tests | |
| # that shell out then read a lockfile that is not the committed one, and six of them failed on | |
| # exactly that. | |
| # | |
| # Every extra by name, `data` and the three it aggregates alike, because | |
| # `tests/test_declared_floors.py` reads these lines and asks for each of them. | |
| run: | | |
| uv venv --python ${{ matrix.python-version }} | |
| uv pip install --only-binary=:all: --resolution lowest-direct -e ".[${{ matrix.extras }}]" ${{ matrix.groups }} | |
| - name: Every installed floor must import | |
| # A release old enough to install and too old to import would otherwise leave the cell green: | |
| # its tests answer `pytest.importorskip` with a skip, and a skip carrying its own reason is | |
| # invisible to the missing-module guard in `tests/conftest.py`. | |
| # | |
| # The `integrations` group is in the list too, not only the published extras: it is installed by | |
| # the same command and its tests skip the same way. The `dev` group is not, because nothing in it | |
| # skips: the suite imports pytest and hypothesis to run at all, and the gates that shell out to | |
| # ruff and ty refuse rather than skip when the tool does not answer. | |
| run: uv run --no-sync python -c "import ${{ matrix.imports }}" | |
| - name: What this cell resolved | |
| # Just what is installed and at which version. Which of those are declared floors, and whether | |
| # each was reached, is decided once in `floors-reached` below, where `tomllib` is available and | |
| # a requirement carrying an environment marker parses rather than being mangled by a regex. | |
| run: | | |
| uv run --no-sync python - <<'PROBE' | |
| import importlib.metadata as md | |
| import pathlib | |
| with pathlib.Path("resolved.txt").open("w", encoding="utf-8") as verdict: | |
| for distribution in sorted(md.distributions(), key=lambda item: item.metadata["Name"] or ""): | |
| name = distribution.metadata["Name"] | |
| if name: | |
| print(f"{name} {distribution.version}", file=verdict) | |
| print(pathlib.Path("resolved.txt").read_text(encoding="utf-8")) | |
| PROBE | |
| - name: Keep this cell's resolution for the aggregate | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: floors-resolved-${{ matrix.python-version }}-${{ strategy.job-index }} | |
| path: resolved.txt | |
| retention-days: 1 | |
| - name: Suite at the floor | |
| # `--no-sync` is load bearing here and nowhere else: a bare `uv run` restores the environment | |
| # from the lockfile, which would put the newest of everything back and leave this job testing | |
| # what the matrix above already tests | |
| run: uv run --no-sync pytest -q --ignore=tests/test_docs_examples.py tests | |
| floors-reached: | |
| # The cross-cell half of the floor job, and it has to be cross-cell: `typing-extensions` reaches its | |
| # floor only where pydantic is absent, and `attrs` only where it is installed by name rather than | |
| # arriving through jsonschema. No cell reaches everything, and between them they do. | |
| # | |
| # Every declaration is read here rather than in the cells, with `tomllib` rather than a regex: a | |
| # requirement carrying an environment marker holds quotes of its own, and reading it by pattern | |
| # silently truncated `typing-extensions>=4.0; python_version < '3.11'` to nothing. | |
| timeout-minutes: 5 | |
| needs: declared-floors | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 | |
| - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| pattern: floors-resolved-* | |
| path: resolutions | |
| - name: Every declared floor is reached somewhere | |
| run: | | |
| uv run --no-project --with packaging python - <<'AGGREGATE' | |
| import pathlib | |
| import tomllib | |
| from packaging.requirements import Requirement | |
| from packaging.version import Version | |
| # keyed by name and floor, because two extras may declare two different bounds on one | |
| # distribution and reaching either says nothing about the other | |
| UNREACHED: dict[tuple[str, str], str] = {} | |
| declared = {} | |
| project = tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8")) | |
| sources = [project["project"]["dependencies"]] | |
| sources += list(project["project"]["optional-dependencies"].values()) | |
| sources += [project["dependency-groups"][name] for name in ("dev", "integrations")] | |
| for group in sources: | |
| for line in group: | |
| requirement = Requirement(line) | |
| floors = [spec.version for spec in requirement.specifier if spec.operator == ">="] | |
| if floors and requirement.name != "assertpy2": | |
| declared.setdefault(requirement.name, set()).add(floors[0]) | |
| installed = {} | |
| files = sorted(pathlib.Path("resolutions").rglob("resolved.txt")) | |
| if not files: | |
| raise SystemExit("no cell reported a resolution, so this job proves nothing") | |
| for path in files: | |
| for row in path.read_text(encoding="utf-8").splitlines(): | |
| name, version = row.rsplit(" ", 1) | |
| installed.setdefault(name.lower().replace("_", "-"), set()).add(version) | |
| never, stale = [], [] | |
| for name, floors in sorted(declared.items()): | |
| key = name.lower().replace("_", "-") | |
| for floor in sorted(floors, key=Version): | |
| reached = any(Version(seen) == Version(floor) for seen in installed.get(key, ())) | |
| excused = (key, floor) in UNREACHED | |
| print(f"{name:18} floor {floor:10} {'reached' if reached else 'NEVER REACHED'}") | |
| if not reached and not excused: | |
| never.append(f"{name}>={floor}") | |
| if reached and excused: | |
| stale.append(f"{name}>={floor}") | |
| if never: | |
| raise SystemExit(f"declared floors no cell installs: {', '.join(never)}") | |
| if stale: | |
| raise SystemExit(f"recorded as unreachable and reached anyway, so the note is stale: {', '.join(stale)}") | |
| AGGREGATE | |
| optional-integrations: | |
| # allure and behave get their own job because installing them is incompatible with the coverage | |
| # gate: with allure present the `except ImportError` fallback in pytest_plugin.py never runs, and | |
| # without it the import itself never runs. The gating cell keeps the second state, this one holds | |
| # the first. Everywhere else the two are tested against mocks, which accept any call and would | |
| # wave through the renamed parameter or moved attribute a Dependabot bump can bring. | |
| timeout-minutes: 10 | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: "3.14" | |
| - name: Install dependencies | |
| run: uv sync --locked --extra json --extra allure --extra behave | |
| - name: Contract tests against the real libraries | |
| run: uv run pytest -v tests/test_optional_integration_contracts.py | |
| - name: The rest of the suite must also pass with both installed | |
| # Installing allure flips `_HAS_ALLURE`, so every failing assertion in the suite takes the | |
| # real attach path. That must not change any other test's outcome. | |
| run: uv run pytest -q --ignore=tests/test_docs_examples.py tests | |
| lint: | |
| timeout-minutes: 10 | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 | |
| - name: Set up Python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: "3.14" | |
| - name: Install dependencies | |
| # the whole `data` stack, because the overloads narrow on *structure* and the structures are | |
| # pandas', polars' and numpy's to change. A stub release that stops declaring the member a | |
| # shape keys on moves those values back to the generic builder, silently: it type-checks, and | |
| # the narrowed methods just stop being offered. The integration matrix below is what notices | |
| run: uv sync --locked --extra json --extra data --extra inline --group typecheck --group integrations | |
| - name: Ruff check | |
| run: uv run ruff check . | |
| - name: Ruff format check | |
| run: uv run ruff format --check . | |
| - name: Type check (ty) | |
| run: uv run ty check assertpy2/ tests/test_typing.py | |
| - name: Type check (mypy --strict, public typing surface) | |
| run: uv run mypy --strict --follow-imports=silent tests/test_typing.py | |
| # the engine, not the launcher: `pyright` on PyPI and the npm engine it launches are released apart | |
| # and have drifted before, and every recorded number here was recorded against one build. The | |
| # gates that call pyright from Python pin the same one, in `tests/typing_harness.py` | |
| - name: Type check (pyright, public typing surface) | |
| env: | |
| PYRIGHT_PYTHON_FORCE_VERSION: "1.1.414" | |
| run: uv run pyright --pythonversion 3.14 tests/test_typing.py | |
| # a fourth overload-resolution engine on the same file, which is what the badge names. Its | |
| # settings are in `pyrefly.toml`, and the preset there is deliberate: the fallback one answers | |
| # green while looking at much less | |
| - name: Type check (pyrefly, public typing surface) | |
| run: uv run pyrefly check tests/test_typing.py | |
| # the fourth checker over the promises too: the harness reads three, and both defects the file | |
| # was written for were accepted by all four | |
| - name: Type check (pyrefly, the object matches the promise) | |
| run: uv run pyrefly check tests/typing_promises.py | |
| # the package itself carries diagnostics that are not defects (values typed `object`, guarded | |
| # optional imports, deliberate overloads). They are recorded per rule with a reason, so a new | |
| # one fails here instead of hiding in a count nobody reads. | |
| - name: Type check (pyright, package baseline) | |
| run: uv run pytest tests/test_pyright_baseline.py -q | |
| # the outside view of the same package: what a user of the installed wheel can see a type for. | |
| # A package checks clean and still hands out `Unknown` on a public method, which is a different | |
| # defect and needs its own gate | |
| - name: Type check (pyright, completeness of the exported surface) | |
| run: uv run pytest tests/test_typing_completeness.py -q | |
| # the one gate that reads the value rather than another declaration, and the only place its | |
| # static half runs: the test matrix has no checkers, so the three checker cases skip there | |
| - name: Type check (the object matches the promise) | |
| run: uv run pytest tests/test_typing_promises.py -q | |
| # the other direction: which incompatible operands the checkers actually refuse. Recorded per | |
| # relation, so tightening a signature shows up here as a deliberate change rather than as a | |
| # number, and loosening one shows up at all | |
| - name: Type check (rejections, all three checkers) | |
| run: uv run pytest tests/test_typing_negative.py -q | |
| # the chain model's chains to two pivots, as typed calls: any change in what a checker says fails | |
| - name: Type check (the chain model's corpus, all four checkers) | |
| run: uv run pytest tests/test_typing_chain_corpus.py -q | |
| # the stand-in classes in `test_typing.py` prove the overloads are written correctly and cannot | |
| # prove the real libraries still match them. This runs the same question against real pandas, | |
| # polars and numpy values, with each checker given its interpreter and target explicitly | |
| - name: Type check (real library values, all three checkers) | |
| run: uv run pytest tests/test_typing_integrations.py -q | |
| # an ASGI or a WSGI response is a callable, so which overload claims one is decided by the order | |
| # of the two. Two clients were reaching the callable view and refusing their own assertions, and | |
| # only a real response says whether a client still matches the capability that describes it | |
| - name: Type check (real HTTP responses, all three checkers) | |
| run: uv run pytest tests/test_typing_http.py -q | |
| # every gate above reads the working tree, where `py.typed` and every subpackage are present | |
| # because they are files on disk. A consumer gets the wheel, and the two agree only for as long | |
| # as the packaging says so | |
| - name: Type check (the typed surface as an installed wheel carries it) | |
| run: uv run pytest tests/test_typing_from_a_wheel.py -q | |
| # the verdict twins are generated from the protocols; this is where an edit to the generated | |
| # file, or a protocol change the twins did not follow, stops being invisible | |
| - name: Type check (the generated verdict twins are in step) | |
| run: uv run pytest tests/test_check_protocols.py tests/test_operation_contract.py -q | |
| # the two halves of every signature: the runtime and the type it is described by. Parity asks | |
| # whether a declared method exists; this asks whether the two agree about its parameters, how | |
| # they may be passed, and which of them are required | |
| - name: Type check (the runtime and the typed surface agree, parameter by parameter) | |
| run: uv run pytest tests/test_typing_conformance.py -q | |
| ci-ok: | |
| timeout-minutes: 5 | |
| needs: [test, declared-floors, floors-reached, optional-integrations, lint] | |
| if: always() | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Verify required jobs succeeded | |
| env: | |
| TEST_RESULT: ${{ needs.test.result }} | |
| FLOOR_RESULT: ${{ needs.declared-floors.result }} | |
| REACHED_RESULT: ${{ needs.floors-reached.result }} | |
| INTEGRATIONS_RESULT: ${{ needs.optional-integrations.result }} | |
| LINT_RESULT: ${{ needs.lint.result }} | |
| run: | | |
| [ "$TEST_RESULT" = "success" ] && | |
| [ "$FLOOR_RESULT" = "success" ] && | |
| [ "$REACHED_RESULT" = "success" ] && | |
| [ "$INTEGRATIONS_RESULT" = "success" ] && | |
| [ "$LINT_RESULT" = "success" ] |