1# Licensed under the LGPL: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html
2# For details: https://github.com/pylint-dev/astroid/blob/main/LICENSE
3# Copyright (c) https://github.com/pylint-dev/astroid/blob/main/CONTRIBUTORS.txt
4
5from __future__ import annotations
6
7import abc
8import enum
9import importlib
10import importlib.machinery
11import importlib.util
12import os
13import pathlib
14import sys
15import types
16import warnings
17import zipimport
18from collections.abc import Iterable, Iterator, Sequence
19from functools import lru_cache
20from pathlib import Path
21from typing import Any, Literal, NamedTuple, Protocol
22
23from astroid.const import PY310_PLUS
24from astroid.modutils import EXT_LIB_DIRS
25
26from . import util
27
28
29# The MetaPathFinder protocol comes from typeshed, which says:
30# Intentionally omits one deprecated and one optional method of `importlib.abc.MetaPathFinder`
31class _MetaPathFinder(Protocol):
32 def find_spec(
33 self,
34 fullname: str,
35 path: Sequence[str] | None,
36 target: types.ModuleType | None = ...,
37 ) -> importlib.machinery.ModuleSpec | None: ... # pragma: no cover
38
39
40class ModuleType(enum.Enum):
41 """Python module types used for ModuleSpec."""
42
43 C_BUILTIN = enum.auto()
44 C_EXTENSION = enum.auto()
45 PKG_DIRECTORY = enum.auto()
46 PY_CODERESOURCE = enum.auto()
47 PY_COMPILED = enum.auto()
48 PY_FROZEN = enum.auto()
49 PY_RESOURCE = enum.auto()
50 PY_SOURCE = enum.auto()
51 PY_ZIPMODULE = enum.auto()
52 PY_NAMESPACE = enum.auto()
53
54
55_MetaPathFinderModuleTypes: dict[str, ModuleType] = {
56 # Finders created by setuptools editable installs
57 "_EditableFinder": ModuleType.PY_SOURCE,
58 "_EditableNamespaceFinder": ModuleType.PY_NAMESPACE,
59 # Finders create by six
60 "_SixMetaPathImporter": ModuleType.PY_SOURCE,
61}
62
63_EditableFinderClasses: set[str] = {
64 "_EditableFinder",
65 "_EditableNamespaceFinder",
66}
67
68
69class ModuleSpec(NamedTuple):
70 """Defines a class similar to PEP 420's ModuleSpec.
71
72 A module spec defines a name of a module, its type, location
73 and where submodules can be found, if the module is a package.
74 """
75
76 name: str
77 type: ModuleType | None
78 location: str | None = None
79 origin: str | None = None
80 submodule_search_locations: Sequence[str] | None = None
81
82
83class Finder:
84 """A finder is a class which knows how to find a particular module."""
85
86 def __init__(self, path: Sequence[str] | None = None) -> None:
87 self._path = path or sys.path
88
89 @abc.abstractmethod
90 def find_module(
91 self,
92 modname: str,
93 module_parts: Sequence[str],
94 processed: list[str],
95 submodule_path: Sequence[str] | None,
96 ) -> ModuleSpec | None:
97 """Find the given module.
98
99 Each finder is responsible for each protocol of finding, as long as
100 they all return a ModuleSpec.
101
102 :param modname: The module which needs to be searched.
103 :param module_parts: It should be a list of strings,
104 where each part contributes to the module's
105 namespace.
106 :param processed: What parts from the module parts were processed
107 so far.
108 :param submodule_path: A list of paths where the module
109 can be looked into.
110 :returns: A ModuleSpec, describing how and where the module was found,
111 None, otherwise.
112 """
113
114 def contribute_to_path(
115 self, spec: ModuleSpec, processed: list[str]
116 ) -> Sequence[str] | None:
117 """Get a list of extra paths where this finder can search."""
118
119
120class ImportlibFinder(Finder):
121 """A finder based on the importlib module."""
122
123 _SUFFIXES: Sequence[tuple[str, ModuleType]] = (
124 [(s, ModuleType.C_EXTENSION) for s in importlib.machinery.EXTENSION_SUFFIXES]
125 + [(s, ModuleType.PY_SOURCE) for s in importlib.machinery.SOURCE_SUFFIXES]
126 + [(s, ModuleType.PY_COMPILED) for s in importlib.machinery.BYTECODE_SUFFIXES]
127 )
128
129 def find_module(
130 self,
131 modname: str,
132 module_parts: Sequence[str],
133 processed: list[str],
134 submodule_path: Sequence[str] | None,
135 ) -> ModuleSpec | None:
136 if submodule_path is not None:
137 submodule_path = list(submodule_path)
138 elif modname in sys.builtin_module_names:
139 return ModuleSpec(
140 name=modname,
141 location=None,
142 type=ModuleType.C_BUILTIN,
143 )
144 else:
145 try:
146 with warnings.catch_warnings():
147 warnings.filterwarnings("ignore", category=UserWarning)
148 spec = importlib.util.find_spec(modname)
149 if (
150 spec
151 and spec.loader # type: ignore[comparison-overlap] # noqa: E501
152 is importlib.machinery.FrozenImporter
153 ):
154 # No need for BuiltinImporter; builtins handled above
155 return ModuleSpec(
156 name=modname,
157 location=getattr(spec.loader_state, "filename", None),
158 type=ModuleType.PY_FROZEN,
159 )
160 except ValueError:
161 pass
162 submodule_path = sys.path
163
164 suffixes = (".py", ".pyi", importlib.machinery.BYTECODE_SUFFIXES[0])
165 for entry in submodule_path:
166 package_directory = os.path.join(entry, modname)
167 for suffix in suffixes:
168 package_file_name = "__init__" + suffix
169 file_path = os.path.join(package_directory, package_file_name)
170 if os.path.isfile(file_path):
171 return ModuleSpec(
172 name=modname,
173 location=package_directory,
174 type=ModuleType.PKG_DIRECTORY,
175 )
176 for suffix, type_ in ImportlibFinder._SUFFIXES:
177 file_name = modname + suffix
178 file_path = os.path.join(entry, file_name)
179 if os.path.isfile(file_path):
180 return ModuleSpec(name=modname, location=file_path, type=type_)
181 return None
182
183 def contribute_to_path(
184 self, spec: ModuleSpec, processed: list[str]
185 ) -> Sequence[str] | None:
186 if spec.location is None:
187 # Builtin.
188 return None
189
190 if _is_setuptools_namespace(Path(spec.location)):
191 # extend_path is called, search sys.path for module/packages
192 # of this name see pkgutil.extend_path documentation
193 path = [
194 os.path.join(p, *processed)
195 for p in sys.path
196 if os.path.isdir(os.path.join(p, *processed))
197 ]
198 elif spec.name == "distutils" and not any(
199 spec.location.lower().startswith(ext_lib_dir.lower())
200 for ext_lib_dir in EXT_LIB_DIRS
201 ):
202 # virtualenv below 20.0 patches distutils in an unexpected way
203 # so we just find the location of distutils that will be
204 # imported to avoid spurious import-error messages
205 # https://github.com/pylint-dev/pylint/issues/5645
206 # A regression test to create this scenario exists in release-tests.yml
207 # and can be triggered manually from GitHub Actions
208 distutils_spec = importlib.util.find_spec("distutils")
209 if distutils_spec and distutils_spec.origin:
210 origin_path = Path(
211 distutils_spec.origin
212 ) # e.g. .../distutils/__init__.py
213 path = [str(origin_path.parent)] # e.g. .../distutils
214 else:
215 path = [spec.location]
216 else:
217 path = [spec.location]
218 return path
219
220
221class ExplicitNamespacePackageFinder(ImportlibFinder):
222 """A finder for the explicit namespace packages."""
223
224 def find_module(
225 self,
226 modname: str,
227 module_parts: Sequence[str],
228 processed: list[str],
229 submodule_path: Sequence[str] | None,
230 ) -> ModuleSpec | None:
231 if processed:
232 modname = ".".join([*processed, modname])
233 if util.is_namespace(modname) and modname in sys.modules:
234 submodule_path = sys.modules[modname].__path__
235 return ModuleSpec(
236 name=modname,
237 location="",
238 origin="namespace",
239 type=ModuleType.PY_NAMESPACE,
240 submodule_search_locations=submodule_path,
241 )
242 return None
243
244 def contribute_to_path(
245 self, spec: ModuleSpec, processed: list[str]
246 ) -> Sequence[str] | None:
247 return spec.submodule_search_locations
248
249
250class ZipFinder(Finder):
251 """Finder that knows how to find a module inside zip files."""
252
253 def __init__(self, path: Sequence[str]) -> None:
254 super().__init__(path)
255 for entry_path in path:
256 if entry_path not in sys.path_importer_cache:
257 try:
258 sys.path_importer_cache[entry_path] = zipimport.zipimporter( # type: ignore[assignment]
259 entry_path
260 )
261 except zipimport.ZipImportError:
262 continue
263
264 def find_module(
265 self,
266 modname: str,
267 module_parts: Sequence[str],
268 processed: list[str],
269 submodule_path: Sequence[str] | None,
270 ) -> ModuleSpec | None:
271 try:
272 file_type, filename, path = _search_zip(module_parts)
273 except ImportError:
274 return None
275
276 return ModuleSpec(
277 name=modname,
278 location=filename,
279 origin="egg",
280 type=file_type,
281 submodule_search_locations=path,
282 )
283
284
285class PathSpecFinder(Finder):
286 """Finder based on importlib.machinery.PathFinder."""
287
288 def find_module(
289 self,
290 modname: str,
291 module_parts: Sequence[str],
292 processed: list[str],
293 submodule_path: Sequence[str] | None,
294 ) -> ModuleSpec | None:
295 spec = importlib.machinery.PathFinder.find_spec(modname, path=submodule_path)
296 if spec is not None:
297 is_namespace_pkg = spec.origin is None
298 location = spec.origin if not is_namespace_pkg else None
299 module_type = ModuleType.PY_NAMESPACE if is_namespace_pkg else None
300 return ModuleSpec(
301 name=spec.name,
302 location=location,
303 origin=spec.origin,
304 type=module_type,
305 submodule_search_locations=list(spec.submodule_search_locations or []),
306 )
307 return spec
308
309 def contribute_to_path(
310 self, spec: ModuleSpec, processed: list[str]
311 ) -> Sequence[str] | None:
312 if spec.type == ModuleType.PY_NAMESPACE:
313 return spec.submodule_search_locations
314 return None
315
316
317_SPEC_FINDERS = (
318 ImportlibFinder,
319 ZipFinder,
320 PathSpecFinder,
321 ExplicitNamespacePackageFinder,
322)
323
324
325def _is_setuptools_namespace(location: pathlib.Path) -> bool:
326 try:
327 with open(location / "__init__.py", "rb") as stream:
328 data = stream.read(4096)
329 except OSError:
330 return False
331 extend_path = b"pkgutil" in data and b"extend_path" in data
332 declare_namespace = (
333 b"pkg_resources" in data and b"declare_namespace(__name__)" in data
334 )
335 return extend_path or declare_namespace
336
337
338def _get_zipimporters() -> Iterator[tuple[str, zipimport.zipimporter]]:
339 for filepath, importer in sys.path_importer_cache.items():
340 if isinstance(importer, zipimport.zipimporter):
341 yield filepath, importer
342
343
344def _search_zip(
345 modpath: Sequence[str],
346) -> tuple[Literal[ModuleType.PY_ZIPMODULE], str, str]:
347 for filepath, importer in _get_zipimporters():
348 if PY310_PLUS:
349 found: Any = importer.find_spec(modpath[0])
350 else:
351 found = importer.find_module(modpath[0])
352 if found:
353 if PY310_PLUS:
354 if not importer.find_spec(os.path.sep.join(modpath)):
355 raise ImportError(
356 "No module named %s in %s/%s"
357 % (".".join(modpath[1:]), filepath, modpath)
358 )
359 elif not importer.find_module(os.path.sep.join(modpath)):
360 raise ImportError(
361 "No module named %s in %s/%s"
362 % (".".join(modpath[1:]), filepath, modpath)
363 )
364 return (
365 ModuleType.PY_ZIPMODULE,
366 os.path.abspath(filepath) + os.path.sep + os.path.sep.join(modpath),
367 filepath,
368 )
369 raise ImportError(f"No module named {'.'.join(modpath)}")
370
371
372def _find_spec_with_path(
373 search_path: Sequence[str],
374 modname: str,
375 module_parts: list[str],
376 processed: list[str],
377 submodule_path: Sequence[str] | None,
378) -> tuple[Finder | _MetaPathFinder, ModuleSpec]:
379 for finder in _SPEC_FINDERS:
380 finder_instance = finder(search_path)
381 spec = finder_instance.find_module(
382 modname, module_parts, processed, submodule_path
383 )
384 if spec is None:
385 continue
386 return finder_instance, spec
387
388 # Support for custom finders
389 for meta_finder in sys.meta_path:
390 # See if we support the customer import hook of the meta_finder
391 meta_finder_name = meta_finder.__class__.__name__
392 if meta_finder_name not in _MetaPathFinderModuleTypes:
393 # Setuptools>62 creates its EditableFinders dynamically and have
394 # "type" as their __class__.__name__. We check __name__ as well
395 # to see if we can support the finder.
396 try:
397 meta_finder_name = meta_finder.__name__ # type: ignore[attr-defined]
398 except AttributeError:
399 continue
400 if meta_finder_name not in _MetaPathFinderModuleTypes:
401 continue
402
403 module_type = _MetaPathFinderModuleTypes[meta_finder_name]
404
405 # Meta path finders are supposed to have a find_spec method since
406 # Python 3.4. However, some third-party finders do not implement it.
407 # PEP302 does not refer to find_spec as well.
408 # See: https://github.com/pylint-dev/astroid/pull/1752/
409 if not hasattr(meta_finder, "find_spec"):
410 continue
411
412 spec = meta_finder.find_spec(modname, submodule_path)
413 if spec:
414 return (
415 meta_finder,
416 ModuleSpec(
417 spec.name,
418 module_type,
419 spec.origin,
420 spec.origin,
421 spec.submodule_search_locations,
422 ),
423 )
424
425 raise ImportError(f"No module named {'.'.join(module_parts)}")
426
427
428def find_spec(modpath: Iterable[str], path: Iterable[str] | None = None) -> ModuleSpec:
429 """Find a spec for the given module.
430
431 :type modpath: list or tuple
432 :param modpath:
433 split module's name (i.e name of a module or package split
434 on '.'), with leading empty strings for explicit relative import
435
436 :type path: list or None
437 :param path:
438 optional list of path where the module or package should be
439 searched (use sys.path if nothing or None is given)
440
441 :rtype: ModuleSpec
442 :return: A module spec, which describes how the module was
443 found and where.
444 """
445 return _find_spec(tuple(modpath), tuple(path) if path else None)
446
447
448@lru_cache(maxsize=1024)
449def _find_spec(module_path: tuple, path: tuple) -> ModuleSpec:
450 _path = path or sys.path
451
452 # Need a copy for not mutating the argument.
453 modpath = list(module_path)
454
455 submodule_path = None
456 module_parts = modpath[:]
457 processed: list[str] = []
458
459 while modpath:
460 modname = modpath.pop(0)
461 finder, spec = _find_spec_with_path(
462 _path, modname, module_parts, processed, submodule_path or path
463 )
464 processed.append(modname)
465 if modpath:
466 if isinstance(finder, Finder):
467 submodule_path = finder.contribute_to_path(spec, processed)
468 # If modname is a package from an editable install, update submodule_path
469 # so that the next module in the path will be found inside of it using importlib.
470 # Existence of __name__ is guaranteed by _find_spec_with_path.
471 elif finder.__name__ in _EditableFinderClasses: # type: ignore[attr-defined]
472 submodule_path = spec.submodule_search_locations
473
474 if spec.type == ModuleType.PKG_DIRECTORY:
475 spec = spec._replace(submodule_search_locations=submodule_path)
476
477 return spec