Coverage for src/rhiza_task/tasks/setup.py: 100%
33 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-13 02:27 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-13 02:27 +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 -- but not, as
35this module first had it, as a wall. Windows cannot ``exec`` a ``.sh`` at all: the OS answers
36*%1 is not a valid Win32 application* before the shebang is read, so the first version of this
37task reported that as a failure and told the reader their platform needed its own arrangement.
38Which was one insertion point short of the design's own claim -- ``install`` is a prerequisite
39of every gate, so a consumer whose matrix includes ``windows-latest`` could not adopt the hook
40at all, even for a dependency that only matters on one job. See #148.
42So on a platform with no execute bit the hook is handed to ``sh`` rather than started
43directly, and there is one to hand it to: GitHub's ``windows-latest`` runners ship git-bash,
44whose ``sh`` is on PATH and is what :func:`shutil.which` finds. One hook file still covers
45every platform, which is the whole point of putting the seam here; a project that provisions
46differently per OS branches inside the script, where the platform knowledge already is.
48Two things follow from handing it to ``sh`` instead of exec'ing it, and both are why the
49POSIX branch is left alone rather than unified with it. The shebang is **not** consulted --
50a hook whose first line names ``python3`` runs under python3 on POSIX and under ``sh`` on
51Windows -- which is what the ``.sh`` in the name is promising and this docstring is repeating.
52And a machine with no ``sh`` on PATH is told *that*, rather than being handed the Win32 error
53from an exec that was never going to work.
54"""
56from __future__ import annotations
58import os
59import shutil
60from pathlib import Path
62from ..config import Config
63from ..spec import Failed, task
64from ..uv import tool
66HOOK = "local-setup.sh"
67"""The repo-owned hook, at the repository root.
69A fixed name rather than a setting, for the same reason the Makefile hard-codes
70``-include local.mk``: a seam whose *location* is configurable has to be discovered before it
71can be used, and there is nothing a project could express by moving it. It sits beside
72``local.mk`` and is committed for the same reason -- anything CI invokes has to be in the
73repository.
74"""
76POSIX = os.name == "posix"
77"""Whether this platform can start the hook itself.
79One platform fact with two consequences, which is why it is one constant rather than two.
81It decides whether there is an **execute bit worth asking about**. Windows has none:
82``os.access(path, os.X_OK)`` reports *every* existing file as executable there, so the check
83below would pass vacuously -- a guard that reads like protection while guaranteeing nothing,
84which is worse than not asking.
86And it decides **how the hook is started**: exec'd directly where the OS honours a shebang,
87handed to ``sh`` where it does not. The two answers move together because they are the same
88question asked twice -- a platform that has no execute bit is a platform that will not start
89the file for you.
91Named rather than inlined as ``os.name == "posix"`` so the assumption is visible at module
92level, and so a test can reach *both* branches on either platform. Patching ``os.name`` to
93get there is not an option -- it is read by :mod:`pathlib` at import, and forcing it makes
94``Path`` unconstructible.
95"""
97SHELL = "sh"
98"""The interpreter the hook is handed to where the platform cannot start it.
100``sh`` rather than ``bash``, because the hook's contract is POSIX shell and ``sh`` is the
101spelling git-bash, WSL and every POSIX system agree on. Not a setting: a repository that
102needs a different interpreter says so in its shebang, which is honoured everywhere the
103platform starts the file itself.
104"""
107def _command(hook: Path) -> tuple[str, ...]:
108 """Build the argument vector that starts the hook on this platform.
110 Args:
111 hook: The absolute path to the hook.
113 Returns:
114 The vector to hand to :func:`~rhiza_task.uv.tool`.
116 Raises:
117 Failed: When the platform cannot start the file and has no ``sh`` to do it.
118 """
119 if POSIX:
120 return (str(hook),)
121 # A missing shell fails rather than skipping, for the same reason a missing execute bit
122 # does: the repository asked for provisioning and it did not happen. It is deliberately
123 # not a `Guard` either -- a guard is declared on the task and would skip the *common*
124 # case, a repository with no hook at all, on any machine without `sh`.
125 shell = shutil.which(SHELL)
126 if shell is None:
127 raise Failed(
128 1,
129 f"could not run {HOOK}: no `{SHELL}` on PATH to run it with -- "
130 "install Git for Windows, whose git-bash provides one",
131 )
132 return (shell, str(hook))
135@task("setup", "run the repository's own environment setup hook", section="Dev")
136def setup(cfg: Config) -> None:
137 """Run ``local-setup.sh`` if the repository has one.
139 The asymmetry between the two failure modes is the whole design. A repository with no
140 hook is a genuine no-op, so it skips. A hook that exists but is not executable is a
141 mistake someone made while expecting it to run, so it fails rather than passing quietly
142 -- that is the case a skip would turn into the silent-green outcome this hook exists to
143 remove.
145 **An absent hook succeeds rather than skipping**, and that is not the usual call in this
146 package. :class:`~rhiza_task.spec.Skip` means work was asked for and did not happen,
147 which is why ``--strict`` promotes it to a failure: CI can then assert that a gate
148 measured something. Nothing was asked for here. Most repositories need no native
149 provisioning at all, so skipping would make every ``--strict`` invocation fail on the
150 common case -- ``book --strict`` reaches this through five prerequisites -- and a switch
151 that cannot be used is worth less than the line it prints. The INFO line is what keeps
152 the outcome legible.
154 Args:
155 cfg: The resolved config.
157 Raises:
158 Failed: When the hook is not executable, cannot be started, or exits non-zero.
159 """
160 hook = cfg.root / HOOK
161 if not hook.is_file():
162 print(f"[INFO] no {HOOK}; nothing to provision")
163 return
164 if POSIX and not os.access(hook, os.X_OK):
165 raise Failed(1, f"{HOOK} is not executable -- run `chmod +x {HOOK}`")
166 command = _command(hook)
167 try:
168 tool(*command, cwd=cfg.root)
169 except OSError as exc:
170 # Windows no longer arrives here -- `_command` hands it a shell rather than the
171 # script. What still does is a malformed shebang on POSIX, which raises ENOEXEC
172 # through this same path, and a resolved `sh` that will not start. Either way an
173 # unhandled OSError would surface as a traceback, which is a poor way to learn that
174 # a provisioning script cannot begin.
175 raise Failed(1, f"could not run {HOOK}: {exc}") from exc