From f8871a24616744fc45a1e4180a184983a5a1ba7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:23:40 +0000 Subject: [PATCH 1/2] Add PEP 810 lazy import sorting support Agent-Logs-Url: https://github.com/PyCQA/isort/sessions/ede8ce0d-f07c-4a61-8637-2887d3f02354 Co-authored-by: DanielNoord <13665637+DanielNoord@users.noreply.github.com> --- isort/_parse_utils.py | 15 +- isort/core.py | 10 +- isort/output.py | 86 ++++++++++-- isort/parse.py | 36 ++++- tests/unit/test_lazy_imports.py | 242 ++++++++++++++++++++++++++++++++ 5 files changed, 373 insertions(+), 16 deletions(-) create mode 100644 tests/unit/test_lazy_imports.py diff --git a/isort/_parse_utils.py b/isort/_parse_utils.py index c8f6689d2..97eb297c7 100644 --- a/isort/_parse_utils.py +++ b/isort/_parse_utils.py @@ -166,8 +166,15 @@ 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 (from or straight). + + For PEP 810 lazy imports (``lazy import X`` / ``lazy from X import Y``) the + returned type is prefixed with ``"lazy_"`` so callers can distinguish them + from regular (eager) imports. + """ 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 +183,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..a7425aa94 100644 --- a/isort/core.py +++ b/isort/core.py @@ -12,7 +12,15 @@ from .settings import FILE_SKIP_COMMENTS CIMPORT_IDENTIFIERS = ("cimport ", "cimport*", "from.cimport") -IMPORT_START_IDENTIFIERS = ("from ", "from.import", "import ", "import*", *CIMPORT_IDENTIFIERS) +IMPORT_START_IDENTIFIERS = ( + "from ", + "from.import", + "import ", + "import*", + "lazy import ", + "lazy from ", + *CIMPORT_IDENTIFIERS, +) DOCSTRING_INDICATORS = ('"""', "'''") COMMENT_INDICATORS = (*DOCSTRING_INDICATORS, "'", '"', "#") CODE_SORT_COMMENTS = ( diff --git a/isort/output.py b/isort/output.py index 52556b6af..cafbd78a9 100644 --- a/isort/output.py +++ b/isort/output.py @@ -35,7 +35,7 @@ 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": @@ -45,6 +45,12 @@ def sorted_imports( parsed.imports[section].get("straight", {}) ) parsed.imports["no_sections"]["from"].update(parsed.imports[section].get("from", {})) + parsed.imports["no_sections"]["lazy_straight"].update( + parsed.imports[section].get("lazy_straight", {}) + ) + parsed.imports["no_sections"]["lazy_from"].update( + parsed.imports[section].get("lazy_from", {}) + ) sections = (*base_sections, "no_sections") output: list[str] = [] @@ -126,6 +132,62 @@ def sorted_imports( section_output.extend(comments) section_output.append(str(line)) + # PEP 810 lazy imports always follow all eager imports within the section. + lazy_straight_modules = parsed.imports[section].get("lazy_straight", {}) + if not config.only_sections: + lazy_straight_modules = sorting.sort( + config, + lazy_straight_modules, + key=lambda key: sorting.module_key( + key, config, section_name=section, straight_import=True + ), + reverse=config.reverse_sort, + ) + + lazy_from_modules = parsed.imports[section].get("lazy_from", {}) + if not config.only_sections: + lazy_from_modules = sorting.sort( + config, + lazy_from_modules, + key=lambda key: sorting.module_key(key, config, section_name=section), + reverse=config.reverse_sort, + ) + + lazy_straight_imports = _with_straight_imports( + parsed, + config, + lazy_straight_modules, + section, + remove_imports, + f"lazy {import_type}", + import_key="lazy_straight", + ) + lazy_from_imports = _with_from_imports( + parsed, + config, + lazy_from_modules, + section, + remove_imports, + f"lazy {import_type}", + import_key="lazy_from", + ) + + lazy_lines_between = [""] * ( + config.lines_between_types + if lazy_from_modules and lazy_straight_modules + else 0 + ) + if config.from_first or section == "FUTURE": + lazy_section_output = lazy_from_imports + lazy_lines_between + lazy_straight_imports + else: + lazy_section_output = lazy_straight_imports + lazy_lines_between + lazy_from_imports + + 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 @@ -254,14 +316,21 @@ def _with_from_imports( section: str, remove_imports: list[str], import_type: str, + import_key: str = "from", ) -> list[str]: output: list[str] = [] 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]) + # For lazy from imports the keyword order is ``lazy from X import ...`` + # rather than the naive ``from X lazy import ...`` that would result from + # placing ``import_type`` between the module name and ``import``. + if import_type.startswith("lazy "): + import_start = f"lazy from {module} import " + else: + import_start = f"from {module} {import_type} " + 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 +368,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 +416,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 +472,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 +609,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,6 +702,7 @@ def _with_straight_imports( section: str, remove_imports: list[str], import_type: str, + import_key: str = "straight", ) -> list[str]: output: list[str] = [] @@ -675,7 +745,7 @@ def _with_straight_imports( import_definition = [] if module in parsed.as_map["straight"]: - if parsed.imports[section]["straight"][module]: + if parsed.imports[section][import_key][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 5928b3926..9554dc925 100644 --- a/isort/parse.py +++ b/isort/parse.py @@ -86,7 +86,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": {}, @@ -185,6 +190,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 = type_of_import[len("lazy_"):] # "lazy_straight" → "straight" + if import_index == -1: import_index = index - 1 nested_comments = {} @@ -305,7 +319,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: @@ -420,13 +434,25 @@ 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 |= imports[placed_module][type_of_import].get(module, False) - imports[placed_module][type_of_import][module] = straight_import + straight_import |= imports[placed_module][ + "lazy_straight" if is_lazy else type_of_import + ].get(module, False) + 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..245b99cf8 --- /dev/null +++ b/tests/unit/test_lazy_imports.py @@ -0,0 +1,242 @@ +"""Tests for PEP 810 explicit lazy import sorting support. + +PEP 810 (https://peps.python.org/pep-0810/) introduces an explicit lazy import +syntax for Python 3.15+:: + + lazy import ast + lazy from dataclasses import dataclass + +isort sorts these so that within each section all *eager* imports come first, +followed by all *lazy* imports. The ordering of modules inside each group +follows the same rules as regular imports. + +The expected output for a mixed file looks like:: + + import json + import os + import subprocess + from collections import defaultdict + from pathlib import Path + from typing import Final + lazy import ast + lazy import shutil + lazy from dataclasses import dataclass + +This matches the behaviour described by ruff for issue #21305. +""" + +import isort +from isort import Config, parse + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _sort(code: str, **config_kwargs) -> str: + """Shorthand: sort *code* and return the result.""" + return isort.code(code, **config_kwargs) + + +# --------------------------------------------------------------------------- +# Parsing tests +# --------------------------------------------------------------------------- + + +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"] + + +# --------------------------------------------------------------------------- +# Sorting / output tests +# --------------------------------------------------------------------------- + + +class TestSorting: + """Verify that sorted output places lazy imports after eager imports.""" + + def test_lazy_straight_imports_come_after_eager(self): + """lazy import lines follow all eager import lines within the section.""" + result = _sort("lazy import ast\nimport os\n") + assert result == "import os\nlazy import ast\n" + + def test_lazy_from_imports_come_after_eager(self): + """lazy from ... import lines follow all eager import lines within the section.""" + result = _sort("lazy from pathlib import Path\nfrom collections import defaultdict\n") + assert result == "from collections import defaultdict\nlazy from pathlib import Path\n" + + def test_lazy_straight_sorted_alphabetically(self): + """Multiple lazy straight imports are sorted alphabetically.""" + result = _sort("lazy import shutil\nlazy import ast\n") + assert result == "lazy import ast\nlazy import shutil\n" + + def test_lazy_from_sorted_alphabetically(self): + """Multiple lazy from imports are sorted alphabetically by module name.""" + result = _sort( + "lazy from pathlib import Path\nlazy from dataclasses import dataclass\n" + ) + assert result == ( + "lazy from dataclasses import dataclass\nlazy from pathlib import Path\n" + ) + + def test_ruff_reference_example(self): + """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" + ) + assert _sort(unsorted) == expected + + def test_lazy_imports_appear_after_eager_in_each_section_independently(self): + """Each section gets its own eager-first / lazy-last grouping.""" + unsorted = ( + "lazy import requests\n" + "lazy import ast\n" + "import os\n" + "import requests\n" + ) + expected = ( + "import os\n" + "lazy import ast\n" + "\n" + "import requests\n" + "lazy import requests\n" + ) + result = _sort(unsorted, known_third_party=["requests"]) + assert result == expected + + def test_only_lazy_imports_in_section(self): + """A section that contains *only* lazy imports is formatted correctly.""" + result = _sort("lazy import ast\nlazy import os\n") + assert result == "lazy import ast\nlazy import os\n" + + def test_only_lazy_from_imports(self): + """A section with only lazy from-imports is formatted correctly.""" + result = _sort( + "lazy from dataclasses import dataclass\nlazy from pathlib import Path\n" + ) + assert result == ( + "lazy from dataclasses import dataclass\nlazy from pathlib import Path\n" + ) + + def test_lazy_import_with_alias(self): + """``lazy import X as Y`` is supported and sorted correctly.""" + result = _sort("import os\nlazy import numpy as np\n", known_third_party=["numpy"]) + # os is STDLIB (eager), numpy is THIRDPARTY (lazy) → two separate sections + assert result == "import os\n\nlazy import numpy as np\n" + + def test_lazy_from_import_multiple_names(self): + """``lazy from X import a, b`` is supported and names are sorted alphabetically.""" + result = _sort("lazy from typing import List, Dict\n") + assert result == "lazy from typing import Dict, List\n" + + def test_idempotency_mixed_eager_and_lazy(self): + """Running isort twice on sorted output must not change anything.""" + once = _sort( + "lazy from dataclasses import dataclass\n" + "import json\n" + "lazy import ast\n" + "import os\n" + ) + twice = _sort(once) + assert once == twice + + def test_idempotency_only_lazy(self): + """Idempotency with a file that contains only lazy imports.""" + once = _sort("lazy import shutil\nlazy import ast\n") + twice = _sort(once) + assert once == twice + + def test_check_code_sorted(self): + """check_code returns True when lazy imports are already sorted.""" + assert isort.check_code( + "import os\n" + "lazy import ast\n", + show_diff=False, + ) + + def test_check_code_unsorted(self): + """check_code returns False when lazy imports are not sorted.""" + assert not isort.check_code( + "lazy import ast\n" + "import os\n", + show_diff=False, + ) + + def test_lazy_and_eager_from_same_module_in_section(self): + """Both eager and lazy imports from the same module are retained.""" + # This is an unusual pattern but should not cause errors. + result = _sort( + "from pathlib import Path\n" + "lazy from pathlib import PurePath\n" + ) + assert "from pathlib import Path" in result + assert "lazy from pathlib import PurePath" in result + # eager must come before lazy + assert result.index("from pathlib import Path") < result.index( + "lazy from pathlib import PurePath" + ) + + def test_no_sections_mode_with_lazy_imports(self): + """lazy imports are supported in no_sections mode.""" + result = _sort( + "lazy import ast\nimport os\n", + no_sections=True, + ) + assert result == "import os\nlazy import ast\n" + + def test_from_first_option_respected_for_lazy(self): + """When from_first=True, lazy from imports precede lazy straight imports.""" + result = _sort( + "lazy import ast\nlazy from dataclasses import dataclass\n", + from_first=True, + ) + assert result == ( + "lazy from dataclasses import dataclass\nlazy import ast\n" + ) From 7a1baf9ccdba85595bba69e2ecd305d271773344 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:25:02 +0000 Subject: [PATCH 2/2] Fix docstring for import_type() to list all return values Agent-Logs-Url: https://github.com/PyCQA/isort/sessions/ede8ce0d-f07c-4a61-8637-2887d3f02354 Co-authored-by: DanielNoord <13665637+DanielNoord@users.noreply.github.com> --- isort/_parse_utils.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/isort/_parse_utils.py b/isort/_parse_utils.py index 97eb297c7..cd93d3f3c 100644 --- a/isort/_parse_utils.py +++ b/isort/_parse_utils.py @@ -169,11 +169,12 @@ def normalize_from_import_string(import_string: str) -> str: 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 (from or straight). + """If the current line is an import line it will return its type. - For PEP 810 lazy imports (``lazy import X`` / ``lazy from X import Y``) the - returned type is prefixed with ``"lazy_"`` so callers can distinguish them - from regular (eager) imports. + Possible return values are ``"straight"`` (``import X``), ``"from"`` + (``from X import Y``), ``"lazy_straight"`` (``lazy import X``), and + ``"lazy_from"`` (``lazy from X import Y``). Returns ``None`` for + non-import lines or lines that should be skipped. """ if config.honor_noqa and line.lower().rstrip().endswith("noqa"): return None