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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Copyright (c) 2012-2023, Matthieu Moy <git@matthieu-moy.fr> and
|
|
2
|
+
contributors.
|
|
3
|
+
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are
|
|
8
|
+
met:
|
|
9
|
+
|
|
10
|
+
1. Redistributions of source code must retain the above copyright
|
|
11
|
+
notice, this list of conditions and the following disclaimer.
|
|
12
|
+
|
|
13
|
+
2. Redistributions in binary form must reproduce the above copyright
|
|
14
|
+
notice, this list of conditions and the following disclaimer in the
|
|
15
|
+
documentation and/or other materials provided with the distribution.
|
|
16
|
+
|
|
17
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
18
|
+
contributors may be used to endorse or promote products derived from
|
|
19
|
+
this software without specific prior written permission.
|
|
20
|
+
|
|
21
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
22
|
+
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
23
|
+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
24
|
+
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
25
|
+
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
26
|
+
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
27
|
+
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
28
|
+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
|
29
|
+
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
30
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
31
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
paperstack/dblp_index.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""Install and query the optional selected-venue DBLP index."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import urllib.request
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from urllib.parse import quote_plus
|
|
16
|
+
|
|
17
|
+
import polars as pl
|
|
18
|
+
|
|
19
|
+
SNAPSHOT = "2026.08"
|
|
20
|
+
RELEASE_REPO = "MilkClouds/my-paperstack"
|
|
21
|
+
ASSET_NAME = "dblp.parquet"
|
|
22
|
+
MIN_RECORDS = 250_000
|
|
23
|
+
SCHEMA_VERSION = "1"
|
|
24
|
+
INDEX_URL = f"https://github.com/{RELEASE_REPO}/releases/download/dblp-index-{SNAPSHOT}/{ASSET_NAME}"
|
|
25
|
+
INDEX_SHA256 = "44b81915cc3a32938b62d25302870ef43ad83ec468dcd33885b920120c4ae258"
|
|
26
|
+
|
|
27
|
+
COLUMNS = (
|
|
28
|
+
"normalized_title",
|
|
29
|
+
"title",
|
|
30
|
+
"dblp_key",
|
|
31
|
+
"authors",
|
|
32
|
+
"venue",
|
|
33
|
+
"year",
|
|
34
|
+
"entry_type",
|
|
35
|
+
"doi",
|
|
36
|
+
"url",
|
|
37
|
+
)
|
|
38
|
+
SCHEMA = pl.Schema(
|
|
39
|
+
{
|
|
40
|
+
"normalized_title": pl.String,
|
|
41
|
+
"title": pl.String,
|
|
42
|
+
"dblp_key": pl.String,
|
|
43
|
+
"authors": pl.String,
|
|
44
|
+
"venue": pl.String,
|
|
45
|
+
"year": pl.Int64,
|
|
46
|
+
"entry_type": pl.String,
|
|
47
|
+
"doi": pl.String,
|
|
48
|
+
"url": pl.String,
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
RESULT_COLUMNS = COLUMNS[1:]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Snapshot:
|
|
56
|
+
version: str
|
|
57
|
+
url: str
|
|
58
|
+
sha256: str
|
|
59
|
+
minimum_records: int = MIN_RECORDS
|
|
60
|
+
asset_id: int | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
PINNED_SNAPSHOT = Snapshot(SNAPSHOT, INDEX_URL, INDEX_SHA256)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def data_dir() -> Path:
|
|
67
|
+
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
68
|
+
return base / "paperstack" / "indexes" / "dblp"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _current_path() -> Path:
|
|
72
|
+
return data_dir() / "current.json"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _current() -> dict[str, str]:
|
|
76
|
+
try:
|
|
77
|
+
current = json.loads(_current_path().read_text())
|
|
78
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
79
|
+
raise RuntimeError("DBLP index pointer is unreadable") from exc
|
|
80
|
+
if not isinstance(current, dict):
|
|
81
|
+
raise TypeError("DBLP index pointer is not an object")
|
|
82
|
+
required = {"file", "snapshot", "source", "sha256"}
|
|
83
|
+
if not required <= current.keys():
|
|
84
|
+
raise RuntimeError("DBLP index pointer is incomplete")
|
|
85
|
+
if not re.fullmatch(r"dblp-[0-9a-f]{64}\.parquet", str(current["file"])):
|
|
86
|
+
raise RuntimeError("DBLP index pointer has an invalid file name")
|
|
87
|
+
if not re.fullmatch(r"[0-9a-f]{64}", str(current["sha256"])):
|
|
88
|
+
raise RuntimeError("DBLP index pointer has an invalid SHA-256")
|
|
89
|
+
if current["file"] != f"dblp-{current['sha256']}.parquet":
|
|
90
|
+
raise RuntimeError("DBLP index pointer file and SHA-256 do not match")
|
|
91
|
+
return {key: str(value) for key, value in current.items()}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def index_path() -> Path:
|
|
95
|
+
try:
|
|
96
|
+
name = _current()["file"]
|
|
97
|
+
except (RuntimeError, TypeError):
|
|
98
|
+
name = ASSET_NAME
|
|
99
|
+
return data_dir() / name
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _legacy_paths() -> tuple[Path, ...]:
|
|
103
|
+
return data_dir() / "dblp.sqlite3", data_dir() / "manifest.json", data_dir() / ASSET_NAME
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def normalize_title(title: str) -> str:
|
|
107
|
+
return "".join(character.lower() for character in title if character.isalnum())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _field(bibtex: str, name: str) -> str | None:
|
|
111
|
+
match = re.search(rf"^\s*{name}\s*=\s*", bibtex, re.MULTILINE)
|
|
112
|
+
if not match:
|
|
113
|
+
return None
|
|
114
|
+
start = match.end()
|
|
115
|
+
if start >= len(bibtex):
|
|
116
|
+
return None
|
|
117
|
+
opening = bibtex[start]
|
|
118
|
+
if opening == "{":
|
|
119
|
+
depth = 0
|
|
120
|
+
position = start
|
|
121
|
+
while position < len(bibtex):
|
|
122
|
+
character = bibtex[position]
|
|
123
|
+
if character == "\\" and position + 1 < len(bibtex) and bibtex[position + 1] in "{}":
|
|
124
|
+
position += 2
|
|
125
|
+
continue
|
|
126
|
+
if character == "{":
|
|
127
|
+
depth += 1
|
|
128
|
+
elif character == "}":
|
|
129
|
+
depth -= 1
|
|
130
|
+
if depth == 0:
|
|
131
|
+
return bibtex[start + 1 : position].strip()
|
|
132
|
+
position += 1
|
|
133
|
+
return None
|
|
134
|
+
if opening == '"':
|
|
135
|
+
position = start + 1
|
|
136
|
+
while position < len(bibtex):
|
|
137
|
+
character = bibtex[position]
|
|
138
|
+
if character == "\\":
|
|
139
|
+
position += 2
|
|
140
|
+
continue
|
|
141
|
+
if character == '"':
|
|
142
|
+
return bibtex[start + 1 : position].strip()
|
|
143
|
+
position += 1
|
|
144
|
+
return None
|
|
145
|
+
end = re.search(r"[,\n}]", bibtex[start:])
|
|
146
|
+
return bibtex[start : start + end.start()].strip() if end else bibtex[start:].strip()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _key(bibtex: str) -> str | None:
|
|
150
|
+
match = re.match(r"@\w+\{([^,]+),", bibtex)
|
|
151
|
+
return match.group(1).strip() if match else None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _entry_type(bibtex: str) -> str | None:
|
|
155
|
+
match = re.match(r"@(\w+)\{", bibtex)
|
|
156
|
+
return match.group(1).lower() if match else None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _row(normalized: str, bibtex: str) -> tuple:
|
|
160
|
+
raw_title = _field(bibtex, "title") or normalized
|
|
161
|
+
title = re.sub(r"[{}]", "", raw_title).rstrip(".")
|
|
162
|
+
year = _field(bibtex, "year")
|
|
163
|
+
key = _key(bibtex)
|
|
164
|
+
doi = _field(bibtex, "doi")
|
|
165
|
+
source_url = (
|
|
166
|
+
f"https://dblp.org/rec/{key.removeprefix('DBLP:')}.html"
|
|
167
|
+
if key
|
|
168
|
+
else f"https://dblp.org/search?q={quote_plus(doi)}"
|
|
169
|
+
if doi
|
|
170
|
+
else _field(bibtex, "url")
|
|
171
|
+
)
|
|
172
|
+
return (
|
|
173
|
+
normalize_title(title) or normalized,
|
|
174
|
+
title,
|
|
175
|
+
key,
|
|
176
|
+
_field(bibtex, "author"),
|
|
177
|
+
_field(bibtex, "booktitle") or _field(bibtex, "journal"),
|
|
178
|
+
int(year) if year and year.isdigit() else None,
|
|
179
|
+
_entry_type(bibtex),
|
|
180
|
+
doi,
|
|
181
|
+
source_url,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@contextmanager
|
|
186
|
+
def _lock():
|
|
187
|
+
root = data_dir()
|
|
188
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
with (root / "install.lock").open("w") as handle:
|
|
190
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
191
|
+
yield
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def installed() -> bool:
|
|
195
|
+
try:
|
|
196
|
+
name = _current()["file"]
|
|
197
|
+
except (RuntimeError, TypeError):
|
|
198
|
+
return False
|
|
199
|
+
path = data_dir() / name
|
|
200
|
+
return path.is_file() and path.stat().st_size > 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _release_version(tag: str) -> tuple[int, int] | None:
|
|
204
|
+
match = re.fullmatch(r"dblp-index-(\d{4})\.(\d{2})", tag)
|
|
205
|
+
return (int(match.group(1)), int(match.group(2))) if match else None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _github_json(endpoint: str) -> object:
|
|
209
|
+
try:
|
|
210
|
+
result = subprocess.run(
|
|
211
|
+
["gh", "api", endpoint],
|
|
212
|
+
capture_output=True,
|
|
213
|
+
text=True,
|
|
214
|
+
timeout=30,
|
|
215
|
+
check=False,
|
|
216
|
+
)
|
|
217
|
+
except FileNotFoundError as exc:
|
|
218
|
+
raise RuntimeError("DBLP index access requires gh; install it and run `gh auth login`") from exc
|
|
219
|
+
except subprocess.TimeoutExpired as exc:
|
|
220
|
+
raise RuntimeError("GitHub Release lookup timed out") from exc
|
|
221
|
+
if result.returncode != 0:
|
|
222
|
+
detail = result.stderr.strip() or "gh api failed"
|
|
223
|
+
raise RuntimeError(f"cannot read paperstack Releases through gh ({detail})")
|
|
224
|
+
try:
|
|
225
|
+
return json.loads(result.stdout)
|
|
226
|
+
except json.JSONDecodeError as exc:
|
|
227
|
+
raise RuntimeError("GitHub Release response is not valid JSON") from exc
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _github_releases() -> list[dict]:
|
|
231
|
+
releases = _github_json(f"repos/{RELEASE_REPO}/releases?per_page=30")
|
|
232
|
+
if not isinstance(releases, list):
|
|
233
|
+
raise TypeError("GitHub Release response is not a list")
|
|
234
|
+
return releases
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _github_release(tag: str) -> dict:
|
|
238
|
+
release = _github_json(f"repos/{RELEASE_REPO}/releases/tags/{tag}")
|
|
239
|
+
if not isinstance(release, dict):
|
|
240
|
+
raise TypeError("GitHub Release response is not an object")
|
|
241
|
+
return release
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def latest_snapshot() -> Snapshot:
|
|
245
|
+
candidates = []
|
|
246
|
+
for release in _github_releases():
|
|
247
|
+
version = _release_version(str(release.get("tag_name", "")))
|
|
248
|
+
if version is None or release.get("draft") or release.get("prerelease"):
|
|
249
|
+
continue
|
|
250
|
+
asset = next((item for item in release.get("assets", []) if item.get("name") == ASSET_NAME), None)
|
|
251
|
+
digest = str((asset or {}).get("digest", ""))
|
|
252
|
+
if asset is None or not digest.startswith("sha256:"):
|
|
253
|
+
continue
|
|
254
|
+
candidates.append(
|
|
255
|
+
(
|
|
256
|
+
version,
|
|
257
|
+
Snapshot(
|
|
258
|
+
f"{version[0]:04d}.{version[1]:02d}",
|
|
259
|
+
str(asset["browser_download_url"]),
|
|
260
|
+
digest.removeprefix("sha256:"),
|
|
261
|
+
asset_id=int(asset["id"]),
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
if not candidates:
|
|
266
|
+
raise RuntimeError(f"no {ASSET_NAME} asset with a SHA-256 digest was found in {RELEASE_REPO} releases")
|
|
267
|
+
return max(candidates, key=lambda item: item[0])[1]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _sha256(path: Path) -> str:
|
|
271
|
+
digest = hashlib.sha256()
|
|
272
|
+
with path.open("rb") as handle:
|
|
273
|
+
while chunk := handle.read(1 << 20):
|
|
274
|
+
digest.update(chunk)
|
|
275
|
+
return digest.hexdigest()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _validate_index(path: Path, snapshot: Snapshot | None = None) -> tuple[dict[str, str], int]:
|
|
279
|
+
try:
|
|
280
|
+
schema = pl.read_parquet_schema(path)
|
|
281
|
+
metadata = pl.read_parquet_metadata(path)
|
|
282
|
+
records = pl.scan_parquet(path).select(pl.len()).collect().item()
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
raise RuntimeError("DBLP Parquet index is unreadable") from exc
|
|
285
|
+
if schema != SCHEMA:
|
|
286
|
+
raise RuntimeError("DBLP Parquet schema mismatch")
|
|
287
|
+
required = {"snapshot", "source", "records", "minimum_records", "coverage", "schema_version"}
|
|
288
|
+
if not required <= metadata.keys():
|
|
289
|
+
raise RuntimeError("DBLP Parquet metadata is incomplete")
|
|
290
|
+
if metadata["schema_version"] != SCHEMA_VERSION:
|
|
291
|
+
raise RuntimeError("DBLP Parquet schema version mismatch")
|
|
292
|
+
if metadata["coverage"] != "selected CS venues":
|
|
293
|
+
raise RuntimeError("DBLP Parquet coverage metadata mismatch")
|
|
294
|
+
try:
|
|
295
|
+
declared_records = int(metadata["records"])
|
|
296
|
+
declared_minimum = int(metadata["minimum_records"])
|
|
297
|
+
except ValueError as exc:
|
|
298
|
+
raise RuntimeError("DBLP Parquet record metadata is invalid") from exc
|
|
299
|
+
if records != declared_records:
|
|
300
|
+
raise RuntimeError("DBLP Parquet record metadata mismatch")
|
|
301
|
+
minimum = snapshot.minimum_records if snapshot else declared_minimum
|
|
302
|
+
if records < minimum:
|
|
303
|
+
raise RuntimeError(f"DBLP Parquet has only {records} records; expected at least {minimum}")
|
|
304
|
+
if snapshot and metadata["snapshot"] != snapshot.version:
|
|
305
|
+
raise RuntimeError("DBLP Parquet snapshot metadata mismatch")
|
|
306
|
+
if snapshot and metadata["source"] != snapshot.url:
|
|
307
|
+
raise RuntimeError("DBLP Parquet source metadata mismatch")
|
|
308
|
+
return metadata, records
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _version(version: object) -> tuple[int, int] | None:
|
|
312
|
+
match = re.fullmatch(r"(\d{4})\.(\d{2})", str(version))
|
|
313
|
+
return (int(match.group(1)), int(match.group(2))) if match else None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _fsync_directory(path: Path) -> None:
|
|
317
|
+
descriptor = os.open(path, os.O_RDONLY)
|
|
318
|
+
try:
|
|
319
|
+
os.fsync(descriptor)
|
|
320
|
+
finally:
|
|
321
|
+
os.close(descriptor)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _publish(staged: Path, snapshot: Snapshot, metadata: dict[str, str]) -> Path:
|
|
325
|
+
root = data_dir()
|
|
326
|
+
name = f"dblp-{snapshot.sha256}.parquet"
|
|
327
|
+
published = root / name
|
|
328
|
+
os.replace(staged, published)
|
|
329
|
+
_fsync_directory(root)
|
|
330
|
+
pointer = {
|
|
331
|
+
"file": name,
|
|
332
|
+
"snapshot": snapshot.version,
|
|
333
|
+
"source": metadata["source"],
|
|
334
|
+
"sha256": snapshot.sha256,
|
|
335
|
+
}
|
|
336
|
+
pending = root / "current.json.new"
|
|
337
|
+
try:
|
|
338
|
+
with pending.open("w") as handle:
|
|
339
|
+
json.dump(pointer, handle, indent=2)
|
|
340
|
+
handle.write("\n")
|
|
341
|
+
handle.flush()
|
|
342
|
+
os.fsync(handle.fileno())
|
|
343
|
+
os.replace(pending, _current_path())
|
|
344
|
+
_fsync_directory(root)
|
|
345
|
+
finally:
|
|
346
|
+
pending.unlink(missing_ok=True)
|
|
347
|
+
return published
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _private_asset_id(snapshot: Snapshot) -> int:
|
|
351
|
+
release = _github_release(f"dblp-index-{snapshot.version}")
|
|
352
|
+
asset = next((item for item in release.get("assets", []) if item.get("name") == ASSET_NAME), None)
|
|
353
|
+
if str((asset or {}).get("digest", "")) != f"sha256:{snapshot.sha256}":
|
|
354
|
+
raise RuntimeError(f"cannot resolve the pinned {ASSET_NAME} asset in {RELEASE_REPO}")
|
|
355
|
+
return int(asset["id"])
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _download(snapshot: Snapshot, staged: Path) -> str:
|
|
359
|
+
asset_id = snapshot.asset_id
|
|
360
|
+
if asset_id is None and snapshot.url.startswith(f"https://github.com/{RELEASE_REPO}/releases/download/"):
|
|
361
|
+
asset_id = _private_asset_id(snapshot)
|
|
362
|
+
if asset_id is not None:
|
|
363
|
+
try:
|
|
364
|
+
with staged.open("wb") as handle:
|
|
365
|
+
result = subprocess.run(
|
|
366
|
+
[
|
|
367
|
+
"gh",
|
|
368
|
+
"api",
|
|
369
|
+
f"repos/{RELEASE_REPO}/releases/assets/{asset_id}",
|
|
370
|
+
"-H",
|
|
371
|
+
"Accept: application/octet-stream",
|
|
372
|
+
],
|
|
373
|
+
stdout=handle,
|
|
374
|
+
stderr=subprocess.PIPE,
|
|
375
|
+
text=False,
|
|
376
|
+
timeout=180,
|
|
377
|
+
check=False,
|
|
378
|
+
)
|
|
379
|
+
except FileNotFoundError as exc:
|
|
380
|
+
raise RuntimeError("DBLP index access requires gh; install it and run `gh auth login`") from exc
|
|
381
|
+
except subprocess.TimeoutExpired as exc:
|
|
382
|
+
raise RuntimeError("DBLP snapshot download timed out") from exc
|
|
383
|
+
if result.returncode != 0:
|
|
384
|
+
detail = result.stderr.decode(errors="replace").strip() or "gh api failed"
|
|
385
|
+
raise RuntimeError(f"cannot download the DBLP snapshot through gh ({detail})")
|
|
386
|
+
return _sha256(staged)
|
|
387
|
+
digest = hashlib.sha256()
|
|
388
|
+
request = urllib.request.Request(
|
|
389
|
+
snapshot.url,
|
|
390
|
+
headers={"User-Agent": "paperstack (+https://github.com/MilkClouds/my-paperstack)"},
|
|
391
|
+
)
|
|
392
|
+
with urllib.request.urlopen(request, timeout=120) as response, staged.open("wb") as handle:
|
|
393
|
+
while chunk := response.read(1 << 20):
|
|
394
|
+
handle.write(chunk)
|
|
395
|
+
digest.update(chunk)
|
|
396
|
+
return digest.hexdigest()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def install(
|
|
400
|
+
*,
|
|
401
|
+
snapshot: Snapshot = PINNED_SNAPSHOT,
|
|
402
|
+
force: bool = False,
|
|
403
|
+
only_if_newer: bool = False,
|
|
404
|
+
) -> dict:
|
|
405
|
+
"""Download, verify, and atomically publish a Parquet snapshot."""
|
|
406
|
+
if not re.fullmatch(r"[0-9a-f]{64}", snapshot.sha256):
|
|
407
|
+
raise ValueError("DBLP snapshot SHA-256 is invalid")
|
|
408
|
+
with _lock():
|
|
409
|
+
if installed():
|
|
410
|
+
try:
|
|
411
|
+
current = status()
|
|
412
|
+
except RuntimeError:
|
|
413
|
+
if not force:
|
|
414
|
+
raise
|
|
415
|
+
else:
|
|
416
|
+
current_version = _version(current.get("snapshot"))
|
|
417
|
+
target_version = _version(snapshot.version)
|
|
418
|
+
current_is_newer = (
|
|
419
|
+
current_version is not None and target_version is not None and current_version > target_version
|
|
420
|
+
)
|
|
421
|
+
same_release = current.get("snapshot") == snapshot.version and current.get("sha256") == snapshot.sha256
|
|
422
|
+
if only_if_newer and (current_is_newer or same_release):
|
|
423
|
+
return {**current, "updated": False}
|
|
424
|
+
if not force:
|
|
425
|
+
return current
|
|
426
|
+
staged = data_dir() / f"{ASSET_NAME}.new"
|
|
427
|
+
staged.unlink(missing_ok=True)
|
|
428
|
+
try:
|
|
429
|
+
if _download(snapshot, staged) != snapshot.sha256:
|
|
430
|
+
raise RuntimeError("DBLP snapshot checksum mismatch")
|
|
431
|
+
metadata, count = _validate_index(staged, snapshot)
|
|
432
|
+
with staged.open("rb") as handle:
|
|
433
|
+
os.fsync(handle.fileno())
|
|
434
|
+
published = _publish(staged, snapshot, metadata)
|
|
435
|
+
for legacy in _legacy_paths():
|
|
436
|
+
legacy.unlink(missing_ok=True)
|
|
437
|
+
info = {
|
|
438
|
+
"snapshot": snapshot.version,
|
|
439
|
+
"source": metadata["source"],
|
|
440
|
+
"sha256": snapshot.sha256,
|
|
441
|
+
"records": count,
|
|
442
|
+
"coverage": metadata["coverage"],
|
|
443
|
+
"path": str(published),
|
|
444
|
+
}
|
|
445
|
+
if only_if_newer:
|
|
446
|
+
info["updated"] = True
|
|
447
|
+
return info
|
|
448
|
+
finally:
|
|
449
|
+
staged.unlink(missing_ok=True)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def update() -> dict:
|
|
453
|
+
return install(snapshot=latest_snapshot(), force=True, only_if_newer=True)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def status() -> dict:
|
|
457
|
+
if not _current_path().is_file():
|
|
458
|
+
return {"installed": False, "snapshot_available": SNAPSHOT}
|
|
459
|
+
current = _current()
|
|
460
|
+
path = data_dir() / current["file"]
|
|
461
|
+
if not path.is_file():
|
|
462
|
+
raise RuntimeError("DBLP index pointer target is missing")
|
|
463
|
+
metadata, records = _validate_index(path)
|
|
464
|
+
digest = _sha256(path)
|
|
465
|
+
if current["snapshot"] != metadata["snapshot"] or current["source"] != metadata["source"]:
|
|
466
|
+
raise RuntimeError("DBLP index pointer does not match its Parquet metadata")
|
|
467
|
+
if current["sha256"] != digest:
|
|
468
|
+
raise RuntimeError("DBLP index checksum mismatch")
|
|
469
|
+
return {
|
|
470
|
+
"snapshot": metadata["snapshot"],
|
|
471
|
+
"source": metadata["source"],
|
|
472
|
+
"sha256": digest,
|
|
473
|
+
"coverage": metadata["coverage"],
|
|
474
|
+
"schema_version": metadata["schema_version"],
|
|
475
|
+
"installed": True,
|
|
476
|
+
"path": str(path),
|
|
477
|
+
"bytes": path.stat().st_size,
|
|
478
|
+
"records": records,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def remove() -> None:
|
|
483
|
+
with _lock():
|
|
484
|
+
_current_path().unlink(missing_ok=True)
|
|
485
|
+
for path in data_dir().glob("dblp-*.parquet"):
|
|
486
|
+
path.unlink(missing_ok=True)
|
|
487
|
+
for legacy in _legacy_paths():
|
|
488
|
+
legacy.unlink(missing_ok=True)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _scan() -> pl.LazyFrame:
|
|
492
|
+
path = index_path()
|
|
493
|
+
try:
|
|
494
|
+
schema = pl.read_parquet_schema(path)
|
|
495
|
+
except Exception as exc:
|
|
496
|
+
raise RuntimeError("DBLP Parquet index is unreadable") from exc
|
|
497
|
+
if schema != SCHEMA:
|
|
498
|
+
raise RuntimeError("DBLP Parquet schema mismatch")
|
|
499
|
+
return pl.scan_parquet(path)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _records(frame: pl.DataFrame) -> list[dict]:
|
|
503
|
+
return frame.select(RESULT_COLUMNS).to_dicts()
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _collect(frame: pl.LazyFrame) -> pl.DataFrame:
|
|
507
|
+
try:
|
|
508
|
+
return frame.collect()
|
|
509
|
+
except Exception as exc:
|
|
510
|
+
raise RuntimeError("DBLP Parquet index is unreadable") from exc
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def lookup(*, key: str | None = None, doi: str | None = None) -> list[dict]:
|
|
514
|
+
if not installed() or not (key or doi):
|
|
515
|
+
return []
|
|
516
|
+
if key:
|
|
517
|
+
values = (key, f"DBLP:{key}")
|
|
518
|
+
query = _scan().filter(pl.col("dblp_key").is_in(values))
|
|
519
|
+
else:
|
|
520
|
+
query = _scan().filter(pl.col("doi").str.to_lowercase() == doi.lower())
|
|
521
|
+
return _records(_collect(query))
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _search_normalized(norm: str, limit: int) -> list[dict]:
|
|
525
|
+
exact = _collect(_scan().filter(pl.col("normalized_title") == norm).head(limit))
|
|
526
|
+
if exact.height or len(norm) < 10:
|
|
527
|
+
return _records(exact)
|
|
528
|
+
matches = (
|
|
529
|
+
_scan()
|
|
530
|
+
.filter(pl.col("normalized_title").str.contains(norm, literal=True))
|
|
531
|
+
.with_columns(
|
|
532
|
+
pl.col("normalized_title").str.starts_with(norm).alias("_prefix"),
|
|
533
|
+
pl.col("normalized_title").str.len_chars().alias("_length"),
|
|
534
|
+
)
|
|
535
|
+
.sort(["_prefix", "_length"], descending=[True, False])
|
|
536
|
+
.head(limit)
|
|
537
|
+
)
|
|
538
|
+
return _records(_collect(matches))
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def search(title: str, limit: int = 10) -> list[dict]:
|
|
542
|
+
if not installed() or not (norm := normalize_title(title)):
|
|
543
|
+
return []
|
|
544
|
+
return _search_normalized(norm, limit)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def search_many(titles: list[str], limit: int = 10) -> list[list[dict]]:
|
|
548
|
+
"""Resolve a title batch with at most two Parquet scans."""
|
|
549
|
+
if not installed():
|
|
550
|
+
return [[] for _ in titles]
|
|
551
|
+
norms = [normalize_title(title) for title in titles]
|
|
552
|
+
wanted = list({norm for norm in norms if norm})
|
|
553
|
+
exact: dict[str, list[dict]] = {}
|
|
554
|
+
if wanted:
|
|
555
|
+
frame = _collect(_scan().filter(pl.col("normalized_title").is_in(wanted)))
|
|
556
|
+
for row in frame.to_dicts():
|
|
557
|
+
norm = row.pop("normalized_title")
|
|
558
|
+
exact.setdefault(norm, []).append(row)
|
|
559
|
+
missing = [norm for norm in wanted if norm not in exact and len(norm) >= 10]
|
|
560
|
+
fallback: dict[str, list[dict]] = {}
|
|
561
|
+
if missing:
|
|
562
|
+
pattern = "|".join(re.escape(norm) for norm in missing)
|
|
563
|
+
candidates = _collect(_scan().filter(pl.col("normalized_title").str.contains(pattern))).to_dicts()
|
|
564
|
+
for norm in missing:
|
|
565
|
+
matches = [row for row in candidates if norm in row["normalized_title"]]
|
|
566
|
+
matches.sort(key=lambda row: (not row["normalized_title"].startswith(norm), len(row["normalized_title"])))
|
|
567
|
+
fallback[norm] = [{key: row[key] for key in RESULT_COLUMNS} for row in matches[:limit]]
|
|
568
|
+
return [(exact.get(norm) or fallback.get(norm) or [])[:limit] for norm in norms]
|
paperstack/entrypoint.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Console entry point with project-local environment loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dotenv import find_dotenv, load_dotenv
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_environment() -> None:
|
|
9
|
+
"""Load the nearest .env without replacing exported variables."""
|
|
10
|
+
if path := find_dotenv(usecwd=True):
|
|
11
|
+
load_dotenv(path, override=False)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> int:
|
|
15
|
+
load_environment()
|
|
16
|
+
|
|
17
|
+
# Import after loading because CLI configuration is initialized at import time.
|
|
18
|
+
from paperstack.cli import main as cli_main
|
|
19
|
+
|
|
20
|
+
return cli_main()
|