diff --git a/CHANGES.rst b/CHANGES.rst index 8f7bbb1b2..5725e9696 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -19,6 +19,8 @@ Added 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. +- Black_ is no longer installed by default. Use ``pip install 'darker[black]'`` to get + Black support. Removed ------- diff --git a/README.rst b/README.rst index 891e6633b..5698f72ef 100644 --- a/README.rst +++ b/README.rst @@ -133,11 +133,11 @@ How? To install or upgrade, use:: - pip install --upgrade darker~=2.1.1 + pip install --upgrade darker[black]~=2.1.1 Or, if you're using Conda_ for package management:: - conda install -c conda-forge darker~=2.1.1 isort + conda install -c conda-forge darker~=2.1.1 black isort conda update -c conda-forge darker .. @@ -146,6 +146,8 @@ Or, if you're using Conda_ for package management:: specifier for Darker. See `Guarding against Black compatibility breakage`_ for more information. +*New in version 3.0.0:* Black is no longer installed by default. + The ``darker `` or ``darker `` command reads the original file(s), formats them using Black_, @@ -371,7 +373,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, @@ -478,7 +480,7 @@ PyCharm/IntelliJ IDEA 1. Install ``darker``:: - $ pip install darker + $ pip install 'darker[black]' 2. Locate your ``darker`` installation folder. @@ -540,7 +542,7 @@ Visual Studio Code 1. Install ``darker``:: - $ pip install darker + $ pip install 'darker[black]' 2. Locate your ``darker`` installation folder. @@ -683,8 +685,10 @@ other reformatter tools you use to known compatible versions, for example: Using arguments --------------- -You can provide arguments, such as enabling isort, by specifying ``args``. -Note the inclusion of the isort Python package under ``additional_dependencies``: +You can provide arguments, such as disabling Darker or enabling isort, +by specifying ``args``. +Note the absence of Black and the inclusion of the isort Python package +under ``additional_dependencies``: .. code-block:: yaml @@ -692,7 +696,9 @@ Note the inclusion of the isort Python package under ``additional_dependencies`` rev: v2.1.1 hooks: - id: darker - args: [--isort] + args: + - --formatter=none + - --isort additional_dependencies: - isort~=5.9 @@ -779,6 +785,9 @@ The ``lint:`` option. Removed the ``lint:`` option and moved it into the GitHub action of the Graylint_ package. +*New in version 3.0.0:* +Black is now explicitly installed when running the action. + Syntax highlighting =================== diff --git a/action/main.py b/action/main.py index b903bca4d..d4cd0f988 100644 --- a/action/main.py +++ b/action/main.py @@ -22,7 +22,7 @@ run([sys.executable, "-m", "venv", str(ENV_PATH)], check=True) # nosec -req = ["darker[color,isort]"] +req = ["darker[black,color,isort]"] if VERSION: if VERSION.startswith("@"): req[0] = f"git+https://github.com/akaihola/darker{VERSION}#egg={req[0]}" diff --git a/action/tests/test_main.py b/action/tests/test_main.py index beca44654..9a10e684a 100644 --- a/action/tests/test_main.py +++ b/action/tests/test_main.py @@ -91,23 +91,25 @@ def test_creates_virtualenv(tmp_path, main_patch): @pytest.mark.kwparametrize( - dict(run_main_env={}, expect=["darker[color,isort]"]), + dict(run_main_env={}, expect=["darker[black,color,isort]"]), dict( - run_main_env={"INPUT_VERSION": "1.5.0"}, expect=["darker[color,isort]==1.5.0"] + run_main_env={"INPUT_VERSION": "1.5.0"}, + expect=["darker[black,color,isort]==1.5.0"], ), dict( run_main_env={"INPUT_VERSION": "@master"}, expect=[ - "git+https://github.com/akaihola/darker@master#egg=darker[color,isort]" + "git+https://github.com/akaihola/darker" + "@master#egg=darker[black,color,isort]" ], ), dict( run_main_env={"INPUT_LINT": "dummy"}, - expect=["darker[color,isort]"], + expect=["darker[black,color,isort]"], ), dict( run_main_env={"INPUT_LINT": "dummy,foobar"}, - expect=["darker[color,isort]"], + expect=["darker[black,color,isort]"], ), ) def test_installs_packages(tmp_path, main_patch, run_main_env, expect): @@ -208,7 +210,7 @@ def test_error_if_pip_fails(tmp_path, capsys): run_module("main") assert main_patch.subprocess.run.call_args_list[-1] == call( - [ANY, "-m", "pip", "install", "darker[color,isort]"], + [ANY, "-m", "pip", "install", "darker[black,color,isort]"], check=False, stdout=PIPE, stderr=STDOUT, @@ -216,7 +218,7 @@ def test_error_if_pip_fails(tmp_path, capsys): ) assert ( capsys.readouterr().out.splitlines()[-1] - == "::error::Failed to install darker[color,isort]." + == "::error::Failed to install darker[black,color,isort]." ) main_patch.sys.exit.assert_called_once_with(42) diff --git a/constraints-oldest.txt b/constraints-oldest.txt index 70d16afb2..667118058 100644 --- a/constraints-oldest.txt +++ b/constraints-oldest.txt @@ -4,7 +4,8 @@ # interpreter and Python ependencies. Keep this up-to-date with minimum # versions in `setup.cfg`. black==22.3.0 -darkgraylib==2.0.1 +# TODO: upgrade to darkgraylib~=2.1.0 when released +darkgraylib @ git+https://github.com/akaihola/darkgraylib.git@module-scope-git-repo-fixture defusedxml==0.7.1 flake8-2020==1.6.1 flake8-bugbear==22.1.11 diff --git a/setup.cfg b/setup.cfg index 4739438df..2c193089e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,8 +28,8 @@ package_dir = packages = find: install_requires = # NOTE: remember to keep `constraints-oldest.txt` in sync with these - black>=22.3.0 - darkgraylib~=2.0.1 + # TODO: upgrade to darkgraylib~=2.1.0 when released + darkgraylib @ git+https://github.com/akaihola/darkgraylib.git@module-scope-git-repo-fixture toml>=0.10.0 typing_extensions>=4.0.1 # NOTE: remember to keep `.github/workflows/python-package.yml` in sync @@ -47,11 +47,14 @@ 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 [options.extras_require] +black = + black>=22.3.0 flynt = flynt>=0.76 isort = @@ -95,8 +98,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/command_line.py b/src/darker/command_line.py index 4a092ba63..5d4f94dfd 100644 --- a/src/darker/command_line.py +++ b/src/darker/command_line.py @@ -5,8 +5,6 @@ from functools import partial from typing import List, Optional, Tuple -from black import TargetVersion - import darkgraylib.command_line from darker import help as hlp from darker.config import ( @@ -15,6 +13,7 @@ DarkerConfig, OutputMode, ) +from darker.configuration.target_version import TargetVersion from darker.formatters import get_formatter_names from darker.version import __version__ from darkgraylib.command_line import add_parser_argument diff --git a/src/darker/configuration/__init__.py b/src/darker/configuration/__init__.py new file mode 100644 index 000000000..d6f0ec5c4 --- /dev/null +++ b/src/darker/configuration/__init__.py @@ -0,0 +1 @@ +"""Configuration and command line handling.""" diff --git a/src/darker/configuration/target_version.py b/src/darker/configuration/target_version.py new file mode 100644 index 000000000..07ad4c01f --- /dev/null +++ b/src/darker/configuration/target_version.py @@ -0,0 +1,19 @@ +"""Data structures configuring Darker and formatter plugin behavior.""" + +from enum import Enum + + +class TargetVersion(Enum): + """Python version numbers.""" + + PY33 = 3 + PY34 = 4 + PY35 = 5 + PY36 = 6 + PY37 = 7 + PY38 = 8 + PY39 = 9 + PY310 = 10 + PY311 = 11 + PY312 = 12 + PY313 = 13 diff --git a/src/darker/files.py b/src/darker/files.py index 068d12c4b..beb92ae35 100644 --- a/src/darker/files.py +++ b/src/darker/files.py @@ -2,18 +2,9 @@ from __future__ import annotations -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, -) +import re +from functools import lru_cache +from typing import TYPE_CHECKING, Collection, Iterable, Iterator, Pattern from darkgraylib.files import find_project_root @@ -25,22 +16,116 @@ 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" if path_pyproject_toml.is_file(): return str(path_pyproject_toml) + return None + + +DEFAULT_EXCLUDE_RE = re.compile( + r"/(\.direnv" + r"|\.eggs" + r"|\.git" + r"|\.hg" + r"|\.ipynb_checkpoints" + r"|\.mypy_cache" + r"|\.nox" + r"|\.pytest_cache" + r"|\.ruff_cache" + r"|\.tox" + r"|\.svn" + r"|\.venv" + r"|\.vscode" + r"|__pypackages__" + r"|_build" + r"|buck-out" + r"|build" + r"|dist" + r"|venv)/" +) +DEFAULT_INCLUDE_RE = re.compile(r"(\.pyi?|\.ipynb)$") + + +@lru_cache +def _cached_resolve(path: Path) -> Path: + return path.resolve() + +def _resolves_outside_root_or_cannot_stat(path: Path, root: Path) -> bool: + """Return whether path is a symlink that points outside the root directory. + + Also returns True if we failed to resolve the path. + + This function has been adapted from Black 24.10.0. + + """ try: - path_user_pyproject_toml = find_user_pyproject_toml() - return ( - str(path_user_pyproject_toml) - if path_user_pyproject_toml.is_file() - else None - ) - except (PermissionError, RuntimeError) as e: - # 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 + resolved_path = _cached_resolve(path) + except OSError: + return True + try: + resolved_path.relative_to(root) + except ValueError: + return True + return False + + +def _path_is_excluded( + normalized_path: str, + pattern: Pattern[str] | None, +) -> bool: + """Return whether the path is excluded by the pattern. + + This function has been adapted from Black 24.10.0. + + """ + match = pattern.search(normalized_path) if pattern else None + return bool(match and match.group(0)) + + +def _gen_python_files( + paths: Iterable[Path], + root: Path, + exclude: Pattern[str], + extend_exclude: Pattern[str] | None, + force_exclude: Pattern[str] | None, +) -> Iterator[Path]: + """Generate all files under ``path`` whose paths are not excluded. + + This function has been adapted from Black 24.10.0. + + """ + if not root.is_absolute(): + message = f"`root` must be absolute, not {root}" + raise ValueError(message) + for child in paths: + if not child.is_absolute(): + message = f"`child` must be absolute, not {child}" + raise ValueError(message) + root_relative_path = child.relative_to(root).as_posix() + + # Then ignore with `--exclude` `--extend-exclude` and `--force-exclude` options. + root_relative_path = f"/{root_relative_path}" + if child.is_dir(): + root_relative_path = f"{root_relative_path}/" + + if any( + _path_is_excluded(root_relative_path, x) + for x in [exclude, extend_exclude, force_exclude] + ) or _resolves_outside_root_or_cannot_stat(child, root): + continue + + if child.is_dir(): + yield from _gen_python_files( + child.iterdir(), root, exclude, extend_exclude, force_exclude + ) + + elif child.is_file(): + include_match = DEFAULT_INCLUDE_RE.search(root_relative_path) + if include_match: + yield child def filter_python_files( @@ -58,32 +143,16 @@ def filter_python_files( ``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( + _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] + formatter.get_exclude(DEFAULT_EXCLUDE_RE), + formatter.get_extend_exclude(), + formatter.get_force_exclude(), ) ) 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/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 540283b05..596508a01 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")') @@ -39,26 +39,27 @@ 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.exceptions import DependencyError 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 -__all__ = ["Mode"] logger = logging.getLogger(__name__) @@ -74,14 +75,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``. @@ -96,6 +96,27 @@ def read_config(self, src: tuple[str, ...], args: Namespace) -> None: self._read_cli_args(args) def _read_config_file(self, config_path: str) -> None: # noqa: C901 + # Local import so Darker can be run without Black installed. + # Do error handling here. This is the first Black importing method being hit. + try: + from black import ( # pylint: disable=import-outside-toplevel + parse_pyproject_toml, + re_compile_maybe_verbose, + ) + except ImportError as exc: + logger.warning( + "To re-format code using Black, install it using e.g." + " `pip install 'darker[black]'` or" + " `pip install black`" + ) + logger.warning( + "To use a different formatter or no formatter, select it on the" + " command line (e.g. `--formatter=none`) or configuration" + " (e.g. `formatter=none`)" + ) + message = "Can't find the Black package" + raise DependencyError(message) from exc + raw_config = parse_pyproject_toml(config_path) if "line_length" in raw_config: self.config["line_length"] = raw_config["line_length"] @@ -112,10 +133,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}" @@ -133,26 +159,23 @@ 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 + ) -> TextDocument: # noqa: ARG002 """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 """ + # Local import so Darker can be run without Black installed. + # No need for error handling, already done in `BlackFormatter.read_config`. + from black import format_str # pylint: disable=import-outside-toplevel + contents_for_black = content.string_with_newline("\n") if contents_for_black.strip(): dst_contents = format_str( @@ -173,19 +196,23 @@ def _make_black_options(self) -> Mode: # 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. + + # Local import so Darker can be run without Black installed. + # No need for error handling, already done in `BlackFormatter.read_config`. + from black import FileMode as Mode # pylint: disable=import-outside-toplevel + from black import TargetVersion # pylint: disable=import-outside-toplevel + 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) + 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..8a345d944 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 + ) -> TextDocument: # noqa: ARG002 """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..1ca7eac23 --- /dev/null +++ b/src/darker/formatters/ruff_formatter.py @@ -0,0 +1,209 @@ +"""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, 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] +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. + 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.""" + 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"] = raw_config["line_length"] + + 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") + + +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. + + """ + 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 = [s for s in output if s.startswith('Type: "py') and s.endswith('"')] + if not type_lines: + message = f"`{cmdline}` returned no target versions on a 'Type: \"py...' line" + raise ConfigurationError(message) + # Drop 'Type:' prefix and the initial and final double quotes + delimited_versions = type_lines[0][len('Type: "') : -len('"')] + # Now we have: py37" | "py38" | "py39" | "py310" | "py311" | "py312 + # which we split 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"py{major}{''.join(minor)}" + for _p, _y, major, *minor in py_versions + } + + +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/help.py b/src/darker/help.py index 1768a0a84..d9e64cade 100644 --- a/src/darker/help.py +++ b/src/darker/help.py @@ -2,8 +2,7 @@ from textwrap import dedent -from black import TargetVersion - +from darker.configuration.target_version import TargetVersion from darker.formatters import get_formatter_names diff --git a/src/darker/tests/helpers.py b/src/darker/tests/helpers.py index 39fbcc314..56c3e6a2f 100644 --- a/src/darker/tests/helpers.py +++ b/src/darker/tests/helpers.py @@ -22,6 +22,13 @@ def _package_present( yield fake_module +@contextmanager +def black_present(*, present: bool) -> Generator[None, None, None]: + """Context manager to remove or add the ``black`` package temporarily for a test.""" + with _package_present("black", present): + yield + + @contextmanager def isort_present(present: bool) -> Generator[None, None, None]: """Context manager to remove or add the `isort` package temporarily for a test""" diff --git a/src/darker/tests/test_command_line.py b/src/darker/tests/test_command_line.py index 6d108e32d..3b85132b5 100644 --- a/src/darker/tests/test_command_line.py +++ b/src/darker/tests/test_command_line.py @@ -11,13 +11,13 @@ import pytest import toml -from black import TargetVersion +from black import FileMode, TargetVersion import darker.help 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 import ruff_formatter from darker.formatters.black_formatter import BlackFormatter from darker.tests.helpers import flynt_present, isort_present from darkgraylib.config import ConfigurationError @@ -196,6 +196,18 @@ def get_darker_help_output(capsys): expect_config=("formatter", "black"), expect_modified=("formatter", ...), ), + dict( + argv=["--formatter", "none", "."], + expect_value=("formatter", "none"), + expect_config=("formatter", "none"), + expect_modified=("formatter", "none"), + ), + dict( + argv=["--formatter=none", "."], + expect_value=("formatter", "none"), + expect_config=("formatter", "none"), + expect_modified=("formatter", "none"), + ), dict( argv=["--formatter", "rustfmt", "."], expect_value=SystemExit, @@ -546,9 +558,8 @@ 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_formatter, "Mode", wraps=black_formatter.Mode - ) as file_mode_class: + with patch("black.FileMode", wraps=FileMode) as file_mode_class: + # end of test setup, now call the function under test main(options + [str(path) for path in added_files.values()]) @@ -556,6 +567,91 @@ def test_black_options(monkeypatch, tmpdir, git_repo, options, expect): file_mode_class.assert_called_once_with(*expect_args, **expect_kwargs) +@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, tmp_path, git_repo, options, expect_opts): + """Ruff options from the command line are passed correctly to Ruff.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[tool.ruff]\n") + (tmp_path / "ruff.cfg").write_text( + dedent( + """ + [tool.ruff] + line_length = 81 + skip_string_normalization = false + target_version = 'py38' + """ + ) + ) + added_files = git_repo.add( + {"main.py": 'print("Hello World!")\n'}, commit="Initial commit" + ) + main_py = added_files["main.py"] + 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(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=call()), dict( @@ -661,16 +757,64 @@ 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_formatter.Mode) + mode_class_mock = Mock(wraps=FileMode) # Speed up tests by mocking `format_str` to skip running Black format_str = Mock(return_value="a = [1, 2,]") - with patch.multiple(black_formatter, Mode=mode_class_mock, format_str=format_str): - + with patch("black.FileMode", mode_class_mock), patch( + "black.format_str", format_str + ): main(options + [str(path) for path in added_files.values()]) assert mode_class_mock.call_args_list == [expect] +@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] + ) + + @pytest.mark.kwparametrize( dict( options=["a.py"], @@ -741,7 +885,7 @@ def test_black_config_file_and_options(git_repo, config, options, expect): {Path("a.py")}, Exclusions(isort={"**/*"}, flynt={"**/*"}), RevisionRange("HEAD", ":WORKTREE:"), - {"target_version": {"py39"}}, + {"target_version": {(3, 9)}}, ), ), dict( diff --git a/src/darker/tests/test_files.py b/src/darker/tests/test_files.py index 227e03f11..32ed1b3b9 100644 --- a/src/darker/tests/test_files.py +++ b/src/darker/tests/test_files.py @@ -1,22 +1,35 @@ """Test for the `darker.files` module.""" -import io -from contextlib import redirect_stderr +# pylint: disable=use-dict-literal + from pathlib import Path -from unittest.mock import MagicMock, patch + +import pytest from darker import files -@patch("darker.files.find_user_pyproject_toml") -def test_find_pyproject_toml(find_user_pyproject_toml: MagicMock) -> None: +@pytest.mark.kwparametrize( + dict(start="only_pyproject/subdir", expect="only_pyproject/pyproject.toml"), + dict(start="only_git/subdir", expect=None), + dict(start="git_and_pyproject/subdir", expect="git_and_pyproject/pyproject.toml"), +) +def test_find_pyproject_toml(tmp_path: Path, start: str, expect: str) -> None: """Test `files.find_pyproject_toml` with no user home directory.""" - find_user_pyproject_toml.side_effect = RuntimeError() - with redirect_stderr(io.StringIO()) as stderr: - # end of test setup + (tmp_path / "only_pyproject").mkdir() + (tmp_path / "only_pyproject" / "pyproject.toml").touch() + (tmp_path / "only_pyproject" / "subdir").mkdir() + (tmp_path / "only_git").mkdir() + (tmp_path / "only_git" / ".git").mkdir() + (tmp_path / "only_git" / "subdir").mkdir() + (tmp_path / "git_and_pyproject").mkdir() + (tmp_path / "git_and_pyproject" / ".git").mkdir() + (tmp_path / "git_and_pyproject" / "pyproject.toml").touch() + (tmp_path / "git_and_pyproject" / "subdir").mkdir() - result = files.find_pyproject_toml(path_search_start=(str(Path.cwd().root),)) + result = files.find_pyproject_toml(path_search_start=(str(tmp_path / start),)) - assert result is None - err = stderr.getvalue() - assert "Ignoring user configuration" in err + if not expect: + assert result is None + else: + assert result == str(tmp_path / expect) diff --git a/src/darker/tests/test_formatters_black.py b/src/darker/tests/test_formatters_black.py index 74ec3d66e..a27364241 100644 --- a/src/darker/tests/test_formatters_black.py +++ b/src/darker/tests/test_formatters_black.py @@ -6,19 +6,21 @@ import sys from argparse import Namespace from dataclasses import dataclass, field +from importlib import reload from pathlib import Path -from typing import TYPE_CHECKING, Dict, Iterable, Iterator, Optional, Pattern -from unittest.mock import ANY, Mock, call, patch +from typing import TYPE_CHECKING +from unittest.mock import patch import pytest import regex -from black import Mode, Report, TargetVersion -from pathspec import PathSpec +from black import Mode, TargetVersion -from darker import files +import darker.formatters.black_formatter +from darker.exceptions import DependencyError from darker.files import DEFAULT_EXCLUDE_RE, filter_python_files -from darker.formatters import black_formatter +from darker.formatters import create_formatter from darker.formatters.black_formatter import BlackFormatter +from darker.tests.helpers import black_present from darkgraylib.config import ConfigurationError from darkgraylib.testtools.helpers import raises_or_matches from darkgraylib.utils import TextDocument @@ -34,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 @@ -51,15 +53,47 @@ def __eq__(self, other): ) +@pytest.mark.parametrize("present", [True, False]) +def test_formatters_black_importable_with_and_without_isort(present): + """Ensure `darker.formatters.black_formatter` imports with/without ``black``.""" + try: + with black_present(present=present): + # end of test setup, now import the module + + # Import when `black` has been removed temporarily + reload(darker.formatters.black_formatter) + + finally: + # Re-import after restoring `black` so other tests won't be affected + reload(darker.formatters.black_formatter) + + +def test_formatter_without_black(caplog): + """`BlackFormatter` logs warnings with instructions if `black` is not installed.""" + args = Namespace() + args.config = None + formatter = create_formatter("black") + with black_present(present=False), pytest.raises( + DependencyError, match="^Can't find the Black package$" + ): + # end of test setup, now exercise the Black formatter + + formatter.read_config((), args) + + assert [ + record.msg for record in caplog.records if record.levelname == "WARNING" + ] == [ + # warning 1: + "To re-format code using Black, install it using e.g." + " `pip install 'darker[black]'` or `pip install black`", + # warning 2: + "To use a different formatter or no formatter, select it on the command line" + " (e.g. `--formatter=none`) or configuration (e.g. `formatter=none`)", + ] + + +@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}, @@ -78,23 +112,22 @@ def __eq__(self, other): ), 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$")}, @@ -113,12 +146,16 @@ def __eq__(self, other): ), 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() @@ -189,7 +226,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, @@ -213,161 +250,6 @@ def test_filter_python_files( # pylint: disable=too-many-arguments assert result == expect_paths -def make_mock_gen_python_files_black_21_7b1_dev8(): - """Create `gen_python_files` mock for Black 21.7b1.dev8+ge76adbe - - Also record the call made to the mock function for test verification. - - This revision didn't yet have the `verbose` and `quiet` parameters. - - """ - calls = Mock() - - # pylint: disable=unused-argument - def gen_python_files( - paths: Iterable[Path], - root: Path, - include: Pattern[str], - exclude: Pattern[str], - extend_exclude: Optional[Pattern[str]], - force_exclude: Optional[Pattern[str]], - report: Report, - gitignore: Optional[PathSpec], - ) -> Iterator[Path]: - calls.gen_python_files = call(gitignore=gitignore) - for _ in []: - yield Path() - - return gen_python_files, calls - - -def make_mock_gen_python_files_black_21_7b1_dev9(): - """Create `gen_python_files` mock for Black 21.7b1.dev9+gb1d0601 - - Also record the call made to the mock function for test verification. - - This revision added `verbose` and `quiet` parameters to `gen_python_files`. - - """ - calls = Mock() - - # pylint: disable=unused-argument - def gen_python_files( - paths: Iterable[Path], - root: Path, - include: Pattern[str], - exclude: Pattern[str], - extend_exclude: Optional[Pattern[str]], - force_exclude: Optional[Pattern[str]], - report: Report, - gitignore: Optional[PathSpec], - *, - verbose: bool, - quiet: bool, - ) -> Iterator[Path]: - calls.gen_python_files = call( - gitignore=gitignore, - verbose=verbose, - quiet=quiet, - ) - for _ in []: - yield Path() - - return gen_python_files, calls - - -def make_mock_gen_python_files_black_22_10_1_dev19(): - """Create `gen_python_files` mock for Black 22.10.1.dev19+gffaaf48 - - Also record the call made to the mock function for test verification. - - This revision renamed the `gitignore` parameter to `gitignore_dict`. - - """ - calls = Mock() - - # pylint: disable=unused-argument - def gen_python_files( - paths: Iterable[Path], - root: Path, - include: Pattern[str], - exclude: Pattern[str], - extend_exclude: Optional[Pattern[str]], - force_exclude: Optional[Pattern[str]], - report: Report, - gitignore_dict: Optional[Dict[Path, PathSpec]], - *, - verbose: bool, - quiet: bool, - ) -> Iterator[Path]: - calls.gen_python_files = call( - gitignore_dict=gitignore_dict, - verbose=verbose, - quiet=quiet, - ) - for _ in []: - yield Path() - - return gen_python_files, calls - - -@pytest.mark.kwparametrize( - dict( - make_mock=make_mock_gen_python_files_black_21_7b1_dev8, - expect={"gitignore": None}, - ), - dict( - make_mock=make_mock_gen_python_files_black_21_7b1_dev9, - expect={"gitignore": None, "verbose": False, "quiet": False}, - ), - dict( - make_mock=make_mock_gen_python_files_black_22_10_1_dev19, - expect={"gitignore_dict": {}, "verbose": False, "quiet": False}, - ), -) -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(files, "gen_python_files", gen_python_files): - # end of test setup - - _ = 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(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.object(black_formatter, "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") @@ -378,57 +260,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( @@ -472,14 +332,14 @@ def test_run_configuration( ): """`BlackFormatter.run` passes correct configuration to Black.""" src = TextDocument.from_str("import os\n") - with patch.object(black_formatter, "format_str") as format_str, raises_or_matches( + with patch("black.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(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..006e0261a --- /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 black_formatter, 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, black_formatter, "format_str"), + (RuffFormatter, 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_module, formatter_func_name = formatter_setup + src = TextDocument.from_str(f"print ( 'touché' ){newline}") + with patch.object(formatter_module, 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 446690ee7..b557d21a7 100644 --- a/src/darker/tests/test_main.py +++ b/src/darker/tests/test_main.py @@ -22,6 +22,7 @@ from darker.help import LINTING_GUIDE from darker.terminal import output from darker.tests.examples import A_PY, A_PY_BLACK, A_PY_BLACK_FLYNT, A_PY_BLACK_ISORT +from darker.tests.helpers import black_present from darker.tests.test_fstring import FLYNTED_SOURCE, MODIFIED_SOURCE, ORIGINAL_SOURCE from darkgraylib.git import RevisionRange from darkgraylib.testtools.highlighting_helpers import BLUE, CYAN, RESET, WHITE, YELLOW @@ -86,6 +87,9 @@ def _replace_diff_timestamps(text, replacement=""): ] +@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), @@ -132,6 +136,8 @@ def _replace_diff_timestamps(text, replacement=""): pyproject_toml=""" [tool.black] exclude = 'a.py' + [tool.ruff.format] + exclude = ['a.py'] """, expect_a_py=A_PY, ), @@ -140,6 +146,8 @@ def _replace_diff_timestamps(text, replacement=""): pyproject_toml=""" [tool.black] exclude = 'a.py' + [tool.ruff.format] + exclude = ['a.py'] """, expect_stdout=[], ), @@ -148,6 +156,8 @@ def _replace_diff_timestamps(text, replacement=""): pyproject_toml=""" [tool.black] extend_exclude = 'a.py' + [tool.ruff] + extend-exclude = ['a.py'] """, expect_a_py=A_PY, ), @@ -156,6 +166,8 @@ def _replace_diff_timestamps(text, replacement=""): pyproject_toml=""" [tool.black] extend_exclude = 'a.py' + [tool.ruff] + extend-exclude = ['a.py'] """, expect_stdout=[], ), @@ -164,6 +176,10 @@ def _replace_diff_timestamps(text, replacement=""): 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, ), @@ -172,6 +188,10 @@ def _replace_diff_timestamps(text, replacement=""): 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=[], ), @@ -190,6 +210,7 @@ def _replace_diff_timestamps(text, replacement=""): ) @pytest.mark.parametrize("newline", ["\n", "\r\n"], ids=["unix", "windows"]) def test_main( + formatter_arguments, git_repo, monkeypatch, capsys, @@ -221,7 +242,9 @@ def test_main( paths["subdir/a.py"].write_bytes(newline.join(A_PY).encode("ascii")) 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(git_repo.root), "") diff_output = stdout.splitlines(False) @@ -244,7 +267,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" @@ -255,7 +279,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 @@ -285,18 +309,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 @@ -368,7 +393,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"}, @@ -376,7 +402,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() @@ -630,3 +656,38 @@ def test_long_command_length(git_repo): git_repo.add(files, commit="Add all the files") result = darker.__main__.main(["--diff", "--check", "src"]) assert result == 0 + + +@pytest.fixture(scope="module") +def formatter_none_repo(git_repo_m): + """Create a Git repository with a single file and a formatter that does nothing.""" + files = git_repo_m.add({"file1.py": "# old content\n"}, commit="Initial") + files["file1.py"].write_text( + dedent( + """ + import sys, os + print ( 'untouched unformatted code' ) + """ + ) + ) + return files + + +@pytest.mark.parametrize("has_black", [False, True]) +def test_formatter_none(has_black, formatter_none_repo): + """The dummy formatter works regardless of whether Black is installed or not.""" + with black_present(present=has_black): + argv = ["--formatter=none", "--isort", "file1.py"] + + result = darker.__main__.main(argv) + + assert result == 0 + expect = dedent( + """ + import os + import sys + + print ( 'untouched unformatted code' ) + """ + ) + assert formatter_none_repo["file1.py"].read_text() == expect diff --git a/src/darker/tests/test_main_format_edited_parts.py b/src/darker/tests/test_main_format_edited_parts.py index 3d57c183f..4b1392b8f 100644 --- a/src/darker/tests/test_main_format_edited_parts.py +++ b/src/darker/tests/test_main_format_edited_parts.py @@ -15,6 +15,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.verification import NotEquivalentError from darkgraylib.git import WORKTREE, RevisionRange @@ -47,7 +48,7 @@ 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], ), @@ -60,18 +61,20 @@ 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( git_repo, - black_config, + formatter_config, black_exclude, isort_exclude, flynt_exclude, + formatter_class, newline, expect, ): @@ -85,8 +88,8 @@ 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 + formatter = formatter_class() + formatter.config = formatter_config result = darker.__main__.format_edited_parts( Path(git_repo.root), @@ -148,8 +151,11 @@ def test_format_edited_parts( ], ), ) +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) @pytest.mark.parametrize("newline", ["\n", "\r\n"], ids=["unix", "windows"]) -def test_format_edited_parts_stdin(git_repo, newline, rev1, rev2, expect): +def test_format_edited_parts_stdin( + git_repo, rev1, rev2, expect, formatter_class, newline +): """`format_edited_parts` with ``--stdin-filename``.""" n = newline # pylint: disable=invalid-name paths = git_repo.add( @@ -179,7 +185,7 @@ def test_format_edited_parts_stdin(git_repo, newline, rev1, rev2, expect): {Path("a.py")}, Exclusions(formatter=set(), isort=set()), RevisionRange(rev1, rev2), - BlackFormatter(), + formatter_class(), report_unmodified=False, ), ) @@ -191,12 +197,15 @@ def test_format_edited_parts_stdin(git_repo, newline, rev1, rev2, expect): 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( @@ -204,7 +213,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, ), ) @@ -212,7 +221,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") @@ -229,7 +239,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, ), ) @@ -251,7 +261,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", @@ -273,7 +284,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, ) @@ -285,7 +296,8 @@ def test_format_edited_parts_isort_on_already_formatted(git_repo): dict(rev1="HEAD^", rev2=WORKTREE, expect=[(":WORKTREE:", "reformatted")]), dict(rev1="HEAD", rev2=WORKTREE, expect=[(":WORKTREE:", "reformatted")]), ) -def test_format_edited_parts_historical(git_repo, rev1, rev2, expect): +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) +def test_format_edited_parts_historical(git_repo, rev1, rev2, expect, formatter_class): """``format_edited_parts()`` is correct for different commit pairs.""" a_py = { "HEAD^": TextDocument.from_lines( @@ -328,7 +340,7 @@ def test_format_edited_parts_historical(git_repo, rev1, rev2, expect): {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 5cee23891..6fba061a6 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.utils import TextDocument @@ -57,6 +58,7 @@ exclusions=Exclusions(), expect="import original\nprint( original )\n", ) +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) def test_reformat_and_flynt_single_file( git_repo, relative_path, @@ -64,6 +66,7 @@ def test_reformat_and_flynt_single_file( rev2_isorted, exclusions, expect, + formatter_class, ): """Test for `_reformat_and_flynt_single_file`.""" git_repo.add( @@ -80,13 +83,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( """\ @@ -143,7 +147,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 == ( @@ -155,7 +159,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( '''\ @@ -202,7 +207,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 5b1b3bf47..0bc72bead 100644 --- a/src/darker/tests/test_main_stdin_filename.py +++ b/src/darker/tests/test_main_stdin_filename.py @@ -2,8 +2,10 @@ # pylint: disable=too-many-arguments,use-dict-literal +from __future__ import annotations + from io import BytesIO -from typing import List, Optional +from typing import TYPE_CHECKING from unittest.mock import Mock, patch import pytest @@ -12,9 +14,11 @@ import darker.__main__ from darkgraylib.command_line import EXIT_CODE_CMDLINE_ERROR from darkgraylib.config import ConfigurationError -from darkgraylib.testtools.git_repo_plugin import GitRepoFixture from darkgraylib.testtools.helpers import raises_if_exception +if TYPE_CHECKING: + from darkgraylib.testtools.git_repo_plugin import GitRepoFixture + pytestmark = pytest.mark.usefixtures("find_project_root_cache_clear") @@ -135,14 +139,16 @@ expect=0, expect_a_py="original\n", ) +@pytest.mark.parametrize("formatter", [[], ["--formatter=black"], ["--formatter=ruff"]]) def test_main_stdin_filename( git_repo: GitRepoFixture, - 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""" if config_src is not None: @@ -165,7 +171,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 paths["a.py"].read_text() == expect_a_py diff --git a/src/darker/tests/test_verification.py b/src/darker/tests/test_verification.py index 4cad7f42a..39e49ae6d 100644 --- a/src/darker/tests/test_verification.py +++ b/src/darker/tests/test_verification.py @@ -2,40 +2,10 @@ # pylint: disable=use-dict-literal -from typing import List - import pytest -from darker.verification import ( - ASTVerifier, - BinarySearch, - NotEquivalentError, - verify_ast_unchanged, -) -from darkgraylib.utils import DiffChunk, TextDocument - - -@pytest.mark.kwparametrize( - dict(dst_content=["if False: pass"], expect=AssertionError), - dict(dst_content=["if True:", " pass"], expect=None), -) -def test_verify_ast_unchanged(dst_content, expect): - """``verify_ast_unchanged`` detects changes correctly""" - black_chunks: List[DiffChunk] = [(1, ("black",), ("chunks",))] - edited_linenums = [1, 2] - try: - - verify_ast_unchanged( - TextDocument.from_lines(["if True: pass"]), - TextDocument.from_lines(dst_content), - black_chunks, - edited_linenums, - ) - - except NotEquivalentError: - assert expect is AssertionError - else: - assert expect is None +from darker.verification import ASTVerifier, BinarySearch +from darkgraylib.utils import TextDocument def test_ast_verifier_is_equivalent(): diff --git a/src/darker/verification.py b/src/darker/verification.py index b3921c97c..f6bd00462 100644 --- a/src/darker/verification.py +++ b/src/darker/verification.py @@ -1,18 +1,14 @@ """Verification for unchanged AST before and after reformatting""" -from typing import Dict, List +from __future__ import annotations -from black import assert_equivalent, parse_ast, stringify_ast +import ast +import sys +import warnings +from typing import TYPE_CHECKING, Dict, Iterator -from darker.utils import debug_dump -from darkgraylib.utils import DiffChunk, TextDocument - -try: - # Black 24.2.1 and later - from black.parsing import ASTSafetyError # pylint: disable=ungrouped-imports -except ImportError: - # Black 24.2.0 and earlier - ASTSafetyError = AssertionError # type: ignore[assignment,misc] +if TYPE_CHECKING: + from darkgraylib.utils import TextDocument class NotEquivalentError(Exception): @@ -63,18 +59,161 @@ def result(self) -> int: return self.high -def verify_ast_unchanged( - edited_to_file: TextDocument, - reformatted: TextDocument, - black_chunks: List[DiffChunk], - edited_linenums: List[int], -) -> None: - """Verify that source code parses to the same AST before and after reformat""" - try: - assert_equivalent(edited_to_file.string, reformatted.string) - except ASTSafetyError as exc_info: - debug_dump(black_chunks, edited_linenums) - raise NotEquivalentError() from exc_info +def parse_ast(src: str) -> ast.AST: + """Parse source code with fallback for type comments. + + This function has been adapted from Black 24.10.0. + + """ + filename = "" + versions = [(3, minor) for minor in range(5, sys.version_info[1] + 1)] + + first_error = "" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", SyntaxWarning) + warnings.simplefilter("ignore", DeprecationWarning) + # Try with type comments first + for version in reversed(versions): + try: + return ast.parse( + src, filename, feature_version=version, type_comments=True + ) + except SyntaxError as e: # noqa: PERF203 + if not first_error: + first_error = str(e) + + # Fallback without type comments + for version in reversed(versions): + try: + return ast.parse( + src, filename, feature_version=version, type_comments=False + ) + except SyntaxError: # noqa: PERF203 + continue + + raise SyntaxError(first_error) + + +def _normalize(lineend: str, value: str) -> str: + """Strip any leading and trailing space from each line. + + This function has been adapted from Black 24.10.0. + + """ + stripped: list[str] = [i.strip() for i in value.splitlines()] + normalized = lineend.join(stripped) + # ...and remove any blank lines at the beginning and end of + # the whole string + return normalized.strip() + + +def stringify_ast(node: ast.AST) -> Iterator[str]: + """Generate strings to compare ASTs by content using a simple visitor. + + This function has been adapted from Black 24.10.0. + + """ + return _stringify_ast(node, []) + + +def _stringify_ast_with_new_parent( + node: ast.AST, parent_stack: list[ast.AST], new_parent: ast.AST +) -> Iterator[str]: + """Generate strings to compare, recurse with a new parent. + + This function has been adapted from Black 24.10.0. + + """ + parent_stack.append(new_parent) + yield from _stringify_ast(node, parent_stack) + parent_stack.pop() + + +def _stringify_ast(node: ast.AST, parent_stack: list[ast.AST]) -> Iterator[str]: + """Generate strings to compare ASTs by content. + + This function has been adapted from Black 24.10.0. + + """ + if ( + isinstance(node, ast.Constant) + and isinstance(node.value, str) + and node.kind == "u" + ): + # It's a quirk of history that we strip the u prefix over here. We used to + # rewrite the AST nodes for Python version compatibility and we never copied + # over the kind + node.kind = None + + yield f"{' ' * len(parent_stack)}{node.__class__.__name__}(" + + for field in sorted(node._fields): + # TypeIgnore has only one field 'lineno' which breaks this comparison + if isinstance(node, ast.TypeIgnore): + break + + try: + value: object = getattr(node, field) + except AttributeError: + continue + + yield f"{' ' * (len(parent_stack) + 1)}{field}=" + + if isinstance(value, list): + for item in value: + yield from _stringify_list_item(field, item, node, parent_stack) + + elif isinstance(value, ast.AST): + yield from _stringify_ast_with_new_parent(value, parent_stack, node) + + else: + normalized: object + if ( + isinstance(node, ast.Constant) + and field == "value" + and isinstance(value, str) + and len(parent_stack) >= 2 + # Any standalone string, ideally this would + # exactly match black.nodes.is_docstring + and isinstance(parent_stack[-1], ast.Expr) + ): + # Constant strings may be indented across newlines, if they are + # docstrings; fold spaces after newlines when comparing. Similarly, + # trailing and leading space may be removed. + normalized = _normalize("\n", value) + elif field == "type_comment" and isinstance(value, str): + # Trailing whitespace in type comments is removed. + normalized = value.rstrip() + else: + normalized = value + yield ( + f"{' ' * (len(parent_stack) + 1)}{normalized!r}, #" + f" {value.__class__.__name__}" + ) + + yield f"{' ' * len(parent_stack)}) # /{node.__class__.__name__}" + + +def _stringify_list_item( + field: str, item: ast.AST, node: ast.AST, parent_stack: list[ast.AST] +) -> Iterator[str]: + """Generate string for an AST list item. + + This function has been adapted from Black 24.10.0. + + """ + # Ignore nested tuples within del statements, because we may insert + # parentheses and they change the AST. + if ( + field == "targets" + and isinstance(node, ast.Delete) + and isinstance(item, ast.Tuple) + ): + for elt in item.elts: + yield from _stringify_ast_with_new_parent(elt, parent_stack, node) + + elif isinstance(item, ast.AST): + yield from _stringify_ast_with_new_parent(item, parent_stack, node) class ASTVerifier: # pylint: disable=too-few-public-methods