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/__init__.py +1 -0
- paperstack/citations.py +97 -0
- paperstack/cli.py +779 -0
- paperstack/content/__init__.py +1 -0
- paperstack/content/arxiv_pdf.py +103 -0
- paperstack/content/arxiv_source.py +440 -0
- paperstack/content/vendor/latexpand +736 -0
- paperstack/content/vendor/latexpand.LICENSE +31 -0
- paperstack/dblp_index.py +568 -0
- paperstack/entrypoint.py +20 -0
- paperstack/metadata.py +392 -0
- paperstack_cli-0.1.0.dist-info/METADATA +203 -0
- paperstack_cli-0.1.0.dist-info/RECORD +15 -0
- paperstack_cli-0.1.0.dist-info/WHEEL +4 -0
- paperstack_cli-0.1.0.dist-info/entry_points.txt +2 -0
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())
|