python-peps/pep-0681.rst

732 lines
26 KiB
ReStructuredText

PEP: 681
Title: Data Class Transforms
Author: Erik De Bonte <erikd at microsoft.com>,
Eric Traut <erictr at microsoft.com>
Sponsor: Jelle Zijlstra <jelle.zijlstra at gmail.com>
Discussions-To: https://mail.python.org/archives/list/typing-sig@python.org/thread/EAALIHA3XEDFDNG2NRXTI3ERFPAD65Z4/
Status: Draft
Type: Standards Track
Content-Type: text/x-rst
Created: 02-Dec-2021
Python-Version: 3.11
Post-History:
Abstract
========
:pep:`557` introduced the dataclass to the Python stdlib. Several popular
libraries have behaviors that are similar to dataclasses, but these
behaviors cannot be described using standard type annotations. Such
projects include attrs, pydantic, and object relational mapper (ORM)
packages such as SQLAlchemy and Django.
Most type checkers, linters and language servers have full support for
dataclasses. This proposal aims to generalize this functionality and
provide a way for third-party libraries to indicate that certain
decorator functions, classes, and metaclasses provide behaviors
similar to dataclasses.
These behaviors include:
* Synthesizing an ``__init__`` method based on declared
data fields.
* Optionally synthesizing ``__eq__``, ``__ne__``, ``__lt__``,
``__le__``, ``__gt__`` and ``__ge__`` methods.
* Supporting "frozen" classes, a way to enforce immutability during
static type checking.
* Supporting "field descriptors", which describe attributes of
individual fields that a static type checker must be aware of,
such as whether a default value is provided for the field.
The full behavior of the stdlib dataclass is described in the `Python
documentation <#dataclass-docs_>`_.
This proposal does not affect CPython directly except for the addition
of a ``dataclass_transform`` decorator in ``typing.py``.
Motivation
==========
There is no existing, standard way for libraries with dataclass-like
semantics to declare their behavior to type checkers. To work around
this limitation, Mypy custom plugins have been developed for many of
these libraries, but these plugins don't work with other type
checkers, linters or language servers. They are also costly to
maintain for library authors, and they require that Python developers
know about the existence of these plugins and download and configure
them within their environment.
Rationale
=========
The intent of this proposal is not to support every feature of every
library with dataclass-like semantics, but rather to make it possible
to use the most common features of these libraries in a way that is
compatible with static type checking. If a user values these libraries
and also values static type checking, they may need to avoid using
certain features or make small adjustments to the way they use them.
That's already true for the Mypy custom plugins, which
don't support every feature of every dataclass-like library.
As new features are added to dataclass in the future, we intend, when
appropriate, to add support for those features on
``dataclass_transform`` as well. Keeping these two feature sets in
sync will make it easier for dataclass users to understand and use
``dataclass_transform`` and will simplify the maintenance of dataclass
support in type checkers.
Specification
=============
The ``dataclass_transform`` decorator
-------------------------------------
This specification introduces a new decorator function in
the ``typing`` module named ``dataclass_transform``. This decorator
can be applied to either a function that is itself a decorator,
a class, or a metaclass. The presence of
``dataclass_transform`` tells a static type checker that the decorated
function, class, or metaclass performs runtime "magic" that transforms
a class, endowing it with dataclass-like behaviors.
If ``dataclass_transform`` is applied to a function, using the decorated
function as a decorator is assumed to apply dataclass-like semantics.
If ``dataclass_transform`` is applied to a class, dataclass-like
semantics will be assumed for any class that derives from the
decorated class or uses the decorated class as a metaclass.
Examples of each approach are shown in the following sections. Each
example creates a ``CustomerModel`` class with dataclass-like semantics.
The implementation of the decorated objects is omitted for brevity,
but we assume that they modify classes in the following ways:
* They synthesize an ``__init__`` method using data fields declared
within the class and its parent classes.
* They synthesize ``__eq__`` and ``__ne__`` methods.
Type checkers supporting this PEP will recognize that the
``CustomerModel`` class can be instantiated using the synthesized
``__init__`` method:
.. code-block:: python
# Using positional arguments
c1 = CustomerModel(327, "John Smith")
# Using keyword arguments
c2 = CustomerModel(id=327, name="John Smith")
# These calls will generate runtime errors and should be flagged as
# errors by a static type checker.
c3 = CustomerModel()
c4 = CustomerModel(327, first_name="John")
c5 = CustomerModel(327, "John Smith", 0)
Decorator function example
''''''''''''''''''''''''''
.. code-block:: python
_T = TypeVar("_T")
# The ``create_model`` decorator is defined by a library.
# This could be in a type stub or inline.
@typing.dataclass_transform()
def create_model(cls: Type[_T]) -> Type[_T]:
cls.__init__ = ...
cls.__eq__ = ...
cls.__ne__ = ...
return cls
# The ``create_model`` decorator can now be used to create new model
# classes, like this:
@create_model
class CustomerModel:
id: int
name: str
Class example
'''''''''''''
.. code-block:: python
# The ``ModelBase`` class is defined by a library. This could be in
# a type stub or inline.
@typing.dataclass_transform()
class ModelBase: ...
# The ``ModelBase`` class can now be used to create new model
# subclasses, like this:
class CustomerModel(ModelBase):
id: int
name: str
Metaclass example
'''''''''''''''''
.. code-block:: python
# The ``ModelMeta`` metaclass and ``ModelBase`` class are defined by
# a library. This could be in a type stub or inline.
@typing.dataclass_transform()
class ModelMeta(type): ...
class ModelBase(metaclass=ModelMeta): ...
# The ``ModelBase`` class can now be used to create new model
# subclasses, like this:
class CustomerModel(ModelBase):
id: int
name: str
Decorator function and class/metaclass parameters
-------------------------------------------------
A decorator function, class, or metaclass that provides dataclass-like
functionality may accept parameters that modify certain behaviors.
This specification defines the following parameters that static type
checkers must honor if they are used by a dataclass transform. Each of
these parameters accepts a bool argument, and it must be possible for
the bool value (``True`` or ``False``) to be statically evaluated.
* ``eq``. ``order``, ``frozen``, ``init`` and ``unsafe_hash`` are parameters
supported in the stdlib dataclass, with meanings defined in
:pep:`PEP 557 <557#id7>`.
* ``kw_only``, ``match_args`` and ``slots`` are parameters supported
in the stdlib dataclass, first introduced in Python 3.10.
``dataclass_transform`` parameters
----------------------------------
Parameters to ``dataclass_transform`` allow for some basic
customization of default behaviors:
.. code-block:: python
_T = TypeVar("_T")
def dataclass_transform(
*,
eq_default: bool = True,
order_default: bool = False,
kw_only_default: bool = False,
field_descriptors: tuple[type | Callable[..., Any], ...] = (),
) -> Callable[[_T], _T]: ...
* ``eq_default`` indicates whether the ``eq`` parameter is assumed to
be True or False if it is omitted by the caller. If not specified,
``eq_default`` will default to True (the default assumption for
dataclass).
* ``order_default`` indicates whether the ``order`` parameter is
assumed to be True or False if it is omitted by the caller. If not
specified, ``order_default`` will default to False (the default
assumption for dataclass).
* ``kw_only_default`` indicates whether the ``kw_only`` parameter is
assumed to be True or False if it is omitted by the caller. If not
specified, ``kw_only_default`` will default to False (the default
assumption for dataclass).
* ``field_descriptors`` specifies a static list of supported classes
that describe fields. Some libraries also supply functions to
allocate instances of field descriptors, and those functions may
also be specified in this tuple. If not specified,
``field_descriptors`` will default to an empty tuple (no field
descriptors supported). The standard dataclass behavior supports
only one type of field descriptor called ``Field`` plus a helper
function (``field``) that instantiates this class, so if we were
describing the stdlib dataclass behavior, we would provide the
tuple argument ``(dataclasses.Field, dataclasses.field)``.
The following sections provide additional examples showing how these
parameters are used.
Decorator function example
''''''''''''''''''''''''''
.. code-block:: python
# Indicate that the ``create_model`` function assumes keyword-only
# parameters for the synthesized ``__init__`` method unless it is
# invoked with ``kw_only=False``. It always synthesizes order-related
# methods and provides no way to override this behavior.
@typing.dataclass_transform(kw_only_default=True, order_default=True)
def create_model(
*,
frozen: bool = False,
kw_only: bool = True,
) -> Callable[[Type[_T]], Type[_T]]: ...
# Example of how this decorator would be used by code that imports
# from this library:
@create_model(frozen=True, kw_only=False)
class CustomerModel:
id: int
name: str
Class example
'''''''''''''
.. code-block:: python
# Indicate that classes that derive from this class default to
# synthesizing comparison methods.
@typing.dataclass_transform(eq_default=True, order_default=True)
class ModelBase:
def __init_subclass__(
cls,
*,
init: bool = True,
frozen: bool = False,
eq: bool = True,
order: bool = True,
):
...
# Example of how this class would be used by code that imports
# from this library:
class CustomerModel(
ModelBase,
init=False,
frozen=True,
eq=False,
order=False,
):
id: int
name: str
Metaclass example
'''''''''''''''''
.. code-block:: python
# Indicate that classes that use this metaclass default to
# synthesizing comparison methods.
@typing.dataclass_transform(eq_default=True, order_default=True)
class ModelMeta(type):
def __new__(
cls,
name,
bases,
namespace,
*,
init: bool = True,
frozen: bool = False,
eq: bool = True,
order: bool = True,
):
...
class ModelBase(metaclass=ModelMeta):
...
# Example of how this class would be used by code that imports
# from this library:
class CustomerModel(
ModelBase,
init=False,
frozen=True,
eq=False,
order=False,
):
id: int
name: str
Field descriptors
-----------------
Most libraries that support dataclass-like semantics provide one or
more "field descriptor" types that allow a class definition to provide
additional metadata about each field in the class. This metadata can
describe, for example, default values, or indicate whether the field
should be included in the synthesized ``__init__`` method.
Field descriptors can be omitted in cases where additional metadata is
not required:
.. code-block:: python
@dataclass
class Employee:
# Field with no descriptor
name: str
# Field that uses field descriptor class instance
age: Optional[int] = field(default=None, init=False)
# Field with type annotation and simple initializer to
# describe default value
is_paid_hourly: bool = True
# Not a field (but rather a class variable) because type
# annotation is not provided.
office_number = "unassigned"
Field descriptor parameters
'''''''''''''''''''''''''''
Libraries that support dataclass-like semantics and support field
descriptor classes typically use common parameter names to construct
these field descriptors. This specification formalizes the names and
meanings of the parameters that must be understood for static type
checkers. These standardized parameters must be keyword-only.
These parameters are a superset of those supported by
``dataclasses.field``, excluding those that do not have an impact on
type checking such as ``compare`` and ``hash``.
Field descriptor classes are allowed to use other
parameters in their constructors, and those parameters can be
positional and may use other names.
* ``init`` is an optional bool parameter that indicates whether the
field should be included in the synthesized ``__init__`` method. If
unspecified, ``init`` defaults to True. Field descriptor functions
can use overloads that implicitly specify the value of ``init``
using a literal bool value type
(``Literal[False]`` or ``Literal[True]``).
* ``default`` is an optional parameter that provides the default value
for the field.
* ``default_factory`` is an optional parameter that provides a runtime
callback that returns the default value for the field. If neither
``default`` nor ``default_factory`` are specified, the field is
assumed to have no default value and must be provided a value when
the class is instantiated.
* ``factory`` is an alias for ``default_factory``. Stdlib dataclasses
use the name ``default_factory``, but attrs uses the name ``factory``
in many scenarios, so this alias is necessary for supporting attrs.
* ``kw_only`` is an optional bool parameter that indicates whether the
field should be marked as keyword-only. If true, the field will be
keyword-only. If false, it will not be keyword-only. If unspecified,
the value of the ``kw_only`` parameter on the object decorated with
``dataclass_transform`` will be used, or if that is unspecified, the
value of ``kw_only_default`` on ``dataclass_transform`` will be used.
* ``alias`` is an optional str parameter that provides an alternative
name for the field. This alternative name is used in the synthesized
``__init__`` method.
It is an error to specify more than one of ``default``,
``default_factory`` and ``factory``.
This example demonstrates the above:
.. code-block:: python
# Library code (within type stub or inline)
# In this library, passing a resolver means that init must be False,
# and the overload with Literal[False] enforces that.
@overload
def model_field(
*,
default: Optional[Any] = ...,
resolver: Callable[[], Any],
init: Literal[False] = False,
) -> Any: ...
@overload
def model_field(
*,
default: Optional[Any] = ...,
resolver: None = None,
init: bool = True,
) -> Any: ...
@typing.dataclass_transform(
kw_only_default=True,
field_descriptors=(model_field, ))
def create_model(
*,
init: bool = True,
) -> Callable[[Type[_T]], Type[_T]]: ...
# Code that imports this library:
@create_model(init=False)
class CustomerModel:
id: int = model_field(resolver=lambda : 0)
name: str
Runtime behavior
----------------
At runtime, the ``dataclass_transform`` decorator's only effect is to
set a string attribute named ``__dataclass_transform__`` on the
decorated function or class to support introspection. The value of the
attribute should be a dict mapping the names of the
``dataclass_transform`` parameters to their values.
For example:
.. code-block:: python
{
"eq_default": True,
"order_default": False,
"kw_only_default": False,
"field_descriptors": (),
}
Dataclass semantics
-------------------
The following dataclass semantics are implied when a function or class
decorated with ``dataclass_transform`` is in use.
* Frozen dataclasses cannot inherit from non-frozen dataclasses. A
class that has been decorated with ``dataclass_transform`` is
considered neither frozen nor non-frozen, thus allowing frozen
classes to inherit from it. Similarly, a class that directly
specifies a metaclass that is decorated with ``dataclass_transform``
is considered neither frozen nor non-frozen.
Consider these class examples:
.. code-block:: python
# ModelBase is not considered either "frozen" or "non-frozen"
# because it is decorated with ``dataclass_transform``
@typing.dataclass_transform()
class ModelBase(): ...
# Vehicle is considered non-frozen because it does not specify
# "frozen=True".
class Vehicle(ModelBase):
name: str
# Car is a frozen class that derives from Vehicle, which is a
# non-frozen class. This is an error.
class Car(Vehicle, frozen=True):
wheel_count: int
And these similar metaclass examples:
.. code-block:: python
@typing.dataclass_transform()
class ModelMeta(type): ...
# ModelBase is not considered either "frozen" or "non-frozen"
# because it directly specifies ModelMeta as its metaclass.
class ModelBase(metaclass=ModelMeta): ...
# Vehicle is considered non-frozen because it does not specify
# "frozen=True".
class Vehicle(ModelBase):
name: str
# Car is a frozen class that derives from Vehicle, which is a
# non-frozen class. This is an error.
class Car(Vehicle, frozen=True):
wheel_count: int
* Field ordering and inheritance is assumed to follow the rules
specified in :pep:`557 <557#inheritance>`. This includes the effects of
overrides (redefining a field in a child class that has already been
defined in a parent class).
* :pep:`PEP 557 indicates <557#post-init-parameters>` that
all fields without default values must appear before
fields with default values. Although not explicitly
stated in PEP 557, this rule is ignored when ``init=False``, and
this specification likewise ignores this requirement in that
situation. Likewise, there is no need to enforce this ordering when
keyword-only parameters are used for ``__init__``, so the rule is
not enforced if ``kw_only`` semantics are in effect.
* As with dataclass, method synthesis is skipped if it would
overwrite a method that is explicitly declared within the class.
For example, if a class declares an ``__init__`` method explicitly,
an ``__init__`` method will not be synthesized for that class.
* KW_ONLY sentinel values are supported as described in `the Python
docs <#kw-only-docs_>`_ and `bpo-43532 <#kw-only-issue_>`_.
* ClassVar attributes are not considered dataclass fields and are
`ignored by dataclass mechanisms <#class-var_>`_.
Undefined behavior
------------------
If multiple ``dataclass_transform`` decorators are found, either on a
single function/class or within a class hierarchy, the resulting
behavior is undefined. Library authors should avoid these scenarios.
Reference Implementation
========================
`Pyright <#pyright_>`_ contains the reference implementation of type
checker support for ``dataclass_transform``. Pyright's
``dataClasses.ts`` `source file <#pyright-impl_>`_ would be a good
starting point for understanding the implementation.
The `attrs <#attrs-usage_>`_ and `pydantic <#pydantic-usage_>`_
libraries are using ``dataclass_transform`` and serve as real-world
examples of its usage.
Rejected Ideas
==============
``auto_attribs`` parameter
--------------------------
The attrs library supports an ``auto_attribs`` parameter that
indicates whether class members decorated with :pep:`526` variable
annotations but with no assignment should be treated as data fields.
We considered supporting ``auto_attribs`` and a corresponding
``auto_attribs_default`` parameter, but decided against this because it
is specific to attrs and appears to be a legacy behavior. Instead of
supporting this in the new standard, we recommend that the maintainers
of attrs move away from the legacy semantics and adopt
``auto_attribs`` behaviors by default.
Django does not support declaring fields using type annotations only,
so Django users who leverage ``dataclass_transform`` should be aware
that they should always supply assigned values.
``cmp`` parameter
-----------------
The attrs library supports a bool parameter ``cmp`` that is equivalent
to setting both ``eq`` and ``order`` to True. We chose not to support
a ``cmp`` parameter, since it only applies to attrs. Attrs users
should use the dataclass-standard ``eq`` and ``order`` parameter names
instead.
Automatic field name aliasing
-----------------------------
The attrs library performs `automatic aliasing <#attrs-aliasing_>`_ of
field names that start with a single underscore, stripping the
underscore from the name of the corresponding ``__init__`` parameter.
This proposal omits that behavior since it is specific to attrs. Users
can manually alias these fields using the ``alias`` parameter.
Alternate field ordering algorithms
-----------------------------------
The attrs library currently supports two approaches to ordering the
fields within a class:
* Dataclass order: The same ordering used by dataclasses. This is the
default behavior of the older APIs (e.g. ``attr.s``).
* Method Resolution Order (MRO): This is the default behavior of the
newer APIs (e.g. define, mutable, frozen). Older APIs (e.g. ``attr.s``)
can opt into this behavior by specifying ``collect_by_mro=True``.
The resulting field orderings can differ in certain diamond-shaped
multiple inheritance scenarios.
For simplicity, this proposal does not support any field ordering
other than that used by dataclasses.
Fields redeclared in subclasses
-------------------------------
The attrs library differs from stdlib dataclasses in how it
handles inherited fields that are redeclared in subclasses. The
dataclass specification preserves the original order, but attrs
defines a new order based on subclasses.
For simplicity, we chose to only support the dataclass behavior.
Users of attrs who rely on the attrs-specific ordering will not see
the expected order of parameters in the synthesized ``__init__``
method.
Django primary and foreign keys
-------------------------------
Django applies `additional logic for primary and foreign keys
<#django-ids_>`_. For example, it automatically adds an ``id`` field
(and ``__init__`` parameter) if there is no field designated as a
primary key.
As this is not broadly applicable to dataclass libraries, this
additional logic is not accommodated with this proposal, so
users of Django would need to explicitly declare the ``id`` field.
Class-wide default values
-------------------------
SQLAlchemy requested that we expose a way to specify that the default
value of all fields in the transformed class is None. It is typical
that all of their fields are optional, and None indicates that the
field is not set.
We chose not to support this feature, since it is specific to
SQLAlchemy. Users can manually set ``default=None`` on these fields
instead.
Descriptor-typed field support
------------------------------
We considered adding a boolean parameter on ``dataclass_transform``
to enable better support for fields with descriptor types, which is
common in SQLAlchemy. When enabled, the type of each parameter on the
synthesized ``__init__`` method corresponding to a descriptor-typed
field would be the type of the value parameter to the descriptor's
``__set__`` method rather than the descriptor type itself. Similarly,
when setting the field, the ``__set__`` value type would be expected.
And when getting the value of the field, its type would be expected to
match the return type of ``__get__``.
This idea was based on the belief that ``dataclass`` did not properly
support descriptor-typed fields. In fact it does, but type checkers
(at least mypy and pyright) did not reflect the runtime behavior which
led to our misunderstanding. For more details, see the
`Pyright bug <#pyright-descriptor-bug_>`__.
``converter`` field descriptor parameter
----------------------------------------
The attrs library supports a ``converter`` field descriptor parameter,
which is a ``Callable`` that is called by the generated
``__init__`` method to convert the supplied value to some other
desired value. This is tricky to support since the parameter type in
the synthesized ``__init__`` method needs to accept uncovered values,
but the resulting field is typed according to the output of the
converter.
Some aspects of this issue are detailed in a
`Pyright discussion <#converters_>`_.
There may be no good way to support this because there's not enough
information to derive the type of the input parameter. One possible
solution would be to add support for a ``converter`` field descriptor
parameter but then use the ``Any`` type for the corresponding
parameter in the ``__init__`` method.
We chose not to support this feature and recommend that attrs users
avoid converters.
References
==========
.. _#dataclass-docs: https://docs.python.org/3.11/library/dataclasses.html
.. _#pyright: https://github.com/Microsoft/pyright
.. _#pyright-impl: https://github.com/microsoft/pyright/blob/main/packages/pyright-internal/src/analyzer/dataClasses.ts
.. _#attrs-usage: https://github.com/python-attrs/attrs/pull/796
.. _#pydantic-usage: https://github.com/samuelcolvin/pydantic/pull/2721
.. _#attrs-aliasing: https://www.attrs.org/en/stable/init.html#private-attributes
.. _#django-ids: https://docs.djangoproject.com/en/4.0/topics/db/models/#automatic-primary-key-fields
.. _#converters: https://github.com/microsoft/pyright/discussions/1782?sort=old#discussioncomment-653909
.. _#kw-only-docs: https://docs.python.org/3/library/dataclasses.html#dataclasses.KW_ONLY
.. _#kw-only-issue: https://bugs.python.org/issue43532
.. _#class-var: https://docs.python.org/3/library/dataclasses.html#class-variables
.. _#pyright-descriptor-bug: https://github.com/microsoft/pyright/issues/3245
Copyright
=========
This document is placed in the public domain or under the
CC0-1.0-Universal license, whichever is more permissive.