From 5d776aca729598899b0bb0755c4c254fcf3ae8b8 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Tue, 17 Sep 2024 09:21:33 +0300 Subject: [PATCH 01/14] refactor: move black_diff.py to formatters/black_formatter.py --- src/darker/__main__.py | 12 ++++++------ src/darker/formatters/__init__.py | 1 + .../{black_diff.py => formatters/black_formatter.py} | 0 src/darker/tests/test_black_diff.py | 12 ++++++------ src/darker/tests/test_command_line.py | 10 ++++++---- 5 files changed, 19 insertions(+), 16 deletions(-) create mode 100644 src/darker/formatters/__init__.py rename src/darker/{black_diff.py => formatters/black_formatter.py} (100%) diff --git a/src/darker/__main__.py b/src/darker/__main__.py index edc4a1968..3d8184a62 100644 --- a/src/darker/__main__.py +++ b/src/darker/__main__.py @@ -10,18 +10,18 @@ from pathlib import Path from typing import Collection, Generator, List, Optional, Tuple -from darker.black_diff import ( - BlackConfig, - filter_python_files, - read_black_config, - run_black, -) from darker.chooser import choose_lines from darker.command_line import parse_command_line from darker.concurrency import get_executor from darker.config import Exclusions, OutputMode, validate_config_output_mode from darker.diff import diff_chunks from darker.exceptions import DependencyError, MissingPackageError +from darker.formatters.black_formatter import ( + BlackConfig, + filter_python_files, + read_black_config, + run_black, +) from darker.fstring import apply_flynt, flynt from darker.git import ( EditedLinenumsDiffer, diff --git a/src/darker/formatters/__init__.py b/src/darker/formatters/__init__.py new file mode 100644 index 000000000..ef890b8db --- /dev/null +++ b/src/darker/formatters/__init__.py @@ -0,0 +1 @@ +"""Built-in code re-formatter plugins.""" diff --git a/src/darker/black_diff.py b/src/darker/formatters/black_formatter.py similarity index 100% rename from src/darker/black_diff.py rename to src/darker/formatters/black_formatter.py diff --git a/src/darker/tests/test_black_diff.py b/src/darker/tests/test_black_diff.py index 3cadf9c5f..d2679da4b 100644 --- a/src/darker/tests/test_black_diff.py +++ b/src/darker/tests/test_black_diff.py @@ -1,4 +1,4 @@ -"""Unit tests for `darker.black_diff`""" +"""Unit tests for `darker.black_formatter`""" # pylint: disable=too-many-arguments,use-dict-literal @@ -14,8 +14,8 @@ from black import Mode, Report, TargetVersion from pathspec import PathSpec -from darker import black_diff -from darker.black_diff import ( +from darker.formatters import black_formatter +from darker.formatters.black_formatter import ( BlackConfig, filter_python_files, read_black_config, @@ -320,7 +320,7 @@ def gen_python_files( def test_filter_python_files_gitignore(make_mock, tmp_path, expect): """`filter_python_files` uses per-Black-version params to `gen_python_files`""" gen_python_files, calls = make_mock() - with patch.object(black_diff, "gen_python_files", gen_python_files): + with patch.object(black_formatter, "gen_python_files", gen_python_files): # end of test setup _ = filter_python_files(set(), tmp_path, BlackConfig()) @@ -352,7 +352,7 @@ def test_run_black(encoding, newline): def test_run_black_always_uses_unix_newlines(newline): """Content is always passed to Black with Unix newlines""" src = TextDocument.from_str(f"print ( 'touché' ){newline}") - with patch.object(black_diff, "format_str") as format_str: + with patch.object(black_formatter, "format_str") as format_str: format_str.return_value = 'print("touché")\n' _ = run_black(src, BlackConfig()) @@ -467,7 +467,7 @@ def test_run_black_configuration( ): """`run_black` passes correct configuration to Black""" src = TextDocument.from_str("import os\n") - with patch.object(black_diff, "format_str") as format_str, raises_or_matches( + with patch.object(black_formatter, "format_str") as format_str, raises_or_matches( expect, [] ) as check: format_str.return_value = "import os\n" diff --git a/src/darker/tests/test_command_line.py b/src/darker/tests/test_command_line.py index ac324b1e7..b4bb00148 100644 --- a/src/darker/tests/test_command_line.py +++ b/src/darker/tests/test_command_line.py @@ -14,10 +14,10 @@ from black import TargetVersion import darker.help -from darker import black_diff from darker.__main__ import main from darker.command_line import make_argument_parser, parse_command_line from darker.config import Exclusions +from darker.formatters import black_formatter from darker.tests.helpers import flynt_present, isort_present from darkgraylib.config import ConfigurationError from darkgraylib.git import RevisionRange @@ -553,7 +553,9 @@ def test_black_options(monkeypatch, tmpdir, git_repo, options, expect): {"main.py": 'print("Hello World!")\n'}, commit="Initial commit" ) added_files["main.py"].write_bytes(b'print ("Hello World!")\n') - with patch.object(black_diff, "Mode", wraps=black_diff.Mode) as file_mode_class: + with patch.object( + black_formatter, "Mode", wraps=black_formatter.Mode + ) as file_mode_class: main(options + [str(path) for path in added_files.values()]) @@ -666,10 +668,10 @@ def test_black_config_file_and_options(git_repo, config, options, expect): commit="Initial commit", ) added_files["main.py"].write_bytes(b"a = [1, 2,]") - mode_class_mock = Mock(wraps=black_diff.Mode) + mode_class_mock = Mock(wraps=black_formatter.Mode) # Speed up tests by mocking `format_str` to skip running Black format_str = Mock(return_value="a = [1, 2,]") - with patch.multiple(black_diff, Mode=mode_class_mock, format_str=format_str): + with patch.multiple(black_formatter, Mode=mode_class_mock, format_str=format_str): main(options + [str(path) for path in added_files.values()]) From 763169bf8ed5e8c094b2cceb096c1ff5499d08a8 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Tue, 17 Sep 2024 09:48:50 +0300 Subject: [PATCH 02/14] refactor: rename test_black_diff.py to test_formatters_black.py --- src/darker/tests/{test_black_diff.py => test_formatters_black.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/darker/tests/{test_black_diff.py => test_formatters_black.py} (100%) diff --git a/src/darker/tests/test_black_diff.py b/src/darker/tests/test_formatters_black.py similarity index 100% rename from src/darker/tests/test_black_diff.py rename to src/darker/tests/test_formatters_black.py From c8118baa09183e8715cf35f265e4d8da229ba225 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Tue, 17 Sep 2024 10:12:28 +0300 Subject: [PATCH 03/14] refactor: move BlackConfig and filter_python_files to more logical places --- src/darker/__main__.py | 9 +-- src/darker/files.py | 68 +++++++++++++++++++- src/darker/formatters/black_formatter.py | 75 +---------------------- src/darker/formatters/formatter_config.py | 23 +++++++ src/darker/tests/test_formatters_black.py | 14 ++--- 5 files changed, 100 insertions(+), 89 deletions(-) create mode 100644 src/darker/formatters/formatter_config.py diff --git a/src/darker/__main__.py b/src/darker/__main__.py index 3d8184a62..8d6c711fb 100644 --- a/src/darker/__main__.py +++ b/src/darker/__main__.py @@ -16,12 +16,9 @@ from darker.config import Exclusions, OutputMode, validate_config_output_mode from darker.diff import diff_chunks from darker.exceptions import DependencyError, MissingPackageError -from darker.formatters.black_formatter import ( - BlackConfig, - filter_python_files, - read_black_config, - run_black, -) +from darker.files import filter_python_files +from darker.formatters.black_formatter import read_black_config, run_black +from darker.formatters.formatter_config import BlackConfig from darker.fstring import apply_flynt, flynt from darker.git import ( EditedLinenumsDiffer, diff --git a/src/darker/files.py b/src/darker/files.py index 99086988a..551ce6491 100644 --- a/src/darker/files.py +++ b/src/darker/files.py @@ -1,13 +1,29 @@ """Helper functions for working with files and directories.""" -from typing import Optional, Tuple +from __future__ import annotations -from black import err, find_user_pyproject_toml +import inspect +from typing import TYPE_CHECKING, Collection + +from black import ( + DEFAULT_EXCLUDES, + DEFAULT_INCLUDES, + Report, + err, + find_user_pyproject_toml, + gen_python_files, + re_compile_maybe_verbose, +) from darkgraylib.files import find_project_root +if TYPE_CHECKING: + from pathlib import Path + + from darker.formatters.formatter_config import BlackConfig + -def find_pyproject_toml(path_search_start: Tuple[str, ...]) -> Optional[str]: +def find_pyproject_toml(path_search_start: tuple[str, ...]) -> str | None: """Find the absolute filepath to a pyproject.toml if it exists""" path_project_root = find_project_root(path_search_start) path_pyproject_toml = path_project_root / "pyproject.toml" @@ -25,3 +41,49 @@ def find_pyproject_toml(path_search_start: Tuple[str, ...]) -> Optional[str]: # We do not have access to the user-level config directory, so ignore it. err(f"Ignoring user configuration directory due to {e!r}") return None + + +def filter_python_files( + paths: Collection[Path], # pylint: disable=unsubscriptable-object + root: Path, + black_config: BlackConfig, +) -> set[Path]: + """Get Python files and explicitly listed files not excluded by Black's config. + + :param paths: Relative file/directory paths from CWD to Python sources + :param root: A common root directory for all ``paths`` + :param black_config: Black configuration which contains the exclude options read + from Black's configuration files + :return: Paths of files which should be reformatted according to + ``black_config``, relative to ``root``. + + """ + sig = inspect.signature(gen_python_files) + # those two exist and are required in black>=21.7b1.dev9 + kwargs = {"verbose": False, "quiet": False} if "verbose" in sig.parameters else {} + # `gitignore=` was replaced with `gitignore_dict=` in black==22.10.1.dev19+gffaaf48 + for param in sig.parameters: + if param == "gitignore": + kwargs[param] = None # type: ignore[assignment] + elif param == "gitignore_dict": + kwargs[param] = {} # type: ignore[assignment] + absolute_paths = {p.resolve() for p in paths} + directories = {p for p in absolute_paths if p.is_dir()} + files = {p for p in absolute_paths if p not in directories} + files_from_directories = set( + gen_python_files( + directories, + root, + include=DEFAULT_INCLUDE_RE, + exclude=black_config.get("exclude", DEFAULT_EXCLUDE_RE), + extend_exclude=black_config.get("extend_exclude"), + force_exclude=black_config.get("force_exclude"), + report=Report(), + **kwargs, # type: ignore[arg-type] + ) + ) + return {p.resolve().relative_to(root) for p in files_from_directories | files} + + +DEFAULT_EXCLUDE_RE = re_compile_maybe_verbose(DEFAULT_EXCLUDES) +DEFAULT_INCLUDE_RE = re_compile_maybe_verbose(DEFAULT_INCLUDES) diff --git a/src/darker/formatters/black_formatter.py b/src/darker/formatters/black_formatter.py index a47db055e..5ebd4f7b6 100644 --- a/src/darker/formatters/black_formatter.py +++ b/src/darker/formatters/black_formatter.py @@ -35,9 +35,8 @@ from __future__ import annotations -import inspect import logging -from typing import TYPE_CHECKING, Collection, Pattern, TypedDict +from typing import TypedDict # `FileMode as Mode` required to satisfy mypy==0.782. Strange. from black import FileMode as Mode @@ -47,43 +46,17 @@ parse_pyproject_toml, re_compile_maybe_verbose, ) -from black.const import ( # pylint: disable=no-name-in-module - DEFAULT_EXCLUDES, - DEFAULT_INCLUDES, -) -from black.files import gen_python_files -from black.report import Report from darker.files import find_pyproject_toml +from darker.formatters.formatter_config import BlackConfig from darkgraylib.config import ConfigurationError from darkgraylib.utils import TextDocument -if TYPE_CHECKING: - from pathlib import Path - -__all__ = ["BlackConfig", "Mode", "run_black"] +__all__ = ["Mode", "run_black"] logger = logging.getLogger(__name__) -DEFAULT_EXCLUDE_RE = re_compile_maybe_verbose(DEFAULT_EXCLUDES) -DEFAULT_INCLUDE_RE = re_compile_maybe_verbose(DEFAULT_INCLUDES) - - -class BlackConfig(TypedDict, total=False): - """Type definition for Black configuration dictionaries""" - - config: str - exclude: Pattern[str] | None - extend_exclude: Pattern[str] | None - force_exclude: Pattern[str] | None - target_version: str | set[str] - line_length: int - skip_string_normalization: bool - skip_magic_trailing_comma: bool - preview: bool - - class BlackModeAttributes(TypedDict, total=False): """Type definition for items accepted by ``black.Mode``""" @@ -137,48 +110,6 @@ def read_black_config(src: tuple[str, ...], value: str | None) -> BlackConfig: return config -def filter_python_files( - paths: Collection[Path], # pylint: disable=unsubscriptable-object - root: Path, - black_config: BlackConfig, -) -> set[Path]: - """Get Python files and explicitly listed files not excluded by Black's config - - :param paths: Relative file/directory paths from CWD to Python sources - :param root: A common root directory for all ``paths`` - :param black_config: Black configuration which contains the exclude options read - from Black's configuration files - :return: Paths of files which should be reformatted according to - ``black_config``, relative to ``root``. - - """ - sig = inspect.signature(gen_python_files) - # those two exist and are required in black>=21.7b1.dev9 - kwargs = {"verbose": False, "quiet": False} if "verbose" in sig.parameters else {} - # `gitignore=` was replaced with `gitignore_dict=` in black==22.10.1.dev19+gffaaf48 - for param in sig.parameters: - if param == "gitignore": - kwargs[param] = None # type: ignore[assignment] - elif param == "gitignore_dict": - kwargs[param] = {} # type: ignore[assignment] - absolute_paths = {p.resolve() for p in paths} - directories = {p for p in absolute_paths if p.is_dir()} - files = {p for p in absolute_paths if p not in directories} - files_from_directories = set( - gen_python_files( - directories, - root, - include=DEFAULT_INCLUDE_RE, - exclude=black_config.get("exclude") or DEFAULT_EXCLUDE_RE, - extend_exclude=black_config.get("extend_exclude"), - force_exclude=black_config.get("force_exclude"), - report=Report(), - **kwargs, # type: ignore[arg-type] - ) - ) - return {p.resolve().relative_to(root) for p in files_from_directories | files} - - def run_black(src_contents: TextDocument, black_config: BlackConfig) -> TextDocument: """Run the black formatter for the Python source code given as a string diff --git a/src/darker/formatters/formatter_config.py b/src/darker/formatters/formatter_config.py new file mode 100644 index 000000000..22ce27c09 --- /dev/null +++ b/src/darker/formatters/formatter_config.py @@ -0,0 +1,23 @@ +"""Code re-formatter plugin configuration type definitions.""" + +from __future__ import annotations + +from typing import Pattern, TypedDict + + +class FormatterConfig(TypedDict): + """Base class for code re-formatter configuration.""" + + +class BlackConfig(FormatterConfig, total=False): + """Type definition for Black configuration dictionaries.""" + + config: str + exclude: Pattern[str] + extend_exclude: Pattern[str] | None + force_exclude: Pattern[str] | None + target_version: str | set[str] + line_length: int + skip_string_normalization: bool + skip_magic_trailing_comma: bool + preview: bool diff --git a/src/darker/tests/test_formatters_black.py b/src/darker/tests/test_formatters_black.py index d2679da4b..74ab39e56 100644 --- a/src/darker/tests/test_formatters_black.py +++ b/src/darker/tests/test_formatters_black.py @@ -14,13 +14,11 @@ from black import Mode, Report, TargetVersion from pathspec import PathSpec +from darker import files +from darker.files import DEFAULT_EXCLUDE_RE, filter_python_files from darker.formatters import black_formatter -from darker.formatters.black_formatter import ( - BlackConfig, - filter_python_files, - read_black_config, - run_black, -) +from darker.formatters.black_formatter import read_black_config, run_black +from darker.formatters.formatter_config import BlackConfig from darkgraylib.config import ConfigurationError from darkgraylib.testtools.helpers import raises_or_matches from darkgraylib.utils import TextDocument @@ -183,7 +181,7 @@ def test_filter_python_files( # pylint: disable=too-many-arguments path.touch() black_config = BlackConfig( { - "exclude": regex.compile(exclude) if exclude else None, + "exclude": regex.compile(exclude) if exclude else DEFAULT_EXCLUDE_RE, "extend_exclude": regex.compile(extend_exclude) if extend_exclude else None, "force_exclude": regex.compile(force_exclude) if force_exclude else None, } @@ -320,7 +318,7 @@ def gen_python_files( def test_filter_python_files_gitignore(make_mock, tmp_path, expect): """`filter_python_files` uses per-Black-version params to `gen_python_files`""" gen_python_files, calls = make_mock() - with patch.object(black_formatter, "gen_python_files", gen_python_files): + with patch.object(files, "gen_python_files", gen_python_files): # end of test setup _ = filter_python_files(set(), tmp_path, BlackConfig()) From d081c27ea1fe28f71ce2f33520474371e29e730b Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:05:39 +0300 Subject: [PATCH 04/14] refactor: indent future class methods for simpler review diffs --- src/darker/formatters/black_formatter.py | 172 ++++++++++++----------- 1 file changed, 88 insertions(+), 84 deletions(-) diff --git a/src/darker/formatters/black_formatter.py b/src/darker/formatters/black_formatter.py index 5ebd4f7b6..bc2c739b5 100644 --- a/src/darker/formatters/black_formatter.py +++ b/src/darker/formatters/black_formatter.py @@ -69,91 +69,95 @@ class BlackModeAttributes(TypedDict, total=False): def read_black_config(src: tuple[str, ...], value: str | None) -> BlackConfig: - """Read the black configuration from ``pyproject.toml`` - - :param src: The source code files and directories to be processed by Darker - :param value: The path of the Black configuration file - :return: A dictionary of those Black parameters from the configuration file which - are supported by Darker - - """ - value = value or find_pyproject_toml(src) - - if not value: - return BlackConfig() - - raw_config = parse_pyproject_toml(value) - - config: BlackConfig = {} - for key in [ - "line_length", - "skip_magic_trailing_comma", - "skip_string_normalization", - "preview", - ]: - if key in raw_config: - config[key] = raw_config[key] # type: ignore - if "target_version" in raw_config: - target_version = raw_config["target_version"] - if isinstance(target_version, str): - config["target_version"] = target_version - elif isinstance(target_version, list): - # Convert TOML list to a Python set - config["target_version"] = set(target_version) - else: - raise ConfigurationError( - f"Invalid target-version = {target_version!r} in {value}" - ) - for key in ["exclude", "extend_exclude", "force_exclude"]: - if key in raw_config: - config[key] = re_compile_maybe_verbose(raw_config[key]) # type: ignore - return config + """Read the black configuration from ``pyproject.toml`` + + :param src: The source code files and directories to be processed by Darker + :param value: The path of the Black configuration file + :return: A dictionary of those Black parameters from the configuration file which + are supported by Darker + + NOTE: the non-standard indentation is here to make review diffs simpler + + """ + value = value or find_pyproject_toml(src) + + if not value: + return BlackConfig() + + raw_config = parse_pyproject_toml(value) + + config: BlackConfig = {} + for key in [ + "line_length", + "skip_magic_trailing_comma", + "skip_string_normalization", + "preview", + ]: + if key in raw_config: + config[key] = raw_config[key] # type: ignore + if "target_version" in raw_config: + target_version = raw_config["target_version"] + if isinstance(target_version, str): + config["target_version"] = target_version + elif isinstance(target_version, list): + # Convert TOML list to a Python set + config["target_version"] = set(target_version) + else: + raise ConfigurationError( + f"Invalid target-version = {target_version!r} in {value}" + ) + for key in ["exclude", "extend_exclude", "force_exclude"]: + if key in raw_config: + config[key] = re_compile_maybe_verbose(raw_config[key]) # type: ignore + return config def run_black(src_contents: TextDocument, black_config: BlackConfig) -> TextDocument: - """Run the black formatter for the Python source code given as a string - - :param src_contents: The source code - :param black_config: Configuration to use for running Black - :return: The reformatted content - - """ - # Collect relevant Black configuration options from ``black_config`` in order to - # pass them to Black's ``format_str()``. File exclusion options aren't needed since - # at this point we already have a single file's content to work on. - mode = BlackModeAttributes() - if "line_length" in black_config: - mode["line_length"] = black_config["line_length"] - if "target_version" in black_config: - if isinstance(black_config["target_version"], set): - target_versions_in = black_config["target_version"] + """Run the black formatter for the Python source code given as a string + + :param src_contents: The source code + :param black_config: Configuration to use for running Black + :return: The reformatted content + + NOTE: the non-standard indentation is here to make review diffs simpler + + """ + # Collect relevant Black configuration options from ``black_config`` in order to + # pass them to Black's ``format_str()``. File exclusion options aren't needed since + # at this point we already have a single file's content to work on. + mode = BlackModeAttributes() + if "line_length" in black_config: + mode["line_length"] = black_config["line_length"] + if "target_version" in black_config: + if isinstance(black_config["target_version"], set): + target_versions_in = black_config["target_version"] + else: + target_versions_in = {black_config["target_version"]} + all_target_versions = {tgt_v.name.lower(): tgt_v for tgt_v in TargetVersion} + bad_target_versions = target_versions_in - set(all_target_versions) + if bad_target_versions: + raise ConfigurationError(f"Invalid target version(s) {bad_target_versions}") + mode["target_versions"] = {all_target_versions[n] for n in target_versions_in} + if "skip_magic_trailing_comma" in black_config: + mode["magic_trailing_comma"] = not black_config["skip_magic_trailing_comma"] + if "skip_string_normalization" in black_config: + # The ``black`` command line argument is + # ``--skip-string-normalization``, but the parameter for + # ``black.Mode`` needs to be the opposite boolean of + # ``skip-string-normalization``, hence the inverse boolean + mode["string_normalization"] = not black_config["skip_string_normalization"] + if "preview" in black_config: + mode["preview"] = black_config["preview"] + + # The custom handling of empty and all-whitespace files below will be unnecessary if + # https://github.com/psf/black/pull/2484 lands in Black. + contents_for_black = src_contents.string_with_newline("\n") + if contents_for_black.strip(): + dst_contents = format_str(contents_for_black, mode=Mode(**mode)) else: - target_versions_in = {black_config["target_version"]} - all_target_versions = {tgt_v.name.lower(): tgt_v for tgt_v in TargetVersion} - bad_target_versions = target_versions_in - set(all_target_versions) - if bad_target_versions: - raise ConfigurationError(f"Invalid target version(s) {bad_target_versions}") - mode["target_versions"] = {all_target_versions[n] for n in target_versions_in} - if "skip_magic_trailing_comma" in black_config: - mode["magic_trailing_comma"] = not black_config["skip_magic_trailing_comma"] - if "skip_string_normalization" in black_config: - # The ``black`` command line argument is - # ``--skip-string-normalization``, but the parameter for - # ``black.Mode`` needs to be the opposite boolean of - # ``skip-string-normalization``, hence the inverse boolean - mode["string_normalization"] = not black_config["skip_string_normalization"] - if "preview" in black_config: - mode["preview"] = black_config["preview"] - - # The custom handling of empty and all-whitespace files below will be unnecessary if - # https://github.com/psf/black/pull/2484 lands in Black. - contents_for_black = src_contents.string_with_newline("\n") - if contents_for_black.strip(): - dst_contents = format_str(contents_for_black, mode=Mode(**mode)) - else: - dst_contents = "\n" if "\n" in src_contents.string else "" - return TextDocument.from_str( - dst_contents, - encoding=src_contents.encoding, - override_newline=src_contents.newline, - ) + dst_contents = "\n" if "\n" in src_contents.string else "" + return TextDocument.from_str( + dst_contents, + encoding=src_contents.encoding, + override_newline=src_contents.newline, + ) From 33206f3e8f2faffff8c3f1744ce3d2d5a049fda1 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:11:59 +0300 Subject: [PATCH 05/14] refactor: move Black run & read_config inside a class --- src/darker/formatters/black_formatter.py | 35 +++++++++++++++++++----- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/darker/formatters/black_formatter.py b/src/darker/formatters/black_formatter.py index bc2c739b5..55ccb0944 100644 --- a/src/darker/formatters/black_formatter.py +++ b/src/darker/formatters/black_formatter.py @@ -68,7 +68,10 @@ class BlackModeAttributes(TypedDict, total=False): preview: bool -def read_black_config(src: tuple[str, ...], value: str | None) -> BlackConfig: +class BlackFormatter: + """Black code formatter interface.""" + + def read_config(self, src: tuple[str, ...], value: str | None) -> BlackConfig: """Read the black configuration from ``pyproject.toml`` :param src: The source code files and directories to be processed by Darker @@ -76,8 +79,6 @@ def read_black_config(src: tuple[str, ...], value: str | None) -> BlackConfig: :return: A dictionary of those Black parameters from the configuration file which are supported by Darker - NOTE: the non-standard indentation is here to make review diffs simpler - """ value = value or find_pyproject_toml(src) @@ -111,16 +112,13 @@ def read_black_config(src: tuple[str, ...], value: str | None) -> BlackConfig: config[key] = re_compile_maybe_verbose(raw_config[key]) # type: ignore return config - -def run_black(src_contents: TextDocument, black_config: BlackConfig) -> TextDocument: + def run(self, src_contents: TextDocument, black_config: BlackConfig) -> TextDocument: """Run the black formatter for the Python source code given as a string :param src_contents: The source code :param black_config: Configuration to use for running Black :return: The reformatted content - NOTE: the non-standard indentation is here to make review diffs simpler - """ # Collect relevant Black configuration options from ``black_config`` in order to # pass them to Black's ``format_str()``. File exclusion options aren't needed since @@ -161,3 +159,26 @@ def run_black(src_contents: TextDocument, black_config: BlackConfig) -> TextDocu encoding=src_contents.encoding, override_newline=src_contents.newline, ) + + +def read_black_config(src: tuple[str, ...], value: str | None) -> BlackConfig: + """Read the black configuration from ``pyproject.toml`` + + :param src: The source code files and directories to be processed by Darker + :param value: The path of the Black configuration file + :return: A dictionary of those Black parameters from the configuration file which + are supported by Darker + + """ + return BlackFormatter().read_config(src, value) + + +def run_black(src_contents: TextDocument, black_config: BlackConfig) -> TextDocument: + """Run the black formatter for the Python source code given as a string + + :param src_contents: The source code + :param black_config: Configuration to use for running Black + :return: The reformatted content + + """ + return BlackFormatter().run(src_contents, black_config) From fdbf7fed85a6abf407ac338920d834e990f8f335 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:36:13 +0300 Subject: [PATCH 06/14] refactor: Black as a plugin --- README.rst | 2 +- setup.cfg | 3 + src/darker/__main__.py | 94 +++++++++---------- src/darker/command_line.py | 5 +- src/darker/config.py | 2 +- src/darker/files.py | 14 +-- src/darker/formatters/__init__.py | 37 ++++++++ src/darker/formatters/base_formatter.py | 60 ++++++++++++ src/darker/formatters/black_formatter.py | 91 +++++++++++------- src/darker/formatters/none_formatter.py | 53 +++++++++++ src/darker/help.py | 7 ++ src/darker/tests/test_command_line.py | 7 +- src/darker/tests/test_formatters_black.py | 81 +++++++++------- .../tests/test_main_format_edited_parts.py | 19 ++-- src/darker/tests/test_main_isort.py | 5 +- ...st_main_reformat_and_flynt_single_file.py} | 25 ++--- 16 files changed, 350 insertions(+), 155 deletions(-) create mode 100644 src/darker/formatters/base_formatter.py create mode 100644 src/darker/formatters/none_formatter.py rename src/darker/tests/{test_main_blacken_and_flynt_single_file.py => test_main_reformat_and_flynt_single_file.py} (90%) diff --git a/README.rst b/README.rst index e378e9dce..891e6633b 100644 --- a/README.rst +++ b/README.rst @@ -371,7 +371,7 @@ The following `command line arguments`_ can also be used to modify the defaults: versions that should be supported by Black's output. [default: per-file auto- detection] --formatter FORMATTER - Formatter to use for reformatting code + [black\|none] Formatter to use for reformatting code. [default: black] To change default values for these options for a given project, add a ``[tool.darker]`` section to ``pyproject.toml`` in the project's root directory, diff --git a/setup.cfg b/setup.cfg index ca689f2fa..4739438df 100644 --- a/setup.cfg +++ b/setup.cfg @@ -45,6 +45,9 @@ darker = .pyi [options.entry_points] +darker.formatter = + black = darker.formatters.black_formatter:BlackFormatter + none = darker.formatters.none_formatter:NoneFormatter console_scripts = darker = darker.__main__:main_with_error_handling diff --git a/src/darker/__main__.py b/src/darker/__main__.py index 8d6c711fb..381c26510 100644 --- a/src/darker/__main__.py +++ b/src/darker/__main__.py @@ -1,4 +1,4 @@ -"""Darker - apply black reformatting to only areas edited since the last commit""" +"""Darker - re-format only code areas edited since the last commit.""" import concurrent.futures import logging @@ -17,8 +17,9 @@ from darker.diff import diff_chunks from darker.exceptions import DependencyError, MissingPackageError from darker.files import filter_python_files -from darker.formatters.black_formatter import read_black_config, run_black -from darker.formatters.formatter_config import BlackConfig +from darker.formatters import create_formatter +from darker.formatters.base_formatter import BaseFormatter +from darker.formatters.none_formatter import NoneFormatter from darker.fstring import apply_flynt, flynt from darker.git import ( EditedLinenumsDiffer, @@ -57,12 +58,12 @@ ProcessedDocument = Tuple[Path, TextDocument, TextDocument] -def format_edited_parts( # pylint: disable=too-many-arguments +def format_edited_parts( # noqa: PLR0913 # pylint: disable=too-many-arguments root: Path, - changed_files: Collection[Path], # pylint: disable=unsubscriptable-object + changed_files: Collection[Path], exclude: Exclusions, revrange: RevisionRange, - black_config: BlackConfig, + formatter: BaseFormatter, report_unmodified: bool, workers: int = 1, ) -> Generator[ProcessedDocument, None, None]: @@ -79,7 +80,7 @@ def format_edited_parts( # pylint: disable=too-many-arguments modified in the repository between the given Git revisions :param exclude: Files to exclude when running Black,``isort`` or ``flynt`` :param revrange: The Git revisions to compare - :param black_config: Configuration to use for running Black + :param formatter: The code re-formatter to use :param report_unmodified: ``True`` to yield also files which weren't modified :param workers: number of cpu processes to use (0 - autodetect) :return: A generator which yields details about changes for each file which should @@ -98,7 +99,7 @@ def format_edited_parts( # pylint: disable=too-many-arguments edited_linenums_differ, exclude, revrange, - black_config, + formatter, ) futures.append(future) @@ -112,21 +113,22 @@ def format_edited_parts( # pylint: disable=too-many-arguments yield (absolute_path_in_rev2, rev2_content, content_after_reformatting) -def _modify_and_reformat_single_file( # pylint: disable=too-many-arguments +def _modify_and_reformat_single_file( # noqa: PLR0913 root: Path, relative_path_in_rev2: Path, edited_linenums_differ: EditedLinenumsDiffer, exclude: Exclusions, revrange: RevisionRange, - black_config: BlackConfig, + formatter: BaseFormatter, ) -> ProcessedDocument: + # pylint: disable=too-many-arguments """Black, isort and/or flynt formatting for modified chunks in a single file :param root: Root directory for the relative path :param relative_path_in_rev2: Relative path to a Python source code file :param exclude: Files to exclude when running Black, ``isort`` or ``flynt`` :param revrange: The Git revisions to compare - :param black_config: Configuration to use for running Black + :param formatter: The code re-formatter to use :return: Details about changes for the file """ @@ -145,13 +147,14 @@ def _modify_and_reformat_single_file( # pylint: disable=too-many-arguments relative_path_in_rev2, exclude.isort, edited_linenums_differ, - black_config.get("config"), - black_config.get("line_length"), + formatter.get_config_path(), + formatter.get_line_length(), ) has_isort_changes = rev2_isorted != rev2_content # 2. run flynt (optional) on the isorted contents of each edited to-file - # 3. run black on the isorted and fstringified contents of each edited to-file - content_after_reformatting = _blacken_and_flynt_single_file( + # 3. run a re-formatter on the isorted and fstringified contents of each edited + # to-file + content_after_reformatting = _reformat_and_flynt_single_file( root, relative_path_in_rev2, get_path_in_repo(relative_path_in_rev2), @@ -160,13 +163,12 @@ def _modify_and_reformat_single_file( # pylint: disable=too-many-arguments rev2_content, rev2_isorted, has_isort_changes, - black_config, + formatter, ) return absolute_path_in_rev2, rev2_content, content_after_reformatting -def _blacken_and_flynt_single_file( - # pylint: disable=too-many-arguments,too-many-locals +def _reformat_and_flynt_single_file( # noqa: PLR0913 root: Path, relative_path_in_rev2: Path, relative_path_in_repo: Path, @@ -175,8 +177,9 @@ def _blacken_and_flynt_single_file( rev2_content: TextDocument, rev2_isorted: TextDocument, has_isort_changes: bool, - black_config: BlackConfig, + formatter: BaseFormatter, ) -> TextDocument: + # pylint: disable=too-many-arguments """In a Python file, reformat chunks with edits since the last commit using Black :param root: The common root of all files to reformat @@ -189,7 +192,7 @@ def _blacken_and_flynt_single_file( :param rev2_content: Contents of the file at ``revrange.rev2`` :param rev2_isorted: Contents of the file after optional import sorting :param has_isort_changes: ``True`` if ``isort`` was run and modified the file - :param black_config: Configuration to use for running Black + :param formatter: The code re-formatter to use :return: Contents of the file after reformatting :raise: NotEquivalentError @@ -211,9 +214,10 @@ def _blacken_and_flynt_single_file( len(fstringified.lines), "some" if has_fstring_changes else "no", ) - # 3. run black on the isorted and fstringified contents of each edited to-file - formatted = _maybe_blacken_single_file( - relative_path_in_rev2, exclude.black, fstringified, black_config + # 3. run the code re-formatter on the isorted and fstringified contents of each + # edited to-file + formatted = _maybe_reformat_single_file( + relative_path_in_rev2, exclude.formatter, fstringified, formatter ) logger.debug( "Black reformat resulted in %s lines, with %s changes from reformatting", @@ -267,26 +271,26 @@ def _maybe_flynt_single_file( return apply_flynt(rev2_isorted, relpath_in_rev2, edited_linenums_differ) -def _maybe_blacken_single_file( +def _maybe_reformat_single_file( relpath_in_rev2: Path, exclude: Collection[str], fstringified: TextDocument, - black_config: BlackConfig, + formatter: BaseFormatter, ) -> TextDocument: - """Format Python source code with Black if the source code file path isn't excluded + """Re-format Python source code if the source code file path isn't excluded. :param relpath_in_rev2: Relative path to a Python source code file. Possibly a VSCode ``.py..tmp`` file in the working tree. - :param exclude: Files to exclude when running Black + :param exclude: Files to exclude when running the re-formatter :param fstringified: Contents of the file after optional import sorting and flynt - :param black_config: Configuration to use for running Black + :param formatter: The code re-formatter to use :return: Python source code after reformatting """ if glob_any(relpath_in_rev2, exclude): # File was excluded by Black configuration, don't reformat return fstringified - return run_black(fstringified, black_config) + return formatter.run(fstringified) def _drop_changes_on_unedited_lines( @@ -456,7 +460,8 @@ def main( # noqa: C901,PLR0912,PLR0915 1. run isort on each edited file (optional) 2. run flynt (optional) on the isorted contents of each edited to-file - 3. run black on the isorted and fstringified contents of each edited to-file + 3. run a code re-formatter on the isorted and fstringified contents of each edited + to-file 4. get a diff between the edited to-file and the processed content 5. convert the diff into chunks, keeping original and reformatted content for each chunk @@ -508,19 +513,8 @@ def main( # noqa: C901,PLR0912,PLR0915 f"{get_extra_instruction('flynt')} to use the `--flynt` option." ) - black_config = read_black_config(tuple(args.src), args.config) - if args.config: - black_config["config"] = args.config - if args.line_length: - black_config["line_length"] = args.line_length - if args.target_version: - black_config["target_version"] = {args.target_version} - if args.skip_string_normalization is not None: - black_config["skip_string_normalization"] = args.skip_string_normalization - if args.skip_magic_trailing_comma is not None: - black_config["skip_magic_trailing_comma"] = args.skip_magic_trailing_comma - if args.preview: - black_config["preview"] = args.preview + formatter = create_formatter(args.formatter) + formatter.read_config(tuple(args.src), args) paths, common_root = resolve_paths(args.stdin_filename, args.src) # `common_root` is now the common root of given paths, @@ -567,8 +561,8 @@ def main( # noqa: C901,PLR0912,PLR0915 else common_root ) # These paths are relative to `common_root`: - files_to_process = filter_python_files(paths, common_root_, {}) - files_to_blacken = filter_python_files(paths, common_root_, black_config) + files_to_process = filter_python_files(paths, common_root_, NoneFormatter()) + files_to_reformat = filter_python_files(paths, common_root_, formatter) # Now decide which files to reformat (Black & isort). Note that this doesn't apply # to linting. @@ -577,7 +571,7 @@ def main( # noqa: C901,PLR0912,PLR0915 # modified or not. Paths have previously been validated to contain exactly one # existing file. changed_files_to_reformat = files_to_process - black_exclude = set() + formatter_exclude = set() else: # In other modes, only reformat files which have been modified. if git_is_repository(common_root): @@ -592,10 +586,10 @@ def main( # noqa: C901,PLR0912,PLR0915 else: changed_files_to_reformat = files_to_process - black_exclude = { + formatter_exclude = { str(path) for path in changed_files_to_reformat - if path not in files_to_blacken + if path not in files_to_reformat } use_color = should_use_color(config["color"]) formatting_failures_on_modified_lines = False @@ -604,12 +598,12 @@ def main( # noqa: C901,PLR0912,PLR0915 common_root, changed_files_to_reformat, Exclusions( - black=black_exclude, + formatter=formatter_exclude, isort=set() if args.isort else {"**/*"}, flynt=set() if args.flynt else {"**/*"}, ), revrange, - black_config, + formatter, report_unmodified=output_mode == OutputMode.CONTENT, workers=config["workers"], ), diff --git a/src/darker/command_line.py b/src/darker/command_line.py index ab7a7369b..4a092ba63 100644 --- a/src/darker/command_line.py +++ b/src/darker/command_line.py @@ -15,6 +15,7 @@ DarkerConfig, OutputMode, ) +from darker.formatters import get_formatter_names from darker.version import __version__ from darkgraylib.command_line import add_parser_argument from darkgraylib.config import ConfigurationError @@ -84,10 +85,10 @@ def make_argument_parser(require_src: bool) -> ArgumentParser: choices=[v.name.lower() for v in TargetVersion], ) add_arg( - "Formatter to use for reformatting code", + hlp.FORMATTER, "--formatter", default="black", - choices=["black"], + choices=get_formatter_names(), metavar="FORMATTER", ) return parser diff --git a/src/darker/config.py b/src/darker/config.py index 25f9c9e42..6b4f90893 100644 --- a/src/darker/config.py +++ b/src/darker/config.py @@ -113,6 +113,6 @@ class Exclusions: """ - black: set[str] = field(default_factory=set) + formatter: set[str] = field(default_factory=set) isort: set[str] = field(default_factory=set) flynt: set[str] = field(default_factory=set) diff --git a/src/darker/files.py b/src/darker/files.py index 551ce6491..068d12c4b 100644 --- a/src/darker/files.py +++ b/src/darker/files.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: from pathlib import Path - from darker.formatters.formatter_config import BlackConfig + from darker.formatters.base_formatter import BaseFormatter def find_pyproject_toml(path_search_start: tuple[str, ...]) -> str | None: @@ -46,14 +46,14 @@ def find_pyproject_toml(path_search_start: tuple[str, ...]) -> str | None: def filter_python_files( paths: Collection[Path], # pylint: disable=unsubscriptable-object root: Path, - black_config: BlackConfig, + formatter: BaseFormatter, ) -> set[Path]: """Get Python files and explicitly listed files not excluded by Black's config. :param paths: Relative file/directory paths from CWD to Python sources :param root: A common root directory for all ``paths`` - :param black_config: Black configuration which contains the exclude options read - from Black's configuration files + :param formatter: The code re-formatter which provides the configuration containing + the exclude options :return: Paths of files which should be reformatted according to ``black_config``, relative to ``root``. @@ -75,9 +75,9 @@ def filter_python_files( directories, root, include=DEFAULT_INCLUDE_RE, - exclude=black_config.get("exclude", DEFAULT_EXCLUDE_RE), - extend_exclude=black_config.get("extend_exclude"), - force_exclude=black_config.get("force_exclude"), + exclude=formatter.get_exclude(DEFAULT_EXCLUDE_RE), + extend_exclude=formatter.get_extend_exclude(), + force_exclude=formatter.get_force_exclude(), report=Report(), **kwargs, # type: ignore[arg-type] ) diff --git a/src/darker/formatters/__init__.py b/src/darker/formatters/__init__.py index ef890b8db..995af966c 100644 --- a/src/darker/formatters/__init__.py +++ b/src/darker/formatters/__init__.py @@ -1 +1,38 @@ """Built-in code re-formatter plugins.""" + +from __future__ import annotations + +import sys +from importlib.metadata import EntryPoint, entry_points +from typing import cast + +from darker.formatters.base_formatter import BaseFormatter + +ENTRY_POINT_GROUP = "darker.formatter" + + +def get_formatter_entry_points(name: str | None = None) -> tuple[EntryPoint, ...]: + """Get the entry points of all built-in code re-formatter plugins.""" + if sys.version_info < (3, 10): + return tuple( + ep + for ep in entry_points()[ENTRY_POINT_GROUP] + if not name or ep.name == name + ) + if name: + result = entry_points(group=ENTRY_POINT_GROUP, name=name) + else: + result = entry_points(group=ENTRY_POINT_GROUP) + return cast(tuple[EntryPoint, ...], result) + + +def get_formatter_names() -> list[str]: + """Get the names of all built-in code re-formatter plugins.""" + return [ep.name for ep in get_formatter_entry_points()] + + +def create_formatter(name: str) -> BaseFormatter: + """Create a code re-formatter plugin instance by name.""" + matching_entry_points = get_formatter_entry_points(name) + formatter_class = next(iter(matching_entry_points)).load() + return cast(BaseFormatter, formatter_class()) diff --git a/src/darker/formatters/base_formatter.py b/src/darker/formatters/base_formatter.py new file mode 100644 index 000000000..93de1ee1f --- /dev/null +++ b/src/darker/formatters/base_formatter.py @@ -0,0 +1,60 @@ +"""Base class for code re-formatters.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Pattern + +if TYPE_CHECKING: + from argparse import Namespace + + from darker.formatters.formatter_config import FormatterConfig + from darkgraylib.utils import TextDocument + + +class BaseFormatter: + """Base class for code re-formatters.""" + + def __init__(self) -> None: + """Initialize the code re-formatter plugin base class.""" + self.config: FormatterConfig = {} + + def read_config(self, src: tuple[str, ...], args: Namespace) -> None: + """Read the formatter configuration from a configuration file + + If not implemented by the subclass, this method does nothing, so the formatter + has no configuration options. + + :param src: The source code files and directories to be processed by Darker + :param args: Command line arguments + + """ + + def run(self, content: TextDocument) -> TextDocument: + """Reformat the content.""" + raise NotImplementedError + + def get_config_path(self) -> str | None: + """Get the path of the configuration file.""" + return None + + def get_line_length(self) -> int | None: + """Get the ``line-length`` configuration option value.""" + return None + + def get_exclude(self, default: Pattern[str]) -> Pattern[str]: + """Get the ``exclude`` configuration option value.""" + return default + + def get_extend_exclude(self) -> Pattern[str] | None: + """Get the ``extend_exclude`` configuration option value.""" + return None + + def get_force_exclude(self) -> Pattern[str] | None: + """Get the ``force_exclude`` configuration option value.""" + return None + + def __eq__(self, other: object) -> bool: + """Compare two formatters for equality.""" + if not isinstance(other, BaseFormatter): + return NotImplemented + return type(self) is type(other) and self.config == other.config diff --git a/src/darker/formatters/black_formatter.py b/src/darker/formatters/black_formatter.py index 55ccb0944..a0aef2a20 100644 --- a/src/darker/formatters/black_formatter.py +++ b/src/darker/formatters/black_formatter.py @@ -16,7 +16,8 @@ First, :func:`run_black` uses Black to reformat the contents of a given file. Reformatted lines are returned e.g.:: - >>> dst = run_black(src_content, black_config={}) + >>> from darker.formatters.black_formatter import BlackFormatter + >>> dst = BlackFormatter().run(src_content) >>> dst.lines ('for i in range(5):', ' print(i)', 'print("done")') @@ -36,9 +37,8 @@ from __future__ import annotations import logging -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict -# `FileMode as Mode` required to satisfy mypy==0.782. Strange. from black import FileMode as Mode from black import ( TargetVersion, @@ -48,11 +48,17 @@ ) from darker.files import find_pyproject_toml -from darker.formatters.formatter_config import BlackConfig +from darker.formatters.base_formatter import BaseFormatter from darkgraylib.config import ConfigurationError from darkgraylib.utils import TextDocument -__all__ = ["Mode", "run_black"] +if TYPE_CHECKING: + from argparse import Namespace + from typing import Pattern + + from darker.formatters.formatter_config import BlackConfig + +__all__ = ["Mode"] logger = logging.getLogger(__name__) @@ -68,26 +74,30 @@ class BlackModeAttributes(TypedDict, total=False): preview: bool -class BlackFormatter: - """Black code formatter interface.""" +class BlackFormatter(BaseFormatter): + """Black code formatter plugin interface.""" - def read_config(self, src: tuple[str, ...], value: str | None) -> BlackConfig: + def __init__(self) -> None: # pylint: disable=super-init-not-called + """Initialize the Black code re-formatter plugin.""" + self.config: BlackConfig = {} + + def read_config(self, src: tuple[str, ...], args: Namespace) -> None: """Read the black configuration from ``pyproject.toml`` :param src: The source code files and directories to be processed by Darker - :param value: The path of the Black configuration file - :return: A dictionary of those Black parameters from the configuration file which - are supported by Darker + :param args: Command line arguments """ + value = args.config value = value or find_pyproject_toml(src) + if value: + self._read_config_file(value) + self._read_cli_args(args) - if not value: - return BlackConfig() - + def _read_config_file(self, value: str) -> None: # noqa: C901 raw_config = parse_pyproject_toml(value) + config = self.config - config: BlackConfig = {} for key in [ "line_length", "skip_magic_trailing_comma", @@ -112,11 +122,24 @@ def read_config(self, src: tuple[str, ...], value: str | None) -> BlackConfig: config[key] = re_compile_maybe_verbose(raw_config[key]) # type: ignore return config - def run(self, src_contents: TextDocument, black_config: BlackConfig) -> TextDocument: + def _read_cli_args(self, args: Namespace) -> None: + if args.config: + self.config["config"] = args.config + if getattr(args, "line_length", None): + self.config["line_length"] = args.line_length + if getattr(args, "target_version", None): + self.config["target_version"] = {args.target_version} + if getattr(args, "skip_string_normalization", None) is not None: + self.config["skip_string_normalization"] = args.skip_string_normalization + if getattr(args, "skip_magic_trailing_comma", None) is not None: + self.config["skip_magic_trailing_comma"] = args.skip_magic_trailing_comma + if getattr(args, "preview", None): + self.config["preview"] = args.preview + + def run(self, src_contents: TextDocument) -> TextDocument: """Run the black formatter for the Python source code given as a string :param src_contents: The source code - :param black_config: Configuration to use for running Black :return: The reformatted content """ @@ -124,6 +147,7 @@ def run(self, src_contents: TextDocument, black_config: BlackConfig) -> TextDocu # pass them to Black's ``format_str()``. File exclusion options aren't needed since # at this point we already have a single file's content to work on. mode = BlackModeAttributes() + black_config = self.config if "line_length" in black_config: mode["line_length"] = black_config["line_length"] if "target_version" in black_config: @@ -160,25 +184,22 @@ def run(self, src_contents: TextDocument, black_config: BlackConfig) -> TextDocu override_newline=src_contents.newline, ) + def get_config_path(self) -> str | None: + """Get the path of the Black configuration file.""" + return self.config.get("config") -def read_black_config(src: tuple[str, ...], value: str | None) -> BlackConfig: - """Read the black configuration from ``pyproject.toml`` - - :param src: The source code files and directories to be processed by Darker - :param value: The path of the Black configuration file - :return: A dictionary of those Black parameters from the configuration file which - are supported by Darker - - """ - return BlackFormatter().read_config(src, value) - + def get_line_length(self) -> int | None: + """Get the ``line-length`` Black configuration option value.""" + return self.config.get("line_length") -def run_black(src_contents: TextDocument, black_config: BlackConfig) -> TextDocument: - """Run the black formatter for the Python source code given as a string + def get_exclude(self, default: Pattern[str]) -> Pattern[str]: + """Get the ``exclude`` Black configuration option value.""" + return self.config.get("exclude", default) - :param src_contents: The source code - :param black_config: Configuration to use for running Black - :return: The reformatted content + def get_extend_exclude(self) -> Pattern[str] | None: + """Get the ``extend_exclude`` Black configuration option value.""" + return self.config.get("extend_exclude") - """ - return BlackFormatter().run(src_contents, black_config) + def get_force_exclude(self) -> Pattern[str] | None: + """Get the ``force_exclude`` Black configuration option value.""" + return self.config.get("force_exclude") diff --git a/src/darker/formatters/none_formatter.py b/src/darker/formatters/none_formatter.py new file mode 100644 index 000000000..fcf6ec81c --- /dev/null +++ b/src/darker/formatters/none_formatter.py @@ -0,0 +1,53 @@ +"""A dummy code formatter plugin interface.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Pattern + +from darker.formatters.base_formatter import BaseFormatter + +if TYPE_CHECKING: + from argparse import Namespace + + from darkgraylib.utils import TextDocument + + +class NoneFormatter(BaseFormatter): + """A dummy code formatter plugin interface.""" + + def run(self, content: TextDocument) -> TextDocument: + """Return the Python source code unmodified. + + :param content: The source code + :return: The source code unmodified + + """ + return content + + def read_config(self, src: tuple[str, ...], args: Namespace) -> None: + """Keep configuration options empty for the dummy formatter. + + :param src: The source code files and directories to be processed by Darker + :param args: Command line arguments + + """ + + def get_config_path(self) -> str | None: + """Get the path of the configuration file.""" + return None + + def get_line_length(self) -> int | None: + """Get the ``line-length`` configuration option value.""" + return 88 + + def get_exclude(self, default: Pattern[str]) -> Pattern[str]: + """Get the ``exclude`` configuration option value.""" + return default + + def get_extend_exclude(self) -> Pattern[str] | None: + """Get the ``extend_exclude`` configuration option value.""" + return None + + def get_force_exclude(self) -> Pattern[str] | None: + """Get the ``force_exclude`` configuration option value.""" + return None diff --git a/src/darker/help.py b/src/darker/help.py index 83766032b..1768a0a84 100644 --- a/src/darker/help.py +++ b/src/darker/help.py @@ -4,6 +4,8 @@ from black import TargetVersion +from darker.formatters import get_formatter_names + def get_extra_instruction(dependency: str) -> str: """Generate the instructions to install Darker with particular extras @@ -155,3 +157,8 @@ def get_extra_instruction(dependency: str) -> str: ) WORKERS = "How many parallel workers to allow, or `0` for one per core [default: 1]" + +FORMATTER = ( + f"[{'|'.join(get_formatter_names())}] Formatter" + " to use for reformatting code. [default: black]" +) diff --git a/src/darker/tests/test_command_line.py b/src/darker/tests/test_command_line.py index b4bb00148..867d72b0a 100644 --- a/src/darker/tests/test_command_line.py +++ b/src/darker/tests/test_command_line.py @@ -18,6 +18,7 @@ from darker.command_line import make_argument_parser, parse_command_line from darker.config import Exclusions from darker.formatters import black_formatter +from darker.formatters.black_formatter import BlackFormatter from darker.tests.helpers import flynt_present, isort_present from darkgraylib.config import ConfigurationError from darkgraylib.git import RevisionRange @@ -776,7 +777,11 @@ def test_options(git_repo, options, expect): retval = main(options) - expect = (Path(git_repo.root), expect[1]) + expect[2:] + expect_formatter = BlackFormatter() + expect_formatter.config = expect[4] + actual_formatter = format_edited_parts.call_args.args[4] + assert actual_formatter.config == expect_formatter.config + expect = (Path(git_repo.root), expect[1]) + expect[2:4] + (expect_formatter,) format_edited_parts.assert_called_once_with( *expect, report_unmodified=False, workers=1 ) diff --git a/src/darker/tests/test_formatters_black.py b/src/darker/tests/test_formatters_black.py index 74ab39e56..74ec3d66e 100644 --- a/src/darker/tests/test_formatters_black.py +++ b/src/darker/tests/test_formatters_black.py @@ -4,6 +4,7 @@ import re import sys +from argparse import Namespace from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Dict, Iterable, Iterator, Optional, Pattern @@ -17,8 +18,7 @@ from darker import files from darker.files import DEFAULT_EXCLUDE_RE, filter_python_files from darker.formatters import black_formatter -from darker.formatters.black_formatter import read_black_config, run_black -from darker.formatters.formatter_config import BlackConfig +from darker.formatters.black_formatter import BlackFormatter from darkgraylib.config import ConfigurationError from darkgraylib.testtools.helpers import raises_or_matches from darkgraylib.utils import TextDocument @@ -33,6 +33,9 @@ else: import tomli as tomllib +if TYPE_CHECKING: + from darker.formatters.formatter_config import BlackConfig + @dataclass class RegexEquality: @@ -110,15 +113,22 @@ def __eq__(self, other): ), config_path=None, ) -def test_read_black_config(tmpdir, config_path, config_lines, expect): - """``read_black_config()`` reads Black configuration from a TOML file correctly""" +def test_read_config(tmpdir, config_path, config_lines, expect): + """`BlackFormatter.read_config` reads Black config correctly from a TOML file.""" tmpdir = Path(tmpdir) src = tmpdir / "src.py" toml = tmpdir / (config_path or "pyproject.toml") toml.write_text("[tool.black]\n{}\n".format("\n".join(config_lines))) - with raises_or_matches(expect, []) as check: + with raises_or_matches(expect, []): + formatter = BlackFormatter() + args = Namespace() + args.config = config_path and str(toml) + if config_path: + expect["config"] = str(toml) + + formatter.read_config((str(src),), args) - check(read_black_config((str(src),), config_path and str(toml))) + assert formatter.config == expect @pytest.mark.kwparametrize( @@ -179,13 +189,11 @@ def test_filter_python_files( # pylint: disable=too-many-arguments paths = {tmp_path / name for name in names} for path in paths: path.touch() - black_config = BlackConfig( - { - "exclude": regex.compile(exclude) if exclude else DEFAULT_EXCLUDE_RE, - "extend_exclude": regex.compile(extend_exclude) if extend_exclude else None, - "force_exclude": regex.compile(force_exclude) if force_exclude else None, - } - ) + black_config: BlackConfig = { + "exclude": regex.compile(exclude) if exclude else DEFAULT_EXCLUDE_RE, + "extend_exclude": regex.compile(extend_exclude) if extend_exclude else None, + "force_exclude": regex.compile(force_exclude) if force_exclude else None, + } explicit = { Path("none+explicit.py"), Path("exclude+explicit.py"), @@ -196,8 +204,10 @@ def test_filter_python_files( # pylint: disable=too-many-arguments Path("extend+force+explicit.py"), Path("exclude+extend+force+explicit.py"), } + formatter = BlackFormatter() + formatter.config = black_config - result = filter_python_files({Path(".")} | explicit, tmp_path, black_config) + result = filter_python_files({Path()} | explicit, tmp_path, formatter) expect_paths = {Path(f"{path}.py") for path in expect} | explicit assert result == expect_paths @@ -321,14 +331,14 @@ def test_filter_python_files_gitignore(make_mock, tmp_path, expect): with patch.object(files, "gen_python_files", gen_python_files): # end of test setup - _ = filter_python_files(set(), tmp_path, BlackConfig()) + _ = filter_python_files(set(), tmp_path, BlackFormatter()) assert calls.gen_python_files.kwargs == expect @pytest.mark.parametrize("encoding", ["utf-8", "iso-8859-1"]) @pytest.mark.parametrize("newline", ["\n", "\r\n"]) -def test_run_black(encoding, newline): +def test_run(encoding, newline): """Running Black through its Python internal API gives correct results""" src = TextDocument.from_lines( [f"# coding: {encoding}", "print ( 'touché' )"], @@ -336,7 +346,7 @@ def test_run_black(encoding, newline): newline=newline, ) - result = run_black(src, BlackConfig()) + result = BlackFormatter().run(src) assert result.lines == ( f"# coding: {encoding}", @@ -347,31 +357,28 @@ def test_run_black(encoding, newline): @pytest.mark.parametrize("newline", ["\n", "\r\n"]) -def test_run_black_always_uses_unix_newlines(newline): +def test_run_always_uses_unix_newlines(newline): """Content is always passed to Black with Unix newlines""" src = TextDocument.from_str(f"print ( 'touché' ){newline}") with patch.object(black_formatter, "format_str") as format_str: format_str.return_value = 'print("touché")\n' - _ = run_black(src, BlackConfig()) + _ = BlackFormatter().run(src) format_str.assert_called_once_with("print ( 'touché' )\n", mode=ANY) -def test_run_black_ignores_excludes(): - """Black's exclude configuration is ignored by ``run_black()``""" +def test_run_ignores_excludes(): + """Black's exclude configuration is ignored by `BlackFormatter.run`.""" src = TextDocument.from_str("a=1\n") + formatter = BlackFormatter() + formatter.config = { + "exclude": regex.compile(r".*"), + "extend_exclude": regex.compile(r".*"), + "force_exclude": regex.compile(r".*"), + } - result = run_black( - src, - BlackConfig( - { - "exclude": regex.compile(r".*"), - "extend_exclude": regex.compile(r".*"), - "force_exclude": regex.compile(r".*"), - } - ), - ) + result = formatter.run(src) assert result.string == "a = 1\n" @@ -389,11 +396,11 @@ def test_run_black_ignores_excludes(): (" \t\r\n", "\r\n"), ], ) -def test_run_black_all_whitespace_input(src_content, expect): +def test_run_all_whitespace_input(src_content, expect): """All-whitespace files are reformatted correctly""" src = TextDocument.from_str(src_content) - result = run_black(src, BlackConfig()) + result = BlackFormatter().run(src) assert result.string == expect @@ -455,7 +462,7 @@ def test_run_black_all_whitespace_input(src_content, expect): expect_string_normalization=True, expect_magic_trailing_comma=True, ) -def test_run_black_configuration( +def test_run_configuration( black_config, expect, expect_target_versions, @@ -463,14 +470,16 @@ def test_run_black_configuration( expect_string_normalization, expect_magic_trailing_comma, ): - """`run_black` passes correct configuration to Black""" + """`BlackFormatter.run` passes correct configuration to Black.""" src = TextDocument.from_str("import os\n") with patch.object(black_formatter, "format_str") as format_str, raises_or_matches( expect, [] ) as check: format_str.return_value = "import os\n" + formatter = BlackFormatter() + formatter.config = black_config - check(run_black(src, black_config)) + check(formatter.run(src)) assert format_str.call_count == 1 mode = format_str.call_args[1]["mode"] diff --git a/src/darker/tests/test_main_format_edited_parts.py b/src/darker/tests/test_main_format_edited_parts.py index 2521557ab..3d57c183f 100644 --- a/src/darker/tests/test_main_format_edited_parts.py +++ b/src/darker/tests/test_main_format_edited_parts.py @@ -14,6 +14,7 @@ import darker.__main__ import darker.verification from darker.config import Exclusions +from darker.formatters.black_formatter import BlackFormatter from darker.tests.examples import A_PY, A_PY_BLACK, A_PY_BLACK_FLYNT, A_PY_BLACK_ISORT from darker.verification import NotEquivalentError from darkgraylib.git import WORKTREE, RevisionRange @@ -84,13 +85,15 @@ def test_format_edited_parts( paths = git_repo.add({"a.py": newline, "b.py": newline}, commit="Initial commit") paths["a.py"].write_bytes(newline.join(A_PY).encode("ascii")) paths["b.py"].write_bytes(f"print(42 ){newline}".encode("ascii")) + formatter = BlackFormatter() + formatter.config = black_config result = darker.__main__.format_edited_parts( Path(git_repo.root), {Path("a.py")}, - Exclusions(black=black_exclude, isort=isort_exclude, flynt=flynt_exclude), + Exclusions(formatter=black_exclude, isort=isort_exclude, flynt=flynt_exclude), RevisionRange("HEAD", ":WORKTREE:"), - black_config, + formatter, report_unmodified=False, ) @@ -174,9 +177,9 @@ def test_format_edited_parts_stdin(git_repo, newline, rev1, rev2, expect): darker.__main__.format_edited_parts( Path(git_repo.root), {Path("a.py")}, - Exclusions(black=set(), isort=set()), + Exclusions(formatter=set(), isort=set()), RevisionRange(rev1, rev2), - {}, + BlackFormatter(), report_unmodified=False, ), ) @@ -201,7 +204,7 @@ def test_format_edited_parts_all_unchanged(git_repo, monkeypatch): {Path("a.py"), Path("b.py")}, Exclusions(), RevisionRange("HEAD", ":WORKTREE:"), - {}, + BlackFormatter(), report_unmodified=False, ), ) @@ -226,7 +229,7 @@ def test_format_edited_parts_ast_changed(git_repo, caplog): {Path("a.py")}, Exclusions(isort={"**/*"}), RevisionRange("HEAD", ":WORKTREE:"), - black_config={}, + BlackFormatter(), report_unmodified=False, ), ) @@ -270,7 +273,7 @@ def test_format_edited_parts_isort_on_already_formatted(git_repo): {Path("a.py")}, Exclusions(), RevisionRange("HEAD", ":WORKTREE:"), - black_config={}, + BlackFormatter(), report_unmodified=False, ) @@ -325,7 +328,7 @@ def test_format_edited_parts_historical(git_repo, rev1, rev2, expect): {Path("a.py")}, Exclusions(), RevisionRange(rev1, rev2), - black_config={}, + BlackFormatter(), report_unmodified=False, ) diff --git a/src/darker/tests/test_main_isort.py b/src/darker/tests/test_main_isort.py index 97db9a51c..2bef090bf 100644 --- a/src/darker/tests/test_main_isort.py +++ b/src/darker/tests/test_main_isort.py @@ -10,6 +10,7 @@ import darker.__main__ import darker.import_sorting from darker.exceptions import MissingPackageError +from darker.formatters import black_formatter from darker.tests.helpers import isort_present from darkgraylib.utils import TextDocument @@ -50,8 +51,8 @@ def run_isort(git_repo, monkeypatch, caplog, request): isorted_code = "import os; import sys;" blacken_code = "import os\nimport sys\n" patch_run_black_ctx = patch.object( - darker.__main__, - "run_black", + black_formatter.BlackFormatter, + "run", return_value=TextDocument(blacken_code), ) with patch_run_black_ctx, patch("darker.import_sorting.isort_code") as isort_code: diff --git a/src/darker/tests/test_main_blacken_and_flynt_single_file.py b/src/darker/tests/test_main_reformat_and_flynt_single_file.py similarity index 90% rename from src/darker/tests/test_main_blacken_and_flynt_single_file.py rename to src/darker/tests/test_main_reformat_and_flynt_single_file.py index 18d1cbcc4..5cee23891 100644 --- a/src/darker/tests/test_main_blacken_and_flynt_single_file.py +++ b/src/darker/tests/test_main_reformat_and_flynt_single_file.py @@ -1,4 +1,4 @@ -"""Unit tests for `darker.__main__._blacken_and_flynt_single_file`""" +"""Unit tests for `darker.__main__._reformat_and_flynt_single_file`.""" # pylint: disable=too-many-arguments,use-dict-literal @@ -7,8 +7,9 @@ import pytest -from darker.__main__ import _blacken_and_flynt_single_file +from darker.__main__ import _reformat_and_flynt_single_file from darker.config import Exclusions +from darker.formatters.black_formatter import BlackFormatter from darker.git import EditedLinenumsDiffer from darkgraylib.git import RevisionRange from darkgraylib.utils import TextDocument @@ -56,7 +57,7 @@ exclusions=Exclusions(), expect="import original\nprint( original )\n", ) -def test_blacken_and_flynt_single_file( +def test_reformat_and_flynt_single_file( git_repo, relative_path, rev2_content, @@ -64,11 +65,11 @@ def test_blacken_and_flynt_single_file( exclusions, expect, ): - """Test for ``_blacken_and_flynt_single_file``""" + """Test for `_reformat_and_flynt_single_file`.""" git_repo.add( {"file.py": "import original\nprint( original )\n"}, commit="Initial commit" ) - result = _blacken_and_flynt_single_file( + result = _reformat_and_flynt_single_file( git_repo.root, Path(relative_path), Path("file.py"), @@ -79,14 +80,14 @@ def test_blacken_and_flynt_single_file( TextDocument(rev2_content), TextDocument(rev2_isorted), has_isort_changes=False, - black_config={}, + formatter=BlackFormatter(), ) assert result.string == expect def test_blacken_and_flynt_single_file_common_ancestor(git_repo): - """`_blacken_and_flynt_single_file` diffs to common ancestor of ``rev1...rev2``""" + """`_blacken_and_flynt_single_file` diffs to common ancestor of ``rev1...rev2``.""" a_py_initial = dedent( """\ a=1 @@ -133,7 +134,7 @@ def test_blacken_and_flynt_single_file_common_ancestor(git_repo): "master...", git_repo.root, stdin_mode=False ) - result = _blacken_and_flynt_single_file( + result = _reformat_and_flynt_single_file( git_repo.root, Path("a.py"), Path("a.py"), @@ -142,7 +143,7 @@ def test_blacken_and_flynt_single_file_common_ancestor(git_repo): rev2_content=worktree, rev2_isorted=worktree, has_isort_changes=False, - black_config={}, + formatter=BlackFormatter(), ) assert result.lines == ( @@ -155,7 +156,7 @@ def test_blacken_and_flynt_single_file_common_ancestor(git_repo): def test_reformat_single_file_docstring(git_repo): - """`_blacken_and_flynt_single_file()` handles docstrings as one contiguous block""" + """`_blacken_and_flynt_single_file()` handles docstrings as one contiguous block.""" initial = dedent( '''\ def docstring_func(): @@ -192,7 +193,7 @@ def docstring_func(): "HEAD..", git_repo.root, stdin_mode=False ) - result = _blacken_and_flynt_single_file( + result = _reformat_and_flynt_single_file( git_repo.root, Path("a.py"), Path("a.py"), @@ -201,7 +202,7 @@ def docstring_func(): rev2_content=TextDocument.from_str(modified), rev2_isorted=TextDocument.from_str(modified), has_isort_changes=False, - black_config={}, + formatter=BlackFormatter(), ) assert result.lines == tuple(expect.splitlines()) From 290b0c90c33f67292ec557bd78ba9eb43db0999c Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Thu, 10 Oct 2024 11:12:47 +0300 Subject: [PATCH 07/14] feat: show formatter name in debug log Thanks @clintonsteiner! --- src/darker/__main__.py | 7 +++++-- src/darker/formatters/base_formatter.py | 2 ++ src/darker/formatters/black_formatter.py | 2 ++ src/darker/formatters/none_formatter.py | 2 ++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/darker/__main__.py b/src/darker/__main__.py index 381c26510..6c69d0f64 100644 --- a/src/darker/__main__.py +++ b/src/darker/__main__.py @@ -220,9 +220,12 @@ def _reformat_and_flynt_single_file( # noqa: PLR0913 relative_path_in_rev2, exclude.formatter, fstringified, formatter ) logger.debug( - "Black reformat resulted in %s lines, with %s changes from reformatting", - len(formatted.lines), + "Running %r by %s.%s resulted in %s changed lines within a total of %s lines", + formatter.name, + formatter.__module__, + type(formatter).__name__, "no" if formatted == fstringified else "some", + len(formatted.lines), ) # 4. get a diff between the edited to-file and the processed content diff --git a/src/darker/formatters/base_formatter.py b/src/darker/formatters/base_formatter.py index 93de1ee1f..2bbe32b98 100644 --- a/src/darker/formatters/base_formatter.py +++ b/src/darker/formatters/base_formatter.py @@ -18,6 +18,8 @@ def __init__(self) -> None: """Initialize the code re-formatter plugin base class.""" self.config: FormatterConfig = {} + name: str + def read_config(self, src: tuple[str, ...], args: Namespace) -> None: """Read the formatter configuration from a configuration file diff --git a/src/darker/formatters/black_formatter.py b/src/darker/formatters/black_formatter.py index a0aef2a20..c295719d8 100644 --- a/src/darker/formatters/black_formatter.py +++ b/src/darker/formatters/black_formatter.py @@ -81,6 +81,8 @@ def __init__(self) -> None: # pylint: disable=super-init-not-called """Initialize the Black code re-formatter plugin.""" self.config: BlackConfig = {} + name = "black" + def read_config(self, src: tuple[str, ...], args: Namespace) -> None: """Read the black configuration from ``pyproject.toml`` diff --git a/src/darker/formatters/none_formatter.py b/src/darker/formatters/none_formatter.py index fcf6ec81c..650acd492 100644 --- a/src/darker/formatters/none_formatter.py +++ b/src/darker/formatters/none_formatter.py @@ -15,6 +15,8 @@ class NoneFormatter(BaseFormatter): """A dummy code formatter plugin interface.""" + name = "dummy reformat" + def run(self, content: TextDocument) -> TextDocument: """Return the Python source code unmodified. From bc5e735b25455c544c0a59010c9eb5e1d8aadd9b Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:42:39 +0300 Subject: [PATCH 08/14] refactor: naming, linting --- src/darker/formatters/black_formatter.py | 85 +++++++++++++----------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/src/darker/formatters/black_formatter.py b/src/darker/formatters/black_formatter.py index c295719d8..6809f5924 100644 --- a/src/darker/formatters/black_formatter.py +++ b/src/darker/formatters/black_formatter.py @@ -1,4 +1,4 @@ -"""Re-format Python source code using Black +"""Re-format Python source code using Black. In examples below, a simple two-line snippet is used. The first line will be reformatted by Black, and the second left intact:: @@ -64,7 +64,7 @@ class BlackModeAttributes(TypedDict, total=False): - """Type definition for items accepted by ``black.Mode``""" + """Type definition for items accepted by ``black.Mode``.""" target_versions: set[TargetVersion] line_length: int @@ -84,45 +84,52 @@ def __init__(self) -> None: # pylint: disable=super-init-not-called name = "black" def read_config(self, src: tuple[str, ...], args: Namespace) -> None: - """Read the black configuration from ``pyproject.toml`` + """Read Black configuration from ``pyproject.toml``. :param src: The source code files and directories to be processed by Darker :param args: Command line arguments """ - value = args.config - value = value or find_pyproject_toml(src) - if value: - self._read_config_file(value) + config_path = args.config or find_pyproject_toml(src) + if config_path: + self._read_config_file(config_path) self._read_cli_args(args) def _read_config_file(self, value: str) -> None: # noqa: C901 raw_config = parse_pyproject_toml(value) - config = self.config - - for key in [ - "line_length", - "skip_magic_trailing_comma", - "skip_string_normalization", - "preview", - ]: - if key in raw_config: - config[key] = raw_config[key] # type: ignore + if "line_length" in raw_config: + self.config["line_length"] = raw_config["line_length"] + if "skip_magic_trailing_comma" in raw_config: + self.config["skip_magic_trailing_comma"] = raw_config[ + "skip_magic_trailing_comma" + ] + if "skip_string_normalization" in raw_config: + self.config["skip_string_normalization"] = raw_config[ + "skip_string_normalization" + ] + if "preview" in raw_config: + self.config["preview"] = raw_config["preview"] if "target_version" in raw_config: target_version = raw_config["target_version"] if isinstance(target_version, str): - config["target_version"] = target_version + self.config["target_version"] = target_version elif isinstance(target_version, list): # Convert TOML list to a Python set - config["target_version"] = set(target_version) + self.config["target_version"] = set(target_version) else: raise ConfigurationError( f"Invalid target-version = {target_version!r} in {value}" ) - for key in ["exclude", "extend_exclude", "force_exclude"]: - if key in raw_config: - config[key] = re_compile_maybe_verbose(raw_config[key]) # type: ignore - return config + if "exclude" in raw_config: + self.config["exclude"] = re_compile_maybe_verbose(raw_config["exclude"]) + if "extend_exclude" in raw_config: + self.config["extend_exclude"] = re_compile_maybe_verbose( + raw_config["extend_exclude"] + ) + if "force_exclude" in raw_config: + self.config["force_exclude"] = re_compile_maybe_verbose( + raw_config["force_exclude"] + ) def _read_cli_args(self, args: Namespace) -> None: if args.config: @@ -139,39 +146,39 @@ def _read_cli_args(self, args: Namespace) -> None: self.config["preview"] = args.preview def run(self, src_contents: TextDocument) -> TextDocument: - """Run the black formatter for the Python source code given as a string + """Run the Black code re-formatter for the Python source code given as a string. :param src_contents: The source code :return: The reformatted content """ - # Collect relevant Black configuration options from ``black_config`` in order to + # Collect relevant Black configuration options from ``self.config`` in order to # pass them to Black's ``format_str()``. File exclusion options aren't needed since # at this point we already have a single file's content to work on. mode = BlackModeAttributes() - black_config = self.config - if "line_length" in black_config: - mode["line_length"] = black_config["line_length"] - if "target_version" in black_config: - if isinstance(black_config["target_version"], set): - target_versions_in = black_config["target_version"] + if "line_length" in self.config: + mode["line_length"] = self.config["line_length"] + if "target_version" in self.config: + if isinstance(self.config["target_version"], set): + target_versions_in = self.config["target_version"] else: - target_versions_in = {black_config["target_version"]} + target_versions_in = {self.config["target_version"]} all_target_versions = {tgt_v.name.lower(): tgt_v for tgt_v in TargetVersion} bad_target_versions = target_versions_in - set(all_target_versions) if bad_target_versions: raise ConfigurationError(f"Invalid target version(s) {bad_target_versions}") - mode["target_versions"] = {all_target_versions[n] for n in target_versions_in} - if "skip_magic_trailing_comma" in black_config: - mode["magic_trailing_comma"] = not black_config["skip_magic_trailing_comma"] - if "skip_string_normalization" in black_config: + mode["target_versions"] = {all_target_versions[n] for n in + target_versions_in} + if "skip_magic_trailing_comma" in self.config: + mode["magic_trailing_comma"] = not self.config["skip_magic_trailing_comma"] + if "skip_string_normalization" in self.config: # The ``black`` command line argument is # ``--skip-string-normalization``, but the parameter for # ``black.Mode`` needs to be the opposite boolean of # ``skip-string-normalization``, hence the inverse boolean - mode["string_normalization"] = not black_config["skip_string_normalization"] - if "preview" in black_config: - mode["preview"] = black_config["preview"] + mode["string_normalization"] = not self.config["skip_string_normalization"] + if "preview" in self.config: + mode["preview"] = self.config["preview"] # The custom handling of empty and all-whitespace files below will be unnecessary if # https://github.com/psf/black/pull/2484 lands in Black. From 4a68432e7f17d71d0fd840462cd580ac60115a5a Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:44:40 +0300 Subject: [PATCH 09/14] refactor: linting, re-formatting --- src/darker/formatters/black_formatter.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/darker/formatters/black_formatter.py b/src/darker/formatters/black_formatter.py index 6809f5924..d969f8b2c 100644 --- a/src/darker/formatters/black_formatter.py +++ b/src/darker/formatters/black_formatter.py @@ -117,9 +117,8 @@ def _read_config_file(self, value: str) -> None: # noqa: C901 # Convert TOML list to a Python set self.config["target_version"] = set(target_version) else: - raise ConfigurationError( - f"Invalid target-version = {target_version!r} in {value}" - ) + message = f"Invalid target-version = {target_version!r} in {value}" + raise ConfigurationError(message) if "exclude" in raw_config: self.config["exclude"] = re_compile_maybe_verbose(raw_config["exclude"]) if "extend_exclude" in raw_config: @@ -153,8 +152,8 @@ def run(self, src_contents: TextDocument) -> TextDocument: """ # Collect relevant Black configuration options from ``self.config`` in order to - # pass them to Black's ``format_str()``. File exclusion options aren't needed since - # at this point we already have a single file's content to work on. + # pass them to Black's ``format_str()``. File exclusion options aren't needed + # since at this point we already have a single file's content to work on. mode = BlackModeAttributes() if "line_length" in self.config: mode["line_length"] = self.config["line_length"] @@ -166,9 +165,11 @@ def run(self, src_contents: TextDocument) -> TextDocument: all_target_versions = {tgt_v.name.lower(): tgt_v for tgt_v in TargetVersion} bad_target_versions = target_versions_in - set(all_target_versions) if bad_target_versions: - raise ConfigurationError(f"Invalid target version(s) {bad_target_versions}") - mode["target_versions"] = {all_target_versions[n] for n in - target_versions_in} + message = f"Invalid target version(s) {bad_target_versions}" + raise ConfigurationError(message) + mode["target_versions"] = { + all_target_versions[n] for n in target_versions_in + } if "skip_magic_trailing_comma" in self.config: mode["magic_trailing_comma"] = not self.config["skip_magic_trailing_comma"] if "skip_string_normalization" in self.config: @@ -180,8 +181,8 @@ def run(self, src_contents: TextDocument) -> TextDocument: if "preview" in self.config: mode["preview"] = self.config["preview"] - # The custom handling of empty and all-whitespace files below will be unnecessary if - # https://github.com/psf/black/pull/2484 lands in Black. + # The custom handling of empty and all-whitespace files below will be + # unnecessary if https://github.com/psf/black/pull/2484 lands in Black. contents_for_black = src_contents.string_with_newline("\n") if contents_for_black.strip(): dst_contents = format_str(contents_for_black, mode=Mode(**mode)) From 4ff41ecea9a57a897ff521adb4ff8148773b4b11 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Wed, 25 Sep 2024 20:09:15 +0300 Subject: [PATCH 10/14] refactor: argument names in formatter methods --- src/darker/formatters/black_formatter.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/darker/formatters/black_formatter.py b/src/darker/formatters/black_formatter.py index d969f8b2c..69e8acacf 100644 --- a/src/darker/formatters/black_formatter.py +++ b/src/darker/formatters/black_formatter.py @@ -95,8 +95,8 @@ def read_config(self, src: tuple[str, ...], args: Namespace) -> None: self._read_config_file(config_path) self._read_cli_args(args) - def _read_config_file(self, value: str) -> None: # noqa: C901 - raw_config = parse_pyproject_toml(value) + def _read_config_file(self, config_path: str) -> None: # noqa: C901 + raw_config = parse_pyproject_toml(config_path) if "line_length" in raw_config: self.config["line_length"] = raw_config["line_length"] if "skip_magic_trailing_comma" in raw_config: @@ -117,7 +117,9 @@ def _read_config_file(self, value: str) -> None: # noqa: C901 # Convert TOML list to a Python set self.config["target_version"] = set(target_version) else: - message = f"Invalid target-version = {target_version!r} in {value}" + message = ( + f"Invalid target-version = {target_version!r} in {config_path}" + ) raise ConfigurationError(message) if "exclude" in raw_config: self.config["exclude"] = re_compile_maybe_verbose(raw_config["exclude"]) @@ -144,10 +146,10 @@ def _read_cli_args(self, args: Namespace) -> None: if getattr(args, "preview", None): self.config["preview"] = args.preview - def run(self, src_contents: TextDocument) -> TextDocument: + def run(self, content: TextDocument) -> TextDocument: """Run the Black code re-formatter for the Python source code given as a string. - :param src_contents: The source code + :param content: The source code :return: The reformatted content """ @@ -183,15 +185,15 @@ def run(self, src_contents: TextDocument) -> TextDocument: # The custom handling of empty and all-whitespace files below will be # unnecessary if https://github.com/psf/black/pull/2484 lands in Black. - contents_for_black = src_contents.string_with_newline("\n") + contents_for_black = content.string_with_newline("\n") if contents_for_black.strip(): dst_contents = format_str(contents_for_black, mode=Mode(**mode)) else: - dst_contents = "\n" if "\n" in src_contents.string else "" + dst_contents = "\n" if "\n" in content.string else "" return TextDocument.from_str( dst_contents, - encoding=src_contents.encoding, - override_newline=src_contents.newline, + encoding=content.encoding, + override_newline=content.newline, ) def get_config_path(self) -> str | None: From 9a29240b17f78e9691541823e1fa28be7362a05d Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Sun, 15 Sep 2024 22:01:22 +0300 Subject: [PATCH 11/14] docs: update the change log --- CHANGES.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index e69f730df..8f7bbb1b2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -15,6 +15,10 @@ Added - Prevent Pylint from updating beyond version 3.2.7 due to dropped Python 3.8 support. - The ``--formatter=black`` option (the default) has been added in preparation for future formatters. +- Invoking Black is now implemented as a plugin. This allows for easier integration of + other formatters in the future. There's also a dummy ``none`` formatter plugin. +- ``--formatter=none`` now skips running Black. This is useful when you only want to run + Isort or Flynt. Removed ------- From ec87cd82869bbd51939c80f99c78e0ea96256b16 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Sun, 6 Oct 2024 19:59:18 +0300 Subject: [PATCH 12/14] docs: improve all-whitespace work-around comment for Black --- src/darker/formatters/black_formatter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/darker/formatters/black_formatter.py b/src/darker/formatters/black_formatter.py index 69e8acacf..baf5fc61e 100644 --- a/src/darker/formatters/black_formatter.py +++ b/src/darker/formatters/black_formatter.py @@ -183,12 +183,12 @@ def run(self, content: TextDocument) -> TextDocument: if "preview" in self.config: mode["preview"] = self.config["preview"] - # The custom handling of empty and all-whitespace files below will be - # unnecessary if https://github.com/psf/black/pull/2484 lands in Black. contents_for_black = content.string_with_newline("\n") if contents_for_black.strip(): dst_contents = format_str(contents_for_black, mode=Mode(**mode)) else: + # The custom handling of empty and all-whitespace files was needed until + # Black 22.12.0. See https://github.com/psf/black/pull/2484 dst_contents = "\n" if "\n" in content.string else "" return TextDocument.from_str( dst_contents, From 7fd8c63d40332701bfdeda25b4c9aec0f558d4b9 Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Sun, 6 Oct 2024 20:32:58 +0300 Subject: [PATCH 13/14] test: remove duplicate main() Black test --- src/darker/tests/test_command_line.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/darker/tests/test_command_line.py b/src/darker/tests/test_command_line.py index 867d72b0a..6d108e32d 100644 --- a/src/darker/tests/test_command_line.py +++ b/src/darker/tests/test_command_line.py @@ -501,14 +501,6 @@ def test_help_with_flynt_package(capsys): options=["--target-version", "py39"], expect=call(target_versions={TargetVersion.PY39}), ), - dict( - options=["-c", "black.cfg", "-S"], - expect=call( - line_length=81, - string_normalization=False, - target_versions={TargetVersion.PY38}, - ), - ), dict( options=["-c", "black.cfg", "-t", "py39"], expect=call( From 44ae494d4fb6e41aeaa0676cbb5c4692ecb6771e Mon Sep 17 00:00:00 2001 From: Antti Kaihola <13725+akaihola@users.noreply.github.com> Date: Wed, 9 Oct 2024 10:46:04 +0300 Subject: [PATCH 14/14] refactor: build options for the Black call in a helper method [black-as-plugin] Thanks @clintonsteiner! --- src/darker/formatters/black_formatter.py | 31 ++++++++++++++---------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/darker/formatters/black_formatter.py b/src/darker/formatters/black_formatter.py index baf5fc61e..540283b05 100644 --- a/src/darker/formatters/black_formatter.py +++ b/src/darker/formatters/black_formatter.py @@ -153,6 +153,23 @@ def run(self, content: TextDocument) -> TextDocument: :return: The reformatted content """ + contents_for_black = content.string_with_newline("\n") + if contents_for_black.strip(): + dst_contents = format_str( + contents_for_black, mode=self._make_black_options() + ) + else: + # The custom handling of empty and all-whitespace files was needed until + # Black 22.12.0. See https://github.com/psf/black/pull/2484 + dst_contents = "\n" if "\n" in content.string else "" + return TextDocument.from_str( + dst_contents, + encoding=content.encoding, + override_newline=content.newline, + ) + + def _make_black_options(self) -> Mode: + """Create a Black ``Mode`` object from the configuration options.""" # Collect relevant Black configuration options from ``self.config`` in order to # pass them to Black's ``format_str()``. File exclusion options aren't needed # since at this point we already have a single file's content to work on. @@ -182,19 +199,7 @@ def run(self, content: TextDocument) -> TextDocument: mode["string_normalization"] = not self.config["skip_string_normalization"] if "preview" in self.config: mode["preview"] = self.config["preview"] - - contents_for_black = content.string_with_newline("\n") - if contents_for_black.strip(): - dst_contents = format_str(contents_for_black, mode=Mode(**mode)) - else: - # The custom handling of empty and all-whitespace files was needed until - # Black 22.12.0. See https://github.com/psf/black/pull/2484 - dst_contents = "\n" if "\n" in content.string else "" - return TextDocument.from_str( - dst_contents, - encoding=content.encoding, - override_newline=content.newline, - ) + return Mode(**mode) def get_config_path(self) -> str | None: """Get the path of the Black configuration file."""