Coverage for src/rhiza_task/config.py: 100%

181 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-10-13 02:27 +0000

1"""Configuration, and the resolution order that replaces make's ``?=`` and ``+=``. 

2 

3The make layer builds its settings from three overlapping mechanisms: ``?=`` defaults in 

4the fragment that owns a setting, ``+=`` accumulation from other fragments 

5(``DEPTRY_FOLDERS``, ``LICENSE_IGNORE_PACKAGES``, ``RHIZA_CHECKS``), and a repo-owned 

6Makefile or ``local.mk`` assigning over the top. The precedence is a consequence of 

7include order, which is why rhiza.mk has to explain that ``-include .rhiza/make.d/*.mk`` 

8comes last and ``-include local.mk`` last of all. 

9 

10Here the order is explicit and testable, lowest precedence first: 

11 

121. The dataclass defaults below. 

132. ``.rhiza/.env`` -- kept unchanged, because it is already the file consumers edit and 

14 the reusable workflows read it too. Now a developer-local channel rather than a 

15 committed one: rhiza no longer ships ``.rhiza/.gitignore``, whose entire content was 

16 the ``!.env`` negation that kept this file tracked, so it falls under the shipped 

17 ``.gitignore``'s ``.env`` rule and a CI checkout never contains it. 

183. ``rhiza.toml`` -- the language-neutral settings file, and the only committed one a Go 

19 module can have: it has no manifest to hide a table in. Read for every project, so a 

20 polyglot repository has one place to look rather than one per layer. 

214. ``[tool.rhiza-task]`` in the language manifest -- ``Cargo.toml``, then 

22 ``pyproject.toml``. This is the new home for what used to require editing a synced 

23 ``.mk`` file or shadowing a target. Cargo ignores unknown top-level tables, so the 

24 table is as harmless there as it is in pyproject. 

255. ``RHIZA_*`` (or bare make-style) environment variables. 

266. Command-line flags. 

27 

28Layers 3 and 4 are two files rather than one because neither alone covers the three 

29language layers: pyproject is Python-only, and a repo that already moved its settings 

30there should not have to move them again. ``rhiza.toml`` ranks *below* the manifest so 

31that adding it to a Python repo cannot silently outrank the table already there. 

32 

33The ``+=`` accumulators do not survive as a mechanism, and do not need to: every one of 

34them was a bundle contributing something it owned, which the task body can now *derive* 

35by asking whether the contributing task is registered. See ``tasks/python.py``'s ``deps`` 

36and ``license``. 

37 

38**This module used to be the one that ranks B on maintainability** -- see issues #153 and 

39#156, which are worth reading together because the second corrects the first. The blocks 

40that put it there are ``Config.load``'s six-layer walk and ``_coerce``'s dispatch on value 

41shape, both flat by choice: the walk *is* the precedence order this docstring spends thirty 

42lines explaining, and reading it as a sequence of ``raw.update`` calls is the point. 

43 

44#153 recorded that as deliberate and gated the rank so the figure was read back by something. 

45#156 found the gate was measuring the wrong thing. radon's MI counts length and comments 

46count as length, so **writing the note that explained the ceiling moved the figure down** -- 

471.78 points for 19 lines of prose, with no branch added or removed. Worse, MI's verdict did 

48not agree with any measure of complexity: it ranked this module B and ``tasks/fences.py`` A, 

49while fences.py carries *more* blocks at rank B or worse and the same total. 

50 

51So the ceiling is now an accumulation count -- blocks at CC >= 6, per module -- in 

52``.github/scripts/accumulation_ceiling.py``, run from ``ci.yml``'s ``gates`` job. It measures 

53what MI was standing in for and no amount of prose can move it. The rank of this module is no 

54longer gated at all, which is the honest outcome: it was never the complexity signal it 

55looked like. 

56""" 

57 

58from __future__ import annotations 

59 

60import json 

61import os 

62import tomllib 

63from collections.abc import Callable, Mapping, Sequence 

64from dataclasses import dataclass, field, fields 

65from pathlib import Path 

66from typing import Any 

67 

68from dotenv import dotenv_values 

69 

70TYPECHECKERS = ("ty", "mypy", "both") 

71 

72LAYERS = ("python", "rust", "go") 

73"""The language layers, in the order :func:`~rhiza_task.spec.lookup` tries them. 

74 

75Python first because it is the layer a polyglot repository is most likely to have grown 

76*into* -- a crate or a module that acquires a pyproject has acquired a Python package, and 

77the gates that package needs are the ones that would otherwise stop running. 

78""" 

79 

80LAYER_MANIFESTS = {"python": "pyproject.toml", "rust": "Cargo.toml", "go": "go.mod"} 

81"""What makes a repository a member of a layer. 

82 

83The make layer answered this at sync time -- exactly one of python.mk, rust.mk and go.mk 

84was ever synced into a repo, and `rhiza.mk`'s ``-include`` did the rest. A pinned CLI 

85carries all three, so the question moves to runtime, and the manifest is the honest 

86answer: it is what the toolchain itself looks for. 

87""" 

88 

89NEUTRAL_RHIZA_CHECKS = ( 

90 "pytest_rhiza.checks.test_readme", 

91 "pytest_rhiza.checks.test_release_tags", 

92 "pytest_rhiza.checks.test_readme_validation", 

93) 

