Source code for deluxe.types

# Copyright (c) 2025 - Gilles Coissac
# This file is part of standard-deluxe library.
#
# standard-deluxe is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
#
# standard-deluxe is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
# See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with standard-deluxe. If not, see <https://www.gnu.org/licenses/>
#
# ruff: noqa: E501
"""
Base types collections.
"""

from __future__ import annotations

# from sys import getsizeof
import sys

# import inspect
# import re
# from collections.abc import Collection, Mapping
from os import PathLike
from types import ModuleType
from typing import (
    Any,
    ClassVar,
    Protocol,
    TypeAlias,
    TypeVar,
    _ProtocolMeta,  # pyright: ignore[reportPrivateUsage]
    no_type_check,
)

from deluxe._multiton import IDError, Multiton, MultitonType
from deluxe._static import FrozenType, StaticType
from deluxe._types import Unset, UnsetType, _Frozen  # pyright: ignore[reportPrivateUsage]


__all__ = (
    "AnyFilePath",
    "AnyStr",
    "FilePath",
    "Frozen",
    "FrozenProtocol",
    "FrozenType",
    "IDError",
    "Multiton",
    "MultitonType",
    "StaticType",
    "Unset",
    "UnsetType",
    # "get_static_attributes",
    # "sizeof",
)


AnyStr = TypeVar("AnyStr", str, bytes)
"""A constrained type variable.

AnyStr is meant to be used for functions that may accept str or bytes arguments
but cannot allow the two to mix.

Note:
    despite its name, AnyStr has nothing to do with the Any type, nor does it mean
    “any string”. In particular, AnyStr and str | bytes are different from each other
    and have different use cases.
"""

FilePath: TypeAlias = AnyStr | PathLike[AnyStr]
"""Type alias for a filepath.

Represents a path that can be either a string/bytes or an :class:`os.PathLike`
object. The type is parameterized by ``AnyStr``, so a filepath can be either
a ``str`` or ``bytes`` backed path, but not a mix of the two.

Example:
    >>> def load_config(path: FilePath[str]) -> dict[str, Any]:
    ...     ...
"""

AnyFilePath: TypeAlias = FilePath[str] | FilePath[bytes]
"""Type alias for a file path of any string type.

Represents a path that can be either a ``str``-backed or ``bytes``-backed
file path. Unlike :data:`FilePath`, this type explicitly allows mixing
string and bytes paths at the cost of losing the constraint that paths
must be of a consistent type.

Example:
    >>> def find_resource(path: AnyFilePath) -> str:
    ...     ...
"""

Unset: UnsetType
"""Sentinel value indicating an attribute has not been set.

This singleton serves as a type-safe alternative to `None` for indicating
the absence of a value. It can be assigned to any type annotation without
causing static type checker warnings.
"""


class _FrozenSlotedMeta(_ProtocolMeta):
    @no_type_check
    def __new__(cls, name, bases, namespace, **kwds):
        if "__slots__" not in namespace:
            namespace["__slots__"] = ()

        if name == "FrozenProtocol":
            return _ProtocolMeta.__new__(cls, name, bases, namespace, **kwds)

        cls_ = type.__new__(cls, name, bases, namespace, **kwds)
        _Frozen.__cinit_subclass__(cls_)
        return cls_


