From ec2b34033b96ff95a155e9ded768ee2324614360 Mon Sep 17 00:00:00 2001 From: Alex Liang <35860220+alex-liang3@users.noreply.github.com> Date: Thu, 12 Dec 2024 11:37:21 +0100 Subject: [PATCH 1/6] feat: add separate_packages option Adds the ability to separate packages within a section with blank lines. --------- Co-authored-by: Lucas --- docs/configuration/options.md | 10 +++ isort/output.py | 33 ++++++++ isort/settings.py | 1 + tests/unit/test_ticketed_features.py | 109 +++++++++++++++++++++++++++ 4 files changed, 153 insertions(+) diff --git a/docs/configuration/options.md b/docs/configuration/options.md index fdf687520..c07c04438 100644 --- a/docs/configuration/options.md +++ b/docs/configuration/options.md @@ -1039,6 +1039,16 @@ If `True` isort will automatically create section groups by the top-level packag **Python & Config File Name:** group_by_package **CLI Flags:** **Not Supported** +## Separate Packages + +Separate packages within the listed sections with newlines. + +**Type:** List of Strings +**Default:** `frozenset()` +**Config default:** `[]` +**Python & Config File Name:** separate_packages +**CLI Flags:** **Not Supported** + ## Ignore Whitespace Tells isort to ignore whitespace differences when --check-only is being used. diff --git a/isort/output.py b/isort/output.py index 3cb3c08b0..4a5a32b3e 100644 --- a/isort/output.py +++ b/isort/output.py @@ -8,6 +8,7 @@ from . import parse, sorting, wrap from .comments import add_to_line as with_comments from .identify import STATEMENT_DECLARATIONS +from .place import module_with_reason from .settings import DEFAULT_CONFIG, Config @@ -149,6 +150,38 @@ def sorted_imports( section_output.append("") # Empty line for black compatibility section_output.append(section_comment_end) + if section in config.separate_packages: + group_keys: set[str] = set() + comments_above: list[str] = [] + processed_section_output: list[str] = [] + for section_line in section_output: + if section_line.startswith("#"): + comments_above.append(section_line) + continue + + package_name: str = section_line.split(" ")[1] + _, reason = module_with_reason(package_name, config) + + if "Matched configured known pattern" in reason: + package_depth = len(reason.split(".")) - 1 # minus 1 for re.compile + key = ".".join(package_name.split(".")[: package_depth + 1]) + else: + key = package_name.split(".")[0] + + if key not in group_keys: + if group_keys: + processed_section_output.append("") + + group_keys.add(key) + + if comments_above: + processed_section_output.extend(comments_above) + comments_above = [] + + processed_section_output.append(section_line) + + section_output = processed_section_output + if pending_lines_before or not no_lines_before: output += [""] * config.lines_between_sections diff --git a/isort/settings.py b/isort/settings.py index a3658ffff..0c43149c1 100644 --- a/isort/settings.py +++ b/isort/settings.py @@ -200,6 +200,7 @@ class _Config: force_sort_within_sections: bool = False lexicographical: bool = False group_by_package: bool = False + separate_packages: FrozenSet[str] = frozenset() ignore_whitespace: bool = False no_lines_before: FrozenSet[str] = frozenset() no_inline_sort: bool = False diff --git a/tests/unit/test_ticketed_features.py b/tests/unit/test_ticketed_features.py index 32eeb709c..4ca458f4b 100644 --- a/tests/unit/test_ticketed_features.py +++ b/tests/unit/test_ticketed_features.py @@ -1,6 +1,7 @@ """A growing set of tests designed to ensure when isort implements a feature described in a ticket it fully works as defined in the associated ticket. """ + from functools import partial from io import StringIO @@ -1071,3 +1072,111 @@ def use_libc_math(): """, show_diff=True, ) + + +def test_sort_separate_packages_issue_2104(): + """ + Test to ensure that packages within a section can be separated by blank lines. + See: https://github.com/PyCQA/isort/issues/2104 + """ + + # Base case as described in issue + assert ( + isort.code( + """ +import os +import sys + +from django.db.models.signals import m2m_changed +from django.utils import functional +from django_filters import BooleanFilter +from junitparser import JUnitXml +from junitparser import TestSuite +from loguru import logger +""", + force_single_line=True, + separate_packages=["THIRDPARTY"], + ) + == """ +import os +import sys + +from django.db.models.signals import m2m_changed +from django.utils import functional + +from django_filters import BooleanFilter + +from junitparser import JUnitXml +from junitparser import TestSuite + +from loguru import logger +""" + ) + + # Check that multiline comments aren't broken up + assert ( + isort.code( + """ +from junitparser import TestSuite +# Some multiline +# comment +from loguru import logger +""", + force_single_line=True, + separate_packages=["THIRDPARTY"], + ) + == """ +from junitparser import TestSuite + +# Some multiline +# comment +from loguru import logger +""" + ) + + # Check it works for custom sections + assert ( + isort.code( + """ +import os +from package2 import bar +from package1 import foo + """, + force_single_line=True, + known_MYPACKAGES=["package1", "package2"], + sections=["STDLIB", "MYPACKAGES"], + separate_packages=["MYPACKAGES"], + ) + == """ +import os + +from package1 import foo + +from package2 import bar +""" + ) + + # Check it works for packages with deeper nesting + assert ( + isort.code( + """ +import os +from package2 import bar +from package1.a.b import foo +from package1.a.c import baz + """, + force_single_line=True, + known_MYPACKAGES=["package1.a", "package2"], + sections=["STDLIB", "MYPACKAGES"], + separate_packages=["MYPACKAGES"], + ) + == """ +import os + +from package1.a.b import foo + +from package1.a.c import baz + +from package2 import bar +""" + ) From 5c749de64cee37abbcd7eb344af03e89d51ae20f Mon Sep 17 00:00:00 2001 From: Alex Liang <35860220+alex-liang3@users.noreply.github.com> Date: Thu, 12 Dec 2024 18:08:35 +0100 Subject: [PATCH 2/6] chore: make python3.8-compatible (#2) chore: use python 3.8-style typing --- isort/output.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/isort/output.py b/isort/output.py index 4a5a32b3e..eb0836218 100644 --- a/isort/output.py +++ b/isort/output.py @@ -151,9 +151,9 @@ def sorted_imports( section_output.append(section_comment_end) if section in config.separate_packages: - group_keys: set[str] = set() - comments_above: list[str] = [] - processed_section_output: list[str] = [] + group_keys: Set[str] = set() + comments_above: List[str] = [] + processed_section_output: List[str] = [] for section_line in section_output: if section_line.startswith("#"): comments_above.append(section_line) From ecd1dbac997c260eb5981dc9ca8279bda3a4a1a1 Mon Sep 17 00:00:00 2001 From: Alex Liang Date: Mon, 3 Feb 2025 18:06:19 +0000 Subject: [PATCH 3/6] ci: ignore PY-R1000 --- isort/output.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/isort/output.py b/isort/output.py index a77f8da63..25c499920 100644 --- a/isort/output.py +++ b/isort/output.py @@ -11,7 +11,9 @@ from .place import module_with_reason from .settings import DEFAULT_CONFIG, Config - +# Ignore DeepSource cyclomatic complexity check for this function. It was +# already complex when this check was enabled. +# skipcq: PY-R1000 def sorted_imports( parsed: parse.ParsedContent, config: Config = DEFAULT_CONFIG, From 0dee7d5ab7f036b4ceac045b3e1df854b91bf1de Mon Sep 17 00:00:00 2001 From: Alex Liang Date: Mon, 3 Feb 2025 18:08:09 +0000 Subject: [PATCH 4/6] style: fix whitespace --- isort/output.py | 1 + 1 file changed, 1 insertion(+) diff --git a/isort/output.py b/isort/output.py index 25c499920..5bb5cc27e 100644 --- a/isort/output.py +++ b/isort/output.py @@ -11,6 +11,7 @@ from .place import module_with_reason from .settings import DEFAULT_CONFIG, Config + # Ignore DeepSource cyclomatic complexity check for this function. It was # already complex when this check was enabled. # skipcq: PY-R1000 From 7808f3feeed394b5779d6b4290742fedbc1d45ed Mon Sep 17 00:00:00 2001 From: Alex Liang <35860220+alex-liang3@users.noreply.github.com> Date: Wed, 19 Feb 2025 23:25:47 +0100 Subject: [PATCH 5/6] refactor: breakout function, add examples (#4) * Revert "ci: ignore PY-R1000" * refactor: breakout logic into separate function * docs: add examples --- docs/configuration/options.md | 43 ++++++++++++++++++++++ isort/output.py | 68 ++++++++++++++++++----------------- 2 files changed, 78 insertions(+), 33 deletions(-) diff --git a/docs/configuration/options.md b/docs/configuration/options.md index c07c04438..262f131bf 100644 --- a/docs/configuration/options.md +++ b/docs/configuration/options.md @@ -1049,6 +1049,49 @@ Separate packages within the listed sections with newlines. **Python & Config File Name:** separate_packages **CLI Flags:** **Not Supported** +**Examples:** + +### Example `.isort.cfg` + +``` +[settings] +separate_packages=THIRDPARTY +``` + +### Example `pyproject.toml` + +``` +[tool.isort] +separate_packages = ["THIRDPARTY"] +``` + +### Example before: +```python +import os +import sys + +from django.db.models.signals import m2m_changed +from django.utils import functional +from django_filters import BooleanFilter +from junitparser import JUnitXml +from loguru import logger +``` + +### Example after: +```python +import os +import sys + +from django.db.models.signals import m2m_changed +from django.utils import functional + +from django_filters import BooleanFilter + +from junitparser import JUnitXml + +from loguru import logger +``` + ## Ignore Whitespace Tells isort to ignore whitespace differences when --check-only is being used. diff --git a/isort/output.py b/isort/output.py index 7ab4bc67a..a826ba04b 100644 --- a/isort/output.py +++ b/isort/output.py @@ -12,9 +12,6 @@ from .settings import DEFAULT_CONFIG, Config -# Ignore DeepSource cyclomatic complexity check for this function. It was -# already complex when this check was enabled. -# skipcq: PY-R1000 def sorted_imports( parsed: parse.ParsedContent, config: Config = DEFAULT_CONFIG, @@ -154,36 +151,7 @@ def sorted_imports( section_output.append(section_comment_end) if section in config.separate_packages: - group_keys: Set[str] = set() - comments_above: List[str] = [] - processed_section_output: List[str] = [] - for section_line in section_output: - if section_line.startswith("#"): - comments_above.append(section_line) - continue - - package_name: str = section_line.split(" ")[1] - _, reason = module_with_reason(package_name, config) - - if "Matched configured known pattern" in reason: - package_depth = len(reason.split(".")) - 1 # minus 1 for re.compile - key = ".".join(package_name.split(".")[: package_depth + 1]) - else: - key = package_name.split(".")[0] - - if key not in group_keys: - if group_keys: - processed_section_output.append("") - - group_keys.add(key) - - if comments_above: - processed_section_output.extend(comments_above) - comments_above = [] - - processed_section_output.append(section_line) - - section_output = processed_section_output + section_output = _separate_packages(section_output, config) if pending_lines_before or not no_lines_before: output += [""] * config.lines_between_sections @@ -710,3 +678,37 @@ def _with_star_comments(parsed: parse.ParsedContent, module: str, comments: List if star_comment: return [*comments, star_comment] return comments + + +def _separate_packages(section_output: List[str], config: Config) -> List[str]: + group_keys: Set[str] = set() + comments_above: List[str] = [] + processed_section_output: List[str] = [] + + for section_line in section_output: + if section_line.startswith("#"): + comments_above.append(section_line) + continue + + package_name: str = section_line.split(" ")[1] + _, reason = module_with_reason(package_name, config) + + if "Matched configured known pattern" in reason: + package_depth = len(reason.split(".")) - 1 # minus 1 for re.compile + key = ".".join(package_name.split(".")[: package_depth + 1]) + else: + key = package_name.split(".")[0] + + if key not in group_keys: + if group_keys: + processed_section_output.append("") + + group_keys.add(key) + + if comments_above: + processed_section_output.extend(comments_above) + comments_above = [] + + processed_section_output.append(section_line) + + return processed_section_output From 1fd010534eedffadec82289a08d449a5e17df1b8 Mon Sep 17 00:00:00 2001 From: Alex Liang Date: Thu, 16 Oct 2025 20:44:16 +0000 Subject: [PATCH 6/6] chore: remove py3.8 types --- isort/output.py | 8 ++++---- isort/settings.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/isort/output.py b/isort/output.py index 37ee1c65e..afa843cfb 100644 --- a/isort/output.py +++ b/isort/output.py @@ -691,10 +691,10 @@ def _with_star_comments(parsed: parse.ParsedContent, module: str, comments: list return comments -def _separate_packages(section_output: List[str], config: Config) -> List[str]: - group_keys: Set[str] = set() - comments_above: List[str] = [] - processed_section_output: List[str] = [] +def _separate_packages(section_output: list[str], config: Config) -> list[str]: + group_keys: set[str] = set() + comments_above: list[str] = [] + processed_section_output: list[str] = [] for section_line in section_output: if section_line.startswith("#"): diff --git a/isort/settings.py b/isort/settings.py index f350e17d8..ac5d74f4c 100644 --- a/isort/settings.py +++ b/isort/settings.py @@ -191,7 +191,7 @@ class _Config: force_sort_within_sections: bool = False lexicographical: bool = False group_by_package: bool = False - separate_packages: FrozenSet[str] = frozenset() + separate_packages: frozenset[str] = frozenset() ignore_whitespace: bool = False no_lines_before: frozenset[str] = frozenset() no_inline_sort: bool = False