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.
- pysci/__init__.py +77 -0
- pysci/__main__.py +8 -0
- pysci/_cli/__init__.py +5 -0
- pysci/_cli/checks.py +173 -0
- pysci/_cli/clients/__init__.py +0 -0
- pysci/_cli/clients/archive.py +456 -0
- pysci/_cli/clients/credentials.py +54 -0
- pysci/_cli/clients/github.py +734 -0
- pysci/_cli/clients/hosting.py +238 -0
- pysci/_cli/clients/http.py +481 -0
- pysci/_cli/clients/huggingface.py +492 -0
- pysci/_cli/clients/index.py +435 -0
- pysci/_cli/clients/tsa.py +223 -0
- pysci/_cli/commands/__init__.py +44 -0
- pysci/_cli/commands/check.py +115 -0
- pysci/_cli/commands/claim.py +179 -0
- pysci/_cli/commands/common.py +91 -0
- pysci/_cli/commands/folders.py +83 -0
- pysci/_cli/commands/index.py +51 -0
- pysci/_cli/commands/keygen.py +58 -0
- pysci/_cli/commands/license.py +75 -0
- pysci/_cli/commands/protocol.py +72 -0
- pysci/_cli/commands/publish.py +282 -0
- pysci/_cli/commands/pull.py +337 -0
- pysci/_cli/commands/read.py +364 -0
- pysci/_cli/commands/revoke.py +207 -0
- pysci/_cli/commands/search.py +400 -0
- pysci/_cli/commands/sign.py +284 -0
- pysci/_cli/commands/submit.py +172 -0
- pysci/_cli/commands/timestamp.py +196 -0
- pysci/_cli/commands/verify.py +163 -0
- pysci/_cli/config.py +233 -0
- pysci/_cli/context.py +79 -0
- pysci/_cli/destinations.py +163 -0
- pysci/_cli/errors.py +168 -0
- pysci/_cli/main.py +229 -0
- pysci/_cli/output.py +80 -0
- pysci/_cli/retrieval.py +51 -0
- pysci/_cli/spec.py +113 -0
- pysci/_cli/storage/__init__.py +0 -0
- pysci/_cli/storage/catalog.py +371 -0
- pysci/_cli/storage/files.py +252 -0
- pysci/_cli/storage/inputs.py +205 -0
- pysci/_cli/storage/keys.py +132 -0
- pysci/_cli/storage/local_publish.py +88 -0
- pysci/accounts.py +135 -0
- pysci/baseline.py +142 -0
- pysci/bundle.py +878 -0
- pysci/citations.py +233 -0
- pysci/data/freetsa-cacert.pem +45 -0
- pysci/data/sci-baseline-LICENSE.txt +457 -0
- pysci/data/sci-protocol.md +613 -0
- pysci/der.py +146 -0
- pysci/discovery.py +461 -0
- pysci/draft.py +374 -0
- pysci/ed25519.py +153 -0
- pysci/errors.py +204 -0
- pysci/ids.py +61 -0
- pysci/jsonstrict.py +225 -0
- pysci/keys.py +247 -0
- pysci/manifest.py +302 -0
- pysci/metadata.py +406 -0
- pysci/paths.py +136 -0
- pysci/png.py +217 -0
- pysci/py.typed +0 -0
- pysci/records.py +384 -0
- pysci/rfc3339.py +66 -0
- pysci/schemas.py +75 -0
- pysci/source.py +166 -0
- pysci/specification.py +58 -0
- pysci/submission.py +225 -0
- pysci/templates.py +129 -0
- pysci/text.py +226 -0
- pysci/timestamps.py +867 -0
- pysci/zipformat.py +400 -0
- sci_protocol-0.2.0.dist-info/METADATA +112 -0
- sci_protocol-0.2.0.dist-info/RECORD +80 -0
- sci_protocol-0.2.0.dist-info/WHEEL +4 -0
- sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
- 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}
|