sci-protocol 0.2.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 (80) hide show
  1. pysci/__init__.py +77 -0
  2. pysci/__main__.py +8 -0
  3. pysci/_cli/__init__.py +5 -0
  4. pysci/_cli/checks.py +173 -0
  5. pysci/_cli/clients/__init__.py +0 -0
  6. pysci/_cli/clients/archive.py +456 -0
  7. pysci/_cli/clients/credentials.py +54 -0
  8. pysci/_cli/clients/github.py +734 -0
  9. pysci/_cli/clients/hosting.py +238 -0
  10. pysci/_cli/clients/http.py +481 -0
  11. pysci/_cli/clients/huggingface.py +492 -0
  12. pysci/_cli/clients/index.py +435 -0
  13. pysci/_cli/clients/tsa.py +223 -0
  14. pysci/_cli/commands/__init__.py +44 -0
  15. pysci/_cli/commands/check.py +115 -0
  16. pysci/_cli/commands/claim.py +179 -0
  17. pysci/_cli/commands/common.py +91 -0
  18. pysci/_cli/commands/folders.py +83 -0
  19. pysci/_cli/commands/index.py +51 -0
  20. pysci/_cli/commands/keygen.py +58 -0
  21. pysci/_cli/commands/license.py +75 -0
  22. pysci/_cli/commands/protocol.py +72 -0
  23. pysci/_cli/commands/publish.py +282 -0
  24. pysci/_cli/commands/pull.py +337 -0
  25. pysci/_cli/commands/read.py +364 -0
  26. pysci/_cli/commands/revoke.py +207 -0
  27. pysci/_cli/commands/search.py +400 -0
  28. pysci/_cli/commands/sign.py +284 -0
  29. pysci/_cli/commands/submit.py +172 -0
  30. pysci/_cli/commands/timestamp.py +196 -0
  31. pysci/_cli/commands/verify.py +163 -0
  32. pysci/_cli/config.py +233 -0
  33. pysci/_cli/context.py +79 -0
  34. pysci/_cli/destinations.py +163 -0
  35. pysci/_cli/errors.py +168 -0
  36. pysci/_cli/main.py +229 -0
  37. pysci/_cli/output.py +80 -0
  38. pysci/_cli/retrieval.py +51 -0
  39. pysci/_cli/spec.py +113 -0
  40. pysci/_cli/storage/__init__.py +0 -0
  41. pysci/_cli/storage/catalog.py +371 -0
  42. pysci/_cli/storage/files.py +252 -0
  43. pysci/_cli/storage/inputs.py +205 -0
  44. pysci/_cli/storage/keys.py +132 -0
  45. pysci/_cli/storage/local_publish.py +88 -0
  46. pysci/accounts.py +135 -0
  47. pysci/baseline.py +142 -0
  48. pysci/bundle.py +878 -0
  49. pysci/citations.py +233 -0
  50. pysci/data/freetsa-cacert.pem +45 -0
  51. pysci/data/sci-baseline-LICENSE.txt +457 -0
  52. pysci/data/sci-protocol.md +613 -0
  53. pysci/der.py +146 -0
  54. pysci/discovery.py +461 -0
  55. pysci/draft.py +374 -0
  56. pysci/ed25519.py +153 -0
  57. pysci/errors.py +204 -0
  58. pysci/ids.py +61 -0
  59. pysci/jsonstrict.py +225 -0
  60. pysci/keys.py +247 -0
  61. pysci/manifest.py +302 -0
  62. pysci/metadata.py +406 -0
  63. pysci/paths.py +136 -0
  64. pysci/png.py +217 -0
  65. pysci/py.typed +0 -0
  66. pysci/records.py +384 -0
  67. pysci/rfc3339.py +66 -0
  68. pysci/schemas.py +75 -0
  69. pysci/source.py +166 -0
  70. pysci/specification.py +58 -0
  71. pysci/submission.py +225 -0
  72. pysci/templates.py +129 -0
  73. pysci/text.py +226 -0
  74. pysci/timestamps.py +867 -0
  75. pysci/zipformat.py +400 -0
  76. sci_protocol-0.2.0.dist-info/METADATA +112 -0
  77. sci_protocol-0.2.0.dist-info/RECORD +80 -0
  78. sci_protocol-0.2.0.dist-info/WHEEL +4 -0
  79. sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
  80. sci_protocol-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,456 @@
