From 6a4b4fbcd343b06163ec6c49d7f05d96618c352a Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sun, 14 Jan 2024 11:44:00 +0300 Subject: [PATCH 1/3] gh-113878: Add `doc` parameter to `dataclasses.field` --- Doc/library/dataclasses.rst | 6 +++- Lib/dataclasses.py | 24 +++++++++------- Lib/test/test_dataclasses/__init__.py | 28 ++++++++++++++++--- Lib/test/test_pydoc.py | 8 ++++++ ...-01-14-11-43-31.gh-issue-113878.dmEIN3.rst | 2 ++ 5 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2024-01-14-11-43-31.gh-issue-113878.dmEIN3.rst diff --git a/Doc/library/dataclasses.rst b/Doc/library/dataclasses.rst index bbbbcb00d8fef83..23a0f36c116496e 100644 --- a/Doc/library/dataclasses.rst +++ b/Doc/library/dataclasses.rst @@ -223,7 +223,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 @@ -292,6 +292,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 no ``default`` is diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 2fba32b5ffbc1e2..35b7ddf44c9a142 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -303,11 +303,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 @@ -320,6 +321,7 @@ def __init__(self, default, default_factory, init, repr, hash, compare, if metadata is None else types.MappingProxyType(metadata)) self.kw_only = kw_only + self.doc = doc self._field_type = None @_recursive_repr @@ -335,6 +337,7 @@ def __repr__(self): 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}' ')') @@ -402,7 +405,7 @@ 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, 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 @@ -414,7 +417,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. """ @@ -422,7 +425,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): @@ -1156,7 +1159,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) @@ -1191,7 +1194,7 @@ def _get_slots(cls): raise TypeError(f"Slots of '{cls.__name__}' cannot be determined") -def _add_slots(cls, is_frozen, weakref_slot): +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. @@ -1208,16 +1211,17 @@ def _add_slots(cls, is_frozen, 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. - cls_dict["__slots__"] = tuple( - itertools.filterfalse( + cls_dict["__slots__"] = { + slot: getattr(defined_fields.get(slot), 'doc', None) + 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 () ) - ), - ) + ) + } for field_name in field_names: # Remove our attributes, if present. They'll still be diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index 272d427875ae407..78eead427b17a45 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -58,7 +58,7 @@ class C: 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," \ @@ -66,6 +66,7 @@ def test_field_repr(self): "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) @@ -3208,7 +3209,7 @@ class Base(Root4): j: str h: str - self.assertEqual(Base.__slots__, ('y', )) + self.assertEqual(Base.__slots__, {'y': None}) @dataclass(slots=True) class Derived(Base): @@ -3218,7 +3219,7 @@ class Derived(Base): k: str h: str - self.assertEqual(Derived.__slots__, ('z', )) + self.assertEqual(Derived.__slots__, {'z': None}) @dataclass class AnotherDerived(Base): @@ -3226,6 +3227,24 @@ class AnotherDerived(Base): 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: @@ -3266,7 +3285,8 @@ class FrozenWithoutSlotsClass: def test_frozen_pickle(self): # bpo-43999 - self.assertEqual(self.FrozenSlotsClass.__slots__, ("foo", "bar")) + self.assertEqual(self.FrozenSlotsClass.__slots__, + {"foo": None, "bar": None}) for proto in range(pickle.HIGHEST_PROTOCOL + 1): with self.subTest(proto=proto): obj = self.FrozenSlotsClass("a", 1) diff --git a/Lib/test/test_pydoc.py b/Lib/test/test_pydoc.py index 99b19d01783a10f..653fefd4704f8cc 100644 --- a/Lib/test/test_pydoc.py +++ b/Lib/test/test_pydoc.py @@ -444,6 +444,14 @@ class BinaryInteger(enum.IntEnum): 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 index 000000000000000..b867d755f852f86 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-01-14-11-43-31.gh-issue-113878.dmEIN3.rst @@ -0,0 +1,2 @@ +Add ``doc`` parameter to :func:`dataclasses.field`, so it can be stored and +shown as a documentation / metadata. From 4e641494640662089721224c1b9149643b37f084 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Fri, 27 Sep 2024 10:23:45 +0300 Subject: [PATCH 2/3] Now we only use dict for slots with metadata --- Lib/dataclasses.py | 42 +++++++++++++------ Lib/test/test_dataclasses/__init__.py | 37 ++++++++-------- ...-01-14-11-43-31.gh-issue-113878.dmEIN3.rst | 11 +++-- 3 files changed, 54 insertions(+), 36 deletions(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 3aacfc0d565d41c..bdda7cc6c00f5d9 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1242,6 +1242,31 @@ def _update_func_cell_for__class__(f, oldcls, newcls): return False +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 @@ -1258,19 +1283,10 @@ def _add_slots(cls, is_frozen, weakref_slot, defined_fields): 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__"] = { - slot: getattr(defined_fields.get(slot), 'doc', None) - 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 () - ) - ) - } + + cls_dict["__slots__"] = _create_slots( + defined_fields, inherited_slots, field_names, weakref_slot, + ) for field_name in field_names: # Remove our attributes, if present. They'll still be diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index 147752bccd16ba3..cb1777c648da962 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -3262,7 +3262,7 @@ class Base(Root4): j: str h: str - self.assertEqual(Base.__slots__, {'y': None}) + self.assertEqual(Base.__slots__, ('y',)) @dataclass(slots=True) class Derived(Base): @@ -3272,7 +3272,7 @@ class Derived(Base): k: str h: str - self.assertEqual(Derived.__slots__, {'z': None}) + self.assertEqual(Derived.__slots__, ('z',)) @dataclass class AnotherDerived(Base): @@ -3338,8 +3338,7 @@ class FrozenWithoutSlotsClass: def test_frozen_pickle(self): # bpo-43999 - self.assertEqual(self.FrozenSlotsClass.__slots__, - {"foo": None, "bar": None}) + self.assertEqual(self.FrozenSlotsClass.__slots__, ("foo", "bar")) for proto in range(pickle.HIGHEST_PROTOCOL + 1): with self.subTest(proto=proto): obj = self.FrozenSlotsClass("a", 1) @@ -3576,7 +3575,7 @@ class A: class B(A): pass - self.assertEqual(B.__slots__, {}) + self.assertEqual(B.__slots__, ()) B() def test_dataclass_derived_generic(self): @@ -3585,14 +3584,14 @@ def test_dataclass_derived_generic(self): @dataclass(slots=True, weakref_slot=True) class A(typing.Generic[T]): pass - self.assertEqual(A.__slots__, {'__weakref__': None}) + self.assertEqual(A.__slots__, ('__weakref__',)) self.assertTrue(A.__weakref__) A() @dataclass(slots=True, weakref_slot=True) class B[T2]: pass - self.assertEqual(B.__slots__, {'__weakref__': None}) + self.assertEqual(B.__slots__, ('__weakref__',)) self.assertTrue(B.__weakref__) B() @@ -3604,20 +3603,20 @@ class RawBase: ... @dataclass(slots=True, weakref_slot=True) class C1(typing.Generic[T], RawBase): pass - self.assertEqual(C1.__slots__, {}) + self.assertEqual(C1.__slots__, ()) self.assertTrue(C1.__weakref__) C1() @dataclass(slots=True, weakref_slot=True) class C2(RawBase, typing.Generic[T]): pass - self.assertEqual(C2.__slots__, {}) + self.assertEqual(C2.__slots__, ()) self.assertTrue(C2.__weakref__) C2() @dataclass(slots=True, weakref_slot=True) class D[T2](RawBase): pass - self.assertEqual(D.__slots__, {}) + self.assertEqual(D.__slots__, ()) self.assertTrue(D.__weakref__) D() @@ -3630,20 +3629,20 @@ class WithSlots: @dataclass(slots=True, weakref_slot=True) class E1(WithSlots, Generic[T]): pass - self.assertEqual(E1.__slots__, {'__weakref__': None}) + self.assertEqual(E1.__slots__, ('__weakref__',)) self.assertTrue(E1.__weakref__) E1() @dataclass(slots=True, weakref_slot=True) class E2(Generic[T], WithSlots): pass - self.assertEqual(E2.__slots__, {'__weakref__': None}) + self.assertEqual(E2.__slots__, ('__weakref__',)) self.assertTrue(E2.__weakref__) E2() @dataclass(slots=True, weakref_slot=True) class F[T2](WithSlots): pass - self.assertEqual(F.__slots__, {'__weakref__': None}) + self.assertEqual(F.__slots__, ('__weakref__',)) self.assertTrue(F.__weakref__) F() @@ -3656,20 +3655,20 @@ class WithWeakrefSlot: @dataclass(slots=True, weakref_slot=True) class G1(WithWeakrefSlot, Generic[T]): pass - self.assertEqual(G1.__slots__, {}) + self.assertEqual(G1.__slots__, ()) self.assertTrue(G1.__weakref__) G1() @dataclass(slots=True, weakref_slot=True) class G2(Generic[T], WithWeakrefSlot): pass - self.assertEqual(G2.__slots__, {}) + self.assertEqual(G2.__slots__, ()) self.assertTrue(G2.__weakref__) G2() @dataclass(slots=True, weakref_slot=True) class H[T2](WithWeakrefSlot): pass - self.assertEqual(H.__slots__, {}) + self.assertEqual(H.__slots__, ()) self.assertTrue(H.__weakref__) H() @@ -3680,7 +3679,7 @@ class WithDictSlot: @dataclass(slots=True) class A(WithDictSlot): ... - self.assertEqual(A.__slots__, {}) + self.assertEqual(A.__slots__, ()) self.assertEqual(A().__dict__, {}) A() @@ -3695,13 +3694,13 @@ def test_dataclass_slot_dict_ctype(self): class HasDictOffset(_testcapi.HeapCTypeWithDict): __dict__: dict = {} self.assertNotEqual(_testcapi.HeapCTypeWithDict.__dictoffset__, 0) - self.assertEqual(HasDictOffset.__slots__, {}) + self.assertEqual(HasDictOffset.__slots__, ()) @dataclass(slots=True) class DoesNotHaveDictOffset(_testcapi.HeapCTypeWithWeakref): __dict__: dict = {} self.assertEqual(_testcapi.HeapCTypeWithWeakref.__dictoffset__, 0) - self.assertEqual(DoesNotHaveDictOffset.__slots__, {'__dict__': None}) + self.assertEqual(DoesNotHaveDictOffset.__slots__, ('__dict__',)) @support.cpython_only def test_slots_with_wrong_init_subclass(self): 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 index b144bdd4d00525b..8c1d5579f2c394c 100644 --- 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 @@ -1,6 +1,9 @@ -Add ``doc`` parameter to :func:`dataclasses.field`, so it can be stored and -shown as a documentation / metadata. -It is only visible when ``@dataclass(slots=True)`` is used. +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 :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`. +in dataclasses from :class:`tuple` to :class:`dict` +when documentation / metadata is present. From f19edf7e7037e5b843fd2382fd83126746d68f55 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Fri, 27 Sep 2024 11:04:44 +0300 Subject: [PATCH 3/3] typo --- .../Library/2024-01-14-11-43-31.gh-issue-113878.dmEIN3.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 8c1d5579f2c394c..8e1937ab73c31b1 100644 --- 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 @@ -1,7 +1,7 @@ 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 :object:`__slots__` dict. Otherwise, -the supplied string is only available in the corresponding +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