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): diff --git a/jedi/inference/value/klass.py b/jedi/inference/value/klass.py index d4074f36..5377cf7b 100644 --- a/jedi/inference/value/klass.py +++ b/jedi/inference/value/klass.py @@ -36,6 +36,10 @@ py__doc__() Returns the docstring for a value. ====================================== ======================================== """ +from __future__ import annotations + +from typing import List, Optional, Tuple + from jedi import debug from jedi.parser_utils import get_cached_parent_scope, expr_is_dotted, \ function_is_property @@ -47,11 +51,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.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 +from inspect import Parameter +from jedi.inference.names import BaseTreeParamName +from jedi.inference.signature import AbstractSignature class ClassName(TreeNameDefinition): @@ -129,6 +137,65 @@ 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 + 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() + 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': + node = annassign.children[1] + if node.type == "atom_expr" and node.children[0].value == "ClassVar": + continue + + 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 +288,73 @@ class ClassMixin: assert x is not None yield x + def _has_dataclass_transform_metaclasses(self) -> Tuple[bool, Optional[bool]]: + for meta in self.get_metaclasses(): # type: ignore[attr-defined] + if ( + isinstance(meta, Decoratee) + # Internal leakage :| + and isinstance(meta._wrapped_value, DataclassTransformer) + ): + return True, meta._wrapped_value.init_mode_from_new() + + 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 = False + default_init_mode: Optional[bool] = None + for cls in reversed(list(self.py__mro__())): + 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. + 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) + ): + 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. + if is_dataclass_transform: + continue + + # All inherited classes behave like dataclass semantics + 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: + return [DataclassSignature(cls, param_names)] + else: + return [] + 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 +366,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 +458,158 @@ class ClassMixin: return ValueSet({self}) +class DataclassParamName(BaseTreeParamName): + """ + Represent a field declaration on a class with dataclass semantics. + """ + + 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): + """ + 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 + + def get_param_names(self, resolve_stars=False): + return self._param_names + + +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, default_init: bool = True): + """ + Args: + function: Decoratee | function + arguments: The parameters to the dataclass function decorator + default_init: Boolean to indicate the default init value + """ + super().__init__(function) + argument_init = self._init_param_value(arguments) + self.init_param_mode = ( + argument_init if argument_init is not None else default_init + ) + + def _init_param_value(self, arguments) -> Optional[bool]: + if not arguments.argument_node: + return None + + arg_nodes = ( + arguments.argument_node.children + if arguments.argument_node.type == "arglist" + else [arguments.argument_node] + ) + + return init_param_value(arg_nodes) + + +class DataclassTransformer(ValueWrapper, ClassMixin): + """ + 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) + + 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 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_transform + def create_model(): pass + + @create_model() + class B: ... # or this + """ + + def __init__( + self, wrapped_value, should_generate_init: bool + ): + super().__init__(wrapped_value) + self.should_generate_init = should_generate_init + + def get_signatures(self): + param_names = [] + for cls in reversed(list(self.py__mro__())): + if ( + isinstance(cls, DataclassWrapper) + and cls.should_generate_init + ): + param_names.extend(get_dataclass_param_names(cls)) + return [DataclassSignature(cls, param_names)] + + class ClassValue(ClassMixin, FunctionAndClassBase, metaclass=CachedMetaClass): api_type = 'class' @@ -385,6 +676,19 @@ class ClassValue(ClassMixin, FunctionAndClassBase, metaclass=CachedMetaClass): return values return NO_VALUES + def init_param_mode(self) -> Optional[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 None + + return init_param_value(bases_arguments.argument_node.children) + @plugin_manager.decorate() def get_metaclass_signatures(self, metaclasses): return [] diff --git a/jedi/plugins/stdlib.py b/jedi/plugins/stdlib.py index e1004ec8..ab641270 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,20 @@ 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.decorator import Decoratee +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, \ 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. @@ -591,63 +595,101 @@ def _random_choice(sequences): def _dataclass(value, arguments, callback): + """ + Decorator entry points for dataclass. + + 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(): - return ValueSet([DataclassWrapper(c)]) + # 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: - return ValueSet([value]) + # @dataclass(init=False) + # dataclass decorator customization + return ValueSet( + [ + DataclassDecorator( + value, + arguments=arguments, + default_init=True, + ) + ] + ) + 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)] +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 + # 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) + ) -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 + 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 = value.init_param_mode + return ValueSet([DataclassWrapper(c, should_generate_init)]) + elif c.is_function(): + # dataclass-like decorator instantiation: + # @dataclass_transform + # def create_model() + return ValueSet( + [ + DataclassDecorator( + value, + arguments=arguments, + default_init=True, + ) + ] + ) + elif ( + # @dataclass_transform + # def create_model(): pass + # @create_model(init=...) + isinstance(value, Decoratee) + ): + # dataclass (or like) decorator customization + return ValueSet( + [ + DataclassDecorator( + value, + arguments=arguments, + default_init=value._wrapped_value.init_param_mode, + ) + ] + ) else: - return self.parent_context.infer_node(self.annotation_node) + # dataclass_transform decorator with parameters; nothing impactful + return ValueSet([value]) + return NO_VALUES class ItemGetterCallable(ValueWrapper): @@ -798,22 +840,17 @@ _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_transform, + }, + 'typing_extensions': { + # Python <3.11 + 'dataclass_transform': _dataclass_transform, }, 'dataclasses': { # 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/setup.py b/setup.py index c8bc6bba..a000aa35 100755 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ setup(name='jedi', 'colorama', 'Django', 'attrs', + 'typing_extensions', ], 'qa': [ # latest version on 2025-06-16 diff --git a/test/test_inference/test_signature.py b/test/test_inference/test_signature.py index 4a1fcb62..acd6eafb 100644 --- a/test/test_inference/test_signature.py +++ b/test/test_inference/test_signature.py @@ -318,40 +318,511 @@ 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, + ], + [ + dedent( + """ + @dataclass + class Y(): + y: int + class Z(Y): # Not included + z = 5 + @dataclass + class X(Z):""" + ), + ["y"], + True, + ], + # init=False + [ + dedent( + """ + @dataclass(init=False) + class X:""" + ), + [], + False, + ], + [ + dedent( + """ + @dataclass(eq=True, init=False) + class X:""" + ), + [], + False, + ], + # custom init + [ + dedent( + """ + @dataclass() + class X: + def __init__(self, toto: str): + pass + """ + ), + ["toto"], + False, + ], + ], + ids=[ + "direct_transformed", + "transformed_with_params", + "subclass_transformed", + "both_transformed", + "intermediate_not_transformed", + "init_false", + "init_false_multiple", + "custom_init", + ], ) -def test_dataclass_signature(Script, skip_pre_python37, start, start_params): +def test_dataclass_signature( + 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: {price_type} + quantity: int = 0.0 + + X(""" + ) + + code = ( + "from dataclasses import dataclass\n" + + "from typing import ClassVar, Final\n" + + start + + code + ) + + 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 == price_type_infer + + +dataclass_transform_cases = [ + # 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_default=True)\nclass X:', [], False], + # Subclass + [dedent(''' + class Y(): + y: int + @dataclass_transform + class X(Y):'''), [], False], + # 2/ Declare dataclass transformed + # Class based + [dedent(''' + @dataclass_transform + class Y(): + 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 + def create_model(): + 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 + class ModelMeta(): + y: int + z = 5 + class ModelBase(metaclass=ModelMeta): + t: int + p = 5 + class X(ModelBase):'''), [], True], + # 3/ Init custom init + [dedent(''' + @dataclass_transform() + class Y(): + y: int + z = 5 + class X(Y): + def __init__(self, toto: str): + pass + '''), ["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(): + y: int + 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(): + pass + @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(): + y: int + z = 5 + class ModelBase(metaclass=ModelMeta): + 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 = [ + "direct_transformer", + "transformer_with_params", + "subclass_transformer", + "base_transformed", + "base_transformed_with_params", + "decorator_transformed_direct", + "decorator_transformed_subclass", + "decorator_transformed_both", + "decorator_transformed_intermediate_not", + "metaclass_transformed", + "custom_init", + # "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", +] + + +@pytest.mark.parametrize( + '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, 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: {price_type} + quantity: int = 0.0 + + X(""" + ) + + code = ( + "from typing_extensions import dataclass_transform\n" + + "from typing import ClassVar, Final\n" + + start + + code + ) + + (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 == 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 +) +def test_dataclass_transform_signature( + Script, skip_pre_python311, start, start_params, include_params +): code = dedent(''' name: str foo = 3 - price: float + blob: ClassVar[str] + price: Final[float] quantity: int = 0.0 X(''') - code = 'from dataclasses import dataclass\n' + start + code + code = ( + "from typing import dataclass_transform\n" + + "from typing import ClassVar, Final\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 == 'object' @pytest.mark.parametrize( @@ -371,7 +842,8 @@ def test_dataclass_signature(Script, skip_pre_python37, start, start_params): 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())