weftcodec 1.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.
@@ -0,0 +1,224 @@
1
+ """Dependency resolution for REFERENCE/DELTA/DICTIONARY frames.
2
+
3
+ Resolution contract (WEFTCODEC-FORMAT §5)::
4
+
5
+ resolve(hash, decoded_len, dep_domain) -> bytes | typed_error
6
+
7
+ The decoder verifies the resolved bytes' length and content hash itself; a
8
+ resolver may never substitute another representation.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from pathlib import Path
15
+
16
+ from .errors import (
17
+ DependencyCycle,
18
+ DependencyMismatch,
19
+ DependencyMissing,
20
+ Io,
21
+ LimitExceeded,
22
+ )
23
+ from .frame import Dependency
24
+ from .hashing import content_root
25
+
26
+
27
+ class Resolver:
28
+ """Resolver protocol. Implementations return the exact dependency bytes.
29
+
30
+ ``resolve_frame`` is optional: a resolver that can also supply complete
31
+ frames (mode 11/14/16 use it to verify a base frame's own dtype/shape)
32
+ overrides it. The default reports frames as unavailable.
33
+ """
34
+
35
+ def resolve(self, hash_bytes: bytes, decoded_len: int, dep_domain: int) -> bytes:
36
+ raise NotImplementedError
37
+
38
+ def resolve_frame(self, content_hash: bytes) -> "bytes | None":
39
+ return None
40
+
41
+
42
+ class DictResolver(Resolver):
43
+ """In-memory resolver keyed by a 32-byte digest (hex keys accepted)."""
44
+
45
+ def __init__(self, entries: "dict[bytes | str, bytes] | None" = None) -> None:
46
+ self._entries = {}
47
+ self._frames = {}
48
+ if entries:
49
+ for key, value in entries.items():
50
+ self.add(key, value)
51
+
52
+ def add(self, key: "bytes | str", value: bytes) -> None:
53
+ raw = bytes.fromhex(key) if isinstance(key, str) else bytes(key)
54
+ if len(raw) != 32:
55
+ raise ValueError("dependency key must be a 32-byte digest")
56
+ self._entries[raw] = bytes(value)
57
+
58
+ def add_frame(self, key: "bytes | str", value: bytes) -> None:
59
+ raw = bytes.fromhex(key) if isinstance(key, str) else bytes(key)
60
+ if len(raw) != 32:
61
+ raise ValueError("frame key must be a 32-byte digest")
62
+ self._frames[raw] = bytes(value)
63
+
64
+ def resolve(self, hash_bytes: bytes, decoded_len: int, dep_domain: int) -> bytes:
65
+ try:
66
+ return self._entries[hash_bytes]
67
+ except KeyError:
68
+ raise DependencyMissing(
69
+ "dependency %s is not present in the resolver" % hash_bytes.hex()
70
+ )
71
+
72
+ def resolve_frame(self, content_hash: bytes) -> "bytes | None":
73
+ return self._frames.get(content_hash)
74
+
75
+
76
+ class DirectoryResolver(Resolver):
77
+ """Resolve dependencies from a directory of files named by lowercase hex.
78
+
79
+ This matches the Rust CLI's ``verify --deps DIR`` layout.
80
+ """
81
+
82
+ def __init__(self, directory: "str | os.PathLike[str]") -> None:
83
+ self.directory = Path(directory)
84
+
85
+ def resolve(self, hash_bytes: bytes, decoded_len: int, dep_domain: int) -> bytes:
86
+ path = self.directory / hash_bytes.hex()
87
+ try:
88
+ with open(path, "rb") as handle:
89
+ return handle.read()
90
+ except FileNotFoundError:
91
+ raise DependencyMissing(
92
+ "dependency %s is not present in %s" % (hash_bytes.hex(), self.directory)
93
+ )
94
+ except OSError as exc:
95
+ raise Io("cannot read dependency %s: %s" % (path, exc))
96
+
97
+
98
+ class FrameSourceResolver(Resolver):
99
+ """Resolve dependency plaintext by decoding frames from a frame source.
100
+
101
+ Mirrors the Rust ``DagResolver``: fetches by content hash, parses each
102
+ frame, decodes recursively with a visiting set (cycle detection), and
103
+ verifies the decoded length and plaintext hash at every boundary. Depth,
104
+ total fetched bytes, and cumulative decoded bytes are enforced.
105
+ """
106
+
107
+ def __init__(self, frames, limits, visiting=None, depth=0, stats=None) -> None:
108
+ self.frames = frames
109
+ self.limits = limits
110
+ self.visiting = visiting if visiting is not None else set()
111
+ self.depth = depth
112
+ self.stats = stats if stats is not None else {"fetched": 0, "decoded": 0}
113
+
114
+ def _frame_bytes(self, content_hash: bytes) -> "bytes | None":
115
+ return self.frames.get(bytes(content_hash))
116
+
117
+ def resolve_frame(self, content_hash: bytes) -> "bytes | None":
118
+ return self._frame_bytes(content_hash)
119
+
120
+ def _child(self, depth: int) -> "FrameSourceResolver":
121
+ return FrameSourceResolver(
122
+ self.frames, self.limits, self.visiting, depth, self.stats
123
+ )
124
+
125
+ def resolve(self, hash_bytes: bytes, decoded_len: int, dep_domain: int) -> bytes:
126
+ from .frame import parse
127
+ from .modes import decode_bytes
128
+
129
+ depth = self.depth + 1
130
+ if depth > self.limits.max_dependency_depth:
131
+ raise LimitExceeded(
132
+ "dependency depth %d exceeds limit %d"
133
+ % (depth, self.limits.max_dependency_depth)
134
+ )
135
+ if hash_bytes in self.visiting:
136
+ raise DependencyCycle(
137
+ "dependency cycle re-entered frame %s" % hash_bytes.hex()
138
+ )
139
+ frame_bytes = self._frame_bytes(hash_bytes)
140
+ if frame_bytes is None:
141
+ raise DependencyMissing(
142
+ "frame source has no frame for %s" % hash_bytes.hex()
143
+ )
144
+ self.stats["fetched"] += len(frame_bytes)
145
+ if self.stats["fetched"] > self.limits.max_total_fetched_bytes:
146
+ raise LimitExceeded(
147
+ "total fetched bytes %d exceed limit %d"
148
+ % (self.stats["fetched"], self.limits.max_total_fetched_bytes)
149
+ )
150
+ parsed = parse(frame_bytes, self.limits)
151
+ if parsed.header.content_hash != hash_bytes:
152
+ raise DependencyMismatch(
153
+ "frame at %s declares content hash %s"
154
+ % (hash_bytes.hex(), parsed.header.content_hash.hex())
155
+ )
156
+
157
+ self.visiting.add(hash_bytes)
158
+ child = self._child(depth)
159
+ try:
160
+ decoded = decode_bytes(frame_bytes, child, self.limits)
161
+ finally:
162
+ self.visiting.discard(hash_bytes)
163
+ if len(decoded) != decoded_len:
164
+ raise DependencyMismatch(
165
+ "dependency frame %s decodes to %d bytes, declared %d"
166
+ % (hash_bytes.hex(), len(decoded), decoded_len)
167
+ )
168
+ if content_root(decoded) != hash_bytes:
169
+ raise DependencyMismatch(
170
+ "dependency frame plaintext hash mismatch"
171
+ )
172
+ self.stats["decoded"] += len(decoded)
173
+ if self.stats["decoded"] > self.limits.max_decoded_bytes:
174
+ raise LimitExceeded(
175
+ "cumulative decoded dependency bytes %d exceed limit %d"
176
+ % (self.stats["decoded"], self.limits.max_decoded_bytes)
177
+ )
178
+ return decoded
179
+
180
+
181
+ class DirectoryFrameSourceResolver(FrameSourceResolver):
182
+ """Frame source backed by a directory of frames named by content hash."""
183
+
184
+ def __init__(self, directory, limits, visiting=None, depth=0, stats=None) -> None:
185
+ self.directory = Path(directory)
186
+ super().__init__({}, limits, visiting, depth, stats)
187
+
188
+ def _frame_bytes(self, content_hash: bytes) -> "bytes | None":
189
+ path = self.directory / bytes(content_hash).hex()
190
+ try:
191
+ with open(path, "rb") as handle:
192
+ return handle.read()
193
+ except FileNotFoundError:
194
+ return None
195
+ except OSError as exc:
196
+ raise Io("cannot read frame %s: %s" % (path, exc))
197
+
198
+ def _child(self, depth: int) -> "FrameSourceResolver":
199
+ return DirectoryFrameSourceResolver(
200
+ self.directory, self.limits, self.visiting, depth, self.stats
201
+ )
202
+
203
+
204
+ def resolve_dependency(
205
+ dependency: Dependency, resolver: "Resolver | None"
206
+ ) -> bytes:
207
+ """Resolve and verify one dependency entry."""
208
+ if resolver is None:
209
+ raise DependencyMissing(
210
+ "frame needs dependency %s but no resolver was supplied"
211
+ % dependency.hash.hex()
212
+ )
213
+ data = resolver.resolve(dependency.hash, dependency.decoded_len, dependency.dep_domain)
214
+ if not isinstance(data, (bytes, bytearray, memoryview)):
215
+ raise DependencyMismatch("resolver returned a non-bytes value")
216
+ data = bytes(data)
217
+ if len(data) != dependency.decoded_len:
218
+ raise DependencyMismatch(
219
+ "dependency length %d differs from declared %d"
220
+ % (len(data), dependency.decoded_len)
221
+ )
222
+ if content_root(data) != dependency.hash:
223
+ raise DependencyMismatch("dependency content hash mismatch")
224
+ return data
weftcodec/errors.py ADDED
@@ -0,0 +1,119 @@
1
+ """Typed error classes for WeftCodec v1.
2
+
3
+ The interoperable contract is the class name, not the human-readable message.
4
+ Names follow ``spec/WEFTCODEC-FORMAT.md`` §11 exactly so a conformance vector's
5
+ ``expect.error`` string can be compared directly with
6
+ ``WeftError.error_class``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class WeftError(Exception):
13
+ """Base class for every typed codec error."""
14
+
15
+ error_class = "Malformed"
16
+
17
+ def __init__(self, message: str = "") -> None:
18
+ super().__init__(message)
19
+ self.message = message
20
+
21
+
22
+ class Malformed(WeftError):
23
+ """Structural or byte error, including bad varints and bad tables."""
24
+
25
+ error_class = "Malformed"
26
+
27
+
28
+ class Corrupt(WeftError):
29
+ """Checksum, hash, or length mismatch."""
30
+
31
+ error_class = "Corrupt"
32
+
33
+
34
+ class UnsupportedVersion(WeftError):
35
+ """Major/minor not supported."""
36
+
37
+ error_class = "UnsupportedVersion"
38
+
39
+
40
+ class UnsupportedMode(WeftError):
41
+ """Mode not implementable."""
42
+
43
+ error_class = "UnsupportedMode"
44
+
45
+
46
+ class UnsupportedFeature(WeftError):
47
+ """Unknown critical flag or feature."""
48
+
49
+ error_class = "UnsupportedFeature"
50
+
51
+
52
+ class LimitExceeded(WeftError):
53
+ """A configured resource bound would be exceeded."""
54
+
55
+ error_class = "LimitExceeded"
56
+
57
+
58
+ class DependencyMissing(WeftError):
59
+ """A dependency cannot be found."""
60
+
61
+ error_class = "DependencyMissing"
62
+
63
+
64
+ class DependencyDenied(WeftError):
65
+ """Authorization refuses the dependency."""
66
+
67
+ error_class = "DependencyDenied"
68
+
69
+
70
+ class DependencyMismatch(WeftError):
71
+ """Resolved bytes fail hash or length verification."""
72
+
73
+ error_class = "DependencyMismatch"
74
+
75
+
76
+ class DependencyCycle(WeftError):
77
+ """The reconstruction dependency graph is cyclic."""
78
+
79
+ error_class = "DependencyCycle"
80
+
81
+
82
+ class NonCanonical(WeftError):
83
+ """Structured bytes violate WCB canonical form."""
84
+
85
+ error_class = "NonCanonical"
86
+
87
+
88
+ class Io(WeftError):
89
+ """Transport or read failure."""
90
+
91
+ error_class = "Io"
92
+
93
+
94
+ ERROR_CLASSES = {
95
+ cls.error_class: cls
96
+ for cls in (
97
+ Malformed,
98
+ Corrupt,
99
+ UnsupportedVersion,
100
+ UnsupportedMode,
101
+ UnsupportedFeature,
102
+ LimitExceeded,
103
+ DependencyMissing,
104
+ DependencyDenied,
105
+ DependencyMismatch,
106
+ DependencyCycle,
107
+ NonCanonical,
108
+ Io,
109
+ )
110
+ }
111
+
112
+
113
+ def error_for_class(name: str, message: str = "") -> WeftError:
114
+ """Build the typed exception for a normative class name."""
115
+ try:
116
+ cls = ERROR_CLASSES[name]
117
+ except KeyError:
118
+ raise ValueError("unknown Weft error class %r" % (name,))
119
+ return cls(message)