Skip to content

Commit 9c7657f

Browse files
authored
gh-113878: Add doc parameter to dataclasses.field (gh-114051)
If using `slots=True`, the `doc` parameter ends up in the `__slots__` dict. The `doc` parameter is also in the corresponding `Field` object.
1 parent 0a3577b commit 9c7657f

File tree

5 files changed

+81
-21
lines changed

5 files changed

+81
-21
lines changed

Doc/library/dataclasses.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ Module contents
231231
follows a field with a default value. This is true whether this
232232
occurs in a single class, or as a result of class inheritance.
233233

234-
.. function:: field(*, default=MISSING, default_factory=MISSING, init=True, repr=True, hash=None, compare=True, metadata=None, kw_only=MISSING)
234+
.. function:: field(*, default=MISSING, default_factory=MISSING, init=True, repr=True, hash=None, compare=True, metadata=None, kw_only=MISSING, doc=None)
235235

236236
For common and simple use cases, no other functionality is
237237
required. There are, however, some dataclass features that
@@ -300,6 +300,10 @@ Module contents
300300

301301
.. versionadded:: 3.10
302302

303+
- ``doc``: optional docstring for this field.
304+
305+
.. versionadded:: 3.13
306+
303307
If the default value of a field is specified by a call to
304308
:func:`!field`, then the class attribute for this field will be
305309
replaced by the specified *default* value. If *default* is not

Lib/dataclasses.py

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,12 @@ class Field:
283283
'compare',
284284
'metadata',
285285
'kw_only',
286+
'doc',
286287
'_field_type', # Private: not to be used by user code.
287288
)
288289

289290
def __init__(self, default, default_factory, init, repr, hash, compare,
290-
metadata, kw_only):
291+
metadata, kw_only, doc):
291292
self.name = None
292293
self.type = None
293294
self.default = default
@@ -300,6 +301,7 @@ def __init__(self, default, default_factory, init, repr, hash, compare,
300301
if metadata is None else
301302
types.MappingProxyType(metadata))
302303
self.kw_only = kw_only
304+
self.doc = doc
303305
self._field_type = None
304306

305307
@recursive_repr()
@@ -315,6 +317,7 @@ def __repr__(self):
315317
f'compare={self.compare!r},'
316318
f'metadata={self.metadata!r},'
317319
f'kw_only={self.kw_only!r},'
320+
f'doc={self.doc!r},'
318321
f'_field_type={self._field_type}'
319322
')')
320323

@@ -382,7 +385,7 @@ def __repr__(self):
382385
# so that a type checker can be told (via overloads) that this is a
383386
# function whose type depends on its parameters.
384387
def field(*, default=MISSING, default_factory=MISSING, init=True, repr=True,
385-
hash=None, compare=True, metadata=None, kw_only=MISSING):
388+
hash=None, compare=True, metadata=None, kw_only=MISSING, doc=None):
386389
"""Return an object to identify dataclass fields.
387390
388391
default is the default value of the field. default_factory is a
@@ -394,15 +397,15 @@ def field(*, default=MISSING, default_factory=MISSING, init=True, repr=True,
394397
comparison functions. metadata, if specified, must be a mapping
395398
which is stored but not otherwise examined by dataclass. If kw_only
396399
is true, the field will become a keyword-only parameter to
397-
__init__().
400+
__init__(). doc is an optional docstring for this field.
398401
399402
It is an error to specify both default and default_factory.
400403
"""
401404

402405
if default is not MISSING and default_factory is not MISSING:
403406
raise ValueError('cannot specify both default and default_factory')
404407
return Field(default, default_factory, init, repr, hash, compare,
405-
metadata, kw_only)
408+
metadata, kw_only, doc)
406409

407410

408411
def _fields_in_init_order(fields):
@@ -1174,7 +1177,7 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen,
11741177
if weakref_slot and not slots:
11751178
raise TypeError('weakref_slot is True but slots is False')
11761179
if slots:
1177-
cls = _add_slots(cls, frozen, weakref_slot)
1180+
cls = _add_slots(cls, frozen, weakref_slot, fields)
11781181

11791182
abc.update_abstractmethods(cls)
11801183

@@ -1239,7 +1242,32 @@ def _update_func_cell_for__class__(f, oldcls, newcls):
12391242
return False
12401243

12411244

