notanda 1.3.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.
Files changed (54) hide show
  1. harvester/__init__.py +9 -0
  2. harvester/acquisition.py +245 -0
  3. harvester/advisor.py +541 -0
  4. harvester/cli.py +684 -0
  5. harvester/config.py +483 -0
  6. harvester/errors.py +270 -0
  7. harvester/evidence.py +310 -0
  8. harvester/http.py +632 -0
  9. harvester/identity.py +315 -0
  10. harvester/logging_setup.py +100 -0
  11. harvester/models.py +278 -0
  12. harvester/orchestrator.py +1711 -0
  13. harvester/preview.py +158 -0
  14. harvester/providers/__init__.py +12 -0
  15. harvester/providers/europepmc.py +355 -0
  16. harvester/providers/openalex.py +567 -0
  17. harvester/providers/unpaywall.py +188 -0
  18. harvester/reporting.py +170 -0
  19. harvester/state.py +2207 -0
  20. harvester/storage.py +258 -0
  21. harvester/util.py +46 -0
  22. harvester/validation.py +213 -0
  23. harvester/verify.py +143 -0
  24. harvester/vocabulary.py +523 -0
  25. harvester/webui/__init__.py +11 -0
  26. harvester/webui/api.py +1006 -0
  27. harvester/webui/runmanager.py +272 -0
  28. harvester/webui/server.py +335 -0
  29. harvester/webui/settings_store.py +361 -0
  30. harvester/webui/static/LICENSE-IBMPlex-OFL.txt +93 -0
  31. harvester/webui/static/LICENSE-SourceSerif4-OFL.txt +93 -0
  32. harvester/webui/static/app.js +2342 -0
  33. harvester/webui/static/favicon.ico +0 -0
  34. harvester/webui/static/favicon.svg +1 -0
  35. harvester/webui/static/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  36. harvester/webui/static/ibm-plex-sans-latin-400-italic.woff2 +0 -0
  37. harvester/webui/static/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  38. harvester/webui/static/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  39. harvester/webui/static/index.html +71 -0
  40. harvester/webui/static/notanda-tokens.css +38 -0
  41. harvester/webui/static/notanda_avatar_180.png +0 -0
  42. harvester/webui/static/notanda_emblem_farbig_dunkel.svg +1 -0
  43. harvester/webui/static/notanda_lockup_dunkel_pfade.svg +1 -0
  44. harvester/webui/static/source-serif-4-latin-wght-italic.woff2 +0 -0
  45. harvester/webui/static/source-serif-4-latin-wght-normal.woff2 +0 -0
  46. harvester/webui/static/styles.css +214 -0
  47. notanda-1.3.0.dist-info/METADATA +16 -0
  48. notanda-1.3.0.dist-info/RECORD +54 -0
  49. notanda-1.3.0.dist-info/WHEEL +5 -0
  50. notanda-1.3.0.dist-info/entry_points.txt +2 -0
  51. notanda-1.3.0.dist-info/licenses/LICENSE +21 -0
  52. notanda-1.3.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +54 -0
  53. notanda-1.3.0.dist-info/licenses/TRADEMARKS.md +23 -0
  54. notanda-1.3.0.dist-info/top_level.txt +1 -0