[docs] class FrozenProtocol(Protocol, metaclass=_FrozenSlotedMeta): """Protocol defining the interface for frozen data objects. This protocol specifies the minimal interface that :class:`Frozen` and similar frozen data classes must implement. Example: >>> def process_frozen(obj: FrozenProtocol) -> None: ... for value in obj: ... print(value) """ __frozen__: ClassVar[tuple[str, ...]]
[docs] class Frozen(FrozenProtocol, _Frozen, metaclass=_FrozenSlotedMeta): """A frozen data class with immutable attributes. Frozen provides a declarative way to create immutable objects with named attributes. Attributes are specified via the `__frozen__` class variable and become read-only after initial assignment. Performance ----------- Object instantiation is moderately slower than Nameduple but significantly faster than `@dataclass` or hand-rolled pure python implementations. Benchmarks (creation time for 3-field objects): - mutable dict or slotted classes are almost equivalent - Named tuple: 1.4x slower - Frozen: 1.6x slower - dataclasses: 2.5x slower - Hand-rolled frozen: 2,5x slower Use Cases --------- - Need **Inheritance**: Supports inheritances of frozen attributes - Need **ABC Mixin**: Can combine with `ABC` for interface design - Allow **Gradual Initialization**: Frozen attributes can be set after instantiation - Need **Mutable Attribute**: defining __slots__ allow additional mutable attributes (eg., need a private attribute for internal state) Prefer `NamedTuple` for defining simpler immutable type: - faster instantiation - less verbose declaration - simpler type hinting - better support with static type checker Attributes: __frozen__ (ClassVar[tuple[str, ...]]): Tuple of attribute names that define the frozen structure. Must be set in subclasses. Iteration Protocol: The class supports iteration over its values. Unset attributes are yielded as `Unset` sentinel values. Example: >>> class Point(Frozen): ... __frozen__ = ("x", "y") >>> p = Point() >>> p.x = 1 >>> p.y = 2 >>> list(p) [1, 2] Sequence Protocol: Implements sequence-like access via `__getitem__` using positional indices (0 to len-1) and supports `in`, `len()`, `count()`, and `index()` operations. Comparison Protocol: Implements `__eq__` for value-based equality comparison with other iterables. Implements `__hash__` only when all attributes are set. Pickling Support: Supports serialization via `__reduce__` and reconstruction via `__setstate__`. Frozen instances can be pickled and unpickled while preserving their attribute values. Raises: TypeError: If an attribute is set twice (immutability violation). TypeError: If `__hash__` is called when any attribute is still Unset. AttributeError: If attempting to delete an attribute. IndexError: If `__getitem__` receives an out-of-range index. Example: >>> class User(Frozen): ... __frozen__ = ("name", "email", "age") >>> user = User() >>> user.name = "Alice" >>> user.email = "alice@example.com" >>> user.age = 30 >>> user.name 'Alice' >>> hash(user) # doctest: +SKIP -1234567890 >>> user == User(name="Alice", email="alice@example.com", age=30) True See Also: :class:`FrozenProtocol`: Protocol interface for frozen objects. :class:`Unset`: Sentinel value for unset attributes. """ __frozen__: ClassVar[tuple[str, ...]] # def __init_subclass__(cls, **kwds: Any) -> None: # _Frozen.__cinit_subclass__(cls) def __hash__(self) -> int: return _Frozen.__hash__(self) def __eq__(self, value: object, /) -> bool: return _Frozen.__eq__(self, value)
# _self_re = re.compile(r"\bself\.(?P<attr>\w*)\s?=\s?") # def get_static_attributes(obj: object) -> set[str]: # """Returns static attributes of an object. # Differences from __static_attributes__ (python 3.12): # * return all __slots__ names, even if never assigned # * dive into class inheritance to look up attributes, # so get_static_attributes(A) >= A.__static_attributes__ # * get_static_attributes(A()) and get_static_attributes(A) # could be different # """ # attrs = set[str]() # type_ = obj if isinstance(obj, type) else type(obj) # for base in type_.__mro__: # attrs.update(getattr(base, "__slots__", ())) # if obj is not type_: # attrs.update(getattr(obj, "__dict__", {}).keys()) # else: # for base in type_.__mro__: # for _name, func in inspect.getmembers_static(base, inspect.isfunction): # attrs.update(_self_re.findall(inspect.getsource(func))) # return attrs # ## # # # def sizeof(obj: object) -> int: # """Returns an approximate size in bytes of object and all of its contents.""" # seen = set[int]() # default_size = getsizeof(object()) # def attr_size() -> int: # return sum( # map( # sizeof, # iter(getattr(obj, key) for key in get_static_attributes(obj) if hasattr(obj, key)), # ) # ) # def sizeof(obj: object) -> int: # if id(obj) in seen: # return 0 # seen.add(id(obj)) # size = getsizeof(obj, default_size) # try: # mv = memoryview(obj) # _pyright: ignore[reportArgumentType] # except TypeError: # size += attr_size() # else: # buffer_size = mv.nbytes # size = size if size > buffer_size else buffer_size + size # return size + attr_size() # if isinstance(obj, Collection) and not isinstance(obj, (str,)): # if isinstance(obj, Mapping): # size += sum(map(sizeof, iter(obj.values()))) # _pyright: ignore[reportUnknownArgumentType] # else: # size += sum(map(sizeof, iter(obj))) # return size # return sizeof(obj) ## # class _FrozenModule(ModuleType): # pragma: no cover def __delattr__(self, name: str, /) -> None: msg = f"cannot delete '{name}'" raise SyntaxError(msg) def __setattr__(self, name: str, value: Any, /) -> None: msg = f"cannot assign to '{name}'" raise SyntaxError(msg) sys.modules[__name__].__class__ = _FrozenModule