Skip to content

Commit 8245311

Browse files
authored
types: add Boolean type and tests (leanEthereum#40)
1 parent 5d7c60a commit 8245311

3 files changed

Lines changed: 349 additions & 2 deletions

File tree

src/lean_spec/types/boolean.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Boolean Type Specification."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from pydantic.annotated_handlers import GetCoreSchemaHandler
8+
from pydantic_core import CoreSchema, core_schema
9+
from typing_extensions import Self
10+
11+
12+
class Boolean(int):
13+
"""
14+
A strict SSZ Boolean type that inherits from `int` for `True`/`False` representation.
15+
16+
This class provides a distinct type for SSZ booleans (`True` as `1`, `False` as `0`).
17+
18+
It integrates with Pydantic for strict validation.
19+
20+
It explicitly disallows standard integer arithmetic to prevent ambiguous operations.
21+
"""
22+
23+
__slots__ = ()
24+
25+
def __new__(cls, value: bool | int) -> Self:
26+
"""
27+
Create and validate a new Boolean instance.
28+
29+
Accepts only `True`, `False`, `1`, or `0`.
30+
31+
Raises:
32+
TypeError: If `value` is not a bool or int.
33+
ValueError: If `value` is an integer other than 0 or 1.
34+
"""
35+
if not isinstance(value, int):
36+
raise TypeError(f"Expected bool or int, got {type(value).__name__}")
37+
38+
int_value = int(value)
39+
if int_value not in (0, 1):
40+
raise ValueError(f"Boolean value must be 0 or 1, not {int_value}")
41+
42+
return super().__new__(cls, int_value)
43+
44+
@classmethod
45+
def __get_pydantic_core_schema__(
46+
cls, source_type: Any, handler: GetCoreSchemaHandler
47+
) -> CoreSchema:
48+
"""
49+
Hook into Pydantic's validation system for strict boolean validation.
50+
51+
This schema ensures that only `True` or `False` are accepted during
52+
Pydantic model validation.
53+
"""
54+
# Validator that takes a standard bool and returns an instance of our class.
55+
from_bool_validator = core_schema.no_info_plain_validator_function(cls)
56+
57+
# Schema that first validates the input is a strict bool, then calls our validator.
58+
python_schema = core_schema.chain_schema(
59+
[core_schema.bool_schema(strict=True), from_bool_validator]
60+
)
61+
62+
return core_schema.union_schema(
63+
[
64+
# Case 1: The value is already our custom Boolean type.
65+
core_schema.is_instance_schema(cls),
66+
# Case 2: The value is a standard bool and needs to be validated and wrapped.
67+
python_schema,
68+
],
69+
# For serialization (e.g., to JSON), convert the instance back to a plain bool.
70+
serialization=core_schema.plain_serializer_function_ser_schema(bool),
71+
)
72+
73+
def encode_bytes(self) -> bytes:
74+
r"""
75+
Serializes the boolean to its SSZ byte representation.
76+
- `True` -> `b'\\x01'`
77+
- `False` -> `b'\\x00'`
78+
"""
79+
return b"\x01" if self else b"\x00"
80+
81+
def _raise_type_error(self, other: Any, op_symbol: str) -> None:
82+
"""Helper to raise a consistent TypeError for unsupported operations."""
83+
raise TypeError(
84+
f"Unsupported operand type(s) for {op_symbol}: "
85+
f"'{type(self).__name__}' and '{type(other).__name__}'"
86+
)
87+
88+
def __add__(self, other: Any) -> Self:
89+
"""Disable the addition operator (`+`)."""
90+
raise TypeError("Arithmetic operations are not supported for Boolean.")
91+
92+
def __radd__(self, other: Any) -> Self:
93+
"""Disable the reverse addition operator (`+`)."""
94+
raise TypeError("Arithmetic operations are not supported for Boolean.")
95+
96+
def __sub__(self, other: Any) -> Self:
97+
"""Disable the subtraction operator (`-`)."""
98+
raise TypeError("Arithmetic operations are not supported for Boolean.")
99+
100+
def __rsub__(self, other: Any) -> Self:
101+
"""Disable the reverse subtraction operator (`-`)."""
102+
raise TypeError("Arithmetic operations are not supported for Boolean.")
103+
104+
def __and__(self, other: Any) -> Self:
105+
"""Handle the bitwise AND operator (`&`) strictly."""
106+
if not isinstance(other, type(self)):
107+
self._raise_type_error(other, "&")
108+
return type(self)(super().__and__(other))
109+
110+
def __rand__(self, other: Any) -> Self:
111+
"""Handle the reverse bitwise AND operator (`&`) strictly."""
112+
return self.__and__(other)
113+
114+
def __or__(self, other: Any) -> Self:
115+
"""Handle the bitwise OR operator (`|`) strictly."""
116+
if not isinstance(other, type(self)):
117+
self._raise_type_error(other, "|")
118+
return type(self)(super().__or__(other))
119+
120+
def __ror__(self, other: Any) -> Self:
121+
"""Handle the reverse bitwise OR operator (`|`) strictly."""
122+
return self.__or__(other)
123+
124+
def __xor__(self, other: Any) -> Self:
125+
"""Handle the bitwise XOR operator (`^`) strictly."""
126+
if not isinstance(other, type(self)):
127+
self._raise_type_error(other, "^")
128+
return type(self)(super().__xor__(other))
129+
130+
def __rxor__(self, other: Any) -> Self:
131+
"""Handle the reverse bitwise XOR operator (`^`) strictly."""
132+
return self.__xor__(other)
133+
134+
def __eq__(self, other: object) -> bool:
135+
"""
136+
Handle the equality operator (`==`).
137+
138+
Allows comparison with native `bool` and `int` types (0 or 1).
139+
140+
It returns `False` for all other types.
141+
"""
142+
if isinstance(other, int):
143+
return int(self) == int(other)
144+
return False
145+
146+
def __ne__(self, other: object) -> bool:
147+
"""
148+
Handle the inequality operator (`!=`).
149+
150+
Allows comparison with native `bool` and `int` types (0 or 1).
151+
152+
It returns `True` for all other types.
153+
"""
154+
return not self.__eq__(other)
155+
156+
def __repr__(self) -> str:
157+
"""Return the official string representation of the object."""
158+
return f"Boolean({bool(self)})"
159+
160+
def __str__(self) -> str:
161+
"""Return the informal, user-friendly string representation."""
162+
return str(bool(self))
163+
164+
def __hash__(self) -> int:
165+
"""Return a distinct hash for the object."""
166+
return hash((type(self), int(self)))
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""Tests for the Boolean Type."""
2+
3+
from typing import Any, Callable
4+
5+
import pytest
6+
from pydantic import ValidationError, create_model
7+
8+
from lean_spec.types.boolean import Boolean
9+
10+
11+
@pytest.mark.parametrize("valid_value", [True, False])
12+
def test_pydantic_validation_accepts_valid_bool(valid_value: bool) -> None:
13+
"""Tests that Pydantic validation correctly accepts a valid boolean."""
14+
model = create_model("Model", value=(Boolean, ...))
15+
instance: Any = model(value=valid_value)
16+
assert isinstance(instance.value, Boolean)
17+
assert instance.value == Boolean(valid_value)
18+
19+
20+
@pytest.mark.parametrize("invalid_value", [1, 0, 1.0, "True"])
21+
def test_pydantic_strict_mode_rejects_invalid_types(invalid_value: Any) -> None:
22+
"""Tests that Pydantic's strict mode rejects types that are not `bool`."""
23+
model = create_model("Model", value=(Boolean, ...))
24+
with pytest.raises(ValidationError):
25+
model(value=invalid_value)
26+
27+
28+
@pytest.mark.parametrize("valid_value", [True, False, 1, 0])
29+
def test_instantiation_from_valid_types(valid_value: bool | int) -> None:
30+
"""Tests that a Boolean can be instantiated from valid bools and ints."""
31+
boolean_instance = Boolean(valid_value)
32+
assert int(boolean_instance) == int(valid_value)
33+
34+
35+
@pytest.mark.parametrize("invalid_int", [-1, 2, 100])
36+
def test_instantiation_from_invalid_int_raises_error(invalid_int: int) -> None:
37+
"""Tests that instantiating with an int other than 0 or 1 raises ValueError."""
38+
with pytest.raises(ValueError, match="Boolean value must be 0 or 1"):
39+
Boolean(invalid_int)
40+
41+
42+
@pytest.mark.parametrize("invalid_type", [1.0, "True", b"\x01", None])
43+
def test_instantiation_from_invalid_types_raises_error(invalid_type: Any) -> None:
44+
"""Tests that instantiating with non-bool/non-int types raises a TypeError."""
45+
with pytest.raises(TypeError, match="Expected bool or int"):
46+
Boolean(invalid_type)
47+
48+
49+
def test_instantiation_and_type() -> None:
50+
"""Tests that a Boolean is an instance of `int` and its own class."""
51+
value = Boolean(True)
52+
assert isinstance(value, int)
53+
assert isinstance(value, Boolean)
54+
55+
56+
def test_to_bytes() -> None:
57+
r"""Tests that serialization to bytes matches the SSZ spec."""
58+
assert Boolean(True).to_bytes() == b"\x01"
59+
assert Boolean(False).to_bytes() == b"\x00"
60+
61+
62+
@pytest.mark.parametrize(
63+
"op",
64+
[
65+
lambda a, b: a + b,
66+
lambda a, b: a - b,
67+
lambda a, b: 1 + b,
68+
lambda a, b: 1 - b,
69+
],
70+
)
71+
def test_arithmetic_operators_raise_error(op: Callable[[Any, Any], Any]) -> None:
72+
"""Tests that all arithmetic operators are disabled and raise TypeError."""
73+
with pytest.raises(TypeError, match="Arithmetic operations are not supported"):
74+
op(Boolean(True), Boolean(False))
75+
76+
77+
def test_bitwise_operators() -> None:
78+
"""Tests all standard bitwise operators between Boolean instances."""
79+
b_true = Boolean(True)
80+
b_false = Boolean(False)
81+
82+
assert b_true & b_true == b_true
83+
assert b_true & b_false == b_false
84+
assert b_true | b_false == b_true
85+
assert b_false | b_false == b_false
86+
assert b_true ^ b_true == b_false
87+
assert b_true ^ b_false == b_true
88+
89+
90+
@pytest.mark.parametrize("invalid_operand", [1, True, 0.0, "a"])
91+
def test_bitwise_operators_with_other_types_raise_error(invalid_operand: Any) -> None:
92+
"""Tests that bitwise operations with non-Boolean types raise TypeError."""
93+
with pytest.raises(TypeError):
94+
_ = Boolean(True) & invalid_operand
95+
with pytest.raises(TypeError):
96+
_ = Boolean(True) | invalid_operand
97+
with pytest.raises(TypeError):
98+
_ = Boolean(True) ^ invalid_operand
99+
100+
101+
def test_strict_equality_with_same_type() -> None:
102+
"""Tests the strict `==` and `!=` operators between two Boolean instances."""
103+
assert Boolean(True) == Boolean(True)
104+
assert Boolean(False) == Boolean(False)
105+
assert Boolean(True) != Boolean(False)
106+
107+
108+
@pytest.mark.parametrize(
109+
"left_operand, right_operand, expected_result",
110+
[
111+
# --- Comparisons between two Boolean instances ---
112+
(Boolean(True), Boolean(False), False),
113+
(Boolean(True), Boolean(True), True),
114+
(Boolean(False), Boolean(False), True),
115+
# --- Comparisons with compatible native types (Boolean on the left) ---
116+
(Boolean(True), True, True),
117+
(Boolean(True), 1, True),
118+
(Boolean(True), False, False),
119+
(Boolean(True), 0, False),
120+
# --- Comparisons with compatible native types (Boolean on the right) ---
121+
(True, Boolean(True), True),
122+
(1, Boolean(True), True),
123+
(False, Boolean(True), False),
124+
(0, Boolean(True), False),
125+
# --- Comparisons with incompatible types ---
126+
(Boolean(True), "a string", False),
127+
("a string", Boolean(True), False),
128+
(Boolean(True), 1.0, False),
129+
(Boolean(True), None, False),
130+
(None, Boolean(True), False),
131+
],
132+
)
133+
def test_equality_operator(left_operand: Any, right_operand: Any, expected_result: bool) -> None:
134+
"""Tests the `__eq__` equality operator (`==`) for various type combinations."""
135+
assert (left_operand == right_operand) is expected_result
136+
137+
138+
@pytest.mark.parametrize(
139+
"left_operand, right_operand, expected_result",
140+
[
141+
# --- Comparisons between two Boolean instances ---
142+
(Boolean(True), Boolean(False), True),
143+
(Boolean(True), Boolean(True), False),
144+
(Boolean(False), Boolean(False), False),
145+
# --- Comparisons with compatible native types (Boolean on the left) ---
146+
(Boolean(True), True, False),
147+
(Boolean(True), 1, False),
148+
(Boolean(True), False, True),
149+
(Boolean(True), 0, True),
150+
# --- Comparisons with compatible native types (Boolean on the right) ---
151+
(True, Boolean(True), False),
152+
(1, Boolean(True), False),
153+
(False, Boolean(True), True),
154+
(0, Boolean(True), True),
155+
# --- Comparisons with incompatible types ---
156+
(Boolean(True), "a string", True),
157+
("a string", Boolean(True), True),
158+
(Boolean(True), 1.0, True),
159+
(Boolean(True), None, True),
160+
(None, Boolean(True), True),
161+
],
162+
)
163+
def test_inequality_operator(left_operand: Any, right_operand: Any, expected_result: bool) -> None:
164+
"""Tests the `__ne__` inequality operator (`!=`) for various type combinations."""
165+
assert (left_operand != right_operand) is expected_result
166+
167+
168+
def test_repr_and_str() -> None:
169+
"""Tests the string and official representations."""
170+
assert str(Boolean(True)) == "True"
171+
assert repr(Boolean(True)) == "Boolean(True)"
172+
assert str(Boolean(False)) == "False"
173+
assert repr(Boolean(False)) == "Boolean(False)"
174+
175+
176+
def test_hash() -> None:
177+
"""Tests that the hash is distinct from a raw bool."""
178+
assert hash(Boolean(True)) != hash(True)
179+
assert hash(Boolean(False)) != hash(False)
180+
assert hash(Boolean(True)) == hash(Boolean(1))
181+
assert hash(Boolean(True)) != hash(Boolean(False))

tests/lean_spec/types/test_uint.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""Unsigned Integer Type Tests."""
22

3-
from typing import Any, Protocol, Type
3+
from typing import Any, Type
44

55
import pytest
6-
from pydantic import BaseModel, ValidationError, create_model
6+
from pydantic import ValidationError, create_model
77

88
from lean_spec.types.uint import (
99
BaseUint,

0 commit comments

Comments
 (0)