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
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__
|