Skip to content

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:

  1. A guard -- if [ -d ${SOURCE_FOLDER} ], or a find for test files. When it fails the recipe prints a yellow WARN and exits 0.
  2. A provision -- uvx <tool> or uv run --with a --with b <tool>.
  3. 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

REGISTRY = {}

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

Failed(code, detail='')

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
def __init__(self, code: int, detail: str = "") -> None:
    """Store the exit status so the CLI can propagate it.

    Args:
        code: The failing process's exit status.
        detail: Human-readable context.
    """
    super().__init__(detail or f"exit {code}")
    self.code = code

Guard dataclass

Guard(folder=None, glob=None, reason='', file=None, tool=None)

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

check(root, folders)

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. {"source_folder": "src"}.

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 glob.

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
def check(self, root: Path, folders: dict[str, str]) -> None:
    """Raise :class:`Skip` when the guard is not satisfied.

    Args:
        root: Repository root.
        folders: Resolved folder settings, e.g. ``{"source_folder": "src"}``.

    Raises:
        Skip: When the tool is absent, or the file is missing, or the folder is
            missing, or the folder holds no file matching ``glob``.

    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()
    """
    # The first unmet precondition wins, so `reason` overrides whichever message that
    # clause generated -- see the note above the class for why the clauses live in a
    # module-level generator rather than in this body or in a second method.
    for unmet, message in _clauses(self, root, folders):
        if unmet:
            raise Skip(self.reason or message)

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

Task(name, help, section, run, needs=(), guards=(), hidden=False, layer=None)

One gate: the unit the CLI exposes and the reusable workflows invoke.

Attributes:

Name Type Description
name str

The command name, e.g. test. Deliberately identical to the retired make target, so the Makefile shim and a consumer's muscle memory need no translation table.

layer str | None

python, rust, go, or None for a language-neutral task. Three layers can define test; which one answers is decided per repository by :func:lookup, not by which bundle happened to be synced.

help str

One line, shown by rhiza-task list. Replaces the ## convention that rhiza.mk parsed with awk.

section str

Help grouping. Replaces ##@.

run Callable[[Config], None]

The task body. Takes a config, returns nothing, raises :class:Failed or :class:Skip.

needs tuple[str, ...]

Tasks to run first. The runner dedupes within one invocation, which is what make gave for free and the reason install can be named by eleven tasks without being run eleven times.

guards tuple[Guard, ...]

Evaluated in order before the body.

hidden bool

Omit from list.

key property

key

Return the registry key: layer:name, or name when neutral.

Returns:

Type Description
str

The key this task is registered under.

have

have(tool)

Return whether tool is on PATH.

Parameters:

Name Type Description Default
tool str

Executable name.

required

Returns:

Type Description
bool

True when found.

Source code in src/rhiza_task/spec.py
def have(tool: str) -> bool:
    """Return whether ``tool`` is on PATH.

    Args:
        tool: Executable name.

    Returns:
        True when found.
    """
    return shutil.which(tool) is not None

key

key(name, layer=None)

Return the registry key for a task name in a layer.

Parameters:

Name Type Description Default
name str

The task name, e.g. test.

required
layer str | None

The layer, or None for a neutral task.

None

Returns:

Type Description
str

layer:name, or name when there is no layer.

Source code in src/rhiza_task/spec.py
def key(name: str, layer: str | None = None) -> str:
    """Return the registry key for a task name in a layer.

    Args:
        name: The task name, e.g. ``test``.
        layer: The layer, or None for a neutral task.

    Returns:
        ``layer:name``, or ``name`` when there is no layer.
    """
    return f"{layer}:{name}" if layer else name

lookup

lookup(name, layers=())

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 layer:name key.

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:

>>> lookup("fmt", ["rust"]).key
'fmt'
>>> lookup("cargo-tools", ["python"]) is None
True
Source code in src/rhiza_task/spec.py
def lookup(name: str, layers: Sequence[str] = ()) -> Task | None:
    """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.

    Args:
        name: A bare task name, or a ``layer:name`` key.
        layers: The active layers, most significant first.

    Returns:
        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:

        >>> lookup("fmt", ["rust"]).key
        'fmt'
        >>> lookup("cargo-tools", ["python"]) is None
        True
    """
    if ":" in name:
        return REGISTRY.get(name)
    for layer in layers:
        if (spec := REGISTRY.get(key(name, layer))) is not None:
            return spec
    return REGISTRY.get(name)

task

task(name, help, section, needs=(), guards=(), hidden=False, layer=None)

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 list.

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
def task(
    name: str,
    help: str,  # noqa: A002 - matches the CLI's own vocabulary
    section: str,
    needs: Sequence[str] = (),
    guards: Sequence[Guard] = (),
    hidden: bool = False,
    layer: str | None = None,
) -> Callable[[Callable[[Config], None]], Callable[[Config], None]]:
    """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.

    Args:
        name: Command name.
        help: One-line description.
        section: Help grouping.
        needs: Prerequisite task names.
        guards: Layout preconditions.
        hidden: Omit from ``list``.
        layer: The language layer this task belongs to, or None for a neutral task.

    Returns:
        The decorator.
    """

    def decorate(fn: Callable[[Config], None]) -> Callable[[Config], None]:
        """Add the task to the registry.

        Args:
            fn: The task body.

        Returns:
            ``fn``, unchanged.
        """
        spec = Task(
            name=name,
            help=help,
            section=section,
            run=fn,
            needs=tuple(needs),
            guards=tuple(guards),
            hidden=hidden,
            layer=layer,
        )
        REGISTRY[spec.key] = spec
        return fn

    return decorate

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

capture(name, *args, cwd)

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
def capture(name: str, *args: str, cwd: Path) -> str:
    """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.

    Args:
        name: The executable.
        *args: Its arguments.
        cwd: Working directory.

    Returns:
        Stripped stdout, or an empty string when the tool failed or is absent.
    """
    try:
        result = subprocess.run(  # noqa: S603  # nosec B603
            [shutil.which(name) or name, *args],
            cwd=cwd,
            capture_output=True,
            text=True,
            check=False,
        )
    except OSError:
        return ""
    return result.stdout.strip() if result.returncode == 0 else ""

tool

tool(name, *args, cwd, check=True, env=None)

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. RUSTDOCFLAGS.

None

Returns:

Type Description
int

The exit status.

Raises:

Type Description
Failed

When check and the tool exited non-zero.

Source code in src/rhiza_task/uv.py
def tool(
    name: str,
    *args: str,
    cwd: Path,
    check: bool = True,
    env: Mapping[str, str] | None = None,
) -> int:
    """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.

    Args:
        name: The executable, or an absolute path to one.
        *args: Its arguments.
        cwd: Working directory.
        check: Raise on non-zero rather than returning the status.
        env: Extra environment variables, e.g. ``RUSTDOCFLAGS``.

    Returns:
        The exit status.

    Raises:
        Failed: When ``check`` and the tool exited non-zero.
    """
    code = _run([shutil.which(name) or name, *args], cwd, env)
    if check and code:
        raise Failed(code, f"{Path(name).name} {args[0] if args else ''} failed".strip())
    return code

uv

uv(*args, cwd, check=True, env=None)

Run uv itself.

Parameters:

Name Type Description Default
*args str

uv subcommand and arguments, e.g. ("sync", "--frozen").

()
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 check and uv exited non-zero.

Source code in src/rhiza_task/uv.py
def uv(*args: str, cwd: Path, check: bool = True, env: Mapping[str, str] | None = None) -> int:
    """Run uv itself.

    Args:
        *args: uv subcommand and arguments, e.g. ``("sync", "--frozen")``.
        cwd: Working directory.
        check: Raise on non-zero rather than returning the status.
        env: Extra environment variables.

    Returns:
        The exit status.

    Raises:
        Failed: When ``check`` and uv exited non-zero.
    """
    code = _run([_bin("uv", "RHIZA_UV_BIN"), *args], cwd, env)
    if check and code:
        raise Failed(code, f"uv {args[0] if args else ''} failed")
    return code

uv_run

uv_run(tool, *args, cwd, withs=(), no_project=False, check=True, env=None)

Run a tool against the project environment via uv run --with.

Parameters:

Name Type Description Default
tool str

The executable, e.g. pytest.

required
*args str

Arguments for the tool.

()
cwd Path

Working directory.

required
withs Sequence[str]

Packages to inject, e.g. ("pytest", "pytest-cov").

()
no_project bool

Pass --no-project, for a tool that must not see the project environment. marimo.mk's marimo target is the one case.

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 check and the tool exited non-zero.

Source code in src/rhiza_task/uv.py
def uv_run(
    tool: str,
    *args: str,
    cwd: Path,
    withs: Sequence[str] = (),
    no_project: bool = False,
    check: bool = True,
    env: Mapping[str, str] | None = None,
) -> int:
    """Run a tool against the project environment via ``uv run --with``.

    Args:
        tool: The executable, e.g. ``pytest``.
        *args: Arguments for the tool.
        cwd: Working directory.
        withs: Packages to inject, e.g. ``("pytest", "pytest-cov")``.
        no_project: Pass ``--no-project``, for a tool that must not see the project
            environment. marimo.mk's ``marimo`` target is the one case.
        check: Raise on non-zero rather than returning the status.
        env: Extra environment variables.

    Returns:
        The exit status.

    Raises:
        Failed: When ``check`` and the tool exited non-zero.
    """
    argv = [_bin("uv", "RHIZA_UV_BIN"), "run"]
    if no_project:
        argv.append("--no-project")
    for w in withs:
        argv += ["--with", w]
    argv += [tool, *args]
    code = _run(argv, cwd, env)
    if check and code:
        raise Failed(code, f"{tool} failed")
    return code

