scipaperlib 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,35 @@
1
+ """Local publication acquisition and evidence-preserving retrieval."""
2
+
3
+ from .storage import Library
4
+ from .service import Service
5
+ from .models import (
6
+ Filters,
7
+ Evidence,
8
+ EvidenceContext,
9
+ DiscoveryReport,
10
+ SciPaperlibError,
11
+ Assertion,
12
+ )
13
+ from .discovery import discover, parse_list
14
+ from .importing import import_papers
15
+ from .acquisition import sync
16
+ from .search import build_index, search_papers, read_context
17
+
18
+ __version__ = "0.1.0"
19
+ __all__ = [
20
+ "Library",
21
+ "Service",
22
+ "Filters",
23
+ "Evidence",
24
+ "EvidenceContext",
25
+ "DiscoveryReport",
26
+ "SciPaperlibError",
27
+ "Assertion",
28
+ "discover",
29
+ "parse_list",
30
+ "import_papers",
31
+ "sync",
32
+ "build_index",
33
+ "search_papers",
34
+ "read_context",
35
+ ]
@@ -0,0 +1,268 @@
1
+ from __future__ import annotations
2
+ import shutil
3
+ import tempfile
4
+ from pathlib import Path
5
+ from .models import Manifest, Artifact, SciPaperlibError, now
6
+ from .storage import digest
7
+ from .extraction import extract
8
+
9
+
10
+ def sync(
11
+ library,
12
+ selection=None,
13
+ *,
14
+ providers,
15
+ artifacts=("pdf", "source"),
16
+ refresh=False,
17
+ progress=None,
18
+ cancelled=None,
19
+ ):
20
+ outcomes = []
21
+ with library.lock():
22
+ papers = (
23
+ [library.get(i) for i in selection]
24
+ if selection is not None
25
+ else library.papers()
26
+ )
27
+ total = len(papers) * len(artifacts)
28
+ completed = 0
29
+
30
+ def emit(stage, **values):
31
+ nonlocal completed
32
+ if stage == "completed":
33
+ completed += 1
34
+ if progress:
35
+ progress(
36
+ {
37
+ "stage": stage,
38
+ "completed": completed,
39
+ "total_files": total,
40
+ **values,
41
+ }
42
+ )
43
+
44
+ emit("planned", papers=len(papers), artifacts=list(artifacts))
45
+ for paper in papers:
46
+ if cancelled and cancelled():
47
+ break
48
+ arxiv = next((i for i in paper.identifiers if i.provider == "arxiv"), None)
49
+ direct = next((i for i in paper.identifiers if i.provider == "pdf"), None)
50
+ identifier = arxiv.alias if arxiv else paper.paper_id
51
+ emit("resolving", paper_id=paper.paper_id, identifier=identifier)
52
+ revision = paper.active_revision
53
+ urls = {}
54
+ try:
55
+ if arxiv:
56
+ if paper.requested_arxiv_version and not refresh:
57
+ version = paper.requested_arxiv_version
58
+ elif revision and not refresh:
59
+ version = int(revision.removeprefix("arxiv-v"))
60
+ elif arxiv.version and not refresh:
61
+ version = arxiv.version
62
+ else:
63
+ resolved = providers.arxiv(
64
+ arxiv.model_copy(update={"version": None})
65
+ if refresh
66
+ else arxiv,
67
+ refresh=refresh,
68
+ )
69
+ version = resolved["identifiers"][0].version
70
+ paper.title = resolved["title"] or paper.title
71
+ revision = f"arxiv-v{version}"
72
+ value = arxiv.value + f"v{version}"
73
+ urls = {
74
+ "pdf": f"https://arxiv.org/pdf/{value}",
75
+ "source": f"https://arxiv.org/src/{value}",
76
+ }
77
+ elif direct:
78
+ urls = {"pdf": direct.value}
79
+ else:
80
+ documents = paper.bibliographic.get("documents", [])
81
+ if documents:
82
+ urls = {"pdf": documents[0]["url"]}
83
+ if not urls:
84
+ raise SciPaperlibError(
85
+ "source_unavailable", "No supported full-text location"
86
+ )
87
+ manifest = Manifest(revision=revision or "pending")
88
+ if (
89
+ revision
90
+ and library.path(
91
+ f"papers/{paper.directory}/revisions/{revision}/manifest.json"
92
+ ).exists()
93
+ ):
94
+ manifest = Manifest.model_validate(
95
+ library.read(
96
+ f"papers/{paper.directory}/revisions/{revision}/manifest.json"
97
+ )
98
+ )
99
+ for kind in artifacts:
100
+ if cancelled and cancelled():
101
+ break
102
+ emit(
103
+ "file_started",
104
+ paper_id=paper.paper_id,
105
+ identifier=identifier,
106
+ artifact=kind,
107
+ )
108
+ old = manifest.artifacts.get(kind)
109
+ if (
110
+ old
111
+ and old.status == "succeeded"
112
+ and old.path
113
+ and library.path(old.path).exists()
114
+ and digest(library.path(old.path).read_bytes()) == old.sha256
115
+ ):
116
+ outcomes.append(
117
+ {
118
+ "paper_id": paper.paper_id,
119
+ "artifact": kind,
120
+ "status": "skipped",
121
+ }
122
+ )
123
+ emit("completed", identifier=identifier, **outcomes[-1])
124
+ continue
125
+ artifact = Artifact(
126
+ kind=kind,
127
+ status="running",
128
+ requested_url=urls.get(kind),
129
+ provider_revision=revision,
130
+ )
131
+ stage = Path(
132
+ tempfile.mkdtemp(prefix="acquire-", dir=library.path("jobs"))
133
+ )
134
+ try:
135
+ if kind not in urls:
136
+ raise SciPaperlibError(
137
+ "source_unavailable", f"No {kind} offered by provider"
138
+ )
139
+ partial = stage / "artifact.part"
140
+ _, final, headers = providers.network.get(
141
+ urls[kind],
142
+ destination=partial,
143
+ progress=lambda event: emit(
144
+ event["stage"],
145
+ paper_id=paper.paper_id,
146
+ identifier=identifier,
147
+ artifact=kind,
148
+ **{k: v for k, v in event.items() if k != "stage"},
149
+ ),
150
+ )
151
+ emit(
152
+ "processing",
153
+ paper_id=paper.paper_id,
154
+ identifier=identifier,
155
+ artifact=kind,
156
+ )
157
+ data = partial.read_bytes()
158
+ checksum = digest(data)
159
+ if kind == "pdf":
160
+ if not data.lstrip().startswith(b"%PDF-"):
161
+ raise SciPaperlibError(
162
+ "invalid_payload", "Response is not a PDF"
163
+ )
164
+ fmt = "pdf"
165
+ else:
166
+ fmt = extract(data, stage / "source")
167
+ if not revision:
168
+ revision = "sha256-" + checksum
169
+ manifest.revision = revision
170
+ root = f"papers/{paper.directory}/revisions/{revision}"
171
+ relative = f"{root}/original/" + (
172
+ "paper.pdf" if kind == "pdf" else "source.bin"
173
+ )
174
+ target = library.path(relative)
175
+ target.parent.mkdir(parents=True, exist_ok=True)
176
+ if target.exists() and digest(target.read_bytes()) != checksum:
177
+ raise SciPaperlibError(
178
+ "revision_conflict",
179
+ "Provider changed bytes for an existing immutable revision",
180
+ )
181
+ if not target.exists():
182
+ partial.replace(target)
183
+ source_target = library.path(f"{root}/source")
184
+ if (
185
+ kind == "source"
186
+ and fmt != "pdf-only"
187
+ and not source_target.exists()
188
+ ):
189
+ manifest.extracted_checksums = {
190
+ str(path.relative_to(stage / "source")): digest(
191
+ path.read_bytes()
192
+ )
193
+ for path in (stage / "source").rglob("*")
194
+ if path.is_file()
195
+ }
196
+ (stage / "source").replace(source_target)
197
+ artifact = Artifact(
198
+ kind=kind,
199
+ status="succeeded",
200
+ requested_url=urls[kind],
201
+ final_url=final,
202
+ fetched=now(),
203
+ byte_count=len(data),
204
+ sha256=checksum,
205
+ path=relative,
206
+ format=fmt,
207
+ provider_revision=revision,
208
+ )
209
+ if fmt == "pdf-only":
210
+ manifest.warnings.append(
211
+ "Source endpoint returned PDF; TeX source unavailable"
212
+ )
213
+ except SciPaperlibError as exc:
214
+ artifact.status = (
215
+ "unavailable"
216
+ if exc.error.code == "source_unavailable"
217
+ else "failed"
218
+ )
219
+ artifact.error = exc.error
220
+ finally:
221
+ shutil.rmtree(stage)
222
+ manifest.artifacts[kind] = artifact
223
+ outcomes.append(
224
+ {
225
+ "paper_id": paper.paper_id,
226
+ "artifact": kind,
227
+ "status": artifact.status,
228
+ "error": artifact.error.model_dump()
229
+ if artifact.error
230
+ else None,
231
+ }
232
+ )
233
+ if revision:
234
+ library.write(
235
+ f"papers/{paper.directory}/revisions/{revision}/manifest.json",
236
+ manifest,
237
+ )
238
+ emit("completed", identifier=identifier, **outcomes[-1])
239
+ if revision:
240
+ paper.active_revision = revision
241
+ paper.status = (
242
+ "available"
243
+ if any(a.status == "succeeded" for a in manifest.artifacts.values())
244
+ else "unavailable"
245
+ )
246
+ library.save(paper)
247
+ except SciPaperlibError as exc:
248
+ # Failure to resolve a location accounts for every planned file.
249
+ for kind in artifacts:
250
+ outcomes.append(
251
+ {
252
+ "paper_id": paper.paper_id,
253
+ "artifact": kind,
254
+ "status": "unavailable"
255
+ if exc.error.code == "source_unavailable"
256
+ else "failed",
257
+ "error": exc.error.model_dump(),
258
+ }
259
+ )
260
+ emit("completed", identifier=identifier, **outcomes[-1])
261
+ library.rebuild_catalogue()
262
+ report = {
263
+ "schema_version": 1,
264
+ "outcomes": outcomes,
265
+ "interrupted": bool(cancelled and cancelled()),
266
+ }
267
+ library.write("logs/last-download.json", report)
268
+ return report