spec.py 16 KB

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