1242-
def _add_slots(cls, is_frozen, weakref_slot):
1245+
def _create_slots(defined_fields, inherited_slots, field_names, weakref_slot):
1246+
# The slots for our class. Remove slots from our base classes. Add
1247+
# '__weakref__' if weakref_slot was given, unless it is already present.
1248+
seen_docs = False
1249+
slots = {}
1250+
for slot in itertools.filterfalse(
1251+
inherited_slots.__contains__,
1252+
itertools.chain(
1253+
# gh-93521: '__weakref__' also needs to be filtered out if
1254+
# already present in inherited_slots
1255+
field_names, ('__weakref__',) if weakref_slot else ()
1256+
)
1257+
):
1258+
doc = getattr(defined_fields.get(slot), 'doc', None)
1259+
if doc is not None:
1260+
seen_docs = True
1261+
slots.update({slot: doc})
1262+
1263+
# We only return dict if there's at least one doc member,
1264+
# otherwise we return tuple, which is the old default format.
1265+
if seen_docs:
1266+
return slots
1267+
return tuple(slots)
1268+
1269+
1270+
def _add_slots(cls, is_frozen, weakref_slot, defined_fields):
12431271
# Need to create a new class, since we can't set __slots__ after a
12441272
# class has been created, and the @dataclass decorator is called
12451273
# after the class is created.
@@ -1255,17 +1283,9 @@ def _add_slots(cls, is_frozen, weakref_slot):
12551283
inherited_slots = set(
12561284
itertools.chain.from_iterable(map(_get_slots, cls.__mro__[1:-1]))
12571285
)
1258-
# The slots for our class. Remove slots from our base classes. Add
1259-
# '__weakref__' if weakref_slot was given, unless it is already present.
1260-
cls_dict["__slots__"] = tuple(
1261-
itertools.filterfalse(
1262-
inherited_slots.__contains__,
1263-
itertools.chain(
1264-
# gh-93521: '__weakref__' also needs to be filtered out if
1265-
# already present in inherited_slots
1266-
field_names, ('__weakref__',) if weakref_slot else ()
1267-
)
1268-
),
1286+
1287+
cls_dict["__slots__"] = _create_slots(
1288+
defined_fields, inherited_slots, field_names, weakref_slot,
12691289
)
12701290

12711291
for field_name in field_names:

Lib/test/test_dataclasses/__init__.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,15 @@ class C:
6161
x: int = field(default=1, default_factory=int)
6262

6363
def test_field_repr(self):
64-
int_field = field(default=1, init=True, repr=False)
64+
int_field = field(default=1, init=True, repr=False, doc='Docstring')
6565
int_field.name = "id"
6666
repr_output = repr(int_field)
6767
expected_output = "Field(name='id',type=None," \
6868
f"default=1,default_factory={MISSING!r}," \
6969
"init=True,repr=False,hash=None," \
7070
"compare=True,metadata=mappingproxy({})," \
7171
f"kw_only={MISSING!r}," \
72+
"doc='Docstring'," \
7273
"_field_type=None)"
7374

7475
self.assertEqual(repr_output, expected_output)
@@ -3304,7 +3305,7 @@ class Base(Root4):
33043305
j: str
33053306
h: str
33063307

3307-
self.assertEqual(Base.__slots__, ('y', ))
3308+
self.assertEqual(Base.__slots__, ('y',))
33083309

33093310
@dataclass(slots=True)
33103311
class Derived(Base):
@@ -3314,14 +3315,32 @@ class Derived(Base):
33143315
k: str
33153316
h: str
33163317

3317-
self.assertEqual(Derived.__slots__, ('z', ))
3318+
self.assertEqual(Derived.__slots__, ('z',))
33183319

33193320
@dataclass
33203321
class AnotherDerived(Base):
33213322
z: int
33223323

33233324
self.assertNotIn('__slots__', AnotherDerived.__dict__)
33243325

3326+
def test_slots_with_docs(self):
3327+
class Root:
3328+
__slots__ = {'x': 'x'}
3329+
3330+
@dataclass(slots=True)
3331+
class Base(Root):
3332+
y1: int = field(doc='y1')
3333+
y2: int
3334+
3335+
self.assertEqual(Base.__slots__, {'y1': 'y1', 'y2': None})
3336+
3337+
@dataclass(slots=True)
3338+
class Child(Base):
3339+
z1: int = field(doc='z1')
3340+
z2: int
3341+
3342+
self.assertEqual(Child.__slots__, {'z1': 'z1', 'z2': None})
3343+
33253344
def test_cant_inherit_from_iterator_slots(self):
33263345

33273346
class Root:

Lib/test/test_pydoc/test_pydoc.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,14 @@ class BinaryInteger(enum.IntEnum):
463463
doc = pydoc.render_doc(BinaryInteger)
464464
self.assertIn('BinaryInteger.zero', doc)
465465

466+
def test_slotted_dataclass_with_field_docs(self):
467+
import dataclasses
468+
@dataclasses.dataclass(slots=True)
469+
class My:
470+
x: int = dataclasses.field(doc='Docstring for x')
471+
doc = pydoc.render_doc(My)
472+
self.assertIn('Docstring for x', doc)
473+
466474
def test_mixed_case_module_names_are_lower_cased(self):
467475
# issue16484
468476
doc_link = get_pydoc_link(xml.etree.ElementTree)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Add *doc* parameter to :func:`dataclasses.field`, so it can be stored and
2+
shown as a documentation / metadata. If ``@dataclass(slots=True)`` is used,
3+
then the supplied string is availabl in the :attr:`~object.__slots__` dict.
4+
Otherwise, the supplied string is only available in the corresponding
5+
:class:`dataclasses.Field` object.
6+
7+
In order to support this feature we are changing the ``__slots__`` format
8+
in dataclasses from :class:`tuple` to :class:`dict`
9+
when documentation / metadata is present.

0 commit comments

Comments
 (0)