Skip to main content

rhiza-task

License: MIT

The rhiza developer tasks as a pinned CLI rather than a synced make layer.

uvx rhiza-task@0.1.0 test

Sibling to pytest-rhiza, which did the same thing for .rhiza/tests.

Why

The pain was never make's syntax — it was distribution by copying, which make structurally cannot fix, because include cannot reach a remote file. Every consumer got a full copy at a template tag, and everything downstream was damage control.

before, per consumer repo after
.rhiza/rhiza.mk — 200 lines, synced gone
.rhiza/make.d/*.mk — 1023 lines in 15 files, synced gone
exclude: entries in template.yml, because "a deletion alone is undone by the next sync" not needed
targets shadowed in the repo Makefile, make printing overriding commands for target as the mechanism working [tool.rhiza-task]
~40 lines of GNU-make guard and Windows POSIX-shell probe gone — no make, no shell
install-uv — 30 lines of bootstrap.mk shell gone — uvx provisions the runtime
Makefile repo-owned, if a repo wants one

Version pinning becomes a dependency pin, which is a real mechanism instead of "copy files at tag v1.3.3 and hope nobody edited them."

Install

Nothing to install. uvx provisions it per invocation:

uvx rhiza-task@0.1.0 list          # what is available
uvx rhiza-task@0.1.0 all           # every gate, as CI runs them
uvx rhiza-task@0.1.0 test --strict # fail rather than skip when a gate measures nothing

A consumer repository calls the CLI directly. A repo that wants make test to keep working owns that Makefile itself and forwards the target to uvx rhiza-task <task>; task names are unchanged from the retired make layer, so the forwarding is one rule.

Tasks

section tasks
Python install test coverage typecheck security deps license docs-coverage all
Rust install cargo-tools test coverage typecheck security deps license docs-coverage all
Go install go-tools test coverage typecheck security deps license docs-coverage all
Quality fmt semgrep rhiza-test test-pyproject todos
Testing extras benchmark hypothesis-test stress mutation
Book book serve marimo marimo-validate
Dev doctor clean
GitHub Helpers view-prs view-issues failed-workflows workflow-status latest-release whoami
Docker docker-build docker-run docker-clean
Git LFS lfs-install lfs-pull lfs-track lfs-status
Paper paper paper-clean
Presentation presentation presentation-pdf presentation-serve

The last five sections are the bundle-owned fragments .rhiza/make.d/ had to keep because nothing here answered for their targets. None is a gate — no all names them and no workflow invokes them — so each is guarded on the CLI it wraps and skips when that tool is absent, with --strict available to a caller who wants the fragment's hard failure instead. Two changed behaviour on purpose, and say so in their module docstring: lfs-install configures the repository and reports how to install the binary rather than downloading one, and presentation reaches Marp through npx --yes rather than npm install -g.

Three layers, one set of names

test is pytest in a Python project, cargo nextest in a crate and go test in a module — the gate-parity contract rhiza's own CLAUDE.md documents, and the reason rhiza_ci.yml can call make typecheck without knowing the language.

The make layer answered "which one?" at sync time, by copying exactly one of python.mk, rust.mk and go.mk into a repository. A pinned CLI carries all three, so the answer is the manifest present: pyproject.toml → python, Cargo.toml → rust, go.mod → go. A repository with two gets both layers, in that order; layers = ["rust"] pins it. The other layer stays addressable as rhiza-task rust:test, and rhiza-task list --all shows what the layers you do not have call things.

RHIZA_CHECKS follows the same derivation, which is what the += accumulator in each language fragment was standing in for: the neutral checks, plus test_pyproject and test_docstrings for python, test_cargo_toml for rust, test_go_module for go.

coverage writes _tests/coverage.xml in every layer, at that exact path, because that is what book's badge step reads and what CI uploads. In Python it is the --cov flags test already carries, split out under the name the other two layers use; in Rust it is cargo llvm-cov --cobertura; in Go it is a coverage profile piped through gocover-cobertura, plus the floor check go test has no flag for.

Not ported: github.mk's seven gh wrappers — gh pr list is shorter than make view-prs and always current — and install-uv, which is not a task because it cannot be one: it is what provisions the runtime every task already runs under. A job that runs on a runner shipping no uv adds an astral-sh/setup-uv step — which is what rhiza's pre-commit job, a required status check that runs fmt, now does.

Fragments a repository owns go to local.mk

This package replaces .rhiza/rhiza.mk and the ten fragments in .rhiza/make.d/. A repository carrying its own fragments has to relocate them first — deleting rhiza.mk removes the -include .rhiza/make.d/*.mk that was reaching them, so they stop being loaded without anything saying so.

In rhiza's own case that is bundles.mk: sync-self, sync-self-check, explain-bundles, e2e and gitlab-docker-test. None of them is a candidate for this package — no bundle ships them, and the tooling behind them (utils/link_dogfood.py, utils/explain_bundles.py) lives in the mother repo. They belong in local.mk, which a repo-owned Makefile -includes for exactly this purpose. Worth doing deliberately rather than discovering later: sync-self is what maintains the bundles/ ↔ root invariant the whole repository rests on.

Design

Reading all ten make fragments back to back, every recipe has the same three parts: a guard on a folder existing, a provision via uv run --with or uvx, and a long, mostly static argument list. So the model is declarative, with an escape hatch for the four recipes that genuinely are not:

  • test — retry once on pytest exit 3 (xdist teardown race), never on 1/2/4
  • mutation — run/html/move/results, reporting the first status
  • doctor — semantic version comparison, formerly an awk function inside a make recipe
  • book — aggregate gates, copy reports, export notebooks, build, badge
module what
spec.py Task, Guard, Skip/Failed, the @task registry, layer resolution
config.py six-layer resolution, replacing ?= and +=
uv.py the three ways rhiza reaches a tool
runner.py prerequisite dedup, guards, outcome bookkeeping
cli.py Typer app, generated from the registry
tasks/*.py the gates themselves, loaded by entry point

Configuration

Six layers, lowest precedence first: dataclass defaults → .rhiza/.env (kept unchanged) → rhiza.toml[tool.rhiza-task] in the language manifest (Cargo.toml, then pyproject.toml) → RHIZA_* or bare make-style environment variables → command-line flags.

rhiza.toml is the language-neutral file, and the only committed settings surface a Go module can have — it has no manifest to hide a table in, and .rhiza/.env is now developer-local, since rhiza ships neither it nor the .rhiza/.gitignore whose entire content was the !.env negation that kept it tracked. Settings sit at the top level there; a [tool.rhiza-task] table is honoured too, and wins when both are present. It ranks below the manifest so that adding it to a Python repo cannot silently outrank the table already there.

# rhiza.toml — a Go module or a Rust crate, or any repo that would rather not
# thread settings through a manifest
source_folder = "cmd"
coverage_fail_under = 95

An empty value is unset in the two string-valued layers: RHIZA_CI_OS_MATRIX= in .rhiza/.env, or an exported empty string, leaves the layer below it alone rather than resolving to "". That is make's $(or ...) rule, and the reusable workflows depend on it — rhiza_ci.yml exports one RHIZA_CI_OS_MATRIX for every caller and deliberately leaves it empty for consumers, whose own .rhiza/.env is meant to answer.

[tool.rhiza-task]
source_folder = "src"
typechecker = "ty"
coverage_fail_under = 95
license_ignore_packages = ["docutils"]

The += accumulators (DEPTRY_FOLDERS, LICENSE_IGNORE_PACKAGES, RHIZA_CHECKS) have no successor and need none: each was a bundle contributing something it owned, which a task body now derives by asking whether the contributing task is registered. See deps and license in tasks/python.py.

Three things that fall out for free

  1. Double-colon rules disappear. book.mk declares test:: ; @: no-op stubs so book can depend on gates the tests bundle may not have contributed. Here that question is "test" in REGISTRY — four stubs and the whole :: mechanism gone.
  2. Skip is a first-class outcome. jointview's own Makefile complains that an excluded folder leaves "a green gate measuring nothing". --strict turns every skip into a failure, so CI can assert a gate actually measured something.
  3. Help stops being a parser. rhiza.mk runs awk over $(MAKEFILE_LIST) hunting ## and ##@ comments. Typer has descriptions natively, from the same registry the runner uses, so they cannot drift.

Adding a task

Register a module under the rhiza_task.tasks entry-point group — the same mechanism the built-ins use, so a project's own task is a first-class citizen rather than an override. That replaces -include local.mk for anything that wants to be a real task. A one-off that is only ever a make target goes in local.mk — but local.mk is in core's .gitignore, so it holds developer-local targets only. A repo-owned target CI invokes needs a committed home, and the Makefile is the only committed make surface there is: rhiza's own e2e, gitlab-docker-test and sync-self live there for exactly that reason.

from rhiza_task.spec import Guard, task
from rhiza_task.uv import uvx


@task("audit", "run the in-house audit", section="Quality", needs=("install",), guards=(Guard("source_folder"),))
def audit(cfg):
    """Audit the source tree."""
    uvx("my-auditor", cfg.source_folder, cwd=cfg.root)

Why not a Taskfile (or just)

Considered and rejected. go-task is a genuinely better make — real deps:, desc: giving task --list for free, and preconditions:/status: that express Guard declaratively. Its remote includes would even attack the same root problem.

Two reasons against. First, that feature is experimental and env-var-gated, and it would be the single load-bearing dependency of the whole multi-repo task layer, whereas uvx pkg@version is boring, stable and already used ~15 times per repo. Second, the four recipes listed above are procedural; in YAML they stay embedded shell, which improves the syntax around the mess without removing it — and embedded shell keeps the Windows problem too.

just and poe don't apply: a Justfile or a noxfile still has to be copied into every repo, which is the problem being deleted.

Migration

Make target names are the interface between the reusable workflows and the consumer checkout — rhiza_ci.yml alone calls make test, typecheck, deps, fmt, docs-coverage, security, license, rhiza-test. Consumers pin @v1.3.3, so old pins keep calling make forever. Hence task names identical to the retired target names: a repo that keeps a Makefile forwards each one to uvx rhiza-task <task> unchanged.

  1. Ship this package; consumers replace the synced make layer with direct uvx rhiza-task calls, or a repo-owned Makefile that forwards to them.
  2. template.yml excludes .rhiza/make.d and .rhiza/rhiza.mk, exactly as it already excludes .rhiza/tests.
  3. Bump the reusable workflows to invoke uvx rhiza-task directly, with one astral-sh/setup-uv step in place of install-uv — faster and cached.
  4. Second pass: retire github.mk and fold doctor into the release checklist.

Open questions

  • Python as a prerequisite for a Rust repo. rust.mk/go.mk needed only make; the Rust and Go layers here are Python calling cargo and go, so a crate now needs uv to run its gates. That is the trade the whole package makes — distribution by pin instead of by copy — but it is the layer where it costs the most, and it is the one place the Taskfile argument stays strong.
  • Nested uv cost. uvx rhiza-task test then internally uv run --with pytest .... Cached this should be milliseconds; measure before rolling out widely.

Development

uv sync --all-groups
uv run pytest

No test in the suite runs uv. Every task test patches the three entry points in uv.py and asserts on the argument vector that would have been executed — which is exactly what the make recipes expressed in $$-escaped shell, and could not assert.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

rhiza_task-1.0.0.tar.gz (117.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

rhiza_task-1.0.0-py3-none-any.whl (65.7 kB view details)

Uploaded Python 3

File details

Details for the file rhiza_task-1.0.0.tar.gz.

File metadata

  • Download URL: rhiza_task-1.0.0.tar.gz
  • Upload date:
  • Size: 117.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for rhiza_task-1.0.0.tar.gz
Algorithm Hash digest
SHA256 720bb9b8436334916e241019f4e361b582c921b8352789034e6df3bb40cb3773
MD5 691485181c7e5a57836c1324d671450d
BLAKE2b-256 e4b69d1360967dc05a97cd9c15af9c060ade314a775873430723122c17505f3b

See more details on using hashes here.

Provenance

The following attestation bundles were made for rhiza_task-1.0.0.tar.gz:

Publisher: rhiza_release.yml on Jebel-Quant/rhiza-task

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file rhiza_task-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: rhiza_task-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 65.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for rhiza_task-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c4c02bae283846715cb77905b73b781847f878d3cf2a9652b1cff8d1cde08a6c
MD5 d03771eef35f8caef4e273adc10d5ff5
BLAKE2b-256 5e09c2bec6fd0b8e21d34c2212138b7c104d35952d41729af404a3d1da0adcc1

See more details on using hashes here.

Provenance

The following attestation bundles were made for rhiza_task-1.0.0-py3-none-any.whl:

Publisher: rhiza_release.yml on Jebel-Quant/rhiza-task

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

1.4.0

2 files

1.3.1

2 files

1.3.0

2 files

1.2.0

2 files

1.1.0

2 files

This release

1.0.0 This release

2 files

0.3.1

2 files

0.3.0

2 files

0.2.0

2 files

0.1.2

2 files

0.1.1

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page