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

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

41 

42from __future__ import annotations 

43 

44import os 

45 

46from ..config import Config 

47from ..spec import Failed, task 

48from ..uv import tool 

49 

50HOOK = "local-setup.sh" 

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

52 

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

59 

60CHECKS_EXECUTABLE_BIT = os.name == "posix" 

61"""Whether this platform has an execute bit worth asking about. 

62 

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. 

67 

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

73 

74 

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. 

78 

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. 

84 

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. 

93 

94 Args: 

95 cfg: The resolved config. 

96 

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