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,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"]