From 65da3be7e52c0d6301a0b571bf4dd986bc70e730 Mon Sep 17 00:00:00 2001 From: Jan Segre Date: Wed, 27 Aug 2025 18:27:42 +0200 Subject: [PATCH] feat(nano): use more custom classes with faux protection --- hathor/nanocontracts/faux_immutable.py | 21 ++- .../nanocontracts/nc_types/bytes_nc_type.py | 2 +- hathor/nanocontracts/types.py | 50 +++++-- .../nanocontracts/test_exposed_properties.py | 134 ------------------ tests/nanocontracts/test_faux_immutability.py | 32 ++--- tests/nanocontracts/test_rng.py | 8 +- 6 files changed, 78 insertions(+), 169 deletions(-) diff --git a/hathor/nanocontracts/faux_immutable.py b/hathor/nanocontracts/faux_immutable.py index dca7deced2..eb60781833 100644 --- a/hathor/nanocontracts/faux_immutable.py +++ b/hathor/nanocontracts/faux_immutable.py @@ -18,6 +18,10 @@ from typing_extensions import ParamSpec +# special attrs: +SKIP_VALIDATION_ATTR: str = '__skip_faux_immutability_validation__' +ALLOW_INHERITANCE_ATTR: str = '__allow_faux_inheritance__' + def _validate_faux_immutable_meta(name: str, bases: tuple[type, ...], attrs: dict[str, object]) -> None: """Run validations during faux-immutable class creation.""" @@ -37,18 +41,25 @@ def _validate_faux_immutable_meta(name: str, bases: tuple[type, ...], attrs: dic '__call__', }) + # pop the attribute so the created class doesn't have it and it isn't inherited + allow_inheritance = attrs.pop(ALLOW_INHERITANCE_ATTR, False) + # Prohibit all other dunder attributes/methods. for attr in attrs: if '__' in attr and attr not in required_attrs | allowed_dunder: raise TypeError(f'faux-immutable class `{name}` must not define `{attr}`') # Prohibit inheritance on faux-immutable classes, this may be less strict in the future, - # but we may only allow bases where `type(base) is _FauxImmutableMeta`. - if len(bases) != 1 or not bases[0] is FauxImmutable: + # but we may only allow bases where `type(base) is FauxImmutableMeta`. + if len(bases) != 1: + raise TypeError('faux-immutable only allows one base') + + base, = bases + if base is not FauxImmutable and not allow_inheritance: raise TypeError(f'faux-immutable class `{name}` must inherit from `FauxImmutable` only') -class _FauxImmutableMeta(type): +class FauxImmutableMeta(type): """ A metaclass for faux-immutable classes. This means the class objects themselves are immutable, that is, `__setattr__` always raises AttributeError. @@ -60,7 +71,7 @@ def __new__(cls, name, bases, attrs, **kwargs): # validations are just a sanity check to make sure we only apply this metaclass to classes # that will actually become immutable, for example, using this metaclass doesn't provide # complete faux-immutability if the class doesn't define `__slots__`. - if not attrs.get('__skip_faux_immutability_validation__', False): + if not attrs.get(SKIP_VALIDATION_ATTR, False): _validate_faux_immutable_meta(name, bases, attrs) return super().__new__(cls, name, bases, attrs, **kwargs) @@ -68,7 +79,7 @@ def __setattr__(cls, name: str, value: object) -> None: raise AttributeError(f'cannot set attribute `{name}` on faux-immutable class') -class FauxImmutable(metaclass=_FauxImmutableMeta): +class FauxImmutable(metaclass=FauxImmutableMeta): """ Utility superclass for creating faux-immutable classes. Simply inherit from it to define a faux-immutable class. diff --git a/hathor/nanocontracts/nc_types/bytes_nc_type.py b/hathor/nanocontracts/nc_types/bytes_nc_type.py index 2358d8b039..f504a372cc 100644 --- a/hathor/nanocontracts/nc_types/bytes_nc_type.py +++ b/hathor/nanocontracts/nc_types/bytes_nc_type.py @@ -49,7 +49,7 @@ def _from_type(cls, type_: type[B], /, *, type_map: NCType.TypeMap) -> Self: @override def _check_value(self, value: bytes, /, *, deep: bool) -> None: if isclass(self._actual_type): - if not isinstance(value, self._actual_type): + if not isinstance(value, (bytes, self._actual_type)): raise TypeError('expected {self._actual_type} instance') else: if not isinstance(value, bytes): diff --git a/hathor/nanocontracts/types.py b/hathor/nanocontracts/types.py index 16a64b3996..5721e5ab9d 100644 --- a/hathor/nanocontracts/types.py +++ b/hathor/nanocontracts/types.py @@ -17,7 +17,7 @@ import inspect from dataclasses import dataclass from enum import Enum, unique -from typing import Any, Callable, Generic, NewType, TypeAlias, TypeVar +from typing import Any, Callable, Generic, TypeAlias, TypeVar from typing_extensions import override @@ -28,28 +28,60 @@ validate_method_types, ) from hathor.nanocontracts.exception import BlueprintSyntaxError, NCSerializationError +from hathor.nanocontracts.faux_immutable import FauxImmutableMeta from hathor.transaction.util import bytes_to_int, int_to_bytes from hathor.utils.typing import InnerTypeMixin +# XXX: mypy gives the following errors on all subclasses of `bytes` that use FauxImmutableMeta: +# +# Metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its +# bases +# +# However `bytes` metaclass appears to be `type` (just like `int`'s) and `FauxImmutableMeta` correctly inherits from +# `type`, so it seems like it's a mypy error. + # Types to be used by blueprints. -class Address(bytes): +class Address(bytes, metaclass=FauxImmutableMeta): # type: ignore[misc] + __allow_faux_inheritance__ = True + __slots__ = () + + +class VertexId(bytes, metaclass=FauxImmutableMeta): # type: ignore[misc] + __slots__ = () + __allow_faux_inheritance__ = True + + +class BlueprintId(VertexId): # type: ignore[misc] + __slots__ = () + __allow_faux_inheritance__ = True + + +class ContractId(VertexId): # type: ignore[misc] + __slots__ = () + __allow_faux_inheritance__ = True + + +class TokenUid(bytes, metaclass=FauxImmutableMeta): # type: ignore[misc] + __slots__ = () + __allow_faux_inheritance__ = True + + +class TxOutputScript(bytes, metaclass=FauxImmutableMeta): # type: ignore[misc] __slots__ = () + __allow_faux_inheritance__ = True -class VertexId(bytes): +class Amount(int, metaclass=FauxImmutableMeta): __slots__ = () + __allow_faux_inheritance__ = True -class ContractId(VertexId): +class Timestamp(int, metaclass=FauxImmutableMeta): __slots__ = () + __allow_faux_inheritance__ = True -Amount = NewType('Amount', int) -Timestamp = NewType('Timestamp', int) -TokenUid = NewType('TokenUid', bytes) -TxOutputScript = NewType('TxOutputScript', bytes) -BlueprintId = NewType('BlueprintId', VertexId) CallerId: TypeAlias = Address | ContractId T = TypeVar('T') diff --git a/tests/nanocontracts/test_exposed_properties.py b/tests/nanocontracts/test_exposed_properties.py index 20abfd420f..50e6902398 100644 --- a/tests/nanocontracts/test_exposed_properties.py +++ b/tests/nanocontracts/test_exposed_properties.py @@ -44,94 +44,6 @@ 'hathor.nanocontracts.exception.NCFail.args', 'hathor.nanocontracts.exception.NCFail.some_new_attribute', 'hathor.nanocontracts.exception.NCFail.with_traceback', - 'hathor.nanocontracts.types.Address.capitalize', - 'hathor.nanocontracts.types.Address.center', - 'hathor.nanocontracts.types.Address.count', - 'hathor.nanocontracts.types.Address.decode', - 'hathor.nanocontracts.types.Address.endswith', - 'hathor.nanocontracts.types.Address.expandtabs', - 'hathor.nanocontracts.types.Address.find', - 'hathor.nanocontracts.types.Address.fromhex', - 'hathor.nanocontracts.types.Address.hex', - 'hathor.nanocontracts.types.Address.index', - 'hathor.nanocontracts.types.Address.isalnum', - 'hathor.nanocontracts.types.Address.isalpha', - 'hathor.nanocontracts.types.Address.isascii', - 'hathor.nanocontracts.types.Address.isdigit', - 'hathor.nanocontracts.types.Address.islower', - 'hathor.nanocontracts.types.Address.isspace', - 'hathor.nanocontracts.types.Address.istitle', - 'hathor.nanocontracts.types.Address.isupper', - 'hathor.nanocontracts.types.Address.join', - 'hathor.nanocontracts.types.Address.ljust', - 'hathor.nanocontracts.types.Address.lower', - 'hathor.nanocontracts.types.Address.lstrip', - 'hathor.nanocontracts.types.Address.maketrans', - 'hathor.nanocontracts.types.Address.partition', - 'hathor.nanocontracts.types.Address.removeprefix', - 'hathor.nanocontracts.types.Address.removesuffix', - 'hathor.nanocontracts.types.Address.replace', - 'hathor.nanocontracts.types.Address.rfind', - 'hathor.nanocontracts.types.Address.rindex', - 'hathor.nanocontracts.types.Address.rjust', - 'hathor.nanocontracts.types.Address.rpartition', - 'hathor.nanocontracts.types.Address.rsplit', - 'hathor.nanocontracts.types.Address.rstrip', - 'hathor.nanocontracts.types.Address.some_new_attribute', - 'hathor.nanocontracts.types.Address.split', - 'hathor.nanocontracts.types.Address.splitlines', - 'hathor.nanocontracts.types.Address.startswith', - 'hathor.nanocontracts.types.Address.strip', - 'hathor.nanocontracts.types.Address.swapcase', - 'hathor.nanocontracts.types.Address.title', - 'hathor.nanocontracts.types.Address.translate', - 'hathor.nanocontracts.types.Address.upper', - 'hathor.nanocontracts.types.Address.zfill', - 'hathor.nanocontracts.types.Amount.some_new_attribute', - 'hathor.nanocontracts.types.BlueprintId.some_new_attribute', - 'hathor.nanocontracts.types.ContractId.capitalize', - 'hathor.nanocontracts.types.ContractId.center', - 'hathor.nanocontracts.types.ContractId.count', - 'hathor.nanocontracts.types.ContractId.decode', - 'hathor.nanocontracts.types.ContractId.endswith', - 'hathor.nanocontracts.types.ContractId.expandtabs', - 'hathor.nanocontracts.types.ContractId.find', - 'hathor.nanocontracts.types.ContractId.fromhex', - 'hathor.nanocontracts.types.ContractId.hex', - 'hathor.nanocontracts.types.ContractId.index', - 'hathor.nanocontracts.types.ContractId.isalnum', - 'hathor.nanocontracts.types.ContractId.isalpha', - 'hathor.nanocontracts.types.ContractId.isascii', - 'hathor.nanocontracts.types.ContractId.isdigit', - 'hathor.nanocontracts.types.ContractId.islower', - 'hathor.nanocontracts.types.ContractId.isspace', - 'hathor.nanocontracts.types.ContractId.istitle', - 'hathor.nanocontracts.types.ContractId.isupper', - 'hathor.nanocontracts.types.ContractId.join', - 'hathor.nanocontracts.types.ContractId.ljust', - 'hathor.nanocontracts.types.ContractId.lower', - 'hathor.nanocontracts.types.ContractId.lstrip', - 'hathor.nanocontracts.types.ContractId.maketrans', - 'hathor.nanocontracts.types.ContractId.partition', - 'hathor.nanocontracts.types.ContractId.removeprefix', - 'hathor.nanocontracts.types.ContractId.removesuffix', - 'hathor.nanocontracts.types.ContractId.replace', - 'hathor.nanocontracts.types.ContractId.rfind', - 'hathor.nanocontracts.types.ContractId.rindex', - 'hathor.nanocontracts.types.ContractId.rjust', - 'hathor.nanocontracts.types.ContractId.rpartition', - 'hathor.nanocontracts.types.ContractId.rsplit', - 'hathor.nanocontracts.types.ContractId.rstrip', - 'hathor.nanocontracts.types.ContractId.some_new_attribute', - 'hathor.nanocontracts.types.ContractId.split', - 'hathor.nanocontracts.types.ContractId.splitlines', - 'hathor.nanocontracts.types.ContractId.startswith', - 'hathor.nanocontracts.types.ContractId.strip', - 'hathor.nanocontracts.types.ContractId.swapcase', - 'hathor.nanocontracts.types.ContractId.title', - 'hathor.nanocontracts.types.ContractId.translate', - 'hathor.nanocontracts.types.ContractId.upper', - 'hathor.nanocontracts.types.ContractId.zfill', 'hathor.nanocontracts.types.NCAcquireAuthorityAction.melt', 'hathor.nanocontracts.types.NCAcquireAuthorityAction.mint', 'hathor.nanocontracts.types.NCAcquireAuthorityAction.name', @@ -204,52 +116,6 @@ 'hathor.nanocontracts.types.SignedData.checksig', 'hathor.nanocontracts.types.SignedData.get_data_bytes', 'hathor.nanocontracts.types.SignedData.some_new_attribute', - 'hathor.nanocontracts.types.Timestamp.some_new_attribute', - 'hathor.nanocontracts.types.TokenUid.some_new_attribute', - 'hathor.nanocontracts.types.TxOutputScript.some_new_attribute', - 'hathor.nanocontracts.types.VertexId.capitalize', - 'hathor.nanocontracts.types.VertexId.center', - 'hathor.nanocontracts.types.VertexId.count', - 'hathor.nanocontracts.types.VertexId.decode', - 'hathor.nanocontracts.types.VertexId.endswith', - 'hathor.nanocontracts.types.VertexId.expandtabs', - 'hathor.nanocontracts.types.VertexId.find', - 'hathor.nanocontracts.types.VertexId.fromhex', - 'hathor.nanocontracts.types.VertexId.hex', - 'hathor.nanocontracts.types.VertexId.index', - 'hathor.nanocontracts.types.VertexId.isalnum', - 'hathor.nanocontracts.types.VertexId.isalpha', - 'hathor.nanocontracts.types.VertexId.isascii', - 'hathor.nanocontracts.types.VertexId.isdigit', - 'hathor.nanocontracts.types.VertexId.islower', - 'hathor.nanocontracts.types.VertexId.isspace', - 'hathor.nanocontracts.types.VertexId.istitle', - 'hathor.nanocontracts.types.VertexId.isupper', - 'hathor.nanocontracts.types.VertexId.join', - 'hathor.nanocontracts.types.VertexId.ljust', - 'hathor.nanocontracts.types.VertexId.lower', - 'hathor.nanocontracts.types.VertexId.lstrip', - 'hathor.nanocontracts.types.VertexId.maketrans', - 'hathor.nanocontracts.types.VertexId.partition', - 'hathor.nanocontracts.types.VertexId.removeprefix', - 'hathor.nanocontracts.types.VertexId.removesuffix', - 'hathor.nanocontracts.types.VertexId.replace', - 'hathor.nanocontracts.types.VertexId.rfind', - 'hathor.nanocontracts.types.VertexId.rindex', - 'hathor.nanocontracts.types.VertexId.rjust', - 'hathor.nanocontracts.types.VertexId.rpartition', - 'hathor.nanocontracts.types.VertexId.rsplit', - 'hathor.nanocontracts.types.VertexId.rstrip', - 'hathor.nanocontracts.types.VertexId.some_new_attribute', - 'hathor.nanocontracts.types.VertexId.split', - 'hathor.nanocontracts.types.VertexId.splitlines', - 'hathor.nanocontracts.types.VertexId.startswith', - 'hathor.nanocontracts.types.VertexId.strip', - 'hathor.nanocontracts.types.VertexId.swapcase', - 'hathor.nanocontracts.types.VertexId.title', - 'hathor.nanocontracts.types.VertexId.translate', - 'hathor.nanocontracts.types.VertexId.upper', - 'hathor.nanocontracts.types.VertexId.zfill', 'hathor.nanocontracts.types.fallback.some_new_attribute', 'hathor.nanocontracts.types.public.some_new_attribute', 'hathor.nanocontracts.types.view.some_new_attribute', diff --git a/tests/nanocontracts/test_faux_immutability.py b/tests/nanocontracts/test_faux_immutability.py index 66bd2c41a2..121ec989a4 100644 --- a/tests/nanocontracts/test_faux_immutability.py +++ b/tests/nanocontracts/test_faux_immutability.py @@ -43,7 +43,7 @@ def test_invalid_inheritance() -> None: class Super: pass - with pytest.raises(TypeError, match='faux-immutable class `Foo` must inherit from `FauxImmutable` only'): + with pytest.raises(TypeError, match='faux-immutable only allows one base'): class Foo(FauxImmutable, Super): __slots__ = () @@ -145,80 +145,80 @@ def class_method(cls) -> None: # Existing attribute on class # - # protected by _FauxImmutableMeta.__setattr__ + # protected by FauxImmutableMeta.__setattr__ with pytest.raises(AttributeError, match='cannot set attribute `attr` on faux-immutable class'): Foo.attr = 'bar' - # protected by _FauxImmutableMeta.__setattr__ + # protected by FauxImmutableMeta.__setattr__ with pytest.raises(AttributeError, match='cannot set attribute `attr` on faux-immutable class'): setattr(Foo, 'attr', 'bar') # protected by Python itself - with pytest.raises(TypeError, match="can't apply this __setattr__ to _FauxImmutableMeta object"): + with pytest.raises(TypeError, match="can't apply this __setattr__ to FauxImmutableMeta object"): object.__setattr__(Foo, 'attr', 'bar') # # Existing class attribute on class # - # protected by _FauxImmutableMeta.__setattr__ + # protected by FauxImmutableMeta.__setattr__ with pytest.raises(AttributeError, match='cannot set attribute `class_attr` on faux-immutable class'): Foo.class_attr = 'bar' - # protected by _FauxImmutableMeta.__setattr__ + # protected by FauxImmutableMeta.__setattr__ with pytest.raises(AttributeError, match='cannot set attribute `class_attr` on faux-immutable class'): setattr(Foo, 'class_attr', 'bar') # protected by Python itself - with pytest.raises(TypeError, match="can't apply this __setattr__ to _FauxImmutableMeta object"): + with pytest.raises(TypeError, match="can't apply this __setattr__ to FauxImmutableMeta object"): object.__setattr__(Foo, 'class_attr', 'bar') # # Existing method on class # - # protected by _FauxImmutableMeta.__setattr__ + # protected by FauxImmutableMeta.__setattr__ with pytest.raises(AttributeError, match='cannot set attribute `method` on faux-immutable class'): Foo.method = lambda self: None # type: ignore[method-assign] - # protected by _FauxImmutableMeta.__setattr__ + # protected by FauxImmutableMeta.__setattr__ with pytest.raises(AttributeError, match='cannot set attribute `method` on faux-immutable class'): setattr(Foo, 'method', lambda self: None) # protected by Python itself - with pytest.raises(TypeError, match="can't apply this __setattr__ to _FauxImmutableMeta object"): + with pytest.raises(TypeError, match="can't apply this __setattr__ to FauxImmutableMeta object"): object.__setattr__(Foo, 'method', lambda self: None) # # Existing class method on class # - # protected by _FauxImmutableMeta.__setattr__ + # protected by FauxImmutableMeta.__setattr__ with pytest.raises(AttributeError, match='cannot set attribute `class_method` on faux-immutable class'): Foo.class_method = lambda: None # type: ignore[method-assign] - # protected by _FauxImmutableMeta.__setattr__ + # protected by FauxImmutableMeta.__setattr__ with pytest.raises(AttributeError, match='cannot set attribute `class_method` on faux-immutable class'): setattr(Foo, 'class_method', lambda self: None) # protected by Python itself - with pytest.raises(TypeError, match="can't apply this __setattr__ to _FauxImmutableMeta object"): + with pytest.raises(TypeError, match="can't apply this __setattr__ to FauxImmutableMeta object"): object.__setattr__(Foo, 'class_method', lambda self: None) # # New attribute on class # - # protected by _FauxImmutableMeta.__setattr__ + # protected by FauxImmutableMeta.__setattr__ with pytest.raises(AttributeError, match='cannot set attribute `new_class_attr` on faux-immutable class'): Foo.new_class_attr = 'bar' - # protected by _FauxImmutableMeta.__setattr__ + # protected by FauxImmutableMeta.__setattr__ with pytest.raises(AttributeError, match='cannot set attribute `new_class_attr` on faux-immutable class'): setattr(Foo, 'new_class_attr', 'bar') # protected by Python itself - with pytest.raises(TypeError, match="can't apply this __setattr__ to _FauxImmutableMeta object"): + with pytest.raises(TypeError, match="can't apply this __setattr__ to FauxImmutableMeta object"): object.__setattr__(Foo, 'new_class_attr', 'bar') diff --git a/tests/nanocontracts/test_rng.py b/tests/nanocontracts/test_rng.py index 33d04e31c0..5b905ced60 100644 --- a/tests/nanocontracts/test_rng.py +++ b/tests/nanocontracts/test_rng.py @@ -140,7 +140,7 @@ def test_rng_override(self) -> None: setattr(NanoRNG, 'random', lambda self: 2) # protected by Python itself - with pytest.raises(TypeError, match='can\'t apply this __setattr__ to _FauxImmutableMeta object'): + with pytest.raises(TypeError, match='can\'t apply this __setattr__ to FauxImmutableMeta object'): object.__setattr__(NanoRNG, 'random', lambda self: 2) # @@ -156,7 +156,7 @@ def test_rng_override(self) -> None: setattr(rng.__class__, 'random', lambda self: 2) # protected by Python itself - with pytest.raises(TypeError, match='can\'t apply this __setattr__ to _FauxImmutableMeta object'): + with pytest.raises(TypeError, match='can\'t apply this __setattr__ to FauxImmutableMeta object'): object.__setattr__(rng.__class__, 'random', lambda self: 2) # @@ -172,7 +172,7 @@ def test_rng_override(self) -> None: setattr(NanoRNG, 'new_attr', 123) # protected by Python itself - with pytest.raises(TypeError, match='can\'t apply this __setattr__ to _FauxImmutableMeta object'): + with pytest.raises(TypeError, match='can\'t apply this __setattr__ to FauxImmutableMeta object'): object.__setattr__(NanoRNG, 'new_attr', 123) assert rng.random() < 1 @@ -190,7 +190,7 @@ def test_rng_shell_class(self) -> None: with pytest.raises(AttributeError, match='cannot set attribute `random` on faux-immutable class'): setattr(rng1.__class__, 'random', lambda self: 2) - with pytest.raises(TypeError, match='can\'t apply this __setattr__ to _FauxImmutableMeta object'): + with pytest.raises(TypeError, match='can\'t apply this __setattr__ to FauxImmutableMeta object'): object.__setattr__(rng1.__class__, 'random', lambda self: 2) def assertGoodnessOfFitTest(self, observed: list[int], expected: list[int]) -> None: