Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-82129: Provide __annotate__ method for dataclasses from make_dataclass #122262

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions Lib/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ def __repr__(self):
property,
})

# Any marker is used in `make_dataclass` to mark unannotated fields as `Any`
# without importing `typing` module.
_ANY_MARKER = object()


class InitVar:
__slots__ = ('type', )
Expand Down Expand Up @@ -1530,7 +1534,7 @@ class C(Base):
for item in fields:
if isinstance(item, str):
name = item
tp = 'typing.Any'
tp = _ANY_MARKER
elif len(item) == 2:
name, tp, = item
elif len(item) == 3:
Expand All @@ -1549,11 +1553,29 @@ class C(Base):
seen.add(name)
annotations[name] = tp

def annotate_method(format):
typing = sys.modules.get("typing")
if typing is None and format == annotationlib.Format.FORWARDREF:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also avoid importing typing for the SOURCE format here I think; is that worth it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure we can. I need _convert_to_source, there can be complex annotations that should be formatted properly. I will open a new issue about converting annotations to string with public API though. Right now I don't see a clear way.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opened #124412 for that.

typing_any = annotationlib.ForwardRef("Any", module="typing")
return {
ann: typing_any if t is _ANY_MARKER else t
for ann, t in annotations.items()
}

from typing import Any, _convert_to_source
ann_dict = {
ann: Any if t is _ANY_MARKER else t
for ann, t in annotations.items()
}
if format == annotationlib.Format.SOURCE:
return _convert_to_source(ann_dict)
return ann_dict

sobolevn marked this conversation as resolved.
Show resolved Hide resolved
# Update 'ns' with the user-supplied namespace plus our calculated values.
def exec_body_callback(ns):
ns['__annotate__'] = annotate_method
ns.update(namespace)
ns.update(defaults)
ns['__annotations__'] = annotations

# We use `types.new_class()` instead of simply `type()` to allow dynamic creation
# of generic dataclasses.
Expand Down
47 changes: 41 additions & 6 deletions Lib/test/test_dataclasses/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from dataclasses import *

import abc
import annotationlib
import io
import pickle
import inspect
Expand All @@ -23,6 +24,7 @@
import dataclasses # Needed for the string "dataclasses.InitVar[int]" to work as an annotation.

from test import support
from test.support import import_helper

# Just any custom exception we can catch.
class CustomError(Exception): pass
Expand Down Expand Up @@ -3667,7 +3669,6 @@ class A(WithDictSlot): ...
@support.cpython_only
def test_dataclass_slot_dict_ctype(self):
# https://github.com/python/cpython/issues/123935
from test.support import import_helper
# Skips test if `_testcapi` is not present:
_testcapi = import_helper.import_module('_testcapi')

Expand Down Expand Up @@ -4159,16 +4160,50 @@ def test_no_types(self):
C = make_dataclass('Point', ['x', 'y', 'z'])
c = C(1, 2, 3)
self.assertEqual(vars(c), {'x': 1, 'y': 2, 'z': 3})
self.assertEqual(C.__annotations__, {'x': 'typing.Any',
'y': 'typing.Any',
'z': 'typing.Any'})
self.assertEqual(C.__annotations__, {'x': typing.Any,
'y': typing.Any,
'z': typing.Any})

C = make_dataclass('Point', ['x', ('y', int), 'z'])
c = C(1, 2, 3)
self.assertEqual(vars(c), {'x': 1, 'y': 2, 'z': 3})
self.assertEqual(C.__annotations__, {'x': 'typing.Any',
self.assertEqual(C.__annotations__, {'x': typing.Any,
'y': int,
'z': 'typing.Any'})
'z': typing.Any})

def test_no_types_get_annotations(self):
C = make_dataclass('C', ['x', ('y', int), 'z'])

self.assertEqual(
annotationlib.get_annotations(C, format=annotationlib.Format.VALUE),
{'x': typing.Any, 'y': int, 'z': typing.Any},
)
self.assertEqual(
annotationlib.get_annotations(
C, format=annotationlib.Format.FORWARDREF),
{'x': typing.Any, 'y': int, 'z': typing.Any},
)
self.assertEqual(
annotationlib.get_annotations(
C, format=annotationlib.Format.SOURCE),
{'x': 'typing.Any', 'y': 'int', 'z': 'typing.Any'},
)

def test_no_types_no_typing_import(self):
import sys

C = make_dataclass('C', ['x', ('y', int)])

with import_helper.CleanImport('typing'):
self.assertEqual(
annotationlib.get_annotations(
C, format=annotationlib.Format.FORWARDREF),
{
'x': annotationlib.ForwardRef('Any', module='typing'),
'y': int,
},
)
self.assertNotIn('typing', sys.modules)

def test_module_attr(self):
self.assertEqual(ByMakeDataClass.__module__, __name__)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fix :exc:`NameError` when calling :func:`typing.get_type_hints` on a :func:`dataclasses.dataclass` created by
:func:`dataclasses.make_dataclass` with un-annotated fields.
Loading