diff --git a/README.md b/README.md index f6bc72c..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,82 +56,38 @@ 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[TField]`: - -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 -`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. - -```python -from dataclassbase import DataclassMetaBase, Field, BasicFieldProvider -from typing import dataclass_transform - - -class CustomField(Field): - # you can override the methods provided by `Field` to control the behavior of the field in derived classes, - # superclasses, to check the assignments, etc. You can also change the behavior of `__init__` as long as its - # signature matches the one from the base - pass - - -@dataclass_transform -class CustomDataclassMeta(DataclassMetaBase[Field]): - @classmethod - def _field_provider(cls) -> BasicFieldProvider[Field]: - # 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) - - -class VariantA(metaclass=CustomDataclassMeta): - field: int - 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. +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 VariantB(metaclass=DataclassMeta): +class SomeOtherClass(metaclass=DataclassMeta): field: int - defaulting: int = 1 ``` -## Fine-tuning the behavior of the dataclasses +## Fine-tuning the behavior dataclass members -`dataclasses.dataclass` comes with quite a lot of nice additions: We can freeze the dataclass, equality and unequality +`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 @@ -143,15 +98,96 @@ derived from the `BehaviorModifier` type. For now, these are * `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`. +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 +from typing import dataclass_transform + + +class CustomField(Field): + # you can override the methods provided by `Field` to control the behavior of the field in derived classes, + # superclasses, to check the assignments, etc. You can also change the behavior of `__init__` as long as its + # signature matches the one from the base + pass + + +@dataclass_transform +class CustomDataclassMeta(DataclassMetaBase): + @classmethod + 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) + + +class VariantA(metaclass=CustomDataclassMeta): + field: int + defaulting: int = 1 +``` + +`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 4a9fb8c..f855a07 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. @@ -237,13 +236,30 @@ def field[**P, TField: Field]( 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.""" 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 +269,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 +287,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 +331,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 +340,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 +372,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 +401,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 +412,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 +427,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 +445,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 +470,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 +485,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 +501,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: 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 +559,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,26 +579,51 @@ 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: 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) - 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 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.' ) @@ -528,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): @@ -580,11 +683,19 @@ def fields_only(self) -> bool: """ return self._fields_only - def modify(self, cls: type[DataclassType]) -> None: + def modify( + self, + cls: 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 +711,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 +733,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,18 +747,26 @@ def __init__(self) -> None: """Initializes the current instance.""" pass - def modify(self, cls: type[DataclassType]) -> None: + def modify( + self, + cls: 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__ + 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): @@ -657,21 +776,29 @@ def __init__(self) -> None: """Initializes the current instance.""" pass - def modify(self, cls: type[DataclassType]) -> None: + def modify( + self, + cls: 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__ + 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[TField: Field]: +class ObjectHandler: """Handles the initialization and set-attribute behavior of a dataclass-like object.""" def init_instance( @@ -692,7 +819,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 +872,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 +950,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 +1004,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,9 +1052,12 @@ 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 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): @@ -857,14 +1067,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.') - cls.__dataclass_fields__ = field_map # type: ignore[attr-defined] + declared_fields = _types.MappingProxyType(fields.declared_fields) + field_map = _types.MappingProxyType(merged_fields) + + 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,13 +1097,30 @@ 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) - cls.__init__ = init # type: ignore[misc] + 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[method-assign] if object_handler.override_set_attribute: inner_set_attribute = getattr(cls, '__setattr__', None) @@ -889,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) @@ -898,23 +1138,46 @@ 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 ) - cls.__dataclass_modifiers__ = initialized_modifiers # type: ignore[attr-defined] + 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) + modifier.modify(dc, field_map, declared_fields) -def dataclass[TField: Field, TCls: type]( +@_typing.dataclass_transform() +@_typing.overload +def dataclass[TCls: type]( *modifiers: BehaviorModifier | ConstructorlessFactory[BehaviorModifier], - field_provider: _E[FieldProvider[TField] | FieldFactory[TField]] = ..., - object_handler: _E[ObjectHandler[TField]] = ..., -) -> _typing.Callable[[TCls], TCls]: + 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] = ..., +) -> 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. @@ -927,14 +1190,26 @@ def dataclass[TField: Field, 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 -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. @@ -945,9 +1220,17 @@ class DataclassMetaBase[TField: Field](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`. """ - __dataclass_fields__: _types.MappingProxyType[str, Field] + __dataclassbase_fields__: _types.MappingProxyType[str, Field] + __dataclassbase_initialized__: bool + __dataclassbase_modifiers__: tuple[BehaviorModifier, ...] + __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) @@ -957,7 +1240,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 +1249,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 +1263,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. @@ -989,21 +1272,56 @@ class DataclassMeta[TField: Field](DataclassMetaBase[TField]): 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. - __dataclass_fields__: _typing.ClassVar[_typing.Mapping[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 __init__(self, *args: _typing.Any, **kwargs: _typing.Any) -> None: + __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 @@ -1013,15 +1331,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 +1401,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 +1494,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: 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 @@ -1193,11 +1516,11 @@ def modify(self, cls: type[DataclassType]) -> None: 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/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_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 3aeabc0..0000000 --- a/tests/dataclassbase_tests/test_dataclassmeta.py +++ /dev/null @@ -1,229 +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): - field: int = 0 - - -def test_inherited_overlap_fields() -> None: - check_field(InheritedOverlap, 'field', int, 0) - 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) - - -def test_extra_fields_kw() -> None: - with _pytest.raises(TypeError): - ThreeFields(x=1, y='2', z=3.0, w=4) - - -def test_extra_fields_mixture() -> None: - with _pytest.raises(TypeError): - ThreeFields(1, 2, z=3.0, w=4) - - -def test_extra_fields_overlap() -> None: - with _pytest.raises(TypeError): - ThreeFields(1, '2', y='x') - - -def test_fields_overlap() -> None: - with _pytest.raises(TypeError): - Base(y='y', z='z') - - -# 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.attr = 1 - - assert hasattr(instance, 'attr') - - -def test_nonfield_deletion() -> None: - instance = ThreeFields(1) - - instance.attr = 1 - - del instance.attr - - with _pytest.raises(AttributeError): - _ = instance.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 - - check_field(FieldContainer, 'x', int) - - assert FieldContainer.__dataclass_fields__['x'] is field 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..09c9952 100644 --- a/tests/dataclassbase_tests/test_modifiers.py +++ b/tests/dataclassbase_tests/test_modifiers.py @@ -1,7 +1,20 @@ +import typing as _typing + import pytest as _pytest import dataclassbase as _dc +# initialization + + +def test_parameterless_decoration() -> None: + @_dc.dataclass + class SomeClass: + some_field: str + + SomeClass('') + + # Equatable @@ -37,6 +50,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