Support init customization on dataclass_transform source

This commit is contained in:
Eric Masseran
2025-05-05 02:02:17 +02:00
parent eb80dc08f3
commit d53a8ef81c
3 changed files with 279 additions and 97 deletions

View File

@@ -36,7 +36,7 @@ py__doc__() Returns the docstring for a value.
====================================== ======================================== ====================================== ========================================
""" """
from typing import List from typing import List, Optional, Tuple
from jedi import debug from jedi import debug
from jedi.parser_utils import get_cached_parent_scope, expr_is_dotted, \ from jedi.parser_utils import get_cached_parent_scope, expr_is_dotted, \
@@ -135,6 +135,27 @@ class ClassFilter(ParserTreeFilter):
return [name for name in names if self._access_possible(name)] return [name for name in names if self._access_possible(name)]
def init_param_value(arg_nodes) -> Optional[bool]:
"""
Returns:
- ``True`` if ``@dataclass(init=True)``
- ``False`` if ``@dataclass(init=False)``
- ``None`` if not specified ``@dataclass()``
"""
for arg_node in arg_nodes:
if (
arg_node.type == "argument"
and arg_node.children[0].value == "init"
):
if arg_node.children[2].value == "False":
return False
elif arg_node.children[2].value == "True":
return True
return None
def get_dataclass_param_names(cls) -> List["DataclassParamName"]: def get_dataclass_param_names(cls) -> List["DataclassParamName"]:
""" """
``cls`` is a :class:`ClassMixin`. The type is only documented as mypy would ``cls`` is a :class:`ClassMixin`. The type is only documented as mypy would
@@ -268,55 +289,69 @@ class ClassMixin:
assert x is not None assert x is not None
yield x yield x
def _has_dataclass_transform_metaclasses(self) -> bool: def _has_dataclass_transform_metaclasses(self) -> Tuple[bool, Optional[bool]]:
for meta in self.get_metaclasses(): # type: ignore[attr-defined] for meta in self.get_metaclasses(): # type: ignore[attr-defined]
if ( if (
# Not sure if necessary isinstance(meta, Decoratee)
(isinstance(meta, DataclassWrapper) and meta.should_generate_init) # Internal leakage :|
or ( and isinstance(meta._wrapped_value, DataclassTransformer)
isinstance(meta, Decoratee)
# Internal leakage :|
and isinstance(meta._wrapped_value, DataclassWrapper)
and meta._wrapped_value.should_generate_init
)
): ):
return True return True, meta._wrapped_value.init_mode_from_new
return False return False, None
def _get_dataclass_transform_signatures(self) -> List["DataclassSignature"]: def _get_dataclass_transform_signatures(self) -> List["DataclassSignature"]:
""" """
Returns: A non-empty list if the class has dataclass semantics else an Returns: A non-empty list if the class has dataclass semantics else an
empty list. empty list.
The dataclass-like semantics will be assumed for any class that directly
or indirectly derives from the decorated class or uses the decorated
class as a metaclass.
""" """
param_names = [] param_names = []
is_dataclass_transform_with_init = False is_dataclass_transform = False
default_init_mode: Optional[bool] = None
for cls in reversed(list(self.py__mro__())): for cls in reversed(list(self.py__mro__())):
if not is_dataclass_transform_with_init and ( if not is_dataclass_transform:
# If dataclass_transform is applied to a class, dataclass-like semantics # If dataclass_transform is applied to a class, dataclass-like semantics
# will be assumed for any class that directly or indirectly derives from # will be assumed for any class that directly or indirectly derives from
# the decorated class or uses the decorated class as a metaclass. # the decorated class or uses the decorated class as a metaclass.
(isinstance(cls, DataclassWrapper) and cls.should_generate_init) if (
or ( isinstance(cls, DataclassTransformer)
and cls.init_mode_from_init_subclass
):
is_dataclass_transform = True
default_init_mode = cls.init_mode_from_init_subclass
elif (
# Some object like CompiledValues would not be compatible # Some object like CompiledValues would not be compatible
isinstance(cls, ClassMixin) isinstance(cls, ClassMixin)
and cls._has_dataclass_transform_metaclasses() ):
) is_dataclass_transform, default_init_mode = (
): cls._has_dataclass_transform_metaclasses()
is_dataclass_transform_with_init = True )
# Attributes on the decorated class and its base classes are not # Attributes on the decorated class and its base classes are not
# considered to be fields. # considered to be fields.
continue if is_dataclass_transform:
continue
# All inherited classes behave like dataclass semantics # All inherited classes behave like dataclass semantics
if is_dataclass_transform_with_init and ( if (
isinstance(cls, ClassValue) and not cls._has_init_param_set_false() is_dataclass_transform
and isinstance(cls, ClassValue)
and (
cls.init_param_mode
or (cls.init_param_mode is None and default_init_mode)
)
): ):
param_names.extend( param_names.extend(
get_dataclass_param_names(cls) get_dataclass_param_names(cls)
) )
if is_dataclass_transform_with_init: if is_dataclass_transform:
return [DataclassSignature(cls, param_names)] return [DataclassSignature(cls, param_names)]
else: else:
return [] return []
@@ -482,61 +517,92 @@ class DataclassDecorator(ValueWrapper, FunctionMixin):
class B: ... class B: ...
""" """
def __init__(self, function, arguments): def __init__(self, function, arguments, default_init: bool = True):
""" """
Args: Args:
arguments: The parameters to the dataclass function decorator. function: Decoratee | function
arguments: The parameters to the dataclass function decorator
default_init: Boolean to indicate the default init value
""" """
super().__init__(function) super().__init__(function)
self.arguments = arguments argument_init = self._init_param_value(arguments)
self.init_param_mode = (
argument_init if argument_init is not None else default_init
)
@property def _init_param_value(self, arguments) -> Optional[bool]:
def has_init_param_set_false(self) -> bool: if not arguments.argument_node:
""" return None
Returns:
bool: ``True`` if ``@dataclass(init=False)``
"""
if not self.arguments.argument_node:
return False
arg_nodes = ( arg_nodes = (
self.arguments.argument_node.children arguments.argument_node.children
if self.arguments.argument_node.type == "arglist" if arguments.argument_node.type == "arglist"
else [self.arguments.argument_node] else [arguments.argument_node]
) )
for arg_node in arg_nodes:
if (
arg_node.type == "argument"
and arg_node.children[0].value == "init"
and arg_node.children[2].value == "False"
):
return True
return False return init_param_value(arg_nodes)
class DataclassTransformer(ValueWrapper, ClassMixin):
"""
A class with ``dataclass_transform`` applies. dataclass-like semantics will
be assumed for any class that directly or indirectly derives from the
decorated class or uses the decorated class as a metaclass. Attributes on
the decorated class and its base classes are not considered to be fields.
"""
def __init__(self, wrapped_value):
super().__init__(wrapped_value)
@property
def init_mode_from_new(self) -> bool:
"""Default value if missing is ``True``"""
new_methods = self._wrapped_value.py__getattribute__("__new__")
if not new_methods:
return True
new_method = list(new_methods)[0]
for param in new_method.get_param_names():
if (
param.string_name == "init"
and param.default_node
and param.default_node.type == "keyword"
):
if param.default_node.value == "False":
return False
elif param.default_node.value == "True":
return True
return True
@property
def init_mode_from_init_subclass(self) -> Optional[bool]:
# def __init_subclass__(cls) -> None: ... is hardcoded in the typeshed
# so the extra parameters can not be inferred.
return True
class DataclassWrapper(ValueWrapper, ClassMixin): class DataclassWrapper(ValueWrapper, ClassMixin):
""" """
A class with dataclass semantics. A class with dataclass semantics from a decorator. The init parameters are
only from the current class and parent classes decorated where the ``init``
parameter was ``True``.
.. code:: python .. code:: python
@dataclass @dataclass class A: ... # this
class A: ... # this
@dataclass_transform @dataclass_transform def create_model(): pass
def create_model(): pass
@create_model() @create_model() class B: ... # or this
class B: ... # or this
""" """
def __init__( def __init__(
self, wrapped_value, should_generate_init: bool, is_dataclass_transform: bool = False self, wrapped_value, should_generate_init: bool
): ):
super().__init__(wrapped_value) super().__init__(wrapped_value)
self.should_generate_init = should_generate_init self.should_generate_init = should_generate_init
self.is_dataclass_transform = is_dataclass_transform
def get_signatures(self): def get_signatures(self):
param_names = [] param_names = []
@@ -544,9 +610,6 @@ class DataclassWrapper(ValueWrapper, ClassMixin):
if ( if (
isinstance(cls, DataclassWrapper) isinstance(cls, DataclassWrapper)
and cls.should_generate_init and cls.should_generate_init
# Attributes on the decorated class and its base classes are not
# considered to be fields.
and not cls.is_dataclass_transform
): ):
param_names.extend(get_dataclass_param_names(cls)) param_names.extend(get_dataclass_param_names(cls))
return [DataclassSignature(cls, param_names)] return [DataclassSignature(cls, param_names)]
@@ -618,7 +681,8 @@ class ClassValue(ClassMixin, FunctionAndClassBase, metaclass=CachedMetaClass):
return values return values
return NO_VALUES return NO_VALUES
def _has_init_param_set_false(self) -> bool: @property
def init_param_mode(self) -> Optional[bool]:
""" """
It returns ``True`` if ``class X(init=False):`` else ``False``. It returns ``True`` if ``class X(init=False):`` else ``False``.
""" """
@@ -627,17 +691,9 @@ class ClassValue(ClassMixin, FunctionAndClassBase, metaclass=CachedMetaClass):
if bases_arguments.argument_node.type != "arglist": if bases_arguments.argument_node.type != "arglist":
# If it is not inheriting from the base model and having # If it is not inheriting from the base model and having
# extra parameters, then init behavior is not changed. # extra parameters, then init behavior is not changed.
return False return None
for arg in bases_arguments.argument_node.children: return init_param_value(bases_arguments.argument_node.children)
if (
arg.type == "argument"
and arg.children[0].value == "init"
and arg.children[2].value == "False"
):
return True
return False
@plugin_manager.decorate() @plugin_manager.decorate()
def get_metaclass_signatures(self, metaclasses): def get_metaclass_signatures(self, metaclasses):

