]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-113878: Add `doc` parameter to `dataclasses.field` (gh-114051)
authorsobolevn <mail@sobolevn.me>
Fri, 27 Sep 2024 16:20:49 +0000 (19:20 +0300)
committerGitHub <noreply@github.com>
Fri, 27 Sep 2024 16:20:49 +0000 (12:20 -0400)
If using `slots=True`, the `doc` parameter ends up in the `__slots__` dict. The `doc` parameter is also in the corresponding `Field` object.

Doc/library/dataclasses.rst
Lib/dataclasses.py
Lib/test/test_dataclasses/__init__.py
Lib/test/test_pydoc/test_pydoc.py
Misc/NEWS.d/next/Library/2024-01-14-11-43-31.gh-issue-113878.dmEIN3.rst [new file with mode: 0644]

index 1457392ce6e86ca1074cea576f628c5f092ea6d0..51c1a427b63787292796fcb3fd41c6ef374da7e4 100644 (file)
@@ -231,7 +231,7 @@ Module contents
    follows a field with a default value.  This is true whether this
    occurs in a single class, or as a result of class inheritance.
 
-.. function:: field(*, default=MISSING, default_factory=MISSING, init=True, repr=True, hash=None, compare=True, metadata=None, kw_only=MISSING)
+.. function:: field(*, default=MISSING, default_factory=MISSING, init=True, repr=True, hash=None, compare=True, metadata=None, kw_only=MISSING, doc=None)
 
    For common and simple use cases, no other functionality is
    required.  There are, however, some dataclass features that
@@ -300,6 +300,10 @@ Module contents
 
     .. versionadded:: 3.10
 
+   - ``doc``: optional docstring for this field.
+
+    .. versionadded:: 3.13
+
    If the default value of a field is specified by a call to
    :func:`!field`, then the class attribute for this field will be
    replaced by the specified *default* value.  If *default* is not
index f5cb97edaf72cdb4f090ed5680b33401ee49a1ac..bdda7cc6c00f5d9c8e158760270e11c261c86123 100644 (file)
@@ -283,11 +283,12 @@ class Field:
                  'compare',
                  'metadata',
                  'kw_only',
+                 'doc',
                  '_field_type',  # Private: not to be used by user code.
                  )
 
     def __init__(self, default, default_factory, init, repr, hash, compare,
-                 metadata, kw_only):
+                 metadata, kw_only, doc):
         self.name = None
         self.type = None
         self.default = default
@@ -300,6 +301,7 @@ class Field:
                          if metadata is None else
                          types.MappingProxyType(metadata))
         self.kw_only = kw_only
+        self.doc = doc
         self._field_type = None
 
     @recursive_repr()
@@ -315,6 +317,7 @@ class Field:
                 f'compare={self.compare!r},'
                 f'metadata={self.metadata!r},'
                 f'kw_only={self.kw_only!r},'
+                f'doc={self.doc!r},'
                 f'_field_type={self._field_type}'
                 ')')
 
@@ -382,7 +385,7 @@ class _DataclassParams:
 # 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, kw_only=MISSING):
+          hash=None, compare=True, metadata=None, kw_only=MISSING, doc=None):
     """Return an object to identify dataclass fields.
 
     default is the default value of the field.  default_factory is a
@@ -394,7 +397,7 @@ def field(*, default=MISSING, default_factory=MISSING, init=True, repr=True,
     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__().
+    __init__().  doc is an optional docstring for this field.
 
     It is an error to specify both default and default_factory.
     """
@@ -402,7 +405,7 @@ def field(*, default=MISSING, default_factory=MISSING, init=True, repr=True,
     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, kw_only)
+                 metadata, kw_only, doc)
 
 
 def _fields_in_init_order(fields):
@@ -1174,7 +1177,7 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen,
     if weakref_slot and not slots:
         raise TypeError('weakref_slot is True but slots is False')
     if slots:
-        cls = _add_slots(cls, frozen, weakref_slot)
+        cls = _add_slots(cls, frozen, weakref_slot, fields)
 
     abc.update_abstractmethods(cls)
 
@@ -1239,7 +1242,32 @@ def _update_func_cell_for__class__(f, oldcls, newcls):
     return False
 
 
-def _add_slots(cls, is_frozen, weakref_slot):
+def _create_slots(defined_fields, inherited_slots, field_names, weakref_slot):
+    # The slots for our class.  Remove slots from our base classes.  Add
+    # '__weakref__' if weakref_slot was given, unless it is already present.
+    seen_docs = False
+    slots = {}
+    for slot in itertools.filterfalse(
+        inherited_slots.__contains__,
+        itertools.chain(
+            # gh-93521: '__weakref__' also needs to be filtered out if
+            # already present in inherited_slots
+            field_names, ('__weakref__',) if weakref_slot else ()
+        )
+    ):
+        doc = getattr(defined_fields.get(slot), 'doc', None)
+        if doc is not None:
+            seen_docs = True
+        slots.update({slot: doc})
+
+    # We only return dict if there's at least one doc member,
+    # otherwise we return tuple, which is the old default format.
+    if seen_docs:
+        return slots
+    return tuple(slots)
+
+
+def _add_slots(cls, is_frozen, weakref_slot, defined_fields):
     # Need to create a new class, since we can't set __slots__ after a
     # class has been created, and the @dataclass decorator is called
     # after the class is created.
