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

1"""The repository's own environment hook: the seam a template cannot ship. 

2 

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. 

7 

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. 

15 

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. 

20 

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. 

27 

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. 

33 

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. 

41 

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. 

47 

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""" 

55 

56from __future__ import annotations 

57 

58import os 

59import shutil 

60from pathlib import Path 

61 

62from ..config import Config 

63from ..spec import Failed, task 

64from ..uv import tool 

65 

66HOOK = "local-setup.sh" 

67"""The repo-owned hook, at the repository root. 

68 

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""" 

75 

76POSIX = os.name == "posix" 

77"""Whether this platform can start the hook itself. 

78 

79One platform fact with two consequences, which is why it is one constant rather than two. 

80 

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. 

85 

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. 

90 

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""" 

96 

97SHELL = "sh" 

98"""The interpreter the hook is handed to where the platform cannot start it. 

99 

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""" 

105 

106 

107def _command(hook: Path) -> tuple[str, ...]: 

108 """Build the argument vector that starts the hook on this platform. 

109 

110 Args: 

111 hook: The absolute path to the hook. 

112 

113 Returns: 

114 The vector to hand to :func:`~rhiza_task.uv.tool`. 

115 

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)) 

133 

134 

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. 

138 

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. 

144 

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. 

153 

154 Args: 

155 cfg: The resolved config. 

156 

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