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.
@@ -0,0 +1 @@
1
+ """Paper content fetchers used by the unified CLI."""
@@ -0,0 +1,103 @@
1
+ """Convert native-PDF arXiv submissions to Markdown.
2
+
3
+ Used by `paperstack paper pdf` after the source fetcher reports no TeX.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import sys
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ _CACHE_ROOT = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
18
+ CACHE_DIR = Path(os.environ.get("PAPERSTACK_PAPERS_DIR", _CACHE_ROOT / "paperstack" / "papers"))
19
+
20
+
21
+ def _fetch(url: str, timeout: int = 60) -> bytes | None:
22
+ req = urllib.request.Request(url, headers={"User-Agent": "my-paperstack/1.0 (+arxiv pdf fetch)"})
23
+ for attempt in range(3):
24
+ try:
25
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
26
+ return resp.read()
27
+ except urllib.error.HTTPError as e:
28
+ if e.code == 429:
29
+ time.sleep(15 * (attempt + 1))
30
+ continue
31
+ if attempt == 2:
32
+ return None
33
+ time.sleep(5)
34
+ except (urllib.error.URLError, OSError, TimeoutError):
35
+ if attempt == 2:
36
+ return None
37
+ time.sleep(5)
38
+ return None
39
+
40
+
41
+ def convert(arxiv_id: str) -> bool:
42
+ d = CACHE_DIR / arxiv_id
43
+ md_path = d / "paper.md"
44
+ if md_path.exists() and md_path.stat().st_size > 1000:
45
+ print(f"{arxiv_id}: cached at {md_path}")
46
+ return True
47
+
48
+ try:
49
+ import pymupdf4llm
50
+ except ImportError:
51
+ print(
52
+ "PDF conversion needs the optional dependency: install paperstack[pdf] "
53
+ "or run `uv tool install --with pymupdf4llm ...`",
54
+ file=sys.stderr,
55
+ )
56
+ return False
57
+
58
+ url = f"https://arxiv.org/pdf/{arxiv_id}"
59
+ raw = _fetch(url)
60
+ if raw is None:
61
+ print(f"{arxiv_id}: could not fetch {url}", file=sys.stderr)
62
+ return False
63
+ if not raw.startswith(b"%PDF"):
64
+ print(f"{arxiv_id}: {url} did not return a PDF", file=sys.stderr)
65
+ return False
66
+
67
+ d.mkdir(parents=True, exist_ok=True)
68
+ pdf_path = d / "paper.pdf"
69
+ pdf_path.write_bytes(raw)
70
+ md = pymupdf4llm.to_markdown(str(pdf_path))
71
+ if len(md) < 500:
72
+ print(f"{arxiv_id}: converted to only {len(md)} chars, treat as a failure", file=sys.stderr)
73
+ return False
74
+
75
+ md_path.write_text(md, encoding="utf-8")
76
+ (d / "meta.json").write_text(
77
+ json.dumps(
78
+ {
79
+ "arxiv_id": arxiv_id,
80
+ "bytes": len(md),
81
+ "sha256": hashlib.sha256(md.encode("utf-8")).hexdigest(),
82
+ "url": url,
83
+ "converter": "pymupdf4llm",
84
+ },
85
+ indent=2,
86
+ ),
87
+ encoding="utf-8",
88
+ )
89
+ print(f"{arxiv_id}: {len(md)} chars -> {md_path}")
90
+ return True
91
+
92
+
93
+ def main(argv: list[str]) -> None:
94
+ if not argv:
95
+ sys.exit("pass one or more arXiv ids")
96
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
97
+ failures = [a for a in argv if not convert(a)]
98
+ if failures:
99
+ sys.exit(f"failed: {' '.join(failures)}")
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main(sys.argv[1:])
@@ -0,0 +1,440 @@
1
+ """Read an arXiv LaTeX source or inspect its section structure.
2
+
3
+ Uses latexpand from PATH, or the vendored copy via Perl. Does not execute TeX.
4
+ Section ids follow source heading order. Used by `paperstack paper read`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import gzip
11
+ import io
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tarfile
18
+ import tempfile
19
+ import time
20
+ import urllib.error
21
+ import urllib.request
22
+ import zlib
23
+ from pathlib import Path
24
+
25
+ _CACHE_ROOT = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
26
+ CACHE_DIR = Path(os.environ.get("PAPERSTACK_PAPERS_DIR", _CACHE_ROOT / "paperstack" / "papers"))
27
+
28
+ LEVELS = {"section": 1, "subsection": 2, "subsubsection": 3}
29
+
30
+ HEAD_RE = re.compile(r"\\(section|subsection|subsubsection)\s*\*?\s*(?=[\[{])")
31
+ TEX_SUFFIXES = {".tex", ".ltx", ".latex"}
32
+ VENDORED_LATEXPAND = Path(__file__).parent / "vendor" / "latexpand"
33
+ LATEXPAND_ARGS = ("--keep-comments", "--empty-comments", "--fatal", "--define", r"subfile=\input")
34
+
35
+
36
+ def _fetch_bytes(url: str, timeout: int = 60) -> bytes | None:
37
+ req = urllib.request.Request(
38
+ url,
39
+ headers={
40
+ "User-Agent": "my-paperstack/1.0 (+arxiv e-print fetch)",
41
+ "Accept": "*/*",
42
+ },
43
+ )
44
+ for attempt in range(3):
45
+ try:
46
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
47
+ return resp.read()
48
+ except urllib.error.HTTPError as e:
49
+ if e.code == 429:
50
+ time.sleep(15 * (attempt + 1))
51
+ continue
52
+ if e.code == 404 or attempt == 2:
53
+ return None
54
+ time.sleep(5)
55
+ except (urllib.error.URLError, OSError, TimeoutError):
56
+ if attempt < 2:
57
+ time.sleep(5)
58
+ continue
59
+ return None
60
+ return None
61
+
62
+
63
+ def _safe_extract(tar: tarfile.TarFile, dest: Path) -> int:
64
+ """Extract regular files without path traversal."""
65
+ n = 0
66
+ root = dest.resolve()
67
+ for m in tar.getmembers():
68
+ if not (m.isreg() or m.isdir()):
69
+ continue
70
+ target = (root / m.name).resolve()
71
+ if target != root and root not in target.parents:
72
+ continue
73
+ if m.isdir():
74
+ target.mkdir(parents=True, exist_ok=True)
75
+ continue
76
+ src = tar.extractfile(m)
77
+ if src is None:
78
+ continue
79
+ target.parent.mkdir(parents=True, exist_ok=True)
80
+ target.write_bytes(src.read())
81
+ n += 1
82
+ return n
83
+
84
+
85
+ def _unpack(raw: bytes, dest: Path) -> int:
86
+ """Unpack a tar or bare TeX response; return zero for PDFs."""
87
+ body = raw
88
+ if raw[:2] == b"\x1f\x8b":
89
+ try:
90
+ body = gzip.decompress(raw)
91
+ except (gzip.BadGzipFile, EOFError, zlib.error):
92
+ return 0
93
+ if body[:5] == b"%PDF-":
94
+ return 0
95
+ try:
96
+ with tarfile.open(fileobj=io.BytesIO(body), mode="r:*") as tar:
97
+ return _safe_extract(tar, dest)
98
+ except tarfile.TarError:
99
+ dest.mkdir(parents=True, exist_ok=True)
100
+ (dest / "main.tex").write_bytes(body)
101
+ return 1
102
+
103
+
104
+ def _ensure_source(arxiv_id: str, refresh: bool = False) -> Path:
105
+ paper_dir = CACHE_DIR / arxiv_id
106
+ src = paper_dir / "src"
107
+ if not refresh and src.is_dir() and _tex_candidates(src):
108
+ return src
109
+ url = f"https://arxiv.org/e-print/{arxiv_id}"
110
+ raw = _fetch_bytes(url)
111
+ if raw is None:
112
+ sys.exit(f"{arxiv_id}: could not fetch {url}")
113
+ paper_dir.mkdir(parents=True, exist_ok=True)
114
+ with tempfile.TemporaryDirectory(prefix=".src-", dir=paper_dir) as tmp:
115
+ staged = Path(tmp) / "new"
116
+ staged.mkdir()
117
+ unpacked = _unpack(raw, staged)
118
+ if not unpacked or not _tex_candidates(staged):
119
+ reason = "no LaTeX source (PDF-only submission?)" if not unpacked else "no .tex files in archive"
120
+ sys.exit(f"{arxiv_id}: {reason} at {url}")
121
+
122
+ previous = Path(tmp) / "previous"
123
+ if src.exists():
124
+ src.rename(previous)
125
+ try:
126
+ staged.rename(src)
127
+ except OSError:
128
+ if previous.exists():
129
+ previous.rename(src)
130
+ raise
131
+ return src
132
+
133
+
134
+ def _tex_candidates(src: Path) -> list[Path]:
135
+ out = []
136
+ for p in sorted(src.rglob("*")):
137
+ if not p.is_file() or "__MACOSX" in p.parts:
138
+ continue
139
+ if p.suffix.lower() in TEX_SUFFIXES or (not p.suffix and p.stat().st_size < 2_000_000):
140
+ out.append(p)
141
+ return out
142
+
143
+
144
+ def _read(p: Path) -> str:
145
+ return p.read_bytes().decode("utf-8", errors="replace")
146
+
147
+
148
+ def _mask_comments(text: str) -> str:
149
+ """Mask comments without changing offsets."""
150
+ out = []
151
+ for line in text.splitlines(keepends=True):
152
+ i, n = 0, len(line)
153
+ while i < n:
154
+ c = line[i]
155
+ if c == "\\":
156
+ i += 2
157
+ continue
158
+ if c == "%":
159
+ nl = len(line) - len(line.rstrip("\r\n"))
160
+ out.append(line[:i] + " " * (n - i - nl) + line[n - nl :])
161
+ break
162
+ i += 1
163
+ else:
164
+ out.append(line)
165
+ return "".join(out)
166
+
167
+
168
+ def _balanced(text: str, start: int, open_ch: str, close_ch: str) -> tuple[str, int] | None:
169
+ """Return balanced content and the position after its closing delimiter."""
170
+ if start >= len(text) or text[start] != open_ch:
171
+ return None
172
+ depth, i = 0, start
173
+ while i < len(text):
174
+ c = text[i]
175
+ if c == "\\":
176
+ i += 2
177
+ continue
178
+ if c == open_ch:
179
+ depth += 1
180
+ elif c == close_ch:
181
+ depth -= 1
182
+ if depth == 0:
183
+ return text[start + 1 : i], i + 1
184
+ i += 1
185
+ return None
186
+
187
+
188
+ def _flatten(path: Path, src: Path) -> str | None:
189
+ """Flatten one root with latexpand, or return None when it is unusable."""
190
+ command = [shutil.which("latexpand") or "perl"]
191
+ if command[0] == "perl":
192
+ command.append(str(VENDORED_LATEXPAND))
193
+ try:
194
+ proc = subprocess.run(
195
+ [*command, *LATEXPAND_ARGS, str(path.relative_to(src))],
196
+ cwd=src,
197
+ capture_output=True,
198
+ timeout=120,
199
+ check=False,
200
+ )
201
+ except FileNotFoundError:
202
+ sys.exit("latexpand not found and Perl is unavailable for the vendored copy")
203
+ except subprocess.TimeoutExpired:
204
+ return None
205
+ return proc.stdout.decode("utf-8", errors="replace") if proc.returncode == 0 else None
206
+
207
+
208
+ def _body_span(masked: str) -> tuple[int, int]:
209
+ """Return the document body without preamble headings."""
210
+ lo = masked.find(r"\begin{document}")
211
+ start = lo + len(r"\begin{document}") if lo >= 0 else 0
212
+ hi = masked.rfind(r"\end{document}")
213
+ return start, (hi if hi > start else len(masked))
214
+
215
+
216
+ _MACRO_RE = re.compile(r"\\(?:(?:new|renew|provide)command\*?\s*\{?|def\s*)\\([A-Za-z@]+)\}?")
217
+
218
+
219
+ def _collect_macros(masked: str) -> dict[str, str]:
220
+ """Collect zero-argument macro bodies used in headings."""
221
+ macros: dict[str, str] = {}
222
+ for m in _MACRO_RE.finditer(masked):
223
+ i = m.end()
224
+ while i < len(masked) and masked[i] in " \t":
225
+ i += 1
226
+ if i >= len(masked) or masked[i] != "{":
227
+ continue
228
+ bal = _balanced(masked, i, "{", "}")
229
+ if bal is not None:
230
+ macros[m.group(1)] = bal[0]
231
+ return macros
232
+
233
+
234
+ def _find_sections(text: str) -> list[dict]:
235
+ """Parse sections with dotted ids, levels, titles, and text."""
236
+ masked_full = _mask_comments(text)
237
+ macros = _collect_macros(masked_full)
238
+ lo, hi = _body_span(masked_full)
239
+ body, masked = text[lo:hi], masked_full[lo:hi]
240
+ found = []
241
+ for m in HEAD_RE.finditer(masked):
242
+ i = m.end()
243
+ if masked[i] == "[":
244
+ opt = _balanced(masked, i, "[", "]")
245
+ if opt is None:
246
+ continue
247
+ i = opt[1]
248
+ while i < len(masked) and masked[i].isspace():
249
+ i += 1
250
+ if i >= len(masked) or masked[i] != "{":
251
+ continue
252
+ arg = _balanced(masked, i, "{", "}")
253
+ if arg is None:
254
+ continue
255
+ # Keep empty headings so subsection nesting remains intact.
256
+ title = _clean_title(arg[0], macros) or "(untitled)"
257
+ found.append({"level": LEVELS[m.group(1)], "title": title, "start": m.start()})
258
+
259
+ counters = [0, 0, 0]
260
+ for k, s in enumerate(found):
261
+ lvl = s["level"]
262
+ counters[lvl - 1] += 1
263
+ for j in range(lvl, 3):
264
+ counters[j] = 0
265
+ for j in range(lvl - 1):
266
+ counters[j] = max(counters[j], 1)
267
+ s["id"] = ".".join(str(c) for c in counters[:lvl])
268
+ s["end"] = len(body)
269
+ for nxt in found[k + 1 :]:
270
+ if nxt["level"] <= lvl:
271
+ s["end"] = nxt["start"]
272
+ break
273
+ s["text"] = body[s["start"] : s["end"]]
274
+ return found
275
+
276
+
277
+ _DROP_CMDS = ("label", "footnote", "thanks", "vspace", "hspace", "protect", "index")
278
+ _SPACE_CMDS = re.compile(r"\\(?:quad|qquad|,|;|:|!|\s)")
279
+
280
+
281
+ def _clean_title(s: str, macros: dict[str, str] | None = None) -> str:
282
+ for _ in range(4):
283
+ if not macros:
284
+ break
285
+ new = re.sub(
286
+ r"\\([A-Za-z@]+)\s*(?:\{\})?",
287
+ lambda m: macros.get(m.group(1), m.group(0)),
288
+ s,
289
+ )
290
+ if new == s:
291
+ break
292
+ s = new
293
+ for cmd in _DROP_CMDS:
294
+ while True:
295
+ m = re.search(r"\\" + cmd + r"\s*\{", s)
296
+ if not m:
297
+ break
298
+ bal = _balanced(s, m.end() - 1, "{", "}")
299
+ if bal is None:
300
+ break
301
+ s = s[: m.start()] + s[bal[1] :]
302
+ while True:
303
+ m = re.search(r"\\texorpdfstring\s*\{", s)
304
+ if not m:
305
+ break
306
+ first = _balanced(s, m.end() - 1, "{", "}")
307
+ if first is None:
308
+ break
309
+ second = _balanced(s, first[1], "{", "}")
310
+ if second is None:
311
+ break
312
+ s = s[: m.start()] + second[0] + s[second[1] :]
313
+ for _ in range(6):
314
+ new = re.sub(r"\\[a-zA-Z]+\*?\s*\{([^{}]*)\}", r"\1", s)
315
+ if new == s:
316
+ break
317
+ s = new
318
+ s = s.replace("\\\\", " ")
319
+ s = _SPACE_CMDS.sub(" ", s)
320
+ s = re.sub(r"\\([a-zA-Z]+)\*?", r"\1", s)
321
+ s = re.sub(r"\\(.)", r"\1", s)
322
+ s = re.sub(r"[${}$~]", " ", s)
323
+ return re.sub(r"\s+", " ", s).strip()
324
+
325
+
326
+ def _load_document(arxiv_id: str, refresh: bool) -> tuple[str, list[dict]]:
327
+ src = _ensure_source(arxiv_id, refresh)
328
+ cands = _tex_candidates(src)
329
+ scored = []
330
+ for p in cands:
331
+ head = _mask_comments(_read(p)[:200_000])
332
+ if r"\documentclass" not in head:
333
+ continue
334
+ flat = _flatten(p, src)
335
+ if flat is None:
336
+ continue
337
+ sections = _find_sections(flat)
338
+ scored.append((r"\begin{document}" in head, len(sections), -len(p.parts), flat, sections))
339
+ if not scored: # Fall back when no file declares a document class.
340
+ for p in cands:
341
+ flat = _flatten(p, src)
342
+ if flat is None:
343
+ continue
344
+ sections = _find_sections(flat)
345
+ scored.append((False, len(sections), -len(p.parts), flat, sections))
346
+ if not scored:
347
+ sys.exit(f"{arxiv_id}: no usable .tex file in {src}")
348
+ best = max(scored, key=lambda t: (t[0], t[1], t[2]))
349
+ if not best[4]:
350
+ sys.exit(f"{arxiv_id}: LaTeX source found but no \\section commands in it")
351
+ masked = _mask_comments(best[3])
352
+ lo, hi = _body_span(masked)
353
+ return best[3][lo:hi], best[4]
354
+
355
+
356
+ def _load(arxiv_id: str, refresh: bool) -> list[dict]:
357
+ return _load_document(arxiv_id, refresh)[1]
358
+
359
+
360
+ def _print_chunk(text: str, start: int, max_chars: int) -> None:
361
+ start = max(0, start)
362
+ chunk = text[start : start + max_chars] if max_chars else text[start:]
363
+ print(chunk)
364
+ shown = start + len(chunk)
365
+ if shown < len(text):
366
+ print(
367
+ f"[truncated: {start}..{shown} of {len(text)} chars; --start {shown} for more]",
368
+ file=sys.stderr,
369
+ )
370
+
371
+
372
+ def cmd_list(args: argparse.Namespace) -> None:
373
+ for s in _load(args.arxiv_id, args.refresh):
374
+ print(f"{s['id']}\t{s['level']}\t{s['title']}")
375
+
376
+
377
+ def cmd_section(args: argparse.Namespace) -> None:
378
+ sections = _load(args.arxiv_id, args.refresh)
379
+ want = args.section_id.strip()
380
+ hit = next((s for s in sections if s["id"] == want or s["title"] == want), None)
381
+ if hit is None:
382
+ hit = next((s for s in sections if s["title"].lower() == want.lower()), None)
383
+ if hit is None:
384
+ avail = ", ".join(f"{s['id']} {s['title']}" for s in sections[:20])
385
+ sys.exit(f"{args.arxiv_id}: no section {want!r}. Available: {avail}")
386
+ _print_chunk(hit["text"], args.start, args.max_chars)
387
+
388
+
389
+ def cmd_read(args: argparse.Namespace) -> None:
390
+ body, sections = _load_document(args.arxiv_id, args.refresh)
391
+ if args.outline:
392
+ for section in sections:
393
+ print(f"{section['id']}\t{section['level']}\t{section['title']}")
394
+ return
395
+ if args.section_id:
396
+ want = args.section_id.strip()
397
+ hit = next((s for s in sections if s["id"] == want or s["title"] == want), None)
398
+ if hit is None:
399
+ hit = next((s for s in sections if s["title"].lower() == want.lower()), None)
400
+ if hit is None:
401
+ avail = ", ".join(f"{s['id']} {s['title']}" for s in sections[:20])
402
+ sys.exit(f"{args.arxiv_id}: no section {want!r}. Available: {avail}")
403
+ body = hit["text"]
404
+ _print_chunk(body, args.start, args.max_chars)
405
+
406
+
407
+ def main(argv: list[str]) -> None:
408
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
409
+ sub = ap.add_subparsers(dest="cmd", required=True)
410
+
411
+ p_list = sub.add_parser("list", help="print <id>\\t<level>\\t<title> per section")
412
+ p_list.add_argument("arxiv_id")
413
+ p_list.set_defaults(func=cmd_list)
414
+
415
+ p_sec = sub.add_parser("section", help="print raw LaTeX of one section")
416
+ p_sec.add_argument("arxiv_id")
417
+ p_sec.add_argument("section_id", help="dotted id (3.2) or exact title")
418
+ p_sec.add_argument("--max-chars", type=int, default=0, help="0 = whole section")
419
+ p_sec.add_argument("--start", type=int, default=0)
420
+ p_sec.set_defaults(func=cmd_section)
421
+
422
+ p_read = sub.add_parser("read", help="print the document body, outline, or one section")
423
+ p_read.add_argument("arxiv_id")
424
+ mode = p_read.add_mutually_exclusive_group()
425
+ mode.add_argument("--outline", action="store_true")
426
+ mode.add_argument("--section", dest="section_id", help="dotted id (3.2) or exact title")
427
+ p_read.add_argument("--max-chars", type=int, default=0, help="0 = all remaining text")
428
+ p_read.add_argument("--start", type=int, default=0)
429
+ p_read.add_argument("--refresh", action="store_true", help="refetch even if cached")
430
+ p_read.set_defaults(func=cmd_read)
431
+
432
+ for p in (p_list, p_sec):
433
+ p.add_argument("--refresh", action="store_true", help="refetch even if cached")
434
+
435
+ args = ap.parse_args(argv)
436
+ args.func(args)
437
+
438
+
439
+ if __name__ == "__main__":
440
+ main(sys.argv[1:])