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,734 @@
|
|
|
1
|
+
"""GitHub archive publishing through the REST API (Git data, contents, and releases)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import functools
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pysci.jsonstrict import JsonValue
|
|
15
|
+
|
|
16
|
+
from ..errors import CliError, ErrorRecord, Exit
|
|
17
|
+
from .hosting import (
|
|
18
|
+
ARCHIVE_PATH,
|
|
19
|
+
LIST_PATH,
|
|
20
|
+
ItemReport,
|
|
21
|
+
Prepared,
|
|
22
|
+
RemoteFile,
|
|
23
|
+
check_descriptor,
|
|
24
|
+
confirm_public,
|
|
25
|
+
descriptor_bytes,
|
|
26
|
+
merged_list,
|
|
27
|
+
plan_reader_files,
|
|
28
|
+
)
|
|
29
|
+
from .http import Deadline, HttpClient, Response, retry_after_seconds
|
|
30
|
+
|
|
31
|
+
API = "https://api.github.com"
|
|
32
|
+
JSON_LIMIT = 32 << 20
|
|
33
|
+
BATCH_SIZE = 50
|
|
34
|
+
"""Publications committed to ``main`` together; a failed run keeps earlier batches."""
|
|
35
|
+
INLINE_FILE_LIMIT = 256 * 1024
|
|
36
|
+
INLINE_REQUEST_LIMIT = 16 << 20
|
|
37
|
+
_INLINE_NAMES = frozenset({"manifest.json", "sci.json", "README.md", "LICENSE"})
|
|
38
|
+
SECONDARY_LIMIT_WAIT = 60.0
|
|
39
|
+
MAX_RATE_LIMIT_WAIT = 3700.0
|
|
40
|
+
MAX_RATE_LIMIT_WAITS = 8
|
|
41
|
+
ASSET_PAGES = 20
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def descriptor_for(owner: str, repo: str) -> dict[str, str]:
|
|
45
|
+
return {
|
|
46
|
+
"schema": "sci-archive/0.1",
|
|
47
|
+
"bundle_url": f"https://github.com/{owner}/{repo}/releases/download/sci-{{id2}}/{{id}}.zip",
|
|
48
|
+
"entry_url": f"https://raw.githubusercontent.com/{owner}/{repo}/main/sci/publications/"
|
|
49
|
+
"{id2}/{id}/{+path}",
|
|
50
|
+
"list_url": f"https://raw.githubusercontent.com/{owner}/{repo}/main/sci/publications.txt",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GitHubError(CliError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _secondary_limit(response: Response) -> bool:
|
|
59
|
+
return response.status in (403, 429) and (
|
|
60
|
+
response.header("retry-after") is not None
|
|
61
|
+
or b"secondary rate limit" in response.body.lower()
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _rate_limit_wait(response: Response) -> float | None:
|
|
66
|
+
"""Seconds GitHub asks us to wait, or None if this is not a rate-limit response."""
|
|
67
|
+
if response.status not in (403, 429):
|
|
68
|
+
return None
|
|
69
|
+
wait = retry_after_seconds(response.header("retry-after"))
|
|
70
|
+
if wait is not None:
|
|
71
|
+
return wait
|
|
72
|
+
if response.header("x-ratelimit-remaining") == "0":
|
|
73
|
+
reset = response.header("x-ratelimit-reset") or ""
|
|
74
|
+
return max(0.0, int(reset) - time.time()) + 1.0 if reset.isdigit() else None
|
|
75
|
+
return SECONDARY_LIMIT_WAIT if _secondary_limit(response) or response.status == 429 else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _inline_text(path: str, data: bytes) -> str | None:
|
|
79
|
+
"""The text of a reader file small enough to send inside the tree request."""
|
|
80
|
+
name = path.rsplit("/", 1)[-1]
|
|
81
|
+
if len(data) > INLINE_FILE_LIMIT:
|
|
82
|
+
return None
|
|
83
|
+
if name not in _INLINE_NAMES and path not in (ARCHIVE_PATH, LIST_PATH):
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
86
|
+
return data.decode("utf-8")
|
|
87
|
+
except UnicodeDecodeError:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _api_error(response: Response, what: str) -> CliError:
|
|
92
|
+
message = ""
|
|
93
|
+
try:
|
|
94
|
+
record = json.loads(response.body)
|
|
95
|
+
if isinstance(record, dict) and isinstance(record.get("message"), str):
|
|
96
|
+
message = ": " + record["message"][:300]
|
|
97
|
+
except ValueError:
|
|
98
|
+
pass
|
|
99
|
+
temporary = response.status >= 500 or response.status == 429
|
|
100
|
+
if response.status in (401,):
|
|
101
|
+
exit_code: int = Exit.USAGE
|
|
102
|
+
code = "unauthorized"
|
|
103
|
+
elif response.status == 403 and (
|
|
104
|
+
response.header("x-ratelimit-remaining") == "0" or _secondary_limit(response)
|
|
105
|
+
):
|
|
106
|
+
exit_code, code, temporary = Exit.INCOMPLETE, "rate_limited", True
|
|
107
|
+
elif response.status == 403:
|
|
108
|
+
exit_code, code = Exit.REFUSED, "forbidden"
|
|
109
|
+
elif temporary:
|
|
110
|
+
exit_code, code = Exit.INCOMPLETE, "temporary_failure"
|
|
111
|
+
else:
|
|
112
|
+
exit_code, code = Exit.INVALID, "github_error"
|
|
113
|
+
return GitHubError(
|
|
114
|
+
code,
|
|
115
|
+
f"GitHub {what} returned HTTP {response.status}{message}",
|
|
116
|
+
exit_code,
|
|
117
|
+
retryable=temporary,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class GitHubArchive:
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
http: HttpClient,
|
|
125
|
+
owner: str,
|
|
126
|
+
repo: str,
|
|
127
|
+
token: str,
|
|
128
|
+
notice: Callable[[str], None] | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
self.http = http
|
|
131
|
+
self.notice = notice or (lambda _message: None)
|
|
132
|
+
self.owner = owner
|
|
133
|
+
self.repo = repo
|
|
134
|
+
self.token = token
|
|
135
|
+
self.descriptor = descriptor_for(owner, repo)
|
|
136
|
+
self.descriptor_url = (
|
|
137
|
+
f"https://raw.githubusercontent.com/{owner}/{repo}/main/{ARCHIVE_PATH}"
|
|
138
|
+
)
|
|
139
|
+
self._trees: dict[str, dict[str, tuple[str, str, int | None]]] = {}
|
|
140
|
+
self._buckets: dict[str, tuple[dict[str, Any], dict[str, dict[str, Any]]]] = {}
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------------ HTTP
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def _repo_path(self) -> str:
|
|
146
|
+
return f"/repos/{self.owner}/{self.repo}"
|
|
147
|
+
|
|
148
|
+
def _call(
|
|
149
|
+
self,
|
|
150
|
+
method: str,
|
|
151
|
+
path: str,
|
|
152
|
+
deadline: Deadline,
|
|
153
|
+
*,
|
|
154
|
+
document: object = None,
|
|
155
|
+
body: Any = None,
|
|
156
|
+
headers: dict[str, str] | None = None,
|
|
157
|
+
max_bytes: int = JSON_LIMIT,
|
|
158
|
+
) -> Response:
|
|
159
|
+
url = path if path.startswith("https://") else API + path
|
|
160
|
+
all_headers = {
|
|
161
|
+
"Accept": "application/vnd.github+json",
|
|
162
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
163
|
+
}
|
|
164
|
+
if document is not None:
|
|
165
|
+
body = json.dumps(document).encode("utf-8")
|
|
166
|
+
all_headers["Content-Type"] = "application/json"
|
|
167
|
+
all_headers.update(headers or {})
|
|
168
|
+
for _wait in range(MAX_RATE_LIMIT_WAITS + 1):
|
|
169
|
+
response = self.http.request(
|
|
170
|
+
method,
|
|
171
|
+
url,
|
|
172
|
+
deadline=deadline,
|
|
173
|
+
headers=all_headers,
|
|
174
|
+
body=body,
|
|
175
|
+
auth=f"Bearer {self.token}",
|
|
176
|
+
max_bytes=max_bytes,
|
|
177
|
+
retry=method in ("GET", "HEAD", "PUT", "PATCH", "DELETE"),
|
|
178
|
+
)
|
|
179
|
+
wait = _rate_limit_wait(response)
|
|
180
|
+
if wait is None or wait > MAX_RATE_LIMIT_WAIT or _wait == MAX_RATE_LIMIT_WAITS:
|
|
181
|
+
return response
|
|
182
|
+
self.notice(f"GitHub rate limit reached; waiting {wait:.0f}s")
|
|
183
|
+
self.http.sleep(wait)
|
|
184
|
+
deadline.extend(wait)
|
|
185
|
+
raise AssertionError("unreachable")
|
|
186
|
+
|
|
187
|
+
def _json(self, response: Response, what: str, ok: tuple[int, ...] = (200,)) -> Any:
|
|
188
|
+
if response.status not in ok:
|
|
189
|
+
raise _api_error(response, what)
|
|
190
|
+
value: JsonValue = response.json(f"GitHub {what}")
|
|
191
|
+
return value
|
|
192
|
+
|
|
193
|
+
# ------------------------------------------------------------------ repository
|
|
194
|
+
|
|
195
|
+
def ensure_repository(self, create: bool, deadline: Deadline) -> dict[str, Any]:
|
|
196
|
+
response = self._call("GET", self._repo_path, deadline)
|
|
197
|
+
if response.status == 404:
|
|
198
|
+
if not create:
|
|
199
|
+
raise CliError(
|
|
200
|
+
"repository_missing",
|
|
201
|
+
f"GitHub repository {self.owner}/{self.repo} does not exist; pass --create",
|
|
202
|
+
Exit.USAGE,
|
|
203
|
+
)
|
|
204
|
+
user = self._json(self._call("GET", "/user", deadline), "user lookup")
|
|
205
|
+
login = str(user.get("login", "")) if isinstance(user, dict) else ""
|
|
206
|
+
body = {
|
|
207
|
+
"name": self.repo,
|
|
208
|
+
"private": False,
|
|
209
|
+
"auto_init": True,
|
|
210
|
+
"description": "SCI publication archive",
|
|
211
|
+
}
|
|
212
|
+
path = (
|
|
213
|
+
"/user/repos"
|
|
214
|
+
if login.lower() == self.owner.lower()
|
|
215
|
+
else f"/orgs/{self.owner}/repos"
|
|
216
|
+
)
|
|
217
|
+
repo = self._json(
|
|
218
|
+
self._call("POST", path, deadline, document=body), "repository creation", (201,)
|
|
219
|
+
)
|
|
220
|
+
repo["_created"] = True
|
|
221
|
+
else:
|
|
222
|
+
repo = self._json(response, "repository lookup")
|
|
223
|
+
if not isinstance(repo, dict):
|
|
224
|
+
raise CliError(
|
|
225
|
+
"invalid_response", "GitHub repository response is not an object", Exit.INVALID
|
|
226
|
+
)
|
|
227
|
+
if repo.get("private") is not False or repo.get("visibility", "public") != "public":
|
|
228
|
+
raise CliError(
|
|
229
|
+
"repository_not_public",
|
|
230
|
+
f"GitHub repository {self.owner}/{self.repo} is not public; its visibility "
|
|
231
|
+
"is left unchanged",
|
|
232
|
+
Exit.USAGE,
|
|
233
|
+
)
|
|
234
|
+
return repo
|
|
235
|
+
|
|
236
|
+
def _ref(self, branch: str, deadline: Deadline) -> str | None:
|
|
237
|
+
response = self._call("GET", f"{self._repo_path}/git/ref/heads/{branch}", deadline)
|
|
238
|
+
if response.status in (404, 409):
|
|
239
|
+
return None
|
|
240
|
+
record = self._json(response, "branch lookup")
|
|
241
|
+
sha = record.get("object", {}).get("sha") if isinstance(record, dict) else None
|
|
242
|
+
if not isinstance(sha, str):
|
|
243
|
+
raise CliError(
|
|
244
|
+
"invalid_response", "GitHub ref response has no object.sha", Exit.INVALID
|
|
245
|
+
)
|
|
246
|
+
return sha
|
|
247
|
+
|
|
248
|
+
def ensure_main(self, repo: dict[str, Any], deadline: Deadline) -> str | None:
|
|
249
|
+
"""The ``main`` head commit; creates ``main`` from the default branch if needed.
|
|
250
|
+
|
|
251
|
+
Returns None for an empty repository (no commits yet).
|
|
252
|
+
"""
|
|
253
|
+
head = self._ref("main", deadline)
|
|
254
|
+
if head is not None:
|
|
255
|
+
return head
|
|
256
|
+
default = repo.get("default_branch")
|
|
257
|
+
if isinstance(default, str) and default != "main":
|
|
258
|
+
base = self._ref(default, deadline)
|
|
259
|
+
if base is not None:
|
|
260
|
+
self._json(
|
|
261
|
+
self._call(
|
|
262
|
+
"POST",
|
|
263
|
+
f"{self._repo_path}/git/refs",
|
|
264
|
+
deadline,
|
|
265
|
+
document={"ref": "refs/heads/main", "sha": base},
|
|
266
|
+
),
|
|
267
|
+
"branch creation",
|
|
268
|
+
(201,),
|
|
269
|
+
)
|
|
270
|
+
return base
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
def initialize(self, deadline: Deadline) -> str:
|
|
274
|
+
"""Create the first commit (``sci/archive.json``) in an empty repository."""
|
|
275
|
+
content = base64.b64encode(descriptor_bytes(self.descriptor)).decode("ascii")
|
|
276
|
+
record = self._json(
|
|
277
|
+
self._call(
|
|
278
|
+
"PUT",
|
|
279
|
+
f"{self._repo_path}/contents/{ARCHIVE_PATH}",
|
|
280
|
+
deadline,
|
|
281
|
+
document={
|
|
282
|
+
"message": "Add SCI archive descriptor",
|
|
283
|
+
"content": content,
|
|
284
|
+
"branch": "main",
|
|
285
|
+
},
|
|
286
|
+
),
|
|
287
|
+
"repository initialization",
|
|
288
|
+
(200, 201),
|
|
289
|
+
)
|
|
290
|
+
sha = record.get("commit", {}).get("sha") if isinstance(record, dict) else None
|
|
291
|
+
if not isinstance(sha, str):
|
|
292
|
+
raise CliError(
|
|
293
|
+
"invalid_response", "GitHub contents response has no commit.sha", Exit.INVALID
|
|
294
|
+
)
|
|
295
|
+
return sha
|
|
296
|
+
|
|
297
|
+
# ------------------------------------------------------------------ trees
|
|
298
|
+
|
|
299
|
+
def _tree(self, sha: str, deadline: Deadline) -> dict[str, tuple[str, str, int | None]]:
|
|
300
|
+
if sha not in self._trees:
|
|
301
|
+
record = self._json(
|
|
302
|
+
self._call("GET", f"{self._repo_path}/git/trees/{sha}", deadline), "tree lookup"
|
|
303
|
+
)
|
|
304
|
+
entries: dict[str, tuple[str, str, int | None]] = {}
|
|
305
|
+
for entry in record.get("tree", []) if isinstance(record, dict) else []:
|
|
306
|
+
if isinstance(entry, dict) and isinstance(entry.get("path"), str):
|
|
307
|
+
size = entry.get("size")
|
|
308
|
+
entries[entry["path"]] = (
|
|
309
|
+
str(entry.get("type")),
|
|
310
|
+
str(entry.get("sha")),
|
|
311
|
+
size if isinstance(size, int) else None,
|
|
312
|
+
)
|
|
313
|
+
self._trees[sha] = entries
|
|
314
|
+
return self._trees[sha]
|
|
315
|
+
|
|
316
|
+
def commit_tree(self, commit: str, deadline: Deadline) -> str:
|
|
317
|
+
record = self._json(
|
|
318
|
+
self._call("GET", f"{self._repo_path}/git/commits/{commit}", deadline), "commit lookup"
|
|
319
|
+
)
|
|
320
|
+
sha = record.get("tree", {}).get("sha") if isinstance(record, dict) else None
|
|
321
|
+
if not isinstance(sha, str):
|
|
322
|
+
raise CliError("invalid_response", "GitHub commit has no tree.sha", Exit.INVALID)
|
|
323
|
+
return sha
|
|
324
|
+
|
|
325
|
+
def lookup(self, root: str, path: str, deadline: Deadline) -> RemoteFile | None:
|
|
326
|
+
tree = root
|
|
327
|
+
parts = path.split("/")
|
|
328
|
+
for index, name in enumerate(parts):
|
|
329
|
+
entry = self._tree(tree, deadline).get(name)
|
|
330
|
+
if entry is None:
|
|
331
|
+
return None
|
|
332
|
+
kind, sha, size = entry
|
|
333
|
+
if index == len(parts) - 1:
|
|
334
|
+
return RemoteFile(size=size, git_sha1=sha) if kind == "blob" else None
|
|
335
|
+
if kind != "tree":
|
|
336
|
+
return None
|
|
337
|
+
tree = sha
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
def read(self, root: str, path: str, deadline: Deadline) -> bytes | None:
|
|
341
|
+
remote = self.lookup(root, path, deadline)
|
|
342
|
+
if remote is None or remote.git_sha1 is None:
|
|
343
|
+
return None
|
|
344
|
+
record = self._json(
|
|
345
|
+
self._call("GET", f"{self._repo_path}/git/blobs/{remote.git_sha1}", deadline),
|
|
346
|
+
"blob download",
|
|
347
|
+
)
|
|
348
|
+
try:
|
|
349
|
+
return base64.b64decode(str(record["content"]))
|
|
350
|
+
except (KeyError, TypeError, ValueError):
|
|
351
|
+
raise CliError(
|
|
352
|
+
"invalid_response", "GitHub blob response is malformed", Exit.INVALID
|
|
353
|
+
) from None
|
|
354
|
+
|
|
355
|
+
# ------------------------------------------------------------------ releases
|
|
356
|
+
|
|
357
|
+
def _bucket(
|
|
358
|
+
self, tag: str, deadline: Deadline
|
|
359
|
+
) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
|
|
360
|
+
"""The shared release for ``tag`` and its assets by name, created if missing."""
|
|
361
|
+
if tag not in self._buckets:
|
|
362
|
+
release = self._find_release(tag, deadline)
|
|
363
|
+
if release is not None:
|
|
364
|
+
assets = self._list_assets(release, deadline)
|
|
365
|
+
else:
|
|
366
|
+
release, assets = self._create_release(tag, deadline)
|
|
367
|
+
self._buckets[tag] = (release, assets)
|
|
368
|
+
return self._buckets[tag]
|
|
369
|
+
|
|
370
|
+
def _find_release(self, tag: str, deadline: Deadline) -> dict[str, Any] | None:
|
|
371
|
+
response = self._call("GET", f"{self._repo_path}/releases/tags/{tag}", deadline)
|
|
372
|
+
if response.status == 404:
|
|
373
|
+
return None
|
|
374
|
+
release: dict[str, Any] = self._json(response, "release lookup")
|
|
375
|
+
return release
|
|
376
|
+
|
|
377
|
+
def _create_release(
|
|
378
|
+
self, tag: str, deadline: Deadline
|
|
379
|
+
) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
|
|
380
|
+
response = self._call(
|
|
381
|
+
"POST",
|
|
382
|
+
f"{self._repo_path}/releases",
|
|
383
|
+
deadline,
|
|
384
|
+
document={
|
|
385
|
+
"tag_name": tag,
|
|
386
|
+
"target_commitish": "main",
|
|
387
|
+
"name": tag,
|
|
388
|
+
"body": f"SCI publication bundles with IDs starting {tag.removeprefix('sci-')}",
|
|
389
|
+
"draft": False,
|
|
390
|
+
"make_latest": "false",
|
|
391
|
+
},
|
|
392
|
+
)
|
|
393
|
+
if response.status == 422: # another publisher created it first
|
|
394
|
+
existing = self._find_release(tag, deadline)
|
|
395
|
+
if existing is not None:
|
|
396
|
+
return existing, self._list_assets(existing, deadline)
|
|
397
|
+
release: dict[str, Any] = self._json(response, "release creation", (201,))
|
|
398
|
+
return release, {}
|
|
399
|
+
|
|
400
|
+
def _list_assets(
|
|
401
|
+
self, release: dict[str, Any], deadline: Deadline
|
|
402
|
+
) -> dict[str, dict[str, Any]]:
|
|
403
|
+
assets: dict[str, dict[str, Any]] = {}
|
|
404
|
+
for page in range(1, ASSET_PAGES + 1):
|
|
405
|
+
records = self._json(
|
|
406
|
+
self._call(
|
|
407
|
+
"GET",
|
|
408
|
+
f"{self._repo_path}/releases/{release['id']}/assets?per_page=100&page={page}",
|
|
409
|
+
deadline,
|
|
410
|
+
),
|
|
411
|
+
"release asset listing",
|
|
412
|
+
)
|
|
413
|
+
if not isinstance(records, list):
|
|
414
|
+
raise CliError(
|
|
415
|
+
"invalid_response", "GitHub asset listing is not a list", Exit.INVALID
|
|
416
|
+
)
|
|
417
|
+
assets.update(
|
|
418
|
+
(record["name"], record)
|
|
419
|
+
for record in records
|
|
420
|
+
if isinstance(record, dict) and isinstance(record.get("name"), str)
|
|
421
|
+
)
|
|
422
|
+
if len(records) < 100:
|
|
423
|
+
break
|
|
424
|
+
return assets
|
|
425
|
+
|
|
426
|
+
def upload_release(self, pub: Prepared, deadline: Deadline) -> str:
|
|
427
|
+
"""Ensure the shared release ``sci-<id2>`` holds ``<id>.zip``; return uploaded/reused.
|
|
428
|
+
|
|
429
|
+
An asset cannot be downloaded until its upload completes, so a release
|
|
430
|
+
never exposes a partial ZIP.
|
|
431
|
+
"""
|
|
432
|
+
tag = f"sci-{pub.digest[:2]}"
|
|
433
|
+
name = f"{pub.digest}.zip"
|
|
434
|
+
for attempt in (1, 2):
|
|
435
|
+
release, assets = self._bucket(tag, deadline)
|
|
436
|
+
asset = assets.get(name)
|
|
437
|
+
if asset is not None and asset.get("state") != "uploaded":
|
|
438
|
+
response = self._call("DELETE", str(asset["url"]), deadline)
|
|
439
|
+
if response.status not in (204, 404):
|
|
440
|
+
raise _api_error(response, "incomplete asset removal")
|
|
441
|
+
del assets[name]
|
|
442
|
+
asset = None
|
|
443
|
+
if asset is not None:
|
|
444
|
+
if not self._asset_matches(asset, pub, deadline):
|
|
445
|
+
raise CliError(
|
|
446
|
+
"destination_conflict",
|
|
447
|
+
f"release {tag} already has a different {name}",
|
|
448
|
+
Exit.INVALID,
|
|
449
|
+
)
|
|
450
|
+
return "reused"
|
|
451
|
+
upload = str(release.get("upload_url", "")).split("{", 1)[0]
|
|
452
|
+
if not upload.startswith("https://"):
|
|
453
|
+
raise CliError("invalid_response", "release has no upload_url", Exit.INVALID)
|
|
454
|
+
response = self._call(
|
|
455
|
+
"POST",
|
|
456
|
+
f"{upload}?{urllib.parse.urlencode({'name': name})}",
|
|
457
|
+
deadline,
|
|
458
|
+
body=pub.zip_path,
|
|
459
|
+
headers={"Content-Type": "application/zip"},
|
|
460
|
+
)
|
|
461
|
+
if response.status == 422 and attempt == 1: # another publisher uploaded it first
|
|
462
|
+
del self._buckets[tag]
|
|
463
|
+
continue
|
|
464
|
+
uploaded = self._json(response, "asset upload", (201,))
|
|
465
|
+
if not isinstance(uploaded, dict) or uploaded.get("size") != pub.zip_size:
|
|
466
|
+
raise CliError(
|
|
467
|
+
"upload_incomplete",
|
|
468
|
+
f"{name} upload size mismatch",
|
|
469
|
+
Exit.INCOMPLETE,
|
|
470
|
+
retryable=True,
|
|
471
|
+
)
|
|
472
|
+
digest = uploaded.get("digest")
|
|
473
|
+
if isinstance(digest, str) and digest != f"sha256:{pub.zip_sha256}":
|
|
474
|
+
raise CliError(
|
|
475
|
+
"upload_corrupted",
|
|
476
|
+
f"{name} digest mismatch after upload",
|
|
477
|
+
Exit.INCOMPLETE,
|
|
478
|
+
retryable=True,
|
|
479
|
+
)
|
|
480
|
+
assets[name] = uploaded
|
|
481
|
+
return "uploaded"
|
|
482
|
+
raise AssertionError("unreachable")
|
|
483
|
+
|
|
484
|
+
def _asset_matches(self, asset: dict[str, Any], pub: Prepared, deadline: Deadline) -> bool:
|
|
485
|
+
digest = asset.get("digest")
|
|
486
|
+
if isinstance(digest, str) and digest.startswith("sha256:"):
|
|
487
|
+
return digest == f"sha256:{pub.zip_sha256}"
|
|
488
|
+
if asset.get("size") != pub.zip_size:
|
|
489
|
+
return False
|
|
490
|
+
response = self._call(
|
|
491
|
+
"GET",
|
|
492
|
+
str(asset["url"]),
|
|
493
|
+
deadline,
|
|
494
|
+
headers={"Accept": "application/octet-stream"},
|
|
495
|
+
max_bytes=pub.zip_size + 1,
|
|
496
|
+
)
|
|
497
|
+
if response.status != 200:
|
|
498
|
+
raise _api_error(response, "asset download")
|
|
499
|
+
return hashlib.sha256(response.body).hexdigest() == pub.zip_sha256
|
|
500
|
+
|
|
501
|
+
# ------------------------------------------------------------------ commits
|
|
502
|
+
|
|
503
|
+
def commit(self, head: str, files: dict[str, bytes], message: str, deadline: Deadline) -> str:
|
|
504
|
+
"""Commit ``files`` on top of ``head`` and fast-forward ``main``.
|
|
505
|
+
|
|
506
|
+
Raises:
|
|
507
|
+
_NotFastForward: ``main`` moved; the caller rebuilds and retries.
|
|
508
|
+
"""
|
|
509
|
+
root = self.commit_tree(head, deadline)
|
|
510
|
+
entries: list[dict[str, str]] = []
|
|
511
|
+
inlined: dict[str, bytes] = {}
|
|
512
|
+
inline_bytes = 0
|
|
513
|
+
for path, data in sorted(files.items()):
|
|
514
|
+
entry = {"path": path, "mode": "100644", "type": "blob"}
|
|
515
|
+
text = _inline_text(path, data)
|
|
516
|
+
if text is not None and inline_bytes + len(data) <= INLINE_REQUEST_LIMIT:
|
|
517
|
+
inline_bytes += len(data)
|
|
518
|
+
inlined[path] = data
|
|
519
|
+
entries.append({**entry, "content": text})
|
|
520
|
+
continue
|
|
521
|
+
blob = self._json(
|
|
522
|
+
self._call(
|
|
523
|
+
"POST",
|
|
524
|
+
f"{self._repo_path}/git/blobs",
|
|
525
|
+
deadline,
|
|
526
|
+
document={
|
|
527
|
+
"content": base64.b64encode(data).decode("ascii"),
|
|
528
|
+
"encoding": "base64",
|
|
529
|
+
},
|
|
530
|
+
),
|
|
531
|
+
"blob creation",
|
|
532
|
+
(201,),
|
|
533
|
+
)
|
|
534
|
+
entries.append({**entry, "sha": blob["sha"]})
|
|
535
|
+
tree = self._json(
|
|
536
|
+
self._call(
|
|
537
|
+
"POST",
|
|
538
|
+
f"{self._repo_path}/git/trees",
|
|
539
|
+
deadline,
|
|
540
|
+
document={"base_tree": root, "tree": entries},
|
|
541
|
+
),
|
|
542
|
+
"tree creation",
|
|
543
|
+
(201,),
|
|
544
|
+
)
|
|
545
|
+
for path, data in inlined.items():
|
|
546
|
+
stored = self.lookup(tree["sha"], path, deadline)
|
|
547
|
+
if stored is None or stored.git_sha1 != _git_sha(data):
|
|
548
|
+
raise CliError(
|
|
549
|
+
"upload_corrupted",
|
|
550
|
+
f"GitHub stored different bytes for {path}",
|
|
551
|
+
Exit.INCOMPLETE,
|
|
552
|
+
retryable=True,
|
|
553
|
+
)
|
|
554
|
+
commit = self._json(
|
|
555
|
+
self._call(
|
|
556
|
+
"POST",
|
|
557
|
+
f"{self._repo_path}/git/commits",
|
|
558
|
+
deadline,
|
|
559
|
+
document={"message": message, "tree": tree["sha"], "parents": [head]},
|
|
560
|
+
),
|
|
561
|
+
"commit creation",
|
|
562
|
+
(201,),
|
|
563
|
+
)
|
|
564
|
+
response = self._call(
|
|
565
|
+
"PATCH",
|
|
566
|
+
f"{self._repo_path}/git/refs/heads/main",
|
|
567
|
+
deadline,
|
|
568
|
+
document={"sha": commit["sha"], "force": False},
|
|
569
|
+
)
|
|
570
|
+
if response.status in (409, 422):
|
|
571
|
+
raise _NotFastForward()
|
|
572
|
+
self._json(response, "branch update")
|
|
573
|
+
return str(commit["sha"])
|
|
574
|
+
|
|
575
|
+
def _commit_batch(
|
|
576
|
+
self,
|
|
577
|
+
head: str,
|
|
578
|
+
writes: dict[str, bytes],
|
|
579
|
+
completed: list[str],
|
|
580
|
+
write_archive: bool,
|
|
581
|
+
timeout: float,
|
|
582
|
+
) -> tuple[str, str | None]:
|
|
583
|
+
"""Commit one batch, rebuilding on a moved ``main``; return (head, commit or None)."""
|
|
584
|
+
for _attempt in range(4):
|
|
585
|
+
deadline = Deadline(timeout)
|
|
586
|
+
root = self.commit_tree(head, deadline)
|
|
587
|
+
files = dict(writes)
|
|
588
|
+
if write_archive and self.lookup(root, ARCHIVE_PATH, deadline) is None:
|
|
589
|
+
files[ARCHIVE_PATH] = descriptor_bytes(self.descriptor)
|
|
590
|
+
files[LIST_PATH] = merged_list(self.read(root, LIST_PATH, deadline), completed)
|
|
591
|
+
files = {
|
|
592
|
+
p: d
|
|
593
|
+
for p, d in files.items()
|
|
594
|
+
if (r := self.lookup(root, p, deadline)) is None or r.git_sha1 != _git_sha(d)
|
|
595
|
+
}
|
|
596
|
+
if not files:
|
|
597
|
+
return head, None
|
|
598
|
+
try:
|
|
599
|
+
commit_sha = self.commit(
|
|
600
|
+
head, files, f"Publish {len(completed)} SCI publication(s)", deadline
|
|
601
|
+
)
|
|
602
|
+
return commit_sha, commit_sha
|
|
603
|
+
except _NotFastForward:
|
|
604
|
+
self._trees.clear()
|
|
605
|
+
new_head = self._ref("main", deadline)
|
|
606
|
+
if new_head is None:
|
|
607
|
+
raise CliError(
|
|
608
|
+
"branch_missing", "main disappeared during publishing", Exit.INCOMPLETE
|
|
609
|
+
) from None
|
|
610
|
+
head = new_head
|
|
611
|
+
raise CliError(
|
|
612
|
+
"concurrent_update",
|
|
613
|
+
"main kept moving during publishing; retry",
|
|
614
|
+
Exit.INCOMPLETE,
|
|
615
|
+
retryable=True,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
# ------------------------------------------------------------------ workflow
|
|
619
|
+
|
|
620
|
+
def publish(
|
|
621
|
+
self, pubs: list[Prepared], *, create: bool, timeout: float
|
|
622
|
+
) -> tuple[dict[str, Any], list[ItemReport]]:
|
|
623
|
+
deadline = Deadline(timeout)
|
|
624
|
+
repo = self.ensure_repository(create, deadline)
|
|
625
|
+
head = self.ensure_main(repo, deadline)
|
|
626
|
+
if head is None:
|
|
627
|
+
head = self.initialize(deadline)
|
|
628
|
+
root = self.commit_tree(head, deadline)
|
|
629
|
+
write_archive = check_descriptor(self.read(root, ARCHIVE_PATH, deadline), self.descriptor)
|
|
630
|
+
merged_list(self.read(root, LIST_PATH, deadline), []) # malformed lists stop here
|
|
631
|
+
reports: list[ItemReport] = []
|
|
632
|
+
writes: dict[str, bytes] = {}
|
|
633
|
+
completed: list[str] = []
|
|
634
|
+
commit_sha = None
|
|
635
|
+
committed_any = False
|
|
636
|
+
stopped: CliError | None = None
|
|
637
|
+
|
|
638
|
+
def commit_batch() -> None:
|
|
639
|
+
nonlocal head, commit_sha, write_archive, committed_any, stopped
|
|
640
|
+
assert head is not None
|
|
641
|
+
try:
|
|
642
|
+
head, new_commit = self._commit_batch(
|
|
643
|
+
head, writes, completed, write_archive, timeout
|
|
644
|
+
)
|
|
645
|
+
except CliError as exc:
|
|
646
|
+
if not committed_any:
|
|
647
|
+
raise
|
|
648
|
+
stopped = exc
|
|
649
|
+
for report in reports:
|
|
650
|
+
if report.status == "pending_commit":
|
|
651
|
+
report.fail(exc.records[0], exc.exit_code)
|
|
652
|
+
return
|
|
653
|
+
commit_sha = new_commit or commit_sha
|
|
654
|
+
write_archive = False
|
|
655
|
+
committed_any = True
|
|
656
|
+
writes.clear()
|
|
657
|
+
completed.clear()
|
|
658
|
+
for report in reports:
|
|
659
|
+
if report.status == "pending_commit":
|
|
660
|
+
report.status = "hosted"
|
|
661
|
+
|
|
662
|
+
for pub in pubs:
|
|
663
|
+
report = ItemReport(pub.item.publication_id)
|
|
664
|
+
reports.append(report)
|
|
665
|
+
if stopped is not None:
|
|
666
|
+
report.fail(stopped.records[0], stopped.exit_code)
|
|
667
|
+
continue
|
|
668
|
+
item_deadline = Deadline(timeout)
|
|
669
|
+
try:
|
|
670
|
+
remote = {
|
|
671
|
+
path: found
|
|
672
|
+
for path in pub.remote_files()
|
|
673
|
+
if (found := self.lookup(root, path, item_deadline)) is not None
|
|
674
|
+
}
|
|
675
|
+
planned = plan_reader_files(
|
|
676
|
+
pub,
|
|
677
|
+
remote,
|
|
678
|
+
functools.partial(self.read, root, deadline=item_deadline),
|
|
679
|
+
report,
|
|
680
|
+
)
|
|
681
|
+
if report.status == "failed":
|
|
682
|
+
continue
|
|
683
|
+
report.bundle = self.upload_release(pub, item_deadline)
|
|
684
|
+
except CliError as exc:
|
|
685
|
+
report.fail(exc.records[0], exc.exit_code)
|
|
686
|
+
continue
|
|
687
|
+
writes.update(planned)
|
|
688
|
+
report.uploaded = sorted(planned)
|
|
689
|
+
completed.append(pub.digest)
|
|
690
|
+
report.status = "pending_commit"
|
|
691
|
+
if len(completed) >= BATCH_SIZE:
|
|
692
|
+
commit_batch()
|
|
693
|
+
if stopped is None:
|
|
694
|
+
commit_batch()
|
|
695
|
+
hosted = [p for p, r in zip(pubs, reports, strict=True) if r.status == "hosted"]
|
|
696
|
+
checks: list[tuple[str, str, bytes | None]] = [
|
|
697
|
+
("descriptor", self.descriptor_url, descriptor_bytes(self.descriptor))
|
|
698
|
+
]
|
|
699
|
+
for pub in hosted:
|
|
700
|
+
entry = (
|
|
701
|
+
self.descriptor["entry_url"]
|
|
702
|
+
.replace("{id2}", pub.digest[:2])
|
|
703
|
+
.replace("{id}", pub.digest)
|
|
704
|
+
)
|
|
705
|
+
checks.append(
|
|
706
|
+
("manifest", entry.replace("{+path}", "manifest.json"), pub.reader["manifest.json"])
|
|
707
|
+
)
|
|
708
|
+
bundle = self.descriptor["bundle_url"].replace("{id}", pub.digest)
|
|
709
|
+
checks.append(("bundle", bundle, None))
|
|
710
|
+
availability = confirm_public(self.http, checks, Deadline(timeout))
|
|
711
|
+
return (
|
|
712
|
+
{
|
|
713
|
+
"repository": f"https://github.com/{self.owner}/{self.repo}",
|
|
714
|
+
"created": bool(repo.get("_created")),
|
|
715
|
+
"descriptor_url": self.descriptor_url,
|
|
716
|
+
"descriptor": self.descriptor,
|
|
717
|
+
"commit": commit_sha,
|
|
718
|
+
"availability": availability,
|
|
719
|
+
},
|
|
720
|
+
reports,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _git_sha(data: bytes) -> str:
|
|
725
|
+
from .hosting import git_blob_sha1
|
|
726
|
+
|
|
727
|
+
return git_blob_sha1(data)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
class _NotFastForward(Exception):
|
|
731
|
+
pass
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
__all__ = ["ErrorRecord", "GitHubArchive", "descriptor_for"]
|