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.
- quirework/__init__.py +3 -0
- quirework/__main__.py +3 -0
- quirework/cli.py +431 -0
- quirework/core/__init__.py +1 -0
- quirework/core/artifact_paths.py +86 -0
- quirework/core/checks.py +235 -0
- quirework/core/frontmatter.py +83 -0
- quirework/core/globs.py +45 -0
- quirework/core/pinned.py +169 -0
- quirework/core/usage.py +604 -0
- quirework/delivery/__init__.py +1 -0
- quirework/delivery/artifacts.py +210 -0
- quirework/delivery/text.py +166 -0
- quirework/manuscript/__init__.py +1 -0
- quirework/manuscript/backfill.py +1972 -0
- quirework/manuscript/editions.py +111 -0
- quirework/manuscript/footprint.py +95 -0
- quirework/manuscript/formats.py +208 -0
- quirework/manuscript/lifecycle.py +191 -0
- quirework/manuscript/notes.py +2301 -0
- quirework/manuscript/pages.py +284 -0
- quirework/manuscript/quarto.py +416 -0
- quirework/manuscript/quotes.py +512 -0
- quirework/manuscript/rename.py +309 -0
- quirework/manuscript/sources.py +286 -0
- quirework/manuscript/trail.py +1875 -0
- quirework/manuscript/units.py +416 -0
- quirework/project.py +645 -0
- quirework/provenance/__init__.py +1 -0
- quirework/provenance/agyfilter.py +436 -0
- quirework/provenance/backup/__init__.py +469 -0
- quirework/provenance/backup/base.py +290 -0
- quirework/provenance/backup/command.py +107 -0
- quirework/provenance/backup/directory.py +142 -0
- quirework/provenance/backup/git.py +150 -0
- quirework/provenance/backup/rclone.py +153 -0
- quirework/provenance/capture/__init__.py +1 -0
- quirework/provenance/capture/commands.py +28 -0
- quirework/provenance/capture/extract_artifacts.py +110 -0
- quirework/provenance/capture/extract_files.py +118 -0
- quirework/provenance/capture/fetch_conversation.py +127 -0
- quirework/provenance/capture/fetch_gemini.py +149 -0
- quirework/provenance/capture/render_transcript.py +126 -0
- quirework/provenance/capture/transform.py +22 -0
- quirework/provenance/catalog.py +733 -0
- quirework/provenance/ccfilter.py +1126 -0
- quirework/provenance/codexfilter.py +883 -0
- quirework/provenance/coldread.py +1731 -0
- quirework/provenance/curate.py +67 -0
- quirework/provenance/humaninput.py +811 -0
- quirework/provenance/ingest.py +31 -0
- quirework/provenance/ingestion/__init__.py +1 -0
- quirework/provenance/ingestion/agy.py +192 -0
- quirework/provenance/ingestion/claude.py +493 -0
- quirework/provenance/ingestion/codex.py +296 -0
- quirework/provenance/lineage.py +148 -0
- quirework/provenance/metrics.py +1247 -0
- quirework/provenance/rulings.py +478 -0
- quirework/provenance/scope.py +73 -0
- quirework/provenance/search.py +455 -0
- quirework/provenance/semantic.py +475 -0
- quirework/provenance/show.py +1213 -0
- quirework/provenance/store.py +1271 -0
- quirework/provenance/trees.py +133 -0
- quirework/provenance/worktrees.py +199 -0
- quirework/providers/__init__.py +1 -0
- quirework/providers/blind.py +2192 -0
- quirework/providers/canary.py +685 -0
- quirework/providers/catalog.py +44 -0
- quirework/providers/claude_login.py +92 -0
- quirework/providers/exits.py +196 -0
- quirework/providers/harness.py +830 -0
- quirework/providers/images.py +92 -0
- quirework/providers/models.py +1284 -0
- quirework/providers/quota.py +588 -0
- quirework/providers/reads.py +474 -0
- quirework/providers/replies.py +175 -0
- quirework/publish/__init__.py +1 -0
- quirework/publish/backends.py +112 -0
- quirework/publish/bookstage.py +1086 -0
- quirework/publish/build.py +60 -0
- quirework/publish/editions.py +48 -0
- quirework/publish/epubcheck.py +287 -0
- quirework/publish/experiment.py +1097 -0
- quirework/publish/figfonts.py +234 -0
- quirework/publish/fignumbers.py +293 -0
- quirework/publish/figures.py +729 -0
- quirework/publish/folios.py +62 -0
- quirework/publish/fonts.py +307 -0
- quirework/publish/graybuild.py +73 -0
- quirework/publish/hugo.py +117 -0
- quirework/publish/kindle.js +411 -0
- quirework/publish/kindle.py +1393 -0
- quirework/publish/panelkit.py +918 -0
- quirework/publish/plans.py +651 -0
- quirework/publish/proof.py +792 -0
- quirework/publish/redpen.py +1654 -0
- quirework/publish/render.py +942 -0
- quirework/publish/targets.py +216 -0
- quirework/publish/variant.py +918 -0
- quirework/runs/__init__.py +1 -0
- quirework/runs/__main__.py +15 -0
- quirework/runs/commands.py +278 -0
- quirework/runs/coverage.py +86 -0
- quirework/runs/engine.py +2143 -0
- quirework/runs/feed.py +853 -0
- quirework/runs/observe.py +379 -0
- quirework/runs/storage.py +155 -0
- quirework/settings.py +492 -0
- quirework/sops/__init__.py +1 -0
- quirework/sops/catalog.py +386 -0
- quirework/sops/commands.py +567 -0
- quirework/sops/composition.py +382 -0
- quirework/sops/instruments.py +184 -0
- quirework/sops/personas.py +296 -0
- quirework/sops/review.py +2568 -0
- quirework/sops/room.py +2791 -0
- quirework/sops/roundtable.py +2578 -0
- quirework/sops/sections.py +169 -0
- quirework/sops/workshop.py +1629 -0
- quirework/templates/backup.toml +39 -0
- quirework/templates/book.toml +88 -0
- quirework/templates/run.toml +32 -0
- quirework/templates/site.toml +90 -0
- quirework/templates/tells.toml +64 -0
- quirework/templates/terminology.toml +20 -0
- quirework/templates/tmpfiles.conf +42 -0
- quirework/testing.py +345 -0
- quirework/text/__init__.py +1 -0
- quirework/text/jargon.py +545 -0
- quirework/text/mdwrap.py +1001 -0
- quirework/text/tells.py +547 -0
- quirework-0.1.0.dist-info/METADATA +444 -0
- quirework-0.1.0.dist-info/RECORD +138 -0
- quirework-0.1.0.dist-info/WHEEL +5 -0
- quirework-0.1.0.dist-info/entry_points.txt +2 -0
- quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
- quirework-0.1.0.dist-info/top_level.txt +1 -0
quirework/core/checks.py
ADDED
|
@@ -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)
|
quirework/core/globs.py
ADDED
|
@@ -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)
|
quirework/core/pinned.py
ADDED
|
@@ -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
|