94"""The checks every repository gets, whatever it is written in.""" 

95 

96LAYER_RHIZA_CHECKS = { 

97 "python": ("pytest_rhiza.checks.test_pyproject", "pytest_rhiza.checks.test_docstrings"), 

98 "rust": ("pytest_rhiza.checks.test_cargo_toml",), 

99 "go": ("pytest_rhiza.checks.test_go_module",), 

100} 

101"""What each layer contributes, enumerated rather than globbed. 

102 

103pytest-rhiza ships all three layers' modules in one distribution, so 

104``--pyargs pytest_rhiza.checks`` would collect checks that cannot pass -- ``test_go_module`` 

105against a Python project asserts a ``go.mod`` that is not there. In the make layer each 

106language fragment appended its own with ``RHIZA_CHECKS +=``; here the accumulator is 

107replaced by the same derivation the ``+=`` was standing in for, from the layer set rather 

108than from include order. 

109""" 

110 

111DEFAULT_RHIZA_CHECKS = NEUTRAL_RHIZA_CHECKS + LAYER_RHIZA_CHECKS["python"] 

112"""The Python resolution, kept as a name because it is the set consumers know. 

113 

114This is jointview's ``RHIZA_CHECKS`` list, promoted from a shadowed make variable to a 

115default: the 60-line override in its Makefile exists only because the make layer had 

116nowhere else to put it. 

117""" 

118 

119 

120def rhiza_checks_for(layers: Sequence[str]) -> tuple[str, ...]: 

121 """Return the check set for a repository's layers. 

122 

123 Args: 

124 layers: The active layers. 

125 

126 Returns: 

127 The neutral checks followed by each layer's own, in layer order, deduplicated. 

128 """ 

129 checks = list(NEUTRAL_RHIZA_CHECKS) 

130 for layer in layers: 

131 checks += [c for c in LAYER_RHIZA_CHECKS.get(layer, ()) if c not in checks] 

132 return tuple(checks) 

133 

134 

135def detect_layers(root: Path) -> tuple[str, ...]: 

136 """Return the language layers a repository belongs to, by its manifests. 

137 

138 Args: 

139 root: Repository root. 

140 

141 Returns: 

142 The layers whose manifest is present, in :data:`LAYERS` order; ``("python",)`` 

143 when a repository has none, because that is what every gate assumed before there 

144 was a choice, and a repo with no manifest at all has nothing for another layer's 

145 gates to measure either. 

146 """ 

147 found = tuple(layer for layer in LAYERS if (root / LAYER_MANIFESTS[layer]).is_file()) 

148 return found or ("python",) 

149 

150 

151DEFAULT_CI_OS_MATRIX = ("ubuntu-latest",) 

152"""The OS every consumer gets unless it asks for more. 

153 

154Named rather than inlined because two callers need the same value: the field default 

155below, and the floor in ``rhiza-task ci-os-matrix`` that stops an explicitly empty 

156setting reaching GitHub as a zero-job matrix. 

157""" 

158 

159 

160@dataclass 

161class Config: 

162 """Resolved settings for one repository. 

163 

164 Field names are the lowercased make variables, so the mapping to what a consumer 

165 already knows stays one-to-one and greppable. 

166 """ 

167 

168 source_folder: str = "src" 

169 tests_folder: str = "tests" 

170 # The prose documentation tree, which ``docs-examples`` checks the fenced examples in. 

171 # A setting rather than a literal ``docs`` for the reason every other folder here is 

172 # one: ``marimo_folder`` and ``paper_folder`` already default to paths *inside* it, so a 

173 # repo that keeps its documentation somewhere else would otherwise have to move two 

174 # settings and hardcode the third. 

175 docs_folder: str = "docs" 

176 marimo_folder: str = "docs/notebooks" 

177 book_output: str = "_book" 

178 python_version: str = "3.13" 

179 

180 coverage_fail_under: int = 90 

181 

182 # The floor ``docs-coverage`` hands interrogate. 100 because a docstring is present or 

183 # it is not -- unlike a line of code, which can be covered by a test that asserts 

184 # nothing -- so there is no honest reason to aim lower in a repo starting from scratch. 

185 # 

186 # A setting rather than the literal it was, because the repos adopting this are not 

187 # starting from scratch. A codebase arriving at 89% has one gate it cannot pass and no 

188 # way to say so, and the two answers left were both worse than a number: write 

189 # docstrings for every nested helper in one sitting, or shadow the whole recipe in 

190 # ``local.mk`` -- which is CI-invisible, so the gate then passes locally and fails in 

191 # CI. Both were observed on the same upgrade; see jebel-quant/rhiza#1654. 

192 # 

193 # Lowering it is a ratchet to raise again, not a destination. The default is unchanged, 

194 # so a repo that says nothing is gated exactly as before. 

195 docs_coverage_fail_under: int = 100 

196 

197 # The ceiling the ``complexity`` gate enforces, as radon's own cyclomatic-complexity 

198 # number rather than its A-F rank. A number and not a rank because rank C spans 11-20, 

199 # which is too coarse to hold a decision: this repository's four deliberate C blocks 

200 # sit at 12-14, and a gate that accepted the whole of C would accept 20 without anyone 

201 # choosing it. 15 is what src/rhiza_task/config.py's own note commits to. 

202 # 

