From 07d327519ec3d3fa54659ba52c3c0eda072c0d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20van=20Noord?= <13665637+DanielNoord@users.noreply.github.com> Date: Wed, 15 Apr 2026 23:29:03 +0200 Subject: [PATCH] Implement support for sorting lazy imports Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- isort/_parse_utils.py | 10 +- isort/core.py | 7 +- isort/output.py | 210 ++++++++++++++++++++------------ isort/parse.py | 39 +++++- tests/unit/test_lazy_imports.py | 140 +++++++++++++++++++++ 5 files changed, 317 insertions(+), 89 deletions(-) create mode 100644 tests/unit/test_lazy_imports.py diff --git a/isort/_parse_utils.py b/isort/_parse_utils.py index c8f6689d2..a3c13ed89 100644 --- a/isort/_parse_utils.py +++ b/isort/_parse_utils.py @@ -166,8 +166,10 @@ def normalize_from_import_string(import_string: str) -> str: # TODO: Return a `StrEnum` once we no longer support Python 3.10. -def import_type(line: str, config: Config) -> Literal["from", "straight"] | None: - """If the current line is an import line it will return its type (from or straight)""" +def import_type( + line: str, config: Config +) -> Literal["from", "straight", "lazy_from", "lazy_straight"] | None: + """If the current line is an import line it will return its type.""" if config.honor_noqa and line.lower().rstrip().endswith("noqa"): return None if "isort:skip" in line or "isort: skip" in line or "isort: split" in line: @@ -176,4 +178,8 @@ def import_type(line: str, config: Config) -> Literal["from", "straight"] | None return "straight" if line.startswith("from "): return "from" + if line.startswith("lazy import "): + return "lazy_straight" + if line.startswith("lazy from "): + return "lazy_from" return None diff --git a/isort/core.py b/isort/core.py index 34ba6599b..663dd669f 100644 --- a/isort/core.py +++ b/isort/core.py @@ -12,7 +12,12 @@ from .settings import FILE_SKIP_COMMENTS CIMPORT_IDENTIFIERS = ("cimport ", "cimport*", "from.cimport") -IMPORT_START_IDENTIFIERS = ("from ", "from.import", "import ", "import*", *CIMPORT_IDENTIFIERS) +_BASE_IMPORT_IDENTIFIERS = ("from ", "from.import", "import ", "import*") +IMPORT_START_IDENTIFIERS = ( + *_BASE_IMPORT_IDENTIFIERS, + *(f"lazy {identifier}" for identifier in _BASE_IMPORT_IDENTIFIERS), + *CIMPORT_IDENTIFIERS, +) DOCSTRING_INDICATORS = ('"""', "'''") COMMENT_INDICATORS = (*DOCSTRING_INDICATORS, "'", '"', "#") CODE_SORT_COMMENTS = ( diff --git a/isort/output.py b/isort/output.py index 03637bdb9..9d9ce9aa2 100644 --- a/isort/output.py +++ b/isort/output.py @@ -2,7 +2,7 @@ import itertools from collections.abc import Iterable from functools import partial -from typing import Any +from typing import Any, Literal from isort.format import format_simplified @@ -35,96 +35,43 @@ def sorted_imports( sections: Iterable[str] = itertools.chain(parsed.sections, config.forced_separate) if config.no_sections: - parsed.imports["no_sections"] = {"straight": {}, "from": {}} + parsed.imports["no_sections"] = { + "straight": {}, + "from": {}, + "lazy_straight": {}, + "lazy_from": {}, + } base_sections: tuple[str, ...] = () for section in sections: if section == "FUTURE": base_sections = ("FUTURE",) continue - parsed.imports["no_sections"]["straight"].update( - parsed.imports[section].get("straight", {}) + parsed.imports["no_sections"]["straight"].update(parsed.imports[section]["straight"]) + parsed.imports["no_sections"]["from"].update(parsed.imports[section]["from"]) + parsed.imports["no_sections"]["lazy_straight"].update( + parsed.imports[section]["lazy_straight"] ) - parsed.imports["no_sections"]["from"].update(parsed.imports[section].get("from", {})) + parsed.imports["no_sections"]["lazy_from"].update(parsed.imports[section]["lazy_from"]) sections = (*base_sections, "no_sections") output: list[str] = [] seen_headings: set[str] = set() pending_lines_before = False for section in sections: - straight_modules: Iterable[str] = parsed.imports[section]["straight"] - if not config.only_sections: - straight_modules = sorting.sort( - config, - straight_modules, - key=lambda key: sorting.module_key( - key, config, section_name=section, straight_import=True - ), - reverse=config.reverse_sort, - ) - - from_modules: Iterable[str] = parsed.imports[section]["from"] - if not config.only_sections: - from_modules = sorting.sort( - config, - from_modules, - key=lambda key: sorting.module_key(key, config, section_name=section), - reverse=config.reverse_sort, - ) - - if config.star_first: - star_modules = [] - other_modules = [] - for module in from_modules: - if "*" in parsed.imports[section]["from"][module]: - star_modules.append(module) - else: - other_modules.append(module) - from_modules = star_modules + other_modules - - straight_imports = _with_straight_imports( - parsed, config, straight_modules, section, remove_imports, import_type - ) - from_imports = _with_from_imports( - parsed, config, from_modules, section, remove_imports, import_type + section_output = _build_import_group( + parsed, config, section, remove_imports, import_type, is_lazy=False ) - lines_between = [""] * ( - config.lines_between_types if from_modules and straight_modules else 0 + # PEP 810 lazy imports always follow all eager imports within the section. + lazy_section_output = _build_import_group( + parsed, config, section, remove_imports, import_type, is_lazy=True ) - if config.from_first or section == "FUTURE": - section_output = from_imports + lines_between + straight_imports - else: - section_output = straight_imports + lines_between + from_imports - if config.force_sort_within_sections: - # collapse comments - comments_above = [] - new_section_output: list[str] = [] - for line in section_output: - if not line: - continue - if line.startswith("#"): - comments_above.append(line) - elif comments_above: - new_section_output.append(_LineWithComments(line, comments_above)) - comments_above = [] - else: - new_section_output.append(line) - # only_sections options is not imposed if force_sort_within_sections is True - new_section_output = sorting.sort( - config, - new_section_output, - key=partial(sorting.section_key, config=config), - reverse=config.reverse_sort, - ) - - # uncollapse comments - section_output = [] - for line in new_section_output: - comments = getattr(line, "comments", ()) - if comments: - section_output.extend(comments) - section_output.append(str(line)) + if lazy_section_output: + if section_output: + section_output += [""] * config.lines_between_types + lazy_section_output + else: + section_output = lazy_section_output section_name = section no_lines_before = section_name in config.no_lines_before @@ -244,6 +191,96 @@ def sorted_imports( return _output_as_string(formatted_output, parsed.line_separator) +# Ignore DeepSource cyclomatic complexity check for this function. +# skipcq: PY-R1000 +def _build_import_group( + parsed: parse.ParsedContent, + config: Config, + section: str, + remove_imports: list[str], + import_type: str, + *, + is_lazy: bool, +) -> list[str]: + """Build the sorted import lines for one group (eager or lazy) within a section.""" + straight_key: Literal["lazy_straight", "straight"] = "lazy_straight" if is_lazy else "straight" + from_key: Literal["lazy_from", "from"] = "lazy_from" if is_lazy else "from" + + straight_modules: Iterable[str] = parsed.imports[section][straight_key] + if not config.only_sections: + straight_modules = sorting.sort( + config, + straight_modules, + key=lambda key: sorting.module_key( + key, config, section_name=section, straight_import=True + ), + reverse=config.reverse_sort, + ) + + from_modules: Iterable[str] = parsed.imports[section][from_key] + if not config.only_sections: + from_modules = sorting.sort( + config, + from_modules, + key=lambda key: sorting.module_key(key, config, section_name=section), + reverse=config.reverse_sort, + ) + + if not is_lazy and config.star_first: + star_modules = [] + other_modules = [] + for module in from_modules: + if "*" in parsed.imports[section]["from"][module]: + star_modules.append(module) + else: + other_modules.append(module) + from_modules = star_modules + other_modules + + straight_imports = _with_straight_imports( + parsed, config, straight_modules, section, remove_imports, import_type, is_lazy=is_lazy + ) + from_imports = _with_from_imports( + parsed, config, from_modules, section, remove_imports, import_type, is_lazy=is_lazy + ) + + lines_between = [""] * (config.lines_between_types if from_modules and straight_modules else 0) + if config.from_first or section == "FUTURE": + group_output = from_imports + lines_between + straight_imports + else: + group_output = straight_imports + lines_between + from_imports + + if config.force_sort_within_sections: + # collapse comments + comments_above: list[str] = [] + new_group_output: list[str] = [] + for line in group_output: + if not line: + continue + if line.startswith("#"): + comments_above.append(line) + elif comments_above: + new_group_output.append(_LineWithComments(line, comments_above)) + comments_above = [] + else: + new_group_output.append(line) + # only_sections option is not imposed if force_sort_within_sections is True + new_group_output = sorting.sort( + config, + new_group_output, + key=partial(sorting.section_key, config=config), + reverse=config.reverse_sort, + ) + # uncollapse comments + group_output = [] + for line in new_group_output: + line_comments = getattr(line, "comments", ()) + if line_comments: + group_output.extend(line_comments) + group_output.append(str(line)) + + return group_output + + # Ignore DeepSource cyclomatic complexity check for this function. It was # already complex when this check was enabled. # skipcq: PY-R1000 @@ -254,14 +291,21 @@ def _with_from_imports( section: str, remove_imports: list[str], import_type: str, + *, + is_lazy: bool, ) -> list[str]: output: list[str] = [] + import_key: Literal["lazy_from", "from"] = "lazy_from" if is_lazy else "from" + for module in from_modules: if module in remove_imports: continue import_start = f"from {module} {import_type} " - from_imports = list(parsed.imports[section]["from"][module]) + if is_lazy: + import_start = f"lazy {import_start}" + from_imports = list(parsed.imports[section][import_key][module]) + if ( not config.no_inline_sort or (config.force_single_line and module not in config.single_line_exclusions) @@ -299,7 +343,7 @@ def _with_from_imports( for from_import in copy.copy(from_imports): if from_import in as_imports: idx = from_imports.index(from_import) - if parsed.imports[section]["from"][module][from_import]: + if parsed.imports[section][import_key][module][from_import]: from_imports[(idx + 1) : (idx + 1)] = as_imports.pop(from_import) else: from_imports[idx : (idx + 1)] = as_imports.pop(from_import) @@ -347,7 +391,7 @@ def _with_from_imports( ) if from_import in as_imports: if ( - parsed.imports[section]["from"][module][from_import] + parsed.imports[section][import_key][module][from_import] and not only_show_as_imports ): output.append( @@ -403,7 +447,7 @@ def _with_from_imports( parsed.categorized_comments["straight"].get(f"{module}.{from_import}") or [] ) if ( - parsed.imports[section]["from"][module][from_import] + parsed.imports[section][import_key][module][from_import] and not only_show_as_imports ): specific_comment = ( @@ -540,7 +584,7 @@ def _with_from_imports( from_imports[0] not in as_imports or ( config.combine_as_imports - and parsed.imports[section]["from"][module][from_import] + and parsed.imports[section][import_key][module][from_import] ) ): from_import_section.append(from_imports.pop(0)) @@ -633,9 +677,13 @@ def _with_straight_imports( section: str, remove_imports: list[str], import_type: str, + *, + is_lazy: bool, ) -> list[str]: output: list[str] = [] + import_type = f"lazy {import_type}" if is_lazy else import_type + as_imports = any(module in parsed.as_map["straight"] for module in straight_modules) # combine_straight_imports only works for bare imports, 'as' imports not included @@ -675,7 +723,7 @@ def _with_straight_imports( import_definition = [] if module in parsed.as_map["straight"]: - if parsed.imports[section]["straight"][module]: + if parsed.imports[section]["lazy_straight" if is_lazy else "straight"][module]: import_definition.append((f"{import_type} {module}", module)) import_definition.extend( (f"{import_type} {module} as {as_import}", f"{module} as {as_import}") diff --git a/isort/parse.py b/isort/parse.py index 9f5945850..036783975 100644 --- a/isort/parse.py +++ b/isort/parse.py @@ -48,6 +48,8 @@ def _infer_line_separator(contents: str) -> str: { "straight": dict[str, bool], "from": dict[str, dict[str, bool]], + "lazy_straight": dict[str, bool], + "lazy_from": dict[str, dict[str, bool]], }, ) @@ -95,7 +97,12 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte verbose_output: list[str] = [] for section in chain(config.sections, config.forced_separate): - imports[section] = {"straight": OrderedDict(), "from": OrderedDict()} + imports[section] = { + "straight": OrderedDict(), + "from": OrderedDict(), + "lazy_straight": OrderedDict(), + "lazy_from": OrderedDict(), + } categorized_comments: CommentsDict = { "from": {}, "straight": {}, @@ -194,6 +201,15 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte out_lines.append(raw_line) continue + # Detect PEP 810 lazy imports (``lazy import X`` / ``lazy from X import Y``). + # We strip the ``lazy `` prefix so the rest of the parsing logic works normally + # on the resulting ``import X`` / ``from X import Y`` string. The original + # lazy type is remembered in ``is_lazy`` and used later when storing the result. + is_lazy = type_of_import in ("lazy_straight", "lazy_from") + if is_lazy: + line = line[len("lazy ") :] + type_of_import = "straight" if type_of_import == "lazy_straight" else "from" + if import_index == -1: import_index = index - 1 nested_comments = {} @@ -314,7 +330,7 @@ def _get_next_line() -> tuple[str, str | None]: if placed_module and placed_module not in imports: raise MissingSection(import_module=import_from, section=placed_module) - root = imports[placed_module][type_of_import] + root = imports[placed_module]["lazy_from" if is_lazy else type_of_import] for import_name in just_imports: associated_comment = nested_comments.get(import_name) if associated_comment is not None: @@ -384,6 +400,7 @@ def _get_next_line() -> tuple[str, str | None]: ): trailing_commas.add(import_from) else: + assert type_of_import == "straight" # noqa: S101 # Only needed for type checker if comments and attach_comments_to is not None: attach_comments_to.extend(comments) comments = [] @@ -429,15 +446,27 @@ def _get_next_line() -> tuple[str, str | None]: " Do you need to define a default section?", stacklevel=2, ) - imports.setdefault("", {"straight": OrderedDict(), "from": OrderedDict()}) + imports.setdefault( + "", + { + "straight": OrderedDict(), + "from": OrderedDict(), + "lazy_straight": OrderedDict(), + "lazy_from": OrderedDict(), + }, + ) if placed_module and placed_module not in imports: raise MissingSection(import_module=module, section=placed_module) straight_import |= bool( - imports[placed_module][type_of_import].get(module, False) + imports[placed_module]["lazy_straight" if is_lazy else type_of_import].get( + module, False + ) ) - imports[placed_module][type_of_import][module] = straight_import + imports[placed_module]["lazy_straight" if is_lazy else type_of_import][ + module + ] = straight_import change_count = len(out_lines) - original_line_count diff --git a/tests/unit/test_lazy_imports.py b/tests/unit/test_lazy_imports.py new file mode 100644 index 000000000..fc7e7c421 --- /dev/null +++ b/tests/unit/test_lazy_imports.py @@ -0,0 +1,140 @@ +from isort import Config, parse + +from .utils import isort_test + + +class TestParsing: + """Verify that ``parse.file_contents`` correctly identifies lazy imports.""" + + def test_lazy_straight_import_is_stored_in_lazy_straight_bucket(self): + result = parse.file_contents("lazy import ast\n", Config()) + assert "ast" in result.imports["STDLIB"]["lazy_straight"] + assert "ast" not in result.imports["STDLIB"]["straight"] + + def test_lazy_from_import_is_stored_in_lazy_from_bucket(self): + result = parse.file_contents("lazy from dataclasses import dataclass\n", Config()) + assert "dataclasses" in result.imports["STDLIB"]["lazy_from"] + assert "dataclasses" not in result.imports["STDLIB"]["from"] + + def test_eager_imports_are_stored_in_regular_buckets(self): + result = parse.file_contents("import os\nfrom pathlib import Path\n", Config()) + assert "os" in result.imports["STDLIB"]["straight"] + assert "pathlib" in result.imports["STDLIB"]["from"] + + def test_lazy_imports_are_placed_in_correct_section(self): + """Lazy imports must be placed in the same section as their eager counterparts.""" + result = parse.file_contents( + "lazy import ast\nlazy import requests\n", + Config(known_third_party=["requests"]), + ) + assert "ast" in result.imports["STDLIB"]["lazy_straight"] + assert "requests" in result.imports["THIRDPARTY"]["lazy_straight"] + + +def test_lazy_straight_imports_come_after_eager(): + """lazy import lines follow all eager import lines within the section.""" + isort_test("lazy import ast\nimport os\n", "import os\nlazy import ast\n") + + +def test_lazy_from_imports_come_after_eager(): + """lazy from ... import lines follow all eager import lines within the section.""" + isort_test( + "lazy from pathlib import Path\nfrom collections import defaultdict\n", + "from collections import defaultdict\nlazy from pathlib import Path\n", + ) + + +def test_lazy_straight_sorted_alphabetically(): + """Multiple lazy straight imports are sorted alphabetically.""" + isort_test("lazy import shutil\nlazy import ast\n", "lazy import ast\nlazy import shutil\n") + + +def test_lazy_from_sorted_alphabetically(): + """Multiple lazy from imports are sorted alphabetically by module name.""" + isort_test( + "lazy from pathlib import Path\nlazy from dataclasses import dataclass\n", + "lazy from dataclasses import dataclass\nlazy from pathlib import Path\n", + ) + + +def test_ruff_reference_example(): + """Reproduce the canonical example from the ruff issue tracker. + + See https://github.com/astral-sh/ruff/issues/21305. + """ + unsorted = ( + "lazy from dataclasses import dataclass\n" + "import json\n" + "import os\n" + "import subprocess\n" + "from collections import defaultdict\n" + "from pathlib import Path\n" + "from typing import Final\n" + "lazy import ast\n" + "lazy import shutil\n" + ) + expected = ( + "import json\n" + "import os\n" + "import subprocess\n" + "from collections import defaultdict\n" + "from pathlib import Path\n" + "from typing import Final\n" + "lazy import ast\n" + "lazy import shutil\n" + "lazy from dataclasses import dataclass\n" + ) + isort_test(unsorted, expected) + + +def test_lazy_imports_appear_after_eager_in_each_section_independently(): + """Each section gets its own eager-first / lazy-last grouping.""" + unsorted = "lazy import requests\nlazy import ast\nimport os\nimport requests\n" + expected = "import os\nlazy import ast\n\nimport requests\nlazy import requests\n" + isort_test(unsorted, expected, known_third_party=["requests"]) + + +def test_lazy_import_with_alias(): + """``lazy import X as Y`` is supported and sorted correctly.""" + isort_test( + "import os\nlazy import numpy as np\n", + "import os\n\nlazy import numpy as np\n", + known_third_party=["numpy"], + ) + + +def test_lazy_from_import_multiple_names(): + """``lazy from X import a, b`` is supported and names are sorted alphabetically.""" + isort_test("lazy from typing import List, Dict\n", "lazy from typing import Dict, List\n") + + +def test_no_sections_mode_with_lazy_imports(): + """lazy imports are supported in no_sections mode.""" + isort_test( + "lazy import ast\nimport os\n", + "import os\nlazy import ast\n", + no_sections=True, + ) + + +def test_from_first_option_respected_for_lazy(): + """When from_first=True, lazy from imports precede lazy straight but appear after eager.""" + isort_test( + "import pathlib\nlazy import ast\nlazy from dataclasses import dataclass\n", + "import pathlib\nlazy from dataclasses import dataclass\nlazy import ast\n", + from_first=True, + ) + + +def test_force_sort_within_sections_applies_to_lazy(): + """force_sort_within_sections toggles lazy import ordering behavior.""" + isort_test( + "lazy import zlib\nlazy from ast import parse\n", + "lazy import zlib\nlazy from ast import parse\n", + force_sort_within_sections=False, + ) + isort_test( + "lazy import zlib\nlazy from ast import parse\n", + "lazy from ast import parse\nlazy import zlib\n", + force_sort_within_sections=True, + )