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

[dataclass_transform] minimal implementation of dataclass_transform #14523

Merged
Merged
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
10 changes: 9 additions & 1 deletion mypy/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,13 @@ def accept(self, visitor: StatementVisitor[T]) -> T:
return visitor.visit_import_all(self)


FUNCBASE_FLAGS: Final = ["is_property", "is_class", "is_static", "is_final"]
FUNCBASE_FLAGS: Final = [
"is_property",
"is_class",
"is_static",
"is_final",
"is_dataclass_transform",
]


class FuncBase(Node):
Expand All @@ -506,6 +512,7 @@ class FuncBase(Node):
"is_static", # Uses "@staticmethod"
"is_final", # Uses "@final"
"_fullname",
"is_dataclass_transform", # Is decorated with "@typing.dataclass_transform" or similar
)

def __init__(self) -> None:
Expand All @@ -524,6 +531,7 @@ def __init__(self) -> None:
self.is_final = False
# Name with module prefix
self._fullname = ""
self.is_dataclass_transform = False

@property
@abstractmethod
Expand Down
2 changes: 1 addition & 1 deletion mypy/plugins/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
Var,
)
from mypy.plugin import CheckerPluginInterface, ClassDefContext, SemanticAnalyzerPluginInterface
from mypy.semanal import ALLOW_INCOMPATIBLE_OVERRIDE, set_callable_name
from mypy.semanal_shared import ALLOW_INCOMPATIBLE_OVERRIDE, set_callable_name
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this move was needed to avoid a cyclic dependency between semanal.py and plugins/common.py

