Metadata-Version: 2.5
Name: rhiza-task
Version: 0.1.1
Summary: The rhiza developer tasks, as a pinned CLI instead of a synced make layer
Project-URL: Homepage, https://github.com/jebel-quant/rhiza-task
Project-URL: Repository, https://github.com/jebel-quant/rhiza-task
Project-URL: Issues, https://github.com/jebel-quant/rhiza-task/issues
Author: Jebel Quant Research
License-Expression: MIT
License-File: LICENSE
Keywords: ci,make,rhiza,task-runner,uv
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Build Tools
Requires-Python: >=3.11
Requires-Dist: python-dotenv>=1.0
Requires-Dist: rich>=13.0
Requires-Dist: typer>=0.15
Description-Content-Type: text/markdown

# rhiza-task

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

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

```bash
uvx rhiza-task@0.1.0 test
```

Sibling to [`pytest-rhiza`](https://github.com/jebel-quant/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` curling `astral.sh/uv/install.sh` into `./bin` | gone — running under `uvx` means uv exists |
| `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:

```bash
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:

```bash
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` `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` |

Not ported: `github.mk`'s seven `gh` wrappers — `gh pr list` is shorter than
`make view-prs` and always current — and `install-uv`, which the `uvx` entry point makes
unnecessary.

## 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 |
| `config.py` | five-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

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

```toml
[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.

```python
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`.
4. Second pass: retire `github.mk` and fold `doctor` into the release checklist.

## Open questions

- **Rust and Go layers.** `rust.mk`/`go.mk` ship the same target names with different
  recipes and today need only make. Shelling out to `cargo` from here works, but it makes
  Python a prerequisite for a Rust repo. This 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

```bash
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.