harvester/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Open-Access API Harvester.
2
+
3
+ A resumable, idempotent, headless CLI pipeline that discovers Open-Access scholarly
4
+ works via OpenAlex Topics, cross-checks them against Europe PMC, falls back to
5
+ Unpaywall for OA locations, and stores validated PDF/XML artifacts plus mandatory
6
+ JSON sidecars in a flat downstream corpus.
7
+ """
8
+
9
+ __version__ = "1.3.0"
@@ -0,0 +1,245 @@
1
+ """Artifact acquisition: bounded streaming download, validation, atomic publication.
2
+
3
+ MASTER_SPEC sections 14, 17, 37, 38, 39.
4
+
5
+ The invariant enforced here: **a final artifact filename never exists unless the bytes
6
+ behind it were fully downloaded, validated and hashed.** Everything is written to a
7
+ ``.part`` file first and published with a single atomic rename.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import tempfile
15
+ import time
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ from .config import Config
21
+ from .errors import (
22
+ BudgetExhaustedError,
23
+ DownloadError,
24
+ HarvesterError,
25
+ SizeLimitExceededError,
26
+ StorageError,
27
+ )
28
+ from .http import ProviderClient, redact_url
29
+ from .identity import artifact_path, validate_remote_url
30
+ from .models import ArtifactKind, ArtifactRecord, FulltextCandidate
31
+ from .util import coerce_int, utc_now_iso
32
+ from .validation import ValidationResult, content_type_is_plausible, validate_pdf, validate_xml
33
+
34
+ LOGGER = logging.getLogger("harvester.acquisition")
35
+
36
+ PART_SUFFIX = ".part"
37
+ _CHUNK_SIZE = 64 * 1024
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class AcquisitionOutcome:
42
+ artifact: ArtifactRecord
43
+ validation: ValidationResult
44
+ #: How many HTTP attempts this artifact needed (1 = no retry).
45
+ attempts: int = 1
46
+
47
+
48
+ class Acquirer:
49
+ """Downloads and publishes one artifact at a time."""
50
+
51
+ def __init__(self, config: Config, client: ProviderClient) -> None:
52
+ self._config = config
53
+ self._client = client
54
+ self._storage_root = Path(config.storage_root)
55
+
56
+ def acquire(
57
+ self,
58
+ *,
59
+ document_id: str,
60
+ candidate: FulltextCandidate,
61
+ on_attempt: Callable[[int, HarvesterError | None, int | None], None] | None = None,
62
+ ) -> AcquisitionOutcome:
63
+ """Download, validate and atomically publish one candidate.
64
+
65
+ Retryable transport conditions (timeout, reset, 429, 5xx) are retried with the
66
+ configured backoff; a rejected *artifact* is not retried, because the same URL
67
+ will keep returning the same bytes. Raises a :class:`HarvesterError` subclass
68
+ on final failure. The temporary file is always removed, so an interrupted or
69
+ rejected download leaves nothing behind.
70
+ """
71
+ url = validate_remote_url(
72
+ candidate.url, allow_private_hosts=self._config.downloads.allow_private_hosts
73
+ )
74
+ final_path = artifact_path(self._storage_root, document_id, candidate.kind.value)
75
+ final_path.parent.mkdir(parents=True, exist_ok=True)
76
+ max_attempts = max(1, self._config.retry.max_attempts)
77
+
78
+ for attempt in range(1, max_attempts + 1):
79
+ part_path = _new_part_path(final_path)
80
+ try:
81
+ resolved_url, status, content_type, _size = self._download(url, part_path)
82
+ validation = self._validate(candidate.kind, part_path)
83
+ if not content_type_is_plausible(candidate.kind.value, content_type):
84
+ LOGGER.info(
85
+ "artifact %s: content-type %r disagrees with validated %s content",
86
+ document_id,
87
+ content_type,
88
+ candidate.kind.value,
89
+ )
90
+ _publish(part_path, final_path)
91
+ except HarvesterError as exc:
92
+ _discard(part_path)
93
+ if on_attempt is not None:
94
+ on_attempt(attempt, exc, exc.http_status)
95
+ if (
96
+ isinstance(exc, BudgetExhaustedError)
97
+ or not exc.retryable
98
+ or attempt >= max_attempts
99
+ ):
100
+ raise
101
+ self._client.sleep_before_retry(attempt, exc)
102
+ continue
103
+ except BaseException:
104
+ _discard(part_path)
105
+ raise
106
+
107
+ if on_attempt is not None:
108
+ on_attempt(attempt, None, status)
109
+ return AcquisitionOutcome(
110
+ artifact=ArtifactRecord(
111
+ kind=candidate.kind,
112
+ filename=final_path.name,
113
+ sha256=validation.sha256,
114
+ size_bytes=validation.size_bytes,
115
+ retrieved_at=utc_now_iso(),
116
+ source=candidate.source,
117
+ original_url=redact_url(candidate.url),
118
+ resolved_url=redact_url(resolved_url),
119
+ http_status=status,
120
+ content_type=content_type,
121
+ ),
122
+ validation=validation,
123
+ attempts=attempt,
124
+ )
125
+ raise AssertionError("unreachable: the retry loop always returns or raises")
126
+
127
+ # ------------------------------------------------------------------ internals
128
+
129
+ def _download(self, url: str, part_path: Path) -> tuple[str, int, str | None, int]:
130
+ limit = self._config.downloads.max_download_size_bytes
131
+ with self._client.stream("GET", url) as response:
132
+ status = response.status_code
133
+ content_type = response.headers.get("content-type")
134
+ resolved_url = str(response.url)
135
+
136
+ # Reject before transferring anything when the server announces a size we
137
+ # will not accept (MASTER_SPEC section 39).
138
+ declared = coerce_int(response.headers.get("content-length"))
139
+ if declared is not None and declared > limit:
140
+ raise SizeLimitExceededError(
141
+ f"server declared {declared} bytes, above the configured limit of {limit}",
142
+ http_status=status,
143
+ url=redact_url(url),
144
+ )
145
+
146
+ written = 0
147
+ try:
148
+ with open(part_path, "wb") as handle:
149
+ for chunk in response.iter_bytes(_CHUNK_SIZE):
150
+ if not chunk:
151
+ continue
152
+ written += len(chunk)
153
+ # The limit is enforced during transfer too: Content-Length may
154
+ # be absent, wrong, or the response may be chunked.
155
+ if written > limit:
156
+ raise SizeLimitExceededError(
157
+ f"download exceeded the configured limit of {limit} bytes",
158
+ http_status=status,
159
+ url=redact_url(url),
160
+ )
161
+ handle.write(chunk)
162
+ handle.flush()
163
+ os.fsync(handle.fileno())
164
+ except SizeLimitExceededError:
165
+ raise
166
+ except HarvesterError:
167
+ raise
168
+ except OSError as exc:
169
+ raise StorageError(
170
+ f"could not write {part_path.name}: {exc}", url=redact_url(url)
171
+ ) from exc
172
+ except Exception as exc: # transport failure part-way through the stream
173
+ raise DownloadError(
174
+ f"download interrupted after {written} bytes: {exc}",
175
+ http_status=status,
176
+ url=redact_url(url),
177
+ ) from exc
178
+
179
+ if written == 0:
180
+ raise DownloadError(
181
+ "server returned an empty body", http_status=status, url=redact_url(url)
182
+ )
183
+ return resolved_url, status, content_type, written
184
+
185
+ def _validate(self, kind: ArtifactKind, path: Path) -> ValidationResult:
186
+ if kind is ArtifactKind.PDF:
187
+ return validate_pdf(path, min_size_bytes=self._config.downloads.min_pdf_size_bytes)
188
+ return validate_xml(path, min_size_bytes=self._config.downloads.min_xml_size_bytes)
189
+
190
+
191
+ def _new_part_path(final_path: Path) -> Path:
192
+ """A unique temporary path beside the final artifact.
193
+
194
+ Uniqueness matters: two workers must never share a ``.part`` file, and a stale
195
+ ``.part`` left by a killed process must never be appended to or mistaken for
196
+ live work.
197
+ """
198
+ handle, name = tempfile.mkstemp(
199
+ prefix=f"{final_path.name}.", suffix=PART_SUFFIX, dir=str(final_path.parent)
200
+ )
201
+ os.close(handle)
202
+ return Path(name)
203
+
204
+
205
+ def _publish(part_path: Path, final_path: Path) -> None:
206
+ """Atomically move the validated temporary file into place."""
207
+ try:
208
+ os.replace(part_path, final_path)
209
+ except OSError as exc:
210
+ raise StorageError(f"could not publish {final_path.name}: {exc}") from exc
211
+
212
+
213
+ def _discard(part_path: Path) -> None:
214
+ try:
215
+ if part_path.exists():
216
+ part_path.unlink()
217
+ except OSError: # pragma: no cover - best effort cleanup
218
+ LOGGER.warning("could not remove temporary file %s", part_path)
219
+
220
+
221
+ def sweep_stale_parts(storage_root: Path, *, min_age_seconds: float = 300.0) -> list[str]:
222
+ """Remove ``.part`` files left behind by a terminated process.
223
+
224
+ MASTER_SPEC section 17: stale temporary files must be safely detectable and
225
+ recoverable. They carry no validated content, so removing them costs nothing —
226
+ the affected documents are re-acquired from their recorded state.
227
+
228
+ Only files untouched for *min_age_seconds* are removed. A second harvester
229
+ process working against the same corpus may have a download in flight, and its
230
+ temporary file must not be deleted underneath it.
231
+ """
232
+ root = Path(storage_root)
233
+ if not root.exists():
234
+ return []
235
+ cutoff = time.time() - max(0.0, min_age_seconds)
236
+ removed: list[str] = []
237
+ for path in sorted(root.glob(f"*{PART_SUFFIX}")):
238
+ try:
239
+ if path.stat().st_mtime > cutoff:
240
+ continue
241
+ path.unlink()
242
+ removed.append(path.name)
243
+ except OSError: # pragma: no cover - concurrent sweep
244
+ LOGGER.warning("could not remove stale temporary file %s", path)
245
+ return removed