View File

@@ -25,7 +25,11 @@ from jedi.inference.base_value import ContextualizedNode, \
NO_VALUES, ValueSet, ValueWrapper, LazyValueWrapper NO_VALUES, ValueSet, ValueWrapper, LazyValueWrapper
from jedi.inference.value import ClassValue, ModuleValue from jedi.inference.value import ClassValue, ModuleValue
from jedi.inference.value.decorator import Decoratee from jedi.inference.value.decorator import Decoratee
from jedi.inference.value.klass import DataclassWrapper, DataclassDecorator from jedi.inference.value.klass import (
DataclassWrapper,
DataclassDecorator,
DataclassTransformer,
)
from jedi.inference.value.function import FunctionMixin from jedi.inference.value.function import FunctionMixin
from jedi.inference.value import iterable from jedi.inference.value import iterable
from jedi.inference.lazy_value import LazyTreeValue, LazyKnownValue, \ from jedi.inference.lazy_value import LazyTreeValue, LazyKnownValue, \
@@ -592,9 +596,7 @@ def _random_choice(sequences):
def _dataclass(value, arguments, callback): def _dataclass(value, arguments, callback):
""" """
It supports dataclass, dataclass_transform and attrs. Decorator entry points for dataclass, dataclass_transform and attrs.
Entry points for the following cases:
1. dataclass-like decorator instantiation from a dataclass_transform decorator 1. dataclass-like decorator instantiation from a dataclass_transform decorator
2. dataclass_transform decorator declaration with parameters 2. dataclass_transform decorator declaration with parameters
@@ -603,15 +605,6 @@ def _dataclass(value, arguments, callback):
""" """
for c in _follow_param(value.inference_state, arguments, 0): for c in _follow_param(value.inference_state, arguments, 0):
if c.is_class(): if c.is_class():
# dataclass(-like) semantics on a class from a
# dataclass(-like) decorator
should_generate_init = (
# Customized decorator, init may be disabled
not value.has_init_param_set_false
if isinstance(value, DataclassDecorator)
# Bare dataclass decorator, always with init
else True
)
is_dataclass_transform = ( is_dataclass_transform = (
value.name.string_name == "dataclass_transform" value.name.string_name == "dataclass_transform"
@@ -623,26 +616,39 @@ def _dataclass(value, arguments, callback):
and not isinstance(value, DataclassDecorator) and not isinstance(value, DataclassDecorator)
) )
return ValueSet( if is_dataclass_transform:
[ # Declare base class
DataclassWrapper( return ValueSet([DataclassTransformer(c)])
c, else:
should_generate_init, # Declare dataclass(-like) semantics on a class from a
is_dataclass_transform, # dataclass(-like) decorator
) should_generate_init = (
] # Customized decorator, init may be disabled
) value.init_param_mode
if isinstance(value, DataclassDecorator)
# Bare dataclass decorator, always with init mode
else True
)
return ValueSet([DataclassWrapper(c, should_generate_init)])
elif c.is_function(): elif c.is_function():
# dataclass-like decorator instantiation: # dataclass-like decorator instantiation:
# @dataclass_transform # @dataclass_transform
# def create_model() # def create_model()
return ValueSet([value]) return ValueSet(
[
DataclassDecorator(
value,
arguments=arguments,
default_init=True,
)
]
)
elif ( elif (
# @dataclass(smth=...) # @dataclass(init=False)
value.name.string_name != "dataclass_transform" value.name.string_name != "dataclass_transform"
# @dataclass_transform # @dataclass_transform
# def create_model(): pass # def create_model(): pass
# @create_model(smth=...) # @create_model(init=...)
or isinstance(value, Decoratee) or isinstance(value, Decoratee)
): ):
# dataclass (or like) decorator customization # dataclass (or like) decorator customization
@@ -651,6 +657,11 @@ def _dataclass(value, arguments, callback):
DataclassDecorator( DataclassDecorator(
value, value,
arguments=arguments, arguments=arguments,
default_init=(
value._wrapped_value.init_param_mode
if isinstance(value, Decoratee)
else True
),
) )
] ]
) )

