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

174 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:13 +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 

39from __future__ import annotations 

40 

41import json 

42import os 

43import tomllib 

44from collections.abc import Callable, Mapping, Sequence 

45from dataclasses import dataclass, field, fields 

46from pathlib import Path 

47from typing import Any 

48 

49from dotenv import dotenv_values 

50 

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

52 

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

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

55 

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

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

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

59""" 

60 

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

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

63 

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

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

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

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

68""" 

69 

70NEUTRAL_RHIZA_CHECKS = ( 

71 "pytest_rhiza.checks.test_readme", 

72 "pytest_rhiza.checks.test_release_tags", 

73 "pytest_rhiza.checks.test_readme_validation", 

74) 

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

76 

77LAYER_RHIZA_CHECKS = { 

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

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

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

81} 

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

83 

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

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

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

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

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

89than from include order. 

90""" 

91 

92DEFAULT_RHIZA_CHECKS = NEUTRAL_RHIZA_CHECKS + LAYER_RHIZA_CHECKS["python"] 

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

94 

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

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

97nowhere else to put it. 

98""" 

99 

100 

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

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

103 

104 Args: 

105 layers: The active layers. 

106 

107 Returns: 

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

109 """ 

110 checks = list(NEUTRAL_RHIZA_CHECKS) 

111 for layer in layers: 

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

113 return tuple(checks) 

114 

115 

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

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

118 

119 Args: 

120 root: Repository root. 

121 

122 Returns: 

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

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

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

126 gates to measure either. 

127 """ 

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

129 return found or ("python",) 

130 

131 

132DEFAULT_CI_OS_MATRIX = ("ubuntu-latest",) 

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

134 

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

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

137setting reaching GitHub as a zero-job matrix. 

138""" 

139 

140 

141@dataclass 

142class Config: 

143 """Resolved settings for one repository. 

144 

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

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

147 """ 

148 

149 source_folder: str = "src" 

150 tests_folder: str = "tests" 

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

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

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

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

155 # settings and hardcode the third. 

156 docs_folder: str = "docs" 

157 marimo_folder: str = "docs/notebooks" 

158 book_output: str = "_book" 

159 python_version: str = "3.13" 

160 

161 coverage_fail_under: int = 90 

162 

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

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

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

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

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

168 # 

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

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

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

172 complexity_max: int = 15 

173 

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

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

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

177 # before any tool is provisioned rather than after. 

178 typechecker: str = "ty" 

179 

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

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

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

183 

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

185 

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

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

188 # depending on declaration order. 

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

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

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

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

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

194 # 

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

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

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

198 # 

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

200 # 

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

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

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

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

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

206 # 

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

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

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

210 

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

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

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

214 # longer a cost to making one. 

215 docker_folder: str = "docker" 

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

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

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

219 docker_image: str = "" 

220 paper_folder: str = "docs/paper" 

221 presentation_file: str = "PRESENTATION.md" 

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

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

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

225 zensical_version: str = ">=0.0.36" 

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

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

228 

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

230 # 

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

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

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

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

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

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

237 # looks like. 

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

239 

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

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

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

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

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

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

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

247 

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

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

250 # yellow line nobody reads. 

251 strict: bool = False 

252 

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

254 

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

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

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

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

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

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

261 # 

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

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

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

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

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

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

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

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

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

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

272 def __post_init__(self) -> None: 

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

274 

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

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

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

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

279 

280 Raises: 

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

282 ``coverage_fail_under`` is outside 0-100, ``complexity_max`` is below 1, 

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

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

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

286 """ 

287 self._coerce_sequence_fields() 

288 self._validate_layers() 

289 self._validate_typechecker() 

290 self._validate_coverage() 

291 self._validate_complexity_max() 

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

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

294 self._validate_root() 

295 self._validate_folders() 

296 

297 def _coerce_sequence_fields(self) -> None: 

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

299 

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

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

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

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

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

305 

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

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

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

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

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

311 ``.devcontainer/bootstrap.sh`` exports. 

312 """ 

313 for f in fields(self): 

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

315 continue 

316 value = getattr(self, f.name) 

317 if isinstance(value, str): 

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

319 elif isinstance(value, list): 

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

321 

322 def _validate_layers(self) -> None: 

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

324 

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

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

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

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

329 

330 Raises: 

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

332 """ 

333 if not self.layers: 

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

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

336 if unknown: 

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

338 raise ValueError(msg) 

339 if not self.rhiza_checks: 

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

341 

342 def _validate_typechecker(self) -> None: 

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

344 

345 Raises: 

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

347 """ 

348 if self.typechecker not in TYPECHECKERS: 

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

350 raise ValueError(msg) 

351 

352 def _validate_coverage(self) -> None: 

353 """Reject a :attr:`coverage_fail_under` that is not a percentage. 

354 

355 Raises: 

356 ValueError: When ``coverage_fail_under`` is outside 0-100. 

