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,210 @@
1
+ """Explicit delivery transforms, independent of exposure and session topology.
2
+
3
+ Text is not silently cleaned or summarized. Source shaping is a preparation
4
+ choice; arbitrary files remain opaque unless their declared delivery needs a
5
+ conversion. A registry allows further transports without editing the engine.
6
+ """
7
+
8
+ import shutil
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+ from quirework.runs.storage import digest
13
+ from quirework.runs.storage import relative
14
+
15
+ TRANSFORMS = {}
16
+
17
+
18
+ def register(name):
19
+ def install(function):
20
+ if name in TRANSFORMS:
21
+ raise ValueError(f"duplicate delivery transform: {name}")
22
+ TRANSFORMS[name] = function
23
+ return function
24
+
25
+ return install
26
+
27
+
28
+ def copy(source, target):
29
+ if source.is_symlink() or not source.is_file():
30
+ raise ValueError(f"material must be a regular file: {source}")
31
+ target.parent.mkdir(parents=True, exist_ok=True)
32
+ shutil.copyfile(source, target)
33
+ return target
34
+
35
+
36
+ @register("text")
37
+ def text(source, directory):
38
+ source.read_text(encoding="utf-8") # Reject a binary pretending to be text.
39
+ return [("text", copy(source, directory / "material.txt"))]
40
+
41
+
42
+ @register("image")
43
+ def image(source, directory):
44
+ # SVG is not a native image attachment in these adapters. Make conversion
45
+ # explicit with delivery=svg; never claim that staging an SVG showed it.
46
+ if source.suffix.lower() not in (".png", ".jpg", ".jpeg", ".webp"):
47
+ raise ValueError(
48
+ "image delivery requires PNG, JPEG or WebP; use svg for conversion"
49
+ )
50
+ return [("image", copy(source, directory / ("image" + source.suffix.lower())))]
51
+
52
+
53
+ @register("svg")
54
+ def svg(source, directory):
55
+ frozen = copy(source, directory / "source.svg")
56
+ dest = directory / "image.png"
57
+ subprocess.run(
58
+ ["rsvg-convert", "-w", "1600", "-o", str(dest), str(frozen)],
59
+ check=True,
60
+ capture_output=True,
61
+ )
62
+ return [("image", dest)]
63
+
64
+
65
+ @register("pages")
66
+ def pages(source, directory):
67
+ from pypdf import PdfReader
68
+
69
+ frozen = copy(source, directory / "source.pdf")
70
+ subprocess.run(
71
+ ["pdftoppm", "-png", "-scale-to", "1600", str(frozen), str(directory / "page")],
72
+ check=True,
73
+ capture_output=True,
74
+ )
75
+ files = sorted(
76
+ directory.glob("page-*.png"), key=lambda p: int(p.stem.rsplit("-", 1)[1])
77
+ )
78
+ if not files or len(files) != len(PdfReader(frozen).pages):
79
+ raise RuntimeError("incomplete PDF page rasterization")
80
+ return [("image", p) for p in files]
81
+
82
+
83
+ @register("file")
84
+ def file(source, directory):
85
+ return [("file", copy(source, directory / ("material" + source.suffix)))]
86
+
87
+
88
+ def prepare(materials, origin, destination, cache=None):
89
+ """Freeze the exact source plus derived delivery bytes, with neutral names."""
90
+ if not isinstance(materials, list):
91
+ raise ValueError("materials must be a list")
92
+ result = []
93
+ for index, item in enumerate(materials):
94
+ if not isinstance(item, dict) or set(item) - {"source", "delivery", "label"}:
95
+ raise ValueError("material fields: source, delivery, optional label")
96
+ if item.get("delivery") not in TRANSFORMS:
97
+ raise ValueError(f"unknown delivery: {item.get('delivery')}")
98
+ if "label" in item and (
99
+ not isinstance(item["label"], str) or not item["label"].strip()
100
+ ):
101
+ raise ValueError("material label must contain text")
102
+ source = Path(item["source"]).expanduser()
103
+ source = source if source.is_absolute() else origin / source
104
+ directory = destination / f"asset-{index:03d}"
105
+ directory.mkdir(parents=True)
106
+ before = digest(source)
107
+ key = (item["delivery"], before, source.suffix.lower())
108
+ cached = cache.get(key) if cache is not None else None
109
+ # Composite prose/figure transforms have multiple source dependencies;
110
+ # don't cache them under the chapter hash alone.
111
+ cacheable = item["delivery"] not in ("prose", "prose-images")
112
+ if cached and cacheable:
113
+ previous, output_items = cached
114
+ shutil.copytree(previous, directory, dirs_exist_ok=True)
115
+ values = [(kind, directory / rel) for kind, rel in output_items]
116
+ else:
117
+ values = TRANSFORMS[item["delivery"]](source, directory)
118
+ if cache is not None and cacheable:
119
+ cache[key] = (
120
+ directory,
121
+ [(kind, str(p.relative_to(directory))) for kind, p in values],
122
+ )
123
+ if digest(source) != before:
124
+ raise RuntimeError(f"source changed while freezing: {source}")
125
+ for kind, path in values:
126
+ result.append(
127
+ dict(
128
+ kind=kind,
129
+ path=str(path.relative_to(destination)),
130
+ sha256=digest(path),
131
+ source_sha256=before,
132
+ delivery=item["delivery"],
133
+ label=item.get("label"),
134
+ )
135
+ )
136
+ return result
137
+
138
+
139
+ def stage(items, frozen, destination, virtual):
140
+ """Only the declared current turn is copied; return prompt and attachments."""
141
+ destination.mkdir(parents=True, exist_ok=True)
142
+ chunks, images, files = [], [], []
143
+ for index, item in enumerate(items):
144
+ source = frozen / relative(item["path"])
145
+ path = destination / f"material-{index:03d}{source.suffix}"
146
+ copy(source, path)
147
+ shown = virtual / path.name
148
+ if item["kind"] == "text":
149
+ label = f": {item['label']}" if item.get("label") else ""
150
+ chunks.append(
151
+ f"\n=== MATERIAL {index + 1}{label} ===\n{path.read_text()}\n=== END MATERIAL ==="
152
+ )
153
+ elif item["kind"] == "image":
154
+ images.append((path, shown))
155
+ if item.get("label"):
156
+ chunks.append(f"\nAttached image {len(images)}: {item['label']}\n")
157
+ else:
158
+ files.append((path, shown))
159
+ if item.get("label"):
160
+ chunks.append(f"\nFile {shown}: {item['label']}\n")
161
+ if files:
162
+ chunks.append(
163
+ "\nRead the supplied files at these exact paths; do not search for them:\n"
164
+ + "\n".join(str(v) for _, v in files)
165
+ )
166
+ return "\n".join(chunks), images, files
167
+
168
+
169
+ @register("prose")
170
+ def prose(source, directory):
171
+ from quirework.delivery.text import reader_markdown
172
+
173
+ frozen = copy(source, directory / ("source" + source.suffix))
174
+ text, figures = reader_markdown(frozen.read_text())
175
+ for figure in figures:
176
+ text = text.replace(
177
+ f"![{figure['caption']}](../{figure['file']})",
178
+ f"[Figure caption: {figure['caption']}]",
179
+ )
180
+ dest = directory / "material.md"
181
+ dest.write_text(text)
182
+ return [("text", dest)]
183
+
184
+
185
+ @register("prose-images")
186
+ def prose_images(source, directory):
187
+ from quirework.delivery.text import reader_markdown
188
+
189
+ # The figures travel as images, so their alt text would only restate
190
+ # them in words, and a seat could rate a figure from that without ever
191
+ # looking at it. Text-only `prose` keeps it: there the description is
192
+ # the only account of the figure the seat gets.
193
+ frozen = copy(source, directory / ("source" + source.suffix))
194
+ text, figures = reader_markdown(frozen.read_text(), alt=False)
195
+ dest = directory / "material.md"
196
+ dest.write_text(text)
197
+ result = [("text", dest)]
198
+ for index, figure in enumerate(figures):
199
+ # A figure travels from beside the page that embeds it. A site-root
200
+ # path or a URL names something only the published site can find.
201
+ if "://" in figure["source"] or Path(figure["source"]).is_absolute():
202
+ raise ValueError(
203
+ f"{source.name}: figure {figure['source']} is not a file beside "
204
+ "the page, so it cannot be delivered as an image; deliver the "
205
+ "page as text, or its figures as their own materials"
206
+ )
207
+ original = source.parent / figure["source"]
208
+ transform = svg if original.suffix.lower() == ".svg" else image
209
+ result += transform(original, directory / f"figure-{index:03d}")
210
+ return result
@@ -0,0 +1,166 @@
1
+ """A reader's copy of a unit's source: the Markdown a reader of the page sees.
2
+
3
+ A seat handed prose must get what a reader of the rendered page gets and
4
+ nothing the author keeps for themselves: no frontmatter (a draft flag, tags, a
5
+ summary the author wrote about the piece), no comments (the marks), no raw
6
+ output blocks, no anchors and no generator syntax. It is not a renderer and
7
+ makes no layout claim.
8
+
9
+ The source may be written for Quarto or for a site generator, and the
10
+ projection reads either by its syntax alone, since a delivery never knows the
11
+ project's format and must not import it:
12
+
13
+ - frontmatter in either convention, TOML between `+++` or YAML between `---`
14
+ - a figure as Pandoc writes it, `![caption](plate.svg){fig-alt="..."}`, whose
15
+ `fig-alt` is required; as plain Markdown, `![alt text](plate.png)`; or as
16
+ Hugo's `{{< figure src="..." alt="..." caption="..." >}}`
17
+ - any other shortcode: a paired one keeps its content and loses its tags, a
18
+ link whose target is one keeps its text, and a standalone one is dropped,
19
+ since it renders as something no Markdown reader can be handed
20
+ - a Pandoc fenced div, such as a table float or a callout, which is paired
21
+ syntax too: its fence lines go and what it holds stays
22
+
23
+ Anything that survives the projection and should not have is refused by name
24
+ rather than delivered.
25
+ """
26
+
27
+ import re
28
+ from pathlib import Path
29
+
30
+ from quirework.core import frontmatter
31
+
32
+ HTML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
33
+
34
+ RAW_BLOCK = re.compile(r"^```\{=[^}]+\}\s*$.*?^```\s*$", re.MULTILINE | re.DOTALL)
35
+
36
+ ATTR_BLOCK = re.compile(r"\s*\{(?=[#.])[^{}\n]*\}")
37
+
38
+ # A fenced div's fence line, opening or closing: three or more colons, then
39
+ # an attribute block or a bare class name on an opening fence, and optionally
40
+ # trailing colons. Only the fence goes; the lines between are prose, a table
41
+ # or a caption, and a reader of the page reads them.
42
+ DIV_FENCE = re.compile(
43
+ r"^[ \t]*:{3,}[ \t]*(?:\{[^{}\n]*\}|[A-Za-z][\w-]*)?[ \t]*:*[ \t]*$",
44
+ re.MULTILINE,
45
+ )
46
+
47
+ _IMAGE = r"(?:svg|png|jpe?g|webp)"
48
+
49
+ # `![text](source){attrs}`: Pandoc's figure when the attribute block is there,
50
+ # plain Markdown when it is not. A title after the source is allowed.
51
+ FIGURE = re.compile(
52
+ r"!\[(?P<caption>[^\]]*)\]\((?P<name>[^)\"]+?\." + _IMAGE + r")"
53
+ r"(?:\s+\"[^\"]*\")?\)"
54
+ r"(?P<attrs>\{[^\n]*\})?"
55
+ )
56
+
57
+ # A shortcode tag, `{{< ... >}}` or `{{% ... %}}`. The escaped form a page
58
+ # uses to show one literally (`{{</* ... */>}}`) is not a tag.
59
+ _SHORTCODE = r"\{\{[<%](?!/\*)\s*(?P<body>.*?)\s*[%>]\}\}"
60
+ SHORTCODE = re.compile(_SHORTCODE, re.DOTALL)
61
+ SHORTCODE_LINK = re.compile(r"\[(?P<text>[^\]]*)\]\(\s*" + _SHORTCODE + r"\s*\)")
62
+ _PARAM = re.compile(r"""(\w+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))""")
63
+
64
+
65
+ def _params(body: str) -> dict[str, str]:
66
+ return {
67
+ m.group(1): next(g for g in m.group(2, 3, 4) if g is not None)
68
+ for m in _PARAM.finditer(body)
69
+ }
70
+
71
+
72
+ def _paired(text: str) -> str:
73
+ """Drop the tags of every paired shortcode, keeping what they enclose."""
74
+ names = {
75
+ m.group("body").split()[0].lstrip("/")
76
+ for m in SHORTCODE.finditer(text)
77
+ if m.group("body").startswith("/")
78
+ }
79
+ for name in names:
80
+ tag = r"\{\{[<%]\s*/?" + re.escape(name) + r"\b.*?[%>]\}\}"
81
+ text = re.sub(tag, "", text, flags=re.DOTALL)
82
+ return text
83
+
84
+
85
+ def reader_markdown(text: str, alt: bool = True) -> tuple[str, list[dict]]:
86
+ """Reader-facing Markdown from a unit's source, and its figures.
87
+
88
+ ``alt`` prints each figure's alt text as a visible description under the
89
+ embed. Keep it where the reader gets no image, since it is then the only
90
+ account of the figure; drop it where the image is delivered, since there
91
+ it restates the picture and lets a reader rate one it never looked at.
92
+ """
93
+ found = frontmatter.block(text)
94
+ if found:
95
+ text = text[found.end() :]
96
+ elif text.startswith(("---\n", "+++\n")):
97
+ raise RuntimeError("unterminated frontmatter")
98
+ text = HTML_COMMENT.sub("", text)
99
+ text = RAW_BLOCK.sub("", text)
100
+ figures = []
101
+
102
+ def figure(name: str, caption: str, description: str) -> str:
103
+ png = Path(name).with_suffix(".png").name
104
+ figures.append(
105
+ {
106
+ "source": name,
107
+ "file": f"figures/{png}",
108
+ "caption": caption,
109
+ "alt": description,
110
+ }
111
+ )
112
+ embed = f"![{caption}](../figures/{png})"
113
+ return f"{embed}\n\n*Figure description:* {description}" if alt else embed
114
+
115
+ def markdown_figure(match: re.Match) -> str:
116
+ caption = match.group("caption").strip()
117
+ name = match.group("name")
118
+ attrs = match.group("attrs")
119
+ if attrs is not None:
120
+ alt_match = re.search(r'fig-alt="([^"]+)"', attrs)
121
+ if not alt_match:
122
+ raise RuntimeError(f"figure has no fig-alt: {name}")
123
+ return figure(name, caption, alt_match.group(1))
124
+ if not caption:
125
+ raise RuntimeError(f"figure has no alt text: {name}")
126
+ return figure(name, caption, caption)
127
+
128
+ def shortcode_figure(match: re.Match) -> str:
129
+ body = match.group("body")
130
+ if not re.match(r"figure\b", body):
131
+ return match.group(0)
132
+ params = _params(body)
133
+ name = params.get("src", "")
134
+ if not re.search(r"\." + _IMAGE + r"\Z", name):
135
+ raise RuntimeError(f"figure shortcode has no image source: {body}")
136
+ caption = params.get("caption") or params.get("title") or ""
137
+ description = params.get("alt") or caption
138
+ if not description:
139
+ raise RuntimeError(f"figure has no alt text: {name}")
140
+ return figure(name, caption, description)
141
+
142
+ text = FIGURE.sub(markdown_figure, text)
143
+ text = SHORTCODE.sub(shortcode_figure, text)
144
+ text = SHORTCODE_LINK.sub(lambda m: m.group("text"), text)
145
+ text = _paired(text)
146
+ text = re.sub(r"(?m)^[ \t]*" + _SHORTCODE + r"[ \t]*$", "", text, flags=re.DOTALL)
147
+ text = SHORTCODE.sub("", text)
148
+ text = DIV_FENCE.sub("", text)
149
+ text = ATTR_BLOCK.sub("", text)
150
+ text = re.sub(r"\n{3,}", "\n\n", text).strip() + "\n"
151
+ leaks = []
152
+ if "<!--" in text:
153
+ leaks.append("HTML comment")
154
+ if "{#" in text:
155
+ leaks.append("anchor")
156
+ if re.search(r"!\[[^\]]*\]\([^)]*\.svg", text):
157
+ leaks.append("SVG source path")
158
+ if re.search(r"^```\{=", text, re.MULTILINE):
159
+ leaks.append("raw-format block")
160
+ if SHORTCODE.search(text):
161
+ leaks.append("shortcode")
162
+ if re.search(r"^[ \t]*:{3,}", text, re.MULTILINE):
163
+ leaks.append("div fence")
164
+ if leaks:
165
+ raise RuntimeError(f"clean Markdown still contains: {', '.join(leaks)}")
166
+ return text, figures
@@ -0,0 +1 @@
1
+ """The written work: its units, spec, marks, quotations and trail."""