from mypy.typeops import ( # noqa: F401 # Part of public API
try_getting_str_literals as try_getting_str_literals,
)
Expand Down
23 changes: 19 additions & 4 deletions mypy/semanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@
Plugin,
SemanticAnalyzerPluginInterface,
)
from mypy.plugins import dataclasses as dataclasses_plugin
from mypy.reachability import (
ALWAYS_FALSE,
ALWAYS_TRUE,
Expand All @@ -208,6 +209,7 @@
from mypy.semanal_namedtuple import NamedTupleAnalyzer
from mypy.semanal_newtype import NewTypeAnalyzer
from mypy.semanal_shared import (
ALLOW_INCOMPATIBLE_OVERRIDE,
PRIORITY_FALLBACKS,
SemanticAnalyzerInterface,
calculate_tuple_fallback,
Expand All @@ -234,6 +236,7 @@
from mypy.typeops import function_type, get_type_vars
from mypy.types import (
ASSERT_TYPE_NAMES,
DATACLASS_TRANSFORM_NAMES,
FINAL_DECORATOR_NAMES,
FINAL_TYPE_NAMES,
NEVER_NAMES,
Expand Down Expand Up @@ -304,10 +307,6 @@
# available very early on.
CORE_BUILTIN_CLASSES: Final = ["object", "bool", "function"]

# Subclasses can override these Var attributes with incompatible types. This can also be
# set for individual attributes using 'allow_incompatible_override' of Var.
ALLOW_INCOMPATIBLE_OVERRIDE: Final = ("__slots__", "__deletable__", "__match_args__")


# Used for tracking incomplete references
Tag: _TypeAlias = int
Expand Down Expand Up @@ -1505,6 +1504,10 @@ def visit_decorator(self, dec: Decorator) -> None:
removed.append(i)
else:
self.fail("@final cannot be used with non-method functions", d)
elif isinstance(d, CallExpr) and refers_to_fullname(
d.callee, DATACLASS_TRANSFORM_NAMES
):
dec.func.is_dataclass_transform = True
elif not dec.var.is_property:
# We have seen a "non-trivial" decorator before seeing @property, if
# we will see a @property later, give an error, as we don't support this.
Expand Down Expand Up @@ -1706,6 +1709,11 @@ def apply_class_plugin_hooks(self, defn: ClassDef) -> None:
decorator_name = self.get_fullname_for_hook(decorator)
if decorator_name:
hook = self.plugin.get_class_decorator_hook(decorator_name)
# Special case: if the decorator is itself decorated with
# typing.dataclass_transform, apply the hook for the dataclasses plugin
# TODO: remove special casing here
if hook is None and is_dataclass_transform_decorator(decorator):
hook = dataclasses_plugin.dataclass_tag_callback
if hook:
hook(ClassDefContext(defn, decorator, self))

Expand Down Expand Up @@ -6596,3 +6604,10 @@ def halt(self, reason: str = ...) -> NoReturn:
return isinstance(stmt, PassStmt) or (
isinstance(stmt, ExpressionStmt) and isinstance(stmt.expr, EllipsisExpr)
)


def is_dataclass_transform_decorator(node: Node | None) -> bool:
if isinstance(node, RefExpr):
return is_dataclass_transform_decorator(node.node)

return isinstance(node, Decorator) and node.func.is_dataclass_transform
14 changes: 12 additions & 2 deletions mypy/semanal_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@
from mypy.nodes import Decorator, FuncDef, MypyFile, OverloadedFuncDef, TypeInfo, Var
from mypy.options import Options
from mypy.plugin import ClassDefContext
from mypy.plugins import dataclasses as dataclasses_plugin
from mypy.semanal import (
SemanticAnalyzer,
apply_semantic_analyzer_patches,
is_dataclass_transform_decorator,
remove_imported_names_from_symtable,
)
from mypy.semanal_classprop import (
Expand Down Expand Up @@ -450,11 +452,19 @@ def apply_hooks_to_class(
ok = True
for decorator in defn.decorators:
with self.file_context(file_node, options, info):
hook = None

decorator_name = self.get_fullname_for_hook(decorator)
if decorator_name:
hook = self.plugin.get_class_decorator_hook_2(decorator_name)
if hook:
ok = ok and hook(ClassDefContext(defn, decorator, self))
# Special case: if the decorator is itself decorated with
# typing.dataclass_transform, apply the hook for the dataclasses plugin
# TODO: remove special casing here
if hook is None and is_dataclass_transform_decorator(decorator):
hook = dataclasses_plugin.dataclass_class_maker_callback

if hook:
ok = ok and hook(ClassDefContext(defn, decorator, self))
return ok


Expand Down
5 changes: 5 additions & 0 deletions mypy/semanal_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@
get_proper_type,
)

# Subclasses can override these Var attributes with incompatible types. This can also be
# set for individual attributes using 'allow_incompatible_override' of Var.
ALLOW_INCOMPATIBLE_OVERRIDE: Final = ("__slots__", "__deletable__", "__match_args__")


# Priorities for ordering of patches within the "patch" phase of semantic analysis
# (after the main pass):

Expand Down
5 changes: 5 additions & 0 deletions mypy/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@
"typing_extensions.Never",
)

DATACLASS_TRANSFORM_NAMES: Final = (
"typing.dataclass_transform",
"typing_extensions.dataclass_transform",
)

# A placeholder used for Bogus[...] parameters
_dummy: Final[Any] = object()

Expand Down
46 changes: 46 additions & 0 deletions test-data/unit/check-dataclass-transform.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
[case testDataclassTransformReusesDataclassLogic]
# flags: --python-version 3.7
from typing import dataclass_transform, Type

@dataclass_transform()
def my_dataclass(cls: Type) -> Type:
return cls

@my_dataclass
class Person:
name: str
age: int

def summary(self):
return "%s is %d years old." % (self.name, self.age)

reveal_type(Person) # N: Revealed type is "def (name: builtins.str, age: builtins.int) -> __main__.Person"
Person('John', 32)
Person('Jonh', 21, None) # E: Too many arguments for "Person"

[typing fixtures/typing-medium.pyi]
[builtins fixtures/dataclasses.pyi]

[case testDataclassTransformIsFoundInTypingExtensions]
# flags: --python-version 3.7
from typing import Type
from typing_extensions import dataclass_transform

@dataclass_transform()
def my_dataclass(cls: Type) -> Type:
return cls

@my_dataclass
class Person:
name: str
age: int

def summary(self):
return "%s is %d years old." % (self.name, self.age)

reveal_type(Person) # N: Revealed type is "def (name: builtins.str, age: builtins.int) -> __main__.Person"
Person('John', 32)
Person('Jonh', 21, None) # E: Too many arguments for "Person"

[typing fixtures/typing-full.pyi]
[builtins fixtures/dataclasses.pyi]
6 changes: 5 additions & 1 deletion test-data/unit/fixtures/dataclasses.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ class dict(Mapping[KT, VT]):
def get(self, k: KT, default: Union[KT, _T]) -> Union[VT, _T]: pass
def __len__(self) -> int: ...

class list(Generic[_T], Sequence[_T]): pass
class list(Generic[_T], Sequence[_T]):
def __contains__(self, item: object) -> int: pass
def __getitem__(self, key: int) -> _T: pass
def __iter__(self) -> Iterator[_T]: pass

Comment on lines +40 to +44
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

needed to use dataclasses.pyi and typing-full.pyi at the same time in a test case

class function: pass
class classmethod: pass
property = object()
2 changes: 2 additions & 0 deletions test-data/unit/fixtures/typing-medium.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,5 @@ class ContextManager(Generic[T]):
class _SpecialForm: pass

TYPE_CHECKING = 1

def dataclass_transform() -> Callable[[T], T]: ...
2 changes: 2 additions & 0 deletions test-data/unit/lib-stub/typing_extensions.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,5 @@ class _TypedDict(Mapping[str, object]):
def TypedDict(typename: str, fields: Dict[str, Type[_T]], *, total: Any = ...) -> Type[dict]: ...

def reveal_type(__obj: T) -> T: pass

def dataclass_transform() -> Callable[[T], T]: ...