diff --git a/CHANGES.rst b/CHANGES.rst index e65063939..b2ce13132 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -21,6 +21,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..7931d98c1 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_, @@ -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/setup.cfg b/setup.cfg index 9920959a3..8b9989682 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,7 +28,6 @@ package_dir = packages = find: install_requires = # NOTE: remember to keep `constraints-oldest.txt` in sync with these - black>=22.3.0 darkgraylib~=2.1.0 toml>=0.10.0 typing_extensions>=4.0.1 @@ -52,6 +51,8 @@ console_scripts = darker = darker.__main__:main_with_error_handling [options.extras_require] +black = + black>=22.3.0 flynt = flynt>=0.76 isort = 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/black_formatter.py b/src/darker/formatters/black_formatter.py index 540283b05..604b717f1 100644 --- a/src/darker/formatters/black_formatter.py +++ b/src/darker/formatters/black_formatter.py @@ -39,14 +39,7 @@ 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 darkgraylib.config import ConfigurationError @@ -56,9 +49,11 @@ from argparse import Namespace 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__) @@ -96,6 +91,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"] @@ -153,6 +169,10 @@ def run(self, content: TextDocument) -> TextDocument: :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,6 +193,12 @@ 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"] 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 22f67e337..fffcf8cb1 100644 --- a/src/darker/tests/helpers.py +++ b/src/darker/tests/helpers.py @@ -24,6 +24,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 cb43cbb73..90abff117 100644 --- a/src/darker/tests/test_command_line.py +++ b/src/darker/tests/test_command_line.py @@ -14,13 +14,12 @@ 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.black_formatter import BlackFormatter from darker.tests.helpers import flynt_present, isort_present from darkgraylib.config import ConfigurationError @@ -221,6 +220,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, @@ -578,9 +589,8 @@ def test_black_options(black_options_files, options, expect): # shared by all test cases. The "main.py" file modified by the test run needs to be # reset to its original content before the next test case. black_options_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 black_options_files.values()]) @@ -705,10 +715,13 @@ def test_black_config_file_and_options( """Black configuration file and command line options are combined correctly""" repo_files = black_config_file_and_options_files repo_files["pyproject.toml"].write_text(joinlines(["[tool.black]", *config])) - 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 + ): + # end of test setup, now call the function under test main(options + [str(path) for path in repo_files.values()]) 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..4f7534c75 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 ANY, 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 @@ -51,6 +53,45 @@ 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.kwparametrize( dict( config_path=None, config_lines=["line-length = 79"], expect={"line_length": 79} @@ -213,129 +254,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): @@ -360,7 +278,7 @@ def test_run(encoding, newline): def test_run_always_uses_unix_newlines(newline): """Content is always passed to Black with Unix newlines""" src = TextDocument.from_str(f"print ( 'touché' ){newline}") - with patch.object(black_formatter, "format_str") as format_str: + with patch("black.format_str") as format_str: format_str.return_value = 'print("touché")\n' _ = BlackFormatter().run(src) @@ -472,7 +390,7 @@ 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" diff --git a/src/darker/tests/test_main.py b/src/darker/tests/test_main.py index ec5bf2880..484c67d9c 100644 --- a/src/darker/tests/test_main.py +++ b/src/darker/tests/test_main.py @@ -23,7 +23,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 unix_and_windows_newline_repos +from darker.tests.helpers import black_present, unix_and_windows_newline_repos from darker.tests.test_fstring import FLYNTED_SOURCE, MODIFIED_SOURCE, ORIGINAL_SOURCE from darkgraylib.git import RevisionRange from darkgraylib.testtools.git_repo_plugin import GitRepoFixture @@ -662,3 +662,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_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