@@ -1255,17 +1283,9 @@ def _add_slots(cls, is_frozen, weakref_slot):
     inherited_slots = set(
         itertools.chain.from_iterable(map(_get_slots, cls.__mro__[1:-1]))
     )
-    # The slots for our class.  Remove slots from our base classes.  Add
-    # '__weakref__' if weakref_slot was given, unless it is already present.
-    cls_dict["__slots__"] = tuple(
-        itertools.filterfalse(
-            inherited_slots.__contains__,
-            itertools.chain(
-                # gh-93521: '__weakref__' also needs to be filtered out if
-                # already present in inherited_slots
-                field_names, ('__weakref__',) if weakref_slot else ()
-            )
-        ),
+
+    cls_dict["__slots__"] = _create_slots(
+        defined_fields, inherited_slots, field_names, weakref_slot,
     )
 
     for field_name in field_names:
index bd2f87819a8eb08d175dda7acde058916752b2b6..2984f4261bd2c4d155d566e323b2e87b18c2c111 100644 (file)
@@ -61,7 +61,7 @@ class TestCase(unittest.TestCase):
                 x: int = field(default=1, default_factory=int)
 
     def test_field_repr(self):
-        int_field = field(default=1, init=True, repr=False)
+        int_field = field(default=1, init=True, repr=False, doc='Docstring')
         int_field.name = "id"
         repr_output = repr(int_field)
         expected_output = "Field(name='id',type=None," \
@@ -69,6 +69,7 @@ class TestCase(unittest.TestCase):
                            "init=True,repr=False,hash=None," \
                            "compare=True,metadata=mappingproxy({})," \
                            f"kw_only={MISSING!r}," \
+                           "doc='Docstring'," \
                            "_field_type=None)"
 
         self.assertEqual(repr_output, expected_output)
@@ -3304,7 +3305,7 @@ class TestSlots(unittest.TestCase):
             j: str
             h: str
 
-        self.assertEqual(Base.__slots__, ('y', ))
+        self.assertEqual(Base.__slots__, ('y',))
 
         @dataclass(slots=True)
         class Derived(Base):
@@ -3314,7 +3315,7 @@ class TestSlots(unittest.TestCase):
             k: str
             h: str
 
-        self.assertEqual(Derived.__slots__, ('z', ))
+        self.assertEqual(Derived.__slots__, ('z',))
 
         @dataclass
         class AnotherDerived(Base):
@@ -3322,6 +3323,24 @@ class TestSlots(unittest.TestCase):
 
         self.assertNotIn('__slots__', AnotherDerived.__dict__)
 
+    def test_slots_with_docs(self):
+        class Root:
+            __slots__ = {'x': 'x'}
+
+        @dataclass(slots=True)
+        class Base(Root):
+            y1: int = field(doc='y1')
+            y2: int
+
+        self.assertEqual(Base.__slots__, {'y1': 'y1', 'y2': None})
+
+        @dataclass(slots=True)
+        class Child(Base):
+            z1: int = field(doc='z1')
+            z2: int
+
+        self.assertEqual(Child.__slots__, {'z1': 'z1', 'z2': None})
+
     def test_cant_inherit_from_iterator_slots(self):
 
         class Root:
index 2dba077cdea6a735b4a65c8d6ab945943e657aa7..776e02f41a1cec3df40aad7e3d9aca11a8eea03d 100644 (file)
@@ -463,6 +463,14 @@ class PydocDocTest(unittest.TestCase):
         doc = pydoc.render_doc(BinaryInteger)
         self.assertIn('BinaryInteger.zero', doc)
 
+    def test_slotted_dataclass_with_field_docs(self):
+        import dataclasses
+        @dataclasses.dataclass(slots=True)
+        class My:
+            x: int = dataclasses.field(doc='Docstring for x')
+        doc = pydoc.render_doc(My)
+        self.assertIn('Docstring for x', doc)
+
     def test_mixed_case_module_names_are_lower_cased(self):
         # issue16484
         doc_link = get_pydoc_link(xml.etree.ElementTree)
diff --git a/Misc/NEWS.d/next/Library/2024-01-14-11-43-31.gh-issue-113878.dmEIN3.rst b/Misc/NEWS.d/next/Library/2024-01-14-11-43-31.gh-issue-113878.dmEIN3.rst
new file mode 100644 (file)
index 0000000..8e1937a
--- /dev/null
@@ -0,0 +1,9 @@
+Add *doc* parameter to :func:`dataclasses.field`, so it can be stored and
+shown as a documentation / metadata. If ``@dataclass(slots=True)`` is used,
+then the supplied string is availabl in the :attr:`~object.__slots__` dict.
+Otherwise, the supplied string is only available in the corresponding
+:class:`dataclasses.Field` object.
+
+In order to support this feature we are changing the ``__slots__`` format
+in dataclasses from :class:`tuple` to :class:`dict`
+when documentation / metadata is present.