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/errors.py ADDED
@@ -0,0 +1,204 @@
1
+ """Outcome vocabulary, structured issues, and the exception hierarchy.
2
+
3
+ Every check in the library reports failures as :class:`Issue` records so that
4
+ callers (such as the CLI) can show the file or field, the violated
5
+ requirement, and the observed value. Fail-fast parsers raise a
6
+ :class:`SciError` subclass carrying the issues; collecting validators return
7
+ lists of issues.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterable, Sequence
13
+ from dataclasses import dataclass, field
14
+ from enum import StrEnum
15
+
16
+ __all__ = [
17
+ "Incomplete",
18
+ "Invalid",
19
+ "Issue",
20
+ "IssueCollector",
21
+ "Outcome",
22
+ "SciError",
23
+ "Unsupported",
24
+ "observed_repr",
25
+ "raise_for",
26
+ ]
27
+
28
+
29
+ class Outcome(StrEnum):
30
+ """Verification outcome (protocol §6)."""
31
+
32
+ VALID = "valid"
33
+ """The requested checks succeeded."""
34
+ INVALID = "invalid"
35
+ """A required format, signature, binding, or association check failed."""
36
+ UNSUPPORTED = "unsupported"
37
+ """The ``schema`` string identifies no supported schema."""
38
+ INCOMPLETE = "incomplete"
39
+ """Unavailable evidence or a local resource limit prevented the checks."""
40
+
41
+
42
+ def observed_repr(value: object, limit: int = 120) -> str:
43
+ """Return a short, printable representation of an observed value."""
44
+ text = repr(value)
45
+ if len(text) > limit:
46
+ text = text[: limit - 3] + "..."
47
+ return text
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class Issue:
52
+ """One failed requirement.
53
+
54
+ Attributes:
55
+ code: Stable machine-readable identifier, e.g. ``"text_not_assignable"``.
56
+ requirement: Human-readable statement of the violated requirement.
57
+ section: Protocol section, e.g. ``"4.2"``.
58
+ file: Bundle path or artifact the issue concerns, e.g. ``"sci.json"``.
59
+ field: JSON pointer (RFC 6901) of the offending value inside ``file``.
60
+ observed: Short representation of the observed value, when useful.
61
+ outcome: The outcome this issue implies (invalid, unsupported, incomplete).
62
+ """
63
+
64
+ code: str
65
+ requirement: str
66
+ section: str | None = None
67
+ file: str | None = None
68
+ field: str | None = None
69
+ observed: str | None = None
70
+ outcome: Outcome = Outcome.INVALID
71
+
72
+ def located(self, *, file: str | None = None, prefix: str | None = None) -> Issue:
73
+ """Return a copy attributed to ``file`` and/or with a JSON-pointer prefix."""
74
+ new_field = self.field
75
+ if prefix is not None:
76
+ new_field = prefix + (self.field or "")
77
+ return Issue(
78
+ code=self.code,
79
+ requirement=self.requirement,
80
+ section=self.section,
81
+ file=file if file is not None else self.file,
82
+ field=new_field,
83
+ observed=self.observed,
84
+ outcome=self.outcome,
85
+ )
86
+
87
+ def __str__(self) -> str:
88
+ where = ""
89
+ if self.file:
90
+ where = self.file
91
+ if self.field:
92
+ where += ("#" if where else "") + self.field
93
+ parts = [f"[{self.code}]"]
94
+ if where:
95
+ parts.append(where + ":")
96
+ parts.append(self.requirement)
97
+ if self.section:
98
+ parts.append(f"(§{self.section})")
99
+ if self.observed is not None:
100
+ parts.append(f"observed {self.observed}")
101
+ return " ".join(parts)
102
+
103
+
104
+ def _worst(issues: Iterable[Issue]) -> Outcome:
105
+ outcomes = {issue.outcome for issue in issues}
106
+ for candidate in (Outcome.UNSUPPORTED, Outcome.INVALID, Outcome.INCOMPLETE):
107
+ if candidate in outcomes:
108
+ return candidate
109
+ return Outcome.VALID
110
+
111
+
112
+ class SciError(Exception):
113
+ """Base class for protocol check failures. Carries one or more issues."""
114
+
115
+ default_outcome = Outcome.INVALID
116
+
117
+ def __init__(self, issues: Issue | Sequence[Issue]) -> None:
118
+ self.issues: tuple[Issue, ...] = (issues,) if isinstance(issues, Issue) else tuple(issues)
119
+ if not self.issues:
120
+ raise ValueError("SciError requires at least one issue")
121
+ super().__init__("; ".join(str(issue) for issue in self.issues))
122
+
123
+ @property
124
+ def outcome(self) -> Outcome:
125
+ return _worst(self.issues)
126
+
127
+ @property
128
+ def issue(self) -> Issue:
129
+ """The first issue."""
130
+ return self.issues[0]
131
+
132
+
133
+ class Invalid(SciError):
134
+ """A required check failed."""
135
+
136
+
137
+ class Unsupported(SciError):
138
+ """A schema string identifies no supported schema version."""
139
+
140
+ default_outcome = Outcome.UNSUPPORTED
141
+
142
+
143
+ class Incomplete(SciError):
144
+ """Evidence was unavailable or a local limit prevented the check."""
145
+
146
+ default_outcome = Outcome.INCOMPLETE
147
+
148
+
149
+ def raise_for(issues: Sequence[Issue]) -> None:
150
+ """Raise the exception class matching the worst issue, if any."""
151
+ if not issues:
152
+ return
153
+ worst = _worst(issues)
154
+ if worst is Outcome.UNSUPPORTED:
155
+ raise Unsupported(issues)
156
+ if worst is Outcome.INCOMPLETE:
157
+ raise Incomplete(issues)
158
+ raise Invalid(issues)
159
+
160
+
161
+ @dataclass(slots=True)
162
+ class IssueCollector:
163
+ """Accumulates issues from independent checks."""
164
+
165
+ issues: list[Issue] = field(default_factory=list)
166
+
167
+ def add(
168
+ self,
169
+ code: str,
170
+ requirement: str,
171
+ *,
172
+ section: str | None = None,
173
+ file: str | None = None,
174
+ field: str | None = None,
175
+ observed: object = None,
176
+ outcome: Outcome = Outcome.INVALID,
177
+ ) -> None:
178
+ self.issues.append(
179
+ Issue(
180
+ code=code,
181
+ requirement=requirement,
182
+ section=section,
183
+ file=file,
184
+ field=field,
185
+ observed=None if observed is None else observed_repr(observed),
186
+ outcome=outcome,
187
+ )
188
+ )
189
+
190
+ def extend(self, issues: Iterable[Issue]) -> None:
191
+ self.issues.extend(issues)
192
+
193
+ def __bool__(self) -> bool:
194
+ return bool(self.issues)
195
+
196
+ def __len__(self) -> int:
197
+ return len(self.issues)
198
+
199
+ @property
200
+ def outcome(self) -> Outcome:
201
+ return _worst(self.issues)
202
+
203
+ def raise_if_any(self) -> None:
204
+ raise_for(self.issues)
pysci/ids.py ADDED
@@ -0,0 +1,61 @@
1
+ """Publication identifiers (protocol §2, §4.4, §8.1.1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from typing import Final
8
+
9
+ __all__ = [
10
+ "is_digest",
11
+ "parse_publication_id",
12
+ "publication_id",
13
+ "publication_id_from_manifest",
14
+ "sci_citation_target",
15
+ ]
16
+
17
+ _DIGEST: Final = re.compile(r"[0-9a-f]{64}")
18
+
19
+
20
+ def is_digest(value: str) -> bool:
21
+ """True for exactly 64 lowercase hexadecimal characters."""
22
+ return _DIGEST.fullmatch(value) is not None
23
+
24
+
25
+ def parse_publication_id(value: str, *, allow_bare: bool = False) -> str:
26
+ """Parse ``sha256:<digest>`` or ``sci:sha256:<digest>``; return the bare digest.
27
+
28
+ With ``allow_bare`` a bare 64-character digest (the §8.1.1 ``id`` form) is
29
+ also accepted. Uppercase hexadecimal is rejected, never normalized.
30
+
31
+ Raises:
32
+ ValueError: the value is not a publication identifier.
33
+ """
34
+ for prefix in ("sci:sha256:", "sha256:"):
35
+ if value.startswith(prefix):
36
+ digest = value[len(prefix) :]
37
+ break
38
+ else:
39
+ if not allow_bare:
40
+ raise ValueError("publication IDs use sha256:<64 lowercase hex> or sci:sha256:<...>")
41
+ digest = value
42
+ if not is_digest(digest):
43
+ raise ValueError("publication digest must be 64 lowercase hexadecimal characters")
44
+ return digest
45
+
46
+
47
+ def publication_id(digest: str) -> str:
48
+ """Format a bare digest as ``sha256:<digest>``."""
49
+ if not is_digest(digest):
50
+ raise ValueError("publication digest must be 64 lowercase hexadecimal characters")
51
+ return "sha256:" + digest
52
+
53
+
54
+ def publication_id_from_manifest(manifest_bytes: bytes) -> str:
55
+ """``sha256:`` identifier of the exact manifest bytes (§2)."""
56
+ return "sha256:" + hashlib.sha256(manifest_bytes).hexdigest()
57
+
58
+
59
+ def sci_citation_target(value: str) -> str:
60
+ """The ``sci:sha256:<digest>`` citation form of an identifier (§4.4)."""
61
+ return "sci:sha256:" + parse_publication_id(value, allow_bare=True)
pysci/jsonstrict.py ADDED
@@ -0,0 +1,225 @@
1
+ """Strict JSON decoding under protocol §7.
2
+
3
+ Rules enforced on top of RFC 8259:
4
+
5
+ * UTF-8 without a byte-order mark (ill-formed UTF-8 is rejected, never replaced);
6
+ * no duplicate object keys after escape decoding, at any depth;
7
+ * no unpaired UTF-16 surrogate escapes;
8
+ * no ``NaN``/``Infinity`` or other non-RFC 8259 tokens.
9
+
10
+ Numbers keep their original spelling so that schema checks can insist on, for
11
+ example, digits-only byte counts (``-0``, ``1.0`` and ``1e3`` are rejected
12
+ where an integer is required).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from typing import Final, TypeAlias
19
+
20
+ from .errors import Incomplete, Invalid, Issue, Outcome
21
+
22
+ __all__ = [
23
+ "MAX_SAFE_INTEGER",
24
+ "JsonFloat",
25
+ "JsonInt",
26
+ "JsonObject",
27
+ "JsonValue",
28
+ "is_json_integer",
29
+ "json_pointer",
30
+ "json_type_name",
31
+ "parse_json",
32
+ "parse_json_object",
33
+ ]
34
+
35
+ MAX_SAFE_INTEGER: Final = 9_007_199_254_740_991
36
+ """2^53 - 1, the largest byte count permitted by §3."""
37
+
38
+ _BOM: Final = b"\xef\xbb\xbf"
39
+
40
+
41
+ class JsonInt(int):
42
+ """An integer literal; ``lexeme`` is its exact source spelling."""
43
+
44
+ lexeme: str
45
+
46
+ def __new__(cls, lexeme: str) -> JsonInt:
47
+ obj = super().__new__(cls, lexeme)
48
+ obj.lexeme = lexeme
49
+ return obj
50
+
51
+
52
+ class JsonFloat(float):
53
+ """A number literal with a fraction or exponent; ``lexeme`` is its spelling."""
54
+
55
+ lexeme: str
56
+
57
+ def __new__(cls, lexeme: str) -> JsonFloat:
58
+ obj = super().__new__(cls, lexeme)
59
+ obj.lexeme = lexeme
60
+ return obj
61
+
62
+
63
+ JsonValue: TypeAlias = (
64
+ "bool | JsonInt | JsonFloat | str | list[JsonValue] | dict[str, JsonValue] | None"
65
+ )
66
+ JsonObject: TypeAlias = "dict[str, JsonValue]"
67
+
68
+
69
+ class _DuplicateKeyError(Exception):
70
+ def __init__(self, key: str) -> None:
71
+ self.key = key
72
+
73
+
74
+ class _ConstantError(Exception):
75
+ def __init__(self, token: str) -> None:
76
+ self.token = token
77
+
78
+
79
+ def _pairs_hook(pairs: list[tuple[str, JsonValue]]) -> dict[str, JsonValue]:
80
+ result: dict[str, JsonValue] = {}
81
+ for key, value in pairs:
82
+ if key in result:
83
+ raise _DuplicateKeyError(key)
84
+ result[key] = value
85
+ return result
86
+
87
+
88
+ def _reject_constant(token: str) -> JsonValue:
89
+ raise _ConstantError(token)
90
+
91
+
92
+ def _has_surrogate(text: str) -> bool:
93
+ # Code points U+D800..U+DFFF only arise from unpaired escapes because the
94
+ # UTF-8 decoder rejects encoded surrogates.
95
+ return any("\ud800" <= ch <= "\udfff" for ch in text)
96
+
97
+
98
+ def _find_surrogate(value: JsonValue, pointer: str) -> str | None:
99
+ stack: list[tuple[JsonValue, str]] = [(value, pointer)]
100
+ while stack:
101
+ item, where = stack.pop()
102
+ if isinstance(item, str):
103
+ if _has_surrogate(item):
104
+ return where
105
+ elif isinstance(item, dict):
106
+ for key, child in item.items():
107
+ child_where = where + "/" + _escape_pointer(key)
108
+ if _has_surrogate(key):
109
+ return child_where
110
+ stack.append((child, child_where))
111
+ elif isinstance(item, list):
112
+ for index, child in enumerate(item):
113
+ stack.append((child, f"{where}/{index}"))
114
+ return None
115
+
116
+
117
+ def _escape_pointer(token: str) -> str:
118
+ return token.replace("~", "~0").replace("/", "~1")
119
+
120
+
121
+ def json_pointer(*tokens: str | int) -> str:
122
+ """Build an RFC 6901 JSON pointer from path tokens."""
123
+ return "".join("/" + _escape_pointer(str(token)) for token in tokens)
124
+
125
+
126
+ def parse_json(data: bytes, *, file: str | None = None, section: str = "7") -> JsonValue:
127
+ """Decode ``data`` under the §7 rules.
128
+
129
+ Raises:
130
+ Invalid: the bytes are not strict JSON.
131
+ Incomplete: nesting exceeded the interpreter's recursion limit (a local
132
+ resource limit, not proof of invalidity).
133
+ """
134
+
135
+ def fail(code: str, requirement: str, observed: str | None = None) -> Invalid:
136
+ return Invalid(Issue(code, requirement, section=section, file=file, observed=observed))
137
+
138
+ if data.startswith(_BOM):
139
+ raise fail("json_bom", "JSON must be UTF-8 without a byte-order mark")
140
+ try:
141
+ text = data.decode("utf-8", errors="strict")
142
+ except UnicodeDecodeError as exc:
143
+ raise fail(
144
+ "json_utf8", "JSON must be well-formed UTF-8", f"byte offset {exc.start}"
145
+ ) from exc
146
+ try:
147
+ value: JsonValue = json.loads(
148
+ text,
149
+ object_pairs_hook=_pairs_hook,
150
+ parse_int=JsonInt,
151
+ parse_float=JsonFloat,
152
+ parse_constant=_reject_constant,
153
+ )
154
+ except _DuplicateKeyError as exc:
155
+ raise fail(
156
+ "json_duplicate_key",
157
+ "object keys must be unique after escape decoding",
158
+ repr(exc.key),
159
+ ) from None
160
+ except _ConstantError as exc:
161
+ raise fail("json_syntax", "RFC 8259 does not permit this token", exc.token) from None
162
+ except json.JSONDecodeError as exc:
163
+ raise fail(
164
+ "json_syntax", "document must be RFC 8259 JSON", f"{exc.msg} at char {exc.pos}"
165
+ ) from None
166
+ except RecursionError:
167
+ raise Incomplete(
168
+ Issue(
169
+ "json_depth_limit",
170
+ "JSON nesting exceeds the local parser limit",
171
+ section="6",
172
+ file=file,
173
+ outcome=Outcome.INCOMPLETE,
174
+ )
175
+ ) from None
176
+ where = _find_surrogate(value, "")
177
+ if where is not None:
178
+ raise Invalid(
179
+ Issue(
180
+ "json_unpaired_surrogate",
181
+ "unpaired UTF-16 surrogate escapes are prohibited",
182
+ section=section,
183
+ file=file,
184
+ field=where or None,
185
+ )
186
+ )
187
+ return value
188
+
189
+
190
+ def parse_json_object(data: bytes, *, file: str | None = None) -> JsonObject:
191
+ """Decode strict JSON and require a top-level object."""
192
+ value = parse_json(data, file=file)
193
+ if not isinstance(value, dict):
194
+ raise Invalid(
195
+ Issue(
196
+ "json_not_object",
197
+ "document must be a JSON object",
198
+ section="7",
199
+ file=file,
200
+ observed=json_type_name(value),
201
+ )
202
+ )
203
+ return value
204
+
205
+
206
+ def is_json_integer(value: JsonValue) -> bool:
207
+ """True for an integer literal (not a boolean, fraction, or exponent)."""
208
+ return type(value) is JsonInt
209
+
210
+
211
+ def json_type_name(value: object) -> str:
212
+ """Return the JSON type name of a decoded value."""
213
+ if value is None:
214
+ return "null"
215
+ if isinstance(value, bool):
216
+ return "boolean"
217
+ if isinstance(value, JsonInt | JsonFloat | int | float):
218
+ return "number"
219
+ if isinstance(value, str):
220
+ return "string"
221
+ if isinstance(value, list):
222
+ return "array"
223
+ if isinstance(value, dict):
224
+ return "object"
225
+ return type(value).__name__