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,60 @@
1
+ """`quirework publish build`: build a target, after the targets it needs.
2
+
3
+ quirework publish build every target, each after its needs
4
+ quirework publish build site one target, after what it needs
5
+ quirework publish build site --no-needs one target, from its needs' last builds
6
+ quirework publish build book --to html arguments after the target go to it
7
+
8
+ With one target the name may be left out, and every argument goes to its
9
+ backend. With several, arguments are for one target, so it must be named.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+
16
+ from quirework.project import ConfigurationError, current_project, project_scope
17
+ from quirework.publish import targets as _targets
18
+
19
+
20
+ def _build(target, known, argv) -> int:
21
+ with project_scope(target.project):
22
+ status = target.backend.build(argv, target.name)
23
+ if status:
24
+ return status
25
+ for line in _targets.copy_into(target, known):
26
+ print(f"publish build: {target.name}: {line}")
27
+ return 0
28
+
29
+
30
+ def main(argv: list[str] | None = None) -> int:
31
+ argv = list(argv or [])
32
+ known = _targets.targets()
33
+ skip_needs = "--no-needs" in argv
34
+ argv = [a for a in argv if a != "--no-needs"]
35
+ if argv and argv[0] in known:
36
+ wanted, argv = [argv[0]], argv[1:]
37
+ elif len(known) == 1:
38
+ wanted = list(known)
39
+ elif argv:
40
+ raise ConfigurationError(
41
+ f"name the target these arguments are for: {', '.join(known)}"
42
+ )
43
+ else:
44
+ wanted = list(known)
45
+ plan = wanted if skip_needs else _targets.order(wanted, known)
46
+ for name in plan:
47
+ # A needed target is built as it would be with no arguments; the ones
48
+ # given are for the target named.
49
+ status = _build(known[name], known, argv if name in wanted else [])
50
+ if status:
51
+ if name not in wanted:
52
+ print(
53
+ f"publish build: {name}, which {', '.join(wanted)} needs, "
54
+ f"failed; nothing after it was built",
55
+ file=sys.stderr,
56
+ )
57
+ return status
58
+ if len(known) > 1:
59
+ print(f"publish build: built {', '.join(plan)} for {current_project().root}")
60
+ return 0
@@ -0,0 +1,48 @@
1
+ """Where a derived edition lives, and how it renders as if it were the book.
2
+
3
+ An edition is one directory per kind under `publish.variants`. The blind
4
+ drafts that `publish variant build` fetches land in its `_raw/`, which is
5
+ evidence of what a model wrote and never a source. The author composes the
6
+ edition beside them: its documents, its own `_quarto.yml`, and their marks
7
+ in `_marks/`. An edition with a `_quarto.yml` is composed, and
8
+ `publish build` renders it.
9
+
10
+ Rendering reuses the book's pipeline whole. The edition's view of the
11
+ project moves the manuscript's source, chapters and Quarto file to the
12
+ edition's directory, and its build tree to `build/variants/KIND/` under the
13
+ book's. Everything else is the book's own: the figure gallery, the fonts
14
+ and the render policies. So an edition renders from staged copies as the
15
+ book does, never in its own directory, where Quarto would leave a cache
16
+ behind, and its figures come from the same sources as the book's.
17
+
18
+ An edition keeps its documents at its top level, beside its `_quarto.yml`,
19
+ because that is where figures are looked for: an embed is
20
+ `illustrations/NAME.svg` relative to the document, as in the book.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from quirework.core.artifact_paths import EDITION_DRAFTS
26
+ from quirework.manuscript.editions import composed, root # noqa: F401
27
+ from quirework.manuscript.quarto import BOOK_CONFIG
28
+ from quirework.project import current_project
29
+
30
+ # An edition's blind drafts: what each model wrote, kept as evidence. Never
31
+ # staged, reflowed or read as a source.
32
+ RAW = EDITION_DRAFTS
33
+
34
+
35
+ def project(kind: str):
36
+ """The project as the edition ``kind`` renders: see the module docstring."""
37
+ book = current_project()
38
+ folder = root() / kind
39
+ return book.replaced(
40
+ {
41
+ "manuscript.source": str(folder),
42
+ "manuscript.chapters": str(folder),
43
+ "manuscript.quarto": str(folder / BOOK_CONFIG),
44
+ "publish.build": str(
45
+ book.path("publish.build", "build") / "variants" / kind
46
+ ),
47
+ }
48
+ )
@@ -0,0 +1,287 @@
1
+ """Validate the built EPUB against the spec, using the reference checker.
2
+
3
+ The EPUB is the one edition whose defects are INVISIBLE from the inside. A
4
+ figure number that disagrees across editions is caught by `fignumbers`, and a
5
+ missing typeface is caught by `fonts`, because both are properties this repo
6
+ can compute. Spec conformance is not: an `.epub` is a zip of XHTML validated
7
+ against schemas nothing here carries, and the failure mode is that every
8
+ desktop reader opens the book perfectly while a retailer's ingestion pipeline
9
+ rejects it. Foliate, Okular, and every browser-based reader are forgiving in
10
+ exactly the places EPUB is strict, so "it looks fine here" is not evidence.
11
+
12
+ So this shells out to **EPUBCheck**, the W3C reference implementation and the
13
+ same validator the retail pipelines run. It is pinned by version AND by
14
+ SHA-256, and fetched into a cache rather than vendored: it is 32 MB of jars,
15
+ which has no business in a prose repository, and an unpinned download is a
16
+ supply-chain hole in a tool that runs on every render.
17
+
18
+ Java is the one dependency, and its absence is reported rather than guessed
19
+ around. Unlike a missing typeface -- which fails SILENTLY, and is why `render`
20
+ refuses outright -- a missing validator is loud by construction: nothing claims
21
+ the book was checked. So `render` prints how to install it and carries on,
22
+ while `quirework publish epubcheck` run directly exits non-zero, since asking for a
23
+ check and getting a shrug is a different thing from rendering without one.
24
+
25
+ Severity follows EPUBCheck's own exit semantics: FATAL and ERROR fail, WARNING
26
+ and INFO are counted and shown. Messages are deduplicated by (severity, code,
27
+ message) because one systematic defect reports once per document -- the font
28
+ `src` bug below produced 152 identical errors across 38 files -- and a raw dump
29
+ buries the count that actually tells you what kind of problem it is.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import re
36
+ import shutil
37
+ import subprocess
38
+ import sys
39
+ from pathlib import Path
40
+
41
+ from quirework.core import pinned
42
+
43
+ # Pinned. Bump the three together: a version without its checksum is an
44
+ # unverified download, and a checksum without its version is a lie.
45
+ VERSION = "5.3.0"
46
+ URL = (
47
+ f"https://github.com/w3c/epubcheck/releases/download/v{VERSION}"
48
+ f"/epubcheck-{VERSION}.zip"
49
+ )
50
+ SHA256 = "6c07e68584b2e2ce2f89fe06e1246dfead3eb36b46b340e7d93524f29dcff6c5"
51
+
52
+ # `ERROR(RSC-007): /path/to.epub/EPUB/nav.xhtml(331,5): message`
53
+ _LINE = re.compile(r"^(FATAL|ERROR|WARNING|INFO|USAGE)\(([A-Z0-9-]+)\):\s*(.*)$")
54
+
55
+ # The location prefix on a message, stripped so identical defects in different
56
+ # files collapse to one row. Keeps the trailing message only.
57
+ _WHERE = re.compile(r"^\S+?\.epub/(\S+?)(?:\(\d+,\d+\))?:\s*")
58
+
59
+ FAIL = ("FATAL", "ERROR")
60
+
61
+ # EPUBCheck quotes the entire permitted-attribute list into a single RSC-005
62
+ # message -- roughly 2 KB of it -- so an untruncated report is unreadable.
63
+ _ELIDE = 140
64
+
65
+
66
+ MEMBER = "epubcheck.jar"
67
+
68
+
69
+ def cache_dir() -> Path:
70
+ """Where the pinned checker lives. XDG, so it is shared across clones."""
71
+ return pinned.cache_root()
72
+
73
+
74
+ def jar_path() -> Path:
75
+ return pinned.tool_root("epubcheck", VERSION) / MEMBER
76
+
77
+
78
+ def find_jar() -> Path | None:
79
+ """The jar, by explicit override, then by cache. Never by PATH guess.
80
+
81
+ `EPUBCHECK_JAR` is honored first so a distro package or a CI image can
82
+ supply its own copy; everything else uses the pinned cache.
83
+ """
84
+ return pinned.find("epubcheck", VERSION, MEMBER, env_override="EPUBCHECK_JAR")
85
+
86
+
87
+ def have_java() -> bool:
88
+ return shutil.which("java") is not None
89
+
90
+
91
+ def install(force: bool = False) -> int:
92
+ """Fetch, verify and unpack the pinned EPUBCheck into the cache."""
93
+ return pinned.install(
94
+ "epubcheck", VERSION, URL, SHA256, MEMBER, label="EPUBCheck", force=force
95
+ )
96
+
97
+
98
+ def parse(output: str) -> list[tuple[str, str, str, str]]:
99
+ """(severity, code, where, message) per reported line.
100
+
101
+ `where` is the path inside the container; it is kept for the sample line
102
+ and dropped from the dedupe key, since one systematic defect reports once
103
+ per document and the count is the interesting part.
104
+ """
105
+ rows: list[tuple[str, str, str, str]] = []
106
+ for line in output.splitlines():
107
+ m = _LINE.match(line.strip())
108
+ if not m:
109
+ continue
110
+ severity, code, rest = m.groups()
111
+ where = ""
112
+ w = _WHERE.match(rest)
113
+ if w:
114
+ where = w.group(1)
115
+ rest = rest[w.end() :]
116
+ rows.append((severity, code, where, rest.strip()))
117
+ return rows
118
+
119
+
120
+ def report(rows: list[tuple[str, str, str, str]]) -> None:
121
+ """Group by (severity, code, message), worst first, with one example."""
122
+ groups: dict[tuple[str, str, str], list[str]] = {}
123
+ for severity, code, where, message in rows:
124
+ groups.setdefault((severity, code, message), []).append(where)
125
+
126
+ order = {"FATAL": 0, "ERROR": 1, "WARNING": 2, "INFO": 3, "USAGE": 4}
127
+ for (severity, code, message), wheres in sorted(
128
+ groups.items(), key=lambda kv: (order.get(kv[0][0], 9), -len(kv[1]))
129
+ ):
130
+ text = message if len(message) <= _ELIDE else message[:_ELIDE] + " ..."
131
+ print(f" {severity}({code}) x{len(wheres)}: {text}")
132
+ if wheres[0]:
133
+ print(f" e.g. {wheres[0]}")
134
+
135
+
136
+ def check(epub: Path) -> tuple[int, list[tuple[str, str, str, str]]] | None:
137
+ """Run the checker. None when it cannot run (no java, no jar)."""
138
+ jar = find_jar()
139
+ if jar is None or not have_java():
140
+ return None
141
+ proc = subprocess.run(
142
+ ["java", "-jar", str(jar), str(epub)], capture_output=True, text=True
143
+ )
144
+ # EPUBCheck writes its findings to stderr and its summary to stdout.
145
+ return proc.returncode, parse(proc.stdout + "\n" + proc.stderr)
146
+
147
+
148
+ def missing_hint() -> str:
149
+ if not have_java():
150
+ return (
151
+ "epubcheck: java not found (EPUBCheck needs a JRE).\n"
152
+ " Fedora: sudo dnf install java-latest-openjdk-headless"
153
+ )
154
+ return (
155
+ "epubcheck: the validator is not installed.\n"
156
+ " Install the pinned copy with: quirework publish epubcheck --install"
157
+ )
158
+
159
+
160
+ def verify(site: Path) -> int:
161
+ """The render-time gate over every EPUB in `site`.
162
+
163
+ Absence is a loud skip rather than a render failure: EPUBCheck is a 32 MB
164
+ download and a JRE, which is a heavy thing to demand of a clone that only
165
+ wants to read the book. It never skips SILENTLY -- the point of the fonts
166
+ rule is that a silent substitution leaves no trace, and this one prints.
167
+ """
168
+ epubs = sorted(site.glob("*.epub"))
169
+ if not epubs:
170
+ return 0
171
+ if find_jar() is None or not have_java():
172
+ print(missing_hint(), file=sys.stderr)
173
+ print(" The EPUB was NOT validated.", file=sys.stderr)
174
+ return 0
175
+
176
+ rc = 0
177
+ for epub in epubs:
178
+ result = check(epub)
179
+ if result is None: # raced with a removal; treated as unchecked
180
+ print(missing_hint(), file=sys.stderr)
181
+ continue
182
+ _, rows = result
183
+ bad = [r for r in rows if r[0] in FAIL]
184
+ if not bad:
185
+ others = len(rows)
186
+ tail = f" ({others} warnings/notices)" if others else ""
187
+ print(f"epubcheck: {epub.name} is valid EPUB{tail}.")
188
+ continue
189
+ print(
190
+ f"epubcheck: {epub.name} FAILED with {len(bad)} error(s):", file=sys.stderr
191
+ )
192
+ report(rows)
193
+ rc = 1
194
+ return rc
195
+
196
+
197
+ HELP = """\
198
+ usage: quirework publish epubcheck [--install] [--force] [EPUB]
199
+
200
+ Validate the built EPUB with the W3C reference checker (EPUBCheck),
201
+ pinned by version and SHA-256 and cached outside the repo. Without an
202
+ argument, checks every .epub in build/book/_site/.
203
+
204
+ Desktop readers are forgiving where EPUB is strict, so "it opens fine"
205
+ is not evidence; a retailer's ingestion pipeline runs this validator.
206
+
207
+ options:
208
+ -h, --help show this help message and exit
209
+ --install fetch and verify the pinned EPUBCheck into the cache
210
+ --force re-download even if the cache already has it
211
+
212
+ Exit status is non-zero when the book has FATAL or ERROR findings, or
213
+ when the checker was asked for and is not installed.
214
+ """
215
+
216
+
217
+ def main(argv: list[str] | None = None) -> int:
218
+ parser = argparse.ArgumentParser(add_help=False)
219
+ parser.add_argument("epub", nargs="?")
220
+ parser.add_argument("--install", action="store_true")
221
+ parser.add_argument("--force", action="store_true")
222
+ parser.add_argument("-h", "--help", action="store_true", dest="help")
223
+ args = parser.parse_args(list(argv if argv is not None else sys.argv[1:]))
224
+
225
+ if args.help:
226
+ print(HELP, end="")
227
+ return 0
228
+ if args.install or args.force:
229
+ return install(force=args.force)
230
+
231
+ import quirework.publish.render as _options_quirework_book_render
232
+ from quirework.publish.render import SITE
233
+
234
+ if args.epub:
235
+ targets = [Path(args.epub)]
236
+ if not targets[0].exists():
237
+ print(f"epubcheck: no such file: {args.epub}", file=sys.stderr)
238
+ return 2
239
+ else:
240
+ targets = sorted(
241
+ (_options_quirework_book_render._options().BUILD_BOOK / SITE).glob("*.epub")
242
+ )
243
+ if not targets:
244
+ print(
245
+ f"epubcheck: no .epub in {_options_quirework_book_render._options().BUILD_BOOK / SITE} "
246
+ f"(run `quirework publish build --to epub` first).",
247
+ file=sys.stderr,
248
+ )
249
+ return 2
250
+
251
+ # Asked for directly, an uninstalled checker is a failure, not a skip.
252
+ if find_jar() is None or not have_java():
253
+ print(missing_hint(), file=sys.stderr)
254
+ return 127
255
+
256
+ try:
257
+ return _check_all(targets)
258
+ except KeyboardInterrupt:
259
+ # `subprocess.run` kills the validator for us and re-raises; all that
260
+ # is left is not to hand the caller a traceback for a deliberate stop.
261
+ print("\nepubcheck: interrupted.", file=sys.stderr)
262
+ return 130
263
+
264
+
265
+ def _check_all(targets: list[Path]) -> int:
266
+ rc = 0
267
+ for epub in targets:
268
+ result = check(epub)
269
+ assert result is not None # guarded by the caller
270
+ _, rows = result
271
+ bad = [r for r in rows if r[0] in FAIL]
272
+ counts = {}
273
+ for severity, _code, _where, _msg in rows:
274
+ counts[severity] = counts.get(severity, 0) + 1
275
+ summary = ", ".join(
276
+ f"{n} {s.lower()}{'s' if n != 1 else ''}" for s, n in sorted(counts.items())
277
+ )
278
+ print(f"{epub.name}: {summary or 'clean'}")
279
+ if rows:
280
+ report(rows)
281
+ if bad:
282
+ rc = 1
283
+ return rc
284
+
285
+
286
+ if __name__ == "__main__":
287
+ raise SystemExit(main())