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

bpo-34776: Fix dataclasses to support __future__ "annotations" mode #9518

Merged
merged 8 commits into from
Dec 9, 2019
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 18 additions & 8 deletions Lib/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,17 @@ def _create_fn(name, args, body, *, globals=None, locals=None,
locals['_return_type'] = return_type
return_annotation = '->_return_type'
args = ','.join(args)
body = '\n'.join(f' {b}' for b in body)
body = '\n'.join(f' {b}' for b in body)

# Compute the text of the entire function.
txt = f'def {name}({args}){return_annotation}:\n{body}'
txt = f' def {name}({args}){return_annotation}:\n{body}'

local_vars = ', '.join(locals.keys())
Copy link
Member Author

Choose a reason for hiding this comment

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

Note to self: add a comment explaining this.

txt = f"def __create_fn__({local_vars}):\n{txt}\n return {name}"

exec(txt, globals, locals)
return locals[name]
create_fn = locals.pop('__create_fn__')
return create_fn(**locals)


def _field_assign(frozen, name, value, self_name):
Expand Down Expand Up @@ -448,7 +452,7 @@ def _init_param(f):
return f'{f.name}:_type_{f.name}{default}'


def _init_fn(fields, frozen, has_post_init, self_name):
def _init_fn(fields, frozen, has_post_init, self_name, modname):
# fields contains both real fields and InitVar pseudo-fields.

# Make sure we don't have fields without defaults following fields
Expand All @@ -466,12 +470,18 @@ def _init_fn(fields, frozen, has_post_init, self_name):
raise TypeError(f'non-default argument {f.name!r} '
'follows default argument')

globals = {'MISSING': MISSING,
'_HAS_DEFAULT_FACTORY': _HAS_DEFAULT_FACTORY}
globals = sys.modules[modname].__dict__

locals = {f'_type_{f.name}': f.type for f in fields}
locals.update({
'MISSING': MISSING,
'_HAS_DEFAULT_FACTORY': _HAS_DEFAULT_FACTORY,
'__builtins__': builtins,
})

body_lines = []
for f in fields:
line = _field_init(f, frozen, globals, self_name)
line = _field_init(f, frozen, locals, self_name)
# line is None means that this field doesn't require
# initialization (it's a pseudo-field). Just skip it.
if line:
Expand All @@ -487,7 +497,6 @@ def _init_fn(fields, frozen, has_post_init, self_name):
if not body_lines:
body_lines = ['pass']

locals = {f'_type_{f.name}': f.type for f in fields}
return _create_fn('__init__',
[self_name] + [_init_param(f) for f in fields if f.init],
body_lines,
Expand Down Expand Up @@ -877,6 +886,7 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen):
# if possible.
'__dataclass_self__' if 'self' in fields
else 'self',
cls.__module__
))

# Get the fields as a list, and include only real fields. This is
Expand Down
12 changes: 12 additions & 0 deletions Lib/test/dataclass_textanno.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from __future__ import annotations

import dataclasses


class Foo:
pass


@dataclasses.dataclass
class Bar:
foo: Foo
12 changes: 12 additions & 0 deletions Lib/test/test_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import unittest
from unittest.mock import Mock
from typing import ClassVar, Any, List, Union, Tuple, Dict, Generic, TypeVar, Optional
from typing import get_type_hints
from collections import deque, OrderedDict, namedtuple
from functools import total_ordering

Expand Down Expand Up @@ -2882,6 +2883,17 @@ def test_classvar_module_level_import(self):
# won't exist on the instance.
self.assertNotIn('not_iv4', c.__dict__)

def test_text_annotations(self):
from test import dataclass_textanno

self.assertEqual(
get_type_hints(dataclass_textanno.Bar),
{'foo': dataclass_textanno.Foo})
self.assertEqual(
get_type_hints(dataclass_textanno.Bar.__init__),
{'foo': dataclass_textanno.Foo,
'return': type(None)})


class TestMakeDataclass(unittest.TestCase):
def test_simple(self):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix dataclasses to support __future__ "annotations" mode
Copy link

@Vlad-Shcherbina Vlad-Shcherbina Jan 15, 2019

Choose a reason for hiding this comment

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

Strictly speaking, this problem is not directly related to __future__ annotations.
It's about postponed evaluation of type annotations in general, including forward references in pre-PEP-563 annotations.
Consider rewording along the lines of "Fix dataclasses to support forward references in type annotations".

Here is an example that does not use PEP 563:

from typing import get_type_hints
from dataclasses import dataclass

class T:
    pass

@dataclass()
class C2:
    x: 'T'

print(get_type_hints(C2.__init__))

Before your change:

Traceback (most recent call last):
  File ".\zzz.py", line 11, in <module>
    print(get_type_hints(C2.__init__))
  File "C:\Python37\lib\typing.py", line 1001, in get_type_hints
    value = _eval_type(value, globalns, localns)
  File "C:\Python37\lib\typing.py", line 260, in _eval_type
    return t._evaluate(globalns, localns)
  File "C:\Python37\lib\typing.py", line 464, in _evaluate
    eval(self.__forward_code__, globalns, localns),
  File "<string>", line 1, in <module>
NameError: name 'T' is not defined

After your change it works as expected:

{'x': <class '__main__.T'>, 'return': <class 'NoneType'>}