From 6e8ff91d742c82bc780ae27ac87e47964ee8cd90 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] 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 | 20 ++-- src/darker/formatters/__init__.py | 37 ++++++++ src/darker/formatters/base_formatter.py | 47 ++++++++++ src/darker/formatters/black_formatter.py | 94 ++++++++++++------- src/darker/formatters/formatter_config.py | 4 + src/darker/formatters/none_formatter.py | 58 ++++++++++++ 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 ++--- 17 files changed, 352 insertions(+), 158 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 ce019b2eb..00249d3ac 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, @@ -56,12 +57,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]: @@ -78,7 +79,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 @@ -97,7 +98,7 @@ def format_edited_parts( # pylint: disable=too-many-arguments edited_linenums_differ, exclude, revrange, - black_config, + formatter, ) futures.append(future) @@ -111,21 +112,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 """ @@ -144,13 +146,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), @@ -159,13 +162,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, @@ -174,8 +176,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 @@ -188,7 +191,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 @@ -210,9 +213,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", @@ -266,26 +270,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( @@ -455,7 +459,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 @@ -507,19 +512,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, @@ -566,8 +560,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. @@ -576,7 +570,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): @@ -591,10 +585,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 @@ -603,12 +597,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 7260cb6af..93f4861b9 100644 --- a/src/darker/files.py +++ b/src/darker/files.py @@ -2,7 +2,7 @@ import inspect from pathlib import Path -from typing import Collection, Optional, Set, Tuple +from typing import Collection, Optional, Tuple from black import ( DEFAULT_EXCLUDES, @@ -14,7 +14,7 @@ re_compile_maybe_verbose, ) -from darker.formatters.formatter_config import BlackConfig +from darker.formatters.base_formatter import BaseFormatter from darkgraylib.files import find_project_root @@ -41,14 +41,14 @@ def find_pyproject_toml(path_search_start: Tuple[str, ...]) -> Optional[str]: 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 + 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``. @@ -70,9 +70,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..1f0013220 --- /dev/null +++ b/src/darker/formatters/base_formatter.py @@ -0,0 +1,47 @@ +"""Base class for code re-formatters.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Pattern + +if TYPE_CHECKING: + 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 run(self, content: TextDocument) -> TextDocument: + """Reformat the content.""" + raise NotImplementedError + + def get_config_path(self) -> str | None: + """Get the path of the configuration file.""" + raise NotImplementedError + + def get_line_length(self) -> int | None: + """Get the ``line-length`` configuration option value.""" + raise NotImplementedError + + def get_exclude(self, default: Pattern[str]) -> Pattern[str]: + """Get the ``exclude`` configuration option value.""" + raise NotImplementedError + + def get_extend_exclude(self) -> Pattern[str] | None: + """Get the ``extend_exclude`` configuration option value.""" + raise NotImplementedError + + def get_force_exclude(self) -> Pattern[str] | None: + """Get the ``force_exclude`` configuration option value.""" + raise NotImplementedError + + 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 13529b407..af187f336 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")') @@ -32,10 +33,12 @@ based on whether reformats touch user-edited lines """ + +from __future__ import annotations + import logging -from typing import Optional, Set, Tuple, 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, @@ -45,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, Set + + from darker.formatters.formatter_config import BlackConfig + +__all__ = ["Mode"] logger = logging.getLogger(__name__) @@ -65,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 __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, ...], value: Optional[str]) -> 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", @@ -109,11 +122,24 @@ def read_config(self, src: Tuple[str, ...], value: Optional[str]) -> 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 """ @@ -121,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: @@ -157,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 configuration file.""" + return self.config.get("config") -def read_black_config(src: Tuple[str, ...], value: Optional[str]) -> 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`` 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`` 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`` 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`` configuration option value.""" + return self.config.get("force_exclude") diff --git a/src/darker/formatters/formatter_config.py b/src/darker/formatters/formatter_config.py index d520ec5ba..01ae2bd69 100644 --- a/src/darker/formatters/formatter_config.py +++ b/src/darker/formatters/formatter_config.py @@ -4,6 +4,10 @@ from typing import Set, TypedDict, Union +class FormatterConfig(TypedDict): + """Base class for code re-formatter configuration.""" + + class BlackConfig(TypedDict, total=False): """Type definition for Black configuration dictionaries""" diff --git a/src/darker/formatters/none_formatter.py b/src/darker/formatters/none_formatter.py new file mode 100644 index 000000000..492d1d56f --- /dev/null +++ b/src/darker/formatters/none_formatter.py @@ -0,0 +1,58 @@ +"""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 darker.formatters.formatter_config import FormatterConfig + from darkgraylib.utils import TextDocument + + +class NoneFormatter(BaseFormatter): + """A dummy code formatter plugin interface.""" + + def __init__(self) -> None: + """Initialize the dummy code re-formatter plugin.""" + self.config: FormatterConfig = {} + + 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 ff523f636..631bb52d6 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 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 None, - "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 None, + "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())