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,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.
@@ -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]
@@ -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()