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.
- scipaperlib/__init__.py +35 -0
- scipaperlib/acquisition.py +268 -0
- scipaperlib/cli.py +765 -0
- scipaperlib/demo.py +83 -0
- scipaperlib/discovery.py +163 -0
- scipaperlib/extraction.py +122 -0
- scipaperlib/identifiers.py +67 -0
- scipaperlib/importing.py +148 -0
- scipaperlib/jobs.py +128 -0
- scipaperlib/mcp_server.py +207 -0
- scipaperlib/models.py +247 -0
- scipaperlib/network.py +252 -0
- scipaperlib/progress.py +152 -0
- scipaperlib/providers.py +309 -0
- scipaperlib/py.typed +0 -0
- scipaperlib/removal.py +112 -0
- scipaperlib/schemas/Artifact.json +162 -0
- scipaperlib/schemas/Assertion.json +67 -0
- scipaperlib/schemas/Candidate.json +156 -0
- scipaperlib/schemas/DiscoveryReport.json +227 -0
- scipaperlib/schemas/Error.json +35 -0
- scipaperlib/schemas/Evidence.json +198 -0
- scipaperlib/schemas/EvidenceContext.json +231 -0
- scipaperlib/schemas/Filters.json +150 -0
- scipaperlib/schemas/Identifier.json +45 -0
- scipaperlib/schemas/Job.json +116 -0
- scipaperlib/schemas/Manifest.json +203 -0
- scipaperlib/schemas/Paper.json +251 -0
- scipaperlib/schemas/SearchPage.json +400 -0
- scipaperlib/search.py +574 -0
- scipaperlib/service.py +313 -0
- scipaperlib/storage.py +267 -0
- scipaperlib/tex.py +318 -0
- scipaperlib/tui.py +859 -0
- scipaperlib-0.1.0.dist-info/METADATA +52 -0
- scipaperlib-0.1.0.dist-info/RECORD +39 -0
- scipaperlib-0.1.0.dist-info/WHEEL +4 -0
- scipaperlib-0.1.0.dist-info/entry_points.txt +4 -0
- scipaperlib-0.1.0.dist-info/licenses/LICENSE +21 -0
scipaperlib/__init__.py
ADDED
|
@@ -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
|