Module dataclass_baseclass

DataClass - inheritable contagious base class.

Instead of (endless?) @dataclass decorating.

Usage

class A(DataClass):  # it's a dataclass

class B(A):  # it's a dataclass too

as opposed to:

@dataclass
class A(): ...  # it's a dataclass

class B(A): ...  # it's *not* a dataclass, needs decorating

@dataclass
class B(A): ...  # now it's a dataclass

Also:

class B(DataClass, A): ...  # all properties from A are dataclassed

as opposed to:

class A():  ...

@dataclass
class B(A): ...  # no properties from A are dataclassed

Instantiation

class C(DataClass):
    a: str
    b: str

defaults: Data = {"a": "A", "b": "B"}

c = C(defaults, a="a", b="b")

or just:

c = C(a="a", b="b")

It kind of supports freezing/unfreezing on the fly, but it is besto keep all classes in the chain either frozen or not.

frozen attribute is dominant, ie as soon as you have one frozen parent class in the mix, class becomes frozen. If you want it unfrozen you need to specify dataclass_params.frozen as False:

class Unfrozen(Frozen, dataclass_params={"frozen": False}

Classes

class BaseDataClass
Expand source code
class BaseDataClass(metaclass=DataClassMeta):
    """Base `dataclass` class.

    It is recommended that all non-property additions to the class be
    underscored, to minimise conflict possibility.
    """

    _frozen: ClassVar[bool]
    """Set to true if the class is frozen (a subclass of `DataClassFrozen`)"""

    @classmethod
    # Has to return Any, otherwise cannot declare attributes as
    # attr: some_type = DataClass._field(...)
    def _field(cls, **kwargs: Any) -> Any:
        """BaseDataClass field creator.

        A convenience for calling `dataclasses.field()`.

        Returns:
            Field
        """

        return field(**kwargs)

    @classmethod
    def _fields(
        cls, *, public_only: bool = True
    ) -> tuple[DataClassField, ...]:
        """BaseDataClass fields.

        A convenience for calling `dataclasses.fields()`.

        Parameters:
            public_only (bool): exclude fields with names starting with "_"

        Returns:
            tuple[Field, ...]
        """

        return tuple(
            DataClassField(f)
            for f in fields(cls)
            if not public_only or not f.name.startswith("_")
        )

    def _as_dict(
        self,
        *,
        public_only: bool = True,
        dict_factory: Callable[[list[tuple[str, Any]]], dict] = dict,
    ) -> Data:
        """`BaseDataClass` serializer.

        A convenience for calling `dataclasses.asdict()` with addition of
        `public_only` filter.

        Parameters:
            public_only (bool): exclude fields with names starting with "_"
            dict_factory (Callable): degfaults to dict (see dataclasses)

        Returns:
            dict
        """

        d = asdict(self, dict_factory=dict_factory)
        if not public_only:
            return d

        def filter_private(d: Data) -> Data:
            return {
                k: filter_private(v) if isinstance(v, dict) else v
                for k, v in d.items()
                if not k.startswith("_")
            }

        return filter_private(d)

    def _replace(self, **changes: Any) -> Self:
        """Copy `BaseDataClass` instance, applying changes.

        A convenience for calling `dataclasses.replace()`.

        Parameters:
            **changes (Any):

        Returns:
            BaseDataClass
        """

        return replace(self, **changes)

    __pdoc__["DataClass._field"] = True
    __pdoc__["DataClass._fields"] = True
    __pdoc__["DataClass._frozen"] = True
    __pdoc__["DataClass._as_dict"] = True
    __pdoc__["DataClass._replace"] = True

Base dataclass class.

It is recommended that all non-property additions to the class be underscored, to minimise conflict possibility.

Subclasses

class DataClass
Expand source code
class DataClass(BaseDataClass, DataClassLoadProtocol):
    """Base dataclass with `DataClassLoadProtocol`."""

Base dataclass with DataClassLoadProtocol.

Ancestors

Class variables

var _frozen : ClassVar[bool]

Inherited members

class DataClassField (f: Field)
Expand source code
class DataClassField(Field):
    """Improved `dataclasses.Field` class."""

    def __init__(self, f: Field) -> None:
        for s in f.__slots__:  # type: ignore[attr-defined]
            setattr(self, s, getattr(f, s))

    def default_value(self) -> Any:
        """Get default value for the field.

        Takes in account `default` / `default_factory`.

        Returns:
            Any
        """

        return (
            self.default
            if self.default is not MISSING
            else self.default_factory()
            if self.default_factory is not MISSING
            else None
        )

Improved dataclasses.Field class.

Ancestors

  • dataclasses.Field

Methods

def default_value(self) ‑> Any
Expand source code
def default_value(self) -> Any:
    """Get default value for the field.

    Takes in account `default` / `default_factory`.

    Returns:
        Any
    """

    return (
        self.default
        if self.default is not MISSING
        else self.default_factory()
        if self.default_factory is not MISSING
        else None
    )

Get default value for the field.

Takes in account default / default_factory.

Returns

Any

class DataClassFrozen
Expand source code
class DataClassFrozen(DataClass, dataclass_params={"frozen": True}):
    """Base dataclass class, frozen version."""

Base dataclass class, frozen version.

Ancestors

Inherited members

class DataClassLoadProtocol (*args, **kwargs)
Expand source code
class DataClassLoadProtocol(Protocol):
    """Add loader to `DataClass` family.

    For proper resolution of embedded properties of `DataClass` type.
    Can be validating or not. Validating loaders may or may not support strict
    and loose modes, ie strict type compliance or implicit conversion if
    possible.

    Default loader is `DataClassLoader.load`
    """

    _loader: ClassVar[Callable[[type[Self], Data, bool], Self]] = (
        DataClassLoader.load  # type: ignore[assignment]
    )
    """Class attribute, actual loader function

        Parameters:
        cls (type(DataClass)):  Load into this class spec
        data (dict):
        strict (bool):
            Load in strict mode, ie no implicit type conversion, optional
    """

    @classmethod
    def _load(
        cls, defaults: Data = {}, strict: bool = False, /, **kwargs: Data
    ) -> Self:
        """Load data into this class

        Classmethod.
        `defaults` is a convenience mechanism, so you can load either a
        dict or with kwargs, or both. See `DataClassMeta`.

        Parameters:
            defaults (dict): Data to load, overriden by kwargs

        Returns:
            Self: Loaded class instance
        """

        data = {**defaults, **kwargs}

        return cls._loader(cls, data, strict)

    __pdoc__["DataClassLoadProtocol._load"] = True
    __pdoc__["DataClassLoadProtocol._loader"] = True

Add loader to DataClass family.

For proper resolution of embedded properties of DataClass type. Can be validating or not. Validating loaders may or may not support strict and loose modes, ie strict type compliance or implicit conversion if possible.

Default loader is DataClassLoader.load()

Ancestors

  • typing.Protocol
  • typing.Generic

Subclasses

Static methods

def _load(defaults: Data = {}, strict: bool = False, /, **kwargs: Data) ‑> Self

Load data into this class

Classmethod. defaults is a convenience mechanism, so you can load either a dict or with kwargs, or both. See DataClassMeta.

Parameters

defaults (dict): Data to load, overriden by kwargs

Returns

Self
Loaded class instance
def _loader(dc: type[DataClassT], data: Data, strict: bool = False) ‑> ~DataClassT

Load data.

Classmethod, suitable for DataClassLoader.load() property.

Parameters

dc (type): Load into this DataClass spec data (dict):

Returns

DataClass instance

class DataClassLoader (dataclass: type[DataClassT])
Expand source code
class DataClassLoader(Generic[DataClassT]):
    """Minimal? version of `DataClass` non-validating recursive loader.

    Parameters:
        dataclass (type):
        data (dict):

    Usage:

    ```
    DataClassLoader.load(SomeDataClassDerivative, data)
    ```

    or

    ```
    loader = DataClassLoader(SomeDataClassDerivative)
    data_obj = loader.load_data(data)
    ```
    """

    dataclass: type[DataClassT]
    """`DataClass` derivative, the load into container spec"""

    @classmethod
    def load(
        cls, dc: type[DataClassT], data: Data, strict: bool = False
    ) -> DataClassT:
        """Load data.

        Classmethod, suitable for `DataClassLoadProtocol._loader` property.

        Parameters:
            dc (type):  Load into this `DataClass` spec
            data (dict):

        Returns:
            DataClass instance
        """

        return cls(dc).load_data(data, strict)

    def __init__(self, dataclass: type[DataClassT]) -> None:
        self.dataclass = dataclass

    def load_data(self, data: Data, strict: bool = False) -> DataClassT:
        """Load data into instance.

        Parameters:
            data (dict):

        Returns:
            DataClass instance

        Raises:
            ValueError
        """

        if strict is True:
            raise ValueError("strict mode not supported")

        def convert(k: str, v: Any) -> Any:
            if k in self.dataclass.__annotations__:
                k_type = self.dataclass.__annotations__[k]
                if DataClassMeta.is_metaclass(k_type) and not isinstance(
                    v, k_type
                ):
                    return self.load(k_type, v, strict)
            return v

        return self.dataclass(**{k: convert(k, v) for k, v in data.items()})

Minimal? version of DataClass non-validating recursive loader.

Parameters

dataclass (type): data (dict):

Usage:

DataClassLoader.load(SomeDataClassDerivative, data)

or

loader = DataClassLoader(SomeDataClassDerivative)
data_obj = loader.load_data(data)

Ancestors

  • typing.Generic

Class variables

var dataclass : type[~DataClassT]

DataClass derivative, the load into container spec

Static methods

def load(dc: type[DataClassT], data: Data, strict: bool = False) ‑> ~DataClassT

Load data.

Classmethod, suitable for DataClassLoader.load() property.

Parameters

dc (type): Load into this DataClass spec data (dict):

Returns

DataClass instance

Methods

def load_data(self, data: Data, strict: bool = False) ‑> ~DataClassT
Expand source code
def load_data(self, data: Data, strict: bool = False) -> DataClassT:
    """Load data into instance.

    Parameters:
        data (dict):

    Returns:
        DataClass instance

    Raises:
        ValueError
    """

    if strict is True:
        raise ValueError("strict mode not supported")

    def convert(k: str, v: Any) -> Any:
        if k in self.dataclass.__annotations__:
            k_type = self.dataclass.__annotations__[k]
            if DataClassMeta.is_metaclass(k_type) and not isinstance(
                v, k_type
            ):
                return self.load(k_type, v, strict)
        return v

    return self.dataclass(**{k: convert(k, v) for k, v in data.items()})

Load data into instance.

Parameters

data (dict):

Returns

DataClass instance

Raises

ValueError

class DataClassMeta (*args, **kwargs)
Expand source code
@dataclass_transform()
class DataClassMeta(_ProtocolMeta):
    """`DataClass` metaclass.

    Turns class with properties into `dataclass`

    `dataclass` can only inherit from another `dataclass`. This is not a problem
    for vertical inheritance, but may pose a challenge for horizontal
    (multiple) inheritance, eg. adding protocols. In those cases
    a non-dataclass mix-in is cloned, and the infected clone is used instead.

    It mandates `kw_only = True`, which means that classes can only be
    instantiated with keyword args. For that reason, we can modify class
    instantiator footprint to accept both dict and kwargs.

    Usage:

    ```
    class C(metaclass=DataClassMeta, dataclass_params={}): ...

    defaults: Data = {}

    c = C(defaults, a="a", b="b" ...)
    ```

    Defaults and keyword args are shallowly merged.
    """

    @classmethod
    def is_metaclass(metacls, cls: type[Any]) -> bool:
        return issubclass(type(cls), metacls)

    @staticmethod
    def __new__(
        metacls: type,
        name: str,
        bases: tuple[type, ...],
        defs: dict[str, Any],
        /,
        dataclass_params: Data | None = None,
        **kwargs: Any,
    ) -> type[DataclassInstance]:
        if dataclass_params is None:
            dataclass_params = {}

        assert "kw_only" not in dataclass_params, "kw_only is not negotiable"

        dc_params = {}
        frozen: bool | None = None

        for b in reversed(bases):
            if is_dataclass(b):
                dcp = getattr(b, _PARAMS)
                dc_params.update({s: getattr(dcp, s) for s in dcp.__slots__})
                if dcp.frozen:
                    frozen = True

        if "frozen" in dataclass_params:
            assert (
                dataclass_params["frozen"] is not None
            ), "Frozen should be set"

            frozen = dataclass_params["frozen"]
        else:
            if frozen is None:
                frozen = False
            dataclass_params["frozen"] = frozen

        dc_params.update(dataclass_params)
        dc_params["kw_only"] = True

        def is_protocol(
            tp: type, /
        ) -> bool:  # copied from typing, it is not there before 3.13
            return (
                isinstance(tp, type)
                and getattr(tp, "_is_protocol", False)
                and tp != Protocol
            )

        def into_dataclass(cls: type) -> type[DataclassInstance]:
            bases = tuple(
                into_dataclass(b)
                for b in cls.__bases__
                if not is_dataclass(b) and is_protocol(b)
            )
            cls_copy = new_class(
                cls.__name__,
                ((cls,) + bases),
                exec_body=lambda ns: ns.update(cls.__dict__),
            )
            return dataclass(cls_copy, **dc_params)

        # Frozen and non-frozen (fresh?) dataclasses dont mix.
        # We need to align them, recursively.
        def munge_base(cls: type) -> type[DataclassInstance]:
            if is_dataclass(cls):
                dcp = getattr(cls, _PARAMS)
                if dcp.frozen is frozen:
                    return cls

                # Copy dataclass with toggled frozen attr.
                # Super flakey.
                flds = [
                    (
                        f.name,
                        (
                            munge_base(t)
                            if DataClassMeta.is_metaclass(t)
                            else t
                        ),
                        f,
                    )
                    for f, t in [
                        (f, cast(type[Any], f.type)) for f in fields(cls)
                    ]
                ]
                methods = {
                    m: classmethod(f.__func__)
                    if inspect.ismethod(f) and f.__self__ is cls
                    else f
                    for m, f in inspect.getmembers(
                        cls,
                        predicate=lambda m: inspect.ismethod(m)
                        or inspect.isfunction(m),
                    )
                    if not m.startswith("__")
                }
                b_cls = type(cls.__name__, (), methods)
                return make_dataclass(
                    cls.__name__, flds, bases=(b_cls,), **dc_params
                )

            return into_dataclass(cls)

        defs["_frozen"] = frozen
        defs["_is_protocol"] = False  # py 3.11

        # Monkey patching for pleasure and profit.
        dataclasses._get_field = our_get_field  # type: ignore[attr-defined]
        try:
            dc_bases = tuple([munge_base(b) for b in bases])

            cls = super().__new__(metacls, name, dc_bases, defs, **kwargs)  # type: ignore[misc]

            try:
                return dataclass(cls, **dc_params)
            except TypeError as e:
                raise TypeError(*e.args, cls, dc_bases, defs, dc_params) from e
        finally:
            dataclasses._get_field = orig_get_field  # type: ignore[attr-defined]

    def __call__(cls, defaults: Data = {}, /, **kwargs) -> DataClass:
        data = {**defaults, **kwargs}

        return super().__call__(**data)

DataClass metaclass.

Turns class with properties into dataclass

dataclass can only inherit from another dataclass. This is not a problem for vertical inheritance, but may pose a challenge for horizontal (multiple) inheritance, eg. adding protocols. In those cases a non-dataclass mix-in is cloned, and the infected clone is used instead.

It mandates kw_only = True, which means that classes can only be instantiated with keyword args. For that reason, we can modify class instantiator footprint to accept both dict and kwargs.

Usage:

class C(metaclass=DataClassMeta, dataclass_params={}): ...

defaults: Data = {}

c = C(defaults, a="a", b="b" ...)

Defaults and keyword args are shallowly merged.

Ancestors

  • typing._ProtocolMeta
  • abc.ABCMeta
  • builtins.type

Static methods

def is_metaclass(cls: type[Any]) ‑> bool