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 — 823 lines in 10 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 three lines of the shim: one file target, one curl
Makefile 6-line shim, generated by rhiza-task shim

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

For a consumer repository, generate the shim once:

uvx rhiza-task@0.1.0 shim > Makefile

make test, make book and the rest keep working — the shim forwards every target to the pinned CLI, so a consumer still on an older reusable workflow needs no change.

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

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. The shim keeps it, as a file target rather than a phony, so make <anything> still works on a runner that ships no uv — which rhiza's pre-commit job, a required status check that runs make fmt with no astral-sh/setup-uv step, depends on.

Fragments a repository owns go to local.mk

The shim's header says it 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 the shim -includes for exactly this purpose and where an explicit rule beats the catch-all. 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. Repo-specific one-offs can also just stay in the Makefile, where an explicit rule beats the shim's catch-all.

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 the shim, and hence task names identical to the retired target names.

  1. Ship this package; consumers replace the synced make layer with rhiza-task shim.
  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, though the shim's own bootstrap means a job that forgets it still works.
  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-0.2.0.tar.gz (102.8 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-0.2.0-py3-none-any.whl (55.8 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for rhiza_task-0.2.0.tar.gz
Algorithm Hash digest
SHA256 d9deec90fdf997677d13ee7fce9ae721ce4fd8e5ee42a2ddb8ed35b605e06980
MD5 d9e780563aed2e0b21e25a1cacacc349
BLAKE2b-256 c509ea04a4cd4b225ed32544df2843dc2cceefc1dc4a7977ef5a23e32b4b413a

See more details on using hashes here.

Provenance

The following attestation bundles were made for rhiza_task-0.2.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-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: rhiza_task-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 55.8 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-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6a84830b8ba91aae673e42f4048fa2b8b4b3782734ee8e7c20bb73bdb8d68272
MD5 1026c6a543bcc217c7bf24b4b70621e4
BLAKE2b-256 e5d69286eed3e2ae79a47e45d4eb8fbe96cd8ff92a13da262f68f8721721045d

See more details on using hashes here.

Provenance

The following attestation bundles were made for rhiza_task-0.2.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

1.0.0

2 files

0.3.1

2 files

0.3.0

2 files

This release

0.2.0 This release

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