Coverage for src/rhiza_task/tasks/setup.py: 100%
21 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
1"""The repository's own environment hook: the seam a template cannot ship.
3A rhiza-managed project may need a **native binary** in place before any gate can run --
4graphviz for a docs plugin, ``libpq`` for psycopg, pandoc, an ODBC driver. The template
5owns every configuration file in such a repository, so there was nowhere to put that step,
6and the answer rhiza documented did not work.
8That answer was to shadow ``install`` in ``local.mk``. It never ran. The Makefile is a shim
9whose ``%:`` catch-all forwards the *goal* to this CLI, and ``install`` is a prerequisite
10**here** rather than at make level -- so ``make test`` resolves it in :mod:`rhiza_task.runner`
11and never consults a make rule of that name. CI is worse: every reusable workflow invokes
12``uvx rhiza-task <gate>`` directly and never runs make at all. The recipe fired only if
13someone typed ``make install`` by hand, which is exactly when a consumer would test it and
14conclude it worked.
16So the hook belongs where ``install`` is, and this is it. ``install`` is the prerequisite of
17essentially every gate, in all three language layers, which is what makes one insertion point
18enough: local ``make test``, GitHub Actions, GitLab CI and the devcontainer's ``make install``
19all arrive here without a workflow edit between them.
21**This is not the package growing a package manager**, which :mod:`rhiza_task.tasks.lfs`
22rules out: a runner provisioned by ``uvx`` should not shell out to ``sudo apt-get`` as a
23side effect of a target someone typed. That rule is about *this* package owning
24apt/brew/winget logic and its liability. Running a script the repository committed inverts
25it -- the CLI decides *when*, the repository decides *what*, and the platform detection
26stays with the people who know which platforms they build on.
28It is also why there is no ``system-packages = [...]`` setting instead. ``graphviz`` happens
29to be spelled the same on apt and brew; ``libgl1-mesa-glx`` is not, and a list cannot express
30"download this tarball" -- which is precisely what rhiza itself does for tectonic. A list
31would be a schema this package then had to keep honest across three package managers, for the
32subset of needs that happen to be a package name.
34The hook is POSIX shell by name and by nature, and Windows is where that shows. The
35executable check is skipped there -- ``os.access(X_OK)`` reports every existing file as
36executable, so asking would be a guard that guarantees nothing -- and the OS refusing to run
37a ``.sh`` is reported as a failure with the reason attached rather than as a traceback. A
38project that must provision on Windows needs its own arrangement; this task will tell you
39clearly that it could not.
40"""
42from __future__ import annotations
44import os
46from ..config import Config
47from ..spec import Failed, task
48from ..uv import tool
50HOOK = "local-setup.sh"
51"""The repo-owned hook, at the repository root.
53A fixed name rather than a setting, for the same reason the Makefile hard-codes
54``-include local.mk``: a seam whose *location* is configurable has to be discovered before it
55can be used, and there is nothing a project could express by moving it. It sits beside
56``local.mk`` and is committed for the same reason -- anything CI invokes has to be in the
57repository.
58"""
60CHECKS_EXECUTABLE_BIT = os.name == "posix"
61"""Whether this platform has an execute bit worth asking about.
63Windows does not. ``os.access(path, os.X_OK)`` reports *every* existing file as executable
64there, so the check below would pass vacuously -- a guard that reads like protection while
65guaranteeing nothing, which is worse than not asking. What covers Windows instead is the
66``OSError`` handler: the OS refuses to start the script, and that is reported with its reason.
68Named rather than inlined as ``os.name == "posix"`` so the assumption is visible at module
69level, and so a test can reach *both* branches on either platform. Patching ``os.name`` to
70get there is not an option -- it is read by :mod:`pathlib` at import, and forcing it makes
71``Path`` unconstructible.
72"""
75@task("setup", "run the repository's own environment setup hook", section="Dev")
76def setup(cfg: Config) -> None:
77 """Run ``local-setup.sh`` if the repository has one.
79 The asymmetry between the two failure modes is the whole design. A repository with no
80 hook is a genuine no-op, so it skips. A hook that exists but is not executable is a
81 mistake someone made while expecting it to run, so it fails rather than passing quietly
82 -- that is the case a skip would turn into the silent-green outcome this hook exists to
83 remove.
85 **An absent hook succeeds rather than skipping**, and that is not the usual call in this
86 package. :class:`~rhiza_task.spec.Skip` means work was asked for and did not happen,
87 which is why ``--strict`` promotes it to a failure: CI can then assert that a gate
88 measured something. Nothing was asked for here. Most repositories need no native
89 provisioning at all, so skipping would make every ``--strict`` invocation fail on the
90 common case -- ``book --strict`` reaches this through five prerequisites -- and a switch
91 that cannot be used is worth less than the line it prints. The INFO line is what keeps
92 the outcome legible.
94 Args:
95 cfg: The resolved config.
97 Raises:
98 Failed: When the hook is not executable, or exits non-zero.
99 """
100 hook = cfg.root / HOOK
101 if not hook.is_file():
102 print(f"[INFO] no {HOOK}; nothing to provision")
103 return
104 if CHECKS_EXECUTABLE_BIT and not os.access(hook, os.X_OK):
105 raise Failed(1, f"{HOOK} is not executable -- run `chmod +x {HOOK}`")
106 try:
107 tool(str(hook), cwd=cfg.root)
108 except OSError as exc:
109 # Not a Windows special case, though it is how Windows arrives: the OS refuses to
110 # execute the file at all. A malformed shebang on POSIX raises ENOEXEC through the
111 # same path. Either way an unhandled OSError would surface as a traceback, which is
112 # a poor way to learn that a provisioning script cannot start.
113 raise Failed(1, f"could not run {HOOK}: {exc}") from exc