settings.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932
  1. """isort/settings.py.
  2. Defines how the default settings for isort should be loaded
  3. """
  4. import configparser
  5. import fnmatch
  6. import os
  7. import posixpath
  8. import re
  9. import stat
  10. import subprocess # nosec # Needed for gitignore support.
  11. import sys
  12. from collections.abc import Callable, Iterable
  13. from dataclasses import dataclass, field
  14. from pathlib import Path
  15. from re import Pattern
  16. from typing import TYPE_CHECKING, Any
  17. from warnings import warn
  18. from . import sorting, stdlibs
  19. from .exceptions import (
  20. FormattingPluginDoesNotExist,
  21. InvalidSettingsPath,
  22. ProfileDoesNotExist,
  23. SortingFunctionDoesNotExist,
  24. UnsupportedSettings,
  25. )
  26. from .profiles import profiles as profiles
  27. from .sections import DEFAULT as SECTION_DEFAULTS
  28. from .sections import FIRSTPARTY, FUTURE, LOCALFOLDER, STDLIB, THIRDPARTY
  29. from .utils import Trie
  30. from .wrap_modes import WrapModes
  31. from .wrap_modes import from_string as wrap_mode_from_string
  32. if TYPE_CHECKING:
  33. from importlib.metadata import EntryPoints
  34. tomllib: Any
  35. else:
  36. if sys.version_info >= (3, 11):
  37. import tomllib
  38. else:
  39. from ._vendored import tomli as tomllib
  40. _SHEBANG_RE = re.compile(rb"^#!.*\bpython[23w]?\b")
  41. CYTHON_EXTENSIONS = frozenset({"pyx", "pxd"})
  42. SUPPORTED_EXTENSIONS = frozenset({"py", "pyi", *CYTHON_EXTENSIONS})
  43. BLOCKED_EXTENSIONS = frozenset({"pex"})
  44. FILE_SKIP_COMMENTS: tuple[str, ...] = (
  45. "isort:" + "skip_file",
  46. "isort: " + "skip_file",
  47. ) # Concatenated to avoid this file being skipped
  48. MAX_CONFIG_SEARCH_DEPTH: int = 25 # The number of parent directories to for a config file within
  49. STOP_CONFIG_SEARCH_ON_DIRS: tuple[str, ...] = (".git", ".hg")
  50. VALID_PY_TARGETS: tuple[str, ...] = tuple(
  51. target.replace("py", "") for target in dir(stdlibs) if not target.startswith("_")
  52. )
  53. CONFIG_SOURCES: tuple[str, ...] = (
  54. ".isort.cfg",
  55. "pyproject.toml",
  56. "setup.cfg",
  57. "tox.ini",
  58. ".editorconfig",
  59. )
  60. DEFAULT_SKIP: frozenset[str] = frozenset(
  61. {
  62. ".venv",
  63. "venv",
  64. ".tox",
  65. ".eggs",
  66. ".git",
  67. ".hg",
  68. ".mypy_cache",
  69. ".nox",
  70. ".svn",
  71. ".bzr",
  72. "_build",
  73. "buck-out",
  74. "build",
  75. "dist",
  76. ".pants.d",
  77. ".direnv",
  78. "node_modules",
  79. "__pypackages__",
  80. ".pytype",
  81. }
  82. )
  83. CONFIG_SECTIONS: dict[str, tuple[str, ...]] = {
  84. ".isort.cfg": ("settings", "isort"),
  85. "pyproject.toml": ("tool.isort",),
  86. "setup.cfg": ("isort", "tool:isort"),
  87. "tox.ini": ("isort", "tool:isort"),
  88. ".editorconfig": ("*", "*.py", "**.py", "*.{py}"),
  89. }
  90. FALLBACK_CONFIG_SECTIONS: tuple[str, ...] = ("isort", "tool:isort", "tool.isort")
  91. IMPORT_HEADING_PREFIX = "import_heading_"
  92. IMPORT_FOOTER_PREFIX = "import_footer_"
  93. KNOWN_PREFIX = "known_"
  94. KNOWN_SECTION_MAPPING: dict[str, str] = {
  95. STDLIB: "STANDARD_LIBRARY",
  96. FUTURE: "FUTURE_LIBRARY",
  97. FIRSTPARTY: "FIRST_PARTY",
  98. THIRDPARTY: "THIRD_PARTY",
  99. LOCALFOLDER: "LOCAL_FOLDER",
  100. }
  101. RUNTIME_SOURCE = "runtime"
  102. DEPRECATED_SETTINGS = ("not_skip", "keep_direct_and_as_imports")
  103. _STR_BOOLEAN_MAPPING = {
  104. "y": True,
  105. "yes": True,
  106. "t": True,
  107. "on": True,
  108. "1": True,
  109. "true": True,
  110. "n": False,
  111. "no": False,
  112. "f": False,
  113. "off": False,
  114. "0": False,
  115. "false": False,
  116. }
  117. @dataclass(frozen=True)
  118. class _Config:
  119. """Defines the data schema and defaults used for isort configuration.
  120. NOTE: known lists, such as known_standard_library, are intentionally not complete as they are
  121. dynamically determined later on.
  122. """
  123. py_version: str = "3"
  124. force_to_top: frozenset[str] = frozenset()
  125. skip: frozenset[str] = DEFAULT_SKIP
  126. extend_skip: frozenset[str] = frozenset()
  127. skip_glob: frozenset[str] = frozenset()
  128. extend_skip_glob: frozenset[str] = frozenset()
  129. skip_gitignore: bool = False
  130. line_length: int = 79
  131. wrap_length: int = 0
  132. line_ending: str = ""
  133. sections: tuple[str, ...] = SECTION_DEFAULTS
  134. no_sections: bool = False
  135. known_future_library: frozenset[str] = frozenset(("__future__",))
  136. known_third_party: frozenset[str] = frozenset()
  137. known_first_party: frozenset[str] = frozenset()
  138. known_local_folder: frozenset[str] = frozenset()
  139. known_standard_library: frozenset[str] = frozenset()
  140. extra_standard_library: frozenset[str] = frozenset()
  141. known_other: dict[str, frozenset[str]] = field(default_factory=dict)
  142. multi_line_output: WrapModes = WrapModes.GRID # type: ignore
  143. forced_separate: tuple[str, ...] = ()
  144. indent: str = " " * 4
  145. comment_prefix: str = " #"
  146. length_sort: bool = False
  147. length_sort_straight: bool = False
  148. length_sort_sections: frozenset[str] = frozenset()
  149. add_imports: frozenset[str] = frozenset()
  150. remove_imports: frozenset[str] = frozenset()
  151. append_only: bool = False
  152. reverse_relative: bool = False
  153. force_single_line: bool = False
  154. single_line_exclusions: tuple[str, ...] = ()
  155. default_section: str = THIRDPARTY
  156. import_headings: dict[str, str] = field(default_factory=dict)
  157. import_footers: dict[str, str] = field(default_factory=dict)
  158. balanced_wrapping: bool = False
  159. use_parentheses: bool = False
  160. order_by_type: bool = True
  161. atomic: bool = False
  162. lines_before_imports: int = -1
  163. lines_after_imports: int = -1
  164. lines_between_sections: int = 1
  165. lines_between_types: int = 0
  166. combine_as_imports: bool = False
  167. combine_star: bool = False
  168. include_trailing_comma: bool = False
  169. from_first: bool = False
  170. verbose: bool = False
  171. quiet: bool = False
  172. force_adds: bool = False
  173. force_alphabetical_sort_within_sections: bool = False
  174. force_alphabetical_sort: bool = False
  175. force_grid_wrap: int = 0
  176. force_sort_within_sections: bool = False
  177. lexicographical: bool = False
  178. group_by_package: bool = False
  179. ignore_whitespace: bool = False
  180. no_lines_before: frozenset[str] = frozenset()
  181. no_inline_sort: bool = False
  182. ignore_comments: bool = False
  183. case_sensitive: bool = False
  184. sources: tuple[dict[str, Any], ...] = ()
  185. virtual_env: str = ""
  186. conda_env: str = ""
  187. ensure_newline_before_comments: bool = False
  188. directory: str = ""
  189. profile: str = ""
  190. honor_noqa: bool = False
  191. src_paths: tuple[Path, ...] = ()
  192. remove_redundant_aliases: bool = False
  193. float_to_top: bool = False
  194. filter_files: bool = False
  195. formatter: str = ""
  196. formatting_function: Callable[[str, str, object], str] | None = None
  197. color_output: bool = False
  198. treat_comments_as_code: frozenset[str] = frozenset()
  199. treat_all_comments_as_code: bool = False
  200. supported_extensions: frozenset[str] = SUPPORTED_EXTENSIONS
  201. blocked_extensions: frozenset[str] = BLOCKED_EXTENSIONS
  202. constants: frozenset[str] = frozenset()
  203. classes: frozenset[str] = frozenset()
  204. variables: frozenset[str] = frozenset()
  205. dedup_headings: bool = False
  206. only_sections: bool = False
  207. only_modified: bool = False
  208. combine_straight_imports: bool = False
  209. auto_identify_namespace_packages: bool = True
  210. namespace_packages: frozenset[str] = frozenset()
  211. follow_links: bool = True
  212. indented_import_headings: bool = True
  213. honor_case_in_force_sorted_sections: bool = False
  214. sort_relative_in_force_sorted_sections: bool = False
  215. overwrite_in_place: bool = False
  216. reverse_sort: bool = False
  217. star_first: bool = False
  218. import_dependencies = dict[str, str]
  219. git_ls_files: dict[Path, set[str]] = field(default_factory=dict)
  220. format_error: str = "{error}: {message}"
  221. format_success: str = "{success}: {message}"
  222. sort_order: str = "natural"
  223. sort_reexports: bool = False
  224. split_on_trailing_comma: bool = False
  225. def __post_init__(self) -> None:
  226. py_version = self.py_version
  227. if py_version == "auto": # pragma: no cover
  228. py_version = f"{sys.version_info.major}{sys.version_info.minor}"
  229. if py_version not in VALID_PY_TARGETS:
  230. raise ValueError(
  231. f"The python version {py_version} is not supported. "
  232. "You can set a python version with the -py or --python-version flag. "
  233. f"The following versions are supported: {VALID_PY_TARGETS}"
  234. )
  235. if py_version != "all":
  236. object.__setattr__(self, "py_version", f"py{py_version}")
  237. if not self.known_standard_library:
  238. object.__setattr__(
  239. self, "known_standard_library", frozenset(getattr(stdlibs, self.py_version).stdlib)
  240. )
  241. if self.multi_line_output == WrapModes.VERTICAL_GRID_GROUPED_NO_COMMA: # type: ignore
  242. vertical_grid_grouped = WrapModes.VERTICAL_GRID_GROUPED # type: ignore
  243. object.__setattr__(self, "multi_line_output", vertical_grid_grouped)
  244. if self.force_alphabetical_sort:
  245. object.__setattr__(self, "force_alphabetical_sort_within_sections", True)
  246. object.__setattr__(self, "no_sections", True)
  247. object.__setattr__(self, "lines_between_types", 1)
  248. object.__setattr__(self, "from_first", True)
  249. if self.wrap_length > self.line_length:
  250. raise ValueError(
  251. "wrap_length must be set lower than or equal to line_length: "
  252. f"{self.wrap_length} > {self.line_length}."
  253. )
  254. def __hash__(self) -> int:
  255. return id(self)
  256. _DEFAULT_SETTINGS = {**vars(_Config()), "source": "defaults"}
  257. class Config(_Config):
  258. def __init__(
  259. self,
  260. settings_file: str = "",
  261. settings_path: str = "",
  262. config: _Config | None = None,
  263. **config_overrides: Any,
  264. ):
  265. self._known_patterns: list[tuple[Pattern[str], str]] | None = None
  266. self._section_comments: tuple[str, ...] | None = None
  267. self._section_comments_end: tuple[str, ...] | None = None
  268. self._skips: frozenset[str] | None = None
  269. self._skip_globs: frozenset[str] | None = None
  270. self._sorting_function: Callable[..., list[str]] | None = None
  271. if config:
  272. config_vars = vars(config).copy()
  273. config_vars.update(config_overrides)
  274. config_vars["py_version"] = config_vars["py_version"].replace("py", "")
  275. config_vars.pop("_known_patterns")
  276. config_vars.pop("_section_comments")
  277. config_vars.pop("_section_comments_end")
  278. config_vars.pop("_skips")
  279. config_vars.pop("_skip_globs")
  280. config_vars.pop("_sorting_function")
  281. super().__init__(**config_vars)
  282. return
  283. # We can't use self.quiet to conditionally show warnings before super.__init__() is called
  284. # at the end of this method. _Config is also frozen so setting self.quiet isn't possible.
  285. # Therefore we extract quiet early here in a variable and use that in warning conditions.
  286. quiet = config_overrides.get("quiet", False)
  287. sources: list[dict[str, Any]] = [_DEFAULT_SETTINGS]
  288. config_settings: dict[str, Any]
  289. project_root: str
  290. if settings_file:
  291. config_settings = _get_config_data(
  292. settings_file,
  293. CONFIG_SECTIONS.get(os.path.basename(settings_file), FALLBACK_CONFIG_SECTIONS),
  294. )
  295. project_root = os.path.dirname(settings_file)
  296. if not config_settings and not quiet:
  297. warn(
  298. f"A custom settings file was specified: {settings_file} but no configuration "
  299. "was found inside. This can happen when [settings] is used as the config "
  300. "header instead of [isort]. "
  301. "See: https://pycqa.github.io/isort/docs/configuration/config_files"
  302. "#custom-config-files for more information.",
  303. stacklevel=2,
  304. )
  305. elif settings_path:
  306. if not os.path.exists(settings_path):
  307. raise InvalidSettingsPath(settings_path)
  308. settings_path = os.path.abspath(settings_path)
  309. project_root, config_settings = _find_config(settings_path)
  310. else:
  311. config_settings = {}
  312. project_root = os.getcwd()
  313. profile_name = config_overrides.get("profile", config_settings.get("profile", ""))
  314. profile: dict[str, Any] = {}
  315. if profile_name:
  316. if profile_name not in profiles:
  317. for plugin in entry_points(group="isort.profiles"):
  318. profiles.setdefault(plugin.name, plugin.load())
  319. if profile_name not in profiles:
  320. raise ProfileDoesNotExist(profile_name)
  321. profile = profiles[profile_name].copy()
  322. profile["source"] = f"{profile_name} profile"
  323. sources.append(profile)
  324. if config_settings:
  325. sources.append(config_settings)
  326. if config_overrides:
  327. config_overrides["source"] = RUNTIME_SOURCE
  328. sources.append(config_overrides)
  329. combined_config = {**profile, **config_settings, **config_overrides}
  330. if "indent" in combined_config:
  331. indent = str(combined_config["indent"])
  332. if indent.isdigit():
  333. indent = " " * int(indent)
  334. else:
  335. indent = indent.strip("'").strip('"')
  336. if indent.lower() == "tab":
  337. indent = "\t"
  338. combined_config["indent"] = indent
  339. known_other = {}
  340. import_headings = {}
  341. import_footers = {}
  342. for key, value in tuple(combined_config.items()):
  343. # Collect all known sections beyond those that have direct entries
  344. if key.startswith(KNOWN_PREFIX) and key not in (
  345. "known_standard_library",
  346. "known_future_library",
  347. "known_third_party",
  348. "known_first_party",
  349. "known_local_folder",
  350. ):
  351. import_heading = key[len(KNOWN_PREFIX) :].lower()
  352. maps_to_section = import_heading.upper()
  353. combined_config.pop(key)
  354. if maps_to_section in KNOWN_SECTION_MAPPING:
  355. section_name = f"known_{KNOWN_SECTION_MAPPING[maps_to_section].lower()}"
  356. if section_name in combined_config and not quiet:
  357. warn(
  358. f"Can't set both {key} and {section_name} in the same config file.\n"
  359. f"Default to {section_name} if unsure."
  360. "\n\n"
  361. "See: https://pycqa.github.io/isort/"
  362. "#custom-sections-and-ordering.",
  363. stacklevel=2,
  364. )
  365. else:
  366. combined_config[section_name] = frozenset(value)
  367. else:
  368. known_other[import_heading] = frozenset(value)
  369. if maps_to_section not in combined_config.get("sections", ()) and not quiet:
  370. warn(
  371. f"`{key}` setting is defined, but {maps_to_section} is not"
  372. " included in `sections` config option:"
  373. f" {combined_config.get('sections', SECTION_DEFAULTS)}.\n\n"
  374. "See: https://pycqa.github.io/isort/"
  375. "#custom-sections-and-ordering.",
  376. stacklevel=2,
  377. )
  378. if key.startswith(IMPORT_HEADING_PREFIX):
  379. import_headings[key[len(IMPORT_HEADING_PREFIX) :].lower()] = str(value)
  380. if key.startswith(IMPORT_FOOTER_PREFIX):
  381. import_footers[key[len(IMPORT_FOOTER_PREFIX) :].lower()] = str(value)
  382. # Coerce all provided config values into their correct type
  383. default_value = _DEFAULT_SETTINGS.get(key, None)
  384. if default_value is None:
  385. continue
  386. combined_config[key] = type(default_value)(value)
  387. for section in combined_config.get("sections", ()):
  388. if section in SECTION_DEFAULTS:
  389. continue
  390. if section.lower() not in known_other:
  391. config_keys = ", ".join(known_other.keys())
  392. warn(
  393. f"`sections` setting includes {section}, but no known_{section.lower()} "
  394. "is defined. "
  395. f"The following known_SECTION config options are defined: {config_keys}.",
  396. stacklevel=2,
  397. )
  398. if "directory" not in combined_config:
  399. combined_config["directory"] = (
  400. os.path.dirname(config_settings["source"])
  401. if config_settings.get("source", None)
  402. else os.getcwd()
  403. )
  404. path_root = Path(combined_config.get("directory", project_root)).resolve()
  405. path_root = path_root if path_root.is_dir() else path_root.parent
  406. if "src_paths" not in combined_config:
  407. combined_config["src_paths"] = (path_root / "src", path_root)
  408. else:
  409. src_paths: list[Path] = []
  410. for src_path in combined_config.get("src_paths", ()):
  411. full_paths = (
  412. path_root.glob(src_path) if "*" in str(src_path) else [path_root / src_path]
  413. )
  414. for path in full_paths:
  415. if path not in src_paths:
  416. src_paths.append(path)
  417. combined_config["src_paths"] = tuple(src_paths)
  418. if "formatter" in combined_config:
  419. for plugin in entry_points(group="isort.formatters"):
  420. if plugin.name == combined_config["formatter"]:
  421. combined_config["formatting_function"] = plugin.load()
  422. break
  423. else:
  424. raise FormattingPluginDoesNotExist(combined_config["formatter"])
  425. # Remove any config values that are used for creating config object but
  426. # aren't defined in dataclass
  427. combined_config.pop("source", None)
  428. combined_config.pop("sources", None)
  429. combined_config.pop("runtime_src_paths", None)
  430. deprecated_options_used = [
  431. option for option in combined_config if option in DEPRECATED_SETTINGS
  432. ]
  433. if deprecated_options_used:
  434. for deprecated_option in deprecated_options_used:
  435. combined_config.pop(deprecated_option)
  436. if not quiet:
  437. warn(
  438. "W0503: Deprecated config options were used: "
  439. f"{', '.join(deprecated_options_used)}."
  440. "Please see the 5.0.0 upgrade guide: "
  441. "https://pycqa.github.io/isort/docs/upgrade_guides/5.0.0.html",
  442. stacklevel=2,
  443. )
  444. if known_other:
  445. combined_config["known_other"] = known_other
  446. if import_headings:
  447. for import_heading_key in import_headings:
  448. combined_config.pop(f"{IMPORT_HEADING_PREFIX}{import_heading_key}")
  449. combined_config["import_headings"] = import_headings
  450. if import_footers:
  451. for import_footer_key in import_footers:
  452. combined_config.pop(f"{IMPORT_FOOTER_PREFIX}{import_footer_key}")
  453. combined_config["import_footers"] = import_footers
  454. unsupported_config_errors = {}
  455. for option in set(combined_config.keys()).difference(
  456. getattr(_Config, "__dataclass_fields__", {}).keys()
  457. ):
  458. for source in reversed(sources):
  459. if option in source:
  460. unsupported_config_errors[option] = {
  461. "value": source[option],
  462. "source": source["source"],
  463. }
  464. if unsupported_config_errors:
  465. raise UnsupportedSettings(unsupported_config_errors)
  466. super().__init__(sources=tuple(sources), **combined_config)
  467. def is_supported_filetype(self, file_name: str) -> bool:
  468. _root, ext = os.path.splitext(file_name)
  469. ext = ext.lstrip(".")
  470. if ext in self.supported_extensions:
  471. return True
  472. if ext in self.blocked_extensions:
  473. return False
  474. # Skip editor backup files.
  475. if file_name.endswith("~"):
  476. return False
  477. try:
  478. if stat.S_ISFIFO(os.stat(file_name).st_mode):
  479. return False
  480. except OSError:
  481. pass
  482. try:
  483. with open(file_name, "rb") as fp:
  484. line = fp.readline(100)
  485. except OSError:
  486. return False
  487. return bool(_SHEBANG_RE.match(line))
  488. def _check_folder_git_ls_files(self, folder: str) -> Path | None:
  489. env = {**os.environ, "LANG": "C.UTF-8"}
  490. try:
  491. topfolder_result = subprocess.check_output( # nosec # skipcq: PYL-W1510
  492. ["git", "-C", folder, "rev-parse", "--show-toplevel"], encoding="utf-8", env=env
  493. )
  494. except subprocess.CalledProcessError:
  495. return None
  496. git_folder = Path(topfolder_result.rstrip()).resolve()
  497. # files committed to git
  498. tracked_files = (
  499. subprocess.check_output( # nosec # skipcq: PYL-W1510
  500. ["git", "-C", str(git_folder), "ls-files", "-z"],
  501. encoding="utf-8",
  502. env=env,
  503. )
  504. .rstrip("\0")
  505. .split("\0")
  506. )
  507. # files that haven't been committed yet, but aren't ignored
  508. tracked_files_others = (
  509. subprocess.check_output( # nosec # skipcq: PYL-W1510
  510. ["git", "-C", str(git_folder), "ls-files", "-z", "--others", "--exclude-standard"],
  511. encoding="utf-8",
  512. env=env,
  513. )
  514. .rstrip("\0")
  515. .split("\0")
  516. )
  517. self.git_ls_files[git_folder] = {
  518. str(git_folder / Path(f)) for f in tracked_files + tracked_files_others
  519. }
  520. return git_folder
  521. def is_skipped(self, file_path: Path) -> bool:
  522. """Returns True if the file and/or folder should be skipped based on current settings."""
  523. if self.directory and Path(self.directory) in file_path.resolve().parents:
  524. file_name = os.path.relpath(file_path.resolve(), self.directory)
  525. else:
  526. file_name = str(file_path)
  527. os_path = str(file_path)
  528. normalized_path = os_path.replace("\\", "/")
  529. if normalized_path[1:2] == ":":
  530. normalized_path = normalized_path[2:]
  531. for skip_path in self.skips:
  532. if posixpath.abspath(normalized_path) == posixpath.abspath(
  533. skip_path.replace("\\", "/")
  534. ):
  535. return True
  536. position = os.path.split(file_name)
  537. while position[1]:
  538. if position[1] in self.skips:
  539. return True
  540. position = os.path.split(position[0])
  541. for sglob in self.skip_globs:
  542. if fnmatch.fnmatch(file_name, sglob) or fnmatch.fnmatch("/" + file_name, sglob):
  543. return True
  544. if not (os.path.isfile(os_path) or os.path.isdir(os_path) or os.path.islink(os_path)):
  545. return True
  546. if self.skip_gitignore:
  547. if file_path.name == ".git": # pragma: no cover
  548. return True
  549. git_folder = None
  550. file_paths = [file_path, file_path.resolve()]
  551. for folder in self.git_ls_files:
  552. if any(folder in path.parents for path in file_paths):
  553. git_folder = folder
  554. break
  555. else:
  556. git_folder = self._check_folder_git_ls_files(str(file_path.parent))
  557. # git_ls_files are good files you should parse. If you're not in the allow list, skip.
  558. if (
  559. git_folder
  560. and not file_path.is_dir()
  561. and str(file_path.resolve()) not in self.git_ls_files[git_folder]
  562. ):
  563. return True
  564. return False
  565. @property
  566. def known_patterns(self) -> list[tuple[Pattern[str], str]]:
  567. if self._known_patterns is not None:
  568. return self._known_patterns
  569. self._known_patterns = []
  570. pattern_sections = [STDLIB] + [section for section in self.sections if section != STDLIB]
  571. for placement in reversed(pattern_sections):
  572. known_placement = KNOWN_SECTION_MAPPING.get(placement, placement).lower()
  573. config_key = f"{KNOWN_PREFIX}{known_placement}"
  574. known_modules = getattr(self, config_key, self.known_other.get(known_placement, ()))
  575. extra_modules = getattr(self, f"extra_{known_placement}", ())
  576. all_modules = set(extra_modules).union(known_modules)
  577. known_patterns = [
  578. pattern
  579. for known_pattern in all_modules
  580. for pattern in self._parse_known_pattern(known_pattern)
  581. ]
  582. for known_pattern in known_patterns:
  583. regexp = "^" + known_pattern.replace("*", ".*").replace("?", ".?") + "$"
  584. self._known_patterns.append((re.compile(regexp), placement))
  585. return self._known_patterns
  586. @property
  587. def section_comments(self) -> tuple[str, ...]:
  588. if self._section_comments is not None:
  589. return self._section_comments
  590. self._section_comments = tuple(f"# {heading}" for heading in self.import_headings.values())
  591. return self._section_comments
  592. @property
  593. def section_comments_end(self) -> tuple[str, ...]:
  594. if self._section_comments_end is not None:
  595. return self._section_comments_end
  596. self._section_comments_end = tuple(f"# {footer}" for footer in self.import_footers.values())
  597. return self._section_comments_end
  598. @property
  599. def skips(self) -> frozenset[str]:
  600. if self._skips is not None:
  601. return self._skips
  602. self._skips = self.skip.union(self.extend_skip)
  603. return self._skips
  604. @property
  605. def skip_globs(self) -> frozenset[str]:
  606. if self._skip_globs is not None:
  607. return self._skip_globs
  608. self._skip_globs = self.skip_glob.union(self.extend_skip_glob)
  609. return self._skip_globs
  610. @property
  611. def sorting_function(self) -> Callable[..., list[str]]:
  612. if self._sorting_function is not None:
  613. return self._sorting_function
  614. if self.sort_order == "natural":
  615. self._sorting_function = sorting.naturally
  616. elif self.sort_order == "native":
  617. self._sorting_function = sorted
  618. else:
  619. available_sort_orders = ["natural", "native"]
  620. for sort_plugin in entry_points(group="isort.sort_function"):
  621. available_sort_orders.append(sort_plugin.name)
  622. if sort_plugin.name == self.sort_order:
  623. self._sorting_function = sort_plugin.load()
  624. break
  625. else:
  626. raise SortingFunctionDoesNotExist(self.sort_order, available_sort_orders)
  627. return self._sorting_function
  628. def _parse_known_pattern(self, pattern: str) -> list[str]:
  629. """Expand pattern if identified as a directory and return found sub packages"""
  630. if pattern.endswith(os.path.sep):
  631. patterns = [
  632. filename
  633. for filename in os.listdir(os.path.join(self.directory, pattern))
  634. if os.path.isdir(os.path.join(self.directory, pattern, filename))
  635. ]
  636. else:
  637. patterns = [pattern]
  638. return patterns
  639. def _get_str_to_type_converter(setting_name: str) -> Callable[[str], Any] | type[Any]:
  640. type_converter: Callable[[str], Any] | type[Any] = type(_DEFAULT_SETTINGS.get(setting_name, ""))
  641. if type_converter == WrapModes:
  642. type_converter = wrap_mode_from_string
  643. return type_converter
  644. def _as_list(value: str) -> list[str]:
  645. if isinstance(value, list):
  646. return [item.strip() for item in value]
  647. filtered = [item.strip() for item in value.replace("\n", ",").split(",") if item.strip()]
  648. return filtered
  649. def _abspaths(cwd: str, values: Iterable[str]) -> set[str]:
  650. paths = {
  651. (
  652. os.path.join(cwd, value)
  653. if not value.startswith(os.path.sep) and value.endswith(os.path.sep)
  654. else value
  655. )
  656. for value in values
  657. }
  658. return paths
  659. def _find_config(path: str) -> tuple[str, dict[str, Any]]:
  660. current_directory = path
  661. tries = 0
  662. while current_directory and tries < MAX_CONFIG_SEARCH_DEPTH:
  663. for config_file_name in CONFIG_SOURCES:
  664. potential_config_file = os.path.join(current_directory, config_file_name)
  665. if os.path.isfile(potential_config_file):
  666. config_data: dict[str, Any]
  667. try:
  668. config_data = _get_config_data(
  669. potential_config_file, CONFIG_SECTIONS[config_file_name]
  670. )
  671. except Exception:
  672. warn(
  673. f"Failed to pull configuration information from {potential_config_file}",
  674. stacklevel=2,
  675. )
  676. config_data = {}
  677. if config_data:
  678. return (current_directory, config_data)
  679. for stop_dir in STOP_CONFIG_SEARCH_ON_DIRS:
  680. if os.path.isdir(os.path.join(current_directory, stop_dir)):
  681. return (current_directory, {})
  682. new_directory = os.path.split(current_directory)[0]
  683. if new_directory == current_directory:
  684. break
  685. current_directory = new_directory
  686. tries += 1
  687. return (path, {})
  688. def find_all_configs(path: str) -> Trie:
  689. """
  690. Looks for config files in the path provided and in all of its sub-directories.
  691. Parses and stores any config file encountered in a trie and returns the root of
  692. the trie
  693. """
  694. trie_root = Trie("default", {})
  695. for dirpath, _, _ in os.walk(path):
  696. for config_file_name in CONFIG_SOURCES:
  697. potential_config_file = os.path.join(dirpath, config_file_name)
  698. if os.path.isfile(potential_config_file):
  699. config_data: dict[str, Any]
  700. try:
  701. config_data = _get_config_data(
  702. potential_config_file, CONFIG_SECTIONS[config_file_name]
  703. )
  704. except Exception:
  705. warn(
  706. f"Failed to pull configuration information from {potential_config_file}",
  707. stacklevel=2,
  708. )
  709. config_data = {}
  710. if config_data:
  711. trie_root.insert(potential_config_file, config_data)
  712. break
  713. return trie_root
  714. def _get_config_data(file_path: str, sections: tuple[str, ...]) -> dict[str, Any]:
  715. settings: dict[str, Any] = {}
  716. if file_path.endswith(".toml"):
  717. with open(file_path, "rb") as bin_config_file:
  718. config = tomllib.load(bin_config_file)
  719. for section in sections:
  720. config_section = config
  721. for key in section.split("."):
  722. config_section = config_section.get(key, {})
  723. settings.update(config_section)
  724. else:
  725. with open(file_path, encoding="utf-8") as config_file:
  726. if file_path.endswith(".editorconfig"):
  727. line = "\n"
  728. last_position = config_file.tell()
  729. while line:
  730. line = config_file.readline()
  731. if "[" in line:
  732. config_file.seek(last_position)
  733. break
  734. last_position = config_file.tell()
  735. config = configparser.ConfigParser(strict=False)
  736. config.read_file(config_file)
  737. for section in sections:
  738. if section.startswith("*.{") and section.endswith("}"):
  739. extension = section[len("*.{") : -1]
  740. for config_key in config:
  741. if (
  742. config_key.startswith("*.{")
  743. and config_key.endswith("}")
  744. and extension
  745. in (text.strip() for text in config_key[len("*.{") : -1].split(","))
  746. ):
  747. settings.update(config.items(config_key))
  748. elif config.has_section(section):
  749. settings.update(config.items(section))
  750. if settings:
  751. settings["source"] = file_path
  752. if file_path.endswith(".editorconfig"):
  753. indent_style = settings.pop("indent_style", "").strip()
  754. indent_size = settings.pop("indent_size", "").strip()
  755. if indent_size == "tab":
  756. indent_size = settings.pop("tab_width", "").strip()
  757. if indent_style == "space":
  758. settings["indent"] = " " * ((indent_size and int(indent_size)) or 4)
  759. elif indent_style == "tab":
  760. settings["indent"] = "\t" * ((indent_size and int(indent_size)) or 1)
  761. max_line_length = settings.pop("max_line_length", "").strip()
  762. if max_line_length and (max_line_length == "off" or max_line_length.isdigit()):
  763. settings["line_length"] = (
  764. float("inf") if max_line_length == "off" else int(max_line_length)
  765. )
  766. settings = {
  767. key: value
  768. for key, value in settings.items()
  769. if key in _DEFAULT_SETTINGS or key.startswith(KNOWN_PREFIX)
  770. }
  771. for key, value in settings.items():
  772. existing_value_type = _get_str_to_type_converter(key)
  773. if existing_value_type is tuple:
  774. settings[key] = tuple(_as_list(value))
  775. elif existing_value_type is frozenset:
  776. settings[key] = frozenset(_as_list(settings.get(key))) # type: ignore
  777. elif existing_value_type is bool:
  778. # Only some configuration formats support native boolean values.
  779. if not isinstance(value, bool):
  780. value = _as_bool(value)
  781. settings[key] = value
  782. elif key.startswith(KNOWN_PREFIX):
  783. settings[key] = _abspaths(os.path.dirname(file_path), _as_list(value))
  784. elif key == "force_grid_wrap":
  785. try:
  786. result = existing_value_type(value)
  787. except ValueError: # backwards compatibility for true / false force grid wrap
  788. result = 0 if value.lower().strip() == "false" else 2
  789. settings[key] = result
  790. elif key == "comment_prefix":
  791. settings[key] = str(value).strip("'").strip('"')
  792. else:
  793. settings[key] = existing_value_type(value)
  794. return settings
  795. def _as_bool(value: str) -> bool:
  796. """Given a string value that represents True or False, returns the Boolean equivalent.
  797. Heavily inspired from distutils strtobool.
  798. """
  799. try:
  800. return _STR_BOOLEAN_MAPPING[value.lower()]
  801. except KeyError:
  802. raise ValueError(f"invalid truth value {value}")
  803. def entry_points(group: str) -> "EntryPoints":
  804. """Call entry_point after lazy loading it.
  805. TODO: The reason for lazy loading here are unknown.
  806. """
  807. from importlib.metadata import entry_points as ep # noqa: PLC0415
  808. return ep(group=group)
  809. DEFAULT_CONFIG = Config()