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,481 @@
1
+ """HTTP with injectable transports, operation deadlines, bounded retries, and pacing.
2
+
3
+ The default transport uses :mod:`urllib.request`. Tests and embedding
4
+ programs pass any object implementing :class:`Transport`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import email.utils
11
+ import io
12
+ import json
13
+ import os
14
+ import random
15
+ import secrets
16
+ import socket
17
+ import time
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ from collections.abc import Callable, Mapping
22
+ from dataclasses import dataclass, field
23
+ from datetime import UTC, datetime
24
+ from pathlib import Path
25
+ from typing import Any, BinaryIO, Protocol
26
+
27
+ from pysci.errors import SciError
28
+ from pysci.jsonstrict import JsonValue, parse_json
29
+
30
+ from ..errors import CliError, Exit
31
+
32
+ READ_CHUNK = 1 << 16
33
+ DEFAULT_TIMEOUT = 120.0
34
+ RETRY_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
35
+ MAX_ATTEMPTS = 6
36
+ BACKOFF_BASE = 0.5
37
+ BACKOFF_CAP = 20.0
38
+
39
+
40
+ class Stream(Protocol):
41
+ def read(self, size: int = -1, /) -> bytes: ...
42
+
43
+ def close(self) -> None: ...
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class HttpRequest:
48
+ method: str
49
+ url: str
50
+ headers: dict[str, str] = field(default_factory=dict)
51
+ body: bytes | Path | None = None
52
+ unredirected: dict[str, str] = field(default_factory=dict)
53
+ """Headers (e.g. credentials) that must not follow redirects to other hosts."""
54
+
55
+ def body_bytes(self) -> bytes:
56
+ if self.body is None:
57
+ return b""
58
+ if isinstance(self.body, Path):
59
+ return self.body.read_bytes()
60
+ return self.body
61
+
62
+
63
+ @dataclass(slots=True)
64
+ class RawResponse:
65
+ status: int
66
+ headers: dict[str, str]
67
+ """Lower-cased header names."""
68
+ stream: Stream
69
+ url: str = ""
70
+
71
+
72
+ class TransportError(Exception):
73
+ """The request could not be completed (connection, DNS, TLS, or timeout)."""
74
+
75
+ def __init__(self, message: str, *, sent: bool = True) -> None:
76
+ super().__init__(message)
77
+ self.sent = sent
78
+
79
+
80
+ class Transport(Protocol):
81
+ def send(self, request: HttpRequest, timeout: float) -> RawResponse: ...
82
+
83
+
84
+ class UrllibTransport:
85
+ """Transport over :mod:`urllib.request`. Follows redirects for GET/HEAD.
86
+
87
+ Credentials go into unredirected headers, so they are not forwarded to a
88
+ redirect target.
89
+ """
90
+
91
+ def __init__(self) -> None:
92
+ self._opener = urllib.request.build_opener()
93
+
94
+ def send(self, request: HttpRequest, timeout: float) -> RawResponse:
95
+ data: Any = None
96
+ headers = dict(request.headers)
97
+ if isinstance(request.body, Path):
98
+ data = open(request.body, "rb") # noqa: SIM115 - closed below
99
+ headers["Content-Length"] = str(request.body.stat().st_size)
100
+ elif request.body is not None:
101
+ data = request.body
102
+ req = urllib.request.Request(request.url, data=data, headers=headers, method=request.method)
103
+ for name, value in request.unredirected.items():
104
+ req.add_unredirected_header(name, value)
105
+ try:
106
+ response = self._opener.open(req, timeout=max(timeout, 0.001))
107
+ return RawResponse(
108
+ response.status,
109
+ {k.lower(): v for k, v in response.headers.items()},
110
+ response,
111
+ response.url,
112
+ )
113
+ except urllib.error.HTTPError as exc:
114
+ return RawResponse(
115
+ exc.code,
116
+ {k.lower(): v for k, v in (exc.headers or {}).items()},
117
+ exc if exc.fp is not None else io.BytesIO(b""),
118
+ request.url,
119
+ )
120
+ except (TimeoutError, urllib.error.URLError, OSError, ValueError) as exc:
121
+ reason = getattr(exc, "reason", exc)
122
+ sent = not isinstance(reason, socket.gaierror | ConnectionRefusedError)
123
+ raise TransportError(f"{type(reason).__name__}: {reason}", sent=sent) from None
124
+ finally:
125
+ if hasattr(data, "close"):
126
+ data.close()
127
+
128
+
129
+ # --------------------------------------------------------------------------- deadline
130
+
131
+
132
+ class Deadline:
133
+ """An operation deadline including retries (``--timeout``)."""
134
+
135
+ def __init__(self, seconds: float, clock: Callable[[], float] = time.monotonic) -> None:
136
+ self.seconds = seconds
137
+ self._clock = clock
138
+ self._end = clock() + seconds
139
+
140
+ def remaining(self) -> float:
141
+ return self._end - self._clock()
142
+
143
+ def expired(self) -> bool:
144
+ return self.remaining() <= 0
145
+
146
+ def extend(self, seconds: float) -> None:
147
+ """Move the deadline later, for time spent in a wait that is not the operation's."""
148
+ self._end += seconds
149
+
150
+ def check(self, what: str) -> float:
151
+ left = self.remaining()
152
+ if left <= 0:
153
+ raise CliError(
154
+ "deadline_exceeded",
155
+ f"operation deadline of {self.seconds:g}s exceeded during {what}",
156
+ Exit.INCOMPLETE,
157
+ retryable=True,
158
+ )
159
+ return left
160
+
161
+
162
+ # --------------------------------------------------------------------------- pacing
163
+
164
+
165
+ class Pacer:
166
+ """At most one paced request per ``interval`` seconds across processes.
167
+
168
+ Applies to timestamp authority requests and index writes from this
169
+ machine (FreeTSA usage policy: one request per second). The last request
170
+ time is kept in ``<state>/pace`` under an advisory lock.
171
+ """
172
+
173
+ interval: float = 1.0
174
+
175
+ def __init__(
176
+ self,
177
+ path: Path | None,
178
+ *,
179
+ sleep: Callable[[float], None] = time.sleep,
180
+ wall: Callable[[], float] = time.time,
181
+ ) -> None:
182
+ self.path = path
183
+ self._sleep = sleep
184
+ self._wall = wall
185
+ self._last = 0.0
186
+
187
+ def wait(self) -> None:
188
+ if self.path is None:
189
+ self._wait_local()
190
+ return
191
+ try:
192
+ self.path.parent.mkdir(parents=True, exist_ok=True)
193
+ fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o600)
194
+ except OSError:
195
+ self._wait_local()
196
+ return
197
+ try:
198
+ _lock(fd)
199
+ os.lseek(fd, 0, os.SEEK_SET)
200
+ raw = os.read(fd, 64)
201
+ try:
202
+ last = float(raw.decode("ascii").strip() or 0)
203
+ except ValueError:
204
+ last = 0.0
205
+ last = max(last, self._last)
206
+ delay = last + self.interval - self._wall()
207
+ if delay > 0:
208
+ self._sleep(min(delay, self.interval))
209
+ now = f"{self._wall():.6f}".encode("ascii")
210
+ os.ftruncate(fd, 0)
211
+ os.lseek(fd, 0, os.SEEK_SET)
212
+ os.write(fd, now)
213
+ self._last = self._wall()
214
+ finally:
215
+ os.close(fd)
216
+
217
+ def _wait_local(self) -> None:
218
+ delay = self._last + self.interval - self._wall()
219
+ if delay > 0:
220
+ self._sleep(min(delay, self.interval))
221
+ self._last = self._wall()
222
+
223
+
224
+ def _lock(fd: int) -> None:
225
+ try:
226
+ import fcntl
227
+ except ImportError: # pragma: no cover - Windows: in-process pacing only
228
+ return
229
+ fcntl.flock(fd, fcntl.LOCK_EX)
230
+
231
+
232
+ # --------------------------------------------------------------------------- client
233
+
234
+
235
+ @dataclass(slots=True)
236
+ class Response:
237
+ status: int
238
+ headers: dict[str, str]
239
+ body: bytes
240
+ url: str
241
+ attempts: int = 1
242
+
243
+ def header(self, name: str) -> str | None:
244
+ return self.headers.get(name.lower())
245
+
246
+ @property
247
+ def content_type(self) -> str:
248
+ return (self.header("content-type") or "").split(";", 1)[0].strip().lower()
249
+
250
+ def json(self, what: str) -> JsonValue:
251
+ try:
252
+ return parse_json(self.body, file=what)
253
+ except SciError as exc:
254
+ raise CliError(
255
+ "invalid_response",
256
+ f"{what} is not valid JSON: {exc.issue.requirement}",
257
+ Exit.INVALID,
258
+ url=self.url,
259
+ ) from None
260
+
261
+ def json_object(self, what: str) -> dict[str, JsonValue]:
262
+ value = self.json(what)
263
+ if not isinstance(value, dict):
264
+ raise CliError(
265
+ "invalid_response", f"{what} must be a JSON object", Exit.INVALID, url=self.url
266
+ )
267
+ return value
268
+
269
+
270
+ class ResponseTooLarge(Exception):
271
+ pass
272
+
273
+
274
+ class NetworkError(CliError):
275
+ """A request failed at the transport level after the permitted retries."""
276
+
277
+ def __init__(self, url: str, message: str, *, lost_response: bool) -> None:
278
+ super().__init__(
279
+ "network_error",
280
+ f"request to {_redact(url)} failed: {message}",
281
+ Exit.INCOMPLETE,
282
+ retryable=True,
283
+ url=_redact(url),
284
+ )
285
+ self.lost_response = lost_response
286
+
287
+
288
+ def _redact(url: str) -> str:
289
+ parts = urllib.parse.urlsplit(url)
290
+ return urllib.parse.urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
291
+
292
+
293
+ def retry_after_seconds(value: str | None, now: datetime | None = None) -> float | None:
294
+ """Parse ``Retry-After`` (delta-seconds or HTTP-date)."""
295
+ if not value:
296
+ return None
297
+ value = value.strip()
298
+ if value.isdigit():
299
+ return float(value)
300
+ try:
301
+ when = email.utils.parsedate_to_datetime(value)
302
+ except (TypeError, ValueError):
303
+ return None
304
+ if when.tzinfo is None:
305
+ when = when.replace(tzinfo=UTC)
306
+ return max(0.0, (when - (now or datetime.now(UTC))).total_seconds())
307
+
308
+
309
+ class HttpClient:
310
+ """Requests with deadlines, retries (backoff, jitter, Retry-After), and limits."""
311
+
312
+ def __init__(
313
+ self,
314
+ transport: Transport,
315
+ *,
316
+ user_agent: str,
317
+ pacer: Pacer | None = None,
318
+ sleep: Callable[[float], None] = time.sleep,
319
+ rng: random.Random | None = None,
320
+ ) -> None:
321
+ self.transport = transport
322
+ self.user_agent = user_agent
323
+ self.pacer = pacer or Pacer(None, sleep=sleep)
324
+ self.sleep = sleep
325
+ self.rng = rng or random.Random(secrets.randbits(64))
326
+
327
+ def request(
328
+ self,
329
+ method: str,
330
+ url: str,
331
+ *,
332
+ deadline: Deadline,
333
+ headers: Mapping[str, str] | None = None,
334
+ body: bytes | Path | None = None,
335
+ auth: str | None = None,
336
+ max_bytes: int = 1 << 20,
337
+ sink: BinaryIO | None = None,
338
+ retry: bool = True,
339
+ paced: bool = False,
340
+ retry_statuses: frozenset[int] = RETRY_STATUSES,
341
+ should_retry: Callable[[Response], bool] | None = None,
342
+ ) -> Response:
343
+ """Send a request; return the final response (any status).
344
+
345
+ Temporary failures (transport errors and ``retry_statuses``) are
346
+ retried with bounded exponential backoff and jitter, honouring
347
+ ``Retry-After``, until the attempt limit or the deadline. Policy and
348
+ validation refusals (other statuses) are returned without retry.
349
+ With ``sink`` the body is streamed there instead of into memory.
350
+
351
+ Raises:
352
+ NetworkError: the transport failed on the last permitted attempt.
353
+ CliError: the response exceeded ``max_bytes`` (exit 5).
354
+ """
355
+ attempt = 0
356
+ all_headers = {"User-Agent": self.user_agent, **(headers or {})}
357
+ unredirected = {"Authorization": auth} if auth else {}
358
+ while True:
359
+ attempt += 1
360
+ left = deadline.check(f"{method} {_redact(url)}")
361
+ if paced:
362
+ self.pacer.wait()
363
+ left = deadline.check(f"{method} {_redact(url)}")
364
+ request = HttpRequest(method, url, dict(all_headers), body, dict(unredirected))
365
+ try:
366
+ raw = self.transport.send(request, left)
367
+ except TransportError as exc:
368
+ if not retry or attempt >= MAX_ATTEMPTS:
369
+ raise NetworkError(url, str(exc), lost_response=exc.sent) from None
370
+ if not self._backoff(attempt, None, deadline):
371
+ raise NetworkError(url, str(exc), lost_response=exc.sent) from None
372
+ continue
373
+ try:
374
+ response = self._read(raw, url, max_bytes, sink, attempt)
375
+ except TransportError as exc:
376
+ if (
377
+ not retry
378
+ or attempt >= MAX_ATTEMPTS
379
+ or not self._backoff(attempt, None, deadline)
380
+ ):
381
+ raise NetworkError(url, str(exc), lost_response=True) from None
382
+ continue
383
+ temporary = response.status in retry_statuses or (
384
+ should_retry is not None and should_retry(response)
385
+ )
386
+ if not temporary or not retry or attempt >= MAX_ATTEMPTS:
387
+ return response
388
+ wait = retry_after_seconds(response.header("retry-after"))
389
+ if not self._backoff(attempt, wait, deadline):
390
+ return response
391
+
392
+ def _backoff(self, attempt: int, retry_after: float | None, deadline: Deadline) -> bool:
393
+ delay = min(BACKOFF_CAP, BACKOFF_BASE * 2 ** (attempt - 1))
394
+ delay = self.rng.uniform(delay / 2, delay)
395
+ if retry_after is not None:
396
+ delay = retry_after + self.rng.uniform(0, 0.25)
397
+ if delay >= deadline.remaining():
398
+ return False
399
+ self.sleep(delay)
400
+ return True
401
+
402
+ def _read(
403
+ self, raw: RawResponse, url: str, max_bytes: int, sink: BinaryIO | None, attempt: int
404
+ ) -> Response:
405
+ total = 0
406
+ chunks: list[bytes] = []
407
+ if sink is not None:
408
+ sink.seek(0)
409
+ sink.truncate()
410
+ stream_ok = 200 <= raw.status < 300
411
+ try:
412
+ while True:
413
+ try:
414
+ chunk = raw.stream.read(READ_CHUNK)
415
+ except (TimeoutError, OSError, ValueError) as exc:
416
+ raise TransportError(f"response interrupted: {exc}") from None
417
+ if not chunk:
418
+ break
419
+ total += len(chunk)
420
+ limit = max_bytes if stream_ok else min(max_bytes, 1 << 20)
421
+ if total > limit:
422
+ raise CliError(
423
+ "response_too_large",
424
+ f"response from {_redact(url)} exceeds the local limit of {limit} bytes",
425
+ Exit.INCOMPLETE,
426
+ url=_redact(url),
427
+ )
428
+ if sink is not None and stream_ok:
429
+ sink.write(chunk)
430
+ else:
431
+ chunks.append(chunk)
432
+ finally:
433
+ with contextlib.suppress(Exception):
434
+ raw.stream.close()
435
+ return Response(raw.status, raw.headers, b"".join(chunks), raw.url or url, attempt)
436
+
437
+ # convenience -------------------------------------------------------------
438
+
439
+ def get(self, url: str, *, deadline: Deadline, **kwargs: Any) -> Response:
440
+ return self.request("GET", url, deadline=deadline, **kwargs)
441
+
442
+ def post_json(
443
+ self, url: str, document: object, *, deadline: Deadline, **kwargs: Any
444
+ ) -> Response:
445
+ body = json.dumps(document, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
446
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
447
+ headers.update(kwargs.pop("headers", {}) or {})
448
+ return self.request("POST", url, deadline=deadline, headers=headers, body=body, **kwargs)
449
+
450
+
451
+ def multipart(parts: list[tuple[str, str | None, bytes, str]]) -> tuple[bytes, str]:
452
+ """Encode ``(name, filename, data, content_type)`` parts as multipart/form-data."""
453
+ boundary = "sci-" + secrets.token_hex(16)
454
+ out = io.BytesIO()
455
+ for name, filename, data, content_type in parts:
456
+ out.write(f"--{boundary}\r\n".encode("ascii"))
457
+ disposition = f'form-data; name="{name}"'
458
+ if filename is not None:
459
+ disposition += f'; filename="{filename}"'
460
+ out.write(f"Content-Disposition: {disposition}\r\n".encode("ascii"))
461
+ out.write(f"Content-Type: {content_type}\r\n\r\n".encode("ascii"))
462
+ out.write(data)
463
+ out.write(b"\r\n")
464
+ out.write(f"--{boundary}--\r\n".encode("ascii"))
465
+ return out.getvalue(), f"multipart/form-data; boundary={boundary}"
466
+
467
+
468
+ def status_error(response: Response, what: str) -> CliError:
469
+ """A CliError for an unexpected HTTP status (5xx/429 temporary, others invalid)."""
470
+ temporary = response.status in RETRY_STATUSES
471
+ record: dict[str, Any] = {"status": response.status, "url": _redact(response.url)}
472
+ retry_after = response.header("retry-after")
473
+ if retry_after:
474
+ record["retry_after"] = retry_after
475
+ return CliError(
476
+ "temporary_failure" if temporary else "unexpected_status",
477
+ f"{what} returned HTTP {response.status}",
478
+ Exit.INCOMPLETE if temporary else Exit.INVALID,
479
+ retryable=temporary,
480
+ **record,
481
+ )