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/schemas.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Record schema identifiers and the schema check of §5.1/§6."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from .errors import Invalid, Issue, Outcome, Unsupported
|
|
8
|
+
from .jsonstrict import JsonObject, json_type_name
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ARCHIVE_SCHEMA",
|
|
12
|
+
"CLAIM_SCHEMA",
|
|
13
|
+
"INDEX_SCHEMA",
|
|
14
|
+
"KNOWN_SCHEMAS",
|
|
15
|
+
"PROTOCOL_VERSION",
|
|
16
|
+
"PUBLICATION_SCHEMA",
|
|
17
|
+
"REVOCATION_SCHEMA",
|
|
18
|
+
"check_schema",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
PROTOCOL_VERSION: Final = "0.1"
|
|
22
|
+
PUBLICATION_SCHEMA: Final = "sci-publication/0.1"
|
|
23
|
+
CLAIM_SCHEMA: Final = "sci-key-claim/0.1"
|
|
24
|
+
REVOCATION_SCHEMA: Final = "sci-key-revocation/0.1"
|
|
25
|
+
INDEX_SCHEMA: Final = "sci-index/0.1"
|
|
26
|
+
ARCHIVE_SCHEMA: Final = "sci-archive/0.1"
|
|
27
|
+
KNOWN_SCHEMAS: Final = frozenset(
|
|
28
|
+
{PUBLICATION_SCHEMA, CLAIM_SCHEMA, REVOCATION_SCHEMA, INDEX_SCHEMA, ARCHIVE_SCHEMA}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_schema(record: JsonObject, expected: str, *, file: str | None = None) -> None:
|
|
33
|
+
"""Check that ``schema`` names the expected record type and version.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
Unsupported: the schema string identifies no supported schema.
|
|
37
|
+
Invalid: the schema is missing, not a string, or a known schema of a
|
|
38
|
+
different record type.
|
|
39
|
+
"""
|
|
40
|
+
schema = record.get("schema")
|
|
41
|
+
if not isinstance(schema, str):
|
|
42
|
+
raise Invalid(
|
|
43
|
+
Issue(
|
|
44
|
+
"schema_missing",
|
|
45
|
+
"record requires a string schema field",
|
|
46
|
+
section="5.1",
|
|
47
|
+
file=file,
|
|
48
|
+
field="/schema",
|
|
49
|
+
observed=None if "schema" not in record else json_type_name(schema),
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
if schema == expected:
|
|
53
|
+
return
|
|
54
|
+
if schema in KNOWN_SCHEMAS:
|
|
55
|
+
raise Invalid(
|
|
56
|
+
Issue(
|
|
57
|
+
"schema_wrong_type",
|
|
58
|
+
f"schema must identify this record type ({expected})",
|
|
59
|
+
section="5.1",
|
|
60
|
+
file=file,
|
|
61
|
+
field="/schema",
|
|
62
|
+
observed=schema,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
raise Unsupported(
|
|
66
|
+
Issue(
|
|
67
|
+
"schema_unsupported",
|
|
68
|
+
f"unsupported schema; this library supports {expected}",
|
|
69
|
+
section="6",
|
|
70
|
+
file=file,
|
|
71
|
+
field="/schema",
|
|
72
|
+
observed=schema,
|
|
73
|
+
outcome=Outcome.UNSUPPORTED,
|
|
74
|
+
)
|
|
75
|
+
)
|
pysci/source.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Random-access byte sources used by the verifiers.
|
|
2
|
+
|
|
3
|
+
Verification reads bounded ranges and streams large regions in chunks, so a
|
|
4
|
+
64 MiB bundle is never held in memory more than one chunk at a time.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import BinaryIO, Final, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"CHUNK_SIZE",
|
|
17
|
+
"ByteSource",
|
|
18
|
+
"FileSource",
|
|
19
|
+
"MemorySource",
|
|
20
|
+
"PathSource",
|
|
21
|
+
"SliceSource",
|
|
22
|
+
"as_source",
|
|
23
|
+
"iter_chunks",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
CHUNK_SIZE: Final = 1 << 20
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class ByteSource(Protocol):
|
|
31
|
+
"""Read-only random access to a finite byte string."""
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def size(self) -> int: ...
|
|
35
|
+
|
|
36
|
+
def read_at(self, offset: int, length: int) -> bytes:
|
|
37
|
+
"""Return exactly ``length`` bytes at ``offset``; raise on short reads."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MemorySource:
|
|
42
|
+
"""A :class:`ByteSource` over bytes already in memory."""
|
|
43
|
+
|
|
44
|
+
__slots__ = ("_data",)
|
|
45
|
+
|
|
46
|
+
def __init__(self, data: bytes | bytearray | memoryview) -> None:
|
|
47
|
+
self._data = memoryview(data).cast("B") if not isinstance(data, bytes) else data
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def size(self) -> int:
|
|
51
|
+
return len(self._data)
|
|
52
|
+
|
|
53
|
+
def read_at(self, offset: int, length: int) -> bytes:
|
|
54
|
+
_check_range(offset, length, len(self._data))
|
|
55
|
+
return bytes(self._data[offset : offset + length])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FileSource:
|
|
59
|
+
"""A :class:`ByteSource` over a regular file, opened once.
|
|
60
|
+
|
|
61
|
+
The size is captured at open time; reads beyond it fail. Use as a context
|
|
62
|
+
manager or call :meth:`close`.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, path: str | os.PathLike[str]) -> None:
|
|
66
|
+
self.path = Path(path)
|
|
67
|
+
self._file: BinaryIO = open(self.path, "rb") # noqa: SIM115 - closed by close()
|
|
68
|
+
self._size = os.fstat(self._file.fileno()).st_size
|
|
69
|
+
self._lock = threading.Lock()
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def size(self) -> int:
|
|
73
|
+
return self._size
|
|
74
|
+
|
|
75
|
+
def read_at(self, offset: int, length: int) -> bytes:
|
|
76
|
+
_check_range(offset, length, self._size)
|
|
77
|
+
if hasattr(os, "pread"):
|
|
78
|
+
data = os.pread(self._file.fileno(), length, offset)
|
|
79
|
+
else: # pragma: no cover - Windows
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._file.seek(offset)
|
|
82
|
+
data = self._file.read(length)
|
|
83
|
+
if len(data) != length:
|
|
84
|
+
raise OSError(f"short read from {self.path}: file changed during verification")
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
def close(self) -> None:
|
|
88
|
+
self._file.close()
|
|
89
|
+
|
|
90
|
+
def __enter__(self) -> FileSource:
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def __exit__(self, *exc: object) -> None:
|
|
94
|
+
self.close()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class PathSource:
|
|
98
|
+
"""A :class:`ByteSource` that opens a file for each read.
|
|
99
|
+
|
|
100
|
+
Suitable for loose bundles with many files (no descriptor is held open).
|
|
101
|
+
The size is captured at construction; a changed file causes ``OSError``.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
__slots__ = ("_size", "path")
|
|
105
|
+
|
|
106
|
+
def __init__(self, path: str | os.PathLike[str], size: int | None = None) -> None:
|
|
107
|
+
self.path = Path(path)
|
|
108
|
+
self._size = self.path.stat().st_size if size is None else size
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def size(self) -> int:
|
|
112
|
+
return self._size
|
|
113
|
+
|
|
114
|
+
def read_at(self, offset: int, length: int) -> bytes:
|
|
115
|
+
_check_range(offset, length, self._size)
|
|
116
|
+
with open(self.path, "rb") as handle:
|
|
117
|
+
if os.fstat(handle.fileno()).st_size != self._size:
|
|
118
|
+
raise OSError(f"{self.path} changed during verification")
|
|
119
|
+
handle.seek(offset)
|
|
120
|
+
data = handle.read(length)
|
|
121
|
+
if len(data) != length:
|
|
122
|
+
raise OSError(f"short read from {self.path}")
|
|
123
|
+
return data
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class SliceSource:
|
|
127
|
+
"""A window of another source."""
|
|
128
|
+
|
|
129
|
+
__slots__ = ("_base", "_length", "_offset")
|
|
130
|
+
|
|
131
|
+
def __init__(self, base: ByteSource, offset: int, length: int) -> None:
|
|
132
|
+
_check_range(offset, length, base.size)
|
|
133
|
+
self._base, self._offset, self._length = base, offset, length
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def size(self) -> int:
|
|
137
|
+
return self._length
|
|
138
|
+
|
|
139
|
+
def read_at(self, offset: int, length: int) -> bytes:
|
|
140
|
+
_check_range(offset, length, self._length)
|
|
141
|
+
return self._base.read_at(self._offset + offset, length)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_range(offset: int, length: int, size: int) -> None:
|
|
145
|
+
if offset < 0 or length < 0 or offset > size or length > size - offset:
|
|
146
|
+
raise ValueError(f"read of {length} bytes at {offset} exceeds source size {size}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def iter_chunks(
|
|
150
|
+
source: ByteSource, offset: int = 0, length: int | None = None, chunk_size: int = CHUNK_SIZE
|
|
151
|
+
) -> Iterator[bytes]:
|
|
152
|
+
"""Yield the range ``[offset, offset + length)`` in bounded chunks."""
|
|
153
|
+
end = source.size if length is None else offset + length
|
|
154
|
+
_check_range(offset, end - offset, source.size)
|
|
155
|
+
position = offset
|
|
156
|
+
while position < end:
|
|
157
|
+
step = min(chunk_size, end - position)
|
|
158
|
+
yield source.read_at(position, step)
|
|
159
|
+
position += step
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def as_source(value: ByteSource | bytes | bytearray | memoryview) -> ByteSource:
|
|
163
|
+
"""Wrap bytes-like values; pass sources through."""
|
|
164
|
+
if isinstance(value, bytes | bytearray | memoryview):
|
|
165
|
+
return MemorySource(value)
|
|
166
|
+
return value
|
pysci/specification.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The SCI protocol specification text this library implements (bundled package data)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from functools import cache
|
|
8
|
+
from importlib import resources
|
|
9
|
+
|
|
10
|
+
#: SHA-256 of ``protocol/README.md`` for protocol draft 0.1, as pinned by the
|
|
11
|
+
#: conformance corpus (``protocol/test-vectors/0.1/catalog.json``).
|
|
12
|
+
PROTOCOL_SPEC_SHA256 = "311e40720392570938fee3ff67d53e6cef933916b228653d1279a9c3f52afc0d"
|
|
13
|
+
|
|
14
|
+
_HEADING = re.compile(r"^(#{2,6}) (\d+(?:\.\d+)*)\.? ")
|
|
15
|
+
_ANY_HEADING = re.compile(r"^(#{1,6}) ")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@cache
|
|
19
|
+
def protocol_specification() -> str:
|
|
20
|
+
"""Return the complete protocol specification as Markdown.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
RuntimeError: the packaged file is damaged (hash mismatch).
|
|
24
|
+
"""
|
|
25
|
+
data = resources.files("pysci").joinpath("data/sci-protocol.md").read_bytes()
|
|
26
|
+
if hashlib.sha256(data).hexdigest() != PROTOCOL_SPEC_SHA256:
|
|
27
|
+
raise RuntimeError("bundled protocol specification does not match its pinned hash")
|
|
28
|
+
return data.decode("utf-8")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def specification_section(number: str) -> str | None:
|
|
32
|
+
"""Return one numbered section (e.g. ``"4.4"``) with its subsections, or None."""
|
|
33
|
+
lines = protocol_specification().splitlines(keepends=True)
|
|
34
|
+
start: int | None = None
|
|
35
|
+
level = 0
|
|
36
|
+
for index, line in enumerate(lines):
|
|
37
|
+
heading = _ANY_HEADING.match(line)
|
|
38
|
+
if heading is None:
|
|
39
|
+
continue
|
|
40
|
+
if start is None:
|
|
41
|
+
match = _HEADING.match(line)
|
|
42
|
+
if match is not None and match.group(2) == number:
|
|
43
|
+
start, level = index, len(match.group(1))
|
|
44
|
+
elif len(heading.group(1)) <= level:
|
|
45
|
+
return "".join(lines[start:index]).rstrip() + "\n"
|
|
46
|
+
if start is None:
|
|
47
|
+
return None
|
|
48
|
+
return "".join(lines[start:]).rstrip() + "\n"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def specification_sections() -> list[tuple[str, str]]:
|
|
52
|
+
"""List ``(number, title)`` for every numbered heading, in document order."""
|
|
53
|
+
sections = []
|
|
54
|
+
for line in protocol_specification().splitlines():
|
|
55
|
+
match = _HEADING.match(line)
|
|
56
|
+
if match:
|
|
57
|
+
sections.append((match.group(2), line[match.end() :].strip()))
|
|
58
|
+
return sections
|
pysci/submission.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Publication submission contract (protocol §8.1.4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from .accounts import Account
|
|
10
|
+
from .discovery import is_absolute_url
|
|
11
|
+
from .errors import Invalid, Issue, IssueCollector
|
|
12
|
+
from .ids import is_digest, parse_publication_id
|
|
13
|
+
from .jsonstrict import JsonObject, json_type_name, parse_json_object
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"SubmissionError",
|
|
17
|
+
"SubmissionRequest",
|
|
18
|
+
"SubmissionResponse",
|
|
19
|
+
"build_submission_request",
|
|
20
|
+
"parse_submission_request",
|
|
21
|
+
"parse_submission_response",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
_FILE = "submission"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class SubmissionRequest:
|
|
29
|
+
id: str
|
|
30
|
+
descriptors: tuple[str, ...]
|
|
31
|
+
account: Account | None = None
|
|
32
|
+
|
|
33
|
+
def to_json_bytes(self) -> bytes:
|
|
34
|
+
document: dict[str, object] = {"id": self.id, "descriptors": list(self.descriptors)}
|
|
35
|
+
if self.account is not None:
|
|
36
|
+
document["account"] = self.account.to_json()
|
|
37
|
+
return json.dumps(document, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class SubmissionError:
|
|
42
|
+
"""The §8.1.4 error object."""
|
|
43
|
+
|
|
44
|
+
code: str
|
|
45
|
+
message: str
|
|
46
|
+
retryable: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class SubmissionResponse:
|
|
51
|
+
"""A conforming response: success (``id`` set) or error (``error`` set).
|
|
52
|
+
|
|
53
|
+
``body`` keeps the full decoded object so service-specific fields can be
|
|
54
|
+
reported; protocol success confirms location registration only, not
|
|
55
|
+
catalog admission.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
status: int
|
|
59
|
+
id: str | None
|
|
60
|
+
error: SubmissionError | None
|
|
61
|
+
body: JsonObject
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def success(self) -> bool:
|
|
65
|
+
return self.error is None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_submission_request(
|
|
69
|
+
publication_id: str, descriptors: Sequence[str], account: Account | None = None
|
|
70
|
+
) -> SubmissionRequest:
|
|
71
|
+
"""Build a request from any accepted ID spelling. Validates the result."""
|
|
72
|
+
request = SubmissionRequest(
|
|
73
|
+
parse_publication_id(publication_id, allow_bare=True), tuple(descriptors), account
|
|
74
|
+
)
|
|
75
|
+
parse_submission_request(request.to_json_bytes())
|
|
76
|
+
return request
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_submission_request(data: bytes) -> SubmissionRequest:
|
|
80
|
+
"""Validate a request body (§8.1.4). Raises :class:`~pysci.errors.Invalid` with all issues."""
|
|
81
|
+
record = parse_json_object(data, file=_FILE)
|
|
82
|
+
issues = IssueCollector()
|
|
83
|
+
missing = [name for name in ("id", "descriptors") if name not in record]
|
|
84
|
+
extra = sorted(set(record) - {"id", "descriptors", "account"})
|
|
85
|
+
if missing:
|
|
86
|
+
issues.add(
|
|
87
|
+
"submission_fields",
|
|
88
|
+
"request requires id and descriptors",
|
|
89
|
+
section="8.1.4",
|
|
90
|
+
file=_FILE,
|
|
91
|
+
observed=missing,
|
|
92
|
+
)
|
|
93
|
+
if extra:
|
|
94
|
+
issues.add(
|
|
95
|
+
"submission_fields",
|
|
96
|
+
"request permits only id, descriptors, and account",
|
|
97
|
+
section="8.1.4",
|
|
98
|
+
file=_FILE,
|
|
99
|
+
observed=extra,
|
|
100
|
+
)
|
|
101
|
+
publication = record.get("id")
|
|
102
|
+
if "id" in record and not (isinstance(publication, str) and is_digest(publication)):
|
|
103
|
+
issues.add(
|
|
104
|
+
"submission_id",
|
|
105
|
+
"id must be 64 lowercase hexadecimal characters without a prefix",
|
|
106
|
+
section="8.1.1",
|
|
107
|
+
file=_FILE,
|
|
108
|
+
field="/id",
|
|
109
|
+
observed=publication,
|
|
110
|
+
)
|
|
111
|
+
descriptors = record.get("descriptors")
|
|
112
|
+
if "descriptors" in record:
|
|
113
|
+
if not isinstance(descriptors, list) or not descriptors:
|
|
114
|
+
issues.add(
|
|
115
|
+
"submission_descriptors",
|
|
116
|
+
"descriptors must be a nonempty array",
|
|
117
|
+
section="8.1.4",
|
|
118
|
+
file=_FILE,
|
|
119
|
+
field="/descriptors",
|
|
120
|
+
observed=json_type_name(descriptors),
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
for index, url in enumerate(descriptors):
|
|
124
|
+
if not (isinstance(url, str) and is_absolute_url(url)):
|
|
125
|
+
issues.add(
|
|
126
|
+
"submission_descriptors",
|
|
127
|
+
"each descriptor must be an absolute URL string",
|
|
128
|
+
section="8.1.4",
|
|
129
|
+
file=_FILE,
|
|
130
|
+
field=f"/descriptors/{index}",
|
|
131
|
+
observed=url,
|
|
132
|
+
)
|
|
133
|
+
if len({json.dumps(u) for u in descriptors}) != len(descriptors):
|
|
134
|
+
issues.add(
|
|
135
|
+
"submission_descriptors",
|
|
136
|
+
"descriptors must be distinct",
|
|
137
|
+
section="8.1.4",
|
|
138
|
+
file=_FILE,
|
|
139
|
+
field="/descriptors",
|
|
140
|
+
)
|
|
141
|
+
account = None
|
|
142
|
+
if "account" in record:
|
|
143
|
+
value = record["account"]
|
|
144
|
+
if not (
|
|
145
|
+
isinstance(value, dict)
|
|
146
|
+
and set(value) == {"type", "id"}
|
|
147
|
+
and all(isinstance(v, str) for v in value.values())
|
|
148
|
+
):
|
|
149
|
+
issues.add(
|
|
150
|
+
"submission_account",
|
|
151
|
+
"account must have exactly two string fields, type and id",
|
|
152
|
+
section="5.2.1",
|
|
153
|
+
file=_FILE,
|
|
154
|
+
field="/account",
|
|
155
|
+
observed=sorted(value) if isinstance(value, dict) else json_type_name(value),
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
account = Account(str(value["type"]), str(value["id"]))
|
|
159
|
+
issues.raise_if_any()
|
|
160
|
+
assert isinstance(publication, str)
|
|
161
|
+
assert isinstance(descriptors, list)
|
|
162
|
+
return SubmissionRequest(publication, tuple(str(u) for u in descriptors), account)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def parse_submission_response(
|
|
166
|
+
status: int, content_type: str | None, body: bytes, requested_id: str
|
|
167
|
+
) -> SubmissionResponse:
|
|
168
|
+
"""Validate a response against the §8.1.4 contract.
|
|
169
|
+
|
|
170
|
+
Success is ``200``/``201`` with an object whose ``id`` equals the
|
|
171
|
+
requested digest; errors are ``4xx``/``5xx`` with an ``error`` object
|
|
172
|
+
holding string ``code``, string ``message``, and Boolean ``retryable``.
|
|
173
|
+
Both require ``application/json``.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
Invalid: the response violates the contract (e.g. wrong status class,
|
|
177
|
+
content type, ID, or error shape).
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def fail(code: str, requirement: str, observed: object = None) -> Invalid:
|
|
181
|
+
return Invalid(
|
|
182
|
+
Issue(
|
|
183
|
+
code,
|
|
184
|
+
requirement,
|
|
185
|
+
section="8.1.4",
|
|
186
|
+
file=_FILE,
|
|
187
|
+
observed=None if observed is None else str(observed),
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
requested = parse_publication_id(requested_id, allow_bare=True)
|
|
192
|
+
media_type = (content_type or "").split(";", 1)[0].strip().lower()
|
|
193
|
+
if media_type != "application/json":
|
|
194
|
+
raise fail("submission_content_type", "responses must use application/json", content_type)
|
|
195
|
+
record = parse_json_object(body, file=_FILE)
|
|
196
|
+
if status in (200, 201):
|
|
197
|
+
if record.get("id") != requested:
|
|
198
|
+
raise fail(
|
|
199
|
+
"submission_response_id",
|
|
200
|
+
"success response id must equal the requested digest",
|
|
201
|
+
record.get("id"),
|
|
202
|
+
)
|
|
203
|
+
return SubmissionResponse(status, requested, None, record)
|
|
204
|
+
if 400 <= status <= 599:
|
|
205
|
+
error = record.get("error")
|
|
206
|
+
if not (
|
|
207
|
+
isinstance(error, dict)
|
|
208
|
+
and isinstance(error.get("code"), str)
|
|
209
|
+
and isinstance(error.get("message"), str)
|
|
210
|
+
and type(error.get("retryable")) is bool
|
|
211
|
+
):
|
|
212
|
+
raise fail(
|
|
213
|
+
"submission_error_shape",
|
|
214
|
+
"error responses need an error object with "
|
|
215
|
+
"string code, string message, and Boolean retryable",
|
|
216
|
+
)
|
|
217
|
+
return SubmissionResponse(
|
|
218
|
+
status,
|
|
219
|
+
None,
|
|
220
|
+
SubmissionError(str(error["code"]), str(error["message"]), bool(error["retryable"])),
|
|
221
|
+
record,
|
|
222
|
+
)
|
|
223
|
+
raise fail(
|
|
224
|
+
"submission_status", "responses use 200/201 for success or 4xx/5xx for errors", status
|
|
225
|
+
)
|
pysci/templates.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""RFC 6570 URI templates restricted to the expressions used by SCI (§8).
|
|
2
|
+
|
|
3
|
+
Only single-variable Level 1 (``{var}``) and Level 2 reserved (``{+var}``)
|
|
4
|
+
expressions are supported, which covers every expression the protocol
|
|
5
|
+
permits: ``{id}``, ``{id2}``, ``{x}``, ``{file}``, ``{sha256}``, ``{+path}``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Final
|
|
14
|
+
|
|
15
|
+
__all__ = ["Expression", "UrlTemplate", "parse_template"]
|
|
16
|
+
|
|
17
|
+
_UNRESERVED: Final = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
|
|
18
|
+
_RESERVED: Final = frozenset(":/?#[]@!$&'()*+,;=")
|
|
19
|
+
_VARNAME: Final = re.compile(
|
|
20
|
+
r"(?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2})+(?:\.(?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2})+)*"
|
|
21
|
+
)
|
|
22
|
+
_FORBIDDEN_LITERAL: Final = frozenset(" \"'<>\\^`{|}")
|
|
23
|
+
_HEX: Final = frozenset("0123456789abcdefABCDEF")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Expression:
|
|
28
|
+
"""One template expression; ``operator`` is ``""`` or ``"+"``."""
|
|
29
|
+
|
|
30
|
+
operator: str
|
|
31
|
+
name: str
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str:
|
|
34
|
+
return "{" + self.operator + self.name + "}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class UrlTemplate:
|
|
39
|
+
"""A parsed template: literal strings interleaved with expressions."""
|
|
40
|
+
|
|
41
|
+
text: str
|
|
42
|
+
parts: tuple[str | Expression, ...]
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def expressions(self) -> frozenset[Expression]:
|
|
46
|
+
return frozenset(p for p in self.parts if isinstance(p, Expression))
|
|
47
|
+
|
|
48
|
+
def expand(self, variables: Mapping[str, str]) -> str:
|
|
49
|
+
"""Expand with string values (RFC 6570 §3.2.2 simple, §3.2.3 reserved).
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
KeyError: a variable is missing.
|
|
53
|
+
"""
|
|
54
|
+
out: list[str] = []
|
|
55
|
+
for part in self.parts:
|
|
56
|
+
if isinstance(part, str):
|
|
57
|
+
out.append(_encode(part, allow_reserved=True))
|
|
58
|
+
else:
|
|
59
|
+
out.append(_encode(variables[part.name], allow_reserved=part.operator == "+"))
|
|
60
|
+
return "".join(out)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _encode(value: str, *, allow_reserved: bool) -> str:
|
|
64
|
+
allowed = _UNRESERVED | _RESERVED if allow_reserved else _UNRESERVED
|
|
65
|
+
out: list[str] = []
|
|
66
|
+
index = 0
|
|
67
|
+
while index < len(value):
|
|
68
|
+
ch = value[index]
|
|
69
|
+
if (
|
|
70
|
+
allow_reserved
|
|
71
|
+
and ch == "%"
|
|
72
|
+
and index + 2 < len(value)
|
|
73
|
+
and value[index + 1] in _HEX
|
|
74
|
+
and value[index + 2] in _HEX
|
|
75
|
+
):
|
|
76
|
+
out.append(value[index : index + 3])
|
|
77
|
+
index += 3
|
|
78
|
+
continue
|
|
79
|
+
if ch in allowed:
|
|
80
|
+
out.append(ch)
|
|
81
|
+
else:
|
|
82
|
+
out.extend(f"%{byte:02X}" for byte in ch.encode("utf-8"))
|
|
83
|
+
index += 1
|
|
84
|
+
return "".join(out)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_template(text: str) -> UrlTemplate:
|
|
88
|
+
"""Parse a template restricted to ``{var}`` and ``{+var}`` expressions.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: malformed template, unsupported operator or modifier, or a
|
|
92
|
+
literal character RFC 6570 does not permit.
|
|
93
|
+
"""
|
|
94
|
+
parts: list[str | Expression] = []
|
|
95
|
+
literal: list[str] = []
|
|
96
|
+
index = 0
|
|
97
|
+
while index < len(text):
|
|
98
|
+
ch = text[index]
|
|
99
|
+
if ch == "{":
|
|
100
|
+
end = text.find("}", index + 1)
|
|
101
|
+
if end < 0:
|
|
102
|
+
raise ValueError("unclosed template expression")
|
|
103
|
+
body = text[index + 1 : end]
|
|
104
|
+
if "{" in body:
|
|
105
|
+
raise ValueError("nested '{' in template expression")
|
|
106
|
+
operator = ""
|
|
107
|
+
if body[:1] == "+":
|
|
108
|
+
operator, body = "+", body[1:]
|
|
109
|
+
elif body[:1] and not (body[0].isalnum() or body[0] in "_%"):
|
|
110
|
+
raise ValueError(f"unsupported template operator {body[0]!r}")
|
|
111
|
+
if not _VARNAME.fullmatch(body):
|
|
112
|
+
raise ValueError(f"unsupported template expression {text[index : end + 1]!r}")
|
|
113
|
+
if literal:
|
|
114
|
+
parts.append("".join(literal))
|
|
115
|
+
literal = []
|
|
116
|
+
parts.append(Expression(operator, body))
|
|
117
|
+
index = end + 1
|
|
118
|
+
continue
|
|
119
|
+
if ch == "}" or ch in _FORBIDDEN_LITERAL or ord(ch) < 0x21 or ord(ch) == 0x7F:
|
|
120
|
+
raise ValueError(f"character {ch!r} is not permitted in a URI template literal")
|
|
121
|
+
if ch == "%" and not (
|
|
122
|
+
index + 2 < len(text) and text[index + 1] in _HEX and text[index + 2] in _HEX
|
|
123
|
+
):
|
|
124
|
+
raise ValueError("'%' must begin a percent-encoded triplet")
|
|
125
|
+
literal.append(ch)
|
|
126
|
+
index += 1
|
|
127
|
+
if literal:
|
|
128
|
+
parts.append("".join(literal))
|
|
129
|
+
return UrlTemplate(text, tuple(parts))
|