paperstack-cli 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.
paperstack/cli.py ADDED
@@ -0,0 +1,779 @@
1
+ """Review critical reads and inspect source-backed paper records from one CLI.
2
+
3
+ Review lookup uses a clone, `$PAPERSTACK_DIR`, or a GitHub-backed cache.
4
+ Remote corpus access uses gh authentication without reading or storing its token.
5
+ Exit codes: 0 hit, 1 no match, 2 ambiguous, 3 unavailable corpus.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import contextlib
12
+ import fcntl
13
+ import io
14
+ import json
15
+ import os
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import tarfile
21
+ import tempfile
22
+ import time
23
+ from pathlib import Path
24
+
25
+ import yaml
26
+
27
+
28
+ def warn(msg: str) -> None:
29
+ print(f"paperstack: {msg}", file=sys.stderr)
30
+
31
+
32
+ FAIL = 3
33
+
34
+
35
+ def die(msg: str, code: int = FAIL) -> None:
36
+ warn(msg)
37
+ raise SystemExit(code)
38
+
39
+
40
+ REPO = os.environ.get("PAPERSTACK_REPO", "MilkClouds/my-paperstack")
41
+
42
+
43
+ def _ttl() -> int:
44
+ """Parse lazily so invalid configuration does not break --help."""
45
+ raw = os.environ.get("PAPERSTACK_TTL", "3600")
46
+ try:
47
+ return int(raw)
48
+ except ValueError:
49
+ warn(f"PAPERSTACK_TTL={raw!r} is not a whole number of seconds; using 3600")
50
+ return 3600
51
+
52
+
53
+ TTL = _ttl()
54
+
55
+ # Keep caches separate across PAPERSTACK_REPO values.
56
+ CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "paperstack" / REPO.replace("/", "_")
57
+ CORPUS = CACHE / "corpus"
58
+ STAGED = CACHE / "corpus.old"
59
+ CHECKED = CACHE / "last-checked"
60
+ SHA_FILE = ".paperstack-sha" # Moves atomically with the corpus.
61
+ LOCK = CACHE / "lock"
62
+
63
+
64
+ def gh(*args: str, binary: bool = False) -> bytes | str | None:
65
+ """Return gh stdout, or None on failure."""
66
+ try:
67
+ p = subprocess.run(["gh", *args], capture_output=True, timeout=120, check=False)
68
+ except FileNotFoundError:
69
+ return None
70
+ except subprocess.TimeoutExpired:
71
+ return None
72
+ if p.returncode != 0:
73
+ return None
74
+ return p.stdout if binary else p.stdout.decode().strip()
75
+
76
+
77
+ def remote_sha() -> str | None:
78
+ return gh("api", f"repos/{REPO}/commits/main", "--jq", ".sha") or None
79
+
80
+
81
+ def local_sha() -> str | None:
82
+ f = CORPUS / SHA_FILE
83
+ return f.read_text().strip() if f.is_file() else None
84
+
85
+
86
+ def valid(d: Path) -> bool:
87
+ """Return whether a directory contains entries."""
88
+ e = d / "entries"
89
+ return e.is_dir() and any(e.glob("*.md"))
90
+
91
+
92
+ @contextlib.contextmanager
93
+ def cache_lock():
94
+ """Serialize cache writes; yield False when the lock is busy."""
95
+ CACHE.mkdir(parents=True, exist_ok=True)
96
+ fd = os.open(LOCK, os.O_CREAT | os.O_RDWR, 0o644)
97
+ try:
98
+ try:
99
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
100
+ except OSError:
101
+ yield False
102
+ return
103
+ yield True
104
+ finally:
105
+ os.close(fd)
106
+
107
+
108
+ def install(staged: Path) -> bool:
109
+ """Publish a staged corpus without exposing partial data."""
110
+ CACHE.mkdir(parents=True, exist_ok=True)
111
+ try:
112
+ shutil.rmtree(STAGED, ignore_errors=True)
113
+ if CORPUS.exists():
114
+ os.replace(CORPUS, STAGED)
115
+ os.replace(staged, CORPUS)
116
+ except OSError as e:
117
+ warn(f"could not publish the downloaded corpus ({e})")
118
+ return False
119
+ shutil.rmtree(STAGED, ignore_errors=True)
120
+ return True
121
+
122
+
123
+ def recover() -> bool:
124
+ """Restore the backup left by an interrupted install."""
125
+ if valid(CORPUS) or not valid(STAGED):
126
+ return False
127
+ with cache_lock() as mine:
128
+ if not mine or valid(CORPUS) or not valid(STAGED):
129
+ return False
130
+ try:
131
+ os.replace(STAGED, CORPUS)
132
+ except OSError as e:
133
+ warn(f"could not recover the cached corpus ({e})")
134
+ return False
135
+ warn("recovered the cached corpus from an interrupted sync")
136
+ return True
137
+
138
+
139
+ def sync(force: bool = False) -> bool:
140
+ """Update the cache from main."""
141
+ with cache_lock() as mine:
142
+ if not mine:
143
+ warn("another paperstack is syncing; using the cache as it stands")
144
+ return valid(CORPUS)
145
+ return _sync(force)
146
+
147
+
148
+ def _sync(force: bool) -> bool:
149
+ sha = remote_sha()
150
+ if not sha:
151
+ return False
152
+ CHECKED.parent.mkdir(parents=True, exist_ok=True)
153
+ if not force and local_sha() == sha and valid(CORPUS):
154
+ CHECKED.touch()
155
+ return True
156
+
157
+ # Pin the archive to the recorded commit.
158
+ blob = gh("api", f"repos/{REPO}/tarball/{sha}", binary=True)
159
+ if not blob:
160
+ return False
161
+
162
+ tmp = Path(tempfile.mkdtemp(dir=CACHE, prefix=".staging-"))
163
+ try:
164
+ try:
165
+ with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
166
+ tar.extractall(tmp, filter="data")
167
+ except (tarfile.TarError, OSError, EOFError) as e:
168
+ warn(f"the downloaded archive is unreadable ({e})")
169
+ return False
170
+ roots = [p for p in tmp.iterdir() if p.is_dir()]
171
+ if len(roots) != 1 or not valid(roots[0]):
172
+ return False
173
+ (roots[0] / SHA_FILE).write_text(sha + "\n")
174
+ if not install(roots[0]):
175
+ return False
176
+ finally:
177
+ shutil.rmtree(tmp, ignore_errors=True)
178
+ # Only successful syncs refresh the TTL.
179
+ CHECKED.touch()
180
+ return True
181
+
182
+
183
+ def stale() -> bool:
184
+ if not CHECKED.is_file():
185
+ return True
186
+ return time.time() - CHECKED.stat().st_mtime > TTL
187
+
188
+
189
+ def git_toplevel() -> Path | None:
190
+ try:
191
+ p = subprocess.run(
192
+ ["git", "rev-parse", "--show-toplevel"],
193
+ capture_output=True,
194
+ text=True,
195
+ timeout=10,
196
+ check=False,
197
+ )
198
+ except (FileNotFoundError, subprocess.TimeoutExpired):
199
+ return None
200
+ return Path(p.stdout.strip()) if p.returncode == 0 else None
201
+
202
+
203
+ def resolve(offline: bool = False, force_sync: bool = False) -> Path:
204
+ if env := os.environ.get("PAPERSTACK_DIR"):
205
+ d = Path(env).expanduser()
206
+ if not valid(d):
207
+ die(f"PAPERSTACK_DIR={env} has no entries/")
208
+ if force_sync:
209
+ warn("--sync does not apply to PAPERSTACK_DIR; reading it as-is")
210
+ return d
211
+
212
+ if (top := git_toplevel()) and valid(top):
213
+ if force_sync:
214
+ warn(f"--sync does not apply to the working tree at {top}; reading it as-is")
215
+ return top
216
+
217
+ recover()
218
+ have = valid(CORPUS)
219
+ if offline:
220
+ if not have:
221
+ die("no local corpus and --offline was given; drop --offline to fetch one")
222
+ return CORPUS
223
+
224
+ if (force_sync or not have or stale()) and not sync(force=force_sync):
225
+ if not have:
226
+ die(
227
+ f"cannot reach {REPO} and there is no cached copy.\n"
228
+ " gh is how this authenticates: check `gh auth status`, then `gh auth login`"
229
+ )
230
+ warn(f"cannot reach {REPO}; using the cached copy from {(local_sha() or 'unknown')[:7]}")
231
+ if not valid(CORPUS):
232
+ die("the cached corpus is unusable and could not be replaced")
233
+ return CORPUS
234
+
235
+
236
+ class FrontLoader(yaml.SafeLoader):
237
+ """SafeLoader that preserves YAML 1.1 boolean-like words."""
238
+
239
+
240
+ FrontLoader.yaml_implicit_resolvers = {
241
+ k: [(tag, rx) for tag, rx in v if tag != "tag:yaml.org,2002:bool"]
242
+ for k, v in yaml.SafeLoader.yaml_implicit_resolvers.items()
243
+ }
244
+
245
+
246
+ def split_front(text: str) -> tuple[str, str] | None:
247
+ """Split frontmatter on delimiter lines."""
248
+ lines = text.splitlines(keepends=True)
249
+ if not lines or lines[0].strip() != "---":
250
+ return None
251
+ for i, ln in enumerate(lines[1:], 1):
252
+ if ln.strip() == "---":
253
+ return "".join(lines[1:i]), "".join(lines[i + 1 :])
254
+ return None
255
+
256
+
257
+ def as_list(v) -> list[str]:
258
+ """Normalize a frontmatter scalar or sequence to strings."""
259
+ if v is None:
260
+ return []
261
+ if isinstance(v, (str, bool, int, float)):
262
+ v = [v]
263
+ return [("yes" if x is True else "no" if x is False else str(x)) for x in v]
264
+
265
+
266
+ def load(root: Path) -> list[dict]:
267
+ out = []
268
+ for p in sorted((root / "entries").glob("*.md")):
269
+ text = p.read_text(encoding="utf-8")
270
+ parts = split_front(text)
271
+ if not parts:
272
+ continue
273
+ fm, body = parts
274
+ try:
275
+ meta = yaml.load(fm, Loader=FrontLoader) or {}
276
+ except yaml.YAMLError:
277
+ warn(f"{p.name}: frontmatter is not valid YAML, skipped")
278
+ continue
279
+ if not isinstance(meta, dict):
280
+ warn(f"{p.name}: frontmatter is not a mapping, skipped")
281
+ continue
282
+ meta["key"] = p.stem
283
+ meta["body"] = body.strip()
284
+ meta["name"] = next((ln[2:].strip() for ln in body.splitlines() if ln.startswith("# ")), p.stem)
285
+ out.append(meta)
286
+ return out
287
+
288
+
289
+ def quality(e: dict) -> str:
290
+ """Return the grade or ungraded."""
291
+ q = e.get("quality")
292
+ return str(q) if q not in (None, "") else "ungraded"
293
+
294
+
295
+ def lead(e: dict, label: str) -> str:
296
+ """Extract a labeled lead line."""
297
+ tag = f"**{label}.**"
298
+ for ln in e["body"].splitlines():
299
+ if ln.startswith(tag):
300
+ return ln[len(tag) :].strip()
301
+ return ""
302
+
303
+
304
+ def searchable(e: dict) -> str:
305
+ """Flatten entry values for search."""
306
+ parts: list[str] = []
307
+
308
+ def walk(v) -> None:
309
+ if isinstance(v, dict):
310
+ for x in v.values():
311
+ walk(x)
312
+ elif isinstance(v, (list, tuple)):
313
+ for x in v:
314
+ walk(x)
315
+ elif v is not None:
316
+ parts.append(str(v))
317
+
318
+ walk(e)
319
+ return "\n".join(parts).lower()
320
+
321
+
322
+ def find(entries: list[dict], q: str) -> list[dict]:
323
+ """Return the first nonempty tier of increasingly broad matches."""
324
+ ql = q.lower()
325
+ for pick in (
326
+ lambda e: e["key"].lower() == ql,
327
+ lambda e: str(e.get("id", "")).lower() in (ql, f"arxiv:{ql}"),
328
+ lambda e: ql in e["name"].lower() or ql in e["key"].lower(),
329
+ lambda e: ql in str(e.get("title", "")).lower(),
330
+ ):
331
+ if hits := [e for e in entries if pick(e)]:
332
+ return hits
333
+ return []
334
+
335
+
336
+ def strip_body(e: dict) -> dict:
337
+ return {k: v for k, v in e.items() if k != "body"}
338
+
339
+
340
+ def one_line(e: dict) -> str:
341
+ bits = [f"{quality(e):<9}", e["key"]]
342
+ if e["name"] != e["key"]:
343
+ bits.append(f"({e['name']})")
344
+ return " ".join(bits)
345
+
346
+
347
+ def show(e: dict, brief: bool) -> None:
348
+ head = [e["name"], f" {quality(e)}"]
349
+ meta = " · ".join(str(x) for x in (e.get("id"), e.get("venue"), ", ".join(as_list(e.get("lab")))) if x)
350
+ print("".join(head))
351
+ if meta:
352
+ print(meta)
353
+ print(f"tags: {', '.join(as_list(e.get('tags')))} entries/{e['key']}.md")
354
+ print()
355
+ for label in ("One-liner", "Why read it", "Read it anyway"):
356
+ if v := lead(e, label):
357
+ print(f"{label}. {v}\n")
358
+ if not brief:
359
+ body = e["body"]
360
+ cut = body.find("## What it is")
361
+ if cut >= 0:
362
+ print(body[cut:])
363
+
364
+
365
+ def _output(parser: argparse.ArgumentParser) -> None:
366
+ parser.add_argument("--json", action="store_true", help="machine-readable output")
367
+
368
+
369
+ def _offline(parser: argparse.ArgumentParser) -> None:
370
+ parser.add_argument("--offline", action="store_true", help="never touch the network")
371
+
372
+
373
+ def _review_commands(sub) -> None:
374
+ """Add commands that read or change the authored review corpus."""
375
+ s = sub.add_parser("show", help="review by key, id, or name")
376
+ s.add_argument("query", help="citation key, CURIE, method name, or title fragment")
377
+ s.add_argument("--brief", action="store_true", help="skip the two long sections")
378
+ _output(s)
379
+ _offline(s)
380
+
381
+ s = sub.add_parser("search", help="search review title, tags, and body")
382
+ s.add_argument("query", help="text to find in the authored review corpus")
383
+ _output(s)
384
+ _offline(s)
385
+
386
+ s = sub.add_parser("list", help="filter review frontmatter")
387
+ s.add_argument("--quality")
388
+ s.add_argument("--tag")
389
+ _output(s)
390
+ _offline(s)
391
+
392
+ s = sub.add_parser("sync", help="refresh the review cache")
393
+ s.add_argument("--force", action="store_true")
394
+
395
+ s = sub.add_parser("init", help="initialize an ungraded review scaffold")
396
+ s.add_argument("key", help="explicit citation key and filename stem")
397
+ s.add_argument("--id", required=True, help="registered CURIE or URL")
398
+ s.add_argument("--title", required=True, help="verified verbatim title")
399
+ s.add_argument("--editor", required=True)
400
+
401
+ s = sub.add_parser("check", help="validate the review corpus")
402
+ s.add_argument("--style", action="store_true", help="include prose-length warnings")
403
+ s = sub.add_parser("audit", help="compare review titles and venues with the local DBLP index")
404
+ _output(s)
405
+ _offline(s)
406
+
407
+ s = sub.add_parser("citations", help="update citation counts for all arXiv reviews")
408
+ s.add_argument("--fetch", action="store_true", help="fetch live counts from Semantic Scholar")
409
+ _output(s)
410
+
411
+
412
+ def _review_init(root: Path, a: argparse.Namespace) -> int:
413
+ if not re.fullmatch(r"[a-z0-9]+", a.key):
414
+ die("review key must contain only lowercase ASCII letters and digits")
415
+ path = root / "entries" / f"{a.key}.md"
416
+ if path.exists():
417
+ die(f"review already exists: {path}", 1)
418
+ if any(str(entry.get("id")) == a.id for entry in load(root)):
419
+ die(f"a review with id {a.id!r} already exists", 1)
420
+ if any("\n" in value or "\r" in value for value in (a.id, a.title, a.editor)):
421
+ die("review id, title, and editor must each fit on one line")
422
+ if not re.fullmatch(r"([a-z][a-z0-9.]*:[^ ]+|https?://[^ ]+)", a.id):
423
+ die("review id must be a registered CURIE or URL")
424
+ title = json.dumps(a.title, ensure_ascii=False)
425
+ editor = json.dumps(a.editor, ensure_ascii=False)
426
+ body = f"""---
427
+ id: {a.id}
428
+ title: {title}
429
+ quality: TODO
430
+ tags: []
431
+ editor: {editor}
432
+ ---
433
+
434
+ # {a.title}
435
+
436
+ **One-liner.** TODO
437
+
438
+ **Why read it.** TODO
439
+
440
+ ## What it is / What it shows
441
+
442
+ TODO
443
+
444
+ ## Critical read and limits
445
+
446
+ **Verdict.** TODO
447
+ """
448
+ path.write_text(body, encoding="utf-8")
449
+ print(path)
450
+ return 0
451
+
452
+
453
+ def _writable_review_root(command: str) -> Path:
454
+ if env := os.environ.get("PAPERSTACK_DIR"):
455
+ root = Path(env).expanduser()
456
+ else:
457
+ root = git_toplevel()
458
+ if root is None or not valid(root):
459
+ die(f"{command} requires a paperstack working tree or PAPERSTACK_DIR")
460
+ return root
461
+
462
+
463
+ def _review_audit(entries: list[dict], *, json_output: bool) -> int:
464
+ from . import dblp_index
465
+
466
+ if not dblp_index.installed():
467
+ die("review audit requires the local index; run `paperstack index dblp install`")
468
+ report = []
469
+ try:
470
+ matches_by_entry = dblp_index.search_many([str(entry.get("title", "")) for entry in entries])
471
+ except RuntimeError as exc:
472
+ die(f"DBLP index lookup failed: {exc}")
473
+ for entry, matches in zip(entries, matches_by_entry, strict=True):
474
+ item = {
475
+ "key": entry["key"],
476
+ "id": entry.get("id"),
477
+ "title": entry.get("title"),
478
+ "current_venue": entry.get("venue"),
479
+ "status": "no_match" if not matches else "matched" if len(matches) == 1 else "ambiguous",
480
+ "dblp_matches": [
481
+ {key: match.get(key) for key in ("title", "dblp_key", "venue", "year", "entry_type", "doi", "url")}
482
+ for match in matches
483
+ ],
484
+ }
485
+ report.append(item)
486
+ if json_output:
487
+ print(json.dumps(report, ensure_ascii=False, indent=2))
488
+ else:
489
+ for item in report:
490
+ if item["status"] == "no_match":
491
+ continue
492
+ match = item["dblp_matches"][0]
493
+ venue = item["current_venue"] or "(absent)"
494
+ print(f"{item['key']}\n review: {venue}\n dblp: {match['venue']} ({match['dblp_key']})")
495
+ if item["status"] == "ambiguous":
496
+ print(f" status: {len(item['dblp_matches'])} candidates; manual review required")
497
+ counts = {
498
+ status: sum(item["status"] == status for item in report) for status in ("matched", "ambiguous", "no_match")
499
+ }
500
+ print(" ".join(f"{key}: {value}" for key, value in counts.items()), file=sys.stderr)
501
+ return 0
502
+
503
+
504
+ def _run_review(a: argparse.Namespace) -> int:
505
+ cmd = a.review_cmd
506
+ if cmd == "sync":
507
+ if not sync(force=a.force):
508
+ die(f"could not reach {REPO}; check `gh auth status`")
509
+ print(f"{CORPUS} {(local_sha() or '?')[:7]}", file=sys.stderr)
510
+ return 0
511
+
512
+ if cmd == "init":
513
+ return _review_init(_writable_review_root("review init"), a)
514
+ if cmd == "citations":
515
+ from . import citations
516
+
517
+ root = _writable_review_root("review citations")
518
+ entries = load(root)
519
+ try:
520
+ document, changed = citations.update(root, entries, live=a.fetch)
521
+ except (OSError, TypeError, ValueError) as exc:
522
+ die(f"citation update failed: {exc}")
523
+ if a.json:
524
+ print(json.dumps(document, indent=2))
525
+ else:
526
+ suffix = " (cached values only; pass --fetch for live counts)" if not a.fetch else ""
527
+ print(
528
+ f"{root / 'citations.json'}: {len(document['papers'])}/{len(citations.collect(entries))} entries, "
529
+ f"{changed} changed{suffix}"
530
+ )
531
+ return 0
532
+ root = resolve(offline=getattr(a, "offline", False))
533
+ if cmd == "check":
534
+ checker = root / "scripts" / "build" / "check.sh"
535
+ if not checker.is_file():
536
+ die("review check requires a paperstack clone with scripts/build/check.sh")
537
+ argv = [str(checker), "--style"] if a.style else [str(checker)]
538
+ return subprocess.run(argv, cwd=root, check=False).returncode
539
+ entries = load(root)
540
+ if not entries:
541
+ die(f"no entries under {root}")
542
+ if cmd == "audit":
543
+ return _review_audit(entries, json_output=a.json)
544
+ if cmd == "show":
545
+ hits = find(entries, a.query)
546
+ if not hits:
547
+ die(f"nothing matches {a.query!r}", 1)
548
+ if len(hits) > 1:
549
+ warn(f"{a.query!r} matches {len(hits)} reviews:")
550
+ for e in hits:
551
+ print(f" {one_line(e)}", file=sys.stderr)
552
+ return 2
553
+ if a.json:
554
+ print(json.dumps(hits[0], ensure_ascii=False, indent=2, default=str))
555
+ else:
556
+ show(hits[0], a.brief)
557
+ return 0
558
+
559
+ if cmd == "search":
560
+ ql = a.query.lower()
561
+ hits = [e for e in entries if ql in searchable(e)]
562
+ else:
563
+ hits = [
564
+ e
565
+ for e in entries
566
+ if (not a.quality or quality(e) == a.quality) and (not a.tag or a.tag in as_list(e.get("tags")))
567
+ ]
568
+ if a.json:
569
+ print(json.dumps([strip_body(e) for e in hits], ensure_ascii=False, indent=2, default=str))
570
+ else:
571
+ order = {"excellent": 0, "good": 1, "fair": 2, "poor": 3}
572
+ for e in sorted(hits, key=lambda e: (order.get(quality(e), 9), e["key"])):
573
+ print(one_line(e))
574
+ print(f"{len(hits)} reviews", file=sys.stderr)
575
+ return 0 if hits else 1
576
+
577
+
578
+ def _paper_cache() -> Path:
579
+ base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
580
+ return Path(os.environ.get("PAPERSTACK_PAPERS_DIR", base / "paperstack" / "papers"))
581
+
582
+
583
+ def _run_paper(a: argparse.Namespace) -> int:
584
+ from . import metadata
585
+
586
+ ref = None
587
+ if a.paper_cmd == "metadata":
588
+ try:
589
+ ref = metadata.PaperRef.parse(a.paper_ref)
590
+ except ValueError as exc:
591
+ die(str(exc))
592
+ if a.offline and a.paper_cmd in ("metadata", "search"):
593
+ from . import dblp_index
594
+
595
+ local_search = a.paper_cmd == "search" and a.source == "dblp"
596
+ local_metadata = (
597
+ a.paper_cmd == "metadata" and a.source == "dblp" and ref is not None and ref.kind in ("dblp", "doi")
598
+ )
599
+ if not dblp_index.installed() or not (local_search or local_metadata):
600
+ die("offline paper lookup is available only through an installed DBLP index")
601
+ if a.paper_cmd == "metadata":
602
+ enabled = None if a.source == "all" else {a.source}
603
+ try:
604
+ results = metadata.fetch_all(ref, enabled, local_only=a.offline)
605
+ except RuntimeError as exc:
606
+ die(f"DBLP index lookup failed: {exc}")
607
+ metadata.print_results(results, json_output=a.json)
608
+ return 0 if any(item["status"] == "ok" for item in results) else 1
609
+ if a.paper_cmd == "search":
610
+ try:
611
+ result = metadata.search(a.source, a.query, local_only=a.offline)
612
+ except RuntimeError as exc:
613
+ die(f"DBLP index lookup failed: {exc}")
614
+ metadata.print_results(result, json_output=a.json)
615
+ return 0 if result["status"] == "ok" else 1
616
+
617
+ try:
618
+ ref = metadata.PaperRef.parse(a.paper_ref)
619
+ except ValueError as exc:
620
+ die(str(exc))
621
+ if ref.kind != "arxiv":
622
+ die(f"paper {a.paper_cmd} currently requires an arxiv: reference")
623
+ if a.paper_cmd == "read":
624
+ from .content import arxiv_source
625
+
626
+ arxiv_source.CACHE_DIR = _paper_cache()
627
+ cached_source = arxiv_source.CACHE_DIR / ref.value / "src"
628
+ if a.offline and a.refresh:
629
+ die("--offline and --refresh cannot be used together")
630
+ if a.offline and (not cached_source.is_dir() or not arxiv_source._tex_candidates(cached_source)):
631
+ die(f"no complete cached source for arxiv:{ref.value}")
632
+ argv = ["read", ref.value]
633
+ if a.outline:
634
+ argv.append("--outline")
635
+ elif a.section_id:
636
+ argv.extend(["--section", a.section_id])
637
+ if a.refresh:
638
+ argv.append("--refresh")
639
+ argv.extend(["--start", str(a.start), "--max-chars", str(a.max_chars)])
640
+ arxiv_source.main(argv)
641
+ return 0
642
+ if a.paper_cmd == "pdf":
643
+ from .content import arxiv_pdf
644
+
645
+ arxiv_pdf.CACHE_DIR = _paper_cache()
646
+ cached_pdf = arxiv_pdf.CACHE_DIR / ref.value / "paper.md"
647
+ if a.offline and (not cached_pdf.is_file() or cached_pdf.stat().st_size <= 1000):
648
+ die(f"no complete cached PDF conversion for arxiv:{ref.value}")
649
+ if not arxiv_pdf.convert(ref.value):
650
+ return 1
651
+ return 0
652
+ raise AssertionError(a.paper_cmd)
653
+
654
+
655
+ def _run_index(a: argparse.Namespace) -> int:
656
+ from . import dblp_index
657
+
658
+ try:
659
+ if a.dblp_cmd == "status":
660
+ info = dblp_index.status()
661
+ elif a.dblp_cmd == "install":
662
+ warn("installing the selected-venue DBLP snapshot; this may take a minute")
663
+ info = dblp_index.install()
664
+ elif a.dblp_cmd == "update":
665
+ warn("checking for a newer selected-venue DBLP snapshot")
666
+ info = dblp_index.update()
667
+ elif a.dblp_cmd == "remove":
668
+ if not a.yes:
669
+ die("index removal requires --yes")
670
+ dblp_index.remove()
671
+ info = {"installed": False, "removed": True}
672
+ else:
673
+ raise AssertionError(a.dblp_cmd)
674
+ except (OSError, RuntimeError, TypeError, ValueError, tarfile.TarError) as exc:
675
+ die(f"DBLP index operation failed: {exc}")
676
+ if a.json:
677
+ print(json.dumps(info, indent=2))
678
+ else:
679
+ for key, value in info.items():
680
+ print(f"{key}: {value}")
681
+ return 0
682
+
683
+
684
+ def main() -> int:
685
+ formatter = argparse.RawDescriptionHelpFormatter
686
+ ap = argparse.ArgumentParser(
687
+ prog="paperstack",
688
+ description=__doc__.split("\n")[0],
689
+ epilog="""typical workflow:
690
+ paperstack review search "RH20T" find an existing critical read
691
+ paperstack review show chen2024rh20tp read the authored judgment
692
+ paperstack paper metadata arxiv:2403.19622 inspect source records
693
+ paperstack paper read arxiv:2403.19622 --outline inspect the paper structure
694
+ paperstack paper read arxiv:2403.19622 --section 3
695
+
696
+ Use `review` for the curated corpus and `paper` for external paper facts and contents.
697
+ Run `paperstack <group> --help` for group-specific examples.""",
698
+ formatter_class=formatter,
699
+ )
700
+ sub = ap.add_subparsers(dest="cmd", required=True)
701
+
702
+ review = sub.add_parser(
703
+ "review",
704
+ help="authored critical reads and judgments",
705
+ description="Search, read, and maintain the authored critical-review corpus.",
706
+ epilog="""examples:
707
+ paperstack review search "flow matching"
708
+ paperstack review show black2024pi0 --brief
709
+ paperstack review show arxiv:2410.24164 --json
710
+ paperstack review list --quality poor --tag vla
711
+
712
+ Use `paperstack paper ...` when you need external metadata or the paper body.""",
713
+ formatter_class=formatter,
714
+ )
715
+ review_sub = review.add_subparsers(dest="review_cmd", required=True)
716
+ _review_commands(review_sub)
717
+
718
+ paper = sub.add_parser(
719
+ "paper",
720
+ help="source-backed paper facts and contents",
721
+ description="Inspect external source records and read arXiv paper contents without choosing a citation.",
722
+ epilog="""examples:
723
+ paperstack paper search "Attention Is All You Need" --source dblp
724
+ paperstack paper metadata arxiv:2403.19622
725
+ paperstack paper read arxiv:2403.19622 --outline
726
+ paperstack paper read arxiv:2403.19622 --section 3
727
+
728
+ Use `paperstack review ...` to find or read an authored critical judgment.""",
729
+ formatter_class=formatter,
730
+ )
731
+ paper_sub = paper.add_subparsers(dest="paper_cmd", required=True)
732
+ s = paper_sub.add_parser("metadata", help="fetch source records without choosing between them")
733
+ s.add_argument("paper_ref", help="arxiv:, doi:, dblp:, or openreview: reference")
734
+ s.add_argument(
735
+ "--source",
736
+ choices=("all", "semantic_scholar", "dblp", "crossref", "openreview", "acl_anthology", "arxiv"),
737
+ default="all",
738
+ )
739
+ _output(s)
740
+ _offline(s)
741
+ s = paper_sub.add_parser("search", help="search one metadata source")
742
+ s.add_argument("query", help="paper title or other source-specific search text")
743
+ s.add_argument("--source", choices=("s2", "dblp", "crossref", "openreview", "arxiv"), default="s2")
744
+ _output(s)
745
+ _offline(s)
746
+ s = paper_sub.add_parser("read", help="read the LaTeX body, outline, or one section")
747
+ s.add_argument("paper_ref", help="arxiv: reference")
748
+ mode = s.add_mutually_exclusive_group()
749
+ mode.add_argument("--outline", action="store_true", help="print numbered section headings only")
750
+ mode.add_argument("--section", dest="section_id", help="print one section by outline number")
751
+ s.add_argument("--refresh", action="store_true", help="replace the cached arXiv source")
752
+ s.add_argument("--start", type=int, default=0, help="start at this character offset")
753
+ s.add_argument("--max-chars", type=int, default=0, help="truncate output after this many characters")
754
+ _offline(s)
755
+ s = paper_sub.add_parser("pdf", help="download and convert a native PDF submission")
756
+ s.add_argument("paper_ref", help="arxiv: reference")
757
+ _offline(s)
758
+
759
+ index = sub.add_parser("index", help="optional local lookup indexes")
760
+ index_sub = index.add_subparsers(dest="index_cmd", required=True)
761
+ dblp = index_sub.add_parser("dblp", help="selected-venue DBLP index")
762
+ dblp_sub = dblp.add_subparsers(dest="dblp_cmd", required=True)
763
+ for command in ("status", "install", "update"):
764
+ _output(dblp_sub.add_parser(command))
765
+ s = dblp_sub.add_parser("remove")
766
+ s.add_argument("--yes", action="store_true")
767
+ _output(s)
768
+
769
+ a = ap.parse_args()
770
+
771
+ if a.cmd == "paper":
772
+ return _run_paper(a)
773
+ if a.cmd == "index":
774
+ return _run_index(a)
775
+ return _run_review(a)
776
+
777
+
778
+ if __name__ == "__main__":
779
+ sys.exit(main())