uvx

uvx(tool, *args, cwd, withs=(), python=None, check=True, env=None)

Run an isolated tool via uvx.

Parameters:

Name Type Description Default
tool str

The tool spec, e.g. deptry or 'zensical>=0.0.36'.

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 MKDOCS_EXTRA_PACKAGES is the only current user.

()
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 -p ${PYTHON_VERSION}.

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 check and the tool exited non-zero.

Source code in src/rhiza_task/uv.py
def uvx(
    tool: str,
    *args: str,
    cwd: Path,
    withs: Sequence[str] = (),
    python: str | None = None,
    check: bool = True,
    env: Mapping[str, str] | None = None,
) -> int:
    """Run an isolated tool via ``uvx``.

    Args:
        tool: The tool spec, e.g. ``deptry`` or ``'zensical>=0.0.36'``.
        *args: Arguments for the tool.
        cwd: Working directory.
        withs: Extra packages injected into the tool's environment. book.mk's
            ``MKDOCS_EXTRA_PACKAGES`` is the only current user.
        python: 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 ``-p ${PYTHON_VERSION}``.
        check: Raise on non-zero rather than returning the status.
        env: Extra environment variables.

    Returns:
        The exit status.

    Raises:
        Failed: When ``check`` and the tool exited non-zero.
    """
    argv = [_bin("uvx", "RHIZA_UVX_BIN")]
    if python:
        argv += ["-p", python]
    for w in withs:
        argv += ["--with", w]
    argv += [tool, *args]
    code = _run(argv, cwd, env)
    if check and code:
        raise Failed(code, f"{tool} failed")
    return code

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:

  1. The dataclass defaults below.
  2. .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 !.env negation that kept this file tracked, so it falls under the shipped .gitignore's .env rule and a CI checkout never contains it.
  3. 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.
  4. [tool.rhiza-task] in the language manifest -- Cargo.toml, then pyproject.toml. This is the new home for what used to require editing a synced .mk file or shadowing a target. Cargo ignores unknown top-level tables, so the table is as harmless there as it is in pyproject.
  5. RHIZA_* (or bare make-style) environment variables.
  6. 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

DEFAULT_CI_OS_MATRIX = ('ubuntu-latest',)

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

LAYERS = ('python', 'rust', 'go')

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

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

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

folders

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__

__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 typechecker is not one of ty, mypy, both, coverage_fail_under is outside 0-100, complexity_max is below 1, layers names a layer that does not exist, :attr:root is not an existing directory, or a *_folder escapes it. Each helper below raises for its own settings and documents the message it uses.

Source code in src/rhiza_task/config.py
def __post_init__(self) -> None:
    """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:
        ValueError: When ``typechecker`` is not one of ty, mypy, both,
            ``coverage_fail_under`` is outside 0-100, ``complexity_max`` is below 1,
            ``layers`` names a layer that does not exist, :attr:`root` is not an
            existing directory, or a ``*_folder`` escapes it. Each helper below raises
            for its own settings and documents the message it uses.
    """
    self._coerce_sequence_fields()
    self._validate_layers()
    self._validate_typechecker()
    self._validate_coverage()
    self._validate_complexity_max()
    # Order matters, and only between these two: `_validate_folders` asks whether a
    # setting escapes the root, which presupposes there is a root to escape.
    self._validate_root()
    self._validate_folders()

field_for staticmethod

field_for(name)

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. RHIZA_CI_OS_MATRIX, SOURCE_FOLDER or rhiza-checks.

required

Returns:

Type Description
str

e.g. ci_os_matrix, source_folder, rhiza_checks.

Examples:

>>> Config.field_for("SOURCE_FOLDER"), Config.field_for("rhiza-checks")
('source_folder', 'rhiza_checks')
Source code in src/rhiza_task/config.py
@staticmethod
def field_for(name: str) -> str:
    """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.

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

    Returns:
        e.g. ``ci_os_matrix``, ``source_folder``, ``rhiza_checks``.

    Examples:
        >>> Config.field_for("SOURCE_FOLDER"), Config.field_for("rhiza-checks")
        ('source_folder', 'rhiza_checks')
    """
    lowered = name.lower().replace("-", "_")
    stripped = lowered.removeprefix("rhiza_")
    if stripped in _FIELD_NAMES or lowered not in _FIELD_NAMES:
        return stripped
    return lowered

load classmethod

load(root=None, **overrides)

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. None values are ignored so an unset flag does not shadow a configured value.

{}

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:

>>> cfg.uv_sync_args
('--group', 'test')

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:

>>> cfg.layers
('python',)
>>> cfg.rhiza_checks[-1]
'pytest_rhiza.checks.test_docstrings'

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
@classmethod
def load(cls, root: Path | None = None, **overrides: Any) -> Config:
    """Build a config by walking the six layers in order.

    Args:
        root: Repository root; defaults to the current directory.
        **overrides: Layer 5, the command-line flags. ``None`` values are ignored so
            an unset flag does not shadow a configured value.

    Returns:
        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:

        >>> cfg.uv_sync_args
        ('--group', 'test')

        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:

        >>> cfg.layers
        ('python',)
        >>> cfg.rhiza_checks[-1]
        'pytest_rhiza.checks.test_docstrings'

        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')
    """
    root = (root or Path.cwd()).absolute()
    raw: dict[str, Any] = {}
    raw.update(_from_env_file(root / ".rhiza" / ".env"))
    raw.update(_from_rhiza_toml(root / "rhiza.toml"))
    # Cargo before pyproject, so a repo carrying both -- a Rust crate with a Python
    # binding package, say -- resolves to the same settings as the Python-only repo it
    # grew out of, rather than to whichever manifest happened to be read last.
    raw.update(_from_manifest(root / "Cargo.toml"))
    raw.update(_from_manifest(root / "pyproject.toml"))
    raw.update(_from_environ(os.environ))
    raw.update({k: v for k, v in overrides.items() if v is not None})

    # .python-version wins over a configured python_version for the reason python.mk
    # reads it: it is what uv itself honours, so a second source of truth could only
    # ever disagree.
    pv = root / ".python-version"
    if pv.is_file() and (text := pv.read_text().strip()):
        raw["python_version"] = text

    known = {f.name for f in fields(cls)} - {"root"}
    return cls(root=root, **{k: v for k, v in raw.items() if k in known})

path

path(folder_field)

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 source_folder.

required

Returns:

Type Description
Path

The absolute path.