1
+ """Archive retrieval (§8.2): descriptors, individual files, ZIP ranges, and complete bundles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import tempfile
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pysci.bundle import (
12
+ DEFAULT_LIMITS,
13
+ VerificationLimits,
14
+ VerificationResult,
15
+ read_zip_entry,
16
+ verify_file,
17
+ verify_zip,
18
+ )
19
+ from pysci.discovery import ArchiveDescriptor, parse_archive_descriptor
20
+ from pysci.errors import Incomplete, Issue, Outcome, SciError
21
+ from pysci.manifest import MANIFEST_PATH, SIGNATURE_PATH, TIMESTAMP_SIDECAR_PATH, Manifest
22
+ from pysci.manifest import verify_manifest as _verify_manifest
23
+ from pysci.source import FileSource
24
+ from pysci.zipformat import parse_zip_layout
25
+
26
+ from ..errors import CliError, ErrorRecord, Exit, exit_for_outcome
27
+ from .http import Deadline, HttpClient, NetworkError, status_error
28
+
29
+ DESCRIPTOR_LIMIT = 1 << 20
30
+ BLOCK = 64 * 1024
31
+ MAX_RANGE_REQUESTS = 4096
32
+ _CONTENT_RANGE = re.compile(r"bytes (\d+)-(\d+)/(\d+|\*)")
33
+
34
+
35
+ class SourceFailure(Exception):
36
+ """One route or source could not supply verified bytes."""
37
+
38
+ def __init__(self, record: ErrorRecord, exit_code: int) -> None:
39
+ super().__init__(record.message)
40
+ self.record = record
41
+ self.exit_code = Exit(exit_code)
42
+
43
+
44
+ def _from_cli(exc: CliError, route: str) -> SourceFailure:
45
+ record = exc.records[0]
46
+ return SourceFailure(
47
+ ErrorRecord(record.code, record.message, record.retryable, extra={"route": route}),
48
+ exc.exit_code,
49
+ )
50
+
51
+
52
+ def _from_sci(exc: SciError, route: str) -> SourceFailure:
53
+ issue = exc.issue
54
+ return SourceFailure(
55
+ ErrorRecord(
56
+ issue.code,
57
+ issue.requirement,
58
+ exc.outcome is Outcome.INCOMPLETE,
59
+ file=issue.file,
60
+ field=issue.field,
61
+ observed=issue.observed,
62
+ section=issue.section,
63
+ extra={"route": route},
64
+ ),
65
+ exit_for_outcome(exc.outcome),
66
+ )
67
+
68
+
69
+ class NoRangeSupport(Exception):
70
+ pass
71
+
72
+
73
+ class HttpRangeSource:
74
+ """A :class:`pysci.source.ByteSource` backed by single-range GET requests.
75
+
76
+ Blocks are cached; contiguous missing blocks are fetched together. The
77
+ number of requests is bounded; exceeding it raises ``Incomplete``.
78
+ """
79
+
80
+ def __init__(self, http: HttpClient, url: str, deadline: Deadline) -> None:
81
+ self.http = http
82
+ self.url = url
83
+ self.deadline = deadline
84
+ self.requests = 0
85
+ self._blocks: dict[int, bytes] = {}
86
+ response = self._get(f"bytes=-{BLOCK}")
87
+ start, end, total = self._content_range(
88
+ response.header("content-range"), len(response.body)
89
+ )
90
+ self._size = total
91
+ self._store(start, response.body)
92
+ del end
93
+
94
+ @property
95
+ def size(self) -> int:
96
+ return self._size
97
+
98
+ def _get(self, value: str) -> Any:
99
+ if self.requests >= MAX_RANGE_REQUESTS:
100
+ raise Incomplete(
101
+ Issue(
102
+ "range_request_limit",
103
+ "ZIP range retrieval exceeded the local request limit",
104
+ section="6",
105
+ outcome=Outcome.INCOMPLETE,
106
+ )
107
+ )
108
+ self.requests += 1
109
+ try:
110
+ response = self.http.get(
111
+ self.url, deadline=self.deadline, headers={"Range": value}, max_bytes=8 * BLOCK
112
+ )
113
+ except CliError as exc:
114
+ if exc.code == "response_too_large":
115
+ raise NoRangeSupport() from None
116
+ raise
117
+ if response.status == 200:
118
+ raise NoRangeSupport()
119
+ if response.status != 206:
120
+ raise status_error(response, "bundle range request")
121
+ return response
122
+
123
+ def _content_range(self, header: str | None, length: int) -> tuple[int, int, int]:
124
+ match = _CONTENT_RANGE.fullmatch((header or "").strip())
125
+ if match is None or match.group(3) == "*":
126
+ raise NoRangeSupport()
127
+ start, end, total = int(match.group(1)), int(match.group(2)), int(match.group(3))
128
+ if end < start or end - start + 1 != length or end >= total:
129
+ raise CliError(
130
+ "invalid_range_response",
131
+ "Content-Range does not match the returned bytes",
132
+ Exit.INCOMPLETE,
133
+ retryable=True,
134
+ )
135
+ return start, end, total
136
+
137
+ def _store(self, start: int, data: bytes) -> None:
138
+ # Cache only whole aligned blocks (and the final partial block).
139
+ position = start
140
+ offset = 0
141
+ while offset < len(data):
142
+ block = position // BLOCK
143
+ block_start = block * BLOCK
144
+ block_end = min(block_start + BLOCK, self._size)
145
+ if position == block_start and len(data) - offset >= block_end - block_start:
146
+ self._blocks[block] = data[offset : offset + block_end - block_start]
147
+ offset += block_end - block_start
148
+ position = block_end
149
+ else:
150
+ skip = min(block_end - position, len(data) - offset)
151
+ offset += skip
152
+ position += skip
153
+
154
+ def read_at(self, offset: int, length: int) -> bytes:
155
+ if offset < 0 or length < 0 or offset + length > self._size:
156
+ raise ValueError("read beyond the end of the bundle")
157
+ if length == 0:
158
+ return b""
159
+ first, last = offset // BLOCK, (offset + length - 1) // BLOCK
160
+ block = first
161
+ while block <= last:
162
+ if block in self._blocks:
163
+ block += 1
164
+ continue
165
+ run_end = block
166
+ while run_end + 1 <= last and run_end + 1 not in self._blocks:
167
+ run_end += 1
168
+ start = block * BLOCK
169
+ end = min((run_end + 1) * BLOCK, self._size) - 1
170
+ if (end - start + 1) > 8 * BLOCK:
171
+ end = start + 8 * BLOCK - 1
172
+ run_end = block + 7
173
+ response = self._get(f"bytes={start}-{end}")
174
+ got_start, _, total = self._content_range(
175
+ response.header("content-range"), len(response.body)
176
+ )
177
+ if got_start != start or total != self._size:
178
+ raise CliError(
179
+ "invalid_range_response",
180
+ "range response does not match the request",
181
+ Exit.INCOMPLETE,
182
+ retryable=True,
183
+ )
184
+ self._store(start, response.body)
185
+ block = run_end + 1
186
+ out = b"".join(self._blocks[b] for b in range(first, last + 1))
187
+ begin = offset - first * BLOCK
188
+ return out[begin : begin + length]
189
+
190
+
191
+ @dataclass
192
+ class ArchiveSource:
193
+ """One archive descriptor offering a publication with an expected ID."""
194
+
195
+ http: HttpClient
196
+ descriptor_url: str
197
+ digest: str
198
+ workdir: Path
199
+ limits: VerificationLimits = DEFAULT_LIMITS
200
+ routes: list[str] = field(default_factory=list)
201
+ _descriptor: ArchiveDescriptor | None = None
202
+ _manifest: Manifest | None = None
203
+ _signature: bytes | None = None
204
+ _zip: Path | None = None
205
+ _range: HttpRangeSource | None = None
206
+ _range_failed: bool = False
207
+ _file: FileSource | None = None
208
+
209
+ def close(self) -> None:
210
+ if self._file is not None:
211
+ self._file.close()
212
+ self._file = None
213
+
214
+ def _open_zip(self, path: Path) -> FileSource:
215
+ if self._file is None:
216
+ self._file = FileSource(path)
217
+ return self._file
218
+
219
+ def descriptor(self, deadline: Deadline) -> ArchiveDescriptor:
220
+ if self._descriptor is None:
221
+ try:
222
+ response = self.http.get(
223
+ self.descriptor_url, deadline=deadline, max_bytes=DESCRIPTOR_LIMIT
224
+ )
225
+ except CliError as exc:
226
+ raise _from_cli(exc, "descriptor") from None
227
+ if response.status != 200:
228
+ raise _from_cli(status_error(response, "archive descriptor"), "descriptor")
229
+ try:
230
+ self._descriptor = parse_archive_descriptor(response.body, file=self.descriptor_url)
231
+ except SciError as exc:
232
+ raise _from_sci(exc, "descriptor") from None
233
+ return self._descriptor
234
+
235
+ # ------------------------------------------------------------------ raw routes
236
+
237
+ def _fetch(self, url: str, deadline: Deadline, max_bytes: int, route: str) -> bytes | None:
238
+ try:
239
+ response = self.http.get(url, deadline=deadline, max_bytes=max_bytes)
240
+ except CliError as exc:
241
+ if exc.code == "response_too_large":
242
+ raise SourceFailure(
243
+ ErrorRecord(
244
+ "file_size_mismatch",
245
+ "retrieved file is larger than its manifest entry",
246
+ extra={"route": route},
247
+ ),
248
+ Exit.INVALID,
249
+ ) from None
250
+ raise _from_cli(exc, route) from None
251
+ if response.status == 404:
252
+ return None
253
+ if response.status != 200:
254
+ raise _from_cli(status_error(response, route), route)
255
+ return response.body
256
+
257
+ def entry(self, path: str, deadline: Deadline, max_bytes: int) -> bytes | None:
258
+ url = self.descriptor(deadline).entry(self.digest, path)
259
+ if url is None:
260
+ return None
261
+ return self._fetch(url, deadline, max_bytes, "entry_url")
262
+
263
+ # ------------------------------------------------------------------ manifest
264
+
265
+ def manifest(self, deadline: Deadline) -> Manifest:
266
+ """Verified manifest (§6.1): entry routes, then ZIP ranges, then the complete ZIP."""
267
+ if self._manifest is not None:
268
+ return self._manifest
269
+ failures: list[SourceFailure] = []
270
+ descriptor = self.descriptor(deadline)
271
+ if descriptor.entry_url is not None:
272
+ try:
273
+ manifest_bytes = self.entry(MANIFEST_PATH, deadline, self.limits.max_manifest_bytes)
274
+ signature = self.entry(SIGNATURE_PATH, deadline, 65)
275
+ if manifest_bytes is not None and signature is not None:
276
+ self._manifest = self._verify(manifest_bytes, signature, "entry_url")
277
+ self._signature = signature
278
+ self.routes.append("entry_url")
279
+ return self._manifest
280
+ except SourceFailure as exc:
281
+ failures.append(exc)
282
+ if descriptor.bundle_url is not None:
283
+ try:
284
+ source = self._zip_source(deadline)
285
+ if source is not None:
286
+ entries = parse_zip_layout(source)
287
+ manifest_bytes = source.read_at(entries[0].data_offset, entries[0].size)
288
+ signature = source.read_at(entries[1].data_offset, min(entries[1].size, 65))
289
+ self._manifest = self._verify(manifest_bytes, signature, "bundle_url")
290
+ self._signature = signature
291
+ return self._manifest
292
+ except SciError as exc:
293
+ failures.append(_from_sci(exc, "bundle_url"))
294
+ except SourceFailure as exc:
295
+ failures.append(exc)
296
+ except CliError as exc:
297
+ failures.append(_from_cli(exc, "bundle_url"))
298
+ if failures:
299
+ raise failures[-1]
300
+ raise SourceFailure(
301
+ ErrorRecord(
302
+ "not_available",
303
+ "the archive does not serve this publication's manifest",
304
+ extra={"descriptor": self.descriptor_url},
305
+ ),
306
+ Exit.INCOMPLETE,
307
+ )
308
+
309
+ def _verify(self, manifest_bytes: bytes, signature: bytes, route: str) -> Manifest:
310
+ try:
311
+ return _verify_manifest(manifest_bytes, signature, expected_id=self.digest)
312
+ except SciError as exc:
313
+ raise _from_sci(exc, route) from None
314
+
315
+ def signature(self, deadline: Deadline) -> bytes:
316
+ self.manifest(deadline)
317
+ assert self._signature is not None
318
+ return self._signature
319
+
320
+ # ------------------------------------------------------------------ ZIP access
321
+
322
+ def _zip_source(self, deadline: Deadline) -> HttpRangeSource | FileSource | None:
323
+ if self._zip is not None:
324
+ return self._open_zip(self._zip)
325
+ url = self.descriptor(deadline).bundle(self.digest)
326
+ if url is None:
327
+ return None
328
+ if self._range is not None:
329
+ return self._range
330
+ if not self._range_failed:
331
+ try:
332
+ self._range = HttpRangeSource(self.http, url, deadline)
333
+ self.routes.append("bundle_url ranges")
334
+ return self._range
335
+ except NoRangeSupport:
336
+ self._range_failed = True
337
+ path = self.download_zip(deadline)
338
+ return self._open_zip(path) if path is not None else None
339
+
340
+ def download_zip(self, deadline: Deadline) -> Path | None:
341
+ """Download the complete ZIP to a temporary file (within the input limit)."""
342
+ if self._zip is not None:
343
+ return self._zip
344
+ url = self.descriptor(deadline).bundle(self.digest)
345
+ if url is None:
346
+ return None
347
+ handle = tempfile.NamedTemporaryFile( # noqa: SIM115 - kept for the operation
348
+ dir=self.workdir, prefix=".sci-tmp-", suffix=".zip", delete=False
349
+ )
350
+ try:
351
+ response = self.http.get(
352
+ url,
353
+ deadline=deadline,
354
+ sink=handle,
355
+ max_bytes=self.limits.max_input_bytes or (1 << 40),
356
+ )
357
+ except CliError as exc:
358
+ handle.close()
359
+ raise _from_cli(exc, "bundle_url") from None
360
+ handle.close()
361
+ if response.status == 404:
362
+ return None
363
+ if response.status != 200:
364
+ raise _from_cli(status_error(response, "bundle download"), "bundle_url")
365
+ self._zip = Path(handle.name)
366
+ self.routes.append("bundle_url")
367
+ return self._zip
368
+
369
+ def verify_complete_zip(self, deadline: Deadline) -> tuple[Path, VerificationResult] | None:
370
+ path = self.download_zip(deadline)
371
+ if path is None:
372
+ return None
373
+ result = verify_zip(path, expected_id=self.digest, limits=self.limits)
374
+ return path, result
375
+
376
+ # ------------------------------------------------------------------ files
377
+
378
+ def file(self, path: str, deadline: Deadline) -> bytes:
379
+ """One listed file after §6.1 and §6.2: entry/blob routes, ZIP ranges, complete ZIP."""
380
+ manifest = self.manifest(deadline)
381
+ entry = manifest.files.get(path)
382
+ if entry is None:
383
+ raise SourceFailure(
384
+ ErrorRecord(
385
+ "file_not_listed",
386
+ "requested path must be a manifest files key",
387
+ file=path,
388
+ section="6.2",
389
+ ),
390
+ Exit.INVALID,
391
+ )
392
+ descriptor = self.descriptor(deadline)
393
+ failures: list[SourceFailure] = []
394
+ routes: list[tuple[str, str | None]] = [
395
+ ("entry_url", descriptor.entry(self.digest, path)),
396
+ ("blob_url", descriptor.blob(entry.sha256)),
397
+ ]
398
+ for route, url in routes:
399
+ if url is None:
400
+ continue
401
+ try:
402
+ data = self._fetch(url, deadline, entry.bytes, route)
403
+ if data is None:
404
+ continue
405
+ verify_file(manifest, path, data)
406
+ self.routes.append(route)
407
+ return data
408
+ except SciError as exc:
409
+ failures.append(_from_sci(exc, route))
410
+ except SourceFailure as exc:
411
+ failures.append(exc)
412
+ if descriptor.bundle_url is not None:
413
+ try:
414
+ source = self._zip_source(deadline)
415
+ if source is not None:
416
+ _, data = read_zip_entry(source, path, expected_id=self.digest)
417
+ return data
418
+ except SciError as exc:
419
+ failures.append(_from_sci(exc, "bundle_url"))
420
+ except SourceFailure as exc:
421
+ failures.append(exc)
422
+ except CliError as exc:
423
+ failures.append(_from_cli(exc, "bundle_url"))
424
+ if failures:
425
+ raise failures[-1]
426
+ raise SourceFailure(
427
+ ErrorRecord("not_available", "no route serves this file", file=path),
428
+ Exit.INCOMPLETE,
429
+ )
430
+
431
+ def sidecar(self, deadline: Deadline) -> bytes | None:
432
+ try:
433
+ return self.entry(TIMESTAMP_SIDECAR_PATH, deadline, 16 << 20)
434
+ except SourceFailure:
435
+ return None
436
+
437
+
438
+ def failure_exit(failures: list[SourceFailure]) -> Exit:
439
+ """No source succeeded: invalid only if every source served failing content."""
440
+ codes = {f.exit_code for f in failures}
441
+ if not failures:
442
+ return Exit.INCOMPLETE
443
+ if codes == {Exit.INVALID}:
444
+ return Exit.INVALID
445
+ if codes == {Exit.UNSUPPORTED}:
446
+ return Exit.UNSUPPORTED
447
+ return Exit.INCOMPLETE
448
+
449
+
450
+ __all__ = [
451
+ "ArchiveSource",
452
+ "HttpRangeSource",
453
+ "NetworkError",
454
+ "SourceFailure",
455
+ "failure_exit",
456
+ ]
@@ -0,0 +1,54 @@
1
+ """Provider credentials: GitHub (``gh auth token``, ``GH_TOKEN``) and Hugging Face tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping, Sequence
6
+ from pathlib import Path
7
+
8
+ from ..errors import CliError, Exit
9
+
10
+ Runner = Callable[[Sequence[str], float], "str | None"]
11
+
12
+
13
+ def github_token(env: Mapping[str, str], runner: Runner) -> str:
14
+ """``GH_TOKEN``, ``GITHUB_TOKEN``, or the token from ``gh auth token``."""
15
+ for name in ("GH_TOKEN", "GITHUB_TOKEN"):
16
+ value = env.get(name, "").strip()
17
+ if value:
18
+ return value
19
+ token = runner(["gh", "auth", "token"], 15.0)
20
+ if token:
21
+ return token
22
+ raise CliError(
23
+ "missing_credentials",
24
+ "GitHub credentials not found: run `gh auth login` or set GH_TOKEN",
25
+ Exit.USAGE,
26
+ )
27
+
28
+
29
+ def huggingface_token(env: Mapping[str, str]) -> str:
30
+ """``HF_TOKEN``, or the token stored by ``hf auth login``."""
31
+ for name in ("HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"):
32
+ value = env.get(name, "").strip()
33
+ if value:
34
+ return value
35
+ candidates: list[Path] = []
36
+ if env.get("HF_TOKEN_PATH"):
37
+ candidates.append(Path(env["HF_TOKEN_PATH"]).expanduser())
38
+ if env.get("HF_HOME"):
39
+ candidates.append(Path(env["HF_HOME"]).expanduser() / "token")
40
+ home = env.get("HOME")
41
+ if home:
42
+ candidates.append(Path(home) / ".cache" / "huggingface" / "token")
43
+ for path in candidates:
44
+ try:
45
+ token = path.read_text(encoding="utf-8").strip()
46
+ except OSError:
47
+ continue
48
+ if token:
49
+ return token
50
+ raise CliError(
51
+ "missing_credentials",
52
+ "Hugging Face credentials not found: run `hf auth login` or set HF_TOKEN",
53
+ Exit.USAGE,
54
+ )