Skip to content

Commit f8871a2

Browse files
CopilotDanielNoord
andcommitted
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>
1 parent 65d8feb commit f8871a2

5 files changed

Lines changed: 373 additions & 16 deletions

File tree

isort/_parse_utils.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,15 @@ def normalize_from_import_string(import_string: str) -> str:
166166

167167

168168
# TODO: Return a `StrEnum` once we no longer support Python 3.10.
169-
def import_type(line: str, config: Config) -> Literal["from", "straight"] | None:
170-
"""If the current line is an import line it will return its type (from or straight)"""
169+
def import_type(
170+
line: str, config: Config
171+
) -> Literal["from", "straight", "lazy_from", "lazy_straight"] | None:
172+
"""If the current line is an import line it will return its type (from or straight).
173+
174+
For PEP 810 lazy imports (``lazy import X`` / ``lazy from X import Y``) the
175+
returned type is prefixed with ``"lazy_"`` so callers can distinguish them
176+
from regular (eager) imports.
177+
"""
171178
if config.honor_noqa and line.lower().rstrip().endswith("noqa"):
172179
return None
173180
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
176183
return "straight"
177184
if line.startswith("from "):
178185
return "from"
186+
if line.startswith("lazy import "):
187+
return "lazy_straight"
188+
if line.startswith("lazy from "):
189+
return "lazy_from"
179190
return None

isort/core.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,15 @@
1212
from .settings import FILE_SKIP_COMMENTS
1313