View File

@@ -534,6 +534,40 @@ dataclass_transform_cases = [
'''), ["toto"], False], '''), ["toto"], False],
# 4/ init=false # 4/ init=false
# Class based # Class based
# WARNING: Unsupported
# [dedent('''
# @dataclass_transform
# class Y():
# y: int
# z = 5
# def __init_subclass__(
# cls,
# *,
# init: bool = False,
# )
# class X(Y):'''), [], False],
[dedent('''
@dataclass_transform
class Y():
y: int
z = 5
def __init_subclass__(
cls,
*,
init: bool = False,
)
class X(Y, init=True):'''), [], True],
[dedent('''
@dataclass_transform
class Y():
y: int
z = 5
def __init_subclass__(
cls,
*,
init: bool = False,
)
class X(Y, init=False):'''), [], False],
[dedent(''' [dedent('''
@dataclass_transform @dataclass_transform
class Y(): class Y():
@@ -541,6 +575,24 @@ dataclass_transform_cases = [
z = 5 z = 5
class X(Y, init=False):'''), [], False], class X(Y, init=False):'''), [], False],
# Decorator based # Decorator based
[dedent('''
@dataclass_transform
def create_model(init=False):
pass
@create_model()
class X:'''), [], False],
[dedent('''
@dataclass_transform
def create_model(init=False):
pass
@create_model(init=True)
class X:'''), [], True],
[dedent('''
@dataclass_transform
def create_model(init=False):
pass
@create_model(init=False)
class X:'''), [], False],
[dedent(''' [dedent('''
@dataclass_transform @dataclass_transform
def create_model(): def create_model():
@@ -548,6 +600,60 @@ dataclass_transform_cases = [
@create_model(init=False) @create_model(init=False)
class X:'''), [], False], class X:'''), [], False],
# Metaclass based # Metaclass based
[dedent('''
@dataclass_transform
class ModelMeta():
y: int
z = 5
def __new__(
cls,
name,
bases,
namespace,
*,
init: bool = False,
):
...
class ModelBase(metaclass=ModelMeta):
t: int
p = 5
class X(ModelBase):'''), [], False],
[dedent('''
@dataclass_transform
class ModelMeta():
y: int
z = 5
def __new__(
cls,
name,
bases,
namespace,
*,
init: bool = False,
):
...
class ModelBase(metaclass=ModelMeta):
t: int
p = 5
class X(ModelBase, init=True):'''), [], True],
[dedent('''
@dataclass_transform
class ModelMeta():
y: int
z = 5
def __new__(
cls,
name,
bases,
namespace,
*,
init: bool = False,
):
...
class ModelBase(metaclass=ModelMeta):
t: int
p = 5
class X(ModelBase, init=False):'''), [], False],
[dedent(''' [dedent('''
@dataclass_transform @dataclass_transform
class ModelMeta(): class ModelMeta():
@@ -596,9 +702,18 @@ ids = [
"decorator_transformed_intermediate_not", "decorator_transformed_intermediate_not",
"metaclass_transformed", "metaclass_transformed",
"custom_init", "custom_init",
"base_transformed_init_false", # "base_transformed_init_false_dataclass_init_default",
"decorator_transformed_init_false", "base_transformed_init_false_dataclass_init_true",
"metaclass_transformed_init_false", "base_transformed_init_false_dataclass_init_false",
"base_transformed_init_default_dataclass_init_false",
"decorator_transformed_init_false_dataclass_init_default",
"decorator_transformed_init_false_dataclass_init_true",
"decorator_transformed_init_false_dataclass_init_false",
"decorator_transformed_init_default_dataclass_init_false",
"metaclass_transformed_init_false_dataclass_init_default",
"metaclass_transformed_init_false_dataclass_init_true",
"metaclass_transformed_init_false_dataclass_init_false",
"metaclass_transformed_init_default_dataclass_init_false",
"base_transformed_other_parameters", "base_transformed_other_parameters",
"decorator_transformed_other_parameters", "decorator_transformed_other_parameters",
"metaclass_transformed_other_parameters", "metaclass_transformed_other_parameters",