quirework 0.1.0__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.
Files changed (138) hide show
  1. quirework/__init__.py +3 -0
  2. quirework/__main__.py +3 -0
  3. quirework/cli.py +431 -0
  4. quirework/core/__init__.py +1 -0
  5. quirework/core/artifact_paths.py +86 -0
  6. quirework/core/checks.py +235 -0
  7. quirework/core/frontmatter.py +83 -0
  8. quirework/core/globs.py +45 -0
  9. quirework/core/pinned.py +169 -0
  10. quirework/core/usage.py +604 -0
  11. quirework/delivery/__init__.py +1 -0
  12. quirework/delivery/artifacts.py +210 -0
  13. quirework/delivery/text.py +166 -0
  14. quirework/manuscript/__init__.py +1 -0
  15. quirework/manuscript/backfill.py +1972 -0
  16. quirework/manuscript/editions.py +111 -0
  17. quirework/manuscript/footprint.py +95 -0
  18. quirework/manuscript/formats.py +208 -0
  19. quirework/manuscript/lifecycle.py +191 -0
  20. quirework/manuscript/notes.py +2301 -0
  21. quirework/manuscript/pages.py +284 -0
  22. quirework/manuscript/quarto.py +416 -0
  23. quirework/manuscript/quotes.py +512 -0
  24. quirework/manuscript/rename.py +309 -0
  25. quirework/manuscript/sources.py +286 -0
  26. quirework/manuscript/trail.py +1875 -0
  27. quirework/manuscript/units.py +416 -0
  28. quirework/project.py +645 -0
  29. quirework/provenance/__init__.py +1 -0
  30. quirework/provenance/agyfilter.py +436 -0
  31. quirework/provenance/backup/__init__.py +469 -0
  32. quirework/provenance/backup/base.py +290 -0
  33. quirework/provenance/backup/command.py +107 -0
  34. quirework/provenance/backup/directory.py +142 -0
  35. quirework/provenance/backup/git.py +150 -0
  36. quirework/provenance/backup/rclone.py +153 -0
  37. quirework/provenance/capture/__init__.py +1 -0
  38. quirework/provenance/capture/commands.py +28 -0
  39. quirework/provenance/capture/extract_artifacts.py +110 -0
  40. quirework/provenance/capture/extract_files.py +118 -0
  41. quirework/provenance/capture/fetch_conversation.py +127 -0
  42. quirework/provenance/capture/fetch_gemini.py +149 -0
  43. quirework/provenance/capture/render_transcript.py +126 -0
  44. quirework/provenance/capture/transform.py +22 -0
  45. quirework/provenance/catalog.py +733 -0
  46. quirework/provenance/ccfilter.py +1126 -0
  47. quirework/provenance/codexfilter.py +883 -0
  48. quirework/provenance/coldread.py +1731 -0
  49. quirework/provenance/curate.py +67 -0
  50. quirework/provenance/humaninput.py +811 -0
  51. quirework/provenance/ingest.py +31 -0
  52. quirework/provenance/ingestion/__init__.py +1 -0
  53. quirework/provenance/ingestion/agy.py +192 -0
  54. quirework/provenance/ingestion/claude.py +493 -0
  55. quirework/provenance/ingestion/codex.py +296 -0
  56. quirework/provenance/lineage.py +148 -0
  57. quirework/provenance/metrics.py +1247 -0
  58. quirework/provenance/rulings.py +478 -0
  59. quirework/provenance/scope.py +73 -0
  60. quirework/provenance/search.py +455 -0
  61. quirework/provenance/semantic.py +475 -0
  62. quirework/provenance/show.py +1213 -0
  63. quirework/provenance/store.py +1271 -0
  64. quirework/provenance/trees.py +133 -0
  65. quirework/provenance/worktrees.py +199 -0
  66. quirework/providers/__init__.py +1 -0
  67. quirework/providers/blind.py +2192 -0
  68. quirework/providers/canary.py +685 -0
  69. quirework/providers/catalog.py +44 -0
  70. quirework/providers/claude_login.py +92 -0
  71. quirework/providers/exits.py +196 -0
  72. quirework/providers/harness.py +830 -0
  73. quirework/providers/images.py +92 -0
  74. quirework/providers/models.py +1284 -0
  75. quirework/providers/quota.py +588 -0
  76. quirework/providers/reads.py +474 -0
  77. quirework/providers/replies.py +175 -0
  78. quirework/publish/__init__.py +1 -0
  79. quirework/publish/backends.py +112 -0
  80. quirework/publish/bookstage.py +1086 -0
  81. quirework/publish/build.py +60 -0
  82. quirework/publish/editions.py +48 -0
  83. quirework/publish/epubcheck.py +287 -0
  84. quirework/publish/experiment.py +1097 -0
  85. quirework/publish/figfonts.py +234 -0
  86. quirework/publish/fignumbers.py +293 -0
  87. quirework/publish/figures.py +729 -0
  88. quirework/publish/folios.py +62 -0
  89. quirework/publish/fonts.py +307 -0
  90. quirework/publish/graybuild.py +73 -0
  91. quirework/publish/hugo.py +117 -0
  92. quirework/publish/kindle.js +411 -0
  93. quirework/publish/kindle.py +1393 -0
  94. quirework/publish/panelkit.py +918 -0
  95. quirework/publish/plans.py +651 -0
  96. quirework/publish/proof.py +792 -0
  97. quirework/publish/redpen.py +1654 -0
  98. quirework/publish/render.py +942 -0
  99. quirework/publish/targets.py +216 -0
  100. quirework/publish/variant.py +918 -0
  101. quirework/runs/__init__.py +1 -0
  102. quirework/runs/__main__.py +15 -0
  103. quirework/runs/commands.py +278 -0
  104. quirework/runs/coverage.py +86 -0
  105. quirework/runs/engine.py +2143 -0
  106. quirework/runs/feed.py +853 -0
  107. quirework/runs/observe.py +379 -0
  108. quirework/runs/storage.py +155 -0
  109. quirework/settings.py +492 -0
  110. quirework/sops/__init__.py +1 -0
  111. quirework/sops/catalog.py +386 -0
  112. quirework/sops/commands.py +567 -0
  113. quirework/sops/composition.py +382 -0
  114. quirework/sops/instruments.py +184 -0
  115. quirework/sops/personas.py +296 -0
  116. quirework/sops/review.py +2568 -0
  117. quirework/sops/room.py +2791 -0
  118. quirework/sops/roundtable.py +2578 -0
  119. quirework/sops/sections.py +169 -0
  120. quirework/sops/workshop.py +1629 -0
  121. quirework/templates/backup.toml +39 -0
  122. quirework/templates/book.toml +88 -0
  123. quirework/templates/run.toml +32 -0
  124. quirework/templates/site.toml +90 -0
  125. quirework/templates/tells.toml +64 -0
  126. quirework/templates/terminology.toml +20 -0
  127. quirework/templates/tmpfiles.conf +42 -0
  128. quirework/testing.py +345 -0
  129. quirework/text/__init__.py +1 -0
  130. quirework/text/jargon.py +545 -0
  131. quirework/text/mdwrap.py +1001 -0
  132. quirework/text/tells.py +547 -0
  133. quirework-0.1.0.dist-info/METADATA +444 -0
  134. quirework-0.1.0.dist-info/RECORD +138 -0
  135. quirework-0.1.0.dist-info/WHEEL +5 -0
  136. quirework-0.1.0.dist-info/entry_points.txt +2 -0
  137. quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
  138. quirework-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,235 @@