203 # Above the default rather than at it is the normal case for a consumer: 15 is a 

204 # ceiling for a codebase that already argues its C blocks in comments, and a repo that 

205 # does not should either raise it or not run the gate. 

206 complexity_max: int = 15 

207 

208 # The ref ``update`` moves .rhiza/template.yml to, e.g. ``v1.8.0``. Empty means "sync at 

209 # the ref the file already names", which is the re-sync case rather than the upgrade one. 

210 # 

211 # A setting and not a flag because a task body takes a Config and nothing else -- see 

212 # spec.Task.run. That is not a workaround: the environment layer already spells it 

213 # (``TEMPLATE_REF=v1.8.0 rhiza-task update``), and a repo pinning its next target in 

214 # pyproject.toml is a use the flag would not have served. 

215 template_ref: str = "" 

216 

217 # ty | mypy | both. python.mk documents that ``both`` masks ty's exit status behind 

218 # mypy's, and jointview sets ``ty`` in .rhiza/.env for that reason. The shell ``case`` 

219 # whose fourth branch validated this is replaced by __post_init__, so a typo now fails 

220 # before any tool is provisioned rather than after. 

221 typechecker: str = "ty" 

222 

223 # What ``typecheck`` provisions each checker *as*: appended to the checker's own name, so 

224 # `==0.0.78` becomes `--with ty==0.0.78`. The shape `zensical_version` already uses, and 

225 # an empty value means the bare name -- whatever is newest, which is what both settings 

226 # replace. 

227 # 

228 # ty is pinned and mypy is not, and the asymmetry belongs to the checkers rather than to 

229 # this package. ty is 0.0.x and its diagnostics change between patch releases, so an 

230 # unpinned `--with ty` makes the verdict a fact about the day the job ran: a consumer 

231 # migrating to this CLI saw one warning locally, where the project's lock supplied ty 

232 # 0.0.18 for `--with ty` to reuse, and twenty-four errors on a runner that had no ty to 

233 # reuse -- same commit, different checker. mypy is post-1.0 and moves slowly enough that 

234 # the same pin would mostly buy staleness. See jebel-quant/rhiza-task#170. 

235 # 

236 # Moving the pin is a deliberate edit here, like `pytest-benchmark==5.2.3` in 

237 # tasks/extras.py: both are places where the tool's *output* is the gate's meaning, so 

238 # what version produced it is part of the gate. A consumer wanting a different ty sets 

239 # this rather than forking the task; one wanting the newest sets it empty, which is a 

240 # manifest-only spelling for the reason `pytest_rhiza` above documents -- `_from_environ` 

241 # and `_from_env_file` drop an empty value as unset, and only TOML tells an empty string 

242 # from an absent key. 

243 ty_version: str = "==0.0.78" 

244 mypy_version: str = "" 

245 

246 # Matched as substrings -- see ``--partial-match`` in the ``license`` task. 

247 license_fail_on: tuple[str, ...] = ("GPL", "LGPL", "AGPL") 

248 license_ignore_packages: tuple[str, ...] = () 

249 

250 deptry_ignore: tuple[str, ...] = () 

251 

252 # rust.mk's CARGO_FLAGS and go.mk's GO_FLAGS / GO_TEST_FLAGS. `-race` and `-shuffle=on` 

253 # are go.mk's default: the Go idiom for a CI run, and the flag that catches tests 

254 # depending on declaration order. 

255 cargo_flags: tuple[str, ...] = () 

256 go_flags: tuple[str, ...] = () 

257 go_test_flags: tuple[str, ...] = ("-race", "-shuffle=on") 

258 # Non-empty on purpose, and it pairs with ``zensical_version`` below: this setting is 

259 # the other half of "what the book gate provisions", and only one half was ever set. 

260 # 

261 # rhiza's ``book`` bundle ships ``docs/mkdocs-base.yml`` with ``mkdocstrings`` enabled 

262 # unconditionally, and every consumer inherits it via ``INHERIT``. With this empty, 

263 # ``book`` invoked ``uvx zensical build`` with no ``--with``, and zensical refused: 

264 # 

265 # Error: mkdocstrings plugin is enabled, but mkdocstrings is not installed. 

266 # 

267 # So the bundle shipped a config that could not build with its own default. The default 

268 # belongs here rather than in the bundle because a bundle has nowhere to put it: core 

269 # ships no ``.rhiza/.env`` (jebel-quant/rhiza#1545 deleted it), ``pyproject.toml`` is 

270 # repo-owned, and ``_from_manifest`` reads only pyproject/Cargo -- so a ``book`` + 

271 # ``go-core`` repo has no TOML surface at all. This is the one layer every consumer has. 

272 # 

273 # A repo that wants no plugins sets ``mkdocs-extra-packages = []`` in its manifest. 

274 # That is TOML-only, deliberately -- see :func:`_from_env_file`. 

275 mkdocs_extra_packages: tuple[str, ...] = ("mkdocstrings[python]",) 

276 

277 # The five bundle-owned fragments' settings. docker.mk's DOCKER_FOLDER was a `:=` 

278 # rather than a `?=` -- not configurable at all -- and paper.mk hard-coded the 

279 # PRESENTATION.md equivalent; both are ordinary settings here, because there is no 

280 # longer a cost to making one. 

281 docker_folder: str = "docker" 