Source code in src/rhiza_task/config.py
def path(self, folder_field: str) -> 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``.

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

    Returns:
        The absolute path.
    """
    # The annotation is load-bearing under `mypy --strict`: `getattr` is typed to return
    # `Any`, `Path / Any` is `Any` too, and returning that from a `-> Path` function is
    # what `no-any-return` reports. Naming the type here is also the honest spelling --
    # every field this reaches ends in `_folder` and holds a `str`, which is the same
    # assumption `folders` above encodes in its `dict[str, str]`.
    value: str = getattr(self, folder_field)
    return self.root / value

detect_layers

detect_layers(root)

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:LAYERS order; ("python",)

...

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
def detect_layers(root: Path) -> tuple[str, ...]:
    """Return the language layers a repository belongs to, by its manifests.

    Args:
        root: Repository root.

    Returns:
        The layers whose manifest is present, in :data:`LAYERS` order; ``("python",)``
        when a repository has none, because that is what every gate assumed before there
        was a choice, and a repo with no manifest at all has nothing for another layer's
        gates to measure either.
    """
    found = tuple(layer for layer in LAYERS if (root / LAYER_MANIFESTS[layer]).is_file())
    return found or ("python",)

rhiza_checks_for

rhiza_checks_for(layers)

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
def rhiza_checks_for(layers: Sequence[str]) -> tuple[str, ...]:
    """Return the check set for a repository's layers.

    Args:
        layers: The active layers.

    Returns:
        The neutral checks followed by each layer's own, in layer order, deduplicated.
    """
    checks = list(NEUTRAL_RHIZA_CHECKS)
    for layer in layers:
        checks += [c for c in LAYER_RHIZA_CHECKS.get(layer, ()) if c not in checks]
    return tuple(checks)

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.

  1. Dedup within one invocation. Eleven tasks name install as a prerequisite and all names eight of those. Without a seen-set, rhiza-task all would sync the environment eight times.
  2. Depth-first ordering. book needs test, which needs install.
  3. A failed prerequisite stops its dependents. As make does, rather than running a gate against a half-built environment.
  4. A missing prerequisite is not an error. book.mk declares test:: ; @: no-op stubs so book can 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

Result(name, status, detail='', code=0)

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:Status.OK.

code int

The failing process's own exit status, carried from :class:~rhiza_task.spec.Failed so :meth:Run.exit_code can propagate it. 0 for every outcome that is not a failure.

Run dataclass

Run(results=list(), seen=set())

One invocation: the results so far, and the tasks already attempted.

failed property

failed

Whether any task failed or was blocked.

Returns:

Type Description
bool

True when the invocation should exit non-zero.

exit_code

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
def exit_code(self) -> int:
    """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:
        0 when nothing failed or was blocked; else the first failing task's exit status,
        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
    """
    if not self.failed:
        return 0
    code = next((r.code for r in self.results if r.status is Status.FAILED), 1)
    return code if 1 <= code <= 255 else 1

status_of

status_of(name)

Return the recorded status of a task, if it ran.

Parameters:

Name Type Description Default
name str

Task name.

required

Returns:

Type Description
Status | None

The status, or None when the task was not attempted.

Source code in src/rhiza_task/runner.py
def status_of(self, name: str) -> Status | None:
    """Return the recorded status of a task, if it ran.

    Args:
        name: Task name.

    Returns:
        The status, or None when the task was not attempted.
    """
    return next((r.status for r in self.results if r.name == name), None)

Status

Bases: StrEnum

The four outcomes a task can have.

run

run(names, cfg)

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:Run.

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
def run(names: list[str], cfg: Config) -> Run:
    """Run the named tasks and their prerequisites, in order.

    Args:
        names: Task names, as typed on the command line.
        cfg: The resolved config.

    Returns:
        The completed :class:`Run`.

    Raises:
        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.
    """
    unknown = [n for n in names if lookup(n, cfg.layers) is None]
    if unknown:
        msg = f"unknown task{'s' if len(unknown) > 1 else ''}: {', '.join(unknown)}"
        raise KeyError(msg)

    run_state = Run()
    for name in names:
        _run_one(name, cfg, run_state)
    return run_state

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

RESERVED = frozenset({'list', 'print', 'run', 'ci-os-matrix', 'version'})

Subcommand names, so the bare-task shorthand in :func:main can tell them apart.

ci_os_matrix

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
@app.command("ci-os-matrix")
def ci_os_matrix() -> None:
    """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.
    """
    print(json.dumps(list(Config.load().ci_os_matrix) or list(DEFAULT_CI_OS_MATRIX)))

list_tasks

list_tasks(every_layer=Option(False, '--all', help="include the other languages' layers"))

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
@app.command("list")
def list_tasks(
    every_layer: bool = typer.Option(False, "--all", help="include the other languages' layers"),
) -> None:
    """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.

    Args:
        every_layer: Show tasks from every language layer, not only this repository's.
    """
    layers = () if every_layer else _layers()
    table = Table("task", "section", "needs", "does", box=None, header_style="bold")
    for _, spec in sorted(REGISTRY.items(), key=lambda kv: (kv[1].section, kv[0])):
        if spec.hidden or (not every_layer and spec.layer is not None and spec.layer not in layers):
            continue
        table.add_row(spec.name, spec.section, " ".join(spec.needs), spec.help)
    console.print(table)

load_tasks

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
def load_tasks() -> None:
    """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.
    """
    for entry in entry_points(group="rhiza_task.tasks"):
        try:
            entry.load()
        except Exception as exc:  # noqa: BLE001 - a plugin must not break the runner
            err.print(f"[yellow]could not load task module {entry.name}: {exc}[/yellow]")

main

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
def main() -> None:
    """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.
    """
    load_tasks()
    argv = sys.argv[1:]
    if argv and argv[0] not in RESERVED and not argv[0].startswith("-"):
        sys.argv = [sys.argv[0], "run", *argv]
    app()

print_setting

print_setting(name)

Print one resolved setting, replacing make's print-% pattern rule.

Parameters:

Name Type Description Default
name str

A config field, spelled either way -- source_folder or SOURCE_FOLDER.

required

Raises:

Type Description
Exit

With status 2 when the setting does not exist.

Source code in src/rhiza_task/cli.py
@app.command("print")
def print_setting(name: str) -> None:
    """Print one resolved setting, replacing make's ``print-%`` pattern rule.

    Args:
        name: A config field, spelled either way -- ``source_folder`` or ``SOURCE_FOLDER``.

    Raises:
        typer.Exit: With status 2 when the setting does not exist.
    """
    cfg = Config.load()
    field = Config.field_for(name)
    if not hasattr(cfg, field):
        err.print(f"[red]unknown setting: {name}[/red]")
        raise typer.Exit(2)
    value = getattr(cfg, field)
    # markup=False, highlight=False: ``print`` is the command you reach for when a setting
    # is not doing what you expect, so it must show the stored value and nothing else.
    # ``mkdocs_extra_packages = ("mkdocstrings[python]",)`` printed as ``mkdocstrings``
    # otherwise, rich having read ``[python]`` as a style tag.
    console.print(
        " ".join(map(str, value)) if isinstance(value, tuple) else str(value),
        markup=False,
        highlight=False,
    )

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, cargo's 101 -- falling back to 1 when it has none. A usage error and a task that exited 2 therefore share a status; the run summary above distinguishes them, and the alternative is discarding the code every consumer's CI wants.

Source code in src/rhiza_task/cli.py
@app.command("run", no_args_is_help=True)
def run_tasks(
    names: list[str] = typer.Argument(..., help="Tasks to run, in order"),
    strict: bool = typer.Option(False, "--strict", help="Treat a skipped gate as a failure"),
    root: Path | None = typer.Option(None, "--root", help="Repository to operate on"),
) -> None:
    """Run one or more tasks, with their prerequisites.

    Args:
        names: Task names.
        strict: Fail rather than skip when a gate has nothing to measure.
        root: Repository root; defaults to the current directory.

    Raises:
        typer.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, ``cargo``'s 101 --
            falling back to 1 when it has none. A usage error and a task that exited 2
            therefore share a status; the run summary above distinguishes them, and the
            alternative is discarding the code every consumer's CI wants.
    """
    try:
        cfg = Config.load(root=root, strict=strict or None)
    except ValueError as exc:  # invalid configuration, e.g. typechecker=tpye
        err.print(f"[red]{exc}[/red]")
        raise typer.Exit(2) from exc

    try:
        state = runner.run(names, cfg)
    except KeyError as exc:
        err.print(f"[red]{exc.args[0]}[/red]  (try `rhiza-task list`)")
        raise typer.Exit(2) from exc

    console.print()
    for result in state.results:
        colour = STATUS_COLOUR[result.status]
        detail = f"  [dim]{result.detail}[/dim]" if result.detail else ""
        console.print(f"[{colour}]{result.status.value:>8}[/{colour}]  {result.name}{detail}")
    raise typer.Exit(state.exit_code())

version

version()

Print the rhiza-task version.

Source code in src/rhiza_task/cli.py
@app.command("version")
def version() -> None:
    """Print the rhiza-task version."""
    console.print(__version__)

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_INTERNAL_ERROR = 3

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_

all_(cfg)

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
@task(
    "all",
    "run every gate, as CI does",
    section="Python",
    layer="python",
    needs=("fmt", "deps", "test", "docs-coverage", "security", "license", "typecheck", "rhiza-test"),
)
def all_(cfg: Config) -> None:
    """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.

    Args:
        cfg: Unused; the prerequisites do the work.
    """

complexity

complexity(cfg)

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
@task(
    "complexity",
    "fail on a block above the cyclomatic-complexity ceiling",
    section="Quality",
    layer="python",
    guards=(Guard("source_folder"),),
)
def complexity(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.

    Raises:
        Skip: When radon produced no report, so nothing was measured.
        Failed: When at least one block is above the ceiling.
    """
    report = cfg.root / "_tests" / "complexity.json"
    report.parent.mkdir(parents=True, exist_ok=True)
    # Stale data first, for the reason `test` unlinks `.coverage*`: a report left by an
    # earlier run would be read as this run's verdict if radon failed to write.
    report.unlink(missing_ok=True)

    uvx("radon", "cc", cfg.source_folder, "--json", "--output-file", str(report), cwd=cfg.root)
    if not report.is_file():
        raise Skip("radon wrote no report")

    over = _over_ceiling(json.loads(report.read_text()), cfg.complexity_max)
    for label, score in over:
        print(f"{label}: {score}")
    if over:
        raise Failed(1, f"{len(over)} block(s) above the complexity ceiling of {cfg.complexity_max}")
    print(f"[INFO] no block above the complexity ceiling of {cfg.complexity_max}")

coverage

