API Reference¶
The modules behind the CLI. Most users never import any of this — but
adding a task uses spec and uv, and the module docstrings are where
the reasoning for each design decision lives.
| module | reach for it when |
|---|---|
spec |
writing a task: @task, Guard, Skip, Failed |
uv |
writing a task body: the ways to reach a tool |
config |
reading a resolved setting, or adding one |
runner |
understanding prerequisite order and outcomes |
cli |
understanding how the registry becomes commands |
spec¶
The task model: the registry, the decorator, guards, outcomes, and layer resolution.
rhiza_task.spec
¶
The task model: what a rhiza gate is, independently of how it is invoked.
Reading all ten make fragments back to back, every recipe has the same three parts:
- A guard --
if [ -d ${SOURCE_FOLDER} ], or afindfor test files. When it fails the recipe prints a yellow WARN and exits 0. - A provision --
uvx <tool>oruv run --with a --with b <tool>. - An invocation -- a long, mostly static argument list with a few substitutions.
Only three recipes in the whole layer need more than that: test (retry on pytest exit
3), doctor (version comparison) and book (a per-notebook export loop). So the model
here is declarative, and the task body is the escape hatch those three use.
The split decides what is data -- reviewable, diffable, overridable from a consumer's
pyproject.toml -- and what is code.
The other thing a task carries is its layer. rhiza has three language layers whose
gates share a name and differ only in engine -- test is pytest, cargo nextest or
go test -- and the make layer expressed that by syncing exactly one of python.mk,
rust.mk and go.mk into a repo, so the question never arose at runtime. Here all three are
installed at once, so the layer is part of the key: python:test and rust:test are
distinct entries, and :func:lookup resolves the bare name against the layers the
repository actually has. A task with no layer -- fmt, todos, book -- is
language-neutral and answers to its bare name, which is what core was.
REGISTRY
module-attribute
¶
Every registered task, keyed by layer:name -- or by bare name when neutral.
This dict replaces make's double-colon rules. book.mk has to declare test:: ; @:
no-op stubs so that book can depend on test without knowing whether the tests
bundle was synced; here the same question is :func:lookup. Four stub declarations and
the whole :: mechanism go away with it.
Failed
¶
Bases: Exception
Raised when a task's command exited non-zero. Carries the exit status.
Store the exit status so the CLI can propagate it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
int
|
The failing process's exit status. |
required |
detail
|
str
|
Human-readable context. |
''
|
Source code in src/rhiza_task/spec.py
Guard
dataclass
¶
A precondition on the repository layout.
folder names a :class:~rhiza_task.config.Config field rather than a path, so
Guard("source_folder") means "SOURCE_FOLDER must exist" without this module
knowing that jointview sets it to src.
glob additionally requires a matching file below that folder -- the declarative
form of python.mk's find ${TESTS_FOLDER} -name 'test_*.py'.
file is the flat case the Rust and Go layers need: their gates are guarded on a
manifest rather than a folder, because cargo and go find the sources
themselves. It is a literal path, not a config field -- Cargo.toml and go.mod
are named by their toolchains and are not a repository's choice to make.
tool is a precondition on the machine rather than on the repository, and it is
what github.mk's require-gh was: a target whose whole body is
command -v gh >/dev/null || exit 1, declared as a prerequisite of every helper.
The five bundle-owned fragments are mostly wrappers over a CLI nobody can assume is
installed -- gh, docker, git-lfs, tectonic, marp -- so the check is declared once here
rather than repeated as the first three lines of a dozen task bodies.
A missing tool is a :class:Skip, not a failure, which is a deliberate change from
require-gh's hard exit. Nothing here is a gate, so a machine without docker should
not fail a run that asked for something else too -- and --strict is the switch for
a caller who does want it to.
check
¶
Raise :class:Skip when the guard is not satisfied.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
root
|
Path
|
Repository root. |
required |
folders
|
dict[str, str]
|
Resolved folder settings, e.g. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When the tool is absent, or the file is missing, or the folder is
missing, or the folder holds no file matching |
Examples:
A satisfied guard returns nothing, which is the whole of its success case:
>>> import tempfile
>>> from pathlib import Path
>>> tmp = tempfile.TemporaryDirectory()
>>> root = Path(tmp.name)
>>> (root / "src").mkdir()
>>> folders = {"source_folder": "src", "tests_folder": "tests"}
>>> Guard("source_folder").check(root, folders)
Each way of not being satisfied raises :class:Skip carrying the line the
runner prints, and folder is resolved through folders -- so the guard
names a setting and never a path:
>>> for guard in (
... Guard("tests_folder"),
... Guard("source_folder", glob="test_*.py"),
... Guard(file="Cargo.toml"),
... Guard(tool="a-tool-nobody-has"),
... ):
... try:
... guard.check(root, folders)
... except Skip as exc:
... print(exc)
tests_folder 'tests' not found
no test_*.py below 'src'
no Cargo.toml
a-tool-nobody-has not found
reason replaces the generated message wherever a task has something more
useful to say:
>>> try:
... Guard("tests_folder", glob="test_*.py", reason="no test files found").check(root, folders)
... except Skip as exc:
... print(exc)
no test files found
>>> tmp.cleanup()
Source code in src/rhiza_task/spec.py
Skip
¶
Bases: Exception
Raised by a guard or a task body to report that there was nothing to do.
The make layer signals this by printing a WARN and exiting 0, which is how jointview
ended up with a rhiza-test that "silently passed over nothing" -- its own Makefile
says so. Making it a distinct outcome rather than a success is the point: --strict
turns every skip into a failure, so CI can assert that a gate measured something.
Task
dataclass
¶
One gate: the unit the CLI exposes and the reusable workflows invoke.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
The command name, e.g. |
layer |
str | None
|
|
help |
str
|
One line, shown by |
section |
str
|
Help grouping. Replaces |
run |
Callable[[Config], None]
|
The task body. Takes a config, returns nothing, raises :class: |
needs |
tuple[str, ...]
|
Tasks to run first. The runner dedupes within one invocation, which is what
make gave for free and the reason |
guards |
tuple[Guard, ...]
|
Evaluated in order before the body. |
hidden |
bool
|
Omit from |
key
property
¶
Return the registry key: layer:name, or name when neutral.
Returns:
| Type | Description |
|---|---|
str
|
The key this task is registered under. |
have
¶
Return whether tool is on PATH.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tool
|
str
|
Executable name. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True when found. |
key
¶
Return the registry key for a task name in a layer.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The task name, e.g. |
required |
layer
|
str | None
|
The layer, or None for a neutral task. |
None
|
Returns:
| Type | Description |
|---|---|
str
|
|
Source code in src/rhiza_task/spec.py
lookup
¶
Resolve a task name against the repository's language layers.
A layered task shadows a neutral one of the same name, and the layers are tried in
order, so a repository that is both -- a crate with a Python binding package -- gets a
single answer rather than an ambiguity. rust:test addresses one layer explicitly,
which is the only way to reach the layer that did not win.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
A bare task name, or a |
required |
layers
|
Sequence[str]
|
The active layers, most significant first. |
()
|
Returns:
| Type | Description |
|---|---|
Task | None
|
The task, or None when nothing matches. |
Examples:
Importing a task module is what registers its tasks -- the entry point group in
pyproject.toml only decides which modules the CLI imports:
>>> from rhiza_task.tasks import python, quality, rust
>>> lookup("test", ["python"]).help
'run all tests'
>>> lookup("test", ["rust"]).help
'run the test suite with nextest, then the doctests'
The layers are tried in order, so a crate that has grown a Python package gets one answer rather than an ambiguity -- and the explicit key is how the layer that lost is still reachable:
>>> lookup("test", ["python", "rust"]).key
'python:test'
>>> lookup("test", ["rust", "python"]).key
'rust:test'
>>> lookup("rust:test", ["python"]).key
'rust:test'
A neutral task answers to its bare name whatever the layers are, and a name no
active layer has is None rather than an error -- which is what lets book
depend on gates a repository may not have, in place of make's test:: ; @:
no-op stubs:
Source code in src/rhiza_task/spec.py
task
¶
Register a task and return the function unchanged.
Returning the undecorated function keeps every task body directly unit-testable without going through the registry or the CLI.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Command name. |
required |
help
|
str
|
One-line description. |
required |
section
|
str
|
Help grouping. |
required |
needs
|
Sequence[str]
|
Prerequisite task names. |
()
|
guards
|
Sequence[Guard]
|
Layout preconditions. |
()
|
hidden
|
bool
|
Omit from |
False
|
layer
|
str | None
|
The language layer this task belongs to, or None for a neutral task. |
None
|
Returns:
| Type | Description |
|---|---|
Callable[[Callable[[Config], None]], Callable[[Config], None]]
|
The decorator. |
Source code in src/rhiza_task/spec.py
uv¶
The ways rhiza reaches a tool, and nothing else.
rhiza_task.uv
¶
The ways rhiza reaches a tool, and nothing else.
Every recipe in the retired Python make layer used one of exactly three forms:
uv <subcommand>-- uv itself (venv,sync,lock --check).uvx <tool>-- an isolated one-shot tool run: prek, deptry, bandit, semgrep, zensical, genbadge.uv run --with a --with b <tool>-- a tool run against the project environment, because it imports the project's own code: pytest, interrogate, hypothesis, ty, mypy.
The second and third are a real distinction that the make layer already gets right, so it is preserved here rather than unified.
rust.mk and go.mk add a fourth: $(CARGO) nextest run, $(GO) test -- a toolchain
binary that is already on PATH, because uv does not provision cargo or go and nothing here
pretends otherwise. :func:tool is that form. It shares this module's environment handling
and echoing rather than being a bare subprocess.call in each language module, so
$ cargo clippy is printed the same way $ uvx bandit is.
go.mk contributes one more, and it is the one that gets missed when these are counted:
:func:capture, which returns stdout rather than an exit status, for the recipe that
needs a value back rather than a verdict -- the licence gate, which has to interpolate
go list -m into its own arguments. It is easy to overlook precisely because it is the
only form whose caller reads the result instead of just its status, and #131 is what that
cost: every prose total for this module disagreed with the code, and with the others. So no
sentence here gives one -- the public functions below are the authority, and a total in
prose goes stale the moment a form is added.
Two things disappear:
install-uv as a task. bootstrap.mk curls https://astral.sh/uv/install.sh into
./bin because make cannot assume uv exists. A process launched by uvx rhiza-task
runs because uv exists, so nothing in this package can be the thing that provisions uv --
it would already be too late. The problem does not disappear with it, though: the make
layer's contract was that make <anything> works on a bare runner, so the bootstrap
lives on in the Makefile shim as three lines and one file target. What is gone is the 30
lines of probe-and-branch shell, and the bin/uv nobody ran directly.
The shell. Commands are argument vectors, never shell strings. rhiza.mk carries a 40-line
probe to detect make falling back to cmd.exe on Windows, because its recipes are
POSIX shell; with no shell there is nothing to detect.
capture
¶
Run a tool and return its stdout, for the one recipe that needs a value back.
go.mk's licence gate is that recipe: go-licenses check ./... --ignore "$(go list -m)"
-- without the module's own path, go-licenses walks the project's own packages and fails
a freshly synced project for having no LICENSE of its own. Found by rhiza's e2e suite
rather than by a dry run, which is why it is carried over rather than rediscovered.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The executable. |
required |
*args
|
str
|
Its arguments. |
()
|
cwd
|
Path
|
Working directory. |
required |
Returns:
| Type | Description |
|---|---|
str
|
Stripped stdout, or an empty string when the tool failed or is absent. |
Source code in src/rhiza_task/uv.py
tool
¶
Run a toolchain binary that is expected to be on PATH.
The Rust and Go layers' engines, which uv neither provisions nor knows about: cargo,
rustup, go, and the binaries cargo-tools and go-tools install. Nothing is
injected and nothing is isolated -- that is what makes it different from :func:uvx,
not an oversight.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The executable, or an absolute path to one. |
required |
*args
|
str
|
Its arguments. |
()
|
cwd
|
Path
|
Working directory. |
required |
check
|
bool
|
Raise on non-zero rather than returning the status. |
True
|
env
|
Mapping[str, str] | None
|
Extra environment variables, e.g. |
None
|
Returns:
| Type | Description |
|---|---|
int
|
The exit status. |
Raises:
| Type | Description |
|---|---|
Failed
|
When |
Source code in src/rhiza_task/uv.py
uv
¶
Run uv itself.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*args
|
str
|
uv subcommand and arguments, e.g. |
()
|
cwd
|
Path
|
Working directory. |
required |
check
|
bool
|
Raise on non-zero rather than returning the status. |
True
|
env
|
Mapping[str, str] | None
|
Extra environment variables. |
None
|
Returns:
| Type | Description |
|---|---|
int
|
The exit status. |
Raises:
| Type | Description |
|---|---|
Failed
|
When |
Source code in src/rhiza_task/uv.py
uv_run
¶
Run a tool against the project environment via uv run --with.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tool
|
str
|
The executable, e.g. |
required |
*args
|
str
|
Arguments for the tool. |
()
|
cwd
|
Path
|
Working directory. |
required |
withs
|
Sequence[str]
|
Packages to inject, e.g. |
()
|
no_project
|
bool
|
Pass |
False
|
check
|
bool
|
Raise on non-zero rather than returning the status. |
True
|
env
|
Mapping[str, str] | None
|
Extra environment variables. |
None
|
Returns:
| Type | Description |
|---|---|
int
|
The exit status. |
Raises:
| Type | Description |
|---|---|
Failed
|
When |
Source code in src/rhiza_task/uv.py
uvx
¶
Run an isolated tool via uvx.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tool
|
str
|
The tool spec, e.g. |
required |
*args
|
str
|
Arguments for the tool. |
()
|
cwd
|
Path
|
Working directory. |
required |
withs
|
Sequence[str]
|
Extra packages injected into the tool's environment. book.mk's
|
()
|
python
|
str | None
|
Interpreter for the tool itself. Usually omitted -- prek and the other
language-neutral tools provision their own toolchains, which is why
quality.mk was able to drop its |
None
|
check
|
bool
|
Raise on non-zero rather than returning the status. |
True
|
env
|
Mapping[str, str] | None
|
Extra environment variables. |
None
|
Returns:
| Type | Description |
|---|---|
int
|
The exit status. |
Raises:
| Type | Description |
|---|---|
Failed
|
When |
Source code in src/rhiza_task/uv.py
config¶
Configuration, and the resolution order that replaces make's ?= and +=.
rhiza_task.config
¶
Configuration, and the resolution order that replaces make's ?= and +=.
The make layer builds its settings from three overlapping mechanisms: ?= defaults in
the fragment that owns a setting, += accumulation from other fragments
(DEPTRY_FOLDERS, LICENSE_IGNORE_PACKAGES, RHIZA_CHECKS), and a repo-owned
Makefile or local.mk assigning over the top. The precedence is a consequence of
include order, which is why rhiza.mk has to explain that -include .rhiza/make.d/*.mk
comes last and -include local.mk last of all.
Here the order is explicit and testable, lowest precedence first:
- The dataclass defaults below.
.rhiza/.env-- kept unchanged, because it is already the file consumers edit and the reusable workflows read it too. Now a developer-local channel rather than a committed one: rhiza no longer ships.rhiza/.gitignore, whose entire content was the!.envnegation that kept this file tracked, so it falls under the shipped.gitignore's.envrule and a CI checkout never contains it.rhiza.toml-- the language-neutral settings file, and the only committed one a Go module can have: it has no manifest to hide a table in. Read for every project, so a polyglot repository has one place to look rather than one per layer.[tool.rhiza-task]in the language manifest --Cargo.toml, thenpyproject.toml. This is the new home for what used to require editing a synced.mkfile or shadowing a target. Cargo ignores unknown top-level tables, so the table is as harmless there as it is in pyproject.RHIZA_*(or bare make-style) environment variables.- Command-line flags.
Layers 3 and 4 are two files rather than one because neither alone covers the three
language layers: pyproject is Python-only, and a repo that already moved its settings
there should not have to move them again. rhiza.toml ranks below the manifest so
that adding it to a Python repo cannot silently outrank the table already there.
The += accumulators do not survive as a mechanism, and do not need to: every one of
them was a bundle contributing something it owned, which the task body can now derive
by asking whether the contributing task is registered. See tasks/python.py's deps
and license.
DEFAULT_CI_OS_MATRIX
module-attribute
¶
The OS every consumer gets unless it asks for more.
Named rather than inlined because two callers need the same value: the field default
below, and the floor in rhiza-task ci-os-matrix that stops an explicitly empty
setting reaching GitHub as a zero-job matrix.
DEFAULT_RHIZA_CHECKS
module-attribute
¶
DEFAULT_RHIZA_CHECKS = NEUTRAL_RHIZA_CHECKS + LAYER_RHIZA_CHECKS['python']
The Python resolution, kept as a name because it is the set consumers know.
This is jointview's RHIZA_CHECKS list, promoted from a shadowed make variable to a
default: the 60-line override in its Makefile exists only because the make layer had
nowhere else to put it.
LAYERS
module-attribute
¶
The language layers, in the order :func:~rhiza_task.spec.lookup tries them.
Python first because it is the layer a polyglot repository is most likely to have grown into -- a crate or a module that acquires a pyproject has acquired a Python package, and the gates that package needs are the ones that would otherwise stop running.
LAYER_MANIFESTS
module-attribute
¶
What makes a repository a member of a layer.
The make layer answered this at sync time -- exactly one of python.mk, rust.mk and go.mk
was ever synced into a repo, and rhiza.mk's -include did the rest. A pinned CLI
carries all three, so the question moves to runtime, and the manifest is the honest
answer: it is what the toolchain itself looks for.
LAYER_RHIZA_CHECKS
module-attribute
¶
LAYER_RHIZA_CHECKS = {'python': ('pytest_rhiza.checks.test_pyproject', 'pytest_rhiza.checks.test_docstrings'), 'rust': ('pytest_rhiza.checks.test_cargo_toml',), 'go': ('pytest_rhiza.checks.test_go_module',)}
What each layer contributes, enumerated rather than globbed.
pytest-rhiza ships all three layers' modules in one distribution, so
--pyargs pytest_rhiza.checks would collect checks that cannot pass -- test_go_module
against a Python project asserts a go.mod that is not there. In the make layer each
language fragment appended its own with RHIZA_CHECKS +=; here the accumulator is
replaced by the same derivation the += was standing in for, from the layer set rather
than from include order.
NEUTRAL_RHIZA_CHECKS
module-attribute
¶
NEUTRAL_RHIZA_CHECKS = ('pytest_rhiza.checks.test_readme', 'pytest_rhiza.checks.test_release_tags', 'pytest_rhiza.checks.test_readme_validation')
The checks every repository gets, whatever it is written in.
Config
dataclass
¶
Config(source_folder='src', tests_folder='tests', docs_folder='docs', marimo_folder='docs/notebooks', book_output='_book', python_version='3.13', coverage_fail_under=90, complexity_max=15, typechecker='ty', license_fail_on=('GPL', 'LGPL', 'AGPL'), license_ignore_packages=(), deptry_ignore=(), cargo_flags=(), go_flags=(), go_test_flags=('-race', '-shuffle=on'), mkdocs_extra_packages=('mkdocstrings[python]',), docker_folder='docker', docker_image='', paper_folder='docs/paper', presentation_file='PRESENTATION.md', marp_package='@marp-team/marp-cli', zensical_version='>=0.0.36', uv_sync_args=('--all-extras', '--all-groups'), ci_os_matrix=DEFAULT_CI_OS_MATRIX, pytest_rhiza='pytest-rhiza @ git+https://github.com/Jebel-Quant/pytest-rhiza@v0.2.0', layers=(), rhiza_checks=(), strict=False, root=cwd())
Resolved settings for one repository.
Field names are the lowercased make variables, so the mapping to what a consumer already knows stays one-to-one and greppable.
folders
property
¶
Return the folder settings, for :meth:~rhiza_task.spec.Guard.check.
Returns:
| Type | Description |
|---|---|
dict[str, str]
|
Mapping of field name to configured relative path. |
__post_init__
¶
Normalise the list fields, then validate the enumerated and numeric ones.
Each step is a helper, so this method's own branch count is zero and the ceiling the history above describes no longer applies to it. A new validated setting adds a call here and its branches to its own helper -- which is what makes "one branch per validated setting" stop being an open-ended growth rule.
Raises:
| Type | Description |
|---|---|
ValueError
|
When |
Source code in src/rhiza_task/config.py
field_for
staticmethod
¶
Normalise a make-style variable name to a field name.
Public because the spelling rule is not private to the layer readers below: the
print command has to answer for SOURCE_FOLDER exactly as .rhiza/.env
does, and a second normaliser written against the same rule is a second thing to
keep in step. A caller outside this module asking "which field is this?" is asking
:class:Config, so it is spelled as a question :class:Config can be asked.
The RHIZA_ prefix is optional, so it is stripped -- but only when what remains
is actually a field. Stripping unconditionally made RHIZA_CHECKS resolve to
the unknown field checks, so the setting was silently dropped and
rhiza_checks was reachable from the environment only as RHIZA_RHIZA_CHECKS.
Trying the whole name as a fallback fixes that without disturbing the fields whose
prefix is redundant: RHIZA_CI_OS_MATRIX still resolves to ci_os_matrix,
and the doubled spelling keeps working for anyone who found it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
e.g. |
required |
Returns:
| Type | Description |
|---|---|
str
|
e.g. |
Examples:
>>> Config.field_for("SOURCE_FOLDER"), Config.field_for("rhiza-checks")
('source_folder', 'rhiza_checks')
Source code in src/rhiza_task/config.py
load
classmethod
¶
Build a config by walking the six layers in order.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
root
|
Path | None
|
Repository root; defaults to the current directory. |
None
|
**overrides
|
Any
|
Layer 5, the command-line flags. |
{}
|
Returns:
| Type | Description |
|---|---|
Config
|
The resolved config. |
Examples:
Layer 4 -- [tool.rhiza-task] in the manifest -- over the dataclass
defaults, with an unset flag passed as None and correctly not shadowing
what the manifest said:
>>> import tempfile
>>> from pathlib import Path
>>> manifest = '''
... [tool.rhiza-task]
... source_folder = "lib"
... coverage_fail_under = 100
... uv_sync_args = "--group test"
... '''
>>> with tempfile.TemporaryDirectory() as tmp:
... root = Path(tmp)
... _ = (root / "pyproject.toml").write_text(manifest)
... cfg = Config.load(root, source_folder=None, typechecker="mypy")
>>> cfg.source_folder, cfg.coverage_fail_under, cfg.typechecker
('lib', 100, 'mypy')
A tuple[str, ...] field given as a string is split on whitespace rather
than one character per argument, which is the make layer's own format and the
bug __post_init__ exists to prevent:
The manifest that carried the table is also what put the repository in a layer, and the check set follows from the layers rather than from a list anyone maintains:
An unreadable setting fails here, before any tool is provisioned -- the shell
case that used to validate it ran after:
>>> with tempfile.TemporaryDirectory() as tmp:
... Config.load(Path(tmp), typechecker="pyright")
Traceback (most recent call last):
...
ValueError: typechecker must be one of ty, mypy, both (got 'pyright')
Source code in src/rhiza_task/config.py
486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 | |
path
¶
Resolve a folder field to an absolute path.
No containment check here: :meth:_validate_folders did it once at construction,
so every field this resolves is already known to stay under root.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
folder_field
|
str
|
A field name such as |
required |
Returns:
| Type | Description |
|---|---|
Path
|
The absolute path. |
Source code in src/rhiza_task/config.py
detect_layers
¶
Return the language layers a repository belongs to, by its manifests.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
root
|
Path
|
Repository root. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The layers whose manifest is present, in :data: |
...
|
when a repository has none, because that is what every gate assumed before there |
tuple[str, ...]
|
was a choice, and a repo with no manifest at all has nothing for another layer's |
tuple[str, ...]
|
gates to measure either. |
Source code in src/rhiza_task/config.py
rhiza_checks_for
¶
Return the check set for a repository's layers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
layers
|
Sequence[str]
|
The active layers. |
required |
Returns:
| Type | Description |
|---|---|
tuple[str, ...]
|
The neutral checks followed by each layer's own, in layer order, deduplicated. |
Source code in src/rhiza_task/config.py
runner¶
Prerequisite resolution, guard evaluation and outcome bookkeeping.
rhiza_task.runner
¶
Prerequisite resolution, guard evaluation, and outcome bookkeeping.
Small on purpose. make gave four behaviours for free, and this module is what buys them back; nothing else belongs here.
- Dedup within one invocation. Eleven tasks name
installas a prerequisite andallnames eight of those. Without a seen-set,rhiza-task allwould sync the environment eight times. - Depth-first ordering.
bookneedstest, which needsinstall. - A failed prerequisite stops its dependents. As make does, rather than running a gate against a half-built environment.
- A missing prerequisite is not an error. book.mk declares
test:: ; @:no-op stubs sobookcan depend on gates that may not have been synced; here a prerequisite absent from the registry is simply not run, and the stubs are gone.
Every name goes through :func:~rhiza_task.spec.lookup rather than a dict subscript, so
test means pytest in a Python repository and cargo nextest in a crate. That is the
question the make layer answered by syncing exactly one language fragment.
Result
dataclass
¶
What happened to one task.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
The task name. |
status |
Status
|
Its outcome. |
detail |
str
|
Why, for anything other than :attr: |
code |
int
|
The failing process's own exit status, carried from
:class: |
Run
dataclass
¶
One invocation: the results so far, and the tasks already attempted.
failed
property
¶
Whether any task failed or was blocked.
Returns:
| Type | Description |
|---|---|
bool
|
True when the invocation should exit non-zero. |
exit_code
¶
Return the aggregate exit status: 0 when nothing failed or was blocked, else non-zero.
The first real failure's own code is propagated where there is one, so a caller can
still distinguish e.g. pytest's 2 from a gate that merely exited 1. "First real"
means the first :attr:Status.FAILED entry: a :attr:Status.BLOCKED dependent has
no process of its own, and the failure that blocked it is recorded earlier in the
list, so it is the one that speaks. Anything outside a shell's 1-255 range -- a
code of 0, or the negative signal number subprocess reports for a killed child
-- collapses to 1, since it cannot be handed to exit as-is.
Returns:
| Type | Description |
|---|---|
int
|
0 when nothing failed or was blocked; else the first failing task's exit status, |
int
|
or 1 when that status is unusable. |
Examples:
An empty run, and a run whose only entry is a skip, both succeed -- a skip is
an outcome, not a failure, and --strict is the switch that changes that:
>>> state = Run()
>>> state.exit_code()
0
>>> state.results.append(Result("fmt", Status.SKIPPED, "no .pre-commit-config.yaml"))
>>> state.failed, state.exit_code()
(False, 0)
A failure, and the dependent it blocks, are both non-zero -- and pytest's own 2 is what the run exits with, not a flattened 1:
>>> state.results.append(Result("test", Status.FAILED, "tests failed", 2))
>>> state.results.append(Result("book", Status.BLOCKED, "prerequisite failed: test"))
>>> state.failed, state.exit_code()
(True, 2)
>>> state.status_of("book") is Status.BLOCKED
True
>>> state.status_of("todos") is None
True
A failure with no usable code of its own -- a guard's own verdict rather than a child process's, or a blocked dependent standing alone -- is 1:
>>> Run([Result("doctor", Status.FAILED, "missing or outdated: uv")]).exit_code()
1
>>> Run([Result("book", Status.BLOCKED, "prerequisite failed: test")]).exit_code()
1
Source code in src/rhiza_task/runner.py
Status
¶
Bases: StrEnum
The four outcomes a task can have.
run
¶
Run the named tasks and their prerequisites, in order.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
names
|
list[str]
|
Task names, as typed on the command line. |
required |
cfg
|
Config
|
The resolved config. |
required |
Returns:
| Type | Description |
|---|---|
Run
|
The completed :class: |
Raises:
| Type | Description |
|---|---|
KeyError
|
When an explicitly requested task does not exist in this repository's layers. Only for requested names -- an unknown prerequisite is skipped, whereas an unknown request is a typo and should say so. |
Source code in src/rhiza_task/runner.py
cli¶
The command line, generated from the registry rather than hand-maintained.
rhiza_task.cli
¶
The command line, generated from the registry rather than hand-maintained.
rhiza.mk builds its help by running awk over $(MAKEFILE_LIST) looking for ## and
##@ comments -- a parser for a documentation convention that exists only because make
has no notion of a task description. Typer has one, so help text, sections, per-task help
and the "unknown task" error all come from the same registry the runner uses, and cannot
drift from it.
RESERVED
module-attribute
¶
Subcommand names, so the bare-task shorthand in :func:main can tell them apart.
ci_os_matrix
¶
Emit the CI OS matrix as a JSON array, for a GitHub Actions matrix input.
Never emits []. A GitHub matrix with no OS in it does not fail the workflow -- it
expands to zero jobs, so the test job disappears and CI goes green having run
nothing. The retired make recipe guarded that with $(or $(RHIZA_CI_OS_MATRIX),
["ubuntu-latest"]) and this is the same floor: after :func:~rhiza_task.config
resolution an empty value can only come from an explicit RHIZA_CI_OS_MATRIX=[],
which is a mistake in every case a caller has ever meant.
Source code in src/rhiza_task/cli.py
list_tasks
¶
Show the available tasks, grouped by section.
A Go module is not helped by being shown benchmark and marimo-validate, so the
default is this repository's own layers plus the language-neutral tasks -- which is
what the make layer showed, having synced exactly one language fragment. --all is
for the question the make layer could not answer: what the other layers call things.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
every_layer
|
bool
|
Show tasks from every language layer, not only this repository's. |
Option(False, '--all', help="include the other languages' layers")
|
Source code in src/rhiza_task/cli.py
load_tasks
¶
Import every module registered under the rhiza_task.tasks entry-point group.
Failures are reported and skipped rather than fatal: a broken third-party task module should not take the built-in gates down with it.
Source code in src/rhiza_task/cli.py
main
¶
Entry point. A bare rhiza-task <task> is shorthand for rhiza-task run <task>.
Not sugar -- it is the compatibility contract. The reusable workflows and a repo-owned
forwarding Makefile both invoke rhiza-task test, and a consumer's muscle memory
is make test. Requiring run would put a word between the two for no gain.
Source code in src/rhiza_task/cli.py
print_setting
¶
Print one resolved setting, replacing make's print-% pattern rule.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
A config field, spelled either way -- |
required |
Raises:
| Type | Description |
|---|---|
Exit
|
With status 2 when the setting does not exist. |
Source code in src/rhiza_task/cli.py
run_tasks
¶
run_tasks(names=Argument(..., help='Tasks to run, in order'), strict=Option(False, '--strict', help='Treat a skipped gate as a failure'), root=Option(None, '--root', help='Repository to operate on'))
Run one or more tasks, with their prerequisites.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
names
|
list[str]
|
Task names. |
Argument(..., help='Tasks to run, in order')
|
strict
|
bool
|
Fail rather than skip when a gate has nothing to measure. |
Option(False, '--strict', help='Treat a skipped gate as a failure')
|
root
|
Path | None
|
Repository root; defaults to the current directory. |
Option(None, '--root', help='Repository to operate on')
|
Raises:
| Type | Description |
|---|---|
Exit
|
With 0 when everything passed, 2 on a usage error, and otherwise the
first failing task's own exit status -- pytest's 2 or 4, |
Source code in src/rhiza_task/cli.py
Task modules¶
The gates themselves, each loaded through the rhiza_task.tasks entry-point group. Every
module docstring names the make fragment it replaces, and records any behaviour that
changed on purpose.
Python¶
rhiza_task.tasks.python
¶
The Python language layer: python.mk, as tasks.
python.mk is 312 lines, over half of the synced make. Most of it converts to the
declarative form in :mod:rhiza_task.spec; test is the one recipe that does not, and
it is written out in full below.
complexity is the one task here with no make ancestor. It lives in this module because
radon is a Python tool and the gate is therefore Python-layer, even though its section is
Quality alongside the neutral gates it reads like.
PYTEST_INTERNAL_ERROR
module-attribute
¶
pytest's INTERNALERROR.
Distinct from test failure (1), interruption (2) and usage error (4), which is what makes
retrying on it safe: it means the runner broke during worker or session teardown -- the
xdist worker_workerfinished KeyError, or a pytest-html report-write race -- not that a
test failed.
PYTEST_WITHS
module-attribute
¶
PYTEST_WITHS = ('pytest', 'pytest-cov', 'pytest-xdist', 'pytest-html', 'pytest-timeout', 'pytest-mock')
What test injects.
A named tuple of packages rather than a literal in the call, so CI and this package's own
tests can assert on it. The make recipe's six --with flags are invisible to anything
but a human reading the recipe.
all_
¶
Aggregate. The body is empty because needs is the definition.
python.mk's all named four gates that lived in the optional tests bundle, so a
project syncing core + python-core without it had an all that could not run.
Here an unregistered prerequisite is skipped by the runner, so the failure mode does
not exist.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
Unused; the prerequisites do the work. |
required |
Source code in src/rhiza_task/tasks/python.py
complexity
¶
Fail when any block's cyclomatic complexity exceeds :attr:Config.complexity_max.
The one task here that is not a python.mk port. It exists because this repository's own
convention -- a C-ranked block carries a comment arguing why the flat form is preferred
-- committed to a number in config.py, and nothing read it back. A stated ceiling
that only a human checks is the same shape as a doctest no gate executes: correct today,
stale-proof only by discipline, in the one place growth is expected.
Why the report goes through a file rather than a pipe: radon's verdict is a number per
block, so the gate has to read its output, and -O is how radon hands output to
something other than a terminal. That keeps the invocation a fixed argument vector with
no shell and no capturing variant of :func:~rhiza_task.uv.uvx -- the same reason every
other call in this package is one.
closures is deliberately not walked. radon only fills it under --show-closures,
which is not passed, so a nested function's complexity is already counted in its
parent's -- walking the empty list would suggest a coverage this gate does not have.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When radon produced no report, so nothing was measured. |
Failed
|
When at least one block is above the ceiling. |
Source code in src/rhiza_task/tasks/python.py
coverage
¶
Run the suite for its coverage reports.
python.mk has no coverage target: its test recipe carries the --cov flags,
so the Cobertura file CI uploads and book badges is a side effect of the test gate.
rust.mk and go.mk both name coverage separately, and the gate-parity contract lists
it for all three layers -- so the Python layer grows the name it was missing rather than
the other two losing it.
It is not a second test run in any meaningful sense: same suite, same floor, same output path. What it buys is a caller that wants the report without asserting anything about the HTML test report, and one name that means the same thing in all three layers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/python.py
coverage_args
¶
Return the --cov flags, including the Cobertura path the other layers write to.
Shared by test and coverage so the two cannot drift: _tests/coverage.xml is
the file book.mk's badge step reads and CI uploads, and rust.mk and go.mk go out of
their way to write it at exactly that path.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
The coverage flags. |
Source code in src/rhiza_task/tasks/python.py
deps
¶
Check declared dependencies against actual imports.
DEPTRY_FOLDERS and DEPTRY_IGNORE were make accumulators that each bundle
appended to, which worked only because of include order. Here the folder set is
derived: the source folder when it exists, plus the marimo folder when the marimo
tasks are registered and that folder exists. DEP004 (misplaced development dependency)
is ignored for the same reason marimo.mk ignores it -- notebooks legitimately import
development dependencies.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When no contributed folder exists. |
Source code in src/rhiza_task/tasks/python.py
docs_coverage
¶
Require 100% docstring coverage over the source and test folders.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/python.py
install
¶
Create .venv if absent, sync from the lock file, install the git hooks.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When the project has no |
Failed
|
When the lock file is out of sync, or a step exits non-zero. |
Source code in src/rhiza_task/tasks/python.py
license_
¶
Fail on GPL/LGPL/AGPL among the installed distributions.
--partial-match is load-bearing: without it pip-licenses compares against the whole
licence string, and GPL never equals a real classifier such as "GNU General Public
License v2 or later (GPLv2+)", so the gate passed with a GPL package installed.
The docutils exemption is derived rather than accumulated. marimo depends on docutils,
which is offered under a choice of licences and reports all of them as one string --
"BSD License; GNU General Public License (GPL); Public Domain". pip-licenses has no
notion of or, so --partial-match fires on the copyleft option even where a
permissive one is taken.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/python.py
security
¶
Scan the source folder with bandit.
The scan scope lives in .bandit rather than in this argument list, so that every
runner -- this task, the pre-commit hook, CI -- sees the same one. --ini is passed
only when that file exists: python.mk passes it unconditionally, and bandit treats a
missing ini as a usage error, so a project without one gets a red gate reporting a
configuration problem as if it were a security finding.
security does not mean the same thing in all three layers, and the asymmetry is
inherited rather than introduced here. Rust runs cargo deny check advisories and Go
runs govulncheck ./... -- both scan dependencies against an advisory database.
Bandit is SAST: it lints the source this repository owns and never looks at what is
installed. So Python, which has the largest advisory surface of the three, is the one
layer whose security gate is not a dependency scan.
No pip-audit here is a decision taken upstream, not an omission: jebel-quant/rhiza
dropped it in #1416 along with rhiza-tools, and pins its absence with a test
(tests/docs/test_doc_consistency.py -- "pip-audit is deliberately not wired up;
this pins the fact the gate depends on"). This module is owned by this repository and
nothing syncs it, so adding a scan here is possible -- but it would put a gate in
consumers' CI that the template they also follow says is not there, and a transitive
advisory with no fix available would then fail a run the template would have passed.
Closing the gap belongs upstream, where both halves move together. Recorded here so
the next reader does not have to rediscover which of the two it is.
What that argument covers is the shipped task, and it is worth being precise about the
limit, because the paragraph above used to be the only note on the subject and so read as
"nothing anywhere audits dependencies". This repository does audit its own: weekly.yml
exports the committed lockfile and runs pip-audit over it on a schedule. Nothing about
that reaches a consumer -- no task name, no prerequisite of all, nothing a
uvx rhiza-task invocation can find -- which is exactly why it is a workflow job and not
the two lines it would take to add here.
So the honest summary is that the gap is closed for this repository and open for consumers, deliberately and in that order. If it is ever closed for consumers too, this is the place that changes, and the note above is the argument that has to be answered first.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/python.py
test
¶
Run the suite with coverage, retrying once on a pytest-internal teardown error.
This is the recipe that justifies a real language. In python.mk it is a 40-line shell
while :; do ... done inside a make recipe, with $$ escaping on every variable,
set -- used to build the argument list because make cannot hold an array, and the
retry condition spelled if [ $$status -ne 3 ]; then exit $$status; fi.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Failed
|
When pytest reports test failures, or reports an internal error twice. |
Source code in src/rhiza_task/tasks/python.py
typecheck
¶
Run the configured type checker(s) over the source folder.
The make recipe is a shell case with four branches, the fourth of which validates
the setting and errors. Validation moved to :meth:Config.__post_init__, so an
invalid value fails before a tool is provisioned, and what is left is a loop.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/python.py
Rust¶
rhiza_task.tasks.rust
¶
The Rust language layer: rust.mk, as tasks.
The gate names are python.mk's, deliberately: install, test, coverage,
typecheck, docs-coverage, security, license, deps, all. That is
the contract the reusable workflows and book depend on -- rhiza_ci.yml calls
make typecheck without knowing what the repository is written in, and rust.mk's own
header says so. Only the engine differs.
Nothing here goes through uv. cargo is not a Python tool and rustup is not a uv-managed
toolchain, so the provisioning half of the make recipe has no analogue: what is left is
:func:~rhiza_task.uv.tool, an argument vector, and the guards.
CARGO_TOOLS
module-attribute
¶
The cargo subcommands the gates need, in rust.mk's order.
A named tuple rather than a literal in the recipe, for the reason
:data:~rhiza_task.tasks.python.PYTEST_WITHS is one: what a gate provisions is part of
its contract, and this is the only place CI can assert on it.
MANIFEST
module-attribute
¶
MANIFEST = Guard(file='Cargo.toml', reason='no Cargo.toml')
What every Rust gate is guarded on.
A file rather than a folder: cargo finds src/ itself from the manifest, and a crate
that renames it is still a crate. This is the flat analogue of python.mk's
if [ -d ${SOURCE_FOLDER} ].
all_
¶
Aggregate, with rust.mk's prerequisite list. The body is empty because needs is it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
Unused; the prerequisites do the work. |
required |
Source code in src/rhiza_task/tasks/rust.py
cargo_tools
¶
Install the missing cargo subcommands, via cargo-binstall where it helps.
binstall fetches a prebuilt binary where the project publishes one and falls back to a source build, which is the difference between seconds and minutes on CI.
The one subtlety, carried over from rust.mk rather than rediscovered: cargo install
puts binaries in $CARGO_HOME/bin, which is not necessarily on PATH --
brew install rustup leaves the shims in Homebrew's bin and never links
~/.cargo/bin. cargo resolves cargo <sub> by searching that directory as well as
PATH, so the gates work either way; what does not work is a bare command -v
cargo-nextest. So presence is probed in both places, and binstall is invoked as a
cargo subcommand.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/rust.py
coverage
¶
Measure coverage with cargo-llvm-cov, enforcing the same floor the Python layer has.
Cobertura XML at exactly _tests/coverage.xml, which is not a detail: it is the path
book.mk's badge step reads, so a Rust project gets a measured coverage badge on its
docs site for the same reason a Python one does.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/rust.py
deps
¶
Run cargo-machete, the deptry analogue.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/rust.py
docs_coverage
¶
Build the docs with missing_docs denied.
interrogate's 100% floor expressed in rustdoc's own terms: pass/fail on an undocumented public item rather than a percentage, because rustdoc has no percentage to report.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/rust.py
install
¶
Materialise the pinned toolchain, fetch dependencies, install the git hooks.
rustup show is what materialises rust-toolchain.toml's channel and components,
because rustup installs a pinned toolchain lazily -- so this is a provisioning step
despite reading like a query.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Failed
|
When rustup is absent, or a step exits non-zero. |
Source code in src/rhiza_task/tasks/rust.py
license_
¶
Run cargo deny check licenses.
The allow-list lives in deny.toml rather than in this argument vector, which is why
license_fail_on -- pip-licenses' flag, and Python-only -- does not appear here. No
guard on that file: cargo-deny falls back to its own defaults and says so, and a gate
that skipped instead would be the "green gate measuring nothing" this port exists to
stop shipping.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/rust.py
security
¶
Run cargo deny check advisories.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/rust.py
test
¶
Run cargo nextest over all targets, then cargo test --doc.
Both, not either: nextest does not run doctests, and a doctest is a real test. This is
the Rust analogue of the retry loop in python.mk being the interesting part of test
-- here the interesting part is that one command is not enough.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/rust.py
typecheck
¶
Run clippy over all targets with warnings denied.
rustc already type-checks, so the parity entry for typecheck is the lint that
catches what compiling does not -- the same relationship go vet has to the Go
compiler.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/rust.py
Go¶
rhiza_task.tasks.go
¶
The Go language layer: go.mk, as tasks.
The third sibling of python.py and rust.py, with the same gate names for the same reason:
rhiza_ci.yml calls make security without knowing the language, and book
consumes _tests/ whatever produced it.
Two differences from the Rust layer are Go's own, not this port's. There is no rustup
step, because go.mod's go and toolchain directives make the go command
download a matching toolchain itself. And the helper tools are ordinary modules installed
with go install rather than cargo subcommands, so they land in a directory this module
has to name -- bin/, the same one the Makefile shim provisions uv into, rather than
whatever the developer's GOPATH happens to be.
COVERAGE_PROFILE
module-attribute
¶
Where go test -coverprofile writes, spelled with forward slashes on every OS.
Not Path.relative_to: this string is an argument to go, not a filesystem operation,
and a backslash-separated path is a different argument. go accepts the forward-slash
spelling on Windows, and the gates run there.
GO_TOOLS
module-attribute
¶
GO_TOOLS = ('github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest', 'golang.org/x/vuln/cmd/govulncheck@latest', 'github.com/google/go-licenses@latest', 'github.com/boumenot/gocover-cobertura@latest', 'github.com/mgechev/revive@latest')
What go-tools installs, as go.mk lists them.
The versions are @latest because go.mk's are: it holds each in its own
*_VERSION ?= variable so that Renovate has one line to bump, and every one of those
lines currently says latest. Pinning them is a decision for the template to make in
one place, not for this port to make silently on the way past.
MANIFEST
module-attribute
¶
MANIFEST = Guard(file='go.mod', reason='no go.mod')
What every Go gate is guarded on: the module file, not a source folder.
all_
¶
Aggregate, with go.mk's prerequisite list. The body is empty because needs is it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
Unused; the prerequisites do the work. |
required |
Source code in src/rhiza_task/tasks/go.py
coverage
¶
Measure coverage, convert it to Cobertura, and enforce the floor.
Three steps because Go's tooling splits them, and a fourth thing go.mk does in awk:
go test has no --fail-under, so the floor is enforced by reading the total:
line out of go tool cover -func. That awk one-liner is the whole reason this is a
task body rather than three argument vectors.
-covermode=atomic because the default set mode is not race-safe and test
runs a race build.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Failed
|
When coverage is below |
Source code in src/rhiza_task/tasks/go.py
deps
¶
Run go mod tidy -diff.
Both halves of deptry's job in one command, and no tool to install: it reports what tidy would change -- an unused requirement or a missing one -- and exits non-zero.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/go.py
docs_coverage
¶
Run revive's exported rule over the module.
The closest analogue of interrogate that Go has: pass/fail on a missing doc comment
rather than a percentage, exactly as rust-core's -D missing_docs is. revive.toml
is what enables that rule and no other, so its absence is a configuration gap rather
than something to paper over -- revive says so itself.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/go.py
go_tools
¶
Install each missing tool into the repository's bin/.
GOBIN rather than the developer's GOPATH, so a gate never depends on what
happens to be installed globally -- go.mk's reason, and the same directory the Makefile
shim uses for uv.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/go.py
install
¶
Download the module's dependencies and install the git hooks.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Failed
|
When go is absent, or a step exits non-zero. |
Source code in src/rhiza_task/tasks/go.py
license_
¶
Run go-licenses, ignoring the module's own packages.
--ignore $(go list -m) is the load-bearing part, and it was found by rhiza's e2e
suite rather than by reading the tool's help: go-licenses walks the project's own
packages alongside its dependencies, so without it a repo with no LICENSE file of its
own fails the gate on itself -- which every freshly synced project is.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/go.py
security
¶
Run govulncheck over the module.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/go.py
test
¶
Run go test ./... with the race detector and shuffled order.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/go.py
typecheck
¶
Run go vet and golangci-lint.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/go.py
Quality¶
rhiza_task.tasks.quality
¶
The language-neutral gates: quality.mk and bootstrap.mk's clean.
Nothing here needs to know how the project declares its dependencies, which is why the
template made core rather than a language layer own them.
The interesting one is rhiza-test. quality.mk runs pytest .rhiza/tests -- a folder
synced from the template -- and prints a WARN and exits 0 when the folder is absent. Since
that folder was replaced by the pytest-rhiza distribution, consumers who excluded it
got a green gate measuring nothing, and jointview carries a 60-line Makefile override to
fix that for itself. Here the plugin is the implementation, so the override is not
needed and the silent-pass branch does not exist.
docs-examples is registered here and implemented in :mod:rhiza_task.tasks.fences.
The split is worth knowing rather than discovering: the checker had grown to two thirds of
this module and pulled its maintainability index from 62 to 36, at which point the docstring
above described half a file. The task keeps the argument for why the gate exists, which is
what a reader looking for a gate wants; that module holds the argument for how it checks.
TAG_VERSION_CHECK
module-attribute
¶
pytest-rhiza's assertion that the newest tag equals the declared version.
Correct about a released tree and false by construction during a release, which is the
window :func:_release_pending exists to detect. A repository cannot satisfy it between the
version bump and the tag: the bump is what the release PR contains, and the tag is cut from
that PR's merge commit, so for the length of the PR the declared version is ahead of every
tag that exists.
What that costs is a red rhiza-task all on the releaser's own machine for the length of the
release, which is where it was hit while cutting v1.1.0.
It costs nothing in CI, and the note this replaces claimed otherwise. ci.yml's checkout sets
no fetch-depth and no fetch-tags, so no CI job has any tags at all and this check already
skips there -- No version tags found in repository. The required gates job was therefore
never blocked by it, and the assertion that v1.0.0's release PR had been merged red was inferred
from a local run rather than read off a CI one. Both claims were wrong, and they were wrong in
the direction that made this change look more necessary than it is. Recorded rather than quietly
deleted, because the overstatement shipped. See #115.
clean
¶
Remove ignored files, build artifacts, and local branches whose remote is gone.
.env files are preserved: they hold local configuration that is expensive to
reconstruct and is not an artifact.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/quality.py
docs_examples
¶
Parse every checkable fence under the docs folder, and diff the executed ones.
The gap this closes: docs-coverage asks whether a docstring exists and
markdownlint asks whether the markdown is well-formed. Neither asks whether what the
documentation claims is still true, and a stale command keeps rendering perfectly --
so the reader who finds out is a newcomer, at the worst moment. README.md was already
covered, by pytest-rhiza's test_readme_validation under :func:rhiza_test; the docs
tree had nothing, and it is the larger half.
Not a second check of README.md, deliberately: that file is pytest-rhiza's subject,
and counting one verdict twice would make two gates report one fact.
Which languages are checked, how, and why two of them can go unavailable on a working
machine all live in :mod:rhiza_task.tasks.fences, which holds the checker. This is the
registration and the argument for the gate; that module is the implementation.
install is a prerequisite because the executed half imports the project's own
packages, exactly as :func:rhiza_test's docstring check does.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When the tree holds no checkable fence, so nothing was measured. A docs tree documenting nothing runnable would otherwise score a silent pass, which is the failure this gate exists to make visible. |
Failed
|
When at least one example is broken or stale. |
Source code in src/rhiza_task/tasks/quality.py
fmt
¶
Run every configured hook via prek.
--config is not decoration. By default prek treats every directory below the root
holding a .pre-commit-config.yaml as a separate project and runs each one's hooks
-- useful in a monorepo, wrong in rhiza's own repo where three bundles ship one as
template content. Naming the config disables that discovery, so fmt means "this
repo's config, once". A consumer wanting the monorepo behaviour drops the flag here and
in the hook install.
prek rather than pre-commit: a Rust reimplementation reading the same config file,
which provisions each hook's toolchain itself. That is what removed the
-p ${PYTHON_VERSION} this recipe used to need, and with it the coupling that made
the language-neutral half of the template depend on a Python version being resolvable.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When the project has no pre-commit config. |
Source code in src/rhiza_task/tasks/quality.py
install_hooks
¶
Install the prek git hooks unless an external manager owns core.hooksPath.
Neutral, and here rather than in a language module, because all three install
recipes carry it verbatim -- python.mk, rust.mk and go.mk each end with the same
twelve lines of shell. prek provisions each hook's own toolchain, so there is nothing
language-specific left in it.
-c must be passed here and in :func:fmt: prek bakes the flag into the generated
shim, so without it the commit-time gate rediscovers nested projects and stops meaning
what fmt means.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/quality.py
rhiza_test
¶
Run the pytest-rhiza checks against this repository.
The check modules are enumerated rather than globbed: pytest-rhiza ships the Rust and
Go modules in the same distribution, so --pyargs pytest_rhiza.checks would collect
checks that cannot pass on a Python project. See
:data:~rhiza_task.config.DEFAULT_RHIZA_CHECKS.
install is a prerequisite because the docstring check imports the project's own
packages to run their doctests, which needs the dependencies present.
RHIZA_DOCTEST_FOLDERS is what tells test_docstrings where to look, and it has
to be passed: the check falls back to SOURCE_FOLDER in .rhiza/.env and then to
a literal src, so a repo whose Python lives anywhere else got
SKIPPED No doctest folder found (looked for: src) and a green gate -- the
doctests went unchecked with nothing failing to say so. .rhiza/.env cannot cover
for it either: since rhiza stopped shipping .rhiza/.gitignore, whose only content
was the !.env negation, that file is gitignored and a CI checkout never has one.
quality.mk exported the variable from DOCSTRING_FOLDERS; this is that export.
One check is dropped while a release is in flight. :data:TAG_VERSION_CHECK asserts that
the newest tag equals the declared version, which a repository cannot satisfy between its
version bump and its tag -- so rhiza-task all, which a developer runs before pushing,
went red for the length of a release. :func:_release_pending detects the window from the
repository's own state, so nothing has to be passed in and the check returns by itself once
the tag exists. It is a local improvement only: CI has no tags, so this check skips there
regardless -- see that constant's own note.
The pin the checks are provisioned from is :func:_provider's answer rather than the
setting itself, so a repository can spell "resolve them from my own environment".
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/quality.py
semgrep
¶
Run semgrep against the source folder with rhiza's rule set.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When the rule file is absent. |
Source code in src/rhiza_task/tasks/quality.py
test_pyproject
¶
Run just the pyproject check, with full reporting.
A narrower, louder view of one module that rhiza-test also runs -- kept because it
is what you want when that check is the thing you are fixing. The reporting flags are
python.mk's verbatim, and the provider is :func:_provider's, so the two gates agree on
where the checks come from.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/quality.py
todos
¶
Report TODO/FIXME/HACK comments with file and line.
quality.mk implements this as find -print0 | xargs -0 grep -nHE | grep -v | awk,
with a grep -v "make todos" filter to stop the recipe matching itself. Reading the
files directly needs no such filter and no shell.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/quality.py
Testing extras¶
rhiza_task.tasks.extras
¶
The optional testing extras: test.mk, as tasks.
Three gates no all depends on -- book is the one aggregate that names them, for
their reports -- each needing its own tool and folder convention. They stay separate from
the language layer for the reason test.mk gives: a project should be able to take the Python
gate set without also declaring an opinion on benchmarks, stress runs or property-based
testing.
Each body is one vector plus, for hypothesis-test, a single exit code: pytest's "no
tests collected", which is a skip rather than a failure for a project that has none.
PYTEST_NO_TESTS_COLLECTED
module-attribute
¶
pytest's "no tests collected".
For hypothesis-test this is a skip, not a failure: a project with no property-based
tests is a valid project, and the marker expression legitimately matches nothing.
benchmark
¶
Run pytest-benchmark over tests/benchmarks, writing a histogram and JSON.
The two pins are test.mk's, kept exact: benchmark results are only comparable across runs of the same tool version.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/extras.py
hypothesis_test
¶
Run the Hypothesis-marked tests with statistics and a fixed seed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Failed
|
When a property test fails. |
Source code in src/rhiza_task/tasks/extras.py
stress
¶
Run the stress-marked tests.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/extras.py
Book and notebooks¶
rhiza_task.tasks.book
¶
The book and notebook tasks: book.mk and marimo.mk, as tasks.
book is the third recipe that resists the declarative form: it aggregates the report-
producing gates, copies their output into the docs tree, exports every notebook, builds
the site, and generates a coverage badge.
The one artefact it does not copy is the paper's PDF. tectonic writes it beside its
source, and paper_folder is already inside docs_dir, so the site build finds it
where it lies -- a prerequisite plus a nav entry, and no plumbing.
Its prerequisite list is also where make's no-op stubs came from. book.mk has to declare
test:: ; @:, benchmark:: ; @:, stress:: ; @: and hypothesis-test:: ; @:
so that book can depend on gates the tests bundle may not have contributed. The
runner skips unregistered prerequisites, so all four stubs are gone.
SCRUBBED_SUFFIXES
module-attribute
¶
Which report files are rewritten. Text formats only, so no binary is touched.
book
¶
Build the MkDocs/Zensical site, with test reports and notebooks folded in.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When there is no |
Source code in src/rhiza_task/tasks/book.py
book_nav
¶
Fail when mkdocs.yml names a nav target the built site does not contain.
The gap this closes, and it is a published one rather than a hypothetical: zensical
reports No issues found for a nav entry whose page does not exist and for one whose
asset does not exist. So - Paper: paper/paper.pdf survived a build in which
rhiza-task paper had skipped for want of an engine, and the site deployed with a 404 in
its own navigation, green the whole way. Every other gate here asks about the source; this
is the only one that asks whether what was published holds together.
Not a prerequisite of :func:book, deliberately. Half the nav entries in a repository
like this one resolve only after the gates that produce them have run -- the two
reports/ pages need a _tests/ tree, the paper needs tectonic -- and a
repository without it must keep building its book, which is exactly what a skipped
prerequisite buys. Making that a failure would break every consumer that documents a
paper it cannot compile locally. So this is a separate gate, named by rhiza_book.yml
on the ref it deploys, where the entries are supposed to be complete and a dangling one is
a defect rather than a machine's shape.
Markdown targets are resolved through :func:_built_candidates; assets are matched
verbatim.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When the book has not been built, or |
Failed
|
When at least one nav target is missing from the built site. |
Source code in src/rhiza_task/tasks/book.py
marimo
¶
Start a headless Marimo server on the notebook folder.
--no-project is marimo.mk's: the editor runs against its own provisioned marimo
rather than the project environment.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/book.py
marimo_validate
¶
Run each notebook as a script, reporting per-notebook pass or fail.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When the folder holds no notebooks. |
Failed
|
When any notebook fails to run. |
Source code in src/rhiza_task/tasks/book.py
serve
¶
Serve the built book over HTTP.
Python's own server rather than an editor's built-in one, because the JetBrains server
refuses to serve gitignored directories and _book is one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/book.py
Dev¶
rhiza_task.tasks.doctor
¶
Prerequisite diagnostics: doctor.mk, as a task.
The fourth recipe that resists the declarative form. In doctor.mk it is 69 lines of shell
containing two functions defined inside a make recipe -- version_ge, which is an awk
program comparing dotted versions component by component, and check_tool, which takes
five positional arguments including a quoted shell command to eval for extracting the
version. The escaping is such that the awk field references appear as \\$$i.
The change of substance is which tools it asks about at all. doctor.mk probes GNU make,
because the whole task layer was make. It is not probed here, and neither is anything else
beyond uv and git -- the two a process running uvx rhiza-task genuinely cannot do
without.
That is a design boundary rather than a short list. Optionality is what
:class:~rhiza_task.spec.Guard is for: docker, gh, git-lfs, tectonic and marp are each
declared as a precondition on the task that wraps them, and a missing one reports itself on
the skipped line of the gate that wanted it, with the install URL in its reason. A
diagnostic that also enumerated them would answer the same question one indirection further
from where it matters, and would need updating every time a bundle gained a tool.
So this task has one tier, not two: everything it names is required, and a miss is a
failure. make was the last inhabitant of the optional tier -- reported as a warning for
the sake of a repo-owned Makefile forwarding to the CLI -- and the tier went with it. If a
genuinely optional core prerequisite ever appears, that is an edit here rather than a
mechanism to keep warm for it.
Tool
dataclass
¶
A prerequisite, its minimum version, and where to get it.
Every entry is required; see the module docstring for why there is no optional tier.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
Executable name. |
minimum |
str
|
Lowest acceptable dotted version. |
url |
str
|
Install instructions, printed when it is missing. |
at_least
¶
Compare dotted versions, padding the shorter one with zeros.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
found
|
tuple[int, ...]
|
The installed version. |
required |
minimum
|
str
|
The required version, dotted. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True when |
Source code in src/rhiza_task/tasks/doctor.py
doctor
¶
Report on each prerequisite, failing when a required one is missing or too old.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Failed
|
When a required tool is missing or below its minimum version. |
Source code in src/rhiza_task/tasks/doctor.py
parse_version
¶
Extract the first dotted version from a tool's --version output.
Replaces doctor.mk's per-tool awk extraction commands -- uv --version | awk 'NR==1
{print $$2}' and the rest -- with one regex, because every tool in TOOLS prints
its version as the first dotted number on the first line.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
text
|
str
|
The raw |
required |
Returns:
| Type | Description |
|---|---|
tuple[int, ...]
|
The version as a tuple of ints, empty when none was found. |
Source code in src/rhiza_task/tasks/doctor.py
GitHub helpers¶
rhiza_task.tasks.github
¶
The GitHub helpers: github.mk, as tasks.
Six thin wrappers over gh, and the reason the fragment could not retire with the
other ten: github is in the github-project profile, so a consumer on the flagship
profile would have lost make view-prs.
Nothing here is a gate. No aggregate names them, no workflow invokes them, and they
produce a table for a human at a prompt -- which is why the gh templates are carried over
character for character rather than reimplemented against --json. Reproducing
timeago and gh's colour handling in Python would be a worse table and a new thing to
maintain.
Two shapes from the fragment disappear:
require-gh and gh-install were both "is gh installed?", spelled twice because make
has no way to say it once -- one hard-failing as a prerequisite, one warning as a target a
human runs. :class:~rhiza_task.spec.Guard's tool field says it once, and the
outcome is a skip with the install URL attached. gh-install as a task goes: it never
installed anything, and rhiza-task doctor is where "what is missing on this machine"
belongs.
FORGE_TYPE goes too. github.mk computes it at parse time from the presence of
.github/workflows or .gitlab-ci.yml and then no target in the fragment -- or in
any other fragment -- ever reads it.
HAVE_GH
module-attribute
¶
HAVE_GH = Guard(tool='gh', reason='gh not found; install from https://github.com/cli/cli#installation')
The single spelling of require-gh, shared by every task in this module.
RELEASE_WORKFLOW_JQ
module-attribute
¶
github.mk's own filter: the first workflow whose name mentions "release", any case.
failed_workflows
¶
Show the ten most recent runs that concluded in failure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/github.py
latest_release
¶
Print tag, author, publication time and status for the newest release.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When the repository has published no release. |
Source code in src/rhiza_task/tasks/github.py
view_issues
¶
List the repository's open issues as a table.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/github.py
view_prs
¶
List the repository's open pull requests as a table.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/github.py
whoami
¶
Report which account gh is authenticated as, and with what scopes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/github.py
workflow_status
¶
Find the release workflow by name, then show its five most recent runs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When no workflow's name mentions "release". |
Source code in src/rhiza_task/tasks/github.py
Docker¶
rhiza_task.tasks.docker
¶
The container tasks: docker.mk, as tasks.
Three wrappers over the docker CLI, and the shortest of the five fragments. The only
thing worth stating is what the image is called: docker.mk defaults it to
$(shell basename $(CURDIR)), so an unset :attr:~rhiza_task.config.Config.docker_image
resolves to the repository directory's name here too -- moving a checkout would rename the
image, which is surprising but is the behaviour consumers already have.
docker-build skips rather than fails on a missing Dockerfile, as the fragment does.
That is not the same judgement as the tool guard's: a repository with no docker/
folder has adopted the bundle and not used it yet, whereas a machine with no docker
cannot answer the question at all. Both are a skip, and --strict fails both.
docker_build
¶
Build <docker_folder>/Dockerfile with the repository root as the context.
PYTHON_VERSION is passed as a build argument whatever the layer, as docker.mk
does. A Dockerfile that declares no such ARG gets a warning from docker and
nothing else, which is cheaper than making the flag conditional on a language.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When the folder holds no Dockerfile. |
Source code in src/rhiza_task/tasks/docker.py
docker_clean
¶
Delete the image, tolerating its absence.
check=False is docker.mk's 2>/dev/null || true: removing an image that was
never built is the expected state of a clean target, not a failure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/docker.py
docker_run
¶
Run the built image interactively, removing the container on exit.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/docker.py
Git LFS¶
rhiza_task.tasks.lfs
¶
The Git LFS tasks: lfs.mk, as tasks.
Three of the four are one git subcommand each. The fourth, lfs-install, is 50 lines of
platform shell and is deliberately not ported as written -- what it did and what it is
now differ, so the change is stated here rather than discovered.
lfs.mk's lfs-install has two branches. On Linux it runs apt-get install git-lfs,
with sudo when not root. On macOS it queries the GitHub releases API for the newest
git-lfs, downloads the architecture-matched zip into .local/tmp, extracts the binary
into .local/bin, and runs PATH=$PWD/.local/bin:$PATH git-lfs install.
The macOS branch does not leave a working installation. .local/bin is not on PATH
after the recipe exits, and every other target in the fragment -- and every git lfs
anywhere else -- invokes the bare command, so make lfs-install && make lfs-pull
fails on a machine that had no git-lfs. The one thing the branch achieves that survives is
the git lfs install at the end, which writes the filter and hook configuration into
the repository.
So this task does that part, and reports how to install the binary rather than
downloading one. Two reasons beyond the broken branch: a task runner provisioned by
uvx should not be shelling out to sudo apt-get as a side effect of a target
someone typed, and pinning a download URL to a release-API shape is a maintenance
liability for something brew/apt/winget all do properly.
Consumers who relied on the apt branch need one line of their own -- in CI, the
setup-git-lfs action or the distribution's package; locally, their package manager.
HAVE_LFS
module-attribute
¶
HAVE_LFS = Guard(tool='git-lfs', reason=f'git-lfs not found; see {INSTALL_URL}')
git lfs <cmd> needs the git-lfs binary on PATH; git reports it as an unknown
command otherwise, which is a confusing way to learn that a tool is missing.
INSTALL_HINTS
module-attribute
¶
INSTALL_HINTS = {'darwin': 'brew install git-lfs', 'linux': "sudo apt-get install git-lfs (or your distribution's package)", 'win32': 'winget install GitHub.GitLFS'}
How to get the binary, by :data:sys.platform. Reported, never run.
install_hint
¶
Return the platform's install command, for the message a missing binary produces.
Returns:
| Type | Description |
|---|---|
str
|
A command to run, or the project's install page when the platform is unknown. |
Source code in src/rhiza_task/tasks/lfs.py
lfs_install
¶
Run git lfs install, writing this repository's filter and hook configuration.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Failed
|
When the git-lfs binary is absent. A failure rather than a skip, unlike every other tool guard in this module's siblings: installing is the one thing this task exists to do, so it has nothing left to report success about. |
Source code in src/rhiza_task/tasks/lfs.py
lfs_pull
¶
Fetch and check out the LFS objects the working tree points at.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/lfs.py
lfs_status
¶
Show which LFS files are modified, staged, or not yet pushed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/lfs.py
Paper¶
rhiza_task.tasks.paper
¶
The LaTeX tasks: paper.mk, as tasks.
Closer in shape to book than to the CLI wrappers: a build with an output worth
naming. The engine does the hard part -- it reruns the TeX pass and bibtex until the
citations and cross-references converge -- so the task is a folder, a file choice and a
fixed flag set.
The engine is tectonic, which is the one substantive change from paper.mk. paper.mk drove a full TeX distribution, so a consumer provisioned the distribution and the list of packages their document happened to cite, and the two workflows here each carried their own copy of that list. tectonic is a single binary that resolves what a document cites out of its web bundle and caches it, so there is one tool to install and no list to keep in step. Three consequences the flag set below records rather than restates:
- Convergence and bibtex are the engine's own loop, not a driver's, so neither is asked for -- the argument vector is the document and nothing about how to build it.
- There is no interaction mode to pin. tectonic never stops for a prompt, so it needs no
flag saying so; a broken document exits non-zero, which :func:
~rhiza_task.uv.toolturns into :class:~rhiza_task.spec.Failed. - A cold cache needs the network. A provisioned distribution did not, and that is the one thing this trade costs; the cache is per-machine and survives between runs, so it is a first-run cost rather than a per-build one.
The file choice is the other thing that changed, and it changed earlier. paper.mk reads
if [ -f $(PAPER_DIR)/basanos.tex ]; then tex_file="basanos.tex"; else <first *.tex>; fi
-- a named preference for one downstream repository's paper, in a template every consumer
syncs. :func:main_document replaces it with two conventional names and then alphabetical
order, so the behaviour is the same for a folder with one .tex (the overwhelmingly
common case) and no longer privileges a stranger's filename.
-maxdepth 1 survives as :meth:~pathlib.Path.glob rather than
:meth:~pathlib.Path.rglob, and deliberately: a LaTeX project's subdirectories hold
included chapters, and the engine must be pointed at the root document, not at a chapter.
AUX_SUFFIXES
module-attribute
¶
What a TeX run leaves beside the document, mirroring .gitignore's list for this folder.
The PDF is deliberately absent: this is the set that is never worth keeping, and both
callers want it -- :func:paper_clean adds the PDF because removing the output is the
point of a clean, and book's prune keeps the PDF because publishing it is the point of
the build.
These are the names TeX itself writes. A driver's own bookkeeping files -- the
rebuild-cache and file-list a make-style LaTeX driver keeps -- are not listed, because no
driver runs here: tectonic is the whole engine and writes the .log (asked for below)
and, only when asked, the rest.
Matched as name suffixes rather than through :attr:~pathlib.PurePath.suffix, because
.synctex.gz is two extensions and suffix would report only .gz.
PREFERRED
module-attribute
¶
Root-document names tried before falling back to alphabetical order.
main_document
¶
Choose the root .tex file in a folder.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
folder
|
Path
|
The paper folder. |
required |
Returns:
| Type | Description |
|---|---|
Path | None
|
The document to compile, or None when the folder holds no top-level |
Source code in src/rhiza_task/tasks/paper.py
paper
¶
Run tectonic over the paper folder's root document.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When the folder holds no top-level |
Source code in src/rhiza_task/tasks/paper.py
paper_clean
¶
Remove the PDF and auxiliary files belonging to the folder's top-level documents.
Pure Python, and unguarded on any tool: tectonic has no clean subcommand to delegate to, so there is nothing to be absent. That makes this the one task in the section that works on a machine which cannot build the paper at all -- an improvement over delegating, where cleaning required the very toolchain you were cleaning up after.
Scoped by document stem, not by extension sweep. paper.tex authorises deleting
paper.pdf and paper.log; a figures/ diagram exported to diagram.pdf and
committed beside the source has no diagram.tex and survives. An extension sweep
would be one line shorter and would delete a consumer's checked-in artwork, which is
not recoverable by rebuilding.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Raises:
| Type | Description |
|---|---|
Skip
|
When there is no paper folder to clean. |
Source code in src/rhiza_task/tasks/paper.py
Presentation¶
rhiza_task.tasks.presentation
¶
The Marp tasks: presentation.mk, as tasks.
The fragment's require-marp does not check for Marp, it installs it:
if ! command -v marp; then npm install -g @marp-team/marp-cli; fi
-- a global npm install, triggered by typing make presentation, changing a machine
outside the repository. :func:marp_argv keeps the property that made that acceptable (a
consumer with Node but no Marp can still build slides) without that side effect:
npx --yes runs the CLI from npm's cache instead. The precedence is Marp on PATH first,
so a deliberately installed or pinned Marp still wins.
:attr:~rhiza_task.config.Config.marp_package is what npx is given, unpinned by default
because npm install -g @marp-team/marp-cli was unpinned too. Pin it to
@marp-team/marp-cli@4.2.3 when reproducible slides matter more than current ones.
PRESENTATION.md becomes a setting rather than a constant, and the output name is
derived from it -- lower-cased, so the default still produces presentation.html and
presentation.pdf exactly as the fragment does.
marp_argv
¶
Resolve how to reach the Marp CLI on this machine.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Returns:
| Type | Description |
|---|---|
tuple[str, tuple[str, ...]]
|
The executable to run and the arguments that must precede Marp's own. |
Raises:
| Type | Description |
|---|---|
Skip
|
When neither marp nor npx is available. |
Source code in src/rhiza_task/tasks/presentation.py
output
¶
Return the output filename for a format.
Lower-cased so that the default PRESENTATION.md yields presentation.html,
which is the name presentation.mk hard-codes and the one a consumer's .gitignore
and links already point at.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
suffix
|
str
|
The output extension, with its dot. |
required |
Returns:
| Type | Description |
|---|---|
str
|
A repository-relative filename. |
Source code in src/rhiza_task/tasks/presentation.py
presentation
¶
Export the deck to a single HTML file.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/presentation.py
presentation_pdf
¶
Export the deck to PDF.
--allow-local-files is presentation.mk's and is required rather than optional:
Marp renders the PDF through headless Chrome, which refuses file:// images
without it, so a deck with a local logo silently loses it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/presentation.py
presentation_serve
¶
Start Marp's watching server over the repository.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Source code in src/rhiza_task/tasks/presentation.py
source
¶
Return the slide deck's source file.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cfg
|
Config
|
The resolved config. |
required |
Returns:
| Type | Description |
|---|---|
Path
|
The absolute path to the configured Markdown file. |
Raises:
| Type | Description |
|---|---|
Skip
|
When the file does not exist. |