From e6bf11877feeb7b1fbb372f5c4961ea08b493912 Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Wed, 22 Mar 2023 21:06:19 +0000 Subject: [PATCH] gh-102699: Add `dataclasses.DataclassLike` --- Doc/library/dataclasses.rst | 24 +++++++++ Lib/dataclasses.py | 27 ++++++++++ Lib/test/test_dataclasses.py | 52 +++++++++++++++++++ ...-03-22-21-05-34.gh-issue-102699.48uE4z.rst | 2 + 4 files changed, 105 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2023-03-22-21-05-34.gh-issue-102699.48uE4z.rst diff --git a/Doc/library/dataclasses.rst b/Doc/library/dataclasses.rst index 5f4dc25bfd78771..33f9a1a5f7ef3c0 100644 --- a/Doc/library/dataclasses.rst +++ b/Doc/library/dataclasses.rst @@ -468,6 +468,30 @@ Module contents def is_dataclass_instance(obj): return is_dataclass(obj) and not isinstance(obj, type) +.. class:: DataclassLike + + An abstract base class for all dataclasses. Mainly useful for type-checking. + + All classes created using the :func:`@dataclass ` decorator are + considered subclasses of this class; all dataclass instances are considered + instances of this class: + + >>> from dataclasses import dataclass, DataclassLike + >>> @dataclass + ... class Foo: + ... x: int + ... + >>> issubclass(Foo, DataclassLike) + True + >>> isinstance(Foo(), DataclassLike) + True + + ``DataclassLike`` is an abstract class that cannot be instantiated. It is + also a "final" class that cannot be subclassed: use the + :func:`@dataclass ` decorator to create new dataclasses. + + .. versionadded:: 3.12 + .. data:: MISSING A sentinel value signifying a missing default or default_factory. diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 82b08fc017884f5..2ce3422895b4e07 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -26,6 +26,7 @@ 'make_dataclass', 'replace', 'is_dataclass', + 'DataclassLike', ] # Conditions for adding methods. The boxes indicate what action the @@ -1267,6 +1268,32 @@ def is_dataclass(obj): return hasattr(cls, _FIELDS) +class DataclassLike(metaclass=abc.ABCMeta): + """Abstract base class for all dataclass types. + + Mainly useful for type-checking. + """ + # __dataclass_fields__ here is really an "abstract class variable", + # but there's no good way of expressing that at runtime, + # so just make it a regular class variable with a dummy value + __dataclass_fields__ = {} + + def __init_subclass__(cls): + raise TypeError( + "Use the @dataclass decorator to create dataclasses, " + "rather than subclassing dataclasses.DataclassLike" + ) + + def __new__(cls): + raise TypeError( + "dataclasses.DataclassLike is an abstract class that cannot be instantiated" + ) + + @classmethod + def __subclasshook__(cls, other): + return hasattr(other, _FIELDS) + + def asdict(obj, *, dict_factory=dict): """Return the fields of a dataclass instance as a new dictionary mapping field names to field values. diff --git a/Lib/test/test_dataclasses.py b/Lib/test/test_dataclasses.py index 46f33043c270714..14ea5dbad18d0a5 100644 --- a/Lib/test/test_dataclasses.py +++ b/Lib/test/test_dataclasses.py @@ -1503,6 +1503,58 @@ class A(types.GenericAlias): self.assertTrue(is_dataclass(type(a))) self.assertTrue(is_dataclass(a)) + def test_DataclassLike(self): + with self.assertRaises(TypeError): + DataclassLike() + + with self.assertRaises(TypeError): + class Foo(DataclassLike): pass + + @dataclass + class Dataclass: + x: int + + self.assertTrue(issubclass(Dataclass, DataclassLike)) + self.assertIsInstance(Dataclass(42), DataclassLike) + + with self.assertRaises(TypeError): + issubclass(Dataclass(42), DataclassLike) + + class NotADataclass: + def __init__(self): + self.x = 42 + + self.assertFalse(issubclass(NotADataclass, DataclassLike)) + self.assertNotIsInstance(NotADataclass(), DataclassLike) + + class NotADataclassButDataclassLike: + """A class from an outside library (attrs?) with dataclass-like behaviour""" + __dataclass_fields__ = {} + + self.assertTrue(issubclass(NotADataclassButDataclassLike, DataclassLike)) + self.assertIsInstance(NotADataclassButDataclassLike(), DataclassLike) + + class HasInstanceDataclassFieldsAttribute: + def __init__(self): + self.__dataclass_fields__ = {} + + self.assertFalse(issubclass(HasInstanceDataclassFieldsAttribute, DataclassLike)) + self.assertNotIsInstance(HasInstanceDataclassFieldsAttribute(), DataclassLike) + + class HasAllAttributes: + def __getattr__(self, name): + return {} + + self.assertFalse(issubclass(HasAllAttributes, DataclassLike)) + self.assertNotIsInstance(HasAllAttributes(), DataclassLike) + + @dataclass + class GenericAliasSubclass(types.GenericAlias): + origin: type + args: type + + self.assertTrue(issubclass(GenericAliasSubclass, DataclassLike)) + self.assertIsInstance(GenericAliasSubclass(int, str), DataclassLike) def test_helper_fields_with_class_instance(self): # Check that we can call fields() on either a class or instance, diff --git a/Misc/NEWS.d/next/Library/2023-03-22-21-05-34.gh-issue-102699.48uE4z.rst b/Misc/NEWS.d/next/Library/2023-03-22-21-05-34.gh-issue-102699.48uE4z.rst new file mode 100644 index 000000000000000..a6e4d242d28ef35 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-03-22-21-05-34.gh-issue-102699.48uE4z.rst @@ -0,0 +1,2 @@ +Add :class:`dataclasses.DataclassLike`, an abstract base class for all +dataclasses. Patch by Alex Waygood.