282 # Empty rather than a computed default: docker.mk's `?= $(shell basename $(CURDIR))` 

283 # cannot be spelled as a dataclass default, and resolving it in the task body keeps 

284 # `rhiza-task print docker_image` honest about the setting being unset. 

285 docker_image: str = "" 

286 paper_folder: str = "docs/paper" 

287 presentation_file: str = "PRESENTATION.md" 

288 # Unpinned, because `npm install -g @marp-team/marp-cli` was too: presentation.mk 

289 # installed whatever latest resolved to. Set it to `@marp-team/marp-cli@4.2.3` to pin. 

290 marp_package: str = "@marp-team/marp-cli" 

291 zensical_version: str = ">=0.0.36" 

292 uv_sync_args: tuple[str, ...] = ("--all-extras", "--all-groups") 

293 ci_os_matrix: tuple[str, ...] = DEFAULT_CI_OS_MATRIX 

294 

295 # Whether rhiza's reusable book workflow publishes to GitHub Pages. No task here reads 

296 # it -- `ci_os_matrix` already had that shape, and for the same reason: a workflow needs 

297 # a *repo-owned* answer, and this is the only config surface every consumer has. 

298 # 

299 # The alternative was the one it replaces. `rhiza_book.yml` takes a `deploy-pages` 

300 # input, so the choice could only be spelled in the caller -- which for a rhiza consumer 

301 # is the `github-book` bundle's workflow stub, a template-owned file. Editing it works, 

302 # is reviewed, is merged, and is overwritten by the next sync; rhiza-hooks' 

303 # `check-managed-files` now refuses the commit outright. So the toggle existed and had 

304 # nowhere to live short of excluding the file from management. 

305 # 

306 # Defaults true, which is what the input already defaulted to: a repo that says nothing 

307 # deploys exactly as before. 

308 deploy_pages: bool = True 

309 

310 # Pinned to a tag rather than a branch: a gate that moves under you is not a gate. 

311 # 

312 # Set it **empty** and `rhiza-test` passes no `--with` at all, resolving pytest-rhiza 

313 # from the project environment instead -- the way to try an unreleased check against a 

314 # real subject without publishing one first. Like `mkdocs_extra_packages` above, that is 

315 # a manifest-only spelling: `_from_environ` and `_from_env_file` drop an empty value as 

316 # unset, and only TOML tells an empty string from an absent key. See 

317 # `tasks/quality.py`'s `_provider`, which also records why `"."` is not the shorthand it 

318 # looks like. 

319 pytest_rhiza: str = "pytest-rhiza @ git+https://github.com/Jebel-Quant/pytest-rhiza@v0.6.0" 

320 

321 # Both are empty by default and filled in `_validate_layers`, because both depend on 

322 # the repository rather than on a constant: the layers come from the manifests present, 

323 # and the check set follows from the layers. Setting either explicitly -- in 

324 # pyproject.toml, or RHIZA_LAYERS=rust -- switches detection off for that field, which 

325 # is what a repository carrying two manifests and wanting one gate set needs. 

326 layers: tuple[str, ...] = () 

327 rhiza_checks: tuple[str, ...] = () 

328 

329 # Turns Skip into failure. The answer to jointview's own complaint about "a green gate 

330 # measuring nothing": set it in CI and a missing folder is a red build rather than a 

331 # yellow line nobody reads. 

332 strict: bool = False 

333 

334 root: Path = field(default_factory=Path.cwd) 

335 

336 # A flat sequence of calls, one per group of settings, and so A (1). It was C (13) -- 

337 # one branch per validated field -- carrying a comment that named **C (15)** as the 

338 # point where the flat form stopped paying for itself, and per-group helpers as the 

339 # answer. #124 honoured that ceiling at two branches of headroom instead of waiting for 

340 # `rhiza-task complexity` to report it, and the shape below is the one that comment 

341 # named: the readable one-field-per-step order survives, each step just bounded. 

342 # 

343 # The helpers are **methods**, which the `Guard`/`_clauses` precedent would have argued 

344 # against -- on the grounds that radon scores a class as the *sum* of its methods, so a 

345 # new method relocates the figure rather than reducing it. That premise is wrong: radon 

346 # scores a class as the **mean** of its methods, so a small method *lowers* it. A 

347 # two-method probe under `uvx radon cc -s` is the check -- a lone method of 5 scores its 

348 # class 6, and adding a second of 1 scores it 4 -- and `Config` here went B (6) -> A (4) 

349 # rather than up. What survives of that precedent is the *other* half, which was never 

350 # about radon: `_clauses` is a module-level generator because it needs no `self` and 

351 # because laziness preserves the guards' evaluation order. These five need `self` and 

352 # have no order to protect, so they are methods. 

353 def __post_init__(self) -> None: 

354 """Normalise the list fields, then validate the enumerated and numeric ones. 

355 

356 Each step is a helper, so this method's own branch count is zero and the ceiling the 

357 history above describes no longer applies to it. A new validated setting adds a call 

358 here and its branches to its own helper -- which is what makes "one branch per 

359 validated setting" stop being an open-ended growth rule. 

360 

361 Raises: 

362 ValueError: When ``typechecker`` is not one of ty, mypy, both, either of 

363 ``coverage_fail_under`` and ``docs_coverage_fail_under`` is outside 0-100, 

364 ``complexity_max`` is below 1, 

365 ``layers`` names a layer that does not exist, :attr:`root` is not an 

366 existing directory, or a ``*_folder`` escapes it. Each helper below raises 

367 for its own settings and documents the message it uses. 

368 """ 

