Skip to content

Commit b101d55

Browse files
authoredOct 15, 2021
Merge pull request #1820 from anirudnits/multiple_configs
Support for Multiple configs
2 parents d714eec + 8797ac7 commit b101d55

File tree

7 files changed

+368
-3
lines changed

7 files changed

+368
-3
lines changed
 

‎isort/api.py

+24-2
Original file line numberDiff line numberDiff line change
@@ -323,12 +323,23 @@ def check_file(
323323
- **extension**: The file extension that contains imports. Defaults to filename extension or py.
324324
- ****config_kwargs**: Any config modifications.
325325
"""
326+
file_config: Config = config
327+
328+
if "config_trie" in config_kwargs:
329+
config_trie = config_kwargs.pop("config_trie", None)
330+
if config_trie:
331+
config_info = config_trie.search(filename)
332+
if config.verbose:
333+
print(f"{config_info[0]} used for file {filename}")
334+
335+
file_config = Config(**config_info[1])
336+
326337
with io.File.read(filename) as source_file:
327338
return check_stream(
328339
source_file.stream,
329340
show_diff=show_diff,
330341
extension=extension,
331-
config=config,
342+
config=file_config,
332343
file_path=file_path or source_file.path,
333344
disregard_skip=disregard_skip,
334345
**config_kwargs,
@@ -380,9 +391,20 @@ def sort_file(
380391
the original file content.
381392
- ****config_kwargs**: Any config modifications.
382393
"""
394+
file_config: Config = config
395+
396+
if "config_trie" in config_kwargs:
397+
config_trie = config_kwargs.pop("config_trie", None)
398+
if config_trie:
399+
config_info = config_trie.search(filename)
400+
if config.verbose:
401+
print(f"{config_info[0]} used for file {filename}")
402+
403+
file_config = Config(**config_info[1])
404+
383405
with io.File.read(filename) as source_file:
384406
actual_file_path = file_path or source_file.path
385-
config = _config(path=actual_file_path, config=config, **config_kwargs)
407+
config = _config(path=actual_file_path, config=file_config, **config_kwargs)
386408
changed: bool = False
387409
try:
388410
if write_to_stdout:

‎isort/main.py

+25-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
from .format import create_terminal_printer
1616
from .logo import ASCII_ART
1717
from .profiles import profiles
18-
from .settings import VALID_PY_TARGETS, Config
18+
from .settings import VALID_PY_TARGETS, Config, find_all_configs
19+
from .utils import Trie
1920
from .wrap_modes import WrapModes
2021

2122
DEPRECATED_SINGLE_DASH_ARGS = {
@@ -259,6 +260,22 @@ def _build_arg_parser() -> argparse.ArgumentParser:
259260
help="Explicitly set the settings path or file instead of auto determining "
260261
"based on file location.",
261262
)
263+
general_group.add_argument(
264+
"--cr",
265+
"--config-root",
266+
dest="config_root",
267+
help="Explicitly set the config root for resolving all configs. When used "
268+
"with the --resolve-all-configs flag, isort will look at all sub-folders "
269+
"in this config root to resolve config files and sort files based on the "
270+
"closest available config(if any)",
271+
)
272+
general_group.add_argument(
273+
"--resolve-all-configs",
274+
dest="resolve_all_configs",
275+
action="store_true",
276+
help="Tells isort to resolve the configs for all sub-directories "
277+
"and sort files in terms of its closest config files.",
278+
)
262279
general_group.add_argument(
263280
"--profile",
264281
dest="profile",
@@ -1074,10 +1091,15 @@ def main(argv: Optional[Sequence[str]] = None, stdin: Optional[TextIOWrapper] =
10741091
stream_filename = config_dict.pop("filename", None)
10751092
ext_format = config_dict.pop("ext_format", None)
10761093
allow_root = config_dict.pop("allow_root", None)
1094+
resolve_all_configs = config_dict.pop("resolve_all_configs", False)
10771095
wrong_sorted_files = False
10781096
all_attempt_broken = False
10791097
no_valid_encodings = False
10801098

1099+
config_trie: Optional[Trie] = None
1100+
if resolve_all_configs:
1101+
config_trie = find_all_configs(config_dict.pop("config_root", "."))
1102+
10811103
if "src_paths" in config_dict:
10821104
config_dict["src_paths"] = {
10831105
Path(src_path).resolve() for src_path in config_dict.get("src_paths", ())
@@ -1165,6 +1187,7 @@ def main(argv: Optional[Sequence[str]] = None, stdin: Optional[TextIOWrapper] =
11651187
ask_to_apply=ask_to_apply,
11661188
write_to_stdout=write_to_stdout,
11671189
extension=ext_format,
1190+
config_trie=config_trie,
11681191
),
11691192
file_names,
11701193
)
@@ -1179,6 +1202,7 @@ def main(argv: Optional[Sequence[str]] = None, stdin: Optional[TextIOWrapper] =
11791202
show_diff=show_diff,
11801203
write_to_stdout=write_to_stdout,
11811204
extension=ext_format,
1205+
config_trie=config_trie,
11821206
)
11831207
for file_name in file_names
11841208
)

‎isort/settings.py

+30
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from .profiles import profiles
4242
from .sections import DEFAULT as SECTION_DEFAULTS
4343
from .sections import FIRSTPARTY, FUTURE, LOCALFOLDER, STDLIB, THIRDPARTY
44+
from .utils import Trie
4445
from .wrap_modes import WrapModes
4546
from .wrap_modes import from_string as wrap_mode_from_string
4647

@@ -785,6 +786,35 @@ def _find_config(path: str) -> Tuple[str, Dict[str, Any]]:
785786
return (path, {})
786787

787788

789+
@lru_cache()
790+
def find_all_configs(path: str) -> Trie:
791+
"""
792+
Looks for config files in the path provided and in all of its sub-directories.
793+
Parses and stores any config file encountered in a trie and returns the root of
794+
the trie
795+
"""
796+
trie_root = Trie("default", {})
797+
798+
for (dirpath, _, _) in os.walk(path):
799+
for config_file_name in CONFIG_SOURCES:
800+
potential_config_file = os.path.join(dirpath, config_file_name)
801+
if os.path.isfile(potential_config_file):
802+
config_data: Dict[str, Any]
803+
try:
804+
config_data = _get_config_data(
805+
potential_config_file, CONFIG_SECTIONS[config_file_name]
806+
)
807+
except Exception:
808+
warn(f"Failed to pull configuration information from {potential_config_file}")
809+
config_data = {}
810+
811+
if config_data:
812+
trie_root.insert(potential_config_file, config_data)
813+
break
814+
815+
return trie_root
816+
817+
788818
@lru_cache()
789819
def _get_config_data(file_path: str, sections: Tuple[str]) -> Dict[str, Any]:
790820
settings: Dict[str, Any] = {}

‎isort/utils.py

+56
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,61 @@
11
import os
22
import sys
3+
from pathlib import Path
4+
from typing import Any, Dict, Optional, Tuple
5+
6+
7+
class TrieNode:
8+
def __init__(self, config_file: str = "", config_data: Optional[Dict[str, Any]] = None) -> None:
9+
if not config_data:
10+
config_data = {}
11+
12+
self.nodes: Dict[str, TrieNode] = {}
13+
self.config_info: Tuple[str, Dict[str, Any]] = (config_file, config_data)
14+
15+
16+
class Trie:
17+
"""
18+
A prefix tree to store the paths of all config files and to search the nearest config
19+
associated with each file
20+
"""
21+
22+
def __init__(self, config_file: str = "", config_data: Optional[Dict[str, Any]] = None) -> None:
23+
self.root: TrieNode = TrieNode(config_file, config_data)
24+
25+
def insert(self, config_file: str, config_data: Dict[str, Any]) -> None:
26+
resolved_config_path_as_tuple = Path(config_file).parent.resolve().parts
27+
28+
temp = self.root
29+
30+
for path in resolved_config_path_as_tuple:
31+
if path not in temp.nodes:
32+
temp.nodes[path] = TrieNode()
33+
34+
temp = temp.nodes[path]
35+
36+
temp.config_info = (config_file, config_data)
37+
38+
def search(self, filename: str) -> Tuple[str, Dict[str, Any]]:
39+
"""
40+
Returns the closest config relative to filename by doing a depth
41+
first search on the prefix tree.
42+
"""
43+
resolved_file_path_as_tuple = Path(filename).resolve().parts
44+
45+
temp = self.root
46+
47+
last_stored_config: Tuple[str, Dict[str, Any]] = ("", {})
48+
49+
for path in resolved_file_path_as_tuple:
50+
if temp.config_info[0]:
51+
last_stored_config = temp.config_info
52+
53+
if path not in temp.nodes:
54+
break
55+
56+
temp = temp.nodes[path]
57+
58+
return last_stored_config
359

460

561
def exists_case_sensitive(path: str) -> bool:

‎tests/unit/test_main.py

+134
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def test_parse_args():
7979
assert main.parse_args(["--dont-follow-links"]) == {"follow_links": False}
8080
assert main.parse_args(["--overwrite-in-place"]) == {"overwrite_in_place": True}
8181
assert main.parse_args(["--from-first"]) == {"from_first": True}
82+
assert main.parse_args(["--resolve-all-configs"]) == {"resolve_all_configs": True}
8283

8384

8485
def test_ascii_art(capsys):
@@ -1211,3 +1212,136 @@ def main_check(args):
12111212
out, error = main_check([str(git_project0), "--skip-gitignore", "--filter-files"])
12121213

12131214
assert all(f"{str(tmpdir)}{file}" in out for file in should_check)
1215+
1216+
1217+
def test_multiple_configs(capsys, tmpdir):
1218+
# Ensure that --resolve-all-configs flag resolves multiple configs correctly
1219+
# and sorts files corresponding to their nearest config
1220+
1221+
setup_cfg = """
1222+
[isort]
1223+
from_first=True
1224+
"""
1225+
1226+
pyproject_toml = """
1227+
[tool.isort]
1228+
no_inline_sort = \"True\"
1229+
"""
1230+
1231+
isort_cfg = """
1232+
[settings]
1233+
force_single_line=True
1234+
"""
1235+
1236+
broken_isort_cfg = """
1237+
[iaort_confg]
1238+
force_single_line=True
1239+
"""
1240+
1241+
dir1 = tmpdir / "subdir1"
1242+
dir2 = tmpdir / "subdir2"
1243+
dir3 = tmpdir / "subdir3"
1244+
dir4 = tmpdir / "subdir4"
1245+
1246+
dir1.mkdir()
1247+
dir2.mkdir()
1248+
dir3.mkdir()
1249+
dir4.mkdir()
1250+
1251+
setup_cfg_file = dir1 / "setup.cfg"
1252+
setup_cfg_file.write_text(setup_cfg, "utf-8")
1253+
1254+
pyproject_toml_file = dir2 / "pyproject.toml"
1255+
pyproject_toml_file.write_text(pyproject_toml, "utf-8")
1256+
1257+
isort_cfg_file = dir3 / ".isort.cfg"
1258+
isort_cfg_file.write_text(isort_cfg, "utf-8")
1259+
1260+
broken_isort_cfg_file = dir4 / ".isort.cfg"
1261+
broken_isort_cfg_file.write_text(broken_isort_cfg, "utf-8")
1262+
1263+
import_section = """
1264+
from a import y, z, x
1265+
import b
1266+
"""
1267+
1268+
file1 = dir1 / "file1.py"
1269+
file1.write_text(import_section, "utf-8")
1270+
1271+
file2 = dir2 / "file2.py"
1272+
file2.write_text(import_section, "utf-8")
1273+
1274+
file3 = dir3 / "file3.py"
1275+
file3.write_text(import_section, "utf-8")
1276+
1277+
file4 = dir4 / "file4.py"
1278+
file4.write_text(import_section, "utf-8")
1279+
1280+
file5 = tmpdir / "file5.py"
1281+
file5.write_text(import_section, "utf-8")
1282+
1283+
main.main([str(tmpdir), "--resolve-all-configs", "--cr", str(tmpdir), "--verbose"])
1284+
out, _ = capsys.readouterr()
1285+
1286+
assert f"{str(setup_cfg_file)} used for file {str(file1)}" in out
1287+
assert f"{str(pyproject_toml_file)} used for file {str(file2)}" in out
1288+
assert f"{str(isort_cfg_file)} used for file {str(file3)}" in out
1289+
assert f"default used for file {str(file4)}" in out
1290+
assert f"default used for file {str(file5)}" in out
1291+
1292+
assert (
1293+
file1.read()
1294+
== """
1295+
from a import x, y, z
1296+
import b
1297+
"""
1298+
)
1299+
1300+
assert (
1301+
file2.read()
1302+
== """
1303+
import b
1304+
from a import y, z, x
1305+
"""
1306+
)
1307+
assert (
1308+
file3.read()
1309+
== """
1310+
import b
1311+
from a import x
1312+
from a import y
1313+
from a import z
1314+
"""
1315+
)
1316+
assert (
1317+
file4.read()
1318+
== """
1319+
import b
1320+
from a import x, y, z
1321+
"""
1322+
)
1323+
1324+
assert (
1325+
file5.read()
1326+
== """
1327+
import b
1328+
from a import x, y, z
1329+
"""
1330+
)
1331+
1332+
# Ensure that --resolve-all-config flags works with --check
1333+
1334+
file6 = dir1 / "file6.py"
1335+
file6.write(
1336+
"""
1337+
import b
1338+
from a import x, y, z
1339+
"""
1340+
)
1341+
1342+
with pytest.raises(SystemExit):
1343+
main.main([str(tmpdir), "--resolve-all-configs", "--cr", str(tmpdir), "--check"])
1344+
1345+
_, err = capsys.readouterr()
1346+
1347+
assert f"{str(file6)} Imports are incorrectly sorted and/or formatted" in err

‎tests/unit/test_settings.py

+61
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,64 @@ def test_as_bool():
238238
settings._as_bool("falsey")
239239
with pytest.raises(ValueError):
240240
settings._as_bool("truthy")
241+
242+
243+
def test_find_all_configs(tmpdir):
244+
setup_cfg = """
245+
[isort]
246+
profile=django
247+
"""
248+
249+
pyproject_toml = """
250+
[tool.isort]
251+
profile = "hug"
252+
"""
253+
254+
isort_cfg = """
255+
[settings]
256+
profile=black
257+
"""
258+
259+
pyproject_toml_broken = """
260+
[tool.isorts]
261+
something = nothing
262+
"""
263+
264+
dir1 = tmpdir / "subdir1"
265+
dir2 = tmpdir / "subdir2"
266+
dir3 = tmpdir / "subdir3"
267+
dir4 = tmpdir / "subdir4"
268+
269+
dir1.mkdir()
270+
dir2.mkdir()
271+
dir3.mkdir()
272+
dir4.mkdir()
273+
274+
setup_cfg_file = dir1 / "setup.cfg"
275+
setup_cfg_file.write_text(setup_cfg, "utf-8")
276+
277+
pyproject_toml_file = dir2 / "pyproject.toml"
278+
pyproject_toml_file.write_text(pyproject_toml, "utf-8")
279+
280+
isort_cfg_file = dir3 / ".isort.cfg"
281+
isort_cfg_file.write_text(isort_cfg, "utf-8")
282+
283+
pyproject_toml_file_broken = dir4 / "pyproject.toml"
284+
pyproject_toml_file_broken.write_text(pyproject_toml_broken, "utf-8")
285+
286+
config_trie = settings.find_all_configs(str(tmpdir))
287+
288+
config_info_1 = config_trie.search(str(dir1 / "test1.py"))
289+
assert config_info_1[0] == str(setup_cfg_file)
290+
assert config_info_1[0] == str(setup_cfg_file) and config_info_1[1]["profile"] == "django"
291+
292+
config_info_2 = config_trie.search(str(dir2 / "test2.py"))
293+
assert config_info_2[0] == str(pyproject_toml_file)
294+
assert config_info_2[0] == str(pyproject_toml_file) and config_info_2[1]["profile"] == "hug"
295+
296+
config_info_3 = config_trie.search(str(dir3 / "test3.py"))
297+
assert config_info_3[0] == str(isort_cfg_file)
298+
assert config_info_3[0] == str(isort_cfg_file) and config_info_3[1]["profile"] == "black"
299+
300+
config_info_4 = config_trie.search(str(tmpdir / "file4.py"))
301+
assert config_info_4[0] == "default"

‎tests/unit/test_utils.py

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from isort.utils import Trie
2+
3+
4+
def test_trie():
5+
trie_root = Trie("default", {"line_length": 70})
6+
7+
trie_root.insert("/temp/config1/.isort.cfg", {"line_length": 71})
8+
trie_root.insert("/temp/config2/setup.cfg", {"line_length": 72})
9+
trie_root.insert("/temp/config3/pyproject.toml", {"line_length": 73})
10+
11+
# Ensure that appropriate configs are resolved for files in different directories
12+
config1 = trie_root.search("/temp/config1/subdir/file1.py")
13+
assert config1[0] == "/temp/config1/.isort.cfg"
14+
assert config1[1] == {"line_length": 71}
15+
16+
config1_2 = trie_root.search("/temp/config1/file1_2.py")
17+
assert config1_2[0] == "/temp/config1/.isort.cfg"
18+
assert config1_2[1] == {"line_length": 71}
19+
20+
config2 = trie_root.search("/temp/config2/subdir/subsubdir/file2.py")
21+
assert config2[0] == "/temp/config2/setup.cfg"
22+
assert config2[1] == {"line_length": 72}
23+
24+
config2_2 = trie_root.search("/temp/config2/subdir/file2_2.py")
25+
assert config2_2[0] == "/temp/config2/setup.cfg"
26+
assert config2_2[1] == {"line_length": 72}
27+
28+
config3 = trie_root.search("/temp/config3/subdir/subsubdir/subsubsubdir/file3.py")
29+
assert config3[0] == "/temp/config3/pyproject.toml"
30+
assert config3[1] == {"line_length": 73}
31+
32+
config3_2 = trie_root.search("/temp/config3/file3.py")
33+
assert config3_2[0] == "/temp/config3/pyproject.toml"
34+
assert config3_2[1] == {"line_length": 73}
35+
36+
config_outside = trie_root.search("/temp/file.py")
37+
assert config_outside[0] == "default"
38+
assert config_outside[1] == {"line_length": 70}

0 commit comments

Comments
 (0)
Please sign in to comment.