Skip to content

Configuration

The make layer built its settings from three overlapping mechanisms: ?= defaults in the fragment that owned a setting, += accumulation from other fragments, and a repo-owned Makefile or local.mk assigning over the top. The precedence was a consequence of include order — which is why rhiza.mk had to explain that -include .rhiza/make.d/*.mk comes last and -include local.mk last of all.

Here the order is explicit and testable.

Six layers

Lowest precedence first — each layer overrides the ones above it:

flowchart TD
    D["1 · dataclass defaults"] --> E["2 · .rhiza/.env<br/><small>developer-local</small>"]
    E --> T["3 · rhiza.toml<br/><small>language-neutral, committed</small>"]
    T --> M["4 · [tool.rhiza-task]<br/><small>Cargo.toml, then pyproject.toml</small>"]
    M --> V["5 · RHIZA_* env vars"]
    V --> F["6 · command-line flags"]
# layer notes
1 dataclass defaults the table below
2 .rhiza/.env kept unchanged, because it is already the file consumers edit and the reusable workflows read it too
3 rhiza.toml the language-neutral file — the only committed settings surface a Go module can have
4 [tool.rhiza-task] in the manifest Cargo.toml, then pyproject.toml
5 RHIZA_*, or bare make-style variables RHIZA_COVERAGE_FAIL_UNDER=95 or COVERAGE_FAIL_UNDER=95
6 command-line flags --strict, --root

.rhiza/.env is now developer-local

rhiza no longer ships .rhiza/.gitignore, whose entire content was the !.env negation that kept the file tracked. It therefore falls under the shipped .gitignore's .env rule, and a CI checkout never contains it. Anything CI needs belongs in layer 3 or 4.

Why two files, not one

Neither layer 3 nor layer 4 alone covers the three language layers: pyproject.toml is Python-only, and a repository that already moved its settings there should not have to move them again.

rhiza.toml ranks below the manifest deliberately, so that adding it to a Python repository cannot silently outrank the table already there.

[tool.rhiza-task]
source_folder = "src"
typechecker = "ty"
coverage_fail_under = 95
license_ignore_packages = ["docutils"]
# A Go module or a Rust crate, or any repo that would rather not thread
# settings through a manifest.
source_folder = "cmd"
coverage_fail_under = 95
# Cargo ignores unknown top-level tables, so this is as harmless here as
# it is in pyproject.
[tool.rhiza-task]
source_folder = "src"
coverage_fail_under = 95

In rhiza.toml, settings sit at the top level; a [tool.rhiza-task] table is honoured too and wins when both are present.

Reading a resolved value

print replaces make's print-% pattern rule, and collapses all six layers to the one value a task will actually see:

uvx rhiza-task@1.8.1 print coverage_fail_under
uvx rhiza-task@1.8.1 print COVERAGE_FAIL_UNDER   # either spelling

Field names are the lowercased make variables, so the mapping to what a consumer already knows stays one-to-one and greppable. print exits 2 when the setting does not exist.

An empty value is unset

In the two string-valued layers, an empty value leaves the layer below it alone rather than resolving to "":

RHIZA_CI_OS_MATRIX= uvx rhiza-task@1.8.1 ci-os-matrix   # still the default

That is make's $(or ...) rule, and the reusable workflows depend on it: rhiza_ci.yml exports one for every caller and deliberately leaves it empty for consumers, whose own .rhiza/.env is meant to answer.

Settings

Layout and versions

setting default what
source_folder src the package tree the gates measure
tests_folder tests the test tree
docs_folder docs the prose docs tree, whose examples docs-examples checks
marimo_folder docs/notebooks Marimo notebooks, exported into the book
book_output _book where book writes the built site
python_version 3.13 the interpreter tasks provision

Gates and thresholds

setting default what
coverage_fail_under 90 the coverage floor, enforced wherever test runs
docs_coverage_fail_under 100 the docstring floor docs-coverage hands interrogate
complexity_max 15 the cyclomatic-complexity ceiling complexity enforces
typechecker ty ty, mypy or both
ty_version ==0.0.78 what typecheck provisions ty as, appended to the name
mypy_version "" the same for mypy, empty meaning whatever is newest
license_fail_on ("GPL", "LGPL", "AGPL") matched as substrings
license_ignore_packages () packages exempt from the copyleft scan
deptry_ignore () deptry rule codes to suppress

The template sync

setting default what
template_ref "" the ref update moves .rhiza/template.yml to; empty re-syncs at the pinned one

Two environment variables belong to the same task and are deliberately not settings, for the reason RHIZA_UV_BIN is not one — they say where a tool lives on this machine, which is not a property of the repository:

  • RHIZA_CLAUDE_DIR — an existing rhiza-claude clone to read the sync scripts from. Never written to.
  • XDG_CACHE_HOME — where this package keeps its own clone when the above is unset.

Adopting docs-coverage on a codebase that already exists

The floor defaults to 100 and stays there, because a docstring is either present or it is not. A repository arriving at 89% has no way to reach it in one commit, though, and the two workarounds are both worse than saying so: writing docstrings for every nested helper in one sitting, or shadowing the recipe in local.mk — which CI never reads, so the gate then passes locally and fails on the runner.

Set the floor to what you have and raise it as a ratchet:

[tool.rhiza-task]
docs_coverage_fail_under = 89

Only the Python layer takes a number. Rust and Go express the same gate as -D missing_docs and revive's exported rule, which are pass/fail on one item rather than a percentage over a tree, so there is no threshold for them to read.

ty is pinned, and mypy is not

typecheck used to pass the bare name to uv run --with, so the gate ran whatever was newest at the moment it ran. For a 0.0.x checker that is not a detail: one consumer saw a single warning locally, where --with ty reused the ty their lock file supplied, and twenty-four errors on a runner that had none to reuse — same commit, different checker. So ty ships pinned and mypy, being post-1.0, does not.

Both are settings because neither answer is right for everyone. Move the pin to adopt a newer ty deliberately:

[tool.rhiza-task]
ty_version = "==0.0.60"
mypy_version = "==1.18.2"

Set either empty to go back to whatever is newest. That is a manifest-only spelling, for the reason pytest_rhiza documents below: the environment layer drops an empty value as unset, and only TOML tells an empty string from an absent key.

typechecker = "both" masks ty

python.mk documented it and the behaviour is unchanged: both hides ty's exit status behind mypy's. A typo like typechecker = "tpye" now fails before any tool is provisioned, rather than after — the shell case that used to validate this is a __post_init__ check.

Rust and Go

setting default what
cargo_flags () extra flags for every cargo invocation
go_flags () extra flags for every go invocation
go_test_flags ("-race", "-shuffle=on") the Go idiom for a CI run — -shuffle=on catches tests that depend on declaration order

Book, paper and slides

setting default what
mkdocs_extra_packages ("mkdocstrings[python]",) provisioned into the zensical build
zensical_version >=0.0.36 the book builder
paper_folder docs/paper the LaTeX root
presentation_file PRESENTATION.md the Marp source
marp_package @marp-team/marp-cli unpinned, as npm install -g was; set @marp-team/marp-cli@4.2.3 to pin

Why mkdocs_extra_packages defaults to something

rhiza's book bundle ships docs/mkdocs-base.yml with mkdocstrings enabled unconditionally, and every consumer inherits it via INHERIT. With this empty, book invoked uvx zensical build with no --with, and zensical refused:

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

So the bundle shipped a config that could not build with its own default. A repository that wants no plugins sets mkdocs-extra-packages = [] in its manifest — TOML only, deliberately.

Docker

setting default what
docker_folder docker the build context
docker_image (empty) resolved in the task body when unset

docker.mk's DOCKER_FOLDER was a := rather than a ?= — not configurable at all. Both are ordinary settings now, because there is no longer a cost to making one. docker_image is empty rather than a computed default because ?= $(shell basename $(CURDIR)) cannot be spelled as a dataclass default, and keeping it empty keeps rhiza-task print docker_image honest about the setting being unset.

Environment and CI

setting default what
uv_sync_args ("--all-extras", "--all-groups") what install passes to uv sync
ci_os_matrix ("ubuntu-latest",) feeds ci-os-matrix
deploy_pages true whether rhiza's reusable book workflow publishes to GitHub Pages
pytest_rhiza pinned to a tag the rhiza-test provider — a gate that moves under you is not a gate

Empty means "omit --with entirely." The pin is right for a consumer, and wrong for the one case that wants the opposite: trying an unreleased check against a real subject, which otherwise means publishing something first. Set it empty and rhiza-test and test-pyproject pass no --with at all, so uv run answers from the project environment — where an editable path source, say, actually tracks the working tree:

[tool.rhiza-task]
pytest-rhiza = ""

TOML only, for the same reason as mkdocs-extra-packages = [] above: RHIZA_PYTEST_RHIZA= reads as unset, and only TOML tells an empty string from an absent key.

deploy_pages is read by a workflow, not by a task, which ci_os_matrix above already is. It exists because the choice had nowhere else to live. rhiza's reusable rhiza_book.yml takes a deploy-pages input, so the only place to spell it was the caller — and for a rhiza consumer the caller is the github-book bundle's workflow stub, which is template-owned: an edit there survives review and merge and is then overwritten by the next sync. Set it here instead and the stub stays managed:

[tool.rhiza-task]
deploy-pages = false

That is the artifact-only mode rhiza's book guide describes — the generic book artifact is still uploaded, and a consumer-owned job deploys it wherever it likes.

pytest-rhiza = "." looks like the shorthand and is a trap. uv run --with . resolves to a cached built copy that is not rebuilt on edit, so a broken check goes uncaught while the gate looks like it ran against your tree — worse than the pin, which at least declares what it is.

Detected, not configured

setting default what
layers (detected) from the manifests present
rhiza_checks (derived) follows from layers
strict false turns Skip into failure
root (cwd) the repository to operate on

Both layers and rhiza_checks are empty by default and filled in afterwards, because both depend on the repository rather than on a constant. Setting either explicitly switches detection off for that field — which is exactly what a repository carrying two manifests but wanting one gate set needs:

[tool.rhiza-task]
layers = ["rust"]
RHIZA_LAYERS=rust uvx rhiza-task@1.8.1 test

No += successor, and none needed

The three accumulators — DEPTRY_FOLDERS, LICENSE_IGNORE_PACKAGES, RHIZA_CHECKS — have no replacement mechanism. Each was a bundle contributing something it owned, which a task body now derives by asking whether the contributing task is registered. See deps and license in tasks/python.py.

This repository's own settings

rhiza-task configures itself with the mechanism it sells — layer 4:

[tool.rhiza-task]
coverage_fail_under = 95

The floor is raised from the shipped default of 90 because 90 is the tool's answer for a repository it knows nothing about. This one can do better, so 95 is a floor that is met with headroom and still says something.