369 self._coerce_sequence_fields() 

370 self._validate_layers() 

371 self._validate_typechecker() 

372 self._validate_coverage() 

373 self._validate_complexity_max() 

374 # Order matters, and only between these two: `_validate_folders` asks whether a 

375 # setting escapes the root, which presupposes there is a root to escape. 

376 self._validate_root() 

377 self._validate_folders() 

378 

379 def _coerce_sequence_fields(self) -> None: 

380 """Turn a ``str`` or ``list`` on a ``tuple[str, ...]`` field into a tuple. 

381 

382 :func:`_coerce` sees a string without knowing which field it is destined for, so it 

383 can only recognise the two shapes that announce themselves -- a JSON array and a 

384 ``;``-separated list. Everything else it leaves as a ``str``, and a ``str`` reaching 

385 a ``tuple[str, ...]`` field is splatted one *character* per argument at the call 

386 site: ``UV_SYNC_ARGS="--group test"`` became ``uv sync - - g r o u p``. 

387 

388 The field's type is known here and nowhere lower, so this is where a string becomes 

389 a tuple. Splitting on whitespace is the make layer's own format -- python.mk 

390 documented ``LICENSE_IGNORE_PACKAGES`` as space-separated and ``RHIZA_CHECKS`` 

391 accumulated space-separated module names -- so a ``.rhiza/.env`` written for make 

392 keeps working, as does the ``UV_SYNC_ARGS`` that rhiza's synced 

393 ``.devcontainer/bootstrap.sh`` exports. 

394 """ 

395 for f in fields(self): 

396 if str(f.type).replace(" ", "") != "tuple[str,...]": 

397 continue 

398 value = getattr(self, f.name) 

399 if isinstance(value, str): 

400 object.__setattr__(self, f.name, tuple(value.split())) 

401 elif isinstance(value, list): 

402 object.__setattr__(self, f.name, tuple(value)) 

403 

404 def _validate_layers(self) -> None: 

405 """Default :attr:`layers` and :attr:`rhiza_checks` from the repository, then check them. 

406 

407 Both are empty by default and filled here, because both depend on the repository 

408 rather than on a constant. Setting either explicitly switches detection off for that 

409 field, which is what a repository carrying two manifests and wanting one gate set 

410 needs -- so the defaulting is conditional and the membership check is not. 

411 

412 Raises: 

413 ValueError: When ``layers`` names a layer that does not exist. 

414 """ 

415 if not self.layers: 

416 object.__setattr__(self, "layers", detect_layers(self.root)) 

417 unknown = [layer for layer in self.layers if layer not in LAYERS] 

418 if unknown: 

419 msg = f"unknown layer(s) {', '.join(unknown)}; known: {', '.join(LAYERS)}" 

420 raise ValueError(msg) 

421 if not self.rhiza_checks: 

422 object.__setattr__(self, "rhiza_checks", rhiza_checks_for(self.layers)) 

423 

424 def _validate_typechecker(self) -> None: 

425 """Reject a :attr:`typechecker` outside the enumerated set. 

426 

427 Raises: 

428 ValueError: When ``typechecker`` is not one of ty, mypy, both. 

429 """ 

430 if self.typechecker not in TYPECHECKERS: 

431 msg = f"typechecker must be one of {', '.join(TYPECHECKERS)} (got {self.typechecker!r})" 

432 raise ValueError(msg) 

433 

434 def _validate_coverage(self) -> None: 

435 """Reject a coverage floor that is not a percentage. 

436 

437 Both floors are checked in one loop rather than in two helpers, because they are 

438 the same validation and not merely a similar one: whatever a third percentage 

439 setting turns out to be, it joins the tuple and adds no branch here. That is the 

440 ceiling this helper commits to -- a name, never an ``if``. 

441 

442 Raises: 

443 ValueError: When ``coverage_fail_under`` or ``docs_coverage_fail_under`` is 

444 outside 0-100. 

445 """ 

446 for name in ("coverage_fail_under", "docs_coverage_fail_under"): 

447 value = getattr(self, name) 

448 if not 0 <= int(value) <= 100: 

449 msg = f"{name} must be a percentage (got {value!r})" 

450 raise ValueError(msg) 

451 

452 def _validate_complexity_max(self) -> None: 

453 """Reject a :attr:`complexity_max` below 1. 

454 

455 A ceiling of 0 would fail every block including the empty ones, which is a typo 

456 rather than a very strict policy. 

457 

458 Raises: 

459 ValueError: When ``complexity_max`` is below 1. 

460 """ 

461 if int(self.complexity_max) < 1: 

462 msg = f"complexity_max must be at least 1 (got {self.complexity_max!r})" 

463 raise ValueError(msg) 

464 

465 def _validate_root(self) -> None: 

