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 ------- 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 edc4a1968..6c69d0f64 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 @@ -10,18 +10,16 @@ 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.files import filter_python_files +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, @@ -60,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]: @@ -82,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 @@ -101,7 +99,7 @@ def format_edited_parts( # pylint: disable=too-many-arguments edited_linenums_differ, exclude, revrange, - black_config, + formatter, ) futures.append(future) @@ -115,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 """ @@ -148,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), @@ -163,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, @@ -178,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 @@ -192,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 @@ -214,14 +214,18 @@ 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", - 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 @@ -270,26 +274,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( @@ -459,7 +463,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 @@ -511,19 +516,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, @@ -570,8 +564,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. @@ -580,7 +574,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): @@ -595,10 +589,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 @@ -607,12 +601,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/black_diff.py b/src/darker/black_diff.py deleted file mode 100644 index a47db055e..000000000 --- a/src/darker/black_diff.py +++ /dev/null @@ -1,228 +0,0 @@ -"""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:: - - >>> from pathlib import Path - >>> from unittest.mock import Mock - >>> src = Path("dummy/file/path.py") - >>> src_content = TextDocument.from_lines( - ... [ - ... "for i in range(5): print(i)", - ... 'print("done")', - ... ] - ... ) - -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={}) - >>> dst.lines - ('for i in range(5):', ' print(i)', 'print("done")') - -See :mod:`darker.diff` and :mod:`darker.chooser` -for how this result is further processed with: - -- :func:`~darker.diff.diff_and_get_opcodes` - to get a diff of the reformatting -- :func:`~darker.diff.opcodes_to_chunks` - to split the diff into chunks of original and reformatted content -- :func:`~darker.chooser.choose_lines` - to reconstruct the source code from original and reformatted chunks - based on whether reformats touch user-edited lines - -""" - -from __future__ import annotations - -import inspect -import logging -from typing import TYPE_CHECKING, Collection, Pattern, TypedDict - -# `FileMode as Mode` required to satisfy mypy==0.782. Strange. -from black import FileMode as Mode -from black import ( - TargetVersion, - format_str, - 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 darkgraylib.config import ConfigurationError -from darkgraylib.utils import TextDocument - -if TYPE_CHECKING: - from pathlib import Path - -__all__ = ["BlackConfig", "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``""" - - target_versions: set[TargetVersion] - line_length: int - string_normalization: bool - is_pyi: bool - magic_trailing_comma: bool - preview: bool - - -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 - - -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 - - :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"] - 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, - ) 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 99086988a..068d12c4b 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.base_formatter import BaseFormatter + -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, + 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 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``. + + """ + 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=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] + ) + ) + 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/__init__.py b/src/darker/formatters/__init__.py new file mode 100644 index 000000000..995af966c --- /dev/null +++ b/src/darker/formatters/__init__.py @@ -0,0 +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..2bbe32b98 --- /dev/null +++ b/src/darker/formatters/base_formatter.py @@ -0,0 +1,62 @@ +"""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 = {} + + name: str + + 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 new file mode 100644 index 000000000..540283b05 --- /dev/null +++ b/src/darker/formatters/black_formatter.py @@ -0,0 +1,222 @@ +"""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:: + + >>> from pathlib import Path + >>> from unittest.mock import Mock + >>> src = Path("dummy/file/path.py") + >>> src_content = TextDocument.from_lines( + ... [ + ... "for i in range(5): print(i)", + ... 'print("done")', + ... ] + ... ) + +First, :func:`run_black` uses Black to reformat the contents of a given file. +Reformatted lines are returned e.g.:: + + >>> from darker.formatters.black_formatter import BlackFormatter + >>> dst = BlackFormatter().run(src_content) + >>> dst.lines + ('for i in range(5):', ' print(i)', 'print("done")') + +See :mod:`darker.diff` and :mod:`darker.chooser` +for how this result is further processed with: + +- :func:`~darker.diff.diff_and_get_opcodes` + to get a diff of the reformatting +- :func:`~darker.diff.opcodes_to_chunks` + to split the diff into chunks of original and reformatted content +- :func:`~darker.chooser.choose_lines` + to reconstruct the source code from original and reformatted chunks + based on whether reformats touch user-edited lines + +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, TypedDict + +from black import FileMode as Mode +from black import ( + TargetVersion, + format_str, + parse_pyproject_toml, + re_compile_maybe_verbose, +) + +from darker.files import find_pyproject_toml +from darker.formatters.base_formatter import BaseFormatter +from darkgraylib.config import ConfigurationError +from darkgraylib.utils import TextDocument + +if TYPE_CHECKING: + from argparse import Namespace + from typing import Pattern + + from darker.formatters.formatter_config import BlackConfig + +__all__ = ["Mode"] + +logger = logging.getLogger(__name__) + + +class BlackModeAttributes(TypedDict, total=False): + """Type definition for items accepted by ``black.Mode``.""" + + target_versions: set[TargetVersion] + line_length: int + string_normalization: bool + is_pyi: bool + magic_trailing_comma: bool + preview: bool + + +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 = {} + + name = "black" + + def read_config(self, src: tuple[str, ...], args: Namespace) -> None: + """Read Black configuration from ``pyproject.toml``. + + :param src: The source code files and directories to be processed by Darker + :param args: Command line arguments + + """ + 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, 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: + 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): + self.config["target_version"] = target_version + elif isinstance(target_version, list): + # Convert TOML list to a Python set + self.config["target_version"] = set(target_version) + else: + 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"]) + 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: + 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, content: TextDocument) -> TextDocument: + """Run the Black code re-formatter for the Python source code given as a string. + + :param content: The source code + :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. + mode = BlackModeAttributes() + 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 = {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: + 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: + # 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 self.config["skip_string_normalization"] + if "preview" in self.config: + mode["preview"] = self.config["preview"] + return Mode(**mode) + + def get_config_path(self) -> str | None: + """Get the path of the Black configuration file.""" + return self.config.get("config") + + def get_line_length(self) -> int | None: + """Get the ``line-length`` Black configuration option value.""" + return self.config.get("line_length") + + def get_exclude(self, default: Pattern[str]) -> Pattern[str]: + """Get the ``exclude`` Black configuration option value.""" + return self.config.get("exclude", default) + + def get_extend_exclude(self) -> Pattern[str] | None: + """Get the ``extend_exclude`` Black configuration option value.""" + return self.config.get("extend_exclude") + + 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/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/formatters/none_formatter.py b/src/darker/formatters/none_formatter.py new file mode 100644 index 000000000..650acd492 --- /dev/null +++ b/src/darker/formatters/none_formatter.py @@ -0,0 +1,55 @@ +"""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.""" + + name = "dummy reformat" + + 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 ac324b1e7..6d108e32d 100644 --- a/src/darker/tests/test_command_line.py +++ b/src/darker/tests/test_command_line.py @@ -14,10 +14,11 @@ 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.formatters.black_formatter import BlackFormatter from darker.tests.helpers import flynt_present, isort_present from darkgraylib.config import ConfigurationError from darkgraylib.git import RevisionRange @@ -500,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( @@ -553,7 +546,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 +661,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()]) @@ -774,7 +769,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_black_diff.py b/src/darker/tests/test_formatters_black.py similarity index 84% rename from src/darker/tests/test_black_diff.py rename to src/darker/tests/test_formatters_black.py index 3cadf9c5f..74ec3d66e 100644 --- a/src/darker/tests/test_black_diff.py +++ b/src/darker/tests/test_formatters_black.py @@ -1,9 +1,10 @@ -"""Unit tests for `darker.black_diff`""" +"""Unit tests for `darker.black_formatter`""" # pylint: disable=too-many-arguments,use-dict-literal 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 @@ -14,13 +15,10 @@ from black import Mode, Report, TargetVersion from pathspec import PathSpec -from darker import black_diff -from darker.black_diff import ( - BlackConfig, - filter_python_files, - read_black_config, - run_black, -) +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 BlackFormatter from darkgraylib.config import ConfigurationError from darkgraylib.testtools.helpers import raises_or_matches from darkgraylib.utils import TextDocument @@ -35,6 +33,9 @@ else: import tomli as tomllib +if TYPE_CHECKING: + from darker.formatters.formatter_config import BlackConfig + @dataclass class RegexEquality: @@ -112,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) - check(read_black_config((str(src),), config_path and str(toml))) + formatter.read_config((str(src),), args) + + assert formatter.config == expect @pytest.mark.kwparametrize( @@ -181,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 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"), @@ -198,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 @@ -320,17 +328,17 @@ 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(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é' )"], @@ -338,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}", @@ -349,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_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()) + _ = 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" @@ -391,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 @@ -457,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, @@ -465,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_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" + 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())