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
« 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 ``+=``.
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``.
37"""
39from __future__ import annotations
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
49from dotenv import dotenv_values
51TYPECHECKERS = ("ty", "mypy", "both")
53LAYERS = ("python", "rust", "go")
54"""The language layers, in the order :func:`~rhiza_task.spec.lookup` tries them.
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"""
61LAYER_MANIFESTS = {"python": "pyproject.toml", "rust": "Cargo.toml", "go": "go.mod"}
62"""What makes a repository a member of a layer.
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"""
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."""
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.
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"""
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.
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"""
101def rhiza_checks_for(layers: Sequence[str]) -> tuple[str, ...]:
102 """Return the check set for a repository's layers.
104 Args:
105 layers: The active layers.
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)
116def detect_layers(root: Path) -> tuple[str, ...]:
117 """Return the language layers a repository belongs to, by its manifests.
119 Args:
120 root: Repository root.
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",)
132DEFAULT_CI_OS_MATRIX = ("ubuntu-latest",)
133"""The OS every consumer gets unless it asks for more.
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"""
141@dataclass
142class Config:
143 """Resolved settings for one repository.
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 """
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"
161 coverage_fail_under: int = 90
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
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"
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, ...] = ()
184 deptry_ignore: tuple[str, ...] = ()
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]",)
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
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"
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, ...] = ()
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
253 root: Path = field(default_factory=Path.cwd)
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.
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.
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()
297 def _coerce_sequence_fields(self) -> None:
298 """Turn a ``str`` or ``list`` on a ``tuple[str, ...]`` field into a tuple.
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``.
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))
322 def _validate_layers(self) -> None:
323 """Default :attr:`layers` and :attr:`rhiza_checks` from the repository, then check them.
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.
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))
342 def _validate_typechecker(self) -> None:
343 """Reject a :attr:`typechecker` outside the enumerated set.
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)
352 def _validate_coverage(self) -> None:
353 """Reject a :attr:`coverage_fail_under` that is not a percentage.
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)
362 def _validate_complexity_max(self) -> None:
363 """Reject a :attr:`complexity_max` below 1.
365 A ceiling of 0 would fail every block including the empty ones, which is a typo
366 rather than a very strict policy.
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)
375 def _validate_root(self) -> None:
376 """Reject a :attr:`root` that is not an existing directory.
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.
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``.
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.
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)
400 def _validate_folders(self) -> None:
401 """Reject a ``*_folder`` setting that resolves outside :attr:`root`.
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.
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.
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)
423 @property
424 def folders(self) -> dict[str, str]:
425 """Return the folder settings, for :meth:`~rhiza_task.spec.Guard.check`.
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")}
432 def path(self, folder_field: str) -> Path:
433 """Resolve a folder field to an absolute path.
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``.
438 Args:
439 folder_field: A field name such as ``source_folder``.
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
452 @staticmethod
453 def field_for(name: str) -> str:
454 """Normalise a make-style variable name to a field name.
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.
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.
470 Args:
471 name: e.g. ``RHIZA_CI_OS_MATRIX``, ``SOURCE_FOLDER`` or ``rhiza-checks``.
473 Returns:
474 e.g. ``ci_os_matrix``, ``source_folder``, ``rhiza_checks``.
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
486 @classmethod
487 def load(cls, root: Path | None = None, **overrides: Any) -> Config:
488 """Build a config by walking the six layers in order.
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.
495 Returns:
496 The resolved config.
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:
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')
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:
522 >>> cfg.uv_sync_args
523 ('--group', 'test')
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:
529 >>> cfg.layers
530 ('python',)
531 >>> cfg.rhiza_checks[-1]
532 'pytest_rhiza.checks.test_docstrings'
534 An unreadable setting fails here, before any tool is provisioned -- the shell
535 ``case`` that used to validate it ran after:
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})
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
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})
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."""
570def _from_env_file(path: Path) -> dict[str, Any]:
571 """Read ``.rhiza/.env``.
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.
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.
588 Args:
589 path: Path to the dotenv file.
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()}
599def _from_manifest(path: Path) -> dict[str, Any]:
600 """Read ``[tool.rhiza-task]`` from a language manifest.
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.
606 Args:
607 path: Path to pyproject.toml or Cargo.toml.
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", {}))
615def _from_rhiza_toml(path: Path) -> dict[str, Any]:
616 """Read ``rhiza.toml``, the manifest-free settings file.
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.
623 Args:
624 path: Path to rhiza.toml.
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)
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.
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.
640 Args:
641 path: Path to the TOML file.
642 select: Picks the settings table out of the parsed document.
644 Returns:
645 Parsed settings; empty when the file is absent or unreadable.
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 }
666def _from_environ(environ: Mapping[str, str]) -> dict[str, Any]:
667 """Read settings from the process environment.
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.
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
676 RHIZA_CI_OS_MATRIX: ${{ github.repository == 'jebel-quant/rhiza'
677 && '["ubuntu-latest","macos-latest"]' || '' }}
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.
685 Args:
686 environ: The environment mapping.
688 Returns:
689 Parsed settings.
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()}
696def _coerce(value: str) -> Any:
697 """Turn a string setting into the field's type.
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.
703 Args:
704 value: The raw string.
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