466 """Reject a :attr:`root` that is not an existing directory. 

467 

468 Every other setting is validated here and this one was not, so a mistyped ``--root`` 

469 travelled all the way into a task body and surfaced as whatever the first tool did 

470 with a working directory that is not there: ``FileNotFoundError`` from 

471 ``subprocess._execute_child`` for a gate that shells out, ``NotADirectoryError`` from 

472 ``Path._scandir`` for one that walks the tree. Both are tracebacks through a private 

473 stdlib frame, and neither names the flag the user got wrong. 

474 

475 The two cases are separated because they are different mistakes: a path that is not 

476 there is usually a typo, and a path that is a file is usually a missing ``dirname``. 

477 

478 Deliberately not folded into :meth:`_validate_folders`. That method asks whether a 

479 *setting* escapes the root, which presupposes there is a root to escape -- so this 

480 has to run first, and merging them would make one message answer two questions. 

481 

482 Raises: 

483 ValueError: When ``root`` does not exist, or exists and is not a directory. 

484 """ 

485 if not self.root.is_dir(): 

486 reason = "is not a directory" if self.root.exists() else "does not exist" 

487 msg = f"root {str(self.root)!r} {reason}" 

488 raise ValueError(msg) 

489 

490 def _validate_folders(self) -> None: 

491 """Reject a ``*_folder`` setting that resolves outside :attr:`root`. 

492 

493 Not a sandbox: the folder settings arrive from ``.rhiza/.env``, ``rhiza.toml``, the 

494 manifest and the environment, which sit at the same trust level as the code they 

495 configure. It is the containment the enumerated fields above already get. 

496 ``SOURCE_FOLDER=../../elsewhere`` silently points a gate at a different checkout, 

497 and that is a typo far more often than an intention -- so it should say the field's 

498 name rather than run and report on somebody else's tree. 

499 

500 Both sides are resolved because ``root`` itself is routinely a symlink -- macOS 

501 ``/tmp``, and every :func:`tempfile.mkdtemp` under it -- and comparing a resolved 

502 child against an unresolved parent would reject every folder in such a checkout. 

503 

504 Raises: 

505 ValueError: When a folder setting escapes ``root``. 

506 """ 

507 root = self.root.resolve() 

508 for name, value in self.folders.items(): 

509 if not (root / value).resolve().is_relative_to(root): 

510 msg = f"{name} must stay inside the repository root (got {value!r})" 

511 raise ValueError(msg) 

512 

513 @property 

514 def folders(self) -> dict[str, str]: 

515 """Return the folder settings, for :meth:`~rhiza_task.spec.Guard.check`. 

516 

517 Returns: 

518 Mapping of field name to configured relative path. 

519 """ 

520 return {f.name: getattr(self, f.name) for f in fields(self) if f.name.endswith("_folder")} 

521 

522 def path(self, folder_field: str) -> Path: 

523 """Resolve a folder field to an absolute path. 

524 

525 No containment check here: :meth:`_validate_folders` did it once at construction, 

526 so every field this resolves is already known to stay under ``root``. 

527 

528 Args: 

529 folder_field: A field name such as ``source_folder``. 

530 

531 Returns: 

532 The absolute path. 

533 """ 

534 # The annotation is load-bearing under `mypy --strict`: `getattr` is typed to return 

535 # `Any`, `Path / Any` is `Any` too, and returning that from a `-> Path` function is 

536 # what `no-any-return` reports. Naming the type here is also the honest spelling -- 

537 # every field this reaches ends in `_folder` and holds a `str`, which is the same 

538 # assumption `folders` above encodes in its `dict[str, str]`. 

539 value: str = getattr(self, folder_field) 

540 return self.root / value 

541 

542 @staticmethod 

543 def field_for(name: str) -> str: 

544 """Normalise a make-style variable name to a field name. 

545 

546 Public because the spelling rule is not private to the layer readers below: the 

547 ``print`` command has to answer for ``SOURCE_FOLDER`` exactly as ``.rhiza/.env`` 

548 does, and a second normaliser written against the same rule is a second thing to 

549 keep in step. A caller outside this module asking "which field is this?" is asking 

550 :class:`Config`, so it is spelled as a question :class:`Config` can be asked. 

551 

552 The ``RHIZA_`` prefix is optional, so it is stripped -- but only when what remains 

553 is actually a field. Stripping unconditionally made ``RHIZA_CHECKS`` resolve to 

554 the unknown field ``checks``, so the setting was silently dropped and 

555 ``rhiza_checks`` was reachable from the environment only as ``RHIZA_RHIZA_CHECKS``. 

556 Trying the whole name as a fallback fixes that without disturbing the fields whose 

557 prefix *is* redundant: ``RHIZA_CI_OS_MATRIX`` still resolves to ``ci_os_matrix``, 

558 and the doubled spelling keeps working for anyone who found it. 

559 

560 Args: 

561 name: e.g. ``RHIZA_CI_OS_MATRIX``, ``SOURCE_FOLDER`` or ``rhiza-checks``. 

562 

563 Returns: 

564 e.g. ``ci_os_matrix``, ``source_folder``, ``rhiza_checks``. 

565 

566 Examples: 

567 >>> Config.field_for("SOURCE_FOLDER"), Config.field_for("rhiza-checks") 

568 ('source_folder', 'rhiza_checks') 

569 """ 

570 lowered = name.lower().replace("-", "_") 

571 stripped = lowered.removeprefix("rhiza_") 

572 if stripped in _FIELD_NAMES or lowered not in _FIELD_NAMES: 

573 return stripped 

574 return lowered 

575 

576 @classmethod 

577 def load(cls, root: Path | None = None, **overrides: Any) -> Config: 

