rhiza-task 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rhiza_task/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """The rhiza developer tasks, as a pinned CLI instead of a synced make layer.
2
+
3
+ What this replaces, per consumer repository: ``.rhiza/rhiza.mk`` (200 lines) and the ten
4
+ fragments in ``.rhiza/make.d/`` (823 lines), synced at a template tag and excluded,
5
+ shadowed or patched wherever a project disagreed with them. Here they are a dependency
6
+ pin -- ``uvx rhiza-task@0.1.1 test`` -- so there is nothing to copy, nothing to exclude in
7
+ ``template.yml``, and nothing to drift.
8
+
9
+ Sibling to ``pytest-rhiza``, which did the same for ``.rhiza/tests``.
10
+
11
+ Layout:
12
+
13
+ * :mod:`rhiza_task.spec` -- the task model: ``Task``, ``Guard``, ``Skip``/``Failed``, and
14
+ the registry that replaces make's double-colon rules.
15
+ * :mod:`rhiza_task.config` -- five-layer settings resolution, replacing ``?=`` and ``+=``.
16
+ * :mod:`rhiza_task.uv` -- the three ways rhiza reaches a tool.
17
+ * :mod:`rhiza_task.runner` -- prerequisite dedup, guard evaluation, outcome bookkeeping.
18
+ * :mod:`rhiza_task.cli` -- the Typer app, generated from the registry.
19
+ * :mod:`rhiza_task.tasks` -- the task modules themselves, loaded by entry point.
20
+ """
21
+
22
+ __all__ = ["__version__"]
23
+
24
+ # Kept in step with [project].version by bump-my-version, which needs a [[files]] entry
25
+ # for this file but not for pyproject.toml itself.
26
+ __version__ = "0.1.1"
rhiza_task/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Support ``python -m rhiza_task``, for a checkout without the console script installed."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__": # pragma: no cover - trivial delegation
6
+ main()
rhiza_task/cli.py ADDED
@@ -0,0 +1,163 @@
1
+ """The command line, generated from the registry rather than hand-maintained.
2
+
3
+ rhiza.mk builds its help by running awk over ``$(MAKEFILE_LIST)`` looking for ``##`` and
4
+ ``##@`` comments -- a parser for a documentation convention that exists only because make
5
+ has no notion of a task description. Typer has one, so help text, sections, per-task help
6
+ and the "unknown task" error all come from the same registry the runner uses, and cannot
7
+ drift from it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ from importlib.metadata import entry_points
15
+ from pathlib import Path
16
+
17
+ import typer
18
+ from rich.console import Console
19
+ from rich.table import Table
20
+
21
+ from . import __version__, runner
22
+ from .config import Config
23
+ from .runner import Status
24
+ from .spec import REGISTRY
25
+
26
+ app = typer.Typer(
27
+ add_completion=False,
28
+ no_args_is_help=True,
29
+ help="rhiza developer tasks. Run `rhiza-task list` to see what is available.",
30
+ )
31
+ console = Console()
32
+ err = Console(stderr=True)
33
+
34
+ STATUS_COLOUR = {
35
+ Status.OK: "green",
36
+ Status.SKIPPED: "yellow",
37
+ Status.FAILED: "red",
38
+ Status.BLOCKED: "red",
39
+ }
40
+
41
+ RESERVED = frozenset({"list", "print", "run", "ci-os-matrix", "shim", "version"})
42
+ """Subcommand names, so the bare-task shorthand in :func:`main` can tell them apart."""
43
+
44
+
45
+ def load_tasks() -> None:
46
+ """Import every module registered under the ``rhiza_task.tasks`` entry-point group.
47
+
48
+ Failures are reported and skipped rather than fatal: a broken third-party task module
49
+ should not take the built-in gates down with it.
50
+ """
51
+ for entry in entry_points(group="rhiza_task.tasks"):
52
+ try:
53
+ entry.load()
54
+ except Exception as exc: # noqa: BLE001 - a plugin must not break the runner
55
+ err.print(f"[yellow]could not load task module {entry.name}: {exc}[/yellow]")
56
+
57
+
58
+ @app.command("list")
59
+ def list_tasks() -> None:
60
+ """Show every available task, grouped by section."""
61
+ table = Table("task", "section", "needs", "does", box=None, header_style="bold")
62
+ for name, spec in sorted(REGISTRY.items(), key=lambda kv: (kv[1].section, kv[0])):
63
+ if not spec.hidden:
64
+ table.add_row(name, spec.section, " ".join(spec.needs), spec.help)
65
+ console.print(table)
66
+
67
+
68
+ @app.command("print")
69
+ def print_setting(name: str) -> None:
70
+ """Print one resolved setting, replacing make's ``print-%`` pattern rule.
71
+
72
+ Args:
73
+ name: A config field, spelled either way -- ``source_folder`` or ``SOURCE_FOLDER``.
74
+
75
+ Raises:
76
+ typer.Exit: With status 2 when the setting does not exist.
77
+ """
78
+ cfg = Config.load()
79
+ field = name.removeprefix("RHIZA_").lower().replace("-", "_")
80
+ if not hasattr(cfg, field):
81
+ err.print(f"[red]unknown setting: {name}[/red]")
82
+ raise typer.Exit(2)
83
+ value = getattr(cfg, field)
84
+ console.print(" ".join(map(str, value)) if isinstance(value, tuple) else str(value))
85
+
86
+
87
+ @app.command("ci-os-matrix")
88
+ def ci_os_matrix() -> None:
89
+ """Emit the CI OS matrix as a JSON array, for a GitHub Actions matrix input."""
90
+ print(json.dumps(list(Config.load().ci_os_matrix)))
91
+
92
+
93
+ @app.command("shim")
94
+ def shim() -> None:
95
+ """Print the Makefile that forwards to this CLI.
96
+
97
+ ``uvx rhiza-task shim > Makefile`` is the whole migration for a consumer repository:
98
+ it is what replaces ``.rhiza/rhiza.mk`` and the ten fragments in ``.rhiza/make.d/``.
99
+ Generated rather than synced, so it cannot drift from the version that produced it.
100
+ """
101
+ template = Path(__file__).parent / "templates" / "Makefile"
102
+ # sys.stdout, not the rich Console: Console word-wraps to the terminal width and
103
+ # expands tabs, and a Makefile survives neither. A wrapped comment is merely ugly, but
104
+ # a wrapped recipe line is a syntax error and a tab expanded to spaces stops make
105
+ # recognising the line as a recipe at all -- so `rhiza-task shim > Makefile` would
106
+ # write a broken file.
107
+ sys.stdout.write(template.read_text())
108
+
109
+
110
+ @app.command("version")
111
+ def version() -> None:
112
+ """Print the rhiza-task version."""
113
+ console.print(__version__)
114
+
115
+
116
+ @app.command("run", no_args_is_help=True)
117
+ def run_tasks(
118
+ names: list[str] = typer.Argument(..., help="Tasks to run, in order"),
119
+ strict: bool = typer.Option(False, "--strict", help="Treat a skipped gate as a failure"),
120
+ root: Path | None = typer.Option(None, "--root", help="Repository to operate on"),
121
+ ) -> None:
122
+ """Run one or more tasks, with their prerequisites.
123
+
124
+ Args:
125
+ names: Task names.
126
+ strict: Fail rather than skip when a gate has nothing to measure.
127
+ root: Repository root; defaults to the current directory.
128
+
129
+ Raises:
130
+ typer.Exit: With 0 when everything passed, 1 on failure, 2 on a usage error.
131
+ """
132
+ try:
133
+ cfg = Config.load(root=root, strict=strict or None)
134
+ except ValueError as exc: # invalid configuration, e.g. typechecker=tpye
135
+ err.print(f"[red]{exc}[/red]")
136
+ raise typer.Exit(2) from exc
137
+
138
+ try:
139
+ state = runner.run(names, cfg)
140
+ except KeyError as exc:
141
+ err.print(f"[red]{exc.args[0]}[/red] (try `rhiza-task list`)")
142
+ raise typer.Exit(2) from exc
143
+
144
+ console.print()
145
+ for result in state.results:
146
+ colour = STATUS_COLOUR[result.status]
147
+ detail = f" [dim]{result.detail}[/dim]" if result.detail else ""
148
+ console.print(f"[{colour}]{result.status.value:>8}[/{colour}] {result.name}{detail}")
149
+ raise typer.Exit(state.exit_code())
150
+
151
+
152
+ def main() -> None:
153
+ """Entry point. A bare ``rhiza-task <task>`` is shorthand for ``rhiza-task run <task>``.
154
+
155
+ Not sugar -- it is the compatibility contract. The reusable workflows and the Makefile
156
+ shim both invoke ``rhiza-task test``, and a consumer's muscle memory is ``make test``.
157
+ Requiring ``run`` would put a word between the two for no gain.
158
+ """
159
+ load_tasks()
160
+ argv = sys.argv[1:]
161
+ if argv and argv[0] not in RESERVED and not argv[0].startswith("-"):
162
+ sys.argv = [sys.argv[0], "run", *argv]
163
+ app()
rhiza_task/config.py ADDED
@@ -0,0 +1,248 @@
1
+ """Configuration, and the resolution order that replaces make's ``?=`` and ``+=``.
2
+
3
+ The make layer builds its settings from three overlapping mechanisms: ``?=`` defaults in
4
+ the fragment that owns a setting, ``+=`` accumulation from other fragments
5
+ (``DEPTRY_FOLDERS``, ``LICENSE_IGNORE_PACKAGES``, ``RHIZA_CHECKS``), and a repo-owned
6
+ Makefile or ``local.mk`` assigning over the top. The precedence is a consequence of
7
+ include order, which is why rhiza.mk has to explain that ``-include .rhiza/make.d/*.mk``
8
+ comes last and ``-include local.mk`` last of all.
9
+
10
+ Here the order is explicit and testable, lowest precedence first:
11
+
12
+ 1. The dataclass defaults below.
13
+ 2. ``.rhiza/.env`` -- kept unchanged, because it is already the file consumers edit and
14
+ the reusable workflows read it too.
15
+ 3. ``[tool.rhiza-task]`` in ``pyproject.toml`` -- the new home for what used to require
16
+ editing a synced ``.mk`` file or shadowing a target.
17
+ 4. ``RHIZA_*`` (or bare make-style) environment variables.
18
+ 5. Command-line flags.
19
+
20
+ The ``+=`` accumulators do not survive as a mechanism, and do not need to: every one of
21
+ them was a bundle contributing something it owned, which the task body can now *derive*
22
+ by asking whether the contributing task is registered. See ``tasks/python.py``'s ``deps``
23
+ and ``license``.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import tomllib
31
+ from collections.abc import Mapping
32
+ from dataclasses import dataclass, field, fields
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from dotenv import dotenv_values
37
+
38
+ TYPECHECKERS = ("ty", "mypy", "both")
39
+
40
+ DEFAULT_RHIZA_CHECKS = (
41
+ "pytest_rhiza.checks.test_readme",
42
+ "pytest_rhiza.checks.test_release_tags",
43
+ "pytest_rhiza.checks.test_pyproject",
44
+ "pytest_rhiza.checks.test_docstrings",
45
+ "pytest_rhiza.checks.test_readme_validation",
46
+ )
47
+ """The Python check set, enumerated rather than globbed.
48
+
49
+ pytest-rhiza ships the Rust and Go modules (``test_cargo_toml``, ``test_go_module``) in
50
+ the same distribution, so ``--pyargs pytest_rhiza.checks`` would collect checks that
51
+ cannot pass here. This is jointview's ``RHIZA_CHECKS`` list, promoted from a shadowed make
52
+ variable to a default: the 60-line override in its Makefile exists only because the make
53
+ layer had nowhere else to put it.
54
+ """
55
+
56
+
57
+ @dataclass
58
+ class Config:
59
+ """Resolved settings for one repository.
60
+
61
+ Field names are the lowercased make variables, so the mapping to what a consumer
62
+ already knows stays one-to-one and greppable.
63
+ """
64
+
65
+ source_folder: str = "src"
66
+ tests_folder: str = "tests"
67
+ marimo_folder: str = "docs/notebooks"
68
+ book_output: str = "_book"
69
+ python_version: str = "3.13"
70
+
71
+ coverage_fail_under: int = 90
72
+
73
+ # ty | mypy | both. python.mk documents that ``both`` masks ty's exit status behind
74
+ # mypy's, and jointview sets ``ty`` in .rhiza/.env for that reason. The shell ``case``
75
+ # whose fourth branch validated this is replaced by __post_init__, so a typo now fails
76
+ # before any tool is provisioned rather than after.
77
+ typechecker: str = "ty"
78
+
79
+ # Matched as substrings -- see ``--partial-match`` in the ``license`` task.
80
+ license_fail_on: tuple[str, ...] = ("GPL", "LGPL", "AGPL")
81
+ license_ignore_packages: tuple[str, ...] = ()
82
+
83
+ deptry_ignore: tuple[str, ...] = ()
84
+ mkdocs_extra_packages: tuple[str, ...] = ()
85
+ zensical_version: str = ">=0.0.36"
86
+ uv_sync_args: tuple[str, ...] = ("--all-extras", "--all-groups")
87
+ ci_os_matrix: tuple[str, ...] = ("ubuntu-latest",)
88
+
89
+ # Pinned to a tag rather than a branch: a gate that moves under you is not a gate.
90
+ pytest_rhiza: str = "pytest-rhiza @ git+https://github.com/Jebel-Quant/pytest-rhiza@v0.2.0"
91
+ rhiza_checks: tuple[str, ...] = DEFAULT_RHIZA_CHECKS
92
+
93
+ # Turns Skip into failure. The answer to jointview's own complaint about "a green gate
94
+ # measuring nothing": set it in CI and a missing folder is a red build rather than a
95
+ # yellow line nobody reads.
96
+ strict: bool = False
97
+
98
+ root: Path = field(default_factory=Path.cwd)
99
+
100
+ def __post_init__(self) -> None:
101
+ """Validate the enumerated and numeric fields.
102
+
103
+ Raises:
104
+ ValueError: When ``typechecker`` is not one of ty, mypy, both, or
105
+ ``coverage_fail_under`` is outside 0-100.
106
+ """
107
+ if self.typechecker not in TYPECHECKERS:
108
+ msg = f"typechecker must be one of {', '.join(TYPECHECKERS)} (got {self.typechecker!r})"
109
+ raise ValueError(msg)
110
+ if not 0 <= int(self.coverage_fail_under) <= 100:
111
+ msg = f"coverage_fail_under must be a percentage (got {self.coverage_fail_under!r})"
112
+ raise ValueError(msg)
113
+
114
+ @property
115
+ def folders(self) -> dict[str, str]:
116
+ """Return the folder settings, for :meth:`~rhiza_task.spec.Guard.check`.
117
+
118
+ Returns:
119
+ Mapping of field name to configured relative path.
120
+ """
121
+ return {f.name: getattr(self, f.name) for f in fields(self) if f.name.endswith("_folder")}
122
+
123
+ def path(self, folder_field: str) -> Path:
124
+ """Resolve a folder field to an absolute path.
125
+
126
+ Args:
127
+ folder_field: A field name such as ``source_folder``.
128
+
129
+ Returns:
130
+ The absolute path.
131
+ """
132
+ return self.root / getattr(self, folder_field)
133
+
134
+ @classmethod
135
+ def load(cls, root: Path | None = None, **overrides: Any) -> Config:
136
+ """Build a config by walking the five layers in order.
137
+
138
+ Args:
139
+ root: Repository root; defaults to the current directory.
140
+ **overrides: Layer 5, the command-line flags. ``None`` values are ignored so
141
+ an unset flag does not shadow a configured value.
142
+
143
+ Returns:
144
+ The resolved config.
145
+ """
146
+ root = (root or Path.cwd()).absolute()
147
+ raw: dict[str, Any] = {}
148
+ raw.update(_from_env_file(root / ".rhiza" / ".env"))
149
+ raw.update(_from_pyproject(root / "pyproject.toml"))
150
+ raw.update(_from_environ(os.environ))
151
+ raw.update({k: v for k, v in overrides.items() if v is not None})
152
+
153
+ # .python-version wins over a configured python_version for the reason python.mk
154
+ # reads it: it is what uv itself honours, so a second source of truth could only
155
+ # ever disagree.
156
+ pv = root / ".python-version"
157
+ if pv.is_file() and (text := pv.read_text().strip()):
158
+ raw["python_version"] = text
159
+
160
+ known = {f.name for f in fields(cls)} - {"root"}
161
+ return cls(root=root, **{k: v for k, v in raw.items() if k in known})
162
+
163
+
164
+ def _from_env_file(path: Path) -> dict[str, Any]:
165
+ """Read ``.rhiza/.env``.
166
+
167
+ Args:
168
+ path: Path to the dotenv file.
169
+
170
+ Returns:
171
+ Parsed settings; empty when the file is absent.
172
+ """
173
+ if not path.is_file():
174
+ return {}
175
+ return {_key(k): _coerce(v) for k, v in dotenv_values(path).items() if v is not None}
176
+
177
+
178
+ def _from_pyproject(path: Path) -> dict[str, Any]:
179
+ """Read ``[tool.rhiza-task]``.
180
+
181
+ Values here are already typed by TOML, so they bypass :func:`_coerce` -- a TOML array
182
+ arrives as a list and is tupled, nothing is parsed out of a string.
183
+
184
+ Args:
185
+ path: Path to pyproject.toml.
186
+
187
+ Returns:
188
+ Parsed settings; empty when the file or the table is absent.
189
+ """
190
+ if not path.is_file():
191
+ return {}
192
+ table = tomllib.loads(path.read_text()).get("tool", {}).get("rhiza-task", {})
193
+ return {k.replace("-", "_"): tuple(v) if isinstance(v, list) else v for k, v in table.items()}
194
+
195
+
196
+ def _from_environ(environ: Mapping[str, str]) -> dict[str, Any]:
197
+ """Read settings from the process environment.
198
+
199
+ Both ``RHIZA_CI_OS_MATRIX`` and bare ``SOURCE_FOLDER`` are accepted, because the
200
+ reusable workflows currently pass bare make-style names on the command line and those
201
+ jobs must keep working through the transition.
202
+
203
+ Args:
204
+ environ: The environment mapping.
205
+
206
+ Returns:
207
+ Parsed settings.
208
+
209
+ """
210
+ known = {f.name for f in fields(Config)}
211
+ return {_key(k): _coerce(v) for k, v in environ.items() if _key(k) in known}
212
+
213
+
214
+ def _key(name: str) -> str:
215
+ """Normalise a make-style variable name to a field name.
216
+
217
+ Args:
218
+ name: e.g. ``RHIZA_CI_OS_MATRIX`` or ``SOURCE_FOLDER``.
219
+
220
+ Returns:
221
+ e.g. ``ci_os_matrix``, ``source_folder``.
222
+ """
223
+ return name.removeprefix("RHIZA_").lower()
224
+
225
+
226
+ def _coerce(value: str) -> Any:
227
+ """Turn a string setting into the field's type.
228
+
229
+ Three shapes exist in ``.rhiza/.env`` today and all must keep parsing: a JSON array
230
+ (``RHIZA_CI_OS_MATRIX=["ubuntu-latest","macos-latest"]``), a semicolon-separated list
231
+ (``LICENSE_FAIL_ON=GPL;LGPL;AGPL``), and a plain scalar.
232
+
233
+ Args:
234
+ value: The raw string.
235
+
236
+ Returns:
237
+ A ``str``, ``int``, ``bool`` or ``tuple[str, ...]``.
238
+ """
239
+ value = value.strip()
240
+ if value.startswith("["):
241
+ return tuple(json.loads(value))
242
+ if ";" in value:
243
+ return tuple(p for p in value.split(";") if p)
244
+ if value.lower() in {"true", "false"}:
245
+ return value.lower() == "true"
246
+ if value.lstrip("-").isdigit():
247
+ return int(value)
248
+ return value
rhiza_task/runner.py ADDED
@@ -0,0 +1,149 @@
1
+ """Prerequisite resolution, guard evaluation, and outcome bookkeeping.
2
+
3
+ Small on purpose. make gave four behaviours for free, and this module is what buys them
4
+ back; nothing else belongs here.
5
+
6
+ 1. **Dedup within one invocation.** Eleven tasks name ``install`` as a prerequisite and
7
+ ``all`` names eight of those. Without a seen-set, ``rhiza-task all`` would sync the
8
+ environment eight times.
9
+ 2. **Depth-first ordering.** ``book`` needs ``test``, which needs ``install``.
10
+ 3. **A failed prerequisite stops its dependents.** As make does, rather than running a
11
+ gate against a half-built environment.
12
+ 4. **A missing prerequisite is not an error.** book.mk declares ``test:: ; @:`` no-op
13
+ stubs so ``book`` can depend on gates that may not have been synced; here a
14
+ prerequisite absent from the registry is simply not run, and the stubs are gone.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from enum import StrEnum
21
+
22
+ from .config import Config
23
+ from .spec import REGISTRY, Failed, Skip
24
+
25
+
26
+ class Status(StrEnum):
27
+ """The four outcomes a task can have."""
28
+
29
+ OK = "ok"
30
+ SKIPPED = "skipped"
31
+ FAILED = "failed"
32
+ BLOCKED = "blocked"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Result:
37
+ """What happened to one task.
38
+
39
+ Attributes:
40
+ name: The task name.
41
+ status: Its outcome.
42
+ detail: Why, for anything other than :attr:`Status.OK`.
43
+ """
44
+
45
+ name: str
46
+ status: Status
47
+ detail: str = ""
48
+
49
+
50
+ @dataclass
51
+ class Run:
52
+ """One invocation: the results so far, and the tasks already attempted."""
53
+
54
+ results: list[Result] = field(default_factory=list)
55
+ seen: set[str] = field(default_factory=set)
56
+
57
+ @property
58
+ def failed(self) -> bool:
59
+ """Whether any task failed or was blocked.
60
+
61
+ Returns:
62
+ True when the invocation should exit non-zero.
63
+ """
64
+ return any(r.status in {Status.FAILED, Status.BLOCKED} for r in self.results)
65
+
66
+ def status_of(self, name: str) -> Status | None:
67
+ """Return the recorded status of a task, if it ran.
68
+
69
+ Args:
70
+ name: Task name.
71
+
72
+ Returns:
73
+ The status, or None when the task was not attempted.
74
+ """
75
+ return next((r.status for r in self.results if r.name == name), None)
76
+
77
+ def exit_code(self) -> int:
78
+ """Return the aggregate exit status.
79
+
80
+ The first real failure's own code is propagated where there is one, so a caller
81
+ can still distinguish e.g. pytest's exit codes. Blocked-only runs exit 1.
82
+
83
+ Returns:
84
+ 0 when nothing failed, else the failing task's exit status.
85
+ """
86
+ return 1 if self.failed else 0
87
+
88
+
89
+ def run(names: list[str], cfg: Config) -> Run:
90
+ """Run the named tasks and their prerequisites, in order.
91
+
92
+ Args:
93
+ names: Task names, as typed on the command line.
94
+ cfg: The resolved config.
95
+
96
+ Returns:
97
+ The completed :class:`Run`.
98
+
99
+ Raises:
100
+ KeyError: When an explicitly requested task does not exist. Only for requested
101
+ names -- an unknown *prerequisite* is skipped, whereas an unknown request is a
102
+ typo and should say so.
103
+ """
104
+ unknown = [n for n in names if n not in REGISTRY]
105
+ if unknown:
106
+ msg = f"unknown task{'s' if len(unknown) > 1 else ''}: {', '.join(unknown)}"
107
+ raise KeyError(msg)
108
+
109
+ run_state = Run()
110
+ for name in names:
111
+ _run_one(name, cfg, run_state)
112
+ return run_state
113
+
114
+
115
+ def _run_one(name: str, cfg: Config, state: Run) -> None:
116
+ """Run one task after its prerequisites, recording the outcome.
117
+
118
+ Args:
119
+ name: Task name.
120
+ cfg: The resolved config.
121
+ state: The invocation state, appended to in place.
122
+ """
123
+ if name in state.seen or name not in REGISTRY:
124
+ return
125
+ state.seen.add(name)
126
+ spec = REGISTRY[name]
127
+
128
+ for need in spec.needs:
129
+ _run_one(need, cfg, state)
130
+ blocked = [n for n in spec.needs if state.status_of(n) in {Status.FAILED, Status.BLOCKED}]
131
+ if blocked:
132
+ state.results.append(Result(name, Status.BLOCKED, f"prerequisite failed: {', '.join(blocked)}"))
133
+ return
134
+
135
+ try:
136
+ for guard in spec.guards:
137
+ guard.check(cfg.root, cfg.folders)
138
+ spec.run(cfg)
139
+ except Skip as exc:
140
+ # The strict switch is the whole reason Skip is a distinct outcome rather than a
141
+ # warning printed on the way to exit 0.
142
+ if cfg.strict:
143
+ state.results.append(Result(name, Status.FAILED, f"skipped under --strict: {exc}"))
144
+ else:
145
+ state.results.append(Result(name, Status.SKIPPED, str(exc)))
146
+ except Failed as exc:
147
+ state.results.append(Result(name, Status.FAILED, str(exc)))
148
+ else:
149
+ state.results.append(Result(name, Status.OK))