diff --git a/hathor/nanocontracts/blueprint.py b/hathor/nanocontracts/blueprint.py index c164562af..b994d3847 100644 --- a/hathor/nanocontracts/blueprint.py +++ b/hathor/nanocontracts/blueprint.py @@ -14,12 +14,21 @@ from __future__ import annotations +from collections.abc import Callable +from functools import wraps from typing import TYPE_CHECKING, Any, final from hathor.nanocontracts.blueprint_env import BlueprintEnvironment from hathor.nanocontracts.exception import BlueprintSyntaxError +from hathor.nanocontracts.fields.container import Container from hathor.nanocontracts.nc_types.utils import pretty_type -from hathor.nanocontracts.types import NC_FALLBACK_METHOD, NC_INITIALIZE_METHOD, NC_METHOD_TYPE_ATTR, NCMethodType +from hathor.nanocontracts.types import ( + NC_ALLOWED_ACTIONS_ATTR, + NC_FALLBACK_METHOD, + NC_INITIALIZE_METHOD, + NC_METHOD_TYPE_ATTR, + NCMethodType, +) if TYPE_CHECKING: from hathor.nanocontracts.nc_exec_logs import NCLogger @@ -38,7 +47,14 @@ class _BlueprintBase(type): This metaclass will modify the attributes and set Fields to them according to their types. """ - def __new__(cls, name, bases, attrs, **kwargs): + def __new__( + cls: type[_BlueprintBase], + name: str, + bases: tuple[type, ...], + attrs: dict[str, Any], + /, + **kwargs: Any + ) -> _BlueprintBase: from hathor.nanocontracts.fields import make_field_for_type # Initialize only subclasses of Blueprint. @@ -68,6 +84,8 @@ def __new__(cls, name, bases, attrs, **kwargs): # Finally, create class! new_class = super().__new__(cls, name, bases, attrs, **kwargs) + container_fields: list[str] = [] + # Create the Field instance according to each type. for field_name, field_type in attrs[NC_FIELDS_ATTR].items(): value = getattr(new_class, field_name, None) @@ -83,6 +101,8 @@ def __new__(cls, name, bases, attrs, **kwargs): f'unsupported field type: `{field_name}: {pretty_type(field_type)}`' ) setattr(new_class, field_name, field) + if field.is_container: + container_fields.append(field_name) else: # This is the case when a value is specified. # Example: @@ -91,6 +111,27 @@ def __new__(cls, name, bases, attrs, **kwargs): # This was not implemented yet and will be extended later. raise BlueprintSyntaxError(f'fields with default values are currently not supported: `{field_name}`') + # validation makes sure we already have it + original_init_fn = getattr(new_class, NC_INITIALIZE_METHOD) + init_containers_fn = _make_initialize_uninitialized_container_fields_fn(container_fields) + + # patch initialize method so it initializes containers fields implicitly + @wraps(original_init_fn) + def patched_init_fn(self: Blueprint, *args: Any, **kwargs: Any) -> Any: + ret = original_init_fn(self, *args, **kwargs) + init_containers_fn(self) + return ret + + # copy important attributes + important_attrs = [NC_METHOD_TYPE_ATTR, NC_ALLOWED_ACTIONS_ATTR, '__annotations__'] + for attr in important_attrs: + setattr(patched_init_fn, attr, getattr(original_init_fn, attr)) + # XXX: this attribute is important for resolving the original method's signature + setattr(patched_init_fn, '__wrapped__', original_init_fn) + + # replace the original init method + setattr(new_class, NC_INITIALIZE_METHOD, patched_init_fn) + return new_class @staticmethod @@ -142,3 +183,12 @@ def syscall(self) -> BlueprintEnvironment: def log(self) -> NCLogger: """Return the logger for the current contract.""" return self.syscall.__log__ + + +def _make_initialize_uninitialized_container_fields_fn(container_fields: list[str]) -> Callable[['Blueprint'], None]: + def _initialize_uninitialized_container_fields(self: Blueprint) -> None: + for field in container_fields: + container: Container = getattr(self, field) + assert isinstance(container, Container) + container.__try_init_storage__() + return _initialize_uninitialized_container_fields diff --git a/hathor/nanocontracts/blueprint_env.py b/hathor/nanocontracts/blueprint_env.py index c2b670da1..2ddc80027 100644 --- a/hathor/nanocontracts/blueprint_env.py +++ b/hathor/nanocontracts/blueprint_env.py @@ -14,7 +14,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Collection, Optional, Sequence, final +from typing import TYPE_CHECKING, Any, Collection, Optional, Sequence, TypeAlias, final from hathor.nanocontracts.storage import NCContractStorage from hathor.nanocontracts.types import Amount, BlueprintId, ContractId, NCAction, TokenUid @@ -27,6 +27,9 @@ from hathor.nanocontracts.types import NCArgs +NCAttrCache: TypeAlias = dict[bytes, Any] | None + + class BlueprintEnvironment: """A class that holds all possible interactions a blueprint may have with the system.""" @@ -43,8 +46,8 @@ def __init__( self.__log__ = nc_logger self.__runner = runner self.__storage__ = storage - # XXX: we could replace dict|None with a Cache that can be disabled, cleared, limited, etc - self.__cache__: dict[str, Any] | None = None if disable_cache else {} + # XXX: we could replace dict|None with a cache class that can be disabled, cleared, limited, etc + self.__cache__: NCAttrCache = None if disable_cache else {} @final @property diff --git a/hathor/nanocontracts/fields/__init__.py b/hathor/nanocontracts/fields/__init__.py index 794dc269d..5fea0f45c 100644 --- a/hathor/nanocontracts/fields/__init__.py +++ b/hathor/nanocontracts/fields/__init__.py @@ -12,35 +12,35 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections import deque +from collections import OrderedDict, deque from typing import TypeVar -from hathor.nanocontracts.fields.deque_field import DequeField -from hathor.nanocontracts.fields.dict_field import DictField +from hathor.nanocontracts.fields.container import TypeToContainerMap +from hathor.nanocontracts.fields.deque_container import DequeContainer +from hathor.nanocontracts.fields.dict_container import DictContainer from hathor.nanocontracts.fields.field import Field -from hathor.nanocontracts.fields.set_field import SetField -from hathor.nanocontracts.fields.utils import TypeToFieldMap -from hathor.nanocontracts.nc_types import DEFAULT_TYPE_ALIAS_MAP, FIELD_TYPE_TO_NC_TYPE_MAP +from hathor.nanocontracts.fields.set_container import SetContainer +from hathor.nanocontracts.nc_types import ESSENTIAL_TYPE_ALIAS_MAP, FIELD_TYPE_TO_NC_TYPE_MAP from hathor.nanocontracts.nc_types.utils import TypeAliasMap, TypeToNCTypeMap __all__ = [ - 'TYPE_TO_FIELD_MAP', - 'DequeField', - 'DictField', + 'TYPE_TO_CONTAINER_MAP', + 'DequeContainer', + 'DictContainer', 'Field', - 'SetField', + 'SetContainer', 'TypeToFieldMap', 'make_field_for_type', ] T = TypeVar('T') -TYPE_TO_FIELD_MAP: TypeToFieldMap = { - dict: DictField, - list: DequeField, # XXX: we should really make a ListField, a deque is different from a list - set: SetField, - deque: DequeField, - # XXX: other types fallback to FIELD_TYPE_TO_NC_TYPE_MAP +TYPE_TO_CONTAINER_MAP: TypeToContainerMap = { + deque: DequeContainer, + dict: DictContainer, + OrderedDict: DictContainer, + list: DequeContainer, # XXX: we should really make a ListField, a deque is different from a list + set: SetContainer, } @@ -49,13 +49,13 @@ def make_field_for_type( type_: type[T], /, *, - type_field_map: TypeToFieldMap = TYPE_TO_FIELD_MAP, + type_alias_map: TypeAliasMap = ESSENTIAL_TYPE_ALIAS_MAP, type_nc_type_map: TypeToNCTypeMap = FIELD_TYPE_TO_NC_TYPE_MAP, - type_alias_map: TypeAliasMap = DEFAULT_TYPE_ALIAS_MAP, + type_container_map: TypeToContainerMap = TYPE_TO_CONTAINER_MAP, ) -> Field[T]: """ Like Field.from_name_and_type, but with default maps. Default arguments can't be easily added to NCType.from_type signature because of recursion. """ - type_map = Field.TypeMap(type_alias_map, type_nc_type_map, type_field_map) + type_map = Field.TypeMap(type_alias_map, type_nc_type_map, type_container_map) return Field.from_name_and_type(name, type_, type_map=type_map) diff --git a/hathor/nanocontracts/fields/container.py b/hathor/nanocontracts/fields/container.py new file mode 100644 index 000000000..6db229482 --- /dev/null +++ b/hathor/nanocontracts/fields/container.py @@ -0,0 +1,323 @@ +# Copyright 2025 Hathor Labs +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Container as ContainerAbc, Mapping +from typing import Generic, TypeAlias, TypeVar + +from typing_extensions import TYPE_CHECKING, Self, final, get_origin, override + +from hathor.nanocontracts.blueprint_env import NCAttrCache +from hathor.nanocontracts.nc_types import BoolNCType, NCType +from hathor.nanocontracts.storage import NCContractStorage + +if TYPE_CHECKING: + from hathor.nanocontracts.blueprint import Blueprint + from hathor.nanocontracts.fields.field import Field + +T = TypeVar('T') + +KEY_SEPARATOR: bytes = b':' +INIT_KEY: bytes = b'__init__' +INIT_NC_TYPE: NCType[bool] = BoolNCType() + + +class Container(Generic[T], ABC): + """ Abstraction over the class that will be returned when accessing a container field. + + Every method and property in this class should use either `__dunder` or `__special__` naming pattern, because + otherwise the property/method would be accessible from an OCB. Even if there would be no harm, this is generally + avoided. + """ + __slots__ = ('__storage__', '__prefix__') + __storage__: NCContractStorage + __prefix__: bytes + + @classmethod + @abstractmethod + def __check_type__(cls, type_: type[ContainerAbc[T]], type_map: Field.TypeMap) -> None: + """Should raise a TypeError if the given name or type is incompatible for use with container.""" + raise NotImplementedError + + @classmethod + @abstractmethod + def __from_prefix_and_type__( + cls, + storage: NCContractStorage, + prefix: bytes, + type_: type[ContainerAbc[T]], + /, + *, + cache: NCAttrCache, + type_map: Field.TypeMap, + ) -> Self: + """Every Container should be able to be built with this signature. + + Expect a type that has been previously checked with `cls.__check_type__`. + """ + raise NotImplementedError + + @abstractmethod + def __init_storage__(self, initial_value: ContainerAbc[T] | None = None) -> None: + """Containers should use this to initialize metadata/length values.""" + raise NotImplementedError + + def __try_init_storage__(self) -> None: + """Used to initialize the container if it's not already initialized. + + When a Blueprint class is built, the initialize() method is patched by the metaclass to call this method on + every container field, so at the end of calling initialize() call, it's guaranteed that every container will + have been initialized. + """ + is_init_key = KEY_SEPARATOR.join([self.__prefix__, INIT_KEY]) + is_init = self.__storage__.get_obj(is_init_key, INIT_NC_TYPE, default=False) + if not is_init: + self.__init_storage__() + + +TypeToContainerMap: TypeAlias = Mapping[type[ContainerAbc], type[Container]] + + +P = TypeVar('P', bound=Container) + + +class ContainerNodeFactory(Generic[T]): + __slots__ = ('type_', 'type_map', 'is_container') + type_: type[T] + type_map: Field.TypeMap + is_container: bool + + @classmethod + def check_is_container(cls, type_: type[T], type_map: Field.TypeMap) -> bool: + """ Checks that the given type can be used with the given type_map, also returns whether it is a container. + """ + # if we have a `dict[int, int]` we use `get_origin()` to get the `dict` part, since it's a different instance + origin_type = get_origin(type_) or type_ + + if origin_type in type_map.container_map: + container_class = type_map.container_map[origin_type] # type: ignore[index] + container_class.__check_type__(type_, type_map) # type: ignore[arg-type] + return True + else: + NCType.check_type(type_, type_map=type_map.to_nc_type_map()) + return False + + def __init__(self, type_: type[T], type_map: Field.TypeMap) -> None: + self.type_ = type_ + self.type_map = type_map + self.is_container = self.check_is_container(type_, type_map) + + def build(self, instance: Blueprint) -> ContainerNode: + return ContainerNode.from_type( + instance.syscall.__storage__, + self.type_, + type_map=self.type_map, + cache=instance.syscall.__cache__, + ) + + +class ContainerNode(ABC, Generic[T]): + """This class is used by containers to abstract over either a Value or another Container. + + For example, consider something like this: + + ``` + class MyBlueprint(Blueprint): + foo: dict[int, int] + bar: dict[int, list[int]] + ``` + + Both `foo` and `bar` will be abstracted with a `DictContainer`, but when doing `foo[1]` or `bar[1]` a + `ContainerNode` is used to decide whether to use a `NCType` (in case of `foo`) or another `DictContainer` + (with a new prefix, in case of `bar`). + """ + __slots__ = ('storage', 'cache') # subclasses must define the appropriate slots + storage: NCContractStorage + cache: NCAttrCache + + def __init__(self, storage: NCContractStorage, cache: NCAttrCache): + self.storage = storage + self.cache = cache + + @final + @staticmethod + def from_type( + storage: NCContractStorage, + type_: type[T], + /, + *, + cache: NCAttrCache, + type_map: Field.TypeMap, + ) -> ContainerNode[T]: + origin_type = get_origin(type_) or type_ + + if origin_type in type_map.container_map: + container_class = type_map.container_map[origin_type] # type: ignore[index] + return ContainerProxy(storage, cache, type_, type_map, container_class) # type: ignore[type-var] + else: + nc_type = NCType.from_type(type_, type_map=type_map.to_nc_type_map()) + return ContainerLeaf(storage, nc_type, cache) + + @abstractmethod + def has_value(self, prefix: bytes) -> bool: + """Whether the value/container exists in the storage.""" + raise NotImplementedError + + @abstractmethod + def get_value(self, prefix: bytes) -> T: + """Resolves to returning either an actual value, or a proxy storage container.""" + raise NotImplementedError + + @abstractmethod + def set_value(self, prefix: bytes, value: T) -> None: + """Represents an assignment to the value or proxy container.""" + raise NotImplementedError + + @abstractmethod + def del_value(self, prefix: bytes) -> None: + """What to do when the value is deleted/popped.""" + raise NotImplementedError + + +@final +class ContainerProxy(ContainerNode[P]): + """A type of container that isn't a value, but delegates storing actual values to child container nodes.""" + + __slots__ = ('storage', 'cache', '_type', '_type_map', '_container_class') + + _type: type[P] + _type_map: Field.TypeMap + _container_class: type[Container[P]] + + def __init__( + self, + storage: NCContractStorage, + cache: NCAttrCache, + type_: type[P], + type_map: Field.TypeMap, + container_class: type[Container], + ) -> None: + super().__init__(storage, cache) + self._type = type_ + self._type_map = type_map + self._container_class = container_class + + def _build_container(self, prefix: bytes) -> Container: + return self._container_class.__from_prefix_and_type__( + self.storage, + prefix, + self._type, # type: ignore[arg-type] + cache=self.cache, + type_map=self._type_map, + ) + + @override + def has_value(self, prefix: bytes) -> bool: + if self.cache is not None and prefix in self.cache: + return True + + is_init_key = KEY_SEPARATOR.join([prefix, INIT_KEY]) + # XXX: is init indicates whether the container exists or not + is_init = self.storage.get_obj(is_init_key, INIT_NC_TYPE, default=False) + return is_init + + @override + def get_value(self, prefix: bytes) -> P: + if self.cache is not None and prefix in self.cache: + return self.cache[prefix] + + container = self._build_container(prefix) + is_init_key = KEY_SEPARATOR.join([prefix, INIT_KEY]) + is_init = self.storage.get_obj(is_init_key, INIT_NC_TYPE, default=False) + if not is_init: + container.__init_storage__() + self.storage.put_obj(is_init_key, INIT_NC_TYPE, True) + + if self.cache is not None: + self.cache[prefix] = container + + # XXX: ignore return-value because mypy doesn't know that the built Container is our P + return container # type: ignore[return-value] + + @override + def set_value(self, prefix: bytes, value: P) -> None: + container = self._build_container(prefix) + if isinstance(value, Container): + if value == container: + # XXX: no-op + return + else: + raise ValueError('invalid assigned value') + is_init_key = KEY_SEPARATOR.join([prefix, INIT_KEY]) + is_init = self.storage.get_obj(is_init_key, INIT_NC_TYPE, default=False) + if is_init: + raise ValueError('already initialized') + # XXX: ignore arg-type, it is correct but hard to typ + container.__init_storage__(value) + self.storage.put_obj(is_init_key, INIT_NC_TYPE, True) + if self.cache is not None: + self.cache[prefix] = container + + @override + def del_value(self, prefix: bytes) -> None: + container = self._build_container(prefix) + is_init_key = KEY_SEPARATOR.join([prefix, INIT_KEY]) + # XXX: container is implicitly Sized, it still has to be made explicit + if len(container) != 0: # type: ignore[arg-type] + raise ValueError('container is not empty') + self.storage.del_obj(is_init_key) + if self.cache is not None and prefix in self.cache: + del self.cache[prefix] + + +@final +class ContainerLeaf(ContainerNode[T]): + """A container-leaf resolves to an actual value and thus has a NCType that it uses to (de)serialize values.""" + + __slots__ = ('storage', 'cache', '_nc_type') + + _nc_type: NCType[T] + + def __init__(self, storage: NCContractStorage, nc_type: NCType[T], cache: NCAttrCache = None) -> None: + super().__init__(storage, cache) + self._nc_type = nc_type + + @override + def has_value(self, prefix: bytes) -> bool: + if self.cache is not None and prefix in self.cache: + return True + return self.storage.has_obj(prefix) + + @override + def get_value(self, prefix: bytes) -> T: + if self.cache is not None and prefix in self.cache: + return self.cache[prefix] + obj = self.storage.get_obj(prefix, self._nc_type) + if self.cache is not None: + self.cache[prefix] = obj + return obj + + @override + def set_value(self, prefix: bytes, value: T) -> None: + self.storage.put_obj(prefix, self._nc_type, value) + if self.cache is not None: + self.cache[prefix] = value + + @override + def del_value(self, prefix: bytes) -> None: + self.storage.del_obj(prefix) + if self.cache is not None and prefix in self.cache: + del self.cache[prefix] diff --git a/hathor/nanocontracts/fields/container_field.py b/hathor/nanocontracts/fields/container_field.py deleted file mode 100644 index 4558562a0..000000000 --- a/hathor/nanocontracts/fields/container_field.py +++ /dev/null @@ -1,131 +0,0 @@ -# Copyright 2025 Hathor Labs -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Container -from typing import Generic, TypeVar - -from typing_extensions import TYPE_CHECKING, Self, override - -from hathor.nanocontracts.fields.field import Field -from hathor.nanocontracts.storage import NCContractStorage -from hathor.util import not_none -from hathor.utils.typing import InnerTypeMixin, get_origin - -if TYPE_CHECKING: - from hathor.nanocontracts.blueprint import Blueprint - -C = TypeVar('C', bound=Container) - -KEY_SEPARATOR: str = ':' - - -class StorageContainer(Generic[C], ABC): - """ Abstraction over the class that will be returned when accessing a container field. - - Every method and property in this class should use either `__dunder` or `__special__` naming pattern, because - otherwise the property/method would be accessible from an OCB. Even if there would be no harm, this is generally - avoided. - """ - __slots__ = () - - @classmethod - @abstractmethod - def __check_name_and_type__(cls, name: str, type_: type[C]) -> None: - """Should raise a TypeError if the given name or type is incompatible for use with container.""" - raise NotImplementedError - - @classmethod - @abstractmethod - def __from_name_and_type__( - cls, - storage: NCContractStorage, - name: str, - type_: type[C], - /, - *, - type_map: Field.TypeMap, - ) -> Self: - """Every StorageContainer should be able to be built with this signature. - - Expect a type that has been previously checked with `cls.__check_name_and_type__`. - """ - raise NotImplementedError - - -T = TypeVar('T', bound=StorageContainer) - - -class ContainerField(InnerTypeMixin[T], Field[T]): - """ This class models a Field with a StorageContainer, it can't be set, only accessed as a container. - - This is modeled after a Python descriptor, similar to the built in `property`, see: - - - https://docs.python.org/3/reference/datamodel.html#implementing-descriptors - - The observed value behaves like a container, the specific behavior depends on the container type. - """ - - __slots__ = ('__name', '__type', '__type_map') - __name: str - __type: type[T] - __type_map: Field.TypeMap - - # XXX: customize InnerTypeMixin behavior so it stores the origin type, since that's what we want - @classmethod - def __extract_inner_type__(cls, args: tuple[type, ...], /) -> type[T]: - inner_type: type[T] = InnerTypeMixin.__extract_inner_type__(args) - return not_none(get_origin(inner_type)) - - @override - @classmethod - def _from_name_and_type(cls, name: str, type_: type[T], /, *, type_map: Field.TypeMap) -> Self: - if not issubclass(cls.__inner_type__, StorageContainer): - raise TypeError(f'{cls.__inner_type__} is not a StorageContainer') - cls.__inner_type__.__check_name_and_type__(name, type_) - field = cls() - field.__name = name - field.__type = type_ - field.__type_map = type_map - return field - - @override - def __set__(self, instance: Blueprint, value: T) -> None: - # XXX: alternatively this could mimick a `my_container.clear(); my_container.update(value)` - raise AttributeError('cannot set a container field') - - @override - def __get__(self, instance: Blueprint, owner: object | None = None) -> T: - cache = instance.syscall.__cache__ - if cache is not None and (obj := cache.get(self.__name)): - return obj - - # XXX: ideally we would instantiate the storage within _from_name_and_type, but we need the blueprint instance - # and we only have access to it when __get__ is called the first time - storage = self.__inner_type__.__from_name_and_type__( - instance.syscall.__storage__, - self.__name, - self.__type, - type_map=self.__type_map, - ) - if cache is not None: - cache[self.__name] = storage - return storage - - @override - def __delete__(self, instance: Blueprint) -> None: - # XXX: alternatively delete the database - raise AttributeError('cannot delete a container field') diff --git a/hathor/nanocontracts/fields/deque_field.py b/hathor/nanocontracts/fields/deque_container.py similarity index 68% rename from hathor/nanocontracts/fields/deque_field.py rename to hathor/nanocontracts/fields/deque_container.py index 2f2b449e6..fe36010b7 100644 --- a/hathor/nanocontracts/fields/deque_field.py +++ b/hathor/nanocontracts/fields/deque_container.py @@ -13,21 +13,22 @@ # limitations under the License. from collections import deque -from collections.abc import Iterable, Iterator, Sequence +from collections.abc import Container as ContainerAbc, Iterable, Iterator, Sequence, Sized from dataclasses import dataclass, replace from typing import ClassVar, SupportsIndex, TypeVar, get_args, get_origin from typing_extensions import Self, override -from hathor.nanocontracts.fields.container_field import KEY_SEPARATOR, ContainerField, StorageContainer +from hathor.nanocontracts.blueprint_env import NCAttrCache +from hathor.nanocontracts.fields.container import KEY_SEPARATOR, Container, ContainerNode, ContainerNodeFactory from hathor.nanocontracts.fields.field import Field -from hathor.nanocontracts.nc_types import NCType, VarInt32NCType -from hathor.nanocontracts.nc_types.dataclass_nc_type import make_dataclass_opt_nc_type +from hathor.nanocontracts.nc_types import VarInt32NCType +from hathor.nanocontracts.nc_types.dataclass_nc_type import make_dataclass_nc_type from hathor.nanocontracts.storage import NCContractStorage from hathor.util import not_none T = TypeVar('T') -_METADATA_KEY: str = '__metadata__' +_METADATA_KEY: bytes = b'__metadata__' _INDEX_NC_TYPE = VarInt32NCType() # TODO: support maxlen (will require support for initialization values) @@ -44,79 +45,77 @@ def last_index(self) -> int: return self.first_index + self.length - 1 -_METADATA_NC_TYPE = make_dataclass_opt_nc_type(_DequeMetadata) +_METADATA_NC_TYPE = make_dataclass_nc_type(_DequeMetadata) -class DequeStorageContainer(StorageContainer[Sequence[T]]): +class DequeContainer(Container[T]): # from https://github.com/python/typeshed/blob/main/stdlib/collections/__init__.pyi - __slots__ = ('__storage', '__name', '__value', '__metadata_key') - __storage: NCContractStorage - __name: str - __value: NCType[T] + __slots__ = ('__storage__', '__prefix__', '__value_node', '__metadata_key') + __value_node: ContainerNode[T] __metadata_key: bytes - def __init__(self, storage: NCContractStorage, name: str, value: NCType[T]) -> None: - self.__storage = storage - self.__name = name - self.__value = value - self.__metadata_key = f'{name}{KEY_SEPARATOR}{_METADATA_KEY}'.encode() + def __init__(self, storage: NCContractStorage, prefix: bytes, value_node: ContainerNode[T]) -> None: + self.__storage__ = storage + self.__prefix__ = prefix + self.__value_node = value_node + self.__metadata_key = KEY_SEPARATOR.join([self.__prefix__, _METADATA_KEY]) - # Methods needed by StorageContainer: + # Methods needed by Container: @override @classmethod - def __check_name_and_type__(cls, name: str, type_: type[Sequence[T]]) -> None: - if not name.isidentifier(): - raise TypeError('field name must be a valid identifier') - origin_type: type[Sequence[T]] = not_none(get_origin(type_)) + def __check_type__(cls, type_: type[ContainerAbc[T]], type_map: Field.TypeMap) -> None: + origin_type: type[ContainerAbc[T]] = not_none(get_origin(type_)) if not issubclass(origin_type, Sequence): raise TypeError('expected Sequence type') args = get_args(type_) if not args or len(args) != 1: - raise TypeError(f'expected {type_.__name__}[]') + raise TypeError('expected exactly 1 type argument') + value_type, = args + _ = ContainerNodeFactory.check_is_container(value_type, type_map) @override @classmethod - def __from_name_and_type__( + def __from_prefix_and_type__( cls, storage: NCContractStorage, - name: str, - type_: type[Sequence[T]], + prefix: bytes, + type_: type[ContainerAbc[T]], /, *, + cache: NCAttrCache, type_map: Field.TypeMap, ) -> 'Self': item_type, = get_args(type_) - item_nc_type = NCType.from_type(item_type, type_map=type_map.to_nc_type_map()) - return cls(storage, name, item_nc_type) + item_node = ContainerNode.from_type(storage, item_type, cache=cache, type_map=type_map) + return cls(storage, prefix, item_node) + + @override + def __init_storage__(self, initial_value: ContainerAbc[T] | None = None) -> None: + self.__storage__.put_obj(self.__metadata_key, _METADATA_NC_TYPE, _DequeMetadata()) + if initial_value is not None: + if not isinstance(initial_value, Sequence): + raise TypeError('expected initial_value to be a Sequence') + self.extend(initial_value) # INTERNAL METHODS: all of these must be __dunder_methods so they aren't accessible from an OCB def __to_db_key(self, index: SupportsIndex) -> bytes: - return f'{self.__name}{KEY_SEPARATOR}'.encode() + _INDEX_NC_TYPE.to_bytes(index.__index__()) + return KEY_SEPARATOR.join([self.__prefix__, _INDEX_NC_TYPE.to_bytes(index.__index__())]) def __get_metadata(self) -> _DequeMetadata: - metadata = self.__storage.get_obj(self.__metadata_key, _METADATA_NC_TYPE, default=None) - - if metadata is None: - metadata = _DequeMetadata() - self.__storage.put_obj(self.__metadata_key, _METADATA_NC_TYPE, metadata) - - assert isinstance(metadata, _DequeMetadata) + metadata = self.__storage__.get_obj(self.__metadata_key, _METADATA_NC_TYPE) return metadata def __update_metadata(self, new_metadata: _DequeMetadata) -> None: - assert new_metadata.length >= 0 - if new_metadata.length == 0: - return self.__storage.del_obj(self.__metadata_key) - self.__storage.put_obj(self.__metadata_key, _METADATA_NC_TYPE, new_metadata) + self.__storage__.put_obj(self.__metadata_key, _METADATA_NC_TYPE, new_metadata) def __extend(self, *, items: Iterable[T], metadata: _DequeMetadata) -> None: new_last_index = metadata.last_index for item in items: new_last_index += 1 - key = self.__to_db_key(new_last_index) - self.__storage.put_obj(key, self.__value, item) + db_key = self.__to_db_key(new_last_index) + self.__value_node.set_value(db_key, item) new_metadata = replace(metadata, length=new_last_index - metadata.first_index + 1) self.__update_metadata(new_metadata) @@ -124,8 +123,8 @@ def __extendleft(self, *, items: Iterable[T], metadata: _DequeMetadata) -> None: new_first_index = metadata.first_index for item in items: new_first_index -= 1 - key = self.__to_db_key(new_first_index) - self.__storage.put_obj(key, self.__value, item) + db_key = self.__to_db_key(new_first_index) + self.__value_node.set_value(db_key, item) new_metadata = replace( metadata, first_index=new_first_index, @@ -138,9 +137,9 @@ def __pop(self, *, metadata: _DequeMetadata, left: bool) -> T: raise IndexError index = metadata.first_index if left else metadata.last_index - key = self.__to_db_key(index) - item = self.__storage.get_obj(key, self.__value) - self.__storage.del_obj(key) + db_key = self.__to_db_key(index) + item = self.__value_node.get_value(db_key) + self.__value_node.del_value(db_key) new_metadata = replace( metadata, first_index=metadata.first_index + 1 if left else metadata.first_index, @@ -165,16 +164,16 @@ def __to_internal_index(self, *, index: SupportsIndex) -> int: def __getitem__(self, index: SupportsIndex, /) -> T: internal_index = self.__to_internal_index(index=index) - key = self.__to_db_key(internal_index) - return self.__storage.get_obj(key, self.__value) + db_key = self.__to_db_key(internal_index) + return self.__value_node.get_value(db_key) def __len__(self) -> int: return self.__get_metadata().length - def __setitem__(self, index: SupportsIndex, value: T, /) -> None: + def __setitem__(self, index: SupportsIndex, item: T, /) -> None: internal_index = self.__to_internal_index(index=index) - key = self.__to_db_key(internal_index) - self.__storage.put_obj(key, self.__value, value) + db_key = self.__to_db_key(internal_index) + self.__value_node.set_value(db_key, item) def __delitem__(self, key: SupportsIndex, /) -> None: raise NotImplementedError @@ -228,8 +227,8 @@ def __iter__(self) -> Iterator[T]: indexes = range(metadata.last_index, metadata.first_index - 1, -1) for i in indexes: - key = self.__to_db_key(i) - yield self.__storage.get_obj(key, self.__value) + db_key = self.__to_db_key(i) + yield self.__value_node.get_value(db_key) # Other deque methods that we implement to look like a deque: @@ -286,7 +285,15 @@ def __ge__(self, value: deque[T], /) -> bool: raise NotImplementedError def __eq__(self, value: object, /) -> bool: - raise NotImplementedError - - -DequeField = ContainerField[DequeStorageContainer[T]] + # XXX: return True if they point to the same data + if isinstance(value, DequeContainer) and self.__prefix__ == value.__prefix__: + return True + if isinstance(value, Iterable) and isinstance(value, Sized): + if len(value) != len(self): + return False + for i, j in zip(value, self): + if i != j: + return False + return True + else: + raise TypeError(f'cannot compare with value of type {type(value)}') diff --git a/hathor/nanocontracts/fields/dict_field.py b/hathor/nanocontracts/fields/dict_container.py similarity index 51% rename from hathor/nanocontracts/fields/dict_field.py rename to hathor/nanocontracts/fields/dict_container.py index 43b76925f..e58459cec 100644 --- a/hathor/nanocontracts/fields/dict_field.py +++ b/hathor/nanocontracts/fields/dict_container.py @@ -12,12 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections.abc import Hashable, Iterator, Mapping -from typing import TypeVar, get_args, get_origin, overload +from collections.abc import Container as ContainerAbc, Hashable, Iterator, Mapping +from typing import Generic, TypeVar, get_args, get_origin, overload from typing_extensions import Self, override -from hathor.nanocontracts.fields.container_field import KEY_SEPARATOR, ContainerField, StorageContainer +from hathor.nanocontracts.blueprint_env import NCAttrCache +from hathor.nanocontracts.fields.container import KEY_SEPARATOR, Container, ContainerNode, ContainerNodeFactory from hathor.nanocontracts.fields.field import Field from hathor.nanocontracts.nc_types import NCType, VarUint32NCType from hathor.nanocontracts.nc_types.utils import is_origin_hashable @@ -27,81 +28,88 @@ K = TypeVar('K', bound=Hashable) V = TypeVar('V') _T = TypeVar('_T') -_LENGTH_KEY: str = '__length__' +_LENGTH_KEY: bytes = b'__length__' _LENGTH_NC_TYPE = VarUint32NCType() -class DictStorageContainer(StorageContainer[Mapping[K, V]]): - """This is a dict-like object. +class DictContainer(Container[K], Generic[K, V]): + """ This is a dict-like object. Based on the implementation of UserDict, see: - https://github.com/python/cpython/blob/main/Lib/collections/__init__.py """ - __slots__ = ('__storage', '__name', '__key', '__value', '__length_key') - __storage: NCContractStorage - __name: str + __slots__ = ('__storage__', '__prefix__', '__key', '__value_node', '__length_key') __key: NCType[K] - __value: NCType[V] + __value_node: ContainerNode[V] __length_key: bytes - def __init__(self, storage: NCContractStorage, name: str, key: NCType[K], value: NCType[V]) -> None: - self.__storage = storage - self.__name = name + def __init__(self, storage: NCContractStorage, prefix: bytes, key: NCType[K], value: ContainerNode[V]) -> None: + self.__storage__ = storage + self.__prefix__ = prefix self.__key = key - self.__value = value - self.__length_key = f'{name}{KEY_SEPARATOR}{_LENGTH_KEY}'.encode() + self.__value_node = value + self.__length_key = KEY_SEPARATOR.join([self.__prefix__, _LENGTH_KEY]) - # Methods needed by StorageContainer: + # Methods needed by Container: @override @classmethod - def __check_name_and_type__(cls, name: str, type_: type[Mapping[K, V]]) -> None: - if not name.isidentifier(): - raise TypeError('field name must be a valid identifier') - origin_type: type[Mapping[K, V]] = not_none(get_origin(type_)) + def __check_type__(cls, type_: type[ContainerAbc[K]], type_map: Field.TypeMap) -> None: + origin_type: type[ContainerAbc[K]] = not_none(get_origin(type_)) if not issubclass(origin_type, Mapping): raise TypeError('expected Mapping type') args = get_args(type_) if not args or len(args) != 2: - raise TypeError(f'expected {type_.__name__}[, ]') + raise TypeError('expected exactly 2 type arguments') key_type, value_type = args if not is_origin_hashable(key_type): raise TypeError(f'{key_type} is not hashable') + NCType.check_type(key_type, type_map=type_map.to_nc_type_map()) + _ = ContainerNodeFactory.check_is_container(value_type, type_map) @override @classmethod - def __from_name_and_type__( + def __from_prefix_and_type__( cls, storage: NCContractStorage, - name: str, - type_: type[Mapping[K, V]], + prefix: bytes, + type_: type[ContainerAbc[K]], /, *, + cache: NCAttrCache, type_map: Field.TypeMap, ) -> Self: key_type, value_type = get_args(type_) key_nc_type = NCType.from_type(key_type, type_map=type_map.to_nc_type_map()) assert key_nc_type.is_hashable(), 'hashable "types" must produce hashable "values"' - value_nc_type = NCType.from_type(value_type, type_map=type_map.to_nc_type_map()) - return cls(storage, name, key_nc_type, value_nc_type) + value_node = ContainerNode.from_type(storage, value_type, cache=cache, type_map=type_map) + return cls(storage, prefix, key_nc_type, value_node) + + @override + def __init_storage__(self, initial_value: ContainerAbc[K] | None = None) -> None: + self.__storage__.put_obj(self.__length_key, _LENGTH_NC_TYPE, 0) + if initial_value is not None: + if not isinstance(initial_value, Mapping): + raise TypeError('expected initial_value to be a Mapping') + self.update(initial_value) # INTERNAL METHODS: all of these must be __dunder_methods so they aren't accessible from an OCB def __to_db_key(self, key: K) -> bytes: # We don't need to explicitly hash the key here, because the trie already does it internally. - return f'{self.__name}{KEY_SEPARATOR}'.encode() + self.__key.to_bytes(key) + return KEY_SEPARATOR.join([self.__prefix__, self.__key.to_bytes(key)]) def __get_length(self) -> int: - return self.__storage.get_obj(self.__length_key, _LENGTH_NC_TYPE, default=0) + return self.__storage__.get_obj(self.__length_key, _LENGTH_NC_TYPE) def __increase_length(self) -> None: - self.__storage.put_obj(self.__length_key, _LENGTH_NC_TYPE, self.__get_length() + 1) + self.__storage__.put_obj(self.__length_key, _LENGTH_NC_TYPE, self.__get_length() + 1) def __decrease_length(self) -> None: length = self.__get_length() assert length > 0 - self.__storage.put_obj(self.__length_key, _LENGTH_NC_TYPE, length - 1) + self.__storage__.put_obj(self.__length_key, _LENGTH_NC_TYPE, length - 1) # Methods needed by MutableMapping (and to behave like a dict) @@ -111,42 +119,79 @@ def __len__(self) -> int: def __getitem__(self, key: K, /) -> V: # get the data from the storage db_key = self.__to_db_key(key) - return self.__storage.get_obj(db_key, self.__value) + has_item = self.__value_node.has_value(db_key) + item = self.__value_node.get_value(db_key) + if not has_item: + # XXX: this means the item was implicitly created by get_value + self.__increase_length() + return item def __setitem__(self, key: K, value: V, /) -> None: + db_key = self.__to_db_key(key) if key not in self: self.__increase_length() # store `value` at `key` in the storage - self.__storage.put_obj(self.__to_db_key(key), self.__value, value) + self.__value_node.set_value(db_key, value) def __delitem__(self, key: K, /) -> None: + db_key = self.__to_db_key(key) if key not in self: return self.__decrease_length() # delete the key from the storage - self.__storage.del_obj(self.__to_db_key(key)) + self.__value_node.del_value(db_key) + + def __eq__(self, value: object, /) -> bool: + if isinstance(value, dict): + if len(value) != len(self): + return False + for k in value.keys(): + if k not in self: + return False + if self[k] != value[k]: + return False + return True + elif isinstance(value, DictContainer): + # XXX: only return True if they point to the same data + if self.__prefix__ == value.__prefix__: + return True + else: + raise ValueError('cannot compare dict-containers that point to different data') + else: + raise TypeError(f'cannot compare with value of type {type(value)}') def __iter__(self) -> Iterator[K]: raise NotImplementedError + def update(self, other=(), /, **kwds): + # builtin docstring: + # D.update([E, ]**F) -> None. Update D from dict/iterable E and F. + # If E is present and has a .keys() method, then does: for k in E: D[k] = E[k] + # If E is present and lacks a .keys() method, then does: for k, v in E: D[k] = v + # In either case, this is followed by: for k in F: D[k] = F[k] + + if hasattr(other, 'keys'): + for k in other.keys(): + self[k] = other[k] + else: + for k, v in other: + self[k] = v + for k, v in kwds.items(): + self[k] = v + # Methods provided by MutableMapping (currently not implemented): # def pop(self, key, default=__marker): # def popitem(self): # def clear(self): - # def update(self, other=(), /, **kwds): # def setdefault(self, key, default=None): # Modify __contains__ and get() to work like dict does when __missing__ is present. def __contains__(self, key: K, /) -> bool: - # return true if the `key` exists in the collection - try: - self[key] - except KeyError: - return False - else: - return True + db_key = self.__to_db_key(key) + has_key = self.__value_node.has_value(db_key) + return has_key @overload def get(self, key: K, /) -> V: @@ -159,10 +204,9 @@ def get(self, key: K, default: V | _T | None, /) -> V | _T | None: # XXX: `misc` is ignored because mypy thinks this function does not accept all arguments of the second get overload def get(self, key: K, default: V | _T | None = None, /) -> V | _T | None: # type: ignore[misc] # return the value for key if key is in the storage, else default - try: + if key in self: return self[key] - except KeyError: - return default + return default # Now, add the methods in dicts but not in MutableMapping @@ -187,6 +231,3 @@ def copy(self): @classmethod def fromkeys(cls, iterable, value=None, /): raise NotImplementedError - - -DictField = ContainerField[DictStorageContainer[K, V]] diff --git a/hathor/nanocontracts/fields/field.py b/hathor/nanocontracts/fields/field.py index 6a8bf007e..f7ad94527 100644 --- a/hathor/nanocontracts/fields/field.py +++ b/hathor/nanocontracts/fields/field.py @@ -14,12 +14,11 @@ from __future__ import annotations -from abc import ABC, abstractmethod -from typing import Generic, NamedTuple, TypeVar, final, get_origin +from typing import Generic, NamedTuple, TypeVar, final -from typing_extensions import TYPE_CHECKING, Self +from typing_extensions import TYPE_CHECKING -from hathor.nanocontracts.fields.utils import TypeToFieldMap +from hathor.nanocontracts.fields.container import ContainerNodeFactory, TypeToContainerMap from hathor.nanocontracts.nc_types import NCType from hathor.nanocontracts.nc_types.utils import TypeAliasMap, TypeToNCTypeMap @@ -29,7 +28,7 @@ T = TypeVar('T') -class Field(Generic[T], ABC): +class Field(Generic[T]): """ This class is used to model the fields of a Blueprint from the signature that defines them. Fields are generally free to implement how they behave, but we have 2 types of behavior: @@ -40,55 +39,48 @@ class Field(Generic[T], ABC): Usually only one of the two patterns above is supported by a field. The base class itself only defines how to construct a Field instance from a name and type signature, which is what the Blueprint metaclass needs. - OCB safety considerations: - A Blueprint must not be able to access a Field instance directly """ + __slots__ = ('_prefix', '_container_node_factory') + _prefix: bytes + _container_node_factory: ContainerNodeFactory + class TypeMap(NamedTuple): alias_map: TypeAliasMap nc_types_map: TypeToNCTypeMap - fields_map: TypeToFieldMap + container_map: TypeToContainerMap def to_nc_type_map(self) -> NCType.TypeMap: return NCType.TypeMap(self.alias_map, self.nc_types_map) # XXX: do we need to define field.__objclass__ for anything? + def __init__(self, prefix: bytes, type_: type[T], type_map: TypeMap) -> None: + self._prefix = prefix + self._container_node_factory = ContainerNodeFactory(type_, type_map) + @final @staticmethod def from_name_and_type(name: str, type_: type[T], /, *, type_map: TypeMap) -> Field[T]: - from hathor.nanocontracts.fields.nc_type_field import NCTypeField - - # if we have a `dict[int, int]` we use `get_origin()` to get the `dict` part, since it's a different instance - origin_type = get_origin(type_) or type_ - - if origin_type in type_map.fields_map: - field_class = type_map.fields_map[origin_type] - return field_class._from_name_and_type(name, type_, type_map=type_map) - else: - try: - return NCTypeField._from_name_and_type(name, type_, type_map=type_map) - except TypeError as e: - raise TypeError(f'type {type_} is not supported by any Field class') from e - - @classmethod - @abstractmethod - def _from_name_and_type(cls, name: str, type_: type[T], /, *, type_map: TypeMap) -> Self: - raise NotImplementedError - - @abstractmethod + assert name.isidentifier() + prefix = name.encode('utf-8') + return Field(prefix, type_, type_map) + + @property + def is_container(self) -> bool: + return self._container_node_factory.is_container + def __set__(self, instance: Blueprint, value: T) -> None: - # called when doing `instance.field = value` - raise NotImplementedError + node = self._container_node_factory.build(instance) + node.set_value(self._prefix, value) - @abstractmethod def __get__(self, instance: Blueprint, owner: object | None = None) -> T: - # called when doing `instance.field` as an expression - raise NotImplementedError + node = self._container_node_factory.build(instance) + return node.get_value(self._prefix) - @abstractmethod def __delete__(self, instance: Blueprint) -> None: - # called when doing `del instance.field` - raise NotImplementedError + node = self._container_node_factory.build(instance) + node.del_value(self._prefix) diff --git a/hathor/nanocontracts/fields/nc_type_field.py b/hathor/nanocontracts/fields/nc_type_field.py deleted file mode 100644 index 26c9027ca..000000000 --- a/hathor/nanocontracts/fields/nc_type_field.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright 2025 Hathor Labs -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from typing import TypeVar - -from typing_extensions import Self - -from hathor.nanocontracts.blueprint import Blueprint -from hathor.nanocontracts.fields.field import Field -from hathor.nanocontracts.nc_types import NCType - -T = TypeVar('T') - - -class NCTypeField(Field[T]): - """ This class models a Field after a NCType, where acessing the field implies deserializing the value from the db. - - This is modeled after a Python descriptor, similar to the built in `property`, see: - - - https://docs.python.org/3/reference/datamodel.html#implementing-descriptors - """ - __slots__ = ('__name', '__nc_type') - - __name: str - __nc_type: NCType[T] - - @classmethod - def _from_name_and_type(cls, name: str, type_: type[T], /, *, type_map: Field.TypeMap) -> Self: - field = cls() - field.__name = name - field.__nc_type = NCType.from_type(type_, type_map=type_map.to_nc_type_map()) - return field - - def __storage_key(self) -> bytes: - return self.__name.encode('utf-8') - - def __set__(self, instance: Blueprint, obj: T) -> None: - instance.syscall.__storage__.put_obj(self.__storage_key(), self.__nc_type, obj) - cache = instance.syscall.__cache__ - if cache is not None: - cache[self.__name] = obj - - def __get__(self, instance: Blueprint, owner: object | None = None) -> T: - cache = instance.syscall.__cache__ - if cache is not None and self.__name in cache: - return cache[self.__name] - - try: - obj = instance.syscall.__storage__.get_obj(self.__storage_key(), self.__nc_type) - if cache is not None: - cache[self.__name] = obj - return obj - except KeyError: - raise AttributeError(f'Contract has no attribute \'{self.__name}\'') - - def __delete__(self, instance: Blueprint) -> None: - instance.syscall.__storage__.del_obj(self.__storage_key()) diff --git a/hathor/nanocontracts/fields/set_field.py b/hathor/nanocontracts/fields/set_container.py similarity index 71% rename from hathor/nanocontracts/fields/set_field.py rename to hathor/nanocontracts/fields/set_container.py index 2826aae73..77ea6b20a 100644 --- a/hathor/nanocontracts/fields/set_field.py +++ b/hathor/nanocontracts/fields/set_container.py @@ -12,12 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections.abc import Iterable, Iterator +from collections.abc import Container as ContainerAbc, Iterable, Iterator from typing import Any, TypeVar, get_args, get_origin from typing_extensions import Self, override -from hathor.nanocontracts.fields.container_field import KEY_SEPARATOR, ContainerField, StorageContainer +from hathor.nanocontracts.blueprint_env import NCAttrCache +from hathor.nanocontracts.fields.container import KEY_SEPARATOR, Container, ContainerLeaf from hathor.nanocontracts.fields.field import Field from hathor.nanocontracts.nc_types import NCType, VarUint32NCType from hathor.nanocontracts.nc_types.utils import is_origin_hashable @@ -27,76 +28,83 @@ T = TypeVar('T') _S = TypeVar('_S') _NOT_PROVIDED = object() -_LENGTH_KEY: str = '__length__' -_LENGTH_NC_TYPE = VarUint32NCType() +_LENGTH_KEY: bytes = b'__length__' +_LENGTH_NC_TYPE: NCType[int] = VarUint32NCType() -class SetStorageContainer(StorageContainer[set[T]]): +class SetContainer(Container[T]): # from https://github.com/python/typeshed/blob/main/stdlib/collections/__init__.pyi # from https://github.com/python/typeshed/blob/main/stdlib/typing.pyi - __slots__ = ('__storage', '__name', '__value', '__length_key') - __storage: NCContractStorage - __name: str - __value: NCType[T] + __slots__ = ('__storage__', '__prefix__', '__member', '__length_key') + __member: ContainerLeaf[T] __length_key: bytes # XXX: what to do with this: # __hash__: ClassVar[None] # type: ignore[assignment] - def __init__(self, storage: NCContractStorage, name: str, value: NCType[T]) -> None: - self.__storage = storage - self.__name = name - self.__value = value - self.__length_key = f'{name}{KEY_SEPARATOR}{_LENGTH_KEY}'.encode() + def __init__(self, storage: NCContractStorage, prefix: bytes, member: ContainerLeaf[T]) -> None: + self.__storage__ = storage + self.__prefix__ = prefix + self.__member = member + self.__length_key = KEY_SEPARATOR.join([self.__prefix__, _LENGTH_KEY]) - # Methods needed by StorageContainer: + # Methods needed by Container: @override @classmethod - def __check_name_and_type__(cls, name: str, type_: type[set[T]]) -> None: - if not name.isidentifier(): - raise TypeError('field name must be a valid identifier') - origin_type: type[set[T]] = not_none(get_origin(type_)) + def __check_type__(cls, type_: type[ContainerAbc[T]], type_map: Field.TypeMap) -> None: + origin_type: type[ContainerAbc[T]] = not_none(get_origin(type_)) if not issubclass(origin_type, set): raise TypeError('expected set type') args = get_args(type_) if not args or len(args) != 1: - raise TypeError(f'expected {type_.__name__}[]') + raise TypeError('expected exactly 1 type argument') item_type, = args if not is_origin_hashable(item_type): raise TypeError(f'{item_type} is not hashable') + NCType.check_type(item_type, type_map=type_map.to_nc_type_map()) @override @classmethod - def __from_name_and_type__( + def __from_prefix_and_type__( cls, storage: NCContractStorage, - name: str, - type_: type[set[T]], + prefix: bytes, + type_: type[ContainerAbc[T]], /, *, + cache: NCAttrCache, type_map: Field.TypeMap, ) -> Self: item_type, = get_args(type_) item_nc_type = NCType.from_type(item_type, type_map=type_map.to_nc_type_map()) assert item_nc_type.is_hashable(), 'hashable "types" must produce hashable "values"' - return cls(storage, name, item_nc_type) + member_node = ContainerLeaf(storage, item_nc_type) + return cls(storage, prefix, member_node) + + @override + def __init_storage__(self, initial_value: ContainerAbc[T] | None = None) -> None: + self.__storage__.put_obj(self.__length_key, _LENGTH_NC_TYPE, 0) + if initial_value is not None: + if not isinstance(initial_value, set): + raise TypeError('expected initial_value to be a set') + self.update(initial_value) def __to_db_key(self, elem: T) -> bytes: # We don't need to explicitly hash the value here, because the trie already does it internally. - return f'{self.__name}{KEY_SEPARATOR}'.encode() + self.__value.to_bytes(elem) + return KEY_SEPARATOR.join([self.__prefix__, self.__member._nc_type.to_bytes(elem)]) def __get_length(self) -> int: - return self.__storage.get_obj(self.__length_key, _LENGTH_NC_TYPE, default=0) + return self.__storage__.get_obj(self.__length_key, _LENGTH_NC_TYPE) def __increase_length(self) -> None: - self.__storage.put_obj(self.__length_key, _LENGTH_NC_TYPE, self.__get_length() + 1) + self.__storage__.put_obj(self.__length_key, _LENGTH_NC_TYPE, self.__get_length() + 1) def __decrease_length(self) -> None: length = self.__get_length() assert length > 0 - self.__storage.put_obj(self.__length_key, _LENGTH_NC_TYPE, length - 1) + self.__storage__.put_obj(self.__length_key, _LENGTH_NC_TYPE, length - 1) # required by Iterable @@ -112,7 +120,7 @@ def __len__(self) -> int: def __contains__(self, elem: T, /) -> bool: key = self.__to_db_key(elem) - return self.__storage.has_obj(key) + return self.__storage__.has_obj(key) # provided by Set (currently not implemented): # @@ -135,16 +143,16 @@ def isdisjoint(self, other: Iterable[Any]) -> bool: def add(self, elem: T, /) -> None: key = self.__to_db_key(elem) - if self.__storage.has_obj(key): + if self.__member.has_value(key): return - self.__storage.put_obj(key, self.__value, elem) + self.__member.set_value(key, elem) self.__increase_length() def discard(self, elem: T, /) -> None: key = self.__to_db_key(elem) - if not self.__storage.has_obj(key): + if not self.__storage__.has_obj(key): return - self.__storage.del_obj(key) + self.__storage__.del_obj(key) self.__decrease_length() # provided by MutableSet (currently not implemented): @@ -161,9 +169,9 @@ def discard(self, elem: T, /) -> None: def remove(self, elem: T, /) -> None: key = self.__to_db_key(elem) - if not self.__storage.has_obj(key): + if not self.__member.has_value(key): raise KeyError - self.__storage.del_obj(key) + self.__member.del_value(key) self.__decrease_length() # Additional methods to behave like a set @@ -206,6 +214,3 @@ def update(self, *others: Iterable[T]) -> None: for other in others: for elem in other: self.add(elem) - - -SetField = ContainerField[SetStorageContainer[T]] diff --git a/hathor/nanocontracts/fields/utils.py b/hathor/nanocontracts/fields/utils.py deleted file mode 100644 index b03027b17..000000000 --- a/hathor/nanocontracts/fields/utils.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright 2025 Hathor Labs -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from collections.abc import Mapping -from typing import TYPE_CHECKING, TypeAlias - -if TYPE_CHECKING: - from hathor.nanocontracts.fields import Field - - -TypeToFieldMap: TypeAlias = Mapping[type, type['Field']] diff --git a/hathor/nanocontracts/nc_types/nc_type.py b/hathor/nanocontracts/nc_types/nc_type.py index 432ee451e..1fdbb5586 100644 --- a/hathor/nanocontracts/nc_types/nc_type.py +++ b/hathor/nanocontracts/nc_types/nc_type.py @@ -67,6 +67,19 @@ def from_type(type_: type[T], /, *, type_map: TypeMap) -> NCType[T]: aliased_type = get_aliased_type(type_, type_map.alias_map) return nc_type._from_type(aliased_type, type_map=type_map) + @final + @staticmethod + def check_type(type_: type[T], /, *, type_map: TypeMap) -> None: + usable_origin = get_usable_origin_type(type_, type_map=type_map) + nc_type = type_map.nc_types_map[usable_origin] + # XXX: first we try to create the nc_type without making an alias, this ensures that an invalid annotation + # would not be accepted + _ = nc_type._from_type(type_, type_map=type_map) + # XXX: then we create the actual nc_type with type-alias + aliased_type = get_aliased_type(type_, type_map.alias_map) + # XXX: currently this is identical to from_type() but doesn't return the nc_type + nc_type._from_type(aliased_type, type_map=type_map) + @classmethod def _from_type(cls, type_: type[T], /, *, type_map: TypeMap) -> Self: """ Instantiate a NCType instance from a type signature. diff --git a/hathor/nanocontracts/nc_types/tuple_nc_type.py b/hathor/nanocontracts/nc_types/tuple_nc_type.py index 85f1c1bc7..b71bea676 100644 --- a/hathor/nanocontracts/nc_types/tuple_nc_type.py +++ b/hathor/nanocontracts/nc_types/tuple_nc_type.py @@ -56,7 +56,7 @@ def _from_type(cls, type_: type[tuple], /, *, type_map: NCType.TypeMap) -> Self: args = list(get_args(type_)) if args is None: raise TypeError('expected tuple[]') - if issubclass(type_, list): + if issubclass(origin_type, list): args.append(Ellipsis) if args and args[-1] == Ellipsis: if len(args) != 2: diff --git a/hathor/nanocontracts/storage/changes_tracker.py b/hathor/nanocontracts/storage/changes_tracker.py index a5155267c..49ec81f65 100644 --- a/hathor/nanocontracts/storage/changes_tracker.py +++ b/hathor/nanocontracts/storage/changes_tracker.py @@ -129,8 +129,11 @@ def get_obj(self, key: bytes, nc_type: NCType[T], *, default: D = _NOT_PROVIDED) obj_td = self.storage.get_obj(key, nc_type, default=default) obj = obj_td if obj is DeletedKey: + if default is not _NOT_PROVIDED: + return default raise KeyError(key) assert not isinstance(obj, DeletedKeyType) + assert obj is not _NOT_PROVIDED return obj @override diff --git a/tests/nanocontracts/fields/test_compound_field.py b/tests/nanocontracts/fields/test_compound_field.py index 068f7824b..ec7a36e19 100644 --- a/tests/nanocontracts/fields/test_compound_field.py +++ b/tests/nanocontracts/fields/test_compound_field.py @@ -1,11 +1,13 @@ from hathor.nanocontracts import Blueprint, Context, public from hathor.nanocontracts.catalog import NCBlueprintCatalog -from hathor.nanocontracts.nc_types import TupleNCType, VarInt32NCType +from hathor.nanocontracts.fields.container import INIT_NC_TYPE +from hathor.nanocontracts.fields.deque_container import _METADATA_NC_TYPE as METADATA_NC_TYPE +from hathor.nanocontracts.nc_types import VarInt32NCType from hathor.transaction import Block, Transaction from tests import unittest from tests.dag_builder.builder import TestDAGBuilder -INT_VARTUPLE_NC_TYPE = TupleNCType(VarInt32NCType()) +INT_NC_TYPE = VarInt32NCType() class BlueprintWithCompoundField(Blueprint): @@ -13,18 +15,34 @@ class BlueprintWithCompoundField(Blueprint): @public def initialize(self, ctx: Context) -> None: - assert self.dc.get('foo', []) == [] - self.dc['foo'] = [1, 2, 3] + self.dc['foo'] = [1, 2] + assert len(self.dc) == 1 + foo = self.dc['foo'] + foo.append(3) + assert len(foo) == 3 self.dc['bar'] = [4, 5, 6, 7] + assert len(self.dc) == 2 assert self.dc['foo'] == [1, 2, 3] assert self.dc['bar'] == [4, 5, 6, 7] + foo = self.dc['foo'] + foo.pop() + assert len(foo) == 2 + foo.pop() + assert len(foo) == 1 + foo.pop() + assert len(foo) == 0 + assert len(self.dc['foo']) == 0 + assert 'bar' in self.dc + assert len(self.dc) == 2 del self.dc['foo'] - try: - self.dc['foo'] - except KeyError as e: - assert e.args[0] == b'dc:\x03foo' - assert 'foo' not in self.dc + assert len(self.dc) == 1 assert 'bar' in self.dc + assert 'foo' not in self.dc + # XXX: implicit creation: + assert self.dc['foo'] == [] + assert len(self.dc) == 2 + # remove foo, test will check it was removed from the storage + del self.dc['foo'] class TestDictField(unittest.TestCase): @@ -60,12 +78,23 @@ def test_dict_field(self) -> None: b11_storage = self.manager.get_nc_storage(b11, nc1.hash) with self.assertRaises(KeyError): - b11_storage.get_obj(b'dc:\x03foo', INT_VARTUPLE_NC_TYPE) - assert b11_storage.get_obj(b'dc:\x03bar', INT_VARTUPLE_NC_TYPE) == (4, 5, 6, 7) + b11_storage.get_obj(b'dc:\x03foo:__init__', INIT_NC_TYPE) + assert b11_storage.get_obj(b'dc:\x03bar:__init__', INIT_NC_TYPE) is True assert b12.get_metadata().voided_by is None b12_storage = self.manager.get_nc_storage(b12, nc1.hash) with self.assertRaises(KeyError): - b12_storage.get_obj(b'dc:\x03foo', INT_VARTUPLE_NC_TYPE) - assert b12_storage.get_obj(b'dc:\x03bar', INT_VARTUPLE_NC_TYPE) == (4, 5, 6, 7) + b12_storage.get_obj(b'dc:\x03foo:__init__', INIT_NC_TYPE) + assert b12_storage.get_obj(b'dc:\x03bar:__init__', INIT_NC_TYPE) is True + assert b12_storage.get_obj(b'dc:\x03bar:\x00', INT_NC_TYPE) == 4 + assert b12_storage.get_obj(b'dc:\x03bar:\x01', INT_NC_TYPE) == 5 + assert b12_storage.get_obj(b'dc:\x03bar:\x02', INT_NC_TYPE) == 6 + assert b12_storage.get_obj(b'dc:\x03bar:\x03', INT_NC_TYPE) == 7 + with self.assertRaises(KeyError): + b12_storage.get_obj(b'dc:\x03bar:\x04', INT_NC_TYPE) + metadata = b12_storage.get_obj(b'dc:\x03bar:__metadata__', METADATA_NC_TYPE) + assert metadata.first_index == 0 + assert metadata.last_index == 3 + assert metadata.length == 4 + assert not metadata.reversed diff --git a/tests/nanocontracts/fields/test_storage_deque.py b/tests/nanocontracts/fields/test_storage_deque.py index 17e4da952..9fb59d94d 100644 --- a/tests/nanocontracts/fields/test_storage_deque.py +++ b/tests/nanocontracts/fields/test_storage_deque.py @@ -16,7 +16,8 @@ import pytest -from hathor.nanocontracts.fields.deque_field import DequeStorageContainer, _DequeMetadata +from hathor.nanocontracts.fields.container import ContainerLeaf +from hathor.nanocontracts.fields.deque_container import DequeContainer, _DequeMetadata from hathor.nanocontracts.nc_types import Int32NCType, StrNCType from tests.nanocontracts.fields.utils import MockNCStorage @@ -26,16 +27,19 @@ def test_basic() -> None: storage = MockNCStorage() - dq = DequeStorageContainer(storage, 'dq', INT_NC_TYPE) - + dq = DequeContainer(storage, b'dq', ContainerLeaf(storage, INT_NC_TYPE)) assert storage.store == {} + dq.__init_storage__() + assert storage.store == {b'dq:__metadata__': _DequeMetadata(first_index=0, length=0, reversed=False)} + assert list(dq) == [] assert dq.maxlen is None def test_append() -> None: storage = MockNCStorage() - dq = DequeStorageContainer(storage, 'dq', STR_NC_TYPE) + dq = DequeContainer(storage, b'dq', ContainerLeaf(storage, STR_NC_TYPE)) + dq.__init_storage__() dq.append('a') dq.append('b') @@ -61,7 +65,8 @@ def test_append() -> None: def test_appendleft() -> None: storage = MockNCStorage() - dq = DequeStorageContainer(storage, 'dq', STR_NC_TYPE) + dq = DequeContainer(storage, b'dq', ContainerLeaf(storage, STR_NC_TYPE)) + dq.__init_storage__() dq.appendleft('a') dq.appendleft('b') @@ -87,7 +92,8 @@ def test_appendleft() -> None: def test_extend() -> None: storage = MockNCStorage() - dq = DequeStorageContainer(storage, 'dq', INT_NC_TYPE) + dq = DequeContainer(storage, b'dq', ContainerLeaf(storage, INT_NC_TYPE)) + dq.__init_storage__() dq.extend([1, 2, 3]) @@ -121,7 +127,8 @@ def test_extend() -> None: def test_extendleft() -> None: storage = MockNCStorage() - dq = DequeStorageContainer(storage, 'dq', INT_NC_TYPE) + dq = DequeContainer(storage, b'dq', ContainerLeaf(storage, INT_NC_TYPE)) + dq.__init_storage__() dq.extendleft([1, 2, 3]) @@ -155,8 +162,8 @@ def test_extendleft() -> None: def test_pop() -> None: storage = MockNCStorage() - dq = DequeStorageContainer(storage, 'dq', INT_NC_TYPE) - dq.extend([1, 2, 3, 4]) + dq = DequeContainer(storage, b'dq', ContainerLeaf(storage, INT_NC_TYPE)) + dq.__init_storage__([1, 2, 3, 4]) assert dq.pop() == 4 assert storage.store == { @@ -183,7 +190,7 @@ def test_pop() -> None: # popping the last element resets the deque assert dq.pop() == 2 - assert storage.store == {} + assert storage.store == {b'dq:__metadata__': _DequeMetadata(first_index=2, length=0, reversed=True)} with pytest.raises(IndexError): dq.pop() @@ -191,8 +198,8 @@ def test_pop() -> None: def test_popleft() -> None: storage = MockNCStorage() - dq = DequeStorageContainer(storage, 'dq', INT_NC_TYPE) - dq.extend([1, 2, 3, 4]) + dq = DequeContainer(storage, b'dq', ContainerLeaf(storage, INT_NC_TYPE)) + dq.__init_storage__([1, 2, 3, 4]) assert dq.popleft() == 1 assert storage.store == { @@ -219,7 +226,7 @@ def test_popleft() -> None: # popping the last element resets the deque assert dq.popleft() == 3 - assert storage.store == {} + assert storage.store == {b'dq:__metadata__': _DequeMetadata(first_index=2, length=0, reversed=True)} with pytest.raises(IndexError): dq.popleft() @@ -228,8 +235,8 @@ def test_popleft() -> None: def test_reverse() -> None: storage = MockNCStorage() - dq = DequeStorageContainer(storage, 'dq', STR_NC_TYPE) - dq.extend(['a', 'b', 'c']) + dq = DequeContainer(storage, b'dq', ContainerLeaf(storage, STR_NC_TYPE)) + dq.__init_storage__(['a', 'b', 'c']) assert storage.store == { b'dq:\x00': 'a', @@ -252,9 +259,8 @@ def test_reverse() -> None: def test_indexing() -> None: storage = MockNCStorage() - dq = DequeStorageContainer(storage, 'dq', STR_NC_TYPE) - - dq.extend(['a', 'b', 'c', 'd']) + dq = DequeContainer(storage, b'dq', ContainerLeaf(storage, STR_NC_TYPE)) + dq.__init_storage__(['a', 'b', 'c', 'd']) assert storage.store == { b'dq:\x00': 'a', @@ -307,9 +313,9 @@ def test_indexing() -> None: def test_indexing_reversed() -> None: storage = MockNCStorage() - dq = DequeStorageContainer(storage, 'dq', STR_NC_TYPE) + dq = DequeContainer(storage, b'dq', ContainerLeaf(storage, STR_NC_TYPE)) + dq.__init_storage__(['a', 'b', 'c', 'd']) - dq.extend(['a', 'b', 'c', 'd']) dq.reverse() assert storage.store == { @@ -351,7 +357,8 @@ def test_indexing_reversed() -> None: def test_len() -> None: storage = MockNCStorage() - dq = DequeStorageContainer(storage, 'dq', STR_NC_TYPE) + dq = DequeContainer(storage, b'dq', ContainerLeaf(storage, STR_NC_TYPE)) + dq.__init_storage__() assert len(dq) == 0 dq.append('a') @@ -366,7 +373,8 @@ def test_len() -> None: def test_reverse_empty() -> None: storage = MockNCStorage() - dq = DequeStorageContainer(storage, 'dq', INT_NC_TYPE) + dq = DequeContainer(storage, b'dq', ContainerLeaf(storage, INT_NC_TYPE)) + dq.__init_storage__() assert list(dq) == [] dq.reverse() assert list(dq) == [] diff --git a/tests/nanocontracts/fields/test_storage_set.py b/tests/nanocontracts/fields/test_storage_set.py index 2253fd2b8..b1c1d8b61 100644 --- a/tests/nanocontracts/fields/test_storage_set.py +++ b/tests/nanocontracts/fields/test_storage_set.py @@ -16,24 +16,27 @@ import pytest -from hathor.nanocontracts.fields.set_field import SetStorageContainer +from hathor.nanocontracts.fields.container import ContainerLeaf +from hathor.nanocontracts.fields.set_container import SetContainer from hathor.nanocontracts.nc_types import Int32NCType from tests.nanocontracts.fields.utils import MockNCStorage -_INT_NC_TYPE = Int32NCType() +INT_NC_TYPE = Int32NCType() def test_basic() -> None: storage = MockNCStorage() - my_set = SetStorageContainer(storage, 'my_set', _INT_NC_TYPE) + my_set = SetContainer(storage, b'my_set', ContainerLeaf(storage, INT_NC_TYPE)) + my_set.__init_storage__() + assert storage.store == {b'my_set:__length__': 0} assert len(my_set) == 0 - assert storage.store == {} def test_add_remove_discard() -> None: storage = MockNCStorage() - my_set = SetStorageContainer(storage, 'my_set', _INT_NC_TYPE) + my_set = SetContainer(storage, b'my_set', ContainerLeaf(storage, INT_NC_TYPE)) + my_set.__init_storage__() my_set.add(1) my_set.add(1) @@ -56,7 +59,8 @@ def test_add_remove_discard() -> None: def test_updates_and_contains() -> None: storage = MockNCStorage() - my_set = SetStorageContainer(storage, 'my_set', _INT_NC_TYPE) + my_set = SetContainer(storage, b'my_set', ContainerLeaf(storage, INT_NC_TYPE)) + my_set.__init_storage__() my_set.update({1, 2, 3}, [2, 3, 4]) assert _get_values(storage) == {1, 2, 3, 4} @@ -75,8 +79,8 @@ def test_updates_and_contains() -> None: def test_isdisjoint() -> None: storage = MockNCStorage() - my_set = SetStorageContainer(storage, 'my_set', _INT_NC_TYPE) - my_set.update({1, 2, 3}) + my_set = SetContainer(storage, b'my_set', ContainerLeaf(storage, INT_NC_TYPE)) + my_set.__init_storage__({1, 2, 3}) assert my_set.isdisjoint(set()) assert my_set.isdisjoint({4, 5, 6}) @@ -87,8 +91,8 @@ def test_isdisjoint() -> None: def test_issuperset() -> None: storage = MockNCStorage() - my_set = SetStorageContainer(storage, 'my_set', _INT_NC_TYPE) - my_set.update({1, 2, 3}) + my_set = SetContainer(storage, b'my_set', ContainerLeaf(storage, INT_NC_TYPE)) + my_set.__init_storage__({1, 2, 3}) assert my_set.issuperset({}) assert my_set.issuperset({1}) @@ -99,8 +103,8 @@ def test_issuperset() -> None: def test_intersection() -> None: storage = MockNCStorage() - my_set = SetStorageContainer(storage, 'my_set', _INT_NC_TYPE) - my_set.update({1, 2, 3}) + my_set = SetContainer(storage, b'my_set', ContainerLeaf(storage, INT_NC_TYPE)) + my_set.__init_storage__({1, 2, 3}) assert my_set.intersection(set()) == set() assert my_set.intersection({1}) == {1} diff --git a/tests/nanocontracts/fields/utils.py b/tests/nanocontracts/fields/utils.py index 9f900a66c..9df7a37d0 100644 --- a/tests/nanocontracts/fields/utils.py +++ b/tests/nanocontracts/fields/utils.py @@ -32,10 +32,10 @@ def __init__(self) -> None: @override def get_obj(self, key: bytes, value: NCType[T], *, default: D = _NOT_PROVIDED) -> T | D: - if item := self.store.get(key, default): - return item + if key in self.store: + return self.store[key] if default is _NOT_PROVIDED: - raise KeyError + raise KeyError(key) return default @override diff --git a/tests/nanocontracts/test_all_fields.py b/tests/nanocontracts/test_all_fields.py index d7eebb59f..00aa61dc3 100644 --- a/tests/nanocontracts/test_all_fields.py +++ b/tests/nanocontracts/test_all_fields.py @@ -12,8 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import re - from hathor.nanocontracts import OnChainBlueprint from hathor.nanocontracts.blueprint import Blueprint from hathor.nanocontracts.context import Context @@ -99,10 +97,7 @@ def initialize(self, ctx: Context) -> None: assert cm.exception.args[0] == 'unsupported field type: `invalid_attribute: NamedTuple`' context_exception = cm.exception.__context__ assert isinstance(context_exception, TypeError) - assert re.match( - r'type is not supported by any Field class', - context_exception.args[0] - ) + assert context_exception.args[0] == 'issubclass() arg 1 must be a class' def test_no_bytearray(self) -> None: with self.assertRaises(BlueprintSyntaxError) as cm: @@ -116,7 +111,7 @@ def initialize(self, ctx: Context) -> None: assert cm.exception.args[0] == 'unsupported field type: `invalid_attribute: bytearray`' context_exception = cm.exception.__context__ assert isinstance(context_exception, TypeError) - assert context_exception.args[0] == r"type is not supported by any Field class" + assert context_exception.args[0] == r"type is not supported by any NCType class" def test_no_typing_union(self) -> None: from typing import Union @@ -132,7 +127,7 @@ def initialize(self, ctx: Context) -> None: assert cm.exception.args[0] == 'unsupported field type: `invalid_attribute: typing.Union[str, int]`' context_exception = cm.exception.__context__ assert isinstance(context_exception, TypeError) - assert context_exception.args[0] == r"type typing.Union[str, int] is not supported by any Field class" + assert context_exception.args[0] == r"type typing.Union[str, int] is not supported by any NCType class" def test_no_union_type(self) -> None: with self.assertRaises(BlueprintSyntaxError) as cm: @@ -146,7 +141,7 @@ def initialize(self, ctx: Context) -> None: assert cm.exception.args[0] == 'unsupported field type: `invalid_attribute: str | int`' context_exception = cm.exception.__context__ assert isinstance(context_exception, TypeError) - assert context_exception.args[0] == r"type str | int is not supported by any Field class" + assert context_exception.args[0] == r"type str | int is not supported by any NCType class" def test_no_none(self) -> None: with self.assertRaises(BlueprintSyntaxError) as cm: @@ -160,4 +155,4 @@ def initialize(self, ctx: Context) -> None: assert cm.exception.args[0] == 'unsupported field type: `invalid_attribute: None`' context_exception = cm.exception.__context__ assert isinstance(context_exception, TypeError) - assert context_exception.args[0] == r"type None is not supported by any Field class" + assert context_exception.args[0] == r"type None is not supported by any NCType class"