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,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
|
+
)
|