quartobot 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.
quartobot/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """quartobot: the manubot manuscript-as-software pattern, on Quarto."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.0.1"
6
+ __all__ = ["__version__"]
quartobot/cli.py ADDED
@@ -0,0 +1,215 @@
1
+ """Command-line interface for quartobot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from quartobot import __version__
10
+
11
+
12
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
13
+ @click.version_option(version=__version__, prog_name="quartobot")
14
+ def main() -> None:
15
+ """quartobot: manuscript-as-software, on Quarto.
16
+
17
+ Pre-render and out-of-render tooling for Quarto projects that use the
18
+ quarto-manubot-cite extension.
19
+ """
20
+
21
+
22
+ @main.command()
23
+ @click.argument(
24
+ "path",
25
+ type=click.Path(exists=True, file_okay=True, dir_okay=True, path_type=Path),
26
+ default=".",
27
+ )
28
+ def scan(path: Path) -> None:
29
+ """Scan a Quarto project for cite keys and group by prefix.
30
+
31
+ Walks .qmd and .md files under PATH, extracts cite keys (both
32
+ persistent-identifier ones like @doi: and @pmid:, and hand-curated
33
+ keys), and reports counts and duplicates. Pure read; no network.
34
+
35
+ Exits 1 if any duplicates are found (so it works as a pre-commit
36
+ hook), 0 otherwise.
37
+ """
38
+ from quartobot.scan import format_scan_result, scan_path
39
+
40
+ result = scan_path(path)
41
+ relative_to = path if path.is_dir() else path.parent
42
+ click.echo(format_scan_result(result, relative_to=relative_to))
43
+ if result.duplicates:
44
+ raise SystemExit(1)
45
+
46
+
47
+ @main.command()
48
+ @click.argument("keys", nargs=-1)
49
+ @click.option(
50
+ "--from-scan",
51
+ type=click.Path(exists=True, file_okay=True, dir_okay=True, path_type=Path),
52
+ default=None,
53
+ help="Resolve every persistent-identifier key found by scanning this path.",
54
+ )
55
+ @click.option(
56
+ "--output",
57
+ type=click.Path(file_okay=True, dir_okay=False, path_type=Path),
58
+ default="references.json",
59
+ show_default=True,
60
+ help="Path to write the resolved CSL JSON bibliography to.",
61
+ )
62
+ @click.option(
63
+ "--cache",
64
+ type=click.Path(file_okay=True, dir_okay=False, path_type=Path),
65
+ default=None,
66
+ help=(
67
+ "Optional path to read cached entries from. Cache hits skip the "
68
+ "network call. Defaults to the value of --output, so resolve is "
69
+ "idempotent against its own previous output."
70
+ ),
71
+ )
72
+ @click.option(
73
+ "--dry-run",
74
+ is_flag=True,
75
+ help="Report what would be resolved without making network calls.",
76
+ )
77
+ @click.option(
78
+ "--id-mode",
79
+ type=click.Choice(["short-hash", "citation-key"]),
80
+ default="short-hash",
81
+ show_default=True,
82
+ help=(
83
+ "How to populate the CSL `id` field. `short-hash` (default) "
84
+ "keeps manubot's hash form, which the `pandoc-manubot-cite` "
85
+ "filter expects. `citation-key` writes the original "
86
+ "`prefix:identifier`, which lets pandoc-citeproc match prose "
87
+ "keys directly with no filter in the chain — the pre-render-"
88
+ "hook architecture."
89
+ ),
90
+ )
91
+ def resolve(
92
+ keys: tuple[str, ...],
93
+ from_scan: Path | None,
94
+ output: Path,
95
+ cache: Path | None,
96
+ dry_run: bool,
97
+ id_mode: str,
98
+ ) -> None:
99
+ """Pre-fetch citations and write CSL JSON to disk.
100
+
101
+ Resolves persistent-identifier cite keys via manubot.cite and writes
102
+ the resulting CSL JSON to --output (default `references.json`). The
103
+ point isn't to replace what manubot does at render — it's to do the
104
+ network work on a developer's machine ahead of push, so CI never
105
+ sees a Crossref or PubMed hiccup.
106
+
107
+ Pass keys as arguments (`@doi:10.x/y pmid:12345`) or use --from-scan
108
+ to resolve every persistent-identifier cite found in a project.
109
+ Hand-curated keys (no recognized prefix) are skipped — those live
110
+ in references.bib and pandoc citeproc handles them.
111
+
112
+ Exits 1 if any keys fail to resolve, 0 otherwise.
113
+ """
114
+ from quartobot.resolve import collect_resolvable_keys, format_outcome, resolve_keys
115
+
116
+ collected: list[str] = []
117
+ for k in keys:
118
+ # Allow either `@doi:10.x/y` or `doi:10.x/y` — strip leading @.
119
+ collected.append(k.lstrip("@"))
120
+
121
+ if from_scan is not None:
122
+ collected.extend(collect_resolvable_keys(from_scan))
123
+
124
+ # De-dup while preserving order.
125
+ seen: set[str] = set()
126
+ unique: list[str] = []
127
+ for k in collected:
128
+ if k not in seen:
129
+ seen.add(k)
130
+ unique.append(k)
131
+
132
+ if not unique:
133
+ click.echo("No persistent-identifier cite keys to resolve.")
134
+ return
135
+
136
+ cache_path = cache if cache is not None else output
137
+ outcome = resolve_keys(
138
+ unique,
139
+ cache_path=cache_path,
140
+ output_path=output,
141
+ dry_run=dry_run,
142
+ id_mode=id_mode,
143
+ )
144
+ click.echo(format_outcome(outcome))
145
+ if outcome.failures:
146
+ raise SystemExit(1)
147
+
148
+
149
+ @main.command()
150
+ @click.argument(
151
+ "project",
152
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
153
+ default=".",
154
+ )
155
+ def validate(project: Path) -> None:
156
+ """Pre-flight check a Quarto project for the quartobot pattern.
157
+
158
+ Runs a battery of static config checks: the extension is installed,
159
+ `_quarto.yml` declares `bibliography:` and the manubot keys, the
160
+ output bibliography is also in the bibliography list, no duplicate
161
+ cite keys across files.
162
+
163
+ Citation-resolution checks (does Crossref actually return metadata
164
+ for this DOI?) are out of scope here — they need network. Run
165
+ `quartobot resolve --dry-run --from-scan .` if you want that.
166
+
167
+ Exits 1 if any check fails, 0 if all pass.
168
+ """
169
+ from quartobot.validate import format_outcome, validate_project
170
+
171
+ outcome = validate_project(project)
172
+ click.echo(format_outcome(outcome))
173
+ if not outcome.passed:
174
+ raise SystemExit(1)
175
+
176
+
177
+ @main.command()
178
+ @click.argument(
179
+ "project",
180
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
181
+ default=".",
182
+ )
183
+ @click.option(
184
+ "--project-type",
185
+ type=click.Choice(["auto", "manuscript", "book"]),
186
+ default="auto",
187
+ show_default=True,
188
+ help=(
189
+ "Quarto project shape. `auto` detects from an existing _quarto.yml "
190
+ "and falls back to manuscript when there's nothing to detect from."
191
+ ),
192
+ )
193
+ def init(project: Path, project_type: str) -> None:
194
+ """Scaffold the quartobot pattern into an existing Quarto project.
195
+
196
+ Writes the files that make a vanilla Quarto project adopt the
197
+ quartobot pattern: `_quarto.yml` (when absent), `references.bib`,
198
+ the version banner template + dev placeholder, a ten-line GitHub
199
+ Actions workflow that calls the upstream reusable workflow, the
200
+ PR-cleanup workflow, and `.gitignore` augments.
201
+
202
+ Conservative — never overwrites existing files. If `_quarto.yml`
203
+ already exists, prints a YAML snippet to merge in manually.
204
+
205
+ Doesn't run `quarto add` or `pip install` for you; the printed
206
+ "next steps" walk through both.
207
+ """
208
+ from quartobot.init_project import format_outcome, init_project
209
+
210
+ outcome = init_project(project, project_type=project_type)
211
+ click.echo(format_outcome(outcome, project=project))
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()
@@ -0,0 +1,428 @@
1
+ """Scaffold the quartobot pattern into an existing Quarto project.
2
+
3
+ `quartobot init` writes the files that make a vanilla Quarto project
4
+ adopt the quartobot pattern: a manubot-wired `_quarto.yml`, a seed
5
+ `references.bib`, the version-banner HTML template, a `.gitignore`
6
+ augment, and a ten-line GitHub Actions workflow that calls the upstream
7
+ reusable workflow.
8
+
9
+ Conservative by default:
10
+
11
+ - Files that already exist are NEVER overwritten. `init` skips them and
12
+ reports what it skipped.
13
+ - `_quarto.yml` is the one file where partial overlap is likely — for
14
+ that path, if the file exists, init prints a YAML snippet the user
15
+ should merge in manually.
16
+ - `.gitignore` gets new lines appended (idempotent).
17
+ - The Quarto extension itself is installed by `quarto add` (run
18
+ separately); init only scaffolds the surrounding files.
19
+
20
+ The flow is intentionally pre-`usethis`: no interactive prompts, no
21
+ auto-merge. Once the CLI matures we can add `--force` for overwrites
22
+ and a `quartobot use-<thing>` family for piecewise scaffolding.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections.abc import Sequence
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Literal
31
+
32
+ import yaml
33
+
34
+ # ---------------------------------------------------------------- templates
35
+
36
+
37
+ _QUARTO_YML_MANUSCRIPT = """\
38
+ project:
39
+ type: default
40
+
41
+ filters:
42
+ - quarto-manubot-cite
43
+
44
+ # Manubot wiring. Defaults shipped by the quartobot extension; tune as needed.
45
+ manubot-output-bibliography: references.json
46
+ manubot-bibliography-cache: _freeze/manubot-cache.json
47
+ manubot-fail-on-errors: false
48
+ manubot-infer-citekey-prefixes: true
49
+
50
+ bibliography:
51
+ - references.bib
52
+ - references.json
53
+
54
+ format:
55
+ html:
56
+ toc: true
57
+ embed-resources: true
58
+ include-before-body:
59
+ - _version-banner.html
60
+ pdf:
61
+ documentclass: article
62
+ keep-tex: true
63
+ """
64
+
65
+ _QUARTO_YML_BOOK = """\
66
+ project:
67
+ type: book
68
+
69
+ book:
70
+ title: "My quartobot book"
71
+ author: "Your Name"
72
+ date: today
73
+ chapters:
74
+ - index.qmd
75
+ search: true
76
+ page-navigation: true
77
+
78
+ filters:
79
+ - quarto-manubot-cite
80
+
81
+ manubot-output-bibliography: references.json
82
+ manubot-bibliography-cache: _freeze/manubot-cache.json
83
+ manubot-fail-on-errors: false
84
+ manubot-infer-citekey-prefixes: true
85
+
86
+ bibliography:
87
+ - references.bib
88
+ - references.json
89
+
90
+ format:
91
+ html:
92
+ theme: cosmo
93
+ toc: true
94
+ include-before-body:
95
+ - _version-banner.html
96
+ """
97
+
98
+ _REFERENCES_BIB = """\
99
+ % Hand-curated entries live here. Auto-resolved entries written by
100
+ % pandoc-manubot-cite land in references.json (regenerated each
101
+ % render, ignored by git).
102
+ """
103
+
104
+ # Embedded HTML banners. Lines are long because CSS is inlined for
105
+ # users who want to drop the file into a fresh project without
106
+ # wrangling a separate stylesheet. Lint exceptions tagged per-line.
107
+ # fmt: off
108
+ _VERSION_BANNER_TEMPLATE = (
109
+ '<div class="version-banner" style="background:#fff7e0;border-bottom:2px solid #f0b400;padding:0.55rem 1rem;font-family:system-ui,-apple-system,\'Segoe UI\',sans-serif;font-size:0.92rem;text-align:center;color:#3a2c00;">\n' # noqa: E501
110
+ " <strong>This version:</strong>\n"
111
+ ' <a href="__VERSION_URL__" style="color:#3a2c00;text-decoration:underline;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">__VERSION_SHA__</a>\n' # noqa: E501
112
+ " &nbsp;&middot;&nbsp;\n"
113
+ ' <a href="__VERSION_LATEST__" style="color:#3a2c00;">latest&nbsp;HTML</a>\n'
114
+ " &nbsp;&middot;&nbsp;\n"
115
+ ' <a href="__VERSION_GH__" style="color:#3a2c00;">GitHub</a>\n'
116
+ "</div>\n"
117
+ )
118
+
119
+ _VERSION_BANNER_DEV = (
120
+ '<div class="version-banner" style="background:#eef2ff;border-bottom:2px solid #6366f1;padding:0.55rem 1rem;font-family:system-ui,-apple-system,\'Segoe UI\',sans-serif;font-size:0.92rem;text-align:center;color:#1e1b4b;">\n' # noqa: E501
121
+ " <strong>Development build</strong> &middot; permalink set by CI on push to <code>main</code>\n" # noqa: E501
122
+ "</div>\n"
123
+ )
124
+ # fmt: on
125
+
126
+
127
+ def _render_workflow(project_type: str) -> str:
128
+ """Return the ten-line workflow caller for the given project type."""
129
+ return f"""\
130
+ # Renders on every push and PR via the upstream reusable workflow.
131
+ # Override inputs in the `with:` block below; see
132
+ # https://github.com/seandavi/quartobot/blob/main/.github/workflows/render-reusable.yml
133
+ # for the full list.
134
+
135
+ name: Render
136
+
137
+ on:
138
+ push:
139
+ branches: [main]
140
+ pull_request:
141
+ branches: [main]
142
+ workflow_dispatch:
143
+
144
+ jobs:
145
+ render:
146
+ uses: seandavi/quartobot/.github/workflows/render-reusable.yml@main
147
+ permissions:
148
+ contents: write
149
+ pull-requests: write
150
+ with:
151
+ project-type: {project_type}
152
+ """
153
+
154
+
155
+ _PR_CLOSED_WORKFLOW = """\
156
+ name: Clean up PR preview
157
+
158
+ on:
159
+ pull_request:
160
+ types: [closed]
161
+
162
+ permissions:
163
+ contents: write
164
+
165
+ concurrency:
166
+ group: gh-pages-deploy
167
+ cancel-in-progress: false
168
+
169
+ jobs:
170
+ cleanup:
171
+ runs-on: ubuntu-latest
172
+ if: github.event.pull_request.head.repo.full_name == github.repository
173
+ steps:
174
+ - name: Check whether gh-pages branch exists
175
+ id: branch
176
+ run: |
177
+ if git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then
178
+ echo "exists=true" >> "$GITHUB_OUTPUT"
179
+ else
180
+ echo "exists=false" >> "$GITHUB_OUTPUT"
181
+ fi
182
+
183
+ - name: Check out gh-pages
184
+ if: steps.branch.outputs.exists == 'true'
185
+ uses: actions/checkout@v4
186
+ with:
187
+ ref: gh-pages
188
+ fetch-depth: 1
189
+
190
+ - name: Remove PR preview directory
191
+ if: steps.branch.outputs.exists == 'true'
192
+ run: |
193
+ pr="${{ github.event.pull_request.number }}"
194
+ [ -d "pr/${pr}" ] && rm -rf "pr/${pr}" || exit 0
195
+
196
+ - name: Commit and push
197
+ if: steps.branch.outputs.exists == 'true'
198
+ run: |
199
+ git config user.name "github-actions[bot]"
200
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
201
+ git add -A
202
+ if git diff --cached --quiet; then exit 0; fi
203
+ git commit -m "Clean up preview for PR #${{ github.event.pull_request.number }}"
204
+ git push
205
+ """
206
+
207
+
208
+ _GITIGNORE_LINES = [
209
+ "# quartobot",
210
+ "_book/",
211
+ "_freeze/",
212
+ ".quarto/",
213
+ "_extensions/",
214
+ "references.json",
215
+ "*_files/",
216
+ "**/*.quarto_ipynb",
217
+ ]
218
+
219
+
220
+ # ---------------------------------------------------------------- outcome types
221
+
222
+
223
+ ActionStatus = Literal["written", "skipped-exists", "appended", "manual-merge"]
224
+
225
+
226
+ @dataclass(frozen=True)
227
+ class Action:
228
+ """One file write (or non-write) the init flow attempted."""
229
+
230
+ path: Path
231
+ status: ActionStatus
232
+ detail: str | None = None
233
+
234
+
235
+ @dataclass
236
+ class InitOutcome:
237
+ """The aggregate result of an init run."""
238
+
239
+ actions: list[Action] = field(default_factory=list)
240
+ project_type: str = "manuscript"
241
+ manual_merge_snippet: str | None = None
242
+
243
+
244
+ # ---------------------------------------------------------------- helpers
245
+
246
+
247
+ def detect_project_type(project: Path) -> str:
248
+ """Read `_quarto.yml`; return `"manuscript"`, `"book"`, or `"unknown"`.
249
+
250
+ A missing or unparsable `_quarto.yml` returns `"unknown"` — init
251
+ treats that as the manuscript default.
252
+ """
253
+ yml = project / "_quarto.yml"
254
+ if not yml.exists():
255
+ return "unknown"
256
+ try:
257
+ loaded = yaml.safe_load(yml.read_text(encoding="utf-8"))
258
+ except yaml.YAMLError:
259
+ return "unknown"
260
+ if not isinstance(loaded, dict):
261
+ return "unknown"
262
+ proj = loaded.get("project")
263
+ if isinstance(proj, dict) and proj.get("type") == "book":
264
+ return "book"
265
+ return "manuscript"
266
+
267
+
268
+ def _write_if_missing(path: Path, content: str) -> Action:
269
+ if path.exists():
270
+ return Action(path=path, status="skipped-exists")
271
+ path.parent.mkdir(parents=True, exist_ok=True)
272
+ path.write_text(content, encoding="utf-8")
273
+ return Action(path=path, status="written")
274
+
275
+
276
+ def _ensure_gitignore(project: Path) -> Action:
277
+ """Append missing lines to `.gitignore`. Idempotent."""
278
+ gi = project / ".gitignore"
279
+ existing = gi.read_text(encoding="utf-8").splitlines() if gi.exists() else []
280
+ existing_set = set(existing)
281
+ to_add = [line for line in _GITIGNORE_LINES if line not in existing_set]
282
+ if not to_add:
283
+ return Action(path=gi, status="skipped-exists", detail="all lines present")
284
+ if existing and existing[-1] != "":
285
+ existing.append("") # leading blank separator
286
+ new_lines = existing + to_add + [""]
287
+ gi.write_text("\n".join(new_lines), encoding="utf-8")
288
+ return Action(
289
+ path=gi,
290
+ status="appended",
291
+ detail=f"added {len(to_add)} line(s)",
292
+ )
293
+
294
+
295
+ # ---------------------------------------------------------------- top-level
296
+
297
+
298
+ def init_project(
299
+ project: Path,
300
+ *,
301
+ project_type: str = "auto",
302
+ ) -> InitOutcome:
303
+ """Scaffold quartobot files into `project`.
304
+
305
+ Args:
306
+ project: Path to an existing Quarto project root (or empty dir).
307
+ project_type: `"auto"` (detect from existing `_quarto.yml`),
308
+ `"manuscript"`, or `"book"`. `"auto"` defaults to
309
+ `"manuscript"` when there's nothing to detect from.
310
+
311
+ Returns:
312
+ `InitOutcome` describing each file action.
313
+ """
314
+ if project_type == "auto":
315
+ detected = detect_project_type(project)
316
+ ptype = detected if detected != "unknown" else "manuscript"
317
+ else:
318
+ ptype = project_type
319
+
320
+ outcome = InitOutcome(project_type=ptype)
321
+
322
+ # _quarto.yml — never overwrite. If absent, write the appropriate
323
+ # default. If present, print a snippet for manual merge.
324
+ yml_path = project / "_quarto.yml"
325
+ if yml_path.exists():
326
+ outcome.actions.append(
327
+ Action(
328
+ path=yml_path,
329
+ status="manual-merge",
330
+ detail="merge the manubot block manually",
331
+ )
332
+ )
333
+ outcome.manual_merge_snippet = _quarto_yml_snippet_for_manual_merge()
334
+ else:
335
+ content = _QUARTO_YML_BOOK if ptype == "book" else _QUARTO_YML_MANUSCRIPT
336
+ outcome.actions.append(_write_if_missing(yml_path, content))
337
+
338
+ # Other files — write only if missing.
339
+ outcome.actions.append(_write_if_missing(project / "references.bib", _REFERENCES_BIB))
340
+ outcome.actions.append(
341
+ _write_if_missing(
342
+ project / "_version-banner.html.template",
343
+ _VERSION_BANNER_TEMPLATE,
344
+ )
345
+ )
346
+ outcome.actions.append(_write_if_missing(project / "_version-banner.html", _VERSION_BANNER_DEV))
347
+ outcome.actions.append(
348
+ _write_if_missing(
349
+ project / ".github" / "workflows" / "render.yml",
350
+ _render_workflow(ptype),
351
+ )
352
+ )
353
+ outcome.actions.append(
354
+ _write_if_missing(
355
+ project / ".github" / "workflows" / "pr-closed.yml",
356
+ _PR_CLOSED_WORKFLOW,
357
+ )
358
+ )
359
+
360
+ # .gitignore is the only file where we modify-in-place.
361
+ outcome.actions.append(_ensure_gitignore(project))
362
+
363
+ return outcome
364
+
365
+
366
+ def _quarto_yml_snippet_for_manual_merge() -> str:
367
+ """Return the YAML lines to add to an existing `_quarto.yml`."""
368
+ return """\
369
+ # Add to your existing _quarto.yml:
370
+
371
+ filters:
372
+ - quarto-manubot-cite
373
+
374
+ manubot-output-bibliography: references.json
375
+ manubot-bibliography-cache: _freeze/manubot-cache.json
376
+ manubot-fail-on-errors: false
377
+ manubot-infer-citekey-prefixes: true
378
+
379
+ bibliography:
380
+ - references.bib
381
+ - references.json
382
+
383
+ format:
384
+ html:
385
+ include-before-body:
386
+ - _version-banner.html
387
+ """
388
+
389
+
390
+ def format_outcome(outcome: InitOutcome, *, project: Path) -> str:
391
+ """Pretty-print an InitOutcome."""
392
+ glyphs = {
393
+ "written": "+",
394
+ "appended": "~",
395
+ "skipped-exists": "·",
396
+ "manual-merge": "!",
397
+ }
398
+ lines: list[str] = []
399
+ lines.append(f"Project type: {outcome.project_type}")
400
+ lines.append("")
401
+ for action in outcome.actions:
402
+ try:
403
+ relative = action.path.relative_to(project)
404
+ except ValueError:
405
+ relative = action.path
406
+ glyph = glyphs.get(action.status, "?")
407
+ line = f" {glyph} {relative} [{action.status}]"
408
+ if action.detail:
409
+ line += f" — {action.detail}"
410
+ lines.append(line)
411
+ lines.append("")
412
+ if outcome.manual_merge_snippet:
413
+ lines.append(outcome.manual_merge_snippet)
414
+ lines.append("Next steps:")
415
+ lines.append(" 1. quarto add seandavi/quartobot --no-prompt")
416
+ lines.append(" 2. pip install 'manubot>=0.6,<0.7'")
417
+ lines.append(" 3. Add citations to your prose: @doi:..., @pmid:..., etc.")
418
+ lines.append(" 4. quarto render")
419
+ return "\n".join(lines)
420
+
421
+
422
+ __all__: Sequence[str] = (
423
+ "Action",
424
+ "InitOutcome",
425
+ "detect_project_type",
426
+ "format_outcome",
427
+ "init_project",
428
+ )