Repository navigation
470 lines (412 loc) · 25.2 KB
/
Copy pathci.yml
File metadata and controls
470 lines (412 loc) · 25.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
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 --group oracle
- 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: The oracle is installed
# its module skips where a package is absent, so an absence here would pass for a green run
if: matrix.full-deps
run: uv run python -c "import openapi_schema_validator, fqdn, isoduration, rfc3986_validator"
- 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, idna, 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, idna, 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, idna, 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, idna, 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, idna, 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, idna, 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 and green there. No combination of the data libraries
# publishes a wheel for it yet. The `integrations` group installs since pydantic 2.14, and stays
# out for one test: this cell resolves anyio 4.15.1 on 3.15 where the cells below resolve
# 4.14.2, and starlette at its floor 0.46.0 imports a name that anyio deprecates, which the
# suite's `-W error` refuses. Move this cell up to the others when both are settled.
- python-version: "3.15"
extras: "allure,behave,inline,json"
groups: "--group dev"
imports: "asttokens, executing, idna, jsonschema, jsonpath_ng, referencing, allure_commons, behave"
reduced: "no data-library wheel for 3.15 yet, and starlette at its floor warns under the anyio resolved here"
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" ]