578 """Build a config by walking the six layers in order. 

579 

580 Args: 

581 root: Repository root; defaults to the current directory. 

582 **overrides: Layer 5, the command-line flags. ``None`` values are ignored so 

583 an unset flag does not shadow a configured value. 

584 

585 Returns: 

586 The resolved config. 

587 

588 Examples: 

589 Layer 4 -- ``[tool.rhiza-task]`` in the manifest -- over the dataclass 

590 defaults, with an unset flag passed as ``None`` and correctly *not* shadowing 

591 what the manifest said: 

592 

593 >>> import tempfile 

594 >>> from pathlib import Path 

595 >>> manifest = ''' 

596 ... [tool.rhiza-task] 

597 ... source_folder = "lib" 

598 ... coverage_fail_under = 100 

599 ... uv_sync_args = "--group test" 

600 ... ''' 

601 >>> with tempfile.TemporaryDirectory() as tmp: 

602 ... root = Path(tmp) 

603 ... _ = (root / "pyproject.toml").write_text(manifest) 

604 ... cfg = Config.load(root, source_folder=None, typechecker="mypy") 

605 >>> cfg.source_folder, cfg.coverage_fail_under, cfg.typechecker 

606 ('lib', 100, 'mypy') 

607 

608 A ``tuple[str, ...]`` field given as a string is split on whitespace rather 

609 than one character per argument, which is the make layer's own format and the 

610 bug ``__post_init__`` exists to prevent: 

611 

612 >>> cfg.uv_sync_args 

613 ('--group', 'test') 

614 

615 The manifest that carried the table is also what put the repository in a 

616 layer, and the check set follows from the layers rather than from a list 

617 anyone maintains: 

618 

619 >>> cfg.layers 

620 ('python',) 

621 >>> cfg.rhiza_checks[-1] 

622 'pytest_rhiza.checks.test_docstrings' 

623 

624 An unreadable setting fails here, before any tool is provisioned -- the shell 

625 ``case`` that used to validate it ran after: 

626 

627 >>> with tempfile.TemporaryDirectory() as tmp: 

628 ... Config.load(Path(tmp), typechecker="pyright") 

629 Traceback (most recent call last): 

630 ... 

631 ValueError: typechecker must be one of ty, mypy, both (got 'pyright') 

632 """ 

633 root = (root or Path.cwd()).absolute() 

634 raw: dict[str, Any] = {} 

635 raw.update(_from_env_file(root / ".rhiza" / ".env")) 

636 raw.update(_from_rhiza_toml(root / "rhiza.toml")) 

637 # Cargo before pyproject, so a repo carrying both -- a Rust crate with a Python 

638 # binding package, say -- resolves to the same settings as the Python-only repo it 

639 # grew out of, rather than to whichever manifest happened to be read last. 

640 raw.update(_from_manifest(root / "Cargo.toml")) 

641 raw.update(_from_manifest(root / "pyproject.toml")) 

642 raw.update(_from_environ(os.environ)) 

643 raw.update({k: v for k, v in overrides.items() if v is not None}) 

644 

645 # .python-version wins over a configured python_version for the reason python.mk 

646 # reads it: it is what uv itself honours, so a second source of truth could only 

647 # ever disagree. 

648 pv = root / ".python-version" 

649 if pv.is_file() and (text := pv.read_text().strip()): 

650 raw["python_version"] = text 

651 

652 known = {f.name for f in fields(cls)} - {"root"} 

653 return cls(root=root, **{k: v for k, v in raw.items() if k in known}) 

654 

655 

656_FIELD_NAMES = frozenset(f.name for f in fields(Config)) 

657"""Every field name, for :meth:`Config.field_for`. Built once rather than per variable.""" 

658 

659 

660def _from_env_file(path: Path) -> dict[str, Any]: 

661 """Read ``.rhiza/.env``. 

662 

663 An empty assignment (``RHIZA_CI_OS_MATRIX=``) is dropped rather than carried as an 

664 empty string, for the reason given in :func:`_from_environ`: that rule mirrors make's 

665 ``?=``, which is what ``rhiza_ci.yml`` relies on when it exports the matrix empty for 

666 every consumer so their own ``.rhiza/.env`` can answer. 

667 

668 **A consequence, now that one default is non-empty.** The rule used to be justified as 

669 "no field's type has a meaningful empty value", and that stopped being true when 

670 :attr:`Config.mkdocs_extra_packages` gained a default: for it, empty means "install no 

671 plugins", which is a real choice rather than an absent one. It is expressible as 

672 ``mkdocs-extra-packages = []`` in a manifest, where TOML distinguishes an empty array 

673 from an absent key -- and *not* here or in the environment, where both arrive as the 

674 same empty string. The behaviour is unchanged and deliberate: the matrix case above 

675 needs it, and a dotenv layer cannot tell the two apart. Only the reasoning is amended, 

676 so a reader does not conclude from the old wording that ``[]`` is meaningless anywhere. 

677 

678 Args: 

679 path: Path to the dotenv file. 

680 

681 Returns: 

682 Parsed settings; empty when the file is absent. 

683 """ 

684 if not path.is_file(): 

685 return {} 

686 return {Config.field_for(k): _coerce(v) for k, v in dotenv_values(path).items() if v and v.strip()} 

687 

688 

