Skip to content

Commit c286e37

Browse files
authored
types: add union type with tests (leanEthereum#44)
1 parent ad24371 commit c286e37

2 files changed

Lines changed: 666 additions & 0 deletions

File tree

src/lean_spec/types/union.py

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
"""
2+
Union Type Specification.
3+
4+
A strict SSZ Union type: a tagged sum type encoded as:
5+
6+
selector: uint8 (1 byte)
7+
value: SSZ(value_type) (0 or more bytes depending on selected option)
8+
9+
Notes:
10+
- Only option index 0 may be None (the "null" option). If selected, the value
11+
is omitted and the encoding is just the selector byte.
12+
- A Union is always variable-size overall because its total length depends on
13+
which option is selected (even if some options are fixed-size individually).
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import io
19+
from typing import (
20+
IO,
21+
Any,
22+
ClassVar,
23+
Dict,
24+
Tuple,
25+
Type,
26+
cast,
27+
)
28+
29+
from pydantic.annotated_handlers import GetCoreSchemaHandler
30+
from pydantic_core import CoreSchema, core_schema
31+
from typing_extensions import Self
32+
33+
from .ssz_base import SSZType
34+
35+
_UNION_CACHE: Dict[
36+
Tuple[Type["Union"], Tuple[Type[SSZType] | None, ...]],
37+
Type["Union"],
38+
] = {}
39+
"""
40+
Cache for dynamically created specialized Union types:
41+
key = (base-class, options-tuple-identity)
42+
"""
43+
44+
45+
class Union(SSZType):
46+
"""
47+
A strict SSZ Union type.
48+
49+
Create specialized types with `Union[Opt0, Opt1, ..., OptN]`, where each
50+
OptK is an SSZ type, and only Opt0 may be None (the "null" option).
51+
52+
Example:
53+
MyUnion = Union[None, Uint16, List[Uint8, 32]]
54+
x = MyUnion(selector=1, value=Uint16(42))
55+
y = MyUnion(selector=0, value=None) # the "None" arm
56+
57+
Instances are constructed with explicit selector + value for clarity and
58+
strictness.
59+
"""
60+
61+
OPTIONS: ClassVar[Tuple[Type[SSZType] | None, ...]]
62+
"""The list of options for this specialized Union type."""
63+
64+
def __class_getitem__(
65+
cls, options: Tuple[Type[SSZType] | None, ...] | Type[SSZType] | None
66+
) -> Type["Union"]:
67+
"""
68+
Create a specific Union type with the given options.
69+
70+
Usage:
71+
Union[None, Uint16, Vector[Uint8, 3]]
72+
Union[Uint32] # single option, no None arm
73+
"""
74+
# Normalize single-element syntax (Python passes a non-tuple in that case).
75+
if not isinstance(options, tuple):
76+
options = (options,)
77+
78+
# Basic arity checks.
79+
if len(options) < 1:
80+
raise TypeError("Union expects at least one option")
81+
if len(options) > 128:
82+
raise TypeError(f"Union expects at most 128 options, got {len(options)}")
83+
84+
# Validate option types: only index 0 may be None.
85+
# Use duck-typing for SSZ types (List/Vector specializations may not
86+
# literally subclass SSZType but implement the protocol).
87+
norm_opts: list[Type[SSZType] | None] = list(options)
88+
for i, opt in enumerate(norm_opts):
89+
if opt is None:
90+
if i != 0:
91+
raise TypeError("Only option 0 may be None")
92+
continue
93+
if not isinstance(opt, type):
94+
raise TypeError(f"Option at index {i} must be a type (or None at index 0)")
95+
# Minimal SSZType protocol used at runtime
96+
required_methods = (
97+
"serialize",
98+
"deserialize",
99+
"encode_bytes",
100+
"decode_bytes",
101+
"is_fixed_size",
102+
)
103+
missing = [m for m in required_methods if not hasattr(opt, m)]
104+
if missing:
105+
raise TypeError(
106+
"Option at index "
107+
f"{i} must be an SSZType-like type implementing: "
108+
f"{', '.join(required_methods)}"
109+
)
110+
111+
# If there is a None option, require at least one additional option.
112+
if norm_opts[0] is None and len(norm_opts) < 2:
113+
raise TypeError("Union with None at option 0 must have at least one non-None option")
114+
115+
key = (cls, tuple(norm_opts))
116+
if key in _UNION_CACHE:
117+
return _UNION_CACHE[key]
118+
119+
# Build the specialized class.
120+
label = ", ".join(opt.__name__ if opt is not None else "None" for opt in norm_opts)
121+
type_name = f"{cls.__name__}[{label}]"
122+
new_type = type(
123+
type_name,
124+
(cls,),
125+
{
126+
"OPTIONS": tuple(norm_opts),
127+
"__doc__": (
128+
"A Union over options: "
129+
f"{label}.\n\n"
130+
"Select with selector=index and provide value matching the "
131+
"selected option.\n"
132+
"If selector==0 and option 0 is None, value must be None."
133+
),
134+
},
135+
)
136+
_UNION_CACHE[key] = new_type
137+
return new_type
138+
139+
def __init__(self, *, selector: int, value: Any) -> None:
140+
"""
141+
Construct a Union value by explicitly specifying the selected arm.
142+
143+
Args:
144+
selector: The index of the selected option (0-based).
145+
value: The value for that option, or None if option 0 is the
146+
None arm.
147+
148+
Raises:
149+
TypeError / ValueError for invalid selector or mismatched value type.
150+
"""
151+
# Validate selector in range.
152+
if not isinstance(selector, int) or selector < 0 or selector >= len(self.OPTIONS):
153+
raise ValueError(
154+
"Invalid selector "
155+
f"{selector} for {type(self).__name__} "
156+
f"with {len(self.OPTIONS)} options"
157+
)
158+
159+
# Enforce the typing rule for the chosen arm.
160+
opt_t = self.OPTIONS[selector]
161+
if opt_t is None:
162+
if value is not None:
163+
raise TypeError("Selected option is None, therefore value must be None")
164+
self._selector = selector
165+
self._value = None
166+
return
167+
168+
# Coerce the provided value into the selected SSZ type if needed.
169+
if isinstance(value, opt_t):
170+
coerced = value
171+
else:
172+
coerced = cast(Any, opt_t)(value)
173+
self._selector = selector
174+
self._value = coerced
175+
176+
@classmethod
177+
def options(cls) -> Tuple[Type[SSZType] | None, ...]:
178+
"""Return the options for this specialized Union type."""
179+
return cls.OPTIONS
180+
181+
def selector(self) -> int:
182+
"""Return the selected option index."""
183+
return self._selector
184+
185+
def selected_type(self) -> Type[SSZType] | None:
186+
"""Return the SSZ type of the selected option (or None for the null arm)."""
187+
return self.OPTIONS[self.selector()]
188+
189+
def value(self) -> Any:
190+
"""Return the current value (or None if the null arm is selected)."""
191+
return self._value
192+
193+
@classmethod
194+
def is_fixed_size(cls) -> bool:
195+
"""
196+
A Union is considered variable-size overall.
197+
198+
Even if some (or all) arms are individually fixed-size, the total length
199+
depends on which arm is selected.
200+
"""
201+
return False
202+
203+
def serialize(self, stream: IO[bytes]) -> int:
204+
"""
205+
Serialize as: 1 byte selector, followed by the selected arm's SSZ
206+
encoding (if any).
207+
208+
Returns:
209+
Total number of bytes written.
210+
"""
211+
# Write selector.
212+
sel = self.selector()
213+
stream.write(sel.to_bytes(length=1, byteorder="little"))
214+
total = 1
215+
216+
# Write value if not None-arm.
217+
opt_t = self.selected_type()
218+
if opt_t is None:
219+
return total
220+
221+
val = cast(SSZType, self.value())
222+
total += val.serialize(stream)
223+
return total
224+
225+
@classmethod
226+
def deserialize(cls, stream: IO[bytes], scope: int) -> Self:
227+
"""
228+
Deserialize from a stream with the provided scope (total bytes available).
229+
230+
Layout:
231+
[ selector:1 ][ value-bytes: (scope-1) ]
232+
233+
For the None arm (option 0 == None), scope must be exactly 1.
234+
For any other arm, the remaining scope-1 bytes are passed to that arm's
235+
.deserialize(stream, remaining_scope).
236+
"""
237+
if scope < 1:
238+
raise ValueError("Scope too small: cannot read Union selector")
239+
240+
# Read selector byte.
241+
sel_bytes = stream.read(1)
242+
if len(sel_bytes) != 1:
243+
raise IOError("Stream ended prematurely while decoding Union selector")
244+
245+
selector = int.from_bytes(sel_bytes, "little")
246+
if selector < 0 or selector >= len(cls.OPTIONS):
247+
raise ValueError(
248+
"Selected index "
249+
f"{selector} is out of range for {cls.__name__} "
250+
f"with {len(cls.OPTIONS)} options"
251+
)
252+
253+
remaining = scope - 1
254+
opt_t = cls.OPTIONS[selector]
255+
256+
if opt_t is None:
257+
# None-arm: must have no payload.
258+
if remaining != 0:
259+
raise ValueError("Invalid encoding: None arm must have no payload bytes")
260+
return cls(selector=selector, value=None)
261+
262+
# If the selected arm is fixed-size, ensure we have enough bytes.
263+
if opt_t.is_fixed_size():
264+
# Most fixed-size SSZ types expose get_byte_length()
265+
expected = getattr(opt_t, "get_byte_length", None)
266+
if callable(expected):
267+
need = expected()
268+
if remaining < need:
269+
raise IOError(
270+
f"Insufficient scope for {opt_t.__name__}: need {need}, got {remaining}"
271+
)
272+
273+
# Non-None arm: delegate to the selected type with the remaining scope.
274+
val = opt_t.deserialize(stream, remaining)
275+
return cls(selector=selector, value=val)
276+
277+
def encode_bytes(self) -> bytes:
278+
"""Serialize to bytes [selector || value-encoding]."""
279+
with io.BytesIO() as s:
280+
self.serialize(s)
281+
return s.getvalue()
282+
283+
@classmethod
284+
def decode_bytes(cls, data: bytes) -> Self:
285+
"""Parse from bytes [selector || value-encoding]."""
286+
with io.BytesIO(data) as s:
287+
return cls.deserialize(s, len(data))
288+
289+
@classmethod
290+
def __get_pydantic_core_schema__(
291+
cls, source_type: Any, handler: GetCoreSchemaHandler
292+
) -> CoreSchema:
293+
"""
294+
Pydantic validation:
295+
- Accept an instance of this specialized Union (pass-through).
296+
- Or accept a dict like {'selector': int, 'value': <obj>} and build an
297+
instance. The 'value' is validated/constructed using the selected
298+
option's schema.
299+
- Serialize to a dict {'selector': int, 'value': <obj>} (value None for
300+
the None arm).
301+
"""
302+
303+
def from_mapping(v: Any) -> "Union":
304+
if isinstance(v, cls):
305+
return v
306+
if not isinstance(v, dict):
307+
# Use ValueError so Pydantic wraps into ValidationError
308+
raise ValueError(f"Expected {cls.__name__} or dict, got {type(v).__name__}")
309+
if "selector" not in v or "value" not in v:
310+
raise ValueError("Expected dict with 'selector' and 'value' keys")
311+
sel = v["selector"]
312+
if not isinstance(sel, int):
313+
raise ValueError("selector must be int")
314+
if sel < 0 or sel >= len(cls.OPTIONS):
315+
raise ValueError(f"selector {sel} out of range for {cls.__name__}")
316+
317+
opt_t = cls.OPTIONS[sel]
318+
if opt_t is None:
319+
if v["value"] is not None:
320+
# ValueError -> Pydantic ValidationError
321+
raise ValueError("value must be None for None arm (selector 0)")
322+
return cls(selector=sel, value=None)
323+
324+
# Construct the inner value using the selected SSZ type.
325+
parsed = cast(Any, opt_t)(v["value"])
326+
return cls(selector=sel, value=parsed)
327+
328+
# Serializer to a simple mapping.
329+
def to_obj(u: "Union") -> dict[str, Any]:
330+
sel = u.selector()
331+
val_t = u.selected_type()
332+
if val_t is None:
333+
return {"selector": sel, "value": None}
334+
val = cast(SSZType, u.value())
335+
return {"selector": sel, "value": val}
336+
337+
return core_schema.union_schema(
338+
[
339+
core_schema.is_instance_schema(cls),
340+
core_schema.no_info_plain_validator_function(from_mapping),
341+
],
342+
serialization=core_schema.plain_serializer_function_ser_schema(to_obj),
343+
)
344+
345+
def __eq__(self, other: object) -> bool:
346+
"""
347+
Structural equality for Union instances.
348+
349+
Two Unions are equal if:
350+
- they are of the exact same specialized Union type, and
351+
- they have the same selector, and
352+
- their contained values are equal.
353+
354+
Args:
355+
other: The object to compare against.
356+
357+
Returns:
358+
True if both are equivalent Unions, False otherwise.
359+
"""
360+
if not isinstance(other, type(self)):
361+
return False
362+
return (self.selector() == other.selector()) and (self.value() == other.value())
363+
364+
def __hash__(self) -> int:
365+
"""
366+
Hash based on the specialized Union type, selector, and value.
367+
368+
Ensures Unions can be used reliably as dictionary keys or in sets.
369+
Two Unions that compare equal will also have the same hash.
370+
"""
371+
return hash((type(self), self.selector(), self.value()))
372+
373+
def __repr__(self) -> str:
374+
"""Return a readable representation showing the selector and value."""
375+
tname = type(self).__name__
376+
return f"{tname}(selector={self.selector()}, value={self.value()!r})"

0 commit comments

Comments
 (0)