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
« 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 ``+=``.
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.
10Here the order is explicit and testable, lowest precedence first:
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.
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.
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``.
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.
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.
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"""
58from __future__ import annotations
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
68from dotenv import dotenv_values
70TYPECHECKERS = ("ty", "mypy", "both")
72LAYERS = ("python", "rust", "go")
73"""The language layers, in the order :func:`~rhiza_task.spec.lookup` tries them.
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"""
80LAYER_MANIFESTS = {"python": "pyproject.toml", "rust": "Cargo.toml", "go": "go.mod"}
81"""What makes a repository a member of a layer.
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"""
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."""
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.
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"""
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.
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"""
120def rhiza_checks_for(layers: Sequence[str]) -> tuple[str, ...]:
121 """Return the check set for a repository's layers.
123 Args:
124 layers: The active layers.
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)
135def detect_layers(root: Path) -> tuple[str, ...]:
136 """Return the language layers a repository belongs to, by its manifests.
138 Args:
139 root: Repository root.
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",)
151DEFAULT_CI_OS_MATRIX = ("ubuntu-latest",)
152"""The OS every consumer gets unless it asks for more.
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"""
160@dataclass
161class Config:
162 """Resolved settings for one repository.
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 """
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"
180 coverage_fail_under: int = 90
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
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
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 = ""
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"
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 = ""
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, ...] = ()
250 deptry_ignore: tuple[str, ...] = ()
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]",)
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
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
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"
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, ...] = ()
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
334 root: Path = field(default_factory=Path.cwd)
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.
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.
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()
379 def _coerce_sequence_fields(self) -> None:
380 """Turn a ``str`` or ``list`` on a ``tuple[str, ...]`` field into a tuple.
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``.
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))
404 def _validate_layers(self) -> None:
405 """Default :attr:`layers` and :attr:`rhiza_checks` from the repository, then check them.
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.
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))
424 def _validate_typechecker(self) -> None:
425 """Reject a :attr:`typechecker` outside the enumerated set.
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)
434 def _validate_coverage(self) -> None:
435 """Reject a coverage floor that is not a percentage.
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``.
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)
452 def _validate_complexity_max(self) -> None:
453 """Reject a :attr:`complexity_max` below 1.
455 A ceiling of 0 would fail every block including the empty ones, which is a typo
456 rather than a very strict policy.
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)
465 def _validate_root(self) -> None:
466 """Reject a :attr:`root` that is not an existing directory.
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.
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``.
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.
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)
490 def _validate_folders(self) -> None:
491 """Reject a ``*_folder`` setting that resolves outside :attr:`root`.
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.
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.
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)
513 @property
514 def folders(self) -> dict[str, str]:
515 """Return the folder settings, for :meth:`~rhiza_task.spec.Guard.check`.
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")}
522 def path(self, folder_field: str) -> Path:
523 """Resolve a folder field to an absolute path.
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``.
528 Args:
529 folder_field: A field name such as ``source_folder``.
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
542 @staticmethod
543 def field_for(name: str) -> str:
544 """Normalise a make-style variable name to a field name.
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.
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.
560 Args:
561 name: e.g. ``RHIZA_CI_OS_MATRIX``, ``SOURCE_FOLDER`` or ``rhiza-checks``.
563 Returns:
564 e.g. ``ci_os_matrix``, ``source_folder``, ``rhiza_checks``.
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
576 @classmethod
577 def load(cls, root: Path | None = None, **overrides: Any) -> Config:
578 """Build a config by walking the six layers in order.
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.
585 Returns:
586 The resolved config.
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:
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')
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:
612 >>> cfg.uv_sync_args
613 ('--group', 'test')
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:
619 >>> cfg.layers
620 ('python',)
621 >>> cfg.rhiza_checks[-1]
622 'pytest_rhiza.checks.test_docstrings'
624 An unreadable setting fails here, before any tool is provisioned -- the shell
625 ``case`` that used to validate it ran after:
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})
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
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})
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."""
660def _from_env_file(path: Path) -> dict[str, Any]:
661 """Read ``.rhiza/.env``.
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.
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.
678 Args:
679 path: Path to the dotenv file.
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()}
689def _from_manifest(path: Path) -> dict[str, Any]:
690 """Read ``[tool.rhiza-task]`` from a language manifest.
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.
696 Args:
697 path: Path to pyproject.toml or Cargo.toml.
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", {}))
705def _from_rhiza_toml(path: Path) -> dict[str, Any]:
706 """Read ``rhiza.toml``, the manifest-free settings file.
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.
713 Args:
714 path: Path to rhiza.toml.
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)
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.
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.
730 Args:
731 path: Path to the TOML file.
732 select: Picks the settings table out of the parsed document.
734 Returns:
735 Parsed settings; empty when the file is absent or unreadable.
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 }
756def _from_environ(environ: Mapping[str, str]) -> dict[str, Any]:
757 """Read settings from the process environment.
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.
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
766 RHIZA_CI_OS_MATRIX: ${{ github.repository == 'jebel-quant/rhiza'
767 && '["ubuntu-latest","macos-latest"]' || '' }}
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.
775 Args:
776 environ: The environment mapping.
778 Returns:
779 Parsed settings.
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()}
786def _coerce(value: str) -> Any:
787 """Turn a string setting into the field's type.
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.
793 Args:
794 value: The raw string.
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