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 +26 -0
- rhiza_task/__main__.py +6 -0
- rhiza_task/cli.py +163 -0
- rhiza_task/config.py +248 -0
- rhiza_task/runner.py +149 -0
- rhiza_task/spec.py +185 -0
- rhiza_task/tasks/__init__.py +7 -0
- rhiza_task/tasks/book.py +195 -0
- rhiza_task/tasks/doctor.py +148 -0
- rhiza_task/tasks/extras.py +164 -0
- rhiza_task/tasks/python.py +315 -0
- rhiza_task/tasks/quality.py +236 -0
- rhiza_task/templates/Makefile +36 -0
- rhiza_task/uv.py +178 -0
- rhiza_task-0.1.1.dist-info/METADATA +209 -0
- rhiza_task-0.1.1.dist-info/RECORD +19 -0
- rhiza_task-0.1.1.dist-info/WHEEL +4 -0
- rhiza_task-0.1.1.dist-info/entry_points.txt +9 -0
- rhiza_task-0.1.1.dist-info/licenses/LICENSE +21 -0
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
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))
|