From 81e2779f5f961f45c3f8c0ceb528f89c3e6d4e09 Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Sat, 26 Sep 2026 18:34:04 +0700 Subject: [PATCH 1/4] Place added future imports before code in append_only mode --- isort/core.py | 95 ++++++++++++++++++++++++++++++++-- tests/unit/test_regressions.py | 74 ++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 3 deletions(-) diff --git a/isort/core.py b/isort/core.py index 24523a3a5..760b9ad11 100644 --- a/isort/core.py +++ b/isort/core.py @@ -7,6 +7,7 @@ from isort.settings import DEFAULT_CONFIG, Config from . import output, parse +from ._parse_utils import skip_line from .exceptions import ExistingSyntaxErrors, FileSkipComment from .format import format_natural, remove_whitespace from .settings import FILE_SKIP_COMMENTS @@ -36,6 +37,75 @@ SKIP_IMPORT_COMMENTS = ("isort:skip", "isort: skip") +def _is_future_addition(addition: str, config: Config) -> bool: + """Whether an --add-import line belongs to the FUTURE section (see #1970).""" + words = addition.split("#", 1)[0].split() + module = words[1] if len(words) > 1 and words[0] in ("from", "import") else "" + return module.split(".")[0] in config.known_future_library + + +def _future_adds_for_stream( + config: Config, add_imports: list[str], input_stream: TextIO +) -> tuple[list[str], TextIO]: + """Split out FUTURE additions; append_only drops them for import-less files (see #1970).""" + future_add_imports = [ + addition for addition in add_imports if _is_future_addition(addition, config) + ] + if not (config.append_only and future_add_imports): + return future_add_imports, input_stream + peeked_lines = list(input_stream) + probe_quote = "" + probe_off = False + file_has_imports = False + for peeked_line in peeked_lines: + peeked_stripped = peeked_line.strip() + if not peeked_stripped: + continue + probe_quote = skip_line(peeked_line, probe_quote).in_quote + if probe_quote: + continue + if peeked_stripped == "# isort: off": + probe_off = True + continue + if peeked_stripped == "# isort: on": + probe_off = False + continue + if probe_off or peeked_stripped.startswith("#"): + continue + if peeked_stripped.startswith(IMPORT_START_IDENTIFIERS): + file_has_imports = True + break + if not file_has_imports: + future_add_imports = [] + return future_add_imports, StringIO("".join(peeked_lines)) + + +def _drop_unplaceable_future_adds( + config: Config, + add_imports: list[str], + future_add_imports: list[str], + saw_off_code: bool, +) -> tuple[list[str], list[str]]: + """Drop future additions that can no longer be placed legally (see #1970).""" + if config.append_only and saw_off_code and future_add_imports: + add_imports = [addition for addition in add_imports if addition not in future_add_imports] + future_add_imports = [] + return add_imports, future_add_imports + + +def _build_early_import_section( + config: Config, + add_imports: list[str], + future_add_imports: list[str], + line_separator: str, +) -> tuple[str, list[str]]: + """Join pending additions ahead of the first import section (see #1970).""" + additions = add_imports if not config.append_only else future_add_imports + section = line_separator.join(additions) + line_separator + remaining = [addition for addition in add_imports if addition not in additions] + return section, remaining + + def _strip_string_prefix(line: str) -> str: """Return `line` without a leading Python string prefix (`r`, `b`, `f`, `u` or a legal combination of them), so quote-based heuristics see the quote itself. @@ -101,6 +171,7 @@ def process( first_import_section: bool = True indent: str = "" isort_off: bool = False + saw_off_code: bool = False skip_file: bool = False code_sorting: bool | str = False code_sorting_section: str = "" @@ -162,6 +233,8 @@ def process( input_stream = StringIO(new_input) + future_add_imports, input_stream = _future_adds_for_stream(config, add_imports, input_stream) + for index, line in enumerate(chain(input_stream, (None,))): if line is None: if index == 0 and not config.force_adds: @@ -222,6 +295,7 @@ def process( isort_off = True elif stripped_line.startswith("# isort: dont-add-imports"): add_imports = [] + future_add_imports = [] elif stripped_line.startswith("# isort: dont-add-import:"): import_not_to_add = stripped_line.split("# isort: dont-add-import:", 1)[ 1 @@ -231,6 +305,13 @@ def process( for import_to_add in add_imports if import_to_add != import_not_to_add ] + future_add_imports = [ + addition for addition in future_add_imports if addition in add_imports + ] + + saw_off_code = saw_off_code or bool( + isort_off and stripped_line and not _is_comment_or_string_start(line) + ) if ( (index == 0 or (index in {1, 2} and not contains_imports)) @@ -446,7 +527,10 @@ def process( if ( add_imports and (stripped_line or end_of_file) - and not config.append_only + and ( + not config.append_only + or (future_add_imports and not isort_off and not saw_off_code) + ) and not in_top_comment and not was_in_quote and not import_section @@ -454,17 +538,22 @@ def process( and not (line.rstrip().endswith(DOCSTRING_INDICATORS) and "=" not in line) ): add_line_separator = line_separator or "\n" - import_section = add_line_separator.join(add_imports) + add_line_separator + import_section, add_imports = _build_early_import_section( + config, add_imports, future_add_imports, add_line_separator + ) + future_add_imports = [] if end_of_file and index != 0: output_stream.write(add_line_separator) contains_imports = True - add_imports = [] if next_import_section and not import_section: # pragma: no cover raw_import_section = import_section = next_import_section next_import_section = "" if import_section: + add_imports, future_add_imports = _drop_unplaceable_future_adds( + config, add_imports, future_add_imports, saw_off_code + ) if add_imports and (contains_imports or not config.append_only) and not indent: import_section = ( line_separator.join(add_imports) + line_separator + import_section diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index ae24724fb..4f423bc1f 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -2657,3 +2657,77 @@ def test_float_to_top_keeps_indented_semicolon_imports_in_place(): isort.code("def f():\n import b; import a # comment\n", float_to_top=True) == "def f():\n import a # comment\n import b\n" ) + + +def test_append_only_adds_future_import_before_code_issue_1970(): + """append_only must place an added future import before all code, not in a later section. + + A future import past the first code line is a SyntaxError (ast.parse does not + catch this; compile does). + See: https://github.com/PyCQA/isort/issues/1970 + """ + source = ( + "#!/usr/bin/env python3\n" + "\n" + "if __name__ == '__main__':\n" + " import gevent.monkey\n" + " gevent.monkey.patch_all()\n" + "\n" + "import sys\n" + "\n" + "print('silly example', file=sys.stderr)\n" + ) + expected = ( + "#!/usr/bin/env python3\n" + "\n" + "from __future__ import annotations\n" + "\n" + "if __name__ == '__main__':\n" + " import gevent.monkey\n" + " gevent.monkey.patch_all()\n" + "\n" + "import sys\n" + "\n" + "print('silly example', file=sys.stderr)\n" + ) + config = {"append_only": True, "add_imports": {"from __future__ import annotations"}} + output = isort.code(source, **config) + assert output == expected + compile(output, "t.py", "exec") # must never raise + assert isort.code(output, **config) == output + + +def test_append_only_future_add_untouched_without_imports_issue_1970(): + """append_only still adds nothing to files without imports, future adds included. + + See: https://github.com/PyCQA/isort/issues/1970 + """ + source = "x = 1\nprint(x)\n" + config = {"append_only": True, "add_imports": {"from __future__ import annotations"}} + assert isort.code(source, **config) == source + + +def test_append_only_future_add_keeps_isort_off_region_intact_issue_1970(): + """append_only must not insert a future add inside an `# isort: off` region. + + The region's imports do not count as existing imports, so the file is + left byte-identical. + See: https://github.com/PyCQA/isort/issues/1970 + """ + source = "# isort: off\nimport os\n# isort: on\nprint(os.getcwd())\n" + config = {"append_only": True, "add_imports": {"from __future__ import annotations"}} + assert isort.code(source, **config) == source + + +def test_append_only_future_add_dropped_after_off_code_issue_1970(): + """append_only drops a future add when frozen executable content precedes placement. + + Inserting after the off region would be illegal; skipping keeps the file intact. + See: https://github.com/PyCQA/isort/issues/1970 + """ + source = "# c1\n# c2\n# c3\n# isort: off\nx = 1\n# isort: on\nimport sys\n\nprint(sys.argv)\n" + config = {"append_only": True, "add_imports": {"from __future__ import annotations"}} + output = isort.code(source, **config) + assert output == source + compile(output, "t.py", "exec") # must never raise + assert isort.code(output, **config) == output From 5d0296b8c65b06441cf39f9b725f547d57a51101 Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Fri, 16 Oct 2026 08:11:57 +0700 Subject: [PATCH 2/4] place added future imports before code in append_only mode --- isort/core.py | 129 ++++++++++++--------------------- tests/unit/test_regressions.py | 34 +-------- 2 files changed, 47 insertions(+), 116 deletions(-) diff --git a/isort/core.py b/isort/core.py index 760b9ad11..0fe6ef0be 100644 --- a/isort/core.py +++ b/isort/core.py @@ -37,73 +37,52 @@ SKIP_IMPORT_COMMENTS = ("isort:skip", "isort: skip") -def _is_future_addition(addition: str, config: Config) -> bool: - """Whether an --add-import line belongs to the FUTURE section (see #1970).""" - words = addition.split("#", 1)[0].split() - module = words[1] if len(words) > 1 and words[0] in ("from", "import") else "" - return module.split(".")[0] in config.known_future_library +def _is_future_addition(addition: str) -> bool: + """Whether an --add-import line is a future import (see #1970).""" + return addition.split("#", 1)[0].split()[:3] == ["from", "__future__", "import"] -def _future_adds_for_stream( +def _stream_has_imports(lines: list[str]) -> bool: + """Whether any line is a real import; strings and comments are skipped (see #1970).""" + in_quote = "" + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + in_quote = skip_line(line, in_quote).in_quote + if in_quote: + continue + if stripped.startswith(IMPORT_START_IDENTIFIERS): + return True + return False + + +def _has_future_addition(add_imports: list[str]) -> bool: + """Whether any pending addition is a future import (see #1970).""" + return any(_is_future_addition(addition) for addition in add_imports) + + +def _drop_future_adds_without_imports( config: Config, add_imports: list[str], input_stream: TextIO ) -> tuple[list[str], TextIO]: - """Split out FUTURE additions; append_only drops them for import-less files (see #1970).""" - future_add_imports = [ - addition for addition in add_imports if _is_future_addition(addition, config) - ] - if not (config.append_only and future_add_imports): - return future_add_imports, input_stream + """Drop future adds when append_only has no imports to attach to (see #1970).""" + if not (config.append_only and _has_future_addition(add_imports)): + return add_imports, input_stream peeked_lines = list(input_stream) - probe_quote = "" - probe_off = False - file_has_imports = False - for peeked_line in peeked_lines: - peeked_stripped = peeked_line.strip() - if not peeked_stripped: - continue - probe_quote = skip_line(peeked_line, probe_quote).in_quote - if probe_quote: - continue - if peeked_stripped == "# isort: off": - probe_off = True - continue - if peeked_stripped == "# isort: on": - probe_off = False - continue - if probe_off or peeked_stripped.startswith("#"): - continue - if peeked_stripped.startswith(IMPORT_START_IDENTIFIERS): - file_has_imports = True - break - if not file_has_imports: - future_add_imports = [] - return future_add_imports, StringIO("".join(peeked_lines)) - - -def _drop_unplaceable_future_adds( - config: Config, - add_imports: list[str], - future_add_imports: list[str], - saw_off_code: bool, -) -> tuple[list[str], list[str]]: - """Drop future additions that can no longer be placed legally (see #1970).""" - if config.append_only and saw_off_code and future_add_imports: - add_imports = [addition for addition in add_imports if addition not in future_add_imports] - future_add_imports = [] - return add_imports, future_add_imports - - -def _build_early_import_section( - config: Config, - add_imports: list[str], - future_add_imports: list[str], - line_separator: str, -) -> tuple[str, list[str]]: - """Join pending additions ahead of the first import section (see #1970).""" - additions = add_imports if not config.append_only else future_add_imports - section = line_separator.join(additions) + line_separator - remaining = [addition for addition in add_imports if addition not in additions] - return section, remaining + if not _stream_has_imports(peeked_lines): + add_imports = [addition for addition in add_imports if not _is_future_addition(addition)] + return add_imports, StringIO("".join(peeked_lines)) + + +def _split_early_additions(config: Config, add_imports: list[str]) -> tuple[list[str], list[str]]: + """Split additions for the early insertion path (see #1970). + + In append_only mode only future imports may go first; the rest keep the old path. + """ + if not config.append_only: + return add_imports, [] + early = [addition for addition in add_imports if _is_future_addition(addition)] + return early, [addition for addition in add_imports if addition not in early] def _strip_string_prefix(line: str) -> str: @@ -159,6 +138,7 @@ def process( """ line_separator: str = config.line_ending add_imports: list[str] = [format_natural(addition) for addition in config.add_imports] + add_imports, input_stream = _drop_future_adds_without_imports(config, add_imports, input_stream) import_section: str = "" next_import_section: str = "" next_cimports: bool = False @@ -171,7 +151,6 @@ def process( first_import_section: bool = True indent: str = "" isort_off: bool = False - saw_off_code: bool = False skip_file: bool = False code_sorting: bool | str = False code_sorting_section: str = "" @@ -233,8 +212,6 @@ def process( input_stream = StringIO(new_input) - future_add_imports, input_stream = _future_adds_for_stream(config, add_imports, input_stream) - for index, line in enumerate(chain(input_stream, (None,))): if line is None: if index == 0 and not config.force_adds: @@ -295,7 +272,6 @@ def process( isort_off = True elif stripped_line.startswith("# isort: dont-add-imports"): add_imports = [] - future_add_imports = [] elif stripped_line.startswith("# isort: dont-add-import:"): import_not_to_add = stripped_line.split("# isort: dont-add-import:", 1)[ 1 @@ -305,13 +281,6 @@ def process( for import_to_add in add_imports if import_to_add != import_not_to_add ] - future_add_imports = [ - addition for addition in future_add_imports if addition in add_imports - ] - - saw_off_code = saw_off_code or bool( - isort_off and stripped_line and not _is_comment_or_string_start(line) - ) if ( (index == 0 or (index in {1, 2} and not contains_imports)) @@ -527,10 +496,7 @@ def process( if ( add_imports and (stripped_line or end_of_file) - and ( - not config.append_only - or (future_add_imports and not isort_off and not saw_off_code) - ) + and (not config.append_only or _has_future_addition(add_imports)) and not in_top_comment and not was_in_quote and not import_section @@ -538,10 +504,8 @@ def process( and not (line.rstrip().endswith(DOCSTRING_INDICATORS) and "=" not in line) ): add_line_separator = line_separator or "\n" - import_section, add_imports = _build_early_import_section( - config, add_imports, future_add_imports, add_line_separator - ) - future_add_imports = [] + early_adds, add_imports = _split_early_additions(config, add_imports) + import_section = add_line_separator.join(early_adds) + add_line_separator if end_of_file and index != 0: output_stream.write(add_line_separator) contains_imports = True @@ -551,9 +515,6 @@ def process( next_import_section = "" if import_section: - add_imports, future_add_imports = _drop_unplaceable_future_adds( - config, add_imports, future_add_imports, saw_off_code - ) if add_imports and (contains_imports or not config.append_only) and not indent: import_section = ( line_separator.join(add_imports) + line_separator + import_section diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index 4f423bc1f..d067d5ba4 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -2662,8 +2662,7 @@ def test_float_to_top_keeps_indented_semicolon_imports_in_place(): def test_append_only_adds_future_import_before_code_issue_1970(): """append_only must place an added future import before all code, not in a later section. - A future import past the first code line is a SyntaxError (ast.parse does not - catch this; compile does). + A future import past the first code line is a SyntaxError. See: https://github.com/PyCQA/isort/issues/1970 """ source = ( @@ -2698,36 +2697,7 @@ def test_append_only_adds_future_import_before_code_issue_1970(): def test_append_only_future_add_untouched_without_imports_issue_1970(): - """append_only still adds nothing to files without imports, future adds included. - - See: https://github.com/PyCQA/isort/issues/1970 - """ + """append_only still adds nothing to files without imports, future adds included.""" source = "x = 1\nprint(x)\n" config = {"append_only": True, "add_imports": {"from __future__ import annotations"}} assert isort.code(source, **config) == source - - -def test_append_only_future_add_keeps_isort_off_region_intact_issue_1970(): - """append_only must not insert a future add inside an `# isort: off` region. - - The region's imports do not count as existing imports, so the file is - left byte-identical. - See: https://github.com/PyCQA/isort/issues/1970 - """ - source = "# isort: off\nimport os\n# isort: on\nprint(os.getcwd())\n" - config = {"append_only": True, "add_imports": {"from __future__ import annotations"}} - assert isort.code(source, **config) == source - - -def test_append_only_future_add_dropped_after_off_code_issue_1970(): - """append_only drops a future add when frozen executable content precedes placement. - - Inserting after the off region would be illegal; skipping keeps the file intact. - See: https://github.com/PyCQA/isort/issues/1970 - """ - source = "# c1\n# c2\n# c3\n# isort: off\nx = 1\n# isort: on\nimport sys\n\nprint(sys.argv)\n" - config = {"append_only": True, "add_imports": {"from __future__ import annotations"}} - output = isort.code(source, **config) - assert output == source - compile(output, "t.py", "exec") # must never raise - assert isort.code(output, **config) == output From 5fec36f87cd847a04dc6a9f438138aed695cc122 Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Tue, 20 Oct 2026 19:57:31 +0700 Subject: [PATCH 3/4] Fix mypy annotations --- tests/unit/test_regressions.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index 3febe3a65..7fc3d2861 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -2857,7 +2857,7 @@ def test_float_to_top_keeps_indented_semicolon_imports_in_place() -> None: ) -def test_append_only_adds_future_import_before_code_issue_1970(): +def test_append_only_adds_future_import_before_code_issue_1970() -> None: """append_only must place an added future import before all code, not in a later section. A future import past the first code line is a SyntaxError. @@ -2887,18 +2887,18 @@ def test_append_only_adds_future_import_before_code_issue_1970(): "\n" "print('silly example', file=sys.stderr)\n" ) - config = {"append_only": True, "add_imports": {"from __future__ import annotations"}} - output = isort.code(source, **config) + add_imports = {"from __future__ import annotations"} + output = isort.code(source, append_only=True, add_imports=add_imports) assert output == expected compile(output, "t.py", "exec") # must never raise - assert isort.code(output, **config) == output + assert isort.code(output, append_only=True, add_imports=add_imports) == output -def test_append_only_future_add_untouched_without_imports_issue_1970(): +def test_append_only_future_add_untouched_without_imports_issue_1970() -> None: """append_only still adds nothing to files without imports, future adds included.""" source = "x = 1\nprint(x)\n" - config = {"append_only": True, "add_imports": {"from __future__ import annotations"}} - assert isort.code(source, **config) == source + add_imports = {"from __future__ import annotations"} + assert isort.code(source, append_only=True, add_imports=add_imports) == source def test_lines_before_imports_keeps_import_after_indented_block() -> None: From a99ffe4efeec58c44f82e753a3ecd77668c54ed0 Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Sun, 25 Oct 2026 14:57:20 +0700 Subject: [PATCH 4/4] Place added future import before a leading f-string --- isort/core.py | 13 ++++++++++++- tests/unit/test_regressions.py | 11 +++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/isort/core.py b/isort/core.py index ef24136b9..3ce22399f 100644 --- a/isort/core.py +++ b/isort/core.py @@ -106,6 +106,14 @@ def _is_comment_or_string_start(line: str) -> bool: return _strip_string_prefix(line.lstrip()).startswith(COMMENT_INDICATORS) +def _is_fstring_expression(line: str) -> bool: + """Whether `line` starts with an f-string literal, which can never be a docstring.""" + stripped = line.lstrip().lower() + return stripped[:2] in ("f'", 'f"') or ( + stripped[:2] in ("fr", "rf") and stripped[2:3] in ("'", '"') + ) + + def _has_skip_comment(import_statement: str) -> bool: """Return whether an import statement carries a per-line ``isort: skip`` directive.""" return any(comment in import_statement for comment in SKIP_IMPORT_COMMENTS) @@ -545,7 +553,10 @@ def process( and not in_top_comment and not was_in_quote and not import_section - and not _is_comment_or_string_start(line) + and ( + not _is_comment_or_string_start(line) + or (config.append_only and _is_fstring_expression(line)) + ) and not (line.rstrip().endswith(DOCSTRING_INDICATORS) and "=" not in line) ): early_adds, add_imports = _split_early_additions(config, add_imports) diff --git a/tests/unit/test_regressions.py b/tests/unit/test_regressions.py index b38d43ba0..504b8cc35 100644 --- a/tests/unit/test_regressions.py +++ b/tests/unit/test_regressions.py @@ -3009,6 +3009,17 @@ def test_append_only_future_add_untouched_without_imports_issue_1970() -> None: assert isort.code(source, append_only=True, add_imports=add_imports) == source +def test_append_only_future_add_before_fstring_expression_issue_1970() -> None: + """An f-string is code, so the future import must precede it.""" + source = "f'initialization'\nimport os\n\nmain()\n" + add_imports = {"from __future__ import annotations"} + expected = "from __future__ import annotations\n\nf'initialization'\nimport os\n\nmain()\n" + output = isort.code(source, append_only=True, add_imports=add_imports) + assert output == expected + compile(output, "t.py", "exec") + assert isort.code(output, append_only=True, add_imports=add_imports) == output + + def test_lines_before_imports_keeps_import_after_indented_block() -> None: """A top-level import that follows an indented import block must not be dropped when ``lines_before_imports`` is set.