Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 10 additions & 122 deletions hathor/nanocontracts/faux_immutable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
207 changes: 2 additions & 205 deletions hathor/utils/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
<class 'hathor.utils.typing.MyData'>

>>> 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
2 changes: 1 addition & 1 deletion hathorlib/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 4 additions & 5 deletions hathorlib/hathorlib/conf/get_settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from pathlib import Path
from typing import NamedTuple, Optional

from hathorlib.conf.settings import HathorSettings as Settings
Expand All @@ -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:
Expand Down
Loading
Loading