From d866ec0f80e45bb713f8a8558e0e30b614aede17 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Sat, 15 Feb 2025 16:34:00 +0100 Subject: [PATCH 01/28] Add support for dataclass_transform decorator --- jedi/plugins/stdlib.py | 6 +++ setup.py | 1 + test/test_inference/test_signature.py | 74 +++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index e1004ec8..f864a3f5 100644 --- a/jedi/plugins/stdlib.py +++ b/jedi/plugins/stdlib.py @@ -798,6 +798,12 @@ _implemented = { # runtime_checkable doesn't really change anything and is just # adding logs for infering stuff, so we can safely ignore it. 'runtime_checkable': lambda value, arguments, callback: NO_VALUES, + # Python 3.11+ + 'dataclass_transform': _dataclass, + }, + 'typing_extensions': { + # Python <3.11 + 'dataclass_transform': _dataclass, }, 'dataclasses': { # For now this works at least better than Jedi trying to understand it. diff --git a/setup.py b/setup.py index 54b5b0a1..6ed5110a 100755 --- a/setup.py +++ b/setup.py @@ -46,6 +46,7 @@ setup(name='jedi', 'colorama', 'Django', 'attrs', + 'typing_extensions', ], 'qa': [ # latest version supporting Python 3.6 diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index 4a1fcb62..cdfbe60e 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -354,6 +354,80 @@ def test_dataclass_signature(Script, skip_pre_python37, start, start_params): assert price.name == 'float' +@pytest.mark.parametrize( + 'start, start_params', [ + ['@dataclass_transform\nclass X:', []], + ['@dataclass_transform(eq=True)\nclass X:', []], + [dedent(''' + class Y(): + y: int + @dataclass_transform + class X(Y):'''), []], + [dedent(''' + @dataclass_transform + class Y(): + y: int + z = 5 + @dataclass_transform + class X(Y):'''), ['y']], + ] +) +def test_extensions_dataclass_transform_signature(Script, skip_pre_python37, start, start_params): + code = dedent(''' + name: str + foo = 3 + price: float + quantity: int = 0.0 + + X(''') + + code = 'from typing_extensions import dataclass_transform\n' + start + code + + sig, = Script(code).get_signatures() + assert [p.name for p in sig.params] == start_params + ['name', 'price', 'quantity'] + quantity, = sig.params[-1].infer() + assert quantity.name == 'int' + price, = sig.params[-2].infer() + assert price.name == 'float' + + +@pytest.mark.parametrize( + 'start, start_params', [ + ['@dataclass_transform\nclass X:', []], + ['@dataclass_transform(eq=True)\nclass X:', []], + [dedent(''' + class Y(): + y: int + @dataclass_transform + class X(Y):'''), []], + [dedent(''' + @dataclass_transform + class Y(): + y: int + z = 5 + @dataclass_transform + class X(Y):'''), ['y']], + ] +) +def test_dataclass_transform_signature(Script, skip_pre_python311, start, start_params): + code = dedent(''' + name: str + foo = 3 + price: float + quantity: int = 0.0 + + X(''') + + code = 'from typing import dataclass_transform\n' + start + code + + sig, = Script(code).get_signatures() + assert [p.name for p in sig.params] == start_params + ['name', 'price', 'quantity'] + quantity, = sig.params[-1].infer() + assert quantity.name == 'int' + price, = sig.params[-2].infer() + assert price.name == 'float' + + @pytest.mark.parametrize( 'start, start_params', [ ['@define\nclass X:', []], From f9beef0f6b48eb2e79a9618b3df45fa7df79bb82 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Sat, 15 Feb 2025 20:09:11 +0100 Subject: [PATCH 02/28] Add fixture to skip pre 3.11 --- conftest.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/conftest.py b/conftest.py index c5d88f3b..146e353d 100644 --- a/conftest.py +++ b/conftest.py @@ -156,6 +156,14 @@ def jedi_path(): return os.path.dirname(__file__) +@pytest.fixture() +def skip_pre_python311(environment): + if environment.version_info < (3, 11): + # This if is just needed to avoid that tests ever skip way more than + # they should for all Python versions. + pytest.skip() + + @pytest.fixture() def skip_pre_python38(environment): if environment.version_info < (3, 8): From 027e29ec50543679c0d559cc747355bae8947122 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Sat, 15 Feb 2025 20:12:53 +0100 Subject: [PATCH 03/28] Support base class and metaclass mode --- jedi/inference/value/klass.py | 122 +++++++++++++++++++++++++- jedi/plugins/stdlib.py | 58 +----------- test/test_inference/test_signature.py | 82 ++++++++++------- 3 files changed, 173 insertions(+), 89 deletions(-) diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index d4074f36..1bd064b2 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -47,11 +47,15 @@ from jedi.inference.filters import ParserTreeFilter from jedi.inference.names import TreeNameDefinition, ValueName from jedi.inference.arguments import unpack_arglist, ValuesArguments from jedi.inference.base_value import ValueSet, iterator_to_value_set, \ - NO_VALUES + NO_VALUES, ValueWrapper from jedi.inference.context import ClassContext from jedi.inference.value.function import FunctionAndClassBase +from jedi.inference.value.decorator import Decoratee from jedi.inference.gradual.generics import LazyGenericManager, TupleGenericManager from jedi.plugins import plugin_manager +from inspect import Parameter +from jedi.inference.names import BaseTreeParamName +from jedi.inference.signature import AbstractSignature class ClassName(TreeNameDefinition): @@ -129,6 +133,32 @@ class ClassFilter(ParserTreeFilter): return [name for name in names if self._access_possible(name)] +def get_dataclass_param_names(cls): + """ + ``cls`` is a :class:`ClassMixin`. + """ + param_names = [] + filter_ = cls.as_context().get_global_filter() + # .values ordering is not guaranteed, at least not in + # Python < 3.6, when dicts where not ordered, which is an + # implementation detail anyway. + for name in sorted(filter_.values(), key=lambda name: name.start_pos): + d = name.tree_name.get_definition() + annassign = d.children[1] + if d.type == 'expr_stmt' and annassign.type == 'annassign': + if len(annassign.children) < 4: + default = None + else: + default = annassign.children[3] + param_names.append(DataclassParamName( + parent_context=cls.parent_context, + tree_name=name.tree_name, + annotation_node=annassign.children[1], + default_node=default, + )) + return param_names + + class ClassMixin: def is_class(self): return True @@ -221,6 +251,53 @@ class ClassMixin: assert x is not None yield x + def _has_dataclass_transform_metaclasses(self) -> bool: + for meta in self.get_metaclasses(): + if ( + # Not sure if necessary + isinstance(meta, DataclassWrapper) + or ( + isinstance(meta, Decoratee) + # Internal leakage :| + and isinstance(meta._wrapped_value, DataclassWrapper) + ) + ): + return True + + return False + + def _get_dataclass_transform_signatures(self): + """ + Returns: A non-empty list if the class is dataclass transformed else an + empty list. + """ + param_names = [] + is_dataclass_transform = False + for cls in reversed(list(self.py__mro__())): + if not is_dataclass_transform and ( + isinstance(cls, DataclassWrapper) + or ( + # Some object like CompiledValues would not be compatible + isinstance(cls, ClassMixin) + and cls._has_dataclass_transform_metaclasses() + ) + ): + is_dataclass_transform = True + # Attributes on the decorated class and its base classes are not + # considered to be fields. + continue + + # All inherited behave like dataclass + if is_dataclass_transform: + param_names.extend( + get_dataclass_param_names(cls) + ) + + if is_dataclass_transform: + return [DataclassSignature(cls, param_names)] + else: + [] + def get_signatures(self): # Since calling staticmethod without a function is illegal, the Jedi # plugin doesn't return anything. Therefore call directly and get what @@ -232,7 +309,12 @@ class ClassMixin: return sigs args = ValuesArguments([]) init_funcs = self.py__call__(args).py__getattribute__('__init__') - return [sig.bind(self) for sig in init_funcs.get_signatures()] + + dataclass_sigs = self._get_dataclass_transform_signatures() + if dataclass_sigs: + return dataclass_sigs + else: + return [sig.bind(self) for sig in init_funcs.get_signatures()] def _as_context(self): return ClassContext(self) @@ -319,6 +401,42 @@ class ClassMixin: return ValueSet({self}) +class DataclassParamName(BaseTreeParamName): + def __init__(self, parent_context, tree_name, annotation_node, default_node): + super().__init__(parent_context, tree_name) + self.annotation_node = annotation_node + self.default_node = default_node + + def get_kind(self): + return Parameter.POSITIONAL_OR_KEYWORD + + def infer(self): + if self.annotation_node is None: + return NO_VALUES + else: + return self.parent_context.infer_node(self.annotation_node) + + +class DataclassSignature(AbstractSignature): + def __init__(self, value, param_names): + super().__init__(value) + self._param_names = param_names + + def get_param_names(self, resolve_stars=False): + return self._param_names + + +class DataclassWrapper(ValueWrapper, ClassMixin): + def get_signatures(self): + param_names = [] + for cls in reversed(list(self.py__mro__())): + if isinstance(cls, DataclassWrapper): + param_names.extend( + get_dataclass_param_names(cls) + ) + return [DataclassSignature(cls, param_names)] + + class ClassValue(ClassMixin, FunctionAndClassBase, metaclass=CachedMetaClass): api_type = 'class' diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index f864a3f5..d85e50db 100644 --- a/jedi/plugins/stdlib.py +++ b/jedi/plugins/stdlib.py @@ -11,7 +11,6 @@ compiled module that returns the types for C-builtins. """ import parso import os -from inspect import Parameter from jedi import debug from jedi.inference.utils import safe_property @@ -25,15 +24,15 @@ from jedi.inference.value.instance import \ from jedi.inference.base_value import ContextualizedNode, \ NO_VALUES, ValueSet, ValueWrapper, LazyValueWrapper from jedi.inference.value import ClassValue, ModuleValue -from jedi.inference.value.klass import ClassMixin +from jedi.inference.value.klass import DataclassWrapper from jedi.inference.value.function import FunctionMixin from jedi.inference.value import iterable from jedi.inference.lazy_value import LazyTreeValue, LazyKnownValue, \ LazyKnownValues -from jedi.inference.names import ValueName, BaseTreeParamName +from jedi.inference.names import ValueName from jedi.inference.filters import AttributeOverwrite, publish_method, \ ParserTreeFilter, DictFilter -from jedi.inference.signature import AbstractSignature, SignatureWrapper +from jedi.inference.signature import SignatureWrapper # Copied from Python 3.6's stdlib. @@ -599,57 +598,6 @@ def _dataclass(value, arguments, callback): return NO_VALUES -class DataclassWrapper(ValueWrapper, ClassMixin): - def get_signatures(self): - param_names = [] - for cls in reversed(list(self.py__mro__())): - if isinstance(cls, DataclassWrapper): - filter_ = cls.as_context().get_global_filter() - # .values ordering is not guaranteed, at least not in - # Python < 3.6, when dicts where not ordered, which is an - # implementation detail anyway. - for name in sorted(filter_.values(), key=lambda name: name.start_pos): - d = name.tree_name.get_definition() - annassign = d.children[1] - if d.type == 'expr_stmt' and annassign.type == 'annassign': - if len(annassign.children) < 4: - default = None - else: - default = annassign.children[3] - param_names.append(DataclassParamName( - parent_context=cls.parent_context, - tree_name=name.tree_name, - annotation_node=annassign.children[1], - default_node=default, - )) - return [DataclassSignature(cls, param_names)] - - -class DataclassSignature(AbstractSignature): - def __init__(self, value, param_names): - super().__init__(value) - self._param_names = param_names - - def get_param_names(self, resolve_stars=False): - return self._param_names - - -class DataclassParamName(BaseTreeParamName): - def __init__(self, parent_context, tree_name, annotation_node, default_node): - super().__init__(parent_context, tree_name) - self.annotation_node = annotation_node - self.default_node = default_node - - def get_kind(self): - return Parameter.POSITIONAL_OR_KEYWORD - - def infer(self): - if self.annotation_node is None: - return NO_VALUES - else: - return self.parent_context.infer_node(self.annotation_node) - - class ItemGetterCallable(ValueWrapper): def __init__(self, instance, args_value_set): super().__init__(instance) diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index cdfbe60e..4c683f9d 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -354,23 +354,56 @@ def test_dataclass_signature(Script, skip_pre_python37, start, start_params): assert price.name == 'float' +dataclass_transform_cases = [ + # Direct + ['@dataclass_transform\nclass X:', []], + # With params + ['@dataclass_transform(eq=True)\nclass X:', []], + # Subclass + [dedent(''' + class Y(): + y: int + @dataclass_transform + class X(Y):'''), []], + # Both classes + [dedent(''' + @dataclass_transform + class Y(): + y: int + z = 5 + @dataclass_transform + class X(Y):'''), ['y']], + # Base class + [dedent(''' + @dataclass_transform + class Y(): + y: int + z = 5 + class X(Y):'''), []], + # Alternative decorator + [dedent(''' + @dataclass_transform + def create_model(cls): + return cls + @create_model + class X:'''), []], + # Metaclass + [dedent(''' + @dataclass_transform + class ModelMeta(): + y: int + z = 5 + class ModelBase(metaclass=ModelMeta): + t: int + p = 5 + class X(ModelBase):'''), []], +] + +ids = ["direct", "with_params", "sub", "both", "base", "alternative_decorator", "metaclass"] + + @pytest.mark.parametrize( - 'start, start_params', [ - ['@dataclass_transform\nclass X:', []], - ['@dataclass_transform(eq=True)\nclass X:', []], - [dedent(''' - class Y(): - y: int - @dataclass_transform - class X(Y):'''), []], - [dedent(''' - @dataclass_transform - class Y(): - y: int - z = 5 - @dataclass_transform - class X(Y):'''), ['y']], - ] + 'start, start_params', dataclass_transform_cases, ids=ids ) def test_extensions_dataclass_transform_signature(Script, skip_pre_python37, start, start_params): code = dedent(''' @@ -392,22 +425,7 @@ def test_extensions_dataclass_transform_signature(Script, skip_pre_python37, sta @pytest.mark.parametrize( - 'start, start_params', [ - ['@dataclass_transform\nclass X:', []], - ['@dataclass_transform(eq=True)\nclass X:', []], - [dedent(''' - class Y(): - y: int - @dataclass_transform - class X(Y):'''), []], - [dedent(''' - @dataclass_transform - class Y(): - y: int - z = 5 - @dataclass_transform - class X(Y):'''), ['y']], - ] + 'start, start_params', dataclass_transform_cases, ids=ids ) def test_dataclass_transform_signature(Script, skip_pre_python311, start, start_params): code = dedent(''' From 74b46f3ee346df393dae1833f721bc71ece6d97d Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Sat, 15 Feb 2025 20:27:08 +0100 Subject: [PATCH 04/28] Add doc --- jedi/inference/value/klass.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index 1bd064b2..8d6c1b93 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -275,6 +275,9 @@ class ClassMixin: is_dataclass_transform = False for cls in reversed(list(self.py__mro__())): if not is_dataclass_transform and ( + # If dataclass_transform is applied to a class, 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. isinstance(cls, DataclassWrapper) or ( # Some object like CompiledValues would not be compatible From efc7248175574ec283fa79e13ad620d62b9c9ab6 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Sat, 15 Mar 2025 12:00:51 +0100 Subject: [PATCH 05/28] Fix mypy --- jedi/inference/value/klass.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index 8d6c1b93..5980e478 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -252,7 +252,7 @@ class ClassMixin: yield x def _has_dataclass_transform_metaclasses(self) -> bool: - for meta in self.get_metaclasses(): + for meta in self.get_metaclasses(): # type: ignore[attr-defined] if ( # Not sure if necessary isinstance(meta, DataclassWrapper) From 68c7bf35ce0c6678f6d62d7885112ac4d637e0f8 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Sat, 15 Mar 2025 13:07:35 +0100 Subject: [PATCH 06/28] Add init cases for dataclass --- test/test_inference/test_signature.py | 93 ++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 16 deletions(-) diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index 4c683f9d..9b9748c5 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -318,40 +318,101 @@ def test_wraps_signature(Script, code, signature): @pytest.mark.parametrize( - 'start, start_params', [ - ['@dataclass\nclass X:', []], - ['@dataclass(eq=True)\nclass X:', []], - [dedent(''' + "start, start_params, include_params", + [ + ["@dataclass\nclass X:", [], True], + ["@dataclass(eq=True)\nclass X:", [], True], + [ + dedent( + """ class Y(): y: int @dataclass - class X(Y):'''), []], - [dedent(''' + class X(Y):""" + ), + [], + True, + ], + [ + dedent( + """ @dataclass class Y(): y: int z = 5 @dataclass - class X(Y):'''), ['y']], - ] + class X(Y):""" + ), + ["y"], + True, + ], + # init=False + [ + dedent( + """ + @dataclass_transform(init=False) + class Y(): + y: int + z = 5 + class X(Y):""" + ), + [], + False, + ], + # custom init + [ + dedent( + """ + @dataclass_transform() + class Y(): + y: int + z = 5 + class X(Y): + def __init__(self, toto: str): + pass + """ + ), + ["toto"], + False, + ], + ], + ids=[ + "direct_transformed", + "transformed_with_params", + "subclass_transformed", + "both_transformed", + "init_false", + "custom_init", + ], ) -def test_dataclass_signature(Script, skip_pre_python37, start, start_params): - code = dedent(''' +def test_dataclass_signature( + Script, skip_pre_python37, start, start_params, include_params +): + code = dedent( + """ name: str foo = 3 price: float quantity: int = 0.0 - X(''') + X(""" + ) code = 'from dataclasses import dataclass\n' + start + code sig, = Script(code).get_signatures() - assert [p.name for p in sig.params] == start_params + ['name', 'price', 'quantity'] - quantity, = sig.params[-1].infer() - assert quantity.name == 'int' - price, = sig.params[-2].infer() - assert price.name == 'float' + expected_params = ( + [*start_params, "name", "price", "quantity"] + if include_params + else [*start_params] + ) + assert [p.name for p in sig.params] == expected_params + + if include_params: + quantity, = sig.params[-1].infer() + assert quantity.name == 'int' + price, = sig.params[-2].infer() + assert price.name == 'float' dataclass_transform_cases = [ From 472ee75e3c72ab50055e1025eec8b1deefa290d4 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Sat, 15 Mar 2025 13:15:19 +0100 Subject: [PATCH 07/28] Add ClassVar support for dataclass --- jedi/inference/value/klass.py | 4 ++++ test/test_inference/test_signature.py | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index 5980e478..ee588391 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -146,6 +146,10 @@ def get_dataclass_param_names(cls): d = name.tree_name.get_definition() annassign = d.children[1] if d.type == 'expr_stmt' and annassign.type == 'annassign': + node = annassign.children[1] + if node.type == "atom_expr" and node.children[0].value == "ClassVar": + continue + if len(annassign.children) < 4: default = None else: diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index 9b9748c5..8c7ab302 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -392,13 +392,18 @@ def test_dataclass_signature( """ name: str foo = 3 + blob: ClassVar[str] price: float quantity: int = 0.0 X(""" ) - code = 'from dataclasses import dataclass\n' + start + code + code = ( + "from dataclasses import dataclass\nfrom typing import ClassVar\n" + + start + + code + ) sig, = Script(code).get_signatures() expected_params = ( From 70efe2134c85e7830d177ab9deee4662bb00f226 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Sat, 15 Mar 2025 13:17:18 +0100 Subject: [PATCH 08/28] Check final support for dataclass --- test/test_inference/test_signature.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index 8c7ab302..fd65b1b1 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -393,14 +393,15 @@ def test_dataclass_signature( name: str foo = 3 blob: ClassVar[str] - price: float + price: Final[float] quantity: int = 0.0 X(""" ) code = ( - "from dataclasses import dataclass\nfrom typing import ClassVar\n" + "from dataclasses import dataclass\n" + + "from typing import ClassVar, Final\n" + start + code ) From 77cf382a1bb2d934caa09d9ce02dd15360f68d62 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Sat, 15 Mar 2025 15:53:51 +0100 Subject: [PATCH 09/28] Support init=False for dataclass --- jedi/inference/value/klass.py | 36 ++++++++++++++++++++++++++- jedi/plugins/stdlib.py | 32 +++++++++++++++++++++--- test/test_inference/test_signature.py | 26 +++++++++++-------- 3 files changed, 79 insertions(+), 15 deletions(-) diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index ee588391..92d947a6 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -49,7 +49,7 @@ from jedi.inference.arguments import unpack_arglist, ValuesArguments from jedi.inference.base_value import ValueSet, iterator_to_value_set, \ NO_VALUES, ValueWrapper from jedi.inference.context import ClassContext -from jedi.inference.value.function import FunctionAndClassBase +from jedi.inference.value.function import FunctionAndClassBase, OverloadedFunctionValue from jedi.inference.value.decorator import Decoratee from jedi.inference.gradual.generics import LazyGenericManager, TupleGenericManager from jedi.plugins import plugin_manager @@ -433,6 +433,40 @@ class DataclassSignature(AbstractSignature): return self._param_names +class DataclassDecorator(OverloadedFunctionValue): + def __init__(self, function, overloaded_functions, arguments): + """ + Args: + arguments: The parameters to the dataclass function decorator. + """ + super().__init__(function, overloaded_functions) + self.arguments = arguments + + @property + def has_dataclass_init_false(self) -> bool: + """ + Returns: + bool: True if dataclass(init=False) + """ + if not self.arguments.argument_node: + return False + + arg_nodes = ( + self.arguments.argument_node.children + if self.arguments.argument_node.type == "arglist" + else [self.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 + + class DataclassWrapper(ValueWrapper, ClassMixin): def get_signatures(self): param_names = [] diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index d85e50db..bcc4fe2b 100644 --- a/jedi/plugins/stdlib.py +++ b/jedi/plugins/stdlib.py @@ -24,7 +24,7 @@ from jedi.inference.value.instance import \ from jedi.inference.base_value import ContextualizedNode, \ NO_VALUES, ValueSet, ValueWrapper, LazyValueWrapper from jedi.inference.value import ClassValue, ModuleValue -from jedi.inference.value.klass import DataclassWrapper +from jedi.inference.value.klass import DataclassWrapper, DataclassDecorator from jedi.inference.value.function import FunctionMixin from jedi.inference.value import iterable from jedi.inference.lazy_value import LazyTreeValue, LazyKnownValue, \ @@ -590,11 +590,37 @@ def _random_choice(sequences): def _dataclass(value, arguments, callback): + """ + dataclass decorator can be called 2 times with different arguments. One to + customize it dataclass(eq=True) and another one with the class to + transform. + """ for c in _follow_param(value.inference_state, arguments, 0): if c.is_class(): - return ValueSet([DataclassWrapper(c)]) + # Decorate the class + dataclass_init = ( + # Customized decorator + not value.has_dataclass_init_false + if isinstance(value, DataclassDecorator) + # Bare dataclass decorator + else True + ) + + if dataclass_init: + return ValueSet([DataclassWrapper(c)]) + else: + return ValueSet([c]) else: - return ValueSet([value]) + # Decorator customization + return ValueSet( + [ + DataclassDecorator( + value._wrapped_value, + value._overloaded_functions, + arguments=arguments, + ) + ] + ) return NO_VALUES diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index fd65b1b1..48cfafc5 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -350,11 +350,17 @@ def test_wraps_signature(Script, code, signature): [ dedent( """ - @dataclass_transform(init=False) - class Y(): - y: int - z = 5 - class X(Y):""" + @dataclass(init=False) + class X:""" + ), + [], + False, + ], + [ + dedent( + """ + @dataclass(eq=True, init=False) + class X:""" ), [], False, @@ -363,11 +369,8 @@ def test_wraps_signature(Script, code, signature): [ dedent( """ - @dataclass_transform() - class Y(): - y: int - z = 5 - class X(Y): + @dataclass() + class X: def __init__(self, toto: str): pass """ @@ -382,6 +385,7 @@ def test_wraps_signature(Script, code, signature): "subclass_transformed", "both_transformed", "init_false", + "init_false_multiple", "custom_init", ], ) @@ -418,7 +422,7 @@ def test_dataclass_signature( quantity, = sig.params[-1].infer() assert quantity.name == 'int' price, = sig.params[-2].infer() - assert price.name == 'float' + assert price.name == 'object' dataclass_transform_cases = [ From 8912a3550211eb8706cf283f51bfcab55f5fa3af Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Sat, 15 Mar 2025 16:00:51 +0100 Subject: [PATCH 10/28] Support init=False for dataclass_transform --- jedi/inference/value/klass.py | 8 +- jedi/plugins/stdlib.py | 3 +- test/test_inference/test_signature.py | 121 +++++++++++++++++++------- 3 files changed, 95 insertions(+), 37 deletions(-) diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index 92d947a6..c8c77703 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -49,7 +49,7 @@ from jedi.inference.arguments import unpack_arglist, ValuesArguments from jedi.inference.base_value import ValueSet, iterator_to_value_set, \ NO_VALUES, ValueWrapper from jedi.inference.context import ClassContext -from jedi.inference.value.function import FunctionAndClassBase, OverloadedFunctionValue +from jedi.inference.value.function import FunctionAndClassBase, FunctionMixin from jedi.inference.value.decorator import Decoratee from jedi.inference.gradual.generics import LazyGenericManager, TupleGenericManager from jedi.plugins import plugin_manager @@ -433,13 +433,13 @@ class DataclassSignature(AbstractSignature): return self._param_names -class DataclassDecorator(OverloadedFunctionValue): - def __init__(self, function, overloaded_functions, arguments): +class DataclassDecorator(ValueWrapper, FunctionMixin): + def __init__(self, function, arguments): """ Args: arguments: The parameters to the dataclass function decorator. """ - super().__init__(function, overloaded_functions) + super().__init__(function) self.arguments = arguments @property diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index bcc4fe2b..0ed2f24c 100644 --- a/jedi/plugins/stdlib.py +++ b/jedi/plugins/stdlib.py @@ -615,8 +615,7 @@ def _dataclass(value, arguments, callback): return ValueSet( [ DataclassDecorator( - value._wrapped_value, - value._overloaded_functions, + value, arguments=arguments, ) ] diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index 48cfafc5..cf89709b 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -426,16 +426,19 @@ def test_dataclass_signature( dataclass_transform_cases = [ - # Direct - ['@dataclass_transform\nclass X:', []], - # With params - ['@dataclass_transform(eq=True)\nclass X:', []], + # Attributes on the decorated class and its base classes + # are not considered to be fields. + # 1/ Declare dataclass transformer + # Base Class + ['@dataclass_transform\nclass X:', [], False], + # Base Class with params + ['@dataclass_transform(eq=True)\nclass X:', [], False], # Subclass [dedent(''' class Y(): y: int @dataclass_transform - class X(Y):'''), []], + class X(Y):'''), [], False], # Both classes [dedent(''' @dataclass_transform @@ -443,22 +446,23 @@ dataclass_transform_cases = [ y: int z = 5 @dataclass_transform - class X(Y):'''), ['y']], - # Base class + class X(Y):'''), [], False], + # 2/ Declare dataclass transformed + # Class based [dedent(''' @dataclass_transform class Y(): y: int z = 5 - class X(Y):'''), []], - # Alternative decorator + class X(Y):'''), [], True], + # Decorator based [dedent(''' @dataclass_transform def create_model(cls): return cls @create_model - class X:'''), []], - # Metaclass + class X:'''), [], True], + # Metaclass based [dedent(''' @dataclass_transform class ModelMeta(): @@ -467,38 +471,86 @@ dataclass_transform_cases = [ class ModelBase(metaclass=ModelMeta): t: int p = 5 - class X(ModelBase):'''), []], + class X(ModelBase):'''), [], True], + # 3/ Init tweaks + # init=False + [dedent(''' + @dataclass_transform(init=False) + class Y(): + y: int + z = 5 + class X(Y):'''), [], False], + [dedent(''' + @dataclass_transform(eq=True, init=False) + class Y(): + y: int + z = 5 + class X(Y):'''), [], False], + # custom init + [dedent(''' + @dataclass_transform() + class Y(): + y: int + z = 5 + class X(Y): + def __init__(self, toto: str): + pass + '''), ["toto"], False], ] -ids = ["direct", "with_params", "sub", "both", "base", "alternative_decorator", "metaclass"] +ids = [ + "direct_transformer", + "transformer_with_params", + "subclass_transformer", + "both_transformer", + "base_transformed", + "decorator_transformed", + "metaclass_transformed", + "init_false", + "init_false_multiple", + "custom_init", +] @pytest.mark.parametrize( - 'start, start_params', dataclass_transform_cases, ids=ids + 'start, start_params, include_params', dataclass_transform_cases, ids=ids ) -def test_extensions_dataclass_transform_signature(Script, skip_pre_python37, start, start_params): - code = dedent(''' +def test_extensions_dataclass_transform_signature( + Script, skip_pre_python37, start, start_params, include_params +): + code = dedent( + """ name: str foo = 3 price: float quantity: int = 0.0 - X(''') + X(""" + ) - code = 'from typing_extensions import dataclass_transform\n' + start + code + code = "from typing_extensions import dataclass_transform\n" + start + code - sig, = Script(code).get_signatures() - assert [p.name for p in sig.params] == start_params + ['name', 'price', 'quantity'] - quantity, = sig.params[-1].infer() - assert quantity.name == 'int' - price, = sig.params[-2].infer() - assert price.name == 'float' + (sig,) = Script(code).get_signatures() + expected_params = ( + [*start_params, "name", "price", "quantity"] + if include_params + else [*start_params] + ) + assert [p.name for p in sig.params] == expected_params + + if include_params: + quantity, = sig.params[-1].infer() + assert quantity.name == 'int' + price, = sig.params[-2].infer() + assert price.name == 'float' @pytest.mark.parametrize( - 'start, start_params', dataclass_transform_cases, ids=ids + "start, start_params, include_params", dataclass_transform_cases, ids=ids ) -def test_dataclass_transform_signature(Script, skip_pre_python311, start, start_params): +def test_dataclass_transform_signature( + Script, skip_pre_python311, start, start_params, include_params +): code = dedent(''' name: str foo = 3 @@ -510,11 +562,18 @@ def test_dataclass_transform_signature(Script, skip_pre_python311, start, start_ code = 'from typing import dataclass_transform\n' + start + code sig, = Script(code).get_signatures() - assert [p.name for p in sig.params] == start_params + ['name', 'price', 'quantity'] - quantity, = sig.params[-1].infer() - assert quantity.name == 'int' - price, = sig.params[-2].infer() - assert price.name == 'float' + expected_params = ( + [*start_params, "name", "price", "quantity"] + if include_params + else [*start_params] + ) + assert [p.name for p in sig.params] == expected_params + + if include_params: + quantity, = sig.params[-1].infer() + assert quantity.name == 'int' + price, = sig.params[-2].infer() + assert price.name == 'float' @pytest.mark.parametrize( From e0797be6813a93fc83b059d91f0cc6bd16e113b2 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Sat, 15 Mar 2025 16:02:23 +0100 Subject: [PATCH 11/28] Check final+classvar support for dataclass transform --- test/test_inference/test_signature.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index cf89709b..cf4ef436 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -522,13 +522,19 @@ def test_extensions_dataclass_transform_signature( """ name: str foo = 3 - price: float + blob: ClassVar[str] + price: Final[float] quantity: int = 0.0 X(""" ) - code = "from typing_extensions import dataclass_transform\n" + start + code + code = ( + "from typing_extensions import dataclass_transform\n" + + "from typing import ClassVar, Final\n" + + start + + code + ) (sig,) = Script(code).get_signatures() expected_params = ( @@ -542,7 +548,7 @@ def test_extensions_dataclass_transform_signature( quantity, = sig.params[-1].infer() assert quantity.name == 'int' price, = sig.params[-2].infer() - assert price.name == 'float' + assert price.name == 'object' @pytest.mark.parametrize( @@ -554,12 +560,18 @@ def test_dataclass_transform_signature( code = dedent(''' name: str foo = 3 - price: float + blob: ClassVar[str] + price: Final[float] quantity: int = 0.0 X(''') - code = 'from typing import dataclass_transform\n' + start + code + code = ( + "from typing import dataclass_transform\n" + + "from typing import ClassVar, Final\n" + + start + + code + ) sig, = Script(code).get_signatures() expected_params = ( @@ -573,7 +585,7 @@ def test_dataclass_transform_signature( quantity, = sig.params[-1].infer() assert quantity.name == 'int' price, = sig.params[-2].infer() - assert price.name == 'float' + assert price.name == 'object' @pytest.mark.parametrize( From 50778c390f539ca160338ac74fe733c6f4521b76 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Sat, 15 Mar 2025 16:23:32 +0100 Subject: [PATCH 12/28] Fix init=false for transform and exclude fields on base transform --- jedi/inference/value/klass.py | 35 ++++++++++++++++++--------- jedi/plugins/stdlib.py | 21 ++++++++++------ test/test_inference/test_signature.py | 9 ------- 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index c8c77703..e6750614 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -259,11 +259,12 @@ class ClassMixin: for meta in self.get_metaclasses(): # type: ignore[attr-defined] if ( # Not sure if necessary - isinstance(meta, DataclassWrapper) + (isinstance(meta, DataclassWrapper) and meta.dataclass_init) or ( isinstance(meta, Decoratee) # Internal leakage :| and isinstance(meta._wrapped_value, DataclassWrapper) + and meta._wrapped_value.dataclass_init ) ): return True @@ -276,31 +277,31 @@ class ClassMixin: empty list. """ param_names = [] - is_dataclass_transform = False + is_dataclass_transform_with_init = False for cls in reversed(list(self.py__mro__())): - if not is_dataclass_transform and ( + if not is_dataclass_transform_with_init and ( # If dataclass_transform is applied to a class, 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. - isinstance(cls, DataclassWrapper) + (isinstance(cls, DataclassWrapper) and cls.dataclass_init) or ( # Some object like CompiledValues would not be compatible isinstance(cls, ClassMixin) and cls._has_dataclass_transform_metaclasses() ) ): - is_dataclass_transform = True + is_dataclass_transform_with_init = True # Attributes on the decorated class and its base classes are not # considered to be fields. continue # All inherited behave like dataclass - if is_dataclass_transform: + if is_dataclass_transform_with_init: param_names.extend( get_dataclass_param_names(cls) ) - if is_dataclass_transform: + if is_dataclass_transform_with_init: return [DataclassSignature(cls, param_names)] else: [] @@ -468,13 +469,25 @@ class DataclassDecorator(ValueWrapper, FunctionMixin): class DataclassWrapper(ValueWrapper, ClassMixin): + + def __init__( + self, wrapped_value, dataclass_init: bool, is_dataclass_transform: bool = False + ): + super().__init__(wrapped_value) + self.dataclass_init = dataclass_init + self.is_dataclass_transform = is_dataclass_transform + def get_signatures(self): param_names = [] for cls in reversed(list(self.py__mro__())): - if isinstance(cls, DataclassWrapper): - param_names.extend( - get_dataclass_param_names(cls) - ) + if ( + isinstance(cls, DataclassWrapper) + and cls.dataclass_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)) return [DataclassSignature(cls, param_names)] diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index 0ed2f24c..6d83d2c6 100644 --- a/jedi/plugins/stdlib.py +++ b/jedi/plugins/stdlib.py @@ -597,19 +597,26 @@ def _dataclass(value, arguments, callback): """ for c in _follow_param(value.inference_state, arguments, 0): if c.is_class(): - # Decorate the class + # Decorate a class dataclass_init = ( - # Customized decorator + # Customized decorator, init may be disabled not value.has_dataclass_init_false if isinstance(value, DataclassDecorator) - # Bare dataclass decorator + # Bare dataclass decorator, always with init else True ) - if dataclass_init: - return ValueSet([DataclassWrapper(c)]) - else: - return ValueSet([c]) + return ValueSet( + [ + DataclassWrapper( + c, + dataclass_init, + is_dataclass_transform=value.name.string_name + == "dataclass_transform", + ) + ] + ) + else: # Decorator customization return ValueSet( diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index cf4ef436..1b7e2d4d 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -439,14 +439,6 @@ dataclass_transform_cases = [ y: int @dataclass_transform class X(Y):'''), [], False], - # Both classes - [dedent(''' - @dataclass_transform - class Y(): - y: int - z = 5 - @dataclass_transform - class X(Y):'''), [], False], # 2/ Declare dataclass transformed # Class based [dedent(''' @@ -502,7 +494,6 @@ ids = [ "direct_transformer", "transformer_with_params", "subclass_transformer", - "both_transformer", "base_transformed", "decorator_transformed", "metaclass_transformed", From 7dcb944b05f8b457a400f965efd99eb5d24428e2 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Sat, 15 Mar 2025 16:42:16 +0100 Subject: [PATCH 13/28] Fix decorator transformed case --- jedi/plugins/stdlib.py | 14 ++++++++++---- test/test_inference/test_signature.py | 4 ++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index 6d83d2c6..1018e808 100644 --- a/jedi/plugins/stdlib.py +++ b/jedi/plugins/stdlib.py @@ -24,6 +24,7 @@ from jedi.inference.value.instance import \ from jedi.inference.base_value import ContextualizedNode, \ NO_VALUES, ValueSet, ValueWrapper, LazyValueWrapper from jedi.inference.value import ClassValue, ModuleValue +from jedi.inference.value.decorator import Decoratee from jedi.inference.value.klass import DataclassWrapper, DataclassDecorator from jedi.inference.value.function import FunctionMixin from jedi.inference.value import iterable @@ -597,7 +598,7 @@ def _dataclass(value, arguments, callback): """ for c in _follow_param(value.inference_state, arguments, 0): if c.is_class(): - # Decorate a class + # Decorate a dataclass / base dataclass dataclass_init = ( # Customized decorator, init may be disabled not value.has_dataclass_init_false @@ -606,17 +607,22 @@ def _dataclass(value, arguments, callback): else True ) + is_dataclass_transform = ( + value.name.string_name == "dataclass_transform" + # The decorator function from dataclass_transform acting as the + # dataclass decorator. + and not isinstance(value, Decoratee) + ) + return ValueSet( [ DataclassWrapper( c, dataclass_init, - is_dataclass_transform=value.name.string_name - == "dataclass_transform", + is_dataclass_transform, ) ] ) - else: # Decorator customization return ValueSet( diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index 1b7e2d4d..3aeca0bb 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -450,8 +450,8 @@ dataclass_transform_cases = [ # Decorator based [dedent(''' @dataclass_transform - def create_model(cls): - return cls + def create_model(): + pass @create_model class X:'''), [], True], # Metaclass based From bd1edfce781239fd21b23c11aeef5bae513f403b Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Mon, 17 Mar 2025 19:48:42 +0100 Subject: [PATCH 14/28] Fix test --- jedi/plugins/stdlib.py | 5 +++-- test/test_inference/test_signature.py | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index 1018e808..604cd595 100644 --- a/jedi/plugins/stdlib.py +++ b/jedi/plugins/stdlib.py @@ -593,8 +593,9 @@ def _random_choice(sequences): def _dataclass(value, arguments, callback): """ dataclass decorator can be called 2 times with different arguments. One to - customize it dataclass(eq=True) and another one with the class to - transform. + customize it dataclass(eq=True) and another one with the class to transform. + + It supports dataclass, dataclass_transform and attrs. """ for c in _follow_param(value.inference_state, arguments, 0): if c.is_class(): diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index 3aeca0bb..21744ad8 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -509,6 +509,10 @@ ids = [ def test_extensions_dataclass_transform_signature( Script, skip_pre_python37, start, start_params, include_params ): + has_typing_ext = bool(Script('import typing_extensions').infer()) + if not has_typing_ext: + raise pytest.skip("typing_extensions needed in target environment to run this test") + code = dedent( """ name: str @@ -596,7 +600,8 @@ def test_dataclass_transform_signature( z = 5 @define class X(Y):'''), ['y']], - ] + ], + ids=["define", "frozen", "define_customized", "define_subclass", "define_both"] ) def test_attrs_signature(Script, skip_pre_python37, start, start_params): has_attrs = bool(Script('import attrs').infer()) From e1405232110473214577c8f8374678fb8a1e1ab7 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Mon, 17 Mar 2025 23:51:53 +0100 Subject: [PATCH 15/28] Fix attrs + remove dataclass_transform init=false tests --- jedi/plugins/stdlib.py | 21 ++++++++------------- test/test_inference/test_signature.py | 25 +++++++++---------------- 2 files changed, 17 insertions(+), 29 deletions(-) diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index 604cd595..491a3b61 100644 --- a/jedi/plugins/stdlib.py +++ b/jedi/plugins/stdlib.py @@ -624,8 +624,11 @@ def _dataclass(value, arguments, callback): ) ] ) - else: - # Decorator customization + elif c.is_function(): + # dataclass_transform on a decorator equivalent of @dataclass + return ValueSet([value]) + elif value.name.string_name != "dataclass_transform": + # dataclass (or like) decorator customization return ValueSet( [ DataclassDecorator( @@ -634,6 +637,9 @@ def _dataclass(value, arguments, callback): ) ] ) + else: + # dataclass_transform decorator customization; nothing impactful + return ValueSet([value]) return NO_VALUES @@ -796,17 +802,6 @@ _implemented = { # For now this works at least better than Jedi trying to understand it. 'dataclass': _dataclass }, - # attrs exposes declaration interface roughly compatible with dataclasses - # via attrs.define, attrs.frozen and attrs.mutable - # https://www.attrs.org/en/stable/names.html - 'attr': { - 'define': _dataclass, - 'frozen': _dataclass, - }, - 'attrs': { - 'define': _dataclass, - 'frozen': _dataclass, - }, 'os.path': { 'dirname': _create_string_input_function(os.path.dirname), 'abspath': _create_string_input_function(os.path.abspath), diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index 21744ad8..47c9f032 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -432,7 +432,7 @@ dataclass_transform_cases = [ # Base Class ['@dataclass_transform\nclass X:', [], False], # Base Class with params - ['@dataclass_transform(eq=True)\nclass X:', [], False], + ['@dataclass_transform(eq_default=True)\nclass X:', [], False], # Subclass [dedent(''' class Y(): @@ -447,6 +447,13 @@ dataclass_transform_cases = [ y: int z = 5 class X(Y):'''), [], True], + # Class based with params + [dedent(''' + @dataclass_transform(eq_default=True) + class Y(): + y: int + z = 5 + class X(Y):'''), [], True], # Decorator based [dedent(''' @dataclass_transform @@ -465,19 +472,6 @@ dataclass_transform_cases = [ p = 5 class X(ModelBase):'''), [], True], # 3/ Init tweaks - # init=False - [dedent(''' - @dataclass_transform(init=False) - class Y(): - y: int - z = 5 - class X(Y):'''), [], False], - [dedent(''' - @dataclass_transform(eq=True, init=False) - class Y(): - y: int - z = 5 - class X(Y):'''), [], False], # custom init [dedent(''' @dataclass_transform() @@ -495,10 +489,9 @@ ids = [ "transformer_with_params", "subclass_transformer", "base_transformed", + "base_transformed_with_params", "decorator_transformed", "metaclass_transformed", - "init_false", - "init_false_multiple", "custom_init", ] From 999332ef77196cdfedd7e1af03dc89010b5ec787 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Tue, 18 Mar 2025 00:30:50 +0100 Subject: [PATCH 16/28] Dataclass transform change init False --- jedi/inference/value/klass.py | 25 ++++++++++++++++++++++++- jedi/plugins/stdlib.py | 12 ++++++++++-- test/test_inference/test_signature.py | 27 +++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index e6750614..7158eee4 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -296,7 +296,9 @@ class ClassMixin: continue # All inherited behave like dataclass - if is_dataclass_transform_with_init: + if is_dataclass_transform_with_init and ( + isinstance(cls, ClassValue) and not cls._has_init_false() + ): param_names.extend( get_dataclass_param_names(cls) ) @@ -557,6 +559,27 @@ class ClassValue(ClassMixin, FunctionAndClassBase, metaclass=CachedMetaClass): return values return NO_VALUES + def _has_init_false(self) -> bool: + """ + It returns ``True`` if ``class X(init=False):`` else ``False``. + """ + bases_arguments = self._get_bases_arguments() + + if bases_arguments.argument_node.type != "arglist": + # If it is not inheriting from the base model and having + # extra parameters, then init behavior is not changed. + return False + + for arg in 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() def get_metaclass_signatures(self, metaclasses): return [] diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index 491a3b61..618965ca 100644 --- a/jedi/plugins/stdlib.py +++ b/jedi/plugins/stdlib.py @@ -625,9 +625,17 @@ def _dataclass(value, arguments, callback): ] ) elif c.is_function(): - # dataclass_transform on a decorator equivalent of @dataclass + # @dataclass_transform + # def create_model(): pass return ValueSet([value]) - elif value.name.string_name != "dataclass_transform": + elif ( + # @dataclass(...) + value.name.string_name != "dataclass_transform" + # @dataclass_transform + # def create_model(): pass + # @create_model(...) + or isinstance(value, Decoratee) + ): # dataclass (or like) decorator customization return ValueSet( [ diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index 47c9f032..b31b277d 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -482,6 +482,30 @@ dataclass_transform_cases = [ def __init__(self, toto: str): pass '''), ["toto"], False], + # Class based init=false + [dedent(''' + @dataclass_transform + class Y(): + y: int + z = 5 + class X(Y, init=False):'''), [], False], + # Decorator based init=false + [dedent(''' + @dataclass_transform + def create_model(): + pass + @create_model(init=False) + class X:'''), [], False], + # Metaclass based init=false + [dedent(''' + @dataclass_transform + class ModelMeta(): + y: int + z = 5 + class ModelBase(metaclass=ModelMeta): + t: int + p = 5 + class X(ModelBase, init=False):'''), [], False], ] ids = [ @@ -493,6 +517,9 @@ ids = [ "decorator_transformed", "metaclass_transformed", "custom_init", + "base_transformed_init_false", + "decorator_transformed_init_false", + "metaclass_transformed_init_false", ] From a3fd90d734804ba9937fcf9b62af65618b7c5871 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Tue, 18 Mar 2025 00:42:58 +0100 Subject: [PATCH 17/28] Fix dataclass decorator other parameters --- jedi/plugins/stdlib.py | 3 +++ test/test_inference/test_signature.py | 38 +++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index 618965ca..0636e705 100644 --- a/jedi/plugins/stdlib.py +++ b/jedi/plugins/stdlib.py @@ -613,6 +613,9 @@ def _dataclass(value, arguments, callback): # The decorator function from dataclass_transform acting as the # dataclass decorator. and not isinstance(value, Decoratee) + # The decorator function from dataclass_transform acting as the + # dataclass decorator with customized parameters + and not isinstance(value, DataclassDecorator) ) return ValueSet( diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index b31b277d..bf58fa22 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -471,8 +471,7 @@ dataclass_transform_cases = [ t: int p = 5 class X(ModelBase):'''), [], True], - # 3/ Init tweaks - # custom init + # 3/ Init custom init [dedent(''' @dataclass_transform() class Y(): @@ -482,21 +481,22 @@ dataclass_transform_cases = [ def __init__(self, toto: str): pass '''), ["toto"], False], - # Class based init=false + # 4/ init=false + # Class based [dedent(''' @dataclass_transform class Y(): y: int z = 5 class X(Y, init=False):'''), [], False], - # Decorator based init=false + # Decorator based [dedent(''' @dataclass_transform def create_model(): pass @create_model(init=False) class X:'''), [], False], - # Metaclass based init=false + # Metaclass based [dedent(''' @dataclass_transform class ModelMeta(): @@ -506,6 +506,31 @@ dataclass_transform_cases = [ t: int p = 5 class X(ModelBase, init=False):'''), [], False], + # 4/ Other parameters + # Class based + [dedent(''' + @dataclass_transform + class Y(): + y: int + z = 5 + class X(Y, eq=True):'''), [], True], + # Decorator based + [dedent(''' + @dataclass_transform + def create_model(): + pass + @create_model(eq=True) + class X:'''), [], True], + # Metaclass based + [dedent(''' + @dataclass_transform + class ModelMeta(): + y: int + z = 5 + class ModelBase(metaclass=ModelMeta): + t: int + p = 5 + class X(ModelBase, eq=True):'''), [], True], ] ids = [ @@ -520,6 +545,9 @@ ids = [ "base_transformed_init_false", "decorator_transformed_init_false", "metaclass_transformed_init_false", + "base_transformed_other_parameters", + "decorator_transformed_other_parameters", + "metaclass_transformed_other_parameters", ] From e20c3c955fbbc382cc6424ee86a142aa7bcee755 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Tue, 18 Mar 2025 00:52:01 +0100 Subject: [PATCH 18/28] Dataclass 3.7 mode without Final --- test/test_inference/test_signature.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index bf58fa22..334f8a42 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -390,14 +390,22 @@ def test_wraps_signature(Script, code, signature): ], ) def test_dataclass_signature( - Script, skip_pre_python37, start, start_params, include_params + Script, skip_pre_python37, start, start_params, include_params, environment ): + if environment.version_info < (3, 8): + # Final is not yet supported + price_type = "float" + price_type_infer = "float" + else: + price_type = "Final[float]" + price_type_infer = "object" + code = dedent( - """ + f""" name: str foo = 3 blob: ClassVar[str] - price: Final[float] + price: {price_type} quantity: int = 0.0 X(""" @@ -422,7 +430,7 @@ def test_dataclass_signature( quantity, = sig.params[-1].infer() assert quantity.name == 'int' price, = sig.params[-2].infer() - assert price.name == 'object' + assert price.name == price_type_infer dataclass_transform_cases = [ From e49032ed6b65fead7a085692d4c6335e85dbd9e3 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Tue, 18 Mar 2025 00:59:27 +0100 Subject: [PATCH 19/28] Dataclass transform typing extension without Final support --- test/test_inference/test_signature.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index 334f8a42..64d830d6 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -563,18 +563,26 @@ ids = [ 'start, start_params, include_params', dataclass_transform_cases, ids=ids ) def test_extensions_dataclass_transform_signature( - Script, skip_pre_python37, start, start_params, include_params + Script, skip_pre_python37, start, start_params, include_params, environment ): has_typing_ext = bool(Script('import typing_extensions').infer()) if not has_typing_ext: raise pytest.skip("typing_extensions needed in target environment to run this test") + if environment.version_info < (3, 8): + # Final is not yet supported + price_type = "float" + price_type_infer = "float" + else: + price_type = "Final[float]" + price_type_infer = "object" + code = dedent( - """ + f""" name: str foo = 3 blob: ClassVar[str] - price: Final[float] + price: {price_type} quantity: int = 0.0 X(""" @@ -599,7 +607,7 @@ def test_extensions_dataclass_transform_signature( quantity, = sig.params[-1].infer() assert quantity.name == 'int' price, = sig.params[-2].infer() - assert price.name == 'object' + assert price.name == price_type_infer @pytest.mark.parametrize( From 5f4afa27e5ab844b5b4d99c3c5f291921df9424f Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Sun, 4 May 2025 23:34:58 +0200 Subject: [PATCH 20/28] Documentation and better naming --- jedi/inference/value/klass.py | 91 +++++++++++++++++++++++++++++------ jedi/plugins/stdlib.py | 28 ++++++----- 2 files changed, 92 insertions(+), 27 deletions(-) diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index 7158eee4..d6c239a7 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -36,6 +36,8 @@ py__doc__() Returns the docstring for a value. ====================================== ======================================== """ +from typing import List + from jedi import debug from jedi.parser_utils import get_cached_parent_scope, expr_is_dotted, \ function_is_property @@ -133,9 +135,19 @@ class ClassFilter(ParserTreeFilter): return [name for name in names if self._access_possible(name)] -def get_dataclass_param_names(cls): +def get_dataclass_param_names(cls) -> List["DataclassParamName"]: """ - ``cls`` is a :class:`ClassMixin`. + ``cls`` is a :class:`ClassMixin`. The type is only documented as mypy would + complain that some fields are missing. + + .. code:: python + + @dataclass + class A: + a: int + b: str = "toto" + + For the previous example, the param names would be ``a`` and ``b``. """ param_names = [] filter_ = cls.as_context().get_global_filter() @@ -154,6 +166,7 @@ def get_dataclass_param_names(cls): default = None else: default = annassign.children[3] + param_names.append(DataclassParamName( parent_context=cls.parent_context, tree_name=name.tree_name, @@ -259,21 +272,21 @@ class ClassMixin: for meta in self.get_metaclasses(): # type: ignore[attr-defined] if ( # Not sure if necessary - (isinstance(meta, DataclassWrapper) and meta.dataclass_init) + (isinstance(meta, DataclassWrapper) and meta.should_generate_init) or ( isinstance(meta, Decoratee) # Internal leakage :| and isinstance(meta._wrapped_value, DataclassWrapper) - and meta._wrapped_value.dataclass_init + and meta._wrapped_value.should_generate_init ) ): return True return False - def _get_dataclass_transform_signatures(self): + def _get_dataclass_transform_signatures(self) -> List["DataclassSignature"]: """ - Returns: A non-empty list if the class is dataclass transformed else an + Returns: A non-empty list if the class has dataclass semantics else an empty list. """ param_names = [] @@ -283,7 +296,7 @@ class ClassMixin: # If dataclass_transform is applied to a class, 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. - (isinstance(cls, DataclassWrapper) and cls.dataclass_init) + (isinstance(cls, DataclassWrapper) and cls.should_generate_init) or ( # Some object like CompiledValues would not be compatible isinstance(cls, ClassMixin) @@ -295,9 +308,9 @@ class ClassMixin: # considered to be fields. continue - # All inherited behave like dataclass + # All inherited classes behave like dataclass semantics if is_dataclass_transform_with_init and ( - isinstance(cls, ClassValue) and not cls._has_init_false() + isinstance(cls, ClassValue) and not cls._has_init_param_set_false() ): param_names.extend( get_dataclass_param_names(cls) @@ -306,7 +319,7 @@ class ClassMixin: if is_dataclass_transform_with_init: return [DataclassSignature(cls, param_names)] else: - [] + return [] def get_signatures(self): # Since calling staticmethod without a function is illegal, the Jedi @@ -412,6 +425,17 @@ class ClassMixin: class DataclassParamName(BaseTreeParamName): + """ + Represent a field declaration on a class with dataclass semantics. + + .. code:: python + + class A: + a: int + + ``a`` is a :class:`DataclassParamName`. + """ + def __init__(self, parent_context, tree_name, annotation_node, default_node): super().__init__(parent_context, tree_name) self.annotation_node = annotation_node @@ -428,6 +452,12 @@ class DataclassParamName(BaseTreeParamName): class DataclassSignature(AbstractSignature): + """ + It represents the ``__init__`` signature of a class with dataclass semantics. + + .. code:: python + + """ def __init__(self, value, param_names): super().__init__(value) self._param_names = param_names @@ -437,6 +467,21 @@ class DataclassSignature(AbstractSignature): class DataclassDecorator(ValueWrapper, FunctionMixin): + """ + A dataclass(-like) decorator with custom parameters. + + .. code:: python + + @dataclass(init=True) # this + class A: ... + + @dataclass_transform + def create_model(*, init=False): pass + + @create_model(init=False) # or this + class B: ... + """ + def __init__(self, function, arguments): """ Args: @@ -446,10 +491,10 @@ class DataclassDecorator(ValueWrapper, FunctionMixin): self.arguments = arguments @property - def has_dataclass_init_false(self) -> bool: + def has_init_param_set_false(self) -> bool: """ Returns: - bool: True if dataclass(init=False) + bool: ``True`` if ``@dataclass(init=False)`` """ if not self.arguments.argument_node: return False @@ -471,12 +516,26 @@ class DataclassDecorator(ValueWrapper, FunctionMixin): class DataclassWrapper(ValueWrapper, ClassMixin): + """ + A class with dataclass semantics. + + .. code:: python + + @dataclass + class A: ... # this + + @dataclass_transform + def create_model(): pass + + @create_model() + class B: ... # or this + """ def __init__( - self, wrapped_value, dataclass_init: bool, is_dataclass_transform: bool = False + self, wrapped_value, should_generate_init: bool, is_dataclass_transform: bool = False ): super().__init__(wrapped_value) - self.dataclass_init = dataclass_init + self.should_generate_init = should_generate_init self.is_dataclass_transform = is_dataclass_transform def get_signatures(self): @@ -484,7 +543,7 @@ class DataclassWrapper(ValueWrapper, ClassMixin): for cls in reversed(list(self.py__mro__())): if ( isinstance(cls, DataclassWrapper) - and cls.dataclass_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 @@ -559,7 +618,7 @@ class ClassValue(ClassMixin, FunctionAndClassBase, metaclass=CachedMetaClass): return values return NO_VALUES - def _has_init_false(self) -> bool: + def _has_init_param_set_false(self) -> bool: """ It returns ``True`` if ``class X(init=False):`` else ``False``. """ diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index 0636e705..7c07d0dc 100644 --- a/jedi/plugins/stdlib.py +++ b/jedi/plugins/stdlib.py @@ -592,17 +592,22 @@ def _random_choice(sequences): def _dataclass(value, arguments, callback): """ - dataclass decorator can be called 2 times with different arguments. One to - customize it dataclass(eq=True) and another one with the class to transform. - It supports dataclass, dataclass_transform and attrs. + + Entry points for the following cases: + + 1. dataclass-like decorator instantiation from a dataclass_transform decorator + 2. dataclass_transform decorator declaration with parameters + 3. dataclass(-like) decorator declaration with parameters + 4. dataclass(-like) semantics on a class from a dataclass(-like) decorator """ for c in _follow_param(value.inference_state, arguments, 0): if c.is_class(): - # Decorate a dataclass / base dataclass - dataclass_init = ( + # dataclass(-like) semantics on a class from a + # dataclass(-like) decorator + should_generate_init = ( # Customized decorator, init may be disabled - not value.has_dataclass_init_false + not value.has_init_param_set_false if isinstance(value, DataclassDecorator) # Bare dataclass decorator, always with init else True @@ -622,21 +627,22 @@ def _dataclass(value, arguments, callback): [ DataclassWrapper( c, - dataclass_init, + should_generate_init, is_dataclass_transform, ) ] ) elif c.is_function(): + # dataclass-like decorator instantiation: # @dataclass_transform - # def create_model(): pass + # def create_model() return ValueSet([value]) elif ( - # @dataclass(...) + # @dataclass(smth=...) value.name.string_name != "dataclass_transform" # @dataclass_transform # def create_model(): pass - # @create_model(...) + # @create_model(smth=...) or isinstance(value, Decoratee) ): # dataclass (or like) decorator customization @@ -649,7 +655,7 @@ def _dataclass(value, arguments, callback): ] ) else: - # dataclass_transform decorator customization; nothing impactful + # dataclass_transform decorator with parameters; nothing impactful return ValueSet([value]) return NO_VALUES From eb80dc08f3888f500e9cedc3719c3a5198e71771 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Mon, 5 May 2025 00:37:38 +0200 Subject: [PATCH 21/28] Add decorator tests - sandwich mode --- test/test_inference/test_signature.py | 48 ++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index 64d830d6..47e89d20 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -346,6 +346,20 @@ def test_wraps_signature(Script, code, signature): ["y"], True, ], + [ + dedent( + """ + @dataclass + class Y(): + y: int + class Z(Y): # Not included + z = 5 + @dataclass + class X(Z):""" + ), + ["y"], + True, + ], # init=False [ dedent( @@ -384,6 +398,7 @@ def test_wraps_signature(Script, code, signature): "transformed_with_params", "subclass_transformed", "both_transformed", + "intermediate_not_transformed", "init_false", "init_false_multiple", "custom_init", @@ -469,6 +484,34 @@ dataclass_transform_cases = [ pass @create_model class X:'''), [], True], + [dedent(''' + @dataclass_transform + def create_model(): + pass + class Y: + y: int + @create_model + class X(Y):'''), [], True], + [dedent(''' + @dataclass_transform + def create_model(): + pass + @create_model + class Y: + y: int + @create_model + class X(Y):'''), ["y"], True], + [dedent(''' + @dataclass_transform + def create_model(): + pass + @create_model + class Y: + y: int + class Z(Y): + z: int + @create_model + class X(Z):'''), ["y"], True], # Metaclass based [dedent(''' @dataclass_transform @@ -547,7 +590,10 @@ ids = [ "subclass_transformer", "base_transformed", "base_transformed_with_params", - "decorator_transformed", + "decorator_transformed_direct", + "decorator_transformed_subclass", + "decorator_transformed_both", + "decorator_transformed_intermediate_not", "metaclass_transformed", "custom_init", "base_transformed_init_false", From d53a8ef81c47183084b286cb12ac81e502158629 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Mon, 5 May 2025 02:02:17 +0200 Subject: [PATCH 22/28] Support init customization on dataclass_transform source --- jedi/inference/value/klass.py | 194 +++++++++++++++++--------- jedi/plugins/stdlib.py | 61 ++++---- test/test_inference/test_signature.py | 121 +++++++++++++++- 3 files changed, 279 insertions(+), 97 deletions(-) diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index d6c239a7..60d05897 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -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.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)] +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"]: """ ``cls`` is a :class:`ClassMixin`. The type is only documented as mypy would @@ -268,55 +289,69 @@ class ClassMixin: assert x is not None 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] if ( - # Not sure if necessary - (isinstance(meta, DataclassWrapper) and meta.should_generate_init) - or ( - isinstance(meta, Decoratee) - # Internal leakage :| - and isinstance(meta._wrapped_value, DataclassWrapper) - and meta._wrapped_value.should_generate_init - ) + isinstance(meta, Decoratee) + # Internal leakage :| + and isinstance(meta._wrapped_value, DataclassTransformer) ): - return True + return True, meta._wrapped_value.init_mode_from_new - return False + return False, None def _get_dataclass_transform_signatures(self) -> List["DataclassSignature"]: """ Returns: A non-empty list if the class has dataclass semantics else an 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 = [] - is_dataclass_transform_with_init = False + is_dataclass_transform = False + default_init_mode: Optional[bool] = None 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 # will be assumed for any class that directly or indirectly derives from # the decorated class or uses the decorated class as a metaclass. - (isinstance(cls, DataclassWrapper) and cls.should_generate_init) - or ( + if ( + 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 isinstance(cls, ClassMixin) - and cls._has_dataclass_transform_metaclasses() - ) - ): - is_dataclass_transform_with_init = True + ): + is_dataclass_transform, default_init_mode = ( + cls._has_dataclass_transform_metaclasses() + ) + # Attributes on the decorated class and its base classes are not # considered to be fields. - continue + if is_dataclass_transform: + continue # All inherited classes behave like dataclass semantics - if is_dataclass_transform_with_init and ( - isinstance(cls, ClassValue) and not cls._has_init_param_set_false() + if ( + 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( get_dataclass_param_names(cls) ) - if is_dataclass_transform_with_init: + if is_dataclass_transform: return [DataclassSignature(cls, param_names)] else: return [] @@ -482,61 +517,92 @@ class DataclassDecorator(ValueWrapper, FunctionMixin): class B: ... """ - def __init__(self, function, arguments): + def __init__(self, function, arguments, default_init: bool = True): """ 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) - 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 has_init_param_set_false(self) -> bool: - """ - Returns: - bool: ``True`` if ``@dataclass(init=False)`` - """ - if not self.arguments.argument_node: - return False + def _init_param_value(self, arguments) -> Optional[bool]: + if not arguments.argument_node: + return None arg_nodes = ( - self.arguments.argument_node.children - if self.arguments.argument_node.type == "arglist" - else [self.arguments.argument_node] + arguments.argument_node.children + if arguments.argument_node.type == "arglist" + 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): """ - 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 - @dataclass - class A: ... # this + @dataclass class A: ... # this - @dataclass_transform - def create_model(): pass + @dataclass_transform def create_model(): pass - @create_model() - class B: ... # or this + @create_model() class B: ... # or this """ 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) self.should_generate_init = should_generate_init - self.is_dataclass_transform = is_dataclass_transform def get_signatures(self): param_names = [] @@ -544,9 +610,6 @@ class DataclassWrapper(ValueWrapper, ClassMixin): if ( isinstance(cls, DataclassWrapper) 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)) return [DataclassSignature(cls, param_names)] @@ -618,7 +681,8 @@ class ClassValue(ClassMixin, FunctionAndClassBase, metaclass=CachedMetaClass): return 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``. """ @@ -627,17 +691,9 @@ class ClassValue(ClassMixin, FunctionAndClassBase, metaclass=CachedMetaClass): if bases_arguments.argument_node.type != "arglist": # If it is not inheriting from the base model and having # extra parameters, then init behavior is not changed. - return False + return None - for arg in 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 + return init_param_value(bases_arguments.argument_node.children) @plugin_manager.decorate() def get_metaclass_signatures(self, metaclasses): diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index 7c07d0dc..18f4779c 100644 --- a/jedi/plugins/stdlib.py +++ b/jedi/plugins/stdlib.py @@ -25,7 +25,11 @@ from jedi.inference.base_value import ContextualizedNode, \ NO_VALUES, ValueSet, ValueWrapper, LazyValueWrapper from jedi.inference.value import ClassValue, ModuleValue 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 import iterable from jedi.inference.lazy_value import LazyTreeValue, LazyKnownValue, \ @@ -592,9 +596,7 @@ def _random_choice(sequences): def _dataclass(value, arguments, callback): """ - It supports dataclass, dataclass_transform and attrs. - - Entry points for the following cases: + Decorator entry points for dataclass, dataclass_transform and attrs. 1. dataclass-like decorator instantiation from a dataclass_transform decorator 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): 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 = ( value.name.string_name == "dataclass_transform" @@ -623,26 +616,39 @@ def _dataclass(value, arguments, callback): and not isinstance(value, DataclassDecorator) ) - return ValueSet( - [ - DataclassWrapper( - c, - should_generate_init, - is_dataclass_transform, - ) - ] - ) + if is_dataclass_transform: + # Declare base class + return ValueSet([DataclassTransformer(c)]) + else: + # Declare dataclass(-like) semantics on a class from a + # 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(): # dataclass-like decorator instantiation: # @dataclass_transform # def create_model() - return ValueSet([value]) + return ValueSet( + [ + DataclassDecorator( + value, + arguments=arguments, + default_init=True, + ) + ] + ) elif ( - # @dataclass(smth=...) + # @dataclass(init=False) value.name.string_name != "dataclass_transform" # @dataclass_transform # def create_model(): pass - # @create_model(smth=...) + # @create_model(init=...) or isinstance(value, Decoratee) ): # dataclass (or like) decorator customization @@ -651,6 +657,11 @@ def _dataclass(value, arguments, callback): DataclassDecorator( value, arguments=arguments, + default_init=( + value._wrapped_value.init_param_mode + if isinstance(value, Decoratee) + else True + ), ) ] ) diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index 47e89d20..fca28ea2 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -534,6 +534,40 @@ dataclass_transform_cases = [ '''), ["toto"], False], # 4/ init=false # 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(''' @dataclass_transform class Y(): @@ -541,6 +575,24 @@ dataclass_transform_cases = [ z = 5 class X(Y, init=False):'''), [], False], # 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(''' @dataclass_transform def create_model(): @@ -548,6 +600,60 @@ dataclass_transform_cases = [ @create_model(init=False) class X:'''), [], False], # 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(''' @dataclass_transform class ModelMeta(): @@ -596,9 +702,18 @@ ids = [ "decorator_transformed_intermediate_not", "metaclass_transformed", "custom_init", - "base_transformed_init_false", - "decorator_transformed_init_false", - "metaclass_transformed_init_false", + # "base_transformed_init_false_dataclass_init_default", + "base_transformed_init_false_dataclass_init_true", + "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", "decorator_transformed_other_parameters", "metaclass_transformed_other_parameters", From 6e5f201f6c021a4ea32a9125523911ef8b4e7208 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Fri, 29 Aug 2025 18:36:54 +0200 Subject: [PATCH 23/28] Use future annotations --- jedi/inference/value/klass.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index 60d05897..a4d06f39 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -36,6 +36,8 @@ py__doc__() Returns the docstring for a value. ====================================== ======================================== """ +from __future__ import annotations + from typing import List, Optional, Tuple from jedi import debug @@ -156,7 +158,7 @@ def init_param_value(arg_nodes) -> Optional[bool]: 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 complain that some fields are missing. @@ -300,7 +302,7 @@ class ClassMixin: 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 empty list. From c1e9aee15b5d3782b1a318f56603607653a02111 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Fri, 29 Aug 2025 18:37:23 +0200 Subject: [PATCH 24/28] Clean code comments --- jedi/inference/value/klass.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index a4d06f39..056a86dc 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -174,9 +174,6 @@ def get_dataclass_param_names(cls) -> List[DataclassParamName]: """ param_names = [] filter_ = cls.as_context().get_global_filter() - # .values ordering is not guaranteed, at least not in - # Python < 3.6, when dicts where not ordered, which is an - # implementation detail anyway. for name in sorted(filter_.values(), key=lambda name: name.start_pos): d = name.tree_name.get_definition() annassign = d.children[1] @@ -464,13 +461,6 @@ class ClassMixin: class DataclassParamName(BaseTreeParamName): """ Represent a field declaration on a class with dataclass semantics. - - .. code:: python - - class A: - a: int - - ``a`` is a :class:`DataclassParamName`. """ def __init__(self, parent_context, tree_name, annotation_node, default_node): @@ -547,7 +537,7 @@ class DataclassDecorator(ValueWrapper, FunctionMixin): class DataclassTransformer(ValueWrapper, ClassMixin): """ - A class with ``dataclass_transform`` applies. dataclass-like semantics will + A class decorated with ``dataclass_transform``. 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. @@ -593,11 +583,14 @@ class DataclassWrapper(ValueWrapper, ClassMixin): .. code:: python - @dataclass class A: ... # this + @dataclass + class A: ... # this - @dataclass_transform def create_model(): pass + @dataclass_transform + def create_model(): pass - @create_model() class B: ... # or this + @create_model() + class B: ... # or this """ def __init__( From 3a436df7aca8f7bd9d465a51316b111aa230a481 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Fri, 29 Aug 2025 18:37:37 +0200 Subject: [PATCH 25/28] Remove property usage --- jedi/inference/value/klass.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index 056a86dc..c993f9d0 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -295,7 +295,7 @@ class ClassMixin: # Internal leakage :| and isinstance(meta._wrapped_value, DataclassTransformer) ): - return True, meta._wrapped_value.init_mode_from_new + return True, meta._wrapped_value.init_mode_from_new() return False, None @@ -342,8 +342,8 @@ class ClassMixin: is_dataclass_transform and isinstance(cls, ClassValue) and ( - cls.init_param_mode - or (cls.init_param_mode is None and default_init_mode) + cls.init_param_mode() + or (cls.init_param_mode() is None and default_init_mode) ) ): param_names.extend( @@ -545,7 +545,6 @@ class DataclassTransformer(ValueWrapper, ClassMixin): 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__") @@ -676,7 +675,6 @@ class ClassValue(ClassMixin, FunctionAndClassBase, metaclass=CachedMetaClass): return values return NO_VALUES - @property def init_param_mode(self) -> Optional[bool]: """ It returns ``True`` if ``class X(init=False):`` else ``False``. From 4ea7981680d4c7d0e9f5792af0ef6a0980b8fb1b Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Fri, 29 Aug 2025 18:37:51 +0200 Subject: [PATCH 26/28] Add complete test --- test/test_inference/test_signature.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index fca28ea2..acd6eafb 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -771,6 +771,23 @@ def test_extensions_dataclass_transform_signature( assert price.name == price_type_infer +def test_dataclass_transform_complete(Script): + script = Script('''\ + @dataclass_transform + class Y(): + y: int + z = 5 + + class X(Y): + name: str + foo = 3 + + def f(x: X): + x.na''') + completion, = script.complete() + assert completion.description == 'name: str' + + @pytest.mark.parametrize( "start, start_params, include_params", dataclass_transform_cases, ids=ids ) From 0f35a1b18bd370aeb78ce855c7fd5a9a2e6e4106 Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Fri, 29 Aug 2025 18:54:14 +0200 Subject: [PATCH 27/28] Split dataclass and dataclass_transform logic --- jedi/plugins/stdlib.py | 69 +++++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index 18f4779c..ab641270 100644 --- a/jedi/plugins/stdlib.py +++ b/jedi/plugins/stdlib.py @@ -596,16 +596,49 @@ def _random_choice(sequences): def _dataclass(value, arguments, callback): """ - Decorator entry points for dataclass, dataclass_transform and attrs. + Decorator entry points for dataclass. - 1. dataclass-like decorator instantiation from a dataclass_transform decorator - 2. dataclass_transform decorator declaration with parameters - 3. dataclass(-like) decorator declaration with parameters - 4. dataclass(-like) semantics on a class from a dataclass(-like) decorator + 1. dataclass decorator declaration with parameters + 2. dataclass semantics on a class from a dataclass(-like) decorator """ for c in _follow_param(value.inference_state, arguments, 0): if c.is_class(): + # Declare dataclass semantics on a class from a dataclass 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)]) + else: + # @dataclass(init=False) + # dataclass decorator customization + return ValueSet( + [ + DataclassDecorator( + value, + arguments=arguments, + default_init=True, + ) + ] + ) + return NO_VALUES + + +def _dataclass_transform(value, arguments, callback): + """ + Decorator entry points for dataclass_transform. + + 1. dataclass-like decorator instantiation from a dataclass_transform decorator + 2. dataclass_transform decorator declaration with parameters + 3. dataclass-like decorator declaration with parameters + 4. dataclass-like semantics on a class from a dataclass-like decorator + """ + for c in _follow_param(value.inference_state, arguments, 0): + if c.is_class(): is_dataclass_transform = ( value.name.string_name == "dataclass_transform" # The decorator function from dataclass_transform acting as the @@ -620,15 +653,9 @@ def _dataclass(value, arguments, callback): # Declare base class return ValueSet([DataclassTransformer(c)]) else: - # Declare dataclass(-like) semantics on a class from a - # 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 - ) + # Declare dataclass-like semantics on a class from a + # dataclass-like decorator + should_generate_init = value.init_param_mode return ValueSet([DataclassWrapper(c, should_generate_init)]) elif c.is_function(): # dataclass-like decorator instantiation: @@ -644,12 +671,10 @@ def _dataclass(value, arguments, callback): ] ) elif ( - # @dataclass(init=False) - value.name.string_name != "dataclass_transform" # @dataclass_transform # def create_model(): pass # @create_model(init=...) - or isinstance(value, Decoratee) + isinstance(value, Decoratee) ): # dataclass (or like) decorator customization return ValueSet( @@ -657,11 +682,7 @@ def _dataclass(value, arguments, callback): DataclassDecorator( value, arguments=arguments, - default_init=( - value._wrapped_value.init_param_mode - if isinstance(value, Decoratee) - else True - ), + default_init=value._wrapped_value.init_param_mode, ) ] ) @@ -820,11 +841,11 @@ _implemented = { # adding logs for infering stuff, so we can safely ignore it. 'runtime_checkable': lambda value, arguments, callback: NO_VALUES, # Python 3.11+ - 'dataclass_transform': _dataclass, + 'dataclass_transform': _dataclass_transform, }, 'typing_extensions': { # Python <3.11 - 'dataclass_transform': _dataclass, + 'dataclass_transform': _dataclass_transform, }, 'dataclasses': { # For now this works at least better than Jedi trying to understand it. From 15a7513fd0860f68b903d34e2bf730e232e9a18d Mon Sep 17 00:00:00 2001 From: Eric Masseran Date: Fri, 29 Aug 2025 18:54:30 +0200 Subject: [PATCH 28/28] Improve code comment --- jedi/inference/value/klass.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index c993f9d0..5377cf7b 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -537,10 +537,11 @@ class DataclassDecorator(ValueWrapper, FunctionMixin): class DataclassTransformer(ValueWrapper, ClassMixin): """ - A class decorated with ``dataclass_transform``. 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. + A class decorated with the ``dataclass_transform`` decorator. 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)