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/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))