From 6d7b23e371fb982177ee1b188af10bacb4adbd21 Mon Sep 17 00:00:00 2001 From: "J. Mayr" Date: Fri, 23 Oct 2026 13:58:33 +0200 Subject: [PATCH 1/2] Minor update. Changes: * Moved __dataclass_initialized__ assignment from the object initializer to make_dataclass * The behavior of __dataclass_initialized__ now is handled properly in derived classes * Improved functionality for dataclass inheritance * Removed TField generic since its support is limited * renamed all __dataclass_* fields to __dataclassbase_* --- README.md | 8 +- dataclassbase/__init__.py | 399 ++++++++++++++---- dataclassbase/_util.py | 17 + tests/dataclassbase_tests/__init__.py | 31 +- .../dataclassbase_tests/test_dataclassmeta.py | 45 +- .../test_field_provider.py | 6 +- tests/dataclassbase_tests/test_modifiers.py | 102 +++++ .../test_typeconstrained_field.py | 6 +- 8 files changed, 493 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index f6bc72c..3a01655 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,10 @@ assumed to be fields or to be converted into some. We do that conversion in the In doing so, I would constrain proper static type checking on the fields. Therefore, I would rather let you indicate the types yourself. To achieve true dataclass-like behavior on a type checker, you can call `typing.dataclass_transform` yourself or rely on -a metaclass derived from `DataclassMetaBase[TField]`: +a metaclass derived from `DataclassMetaBase[Field]`: Rather than using `dataclassbase.dataclass` as above, you can define your own dataclass by deriving a metaclass from -class `DataclassMetaBase[TField]` and attaching a `typing.dataclass_transform` to it. `DataclassMetaBase` invokes +class `DataclassMetaBase[Field]` and attaching a `typing.dataclass_transform` to it. `DataclassMetaBase` invokes `make_dataclass` when creating the new type (`dataclass` is the decorator-variant of `make_dataclass`). It provides the methods `_field_provider` and `_object_handler` which act as factories for the field provider and object handler for the types using the metaclass. @@ -99,9 +99,9 @@ class CustomField(Field): @dataclass_transform -class CustomDataclassMeta(DataclassMetaBase[Field]): +class CustomDataclassMeta(DataclassMetaBase): @classmethod - def _field_provider(cls) -> BasicFieldProvider[Field]: + def _field_provider(cls) -> BasicFieldProvider: # we want it to return our custom field. The factory works since the signature of `CustomField.__init__` matches # the one of `Field.__init__` return BasicFieldProvider(CustomField) diff --git a/dataclassbase/__init__.py b/dataclassbase/__init__.py index 4a9fb8c..e396c55 100644 --- a/dataclassbase/__init__.py +++ b/dataclassbase/__init__.py @@ -2,11 +2,12 @@ import types as _types import typing as _typing +from . import _util from ._util import E as _E from ._util import throws as _throws -class FieldInitializer[**P, TField: Field](_typing.Protocol): +class FieldInitializer[**P](_typing.Protocol): """ A generic factory that creates a new field from a name, an annotation, and additional parameters defined via the signature defined via `P`. @@ -16,7 +17,7 @@ class FieldInitializer[**P, TField: Field](_typing.Protocol): # remark: this is less restrictive than `FieldFactory` which requires an exact match between the signature of # `Field.__init__` and its `__call__` - def __call__(self, __name: str, __annotation: _typing.Any, /, *args: P.args, **kwargs: P.kwargs) -> TField: + def __call__(self, __name: str, __annotation: _typing.Any, /, *args: P.args, **kwargs: P.kwargs) -> Field: """ Creates a new instance of the field. @@ -167,9 +168,7 @@ def check_overridden_field(self, overridden_field: Field) -> None: pass @classmethod - def declare[**P, TField: Field]( - cls: FieldInitializer[P, TField], *args: P.args, **kwargs: P.kwargs - ) -> FieldDeclaration[P, TField]: + def declare[**P](cls: FieldInitializer[P], *args: P.args, **kwargs: P.kwargs) -> FieldDeclaration[P]: """ Declares, but does not fully initialize a field. The field will be instantiated when the dataclass gets built. @@ -186,10 +185,10 @@ def declare[**P, TField: Field]( return field(cls, *args, **kwargs) -class FieldDeclaration[**P, TField: Field]: +class FieldDeclaration[**P]: """Declares, but does not fully initialize a field. The field will be instantiated when the dataclass gets built.""" - def __init__(self, __factory: FieldInitializer[P, TField], /, *args: P.args, **kwargs: P.kwargs) -> None: + def __init__(self, __factory: FieldInitializer[P], /, *args: P.args, **kwargs: P.kwargs) -> None: """ Initializes the current instance. @@ -201,7 +200,7 @@ def __init__(self, __factory: FieldInitializer[P, TField], /, *args: P.args, **k self._args = args self._kwargs = kwargs - def __call__(self, name: str, annotation: _typing.Any) -> TField: + def __call__(self, name: str, annotation: _typing.Any) -> Field: """ Instantiates the field from the declaration represented by the current instance. @@ -212,12 +211,12 @@ def __call__(self, name: str, annotation: _typing.Any) -> TField: return self._factory(name, annotation, *self._args, **self._kwargs) -def field[**P, TField: Field]( - __cls: FieldInitializer[P, TField] = Field, # type: ignore[assignment] +def field[**P]( + __cls: FieldInitializer[P] = Field, # type: ignore[assignment] /, *args: P.args, **kwargs: P.kwargs, -) -> FieldDeclaration[P, TField]: +) -> FieldDeclaration[P]: """ Declares, but does not fully initialize a field. The field will be instantiated when the dataclass gets built. @@ -243,7 +242,7 @@ class _Unspecified: pass -class FieldFactory[TField: Field](_typing.Protocol): +class FieldFactory(_typing.Protocol): """A protocol that behaves similar to the factory represented by the :class:`Field` type.""" def __call__( @@ -253,7 +252,7 @@ def __call__( has_default: bool = False, default: _typing.Any = None, default_factory: _typing.Callable[[], None] | None = None, - ) -> TField: + ) -> Field: """ Initializes the current instance. @@ -271,7 +270,33 @@ def __call__( ... -class FieldProvider[TField: Field](_abc.ABC): +class Fields(_typing.NamedTuple): + """Represents a pair of declared and inherited fields of a type.""" + + declared_fields: dict[str, Field] + """The fields declared on the type itself.""" + + inherited_fields: dict[str, Field] + """The fields inherited from a base type.""" + + def all(self) -> dict[str, Field]: + """ + Merges the inherited and declared fields and returns the resulting dictionary. + + :return: The dictionary of pairs mapping field names to fields. + """ + result = self.inherited_fields.copy() + result.update(self.declared_fields) + return result + + @staticmethod + def from_lists(declared_fields: list[Field], inherited_fields: list[Field]) -> Fields: + return Fields( + {field.name: field for field in declared_fields}, {field.name: field for field in inherited_fields} + ) + + +class FieldProvider(_abc.ABC): """Defines an abstract class for handling the creation and override behavior of fields.""" @_abc.abstractmethod @@ -289,7 +314,7 @@ def check_overrides( """ ... - def gather_fields(self, cls: type) -> dict[str, TField]: + def gather_fields(self, cls: type) -> Fields: """ Gathers the dataclass fields of a class. @@ -298,13 +323,17 @@ def gather_fields(self, cls: type) -> dict[str, TField]: """ field_dict = self._gather_fields_core(cls) - if any(field.name != k for k, field in field_dict.items()): - raise RuntimeError('The field names must be preserved by the operations of the field provider.') + if not field_dict.declared_fields.keys().isdisjoint(field_dict.inherited_fields): + raise ValueError('The field names of the declared and inherited fields must be disjoint.') + + for src in (field_dict.inherited_fields, field_dict.declared_fields): + if any(field.name != k for k, field in src.items()): + raise RuntimeError('The field names must be preserved by the operations of the field provider.') return field_dict @_abc.abstractmethod - def _gather_fields_core(self, cls: type) -> dict[str, TField]: + def _gather_fields_core(self, cls: type) -> Fields: """ Performs the core logic for gathering the dataclass fields of a class. @@ -326,13 +355,13 @@ def _skip_annotation(cls: type, key: str, value: _typing.Any) -> bool: return _typing.get_origin(value) is _typing.ClassVar -class BasicFieldProvider[TField: Field](FieldProvider[TField]): +class BasicFieldProvider(FieldProvider): """Implements a basic mechanism for handling the creation and override behavior of fields.""" def __init__( self, - field_factory: FieldFactory[TField] = Field, # type: ignore[assignment] - external_field_validator: _E[type[TField] | _typing.Callable[[Field], TField]] = ..., + field_factory: FieldFactory = Field, + external_field_validator: _E[type[Field] | _typing.Callable[[Field], Field]] = ..., ) -> None: """ Initializes the current instance. @@ -355,9 +384,9 @@ def __init__( self._field_factory = field_factory self._external_field_validator = external_field_validator - def _validate_field(self, field: Field) -> TField: + def _validate_field(self, field: Field) -> Field: """ - Validates an externally defined field and (unsafely) casts it to `TField`. + Validates an externally defined field and (unsafely) casts it to `Field`. :param field: The field to validate. :return: The converted field. @@ -366,13 +395,11 @@ def _validate_field(self, field: Field) -> TField: if not isinstance(field, self._external_field_validator): raise TypeError(f'Field `{field.name}` was set to a value of an unsupported type.') - return _typing.cast(TField, field) + return field return self._external_field_validator(field) # type: ignore - def instantiate_field[**P]( - self, name: str, annotation: _typing.Any, declaration: FieldDeclaration[P, TField] - ) -> TField: + def instantiate_field[**P](self, name: str, annotation: _typing.Any, declaration: FieldDeclaration[P]) -> Field: """ Instantiates a field from a field declaration. @@ -383,7 +410,7 @@ def instantiate_field[**P]( """ return self._validate_field(declaration(name, annotation)) - def make_field(self, name: str, annotation: _typing.Any, has_default: bool, default: _typing.Any) -> TField: + def make_field(self, name: str, annotation: _typing.Any, has_default: bool, default: _typing.Any) -> Field: """ Initializes a new field. @@ -401,7 +428,7 @@ def make_field(self, name: str, annotation: _typing.Any, has_default: bool, defa return self._field_factory(name, annotation=annotation, has_default=has_default, default=default) - def create_field_override(self, original: Field) -> TField: + def create_field_override(self, original: Field) -> Field: """ Creates a new field from an original field declared on a base class. @@ -426,13 +453,13 @@ def check_overrides( v.check_overriding_field(override) override.check_overridden_field(v) - def _gather_fields_core(self, cls: type) -> dict[str, TField]: + def _gather_fields_core(self, cls: type) -> Fields: base_field_dict: dict[str, Field] = {} # 1. gather the fields from the base classes for base_type in reversed(cls.__mro__[1:]): - fields = getattr(base_type, '__dataclass_fields__', None) + fields = dataclass_fields(base_type, None) if fields is not None: base_field_dict.update(fields) @@ -441,14 +468,15 @@ def _gather_fields_core(self, cls: type) -> dict[str, TField]: annotations = {k: v for k, v in cls.__annotations__.items() if not _skip_annotation(cls, k, v)} - field_dict: dict[str, TField] = {} + inherited: dict[str, Field] = {} + declared: dict[str, Field] = {} for k, field in base_field_dict.items(): if k in annotations: # we leave out those that'll receive an annotation in 3. continue - field_dict[k] = self.create_field_override(field) + inherited[k] = self.create_field_override(field) # 3. create the fields defined via annotations on the current instance for k, annot in annotations.items(): @@ -456,16 +484,46 @@ def _gather_fields_core(self, cls: type) -> dict[str, TField]: field = self.make_field(k, annot, default is not _Unspecified, None if default is _Unspecified else default) - field_dict[k] = field + declared[k] = field + + return Fields(declared, inherited) + + +@_typing.overload +def dataclass_fields(cls: DataclassType) -> _types.MappingProxyType[str, Field]: ... - return field_dict + +@_typing.overload +def dataclass_fields[TOpt](cls: type, default: TOpt) -> _types.MappingProxyType[str, Field] | TOpt: ... + + +def dataclass_fields(cls: type | DataclassType, default: _typing.Any = _Unspecified) -> _typing.Any: + result = getattr(cls, '__dataclassbase_fields__', default) + + if result is _Unspecified: + raise ValueError('The indicated type is not a dataclass.') + + return result class BehaviorModifier(_abc.ABC): """An abstract base class defining a mechanism for changing the behavior of a dataclass and its instances.""" @_abc.abstractmethod - def modify(self, cls: type[DataclassType]) -> None: + def modify( + self, + cls: type[DataclassType], + fields: _types.MappingProxyType[str, Field], + declared_fields: _types.MappingProxyType[str, Field], + ) -> None: + """ + Applies the behavior modification to the indicated dataclass type. + + :param cls: The dataclass to modify. + :param fields: All fields of the dataclass. + :param declared_fields: + The fields of the dataclass that were declared on the type itself rather than being inherited. + """ pass @@ -484,13 +542,16 @@ def __call__(self) -> T: class Frozen(BehaviorModifier): """Used to make dataclass fields immutable.""" - def __init__(self, fields_only: bool = True) -> None: + def __init__(self, fields_only: bool = True, freeze_inherited: bool = False) -> None: """ Initializes the current instance. :param fields_only: Set to `True` to only freeze fields, set to `False` to freeze all attributes. + :param freeze_inherited: Set to `True` to freeze inherited fields, too. Note that this parameter is ignored if + `fields_only` is set to `True`. """ self._fields_only = fields_only + self._freeze_inherited = freeze_inherited @property def fields_only(self) -> bool: @@ -501,18 +562,43 @@ def fields_only(self) -> bool: """ return self._fields_only - def modify(self, cls: type[DataclassType]) -> None: + @property + def freeze_inherited(self) -> bool: + """ + Indicates whether the current class freezes all inherited fields, too. + + :return: `True` if parent fields get frozen, too, `False` otherwise. + """ + return self._freeze_inherited + + def modify( + self, + cls: type[DataclassType], + fields: _types.MappingProxyType[str, Field], + declared_fields: _types.MappingProxyType[str, Field], + ) -> None: """ Makes the indicated dataclass frozen, i.e., after initialization, its fields may no longer be changed. - :param cls: The class to freeze. + :param cls: The dataclass to modify. + :param fields: All fields of the dataclass. + :param declared_fields: + The fields of the dataclass that were declared on the type itself rather than being inherited. """ inner_setattrib = getattr(cls, '__setattr__', None) inner_init = getattr(cls, '__init__', None) def setattrib(obj: _typing.Any, /, name: str, value: _typing.Any) -> None: - if not self.fields_only or name in cls.__dataclass_fields__ and obj.__dataclass_initialized__: - raise RuntimeError('The current dataclass is frozen.') + initialized = getattr(obj, '__dataclassbase_initialized__', False) + + if initialized: + passes = self.fields_only + + if passes and name in (fields if self.freeze_inherited else declared_fields): + passes = False + + if not passes: + raise RuntimeError('The current dataclass is frozen.') if inner_setattrib is not None: inner_setattrib(obj, name, value) @@ -520,7 +606,7 @@ def setattrib(obj: _typing.Any, /, name: str, value: _typing.Any) -> None: cls.__setattr__ = setattrib # type: ignore[method-assign] def init(obj: _typing.Any, /, *args: _typing.Any, **kwargs: _typing.Any) -> None: - if hasattr(obj, '__dataclass_initialized__'): + if getattr(obj, '__dataclassbase_initialized__', False): raise RuntimeError( 'The dataclass has already been initialized before. It cannot be reinitialized since it is frozen.' ) @@ -580,11 +666,19 @@ def fields_only(self) -> bool: """ return self._fields_only - def modify(self, cls: type[DataclassType]) -> None: + def modify( + self, + cls: type[DataclassType], + fields: _types.MappingProxyType[str, Field], + declared_fields: _types.MappingProxyType[str, Field], + ) -> None: """ Implements, if demanded, the equality and inequality operator and the hash function for the indicated type. - :param cls: The type whose behavior to override. + :param cls: The dataclass to modify. + :param fields: All fields of the dataclass. + :param declared_fields: + The fields of the dataclass that were declared on the type itself rather than being inherited. """ if self.override_eq: @@ -600,7 +694,7 @@ def eq(obj: DataclassType, other: DataclassType, /) -> bool: if names != other.__dict__.keys(): return False else: - names = cls.__dataclass_fields__ + names = cls.__dataclassbase_fields__ for name in names: if getattr(obj, name) != getattr(other, name): @@ -622,7 +716,7 @@ def hashcode(obj: DataclassType) -> int: if self.fields_only: names = obj.__dict__.keys() else: - names = cls.__dataclass_fields__ + names = cls.__dataclassbase_fields__ return hash(tuple(getattr(obj, name) for name in names)) @@ -636,11 +730,19 @@ def __init__(self) -> None: """Initializes the current instance.""" pass - def modify(self, cls: type[DataclassType]) -> None: + def modify( + self, + cls: type[DataclassType], + fields: _types.MappingProxyType[str, Field], + declared_fields: _types.MappingProxyType[str, Field], + ) -> None: """ Modifies the initializer of the indicated class to allow only keyword arguments. :param cls: The dataclass to modify. + :param fields: All fields of the dataclass. + :param declared_fields: + The fields of the dataclass that were declared on the type itself rather than being inherited. """ inner_init = cls.__init__ @@ -657,11 +759,19 @@ def __init__(self) -> None: """Initializes the current instance.""" pass - def modify(self, cls: type[DataclassType]) -> None: + def modify( + self, + cls: type[DataclassType], + fields: _types.MappingProxyType[str, Field], + declared_fields: _types.MappingProxyType[str, Field], + ) -> None: """ Modifies the initializer of the indicated class to allow only positional arguments. :param cls: The dataclass to modify. + :param fields: All fields of the dataclass. + :param declared_fields: + The fields of the dataclass that were declared on the type itself rather than being inherited. """ inner_init = cls.__init__ @@ -671,7 +781,7 @@ def init(obj: _typing.Any, /, *args: _typing.Any) -> None: cls.__init__ = init # type: ignore[method-assign, assignment] -class ObjectHandler[TField: Field]: +class ObjectHandler: """Handles the initialization and set-attribute behavior of a dataclass-like object.""" def init_instance( @@ -692,7 +802,7 @@ def init_instance( :param args: The positional arguments. :param kwargs: The keyword arguments. """ - fields = cls.__dataclass_fields__ + fields = cls.__dataclassbase_fields__ # Ensure that `args` and `kwargs` are not overlapping and point to existing fields @@ -745,19 +855,66 @@ def init_instance( field.check_assignment(field.default) assignments[k] = field.default - obj.__dataclass_initialized__ = False - # assign on current instance + for k, v in assignments.items(): setattr(obj, k, v) - obj.__dataclass_initialized__ = True + def init_instance_post( + self, + cls: DataclassType, + obj: _typing.Any, + args: tuple[_typing.Any, ...], + kwargs: dict[str, _typing.Any], + ) -> None: + """ + Called during initialization after invoking the user `__init__`-code. + + :param cls: The class of the object to initialize. + :param obj: The object to initialize. + :param args: The positional arguments. + :param kwargs: The keyword arguments. + """ + # mark dataclass as initialized, but only if it is an actual instance of cls. This may change the behavior of + # the dataclass # invoke postinit, if available postinit = getattr(obj, '__postinit__', None) if postinit is not None and callable(postinit): postinit() + def init_instance_subtype_pre( + self, + cls: DataclassType, + obj: _typing.Any, + args: tuple[_typing.Any, ...], + kwargs: dict[str, _typing.Any], + ) -> None: + """ + Called during initialization in a subtype before the user `__init__` gets invoked. + + :param cls: The class of the object to initialize. + :param obj: The object to initialize. + :param args: The positional arguments. + :param kwargs: The keyword arguments. + """ + + def init_instance_subtype_post( + self, + cls: DataclassType, + obj: _typing.Any, + args: tuple[_typing.Any, ...], + kwargs: dict[str, _typing.Any], + ) -> None: + """ + Called during initialization in a subtype after the user `__init__` gets invoked. + + :param cls: The class of the object to initialize. + :param obj: The object to initialize. + :param args: The positional arguments. + :param kwargs: The keyword arguments. + """ + def handle_set_attribute( self, obj: _typing.Any, @@ -776,7 +933,7 @@ def handle_set_attribute( :param value: The new value to assign to the attribute. :param inner_set_attribute: The original `__setattr__`-procedure, if there was one; `None` otherwise. """ - if field is not None and obj.__dataclass_initialized__: + if field is not None and obj.__dataclassbase_initialized__: field.check_assignment(value) if inner_set_attribute is None: @@ -830,10 +987,43 @@ def override_delete_attribute(self) -> bool: return True -def make_dataclass[TField: Field]( +def dataclass_leaf_types(cls: type) -> tuple[type, ...]: + """ + Determines the dataclass types of a class that are the nearest from the type itself in its inheritance graph. If + the class itself is a dataclass, only the class itself is returned. Otherwise, the MRO of the class gets filtered + for its most direct dataclass type descendants. + + :param cls: The class for which to obtain the dataclasses. + :return: The tuple of dataclasses obtained for the type. Empty if the supplied class is not a dataclass and does not + define a dataclass in any base type. + """ + direct = _typing.cast(tuple[type, ...] | None, cls.__dict__.get('__dataclassbase_types__')) + + if direct is not None: + return direct + else: + s: set[type] = set() + lst: list[type] = [] + + for base in cls.__mro__[1:]: + if base.__dict__ is None or base in s: + continue + + dataclass_types = _typing.cast(tuple[type, ...] | None, base.__dict__.get('__dataclassbase_types__')) + + if dataclass_types is None: + continue + + s.update(base.__mro__) + lst.append(base) + + return tuple(lst) + + +def make_dataclass( cls: type, - field_provider: _E[FieldProvider[TField] | FieldFactory[TField]] = ..., - object_handler: _E[ObjectHandler[TField]] = ..., + field_provider: _E[FieldProvider | FieldFactory] = ..., + object_handler: _E[ObjectHandler] = ..., modifiers: tuple[BehaviorModifier | ConstructorlessFactory[BehaviorModifier], ...] = (), ) -> None: """ @@ -845,7 +1035,7 @@ def make_dataclass[TField: Field]( :param object_handler: Implements the initialization and attribute assignment of instances of the dataclass. :param modifiers: Additional modifiers changing the behavior of the dataclass. """ - if '__dataclass_fields__' in cls.__dict__: + if '__dataclassbase_fields__' in cls.__dict__: raise TypeError('The indicated type already is a dataclass.') if field_provider is ...: @@ -857,14 +1047,27 @@ def make_dataclass[TField: Field]( if object_handler is ...: object_handler = ObjectHandler() - field_map = _types.MappingProxyType(field_provider._gather_fields_core(cls)) + fields = field_provider.gather_fields(cls) + merged_fields = fields.all() + + has_default = False + + for field in merged_fields.values(): + if field.has_default: + has_default = True + elif has_default: + raise RuntimeError('Fields with default value must not be followed by fields without a default value.') + + declared_fields = _types.MappingProxyType(fields.declared_fields) + field_map = _types.MappingProxyType(merged_fields) - cls.__dataclass_fields__ = field_map # type: ignore[attr-defined] + cls.__dataclassbase_fields__ = field_map # type: ignore[attr-defined] + cls.__dataclassbase_types__ = (cls,) # type: ignore[attr-defined] - # check with all bases (that are not metaclasses) that our overrides are valid for base in cls.__bases__: + # check with all bases (that are not metaclasses) that our overrides are valid if isinstance(base, DataclassMetaBase): - field_provider.check_overrides(base.__dataclass_fields__, field_map) + field_provider.check_overrides(base.__dataclassbase_fields__, field_map) # determine the new `__init__` procedure. The old one gets called, too, if there is one (different from the one of # `object`) @@ -874,11 +1077,28 @@ def make_dataclass[TField: Field]( user_init = None def init(self: _typing.Any, *args: _typing.Any, **kwargs: _typing.Any) -> None: - # noinspection PyTypeChecker - object_handler.init_instance(cls, self, args, kwargs) # type: ignore[arg-type] + # we treat the root dataclass (the one on top of the inheritance tree) differently from the other ones + is_root_dataclass = cls in dataclass_leaf_types(type(self)) + + if is_root_dataclass: + self.__dataclassbase_initialized__ = False + + # noinspection PyTypeChecker + object_handler.init_instance(cls, self, args, kwargs) # type: ignore[arg-type] + + if user_init is not None: + user_init(self, *args, **kwargs) - if user_init is not None: - user_init(self, *args, **kwargs) + object_handler.init_instance_post(_typing.cast(DataclassType, cls), self, args, kwargs) + + self.__dataclassbase_initialized__ = True + else: + object_handler.init_instance_subtype_pre(_typing.cast(DataclassType, cls), self, args, kwargs) + + if user_init is not None: + user_init(self, *args, **kwargs) + + object_handler.init_instance_subtype_post(_typing.cast(DataclassType, cls), self, args, kwargs) cls.__init__ = init # type: ignore[misc] @@ -904,16 +1124,17 @@ def delattrib(self: _typing.Any, item: str) -> None: modifier if isinstance(modifier, BehaviorModifier) else modifier() for modifier in modifiers ) - cls.__dataclass_modifiers__ = initialized_modifiers # type: ignore[attr-defined] + cls.__dataclassbase_modifiers__ = initialized_modifiers # type: ignore[attr-defined] for modifier in initialized_modifiers: - modifier.modify(cls) + modifier.modify(cls, field_map, declared_fields) -def dataclass[TField: Field, TCls: type]( +@_typing.dataclass_transform() +def dataclass[TCls: type]( *modifiers: BehaviorModifier | ConstructorlessFactory[BehaviorModifier], - field_provider: _E[FieldProvider[TField] | FieldFactory[TField]] = ..., - object_handler: _E[ObjectHandler[TField]] = ..., + field_provider: _E[FieldProvider | FieldFactory] = ..., + object_handler: _E[ObjectHandler] = ..., ) -> _typing.Callable[[TCls], TCls]: """ Returns a function that that makes the classes supplied to it dataclass-like. The classes must not be dataclasses, @@ -934,7 +1155,7 @@ def handle[T: type](cls: T) -> T: return handle -class DataclassMetaBase[TField: Field](type): +class DataclassMetaBase(type): """ A base class for metaclasses inducing dataclass-like behavior. The :py:func:`make_dataclass` gets invoked on subclasses. @@ -947,7 +1168,10 @@ class DataclassMetaBase[TField: Field](type): conversion of the field specifies into fields. """ - __dataclass_fields__: _types.MappingProxyType[str, Field] + __dataclassbase_fields__: _types.MappingProxyType[str, Field] + __dataclassbase_initialized__: bool + __dataclassbase_modifiers__: tuple[BehaviorModifier, ...] + __dataclassbase_types__: tuple[type[DataclassType], ...] def __new__(mcs, name: str, bases: tuple[type, ...], dct: dict[str, _typing.Any]) -> type: cls = type.__new__(mcs, name, bases, dct) @@ -957,7 +1181,7 @@ def __new__(mcs, name: str, bases: tuple[type, ...], dct: dict[str, _typing.Any] return cls @classmethod - def _field_provider(cls) -> FieldProvider[TField]: + def _field_provider(cls) -> FieldProvider: """ The field provider to use to control the creation and override behavior of fields. @@ -966,7 +1190,7 @@ def _field_provider(cls) -> FieldProvider[TField]: return BasicFieldProvider() @classmethod - def _object_handler(cls) -> ObjectHandler[TField]: + def _object_handler(cls) -> ObjectHandler: """ The object handler to use to control the object initialization and set-attribute behavior. @@ -980,7 +1204,7 @@ def _modifiers(cls) -> tuple[BehaviorModifier | ConstructorlessFactory[BehaviorM @_typing.dataclass_transform(field_specifiers=(Field, FieldInitializer)) -class DataclassMeta[TField: Field](DataclassMetaBase[TField]): +class DataclassMeta(DataclassMetaBase): """ Provides a basic :class:`Field`-based metaclass. When used as a metaclass, the class and any subclass inherit a dataclass-like behavior. See :class:`DataclassMetaBase` and :py:func:`make_dataclass` for further information. @@ -993,10 +1217,10 @@ class DataclassMeta[TField: Field](DataclassMetaBase[TField]): class DataclassType(_typing.Protocol): """A protocol that describes the members of dataclass-like classes.""" - __dataclass_fields__: _typing.ClassVar[_typing.Mapping[str, Field]] + __dataclassbase_fields__: _util.MappingProto[str, Field] """The fields of the dataclass.""" - def __init__(self, *args: _typing.Any, **kwargs: _typing.Any) -> None: + def __call__(self, *args: _typing.Any, **kwargs: _typing.Any) -> _typing.Self: """ Initializes the dataclass. @@ -1013,15 +1237,12 @@ class DataclassbaseInstance(_typing.Protocol): :py:func:`make_dataclass`, etc. """ - __dataclass_initialized__: bool + __dataclassbase_initialized__: bool """Determines the dataclass instance has been initialized.""" - __dataclass_fields__: _typing.ClassVar[_typing.Mapping[str, Field]] + __dataclassbase_fields__: _typing.ClassVar[_util.MappingProto[str, Field]] """The fields of the dataclass.""" - __dataclass_modifiers__: _typing.ClassVar[tuple[BehaviorModifier, ...]] - """The behavior modifiers applied to the dataclass.""" - class TypeConstrainedField(Field): """ @@ -1086,7 +1307,7 @@ def as_dict( def child_dataclass_to_dict(obj: DataclassbaseInstance) -> _typing.Any: return as_dict(obj, child_dataclass_to_dict=child_dataclass_to_dict, fields_only=fields_only) - dct = {k: getattr(obj, k) for k in type(obj).__dataclass_fields__} if fields_only else obj.__dict__.copy() + dct = {k: getattr(obj, k) for k in type(obj).__dataclassbase_fields__} if fields_only else obj.__dict__.copy() if child_dataclass_to_dict is not None: for k, v in dct.items(): @@ -1179,11 +1400,19 @@ def __init__( self._dict_to_str = dict_to_str self._dict_to_repr = dict_to_repr - def modify(self, cls: type[DataclassType]) -> None: + def modify( + self, + cls: type[DataclassType], + fields: _types.MappingProxyType[str, Field], + declared_fields: _types.MappingProxyType[str, Field], + ) -> None: """ Overrides :py:func:`__str__` and/or :py:func:`__repr__` according to the behavior defined during initialization. :param cls: The class to modify. + :param fields: All fields of the dataclass. + :param declared_fields: + The fields of the dataclass that were declared on the type itself rather than being inherited. """ dict_to_repr = self._dict_to_repr dict_to_str = self._dict_to_str diff --git a/dataclassbase/_util.py b/dataclassbase/_util.py index 8f9092c..d2de11e 100644 --- a/dataclassbase/_util.py +++ b/dataclassbase/_util.py @@ -23,3 +23,20 @@ def throws(f: _typing.Callable[[], _typing.Any], *exception_types: type[BaseExce raise return False + + +@_typing.runtime_checkable +class MappingProto[TKey, TValue](_typing.Protocol): + def keys(self) -> _typing.AbstractSet[TKey]: ... + + def values(self) -> _typing.Iterable[TValue]: ... + + def items(self) -> _typing.Iterable[tuple[TKey, TValue]]: ... + + def __getitem__(self, item: TKey, /) -> TValue: ... + + def __iter__(self) -> _typing.Iterator[TKey]: ... + + def __contains__(self, item: object, /) -> bool: ... + + def __len__(self) -> int: ... diff --git a/tests/dataclassbase_tests/__init__.py b/tests/dataclassbase_tests/__init__.py index baf3f39..3fbc202 100644 --- a/tests/dataclassbase_tests/__init__.py +++ b/tests/dataclassbase_tests/__init__.py @@ -9,37 +9,48 @@ def __init__(self) -> None: def check_field( - instance: _dc.DataclassType, + instance: _typing.Any, name: str, annotation: _typing.Any, default: type[SomeFactory] | _typing.Any = ..., field_type: type[_dc.Field] = _dc.Field, type_exact: bool = True, ) -> None: - field: _dc.Field = instance.__dataclass_fields__[name] + field: _dc.Field = instance.__dataclassbase_fields__[name] if field.name != name: - raise ValueError("The field's name disagrees with `name`.") + raise ValueError(f"Field's name disagrees with `name` (expected: {name!r}, actual: {field.name!r}).") if field.annotation != annotation: - raise ValueError("The field's annotation disagrees with `annotation`.") + raise ValueError(f"The field's annotation disagrees with `annotation` (field: {name!r}).") if default is ...: if field.has_default: - raise ValueError('No default value was indicated, but the field has a default value.') + raise ValueError(f'No default value was indicated, but the field has a default value (field: {name!r}).') if field.default is not None: - raise ValueError("The field's default value was not `None` though its `has_default`-value was `False`.") + raise ValueError( + "The field's default value was not `None` though its `has_default`-value was `False` " + f'(field: {name!r}).' + ) else: if not field.has_default: - raise ValueError('The field does not have a default value, but `default` is set to a value.') + raise ValueError( + f'The field does not have a default value, but `default` is set to a value (field: {name!r}).' + ) + if field.default_factory is None: if default is SomeFactory: - raise ValueError('The field defines a constant default value, but `default` implies a factory.') + raise ValueError( + f'The field defines a constant default value, but `default` implies a factory (field: {name!r}).' + ) if field.default != default: - raise ValueError("The field's default value is not equal to `default`.") + raise ValueError(f"The field's default value is not equal to `default` (field: {name!r}).") elif default is not SomeFactory: - raise ValueError('The field defines a default factory, but `default` implies a constant default value.') + raise ValueError( + f'The field defines a default factory, but `default` implies a constant default value ' + f'(field: {name!r}).' + ) if type_exact: assert type(field) is field_type diff --git a/tests/dataclassbase_tests/test_dataclassmeta.py b/tests/dataclassbase_tests/test_dataclassmeta.py index 3aeabc0..f0ae8b3 100644 --- a/tests/dataclassbase_tests/test_dataclassmeta.py +++ b/tests/dataclassbase_tests/test_dataclassmeta.py @@ -75,12 +75,12 @@ def test_inherited_fields() -> None: class InheritedOverlap(Inherited): - field: int = 0 + other: str = '' def test_inherited_overlap_fields() -> None: - check_field(InheritedOverlap, 'field', int, 0) - check_field(InheritedOverlap, 'other', str) + check_field(InheritedOverlap, 'field', int) + check_field(InheritedOverlap, 'other', str, '') # Test initialization @@ -142,27 +142,27 @@ def test_mixed_init_partial() -> None: def test_extra_fields_pos() -> None: with _pytest.raises(TypeError): - ThreeFields(1, '2', 3.0, 4) + ThreeFields(1, '2', 3.0, 4) # type: ignore[call-arg] def test_extra_fields_kw() -> None: with _pytest.raises(TypeError): - ThreeFields(x=1, y='2', z=3.0, w=4) + ThreeFields(x=1, y='2', z=3.0, w=4) # type: ignore[call-arg] def test_extra_fields_mixture() -> None: with _pytest.raises(TypeError): - ThreeFields(1, 2, z=3.0, w=4) + ThreeFields(1, 2, z=3.0, w=4) # type: ignore[call-arg, arg-type] def test_extra_fields_overlap() -> None: with _pytest.raises(TypeError): - ThreeFields(1, '2', y='x') + ThreeFields(1, '2', y='x') # type: ignore[misc] def test_fields_overlap() -> None: with _pytest.raises(TypeError): - Base(y='y', z='z') + Base(y='y', z='z') # type: ignore[call-arg] # Test __setattr__ @@ -188,20 +188,20 @@ def test_field_deletion() -> None: def test_nonfield_assignment() -> None: instance = ThreeFields(1) - instance.attr = 1 + instance.undefined_attr = 1 # type: ignore[attr-defined] - assert hasattr(instance, 'attr') + assert hasattr(instance, 'undefined_attr') def test_nonfield_deletion() -> None: instance = ThreeFields(1) - instance.attr = 1 + instance.undefined_attr = 1 # type: ignore[attr-defined] - del instance.attr + del instance.undefined_attr # type: ignore[attr-defined] with _pytest.raises(AttributeError): - _ = instance.attr + _ = instance.undefined_attr # type: ignore[attr-defined] # @@ -222,8 +222,23 @@ def test_identity_for_fields() -> None: field = _dc.Field('x', int) class FieldContainer(metaclass=_dc.DataclassMeta): - x: int = field + x: int = field # type: ignore[assignment] check_field(FieldContainer, 'x', int) - assert FieldContainer.__dataclass_fields__['x'] is field + assert FieldContainer.__dataclassbase_fields__['x'] is field + + +def test_initialized() -> None: + x = Base(0) + + assert x.__dataclassbase_initialized__ # type: ignore + + +def test_error_nondefault_after_default() -> None: + # fields with a default value must not be followed by fields without a default value + with _pytest.raises(RuntimeError): + + class InvalidDefaultingMixture(metaclass=_dc.DataclassMeta): + x: int = 0 + y: int # type: ignore[misc] diff --git a/tests/dataclassbase_tests/test_field_provider.py b/tests/dataclassbase_tests/test_field_provider.py index d87f410..2acad68 100644 --- a/tests/dataclassbase_tests/test_field_provider.py +++ b/tests/dataclassbase_tests/test_field_provider.py @@ -62,8 +62,8 @@ class ChildDataclass(BaseDataclass): check_overridden_field_callback=check_overridden_field_callback_child, ) # type: ignore[assignment] - base_field = BaseDataclass.__dataclass_fields__['x'] - child_field = ChildDataclass.__dataclass_fields__['x'] + base_field = BaseDataclass.__dataclassbase_fields__['x'] + child_field = ChildDataclass.__dataclassbase_fields__['x'] assert base_overridden_by is child_field assert base_overriding is None @@ -98,4 +98,4 @@ class X: a: int b: int - assert X.__dataclass_fields__.keys() == {'a', 'b'} # type: ignore[attr-defined] + assert X.__dataclassbase_fields__.keys() == {'a', 'b'} # type: ignore[attr-defined] diff --git a/tests/dataclassbase_tests/test_modifiers.py b/tests/dataclassbase_tests/test_modifiers.py index 5931acc..eee1aee 100644 --- a/tests/dataclassbase_tests/test_modifiers.py +++ b/tests/dataclassbase_tests/test_modifiers.py @@ -1,3 +1,5 @@ +import typing as _typing + import pytest as _pytest import dataclassbase as _dc @@ -37,6 +39,106 @@ class Dataclass: assert inst.x == 1 +def test_frozen_combo() -> None: + @_dc.dataclass(_dc.Frozen, _dc.KeywordOnly) + class A: + x: int + + @_dc.dataclass(_dc.KeywordOnly, _dc.Frozen) + class B: + x: int + + A(x=1) + B(x=1) + + +def test_frozen_inheritance() -> None: + @_dc.dataclass(_dc.Frozen) + class A: + x: int + + @_dc.dataclass(_dc.Frozen) + class B(A): + x: int + + instance = B(x=1) + + with _pytest.raises(RuntimeError): + instance.x = 2 + + +def test_frozen_inheritance_nondataclass() -> None: + @_dc.dataclass(_dc.Frozen) + @_typing.dataclass_transform() + class A: + x: int + + class B(A): + pass + + instance = B(x=1) + + with _pytest.raises(RuntimeError): + instance.x = 2 + + +def test_frozen_inheritance_mix() -> None: + @_dc.dataclass(_dc.Frozen) + class A: + x: int + + @_dc.dataclass(_dc.Frozen) + class B: + x: int + + @_dc.dataclass(_dc.Frozen) + class C(A, B): + pass + + instance = C(x=1) + + with _pytest.raises(RuntimeError): + instance.x = 2 + + +def test_frozen_inheritance_mix_nondataclass() -> None: + # A, B are dataclasses, C(A, B) is not. + # Note that this is a special case that is rather untypical + + @_dc.dataclass(_dc.Frozen) + class A: + x: int + + @_dc.dataclass(_dc.Frozen) + class B: + x: int + + class C(A, B): + pass + + instance = C(x=1) + + with _pytest.raises(RuntimeError): + instance.x = 3 + + +def test_frozen_inheritance_partly() -> None: + @_dc.dataclass() + class A: + x: int + + @_dc.dataclass(_dc.Frozen) + class B(A): + y: int + + instance = B(x=1, y=2) + + instance.x = 3 + + with _pytest.raises(RuntimeError): + instance.y = 4 + + # PositionalOnly diff --git a/tests/dataclassbase_tests/test_typeconstrained_field.py b/tests/dataclassbase_tests/test_typeconstrained_field.py index 2ad707f..ba9278a 100644 --- a/tests/dataclassbase_tests/test_typeconstrained_field.py +++ b/tests/dataclassbase_tests/test_typeconstrained_field.py @@ -15,13 +15,11 @@ def test_valid_assignment() -> None: def test_invalid_assignment() -> None: - with _pytest.raises(TypeError): - assert isinstance(TypeConstrainedDataclass('').x, int) + assert isinstance(TypeConstrainedDataclass('').x, int) # type: ignore[arg-type] def test_unsupported_annotation() -> None: - with _pytest.raises(TypeError): @_dc.dataclass(field_provider=_dc.TypeConstrainedField) @@ -41,4 +39,4 @@ def test_invalid_reassignment() -> None: instance = TypeConstrainedDataclass(1) with _pytest.raises(TypeError): - instance.x = '1' + instance.x = '1' # type: ignore From ec0b98f182e099747c6988ba5826c25bd75ba2aa Mon Sep 17 00:00:00 2001 From: "J. Mayr" Date: Fri, 23 Oct 2026 18:20:49 +0200 Subject: [PATCH 2/2] * Re-enabled dataclass initialization * Added non-metaclass variants `Dataclass` and `DataclassBase` * README.md updated --- README.md | 184 +++++++----- dataclassbase/__init__.py | 156 ++++++++-- tests/dataclassbase_tests/test_dataclasses.py | 284 ++++++++++++++++++ .../dataclassbase_tests/test_dataclassmeta.py | 244 --------------- tests/dataclassbase_tests/test_modifiers.py | 11 + 5 files changed, 530 insertions(+), 349 deletions(-) create mode 100644 tests/dataclassbase_tests/test_dataclasses.py delete mode 100644 tests/dataclassbase_tests/test_dataclassmeta.py diff --git a/README.md b/README.md index 3a01655..13e7507 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,12 @@ See [Documentation](https://dataclassbase.readthedocs.io/en/latest/) for the lat ## What it is and how to get it -**dataclassbase** is a small toolkit for providing your own dataclasses. For dataclasses, attributes are declared via -annotations, and constructors are created automatically making development fast. +**dataclassbase** is a small toolkit for providing your own dataclasses. -However, often data in dataclasses shares common properties (e.g., data shape, etc.) or we might want to have more -control over the fields in dataclasses. This is where **dataclassbase** comes to help: You can define your own -dataclass-like behavior using `typing.dataclass_transform` or metaclasses, and control the behavior of your dataclass in -a fine-grained manner. +For dataclasses, attributes are declared via annotations, and initializers are created automatically making development +fast. However, often data, in dataclasses, share common properties (e.g., data shape, etc.) or we might want to have +more control over the fields in dataclasses. This is where **dataclassbase** comes to help: You can define your own +dataclass-like behavior and control the behavior of your dataclass in a fine-grained manner. You can install it into your environment from PyPI via @@ -32,7 +31,7 @@ plan to use it as a base for further work. The `dataclasses` module provides very basic dataclasses with little customization. The objective of this package is to overcome this limitation by providing customizable dataclasses. -A possible definition for the built-in dataclasses is +A possible definition using the built-in dataclasses is ```python from dataclasses import dataclass @@ -57,34 +56,103 @@ class SomeClass: ``` The most obvious difference above is that we import from `dataclassbase` rather than `dataclasses`. However, looking at -the signature of `dataclassbase.dataclass`, we note that there are two notable parameters: `field_provider` and -`object_handler`. `field_provider` allows customization of the field generation and processing (if the user provides a -`dataclassbase.Field` as a default value), `object_handler` implements the logic for the initialization (`__init__`) and -attribute assignment (`__setattr__`) of objects of the generated dataclass. We therefore can hook into or override the -behavior of the dataclass. - -If you omit the `field_provider` or `object_handler`, the respective default implementations provided in the -`BasicFieldProvider` and `ObjectHandler` classes, which reproduce the default dataclass behavior, are used instead. -If you provide a callable matching the signature of `dataclassbase.Field` to `field_provider`, a `BasicFieldProvider` is -generated using the initializer as a factory. Therefore, you can directly specify the type of field to use, as long as -it matches the respective signature. In your custom field implementation, you may, for example, further process the -value supplied to the `default` parameter. - -Note that while it suffices to call `dataclassbase.dataclass` on a class to initialize dataclass-like behavior, it does -not truly behave like a dataclass to static type checkers since it lacks the `typing.dataclass_transform` decorator. -My reason for that choice is that I would have to fix the `field_specifiers` parameter. -(Instances of the types provided in `field_specifiers` are allowed as default values for fields since they are -assumed to be fields or to be converted into some. We do that conversion in the provided `field_provider`.) -In doing so, I would constrain proper static type checking on the fields. Therefore, I would rather let you indicate the -types yourself. -To achieve true dataclass-like behavior on a type checker, you can call `typing.dataclass_transform` yourself or rely on -a metaclass derived from `DataclassMetaBase[Field]`: - -Rather than using `dataclassbase.dataclass` as above, you can define your own dataclass by deriving a metaclass from -class `DataclassMetaBase[Field]` and attaching a `typing.dataclass_transform` to it. `DataclassMetaBase` invokes -`make_dataclass` when creating the new type (`dataclass` is the decorator-variant of `make_dataclass`). It provides -the methods `_field_provider` and `_object_handler` which act as factories for the field provider and object handler for -the types using the metaclass. +the signature of `dataclassbase.dataclass`, we notice that there are two notable parameters: `field_provider` and +`object_handler`. `field_provider` allows customization of the field generation and processing, `object_handler` +implements the logic for the initialization (`__init__`) and attribute assignment (`__setattr__`) of objects of the +generated dataclass. We therefore can hook into or override the behavior of the dataclass. + +If you omit the `field_provider` or `object_handler`, the respective default implementations are used. These imitate the +behavior of the default dataclass behavior; their implementation can be found in the `BasicFieldProvider` and +`ObjectHandler` classes. +If you provide a callable to `field_provider` it must match the signature of `dataclassbase.Field`: A +`BasicFieldProvider` gets generated using the initializer as a factory +You can thus directly specify the type of field to use, as long as it matches the signature of the initializer of +`dataclassbase.Field`. +In your custom field implementation, you may, for example, further process the value supplied to the `default` +parameter to transform custom values into fields. + +Variants to `dataclassbase.dataclass` are the classes `DataclassMetaBase` and `DataclassMeta` which is a subclass of the +former. `dataclassbase.dataclass` gets applied using a decorator whereas the other two are metaclasses from which to +inherit. Their benefit lies mainly in the fact that the static attributes created for dataclasses get recognized by the +static type checker - for `dataclassbase.dataclass`, these are not available even though they get declared nonetheless. +A more in-depth explanation is given below. + +```python +from dataclassbase import DataclassMeta + + +class SomeOtherClass(metaclass=DataclassMeta): + field: int +``` + +## Fine-tuning the behavior dataclass members + +`dataclassbase.dataclass` comes with quite a lot of nice additions: We can freeze the dataclass, equality and unequality +operators get generated, a hash function may be provided, and so forth. This behavior can be imitated using the classes +derived from the `BehaviorModifier` type. For now, these are + +* `Frozen`: Dataclasses cannot be assigned new values and cannot be reinitialized +* `Representable`: Adds a string representation via `__repr__` and `__str__` +* `Equatable`: Adds overloads for `__eq__` and `__ne__`, and, optionally, for `__hash__` +* `PositionalOnly`: The initializer only allows positional arguments +* `KeywordOnly`: The initializer only allows keyword arguments + +Add these as `modifiers` when calling `dataclass` or `make_dataclass` or return them as `_modifiers` in metaclasses +derived from `DataclassMetaBase` or types derived from `DataclassBase`. + +Note that some of the behaviors are "unsafe", and it is up to you to decide whether your use-case is safe. For +example, hash codes typically change once fields of the dataclass change. Using such a class as a key is unsafe. You +thus will typically combine `Frozen` and `Equatable`. Note that `Frozen` does not affect nested dataclasses: If these +are not frozen, the dataclass itself may still be mutable. Another unsafe example is the combination of `PositionalOnly` +and `KeywordOnly` which does not make any sense, but is not caught for now. +I may add such safeguards later or implement them via inheritance, but for now it is obvious that such combinations do +not make any sense. + +A more powerful mechanism is provided in the `ObjectHandler` type which can be supplied to `dataclass` and +`make_dataclass`. It provides the actual implementations handling initialization, set-attribute, and delete-attribute. + +## Customizing dataclass fields + +`dataclassbase.Field`s themselves can check whether the value assigned to them is valid. Furthermore, they can check +whether fields in derived classes are valid and, in the opposite direction, whether they are valid overrides of fields +of superclasses. To control the behavior, you need to create a new type that inherits from `dataclassbase.Field` and +override the respective methods. You then create a `dataclassbase.FieldProvider` that provides the field in the newly +created type and supply it to the `field_provider` parameter of `dataclass`/`make_dataclass` or `_field_provider` +function of types inheriting from `DataclassMetaBase`/`DataclassBase`). +If the field's initializer matches the one of `dataclassbase.Field`, you can also use supply it directly without +wrapping it in a custom field provider - the framework does it for you. + +As an example, you can look at the provided `dataclassbase.TypeConstrainedField` which provides simple type checking +via `isinstance`. + +Generally, you can also directly declare fields or instances of `FieldInitializer` on a dataclass, e.g., obtained from +`TField.declare` where `TField` is your field type. The `dataclassbase.FieldProvider` has a method for both parametric +construction and validating preexisting fields. + +## dataclass vs. make_dataclass vs. Dataclass (Meta) vs. Dataclass (Meta)Base + +There are three (and a half) functions you may use to initialize a dataclass on a type: + +* `dataclass` +* `make_dataclass` +* `DataclassMeta`/`Dataclass` +* `DataclassMetaBase`/`DataclassBase` + +`dataclass` is to be used as a decorator whereas `make_dataclass` implements the actual logic behind it. Typically, +it will not be necessary to call the latter. Another way to initialize a dataclass is to use `DataclassMeta` as a +metaclass or `Dataclass` as a base class. `Dataclass` and `DataclassBase` are non-meta classes, i.e., you can use them +with another abstract base class right away. + +`Dataclass` and `DataclassMeta` basically do the same as `dataclass`, but enable better static type-checking: +`make_dataclass` creates a couple of static attributes on the type made into a dataclass. Using `dataclass`, these +are not visible to static type checkers and analyzers since the current version of Python typing does not allow +annotating the addition of new members to existing objects. + +In Python, dataclasses are reported to static type checkers by calling `typing.dataclass_transform` on their creating +function, an abstract base class, or an inherited class (as described +[here](https://typing.python.org/en/latest/spec/dataclasses.html)). These are, however, static, i.e., we cannot supply +behavior modifiers to `make_dataclass` and expect the differing behavior to be recognized by a static type checker. +Therefore, you can use the `DataclassMetaBase` and `DataclassBase` classes to derive your own dataclasses: ```python from dataclassbase import DataclassMetaBase, Field, BasicFieldProvider @@ -112,46 +180,14 @@ class VariantA(metaclass=CustomDataclassMeta): defaulting: int = 1 ``` -`dataclassbase.Field`s themselves can check whether the value assigned to them is valid. Furthermore, they can check -whether fields in derived classes are valid and, in the opposite direction, whether they are valid overrides of fields -of superclasses. As an example, you can look at the provided `dataclassbase.TypeConstrainedField` which provides -simple type checking via `isinstance`. - -The default implementation is `DataclassMeta` which just uses the default values and allows any -`dataclasses.Field` as a default value for its fields. While it behaves in the same way as the built-in -`dataclasses`-dataclasses, it does however not give us any benefits over it. - -```python -from dataclassbase import DataclassMeta - - -class VariantB(metaclass=DataclassMeta): - field: int - defaulting: int = 1 -``` - -## Fine-tuning the behavior of the dataclasses - -`dataclasses.dataclass` comes with quite a lot of nice additions: We can freeze the dataclass, equality and unequality -operators get generated, a hash function may be provided, and so forth. This behavior can be imitated using the classes -derived from the `BehaviorModifier` type. For now, these are - -* `Frozen`: Dataclasses cannot be assigned new values and cannot be reinitialized -* `Representable`: Adds a string representation via `__repr__` and `__str__` -* `Equatable`: Adds overloads for `__eq__` and `__ne__`, and, optionally, for `__hash__` -* `PositionalOnly`: The initializer only allows positional arguments -* `KeywordOnly`: The initializer only allows keyword arguments - -Add these as `modifiers` when calling `dataclass` or `make_dataclass` or return them as `_modifiers` in metaclasses -derived from `DataclassMetaBase`. - -Note that some of the behaviors are "unsafe", and it is up to you to decide whether your use-case is safe. For -example, hash codes typically change once fields of the dataclass change. Using such a class as a key is unsafe. You -thus will typically combine `Frozen` and `Equatable`. Note that `Frozen` does not affect nested dataclasses: If these -are not frozen, the dataclass itself may still be mutable. Another unsafe example is the combination of `PositionalOnly` -and `KeywordOnly` which does not make any sense, but is not caught for now. +`Dataclass` and `DataclassMeta` provide default implementations for `DataclassBase` and `DataclassMetaBase`, +respectively. These predeclare the `dataclass_transform` with default field specifiers (`Field` and `FieldInitializer`), +and they do neither add any behavior modifiers nor inform static type checkers about such behaviors being applied. -I may add such safeguards later, but for now it is obvious that such combinations do not make any sense. +Between the metaclass and non-metaclass variant, it does not really make a difference which one you choose if you do not +have another metaclass you would like to inherit from (e.g. `abc.ABC` includes metaclass `abc.ABCMeta`): `DataclassBase` +uses `__init_subclass__` rather than the `type.__new__` overridden in the metaclass. There are various methods declared +as class-methods either on the base class or the metaclass. # When to use dataclassbase and not ...? diff --git a/dataclassbase/__init__.py b/dataclassbase/__init__.py index e396c55..f855a07 100644 --- a/dataclassbase/__init__.py +++ b/dataclassbase/__init__.py @@ -236,6 +236,23 @@ def field[**P]( return FieldDeclaration(__cls, *args, **kwargs) +@_typing.runtime_checkable +class DataclassType(_typing.Protocol): + """A protocol that describes the members of dataclass-like classes.""" + + __dataclassbase_fields__: _util.MappingProto[str, Field] + """The fields of the dataclass.""" + + def __call__(self, *args: _typing.Any, **kwargs: _typing.Any) -> _typing.Self: + """ + Initializes the dataclass. + + :param args: The positional arguments to initialize the dataclass type with. + :param kwargs: The keyword arguments to initialize the dataclass type with. + """ + ... + + class _Unspecified: """The class is used as a singleton together with dictionaries.""" @@ -512,7 +529,7 @@ class BehaviorModifier(_abc.ABC): @_abc.abstractmethod def modify( self, - cls: type[DataclassType], + cls: DataclassType, fields: _types.MappingProxyType[str, Field], declared_fields: _types.MappingProxyType[str, Field], ) -> None: @@ -573,7 +590,7 @@ def freeze_inherited(self) -> bool: def modify( self, - cls: type[DataclassType], + cls: DataclassType, fields: _types.MappingProxyType[str, Field], declared_fields: _types.MappingProxyType[str, Field], ) -> None: @@ -603,7 +620,7 @@ def setattrib(obj: _typing.Any, /, name: str, value: _typing.Any) -> None: if inner_setattrib is not None: inner_setattrib(obj, name, value) - cls.__setattr__ = setattrib # type: ignore[method-assign] + cls.__setattr__ = setattrib # type: ignore[assignment, method-assign] def init(obj: _typing.Any, /, *args: _typing.Any, **kwargs: _typing.Any) -> None: if getattr(obj, '__dataclassbase_initialized__', False): @@ -614,7 +631,7 @@ def init(obj: _typing.Any, /, *args: _typing.Any, **kwargs: _typing.Any) -> None if inner_init is not None: inner_init(obj, *args, **kwargs) - cls.__init__ = init # type: ignore[assignment] + cls.__init__ = init # type: ignore[misc] class Equatable(BehaviorModifier): @@ -668,7 +685,7 @@ def fields_only(self) -> bool: def modify( self, - cls: type[DataclassType], + cls: DataclassType, fields: _types.MappingProxyType[str, Field], declared_fields: _types.MappingProxyType[str, Field], ) -> None: @@ -732,7 +749,7 @@ def __init__(self) -> None: def modify( self, - cls: type[DataclassType], + cls: DataclassType, fields: _types.MappingProxyType[str, Field], declared_fields: _types.MappingProxyType[str, Field], ) -> None: @@ -744,12 +761,12 @@ def modify( :param declared_fields: The fields of the dataclass that were declared on the type itself rather than being inherited. """ - inner_init = cls.__init__ + inner_init = cls.__init__ # type: ignore[misc] def init(obj: _typing.Any, /, **kwargs: _typing.Any) -> None: inner_init(obj, **kwargs) - cls.__init__ = init # type: ignore[method-assign, assignment] + cls.__init__ = init # type: ignore[misc] class PositionalOnly(BehaviorModifier): @@ -761,7 +778,7 @@ def __init__(self) -> None: def modify( self, - cls: type[DataclassType], + cls: DataclassType, fields: _types.MappingProxyType[str, Field], declared_fields: _types.MappingProxyType[str, Field], ) -> None: @@ -773,12 +790,12 @@ def modify( :param declared_fields: The fields of the dataclass that were declared on the type itself rather than being inherited. """ - inner_init = cls.__init__ + inner_init = cls.__init__ # type: ignore[misc] def init(obj: _typing.Any, /, *args: _typing.Any) -> None: inner_init(obj, *args) - cls.__init__ = init # type: ignore[method-assign, assignment] + cls.__init__ = init # type: ignore[misc] class ObjectHandler: @@ -1038,6 +1055,9 @@ def make_dataclass( if '__dataclassbase_fields__' in cls.__dict__: raise TypeError('The indicated type already is a dataclass.') + if issubclass(cls, BehaviorModifier): + raise RuntimeError('Behavior modifiers cannot be turned into dataclasses.') + if field_provider is ...: field_provider = BasicFieldProvider() elif not isinstance(field_provider, FieldProvider): @@ -1100,7 +1120,7 @@ def init(self: _typing.Any, *args: _typing.Any, **kwargs: _typing.Any) -> None: object_handler.init_instance_subtype_post(_typing.cast(DataclassType, cls), self, args, kwargs) - cls.__init__ = init # type: ignore[misc] + cls.__init__ = init # type: ignore[method-assign] if object_handler.override_set_attribute: inner_set_attribute = getattr(cls, '__setattr__', None) @@ -1109,7 +1129,7 @@ def setattrib(self: _typing.Any, key: str, value: _typing.Any) -> None: field = field_map.get(key) object_handler.handle_set_attribute(self, field, key, value, inner_set_attribute) - cls.__setattr__ = setattrib # type: ignore[method-assign, assignment] + cls.__setattr__ = setattrib # type: ignore[method-assign] if object_handler.override_delete_attribute: inner_del_attribute = getattr(cls, '__delattr__', None) @@ -1118,7 +1138,7 @@ def delattrib(self: _typing.Any, item: str) -> None: field = field_map.get(item) object_handler.handle_delete_attribute(self, field, item, inner_del_attribute) - cls.__delattr__ = delattrib # type: ignore[method-assign, assignment] + cls.__delattr__ = delattrib # type: ignore[method-assign] initialized_modifiers = tuple( modifier if isinstance(modifier, BehaviorModifier) else modifier() for modifier in modifiers @@ -1126,16 +1146,38 @@ def delattrib(self: _typing.Any, item: str) -> None: cls.__dataclassbase_modifiers__ = initialized_modifiers # type: ignore[attr-defined] + dc = _typing.cast(DataclassType, _typing.cast(object, cls)) + for modifier in initialized_modifiers: - modifier.modify(cls, field_map, declared_fields) + modifier.modify(dc, field_map, declared_fields) + + +@_typing.dataclass_transform() +@_typing.overload +def dataclass[TCls: type]( + *modifiers: BehaviorModifier | ConstructorlessFactory[BehaviorModifier], + field_provider: _E[FieldProvider | FieldFactory] = ..., + object_handler: _E[ObjectHandler] = ..., +) -> _typing.Callable[[TCls], TCls]: ... @_typing.dataclass_transform() +@_typing.overload def dataclass[TCls: type]( + cls: TCls, + /, *modifiers: BehaviorModifier | ConstructorlessFactory[BehaviorModifier], field_provider: _E[FieldProvider | FieldFactory] = ..., object_handler: _E[ObjectHandler] = ..., -) -> _typing.Callable[[TCls], TCls]: +) -> TCls: ... + + +@_typing.dataclass_transform() +def dataclass( + *modifiers: type | BehaviorModifier | ConstructorlessFactory[BehaviorModifier], + field_provider: _E[FieldProvider | FieldFactory] = ..., + object_handler: _E[ObjectHandler] = ..., +) -> _typing.Any: """ Returns a function that that makes the classes supplied to it dataclass-like. The classes must not be dataclasses, yet. @@ -1148,10 +1190,22 @@ def dataclass[TCls: type]( :param modifiers: Additional modifiers changing the behavior of the dataclass. """ - def handle[T: type](cls: T) -> T: + if len(modifiers) > 0 and isinstance(modifiers[0], type) and not issubclass(modifiers[0], BehaviorModifier): + cls = _typing.cast(type, modifiers[0]) + modifiers = _typing.cast(tuple[BehaviorModifier | ConstructorlessFactory[BehaviorModifier], ...], modifiers[1:]) make_dataclass(cls, field_provider, object_handler, modifiers) return cls + def handle[T: type](cls: T) -> T: + make_dataclass( + cls, + field_provider, + object_handler, + _typing.cast(tuple[BehaviorModifier | ConstructorlessFactory[BehaviorModifier], ...], modifiers), + ) + + return cls + return handle @@ -1166,12 +1220,17 @@ class DataclassMetaBase(type): is derived from the :class:`DataclassMetaBase` class if you want to define a class with custom field specifiers and add a :py:func:`typing.dataclass_transform`-decoration to them. Use the :py:func:`_field_provider` to control the conversion of the field specifies into fields. + + Note that this class is not intended to be used directly as a metaclass of a dataclass. The + :py:func:`typing.dataclass_transform` method does not get applied to the current type since it would constrain the + parameters of :py:func:`typing.dataclass_transform` to a specific choice. Use a derived class and apply + `:py:func:`typing.dataclass_transform` to it or use the default implementation :py:class:`DataclassMeta`. """ __dataclassbase_fields__: _types.MappingProxyType[str, Field] __dataclassbase_initialized__: bool __dataclassbase_modifiers__: tuple[BehaviorModifier, ...] - __dataclassbase_types__: tuple[type[DataclassType], ...] + __dataclassbase_types__: tuple[DataclassType, ...] def __new__(mcs, name: str, bases: tuple[type, ...], dct: dict[str, _typing.Any]) -> type: cls = type.__new__(mcs, name, bases, dct) @@ -1213,21 +1272,56 @@ class DataclassMeta(DataclassMetaBase): pass -@_typing.runtime_checkable -class DataclassType(_typing.Protocol): - """A protocol that describes the members of dataclass-like classes.""" +class DataclassBase: + """ + A base class for classes inducing dataclass-like behavior. The :py:func:`make_dataclass` gets invoked during the + initialization of the subclasses. - __dataclassbase_fields__: _util.MappingProto[str, Field] - """The fields of the dataclass.""" + Note that this class is not intended to be used directly as a base class of a dataclass. The + :py:func:`typing.dataclass_transform` method does not get applied to the current type since it would constrain the + parameters of :py:func:`typing.dataclass_transform` to a specific choice. Use a derived class and apply + `:py:func:`typing.dataclass_transform` to it or use the default implementation :py:class:`Dataclass`. + """ - def __call__(self, *args: _typing.Any, **kwargs: _typing.Any) -> _typing.Self: + __dataclassbase_fields__: _types.MappingProxyType[str, Field] + __dataclassbase_initialized__: bool + __dataclassbase_modifiers__: tuple[BehaviorModifier, ...] + __dataclassbase_types__: tuple[DataclassType, ...] + + def __init_subclass__(cls) -> None: + make_dataclass(cls, cls._field_provider(), cls._object_handler(), cls._modifiers()) + + @classmethod + def _field_provider(cls) -> FieldProvider: """ - Initializes the dataclass. + The field provider to use to control the creation and override behavior of fields. - :param args: The positional arguments to initialize the dataclass type with. - :param kwargs: The keyword arguments to initialize the dataclass type with. + :return: The field provider. """ - ... + return BasicFieldProvider() + + @classmethod + def _object_handler(cls) -> ObjectHandler: + """ + The object handler to use to control the object initialization and set-attribute behavior. + + :return: The object handler. + """ + return ObjectHandler() + + @classmethod + def _modifiers(cls) -> tuple[BehaviorModifier | ConstructorlessFactory[BehaviorModifier], ...]: + return () + + +@_typing.dataclass_transform(field_specifiers=(Field, FieldInitializer)) +class Dataclass(DataclassBase): + """ + Provides a basic :class:`Field`-based metaclass. When used as a metaclass, the class and any subclass inherit a + dataclass-like behavior. See :class:`DataclassMetaBase` and :py:func:`make_dataclass` for further information. + """ + + pass @_typing.runtime_checkable @@ -1402,7 +1496,7 @@ def __init__( def modify( self, - cls: type[DataclassType], + cls: DataclassType, fields: _types.MappingProxyType[str, Field], declared_fields: _types.MappingProxyType[str, Field], ) -> None: @@ -1422,11 +1516,11 @@ def modify( def repr_impl(obj: _typing.Any, /) -> str: return dict_to_repr(obj, self._obj_as_dict(obj)) - cls.__repr__ = repr_impl # type: ignore[method-assign] + cls.__repr__ = repr_impl # type: ignore[assignment, method-assign] if dict_to_str is not None: def str_impl(obj: _typing.Any, /) -> str: return dict_to_str(obj, self._obj_as_dict(obj)) - cls.__str__ = str_impl # type: ignore[method-assign] + cls.__str__ = str_impl # type: ignore[assignment, method-assign] diff --git a/tests/dataclassbase_tests/test_dataclasses.py b/tests/dataclassbase_tests/test_dataclasses.py new file mode 100644 index 0000000..1183c85 --- /dev/null +++ b/tests/dataclassbase_tests/test_dataclasses.py @@ -0,0 +1,284 @@ +import pytest as _pytest + +import dataclassbase as _dc + +from . import check_field + +# +# Base +# + + +class BaseMeta(metaclass=_dc.DataclassMeta): + field: int + + +class Base(_dc.Dataclass): + field: int + + +@_pytest.mark.parametrize('cls', [BaseMeta, Base]) +def test_base_fields(cls: type[BaseMeta] | type[Base]) -> None: + check_field(cls, 'field', int) + + +@_pytest.mark.parametrize('cls', [BaseMeta, Base]) +def test_base_init_args(cls: type[BaseMeta] | type[Base]) -> None: + instance = cls(1) + assert instance.field == 1 + + +@_pytest.mark.parametrize('cls', [BaseMeta, Base]) +def test_base_init_kwargs(cls: type[BaseMeta] | type[Base]) -> None: + instance = cls(field=1) + assert instance.field == 1 + + +# +# BaseDefaulting +# + + +class BaseDefaultingMeta(metaclass=_dc.DataclassMeta): + field: int = 0 + + +class BaseDefaulting(_dc.Dataclass): + field: int = 0 + + +@_pytest.mark.parametrize('cls', [BaseDefaultingMeta, BaseDefaulting]) +def test_base_defaulting_fields(cls: type[BaseDefaultingMeta] | type[BaseDefaulting]) -> None: + check_field(cls, 'field', int, 0) + + +@_pytest.mark.parametrize('cls', [BaseDefaultingMeta, BaseDefaulting]) +def test_base_defaulting_init(cls: type[BaseDefaultingMeta] | type[BaseDefaulting]) -> None: + instance = cls() + assert instance.field == 0 + + +@_pytest.mark.parametrize('cls', [BaseDefaultingMeta, BaseDefaulting]) +def test_base_defaulting_init_args(cls: type[BaseDefaultingMeta] | type[BaseDefaulting]) -> None: + instance = cls(1) + assert instance.field == 1 + + +@_pytest.mark.parametrize('cls', [BaseDefaultingMeta, BaseDefaulting]) +def test_base_defaulting_init_kwargs(cls: type[BaseDefaultingMeta] | type[BaseDefaulting]) -> None: + instance = cls(field=1) + assert instance.field == 1 + + +# +# Inherited +# + + +class InheritedMeta(BaseMeta): + other: str + + +class Inherited(Base): + other: str + + +@_pytest.mark.parametrize('cls', [InheritedMeta, Inherited]) +def test_inherited_fields(cls: type[InheritedMeta] | type[Inherited]) -> None: + check_field(cls, 'field', int) + check_field(cls, 'other', str) + + +# +# InheritedOverlap +# + + +class InheritedOverlapMeta(InheritedMeta): + other: str = '' + + +class InheritedOverlap(InheritedMeta): + other: str = '' + + +@_pytest.mark.parametrize('cls', [InheritedOverlapMeta, InheritedOverlap]) +def test_inherited_overlap_fields(cls: type[InheritedOverlapMeta] | type[InheritedOverlap]) -> None: + check_field(cls, 'field', int) + check_field(cls, 'other', str, '') + + +# Test initialization + + +class ThreeFieldsMeta(metaclass=_dc.DataclassMeta): + x: int + y: str = 'y' + z: float = 1.0 + + +class ThreeFields(_dc.Dataclass): + x: int + y: str = 'y' + z: float = 1.0 + + +@_pytest.mark.parametrize('cls', [ThreeFieldsMeta, ThreeFields]) +def test_pos_init_full(cls: type[ThreeFieldsMeta] | type[ThreeFields]) -> None: + instance = cls(1, '2', 3.0) + + assert instance.x == 1 + assert instance.y == '2' + assert instance.z == 3.0 + + +@_pytest.mark.parametrize('cls', [ThreeFieldsMeta, ThreeFields]) +def test_pos_init_partial(cls: type[ThreeFieldsMeta] | type[ThreeFields]) -> None: + instance = cls(1, '2') + + assert instance.x == 1 + assert instance.y == '2' + assert instance.z == 1.0 + + +@_pytest.mark.parametrize('cls', [ThreeFieldsMeta, ThreeFields]) +def test_kw_init_full(cls: type[ThreeFieldsMeta] | type[ThreeFields]) -> None: + instance = cls(x=1, y='2', z=3.0) + + assert instance.x == 1 + assert instance.y == '2' + assert instance.z == 3.0 + + +@_pytest.mark.parametrize('cls', [ThreeFieldsMeta, ThreeFields]) +def test_kw_init_partial(cls: type[ThreeFieldsMeta] | type[ThreeFields]) -> None: + instance = cls(x=1, z=3.0) + + assert instance.x == 1 + assert instance.y == 'y' + assert instance.z == 3.0 + + +@_pytest.mark.parametrize('cls', [ThreeFieldsMeta, ThreeFields]) +def test_mixed_init_full(cls: type[ThreeFieldsMeta] | type[ThreeFields]) -> None: + instance = cls(1, y='2', z=3.0) + + assert instance.x == 1 + assert instance.y == '2' + assert instance.z == 3.0 + + +@_pytest.mark.parametrize('cls', [ThreeFieldsMeta, ThreeFields]) +def test_mixed_init_partial(cls: type[ThreeFieldsMeta] | type[ThreeFields]) -> None: + instance = cls(1, z=3.0) + + assert instance.x == 1 + assert instance.y == 'y' + assert instance.z == 3.0 + + +@_pytest.mark.parametrize('cls', [ThreeFieldsMeta, ThreeFields]) +def test_extra_fields_pos(cls: type[ThreeFieldsMeta] | type[ThreeFields]) -> None: + with _pytest.raises(TypeError): + cls(1, '2', 3.0, 4) # type: ignore[call-arg] + + +@_pytest.mark.parametrize('cls', [ThreeFieldsMeta, ThreeFields]) +def test_extra_fields_kw(cls: type[ThreeFieldsMeta] | type[ThreeFields]) -> None: + with _pytest.raises(TypeError): + cls(x=1, y='2', z=3.0, w=4) # type: ignore[call-arg] + + +@_pytest.mark.parametrize('cls', [ThreeFieldsMeta, ThreeFields]) +def test_extra_fields_mixture(cls: type[ThreeFieldsMeta] | type[ThreeFields]) -> None: + with _pytest.raises(TypeError): + cls(1, 2, z=3.0, w=4) # type: ignore[call-arg, arg-type] + + +@_pytest.mark.parametrize('cls', [ThreeFieldsMeta, ThreeFields]) +def test_extra_fields_overlap(cls: type[ThreeFieldsMeta] | type[ThreeFields]) -> None: + with _pytest.raises(TypeError): + cls(1, '2', y='x') # type: ignore[misc] + + +# Test __setattr__ + + +@_pytest.mark.parametrize('cls', [ThreeFieldsMeta, ThreeFields]) +def test_reassignment(cls: type[ThreeFieldsMeta] | type[ThreeFields]) -> None: + instance = cls(1) + + instance.x = 12 + assert instance.x == 12 + + +# Test __delattr__ + + +@_pytest.mark.parametrize('cls', [ThreeFieldsMeta, ThreeFields]) +def test_field_deletion(cls: type[ThreeFieldsMeta] | type[ThreeFields]) -> None: + instance = cls(1) + + with _pytest.raises(RuntimeError): + del instance.x + + +@_pytest.mark.parametrize('cls', [ThreeFieldsMeta, ThreeFields]) +def test_nonfield_assignment(cls: type[ThreeFieldsMeta] | type[ThreeFields]) -> None: + instance = cls(1) + + instance.undefined_attr = 1 # type: ignore[union-attr] + + assert hasattr(instance, 'undefined_attr') + + +@_pytest.mark.parametrize('cls', [ThreeFieldsMeta, ThreeFields]) +def test_nonfield_deletion(cls: type[ThreeFieldsMeta] | type[ThreeFields]) -> None: + instance = cls(1) + + instance.undefined_attr = 1 # type: ignore[union-attr] + + del instance.undefined_attr # type: ignore[union-attr] + + with _pytest.raises(AttributeError): + _ = instance.undefined_attr # type: ignore[union-attr] + + +# +# Other +# + + +def test_incode() -> None: + class InCode(metaclass=_dc.DataclassMeta): + field: int + defaulting: int = 1 + + check_field(InCode, 'field', int) + check_field(InCode, 'defaulting', int, 1) + + +def test_identity_for_fields() -> None: + field = _dc.Field('x', int) + + class FieldContainer(metaclass=_dc.DataclassMeta): + x: int = field # type: ignore[assignment] + + check_field(FieldContainer, 'x', int) + + assert FieldContainer.__dataclassbase_fields__['x'] is field + + +def test_initialized() -> None: + x = BaseMeta(0) + + assert x.__dataclassbase_initialized__ # type: ignore + + +def test_error_nondefault_after_default() -> None: + # fields with a default value must not be followed by fields without a default value + with _pytest.raises(RuntimeError): + + class InvalidDefaultingMixture(metaclass=_dc.DataclassMeta): + x: int = 0 + y: int # type: ignore[misc] diff --git a/tests/dataclassbase_tests/test_dataclassmeta.py b/tests/dataclassbase_tests/test_dataclassmeta.py deleted file mode 100644 index f0ae8b3..0000000 --- a/tests/dataclassbase_tests/test_dataclassmeta.py +++ /dev/null @@ -1,244 +0,0 @@ -import pytest as _pytest - -import dataclassbase as _dc - -from . import check_field - -# -# Base -# - - -class Base(metaclass=_dc.DataclassMeta): - field: int - - -def test_base_fields() -> None: - check_field(Base, 'field', int) - - -def test_base_init_args() -> None: - instance = Base(1) - assert instance.field == 1 - - -def test_base_init_kwargs() -> None: - instance = Base(field=1) - assert instance.field == 1 - - -# -# BaseDefaulting -# - - -class BaseDefaulting(metaclass=_dc.DataclassMeta): - field: int = 0 - - -def test_base_defaulting_fields() -> None: - check_field(BaseDefaulting, 'field', int, 0) - - -def test_base_defaulting_init() -> None: - instance = BaseDefaulting() - assert instance.field == 0 - - -def test_base_defaulting_init_args() -> None: - instance = BaseDefaulting(1) - assert instance.field == 1 - - -def test_base_defaulting_init_kwargs() -> None: - instance = BaseDefaulting(field=1) - assert instance.field == 1 - - -# -# Inherited -# - - -class Inherited(Base): - other: str - - -def test_inherited_fields() -> None: - check_field(Inherited, 'field', int) - check_field(Inherited, 'other', str) - - -# -# InheritedOverlap -# - - -class InheritedOverlap(Inherited): - other: str = '' - - -def test_inherited_overlap_fields() -> None: - check_field(InheritedOverlap, 'field', int) - check_field(InheritedOverlap, 'other', str, '') - - -# Test initialization - - -class ThreeFields(metaclass=_dc.DataclassMeta): - x: int - y: str = 'y' - z: float = 1.0 - - -def test_pos_init_full() -> None: - instance = ThreeFields(1, '2', 3.0) - - assert instance.x == 1 - assert instance.y == '2' - assert instance.z == 3.0 - - -def test_pos_init_partial() -> None: - instance = ThreeFields(1, '2') - - assert instance.x == 1 - assert instance.y == '2' - assert instance.z == 1.0 - - -def test_kw_init_full() -> None: - instance = ThreeFields(x=1, y='2', z=3.0) - - assert instance.x == 1 - assert instance.y == '2' - assert instance.z == 3.0 - - -def test_kw_init_partial() -> None: - instance = ThreeFields(x=1, z=3.0) - - assert instance.x == 1 - assert instance.y == 'y' - assert instance.z == 3.0 - - -def test_mixed_init_full() -> None: - instance = ThreeFields(1, y='2', z=3.0) - - assert instance.x == 1 - assert instance.y == '2' - assert instance.z == 3.0 - - -def test_mixed_init_partial() -> None: - instance = ThreeFields(1, z=3.0) - - assert instance.x == 1 - assert instance.y == 'y' - assert instance.z == 3.0 - - -def test_extra_fields_pos() -> None: - with _pytest.raises(TypeError): - ThreeFields(1, '2', 3.0, 4) # type: ignore[call-arg] - - -def test_extra_fields_kw() -> None: - with _pytest.raises(TypeError): - ThreeFields(x=1, y='2', z=3.0, w=4) # type: ignore[call-arg] - - -def test_extra_fields_mixture() -> None: - with _pytest.raises(TypeError): - ThreeFields(1, 2, z=3.0, w=4) # type: ignore[call-arg, arg-type] - - -def test_extra_fields_overlap() -> None: - with _pytest.raises(TypeError): - ThreeFields(1, '2', y='x') # type: ignore[misc] - - -def test_fields_overlap() -> None: - with _pytest.raises(TypeError): - Base(y='y', z='z') # type: ignore[call-arg] - - -# Test __setattr__ - - -def test_reassignment() -> None: - instance = ThreeFields(1) - - instance.x = 12 - assert instance.x == 12 - - -# Test __delattr__ - - -def test_field_deletion() -> None: - instance = ThreeFields(1) - - with _pytest.raises(RuntimeError): - del instance.x - - -def test_nonfield_assignment() -> None: - instance = ThreeFields(1) - - instance.undefined_attr = 1 # type: ignore[attr-defined] - - assert hasattr(instance, 'undefined_attr') - - -def test_nonfield_deletion() -> None: - instance = ThreeFields(1) - - instance.undefined_attr = 1 # type: ignore[attr-defined] - - del instance.undefined_attr # type: ignore[attr-defined] - - with _pytest.raises(AttributeError): - _ = instance.undefined_attr # type: ignore[attr-defined] - - -# -# Other -# - - -def test_incode() -> None: - class InCode(metaclass=_dc.DataclassMeta): - field: int - defaulting: int = 1 - - check_field(InCode, 'field', int) - check_field(InCode, 'defaulting', int, 1) - - -def test_identity_for_fields() -> None: - field = _dc.Field('x', int) - - class FieldContainer(metaclass=_dc.DataclassMeta): - x: int = field # type: ignore[assignment] - - check_field(FieldContainer, 'x', int) - - assert FieldContainer.__dataclassbase_fields__['x'] is field - - -def test_initialized() -> None: - x = Base(0) - - assert x.__dataclassbase_initialized__ # type: ignore - - -def test_error_nondefault_after_default() -> None: - # fields with a default value must not be followed by fields without a default value - with _pytest.raises(RuntimeError): - - class InvalidDefaultingMixture(metaclass=_dc.DataclassMeta): - x: int = 0 - y: int # type: ignore[misc] diff --git a/tests/dataclassbase_tests/test_modifiers.py b/tests/dataclassbase_tests/test_modifiers.py index eee1aee..09c9952 100644 --- a/tests/dataclassbase_tests/test_modifiers.py +++ b/tests/dataclassbase_tests/test_modifiers.py @@ -4,6 +4,17 @@ import dataclassbase as _dc +# initialization + + +def test_parameterless_decoration() -> None: + @_dc.dataclass + class SomeClass: + some_field: str + + SomeClass('') + + # Equatable