From f2c96a468c314b4b02d46dfc1e348b642ec8806f Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Wed, 22 Mar 2023 21:16:21 +0000 Subject: [PATCH 1/6] 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 5f4dc25bfd7877..33077132f1c8bb 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(42), 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 82b08fc017884f..2ce3422895b4e0 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 46f33043c27071..14ea5dbad18d0a 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 00000000000000..a6e4d242d28ef3 --- /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. From 2961b837da536dea49c16c5022c51c77cee80e3e Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Thu, 23 Mar 2023 12:50:13 +0000 Subject: [PATCH 2/6] Improve coverage as per review --- Lib/test/test_dataclasses.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Lib/test/test_dataclasses.py b/Lib/test/test_dataclasses.py index 14ea5dbad18d0a..0de227d7984cd6 100644 --- a/Lib/test/test_dataclasses.py +++ b/Lib/test/test_dataclasses.py @@ -1504,6 +1504,11 @@ class A(types.GenericAlias): self.assertTrue(is_dataclass(a)) def test_DataclassLike(self): + # As an abstract base class for all dataclasses, + # it makes sense for DataclassLike to also be considered a dataclass + self.assertTrue(is_dataclass(DataclassLike)) + self.assertTrue(issubclass(DataclassLike, DataclassLike)) + with self.assertRaises(TypeError): DataclassLike() From adad18948d36ad90fe39d639e57b32f39fb576f5 Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Thu, 23 Mar 2023 13:24:21 +0000 Subject: [PATCH 3/6] Make it an actual dataclass rather than faking it --- Lib/dataclasses.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 2ce3422895b4e0..8f7c29e4412996 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1268,16 +1268,12 @@ def is_dataclass(obj): return hasattr(cls, _FIELDS) +@dataclass(init=False, repr=False, eq=False, match_args=False) 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, " From e5535a92e3a5868815cd5e781a624bb4ad252178 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Thu, 23 Mar 2023 15:01:48 +0000 Subject: [PATCH 4/6] use `is_dataclass` over `hasattr` Co-authored-by: Nikita Sobolev --- Lib/dataclasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 8f7c29e4412996..590b713606cf11 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1287,7 +1287,7 @@ def __new__(cls): @classmethod def __subclasshook__(cls, other): - return hasattr(other, _FIELDS) + return is_dataclass(other) def asdict(obj, *, dict_factory=dict): From 407d0ee00ec39f933c97c7658642df51263ae395 Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Thu, 23 Mar 2023 15:04:26 +0000 Subject: [PATCH 5/6] Simplify --- Lib/dataclasses.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 8f7c29e4412996..3b30eb4cbd43f2 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1285,9 +1285,7 @@ def __new__(cls): "dataclasses.DataclassLike is an abstract class that cannot be instantiated" ) - @classmethod - def __subclasshook__(cls, other): - return hasattr(other, _FIELDS) + __subclasshook__ = staticmethod(is_dataclass) def asdict(obj, *, dict_factory=dict): From c94362841270cbe236d700deaf410f0705d2c899 Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Thu, 23 Mar 2023 15:09:35 +0000 Subject: [PATCH 6/6] Simplify more --- Lib/dataclasses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 5402529f7fed2d..6001c070504c60 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1285,7 +1285,7 @@ def __new__(cls): "dataclasses.DataclassLike is an abstract class that cannot be instantiated" ) - __subclasshook__ = staticmethod(is_dataclass) + __subclasshook__ = is_dataclass def asdict(obj, *, dict_factory=dict):