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
pysci/metadata.py ADDED
@@ -0,0 +1,406 @@
1
+ """Publication metadata ``sci.json`` (protocol §4) and its checks (§6.3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Collection
7
+ from dataclasses import dataclass, field
8
+ from typing import Final
9
+
10
+ from .citations import CITATION_RELATIONS, CitationTarget, parse_citation_target
11
+ from .errors import IssueCollector
12
+ from .jsonstrict import JsonObject, JsonValue, json_pointer, json_type_name, parse_json_object
13
+ from .text import check_text
14
+
15
+ __all__ = [
16
+ "MAX_CATEGORIES",
17
+ "MAX_CITATIONS",
18
+ "MAX_KEYWORDS",
19
+ "Category",
20
+ "Citation",
21
+ "DisplayItem",
22
+ "Metadata",
23
+ "check_metadata",
24
+ "parse_metadata",
25
+ ]
26
+
27
+ MAX_TITLE: Final = 200
28
+ MAX_ABSTRACT: Final = 2500
29
+ MAX_CATEGORIES: Final = 3
30
+ MAX_KEYWORDS: Final = 20
31
+ MAX_KEYWORD: Final = 64
32
+ MAX_CATEGORY_CODE: Final = 128
33
+ MAX_CAPTION: Final = 2000
34
+ MAX_CITATIONS: Final = 1000
35
+ MAX_REASON: Final = 1000
36
+ _SCHEME: Final = re.compile(r"[a-z][a-z0-9.-]{0,63}")
37
+ _FIELDS: Final = ("title", "abstract", "categories", "keywords", "display", "citations")
38
+ _FILE: Final = "sci.json"
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class Category:
43
+ scheme: str
44
+ code: str
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class DisplayItem:
49
+ path: str
50
+ caption: str | None = None
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class Citation:
55
+ target: CitationTarget
56
+ relations: tuple[str, ...] = ()
57
+ reason: str | None = None
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class Metadata:
62
+ """Validated publication metadata."""
63
+
64
+ title: str
65
+ abstract: str
66
+ categories: tuple[Category, ...] = ()
67
+ keywords: tuple[str, ...] = ()
68
+ display: tuple[DisplayItem, ...] = ()
69
+ citations: tuple[Citation, ...] = ()
70
+ raw: JsonObject = field(default_factory=dict, compare=False, repr=False)
71
+
72
+
73
+ def parse_metadata(data: bytes, listed_paths: Collection[str] | None = None) -> Metadata:
74
+ """Decode and check ``sci.json``. Raises :class:`~pysci.errors.Invalid` with all issues.
75
+
76
+ ``listed_paths`` are the manifest's ``files`` keys used to check display
77
+ paths (§4.3); pass None to skip that cross-check (e.g. for drafts, where the
78
+ caller supplies the planned file list instead).
79
+ """
80
+ record = parse_json_object(data, file=_FILE)
81
+ issues = IssueCollector()
82
+ metadata = check_metadata(record, listed_paths, issues)
83
+ issues.raise_if_any()
84
+ assert metadata is not None
85
+ return metadata
86
+
87
+
88
+ def check_metadata(
89
+ record: JsonObject, listed_paths: Collection[str] | None, issues: IssueCollector
90
+ ) -> Metadata | None:
91
+ """Collect every §4 violation in a decoded ``sci.json`` object."""
92
+ start = len(issues)
93
+ _check_keys(record, "", issues)
94
+ extra = sorted(set(record) - set(_FIELDS))
95
+ if extra:
96
+ issues.add(
97
+ "metadata_fields",
98
+ "sci.json permits only title, abstract, categories, keywords, display, and citations",
99
+ section="4",
100
+ file=_FILE,
101
+ observed=extra,
102
+ )
103
+ title = _string(record, "title", "/title", issues, max_len=MAX_TITLE, required=True)
104
+ abstract = _string(
105
+ record, "abstract", "/abstract", issues, max_len=MAX_ABSTRACT, required=True, multiline=True
106
+ )
107
+ categories = _categories(record, issues)
108
+ keywords = _keywords(record, issues)
109
+ display = _display(record, listed_paths, issues)
110
+ citations = _citations(record, issues)
111
+ if len(issues) != start:
112
+ return None
113
+ assert title is not None
114
+ assert abstract is not None
115
+ return Metadata(
116
+ title=title,
117
+ abstract=abstract,
118
+ categories=categories,
119
+ keywords=keywords,
120
+ display=display,
121
+ citations=citations,
122
+ raw=record,
123
+ )
124
+
125
+
126
+ def _check_keys(value: JsonValue, where: str, issues: IssueCollector) -> None:
127
+ """Object keys are decoded strings and follow §4.2 (single-line rules)."""
128
+ stack: list[tuple[JsonValue, str]] = [(value, where)]
129
+ while stack:
130
+ item, pointer = stack.pop()
131
+ if isinstance(item, dict):
132
+ for key, child in item.items():
133
+ child_pointer = pointer + json_pointer(key)
134
+ check_text(key, file=_FILE, field=child_pointer, collector=issues)
135
+ stack.append((child, child_pointer))
136
+ elif isinstance(item, list):
137
+ stack.extend((child, f"{pointer}/{index}") for index, child in enumerate(item))
138
+
139
+
140
+ def _string(
141
+ container: JsonObject,
142
+ name: str,
143
+ where: str,
144
+ issues: IssueCollector,
145
+ *,
146
+ max_len: int,
147
+ required: bool = False,
148
+ multiline: bool = False,
149
+ ) -> str | None:
150
+ if name not in container:
151
+ if required:
152
+ issues.add(
153
+ "metadata_required", f"{name} is required", section="4", file=_FILE, field=where
154
+ )
155
+ return None
156
+ value = container[name]
157
+ if not isinstance(value, str):
158
+ issues.add(
159
+ "metadata_type",
160
+ f"{name} must be a string",
161
+ section="4",
162
+ file=_FILE,
163
+ field=where,
164
+ observed=json_type_name(value),
165
+ )
166
+ return None
167
+ before = len(issues)
168
+ check_text(
169
+ value,
170
+ multiline=multiline,
171
+ nonblank=True,
172
+ max_code_points=max_len,
173
+ file=_FILE,
174
+ field=where,
175
+ collector=issues,
176
+ )
177
+ return value if len(issues) == before else None
178
+
179
+
180
+ def _array(
181
+ record: JsonObject, name: str, issues: IssueCollector, max_items: int | None
182
+ ) -> list[JsonValue]:
183
+ value = record.get(name, [])
184
+ if not isinstance(value, list):
185
+ issues.add(
186
+ "metadata_type",
187
+ f"{name} must be an array",
188
+ section="4",
189
+ file=_FILE,
190
+ field="/" + name,
191
+ observed=json_type_name(value),
192
+ )
193
+ return []
194
+ if max_items is not None and len(value) > max_items:
195
+ issues.add(
196
+ "metadata_array_length",
197
+ f"{name} has at most {max_items} items",
198
+ section="4",
199
+ file=_FILE,
200
+ field="/" + name,
201
+ observed=f"{len(value)} items",
202
+ )
203
+ return value
204
+
205
+
206
+ def _object(
207
+ item: JsonValue,
208
+ where: str,
209
+ required: tuple[str, ...],
210
+ optional: tuple[str, ...],
211
+ label: str,
212
+ issues: IssueCollector,
213
+ ) -> JsonObject | None:
214
+ if not isinstance(item, dict):
215
+ issues.add(
216
+ "metadata_type",
217
+ f"each {label} must be an object",
218
+ section="4",
219
+ file=_FILE,
220
+ field=where,
221
+ observed=json_type_name(item),
222
+ )
223
+ return None
224
+ missing = [name for name in required if name not in item]
225
+ extra = sorted(set(item) - set(required) - set(optional))
226
+ if missing:
227
+ issues.add(
228
+ "metadata_fields",
229
+ f"{label} requires {', '.join(required)}",
230
+ section="4",
231
+ file=_FILE,
232
+ field=where,
233
+ observed=missing,
234
+ )
235
+ if extra:
236
+ allowed = ", ".join(required + optional)
237
+ issues.add(
238
+ "metadata_fields",
239
+ f"{label} permits only {allowed}",
240
+ section="4",
241
+ file=_FILE,
242
+ field=where,
243
+ observed=extra,
244
+ )
245
+ return item
246
+
247
+
248
+ def _categories(record: JsonObject, issues: IssueCollector) -> tuple[Category, ...]:
249
+ result: list[Category] = []
250
+ seen: set[tuple[str, str]] = set()
251
+ for index, item in enumerate(_array(record, "categories", issues, MAX_CATEGORIES)):
252
+ where = f"/categories/{index}"
253
+ obj = _object(item, where, ("scheme", "code"), (), "category", issues)
254
+ if obj is None:
255
+ continue
256
+ scheme = obj.get("scheme")
257
+ if "scheme" in obj and not (isinstance(scheme, str) and _SCHEME.fullmatch(scheme)):
258
+ issues.add(
259
+ "category_scheme",
260
+ "scheme must match [a-z][a-z0-9.-]{0,63}",
261
+ section="4.1",
262
+ file=_FILE,
263
+ field=where + "/scheme",
264
+ observed=scheme,
265
+ )
266
+ scheme = None
267
+ code = _string(obj, "code", where + "/code", issues, max_len=MAX_CATEGORY_CODE)
268
+ if isinstance(scheme, str) and code is not None:
269
+ if (scheme, code) in seen:
270
+ issues.add(
271
+ "category_duplicate",
272
+ "duplicate (scheme, code) pairs are prohibited",
273
+ section="4.1",
274
+ file=_FILE,
275
+ field=where,
276
+ observed=[scheme, code],
277
+ )
278
+ seen.add((scheme, code))
279
+ result.append(Category(scheme, code))
280
+ return tuple(result)
281
+
282
+
283
+ def _keywords(record: JsonObject, issues: IssueCollector) -> tuple[str, ...]:
284
+ result: list[str] = []
285
+ for index, item in enumerate(_array(record, "keywords", issues, MAX_KEYWORDS)):
286
+ value = _string({"k": item}, "k", f"/keywords/{index}", issues, max_len=MAX_KEYWORD)
287
+ if value is not None:
288
+ result.append(value)
289
+ return tuple(result)
290
+
291
+
292
+ def _display(
293
+ record: JsonObject, listed_paths: Collection[str] | None, issues: IssueCollector
294
+ ) -> tuple[DisplayItem, ...]:
295
+ result: list[DisplayItem] = []
296
+ seen: set[str] = set()
297
+ for index, item in enumerate(_array(record, "display", issues, None)):
298
+ where = f"/display/{index}"
299
+ obj = _object(item, where, ("path",), ("caption",), "display item", issues)
300
+ if obj is None:
301
+ continue
302
+ path = obj.get("path")
303
+ ok = True
304
+ if "path" in obj:
305
+ if (
306
+ not isinstance(path, str)
307
+ or not path.startswith("files/")
308
+ or (listed_paths is not None and path not in listed_paths)
309
+ ):
310
+ issues.add(
311
+ "display_path",
312
+ "display path must exactly match an entry under files/ in the manifest",
313
+ section="4.3",
314
+ file=_FILE,
315
+ field=where + "/path",
316
+ observed=path,
317
+ )
318
+ ok = False
319
+ elif path in seen:
320
+ issues.add(
321
+ "display_duplicate",
322
+ "display paths must be distinct",
323
+ section="4.3",
324
+ file=_FILE,
325
+ field=where + "/path",
326
+ observed=path,
327
+ )
328
+ ok = False
329
+ caption = _string(obj, "caption", where + "/caption", issues, max_len=MAX_CAPTION)
330
+ if isinstance(path, str) and ok:
331
+ seen.add(path)
332
+ result.append(DisplayItem(path, caption))
333
+ return tuple(result)
334
+
335
+
336
+ def _citations(record: JsonObject, issues: IssueCollector) -> tuple[Citation, ...]:
337
+ result: list[Citation] = []
338
+ for index, item in enumerate(_array(record, "citations", issues, MAX_CITATIONS)):
339
+ where = f"/citations/{index}"
340
+ obj = _object(item, where, ("target",), ("relations", "reason"), "citation", issues)
341
+ if obj is None:
342
+ continue
343
+ target_value = obj.get("target")
344
+ target: CitationTarget | None = None
345
+ if "target" in obj:
346
+ if not isinstance(target_value, str):
347
+ issues.add(
348
+ "citation_target",
349
+ "target must be a string",
350
+ section="4.4",
351
+ file=_FILE,
352
+ field=where + "/target",
353
+ observed=json_type_name(target_value),
354
+ )
355
+ else:
356
+ check_text(target_value, file=_FILE, field=where + "/target", collector=issues)
357
+ target = parse_citation_target(target_value)
358
+ if target is None:
359
+ issues.add(
360
+ "citation_target",
361
+ "target must be sci:sha256:<hash>, a doi: URI, or an http(s) URI",
362
+ section="4.4",
363
+ file=_FILE,
364
+ field=where + "/target",
365
+ observed=target_value,
366
+ )
367
+ relations: tuple[str, ...] = ()
368
+ if "relations" in obj:
369
+ value = obj["relations"]
370
+ if not (isinstance(value, list) and all(isinstance(r, str) for r in value)):
371
+ issues.add(
372
+ "citation_relations",
373
+ "relations must be an array of strings",
374
+ section="4.4",
375
+ file=_FILE,
376
+ field=where + "/relations",
377
+ observed=json_type_name(value),
378
+ )
379
+ else:
380
+ names = [r for r in value if isinstance(r, str)]
381
+ unknown = sorted(set(names) - CITATION_RELATIONS)
382
+ if unknown:
383
+ issues.add(
384
+ "citation_relations",
385
+ "relations must come from uses, reviews, supports, challenges, supersedes",
386
+ section="4.4",
387
+ file=_FILE,
388
+ field=where + "/relations",
389
+ observed=unknown,
390
+ )
391
+ if len(set(names)) != len(names):
392
+ issues.add(
393
+ "citation_relations",
394
+ "relations must be distinct",
395
+ section="4.4",
396
+ file=_FILE,
397
+ field=where + "/relations",
398
+ observed=names,
399
+ )
400
+ relations = tuple(names)
401
+ reason = _string(
402
+ obj, "reason", where + "/reason", issues, max_len=MAX_REASON, multiline=True
403
+ )
404
+ if target is not None:
405
+ result.append(Citation(target, relations, reason))
406
+ return tuple(result)
pysci/paths.py ADDED
@@ -0,0 +1,136 @@
1
+ """Bundle file path rules (protocol §3.1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+ from typing import Final
8
+
9
+ from .errors import IssueCollector
10
+
11
+ __all__ = [
12
+ "MAX_PATH_BYTES",
13
+ "check_path",
14
+ "check_path_conflicts",
15
+ "check_paths",
16
+ "is_valid_path",
17
+ "path_sort_key",
18
+ ]
19
+
20
+ MAX_PATH_BYTES: Final = 255
21
+ _COMPONENT: Final = re.compile(r"[A-Za-z0-9._-]+")
22
+ _DEVICE: Final = re.compile(r"(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?", re.IGNORECASE)
23
+
24
+
25
+ def path_sort_key(path: str) -> bytes:
26
+ """Ordering key: ascending lexicographic order of the ASCII path bytes."""
27
+ return path.encode("utf-8")
28
+
29
+
30
+ def _path_problem(path: str) -> tuple[str, str] | None:
31
+ if not path:
32
+ return ("path_empty", "path must not be empty")
33
+ if not path.isascii():
34
+ return ("path_component", "paths use only ASCII letters, digits, '.', '_', and '-'")
35
+ if len(path.encode("ascii")) > MAX_PATH_BYTES:
36
+ return ("path_too_long", f"full path must be at most {MAX_PATH_BYTES} ASCII bytes")
37
+ for component in path.split("/"):
38
+ if not component:
39
+ return (
40
+ "path_empty_component",
41
+ "path components must be nonempty (no leading, trailing, or repeated '/')",
42
+ )
43
+ if not _COMPONENT.fullmatch(component):
44
+ return (
45
+ "path_component",
46
+ "components use only ASCII letters, digits, '.', '_', and '-'",
47
+ )
48
+ if component in (".", "..") or component.endswith("."):
49
+ return ("path_dot_component", "'.', '..', and components ending in '.' are prohibited")
50
+ if _DEVICE.fullmatch(component):
51
+ return ("path_device_name", "Windows device names are prohibited in any component")
52
+ return None
53
+
54
+
55
+ def is_valid_path(path: str) -> bool:
56
+ """True if ``path`` satisfies the per-path rules of §3.1."""
57
+ return _path_problem(path) is None
58
+
59
+
60
+ def check_path(
61
+ path: str, *, collector: IssueCollector, file: str | None = None, field: str | None = None
62
+ ) -> bool:
63
+ """Check one path; append an issue and return False on failure."""
64
+ problem = _path_problem(path)
65
+ if problem is None:
66
+ return True
67
+ code, requirement = problem
68
+ collector.add(code, requirement, section="3.1", file=file, field=field, observed=path)
69
+ return False
70
+
71
+
72
+ def check_path_conflicts(
73
+ paths: Iterable[str],
74
+ *,
75
+ collector: IssueCollector,
76
+ file: str | None = None,
77
+ field: str | None = None,
78
+ ) -> None:
79
+ """Case-insensitive file/file, file/directory, and directory-spelling conflicts."""
80
+ files: dict[str, str] = {}
81
+ directories: dict[str, str] = {}
82
+ for path in paths:
83
+ folded = path.lower()
84
+ if folded in files:
85
+ collector.add(
86
+ "path_conflict",
87
+ "paths must differ without regard to ASCII case",
88
+ section="3.1",
89
+ file=file,
90
+ field=field,
91
+ observed=[files[folded], path],
92
+ )
93
+ else:
94
+ files[folded] = path
95
+ components = path.split("/")
96
+ for depth in range(1, len(components)):
97
+ directory = "/".join(components[:depth])
98
+ existing = directories.setdefault(directory.lower(), directory)
99
+ if existing != directory:
100
+ collector.add(
101
+ "path_directory_case",
102
+ "every occurrence of a directory must use the same capitalization",
103
+ section="3.1",
104
+ file=file,
105
+ field=field,
106
+ observed=[existing, directory],
107
+ )
108
+ for folded, directory in directories.items():
109
+ if folded in files:
110
+ collector.add(
111
+ "path_file_directory_conflict",
112
+ "a file path must not also name a directory used by another entry",
113
+ section="3.1",
114
+ file=file,
115
+ field=field,
116
+ observed=[files[folded], directory],
117
+ )
118
+
119
+
120
+ def check_paths(
121
+ paths: Iterable[str],
122
+ *,
123
+ collector: IssueCollector,
124
+ file: str | None = None,
125
+ field_prefix: str | None = None,
126
+ ) -> None:
127
+ """Per-path rules plus conflict checks for a full set of paths."""
128
+ path_list = list(paths)
129
+ for path in path_list:
130
+ field = (
131
+ None
132
+ if field_prefix is None
133
+ else field_prefix + "/" + path.replace("~", "~0").replace("/", "~1")
134
+ )
135
+ check_path(path, collector=collector, file=file, field=field)
136
+ check_path_conflicts(path_list, collector=collector, file=file, field=field_prefix)