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,238 @@
1
+ """Shared logic for hosted archives (GitHub, Hugging Face): layout, planning, and checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from pysci.discovery import parse_publication_list
13
+ from pysci.errors import SciError
14
+ from pysci.manifest import TIMESTAMP_SIDECAR_PATH
15
+
16
+ from ..errors import CliError, ErrorRecord, Exit
17
+ from ..storage.inputs import SignedInput
18
+ from .http import Deadline, HttpClient
19
+ from .tsa import merge_proofs
20
+
21
+ ARCHIVE_PATH = "sci/archive.json"
22
+ LIST_PATH = "sci/publications.txt"
23
+
24
+
25
+ def publication_prefix(digest: str) -> str:
26
+ return f"sci/publications/{digest[:2]}/{digest}"
27
+
28
+
29
+ def git_blob_sha1(data: bytes) -> str:
30
+ return hashlib.sha1(b"blob %d\0" % len(data) + data, usedforsecurity=False).hexdigest()
31
+
32
+
33
+ def descriptor_bytes(descriptor: dict[str, str]) -> bytes:
34
+ return json.dumps(descriptor, indent=2).encode("utf-8") + b"\n"
35
+
36
+
37
+ @dataclass
38
+ class Prepared:
39
+ """A verified publication ready for upload."""
40
+
41
+ item: SignedInput
42
+ zip_path: Path
43
+ reader: dict[str, bytes]
44
+ """Reader files keyed by bundle path, plus the sidecar if available."""
45
+
46
+ @property
47
+ def digest(self) -> str:
48
+ return self.item.digest
49
+
50
+ @property
51
+ def zip_sha256(self) -> str:
52
+ return self.item.zip_sha256
53
+
54
+ @property
55
+ def zip_size(self) -> int:
56
+ return self.zip_path.stat().st_size
57
+
58
+ def remote_files(self) -> dict[str, bytes]:
59
+ prefix = publication_prefix(self.digest)
60
+ return {f"{prefix}/{path}": data for path, data in self.reader.items()}
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class RemoteFile:
65
+ """What a provider reports about an existing file."""
66
+
67
+ size: int | None = None
68
+ git_sha1: str | None = None
69
+ sha256: str | None = None
70
+
71
+
72
+ def matches(remote: RemoteFile, data: bytes) -> bool | None:
73
+ """True/False if a digest decides equality; None if the content must be downloaded."""
74
+ if remote.sha256 is not None:
75
+ return remote.sha256 == hashlib.sha256(data).hexdigest()
76
+ if remote.git_sha1 is not None:
77
+ return remote.git_sha1 == git_blob_sha1(data)
78
+ if remote.size is not None and remote.size != len(data):
79
+ return False
80
+ return None
81
+
82
+
83
+ @dataclass
84
+ class ItemReport:
85
+ publication_id: str
86
+ status: str = "pending"
87
+ reused: list[str] = field(default_factory=list)
88
+ uploaded: list[str] = field(default_factory=list)
89
+ bundle: str | None = None
90
+ error: ErrorRecord | None = None
91
+ exit_code: int = Exit.OK
92
+ warnings: list[str] = field(default_factory=list)
93
+
94
+ def fail(self, record: ErrorRecord, exit_code: int) -> None:
95
+ self.status = "failed"
96
+ self.error = record
97
+ self.exit_code = exit_code
98
+
99
+ def to_json(self) -> dict[str, Any]:
100
+ out: dict[str, Any] = {
101
+ "publication_id": self.publication_id,
102
+ "status": self.status,
103
+ "reader_files_uploaded": self.uploaded,
104
+ "reader_files_reused": self.reused,
105
+ "bundle": self.bundle,
106
+ }
107
+ if self.error is not None:
108
+ out["error"] = self.error.to_json()
109
+ if self.warnings:
110
+ out["warnings"] = self.warnings
111
+ return out
112
+
113
+
114
+ def plan_reader_files(
115
+ pub: Prepared,
116
+ remote: dict[str, RemoteFile],
117
+ download: Callable[[str], bytes | None],
118
+ report: ItemReport,
119
+ ) -> dict[str, bytes]:
120
+ """Files to write for one publication; reuse matches, merge sidecars, flag conflicts."""
121
+ writes: dict[str, bytes] = {}
122
+ conflicts: list[str] = []
123
+ for path, data in pub.remote_files().items():
124
+ existing = remote.get(path)
125
+ if existing is None:
126
+ writes[path] = data
127
+ continue
128
+ same = matches(existing, data)
129
+ current: bytes | None = None
130
+ if same is None:
131
+ current = download(path)
132
+ same = current == data
133
+ if same:
134
+ report.reused.append(path)
135
+ continue
136
+ if path.endswith("/" + TIMESTAMP_SIDECAR_PATH):
137
+ current = current if current is not None else download(path)
138
+ merged, problems = merge_proofs(current or b"", data)
139
+ if problems:
140
+ report.warnings.append(
141
+ f"remote timestamp sidecar {path} is malformed and was preserved"
142
+ )
143
+ elif merged != current:
144
+ writes[path] = merged
145
+ else:
146
+ report.reused.append(path)
147
+ continue
148
+ conflicts.append(path)
149
+ if conflicts:
150
+ report.fail(
151
+ ErrorRecord(
152
+ "destination_conflict",
153
+ "the repository already holds different bytes at "
154
+ + ", ".join(conflicts[:3])
155
+ + ("..." if len(conflicts) > 3 else ""),
156
+ ),
157
+ Exit.INVALID,
158
+ )
159
+ return {}
160
+ return writes
161
+
162
+
163
+ def check_descriptor(existing: bytes | None, wanted: dict[str, str]) -> bool:
164
+ """True if ``sci/archive.json`` must be written; raise on a conflicting descriptor."""
165
+ if existing is None:
166
+ return True
167
+ try:
168
+ record = json.loads(existing)
169
+ except ValueError:
170
+ record = None
171
+ if record == wanted:
172
+ return False
173
+ raise CliError(
174
+ "destination_conflict",
175
+ f"{ARCHIVE_PATH} exists with different routes; it was left unchanged",
176
+ Exit.INVALID,
177
+ file=ARCHIVE_PATH,
178
+ observed=existing[:200].decode("utf-8", "replace"),
179
+ )
180
+
181
+
182
+ def merged_list(existing: bytes | None, completed: list[str]) -> bytes:
183
+ """Sorted ``sci/publications.txt`` with completed uploads added."""
184
+ ids: set[str] = set()
185
+ if existing:
186
+ try:
187
+ ids.update(parse_publication_list(existing))
188
+ except SciError:
189
+ raise CliError(
190
+ "destination_conflict",
191
+ f"{LIST_PATH} is malformed; it was left unchanged",
192
+ Exit.INVALID,
193
+ file=LIST_PATH,
194
+ ) from None
195
+ ids.update(completed)
196
+ return "".join(f"{digest}\n" for digest in sorted(ids)).encode("ascii")
197
+
198
+
199
+ def confirm_public(
200
+ http: HttpClient,
201
+ checks: list[tuple[str, str, bytes | None]],
202
+ deadline: Deadline,
203
+ ) -> dict[str, Any]:
204
+ """Unauthenticated GETs of public URLs: availability, exact bytes, and CORS.
205
+
206
+ ``checks`` holds ``(label, url, expected bytes or None)``; None checks
207
+ reachability only (a one-byte range request).
208
+ """
209
+ results: list[dict[str, Any]] = []
210
+ ok = True
211
+ for label, url, expected in checks:
212
+ entry: dict[str, Any] = {"label": label, "url": url}
213
+ try:
214
+ headers = {"Origin": "https://example.org"}
215
+ if expected is None:
216
+ headers["Range"] = "bytes=0-0"
217
+ response = http.get(
218
+ url,
219
+ deadline=deadline,
220
+ headers=headers,
221
+ max_bytes=max(len(expected or b""), 1) + 1024,
222
+ )
223
+ entry["status"] = response.status
224
+ entry["available"] = response.status in (200, 206) and (
225
+ expected is None or response.body == expected
226
+ )
227
+ entry["cors"] = (response.header("access-control-allow-origin") or "") in (
228
+ "*",
229
+ "https://example.org",
230
+ )
231
+ except CliError as exc:
232
+ entry["available"] = False
233
+ entry["error"] = exc.records[0].to_json()
234
+ if label != "bundle" and not entry.get("available"):
235
+ ok = False
236
+ results.append(entry)
237
+ reader_cors = all(e.get("cors") for e in results if e["label"] != "bundle")
238
+ return {"confirmed": ok, "reader_cors": reader_cors, "checks": results}