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

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

24 "pytest-cov", 

25 "pytest-xdist", 

26 "pytest-html", 

27 "pytest-timeout", 

28 "pytest-mock", 

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 

37PYTEST_INTERNAL_ERROR = 3 

38"""pytest's INTERNALERROR. 

39 

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

45 

46MAX_ATTEMPTS = 2 

47 

48 

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. 

55 

56 Args: 

57 cfg: The resolved config. 

58 

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

68 

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

70 raise Skip("no pyproject.toml") 

71 

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

80 

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) 

85 

86 install_hooks(cfg) 

87 

88 

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. 

99 

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

104 

105 Args: 

106 cfg: The resolved config. 

107 

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) 

113 

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

121 

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) 

129 

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

138 

139 

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. 

153 

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. 

159 

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. 

163 

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) 

171 

172 

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

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

175 

176 Args: 

177 cfg: The resolved config. 

178 

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 ] 

188 

189 

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

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

192 

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. 

196 

197 Args: 

198 cfg: The resolved config. 

199 

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 ] 

211 

212 

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. 

223 

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. 

227 

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

236 

237 

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. 

248 

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. 

254 

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. 

261 

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. 

271 

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. 

279 

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. 

283 

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) 

289 

290 

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. 

294 

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. 

301 

302 Args: 

303 cfg: The resolved config. 

304 

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) 

316 

317 

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. 

321 

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. 

325 

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. 

331 

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

344 

345 

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. 

356 

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 ) 

372 

373 

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

383 

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. 

389 

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. 

395 

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. 

399 

400 Args: 

401 cfg: The resolved config. 

402 

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) 

412 

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

416 

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

423 

424 

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

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

427 

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. 

432 

433 Args: 

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

435 ceiling: The highest complexity a block may have. 

436 

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

452 

453 

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. 

471 

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. 

476 

477 Args: 

478 cfg: Unused; the prerequisites do the work. 

479 """