diff --git a/hathor/nanocontracts/faux_immutable.py b/hathor/nanocontracts/faux_immutable.py index 0587d3b32..a6fbdbf3b 100644 --- a/hathor/nanocontracts/faux_immutable.py +++ b/hathor/nanocontracts/faux_immutable.py @@ -12,125 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - -from typing import Callable, TypeVar - -from typing_extensions import ParamSpec - -# special attrs: -SKIP_VALIDATION_ATTR: str = '__skip_faux_immutability_validation__' -ALLOW_INHERITANCE_ATTR: str = '__allow_faux_inheritance__' -ALLOW_DUNDER_ATTR: str = '__allow_faux_dunder__' - - -def _validate_faux_immutable_meta(name: str, bases: tuple[type, ...], attrs: dict[str, object]) -> None: - """Run validations during faux-immutable class creation.""" - required_attrs = frozenset({ - '__slots__', - }) - - for attr in required_attrs: - if attr not in attrs: - raise TypeError(f'faux-immutable class `{name}` must define `{attr}`') - - custom_allowed_dunder_value: tuple[str, ...] = attrs.pop(ALLOW_DUNDER_ATTR, ()) # type: ignore[assignment] - custom_allowed_dunder = frozenset(custom_allowed_dunder_value) - allowed_dunder = frozenset({ - '__module__', - '__qualname__', - '__doc__', - '__init__', - '__call__', - '__firstlineno__', - '__static_attributes__', - }) | custom_allowed_dunder - - # 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: - 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): - """ - A metaclass for faux-immutable classes. - This means the class objects themselves are immutable, that is, `__setattr__` always raises AttributeError. - Don't use this metaclass directly, inherit from `FauxImmutable` instead. - """ - __slots__ = () - - 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_VALIDATION_ATTR, False): - _validate_faux_immutable_meta(name, bases, attrs) - return super().__new__(cls, name, bases, attrs, **kwargs) - - def __setattr__(cls, name: str, value: object) -> None: - raise AttributeError(f'cannot set attribute `{name}` on faux-immutable class') - - -class FauxImmutable(metaclass=FauxImmutableMeta): - """ - Utility superclass for creating faux-immutable classes. - Simply inherit from it to define a faux-immutable class. - """ - __slots__ = () - __skip_faux_immutability_validation__: bool = True # Skip validation to bypass the no dunder rule. - - def __setattr__(self, name: str, value: object) -> None: - raise AttributeError(f'cannot set attribute `{name}` on faux-immutable object') - - -T = TypeVar('T', bound=FauxImmutable) -P = ParamSpec('P') - - -def create_with_shell(cls: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T: - """Mimic `cls.__call__` method behavior, but wrapping the created instance with an ad-hoc shell class.""" - # Keep the same name as the original class. - assert isinstance(cls, type) - name = cls.__name__ - - # The original class is the shell's only base. - bases = (cls,) - - # The shell doesn't have any slots and must skip validation to bypass the inheritance rule. - attrs = dict(__slots__=(), __skip_faux_immutability_validation__=True) - - # Create a dynamic class that is only used on this call. - shell_type: type[T] = type(name, bases, attrs) - - # Use it to instantiate the object, init it, and return it. This mimics the default `__call__` behavior. - obj: T = cls.__new__(shell_type) # type: ignore[call-overload] - shell_type.__init__(obj, *args, **kwargs) - return obj - - -def __set_faux_immutable__(obj: FauxImmutable, name: str, value: object) -> None: - """ - When setting attributes on the `__init__` method of a faux-immutable class, - use this utility function to bypass the protections. - Only use it when you know what you're doing. - """ - if name.startswith('__') and not name.endswith('__'): - # Account for Python's name mangling. - name = f'_{obj.__class__.__name__}{name}' - - # This shows that a faux-immutable class is never actually immutable. - # It's always possible to mutate it via `object.__setattr__`. - object.__setattr__(obj, name, value) +# Re-export from hathorlib for backward compatibility +from hathorlib.nanocontracts.faux_immutable import ( # noqa: F401 + ALLOW_DUNDER_ATTR, + ALLOW_INHERITANCE_ATTR, + SKIP_VALIDATION_ATTR, + FauxImmutable, + FauxImmutableMeta, + __set_faux_immutable__, + create_with_shell, +) diff --git a/hathor/utils/typing.py b/hathor/utils/typing.py index 1e7d2a6e5..51aa6f67e 100644 --- a/hathor/utils/typing.py +++ b/hathor/utils/typing.py @@ -12,208 +12,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - -from types import UnionType -from typing import Generic, TypeVar, get_args as _typing_get_args, get_origin as _typing_get_origin -from weakref import WeakValueDictionary - -from typing_extensions import Self - -T = TypeVar('T') - - -def get_origin(t: type | UnionType, /) -> type | None: - """Extension of typing.get_origin to also work with classes that use InnerTypeMixin""" - if isinstance(t, type) and issubclass(t, InnerTypeMixin): - return getattr(t, '__origin__', None) - return _typing_get_origin(t) - - -def get_args(t: type | UnionType, /) -> tuple[type, ...] | None: - """Extension of typing.get_args to also work with classes that use InnerTypeMixin""" - if isinstance(t, type) and issubclass(t, InnerTypeMixin): - return getattr(t, '__args__', None) - return _typing_get_args(t) - - -class InnerTypeMixin(Generic[T]): - """ - Mixin class that exposes its single type‐argument at runtime as `self.__inner_type__`, - enforces exactly one type argument at subscription time, caches parameterized subclasses - so C[int] is C[int], and provides a clean repr listing public fields. - - >>> from typing import TypeVar - >>> U = TypeVar('U') - >>> class MyData(InnerTypeMixin, Generic[T]): - ... def __init__(self, data: T): - ... self.data = data - ... - >>> class MyCounter(InnerTypeMixin, Generic[T]): - ... def __init__(self, first: T, count: int): - ... self.first = first - ... self.count = count - ... - - # 1) You must supply exactly one type argument: - >>> try: - ... MyData(1) - ... except TypeError as e: - ... print(e) - MyData[...] requires exactly one type argument, got none - - >>> try: - ... MyData[int, str](1) - ... except TypeError as e: - ... print(e) - MyData[...] expects exactly one type argument; got 2 - - # You may write MyData[U] for signatures, but instantiation will reject a bare TypeVar: - >>> MyData[U] # no error - - - >>> try: - ... MyData[U]() - ... except TypeError as e: - ... print(e) - MyData[...] requires a concrete type argument, got ~U - - # Correct usage with a concrete type: - >>> sd = MyData[int](123) - >>> MyData[int] is MyData[int] - True - >>> sd.__inner_type__ is int - True - >>> print(sd) - MyData[int](data=123) - - # Works with multiple fields too: - >>> h = MyCounter[str]("foo", 42) - >>> h.__inner_type__ is str - True - >>> print(h) - MyCounter[str](first='foo', count=42) - """ - - # cache shared by all subclasses, maps concrete inner_type -> subclass, but doesn't keep subclasses alive if it has - # no live references anymore, this keeps the cache from growing indefinitely in case of dynamically generated - # classes, there's no point in holding unreferenced classes here - __type_cache: WeakValueDictionary[tuple[type, type], type[Self]] = WeakValueDictionary() - - # this class will expose this instance property - __inner_type__: type[T] - - @classmethod - def __extract_inner_type__(cls, args: tuple[type, ...], /) -> type[T]: - """Defines how to convert the recived argument tuples into the stored type. - - If customization is needed, this class method is the place to do it. I could be used so only the origin-type is - stored, or to accept multiple arguments and store a tuple of types, or to convert the arguments into different - types. - """ - if len(args) != 1: - raise TypeError(f'{cls.__name__}[...] expects exactly one type argument; got {len(args)}') - inner_type, = args - return inner_type - - @classmethod - def __class_getitem__(cls, params): - # parameterizing the mixin itself delegates to Generic - if cls is InnerTypeMixin: - return super().__class_getitem__(params) - - # normalize to a 1-tuple - args = params if isinstance(params, tuple) else (params,) - inner_type = cls.__extract_inner_type__(args) - - cache = cls.__type_cache - key = (cls, inner_type) - sub = cache.get(key) - if sub is None: - # subclass keeps the same name for clean repr - sub = type(cls.__name__, (cls,), {}) - sub.__inner_type__ = inner_type - sub.__origin__ = cls - sub.__args__ = (inner_type,) - sub.__module__ = cls.__module__ - sub.__type_cache = cache - cache[key] = sub - return sub - - def __new__(cls, *args, **kwargs): - # reject unsubscripted class - if not get_args(cls): - raise TypeError(f'{cls.__name__}[...] requires exactly one type argument, got none') - - # reject if the subscribed‐in type is still a TypeVar - inner_type = getattr(cls, '__inner_type__', None) - if isinstance(inner_type, TypeVar): - raise TypeError(f'{cls.__name__}[...] requires a concrete type argument, got {inner_type!r}') - - # build instance and copy down the inner type - self = super().__new__(cls) - self.__inner_type__ = inner_type - return self - - def __repr__(self) -> str: - name = type(self).__name__ - t = self.__inner_type__ - tname = getattr(t, '__name__', repr(t)) - public = [(n, v) for n, v in vars(self).items() if not n.startswith('_')] - if public: - body = ', '.join(f'{n}={v!r}' for n, v in public) - return f'{name}[{tname}]({body})' - return f'{name}[{tname}]()' - - -def is_subclass(cls: type, class_or_tuple: type | tuple[type] | UnionType, /) -> bool: - """ Reimplements issubclass() with support for recursive NewType classes. - - Normal behavior from `issubclass`: - - >>> is_subclass(int, int) - True - >>> is_subclass(bool, int) - True - >>> is_subclass(bool, (int, str)) - True - >>> is_subclass(bool, int | str) - True - >>> is_subclass(bool, bytes | str) - False - >>> is_subclass(str, int) - False - - But `is_subclass` also works when a NewType is given as arg 1: - - >>> from typing import NewType - >>> N = NewType('N', int) - >>> is_subclass(N, int) - True - >>> is_subclass(N, int | str) - True - >>> is_subclass(N, str) - False - >>> M = NewType('M', N) - >>> is_subclass(M, int) - True - >>> is_subclass(M, str) - False - >>> try: - ... is_subclass(M, N) - ... except TypeError as e: - ... print(*e.args) - issubclass() arg 2 must be a class, a tuple of classes, or a union - - It is also expeced to fail in the same way as `issubclass` when the resolving the NewType doesn't lead to a class: - - >>> F = NewType('F', 'not a class') - >>> try: - ... is_subclass(F, str) - ... except TypeError as e: - ... print(*e.args) - issubclass() arg 1 must be a class - """ - while (super_type := getattr(cls, '__supertype__', None)) is not None: - cls = super_type - return issubclass(cls, class_or_tuple) +# Re-export from hathorlib for backward compatibility +from hathorlib.utils.typing import InnerTypeMixin, get_args, get_origin, is_subclass # noqa: F401 diff --git a/hathorlib/Makefile b/hathorlib/Makefile index f3681ce51..feeaf19d5 100644 --- a/hathorlib/Makefile +++ b/hathorlib/Makefile @@ -10,7 +10,7 @@ tests_lib = ./tests/ pytest_flags = -p no:warnings --cov-report=term --cov-report=html --cov=hathorlib mypy_tests_flags = --warn-unused-configs --disallow-incomplete-defs --no-implicit-optional --warn-redundant-casts --strict-equality --disallow-subclassing-any --warn-return-any --disallow-untyped-decorators --show-error-codes -mypy_sources_flags = --strict --show-error-codes +mypy_sources_flags = --show-error-codes .PHONY: tests tests: diff --git a/hathorlib/hathorlib/conf/get_settings.py b/hathorlib/hathorlib/conf/get_settings.py index 6c65f491a..05474c09d 100644 --- a/hathorlib/hathorlib/conf/get_settings.py +++ b/hathorlib/hathorlib/conf/get_settings.py @@ -1,4 +1,5 @@ import os +from pathlib import Path from typing import NamedTuple, Optional from hathorlib.conf.settings import HathorSettings as Settings @@ -25,11 +26,9 @@ def HathorSettings() -> Settings: if settings_module_filepath is not None: return _load_settings_singleton(settings_module_filepath, is_yaml=False) - settings_yaml_filepath = os.environ.get('HATHOR_CONFIG_YAML') - if settings_yaml_filepath is not None: - return _load_settings_singleton(settings_yaml_filepath, is_yaml=True) - - return _load_settings_singleton('hathorlib.conf.mainnet', is_yaml=False) + default_settings = str(Path(__file__).parent / 'mainnet.yml') + settings_yaml_filepath = os.environ.get('HATHOR_CONFIG_YAML', default_settings) + return _load_settings_singleton(settings_yaml_filepath, is_yaml=True) def _load_settings_singleton(source: str, *, is_yaml: bool) -> Settings: diff --git a/hathorlib/hathorlib/nanocontracts/__init__.py b/hathorlib/hathorlib/nanocontracts/__init__.py index 563962b7b..c7cf6a872 100644 --- a/hathorlib/hathorlib/nanocontracts/__init__.py +++ b/hathorlib/hathorlib/nanocontracts/__init__.py @@ -12,10 +12,26 @@ # See the License for the specific language governing permissions and # limitations under the License. +from hathorlib.nanocontracts.faux_immutable import ( + ALLOW_DUNDER_ATTR, + ALLOW_INHERITANCE_ATTR, + SKIP_VALIDATION_ATTR, + FauxImmutable, + FauxImmutableMeta, + __set_faux_immutable__, + create_with_shell, +) from hathorlib.nanocontracts.nanocontract import DeprecatedNanoContract from hathorlib.nanocontracts.on_chain_blueprint import OnChainBlueprint __all__ = [ + 'ALLOW_DUNDER_ATTR', + 'ALLOW_INHERITANCE_ATTR', 'DeprecatedNanoContract', + 'FauxImmutable', + 'FauxImmutableMeta', 'OnChainBlueprint', + 'SKIP_VALIDATION_ATTR', + '__set_faux_immutable__', + 'create_with_shell', ] diff --git a/hathorlib/hathorlib/nanocontracts/faux_immutable.py b/hathorlib/hathorlib/nanocontracts/faux_immutable.py new file mode 100644 index 000000000..0587d3b32 --- /dev/null +++ b/hathorlib/hathorlib/nanocontracts/faux_immutable.py @@ -0,0 +1,136 @@ +# 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 typing import Callable, TypeVar + +from typing_extensions import ParamSpec + +# special attrs: +SKIP_VALIDATION_ATTR: str = '__skip_faux_immutability_validation__' +ALLOW_INHERITANCE_ATTR: str = '__allow_faux_inheritance__' +ALLOW_DUNDER_ATTR: str = '__allow_faux_dunder__' + + +def _validate_faux_immutable_meta(name: str, bases: tuple[type, ...], attrs: dict[str, object]) -> None: + """Run validations during faux-immutable class creation.""" + required_attrs = frozenset({ + '__slots__', + }) + + for attr in required_attrs: + if attr not in attrs: + raise TypeError(f'faux-immutable class `{name}` must define `{attr}`') + + custom_allowed_dunder_value: tuple[str, ...] = attrs.pop(ALLOW_DUNDER_ATTR, ()) # type: ignore[assignment] + custom_allowed_dunder = frozenset(custom_allowed_dunder_value) + allowed_dunder = frozenset({ + '__module__', + '__qualname__', + '__doc__', + '__init__', + '__call__', + '__firstlineno__', + '__static_attributes__', + }) | custom_allowed_dunder + + # 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: + 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): + """ + A metaclass for faux-immutable classes. + This means the class objects themselves are immutable, that is, `__setattr__` always raises AttributeError. + Don't use this metaclass directly, inherit from `FauxImmutable` instead. + """ + __slots__ = () + + 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_VALIDATION_ATTR, False): + _validate_faux_immutable_meta(name, bases, attrs) + return super().__new__(cls, name, bases, attrs, **kwargs) + + def __setattr__(cls, name: str, value: object) -> None: + raise AttributeError(f'cannot set attribute `{name}` on faux-immutable class') + + +class FauxImmutable(metaclass=FauxImmutableMeta): + """ + Utility superclass for creating faux-immutable classes. + Simply inherit from it to define a faux-immutable class. + """ + __slots__ = () + __skip_faux_immutability_validation__: bool = True # Skip validation to bypass the no dunder rule. + + def __setattr__(self, name: str, value: object) -> None: + raise AttributeError(f'cannot set attribute `{name}` on faux-immutable object') + + +T = TypeVar('T', bound=FauxImmutable) +P = ParamSpec('P') + + +def create_with_shell(cls: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T: + """Mimic `cls.__call__` method behavior, but wrapping the created instance with an ad-hoc shell class.""" + # Keep the same name as the original class. + assert isinstance(cls, type) + name = cls.__name__ + + # The original class is the shell's only base. + bases = (cls,) + + # The shell doesn't have any slots and must skip validation to bypass the inheritance rule. + attrs = dict(__slots__=(), __skip_faux_immutability_validation__=True) + + # Create a dynamic class that is only used on this call. + shell_type: type[T] = type(name, bases, attrs) + + # Use it to instantiate the object, init it, and return it. This mimics the default `__call__` behavior. + obj: T = cls.__new__(shell_type) # type: ignore[call-overload] + shell_type.__init__(obj, *args, **kwargs) + return obj + + +def __set_faux_immutable__(obj: FauxImmutable, name: str, value: object) -> None: + """ + When setting attributes on the `__init__` method of a faux-immutable class, + use this utility function to bypass the protections. + Only use it when you know what you're doing. + """ + if name.startswith('__') and not name.endswith('__'): + # Account for Python's name mangling. + name = f'_{obj.__class__.__name__}{name}' + + # This shows that a faux-immutable class is never actually immutable. + # It's always possible to mutate it via `object.__setattr__`. + object.__setattr__(obj, name, value) diff --git a/hathorlib/hathorlib/utils/typing.py b/hathorlib/hathorlib/utils/typing.py new file mode 100644 index 000000000..8dec7f4cf --- /dev/null +++ b/hathorlib/hathorlib/utils/typing.py @@ -0,0 +1,219 @@ +# 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 types import UnionType +from typing import Generic, TypeVar, get_args as _typing_get_args, get_origin as _typing_get_origin +from weakref import WeakValueDictionary + +from typing_extensions import Self + +T = TypeVar('T') + + +def get_origin(t: type | UnionType, /) -> type | None: + """Extension of typing.get_origin to also work with classes that use InnerTypeMixin""" + if isinstance(t, type) and issubclass(t, InnerTypeMixin): + return getattr(t, '__origin__', None) + return _typing_get_origin(t) + + +def get_args(t: type | UnionType, /) -> tuple[type, ...] | None: + """Extension of typing.get_args to also work with classes that use InnerTypeMixin""" + if isinstance(t, type) and issubclass(t, InnerTypeMixin): + return getattr(t, '__args__', None) + return _typing_get_args(t) + + +class InnerTypeMixin(Generic[T]): + """ + Mixin class that exposes its single type‐argument at runtime as `self.__inner_type__`, + enforces exactly one type argument at subscription time, caches parameterized subclasses + so C[int] is C[int], and provides a clean repr listing public fields. + + >>> from typing import TypeVar + >>> U = TypeVar('U') + >>> class MyData(InnerTypeMixin, Generic[T]): + ... def __init__(self, data: T): + ... self.data = data + ... + >>> class MyCounter(InnerTypeMixin, Generic[T]): + ... def __init__(self, first: T, count: int): + ... self.first = first + ... self.count = count + ... + + # 1) You must supply exactly one type argument: + >>> try: + ... MyData(1) + ... except TypeError as e: + ... print(e) + MyData[...] requires exactly one type argument, got none + + >>> try: + ... MyData[int, str](1) + ... except TypeError as e: + ... print(e) + MyData[...] expects exactly one type argument; got 2 + + # You may write MyData[U] for signatures, but instantiation will reject a bare TypeVar: + >>> MyData[U] # no error + + + >>> try: + ... MyData[U]() + ... except TypeError as e: + ... print(e) + MyData[...] requires a concrete type argument, got ~U + + # Correct usage with a concrete type: + >>> sd = MyData[int](123) + >>> MyData[int] is MyData[int] + True + >>> sd.__inner_type__ is int + True + >>> print(sd) + MyData[int](data=123) + + # Works with multiple fields too: + >>> h = MyCounter[str]("foo", 42) + >>> h.__inner_type__ is str + True + >>> print(h) + MyCounter[str](first='foo', count=42) + """ + + # cache shared by all subclasses, maps concrete inner_type -> subclass, but doesn't keep subclasses alive if it has + # no live references anymore, this keeps the cache from growing indefinitely in case of dynamically generated + # classes, there's no point in holding unreferenced classes here + __type_cache: WeakValueDictionary[tuple[type, type], type[Self]] = WeakValueDictionary() + + # this class will expose this instance property + __inner_type__: type[T] + + @classmethod + def __extract_inner_type__(cls, args: tuple[type, ...], /) -> type[T]: + """Defines how to convert the received argument tuples into the stored type. + + If customization is needed, this class method is the place to do it. I could be used so only the origin-type is + stored, or to accept multiple arguments and store a tuple of types, or to convert the arguments into different + types. + """ + if len(args) != 1: + raise TypeError(f'{cls.__name__}[...] expects exactly one type argument; got {len(args)}') + inner_type, = args + return inner_type + + @classmethod + def __class_getitem__(cls, params): + # parameterizing the mixin itself delegates to Generic + if cls is InnerTypeMixin: + return super().__class_getitem__(params) + + # normalize to a 1-tuple + args = params if isinstance(params, tuple) else (params,) + inner_type = cls.__extract_inner_type__(args) + + cache = cls.__type_cache + key = (cls, inner_type) + sub = cache.get(key) + if sub is None: + # subclass keeps the same name for clean repr + sub = type(cls.__name__, (cls,), {}) + sub.__inner_type__ = inner_type + sub.__origin__ = cls + sub.__args__ = (inner_type,) + sub.__module__ = cls.__module__ + sub.__type_cache = cache + cache[key] = sub + return sub + + def __new__(cls, *args, **kwargs): + # reject unsubscripted class + if not get_args(cls): + raise TypeError(f'{cls.__name__}[...] requires exactly one type argument, got none') + + # reject if the subscribed‐in type is still a TypeVar + inner_type = getattr(cls, '__inner_type__', None) + if isinstance(inner_type, TypeVar): + raise TypeError(f'{cls.__name__}[...] requires a concrete type argument, got {inner_type!r}') + + # build instance and copy down the inner type + self = super().__new__(cls) + self.__inner_type__ = inner_type + return self + + def __repr__(self) -> str: + name = type(self).__name__ + t = self.__inner_type__ + tname = getattr(t, '__name__', repr(t)) + public = [(n, v) for n, v in vars(self).items() if not n.startswith('_')] + if public: + body = ', '.join(f'{n}={v!r}' for n, v in public) + return f'{name}[{tname}]({body})' + return f'{name}[{tname}]()' + + +def is_subclass(cls: type, class_or_tuple: type | tuple[type] | UnionType, /) -> bool: + """ Reimplements issubclass() with support for recursive NewType classes. + + Normal behavior from `issubclass`: + + >>> is_subclass(int, int) + True + >>> is_subclass(bool, int) + True + >>> is_subclass(bool, (int, str)) + True + >>> is_subclass(bool, int | str) + True + >>> is_subclass(bool, bytes | str) + False + >>> is_subclass(str, int) + False + + But `is_subclass` also works when a NewType is given as arg 1: + + >>> from typing import NewType + >>> N = NewType('N', int) + >>> is_subclass(N, int) + True + >>> is_subclass(N, int | str) + True + >>> is_subclass(N, str) + False + >>> M = NewType('M', N) + >>> is_subclass(M, int) + True + >>> is_subclass(M, str) + False + >>> try: + ... is_subclass(M, N) + ... except TypeError as e: + ... print(*e.args) + issubclass() arg 2 must be a class, a tuple of classes, or a union + + It is also expeced to fail in the same way as `issubclass` when the resolving the NewType doesn't lead to a class: + + >>> F = NewType('F', 'not a class') + >>> try: + ... is_subclass(F, str) + ... except TypeError as e: + ... print(*e.args) + issubclass() arg 1 must be a class + """ + while (super_type := getattr(cls, '__supertype__', None)) is not None: + cls = super_type + return issubclass(cls, class_or_tuple) diff --git a/hathorlib/pyproject.toml b/hathorlib/pyproject.toml index 88d27d223..4f2b639e1 100644 --- a/hathorlib/pyproject.toml +++ b/hathorlib/pyproject.toml @@ -78,6 +78,14 @@ plugins = [ "pydantic.mypy", ] +[[tool.mypy.overrides]] +module = [ + "hathorlib.nanocontracts.*", + "hathorlib.utils.*", +] +check_untyped_defs = false +disallow_untyped_defs = false + [tool.pytest.ini_options] minversion = "6.0" testpaths = ["tests"] diff --git a/poetry.lock b/poetry.lock index 447136741..094e4d20f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -766,6 +766,7 @@ base58 = "~2.1.1" cryptography = "~42.0.5" pycoin = "~0.92" pydantic = "^2.0" +pyyaml = "^6.0.1" [package.extras] client = ["aiohttp (>=3.9.3,<3.10.0)", "structlog (>=22.3.0,<22.4.0)"]