diff --git a/README.rst b/README.rst index 7931d98c1..9e6a81e50 100644 --- a/README.rst +++ b/README.rst @@ -167,6 +167,9 @@ You can enable additional features with command line options: - ``-f`` / ``--flynt``: Also convert string formatting to use f-strings using the ``flynt`` package +If you only want to run those tools without reformatting with Black, +use the ``--formatter=none`` option. + *New in version 1.1.0:* The ``-L`` / ``--lint`` option. *New in version 1.2.2:* Package available in conda-forge_. @@ -176,6 +179,8 @@ You can enable additional features with command line options: *New in version 3.0.0:* Removed the ``-L`` / ``--lint`` functionality and moved it into the Graylint_ package. +*New in version 3.0.0:* The ``--formatter`` option. + .. _Conda: https://conda.io/ .. _conda-forge: https://conda-forge.org/ @@ -373,7 +378,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 - [black\|none] Formatter to use for reformatting code. [default: black] + [black\|none\|ruff] 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/pyproject.toml b/pyproject.toml index 578824a8a..695aeca7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,6 @@ target-version = "py38" select = ["ALL"] ignore = [ "A002", # builtin-argument-shadowing - "ANN101", # Missing type annotation for `self` in method "COM812", # Trailing comma missing "D203", # One blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line diff --git a/setup.cfg b/setup.cfg index 8b9989682..f30182804 100644 --- a/setup.cfg +++ b/setup.cfg @@ -46,6 +46,7 @@ darker = [options.entry_points] darker.formatter = black = darker.formatters.black_formatter:BlackFormatter + ruff = darker.formatters.ruff_formatter:RuffFormatter none = darker.formatters.none_formatter:NoneFormatter console_scripts = darker = darker.__main__:main_with_error_handling @@ -96,8 +97,14 @@ ignore = D400 # D415 First line should end with a period, question mark, or exclamation point D415 + # E203 Whitespace before ':' + E203 # E231 missing whitespace after ',' E231 + # E501 Line too long (82 > 79 characters) + E501 + # E701 Multiple statements on one line (colon) + E701 # W503 line break before binary operator W503 # Darglint options when run as a Flake8 plugin: diff --git a/src/darker/__main__.py b/src/darker/__main__.py index d9d23a2da..9b471cc55 100644 --- a/src/darker/__main__.py +++ b/src/darker/__main__.py @@ -293,7 +293,7 @@ def _maybe_reformat_single_file( if glob_any(relpath_in_rev2, exclude): # File was excluded by Black configuration, don't reformat return fstringified - return formatter.run(fstringified) + return formatter.run(fstringified, relpath_in_rev2) def _drop_changes_on_unedited_lines( diff --git a/src/darker/formatters/base_formatter.py b/src/darker/formatters/base_formatter.py index 2bbe32b98..15e10d833 100644 --- a/src/darker/formatters/base_formatter.py +++ b/src/darker/formatters/base_formatter.py @@ -2,39 +2,53 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Pattern +from typing import TYPE_CHECKING, Generic, Pattern, TypeVar + +from darker.files import find_pyproject_toml +from darker.formatters.formatter_config import FormatterConfig if TYPE_CHECKING: from argparse import Namespace + from pathlib import Path - from darker.formatters.formatter_config import FormatterConfig from darkgraylib.utils import TextDocument -class BaseFormatter: +T = TypeVar("T", bound=FormatterConfig) + + +class HasConfig(Generic[T]): # pylint: disable=too-few-public-methods """Base class for code re-formatters.""" def __init__(self) -> None: """Initialize the code re-formatter plugin base class.""" - self.config: FormatterConfig = {} + self.config = {} # type: ignore[var-annotated] + + +class BaseFormatter(HasConfig[FormatterConfig]): + """Base class for code re-formatters.""" 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. + """Read code re-formatter configuration from a configuration file. :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 run(self, content: TextDocument) -> TextDocument: + def run(self, content: TextDocument, path_from_cwd: Path) -> TextDocument: """Reformat the content.""" raise NotImplementedError + def _read_cli_args(self, args: Namespace) -> None: + pass + def get_config_path(self) -> str | None: """Get the path of the configuration file.""" return None @@ -60,3 +74,6 @@ def __eq__(self, other: object) -> bool: if not isinstance(other, BaseFormatter): return NotImplemented return type(self) is type(other) and self.config == other.config + + def _read_config_file(self, config_path: str) -> None: + pass diff --git a/src/darker/formatters/black_formatter.py b/src/darker/formatters/black_formatter.py index 94fc444b0..fe89b3465 100644 --- a/src/darker/formatters/black_formatter.py +++ b/src/darker/formatters/black_formatter.py @@ -13,11 +13,11 @@ ... ] ... ) -First, :func:`run_black` uses Black to reformat the contents of a given file. +First, `BlackFormatter.run` 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 = BlackFormatter().run(src_content, src) >>> dst.lines ('for i in range(5):', ' print(i)', 'print("done")') @@ -40,19 +40,23 @@ from typing import TYPE_CHECKING, TypedDict from darker.files import find_pyproject_toml -from darker.formatters.base_formatter import BaseFormatter +from darker.formatters.base_formatter import BaseFormatter, HasConfig +from darker.formatters.formatter_config import ( + BlackCompatibleConfig, + read_black_compatible_cli_args, + validate_target_versions, +) from darkgraylib.config import ConfigurationError from darkgraylib.utils import TextDocument if TYPE_CHECKING: from argparse import Namespace + from pathlib import Path from typing import Pattern from black import FileMode as Mode from black import TargetVersion - from darker.formatters.formatter_config import BlackConfig - logger = logging.getLogger(__name__) @@ -68,14 +72,13 @@ class BlackModeAttributes(TypedDict, total=False): preview: bool -class BlackFormatter(BaseFormatter): +class BlackFormatter(BaseFormatter, HasConfig[BlackCompatibleConfig]): """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 = {} + config: BlackCompatibleConfig # type: ignore[assignment] name = "black" + config_section = "tool.black" def read_config(self, src: tuple[str, ...], args: Namespace) -> None: """Read Black configuration from ``pyproject.toml``. @@ -114,10 +117,15 @@ def _read_config_file(self, config_path: str) -> None: # noqa: C901 if "target_version" in raw_config: target_version = raw_config["target_version"] if isinstance(target_version, str): - self.config["target_version"] = target_version + self.config["target_version"] = ( + int(target_version[2]), + int(target_version[3:]), + ) elif isinstance(target_version, list): - # Convert TOML list to a Python set - self.config["target_version"] = set(target_version) + # Convert TOML list to a Python set of int-tuples + self.config["target_version"] = { + (int(v[2]), int(v[3:])) for v in target_version + } else: message = ( f"Invalid target-version = {target_version!r} in {config_path}" @@ -135,23 +143,16 @@ def _read_config_file(self, config_path: str) -> None: # noqa: C901 ) 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: + return read_black_compatible_cli_args(args, self.config) + + def run( + self, content: TextDocument, path_from_cwd: Path # noqa: ARG002 + ) -> TextDocument: """Run the Black code re-formatter for the Python source code given as a string. :param content: The source code + :param path_from_cwd: The path to the source code file being reformatted, either + absolute or relative to the current working directory :return: The reformatted content """ @@ -191,15 +192,13 @@ def _make_black_options(self) -> Mode: 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) + all_target_versions = { + (int(tgt_v.name[2]), int(tgt_v.name[3:])): tgt_v + for tgt_v in TargetVersion + } + target_versions_in = validate_target_versions( + self.config["target_version"], all_target_versions + ) mode["target_versions"] = { all_target_versions[n] for n in target_versions_in } diff --git a/src/darker/formatters/formatter_config.py b/src/darker/formatters/formatter_config.py index 22ce27c09..5bea1f300 100644 --- a/src/darker/formatters/formatter_config.py +++ b/src/darker/formatters/formatter_config.py @@ -2,22 +2,63 @@ from __future__ import annotations -from typing import Pattern, TypedDict +from typing import TYPE_CHECKING, Iterable, Pattern, TypedDict + +from darkgraylib.config import ConfigurationError + +if TYPE_CHECKING: + from argparse import Namespace class FormatterConfig(TypedDict): """Base class for code re-formatter configuration.""" -class BlackConfig(FormatterConfig, total=False): - """Type definition for Black configuration dictionaries.""" +def validate_target_versions( + value: tuple[int, int] | set[tuple[int, int]], + valid_target_versions: Iterable[tuple[int, int]], +) -> set[tuple[int, int]]: + """Validate the target-version configuration option value.""" + target_versions_in = value if isinstance(value, set) else {value} + if not isinstance(value, (tuple, set)): + message = f"Invalid target version(s) {value!r}" # type: ignore[unreachable] + raise ConfigurationError(message) + bad_target_versions = target_versions_in - set(valid_target_versions) + if bad_target_versions: + message = f"Invalid target version(s) {bad_target_versions}" + raise ConfigurationError(message) + return target_versions_in + + +class BlackCompatibleConfig(FormatterConfig, total=False): + """Type definition for configuration dictionaries of Black compatible formatters.""" config: str exclude: Pattern[str] extend_exclude: Pattern[str] | None force_exclude: Pattern[str] | None - target_version: str | set[str] + target_version: tuple[int, int] | set[tuple[int, int]] line_length: int skip_string_normalization: bool skip_magic_trailing_comma: bool preview: bool + + +def read_black_compatible_cli_args( + args: Namespace, config: BlackCompatibleConfig +) -> None: + """Read Black-compatible configuration from command line arguments.""" + if args.config: + config["config"] = args.config + if getattr(args, "line_length", None): + config["line_length"] = args.line_length + if getattr(args, "target_version", None): + config["target_version"] = { + (int(args.target_version[2]), int(args.target_version[3:])) + } + if getattr(args, "skip_string_normalization", None) is not None: + config["skip_string_normalization"] = args.skip_string_normalization + if getattr(args, "skip_magic_trailing_comma", None) is not None: + config["skip_magic_trailing_comma"] = args.skip_magic_trailing_comma + if getattr(args, "preview", None): + config["preview"] = args.preview diff --git a/src/darker/formatters/none_formatter.py b/src/darker/formatters/none_formatter.py index 650acd492..549fdde59 100644 --- a/src/darker/formatters/none_formatter.py +++ b/src/darker/formatters/none_formatter.py @@ -8,6 +8,7 @@ if TYPE_CHECKING: from argparse import Namespace + from pathlib import Path from darkgraylib.utils import TextDocument @@ -17,10 +18,14 @@ class NoneFormatter(BaseFormatter): name = "dummy reformat" - def run(self, content: TextDocument) -> TextDocument: + def run( + self, content: TextDocument, path_from_cwd: Path # noqa: ARG002 + ) -> TextDocument: """Return the Python source code unmodified. :param content: The source code + :param path_from_cwd: The path to the source code file being reformatted, either + absolute or relative to the current working directory :return: The source code unmodified """ diff --git a/src/darker/formatters/ruff_formatter.py b/src/darker/formatters/ruff_formatter.py new file mode 100644 index 000000000..667079d5f --- /dev/null +++ b/src/darker/formatters/ruff_formatter.py @@ -0,0 +1,241 @@ +"""Re-format Python source code using Ruff. + +In examples below, a simple two-line snippet is used. +The first line will be reformatted by Ruff, 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, `RuffFormatter.run` uses Ruff to reformat the contents of a given file. +Reformatted lines are returned e.g.:: + + >>> from darker.formatters.ruff_formatter import RuffFormatter + >>> dst = RuffFormatter().run(src_content, src) + >>> 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 +import sys +from pathlib import Path +from subprocess import PIPE, SubprocessError, run # nosec +from typing import TYPE_CHECKING, Collection + +from darker.formatters.base_formatter import BaseFormatter, HasConfig +from darker.formatters.formatter_config import ( + BlackCompatibleConfig, + read_black_compatible_cli_args, + validate_target_versions, +) +from darkgraylib.config import ConfigurationError +from darkgraylib.utils import TextDocument + +if sys.version_info >= (3, 11): + # On Python 3.11+, we can use the `tomllib` module from the standard library. + try: + import tomllib + except ImportError: + # Help users on older Python 3.11 alphas + import tomli as tomllib # type: ignore[no-redef,import-not-found] +else: + # On older Pythons, we must use the backport. + import tomli as tomllib + +if TYPE_CHECKING: + from argparse import Namespace + from typing import Pattern + +logger = logging.getLogger(__name__) + + +class RuffFormatter(BaseFormatter, HasConfig[BlackCompatibleConfig]): + """Ruff code formatter plugin interface.""" + + config: BlackCompatibleConfig # type: ignore[assignment] + + name = "ruff format" + config_section = "tool.ruff" + + def run(self, content: TextDocument, path_from_cwd: Path) -> TextDocument: + """Run the Ruff code re-formatter for the Python source code given as a string. + + :param content: The source code + :param path_from_cwd: The path to the file being reformatted, either absolute or + relative to the current working directory + :return: The reformatted content + + """ + # Collect relevant Ruff configuration options from ``self.config`` in order to + # pass them to Ruff's ``format_str()``. File exclusion options aren't needed + # since at this point we already have a single file's content to work on. + # Ignore ISC001 (single-line-implicit-string-concatenation) since it conflicts + # with Black's string formatting + args = ['--config=lint.ignore=["ISC001"]'] + if "line_length" in self.config: + args.append(f"--line-length={self.config['line_length']}") + if "target_version" in self.config: + supported_target_versions = _get_supported_target_versions() + target_versions_in = validate_target_versions( + self.config["target_version"], supported_target_versions + ) + target_version_str = supported_target_versions[min(target_versions_in)] + args.append(f"--target-version={target_version_str}") + if self.config.get("skip_magic_trailing_comma", False): + args.append('--config="format.skip-magic-trailing-comma=true"') + args.append('--config="lint.isort.split-on-trailing-comma=false"') + if self.config.get("skip_string_normalization", False): + args.append('''--config=format.quote-style="preserve"''') + if self.config.get("preview", False): + args.append("--preview") + + # The custom handling of empty and all-whitespace files below will be + # unnecessary if https://github.com/psf/ruff/pull/2484 lands in Ruff. + contents_for_ruff = content.string_with_newline("\n") + dst_contents = _ruff_format_stdin(contents_for_ruff, path_from_cwd, args) + return TextDocument.from_str( + dst_contents, + encoding=content.encoding, + override_newline=content.newline, + ) + + def _read_config_file(self, config_path: str) -> None: + """Read Ruff configuration from a configuration file. + + :param config_path: Path to the configuration file + :raises ConfigurationError: If the configuration file cannot be read or parsed + + """ + try: + with Path(config_path).open(mode="rb") as config_file: + raw_config = tomllib.load(config_file).get("tool", {}).get("ruff", {}) + if "line-length" in raw_config: + self.config["line_length"] = int(raw_config["line-length"]) + except (OSError, ValueError, tomllib.TOMLDecodeError) as exc: + message = f"Failed to read Ruff config: {exc}" + raise ConfigurationError(message) from exc + + def _read_cli_args(self, args: Namespace) -> None: + return read_black_compatible_cli_args(args, self.config) + + def get_config_path(self) -> str | None: + """Get the path of the configuration file.""" + return self.config.get("config") + + # pylint: disable=duplicate-code + def get_line_length(self) -> int | None: + """Get the ``line-length`` Ruff configuration option value.""" + return self.config.get("line_length") + + # pylint: disable=duplicate-code + def get_exclude(self, default: Pattern[str]) -> Pattern[str]: + """Get the ``exclude`` Ruff configuration option value.""" + return self.config.get("exclude", default) + + # pylint: disable=duplicate-code + def get_extend_exclude(self) -> Pattern[str] | None: + """Get the ``extend_exclude`` Ruff configuration option value.""" + return self.config.get("extend_exclude") + + # pylint: disable=duplicate-code + def get_force_exclude(self) -> Pattern[str] | None: + """Get the ``force_exclude`` Ruff configuration option value.""" + return self.config.get("force_exclude") + + +TYPE_PREFIX = 'Type: "' +VER_PREFIX = "py" + + +def _get_supported_target_versions() -> dict[tuple[int, int], str]: + """Get the supported target versions for Ruff. + + Calls ``ruff config target-version`` as a subprocess, looks for the line looking + like ``Type: "py38" | "py39" | "py310"``, and returns the target versions as a dict + of int-tuples mapped to version strings. + + :returns: A dictionary mapping Python version tuples to their string + representations. For example: ``{(3, 8): "py38", (3, 9): "py39"}`` + :raises ConfigurationError: If target versions cannot be determined from Ruff output + """ + try: + cmdline = "ruff config target-version" + output = run( # noqa: S603 # nosec + cmdline.split(), stdout=PIPE, check=True, text=True + ).stdout.splitlines() + # Find a line like: Type: "py37" | "py38" | "py39" | "py310" | "py311" | "py312" + type_lines = [ + line + for line in output + if line.startswith(TYPE_PREFIX + VER_PREFIX) and line.endswith('"') + ] + if not type_lines: + message = ( + f"`{cmdline}` returned no target versions on a" + f" '{TYPE_PREFIX}{VER_PREFIX}...' line" + ) + raise ConfigurationError(message) + # Drop 'Type:' prefix and the initial and final double quotes + delimited_versions = type_lines[0][len(TYPE_PREFIX) : -len('"')] + # Now we have: py37" | "py38" | "py39" | "py310" | "py311" | "py312 + # Split it by '" | "' (turn strs to lists since Mypy disallows str unpacking) + py_versions = [ + list(py_version) for py_version in delimited_versions.split('" | "') + ] + # Now we have: [("p", "y", "3", "7"), ("p", "y", "3", "8"), ...] + # Turn it into {(3, 7): "py37", (3, 8): "py38", (3, 9): "py39", ...} + return { + (int(major), int("".join(minor))): f"{VER_PREFIX}{major}{''.join(minor)}" + for _p, _y, major, *minor in py_versions + } + + except (OSError, ValueError, SubprocessError) as exc: + message = f"Failed to get Ruff target versions: {exc}" + raise ConfigurationError(message) from exc + + +def _ruff_format_stdin( + contents: str, path_from_cwd: Path, args: Collection[str] +) -> str: + """Run the contents through ``ruff format``. + + :param contents: The source code to be reformatted + :param path_from_cwd: The path to the file being reformatted, either absolute or + relative to the current working directory + :param args: Additional command line arguments to pass to Ruff + :return: The reformatted source code + + """ + cmdline = [ + "ruff", + "format", + "--force-exclude", # apply `exclude =` from conffile even with stdin + f"--stdin-filename={path_from_cwd}", # allow to match exclude patterns + *args, + "-", + ] + logger.debug("Running %s", " ".join(cmdline)) + result = run( # noqa: S603 # nosec + cmdline, input=contents, stdout=PIPE, check=True, text=True, encoding="utf-8" + ) + return result.stdout diff --git a/src/darker/tests/test_command_line.py b/src/darker/tests/test_command_line.py index 9609516ac..24cc30072 100644 --- a/src/darker/tests/test_command_line.py +++ b/src/darker/tests/test_command_line.py @@ -821,7 +821,7 @@ def options_repo(request, tmp_path_factory): {Path("a.py")}, Exclusions(isort={"**/*"}, flynt={"**/*"}), RevisionRange("HEAD", ":WORKTREE:"), - {"target_version": {"py39"}}, + {"target_version": {(3, 9)}}, ), ), dict( diff --git a/src/darker/tests/test_command_line_ruff.py b/src/darker/tests/test_command_line_ruff.py new file mode 100644 index 000000000..68c9ada7d --- /dev/null +++ b/src/darker/tests/test_command_line_ruff.py @@ -0,0 +1,150 @@ +"""Unit tests for Ruff related parts of `darker.command_line`.""" + +# pylint: disable=no-member,redefined-outer-name,unused-argument,use-dict-literal + +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent +from unittest.mock import Mock, patch + +import pytest + +from darker.__main__ import main +from darker.formatters import ruff_formatter +from darkgraylib.testtools.git_repo_plugin import GitRepoFixture +from darkgraylib.utils import joinlines + + +@pytest.fixture(scope="module") +def ruff_options_files(request, tmp_path_factory): + """Fixture for the `ruff_black_options` test.""" + with GitRepoFixture.context(request, tmp_path_factory) as repo: + (repo.root / "pyproject.toml").write_bytes(b"[tool.ruff]\n") + (repo.root / "ruff.cfg").write_text( + dedent( + """ + [tool.ruff] + line-length = 81 + skip-string-normalization = false + target-version = 'py38' + """ + ) + ) + yield repo.add({"main.py": 'print("Hello World!")\n'}, commit="Initial commit") + + +@pytest.mark.kwparametrize( + dict(options=[]), + dict(options=["-c", "ruff.cfg"], expect_opts=["--line-length=81"]), + dict(options=["--config", "ruff.cfg"], expect_opts=["--line-length=81"]), + dict( + options=["-S"], + expect_opts=['--config=format.quote-style="preserve"'], + ), + dict( + options=["--skip-string-normalization"], + expect_opts=['--config=format.quote-style="preserve"'], + ), + dict(options=["-l", "90"], expect_opts=["--line-length=90"]), + dict(options=["--line-length", "90"], expect_opts=["--line-length=90"]), + dict( + options=["-c", "ruff.cfg", "-S"], + expect_opts=["--line-length=81", '--config=format.quote-style="preserve"'], + ), + dict( + options=["-c", "ruff.cfg", "-l", "90"], + expect_opts=["--line-length=90"], + ), + dict( + options=["-l", "90", "-S"], + expect_opts=["--line-length=90", '--config=format.quote-style="preserve"'], + ), + dict( + options=["-c", "ruff.cfg", "-l", "90", "-S"], + expect_opts=["--line-length=90", '--config=format.quote-style="preserve"'], + ), + dict(options=["-t", "py39"], expect_opts=["--target-version=py39"]), + dict(options=["--target-version", "py39"], expect_opts=["--target-version=py39"]), + dict( + options=["-c", "ruff.cfg", "-t", "py39"], + expect_opts=["--line-length=81", "--target-version=py39"], + ), + dict( + options=["-t", "py39", "-S"], + expect_opts=[ + "--target-version=py39", + '--config=format.quote-style="preserve"', + ], + ), + dict( + options=["-c", "ruff.cfg", "-t", "py39", "-S"], + expect_opts=[ + "--line-length=81", + "--target-version=py39", + '--config=format.quote-style="preserve"', + ], + ), + dict(options=["--preview"], expect_opts=["--preview"]), + expect_opts=[], +) +def test_ruff_options(monkeypatch, ruff_options_files, options, expect_opts): + """Ruff options from the command line are passed correctly to Ruff.""" + ruff_options_files["main.py"].write_bytes(b'print ("Hello World!")\n') + with patch.object(ruff_formatter, "_ruff_format_stdin") as format_stdin: + format_stdin.return_value = 'print("Hello World!")\n' + + main([*options, "--formatter=ruff", str(ruff_options_files["main.py"])]) + + format_stdin.assert_called_once_with( + 'print ("Hello World!")\n', + Path("main.py"), + ['--config=lint.ignore=["ISC001"]', *expect_opts], + ) + + +@pytest.mark.kwparametrize( + dict(config=[], options=[], expect=[]), + dict(options=["--line-length=50"], expect=["--line-length=50"]), + dict(config=["line-length = 60"], expect=["--line-length=60"]), + dict( + config=["line-length = 60"], + options=["--line-length=50"], + expect=["--line-length=50"], + ), + dict( + options=["--skip-string-normalization"], + expect=['--config=format.quote-style="preserve"'], + ), + dict(options=["--no-skip-string-normalization"], expect=[]), + dict( + options=["--skip-magic-trailing-comma"], + expect=[ + '--config="format.skip-magic-trailing-comma=true"', + '--config="lint.isort.split-on-trailing-comma=false"', + ], + ), + dict(options=["--target-version", "py39"], expect=["--target-version=py39"]), + dict(options=["--preview"], expect=["--preview"]), + config=[], + options=[], +) +def test_ruff_config_file_and_options(git_repo, config, options, expect): + """Ruff configuration file and command line options are combined correctly.""" + # Only line length is both supported as a command line option and read by Darker + # from Ruff configuration. + added_files = git_repo.add( + {"main.py": "foo", "pyproject.toml": joinlines(["[tool.ruff]", *config])}, + commit="Initial commit", + ) + added_files["main.py"].write_bytes(b"a = [1, 2,]") + # Speed up tests by mocking `_ruff_format_stdin` to skip running Ruff + format_stdin = Mock(return_value="a = [1, 2,]") + with patch.object(ruff_formatter, "_ruff_format_stdin", format_stdin): + # end of test setup, now run the test: + + main([*options, "--formatter=ruff", str(added_files["main.py"])]) + + format_stdin.assert_called_once_with( + "a = [1, 2,]", Path("main.py"), ['--config=lint.ignore=["ISC001"]', *expect] + ) diff --git a/src/darker/tests/test_formatters_black.py b/src/darker/tests/test_formatters_black.py index 94332a03f..9f2a1273f 100644 --- a/src/darker/tests/test_formatters_black.py +++ b/src/darker/tests/test_formatters_black.py @@ -9,7 +9,7 @@ from importlib import reload from pathlib import Path from typing import TYPE_CHECKING -from unittest.mock import ANY, patch +from unittest.mock import patch import pytest import regex @@ -36,7 +36,7 @@ import tomli as tomllib if TYPE_CHECKING: - from darker.formatters.formatter_config import BlackConfig + from darker.formatters.formatter_config import BlackCompatibleConfig @dataclass @@ -92,15 +92,8 @@ def test_formatter_without_black(caplog): ] +@pytest.mark.parametrize("option_name_delimiter", ["-", "_"]) @pytest.mark.kwparametrize( - dict( - config_path=None, config_lines=["line-length = 79"], expect={"line_length": 79} - ), - dict( - config_path="custom.toml", - config_lines=["line-length = 99"], - expect={"line_length": 99}, - ), dict( config_lines=["skip-string-normalization = true"], expect={"skip_string_normalization": True}, @@ -119,23 +112,23 @@ def test_formatter_without_black(caplog): ), dict(config_lines=["target-version ="], expect=tomllib.TOMLDecodeError()), dict(config_lines=["target-version = false"], expect=ConfigurationError()), - dict(config_lines=["target-version = 'py37'"], expect={"target_version": "py37"}), + dict(config_lines=["target-version = 'py37'"], expect={"target_version": (3, 7)}), dict( - config_lines=["target-version = ['py37']"], expect={"target_version": {"py37"}} + config_lines=["target-version = ['py37']"], + expect={"target_version": {(3, 7)}}, ), dict( config_lines=["target-version = ['py39']"], - expect={"target_version": {"py39"}}, + expect={"target_version": {(3, 9)}}, ), dict( config_lines=["target-version = ['py37', 'py39']"], - expect={"target_version": {"py37", "py39"}}, + expect={"target_version": {(3, 7), (3, 9)}}, ), dict( config_lines=["target-version = ['py39', 'py37']"], - expect={"target_version": {"py39", "py37"}}, + expect={"target_version": {(3, 9), (3, 7)}}, ), - dict(config_lines=[r"include = '\.pyi$'"], expect={}), dict( config_lines=[r"exclude = '\.pyx$'"], expect={"exclude": RegexEquality("\\.pyx$")}, @@ -154,12 +147,16 @@ def test_formatter_without_black(caplog): ), config_path=None, ) -def test_read_config(tmpdir, config_path, config_lines, expect): - """`BlackFormatter.read_config` reads Black config correctly from a TOML file.""" +def test_read_config(tmpdir, option_name_delimiter, config_path, config_lines, expect): + """``read_config()`` reads Black config correctly from a TOML file.""" + # Test both hyphen and underscore delimited option names + config = "\n".join( + line.replace("-", option_name_delimiter) for line in config_lines + ) 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))) + toml.write_text(f"[tool.black]\n{config}\n") with raises_or_matches(expect, []): formatter = BlackFormatter() args = Namespace() @@ -230,7 +227,7 @@ 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 = { + black_config: BlackCompatibleConfig = { "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, @@ -254,38 +251,6 @@ def test_filter_python_files( # pylint: disable=too-many-arguments assert result == expect_paths -@pytest.mark.parametrize("encoding", ["utf-8", "iso-8859-1"]) -@pytest.mark.parametrize("newline", ["\n", "\r\n"]) -def test_run(encoding, newline): - """Running Black through its Python internal API gives correct results""" - src = TextDocument.from_lines( - [f"# coding: {encoding}", "print ( 'touché' )"], - encoding=encoding, - newline=newline, - ) - - result = BlackFormatter().run(src) - - assert result.lines == ( - f"# coding: {encoding}", - 'print("touché")', - ) - assert result.encoding == encoding - assert result.newline == newline - - -@pytest.mark.parametrize("newline", ["\n", "\r\n"]) -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("darker.formatters.black_wrapper.format_str") as format_str: - format_str.return_value = 'print("touché")\n' - - _ = BlackFormatter().run(src) - - format_str.assert_called_once_with("print ( 'touché' )\n", mode=ANY) - - def test_run_ignores_excludes(): """Black's exclude configuration is ignored by `BlackFormatter.run`.""" src = TextDocument.from_str("a=1\n") @@ -296,57 +261,35 @@ def test_run_ignores_excludes(): "force_exclude": regex.compile(r".*"), } - result = formatter.run(src) + result = formatter.run(src, Path("a.py")) assert result.string == "a = 1\n" -@pytest.mark.parametrize( - "src_content, expect", - [ - ("", ""), - ("\n", "\n"), - ("\r\n", "\r\n"), - (" ", ""), - ("\t", ""), - (" \t", ""), - (" \t\n", "\n"), - (" \t\r\n", "\r\n"), - ], -) -def test_run_all_whitespace_input(src_content, expect): - """All-whitespace files are reformatted correctly""" - src = TextDocument.from_str(src_content) - - result = BlackFormatter().run(src) - - assert result.string == expect - - @pytest.mark.kwparametrize( dict(black_config={}), dict( - black_config={"target_version": "py37"}, + black_config={"target_version": (3, 7)}, expect_target_versions={TargetVersion.PY37}, ), dict( - black_config={"target_version": "py39"}, + black_config={"target_version": (3, 9)}, expect_target_versions={TargetVersion.PY39}, ), dict( - black_config={"target_version": {"py37"}}, + black_config={"target_version": {(3, 7)}}, expect_target_versions={TargetVersion.PY37}, ), dict( - black_config={"target_version": {"py39"}}, + black_config={"target_version": {(3, 9)}}, expect_target_versions={TargetVersion.PY39}, ), dict( - black_config={"target_version": {"py37", "py39"}}, + black_config={"target_version": {(3, 7), (3, 9)}}, expect_target_versions={TargetVersion.PY37, TargetVersion.PY39}, ), dict( - black_config={"target_version": {"py39", "py37"}}, + black_config={"target_version": {(3, 9), (3, 7)}}, expect_target_versions={TargetVersion.PY37, TargetVersion.PY39}, ), dict( @@ -397,7 +340,7 @@ def test_run_configuration( formatter = BlackFormatter() formatter.config = black_config - check(formatter.run(src)) + check(formatter.run(src, Path("a.py"))) assert format_str.call_count == 1 mode = format_str.call_args[1]["mode"] diff --git a/src/darker/tests/test_formatters_black_compatible.py b/src/darker/tests/test_formatters_black_compatible.py new file mode 100644 index 000000000..f0afd1b96 --- /dev/null +++ b/src/darker/tests/test_formatters_black_compatible.py @@ -0,0 +1,144 @@ +"""Unit tests for Black compatible formatter plugins.""" + +# pylint: disable=use-dict-literal + +from argparse import Namespace +from pathlib import Path +from unittest.mock import patch + +import pytest + +from darker.formatters import ruff_formatter +from darker.formatters.black_formatter import BlackFormatter +from darker.formatters.ruff_formatter import RuffFormatter +from darkgraylib.testtools.helpers import raises_or_matches +from darkgraylib.utils import TextDocument + + +@pytest.mark.parametrize( + "formatter_setup", + [(BlackFormatter, "-"), (BlackFormatter, "_"), (RuffFormatter, "-")], +) +@pytest.mark.kwparametrize( + dict( + config_path=None, config_lines=["line-length = 79"], expect={"line_length": 79} + ), + dict( + config_path="custom.toml", + config_lines=["line-length = 99"], + expect={"line_length": 99}, + ), + dict(config_lines=[r"include = '\.pyi$'"], expect={}), + config_path=None, +) +def test_read_config_black_and_ruff( + tmpdir, formatter_setup, config_path, config_lines, expect +): + """``read_config()`` reads Black and Ruff config correctly from a TOML file.""" + formatter_class, option_name_delimiter = formatter_setup + # For Black, we test both hyphen and underscore delimited option names + config = "\n".join( # pylint: disable=duplicate-code + line.replace("-", option_name_delimiter) for line in config_lines + ) + tmpdir = Path(tmpdir) + src = tmpdir / "src.py" + toml = tmpdir / (config_path or "pyproject.toml") + section = formatter_class.config_section + toml.write_text(f"[{section}]\n{config}\n") + with raises_or_matches(expect, []): + formatter = formatter_class() + args = Namespace() + args.config = config_path and str(toml) + if config_path: + expect["config"] = str(toml) + + # pylint: disable=duplicate-code + formatter.read_config((str(src),), args) + + assert formatter.config == expect + + +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) +@pytest.mark.parametrize("encoding", ["utf-8", "iso-8859-1"]) +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +def test_run(formatter_class, encoding, newline): + """Running formatter through their plugin ``run`` method gives correct results.""" + src = TextDocument.from_lines( + [f"# coding: {encoding}", "print ( 'touché' )"], + encoding=encoding, + newline=newline, + ) + + result = formatter_class().run(src, Path("a.py")) + + assert result.lines == ( + f"# coding: {encoding}", + 'print("touché")', + ) + assert result.encoding == encoding + assert result.newline == newline + + +@pytest.mark.parametrize( + "formatter_setup", + [ + (BlackFormatter, "darker.formatters.black_wrapper.format_str"), + (RuffFormatter, "darker.formatters.ruff_formatter._ruff_format_stdin"), + ], +) +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +def test_run_always_uses_unix_newlines(formatter_setup, newline): + """Content is always passed to Black and Ruff with Unix newlines.""" + formatter_class, formatter_func_name = formatter_setup + src = TextDocument.from_str(f"print ( 'touché' ){newline}") + with patch(formatter_func_name) as formatter_func: + formatter_func.return_value = 'print("touché")\n' + + _ = formatter_class().run(src, Path("a.py")) + + (formatter_func_call,) = formatter_func.call_args_list + assert formatter_func_call.args[0] == "print ( 'touché' )\n" + + +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) +@pytest.mark.parametrize( + ("src_content", "expect"), + [ + ("", ""), + ("\n", "\n"), + ("\r\n", "\r\n"), + (" ", ""), + ("\t", ""), + (" \t", ""), + (" \t\n", "\n"), + (" \t\r\n", "\r\n"), + ], +) +def test_run_all_whitespace_input(formatter_class, src_content, expect): + """All-whitespace files are reformatted correctly.""" + src = TextDocument.from_str(src_content) + + result = formatter_class().run(src, Path("a.py")) + + assert result.string == expect + + +@pytest.mark.kwparametrize( + dict(formatter_config={}, expect=[]), + dict(formatter_config={"line_length": 80}, expect=["--line-length=80"]), +) +def test_run_configuration(formatter_config, expect): + """`RuffFormatter.run` passes correct configuration to Ruff.""" + src = TextDocument.from_str("import os\n") + with patch.object(ruff_formatter, "_ruff_format_stdin") as format_stdin: + format_stdin.return_value = "import os\n" + formatter = RuffFormatter() + formatter.config = formatter_config + + formatter.run(src, Path("a.py")) + + format_stdin.assert_called_once_with( + "import os\n", + Path("a.py"), + ['--config=lint.ignore=["ISC001"]', *expect], + ) diff --git a/src/darker/tests/test_formatters_ruff.py b/src/darker/tests/test_formatters_ruff.py new file mode 100644 index 000000000..e9169cb1d --- /dev/null +++ b/src/darker/tests/test_formatters_ruff.py @@ -0,0 +1,66 @@ +"""Unit tests for `darker.formatters.ruff_formatter`.""" + +# pylint: disable=redefined-outer-name + +from subprocess import run # nosec +from textwrap import dedent +from unittest.mock import patch + +import pytest + +from darker.formatters import ruff_formatter + + +def test_get_supported_target_versions(): + """`ruff_formatter._get_supported_target_versions` runs Ruff, gets py versions.""" + with patch.object(ruff_formatter, "run") as run_mock: + run_mock.return_value.stdout = dedent( + """ + Default value: "py38" + Type: "py37" | "py38" | "py39" | "py310" | "py311" | "py312" + Example usage: + """ + ) + + # pylint: disable=protected-access + result = ruff_formatter._get_supported_target_versions() # noqa: SLF001 + + assert result == { + (3, 7): "py37", + (3, 8): "py38", + (3, 9): "py39", + (3, 10): "py310", + (3, 11): "py311", + (3, 12): "py312", + } + + +@pytest.fixture +def ruff(): + """Make a Ruff call and return the `subprocess.CompletedProcess` instance.""" + cmdline = [ + "ruff", + "format", + "--force-exclude", # apply `exclude =` from conffile even with stdin + "--stdin-filename=myfile.py", # allow to match exclude patterns + '--config=lint.ignore=["ISC001"]', + "-", + ] + return run( # noqa: S603 # nosec + cmdline, input="print( 1)\n", capture_output=True, check=False, text=True + ) + + +def test_ruff_returncode(ruff): + """A basic Ruff subprocess call returns a zero returncode.""" + assert ruff.returncode == 0 + + +def test_ruff_stderr(ruff): + """A basic Ruff subprocess call prints nothing on standard error.""" + assert ruff.stderr == "" + + +def test_ruff_stdout(ruff): + """A basic Ruff subprocess call prints the reformatted file on standard output.""" + assert ruff.stdout == "print(1)\n" diff --git a/src/darker/tests/test_main.py b/src/darker/tests/test_main.py index d8d1850f5..6ef0b77ba 100644 --- a/src/darker/tests/test_main.py +++ b/src/darker/tests/test_main.py @@ -104,6 +104,9 @@ def main_repo(request, tmp_path_factory): yield fixture +@pytest.mark.parametrize( + "formatter_arguments", [[], ["--formatter=black"], ["--formatter=ruff"]] +) @pytest.mark.kwparametrize( dict(arguments=["--diff"], expect_stdout=A_PY_DIFF_BLACK), dict(arguments=["--isort"], expect_a_py=A_PY_BLACK_ISORT), @@ -150,6 +153,8 @@ def main_repo(request, tmp_path_factory): pyproject_toml=""" [tool.black] exclude = 'a.py' + [tool.ruff.format] + exclude = ['a.py'] """, expect_a_py=A_PY, ), @@ -158,6 +163,8 @@ def main_repo(request, tmp_path_factory): pyproject_toml=""" [tool.black] exclude = 'a.py' + [tool.ruff.format] + exclude = ['a.py'] """, expect_stdout=[], ), @@ -166,6 +173,8 @@ def main_repo(request, tmp_path_factory): pyproject_toml=""" [tool.black] extend_exclude = 'a.py' + [tool.ruff] + extend-exclude = ['a.py'] """, expect_a_py=A_PY, ), @@ -174,6 +183,8 @@ def main_repo(request, tmp_path_factory): pyproject_toml=""" [tool.black] extend_exclude = 'a.py' + [tool.ruff] + extend-exclude = ['a.py'] """, expect_stdout=[], ), @@ -182,6 +193,10 @@ def main_repo(request, tmp_path_factory): pyproject_toml=""" [tool.black] force_exclude = 'a.py' + [tool.ruff.format] + exclude = ['a.py'] + [tool.ruff] + force-exclude = true # redundant, always passed to ruff anyway """, expect_a_py=A_PY, ), @@ -190,6 +205,10 @@ def main_repo(request, tmp_path_factory): pyproject_toml=""" [tool.black] force_exclude = 'a.py' + [tool.ruff.format] + exclude = ['a.py'] + [tool.ruff] + force-exclude = true # redundant, always passed to ruff anyway """, expect_stdout=[], ), @@ -211,6 +230,7 @@ def test_main( main_repo, monkeypatch, capsys, + formatter_arguments, arguments, newline, pyproject_toml, @@ -233,7 +253,9 @@ def test_main( repo.paths["subdir/a.py"].write_bytes(newline.join(A_PY).encode("ascii")) repo.paths["b.py"].write_bytes(f"print(42 ){newline}".encode("ascii")) - retval = darker.__main__.main(arguments + [str(pwd / "subdir")]) + retval = darker.__main__.main( + [*formatter_arguments, *arguments, str(pwd / "subdir")] + ) stdout = capsys.readouterr().out.replace(str(repo.root), "") diff_output = stdout.splitlines(False) @@ -256,7 +278,8 @@ def test_main( assert retval == expect_retval -def test_main_in_plain_directory(tmp_path, capsys): +@pytest.mark.parametrize("formatter", [[], ["--formatter=black"], ["--formatter=ruff"]]) +def test_main_in_plain_directory(tmp_path, capsys, formatter): """Darker works also in a plain directory tree""" subdir_a = tmp_path / "subdir_a" subdir_c = tmp_path / "subdir_b/subdir_c" @@ -267,7 +290,7 @@ def test_main_in_plain_directory(tmp_path, capsys): (subdir_c / "another python file.py").write_text("a =5") retval = darker.__main__.main( - ["--diff", "--check", "--isort", "--lint", "dummy", str(tmp_path)], + [*formatter, "--diff", "--check", "--isort", "--lint", "dummy", str(tmp_path)], ) assert retval == 1 @@ -297,18 +320,19 @@ def test_main_in_plain_directory(tmp_path, capsys): ) +@pytest.mark.parametrize("formatter", [[], ["--formatter=black"], ["--formatter=ruff"]]) @pytest.mark.parametrize( "encoding, text", [(b"utf-8", b"touch\xc3\xa9"), (b"iso-8859-1", b"touch\xe9")] ) @pytest.mark.parametrize("newline", [b"\n", b"\r\n"]) -def test_main_encoding(git_repo, encoding, text, newline): +def test_main_encoding(git_repo, formatter, encoding, text, newline): """Encoding and newline of the file is kept unchanged after reformatting""" paths = git_repo.add({"a.py": newline.decode("ascii")}, commit="Initial commit") edited = [b"# coding: ", encoding, newline, b's="', text, b'"', newline] expect = [b"# coding: ", encoding, newline, b's = "', text, b'"', newline] paths["a.py"].write_bytes(b"".join(edited)) - retval = darker.__main__.main(["a.py"]) + retval = darker.__main__.main([*formatter, "a.py"]) result = paths["a.py"].read_bytes() assert retval == 0 @@ -387,7 +411,8 @@ def test_main_historical_pre_commit(git_repo, monkeypatch): darker.__main__.main(["--revision=:PRE-COMMIT:", "a.py"]) -def test_main_vscode_tmpfile(git_repo, capsys): +@pytest.mark.parametrize("formatter", [[], ["--formatter=black"], ["--formatter=ruff"]]) +def test_main_vscode_tmpfile(git_repo, capsys, formatter): """Main function handles VSCode `.py..tmp` files correctly""" _ = git_repo.add( {"a.py": "print ( 'reformat me' ) \n"}, @@ -395,7 +420,7 @@ def test_main_vscode_tmpfile(git_repo, capsys): ) (git_repo.root / "a.py.hash.tmp").write_text("print ( 'reformat me now' ) \n") - retval = darker.__main__.main(["--diff", "a.py.hash.tmp"]) + retval = darker.__main__.main([*formatter, "--diff", "a.py.hash.tmp"]) assert retval == 0 outerr = capsys.readouterr() diff --git a/src/darker/tests/test_main_format_edited_parts.py b/src/darker/tests/test_main_format_edited_parts.py index 2fa93ab35..9160950f2 100644 --- a/src/darker/tests/test_main_format_edited_parts.py +++ b/src/darker/tests/test_main_format_edited_parts.py @@ -16,6 +16,7 @@ import darker.verification from darker.config import Exclusions from darker.formatters.black_formatter import BlackFormatter +from darker.formatters.ruff_formatter import RuffFormatter from darker.tests.examples import A_PY, A_PY_BLACK, A_PY_BLACK_FLYNT, A_PY_BLACK_ISORT from darker.tests.helpers import unix_and_windows_newline_repos from darker.verification import NotEquivalentError @@ -71,7 +72,7 @@ def format_edited_parts_repo(request, tmp_path_factory): expect=[A_PY_BLACK_ISORT_FLYNT], ), dict( - black_config={"skip_string_normalization": True}, + formatter_config={"skip_string_normalization": True}, black_exclude=set(), expect=[A_PY_BLACK_UNNORMALIZE], ), @@ -84,18 +85,20 @@ def format_edited_parts_repo(request, tmp_path_factory): isort_exclude=set(), expect=[A_PY_ISORT], ), - black_config={}, + formatter_config={}, black_exclude={"**/*"}, isort_exclude={"**/*"}, flynt_exclude={"**/*"}, ) +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) @pytest.mark.parametrize("newline", ["\n", "\r\n"], ids=["unix", "windows"]) def test_format_edited_parts( format_edited_parts_repo, - black_config, + formatter_config, black_exclude, isort_exclude, flynt_exclude, + formatter_class, newline, expect, ): @@ -106,8 +109,8 @@ def test_format_edited_parts( :func:`~darker.__main__.format_edited_parts`. """ - formatter = BlackFormatter() - formatter.config = black_config + formatter = formatter_class() + formatter.config = formatter_config result = darker.__main__.format_edited_parts( Path(format_edited_parts_repo[newline].root), @@ -195,9 +198,10 @@ def format_edited_parts_stdin_repo(request, tmp_path_factory): ], ), ) +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) @pytest.mark.parametrize("newline", ["\n", "\r\n"], ids=["unix", "windows"]) def test_format_edited_parts_stdin( - format_edited_parts_stdin_repo, newline, rev1, rev2, expect + format_edited_parts_stdin_repo, rev1, rev2, expect, formatter_class, newline ): """`format_edited_parts` with ``--stdin-filename``.""" repo = format_edited_parts_stdin_repo[newline] @@ -216,7 +220,7 @@ def test_format_edited_parts_stdin( {Path("a.py")}, Exclusions(formatter=set(), isort=set()), RevisionRange(rev1, rev2), - BlackFormatter(), + formatter_class(), report_unmodified=False, ), ) @@ -232,12 +236,15 @@ def test_format_edited_parts_stdin( assert result == expect -def test_format_edited_parts_all_unchanged(git_repo, monkeypatch): +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) +def test_format_edited_parts_all_unchanged(git_repo, monkeypatch, formatter_class): """``format_edited_parts()`` yields nothing if no reformatting was needed.""" monkeypatch.chdir(git_repo.root) paths = git_repo.add({"a.py": "pass\n", "b.py": "pass\n"}, commit="Initial commit") - paths["a.py"].write_bytes(b'"properly"\n"formatted"\n') - paths["b.py"].write_bytes(b'"not"\n"checked"\n') + # Note: `ruff format` likes to add a blank line between strings, Black not + # - but since black won't remove it either, this works for our test: + paths["a.py"].write_bytes(b'"properly"\n\n"formatted"\n') + paths["b.py"].write_bytes(b'"not"\n\n"checked"\n') result = list( darker.__main__.format_edited_parts( @@ -245,7 +252,7 @@ def test_format_edited_parts_all_unchanged(git_repo, monkeypatch): {Path("a.py"), Path("b.py")}, Exclusions(), RevisionRange("HEAD", ":WORKTREE:"), - BlackFormatter(), + formatter_class(), report_unmodified=False, ), ) @@ -253,7 +260,8 @@ def test_format_edited_parts_all_unchanged(git_repo, monkeypatch): assert result == [] -def test_format_edited_parts_ast_changed(git_repo, caplog): +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) +def test_format_edited_parts_ast_changed(git_repo, caplog, formatter_class): """``darker.__main__.format_edited_parts()`` when reformatting changes the AST.""" caplog.set_level(logging.DEBUG, logger="darker.__main__") paths = git_repo.add({"a.py": "1\n2\n3\n4\n5\n6\n7\n8\n"}, commit="Initial commit") @@ -270,7 +278,7 @@ def test_format_edited_parts_ast_changed(git_repo, caplog): {Path("a.py")}, Exclusions(isort={"**/*"}), RevisionRange("HEAD", ":WORKTREE:"), - BlackFormatter(), + formatter_class(), report_unmodified=False, ), ) @@ -292,7 +300,8 @@ def test_format_edited_parts_ast_changed(git_repo, caplog): ] -def test_format_edited_parts_isort_on_already_formatted(git_repo): +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) +def test_format_edited_parts_isort_on_already_formatted(git_repo, formatter_class): """An already correctly formatted file after ``isort`` is simply skipped.""" before = [ "import a", @@ -314,7 +323,7 @@ def test_format_edited_parts_isort_on_already_formatted(git_repo): {Path("a.py")}, Exclusions(), RevisionRange("HEAD", ":WORKTREE:"), - BlackFormatter(), + formatter_class(), report_unmodified=False, ) @@ -368,8 +377,9 @@ def format_edited_parts_historical_repo(request, tmp_path_factory): dict(rev1="HEAD^", rev2=WORKTREE, expect=[(":WORKTREE:", "reformatted")]), dict(rev1="HEAD", rev2=WORKTREE, expect=[(":WORKTREE:", "reformatted")]), ) +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) def test_format_edited_parts_historical( - format_edited_parts_historical_repo, rev1, rev2, expect + format_edited_parts_historical_repo, rev1, rev2, expect, formatter_class ): """``format_edited_parts()`` is correct for different commit pairs.""" repo = format_edited_parts_historical_repo @@ -379,7 +389,7 @@ def test_format_edited_parts_historical( {Path("a.py")}, Exclusions(), RevisionRange(rev1, rev2), - BlackFormatter(), + formatter_class(), report_unmodified=False, ) diff --git a/src/darker/tests/test_main_reformat_and_flynt_single_file.py b/src/darker/tests/test_main_reformat_and_flynt_single_file.py index 0b1756a77..7e14d6e1a 100644 --- a/src/darker/tests/test_main_reformat_and_flynt_single_file.py +++ b/src/darker/tests/test_main_reformat_and_flynt_single_file.py @@ -10,6 +10,7 @@ from darker.__main__ import _reformat_and_flynt_single_file from darker.config import Exclusions from darker.formatters.black_formatter import BlackFormatter +from darker.formatters.ruff_formatter import RuffFormatter from darker.git import EditedLinenumsDiffer from darkgraylib.git import RevisionRange from darkgraylib.testtools.git_repo_plugin import GitRepoFixture @@ -69,6 +70,7 @@ def reformat_and_flynt_single_file_repo(request, tmp_path_factory): exclusions=Exclusions(), expect="import original\nprint( original )\n", ) +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) def test_reformat_and_flynt_single_file( reformat_and_flynt_single_file_repo, relative_path, @@ -76,6 +78,7 @@ def test_reformat_and_flynt_single_file( rev2_isorted, exclusions, expect, + formatter_class, ): """Test for `_reformat_and_flynt_single_file`.""" repo = reformat_and_flynt_single_file_repo @@ -88,13 +91,14 @@ def test_reformat_and_flynt_single_file( TextDocument(rev2_content), TextDocument(rev2_isorted), has_isort_changes=False, - formatter=BlackFormatter(), + formatter=formatter_class(), ) assert result.string == expect -def test_blacken_and_flynt_single_file_common_ancestor(git_repo): +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) +def test_blacken_and_flynt_single_file_common_ancestor(git_repo, formatter_class): """`_blacken_and_flynt_single_file` diffs to common ancestor of ``rev1...rev2``.""" a_py_initial = dedent( """\ @@ -151,7 +155,7 @@ def test_blacken_and_flynt_single_file_common_ancestor(git_repo): rev2_content=worktree, rev2_isorted=worktree, has_isort_changes=False, - formatter=BlackFormatter(), + formatter=formatter_class(), ) assert result.lines == ( @@ -163,7 +167,8 @@ def test_blacken_and_flynt_single_file_common_ancestor(git_repo): ) -def test_reformat_single_file_docstring(git_repo): +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) +def test_reformat_single_file_docstring(git_repo, formatter_class): """`_blacken_and_flynt_single_file()` handles docstrings as one contiguous block.""" initial = dedent( '''\ @@ -210,7 +215,7 @@ def docstring_func(): rev2_content=TextDocument.from_str(modified), rev2_isorted=TextDocument.from_str(modified), has_isort_changes=False, - formatter=BlackFormatter(), + formatter=formatter_class(), ) assert result.lines == tuple(expect.splitlines()) diff --git a/src/darker/tests/test_main_stdin_filename.py b/src/darker/tests/test_main_stdin_filename.py index 87914037d..aab1d189c 100644 --- a/src/darker/tests/test_main_stdin_filename.py +++ b/src/darker/tests/test_main_stdin_filename.py @@ -2,9 +2,10 @@ # pylint: disable=no-member,redefined-outer-name,too-many-arguments,use-dict-literal +from __future__ import annotations + from io import BytesIO from types import SimpleNamespace -from typing import List, Optional from unittest.mock import Mock, patch import pytest @@ -148,14 +149,16 @@ def main_stdin_filename_repo(request, tmp_path_factory): expect=0, expect_a_py="original\n", ) +@pytest.mark.parametrize("formatter", [[], ["--formatter=black"], ["--formatter=ruff"]]) def test_main_stdin_filename( main_stdin_filename_repo: SimpleNamespace, - config_src: Optional[List[str]], - src: List[str], - stdin_filename: Optional[str], - revision: Optional[str], + config_src: list[str] | None, + src: list[str], + stdin_filename: str | None, + revision: str | None, expect: int, expect_a_py: str, + formatter: list[str], ) -> None: """Tests for `darker.__main__.main` and the ``--stdin-filename`` option""" repo = main_stdin_filename_repo @@ -177,7 +180,7 @@ def test_main_stdin_filename( ), raises_if_exception(expect): # end of test setup - retval = darker.__main__.main(arguments) + retval = darker.__main__.main([*formatter, *arguments]) assert retval == expect assert repo.paths["a.py"].read_text() == expect_a_py