Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add kw_only to dataclass() and field(), and add KW_ONLY marker.
  • Loading branch information
ericvsmith committed Mar 17, 2021
commit 7e379c655b06c33766f39ef9a70ef19d1d4e8222
106 changes: 83 additions & 23 deletions Lib/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
'Field',
'FrozenInstanceError',
'InitVar',
'KW_ONLY',
'MISSING',

# Helper functions.
Expand Down Expand Up @@ -179,6 +180,12 @@ class _MISSING_TYPE:
pass
MISSING = _MISSING_TYPE()

# A sentinel object to indicate that following fields are keyword-only by
# default. Use a class to give it a better repr.
class _KW_ONLY_TYPE:
pass
KW_ONLY = _KW_ONLY_TYPE()

# Since most per-field metadata will be unused, create an empty
# read-only proxy that can be shared among all fields.
_EMPTY_METADATA = types.MappingProxyType({})
Expand Down Expand Up @@ -227,7 +234,6 @@ def __repr__(self):
def __class_getitem__(cls, type):
return InitVar(type)


# Instances of Field are only ever created from within this module,
# and only from the field() function, although Field instances are
# exposed externally as (conceptually) read-only objects.
Expand All @@ -248,11 +254,12 @@ class Field:
'init',
'compare',
'metadata',
'kw_only',
'_field_type', # Private: not to be used by user code.
)

def __init__(self, default, default_factory, init, repr, hash, compare,
metadata):
metadata, kw_only):
self.name = None
self.type = None
self.default = default
Expand All @@ -264,6 +271,7 @@ def __init__(self, default, default_factory, init, repr, hash, compare,
self.metadata = (_EMPTY_METADATA
if metadata is None else
types.MappingProxyType(metadata))
self.kw_only = kw_only
self._field_type = None

def __repr__(self):
Expand All @@ -277,6 +285,7 @@ def __repr__(self):
f'hash={self.hash!r},'
f'compare={self.compare!r},'
f'metadata={self.metadata!r},'
f'kw_only={self.kw_only!r},'
f'_field_type={self._field_type}'
')')

Expand Down Expand Up @@ -330,25 +339,35 @@ def __repr__(self):
# so that a type checker can be told (via overloads) that this is a
# function whose type depends on its parameters.
def field(*, default=MISSING, default_factory=MISSING, init=True, repr=True,
hash=None, compare=True, metadata=None):
hash=None, compare=True, metadata=None, kw_only=MISSING):
"""Return an object to identify dataclass fields.

default is the default value of the field. default_factory is a
0-argument function called to initialize a field's value. If init
is True, the field will be a parameter to the class's __init__()
function. If repr is True, the field will be included in the
object's repr(). If hash is True, the field will be included in
the object's hash(). If compare is True, the field will be used
in comparison functions. metadata, if specified, must be a
mapping which is stored but not otherwise examined by dataclass.
is true, the field will be a parameter to the class's __init__()
function. If repr is true, the field will be included in the
object's repr(). If hash is true, the field will be included in the
object's hash(). If compare is true, the field will be used in
comparison functions. metadata, if specified, must be a mapping
which is stored but not otherwise examined by dataclass. If kw_only
is true, the field will become a keyword-only parameter to
__init__().

It is an error to specify both default and default_factory.
"""

if default is not MISSING and default_factory is not MISSING:
raise ValueError('cannot specify both default and default_factory')
return Field(default, default_factory, init, repr, hash, compare,
metadata)
metadata, kw_only)


def _fields_in_init_order(fields):
# Returns the fields as __init__ will output them. It returns 2 tuples:
# the first for normal args, and the second for keyword args.
return (tuple(f for f in fields if f.init and not f.kw_only),
tuple(f for f in fields if f.init and f.kw_only)
)