1
+ """Run an explicitly configured project check suite."""
2
+
3
+ import argparse
4
+ import ast
5
+ import contextlib
6
+ import io
7
+ import json
8
+ import shutil
9
+ import subprocess
10
+ from pathlib import Path
11
+ from quirework.project import current_project, extension
12
+ from quirework.text import mdwrap
13
+
14
+
15
+ SUITE = (
16
+ ("Python source checks", "every selected .py file parses"),
17
+ ("Formatting", "`ruff format` would leave the tree unchanged"),
18
+ ("Markdown wrapping", "`quirework text wrap` would leave the tree unchanged"),
19
+ )
20
+
21
+ # What `text wrap` reads. Selecting by suffix before asking the project's
22
+ # rules keeps a binary file away from a classifier written for prose.
23
+ MARKDOWN = (".md", ".qmd")
24
+
25
+
26
+ def default_checks(argv=None):
27
+ parser = argparse.ArgumentParser(prog="quirework dev check")
28
+ parser.add_argument("--staged", action="store_true")
29
+ # The dispatcher offers --list for any suite, so the default one has to
30
+ # answer it too. It reached this parser and was rejected as unknown,
31
+ # which made the option unusable in exactly the projects that have no
32
+ # configured suite to describe.
33
+ parser.add_argument("--list", action="store_true")
34
+ args = parser.parse_args(argv)
35
+ if args.list:
36
+ print("Default suite:")
37
+ for name, description in SUITE:
38
+ print(f" {name:22} {description}")
39
+ return 0
40
+ root = current_project().root
41
+ # Both listings name files from the project's root and stop at its edge.
42
+ # `ls-files` does that on its own; `diff` names them from the repository's
43
+ # top unless told otherwise, which for a book kept inside a larger
44
+ # repository pointed every path at a file that was not there.
45
+ if args.staged:
46
+ staged = _git(
47
+ root, "diff", "--cached", "--name-only", "--diff-filter=ACMR", "--relative"
48
+ )
49
+ return _suite(root, staged)
50
+ # Tracked files, which include a new one the moment it is added, so
51
+ # nothing reaches a commit unjudged. A file nobody added is the author's
52
+ # scratch until then, and a red that only means "you keep notes here"
53
+ # teaches people to ignore red.
54
+ status = _suite(root, _git(root, "ls-files", "--cached"))
55
+ _advise(root, _git(root, "ls-files", "--others", "--exclude-standard"))
56
+ return status
57
+
58
+
59
+ def _git(root, *args):
60
+ command = ["git", "-C", str(root), *args]
61
+ result = subprocess.run(command, capture_output=True, text=True, check=True)
62
+ return result.stdout.splitlines()
63
+
64
+
65
+ def _suite(root, files):
66
+ """Run every check over ``files``, relative to ``root``; 1 if any fails."""
67
+ failed, sources, markdown = [], [], []
68
+ for rel in files:
69
+ path = root / rel
70
+ if path.suffix in MARKDOWN and path.is_file() and _own(root, rel):
71
+ markdown.append(rel)
72
+ if path.suffix == ".py" and path.is_file():
73
+ sources.append(rel)
74
+ try:
75
+ ast.parse(path.read_text(), filename=rel)
76
+ except SyntaxError as exc:
77
+ failed.append(str(exc))
78
+ for message in failed:
79
+ print(message)
80
+ print(f"Python source checks: {'failed' if failed else 'passed'}")
81
+ return (
82
+ int(bool(failed))
83
+ | formatting_check(root, sources)
84
+ | wrapping_check(root, markdown)
85
+ )
86
+
87
+
88
+ def _advise(root, files):
89
+ """Judge untracked files, and say what would fail, without failing.
90
+
91
+ The same checks as the suite, so a new chapter not yet added is warned
92
+ about early; only the files they name are shown, under a heading that
93
+ says they are not counted.
94
+ """
95
+ if not files:
96
+ return
97
+ report = io.StringIO()
98
+ try:
99
+ with contextlib.redirect_stdout(report):
100
+ status = _suite(root, files)
101
+ except SystemExit as exc: # the wrapper refusing a file it cannot reflow
102
+ status = 1
103
+ report.write(f"{exc}\n")
104
+ if not status:
105
+ return
106
+ print("Untracked, so not counted until `git add` (or ignore them):")
107
+ for line in report.getvalue().splitlines():
108
+ if not any(line.startswith(f"{name}:") for name, _ in SUITE):
109
+ print(f" {line}")
110
+
111
+
112
+ def _own(root, rel):
113
+ """Whether a file belongs to this project rather than to one inside it.
114
+
115
+ A directory holding its own `quirework.toml` is a project of its own, as
116
+ an example book inside this repository is, and its configuration chooses
117
+ how its Markdown wraps. Judged by the outer project's rules, a book that
118
+ keeps its chapters one sentence per line would be told to hard-wrap
119
+ them. It answers to its own check instead.
120
+ """
121
+ return not any(
122
+ (root / folder / "quirework.toml").is_file()
123
+ for folder in Path(rel).parents[:-1]
124
+ )
125
+
126
+
127
+ def formatting_check(root, paths):
128
+ """Whether the formatter would still change any of these files.
129
+
130
+ Not a style opinion. It asserts the tree already sits at the formatter's
131
+ fixed point, so a contributor who runs the formatter out of tidiness gets
132
+ an empty diff instead of reformatting a neighbour's code inside a commit
133
+ about something else. That drift is how this repository came to hold
134
+ ninety-five formatted files and ten that were not, and the stray hunks it
135
+ produces are invisible in review because they look like the change they
136
+ are hiding in.
137
+
138
+ Tolerant of an absent formatter, because a project that does not use one
139
+ has nothing here to be wrong about, and a check that fails on a missing
140
+ tool teaches people to ignore it. It says so rather than saying nothing:
141
+ printing no line at all is indistinguishable from passing, which is how a
142
+ machine without the formatter reports a clean tree over a drifted one.
143
+ """
144
+ if not paths:
145
+ return 0
146
+ if not shutil.which("ruff"):
147
+ print("Formatting: skipped, no formatter installed")
148
+ return 0
149
+ # The report is read as JSON, never as the text a person would see. That
150
+ # text follows the caller's environment: FORCE_COLOR puts escape codes
151
+ # between a label and the file it names, and outranks NO_COLOR, so a
152
+ # session that forced colour got a check naming "\x1b[0mdrifted.py".
153
+ # RUFF_OUTPUT_FORMAT can change the layout altogether. The flag outranks
154
+ # both, and JSON carries no colour.
155
+ result = subprocess.run(
156
+ ["ruff", "format", "--check", "--output-format", "json", *paths],
157
+ cwd=root,
158
+ capture_output=True,
159
+ text=True,
160
+ check=False,
161
+ )
162
+ if result.returncode == 0:
163
+ print("Formatting: passed")
164
+ return 0
165
+ try:
166
+ diagnostics = json.loads(result.stdout)
167
+ except json.JSONDecodeError:
168
+ # No report at all: the formatter refused the invocation itself (an
169
+ # unreadable configuration, or a ruff too old for --output-format).
170
+ # Its own words are the only diagnosis there is.
171
+ print(result.stderr.strip())
172
+ print("Formatting: failed; the formatter did not run")
173
+ return 1
174
+ base = Path(root).resolve()
175
+ for item in sorted(diagnostics, key=lambda d: (d["filename"], d["code"])):
176
+ path = Path(item["filename"])
177
+ rel = path.relative_to(base) if path.is_relative_to(base) else path
178
+ if item["code"] == "unformatted":
179
+ print(f"unformatted: {rel}")
180
+ else:
181
+ print(f"{item['code']}: {rel}: {item['message']}")
182
+ print("Formatting: failed; run `ruff format` and commit that on its own")
183
+ return 1
184
+
185
+
186
+ def wrapping_check(root, paths):
187
+ """Whether `text wrap` would still change any of these Markdown files.
188
+
189
+ The same fixed point as `formatting_check`, held for prose. A guide off
190
+ it is reflowed whole the first time somebody wraps it to fit a
191
+ one-paragraph edit, so a change made with the project's own tool
192
+ carries hundreds of lines that are not the change. The project's
193
+ `text.modes` or `text.classifier` chooses each file's convention, and
194
+ a file inside a saved run record is never asked to move.
195
+
196
+ That exemption reaches only the records a project declares. A room's
197
+ prompts and replies, saved where the project never said, are named here
198
+ like any guide, and wrapping them would rewrite what a model was sent
199
+ and what it said. So the failure says where such a file belongs before
200
+ it says how to wrap one.
201
+
202
+ Unlike the formatter, the wrapper ships with Quirework, so there is no
203
+ missing tool to tolerate and no reason to say "skipped".
204
+ """
205
+ if not paths:
206
+ return 0
207
+ drifted = [rel for rel in paths if not mdwrap.at_fixed_point(str(root / rel))]
208
+ for rel in drifted:
209
+ print(f"needs wrapping: {rel}")
210
+ if drifted:
211
+ print(
212
+ "Markdown wrapping: failed; a saved run record belongs under "
213
+ "`runs.evidence_roots`, and anything else wants `quirework text "
214
+ "wrap`, committed on its own"
215
+ )
216
+ return 1
217
+ print("Markdown wrapping: passed")
218
+ return 0
219
+
220
+
221
+ def main(argv=None):
222
+ parser = argparse.ArgumentParser(prog="quirework dev check", description=__doc__)
223
+ parser.add_argument(
224
+ "--staged", action="store_true", help="check the paths selected for commit"
225
+ )
226
+ parser.add_argument("--list", action="store_true", help="list the configured suite")
227
+ if argv is not None and any(a in ("-h", "--help") for a in argv):
228
+ parser.parse_args(argv)
229
+ return 0
230
+ reference = current_project().get("checks.command")
231
+ if reference is None:
232
+ return default_checks(argv)
233
+ if reference == "quirework.core.checks:main":
234
+ raise ValueError("checks.command recursively selects its own dispatcher")
235
+ return extension(reference)(argv)
@@ -0,0 +1,83 @@
1
+ """A page's frontmatter, in either Markdown convention.
2
+
3
+ A page opens with TOML between `+++` lines, which is Hugo's default, or YAML
4
+ between `---` lines, which is what Quarto, Jekyll and most other generators
5
+ write. The manuscript reads a few top-level scalars from it (`title`, `date`,
6
+ `draft`) and never writes it, and a reader copy made for delivery drops the
7
+ block whole, since a reader of the rendered page never sees it.
8
+
9
+ TOML is parsed with the standard library. YAML is not in it, and Quirework's
10
+ core takes no third-party dependency, so YAML is read for top-level scalar
11
+ keys only: a quoted or bare string, or a boolean. A nested map, a list or a
12
+ block scalar under a key is skipped rather than guessed at, which is enough
13
+ for the three fields the manuscript uses and wrong for nothing it reads.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import tomllib
20
+ from datetime import date, datetime
21
+
22
+ # Either block, anchored at the very start of the file. The closing fence may
23
+ # end the file. Offsets into the text are what callers mask with, so both
24
+ # patterns keep the fences inside the match.
25
+ TOML = re.compile(r"(?s)\A\+\+\+[ \t]*\n(.*?)\n\+\+\+[ \t]*(?:\n|\Z)")
26
+ YAML = re.compile(r"(?s)\A---[ \t]*\n(.*?)\n---[ \t]*(?:\n|\Z)")
27
+
28
+ _YAML_SCALAR = re.compile(r"^([A-Za-z_][\w-]*):[ \t]*(.*?)[ \t]*$")
29
+
30
+
31
+ def block(text: str) -> re.Match | None:
32
+ """The frontmatter block opening ``text``, fences included, if any."""
33
+ return TOML.match(text) or YAML.match(text)
34
+
35
+
36
+ def _yaml_value(raw: str):
37
+ if not raw or raw[0] in "[{|>&*!":
38
+ return None
39
+ if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'":
40
+ return raw[1:-1]
41
+ raw = re.sub(r"\s+#.*$", "", raw)
42
+ lowered = raw.lower()
43
+ if lowered in ("true", "yes"):
44
+ return True
45
+ if lowered in ("false", "no"):
46
+ return False
47
+ return raw
48
+
49
+
50
+ def fields(text: str) -> dict:
51
+ """Top-level frontmatter fields; empty when the page has none.
52
+
53
+ A TOML block that does not parse raises ``ValueError`` naming the problem:
54
+ a page whose metadata cannot be read is not one to silently call untitled.
55
+ """
56
+ m = TOML.match(text)
57
+ if m:
58
+ try:
59
+ return tomllib.loads(m.group(1))
60
+ except tomllib.TOMLDecodeError as exc:
61
+ raise ValueError(f"frontmatter is not valid TOML: {exc}") from exc
62
+ m = YAML.match(text)
63
+ if not m:
64
+ return {}
65
+ out = {}
66
+ for line in m.group(1).splitlines():
67
+ if not line or line[0] in " \t#-":
68
+ continue # nested content, a comment, or a list item
69
+ found = _YAML_SCALAR.match(line)
70
+ if found:
71
+ value = _yaml_value(found.group(2))
72
+ if value is not None:
73
+ out[found.group(1)] = value
74
+ return out
75
+
76
+
77
+ def as_text(value) -> str | None:
78
+ """A frontmatter date or string as the text a listing prints."""
79
+ if value is None:
80
+ return None
81
+ if isinstance(value, (datetime, date)):
82
+ return value.isoformat()
83
+ return str(value)
@@ -0,0 +1,45 @@
1
+ """Path globs over project-relative POSIX paths.
2
+
3
+ One dialect for every setting that names files by pattern (`text.modes`, a
4
+ work's footprint), so a pattern means the same thing wherever it is written.
5
+ `**/` matches any number of directories, including none, and a trailing `**`
6
+ matches everything below; `*` and `?` stay inside one path segment. `fnmatch`
7
+ lets `*` cross a slash, which would make `content/*.md` claim every file
8
+ under `content/`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from functools import lru_cache
15
+
16
+
17
+ @lru_cache(maxsize=256)
18
+ def regex(pattern: str) -> re.Pattern:
19
+ """A path glob as a compiled regex that must match the whole path."""
20
+ out, i = [], 0
21
+ while i < len(pattern):
22
+ if pattern.startswith("**/", i):
23
+ out.append("(?:[^/]+/)*")
24
+ i += 3
25
+ elif pattern.startswith("**", i):
26
+ out.append(".*")
27
+ i += 2
28
+ elif pattern[i] == "*":
29
+ out.append("[^/]*")
30
+ i += 1
31
+ elif pattern[i] == "?":
32
+ out.append("[^/]")
33
+ i += 1
34
+ else:
35
+ out.append(re.escape(pattern[i]))
36
+ i += 1
37
+ return re.compile("".join(out) + r"\Z")
38
+
39
+
40
+ def matches(path: str, pattern: str) -> bool:
41
+ return regex(pattern).match(path) is not None
42
+
43
+
44
+ def any_match(path: str, patterns) -> bool:
45
+ return any(matches(path, p) for p in patterns)
@@ -0,0 +1,169 @@
1
+ """Fetch a pinned third-party tool into the cache, verified before it unpacks.
2
+
3
+ Some jobs need a program this package has no business vendoring: an EPUB
4
+ validator is 32 MB of jars, an e-reader renderer is 29 MB of Lua, and neither
5
+ belongs in a prose repository. They are downloaded instead, and a download is
6
+ a supply-chain hole unless it is pinned and checked, so every caller supplies
7
+ a version, a URL and a SHA-256.
8
+
9
+ The order of operations is the point, and it is why this lives in one place
10
+ rather than once per tool: the archive's bytes are hashed BEFORE anything is
11
+ unpacked, so an archive that was corrupted or substituted never reaches the
12
+ filesystem as an executable. A second copy of that reasoning is a second
13
+ chance to get it wrong.
14
+
15
+ Everything lands under the cache root, one directory per tool and version, so
16
+ a clone shares the download and `quirework config template tmpfiles` collects
17
+ it when it stops being used.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import os
24
+ import shutil
25
+ import sys
26
+ import tarfile
27
+ import tempfile
28
+ import urllib.request
29
+ import zipfile
30
+ from pathlib import Path
31
+
32
+
33
+ class PinnedToolError(RuntimeError):
34
+ """The tool could not be made available; the message is operator-facing."""
35
+
36
+
37
+ def cache_root() -> Path:
38
+ """Where pinned tools live. XDG, so it is shared across clones."""
39
+ base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
40
+ return Path(base) / "quirework"
41
+
42
+
43
+ def tool_root(name: str, version: str) -> Path:
44
+ return cache_root() / f"{name}-{version}"
45
+
46
+
47
+ def find(name, version, member, *, env_override=None) -> Path | None:
48
+ """The tool, by explicit override, then by cache. Never by PATH guess.
49
+
50
+ An override is honored first so a distribution package or a CI image can
51
+ supply its own copy. A PATH lookup is deliberately not a fallback: an
52
+ unpinned binary of the right name is exactly what the pin exists to
53
+ exclude.
54
+ """
55
+ if env_override:
56
+ supplied = os.environ.get(env_override)
57
+ if supplied:
58
+ path = Path(supplied).expanduser()
59
+ return path if path.exists() else None
60
+ path = tool_root(name, version) / member
61
+ return path if path.exists() else None
62
+
63
+
64
+ def _unpack(archive: Path, into: Path, member: str) -> None:
65
+ """Extract, stripping the archive's own root directory if it has one.
66
+
67
+ Some releases wrap everything in a `tool-1.2.3/` directory and some
68
+ unpack straight into place, and the caller should not have to know which.
69
+ The wanted member decides it rather than a shape heuristic: a tarball
70
+ whose only top-level entry happens to be `bin/` is not wrapped, and
71
+ guessing from the shape alone would strip exactly the directory the
72
+ member is named against.
73
+
74
+ Extracting to a staging directory and renaming is both simpler and safer
75
+ than rewriting member names by hand: it keeps the standard library's own
76
+ traversal guards and its handling of modes, so the executable bit
77
+ survives.
78
+ """
79
+ staging = Path(tempfile.mkdtemp(prefix=f".{into.name}.", dir=into.parent))
80
+ try:
81
+ if archive.suffix == ".zip":
82
+ with zipfile.ZipFile(archive) as bundle:
83
+ bundle.extractall(staging)
84
+ else:
85
+ with tarfile.open(archive) as bundle:
86
+ bundle.extractall(staging, filter="data")
87
+ root = staging
88
+ if not (staging / member).exists():
89
+ entries = list(staging.iterdir())
90
+ if len(entries) == 1 and entries[0].is_dir():
91
+ root = entries[0]
92
+ if into.exists():
93
+ shutil.rmtree(into)
94
+ # The tool appears at its final path complete or not at all, so an
95
+ # interrupted unpack cannot be mistaken for an installed tool.
96
+ os.replace(root, into)
97
+ finally:
98
+ shutil.rmtree(staging, ignore_errors=True)
99
+
100
+
101
+ def ensure(
102
+ name,
103
+ version,
104
+ url,
105
+ sha256,
106
+ member,
107
+ *,
108
+ label=None,
109
+ force=False,
110
+ timeout=120,
111
+ log=None,
112
+ ) -> Path:
113
+ """Make the pinned tool available and return the path to `member`.
114
+
115
+ Raises `PinnedToolError` with an operator-facing message on any failure.
116
+ """
117
+ label = label or name
118
+ log = log if log is not None else (lambda message: print(message))
119
+ target = tool_root(name, version) / member
120
+ if target.exists() and not force:
121
+ return target
122
+
123
+ root = cache_root()
124
+ root.mkdir(parents=True, exist_ok=True)
125
+ archive = root / url.rsplit("/", 1)[-1]
126
+
127
+ if not archive.exists() or force:
128
+ log(f"fetching {label} {version} ...")
129
+ try:
130
+ with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310
131
+ archive.write_bytes(response.read())
132
+ except KeyboardInterrupt:
133
+ # A half-written archive would be SKIPPED by the exists() test on
134
+ # the next run and then fail its checksum, costing a second run to
135
+ # recover from one ctrl-c.
136
+ archive.unlink(missing_ok=True)
137
+ raise
138
+ except Exception as exc: # network, TLS, 404
139
+ archive.unlink(missing_ok=True)
140
+ raise PinnedToolError(f"{name}: download failed: {exc}") from exc
141
+
142
+ got = hashlib.sha256(archive.read_bytes()).hexdigest()
143
+ if got != sha256:
144
+ archive.unlink(missing_ok=True)
145
+ raise PinnedToolError(
146
+ f"{name}: checksum mismatch for {url}\n"
147
+ f" expected {sha256}\n got {got}\n"
148
+ f" the download was discarded."
149
+ )
150
+
151
+ _unpack(archive, tool_root(name, version), member)
152
+ if not target.exists():
153
+ raise PinnedToolError(f"{name}: {target} missing after unpack.")
154
+ log(f"{name} {version} installed: {target}")
155
+ return target
156
+
157
+
158
+ def install(name, version, url, sha256, member, *, label=None, force=False) -> int:
159
+ """`ensure` as a command: prints, and returns a process exit status."""
160
+ target = tool_root(name, version) / member
161
+ if target.exists() and not force:
162
+ print(f"{name} {version} already installed: {target}")
163
+ return 0
164
+ try:
165
+ ensure(name, version, url, sha256, member, label=label, force=force)
166
+ except PinnedToolError as exc:
167
+ print(str(exc), file=sys.stderr)
168
+ return 1
169
+ return 0