finders.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. """Finders try to find right section for passed module name"""
  2. import importlib.machinery
  3. import inspect
  4. import os
  5. import os.path
  6. import re
  7. import sys
  8. import sysconfig
  9. from abc import ABCMeta, abstractmethod
  10. from contextlib import contextmanager
  11. from fnmatch import fnmatch
  12. from functools import lru_cache
  13. from glob import glob
  14. from pathlib import Path
  15. from typing import Dict, Iterable, Iterator, List, Optional, Pattern, Sequence, Tuple, Type
  16. from isort import sections
  17. from isort.settings import KNOWN_SECTION_MAPPING, Config
  18. from isort.utils import exists_case_sensitive
  19. try:
  20. from pipreqs import pipreqs # type: ignore
  21. except ImportError:
  22. pipreqs = None
  23. try:
  24. from pip_api import parse_requirements # type: ignore
  25. except ImportError:
  26. parse_requirements = None
  27. try:
  28. from requirementslib import Pipfile # type: ignore
  29. except ImportError:
  30. Pipfile = None
  31. @contextmanager
  32. def chdir(path: str) -> Iterator[None]:
  33. """Context manager for changing dir and restoring previous workdir after exit."""
  34. curdir = os.getcwd()
  35. os.chdir(path)
  36. try:
  37. yield
  38. finally:
  39. os.chdir(curdir)
  40. class BaseFinder(metaclass=ABCMeta):
  41. def __init__(self, config: Config) -> None:
  42. self.config = config
  43. @abstractmethod
  44. def find(self, module_name: str) -> Optional[str]:
  45. raise NotImplementedError
  46. class ForcedSeparateFinder(BaseFinder):
  47. def find(self, module_name: str) -> Optional[str]:
  48. for forced_separate in self.config.forced_separate:
  49. # Ensure all forced_separate patterns will match to end of string
  50. path_glob = forced_separate
  51. if not forced_separate.endswith("*"):
  52. path_glob = "%s*" % forced_separate
  53. if fnmatch(module_name, path_glob) or fnmatch(module_name, "." + path_glob):
  54. return forced_separate
  55. return None
  56. class LocalFinder(BaseFinder):
  57. def find(self, module_name: str) -> Optional[str]:
  58. if module_name.startswith("."):
  59. return "LOCALFOLDER"
  60. return None
  61. class KnownPatternFinder(BaseFinder):
  62. def __init__(self, config: Config) -> None:
  63. super().__init__(config)
  64. self.known_patterns: List[Tuple[Pattern[str], str]] = []
  65. for placement in reversed(config.sections):
  66. known_placement = KNOWN_SECTION_MAPPING.get(placement, placement).lower()
  67. config_key = f"known_{known_placement}"
  68. known_patterns = list(
  69. getattr(self.config, config_key, self.config.known_other.get(known_placement, []))
  70. )
  71. known_patterns = [
  72. pattern
  73. for known_pattern in known_patterns
  74. for pattern in self._parse_known_pattern(known_pattern)
  75. ]
  76. for known_pattern in known_patterns:
  77. regexp = "^" + known_pattern.replace("*", ".*").replace("?", ".?") + "$"
  78. self.known_patterns.append((re.compile(regexp), placement))
  79. def _parse_known_pattern(self, pattern: str) -> List[str]:
  80. """Expand pattern if identified as a directory and return found sub packages"""
  81. if pattern.endswith(os.path.sep):
  82. patterns = [
  83. filename
  84. for filename in os.listdir(os.path.join(self.config.directory, pattern))
  85. if os.path.isdir(os.path.join(self.config.directory, pattern, filename))
  86. ]
  87. else:
  88. patterns = [pattern]
  89. return patterns
  90. def find(self, module_name: str) -> Optional[str]:
  91. # Try to find most specific placement instruction match (if any)
  92. parts = module_name.split(".")
  93. module_names_to_check = (".".join(parts[:first_k]) for first_k in range(len(parts), 0, -1))
  94. for module_name_to_check in module_names_to_check:
  95. for pattern, placement in self.known_patterns:
  96. if pattern.match(module_name_to_check):
  97. return placement
  98. return None
  99. class PathFinder(BaseFinder):
  100. def __init__(self, config: Config, path: str = ".") -> None:
  101. super().__init__(config)
  102. # restore the original import path (i.e. not the path to bin/isort)
  103. root_dir = os.path.abspath(path)
  104. src_dir = f"{root_dir}/src"
  105. self.paths = [root_dir, src_dir]
  106. # virtual env
  107. self.virtual_env = self.config.virtual_env or os.environ.get("VIRTUAL_ENV")
  108. if self.virtual_env:
  109. self.virtual_env = os.path.realpath(self.virtual_env)
  110. self.virtual_env_src = ""
  111. if self.virtual_env:
  112. self.virtual_env_src = f"{self.virtual_env}/src/"
  113. for venv_path in glob(f"{self.virtual_env}/lib/python*/site-packages"):
  114. if venv_path not in self.paths:
  115. self.paths.append(venv_path)
  116. for nested_venv_path in glob(f"{self.virtual_env}/lib/python*/*/site-packages"):
  117. if nested_venv_path not in self.paths:
  118. self.paths.append(nested_venv_path)
  119. for venv_src_path in glob(f"{self.virtual_env}/src/*"):
  120. if os.path.isdir(venv_src_path):
  121. self.paths.append(venv_src_path)
  122. # conda
  123. self.conda_env = self.config.conda_env or os.environ.get("CONDA_PREFIX") or ""
  124. if self.conda_env:
  125. self.conda_env = os.path.realpath(self.conda_env)
  126. for conda_path in glob(f"{self.conda_env}/lib/python*/site-packages"):
  127. if conda_path not in self.paths:
  128. self.paths.append(conda_path)
  129. for nested_conda_path in glob(f"{self.conda_env}/lib/python*/*/site-packages"):
  130. if nested_conda_path not in self.paths:
  131. self.paths.append(nested_conda_path)
  132. # handle case-insensitive paths on windows
  133. self.stdlib_lib_prefix = os.path.normcase(sysconfig.get_paths()["stdlib"])
  134. if self.stdlib_lib_prefix not in self.paths:
  135. self.paths.append(self.stdlib_lib_prefix)
  136. # add system paths
  137. for system_path in sys.path[1:]:
  138. if system_path not in self.paths:
  139. self.paths.append(system_path)
  140. def find(self, module_name: str) -> Optional[str]:
  141. for prefix in self.paths:
  142. package_path = "/".join((prefix, module_name.split(".")[0]))
  143. path_obj = Path(package_path).resolve()
  144. is_module = (
  145. exists_case_sensitive(package_path + ".py")
  146. or any(
  147. exists_case_sensitive(package_path + ext_suffix)
  148. for ext_suffix in importlib.machinery.EXTENSION_SUFFIXES
  149. )
  150. or exists_case_sensitive(package_path + "/__init__.py")
  151. )
  152. is_package = exists_case_sensitive(package_path) and os.path.isdir(package_path)
  153. if is_module or is_package:
  154. if (
  155. "site-packages" in prefix
  156. or "dist-packages" in prefix
  157. or (self.virtual_env and self.virtual_env_src in prefix)
  158. ):
  159. return sections.THIRDPARTY
  160. if os.path.normcase(prefix) == self.stdlib_lib_prefix:
  161. return sections.STDLIB
  162. if self.conda_env and self.conda_env in prefix:
  163. return sections.THIRDPARTY
  164. for src_path in self.config.src_paths:
  165. if src_path in path_obj.parents and not self.config.is_skipped(path_obj):
  166. return sections.FIRSTPARTY
  167. if os.path.normcase(prefix).startswith(self.stdlib_lib_prefix):
  168. return sections.STDLIB # pragma: no cover - edge case for one OS. Hard to test.
  169. return self.config.default_section
  170. return None
  171. class ReqsBaseFinder(BaseFinder):
  172. enabled = False
  173. def __init__(self, config: Config, path: str = ".") -> None:
  174. super().__init__(config)
  175. self.path = path
  176. if self.enabled:
  177. self.mapping = self._load_mapping()
  178. self.names = self._load_names()
  179. @abstractmethod
  180. def _get_names(self, path: str) -> Iterator[str]:
  181. raise NotImplementedError
  182. @abstractmethod
  183. def _get_files_from_dir(self, path: str) -> Iterator[str]:
  184. raise NotImplementedError
  185. @staticmethod
  186. def _load_mapping() -> Optional[Dict[str, str]]:
  187. """Return list of mappings `package_name -> module_name`
  188. Example:
  189. django-haystack -> haystack
  190. """
  191. if not pipreqs:
  192. return None
  193. path = os.path.dirname(inspect.getfile(pipreqs))
  194. path = os.path.join(path, "mapping")
  195. with open(path) as f:
  196. mappings: Dict[str, str] = {} # pypi_name: import_name
  197. for line in f:
  198. import_name, _, pypi_name = line.strip().partition(":")
  199. mappings[pypi_name] = import_name
  200. return mappings
  201. # return dict(tuple(line.strip().split(":")[::-1]) for line in f)
  202. def _load_names(self) -> List[str]:
  203. """Return list of thirdparty modules from requirements"""
  204. names = []
  205. for path in self._get_files():
  206. for name in self._get_names(path):
  207. names.append(self._normalize_name(name))
  208. return names
  209. @staticmethod
  210. def _get_parents(path: str) -> Iterator[str]:
  211. prev = ""
  212. while path != prev:
  213. prev = path
  214. yield path
  215. path = os.path.dirname(path)
  216. def _get_files(self) -> Iterator[str]:
  217. """Return paths to all requirements files"""
  218. path = os.path.abspath(self.path)
  219. if os.path.isfile(path):
  220. path = os.path.dirname(path)
  221. for path in self._get_parents(path):
  222. yield from self._get_files_from_dir(path)
  223. def _normalize_name(self, name: str) -> str:
  224. """Convert package name to module name
  225. Examples:
  226. Django -> django
  227. django-haystack -> django_haystack
  228. Flask-RESTFul -> flask_restful
  229. """
  230. if self.mapping:
  231. name = self.mapping.get(name.replace("-", "_"), name)
  232. return name.lower().replace("-", "_")
  233. def find(self, module_name: str) -> Optional[str]:
  234. # required lib not installed yet
  235. if not self.enabled:
  236. return None
  237. module_name, _sep, _submodules = module_name.partition(".")
  238. module_name = module_name.lower()
  239. if not module_name:
  240. return None
  241. for name in self.names:
  242. if module_name == name:
  243. return sections.THIRDPARTY
  244. return None
  245. class RequirementsFinder(ReqsBaseFinder):
  246. exts = (".txt", ".in")
  247. enabled = bool(parse_requirements)
  248. def _get_files_from_dir(self, path: str) -> Iterator[str]:
  249. """Return paths to requirements files from passed dir."""
  250. yield from self._get_files_from_dir_cached(path)
  251. @classmethod
  252. @lru_cache(maxsize=16)
  253. def _get_files_from_dir_cached(cls, path: str) -> List[str]:
  254. results = []
  255. for fname in os.listdir(path):
  256. if "requirements" not in fname:
  257. continue
  258. full_path = os.path.join(path, fname)
  259. # *requirements*/*.{txt,in}
  260. if os.path.isdir(full_path):
  261. for subfile_name in os.listdir(full_path):
  262. for ext in cls.exts:
  263. if subfile_name.endswith(ext):
  264. results.append(os.path.join(full_path, subfile_name))
  265. continue
  266. # *requirements*.{txt,in}
  267. if os.path.isfile(full_path):
  268. for ext in cls.exts:
  269. if fname.endswith(ext):
  270. results.append(full_path)
  271. break
  272. return results
  273. def _get_names(self, path: str) -> Iterator[str]:
  274. """Load required packages from path to requirements file"""
  275. yield from self._get_names_cached(path)
  276. @classmethod
  277. @lru_cache(maxsize=16)
  278. def _get_names_cached(cls, path: str) -> List[str]:
  279. result = []
  280. with chdir(os.path.dirname(path)):
  281. requirements = parse_requirements(path)
  282. for req in requirements.values():
  283. if req.name:
  284. result.append(req.name)
  285. return result
  286. class PipfileFinder(ReqsBaseFinder):
  287. enabled = bool(Pipfile)
  288. def _get_names(self, path: str) -> Iterator[str]:
  289. with chdir(path):
  290. project = Pipfile.load(path)
  291. for req in project.packages:
  292. yield req.name
  293. def _get_files_from_dir(self, path: str) -> Iterator[str]:
  294. if "Pipfile" in os.listdir(path):
  295. yield path
  296. class DefaultFinder(BaseFinder):
  297. def find(self, module_name: str) -> Optional[str]:
  298. return self.config.default_section
  299. class FindersManager:
  300. _default_finders_classes: Sequence[Type[BaseFinder]] = (
  301. ForcedSeparateFinder,
  302. LocalFinder,
  303. KnownPatternFinder,
  304. PathFinder,
  305. PipfileFinder,
  306. RequirementsFinder,
  307. DefaultFinder,
  308. )
  309. def __init__(
  310. self, config: Config, finder_classes: Optional[Iterable[Type[BaseFinder]]] = None
  311. ) -> None:
  312. self.verbose: bool = config.verbose
  313. if finder_classes is None:
  314. finder_classes = self._default_finders_classes
  315. finders: List[BaseFinder] = []
  316. for finder_cls in finder_classes:
  317. try:
  318. finders.append(finder_cls(config))
  319. except Exception as exception:
  320. # if one finder fails to instantiate isort can continue using the rest
  321. if self.verbose:
  322. print(
  323. (
  324. f"{finder_cls.__name__} encountered an error ({exception}) during "
  325. "instantiation and cannot be used"
  326. )
  327. )
  328. self.finders: Tuple[BaseFinder, ...] = tuple(finders)
  329. def find(self, module_name: str) -> Optional[str]:
  330. for finder in self.finders:
  331. try:
  332. section = finder.find(module_name)
  333. if section is not None:
  334. return section
  335. except Exception as exception:
  336. # isort has to be able to keep trying to identify the correct
  337. # import section even if one approach fails
  338. if self.verbose:
  339. print(
  340. f"{finder.__class__.__name__} encountered an error ({exception}) while "
  341. f"trying to identify the {module_name} module"
  342. )
  343. return None