def _tuple_str(obj_name, fields):
Expand Down Expand Up @@ -405,7 +424,6 @@ def _create_fn(name, args, body, *, globals=None, locals=None,

local_vars = ', '.join(locals.keys())
txt = f"def __create_fn__({local_vars}):\n{txt}\n return {name}"

ns = {}
exec(txt, globals, ns)
func = ns['__create_fn__'](**locals)
Expand Down Expand Up @@ -540,8 +558,16 @@ def _init_fn(fields, frozen, has_post_init, self_name, globals):
if not body_lines:
body_lines = ['pass']

arg_fields, kw_arg_fields = _fields_in_init_order(fields)
_init_params = [_init_param(f) for f in arg_fields]
if kw_arg_fields:
# Add the keyword-only args. Because the * can only be added if
# there's at least one keyword-only arg, there needs to be a test here
# (instead of just concatenting the lists together).
_init_params += ['*']
_init_params += [_init_param(f) for f in kw_arg_fields]
return _create_fn('__init__',
[self_name] + [_init_param(f) for f in fields if f.init],
[self_name] + _init_params,
body_lines,
locals=locals,
globals=globals,
Expand Down Expand Up @@ -620,6 +646,9 @@ def _is_initvar(a_type, dataclasses):
return (a_type is dataclasses.InitVar
or type(a_type) is dataclasses.InitVar)

def _is_kw_only(a_type, dataclasses):
return a_type is dataclasses.KW_ONLY


def _is_type(annotation, cls, a_module, a_type, is_type_predicate):
# Given a type annotation string, does it refer to a_type in
Expand Down Expand Up @@ -685,10 +714,11 @@ def _is_type(annotation, cls, a_module, a_type, is_type_predicate):
return False


def _get_field(cls, a_name, a_type):
# Return a Field object for this field name and type. ClassVars
# and InitVars are also returned, but marked as such (see
# f._field_type).
def _get_field(cls, a_name, a_type, default_kw_only):
# Return a Field object for this field name and type. ClassVars and
# InitVars are also returned, but marked as such (see f._field_type).
# default_kw_only is the value of kw_only to use if there isn't a field()
# that defines it.

# If the default value isn't derived from Field, then it's only a
# normal default value. Convert it to a Field().
Expand Down Expand Up @@ -759,6 +789,19 @@ def _get_field(cls, a_name, a_type):
# init=<not-the-default-init-value>)? It makes no sense for
# ClassVar and InitVar to specify init=<anything>.

# kw_only validation and assignment.
if f._field_type in (_FIELD, _FIELD_INITVAR):
# For real and InitVar fields, if kw_only wasn't specified use the
# default value.
if f.kw_only is MISSING:
f.kw_only = default_kw_only
else:
# Make sure kw_only isn't set for ClassVars
assert f._field_type is _FIELD_CLASSVAR
if f.kw_only is not MISSING:
raise TypeError(f'field {f.name} is a ClassVar but specifies '
'kw_only')

# For real fields, disallow mutable defaults for known types.
if f._field_type is _FIELD and isinstance(f.default, (list, dict, set)):
raise ValueError(f'mutable default {type(f.default)} for field '
Expand Down Expand Up @@ -830,7 +873,7 @@ def _hash_exception(cls, fields, globals):
# version of this table.


def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen):
def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen, kw_only):
# Now that dicts retain insertion order, there's no reason to use
# an ordered dict. I am leveraging that ordering here, because
# derived class fields overwrite base class fields, but the order
Expand Down Expand Up @@ -884,8 +927,22 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen):
# Now find fields in our class. While doing so, validate some
# things, and set the default values (as class attributes) where
# we can.
cls_fields = [_get_field(cls, name, type)
for name, type in cls_annotations.items()]
cls_fields = []
# Get a reference to this module for the _is_kw_only() test.
dataclasses = sys.modules[__name__]
for name, type in cls_annotations.items():
# See if this is a marker to change the value of kw_only.
if (_is_kw_only(type, dataclasses)
or (isinstance(type, str)
and _is_type(type, cls, dataclasses, dataclasses.KW_ONLY,
_is_kw_only))):
# Switch the default to kw_only=True, and ignore this
# annotation: it's not a real field.
kw_only = True
else:
# Otherwise it's a field of some type.
cls_fields.append(_get_field(cls, name, type, kw_only))

for f in cls_fields:
fields[f.name] = f

Expand Down Expand Up @@ -1017,15 +1074,16 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen):
str(inspect.signature(cls)).replace(' -> NoneType', ''))

if '__match_args__' not in cls.__dict__:
cls.__match_args__ = tuple(f.name for f in flds if f.init)
args, kw_args = _fields_in_init_order(flds)
cls.__match_args__ = tuple(f.name for f in args)

abc.update_abstractmethods(cls)

return cls


def dataclass(cls=None, /, *, init=True, repr=True, eq=True, order=False,
unsafe_hash=False, frozen=False):
unsafe_hash=False, frozen=False, kw_only=False):
"""Returns the same class as was passed in, with dunder methods
added based on the fields defined in the class.

Expand All @@ -1035,11 +1093,13 @@ def dataclass(cls=None, /, *, init=True, repr=True, eq=True, order=False,
repr is true, a __repr__() method is added. If order is true, rich
comparison dunder methods are added. If unsafe_hash is true, a
__hash__() method function is added. If frozen is true, fields may
not be assigned to after instance creation.
not be assigned to after instance creation. If kw_only is true,
then by default all fields are keyword-only.
"""

def wrap(cls):
return _process_class(cls, init, repr, eq, order, unsafe_hash, frozen)
return _process_class(cls, init, repr, eq, order, unsafe_hash,
frozen, kw_only)

# See if we're being called as @dataclass or @dataclass().
if cls is None:
Expand Down
Loading