coverage(cfg)

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
@task(
    "coverage",
    "measure coverage and write _tests/coverage.xml",
    section="Python",
    layer="python",
    needs=("install",),
    guards=(
        Guard("tests_folder", glob="test_*.py", reason="no test files found"),
        Guard("source_folder"),
    ),
)
def coverage(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    (cfg.root / "_tests" / "html-coverage").mkdir(parents=True, exist_ok=True)
    for stale in cfg.root.glob(".coverage*"):
        stale.unlink(missing_ok=True)
    uv_run("pytest", *_pytest_args(cfg), *coverage_args(cfg), cwd=cfg.root, withs=PYTEST_WITHS)

coverage_args

coverage_args(cfg)

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
def coverage_args(cfg: Config) -> list[str]:
    """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.

    Args:
        cfg: The resolved config.

    Returns:
        The coverage flags.
    """
    return [
        f"--cov={cfg.source_folder}",
        "--cov-report=term",
        "--cov-report=html:_tests/html-coverage",
        "--cov-report=json:_tests/coverage.json",
        "--cov-report=xml:_tests/coverage.xml",
        f"--cov-fail-under={cfg.coverage_fail_under}",
    ]

deps

deps(cfg)

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
@task("deps", "run deptry over the contributed folders", section="Python", layer="python", needs=("install",))
def deps(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.

    Raises:
        Skip: When no contributed folder exists.
    """
    folders = [cfg.source_folder] if cfg.path("source_folder").is_dir() else []
    ignores = list(cfg.deptry_ignore)
    if "marimo" in REGISTRY and cfg.path("marimo_folder").is_dir():
        folders.append(cfg.marimo_folder)
        ignores += ["--ignore", "DEP004"]
    if not folders:
        raise Skip("no deptry folders")
    uvx("deptry", *folders, *ignores, cwd=cfg.root)

docs_coverage

docs_coverage(cfg)

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
@task(
    "docs-coverage",
    "check docstring coverage with interrogate",
    section="Python",
    layer="python",
    needs=("install",),
    guards=(Guard("source_folder"),),
)
def docs_coverage(cfg: Config) -> None:
    """Require 100% docstring coverage over the source and test folders.

    Args:
        cfg: The resolved config.
    """
    folders = [f for f in (cfg.source_folder, cfg.tests_folder) if (cfg.root / f).is_dir()]
    uv_run(
        "interrogate",
        "-vv",
        "--fail-under",
        "100",
        "--ignore-init-method",
        "--ignore-magic",
        *folders,
        cwd=cfg.root,
        withs=("interrogate",),
    )

install

install(cfg)

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 pyproject.toml.

Failed

When the lock file is out of sync, or a step exits non-zero.

Source code in src/rhiza_task/tasks/python.py
@task("install", "create the venv and sync dependencies", section="Python", layer="python", needs=("setup",))
def install(cfg: Config) -> None:
    """Create ``.venv`` if absent, sync from the lock file, install the git hooks.

    Args:
        cfg: The resolved config.

    Raises:
        Skip: When the project has no ``pyproject.toml``.
        Failed: When the lock file is out of sync, or a step exits non-zero.
    """
    venv = cfg.root / ".venv"
    if not venv.is_dir():
        uv("venv", "--python", cfg.python_version, str(venv), cwd=cfg.root)
    else:
        print(f"[INFO] using existing virtual environment at {venv}")

    if not (cfg.root / "pyproject.toml").is_file():
        raise Skip("no pyproject.toml")

    frozen: tuple[str, ...] = ()
    if (cfg.root / "uv.lock").is_file():
        # python.mk runs this check, swallows its output and prints three lines of
        # guidance on failure. The check is worth keeping; the guidance belongs with it
        # rather than in a shell heredoc.
        if uv("lock", "--check", cwd=cfg.root, check=False):
            raise Failed(1, "uv.lock is out of sync with pyproject.toml -- run `uv lock`")
        frozen = ("--frozen",)

    # --inexact: leave packages uv did not manage in place instead of pruning them on
    # every run, so repeated task invocations do not churn the environment. Per-task
    # tooling is provisioned on the fly by uv.py, so there is no separate step for it.
    uv("sync", *cfg.uv_sync_args, "--inexact", *frozen, cwd=cfg.root)

    install_hooks(cfg)

license_

license_(cfg)

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
@task("license", "scan for copyleft licences", section="Python", layer="python", needs=("install",))
def license_(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    ignored = list(cfg.license_ignore_packages)
    if "marimo" in REGISTRY and "docutils" not in ignored:
        ignored.append("docutils")
    args = [f"--fail-on={';'.join(cfg.license_fail_on)}", "--partial-match"]
    if ignored:
        # --ignore-packages errors on a bare flag, so it is omitted entirely when nothing
        # is exempted.
        args += ["--ignore-packages", *ignored]
    uv_run("pip-licenses", *args, cwd=cfg.root, withs=("pip-licenses",))

security

security(cfg)

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
@task(
    "security",
    "run the bandit security scan",
    section="Python",
    layer="python",
    needs=("install",),
    guards=(Guard("source_folder"),),
)
def security(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    ini = ("--ini", ".bandit") if (cfg.root / ".bandit").is_file() else ()
    uvx("bandit", "-r", cfg.source_folder, "-ll", "-q", *ini, cwd=cfg.root)

test

test(cfg)

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
@task(
    "test",
    "run all tests",
    section="Python",
    layer="python",
    needs=("install",),
    guards=(Guard("tests_folder", glob="test_*.py", reason="no test files found"),),
)
def test(cfg: Config) -> None:
    """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``.

    Args:
        cfg: The resolved config.

    Raises:
        Failed: When pytest reports test failures, or reports an internal error twice.
    """
    reports = cfg.root / "_tests"
    shutil.rmtree(reports, ignore_errors=True)

    args = [*_pytest_args(cfg)]
    if cfg.path("source_folder").is_dir():
        args += coverage_args(cfg)
    else:
        # Not a Skip: the tests exist and must run. Only coverage is unavailable.
        print(f"[WARN] source folder '{cfg.source_folder}' not found; running without coverage")
    args.append("--html=_tests/html-report/report.html")

    for attempt in range(1, MAX_ATTEMPTS + 1):
        # Stale data first: a crashed run can leave a corrupt .coverage file, which then
        # reports a false 0% on the next run.
        for stale in cfg.root.glob(".coverage*"):
            stale.unlink(missing_ok=True)
        (reports / "html-coverage").mkdir(parents=True, exist_ok=True)
        (reports / "html-report").mkdir(parents=True, exist_ok=True)

        code = uv_run("pytest", *args, cwd=cfg.root, withs=PYTEST_WITHS, check=False)
        if code != PYTEST_INTERNAL_ERROR:
            if code:
                raise Failed(code, "tests failed")
            return
        if attempt == MAX_ATTEMPTS:
            raise Failed(code, f"pytest reported an internal (teardown) error {attempt}x")
        print(f"[WARN] pytest exited {code} (xdist teardown race); retrying {attempt + 1}/{MAX_ATTEMPTS}")

typecheck

typecheck(cfg)

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
@task(
    "typecheck",
    "run ty and/or mypy (typechecker = ty | mypy | both)",
    section="Python",
    layer="python",
    needs=("install",),
    guards=(Guard("source_folder"),),
)
def typecheck(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    checkers = ("ty", "mypy") if cfg.typechecker == "both" else (cfg.typechecker,)
    for checker in checkers:
        # The asymmetry is preserved from python.mk: mypy runs --strict, ty does not.
        args = ("check", cfg.source_folder) if checker == "ty" else ("--strict", cfg.source_folder)
        uv_run(checker, *args, cwd=cfg.root, withs=(checker,))

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

CARGO_TOOLS = ('cargo-nextest', 'cargo-llvm-cov', 'cargo-deny', 'cargo-machete')

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_

all_(cfg)

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
@task(
    "all",
    "run every gate, as CI does",
    section="Rust",
    layer="rust",
    needs=("fmt", "test", "docs-coverage", "security", "deps", "license", "typecheck", "rhiza-test"),
)
def all_(cfg: Config) -> None:
    """Aggregate, with rust.mk's prerequisite list. The body is empty because ``needs`` is it.

    Args:
        cfg: Unused; the prerequisites do the work.
    """

cargo_tools

cargo_tools(cfg)

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
@task("cargo-tools", "install the cargo subcommands the gates need", section="Rust", layer="rust")
def cargo_tools(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    if not have("cargo-binstall") and not (_cargo_bin() / "cargo-binstall").exists():
        print("[INFO] installing cargo-binstall")
        tool("cargo", "install", "cargo-binstall", "--locked", cwd=cfg.root)

    missing = [t for t in CARGO_TOOLS if not have(t) and not (_cargo_bin() / t).exists()]
    if not missing:
        print("[INFO] all cargo tools already installed")
        return
    print(f"[INFO] installing: {' '.join(missing)}")
    tool("cargo", "binstall", "--no-confirm", "--locked", *missing, cwd=cfg.root)

coverage

coverage(cfg)

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
@task(
    "coverage",
    "measure coverage and write _tests/coverage.xml",
    section="Rust",
    layer="rust",
    needs=("install", "cargo-tools"),
    guards=(MANIFEST,),
)
def coverage(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    (cfg.root / "_tests" / "html-coverage").mkdir(parents=True, exist_ok=True)
    print(f"[INFO] measuring coverage (floor: {cfg.coverage_fail_under}%)")
    tool(
        "cargo",
        "llvm-cov",
        "nextest",
        "--all-targets",
        *cfg.cargo_flags,
        "--fail-under-lines",
        str(cfg.coverage_fail_under),
        "--cobertura",
        "--output-path",
        "_tests/coverage.xml",
        cwd=cfg.root,
    )
    tool("cargo", "llvm-cov", "report", "--html", "--output-dir", "_tests/html-coverage", cwd=cfg.root)

deps

deps(cfg)

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
@task(
    "deps",
    "report unused dependencies",
    section="Rust",
    layer="rust",
    needs=("install", "cargo-tools"),
    guards=(MANIFEST,),
)
def deps(cfg: Config) -> None:
    """Run cargo-machete, the deptry analogue.

    Args:
        cfg: The resolved config.
    """
    tool("cargo", "machete", cwd=cfg.root)

docs_coverage

docs_coverage(cfg)

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
@task(
    "docs-coverage",
    "fail on any undocumented public item",
    section="Rust",
    layer="rust",
    needs=("install",),
    guards=(MANIFEST,),
)
def docs_coverage(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    tool(
        "cargo",
        "doc",
        "--no-deps",
        *cfg.cargo_flags,
        cwd=cfg.root,
        env={"RUSTDOCFLAGS": "-D missing_docs -D rustdoc::broken_intra_doc_links"},
    )

install

install(cfg)

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
@task("install", "install the toolchain and fetch dependencies", section="Rust", layer="rust", needs=("setup",))
def install(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.

    Raises:
        Failed: When rustup is absent, or a step exits non-zero.
    """
    if not have("rustup"):
        raise Failed(1, "rustup not found -- install it from https://rustup.rs (or: brew install rustup)")

    if (cfg.root / "rust-toolchain.toml").is_file():
        print("[INFO] installing the toolchain pinned in rust-toolchain.toml")
        tool("rustup", "show", cwd=cfg.root)
    else:
        print("[WARN] no rust-toolchain.toml; using the active default toolchain")

    if (cfg.root / "Cargo.toml").is_file():
        # --locked first, unlocked as a fallback: rust.mk's `|| $(CARGO) fetch`, which
        # exists because a crate without a committed Cargo.lock is legitimate.
        if tool("cargo", "fetch", "--locked", cwd=cfg.root, check=False):
            tool("cargo", "fetch", cwd=cfg.root)
    else:
        print("[WARN] no Cargo.toml; skipping fetch")

    install_hooks(cfg)

license_

license_(cfg)

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
@task(
    "license",
    "run the licence compliance scan",
    section="Rust",
    layer="rust",
    needs=("install", "cargo-tools"),
    guards=(MANIFEST,),
)
def license_(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    tool("cargo", "deny", "check", "licenses", cwd=cfg.root)

security

security(cfg)

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
@task(
    "security",
    "scan dependencies for known advisories",
    section="Rust",
    layer="rust",
    needs=("install", "cargo-tools"),
    guards=(MANIFEST,),
)
def security(cfg: Config) -> None:
    """Run ``cargo deny check advisories``.

    Args:
        cfg: The resolved config.
    """
    tool("cargo", "deny", "check", "advisories", cwd=cfg.root)

test

test(cfg)

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
@task(
    "test",
    "run the test suite with nextest, then the doctests",
    section="Rust",
    layer="rust",
    needs=("install", "cargo-tools"),
    guards=(MANIFEST,),
)
def test(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    reports = cfg.root / "_tests"
    reports.mkdir(parents=True, exist_ok=True)
    tool("cargo", "nextest", "run", "--all-targets", *cfg.cargo_flags, cwd=cfg.root)
    print("[INFO] running doctests")
    tool("cargo", "test", "--doc", *cfg.cargo_flags, cwd=cfg.root)

typecheck

typecheck(cfg)

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
@task(
    "typecheck",
    "lint with clippy, warnings as errors",
    section="Rust",
    layer="rust",
    needs=("install",),
    guards=(MANIFEST,),
)
def typecheck(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    tool("cargo", "clippy", "--all-targets", *cfg.cargo_flags, "--", "-D", "warnings", cwd=cfg.root)

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

COVERAGE_PROFILE = '_tests/coverage.out'

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_

all_(cfg)

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
@task(
    "all",
    "run every gate, as CI does",
    section="Go",
    layer="go",
    needs=("fmt", "test", "docs-coverage", "security", "deps", "license", "typecheck", "rhiza-test"),
)
def all_(cfg: Config) -> None:
    """Aggregate, with go.mk's prerequisite list. The body is empty because ``needs`` is it.

    Args:
        cfg: Unused; the prerequisites do the work.
    """

coverage

coverage(cfg)

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 coverage_fail_under.

Source code in src/rhiza_task/tasks/go.py
@task(
    "coverage",
    "measure coverage and write _tests/coverage.xml",
    section="Go",
    layer="go",
    needs=("install", "go-tools"),
    guards=(MANIFEST,),
)
def coverage(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.

    Raises:
        Failed: When coverage is below ``coverage_fail_under``.
    """
    reports = cfg.root / "_tests"
    (reports / "html-coverage").mkdir(parents=True, exist_ok=True)
    profile = cfg.root / COVERAGE_PROFILE
    print(f"[INFO] measuring coverage (floor: {cfg.coverage_fail_under}%)")
    tool(
        "go",
        "test",
        "./...",
        "-covermode=atomic",
        f"-coverprofile={COVERAGE_PROFILE}",
        *cfg.go_flags,
        cwd=cfg.root,
    )
    _cobertura(cfg, profile, reports / "coverage.xml")
    tool(
        "go",
        "tool",
        "cover",
        f"-html={COVERAGE_PROFILE}",
        "-o",
        "_tests/html-coverage/index.html",
        cwd=cfg.root,
    )

    measured = _total_coverage(cfg)
    if measured is None:
        print("[WARN] could not read a total from `go tool cover -func`; floor not enforced")
        return
    if measured < cfg.coverage_fail_under:
        raise Failed(1, f"coverage {measured:.1f}% is below the {cfg.coverage_fail_under}% floor")
    print(f"[INFO] coverage {measured:.1f}% (floor: {cfg.coverage_fail_under}%)")

deps

deps(cfg)

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
@task(
    "deps",
    "report dependency drift",
    section="Go",
    layer="go",
    needs=("install",),
    guards=(MANIFEST,),
)
def deps(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    tool("go", "mod", "tidy", "-diff", cwd=cfg.root)

docs_coverage

docs_coverage(cfg)

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
@task(
    "docs-coverage",
    "fail on any undocumented exported item",
    section="Go",
    layer="go",
    needs=("install", "go-tools"),
    guards=(MANIFEST,),
)
def docs_coverage(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    tool(_tool_path(cfg, "revive"), "-config", "revive.toml", "-set_exit_status", "./...", cwd=cfg.root)

go_tools

go_tools(cfg)

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
@task("go-tools", "install the Go tools the gates need", section="Go", layer="go")
def go_tools(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    target = _bin_dir(cfg)
    target.mkdir(parents=True, exist_ok=True)
    for spec in GO_TOOLS:
        name = spec.rsplit("@", 1)[0].rsplit("/", 1)[-1]
        if (target / name).exists():
            continue
        print(f"[INFO] installing {name}")
        tool("go", "install", spec, cwd=cfg.root, env={"GOBIN": str(target)})
    print(f"[INFO] all Go tools available in {target}")

install

install(cfg)

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
@task("install", "install the toolchain and download dependencies", section="Go", layer="go", needs=("setup",))
def install(cfg: Config) -> None:
    """Download the module's dependencies and install the git hooks.

    Args:
        cfg: The resolved config.

    Raises:
        Failed: When go is absent, or a step exits non-zero.
    """
    if not have("go"):
        raise Failed(1, "go not found -- install it from https://go.dev/doc/install (or: brew install go)")

    if (cfg.root / "go.mod").is_file():
        print("[INFO] downloading dependencies")
        tool("go", "mod", "download", cwd=cfg.root)
    else:
        print("[WARN] no go.mod; skipping download")

    install_hooks(cfg)

license_

license_(cfg)

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
@task(
    "license",
    "run the licence compliance scan",
    section="Go",
    layer="go",
    needs=("install", "go-tools"),
    guards=(MANIFEST,),
)
def license_(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    args = ["check", "./..."]
    if module := capture("go", "list", "-m", cwd=cfg.root):
        args += ["--ignore", module]
    else:
        print("[WARN] could not read the module path; go-licenses may fail on the project itself")
    tool(_tool_path(cfg, "go-licenses"), *args, cwd=cfg.root)

security

security(cfg)

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
@task(
    "security",
    "scan dependencies for known vulnerabilities",
    section="Go",
    layer="go",
    needs=("install", "go-tools"),
    guards=(MANIFEST,),
)
def security(cfg: Config) -> None:
    """Run govulncheck over the module.

    Args:
        cfg: The resolved config.
    """
    tool(_tool_path(cfg, "govulncheck"), "./...", cwd=cfg.root)

test

test(cfg)

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
@task(
    "test",
    "run the test suite",
    section="Go",
    layer="go",
    needs=("install",),
    guards=(MANIFEST,),
)
def test(cfg: Config) -> None:
    """Run ``go test ./...`` with the race detector and shuffled order.

    Args:
        cfg: The resolved config.
    """
    reports = cfg.root / "_tests"
    reports.mkdir(parents=True, exist_ok=True)
    tool("go", "test", "./...", *cfg.go_test_flags, *cfg.go_flags, cwd=cfg.root)

typecheck

typecheck(cfg)

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
@task(
    "typecheck",
    "vet and lint (the compiler already type-checks)",
    section="Go",
    layer="go",
    needs=("install", "go-tools"),
    guards=(MANIFEST,),
)
def typecheck(cfg: Config) -> None:
    """Run ``go vet`` and golangci-lint.

    Args:
        cfg: The resolved config.
    """
    tool("go", "vet", "./...", *cfg.go_flags, cwd=cfg.root)
    tool(_tool_path(cfg, "golangci-lint"), "run", cwd=cfg.root)

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

TAG_VERSION_CHECK = 'test_latest_tag_matches_pyproject_version'

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

clean(cfg)

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
@task("clean", "remove build artifacts and stale local branches", section="Dev")
def clean(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    git = shutil.which("git") or "git"
    _git(git, ["clean", "-d", "-X", "-f", "-e", "!.env", "-e", "!.env.*"], cfg.root)

    for name in CLEAN_ARTIFACTS:
        target = cfg.root / name
        if target.is_dir():
            shutil.rmtree(target, ignore_errors=True)
        elif target.exists():
            target.unlink(missing_ok=True)
    for egg in cfg.root.glob("*.egg-info"):
        shutil.rmtree(egg, ignore_errors=True)

    print("[INFO] removing local branches with no remote counterpart")
    _git(git, ["fetch", "--prune"], cfg.root)
    listing = _git(git, ["branch", "-vv"], cfg.root, capture=True)
    for line in listing.splitlines():
        # A leading * or + marks the current or a worktree-checked-out branch; neither can
        # be deleted, and attempting it is how the make recipe's xargs used to fail.
        if ": gone]" in line and not line.startswith(("*", "+")):
            branch = line.strip().split()[0]
            _git(git, ["branch", "-D", branch], cfg.root)

docs_examples

docs_examples(cfg)

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
@task(
    "docs-examples",
    "check the fenced examples in the docs tree",
    section="Quality",
    needs=("install",),
    guards=(Guard("docs_folder"),),
)
def docs_examples(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.

    Raises:
        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.
    """
    fences.check(cfg)

fmt

fmt(cfg)

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
@task("fmt", "run the pre-commit hooks over all files", section="Quality")
def fmt(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.

    Raises:
        Skip: When the project has no pre-commit config.
    """
    if not (cfg.root / ".pre-commit-config.yaml").is_file():
        raise Skip("no .pre-commit-config.yaml")
    uvx("prek", "run", "--all-files", "--config", ".pre-commit-config.yaml", cwd=cfg.root)

install_hooks

install_hooks(cfg)

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
def install_hooks(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    if not (cfg.root / ".pre-commit-config.yaml").is_file():
        return
    git = shutil.which("git") or "git"
    hooks_path = _git(git, ["config", "--get", "core.hooksPath"], cfg.root, capture=True).strip()
    if hooks_path:
        print("[INFO] skipping hook install: core.hooksPath is set")
        return
    # A hook-install failure warns rather than fails, as the make recipes do: it does not
    # invalidate the environment that was just built.
    uvx("prek", "install", "-c", ".pre-commit-config.yaml", cwd=cfg.root, check=False)

rhiza_test

rhiza_test(cfg)

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
@task("rhiza-test", "run the rhiza repository checks", section="Quality", needs=("install",))
def rhiza_test(cfg: Config) -> None:
    """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".

    Args:
        cfg: The resolved config.
    """
    # `-k`, not `--deselect`: a deselect needs the collected node id, and under `--pyargs` that
    # is the *installed* package's file path inside the uv cache -- a string this task would
    # have to reconstruct and that changes with the pin. Matching on the test's name needs
    # neither.
    #
    # Announced on stdout rather than passed over silently. A relaxed gate that says nothing is
    # how a real mismatch would hide behind this, and the whole argument for relaxing it is
    # that a permanently-red required check is worse than a visibly narrower one.
    selection: tuple[str, ...] = ()
    if _release_pending(cfg):
        print(f"[INFO] release in flight: the declared version leads every tag, so {TAG_VERSION_CHECK} is deselected")
        selection = ("-k", f"not {TAG_VERSION_CHECK}")

    uv_run(
        "pytest",
        "--pyargs",
        *cfg.rhiza_checks,
        *selection,
        cwd=cfg.root,
        withs=_provider(cfg),
        env={"RHIZA_DOCTEST_FOLDERS": cfg.source_folder},
    )

semgrep

semgrep(cfg)

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
@task("semgrep", "run the semgrep static analysis rules", section="Quality", guards=(Guard("source_folder"),))
def semgrep(cfg: Config) -> None:
    """Run semgrep against the source folder with rhiza's rule set.

    Args:
        cfg: The resolved config.

    Raises:
        Skip: When the rule file is absent.
    """
    rules = cfg.root / ".rhiza" / "semgrep.yml"
    if not rules.is_file():
        raise Skip("no .rhiza/semgrep.yml")
    uvx("semgrep", "--config", str(rules), cfg.source_folder, cwd=cfg.root)

test_pyproject

test_pyproject(cfg)

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
@task(
    "test-pyproject",
    "run the pyproject.toml structure checks, verbosely",
    section="Quality",
    layer="python",
    needs=("install",),
)
def test_pyproject(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    uv_run(
        "pytest",
        "--pyargs",
        "pytest_rhiza.checks.test_pyproject",
        "-v",
        "--tb=long",
        "--showlocals",
        "-rA",
        "--durations=0",
        "--no-header",
        cwd=cfg.root,
        withs=_provider(cfg),
    )

todos

todos(cfg)

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
@task("todos", "list every TODO, FIXME and HACK comment", section="Quality")
def todos(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    hits = 0
    for path in sorted(_walk(cfg.root)):
        try:
            lines = path.read_text(errors="replace").splitlines()
        except OSError:
            # Skip, not fail: a file the gate cannot open is not a TODO, and one unreadable
            # path in a tree must not cost the report every hit after it. `errors="replace"`
            # already absorbs undecodable *bytes*, so what reaches here is the file that
            # could not be opened at all -- a permission bit, a dangling symlink, a name the
            # OS accepted and cannot serve.
            continue
        for number, line in enumerate(lines, start=1):
            if TODO_PATTERN.search(line):
                # as_posix, not str: this is report output a reader copies into a grep or an
                # editor's go-to-file, so the separator must not depend on the OS that ran
                # the gate. The paths are repo-relative and never touch the filesystem again.
                rel = path.relative_to(cfg.root).as_posix()
                print(f"{rel}:{number}: {line.strip()}")
                hits += 1
    print(f"\n[INFO] {hits} item(s) found.")

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_NO_TESTS_COLLECTED = 5

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

benchmark(cfg)

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
@task(
    "benchmark",
    "run the performance benchmarks",
    section="Testing extras",
    layer="python",
    needs=("install",),
    guards=(Guard("tests_folder", glob="benchmarks/*.py", reason="no benchmarks folder"),),
)
def benchmark(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    (cfg.root / "_tests" / "benchmarks").mkdir(parents=True, exist_ok=True)
    uv_run(
        "pytest",
        f"{cfg.tests_folder}/benchmarks/",
        "--benchmark-only",
        "--benchmark-histogram=_tests/benchmarks/histogram",
        "--benchmark-json=_tests/benchmarks/results.json",
        cwd=cfg.root,
        withs=("pytest", "pytest-benchmark==5.2.3", "pygal==3.1.0"),
    )

hypothesis_test

hypothesis_test(cfg)

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
@task(
    "hypothesis-test",
    "run the property-based tests",
    section="Testing extras",
    layer="python",
    needs=("install",),
    guards=(Guard("tests_folder", glob="test_*.py", reason="no test files found"),),
)
def hypothesis_test(cfg: Config) -> None:
    """Run the Hypothesis-marked tests with statistics and a fixed seed.

    Args:
        cfg: The resolved config.

    Raises:
        Failed: When a property test fails.
    """
    (cfg.root / "_tests" / "hypothesis").mkdir(parents=True, exist_ok=True)
    code = uv_run(
        "pytest",
        f"--ignore={cfg.tests_folder}/benchmarks",
        "-v",
        "--hypothesis-show-statistics",
        "--hypothesis-seed=0",
        "-m",
        "hypothesis or property",
        "--tb=short",
        "--html=_tests/hypothesis/report.html",
        cwd=cfg.root,
        withs=("pytest", "hypothesis", "pytest-html"),
        env={"PYTEST_HTML_TITLE": "Hypothesis tests"},
        check=False,
    )
    if code == PYTEST_NO_TESTS_COLLECTED:
        print("[INFO] no hypothesis/property tests collected")
        return
    if code:
        raise Failed(code, "property tests failed")

stress

stress(cfg)

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
@task(
    "stress",
    "run the stress and load tests",
    section="Testing extras",
    layer="python",
    needs=("install",),
    guards=(Guard("tests_folder", glob="stress/*.py", reason="no stress folder"),),
)
def stress(cfg: Config) -> None:
    """Run the stress-marked tests.

    Args:
        cfg: The resolved config.
    """
    (cfg.root / "_tests" / "stress").mkdir(parents=True, exist_ok=True)
    uv_run(
        "pytest",
        "-v",
        "-m",
        "stress",
        "--tb=short",
        "--html=_tests/stress/report.html",
        cwd=cfg.root,
        withs=("pytest", "pytest-html"),
    )

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

SCRUBBED_SUFFIXES = ('.html', '.htm', '.xml', '.json', '.js', '.css', '.txt', '.svg')

Which report files are rewritten. Text formats only, so no binary is touched.

book

book(cfg)

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 mkdocs.yml to build.

Source code in src/rhiza_task/tasks/book.py
@task(
    "book",
    "build the companion book",
    section="Book",
    needs=("test", "benchmark", "stress", "hypothesis-test", "paper"),
)
def book(cfg: Config) -> None:
    """Build the MkDocs/Zensical site, with test reports and notebooks folded in.

    Args:
        cfg: The resolved config.

    Raises:
        Skip: When there is no ``mkdocs.yml`` to build.
    """
    if not (cfg.root / "mkdocs.yml").is_file():
        raise Skip("no mkdocs.yml")

    _copy_reports(cfg)
    _export_notebooks(cfg)

    output = cfg.root / cfg.book_output
    shutil.rmtree(output, ignore_errors=True)
    uvx(
        f"zensical{cfg.zensical_version}",
        "build",
        "-f",
        str(cfg.root / "mkdocs.yml"),
        cwd=cfg.root,
        withs=cfg.mkdocs_extra_packages,
    )
    output.mkdir(parents=True, exist_ok=True)
    (output / ".nojekyll").touch()
    _prune_latex_artifacts(cfg, output)

    coverage = cfg.root / "_tests" / "coverage.xml"
    if coverage.is_file():
        uvx(
            "genbadge[coverage]",
            "coverage",
            "-i",
            str(coverage),
            "-o",
            str(output / "coverage-badge.svg"),
            cwd=cfg.root,
            check=False,
        )
    print(f"[SUCCESS] book built at {cfg.book_output}/")

book_nav

book_nav(cfg)

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 mkdocs.yml declares no nav targets. Both are "the question is not askable", not "the answer is wrong".

Failed

When at least one nav target is missing from the built site.

Source code in src/rhiza_task/tasks/book.py
@task(
    "book-nav",
    "check that every mkdocs nav entry resolves in the built book",
    section="Book",
    guards=(Guard(file="mkdocs.yml"),),
)
def book_nav(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.

    Raises:
        Skip: When the book has not been built, or ``mkdocs.yml`` declares no nav targets.
            Both are "the question is not askable", not "the answer is wrong".
        Failed: When at least one nav target is missing from the built site.
    """
    output = cfg.path("book_output")
    if not output.is_dir():
        raise Skip(f"no built book at '{cfg.book_output}'; run `rhiza-task book` first")

    targets = _nav_targets((cfg.root / "mkdocs.yml").read_text(errors="replace"))
    if not targets:
        raise Skip("mkdocs.yml declares no nav targets")

    missing = [
        target
        for target in targets
        if not any((output / candidate).exists() for candidate in _built_candidates(target))
    ]
    for target in missing:
        print(f"[ERROR] nav target not in the built book: {target}")

    if missing:
        raise Failed(
            1,
            f"{len(missing)} of {len(targets)} nav target(s) missing from '{cfg.book_output}' -- "
            f"the site would publish a 404 in its own navigation",
        )
    print(f"[SUCCESS] all {len(targets)} nav target(s) resolve in {cfg.book_output}/")

marimo

marimo(cfg)

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
@task(
    "marimo",
    "start the Marimo editor",
    section="Book",
    needs=("install",),
    guards=(Guard("marimo_folder"),),
)
def marimo(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    uv_run(
        "marimo",
        "edit",
        "--no-token",
        "--headless",
        cwd=cfg.path("marimo_folder"),
        withs=("marimo",),
        no_project=True,
    )

marimo_validate

marimo_validate(cfg)

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
@task(
    "marimo-validate",
    "check that every Marimo notebook runs",
    section="Book",
    needs=("install",),
    guards=(Guard("marimo_folder"),),
)
def marimo_validate(cfg: Config) -> None:
    """Run each notebook as a script, reporting per-notebook pass or fail.

    Args:
        cfg: The resolved config.

    Raises:
        Skip: When the folder holds no notebooks.
        Failed: When any notebook fails to run.
    """
    notebooks = sorted(cfg.path("marimo_folder").glob("*.py"))
    if not notebooks:
        raise Skip(f"no notebooks in '{cfg.marimo_folder}'")

    failures: list[str] = []
    for notebook in notebooks:
        artefacts = cfg.root / "results" / notebook.stem
        artefacts.mkdir(parents=True, exist_ok=True)
        print(f"[INFO] validating {notebook.name} (artefacts -> {artefacts})")
        code = uv_run(
            "python",
            str(notebook),
            cwd=cfg.root,
            check=False,
            env={"NOTEBOOK_OUTPUT_FOLDER": str(artefacts)},
        )
        if code:
            failures.append(notebook.name)

    if failures:
        raise Failed(1, f"{len(failures)} notebook(s) failed: {', '.join(failures)}")
    print(f"[SUCCESS] all {len(notebooks)} notebook(s) valid")

serve

serve(cfg)

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
@task("serve", "build the book and serve it on port 8000", section="Book", needs=("book",))
def serve(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    print("[INFO] serving at http://localhost:8000 (Ctrl-C to stop)")
    uv_run("python", "-m", "http.server", "8000", cwd=cfg.path("book_output"))

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

Tool(name, minimum, url)

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

at_least(found, minimum)

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 found is at least minimum.

Source code in src/rhiza_task/tasks/doctor.py
def at_least(found: tuple[int, ...], minimum: str) -> bool:
    """Compare dotted versions, padding the shorter one with zeros.

    Args:
        found: The installed version.
        minimum: The required version, dotted.

    Returns:
        True when ``found`` is at least ``minimum``.
    """
    want = tuple(int(p) for p in minimum.split("."))
    width = max(len(found), len(want))
    return found + (0,) * (width - len(found)) >= want + (0,) * (width - len(want))

doctor

doctor(cfg)

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
@task("doctor", "check local prerequisites", section="Dev")
def doctor(cfg: Config) -> None:
    """Report on each prerequisite, failing when a required one is missing or too old.

    Args:
        cfg: The resolved config.

    Raises:
        Failed: When a required tool is missing or below its minimum version.
    """
    failed: list[str] = []
    for tool in TOOLS:
        path = shutil.which(tool.name)
        if path is None:
            _report(tool, "missing", ok=False, note=f"install: {tool.url}")
            failed.append(tool.name)
            continue

        output = subprocess.run(  # noqa: S603  # nosec B603
            [path, "--version"],
            capture_output=True,
            text=True,
            check=False,
        ).stdout
        version = parse_version(output)
        if not version:
            # Not assumed fine. A tool that prints no parseable version is a tool this
            # diagnostic cannot vouch for, and saying nothing would be the same as passing.
            _report(tool, "unknown", ok=False, note=f"required >= {tool.minimum}")
            failed.append(tool.name)
        elif at_least(version, tool.minimum):
            _report(tool, ".".join(map(str, version)), ok=True, note=f">= {tool.minimum}")
        else:
            _report(tool, ".".join(map(str, version)), ok=False, note=f"< {tool.minimum}")
            failed.append(tool.name)

    print(f"\n[INFO] python {cfg.python_version} (from .python-version or config)")
    if failed:
        raise Failed(1, f"missing or outdated: {', '.join(failed)}")

parse_version

parse_version(text)

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 --version output.

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
def parse_version(text: str) -> tuple[int, ...]:
    """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.

    Args:
        text: The raw ``--version`` output.

    Returns:
        The version as a tuple of ints, empty when none was found.
    """
    match = VERSION_RE.search(text.splitlines()[0] if text.strip() else "")
    return tuple(int(p) for p in match.group(1).split(".")) if match else ()

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

RELEASE_WORKFLOW_JQ = '.[] | select(.name | test("release";"i")) | .name'

github.mk's own filter: the first workflow whose name mentions "release", any case.

failed_workflows

failed_workflows(cfg)

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
@task("failed-workflows", "list recent failing workflow runs", section=SECTION, guards=(HAVE_GH,))
def failed_workflows(cfg: Config) -> None:
    """Show the ten most recent runs that concluded in failure.

    Args:
        cfg: The resolved config.
    """
    print("[INFO] Recent Failing Workflow Runs:")
    tool(
        "gh",
        "run",
        "list",
        "--limit",
        "10",
        "--status",
        "failure",
        "--json",
        "conclusion,name,headBranch,event,createdAt",
        "--template",
        FAILED_RUN_TEMPLATE,
        cwd=cfg.root,
    )

latest_release

latest_release(cfg)

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
@task("latest-release", "show information about the latest GitHub release", section=SECTION, guards=(HAVE_GH,))
def latest_release(cfg: Config) -> None:
    """Print tag, author, publication time and status for the newest release.

    Args:
        cfg: The resolved config.

    Raises:
        Skip: When the repository has published no release.
    """
    # The probe is `capture`, not a second `gh release view`: it is the one call whose
    # *value* matters rather than its output, and capture returns "" for a non-zero exit,
    # which is exactly github.mk's `if gh release view ... >/dev/null 2>&1` branch.
    if not capture("gh", "release", "view", "--json", "tagName", "--jq", ".tagName", cwd=cfg.root):
        raise Skip("no releases in this repository")

    print("[INFO] Latest release:")
    tool(
        "gh",
        "release",
        "view",
        "--json",
        "tagName,name,publishedAt,url,isDraft,isPrerelease,author",
        "--template",
        RELEASE_TEMPLATE,
        cwd=cfg.root,
    )

view_issues

view_issues(cfg)

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
@task("view-issues", "list open issues", section=SECTION, guards=(HAVE_GH,))
def view_issues(cfg: Config) -> None:
    """List the repository's open issues as a table.

    Args:
        cfg: The resolved config.
    """
    print("[INFO] Open Issues:")
    tool(
        "gh",
        "issue",
        "list",
        "--json",
        "number,title,author,labels,updatedAt",
        "--template",
        ISSUE_TEMPLATE,
        cwd=cfg.root,
    )

view_prs

view_prs(cfg)

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
@task("view-prs", "list open pull requests", section=SECTION, guards=(HAVE_GH,))
def view_prs(cfg: Config) -> None:
    """List the repository's open pull requests as a table.

    Args:
        cfg: The resolved config.
    """
    print("[INFO] Open Pull Requests:")
    tool(
        "gh",
        "pr",
        "list",
        "--json",
        "number,title,author,headRefName,updatedAt",
        "--template",
        PR_TEMPLATE,
        cwd=cfg.root,
    )

whoami

whoami(cfg)

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
@task("whoami", "check github auth status", section=SECTION, guards=(HAVE_GH,))
def whoami(cfg: Config) -> None:
    """Report which account gh is authenticated as, and with what scopes.

    Args:
        cfg: The resolved config.
    """
    print("[INFO] GitHub Authentication Status:")
    tool(
        "gh",
        "auth",
        "status",
        "--hostname",
        "github.com",
        "--json",
        "hosts",
        "--template",
        WHOAMI_TEMPLATE,
        cwd=cfg.root,
    )

workflow_status

workflow_status(cfg)

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
@task("workflow-status", "show recent runs for the release workflow", section=SECTION, guards=(HAVE_GH,))
def workflow_status(cfg: Config) -> None:
    """Find the release workflow by name, then show its five most recent runs.

    Args:
        cfg: The resolved config.

    Raises:
        Skip: When no workflow's name mentions "release".
    """
    listing = capture("gh", "workflow", "list", "--json", "name,id", "--jq", RELEASE_WORKFLOW_JQ, cwd=cfg.root)
    workflow = next((line.strip() for line in listing.splitlines() if line.strip()), "")
    if not workflow:
        raise Skip("no release workflow in this repository")

    print(f"[INFO] Release workflow: {workflow}")
    tool(
        "gh",
        "run",
        "list",
        "--workflow",
        workflow,
        "--limit",
        "5",
        "--json",
        "status,conclusion,headBranch,event,createdAt,displayTitle,url",
        "--template",
        WORKFLOW_RUN_TEMPLATE,
        cwd=cfg.root,
    )

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

docker_build(cfg)

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
@task("docker-build", "build the Docker image", section=SECTION, guards=(HAVE_DOCKER,))
def docker_build(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.

    Raises:
        Skip: When the folder holds no Dockerfile.
    """
    dockerfile = cfg.root / cfg.docker_folder / "Dockerfile"
    if not dockerfile.is_file():
        raise Skip(f"no {cfg.docker_folder}/Dockerfile")

    tag = f"{image_name(cfg)}:latest"
    print(f"[INFO] building {tag} with Python {cfg.python_version}")
    tool(
        "docker",
        "buildx",
        "build",
        "--file",
        f"{cfg.docker_folder}/Dockerfile",
        "--build-arg",
        f"PYTHON_VERSION={cfg.python_version}",
        "--tag",
        tag,
        "--load",
        ".",
        cwd=cfg.root,
    )

docker_clean

docker_clean(cfg)

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
@task("docker-clean", "remove the Docker image", section=SECTION, guards=(HAVE_DOCKER,))
def docker_clean(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    tag = f"{image_name(cfg)}:latest"
    print(f"[INFO] removing {tag}")
    tool("docker", "rmi", tag, cwd=cfg.root, check=False)

docker_run

docker_run(cfg)

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
@task("docker-run", "run the Docker container", section=SECTION, needs=("docker-build",), guards=(HAVE_DOCKER,))
def docker_run(cfg: Config) -> None:
    """Run the built image interactively, removing the container on exit.

    Args:
        cfg: The resolved config.
    """
    tag = f"{image_name(cfg)}:latest"
    print(f"[INFO] running {tag}")
    tool("docker", "run", "--rm", "-it", tag, cwd=cfg.root)

image_name

image_name(cfg)

Return the tag to build and run.

Parameters:

Name Type Description Default
cfg Config

The resolved config.

required

Returns:

Type Description
str

The configured image name, or the repository directory's name.

Source code in src/rhiza_task/tasks/docker.py
def image_name(cfg: Config) -> str:
    """Return the tag to build and run.

    Args:
        cfg: The resolved config.

    Returns:
        The configured image name, or the repository directory's name.
    """
    return cfg.docker_image or cfg.root.name

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

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
def install_hint() -> str:
    """Return the platform's install command, for the message a missing binary produces.

    Returns:
        A command to run, or the project's install page when the platform is unknown.
    """
    return INSTALL_HINTS.get(sys.platform, f"see {INSTALL_URL}")

lfs_install

lfs_install(cfg)

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
@task("lfs-install", "configure git-lfs for this repository", section=SECTION)
def lfs_install(cfg: Config) -> None:
    """Run ``git lfs install``, writing this repository's filter and hook configuration.

    Args:
        cfg: The resolved config.

    Raises:
        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.
    """
    if not have("git-lfs"):
        print(f"[ERROR] git-lfs not found. Install it with:\n    {install_hint()}")
        raise Failed(1, "git-lfs is not installed")
    tool("git", "lfs", "install", cwd=cfg.root)

lfs_pull

lfs_pull(cfg)

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
@task("lfs-pull", "download the LFS files for the current branch", section=SECTION, guards=(HAVE_LFS,))
def lfs_pull(cfg: Config) -> None:
    """Fetch and check out the LFS objects the working tree points at.

    Args:
        cfg: The resolved config.
    """
    tool("git", "lfs", "pull", cwd=cfg.root)

lfs_status

lfs_status(cfg)

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
@task("lfs-status", "show the status of LFS files", section=SECTION, guards=(HAVE_LFS,))
def lfs_status(cfg: Config) -> None:
    """Show which LFS files are modified, staged, or not yet pushed.

    Args:
        cfg: The resolved config.
    """
    tool("git", "lfs", "status", cwd=cfg.root)

lfs_track

lfs_track(cfg)

Show the .gitattributes patterns routed through LFS.

Parameters:

Name Type Description Default
cfg Config

The resolved config.

required
Source code in src/rhiza_task/tasks/lfs.py
@task("lfs-track", "list the patterns tracked by git-lfs", section=SECTION, guards=(HAVE_LFS,))
def lfs_track(cfg: Config) -> None:
    """Show the ``.gitattributes`` patterns routed through LFS.

    Args:
        cfg: The resolved config.
    """
    tool("git", "lfs", "track", cwd=cfg.root)

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.tool turns 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

AUX_SUFFIXES = ('.aux', '.bbl', '.blg', '.log', '.out', '.synctex.gz', '.toc')

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

PREFERRED = ('main.tex', 'paper.tex')

Root-document names tried before falling back to alphabetical order.

main_document

main_document(folder)

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 .tex.

Source code in src/rhiza_task/tasks/paper.py
def main_document(folder: Path) -> Path | None:
    """Choose the root ``.tex`` file in a folder.

    Args:
        folder: The paper folder.

    Returns:
        The document to compile, or None when the folder holds no top-level ``.tex``.
    """
    candidates = sorted(p for p in folder.glob("*.tex") if p.is_file())
    if not candidates:
        return None
    by_name = {p.name: p for p in candidates}
    return next((by_name[name] for name in PREFERRED if name in by_name), candidates[0])

paper

paper(cfg)

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 .tex file.

Source code in src/rhiza_task/tasks/paper.py
@task("paper", "compile the LaTeX paper to PDF", section=SECTION, guards=(HAVE_TECTONIC, Guard("paper_folder")))
def paper(cfg: Config) -> None:
    """Run tectonic over the paper folder's root document.

    Args:
        cfg: The resolved config.

    Raises:
        Skip: When the folder holds no top-level ``.tex`` file.
    """
    folder = cfg.path("paper_folder")
    document = main_document(folder)
    if document is None:
        raise Skip(f"no .tex files in '{cfg.paper_folder}'")

    print(f"[INFO] compiling {document.name}")
    # cwd is the paper folder, as `cd $(PAPER_DIR) && <engine>` was: tectonic resolves
    # \input paths relative to the document and writes its output beside it, which is what
    # lets `book` publish the PDF with no copy step.
    #
    # `--keep-logs` is the one flag, and it is not cosmetic: tectonic writes only the PDF by
    # default, and on a runner the log is the artefact you upload when a compile fails. It
    # is also the file `book`'s prune exists to keep out of the published site, since it
    # records absolute paths from whichever machine built it.
    tool("tectonic", "--keep-logs", document.name, cwd=folder)
    print(f"[SUCCESS] {cfg.paper_folder}/{document.stem}.pdf")

paper_clean

paper_clean(cfg)

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
@task("paper-clean", "remove the LaTeX build artifacts", section=SECTION)
def paper_clean(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.

    Raises:
        Skip: When there is no paper folder to clean.
    """
    folder = cfg.path("paper_folder")
    if not folder.is_dir():
        raise Skip(f"paper_folder '{cfg.paper_folder}' not found")

    removed = 0
    for stem in sorted({p.stem for p in folder.glob("*.tex") if p.is_file()}):
        for suffix in (*AUX_SUFFIXES, ".pdf"):
            artifact = folder / f"{stem}{suffix}"
            if artifact.is_file():
                artifact.unlink()
                removed += 1
    # Cleaning a folder that was never built leaves nothing to report and is not a failure,
    # which is what paper.mk's `|| true` bought; here it falls out of there being no tool
    # to fail.
    print(f"[SUCCESS] cleaned {cfg.paper_folder} ({removed} file(s))")

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

marp_argv(cfg)

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
def marp_argv(cfg: Config) -> tuple[str, tuple[str, ...]]:
    """Resolve how to reach the Marp CLI on this machine.

    Args:
        cfg: The resolved config.

    Returns:
        The executable to run and the arguments that must precede Marp's own.

    Raises:
        Skip: When neither marp nor npx is available.
    """
    if have("marp"):
        return "marp", ()
    if have("npx"):
        return "npx", ("--yes", cfg.marp_package)
    raise Skip(f"neither marp nor npx found; install Node.js ({NODE_URL})")

output

output(cfg, suffix)

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
def output(cfg: Config, suffix: str) -> str:
    """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.

    Args:
        cfg: The resolved config.
        suffix: The output extension, with its dot.

    Returns:
        A repository-relative filename.
    """
    return Path(cfg.presentation_file).with_suffix(suffix).name.lower()

presentation

presentation(cfg)

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
@task("presentation", "generate the HTML slides with Marp", section=SECTION)
def presentation(cfg: Config) -> None:
    """Export the deck to a single HTML file.

    Args:
        cfg: The resolved config.
    """
    binary, prefix = marp_argv(cfg)
    target = output(cfg, ".html")
    tool(binary, *prefix, source(cfg).name, "-o", target, cwd=cfg.root)
    print(f"[SUCCESS] {target} — open it in a browser to view the slides")

presentation_pdf

presentation_pdf(cfg)

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
@task("presentation-pdf", "generate the PDF slides with Marp", section=SECTION)
def presentation_pdf(cfg: Config) -> None:
    """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.

    Args:
        cfg: The resolved config.
    """
    binary, prefix = marp_argv(cfg)
    target = output(cfg, ".pdf")
    tool(binary, *prefix, source(cfg).name, "-o", target, "--allow-local-files", cwd=cfg.root)
    print(f"[SUCCESS] {target}")

presentation_serve

presentation_serve(cfg)

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
@task("presentation-serve", "serve the slides with Marp's live preview", section=SECTION)
def presentation_serve(cfg: Config) -> None:
    """Start Marp's watching server over the repository.

    Args:
        cfg: The resolved config.
    """
    binary, prefix = marp_argv(cfg)
    print("[INFO] starting the Marp server (Ctrl-C to stop)")
    tool(binary, *prefix, "-s", ".", cwd=cfg.root)

source

source(cfg)

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.

Source code in src/rhiza_task/tasks/presentation.py
def source(cfg: Config) -> Path:
    """Return the slide deck's source file.

    Args:
        cfg: The resolved config.

    Returns:
        The absolute path to the configured Markdown file.

    Raises:
        Skip: When the file does not exist.
    """
    path = cfg.root / cfg.presentation_file
    if not path.is_file():
        raise Skip(f"no {cfg.presentation_file}")
    return path