spec.py 17 KB

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