diff --git a/ChangeLog b/ChangeLog index 3e0a3b5386..e1187ded38 100644 --- a/ChangeLog +++ b/ChangeLog @@ -24,6 +24,12 @@ Release date: TBA thus allowing return of partially inferred strings, for example "a/{MISSING_VALUE}/b" when inferring ``f"a/{missing}/b"`` with ``missing`` being uninferable + Closes #2621 + +* Fix crash when typing._alias() call is missing arguments. + + Closes #2513 + What's New in astroid 3.3.6? ============================ diff --git a/astroid/brain/brain_typing.py b/astroid/brain/brain_typing.py index 239e63af24..c44687bf89 100644 --- a/astroid/brain/brain_typing.py +++ b/astroid/brain/brain_typing.py @@ -258,6 +258,7 @@ def _looks_like_typing_alias(node: Call) -> bool: isinstance(node.func, Name) # TODO: remove _DeprecatedGenericAlias when Py3.14 min and node.func.name in {"_alias", "_DeprecatedGenericAlias"} + and len(node.args) == 2 and ( # _alias function works also for builtins object such as list and dict isinstance(node.args[0], (Attribute, Name)) diff --git a/astroid/interpreter/_import/spec.py b/astroid/interpreter/_import/spec.py index 9795835e15..e0c54d4a8a 100644 --- a/astroid/interpreter/_import/spec.py +++ b/astroid/interpreter/_import/spec.py @@ -92,7 +92,7 @@ def find_module( modname: str, module_parts: tuple[str, ...], processed: tuple[str, ...], - submodule_path: Sequence[str] | None, + submodule_path: tuple[str, ...] | None, ) -> ModuleSpec | None: """Find the given module. @@ -105,7 +105,7 @@ def find_module( namespace. :param processed: What parts from the module parts were processed so far. - :param submodule_path: A list of paths where the module + :param submodule_path: A tuple of paths where the module can be looked into. :returns: A ModuleSpec, describing how and where the module was found, None, otherwise. @@ -127,11 +127,12 @@ class ImportlibFinder(Finder): ) @staticmethod + @lru_cache(maxsize=1024) def find_module( modname: str, module_parts: tuple[str, ...], processed: tuple[str, ...], - submodule_path: Sequence[str] | None, + submodule_path: tuple[str, ...] | None, ) -> ModuleSpec | None: if submodule_path is not None: search_paths = list(submodule_path) @@ -222,11 +223,12 @@ class ExplicitNamespacePackageFinder(ImportlibFinder): """A finder for the explicit namespace packages.""" @staticmethod + @lru_cache(maxsize=1024) def find_module( modname: str, module_parts: tuple[str, ...], processed: tuple[str, ...], - submodule_path: Sequence[str] | None, + submodule_path: tuple[str, ...] | None, ) -> ModuleSpec | None: if processed: modname = ".".join([*processed, modname]) @@ -261,11 +263,12 @@ def __init__(self, path: Sequence[str]) -> None: continue @staticmethod + @lru_cache(maxsize=1024) def find_module( modname: str, module_parts: tuple[str, ...], processed: tuple[str, ...], - submodule_path: Sequence[str] | None, + submodule_path: tuple[str, ...] | None, ) -> ModuleSpec | None: try: file_type, filename, path = _search_zip(module_parts) @@ -285,11 +288,12 @@ class PathSpecFinder(Finder): """Finder based on importlib.machinery.PathFinder.""" @staticmethod + @lru_cache(maxsize=1024) def find_module( modname: str, module_parts: tuple[str, ...], processed: tuple[str, ...], - submodule_path: Sequence[str] | None, + submodule_path: tuple[str, ...] | None, ) -> ModuleSpec | None: spec = importlib.machinery.PathFinder.find_spec(modname, path=submodule_path) if spec is not None: @@ -373,7 +377,7 @@ def _find_spec_with_path( modname: str, module_parts: tuple[str, ...], processed: tuple[str, ...], - submodule_path: Sequence[str] | None, + submodule_path: tuple[str, ...] | None, ) -> tuple[Finder | _MetaPathFinder, ModuleSpec]: for finder in _SPEC_FINDERS: finder_instance = finder(search_path) @@ -451,25 +455,30 @@ def _find_spec( # Need a copy for not mutating the argument. modpath = list(module_path) - submodule_path = None + search_paths = None processed: list[str] = [] while modpath: modname = modpath.pop(0) + + submodule_path = search_paths or path + if submodule_path is not None: + submodule_path = tuple(submodule_path) + finder, spec = _find_spec_with_path( - _path, modname, module_path, tuple(processed), submodule_path or path + _path, modname, module_path, tuple(processed), submodule_path ) processed.append(modname) if modpath: if isinstance(finder, Finder): - submodule_path = finder.contribute_to_path(spec, processed) - # If modname is a package from an editable install, update submodule_path + search_paths = finder.contribute_to_path(spec, processed) + # If modname is a package from an editable install, update search_paths # so that the next module in the path will be found inside of it using importlib. # Existence of __name__ is guaranteed by _find_spec_with_path. elif finder.__name__ in _EditableFinderClasses: # type: ignore[attr-defined] - submodule_path = spec.submodule_search_locations + search_paths = spec.submodule_search_locations if spec.type == ModuleType.PKG_DIRECTORY: - spec = spec._replace(submodule_search_locations=submodule_path) + spec = spec._replace(submodule_search_locations=search_paths) return spec diff --git a/astroid/manager.py b/astroid/manager.py index ee4024448d..87420585c4 100644 --- a/astroid/manager.py +++ b/astroid/manager.py @@ -482,6 +482,9 @@ def clear_cache(self) -> None: ): lru_cache.cache_clear() # type: ignore[attr-defined] + for finder in spec._SPEC_FINDERS: + finder.find_module.cache_clear() + self.bootstrap() # Reload brain plugins. During initialisation this is done in astroid.manager.py diff --git a/astroid/nodes/scoped_nodes/scoped_nodes.py b/astroid/nodes/scoped_nodes/scoped_nodes.py index f94404086f..99ed79675b 100644 --- a/astroid/nodes/scoped_nodes/scoped_nodes.py +++ b/astroid/nodes/scoped_nodes/scoped_nodes.py @@ -2354,26 +2354,24 @@ def getattr( if name in self.special_attributes and class_context and not values: result = [self.special_attributes.lookup(name)] - if name == "__bases__": - # Need special treatment, since they are mutable - # and we need to return all the values. - result += values return result if class_context: values += self._metaclass_lookup_attribute(name, context) - # Remove AnnAssigns without value, which are not attributes in the purest sense. - for value in values.copy(): + result: list[InferenceResult] = [] + for value in values: if isinstance(value, node_classes.AssignName): stmt = value.statement() + # Ignore AnnAssigns without value, which are not attributes in the purest sense. if isinstance(stmt, node_classes.AnnAssign) and stmt.value is None: - values.pop(values.index(value)) + continue + result.append(value) - if not values: + if not result: raise AttributeInferenceError(target=self, attribute=name, context=context) - return values + return result @lru_cache(maxsize=1024) # noqa def _metaclass_lookup_attribute(self, name, context): @@ -2385,7 +2383,7 @@ def _metaclass_lookup_attribute(self, name, context): for cls in (implicit_meta, metaclass): if cls and cls != self and isinstance(cls, ClassDef): cls_attributes = self._get_attribute_from_metaclass(cls, name, context) - attrs.update(set(cls_attributes)) + attrs.update(cls_attributes) return attrs def _get_attribute_from_metaclass(self, cls, name, context): diff --git a/tests/brain/test_typing.py b/tests/brain/test_typing.py index 1e8e3eaf88..11b77b7f9e 100644 --- a/tests/brain/test_typing.py +++ b/tests/brain/test_typing.py @@ -4,7 +4,7 @@ import pytest -from astroid import builder +from astroid import bases, builder, nodes from astroid.exceptions import InferenceError @@ -23,3 +23,50 @@ def test_infer_typevar() -> None: ) with pytest.raises(InferenceError): call_node.inferred() + + +class TestTypingAlias: + def test_infer_typing_alias(self) -> None: + """ + Test that _alias() calls can be inferred. + """ + node = builder.extract_node( + """ + from typing import _alias + x = _alias(int, float) + """ + ) + assert isinstance(node, nodes.Assign) + assert isinstance(node.value, nodes.Call) + inferred = next(node.value.infer()) + assert isinstance(inferred, nodes.ClassDef) + assert len(inferred.bases) == 1 + assert inferred.bases[0].name == "int" + + @pytest.mark.parametrize( + "alias_args", + [ + "", # two missing arguments + "int", # one missing argument + "int, float, tuple", # one additional argument + ], + ) + def test_infer_typing_alias_incorrect_number_of_arguments( + self, alias_args: str + ) -> None: + """ + Regression test for: https://github.com/pylint-dev/astroid/issues/2513 + + Test that _alias() calls with the incorrect number of arguments can be inferred. + """ + node = builder.extract_node( + f""" + from typing import _alias + x = _alias({alias_args}) + """ + ) + assert isinstance(node, nodes.Assign) + assert isinstance(node.value, nodes.Call) + inferred = next(node.value.infer()) + assert isinstance(inferred, bases.Instance) + assert inferred.name == "_SpecialGenericAlias"