diff --git a/CHANGES.rst b/CHANGES.rst index bb19e2471..9bd06168e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,7 +8,9 @@ Added - The ``--workers``/``-W`` option now specifies how many Darker jobs are used to process files in parallel to complete reformatting/linting faster. - Linters can now be installed and run in the GitHub Action using the ``lint:`` option. - +- Sort imports only if the range of modified lines overlaps with changes resulting from + sorting the imports. + Fixed ----- - Avoid memory leak from using ``@lru_cache`` on a method. diff --git a/src/darker/__main__.py b/src/darker/__main__.py index e70c459a3..8a152d00b 100644 --- a/src/darker/__main__.py +++ b/src/darker/__main__.py @@ -20,7 +20,7 @@ from darker.command_line import parse_command_line from darker.concurrency import get_executor from darker.config import OutputMode, dump_config -from darker.diff import diff_and_get_opcodes, opcodes_to_chunks +from darker.diff import diff_chunks from darker.exceptions import DependencyError, MissingPackageError from darker.git import ( PRE_COMMIT_FROM_TO_REFS, @@ -28,7 +28,7 @@ EditedLinenumsDiffer, RevisionRange, get_missing_at_revision, - get_rev1_path, + get_path_in_repo, git_get_content_at_revision, git_get_modified_python_files, git_is_repository, @@ -80,63 +80,77 @@ def format_edited_parts( with get_executor(max_workers=workers) as executor: # pylint: disable=unsubscriptable-object futures: List[concurrent.futures.Future[ProcessedDocument]] = [] - for path_in_repo in sorted(changed_files): + edited_linenums_differ = EditedLinenumsDiffer(root, revrange) + for relative_path_in_rev2 in sorted(changed_files): future = executor.submit( _isort_and_blacken_single_file, root, - path_in_repo, + relative_path_in_rev2, + edited_linenums_differ, + black_exclude, revrange, - black_config, enable_isort, - enable_black=path_in_repo not in black_exclude, + black_config, ) futures.append(future) for future in concurrent.futures.as_completed(futures): - src, rev2_content, content_after_reformatting = future.result() + ( + absolute_path_in_rev2, + rev2_content, + content_after_reformatting, + ) = future.result() if report_unmodified or content_after_reformatting != rev2_content: - yield (src, rev2_content, content_after_reformatting) + yield (absolute_path_in_rev2, rev2_content, content_after_reformatting) def _isort_and_blacken_single_file( # pylint: disable=too-many-arguments root: Path, - relative_path: Path, + relative_path_in_rev2: Path, + edited_linenums_differ: EditedLinenumsDiffer, + black_exclude: Collection[Path], # pylint: disable=unsubscriptable-object revrange: RevisionRange, - black_config: BlackConfig, enable_isort: bool, - enable_black: bool, + black_config: BlackConfig, ) -> ProcessedDocument: """Black and/or isort formatting for modified chunks in a single file :param root: Root directory for the relative path - :param relative_path: Relative path to a Python source code file + :param relative_path_in_rev2: Relative path to a Python source code file + :param black_exclude: Python files to not reformat using Black, according to Black + configuration :param revrange: The Git revisions to compare - :param black_config: Configuration to use for running Black :param enable_isort: ``True`` to run ``isort`` first on the file contents - :param enable_black: ``True`` to also run ``black`` on the file contents + :param black_config: Configuration to use for running Black :return: Details about changes for the file """ - src = root / relative_path - rev2_content = git_get_content_at_revision(relative_path, revrange.rev2, root) + # With VSCode, `relative_path_in_rev2` may be a `.py..tmp` file in the + # working tree instead of a `.py` file. + absolute_path_in_rev2 = root / relative_path_in_rev2 + rev2_content = git_get_content_at_revision( + relative_path_in_rev2, revrange.rev2, root + ) # 1. run isort if enable_isort: rev2_isorted = apply_isort( rev2_content, - src, + relative_path_in_rev2, + edited_linenums_differ, black_config.get("config"), black_config.get("line_length"), ) else: rev2_isorted = rev2_content - if enable_black: + if relative_path_in_rev2 not in black_exclude: # 9. A re-formatted Python file which produces an identical AST was # created successfully - write an updated file or print the diff if # there were any changes to the original content_after_reformatting = _blacken_single_file( root, - relative_path, - revrange, + relative_path_in_rev2, + get_path_in_repo(relative_path_in_rev2), + edited_linenums_differ, rev2_content, rev2_isorted, enable_isort, @@ -145,13 +159,14 @@ def _isort_and_blacken_single_file( # pylint: disable=too-many-arguments else: # File was excluded by Black configuration, don't reformat content_after_reformatting = rev2_isorted - return src, rev2_content, content_after_reformatting + return absolute_path_in_rev2, rev2_content, content_after_reformatting def _blacken_single_file( # pylint: disable=too-many-arguments,too-many-locals root: Path, - relative_path: Path, - revrange: RevisionRange, + relative_path_in_rev2: Path, + relative_path_in_repo: Path, + edited_linenums_differ: EditedLinenumsDiffer, rev2_content: TextDocument, rev2_isorted: TextDocument, enable_isort: bool, @@ -159,9 +174,12 @@ def _blacken_single_file( # pylint: disable=too-many-arguments,too-many-locals ) -> TextDocument: """In a Python file, reformat chunks with edits since the last commit using Black - :param root: Root directory for the relative path - :param relative_path: Relative path to a Python source code file - :param revrange: The Git revisions to compare + :param root: The common root of all files to reformat + :param relative_path_in_rev2: Relative path to a Python source code file. Possibly a + VSCode ``.py..tmp`` file in the working tree. + :param relative_path_in_repo: Relative path to source in the Git repository. Same as + ``relative_path_in_rev2`` save for VSCode temp files. + :param edited_linenums_differ: Helper for finding out which lines were edited :param rev2_content: Contents of the file at ``revrange.rev2`` :param rev2_isorted: Contents of the file after optional import sorting :param enable_isort: ``True`` if ``isort`` was already run for the file @@ -170,20 +188,20 @@ def _blacken_single_file( # pylint: disable=too-many-arguments,too-many-locals :raise: NotEquivalentError """ - src = root / relative_path - rev1_relative_path = get_rev1_path(relative_path) - edited_linenums_differ = EditedLinenumsDiffer(root, revrange) + absolute_path_in_rev2 = root / relative_path_in_rev2 # 4. run black formatted = run_black(rev2_isorted, black_config) - logger.debug("Read %s lines from edited file %s", len(rev2_isorted.lines), src) + logger.debug( + "Read %s lines from edited file %s", + len(rev2_isorted.lines), + absolute_path_in_rev2, + ) logger.debug("Black reformat resulted in %s lines", len(formatted.lines)) # 5. get the diff between the edited and reformatted file - opcodes = diff_and_get_opcodes(rev2_isorted, formatted) - # 6. convert the diff into chunks - black_chunks = list(opcodes_to_chunks(opcodes, rev2_isorted, formatted)) + black_chunks = diff_chunks(rev2_isorted, formatted) # Exit early if nothing to do if not black_chunks: @@ -201,15 +219,15 @@ def _blacken_single_file( # pylint: disable=too-many-arguments,too-many-locals logger.debug( "Trying with %s lines of context for `git diff -U %s`", context_lines, - src, + absolute_path_in_rev2, ) # 2. diff the given revision and worktree for the file # 3. extract line numbers in the edited to-file for changed lines edited_linenums = edited_linenums_differ.revision_vs_lines( - rev1_relative_path, rev2_isorted, context_lines + relative_path_in_repo, rev2_isorted, context_lines ) if enable_isort and not edited_linenums and rev2_isorted == rev2_content: - logger.debug("No changes in %s after isort", src) + logger.debug("No changes in %s after isort", absolute_path_in_rev2) last_successful_reformat = rev2_isorted break @@ -232,7 +250,7 @@ def _blacken_single_file( # pylint: disable=too-many-arguments,too-many-locals debug_dump(black_chunks, edited_linenums) logger.debug( "AST verification of %s with %s lines of context failed", - src, + absolute_path_in_rev2, context_lines, ) minimum_context_lines.respond(False) @@ -240,7 +258,7 @@ def _blacken_single_file( # pylint: disable=too-many-arguments,too-many-locals minimum_context_lines.respond(True) last_successful_reformat = chosen if not last_successful_reformat: - raise NotEquivalentError(relative_path) + raise NotEquivalentError(relative_path_in_rev2) return last_successful_reformat diff --git a/src/darker/diff.py b/src/darker/diff.py index 646ae2723..2445af45b 100644 --- a/src/darker/diff.py +++ b/src/darker/diff.py @@ -177,3 +177,29 @@ def opcodes_to_chunks( _validate_opcodes(opcodes) for _tag, src_start, src_end, dst_start, dst_end in opcodes: yield src_start + 1, src.lines[src_start:src_end], dst.lines[dst_start:dst_end] + + +def diff_chunks(src: TextDocument, dst: TextDocument) -> List[DiffChunk]: + """Diff two documents and return the list of chunks in the diff + + Each chunk is a 3-tuple:: + + ( + linenum: int, + old_lines: List[str], + new_lines: List[str], + ) + + ``old_lines`` and ``new_lines`` may be + + - identical to indicate a chunk with no changes, + - of the same length but different items to indicate some modified lines, or + - of different lengths to indicate removed or inserted lines. + + For the return value ``retval``, the following always holds:: + + retval[n + 1][0] == retval[n][0] + len(retval[n][old_lines]) + + """ + opcodes = diff_and_get_opcodes(src, dst) + return list(opcodes_to_chunks(opcodes, src, dst)) diff --git a/src/darker/git.py b/src/darker/git.py index 75ff318c4..0e294c86e 100644 --- a/src/darker/git.py +++ b/src/darker/git.py @@ -170,7 +170,7 @@ def _with_common_ancestor(cls, rev1: str, rev2: str, cwd: Path) -> "RevisionRang return cls(rev1 if common_ancestor == rev1_hash else common_ancestor, rev2) -def get_rev1_path(path: Path) -> Path: +def get_path_in_repo(path: Path) -> Path: """Return the relative path to the file in the old revision This is usually the same as the relative path on the command line. But in the @@ -188,7 +188,7 @@ def get_rev1_path(path: Path) -> Path: def should_reformat_file(path: Path) -> bool: - return path.exists() and get_rev1_path(path).suffix == ".py" + return path.exists() and get_path_in_repo(path).suffix == ".py" @lru_cache(maxsize=1) diff --git a/src/darker/import_sorting.py b/src/darker/import_sorting.py index f226d53b9..1174d96f4 100644 --- a/src/darker/import_sorting.py +++ b/src/darker/import_sorting.py @@ -1,11 +1,13 @@ import logging import sys from pathlib import Path -from typing import Any, Optional +from typing import Any, List, Optional from darker.black_compat import find_project_root +from darker.diff import diff_chunks from darker.exceptions import IncompatiblePackageError, MissingPackageError -from darker.utils import TextDocument +from darker.git import EditedLinenumsDiffer +from darker.utils import DiffChunk, TextDocument if sys.version_info >= (3, 8): from typing import TypedDict @@ -55,31 +57,113 @@ class IsortArgs(TypedDict, total=False): def apply_isort( content: TextDocument, src: Path, + edited_linenums_differ: EditedLinenumsDiffer, config: Optional[str] = None, line_length: Optional[int] = None, ) -> TextDocument: - isort_args = IsortArgs() + """Run isort on the given Python source file content + + :param content: The contents of the Python source code file to sort imports in + :param src: The relative path to the file. This must be the actual path in the + repository, which may differ from the path given on the command line in + case of VSCode temporary files. + :param edited_linenums_differ: Helper for finding out which lines were edited + :param config: Path to configuration file + :param line_length: Maximum line length to use + + """ + edited_linenums = edited_linenums_differ.revision_vs_lines( + src, + content, + context_lines=0, + ) + if not edited_linenums: + return content + isort_args = _build_isort_args(src, config, line_length) + rev2_isorted = _call_isort_code(content, isort_args) + # Get the chunks in the diff between the edited and import-sorted file + isort_chunks = diff_chunks(content, rev2_isorted) + if not isort_chunks: + # No imports were sorted. Return original content. + return content + if not _diff_overlaps_with_edits(edited_linenums, isort_chunks): + # No lines had been modified in the range of modified import lines. Return + # original content. + return content + # The range lines modified by sorted imports overlaps with user modifications in the + # code. Return the import-sorted file. + return rev2_isorted + + +def _build_isort_args( + src: Path, + config: Optional[str] = None, + line_length: Optional[int] = None, +) -> IsortArgs: + """Build ``isort.code()`` keyword arguments + + :param src: The relative path to the file. This must be the actual path in the + repository, which may differ from the path given on the command line in + case of VSCode temporary files. + :param config: Path to configuration file + :param line_length: Maximum line length to use + + """ + isort_args: IsortArgs = {} if config: isort_args["settings_file"] = config else: isort_args["settings_path"] = str(find_project_root((str(src),))) if line_length: isort_args["line_length"] = line_length + return isort_args - logger.debug( - "isort.code(code=..., {})".format( - ", ".join(f"{k}={v!r}" for k, v in isort_args.items()) - ) - ) +def _call_isort_code(content: TextDocument, isort_args: IsortArgs) -> TextDocument: + """Call ``isort.code()`` and return the result as a `TextDocument` object + + :param content: The contents of the Python source code file to sort imports in + :param isort_args: Keyword arguments for ``isort.code()`` + + """ code = content.string + logger.debug( + "isort.code(code=..., %s)", + ", ".join(f"{k}={v!r}" for k, v in isort_args.items()), + ) try: code = isort_code(code=code, **isort_args) except isort.exceptions.FileSkipComment: pass - return TextDocument.from_str( code, encoding=content.encoding, mtime=content.mtime, ) + + +def _diff_overlaps_with_edits( + edited_linenums: List[int], isort_chunks: List[DiffChunk] +) -> bool: + """Return ``True`` if the complete diff overlaps the range of edited lines + + :param edited_linenums: The line numbers of all edited lines + :param isort_chunks: The diff chunks + :return: ``True`` if the two overlap + + """ + if not edited_linenums: + return False + first_edited_linenum, last_edited_linenum = edited_linenums[0], edited_linenums[-1] + modified_chunks = [ + (linenum, old, new) for linenum, old, new in isort_chunks if old != new + ] + if not modified_chunks: + return False + (first_isort_line, _, _) = modified_chunks[0] + (last_isort_chunk_start, last_isort_chunk_original_lines, _) = modified_chunks[-1] + last_isort_line = last_isort_chunk_start + len(last_isort_chunk_original_lines) + return ( + first_edited_linenum < last_isort_line + and last_edited_linenum >= first_isort_line + ) diff --git a/src/darker/tests/test_git.py b/src/darker/tests/test_git.py index 610b75b7e..d0ad478c3 100644 --- a/src/darker/tests/test_git.py +++ b/src/darker/tests/test_git.py @@ -250,9 +250,9 @@ def test_revisionrange_parse_with_common_ancestor(git_repo, revrange, expect): dict(path="file.12345.tmp", expect="file.12345.tmp"), dict(path="subdir/file.12345.tmp", expect="subdir/file.12345.tmp"), ) -def test_get_rev1_path(path, expect): - """``get_rev1_path`` drops two suffixes from ``.py..tmp``""" - result = git.get_rev1_path(Path(path)) +def test_get_path_in_repo(path, expect): + """``get_path_in_repo`` drops two suffixes from ``.py..tmp``""" + result = git.get_path_in_repo(Path(path)) assert result == Path(expect) diff --git a/src/darker/tests/test_import_sorting.py b/src/darker/tests/test_import_sorting.py index 7f5b3ca9d..8e00fa670 100644 --- a/src/darker/tests/test_import_sorting.py +++ b/src/darker/tests/test_import_sorting.py @@ -1,6 +1,6 @@ """Tests for :mod:`darker.import_sorting`""" -# pylint: disable=unused-argument +# pylint: disable=unused-argument,protected-access from importlib import reload from pathlib import Path @@ -9,11 +9,12 @@ import pytest import darker.import_sorting +from darker.git import EditedLinenumsDiffer, RevisionRange from darker.tests.helpers import isort_present -from darker.utils import TextDocument +from darker.utils import TextDocument, joinlines -ORIGINAL_SOURCE = ("import sys", "import os") -ISORTED_SOURCE = ("import os", "import sys") +ORIGINAL_SOURCE = ("import sys", "import os", "", "print(42)") +ISORTED_SOURCE = ("import os", "import sys", "", "print(42)") @pytest.mark.parametrize("present", [True, False]) @@ -31,14 +32,32 @@ def test_import_sorting_importable_with_and_without_isort(present): @pytest.mark.parametrize("encoding", ["utf-8", "iso-8859-1"]) @pytest.mark.parametrize("newline", ["\n", "\r\n"]) -def test_apply_isort(encoding, newline): - """Import sorting is applied correctly, with encoding and newline intact""" - result = darker.import_sorting.apply_isort( - TextDocument.from_lines(ORIGINAL_SOURCE, encoding=encoding, newline=newline), - Path("test1.py"), +@pytest.mark.kwparametrize( + dict(content=ORIGINAL_SOURCE, expect=ORIGINAL_SOURCE), + dict(content=("import sys", "import os"), expect=("import sys", "import os")), + dict( + content=("import sys", "import os", "# foo", "print(42)"), + expect=("import sys", "import os", "# foo", "print(42)"), + ), + dict( + content=("import sys", "import os", "", "print(43)"), + expect=("import sys", "import os", "", "print(43)"), + ), + dict(content=("import sys", "import os", "", "print(42)"), expect=ISORTED_SOURCE), + dict(content=("import sys", "import os", "", "print(42)"), expect=ISORTED_SOURCE), +) +def test_apply_isort(git_repo, encoding, newline, content, expect): + """Imports are sorted if edits overlap them, with encoding and newline intact""" + git_repo.add({"test1.py": joinlines(ORIGINAL_SOURCE, newline)}, commit="Initial") + edited_linenums_differ = EditedLinenumsDiffer( + git_repo.root, RevisionRange("HEAD", ":WORKTREE:") ) + src = Path("test1.py") + content_ = TextDocument.from_lines(content, encoding=encoding, newline=newline) + + result = darker.import_sorting.apply_isort(content_, src, edited_linenums_differ) - assert result.lines == ISORTED_SOURCE + assert result.lines == expect assert result.encoding == encoding assert result.newline == newline @@ -100,18 +119,100 @@ def test_isort_config( config = str(tmpdir / settings_file) if settings_file else None actual = darker.import_sorting.apply_isort( - TextDocument.from_str(content), Path("test1.py"), config + TextDocument.from_str(content), + Path("test1.py"), + EditedLinenumsDiffer(Path("."), RevisionRange("master", "HEAD")), + config, ) assert actual.string == expect +@pytest.mark.kwparametrize( + dict(src=Path("file.py"), expect={"settings_path": "{cwd}"}), + dict( + config="myconfig.toml", + expect={"settings_file": "myconfig.toml"}, + ), + dict(line_length=42, expect={"settings_path": "{cwd}", "line_length": 42}), + src=Path("file.py"), + config=None, + line_length=None, +) +def test_build_isort_args(src, config, line_length, expect): + """``_build_isort_args`` returns correct arguments for isort""" + result = darker.import_sorting._build_isort_args(src, config, line_length) + + if "settings_path" in expect: + expect["settings_path"] = str(expect["settings_path"].format(cwd=Path.cwd())) + assert result == expect + + def test_isort_file_skip_comment(): """``apply_isort()`` handles ``FileSkipComment`` exception correctly""" # Avoid https://github.com/PyCQA/isort/pull/1833 by splitting the skip string content = "# iso" + "rt:skip_file" actual = darker.import_sorting.apply_isort( - TextDocument.from_str(content), Path("test1.py") + TextDocument.from_str(content), + Path("test1.py"), + EditedLinenumsDiffer(Path("."), RevisionRange("master", "HEAD")), ) assert actual.string == content + + +@pytest.mark.kwparametrize( + dict(edited_linenums=[], isort_chunks=[], expect=False), + dict(edited_linenums=[1, 2, 3, 4, 5, 6, 7, 8, 9], isort_chunks=[], expect=False), + dict(edited_linenums=[], isort_chunks=[(1, ("a", "b"), ("A", "B"))], expect=False), + dict(edited_linenums=[1], isort_chunks=[(1, ("a", "b"), ("A", "B"))], expect=True), + dict(edited_linenums=[2], isort_chunks=[(1, ("a", "b"), ("A", "B"))], expect=True), + dict(edited_linenums=[3], isort_chunks=[(1, ("a", "b"), ("A", "B"))], expect=False), + dict(edited_linenums=[], isort_chunks=[(1, ("A", "B"), ("A", "B"))], expect=False), + dict(edited_linenums=[1], isort_chunks=[(1, ("A", "B"), ("A", "B"))], expect=False), + dict(edited_linenums=[2], isort_chunks=[(1, ("A", "B"), ("A", "B"))], expect=False), + dict(edited_linenums=[3], isort_chunks=[(1, ("A", "B"), ("A", "B"))], expect=False), + dict( + edited_linenums=[3, 9], + isort_chunks=[ + (1, ("a", "b"), ("A", "B")), + (3, ("c", "d", "e", "f", "g"), ("c", "d", "e", "f", "g")), + (8, ("h", "i", "j"), ("h", "i", "j")), + ], + expect=False, + ), + dict( + edited_linenums=[3, 9], + isort_chunks=[ + (1, ("a", "b", "c"), ("A", "B", "C")), + (4, ("d", "e", "f", "g"), ("d", "e", "f", "g")), + (8, ("h", "i", "j"), ("h", "i", "j")), + ], + expect=True, + ), + dict( + edited_linenums=[3, 9], + isort_chunks=[ + (1, ("a", "b", "c"), ("a", "b", "c")), + (4, ("d", "e", "f", "g"), ("d", "e", "f", "g")), + (8, ("h", "i", "j"), ("H", "I", "J")), + ], + expect=True, + ), + dict( + edited_linenums=[3, 9], + isort_chunks=[ + (1, ("a", "b", "c", "d"), ("a", "b", "c", "d")), + (5, ("e", "f", "g", "h", "i"), ("e", "f", "g", "h", "i")), + (10, ("j"), ("J")), + ], + expect=False, + ), +) +def test_diff_overlaps_with_edits(edited_linenums, isort_chunks, expect): + """Overlapping edits and sorting of imports are detected correctly""" + result = darker.import_sorting._diff_overlaps_with_edits( + edited_linenums, isort_chunks + ) + + assert result == expect diff --git a/src/darker/tests/test_main.py b/src/darker/tests/test_main.py index b1bca0771..613e56a92 100644 --- a/src/darker/tests/test_main.py +++ b/src/darker/tests/test_main.py @@ -16,7 +16,7 @@ import darker.import_sorting import darker.linting from darker.exceptions import MissingPackageError -from darker.git import WORKTREE, RevisionRange +from darker.git import WORKTREE, EditedLinenumsDiffer, RevisionRange from darker.tests.helpers import isort_present from darker.utils import TextDocument, joinlines from darker.verification import NotEquivalentError @@ -391,7 +391,8 @@ def test_blacken_single_file( result = darker.__main__._blacken_single_file( git_repo.root, Path(relative_path), - RevisionRange(rev1, rev2), + Path("file.py"), + EditedLinenumsDiffer(git_repo.root, RevisionRange(rev1, rev2)), TextDocument(rev2_content), TextDocument(rev2_isorted), enable_isort, diff --git a/src/darker/tests/test_main_blacken_single_file.py b/src/darker/tests/test_main_blacken_single_file.py index ab38b1e99..e89c1cad6 100644 --- a/src/darker/tests/test_main_blacken_single_file.py +++ b/src/darker/tests/test_main_blacken_single_file.py @@ -6,7 +6,7 @@ from textwrap import dedent import darker.__main__ -from darker.git import RevisionRange +from darker.git import EditedLinenumsDiffer, RevisionRange from darker.utils import TextDocument @@ -58,7 +58,11 @@ def test_blacken_single_file_common_ancestor(git_repo): result = darker.__main__._blacken_single_file( git_repo.root, Path("a.py"), - RevisionRange.parse_with_common_ancestor("master...", git_repo.root), + Path("a.py"), + EditedLinenumsDiffer( + git_repo.root, + RevisionRange.parse_with_common_ancestor("master...", git_repo.root), + ), rev2_content=worktree, rev2_isorted=worktree, enable_isort=False, @@ -112,7 +116,11 @@ def docstring_func(): result = darker.__main__._blacken_single_file( git_repo.root, Path("a.py"), - RevisionRange("HEAD", ":WORKTREE:"), + Path("a.py"), + EditedLinenumsDiffer( + git_repo.root, + RevisionRange.parse_with_common_ancestor("HEAD..", git_repo.root), + ), rev2_content=TextDocument.from_str(modified), rev2_isorted=TextDocument.from_str(modified), enable_isort=False,