689def _from_manifest(path: Path) -> dict[str, Any]: 

690 """Read ``[tool.rhiza-task]`` from a language manifest. 

691 

692 ``pyproject.toml`` and ``Cargo.toml`` alike: the table is namespaced under ``tool``, 

693 which cargo ignores as readily as any Python build backend does, so one reader serves 

694 both and a Rust crate needs no file a Python project does not have. 

695 

696 Args: 

697 path: Path to pyproject.toml or Cargo.toml. 

698 

699 Returns: 

700 Parsed settings; empty when the file or the table is absent. 

701 """ 

702 return _table(path, lambda data: data.get("tool", {}).get("rhiza-task", {})) 

703 

704 

705def _from_rhiza_toml(path: Path) -> dict[str, Any]: 

706 """Read ``rhiza.toml``, the manifest-free settings file. 

707 

708 Settings sit at the top level, because a file named after this tool has nothing to 

709 namespace against. A ``[tool.rhiza-task]`` table is honoured too, and wins when both 

710 are present: the pyproject spelling is what a reader will have seen first, and 

711 silently ignoring it would be the worst of the three possible behaviours. 

712 

713 Args: 

714 path: Path to rhiza.toml. 

715 

716 Returns: 

717 Parsed settings; empty when the file is absent. 

718 """ 

719 return _table(path, lambda data: data.get("tool", {}).get("rhiza-task") or data) 

720 

721 

722def _table(path: Path, select: Callable[[dict[str, Any]], Any]) -> dict[str, Any]: 

723 """Read a TOML file and normalise the selected table's keys and values. 

724 

725 Values here are already typed by TOML, so they bypass :func:`_coerce` -- a TOML array 

726 arrives as a list and is tupled, nothing is parsed out of a string. Keys that are not 

727 field names are left in place and dropped by :meth:`Config.load`, which is what makes 

728 reading ``rhiza.toml``'s top level safe. 

729 

730 Args: 

731 path: Path to the TOML file. 

732 select: Picks the settings table out of the parsed document. 

733 

734 Returns: 

735 Parsed settings; empty when the file is absent or unreadable. 

736 

737 Raises: 

738 ValueError: When the file is not valid TOML. A settings file that does not parse 

739 is a mistake worth reporting, not a file to skip -- unlike an absent one. 

740 """ 

741 if not path.is_file(): 

742 return {} 

743 try: 

744 data = tomllib.loads(path.read_text()) 

745 except tomllib.TOMLDecodeError as exc: 

746 msg = f"{path.name} is not valid TOML: {exc}" 

747 raise ValueError(msg) from exc 

748 table = select(data) 

749 if not isinstance(table, dict): 

750 return {} 

751 return { 

752 k.replace("-", "_"): tuple(v) if isinstance(v, list) else v for k, v in table.items() if not isinstance(v, dict) 

753 } 

754 

755 

756def _from_environ(environ: Mapping[str, str]) -> dict[str, Any]: 

757 """Read settings from the process environment. 

758 

759 Both ``RHIZA_CI_OS_MATRIX`` and bare ``SOURCE_FOLDER`` are accepted, because the 

760 reusable workflows currently pass bare make-style names on the command line and those 

761 jobs must keep working through the transition. 

762 

763 **An empty value counts as unset.** This is not a nicety, it is the make semantics 

764 this layer replaces. rhiza_ci.yml's ``generate-matrix`` job exports 

765 

766 RHIZA_CI_OS_MATRIX: ${{ github.repository == 'jebel-quant/rhiza' 

767 && '["ubuntu-latest","macos-latest"]' || '' }} 

768 

769 -- one variable, set for the mother repo and *deliberately empty* for every consumer, 

770 whose own ``.rhiza/.env`` is then meant to answer. make's ``?=`` treats an exported 

771 empty string as set, which is why the retired ``ci-os-matrix`` recipe resolved through 

772 ``$(or ...)``; dropping empties here is the same rule, applied one layer earlier so 

773 every setting gets it rather than only the one whose recipe remembered to ask. 

774 

775 Args: 

776 environ: The environment mapping. 

777 

778 Returns: 

779 Parsed settings. 

780 

781 """ 

782 settings = ((Config.field_for(k), v) for k, v in environ.items()) 

783 return {k: _coerce(v) for k, v in settings if k in _FIELD_NAMES and v.strip()} 

784 

785 

786def _coerce(value: str) -> Any: 

787 """Turn a string setting into the field's type. 

788 

789 Three shapes exist in ``.rhiza/.env`` today and all must keep parsing: a JSON array 

790 (``RHIZA_CI_OS_MATRIX=["ubuntu-latest","macos-latest"]``), a semicolon-separated list 

791 (``LICENSE_FAIL_ON=GPL;LGPL;AGPL``), and a plain scalar. 

792 

793 Args: 

794 value: The raw string. 

795 

796 Returns: 

797 A ``str``, ``int``, ``bool`` or ``tuple[str, ...]``. 

798 """ 

799 value = value.strip() 

800 if value.startswith("["): 

801 return tuple(json.loads(value)) 

802 if ";" in value: 

803 return tuple(p for p in value.split(";") if p) 

804 if value.lower() in {"true", "false"}: 

805 return value.lower() == "true" 

806 if value.lstrip("-").isdigit(): 

807 return int(value) 

808 return value