Coverage for src/rhiza_task/tasks/python.py: 100%
120 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
1"""The Python language layer: python.mk, as tasks.
3python.mk is 312 lines, over half of the synced make. Most of it converts to the
4declarative form in :mod:`rhiza_task.spec`; ``test`` is the one recipe that does not, and
5it is written out in full below.
7``complexity`` is the one task here with no make ancestor. It lives in this module because
8radon is a Python tool and the gate is therefore Python-layer, even though its section is
9``Quality`` alongside the neutral gates it reads like.
10"""
12from __future__ import annotations
14import json
15import shutil
17from ..config import Config
18from ..spec import REGISTRY, Failed, Guard, Skip, task
19from ..uv import uv, uv_run, uvx
20from .quality import install_hooks
22PYTEST_WITHS = (
23 "pytest",
24 "pytest-cov",
25 "pytest-xdist",
26 "pytest-html",
27 "pytest-timeout",
28 "pytest-mock",
29)
30"""What ``test`` injects.
32A named tuple of packages rather than a literal in the call, so CI and this package's own
33tests can assert on it. The make recipe's six ``--with`` flags are invisible to anything
34but a human reading the recipe.
35"""
37PYTEST_INTERNAL_ERROR = 3
38"""pytest's INTERNALERROR.
40Distinct from test failure (1), interruption (2) and usage error (4), which is what makes
41retrying on it safe: it means the *runner* broke during worker or session teardown -- the
42xdist ``worker_workerfinished`` KeyError, or a pytest-html report-write race -- not that a
43test failed.
44"""
46MAX_ATTEMPTS = 2
49# `setup` first, and in all three layers: a native library needed to *build* a wheel has to
50# be on the machine before `uv sync`, not after it. See tasks/setup.py for why the hook is
51# anchored on `install` rather than run by the workflows.
52@task("install", "create the venv and sync dependencies", section="Python", layer="python", needs=("setup",))
53def install(cfg: Config) -> None:
54 """Create ``.venv`` if absent, sync from the lock file, install the git hooks.
56 Args:
57 cfg: The resolved config.
59 Raises:
60 Skip: When the project has no ``pyproject.toml``.
61 Failed: When the lock file is out of sync, or a step exits non-zero.
62 """
63 venv = cfg.root / ".venv"
64 if not venv.is_dir():
65 uv("venv", "--python", cfg.python_version, str(venv), cwd=cfg.root)
66 else:
67 print(f"[INFO] using existing virtual environment at {venv}")
69 if not (cfg.root / "pyproject.toml").is_file():
70 raise Skip("no pyproject.toml")
72 frozen: tuple[str, ...] = ()
73 if (cfg.root / "uv.lock").is_file():
74 # python.mk runs this check, swallows its output and prints three lines of
75 # guidance on failure. The check is worth keeping; the guidance belongs with it
76 # rather than in a shell heredoc.
77 if uv("lock", "--check", cwd=cfg.root, check=False):
78 raise Failed(1, "uv.lock is out of sync with pyproject.toml -- run `uv lock`")
79 frozen = ("--frozen",)
81 # --inexact: leave packages uv did not manage in place instead of pruning them on
82 # every run, so repeated task invocations do not churn the environment. Per-task
83 # tooling is provisioned on the fly by uv.py, so there is no separate step for it.
84 uv("sync", *cfg.uv_sync_args, "--inexact", *frozen, cwd=cfg.root)
86 install_hooks(cfg)
89@task(
90 "test",
91 "run all tests",
92 section="Python",
93 layer="python",
94 needs=("install",),
95 guards=(Guard("tests_folder", glob="test_*.py", reason="no test files found"),),
96)
97def test(cfg: Config) -> None:
98 """Run the suite with coverage, retrying once on a pytest-internal teardown error.
100 This is the recipe that justifies a real language. In python.mk it is a 40-line shell
101 ``while :; do ... done`` inside a make recipe, with ``$$`` escaping on every variable,
102 ``set --`` used to build the argument list because make cannot hold an array, and the
103 retry condition spelled ``if [ $$status -ne 3 ]; then exit $$status; fi``.
105 Args:
106 cfg: The resolved config.
108 Raises:
109 Failed: When pytest reports test failures, or reports an internal error twice.
110 """
111 reports = cfg.root / "_tests"
112 shutil.rmtree(reports, ignore_errors=True)
114 args = [*_pytest_args(cfg)]
115 if cfg.path("source_folder").is_dir():
116 args += coverage_args(cfg)
117 else:
118 # Not a Skip: the tests exist and must run. Only coverage is unavailable.
119 print(f"[WARN] source folder '{cfg.source_folder}' not found; running without coverage")
120 args.append("--html=_tests/html-report/report.html")
122 for attempt in range(1, MAX_ATTEMPTS + 1):
123 # Stale data first: a crashed run can leave a corrupt .coverage file, which then
124 # reports a false 0% on the next run.
125 for stale in cfg.root.glob(".coverage*"):
126 stale.unlink(missing_ok=True)
127 (reports / "html-coverage").mkdir(parents=True, exist_ok=True)
128 (reports / "html-report").mkdir(parents=True, exist_ok=True)
130 code = uv_run("pytest", *args, cwd=cfg.root, withs=PYTEST_WITHS, check=False)
131 if code != PYTEST_INTERNAL_ERROR:
132 if code:
133 raise Failed(code, "tests failed")
134 return
135 if attempt == MAX_ATTEMPTS:
136 raise Failed(code, f"pytest reported an internal (teardown) error {attempt}x")
137 print(f"[WARN] pytest exited {code} (xdist teardown race); retrying {attempt + 1}/{MAX_ATTEMPTS}")
140@task(
141 "coverage",
142 "measure coverage and write _tests/coverage.xml",
143 section="Python",
144 layer="python",
145 needs=("install",),
146 guards=(
147 Guard("tests_folder", glob="test_*.py", reason="no test files found"),
148 Guard("source_folder"),
149 ),
150)
151def coverage(cfg: Config) -> None:
152 """Run the suite for its coverage reports.
154 python.mk has no ``coverage`` target: its ``test`` recipe carries the ``--cov`` flags,
155 so the Cobertura file CI uploads and ``book`` badges is a side effect of the test gate.
156 rust.mk and go.mk both name ``coverage`` separately, and the gate-parity contract lists
157 it for all three layers -- so the Python layer grows the name it was missing rather than
158 the other two losing it.
160 It is not a second test run in any meaningful sense: same suite, same floor, same
161 output path. What it buys is a caller that wants the report without asserting anything
162 about the HTML test report, and one name that means the same thing in all three layers.
164 Args:
165 cfg: The resolved config.
166 """
167 (cfg.root / "_tests" / "html-coverage").mkdir(parents=True, exist_ok=True)
168 for stale in cfg.root.glob(".coverage*"):
169 stale.unlink(missing_ok=True)
170 uv_run("pytest", *_pytest_args(cfg), *coverage_args(cfg), cwd=cfg.root, withs=PYTEST_WITHS)
173def _pytest_args(cfg: Config) -> list[str]:
174 """Return the arguments both pytest-running gates share.
176 Args:
177 cfg: The resolved config.
179 Returns:
180 Parallelism, and the two folders the testing extras own.
181 """
182 return [
183 "-n",
184 "auto",
185 f"--ignore={cfg.tests_folder}/benchmarks",
186 f"--ignore={cfg.tests_folder}/stress",
187 ]
190def coverage_args(cfg: Config) -> list[str]:
191 """Return the ``--cov`` flags, including the Cobertura path the other layers write to.
193 Shared by ``test`` and ``coverage`` so the two cannot drift: ``_tests/coverage.xml`` is
194 the file book.mk's badge step reads and CI uploads, and rust.mk and go.mk go out of
195 their way to write it at exactly that path.
197 Args:
198 cfg: The resolved config.
200 Returns:
201 The coverage flags.
202 """
203 return [
204 f"--cov={cfg.source_folder}",
205 "--cov-report=term",
206 "--cov-report=html:_tests/html-coverage",
207 "--cov-report=json:_tests/coverage.json",
208 "--cov-report=xml:_tests/coverage.xml",
209 f"--cov-fail-under={cfg.coverage_fail_under}",
210 ]
213@task(
214 "typecheck",
215 "run ty and/or mypy (typechecker = ty | mypy | both)",
216 section="Python",
217 layer="python",
218 needs=("install",),
219 guards=(Guard("source_folder"),),
220)
221def typecheck(cfg: Config) -> None:
222 """Run the configured type checker(s) over the source folder.
224 The make recipe is a shell ``case`` with four branches, the fourth of which validates
225 the setting and errors. Validation moved to :meth:`Config.__post_init__`, so an
226 invalid value fails before a tool is provisioned, and what is left is a loop.
228 Args:
229 cfg: The resolved config.
230 """
231 checkers = ("ty", "mypy") if cfg.typechecker == "both" else (cfg.typechecker,)
232 for checker in checkers:
233 # The asymmetry is preserved from python.mk: mypy runs --strict, ty does not.
234 args = ("check", cfg.source_folder) if checker == "ty" else ("--strict", cfg.source_folder)
235 uv_run(checker, *args, cwd=cfg.root, withs=(checker,))
238@task(
239 "security",
240 "run the bandit security scan",
241 section="Python",
242 layer="python",
243 needs=("install",),
244 guards=(Guard("source_folder"),),
245)
246def security(cfg: Config) -> None:
247 """Scan the source folder with bandit.
249 The scan scope lives in ``.bandit`` rather than in this argument list, so that every
250 runner -- this task, the pre-commit hook, CI -- sees the same one. ``--ini`` is passed
251 only when that file exists: python.mk passes it unconditionally, and bandit treats a
252 missing ini as a usage error, so a project without one gets a red gate reporting a
253 configuration problem as if it were a security finding.
255 ``security`` does not mean the same thing in all three layers, and the asymmetry is
256 inherited rather than introduced here. Rust runs ``cargo deny check advisories`` and Go
257 runs ``govulncheck ./...`` -- both scan *dependencies* against an advisory database.
258 Bandit is SAST: it lints the source this repository owns and never looks at what is
259 installed. So Python, which has the largest advisory surface of the three, is the one
260 layer whose ``security`` gate is not a dependency scan.
262 No ``pip-audit`` here is a decision taken upstream, not an omission: ``jebel-quant/rhiza``
263 dropped it in #1416 along with rhiza-tools, and pins its absence with a test
264 (``tests/docs/test_doc_consistency.py`` -- "pip-audit is deliberately not wired up;
265 this pins the fact the gate depends on"). This module is owned by this repository and
266 nothing syncs it, so adding a scan here is *possible* -- but it would put a gate in
267 consumers' CI that the template they also follow says is not there, and a transitive
268 advisory with no fix available would then fail a run the template would have passed.
269 Closing the gap belongs upstream, where both halves move together. Recorded here so
270 the next reader does not have to rediscover which of the two it is.
272 What that argument covers is the *shipped task*, and it is worth being precise about the
273 limit, because the paragraph above used to be the only note on the subject and so read as
274 "nothing anywhere audits dependencies". This repository does audit its own: ``weekly.yml``
275 exports the committed lockfile and runs ``pip-audit`` over it on a schedule. Nothing about
276 that reaches a consumer -- no task name, no prerequisite of ``all``, nothing a
277 ``uvx rhiza-task`` invocation can find -- which is exactly why it is a workflow job and not
278 the two lines it would take to add here.
280 So the honest summary is that the gap is closed for this repository and open for consumers,
281 deliberately and in that order. If it is ever closed for consumers too, this is the place
282 that changes, and the note above is the argument that has to be answered first.
284 Args:
285 cfg: The resolved config.
286 """
287 ini = ("--ini", ".bandit") if (cfg.root / ".bandit").is_file() else ()
288 uvx("bandit", "-r", cfg.source_folder, "-ll", "-q", *ini, cwd=cfg.root)
291@task("deps", "run deptry over the contributed folders", section="Python", layer="python", needs=("install",))
292def deps(cfg: Config) -> None:
293 """Check declared dependencies against actual imports.
295 ``DEPTRY_FOLDERS`` and ``DEPTRY_IGNORE`` were make accumulators that each bundle
296 appended to, which worked only because of include order. Here the folder set is
297 *derived*: the source folder when it exists, plus the marimo folder when the marimo
298 tasks are registered and that folder exists. DEP004 (misplaced development dependency)
299 is ignored for the same reason marimo.mk ignores it -- notebooks legitimately import
300 development dependencies.
302 Args:
303 cfg: The resolved config.
305 Raises:
306 Skip: When no contributed folder exists.
307 """
308 folders = [cfg.source_folder] if cfg.path("source_folder").is_dir() else []
309 ignores = list(cfg.deptry_ignore)
310 if "marimo" in REGISTRY and cfg.path("marimo_folder").is_dir():
311 folders.append(cfg.marimo_folder)
312 ignores += ["--ignore", "DEP004"]
313 if not folders:
314 raise Skip("no deptry folders")
315 uvx("deptry", *folders, *ignores, cwd=cfg.root)
318@task("license", "scan for copyleft licences", section="Python", layer="python", needs=("install",))
319def license_(cfg: Config) -> None:
320 """Fail on GPL/LGPL/AGPL among the installed distributions.
322 ``--partial-match`` is load-bearing: without it pip-licenses compares against the whole
323 licence string, and ``GPL`` never equals a real classifier such as "GNU General Public
324 License v2 or later (GPLv2+)", so the gate passed with a GPL package installed.
326 The docutils exemption is derived rather than accumulated. marimo depends on docutils,
327 which is offered under a *choice* of licences and reports all of them as one string --
328 "BSD License; GNU General Public License (GPL); Public Domain". pip-licenses has no
329 notion of *or*, so ``--partial-match`` fires on the copyleft option even where a
330 permissive one is taken.
332 Args:
333 cfg: The resolved config.
334 """
335 ignored = list(cfg.license_ignore_packages)
336 if "marimo" in REGISTRY and "docutils" not in ignored:
337 ignored.append("docutils")
338 args = [f"--fail-on={';'.join(cfg.license_fail_on)}", "--partial-match"]
339 if ignored:
340 # --ignore-packages errors on a bare flag, so it is omitted entirely when nothing
341 # is exempted.
342 args += ["--ignore-packages", *ignored]
343 uv_run("pip-licenses", *args, cwd=cfg.root, withs=("pip-licenses",))
346@task(
347 "docs-coverage",
348 "check docstring coverage with interrogate",
349 section="Python",
350 layer="python",
351 needs=("install",),
352 guards=(Guard("source_folder"),),
353)
354def docs_coverage(cfg: Config) -> None:
355 """Require 100% docstring coverage over the source and test folders.
357 Args:
358 cfg: The resolved config.
359 """
360 folders = [f for f in (cfg.source_folder, cfg.tests_folder) if (cfg.root / f).is_dir()]
361 uv_run(
362 "interrogate",
363 "-vv",
364 "--fail-under",
365 "100",
366 "--ignore-init-method",
367 "--ignore-magic",
368 *folders,
369 cwd=cfg.root,
370 withs=("interrogate",),
371 )
374@task(
375 "complexity",
376 "fail on a block above the cyclomatic-complexity ceiling",
377 section="Quality",
378 layer="python",
379 guards=(Guard("source_folder"),),
380)
381def complexity(cfg: Config) -> None:
382 """Fail when any block's cyclomatic complexity exceeds :attr:`Config.complexity_max`.
384 The one task here that is not a python.mk port. It exists because this repository's own
385 convention -- a C-ranked block carries a comment arguing why the flat form is preferred
386 -- committed to a *number* in ``config.py``, and nothing read it back. A stated ceiling
387 that only a human checks is the same shape as a doctest no gate executes: correct today,
388 stale-proof only by discipline, in the one place growth is expected.
390 Why the report goes through a file rather than a pipe: radon's verdict is a number per
391 block, so the gate has to read its output, and ``-O`` is how radon hands output to
392 something other than a terminal. That keeps the invocation a fixed argument vector with
393 no shell and no capturing variant of :func:`~rhiza_task.uv.uvx` -- the same reason every
394 other call in this package is one.
396 ``closures`` is deliberately not walked. radon only fills it under ``--show-closures``,
397 which is not passed, so a nested function's complexity is already counted in its
398 parent's -- walking the empty list would suggest a coverage this gate does not have.
400 Args:
401 cfg: The resolved config.
403 Raises:
404 Skip: When radon produced no report, so nothing was measured.
405 Failed: When at least one block is above the ceiling.
406 """
407 report = cfg.root / "_tests" / "complexity.json"
408 report.parent.mkdir(parents=True, exist_ok=True)
409 # Stale data first, for the reason `test` unlinks `.coverage*`: a report left by an
410 # earlier run would be read as this run's verdict if radon failed to write.
411 report.unlink(missing_ok=True)
413 uvx("radon", "cc", cfg.source_folder, "--json", "--output-file", str(report), cwd=cfg.root)
414 if not report.is_file():
415 raise Skip("radon wrote no report")
417 over = _over_ceiling(json.loads(report.read_text()), cfg.complexity_max)
418 for label, score in over:
419 print(f"{label}: {score}")
420 if over:
421 raise Failed(1, f"{len(over)} block(s) above the complexity ceiling of {cfg.complexity_max}")
422 print(f"[INFO] no block above the complexity ceiling of {cfg.complexity_max}")
425def _over_ceiling(measured: dict[str, object], ceiling: int) -> list[tuple[str, int]]:
426 """Return the blocks above ``ceiling``, worst first.
428 radon keys its JSON by path, and the value is either a list of blocks or -- for a file
429 it could not parse -- a dict carrying an ``error``. The dict is skipped rather than
430 raised on: an unparseable file is ruff's finding to report, and failing the complexity
431 gate for it would put one syntax error behind two red gates.
433 Args:
434 measured: radon's parsed ``cc --json`` output.
435 ceiling: The highest complexity a block may have.
437 Returns:
438 ``(label, complexity)`` pairs, highest complexity first, then by label so the
439 ordering is total and the output is diffable between runs.
440 """
441 over: list[tuple[str, int]] = []
442 for path, blocks in measured.items():
443 if not isinstance(blocks, list):
444 continue
445 for block in blocks:
446 score = int(block["complexity"])
447 if score <= ceiling:
448 continue
449 qualified = f"{block['classname']}.{block['name']}" if block.get("classname") else block["name"]
450 over.append((f"{path}:{block['lineno']} {qualified}", score))
451 return sorted(over, key=lambda item: (-item[1], item[0]))
454# `complexity` is deliberately *not* a prerequisite below, and the reason is semver rather
455# than doubt about the gate. `all` is the aggregate every consumer's CI invokes, so adding a
456# prerequisite to it fails builds in repositories that changed nothing -- a breaking change
457# shipped as a feature. A consumer opts in by naming it, in `all`'s own `[tool.rhiza-task]`
458# repo or in a workflow step, and this repository does the latter in ci.yml's `gates` job.
459#
460# Stated here for the reason ci.yml states the same thing about `semgrep`: a reader has to
461# be able to tell "outside `all` on purpose" from "forgotten".
462@task(
463 "all",
464 "run every gate, as CI does",
465 section="Python",
466 layer="python",
467 needs=("fmt", "deps", "test", "docs-coverage", "security", "license", "typecheck", "rhiza-test"),
468)
469def all_(cfg: Config) -> None:
470 """Aggregate. The body is empty because ``needs`` *is* the definition.
472 python.mk's ``all`` named four gates that lived in the optional ``tests`` bundle, so a
473 project syncing ``core + python-core`` without it had an ``all`` that could not run.
474 Here an unregistered prerequisite is skipped by the runner, so the failure mode does
475 not exist.
477 Args:
478 cfg: Unused; the prerequisites do the work.
479 """