357 """ 

358 if not 0 <= int(self.coverage_fail_under) <= 100: 

359 msg = f"coverage_fail_under must be a percentage (got {self.coverage_fail_under!r})" 

360 raise ValueError(msg) 

361 

362 def _validate_complexity_max(self) -> None: 

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

364 

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

366 rather than a very strict policy. 

367 

368 Raises: 

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

370 """ 

371 if int(self.complexity_max) < 1: 

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

373 raise ValueError(msg) 

374 

375 def _validate_root(self) -> None: 

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

377 

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

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

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

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

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

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

384 

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

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

387 

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

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

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

391 

392 Raises: 

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

394 """ 

395 if not self.root.is_dir(): 

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

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

398 raise ValueError(msg) 

399 

400 def _validate_folders(self) -> None: 

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

402 

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

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

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

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

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

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

409 

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

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

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

413 

414 Raises: 

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

416 """ 

417 root = self.root.resolve() 

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

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

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

421 raise ValueError(msg) 

422 

423 @property 

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

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

426 

427 Returns: 

428 Mapping of field name to configured relative path. 

429 """ 

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

431 

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

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

434 

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

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

437 

438 Args: 

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

440 

441 Returns: 

442 The absolute path. 

443 """ 

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

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

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

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

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

449 value: str = getattr(self, folder_field) 

450 return self.root / value 

451 

452 @staticmethod 

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

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

455 

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

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

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

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

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

461 

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

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

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

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

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

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

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

469 

470 Args: 

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

472 

473 Returns: 

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

475 

476 Examples: 

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

478 ('source_folder', 'rhiza_checks') 

479 """ 

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

481 stripped = lowered.removeprefix("rhiza_") 

482 if stripped in _FIELD_NAMES or lowered not in _FIELD_NAMES: 

483 return stripped 

484 return lowered 

485 

486 @classmethod 

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

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

489 

490 Args: 

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

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

493 an unset flag does not shadow a configured value. 

494 

495 Returns: 

496 The resolved config. 

497 

498 Examples: 

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

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

501 what the manifest said: 

502 

503 >>> import tempfile 

504 >>> from pathlib import Path 

505 >>> manifest = ''' 

506 ... [tool.rhiza-task] 

507 ... source_folder = "lib" 

508 ... coverage_fail_under = 100 

509 ... uv_sync_args = "--group test" 

510 ... ''' 

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

512 ... root = Path(tmp) 

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

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

515 >>> cfg.source_folder, cfg.coverage_fail_under, cfg.typechecker 

516 ('lib', 100, 'mypy') 

517 

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

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

520 bug ``__post_init__`` exists to prevent: 

521 

522 >>> cfg.uv_sync_args 

523 ('--group', 'test') 

524 

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

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

527 anyone maintains: 

528 

529 >>> cfg.layers 

530 ('python',) 

531 >>> cfg.rhiza_checks[-1] 

532 'pytest_rhiza.checks.test_docstrings' 

533 

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

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

536 

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

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

539 Traceback (most recent call last): 

540 ... 

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

542 """ 

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

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

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

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

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

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

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

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

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

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

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

554 

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

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

557 # ever disagree. 

558 pv = root / ".python-version" 

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

560 raw["python_version"] = text 

561 

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

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

564 

565 

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

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

568 

569 

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

571 """Read ``.rhiza/.env``. 

572 

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

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

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

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

577 

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

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

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

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

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

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

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

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

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

587 

588 Args: 

589 path: Path to the dotenv file. 

590 

591 Returns: 

592 Parsed settings; empty when the file is absent. 

593 """ 

594 if not path.is_file(): 

595 return {} 

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

597 

598 

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

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

601 

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

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

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

605 

606 Args: 

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

608 

609 Returns: 

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

611 """ 

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

613 

614 

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

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

617 

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

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

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

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

622 

623 Args: 

624 path: Path to rhiza.toml. 

625 

626 Returns: 

627 Parsed settings; empty when the file is absent. 

628 """ 

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

630 

631 

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

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

634 

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

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

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

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

639 

640 Args: 

641 path: Path to the TOML file. 

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

643 

644 Returns: 

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

646 

647 Raises: 

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

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

650 """ 

651 if not path.is_file(): 

652 return {} 

653 try: 

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

655 except tomllib.TOMLDecodeError as exc: 

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

657 raise ValueError(msg) from exc 

658 table = select(data) 

659 if not isinstance(table, dict): 

660 return {} 

661 return { 

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

663 } 

664 

665 

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

667 """Read settings from the process environment. 

668 

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

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

671 jobs must keep working through the transition. 

672 

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

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

675 

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

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

678 

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

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

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

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

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

684 

685 Args: 

686 environ: The environment mapping. 

687 

688 Returns: 

689 Parsed settings. 

690 

691 """ 

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

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

694 

695 

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

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

698 

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

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

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

702 

703 Args: 

704 value: The raw string. 

705 

706 Returns: 

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

708 """ 

709 value = value.strip() 

710 if value.startswith("["): 

711 return tuple(json.loads(value)) 

712 if ";" in value: 

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

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

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

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

717 return int(value) 

718 return value