preprint-fulltext 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,8 @@
1
+ """preprint-fulltext — retrieve full text of bioRxiv/medRxiv preprints.
2
+
3
+ Ports-and-adapters toolkit: sources map into a canonical pydantic model
4
+ (:mod:`preprint_fulltext.core.models`); the CLI and MCP server are thin
5
+ frontends over the same core library.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,324 @@
1
+ """Typer CLI — thin frontend over the core library.
2
+
3
+ Subcommands: `get` (single full text), `discover`/`search` (SearchHit JSONL),
4
+ `ingest` (bulk chunked corpus). All heavy lifting lives in the core library and
5
+ `pipeline/`; this module only parses args, calls the router/pipeline, and formats.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime as dt
11
+ import sys
12
+ from collections.abc import Iterable, Iterator
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ import orjson
17
+ import typer
18
+
19
+ from .config import get_settings
20
+ from .core.models import FullText, SearchHit
21
+
22
+ app = typer.Typer(
23
+ name="preprint-fulltext",
24
+ help="Retrieve full text of bioRxiv/medRxiv preprints into embedding-ready corpora.",
25
+ no_args_is_help=True,
26
+ add_completion=False,
27
+ )
28
+
29
+
30
+ @app.callback()
31
+ def _main() -> None:
32
+ """preprint-fulltext — CLI frontend over the shared core library."""
33
+
34
+
35
+ def _dumps(obj) -> bytes:
36
+ return orjson.dumps(obj, option=orjson.OPT_INDENT_2)
37
+
38
+
39
+ def _write_hits_jsonl(hits: Iterable[SearchHit], out: Optional[Path], limit: int) -> int:
40
+ """Stream SearchHit records as one JSON object per line; returns the count."""
41
+ fh = out.open("wb") if out else sys.stdout.buffer
42
+ n = 0
43
+ try:
44
+ for hit in hits:
45
+ fh.write(orjson.dumps(hit.model_dump(mode="json")))
46
+ fh.write(b"\n")
47
+ n += 1
48
+ if n >= limit:
49
+ break
50
+ finally:
51
+ if out:
52
+ fh.close()
53
+ return n
54
+
55
+
56
+ def _since_to_date(since: Optional[str]) -> Optional[dt.date]:
57
+ """Accept YYYY-MM or YYYY-MM-DD; map YYYY-MM to the first of the month."""
58
+ if not since:
59
+ return None
60
+ parts = since.split("-")
61
+ if len(parts) == 2:
62
+ return dt.date(int(parts[0]), int(parts[1]), 1)
63
+ return dt.date.fromisoformat(since)
64
+
65
+
66
+ def _render_markdown(ft: FullText) -> str:
67
+ p = ft.preprint
68
+ lines = [f"# {p.title or p.doi}", ""]
69
+ meta = [f"**DOI:** {p.doi}", f"**Server:** {p.server.value}"]
70
+ if p.version is not None:
71
+ meta.append(f"**Version:** {p.version}")
72
+ if p.authors:
73
+ meta.append(f"**Authors:** {', '.join(p.authors)}")
74
+ if p.license:
75
+ lic = p.license.spdx_id or p.license.raw or "unknown"
76
+ meta.append(f"**License:** {lic} (redistributable={p.license.redistributable})")
77
+ meta.append(f"**Retrieved from:** {ft.retrieved_from.value}")
78
+ lines.append(" \n".join(meta))
79
+ lines.append("")
80
+ for s in ft.sections:
81
+ heading = s.title or s.kind.value.capitalize()
82
+ lines.append(f"## {heading}")
83
+ lines.append("")
84
+ lines.append(s.text)
85
+ lines.append("")
86
+ return "\n".join(lines).rstrip() + "\n"
87
+
88
+
89
+ @app.command()
90
+ def version() -> None:
91
+ """Print the installed version."""
92
+ from . import __version__
93
+
94
+ typer.echo(__version__)
95
+
96
+
97
+ @app.command()
98
+ def get(
99
+ doi: str = typer.Argument(..., help="DOI, arXiv id, or a doi.org/bioRxiv/arxiv.org URL."),
100
+ version: Optional[int] = typer.Option(None, "--version", "-v", help="Specific version."),
101
+ source: Optional[str] = typer.Option(
102
+ None, "--source", help="Force a source: europepmc | s3 | html | arxiv (default: auto)."
103
+ ),
104
+ server: Optional[str] = typer.Option(None, "--server", help="biorxiv | medrxiv (hint for S3/HTML)."),
105
+ html: bool = typer.Option(
106
+ False, "--html", help="Allow the public HTML page as a last-resort fallback (interactive only)."
107
+ ),
108
+ markdown: bool = typer.Option(False, "--markdown", "-m", help="Render sections as markdown."),
109
+ out: Optional[Path] = typer.Option(None, "--out", "-o", help="Write to file instead of stdout."),
110
+ ) -> None:
111
+ """Retrieve one preprint's full text as structured JSON (default) or markdown.
112
+
113
+ Accepts a bioRxiv/medRxiv DOI or content URL, a https://doi.org/… URL, or an arXiv id
114
+ / arxiv.org URL / 10.48550/arXiv.* DOI (routed to arXiv's LaTeXML full text).
115
+ """
116
+ from .pipeline.router import Router
117
+
118
+ result = Router(get_settings()).get_fulltext(
119
+ doi, version=version, source=source, server=server, allow_html=html
120
+ )
121
+ if result.fulltext is None:
122
+ typer.secho(
123
+ f"No full text for {doi} (tried: {', '.join(result.tried) or 'none'}). "
124
+ f"{result.reason or ''}".strip(),
125
+ err=True,
126
+ fg=typer.colors.YELLOW,
127
+ )
128
+ raise typer.Exit(code=2)
129
+
130
+ if markdown:
131
+ payload = _render_markdown(result.fulltext).encode("utf-8")
132
+ else:
133
+ payload = _dumps(result.fulltext.model_dump(mode="json"))
134
+
135
+ if out:
136
+ out.write_bytes(payload)
137
+ typer.secho(f"wrote {out}", err=True, fg=typer.colors.GREEN)
138
+ else:
139
+ sys.stdout.buffer.write(payload)
140
+ if not payload.endswith(b"\n"):
141
+ sys.stdout.buffer.write(b"\n")
142
+
143
+
144
+ @app.command()
145
+ def search(
146
+ query: str = typer.Argument(..., help="Keyword query."),
147
+ field: str = typer.Option("fulltext", "--field", help="fulltext | title | abstract | author."),
148
+ source: str = typer.Option("europepmc", "--source", help="europepmc | openalex | arxiv."),
149
+ limit: int = typer.Option(25, "--limit", "-n", help="Max hits."),
150
+ out: Optional[Path] = typer.Option(None, "--out", "-o", help="Write JSONL to file."),
151
+ ) -> None:
152
+ """Keyword search → SearchHit JSONL. --field scopes to title/abstract/author/fulltext."""
153
+ settings = get_settings()
154
+ if source == "europepmc":
155
+ from .sources.europepmc import EuropePMC
156
+
157
+ hits: Iterator[SearchHit] = EuropePMC(settings).search(query, limit, field=field)
158
+ elif source == "openalex":
159
+ from .sources.openalex import OpenAlex
160
+
161
+ hits = OpenAlex(settings).search(query, limit, field=field)
162
+ elif source == "arxiv":
163
+ from .sources.arxiv import ArxivAPI
164
+
165
+ hits = ArxivAPI(settings).search(query, limit, field=field)
166
+ else:
167
+ typer.secho(f"unknown source {source!r} (europepmc|openalex|arxiv)", err=True, fg="red")
168
+ raise typer.Exit(code=2)
169
+ _run_hits(hits, out, limit)
170
+
171
+
172
+ @app.command()
173
+ def discover(
174
+ query: Optional[str] = typer.Option(None, "--query", "-q", help="Free-text topic."),
175
+ category: Optional[str] = typer.Option(None, "--category", help="Subject/concept."),
176
+ since: Optional[str] = typer.Option(None, "--since", help="From date (YYYY-MM or YYYY-MM-DD)."),
177
+ to: Optional[str] = typer.Option(None, "--to", help="To date (YYYY-MM-DD)."),
178
+ source: str = typer.Option("openalex", "--source", help="openalex | europepmc | arxiv."),
179
+ limit: int = typer.Option(100, "--limit", "-n", help="Max hits."),
180
+ out: Optional[Path] = typer.Option(None, "--out", "-o", help="Write JSONL to file."),
181
+ ) -> None:
182
+ """Discover preprints by topic/category/date window → SearchHit JSONL."""
183
+ from .sources.base import DiscoverQuery
184
+
185
+ settings = get_settings()
186
+ dq = DiscoverQuery(
187
+ query=query, category=category,
188
+ from_date=_since_to_date(since), to_date=_since_to_date(to), limit=limit,
189
+ )
190
+ if source == "openalex":
191
+ from .sources.openalex import OpenAlex
192
+
193
+ hits: Iterator[SearchHit] = OpenAlex(settings).discover(dq)
194
+ elif source == "europepmc":
195
+ from .sources.europepmc import EuropePMC
196
+
197
+ hits = EuropePMC(settings).discover(dq)
198
+ elif source == "arxiv":
199
+ from .sources.arxiv import ArxivAPI
200
+
201
+ hits = ArxivAPI(settings).discover(dq)
202
+ else:
203
+ typer.secho(f"unknown source {source!r} (openalex|europepmc|arxiv)", err=True, fg="red")
204
+ raise typer.Exit(code=2)
205
+ _run_hits(hits, out, limit)
206
+
207
+
208
+ @app.command()
209
+ def ingest(
210
+ out: Path = typer.Argument(..., help="Output corpus path (JSONL, or .parquet with --format parquet)."),
211
+ source: str = typer.Option("s3", "--source", help="s3 | europepmc."),
212
+ server: str = typer.Option("biorxiv", "--server", help="biorxiv | medrxiv | both (s3)."),
213
+ since: Optional[str] = typer.Option(None, "--since", help="From month YYYY-MM (Current_Content only)."),
214
+ query: Optional[str] = typer.Option(None, "--query", "-q", help="EPMC query (source=europepmc)."),
215
+ include_back_content: bool = typer.Option(
216
+ False, "--include-back-content", help="Also ingest undated S3 Back_Content batches."
217
+ ),
218
+ redistribution: bool = typer.Option(
219
+ False, "--redistribution", help="Gate for a shareable corpus (degrade non-CC to stubs)."
220
+ ),
221
+ limit: Optional[int] = typer.Option(None, "--limit", "-n", help="Max preprints (testing/sampling)."),
222
+ fmt: str = typer.Option("jsonl", "--format", help="jsonl | parquet."),
223
+ ) -> None:
224
+ """Bulk full text → chunked corpus JSONL/Parquet. Resumable and incremental."""
225
+ from .pipeline.export import ExportMode
226
+ from .pipeline.ingest import Ingest, Target
227
+
228
+ settings = get_settings()
229
+ mode = ExportMode.REDISTRIBUTION if redistribution else ExportMode.ANALYSIS
230
+ since_date = _since_to_date(since)
231
+
232
+ targets: Iterator[Target]
233
+ bytes_source = None
234
+ if source == "s3":
235
+ from .core.models import Server
236
+ from .sources.biorxiv_s3 import BiorxivS3
237
+
238
+ s3 = BiorxivS3(settings)
239
+ bytes_source = s3
240
+ servers = [Server.BIORXIV, Server.MEDRXIV] if server == "both" else [Server(server)]
241
+
242
+ def _s3_targets() -> Iterator[Target]:
243
+ count = 0
244
+ for srv in servers:
245
+ for key in s3.iter_keys(srv, since=since_date, include_back_content=include_back_content):
246
+ yield Target(
247
+ id=f"{srv.value}:{key}",
248
+ load=lambda srv=srv, key=key: s3.parse_meca(s3.fetch_meca(srv, key), srv, key),
249
+ )
250
+ count += 1
251
+ if limit and count >= limit:
252
+ return
253
+
254
+ targets = _s3_targets()
255
+ elif source == "europepmc":
256
+ from .sources.europepmc import EuropePMC
257
+
258
+ epmc = EuropePMC(settings)
259
+ q = query or "*"
260
+
261
+ def _epmc_targets() -> Iterator[Target]:
262
+ for i, hit in enumerate(epmc.search(q, limit or 1000)):
263
+ if not hit.doi:
264
+ continue
265
+ yield Target(id=f"doi:{hit.doi}", load=lambda doi=hit.doi: epmc.get_fulltext(doi))
266
+ if limit and i + 1 >= limit:
267
+ return
268
+
269
+ targets = _epmc_targets()
270
+ elif source == "arxiv":
271
+ typer.secho(
272
+ "arXiv bulk ingest is not supported (arXiv full text is HTML, single-document). "
273
+ "Use `get`/`search` for arXiv, or arXiv's own S3 LaTeX-source bucket for bulk.",
274
+ err=True, fg="red",
275
+ )
276
+ raise typer.Exit(code=2)
277
+ else:
278
+ typer.secho(f"unknown source {source!r} (s3|europepmc)", err=True, fg="red")
279
+ raise typer.Exit(code=2)
280
+
281
+ ing = Ingest(out, mode=mode, settings=settings,
282
+ on_reminder=lambda m: typer.secho(m, err=True, fg="yellow"),
283
+ bytes_source=bytes_source)
284
+ report = ing.run(targets)
285
+
286
+ if fmt == "parquet":
287
+ _to_parquet(out)
288
+
289
+ typer.secho(
290
+ f"ingest done: {report.processed} processed, {report.skipped} skipped, "
291
+ f"{report.degraded} degraded, {report.not_found} not-found, "
292
+ f"{report.n_chunks} chunks, {report.bytes_downloaded} bytes (requester-pays).",
293
+ err=True, fg="green",
294
+ )
295
+
296
+
297
+ def _to_parquet(jsonl_out: Path) -> None:
298
+ """Convert the durable JSONL corpus to Parquet (best-effort, needs pyarrow)."""
299
+ try:
300
+ import pyarrow as pa
301
+ import pyarrow.parquet as pq
302
+ except ImportError:
303
+ typer.secho("pyarrow not installed; keeping JSONL only", err=True, fg="yellow")
304
+ return
305
+ rows = [orjson.loads(line) for line in jsonl_out.read_bytes().splitlines() if line.strip()]
306
+ if not rows:
307
+ return
308
+ table = pa.Table.from_pylist(rows)
309
+ pq.write_table(table, str(jsonl_out.with_suffix(".parquet")))
310
+
311
+
312
+ def _run_hits(hits: Iterator[SearchHit], out: Optional[Path], limit: int) -> None:
313
+ from .sources.base import SourceError
314
+
315
+ try:
316
+ n = _write_hits_jsonl(hits, out, limit)
317
+ except SourceError as e:
318
+ typer.secho(str(e), err=True, fg="red")
319
+ raise typer.Exit(code=2)
320
+ typer.secho(f"{n} hit(s)" + (f" → {out}" if out else ""), err=True, fg="green")
321
+
322
+
323
+ if __name__ == "__main__": # pragma: no cover
324
+ app()
@@ -0,0 +1,127 @@
1
+ """Runtime configuration.
2
+
3
+ Backed by ``pydantic-settings``: values come from (highest precedence first)
4
+ constructor args, environment variables, a ``.env`` file, then an optional
5
+ ``preprint-fulltext.toml``. AWS credentials themselves are left to the standard
6
+ boto3 chain; only the region and bucket names live here.
7
+
8
+ Externally-standard names (``CONTACT_EMAIL``, ``OPENALEX_API_KEY``,
9
+ ``AWS_REGION``) are accepted both bare and with the ``PREPRINT_FULLTEXT_`` prefix.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from functools import lru_cache
15
+ from pathlib import Path
16
+
17
+ from pydantic import AliasChoices, Field
18
+ from pydantic_settings import (
19
+ BaseSettings,
20
+ PydanticBaseSettingsSource,
21
+ SettingsConfigDict,
22
+ TomlConfigSettingsSource,
23
+ )
24
+
25
+ from . import __version__
26
+
27
+ # Both buckets confirmed against the openRxiv TDM pages (biorxiv.org/tdm,
28
+ # medrxiv.org/tdm, 2026-07-17): requester-pays, us-east-1, $0.09/GB (~6 TB total).
29
+ DEFAULT_BIORXIV_BUCKET = "biorxiv-src-monthly"
30
+ DEFAULT_MEDRXIV_BUCKET = "medrxiv-src-monthly"
31
+
32
+
33
+ class Settings(BaseSettings):
34
+ """Process-wide settings. Instantiate via :func:`get_settings`."""
35
+
36
+ model_config = SettingsConfigDict(
37
+ env_prefix="PREPRINT_FULLTEXT_",
38
+ env_file=".env",
39
+ env_file_encoding="utf-8",
40
+ toml_file="preprint-fulltext.toml",
41
+ extra="ignore",
42
+ populate_by_name=True, # accept the field name too, not only validation_alias
43
+ )
44
+
45
+ # --- polite-pool identity -------------------------------------------------
46
+ contact_email: str | None = Field(
47
+ default=None,
48
+ validation_alias=AliasChoices("PREPRINT_FULLTEXT_CONTACT_EMAIL", "CONTACT_EMAIL"),
49
+ description="Email for OpenAlex/EPMC polite pools; sent in User-Agent and mailto.",
50
+ )
51
+ openalex_api_key: str | None = Field(
52
+ default=None,
53
+ validation_alias=AliasChoices("PREPRINT_FULLTEXT_OPENALEX_API_KEY", "OPENALEX_API_KEY"),
54
+ description="OpenAlex API key (required since 2026-02-13, still free).",
55
+ )
56
+
57
+ # --- AWS / S3 -------------------------------------------------------------
58
+ aws_region: str = Field(
59
+ default="us-east-1",
60
+ validation_alias=AliasChoices("PREPRINT_FULLTEXT_AWS_REGION", "AWS_REGION"),
61
+ description="Region for the requester-pays openRxiv buckets (us-east-1).",
62
+ )
63
+ biorxiv_bucket: str = DEFAULT_BIORXIV_BUCKET
64
+ medrxiv_bucket: str = DEFAULT_MEDRXIV_BUCKET
65
+
66
+ # --- arXiv ----------------------------------------------------------------
67
+ arxiv_api_base: str = "https://export.arxiv.org/api/query"
68
+ arxiv_html_base: str = "https://arxiv.org/html"
69
+ ar5iv_base: str = "https://ar5iv.labs.arxiv.org/html"
70
+
71
+ # --- cache ----------------------------------------------------------------
72
+ cache_dir: Path = Field(
73
+ default=Path.home() / ".cache" / "preprint-fulltext",
74
+ description="Content-addressed cache of fetched .meca/XML.",
75
+ )
76
+
77
+ # --- chunking -------------------------------------------------------------
78
+ chunk_tokens: int = Field(default=512, ge=1, description="Max tokens per chunk.")
79
+ chunk_overlap: int = Field(default=64, ge=0, description="Token overlap within a section.")
80
+ tokenizer: str = Field(
81
+ default="tiktoken:cl100k_base",
82
+ description="Tokenizer spec, 'tiktoken:<encoding>'.",
83
+ )
84
+ cross_section: bool = Field(
85
+ default=False, description="Allow a chunk to span section boundaries."
86
+ )
87
+
88
+ @classmethod
89
+ def settings_customise_sources(
90
+ cls,
91
+ settings_cls: type[BaseSettings],
92
+ init_settings: PydanticBaseSettingsSource,
93
+ env_settings: PydanticBaseSettingsSource,
94
+ dotenv_settings: PydanticBaseSettingsSource,
95
+ file_secret_settings: PydanticBaseSettingsSource,
96
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
97
+ # env/.env win over the TOML file; TOML wins over hard-coded defaults.
98
+ return (
99
+ init_settings,
100
+ env_settings,
101
+ dotenv_settings,
102
+ TomlConfigSettingsSource(settings_cls),
103
+ file_secret_settings,
104
+ )
105
+
106
+ @property
107
+ def user_agent(self) -> str:
108
+ """User-Agent for polite-pool HTTP requests."""
109
+ base = f"preprint-fulltext/{__version__}"
110
+ return f"{base} (mailto:{self.contact_email})" if self.contact_email else base
111
+
112
+ def bucket_for(self, server: str) -> str:
113
+ """Return the S3 bucket name for a server ('biorxiv' | 'medrxiv')."""
114
+ if server == "biorxiv":
115
+ return self.biorxiv_bucket
116
+ if server == "medrxiv":
117
+ return self.medrxiv_bucket
118
+ raise ValueError(f"unknown server: {server!r}")
119
+
120
+
121
+ @lru_cache(maxsize=1)
122
+ def get_settings() -> Settings:
123
+ """Return the process-wide settings singleton."""
124
+ return Settings()
125
+
126
+
127
+ __all__ = ["Settings", "get_settings", "DEFAULT_BIORXIV_BUCKET", "DEFAULT_MEDRXIV_BUCKET"]
@@ -0,0 +1 @@
1
+ """core subpackage."""
@@ -0,0 +1,72 @@
1
+ """assemble.py — map a pydantic-free :class:`ParsedArticle` into the canonical model.
2
+
3
+ This is the single seam between the shared JATS parser and the canonical model,
4
+ used by BOTH the Europe PMC and S3 paths so their `FullText` is byte-for-byte
5
+ identical for the same document. The abstract is emitted as an
6
+ ``abstract``-kind :class:`Section` so the chunker treats it uniformly; sections
7
+ are renumbered from 1 with ``id = str(order)``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime as dt
13
+
14
+ from .jats import ParsedArticle
15
+ from .licenses import parse_license
16
+ from .models import FullText, Preprint, Section, SectionKind, Server, SourceName
17
+
18
+
19
+ def build_fulltext(
20
+ parsed: ParsedArticle,
21
+ *,
22
+ doi: str,
23
+ server: Server | str,
24
+ source: SourceName | str,
25
+ version: int | None = None,
26
+ raw_ref: str | None = None,
27
+ published_doi: str | None = None,
28
+ date: dt.date | None = None,
29
+ category: str | None = None,
30
+ provenance: dict | None = None,
31
+ ) -> FullText:
32
+ """Assemble a :class:`FullText` from parser output plus caller-supplied metadata."""
33
+ license_ = parse_license(parsed.license_raw)
34
+
35
+ preprint = Preprint(
36
+ doi=doi,
37
+ version=version,
38
+ server=Server(server),
39
+ title=parsed.title,
40
+ authors=list(parsed.authors),
41
+ date=date,
42
+ category=category,
43
+ abstract=parsed.abstract,
44
+ license=license_,
45
+ published_doi=published_doi,
46
+ provenance=provenance or {},
47
+ )
48
+
49
+ sections: list[Section] = []
50
+ order = 0
51
+ if parsed.abstract:
52
+ order += 1
53
+ sections.append(
54
+ Section(id=str(order), kind=SectionKind.ABSTRACT, title="Abstract",
55
+ text=parsed.abstract, order=order)
56
+ )
57
+ for ps in parsed.sections:
58
+ order += 1
59
+ sections.append(
60
+ Section(id=str(order), kind=SectionKind(ps.kind), title=ps.title,
61
+ text=ps.text, order=order)
62
+ )
63
+
64
+ return FullText(
65
+ preprint=preprint,
66
+ sections=sections,
67
+ retrieved_from=SourceName(source),
68
+ raw_ref=raw_ref,
69
+ )
70
+
71
+
72
+ __all__ = ["build_fulltext"]
@@ -0,0 +1,49 @@
1
+ """cache.py — content-addressed local cache of fetched bytes (.meca/XML).
2
+
3
+ Keyed by ``sha256(source | id | version)`` so re-runs skip S3/EPMC. Sharded into
4
+ 256 subdirectories to keep any one directory small.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+
13
+ from ..config import Settings, get_settings
14
+
15
+
16
+ class Cache:
17
+ def __init__(self, cache_dir: Path | str | None = None, settings: Settings | None = None):
18
+ settings = settings or get_settings()
19
+ self.dir = Path(cache_dir or settings.cache_dir)
20
+
21
+ @staticmethod
22
+ def key(*parts: object) -> str:
23
+ raw = "|".join("" if p is None else str(p) for p in parts)
24
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
25
+
26
+ def _path(self, key: str) -> Path:
27
+ return self.dir / key[:2] / key
28
+
29
+ def get(self, key: str) -> bytes | None:
30
+ p = self._path(key)
31
+ return p.read_bytes() if p.exists() else None
32
+
33
+ def put(self, key: str, data: bytes) -> None:
34
+ p = self._path(key)
35
+ p.parent.mkdir(parents=True, exist_ok=True)
36
+ tmp = p.with_suffix(".tmp")
37
+ tmp.write_bytes(data)
38
+ tmp.replace(p) # atomic within the same filesystem
39
+
40
+ def get_or_fetch(self, key: str, fetch_fn: Callable[[], bytes]) -> bytes:
41
+ cached = self.get(key)
42
+ if cached is not None:
43
+ return cached
44
+ data = fetch_fn()
45
+ self.put(key, data)
46
+ return data
47
+
48
+
49
+ __all__ = ["Cache"]