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,492 @@
1
+ """Hugging Face dataset archive publishing through the Hub HTTP API and Git LFS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import functools
7
+ import hashlib
8
+ import json
9
+ import urllib.parse
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from ..errors import CliError, Exit
15
+ from .hosting import (
16
+ ARCHIVE_PATH,
17
+ LIST_PATH,
18
+ ItemReport,
19
+ Prepared,
20
+ RemoteFile,
21
+ check_descriptor,
22
+ confirm_public,
23
+ descriptor_bytes,
24
+ git_blob_sha1,
25
+ merged_list,
26
+ plan_reader_files,
27
+ )
28
+ from .http import Deadline, HttpClient, Response
29
+
30
+ HUB = "https://huggingface.co"
31
+ JSON_LIMIT = 32 << 20
32
+ LFS_TYPE = "application/vnd.git-lfs+json"
33
+
34
+
35
+ def descriptor_for(owner: str, repo: str) -> dict[str, str]:
36
+ base = f"{HUB}/datasets/{owner}/{repo}/resolve/main"
37
+ return {
38
+ "schema": "sci-archive/0.1",
39
+ "bundle_url": f"{base}/bundles/{{id2}}/{{id}}.zip",
40
+ "entry_url": f"{base}/sci/publications/{{id2}}/{{id}}/{{+path}}",
41
+ "list_url": f"{base}/sci/publications.txt",
42
+ }
43
+
44
+
45
+ def bundle_path(digest: str) -> str:
46
+ return f"bundles/{digest[:2]}/{digest}.zip"
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class LfsFile:
51
+ path: str
52
+ sha256: str
53
+ size: int
54
+ source: Path | bytes
55
+
56
+
57
+ def _hub_error(response: Response, what: str) -> CliError:
58
+ message = ""
59
+ try:
60
+ record = json.loads(response.body)
61
+ if isinstance(record, dict) and isinstance(record.get("error"), str):
62
+ message = ": " + record["error"][:300]
63
+ except ValueError:
64
+ pass
65
+ temporary = response.status >= 500 or response.status == 429
66
+ if response.status == 401:
67
+ return CliError("unauthorized", f"Hugging Face {what}: HTTP 401{message}", Exit.USAGE)
68
+ if response.status == 403:
69
+ return CliError("forbidden", f"Hugging Face {what}: HTTP 403{message}", Exit.REFUSED)
70
+ return CliError(
71
+ "temporary_failure" if temporary else "huggingface_error",
72
+ f"Hugging Face {what} returned HTTP {response.status}{message}",
73
+ Exit.INCOMPLETE if temporary else Exit.INVALID,
74
+ retryable=temporary,
75
+ )
76
+
77
+
78
+ class _NotFastForward(Exception):
79
+ pass
80
+
81
+
82
+ class HuggingFaceArchive:
83
+ def __init__(self, http: HttpClient, owner: str, repo: str, token: str) -> None:
84
+ self.http = http
85
+ self.owner = owner
86
+ self.repo = repo
87
+ self.repo_id = f"{owner}/{repo}"
88
+ self.token = token
89
+ self.descriptor = descriptor_for(owner, repo)
90
+ self.descriptor_url = f"{HUB}/datasets/{self.repo_id}/resolve/main/{ARCHIVE_PATH}"
91
+ self.api = f"{HUB}/api/datasets/{self.repo_id}"
92
+
93
+ def _call(
94
+ self,
95
+ method: str,
96
+ url: str,
97
+ deadline: Deadline,
98
+ *,
99
+ document: object = None,
100
+ body: Any = None,
101
+ headers: dict[str, str] | None = None,
102
+ max_bytes: int = JSON_LIMIT,
103
+ auth: bool = True,
104
+ ) -> Response:
105
+ all_headers: dict[str, str] = {"Accept": "application/json"}
106
+ if document is not None:
107
+ body = json.dumps(document).encode("utf-8")
108
+ all_headers["Content-Type"] = "application/json"
109
+ all_headers.update(headers or {})
110
+ return self.http.request(
111
+ method,
112
+ url,
113
+ deadline=deadline,
114
+ headers=all_headers,
115
+ body=body,
116
+ auth=f"Bearer {self.token}" if auth else None,
117
+ max_bytes=max_bytes,
118
+ retry=method in ("GET", "PUT"),
119
+ )
120
+
121
+ def _json(self, response: Response, what: str, ok: tuple[int, ...] = (200,)) -> Any:
122
+ if response.status not in ok:
123
+ raise _hub_error(response, what)
124
+ return response.json(f"Hugging Face {what}")
125
+
126
+ # ------------------------------------------------------------------ repository
127
+
128
+ def ensure_repository(self, create: bool, deadline: Deadline) -> dict[str, Any]:
129
+ response = self._call("GET", self.api, deadline)
130
+ created = False
131
+ if response.status in (401, 404):
132
+ if not create:
133
+ raise CliError(
134
+ "repository_missing",
135
+ f"Hugging Face dataset {self.repo_id} does not exist (or is not accessible); "
136
+ "pass --create",
137
+ Exit.USAGE,
138
+ )
139
+ who = self._json(self._call("GET", f"{HUB}/api/whoami-v2", deadline), "whoami")
140
+ name = who.get("name") if isinstance(who, dict) else None
141
+ body: dict[str, Any] = {"type": "dataset", "name": self.repo, "private": False}
142
+ if not isinstance(name, str) or name.lower() != self.owner.lower():
143
+ body["organization"] = self.owner
144
+ self._json(
145
+ self._call("POST", f"{HUB}/api/repos/create", deadline, document=body),
146
+ "repository creation",
147
+ (200, 201),
148
+ )
149
+ created = True
150
+ response = self._call("GET", self.api, deadline)
151
+ info = self._json(response, "repository lookup")
152
+ if not isinstance(info, dict):
153
+ raise CliError("invalid_response", "repository info is not an object", Exit.INVALID)
154
+ if info.get("private") is not False:
155
+ raise CliError(
156
+ "repository_not_public",
157
+ f"Hugging Face dataset {self.repo_id} is not public; its visibility is left "
158
+ "unchanged",
159
+ Exit.USAGE,
160
+ )
161
+ if info.get("gated") not in (None, False):
162
+ raise CliError(
163
+ "repository_not_public",
164
+ f"Hugging Face dataset {self.repo_id} is gated; readers need open access",
165
+ Exit.USAGE,
166
+ )
167
+ info["_created"] = created
168
+ return info
169
+
170
+ def head(self, deadline: Deadline) -> str | None:
171
+ response = self._call("GET", f"{self.api}/revision/main", deadline)
172
+ if response.status == 404:
173
+ return None
174
+ record = self._json(response, "revision lookup")
175
+ sha = record.get("sha") if isinstance(record, dict) else None
176
+ return sha if isinstance(sha, str) else None
177
+
178
+ def paths_info(self, paths: list[str], deadline: Deadline) -> dict[str, RemoteFile]:
179
+ found: dict[str, RemoteFile] = {}
180
+ for start in range(0, len(paths), 50):
181
+ chunk = paths[start : start + 50]
182
+ form = urllib.parse.urlencode([*(("paths", p) for p in chunk), ("expand", "true")])
183
+ response = self._call(
184
+ "POST",
185
+ f"{self.api}/paths-info/main",
186
+ deadline,
187
+ body=form.encode("ascii"),
188
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
189
+ )
190
+ if response.status == 404:
191
+ return found
192
+ records = self._json(response, "paths-info")
193
+ for record in records if isinstance(records, list) else []:
194
+ if not isinstance(record, dict) or record.get("type") != "file":
195
+ continue
196
+ lfs = record.get("lfs")
197
+ sha256 = lfs.get("oid") if isinstance(lfs, dict) else None
198
+ size = record.get("size")
199
+ found[str(record.get("path"))] = RemoteFile(
200
+ size=size if isinstance(size, int) else None,
201
+ git_sha1=None if sha256 else str(record.get("oid")),
202
+ sha256=str(sha256) if sha256 else None,
203
+ )
204
+ return found
205
+
206
+ def read(self, path: str, deadline: Deadline) -> bytes | None:
207
+ response = self._call(
208
+ "GET",
209
+ f"{HUB}/datasets/{self.repo_id}/resolve/main/{urllib.parse.quote(path)}",
210
+ deadline,
211
+ max_bytes=64 << 20,
212
+ )
213
+ if response.status == 404:
214
+ return None
215
+ if response.status != 200:
216
+ raise _hub_error(response, f"download of {path}")
217
+ return response.body
218
+
219
+ def upload_modes(self, files: dict[str, bytes], deadline: Deadline) -> dict[str, str]:
220
+ if not files:
221
+ return {}
222
+ document = {
223
+ "files": [
224
+ {"path": p, "size": len(d), "sample": base64.b64encode(d[:512]).decode("ascii")}
225
+ for p, d in files.items()
226
+ ]
227
+ }
228
+ record = self._json(
229
+ self._call("POST", f"{self.api}/preupload/main", deadline, document=document),
230
+ "preupload",
231
+ )
232
+ modes: dict[str, str] = {}
233
+ for entry in record.get("files", []) if isinstance(record, dict) else []:
234
+ if isinstance(entry, dict):
235
+ modes[str(entry.get("path"))] = str(entry.get("uploadMode", "regular"))
236
+ return modes
237
+
238
+ def lfs_upload(self, item: LfsFile, deadline: Deadline) -> str:
239
+ """Upload one LFS object through the batch API; return uploaded/present."""
240
+ batch = self._json(
241
+ self._call(
242
+ "POST",
243
+ f"{HUB}/datasets/{self.repo_id}.git/info/lfs/objects/batch",
244
+ deadline,
245
+ document={
246
+ "operation": "upload",
247
+ "transfers": ["basic"],
248
+ "objects": [{"oid": item.sha256, "size": item.size}],
249
+ "hash_algo": "sha256",
250
+ "ref": {"name": "main"},
251
+ },
252
+ headers={"Accept": LFS_TYPE, "Content-Type": LFS_TYPE},
253
+ ),
254
+ "LFS batch",
255
+ )
256
+ objects = batch.get("objects") if isinstance(batch, dict) else None
257
+ if not isinstance(objects, list) or not objects or not isinstance(objects[0], dict):
258
+ raise CliError("invalid_response", "LFS batch response has no objects", Exit.INVALID)
259
+ obj = objects[0]
260
+ if "error" in obj:
261
+ raise CliError(
262
+ "lfs_error",
263
+ f"LFS refused {item.path}: {obj['error']}",
264
+ Exit.INCOMPLETE,
265
+ retryable=True,
266
+ )
267
+ actions = obj.get("actions") or {}
268
+ upload = actions.get("upload") if isinstance(actions, dict) else None
269
+ if upload is None:
270
+ return "present"
271
+ header = dict(upload.get("header") or {})
272
+ if "chunk_size" in header:
273
+ raise CliError(
274
+ "unsupported_capability",
275
+ "the Hub requested a multipart LFS upload, which this CLI does not support",
276
+ Exit.UNSUPPORTED,
277
+ )
278
+ response = self.http.request(
279
+ "PUT",
280
+ str(upload["href"]),
281
+ deadline=deadline,
282
+ headers={str(k): str(v) for k, v in header.items()},
283
+ body=item.source,
284
+ )
285
+ if response.status not in (200, 201):
286
+ raise _hub_error(response, f"LFS upload of {item.path}")
287
+ verify = actions.get("verify")
288
+ if isinstance(verify, dict):
289
+ response = self.http.request(
290
+ "POST",
291
+ str(verify["href"]),
292
+ deadline=deadline,
293
+ headers={
294
+ "Accept": LFS_TYPE,
295
+ "Content-Type": LFS_TYPE,
296
+ **{str(k): str(v) for k, v in (verify.get("header") or {}).items()},
297
+ },
298
+ body=json.dumps({"oid": item.sha256, "size": item.size}).encode("ascii"),
299
+ auth=f"Bearer {self.token}",
300
+ )
301
+ if response.status != 200:
302
+ raise _hub_error(response, f"LFS verification of {item.path}")
303
+ return "uploaded"
304
+
305
+ def commit(
306
+ self,
307
+ parent: str | None,
308
+ regular: dict[str, bytes],
309
+ lfs: list[LfsFile],
310
+ message: str,
311
+ deadline: Deadline,
312
+ ) -> str:
313
+ header: dict[str, Any] = {"summary": message, "description": ""}
314
+ if parent is not None:
315
+ header["parentCommit"] = parent
316
+ lines = [json.dumps({"key": "header", "value": header})]
317
+ for path, data in sorted(regular.items()):
318
+ lines.append(
319
+ json.dumps(
320
+ {
321
+ "key": "file",
322
+ "value": {
323
+ "path": path,
324
+ "content": base64.b64encode(data).decode("ascii"),
325
+ "encoding": "base64",
326
+ },
327
+ }
328
+ )
329
+ )
330
+ for item in lfs:
331
+ lines.append(
332
+ json.dumps(
333
+ {
334
+ "key": "lfsFile",
335
+ "value": {
336
+ "path": item.path,
337
+ "algo": "sha256",
338
+ "oid": item.sha256,
339
+ "size": item.size,
340
+ },
341
+ }
342
+ )
343
+ )
344
+ response = self._call(
345
+ "POST",
346
+ f"{self.api}/commit/main",
347
+ deadline,
348
+ body=("\n".join(lines) + "\n").encode("utf-8"),
349
+ headers={"Content-Type": "application/x-ndjson"},
350
+ )
351
+ if response.status in (409, 412):
352
+ raise _NotFastForward()
353
+ record = self._json(response, "commit", (200, 201))
354
+ oid = record.get("commitOid") if isinstance(record, dict) else None
355
+ return str(oid) if oid else ""
356
+
357
+ # ------------------------------------------------------------------ workflow
358
+
359
+ def publish(
360
+ self, pubs: list[Prepared], *, create: bool, timeout: float
361
+ ) -> tuple[dict[str, Any], list[ItemReport]]:
362
+ deadline = Deadline(timeout)
363
+ repo = self.ensure_repository(create, deadline)
364
+ head = self.head(deadline)
365
+ managed = self.paths_info([ARCHIVE_PATH, LIST_PATH], deadline)
366
+ archive_existing = self.read(ARCHIVE_PATH, deadline) if ARCHIVE_PATH in managed else None
367
+ write_archive = check_descriptor(archive_existing, self.descriptor)
368
+ list_existing = self.read(LIST_PATH, deadline) if LIST_PATH in managed else None
369
+ merged_list(list_existing, [])
370
+ reports: list[ItemReport] = []
371
+ regular: dict[str, bytes] = {}
372
+ lfs: list[LfsFile] = []
373
+ completed: list[str] = []
374
+ for pub in pubs:
375
+ report = ItemReport(pub.item.publication_id)
376
+ reports.append(report)
377
+ item_deadline = Deadline(timeout)
378
+ try:
379
+ paths = [*pub.remote_files(), bundle_path(pub.digest)]
380
+ remote = self.paths_info(paths, item_deadline)
381
+ planned = plan_reader_files(
382
+ pub, remote, functools.partial(self.read, deadline=item_deadline), report
383
+ )
384
+ if report.status == "failed":
385
+ continue
386
+ bundle_remote = remote.get(bundle_path(pub.digest))
387
+ item_lfs: list[LfsFile] = []
388
+ if bundle_remote is not None:
389
+ if bundle_remote.sha256 != pub.zip_sha256:
390
+ raise CliError(
391
+ "destination_conflict",
392
+ f"{bundle_path(pub.digest)} exists with different bytes",
393
+ Exit.INVALID,
394
+ )
395
+ report.bundle = "reused"
396
+ else:
397
+ item_lfs.append(
398
+ LfsFile(bundle_path(pub.digest), pub.zip_sha256, pub.zip_size, pub.zip_path)
399
+ )
400
+ modes = self.upload_modes(planned, item_deadline)
401
+ item_regular: dict[str, bytes] = {}
402
+ for path, data in planned.items():
403
+ if modes.get(path) == "lfs":
404
+ item_lfs.append(
405
+ LfsFile(path, hashlib.sha256(data).hexdigest(), len(data), data)
406
+ )
407
+ else:
408
+ item_regular[path] = data
409
+ for item in item_lfs:
410
+ state = self.lfs_upload(item, item_deadline)
411
+ if item.path == bundle_path(pub.digest):
412
+ report.bundle = "uploaded" if state == "uploaded" else "reused"
413
+ except CliError as exc:
414
+ report.fail(exc.records[0], exc.exit_code)
415
+ continue
416
+ regular.update(item_regular)
417
+ lfs.extend(item_lfs)
418
+ report.uploaded = sorted(planned)
419
+ completed.append(pub.digest)
420
+ report.status = "pending_commit"
421
+ commit_oid = None
422
+ for _attempt in range(4):
423
+ commit_deadline = Deadline(timeout)
424
+ files = dict(regular)
425
+ current = self.paths_info([ARCHIVE_PATH, LIST_PATH], commit_deadline)
426
+ if write_archive and ARCHIVE_PATH not in current:
427
+ files[ARCHIVE_PATH] = descriptor_bytes(self.descriptor)
428
+ existing_list = self.read(LIST_PATH, commit_deadline) if LIST_PATH in current else None
429
+ new_list = merged_list(existing_list, completed)
430
+ if new_list != existing_list:
431
+ files[LIST_PATH] = new_list
432
+ if not files and not lfs:
433
+ break
434
+ try:
435
+ commit_oid = self.commit(
436
+ head,
437
+ files,
438
+ lfs,
439
+ f"Publish {len(completed)} SCI publication(s)",
440
+ commit_deadline,
441
+ )
442
+ break
443
+ except _NotFastForward:
444
+ head = self.head(commit_deadline)
445
+ else:
446
+ raise CliError(
447
+ "concurrent_update",
448
+ "main kept moving during publishing; retry",
449
+ Exit.INCOMPLETE,
450
+ retryable=True,
451
+ )
452
+ for report in reports:
453
+ if report.status == "pending_commit":
454
+ report.status = "hosted"
455
+ checks: list[tuple[str, str, bytes | None]] = [
456
+ ("descriptor", self.descriptor_url, descriptor_bytes(self.descriptor))
457
+ ]
458
+ for pub, report in zip(pubs, reports, strict=True):
459
+ if report.status != "hosted":
460
+ continue
461
+ entry = (
462
+ self.descriptor["entry_url"]
463
+ .replace("{id2}", pub.digest[:2])
464
+ .replace("{id}", pub.digest)
465
+ )
466
+ checks.append(
467
+ ("manifest", entry.replace("{+path}", "manifest.json"), pub.reader["manifest.json"])
468
+ )
469
+ checks.append(
470
+ (
471
+ "bundle",
472
+ self.descriptor["bundle_url"]
473
+ .replace("{id2}", pub.digest[:2])
474
+ .replace("{id}", pub.digest),
475
+ None,
476
+ )
477
+ )
478
+ availability = confirm_public(self.http, checks, Deadline(timeout))
479
+ return (
480
+ {
481
+ "repository": f"{HUB}/datasets/{self.repo_id}",
482
+ "created": bool(repo.get("_created")),
483
+ "descriptor_url": self.descriptor_url,
484
+ "descriptor": self.descriptor,
485
+ "commit": commit_oid,
486
+ "availability": availability,
487
+ },
488
+ reports,
489
+ )
490
+
491
+
492
+ __all__ = ["HuggingFaceArchive", "descriptor_for", "git_blob_sha1"]