Coverage for src/rhiza_task/tasks/python.py: 100%
126 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-13 02:27 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-13 02:27 +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>=8.0.0",
24 "pytest-cov>=5.0.0",
25 "pytest-xdist>=3.5.0",
26 "pytest-html>=4.1.0",
27 "pytest-timeout>=2.2.0",
28 "pytest-mock>=3.12.0",
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.
36**The floors change nothing under the default resolution** -- uv takes the newest of each
37either way -- and they are not there for it. ``test-lowest`` runs these same six through
38``--resolution lowest-direct``, which applies to every *direct* requirement including the
39injected ones, and a bare ``pytest`` there resolves to the oldest release uv can see --
40``pytest 2.0.0``, whose sdist does not build -- so the gate fails on its own instrumentation
41without ever reaching the floors it was asked about. Each name therefore carries the oldest version this package is
42willing to run a suite with.
44They bound the *instrumentation* and nothing else, which is the boundary to know before
45reading a failure: ``--with`` is an overlay uv resolves separately from the project, so a
46floor here cannot rescue a bare name in the manifest under test -- and should not. A
47requirement a repository declares without a lower bound has no floor for this gate to read
48back, and saying so is the gate working.
50That makes them a claim, and the claim is read back rather than asserted: ``ci.yml``'s
51``python-layer`` runs ``test-lowest`` against a real project on Linux and Windows, which is
52where a floor that cannot actually run ``-n auto`` shows up.
53"""
55PYTEST_INTERNAL_ERROR = 3
56"""pytest's INTERNALERROR.
58Distinct from test failure (1), interruption (2) and usage error (4), which is what makes
59retrying on it safe: it means the *runner* broke during worker or session teardown -- the
60xdist ``worker_workerfinished`` KeyError, or a pytest-html report-write race -- not that a
61test failed.
62"""
64MAX_ATTEMPTS = 2
66LOWEST_DIRECT = ("--isolated", "--resolution", "lowest-direct")
67"""How ``test-lowest`` asks uv for the floors, and why it asks in an ephemeral environment.
69``--resolution lowest-direct`` is the gate itself: every direct requirement resolves to the
70oldest version its specifier allows and every transitive one to the newest, so what fails is
71a floor that was never true rather than a dependency somebody else moved.
73``--isolated`` is what keeps the answer free of charge. Without it uv re-resolves the
74*project* environment in place -- it says so, ``Ignoring existing lockfile due to change in
75resolution mode`` -- and leaves both ``.venv`` and ``uv.lock`` downgraded when the gate
76finishes. On a runner that is invisible; on a developer's machine it is a gate that rewrites
77a tracked file and silently changes what every later task runs against. An ephemeral
78environment costs one resolve per invocation and leaves nothing behind.
79"""
82# `setup` first, and in all three layers: a native library needed to *build* a wheel has to
83# be on the machine before `uv sync`, not after it. See tasks/setup.py for why the hook is
84# anchored on `install` rather than run by the workflows.
85@task("install", "create the venv and sync dependencies", section="Python", layer="python", needs=("setup",))
86def install(cfg: Config) -> None:
87 """Create ``.venv`` if absent, sync from the lock file, install the git hooks.
89 Args:
90 cfg: The resolved config.
92 Raises:
93 Skip: When the project has no ``pyproject.toml``.
94 Failed: When the lock file is out of sync, or a step exits non-zero.
95 """
96 venv = cfg.root / ".venv"
97 if not venv.is_dir():
98 uv("venv", "--python", cfg.python_version, str(venv), cwd=cfg.root)
99 else:
100 print(f"[INFO] using existing virtual environment at {venv}")
102 if not (cfg.root / "pyproject.toml").is_file():
103 raise Skip("no pyproject.toml")
105 frozen: tuple[str, ...] = ()
106 if (cfg.root / "uv.lock").is_file():
107 # python.mk runs this check, swallows its output and prints three lines of
108 # guidance on failure. The check is worth keeping; the guidance belongs with it
109 # rather than in a shell heredoc.
110 if uv("lock", "--check", cwd=cfg.root, check=False):
111 raise Failed(1, "uv.lock is out of sync with pyproject.toml -- run `uv lock`")
112 frozen = ("--frozen",)
114 # --inexact: leave packages uv did not manage in place instead of pruning them on
115 # every run, so repeated task invocations do not churn the environment. Per-task
116 # tooling is provisioned on the fly by uv.py, so there is no separate step for it.
117 uv("sync", *cfg.uv_sync_args, "--inexact", *frozen, cwd=cfg.root)
119 install_hooks(cfg)
122@task(
123 "test",
124 "run all tests",
125 section="Python",
126 layer="python",
127 needs=("install",),
128 guards=(Guard("tests_folder", glob="test_*.py", reason="no test files found"),),
129)
130def test(cfg: Config) -> None:
131 """Run the suite with coverage, retrying once on a pytest-internal teardown error.
133 This is the recipe that justifies a real language. In python.mk it is a 40-line shell
134 ``while :; do ... done`` inside a make recipe, with ``$$`` escaping on every variable,
135 ``set --`` used to build the argument list because make cannot hold an array, and the
136 retry condition spelled ``if [ $$status -ne 3 ]; then exit $$status; fi``.
138 Args:
139 cfg: The resolved config.
141 Raises:
142 Failed: When pytest reports test failures, or reports an internal error twice.
143 """
144 reports = cfg.root / "_tests"
145 shutil.rmtree(reports, ignore_errors=True)
147 args = [*_pytest_args(cfg)]
148 if cfg.path("source_folder").is_dir():
149 args += coverage_args(cfg)
150 else:
151 # Not a Skip: the tests exist and must run. Only coverage is unavailable.
152 print(f"[WARN] source folder '{cfg.source_folder}' not found; running without coverage")
153 args.append("--html=_tests/html-report/report.html")
155 for attempt in range(1, MAX_ATTEMPTS + 1):
156 # Stale data first: a crashed run can leave a corrupt .coverage file, which then
157 # reports a false 0% on the next run.
158 for stale in cfg.root.glob(".coverage*"):
159 stale.unlink(missing_ok=True)
160 (reports / "html-coverage").mkdir(parents=True, exist_ok=True)
161 (reports / "html-report").mkdir(parents=True, exist_ok=True)
163 code = uv_run("pytest", *args, cwd=cfg.root, withs=PYTEST_WITHS, check=False)
164 if code != PYTEST_INTERNAL_ERROR:
165 if code:
166 raise Failed(code, "tests failed")
167 return
168 if attempt == MAX_ATTEMPTS:
169 raise Failed(code, f"pytest reported an internal (teardown) error {attempt}x")
170 print(f"[WARN] pytest exited {code} (xdist teardown race); retrying {attempt + 1}/{MAX_ATTEMPTS}")
173@task(
174 "coverage",
175 "measure coverage and write _tests/coverage.xml",
176 section="Python",
177 layer="python",
178 needs=("install",),
179 guards=(
180 Guard("tests_folder", glob="test_*.py", reason="no test files found"),
181 Guard("source_folder"),
182 ),
183)
184def coverage(cfg: Config) -> None:
185 """Run the suite for its coverage reports.
187 python.mk has no ``coverage`` target: its ``test`` recipe carries the ``--cov`` flags,
188 so the Cobertura file CI uploads and ``book`` badges is a side effect of the test gate.
189 rust.mk and go.mk both name ``coverage`` separately, and the gate-parity contract lists
190 it for all three layers -- so the Python layer grows the name it was missing rather than
191 the other two losing it.
193 It is not a second test run in any meaningful sense: same suite, same floor, same
194 output path. What it buys is a caller that wants the report without asserting anything
195 about the HTML test report, and one name that means the same thing in all three layers.
197 Args:
198 cfg: The resolved config.
199 """
200 (cfg.root / "_tests" / "html-coverage").mkdir(parents=True, exist_ok=True)
201 for stale in cfg.root.glob(".coverage*"):
202 stale.unlink(missing_ok=True)
203 uv_run("pytest", *_pytest_args(cfg), *coverage_args(cfg), cwd=cfg.root, withs=PYTEST_WITHS)
206@task(
207 "test-lowest",
208 "run the tests against the oldest dependencies the manifest allows",
209 section="Python",
210 layer="python",
211 needs=("install",),
212 guards=(Guard("tests_folder", glob="test_*.py", reason="no test files found"),),
213)
214def test_lowest(cfg: Config) -> None:
215 """Run the suite with every direct dependency resolved to its declared floor.
217 A declared floor is a claim -- ``typer>=0.15`` says the package works with typer 0.15 --
218 and ``--frozen`` everywhere else means nothing reads it back: the lock file resolves
219 whatever is newest, so a floor can be years stale while every gate stays green.
221 **It is a task rather than a workflow step, and that is the whole of #169.** rhiza's
222 ``lowest-deps`` job called plain ``uv`` because there was nothing here to call, and a
223 caller that bypasses the task graph bypasses ``setup`` with it -- so a repository whose
224 ``local-setup.sh`` provisions a native binary got a green ``test`` and a red
225 ``lowest-deps`` on identical code, reporting a resolution problem that was really a
226 missing ``dot`` on the runner. Arriving through ``install`` puts this gate back inside
227 the single insertion point :mod:`rhiza_task.tasks.setup` documents, which is the whole
228 argument for anchoring the hook there.
230 Three things it shares rather than restates, because the job it replaces restated all
231 three by hand and the copy had already drifted by one flag:
233 * ``_pytest_args``, so the suite is selected the way ``test`` and ``coverage`` select it.
234 * :data:`PYTEST_WITHS`, so ``-n auto`` has an xdist to run under -- see that constant for
235 why its floors exist and why they are invisible anywhere else.
236 * :attr:`~rhiza_task.config.Config.uv_sync_args`, because "which of the manifest's
237 optional pieces count" is the same question ``install`` asks, and a repository that
238 answered it once has answered it here.
240 No coverage flags, deliberately. The subject is whether the suite *runs* at the floors,
241 and a coverage floor here would either report what ``test`` already reported or give this
242 gate a second way to go red that has nothing to do with resolution.
244 Args:
245 cfg: The resolved config.
247 Raises:
248 Failed: When pytest reports test failures.
249 """
250 uv_run(
251 "pytest",
252 *_pytest_args(cfg),
253 cwd=cfg.root,
254 withs=PYTEST_WITHS,
255 uv_args=(*LOWEST_DIRECT, *cfg.uv_sync_args),
256 )
259def _pytest_args(cfg: Config) -> list[str]:
260 """Return the arguments both pytest-running gates share.
262 Args:
263 cfg: The resolved config.
265 Returns:
266 Parallelism, and the two folders the testing extras own.
267 """
268 return [
269 "-n",
270 "auto",
271 f"--ignore={cfg.tests_folder}/benchmarks",
272 f"--ignore={cfg.tests_folder}/stress",
273 ]
276def coverage_args(cfg: Config) -> list[str]:
277 """Return the ``--cov`` flags, including the Cobertura path the other layers write to.
279 Shared by ``test`` and ``coverage`` so the two cannot drift: ``_tests/coverage.xml`` is
280 the file book.mk's badge step reads and CI uploads, and rust.mk and go.mk go out of
281 their way to write it at exactly that path.
283 Args:
284 cfg: The resolved config.
286 Returns:
287 The coverage flags.
288 """
289 return [
290 f"--cov={cfg.source_folder}",
291 "--cov-report=term",
292 "--cov-report=html:_tests/html-coverage",
293 "--cov-report=json:_tests/coverage.json",
294 "--cov-report=xml:_tests/coverage.xml",
295 f"--cov-fail-under={cfg.coverage_fail_under}",
296 ]
299@task(
300 "typecheck",
301 "run ty and/or mypy (typechecker = ty | mypy | both)",
302 section="Python",
303 layer="python",
304 needs=("install",),
305 guards=(Guard("source_folder"),),
306)
307def typecheck(cfg: Config) -> None:
308 """Run the configured type checker(s) over the source folder.
310 The make recipe is a shell ``case`` with four branches, the fourth of which validates
311 the setting and errors. Validation moved to :meth:`Config.__post_init__`, so an
312 invalid value fails before a tool is provisioned, and what is left is a loop.
314 **What each checker is provisioned *as* is a setting, and it has to be.** The ``--with``
315 here used to carry the bare name, so the gate ran whatever was newest at the moment it
316 ran -- and for a 0.0.x checker that is not a detail: one consumer saw a single warning
317 locally, where ``--with ty`` reused the ty their lock file supplied, and twenty-four
318 errors on a runner that had none to reuse, on the same commit. An unchanged repository
319 could go from green to red overnight, and two machines could disagree about it on the
320 same day. See #170, and ``ty_version`` in :mod:`rhiza_task.config` for why ty is pinned
321 by default and mypy is not.
323 Args:
324 cfg: The resolved config.
325 """
326 checkers = ("ty", "mypy") if cfg.typechecker == "both" else (cfg.typechecker,)
327 for checker in checkers:
328 # The asymmetry is preserved from python.mk: mypy runs --strict, ty does not.
329 args = ("check", cfg.source_folder) if checker == "ty" else ("--strict", cfg.source_folder)
330 # The second asymmetry, and this one is not python.mk's: `ty` is provisioned at an
331 # exact version and mypy is not. See `ty_version` in config.py for why -- both are
332 # settings, so a consumer needing the other answer changes it rather than the task.
333 requirement = f"ty{cfg.ty_version}" if checker == "ty" else f"mypy{cfg.mypy_version}"
334 uv_run(checker, *args, cwd=cfg.root, withs=(requirement,))
337@task(
338 "security",
339 "run the bandit security scan",
340 section="Python",
341 layer="python",
342 needs=("install",),
343 guards=(Guard("source_folder"),),
344)
345def security(cfg: Config) -> None:
346 """Scan the source folder with bandit.
348 The scan scope lives in ``.bandit`` rather than in this argument list, so that every
349 runner -- this task, the pre-commit hook, CI -- sees the same one. ``--ini`` is passed
350 only when that file exists: python.mk passes it unconditionally, and bandit treats a
351 missing ini as a usage error, so a project without one gets a red gate reporting a
352 configuration problem as if it were a security finding.
354 ``security`` does not mean the same thing in all three layers, and the asymmetry is
355 inherited rather than introduced here. Rust runs ``cargo deny check advisories`` and Go
356 runs ``govulncheck ./...`` -- both scan *dependencies* against an advisory database.
357 Bandit is SAST: it lints the source this repository owns and never looks at what is
358 installed. So Python, which has the largest advisory surface of the three, is the one
359 layer whose ``security`` gate is not a dependency scan.
361 No ``pip-audit`` here is a decision taken upstream, not an omission: ``jebel-quant/rhiza``
362 dropped it in #1416 along with rhiza-tools, and pins its absence with a test
363 (``tests/docs/test_doc_consistency.py`` -- "pip-audit is deliberately not wired up;
364 this pins the fact the gate depends on"). This module is owned by this repository and
365 nothing syncs it, so adding a scan here is *possible* -- but it would put a gate in
366 consumers' CI that the template they also follow says is not there, and a transitive
367 advisory with no fix available would then fail a run the template would have passed.
368 Closing the gap belongs upstream, where both halves move together. Recorded here so
369 the next reader does not have to rediscover which of the two it is.
371 What that argument covers is the *shipped task*, and it is worth being precise about the
372 limit, because the paragraph above used to be the only note on the subject and so read as
373 "nothing anywhere audits dependencies". This repository does audit its own: ``weekly.yml``
374 exports the committed lockfile and runs ``pip-audit`` over it on a schedule. Nothing about
375 that reaches a consumer -- no task name, no prerequisite of ``all``, nothing a
376 ``uvx rhiza-task`` invocation can find -- which is exactly why it is a workflow job and not
377 the two lines it would take to add here.
379 So the honest summary is that the gap is closed for this repository and open for consumers,
380 deliberately and in that order. If it is ever closed for consumers too, this is the place
381 that changes, and the note above is the argument that has to be answered first.
383 Args:
384 cfg: The resolved config.
385 """
386 ini = ("--ini", ".bandit") if (cfg.root / ".bandit").is_file() else ()
387 uvx("bandit", "-r", cfg.source_folder, "-ll", "-q", *ini, cwd=cfg.root)
390@task("deps", "run deptry over the contributed folders", section="Python", layer="python", needs=("install",))
391def deps(cfg: Config) -> None:
392 """Check declared dependencies against actual imports.
394 ``DEPTRY_FOLDERS`` and ``DEPTRY_IGNORE`` were make accumulators that each bundle
395 appended to, which worked only because of include order. Here the folder set is
396 *derived*: the source folder when it exists, plus the marimo folder when the marimo
397 tasks are registered and that folder exists. DEP004 (misplaced development dependency)
398 is ignored for the same reason marimo.mk ignores it -- notebooks legitimately import
399 development dependencies.
401 Args:
402 cfg: The resolved config.
404 Raises:
405 Skip: When no contributed folder exists.
406 """
407 folders = [cfg.source_folder] if cfg.path("source_folder").is_dir() else []
408 ignores = list(cfg.deptry_ignore)
409 if "marimo" in REGISTRY and cfg.path("marimo_folder").is_dir():
410 folders.append(cfg.marimo_folder)
411 ignores += ["--ignore", "DEP004"]
412 if not folders:
413 raise Skip("no deptry folders")
414 uvx("deptry", *folders, *ignores, cwd=cfg.root)
417@task("license", "scan for copyleft licences", section="Python", layer="python", needs=("install",))
418def license_(cfg: Config) -> None:
419 """Fail on GPL/LGPL/AGPL among the installed distributions.
421 ``--partial-match`` is load-bearing: without it pip-licenses compares against the whole
422 licence string, and ``GPL`` never equals a real classifier such as "GNU General Public
423 License v2 or later (GPLv2+)", so the gate passed with a GPL package installed.
425 The docutils exemption is derived rather than accumulated. marimo depends on docutils,
426 which is offered under a *choice* of licences and reports all of them as one string --
427 "BSD License; GNU General Public License (GPL); Public Domain". pip-licenses has no
428 notion of *or*, so ``--partial-match`` fires on the copyleft option even where a
429 permissive one is taken.
431 Args:
432 cfg: The resolved config.
433 """
434 ignored = list(cfg.license_ignore_packages)
435 if "marimo" in REGISTRY and "docutils" not in ignored:
436 ignored.append("docutils")
437 args = [f"--fail-on={';'.join(cfg.license_fail_on)}", "--partial-match"]
438 if ignored:
439 # --ignore-packages errors on a bare flag, so it is omitted entirely when nothing
440 # is exempted.
441 args += ["--ignore-packages", *ignored]
442 uv_run("pip-licenses", *args, cwd=cfg.root, withs=("pip-licenses",))
445@task(
446 "docs-coverage",
447 "check docstring coverage with interrogate",
448 section="Python",
449 layer="python",
450 needs=("install",),
451 guards=(Guard("source_folder"),),
452)
453def docs_coverage(cfg: Config) -> None:
454 """Enforce :attr:`Config.docs_coverage_fail_under` over the source and test folders.
456 The floor was the literal ``100`` until it became a setting; it still *defaults* to
457 100, so a repo that configures nothing is gated exactly as it was. The field's own
458 comment carries why it is now askable.
460 Args:
461 cfg: The resolved config.
462 """
463 folders = [f for f in (cfg.source_folder, cfg.tests_folder) if (cfg.root / f).is_dir()]
464 uv_run(
465 "interrogate",
466 "-vv",
467 "--fail-under",
468 str(cfg.docs_coverage_fail_under),
469 "--ignore-init-method",
470 "--ignore-magic",
471 *folders,
472 cwd=cfg.root,
473 withs=("interrogate",),
474 )
477@task(
478 "complexity",
479 "fail on a block above the cyclomatic-complexity ceiling",
480 section="Quality",
481 layer="python",
482 guards=(Guard("source_folder"),),
483)
484def complexity(cfg: Config) -> None:
485 """Fail when any block's cyclomatic complexity exceeds :attr:`Config.complexity_max`.
487 The one task here that is not a python.mk port. It exists because this repository's own
488 convention -- a C-ranked block carries a comment arguing why the flat form is preferred
489 -- committed to a *number* in ``config.py``, and nothing read it back. A stated ceiling
490 that only a human checks is the same shape as a doctest no gate executes: correct today,
491 stale-proof only by discipline, in the one place growth is expected.
493 Why the report goes through a file rather than a pipe: radon's verdict is a number per
494 block, so the gate has to read its output, and ``-O`` is how radon hands output to
495 something other than a terminal. That keeps the invocation a fixed argument vector with
496 no shell and no capturing variant of :func:`~rhiza_task.uv.uvx` -- the same reason every
497 other call in this package is one.
499 ``closures`` is deliberately not walked. radon only fills it under ``--show-closures``,
500 which is not passed, so a nested function's complexity is already counted in its
501 parent's -- walking the empty list would suggest a coverage this gate does not have.
503 Args:
504 cfg: The resolved config.
506 Raises:
507 Skip: When radon produced no report, so nothing was measured.
508 Failed: When at least one block is above the ceiling.
509 """
510 report = cfg.root / "_tests" / "complexity.json"
511 report.parent.mkdir(parents=True, exist_ok=True)
512 # Stale data first, for the reason `test` unlinks `.coverage*`: a report left by an
513 # earlier run would be read as this run's verdict if radon failed to write.
514 report.unlink(missing_ok=True)
516 uvx("radon", "cc", cfg.source_folder, "--json", "--output-file", str(report), cwd=cfg.root)
517 if not report.is_file():
518 raise Skip("radon wrote no report")
520 over = _over_ceiling(json.loads(report.read_text()), cfg.complexity_max)
521 for label, score in over:
522 print(f"{label}: {score}")
523 if over:
524 raise Failed(1, f"{len(over)} block(s) above the complexity ceiling of {cfg.complexity_max}")
525 print(f"[INFO] no block above the complexity ceiling of {cfg.complexity_max}")
528def _over_ceiling(measured: dict[str, object], ceiling: int) -> list[tuple[str, int]]:
529 """Return the blocks above ``ceiling``, worst first.
531 radon keys its JSON by path, and the value is either a list of blocks or -- for a file
532 it could not parse -- a dict carrying an ``error``. The dict is skipped rather than
533 raised on: an unparseable file is ruff's finding to report, and failing the complexity
534 gate for it would put one syntax error behind two red gates.
536 Args:
537 measured: radon's parsed ``cc --json`` output.
538 ceiling: The highest complexity a block may have.
540 Returns:
541 ``(label, complexity)`` pairs, highest complexity first, then by label so the
542 ordering is total and the output is diffable between runs.
543 """
544 over: list[tuple[str, int]] = []
545 for path, blocks in measured.items():
546 if not isinstance(blocks, list):
547 continue
548 for block in blocks:
549 score = int(block["complexity"])
550 if score <= ceiling:
551 continue
552 qualified = f"{block['classname']}.{block['name']}" if block.get("classname") else block["name"]
553 over.append((f"{path}:{block['lineno']} {qualified}", score))
554 return sorted(over, key=lambda item: (-item[1], item[0]))
557# `complexity` is deliberately *not* a prerequisite below, and the reason is semver rather
558# than doubt about the gate. `all` is the aggregate every consumer's CI invokes, so adding a
559# prerequisite to it fails builds in repositories that changed nothing -- a breaking change
560# shipped as a feature. A consumer opts in by naming it, in `all`'s own `[tool.rhiza-task]`
561# repo or in a workflow step, and this repository does the latter in ci.yml's `gates` job.
562#
563# Stated here for the reason ci.yml states the same thing about `semgrep`: a reader has to
564# be able to tell "outside `all` on purpose" from "forgotten".
565@task(
566 "all",
567 "run every gate, as CI does",
568 section="Python",
569 layer="python",
570 needs=("fmt", "deps", "test", "docs-coverage", "security", "license", "typecheck", "rhiza-test"),
571)
572def all_(cfg: Config) -> None:
573 """Aggregate. The body is empty because ``needs`` *is* the definition.
575 python.mk's ``all`` named four gates that lived in the optional ``tests`` bundle, so a
576 project syncing ``core + python-core`` without it had an ``all`` that could not run.
577 Here an unregistered prerequisite is skipped by the runner, so the failure mode does
578 not exist.
580 Args:
581 cfg: Unused; the prerequisites do the work.
582 """