1414
CIMPORT_IDENTIFIERS = ("cimport ", "cimport*", "from.cimport")
15-
IMPORT_START_IDENTIFIERS = ("from ", "from.import", "import ", "import*", *CIMPORT_IDENTIFIERS)
15+
IMPORT_START_IDENTIFIERS = (
16+
"from ",
17+
"from.import",
18+
"import ",
19+
"import*",
20+
"lazy import ",
21+
"lazy from ",
22+
*CIMPORT_IDENTIFIERS,
23+
)
1624
DOCSTRING_INDICATORS = ('"""', "'''")
1725
COMMENT_INDICATORS = (*DOCSTRING_INDICATORS, "'", '"', "#")
1826
CODE_SORT_COMMENTS = (

isort/output.py

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def sorted_imports(
3535
sections: Iterable[str] = itertools.chain(parsed.sections, config.forced_separate)
3636

3737
if config.no_sections:
38-
parsed.imports["no_sections"] = {"straight": {}, "from": {}}
38+
parsed.imports["no_sections"] = {"straight": {}, "from": {}, "lazy_straight": {}, "lazy_from": {}}
3939
base_sections: tuple[str, ...] = ()
4040
for section in sections:
4141
if section == "FUTURE":
@@ -45,6 +45,12 @@ def sorted_imports(
4545
parsed.imports[section].get("straight", {})
4646
)
4747
parsed.imports["no_sections"]["from"].update(parsed.imports[section].get("from", {}))
48+
parsed.imports["no_sections"]["lazy_straight"].update(
49+
parsed.imports[section].get("lazy_straight", {})
50+
)
51+
parsed.imports["no_sections"]["lazy_from"].update(
52+
parsed.imports[section].get("lazy_from", {})
53+
)
4854
sections = (*base_sections, "no_sections")
4955

5056
output: list[str] = []
@@ -126,6 +132,62 @@ def sorted_imports(
126132
section_output.extend(comments)
127133
section_output.append(str(line))
128134

135+
# PEP 810 lazy imports always follow all eager imports within the section.
136+
lazy_straight_modules = parsed.imports[section].get("lazy_straight", {})
137+
if not config.only_sections:
138+
lazy_straight_modules = sorting.sort(
139+
config,
140+
lazy_straight_modules,
141+
key=lambda key: sorting.module_key(
142+
key, config, section_name=section, straight_import=True
143+
),
144+
reverse=config.reverse_sort,
145+
)
146+
147+
lazy_from_modules = parsed.imports[section].get("lazy_from", {})
148+
if not config.only_sections:
149+
lazy_from_modules = sorting.sort(
150+
config,
151+
lazy_from_modules,
152+
key=lambda key: sorting.module_key(key, config, section_name=section),
153+
reverse=config.reverse_sort,
154+
)
155+
156+
lazy_straight_imports = _with_straight_imports(
157+
parsed,
158+
config,
159+
lazy_straight_modules,
160+
section,
161+
remove_imports,
162+
f"lazy {import_type}",
163+
import_key="lazy_straight",
164+
)
165+
lazy_from_imports = _with_from_imports(
166+
parsed,
167+
config,
168+
lazy_from_modules,
169+
section,
170+
remove_imports,
171+
f"lazy {import_type}",
172+
import_key="lazy_from",
173+
)
174+
175+
lazy_lines_between = [""] * (
176+
config.lines_between_types
177+
if lazy_from_modules and lazy_straight_modules
178+
else 0
179+
)
180+
if config.from_first or section == "FUTURE":
181+
lazy_section_output = lazy_from_imports + lazy_lines_between + lazy_straight_imports
182+
else:
183+
lazy_section_output = lazy_straight_imports + lazy_lines_between + lazy_from_imports
184+
185+
if lazy_section_output:
186+
if section_output:
187+
section_output += [""] * config.lines_between_types + lazy_section_output
188+
else:
189+
section_output = lazy_section_output
190+
129191
section_name = section
130192
no_lines_before = section_name in config.no_lines_before
131193

@@ -254,14 +316,21 @@ def _with_from_imports(
254316
section: str,
255317
remove_imports: list[str],
256318
import_type: str,
319+
import_key: str = "from",
257320
) -> list[str]:
258321
output: list[str] = []
259322
for module in from_modules:
260323
if module in remove_imports:
261324
continue
262325

263-
import_start = f"from {module} {import_type} "
264-
from_imports = list(parsed.imports[section]["from"][module])
326+
# For lazy from imports the keyword order is ``lazy from X import ...``
327+
# rather than the naive ``from X lazy import ...`` that would result from
328+
# placing ``import_type`` between the module name and ``import``.
329+
if import_type.startswith("lazy "):
330+
import_start = f"lazy from {module} import "
331+
else:
332+
import_start = f"from {module} {import_type} "
333+
from_imports = list(parsed.imports[section][import_key][module])
265334
if (
266335
not config.no_inline_sort
267336
or (config.force_single_line and module not in config.single_line_exclusions)
@@ -299,7 +368,7 @@ def _with_from_imports(
299368
for from_import in copy.copy(from_imports):
300369
if from_import in as_imports:
301370
idx = from_imports.index(from_import)
302-
if parsed.imports[section]["from"][module][from_import]:
371+
if parsed.imports[section][import_key][module][from_import]:
303372
from_imports[(idx + 1) : (idx + 1)] = as_imports.pop(from_import)
304373
else:
305374
from_imports[idx : (idx + 1)] = as_imports.pop(from_import)
@@ -347,7 +416,7 @@ def _with_from_imports(
347416
)
348417
if from_import in as_imports:
349418
if (
350-
parsed.imports[section]["from"][module][from_import]
419+
parsed.imports[section][import_key][module][from_import]
351420
and not only_show_as_imports
352421
):
353422
output.append(
@@ -403,7 +472,7 @@ def _with_from_imports(
403472
parsed.categorized_comments["straight"].get(f"{module}.{from_import}") or []
404473
)
405474
if (
406-
parsed.imports[section]["from"][module][from_import]
475+
parsed.imports[section][import_key][module][from_import]
407476
and not only_show_as_imports
408477
):
409478
specific_comment = (
@@ -540,7 +609,7 @@ def _with_from_imports(
540609
from_imports[0] not in as_imports
541610
or (
542611
config.combine_as_imports
543-
and parsed.imports[section]["from"][module][from_import]
612+
and parsed.imports[section][import_key][module][from_import]
544613
)
545614
):
546615
from_import_section.append(from_imports.pop(0))
@@ -633,6 +702,7 @@ def _with_straight_imports(
633702
section: str,
634703
remove_imports: list[str],
635704
import_type: str,
705+
import_key: str = "straight",
636706
) -> list[str]:
637707
output: list[str] = []
638708

@@ -675,7 +745,7 @@ def _with_straight_imports(
675745

676746
import_definition = []
677747
if module in parsed.as_map["straight"]:
678-
if parsed.imports[section]["straight"][module]:
748+
if parsed.imports[section][import_key][module]:
679749
import_definition.append((f"{import_type} {module}", module))
680750
import_definition.extend(
681751
(f"{import_type} {module} as {as_import}", f"{module} as {as_import}")

isort/parse.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,12 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte
8686
verbose_output: list[str] = []
8787

8888
for section in chain(config.sections, config.forced_separate):
89-
imports[section] = {"straight": OrderedDict(), "from": OrderedDict()}
89+
imports[section] = {
90+
"straight": OrderedDict(),
91+
"from": OrderedDict(),
92+
"lazy_straight": OrderedDict(),
93+
"lazy_from": OrderedDict(),
94+
}
9095
categorized_comments: CommentsDict = {
9196
"from": {},
9297
"straight": {},
@@ -185,6 +190,15 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte
185190
out_lines.append(raw_line)
186191
continue
187192

193+
# Detect PEP 810 lazy imports (``lazy import X`` / ``lazy from X import Y``).
194+
# We strip the ``lazy `` prefix so the rest of the parsing logic works normally
195+
# on the resulting ``import X`` / ``from X import Y`` string. The original
196+
# lazy type is remembered in ``is_lazy`` and used later when storing the result.
197+
is_lazy = type_of_import in ("lazy_straight", "lazy_from")
198+
if is_lazy:
199+
line = line[len("lazy "):]
200+
type_of_import = type_of_import[len("lazy_"):] # "lazy_straight" → "straight"
201+
188202
if import_index == -1:
189203
import_index = index - 1
190204
nested_comments = {}
@@ -305,7 +319,7 @@ def _get_next_line() -> tuple[str, str | None]:
305319
if placed_module and placed_module not in imports:
306320
raise MissingSection(import_module=import_from, section=placed_module)
307321

308-
root = imports[placed_module][type_of_import]
322+
root = imports[placed_module]["lazy_from" if is_lazy else type_of_import]
309323
for import_name in just_imports:
310324
associated_comment = nested_comments.get(import_name)
311325
if associated_comment is not None:
@@ -420,13 +434,25 @@ def _get_next_line() -> tuple[str, str | None]:
420434
" Do you need to define a default section?",
421435
stacklevel=2,
422436
)
423-
imports.setdefault("", {"straight": OrderedDict(), "from": OrderedDict()})
437+
imports.setdefault(
438+
"",
439+
{
440+
"straight": OrderedDict(),
441+
"from": OrderedDict(),
442+
"lazy_straight": OrderedDict(),
443+
"lazy_from": OrderedDict(),
444+
},
445+
)
424446

425447
if placed_module and placed_module not in imports:
426448
raise MissingSection(import_module=module, section=placed_module)
427449

428-
straight_import |= imports[placed_module][type_of_import].get(module, False)
429-
imports[placed_module][type_of_import][module] = straight_import
450+
straight_import |= imports[placed_module][
451+
"lazy_straight" if is_lazy else type_of_import
452+
].get(module, False)
453+
imports[placed_module]["lazy_straight" if is_lazy else type_of_import][
454+
module
455+
] = straight_import
430456

431457
change_count = len(out_lines) - original_line_count
432458

0 commit comments

Comments
 (0)