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

1"""The Python language layer: python.mk, as tasks. 

2 

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. 

6 

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""" 

11 

12from __future__ import annotations 

13 

14import json 

15import shutil 

16 

17from ..config import Config 

18from ..spec import REGISTRY, Failed, Guard, Skip, task 

19from ..uv import uv, uv_run, uvx 

20from .quality import install_hooks 

21 

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. 

31 

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 

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. 

43 

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. 

49 

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""" 

54 

55PYTEST_INTERNAL_ERROR = 3 

56"""pytest's INTERNALERROR. 

57 

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""" 

63 

64MAX_ATTEMPTS = 2 

65 

66LOWEST_DIRECT = ("--isolated", "--resolution", "lowest-direct") 

67"""How ``test-lowest`` asks uv for the floors, and why it asks in an ephemeral environment. 

68 

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. 

72 

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""" 

80 

81 

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. 

88 

89 Args: 

90 cfg: The resolved config. 

91 

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}") 

101 

102 if not (cfg.root / "pyproject.toml").is_file(): 

103 raise Skip("no pyproject.toml") 

104 

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",) 

113 

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) 

118 

119 install_hooks(cfg) 

120 

121 

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. 

132 

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``. 

137 

138 Args: 

139 cfg: The resolved config. 

140 

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) 

146 

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") 

154 

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) 

162 

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}") 

171 

172 

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. 

186 

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. 

192 

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. 

196 

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) 

204 

205 

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. 

216 

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. 

220 

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. 

229 

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: 

232 

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. 

239 

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. 

243 

244 Args: 

245 cfg: The resolved config. 

246 

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 ) 

257 

258 

259def _pytest_args(cfg: Config) -> list[str]: 

260 """Return the arguments both pytest-running gates share. 

261 

262 Args: 

263 cfg: The resolved config. 

264 

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 ] 

274 

275 

276def coverage_args(cfg: Config) -> list[str]: 

277 """Return the ``--cov`` flags, including the Cobertura path the other layers write to. 

278 

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. 

282 

283 Args: 

284 cfg: The resolved config. 

285 

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 ] 

297 

298 

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. 

309 

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. 

313 

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. 

322 

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,)) 

335 

336 

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. 

347 

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. 

353 

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. 

360 

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. 

370 

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. 

378 

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. 

382 

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) 

388 

389 

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. 

393 

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. 

400 

401 Args: 

402 cfg: The resolved config. 

403 

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) 

415 

416 

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. 

420 

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. 

424 

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. 

430 

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",)) 

443 

444 

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. 

455 

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. 

459 

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 ) 

475 

476 

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`. 

486 

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. 

492 

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. 

498 

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. 

502 

503 Args: 

504 cfg: The resolved config. 

505 

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) 

515 

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") 

519 

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}") 

526 

527 

528def _over_ceiling(measured: dict[str, object], ceiling: int) -> list[tuple[str, int]]: 

529 """Return the blocks above ``ceiling``, worst first. 

530 

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. 

535 

536 Args: 

537 measured: radon's parsed ``cc --json`` output. 

538 ceiling: The highest complexity a block may have. 

539 

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])) 

555 

556 

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. 

574 

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. 

579 

580 Args: 

581 cfg: Unused; the prerequisites do the work. 

582 """