diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 7005eb088..85e4b067a 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -23,6 +23,7 @@ jobs: pygments \ 'pylint<=3.2.7' \ pytest>=6.2.0 \ + pyupgrade>=2.31.0 \ regex \ requests \ requests-cache \ diff --git a/.github/workflows/pyupgrade.yml b/.github/workflows/pyupgrade.yml index 22af5bbdf..53e5d90dd 100644 --- a/.github/workflows/pyupgrade.yml +++ b/.github/workflows/pyupgrade.yml @@ -9,16 +9,13 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@v3 - uses: actions/setup-python@v5 - name: Ensure modern Python style using pyupgrade - # This script is written in a Linux / macos / windows portable way run: | - uvx --from pyupgrade python -c " - import sys - from pyupgrade._main import main - from glob import glob - files = glob('**/*.py', recursive=True) - sys.exit(main(files + ['--py38-plus'])) - " || ( git diff ; false ) + uvx \ + --from '.[pyupgrade]' \ + darker --formatter=pyupgrade --target-version=py38 --diff diff --git a/CHANGES.rst b/CHANGES.rst index 8f7bbb1b2..00db44bcb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -19,6 +19,10 @@ 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. +- pyupgrade_ is now supported as a formatter plugin. Note that changes from pyupgrade + are applied on a per-file basis, not only for modified lines as with Black_ and Ruff_. Removed ------- @@ -682,3 +686,4 @@ Added .. _Ruff: https://astral.sh/ruff .. _Black: https://black.readthedocs.io/ .. _NixOS: https://nixos.org/ +.. _pyupgrade: https://pypi.org/project/pyupgrade/ diff --git a/README.rst b/README.rst index 891e6633b..bde32b550 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_, @@ -165,6 +167,9 @@ You can enable additional features with command line options: - ``-f`` / ``--flynt``: Also convert string formatting to use f-strings using the ``flynt`` package +If you only want to run those tools without reformatting with Black, +use the ``--formatter=none`` option. + *New in version 1.1.0:* The ``-L`` / ``--lint`` option. *New in version 1.2.2:* Package available in conda-forge_. @@ -174,6 +179,8 @@ You can enable additional features with command line options: *New in version 3.0.0:* Removed the ``-L`` / ``--lint`` functionality and moved it into the Graylint_ package. +*New in version 3.0.0:* The ``--formatter`` option. + .. _Conda: https://conda.io/ .. _conda-forge: https://conda-forge.org/ @@ -371,7 +378,8 @@ 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\|pyupgrade\|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 +486,7 @@ PyCharm/IntelliJ IDEA 1. Install ``darker``:: - $ pip install darker + $ pip install 'darker[black]' 2. Locate your ``darker`` installation folder. @@ -540,7 +548,7 @@ Visual Studio Code 1. Install ``darker``:: - $ pip install darker + $ pip install 'darker[black]' 2. Locate your ``darker`` installation folder. @@ -683,8 +691,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 +702,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 +791,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/mypy.ini b/mypy.ini index eeb3faaf6..5d0219f11 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,4 +1,6 @@ [mypy] +mypy_path = stubs/ + disallow_any_unimported = True disallow_any_expr = False disallow_any_decorated = True diff --git a/pyproject.toml b/pyproject.toml index 4cf7b7edc..8dc018c5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,10 +31,18 @@ revision = "origin/master..." revision = "origin/master..." src = ["."] +[tool.pylint.MASTER] +load-plugins = [ + "pylint_per_file_ignores", +] + [tool.pylint."messages control"] # Check import order only with isort. Pylint doesn't support a custom list of first # party packages. We want to consider "darkgraylib" and "graylint" as first party. disable = ["wrong-import-order"] +per-file-ignores = [ + "/stubs/:missing-class-docstring,missing-function-docstring,unused-argument", +] [tool.ruff] target-version = "py38" @@ -48,6 +56,7 @@ ignore = [ "D203", # One blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line "D400", # First line should end with a period (duplicates D415) + "ISC001", # Checks for implicitly concatenated strings on a single line # Remove these when support for Python 3.8 is dropped: "UP006", # Use `xyz` instead of `Xyz` for type annotation diff --git a/setup.cfg b/setup.cfg index 4739438df..db393b6dd 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,17 +47,23 @@ darker = [options.entry_points] darker.formatter = black = darker.formatters.black_formatter:BlackFormatter + ruff = darker.formatters.ruff_formatter:RuffFormatter + pyupgrade = darker.formatters.pyupgrade_formatter:PyupgradeFormatter 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 = isort>=5.0.1 color = Pygments>=2.4.0 +pyupgrade = + pyupgrade>=2.31.0 test = # NOTE: remember to keep `constraints-oldest.txt` in sync with these black>=22.3.0 @@ -70,8 +76,10 @@ test = pydocstyle pygments pylint<=3.2.7 # pylint 3.3.0 dropped Python 3.8 support + pylint-per-file-ignores pytest>=6.2.0 pytest-kwparametrize>=0.0.3 + pyupgrade>=2.31.0 regex>=2021.4.4 requests_cache>=0.7 ruamel.yaml>=0.17.21 @@ -95,8 +103,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..29b066b41 100644 --- a/src/darker/__main__.py +++ b/src/darker/__main__.py @@ -227,9 +227,17 @@ def _reformat_and_flynt_single_file( # noqa: PLR0913 "no" if formatted == fstringified else "some", len(formatted.lines), ) + # 4. apply all re-formatter modifications if the re-formatter doesn't guarantee + # preserving the abstract syntax tree (AST); otherwise do steps 5 to 10 + if not formatter.preserves_ast: + logger.debug( + "Preserving the AST not guaranteed by %s, applying all changes", + formatter.name, + ) + return formatted - # 4. get a diff between the edited to-file and the processed content - # 5. convert the diff into chunks, keeping original and reformatted content for each + # 5. get a diff between the edited to-file and the processed content + # 6. convert the diff into chunks, keeping original and reformatted content for each # chunk new_chunks = diff_chunks(rev2_isorted, formatted) @@ -293,7 +301,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( @@ -337,9 +345,9 @@ def _drop_changes_on_unedited_lines( context_lines, abspath_in_rev2, ) - # 6. diff the given revisions (optionally with isort modifications) for each + # 7. diff the given revisions (optionally with isort modifications) for each # file - # 7. extract line numbers in each edited to-file for changed lines + # 8. extract line numbers in each edited to-file for changed lines edited_linenums = edited_linenums_differ.revision_vs_lines( relpath_in_repo, rev2_isorted, context_lines ) @@ -348,7 +356,7 @@ def _drop_changes_on_unedited_lines( last_successful_reformat = rev2_isorted break - # 8. choose processed content for each chunk if there were any changed lines + # 9. choose processed content for each chunk if there were any changed lines # inside the chunk in the edited to-file, or choose the chunk's original # contents if no edits were done in that chunk chosen = TextDocument.from_lines( @@ -358,8 +366,8 @@ def _drop_changes_on_unedited_lines( mtime=datetime.utcnow().strftime(GIT_DATEFORMAT), ) - # 9. verify that the resulting reformatted source code parses to an identical - # AST as the original edited to-file + # 10. verify that the resulting reformatted source code parses to an identical + # AST as the original edited to-file if not has_fstring_changes and not verifier.is_equivalent_to_baseline(chosen): logger.debug( "Verifying that the %s original edited lines and %s reformatted lines " @@ -459,32 +467,26 @@ def _import_pygments(): # type: ignore def main( # noqa: C901,PLR0912,PLR0915 argv: List[str] = None, ) -> int: - """Parse the command line and reformat and optionally lint each source file + """Parse the command line and reformat and optionally lint each source file. 1. run isort on each edited file (optional) 2. run flynt (optional) on the isorted contents of each edited to-file 3. run a code re-formatter on the isorted and fstringified contents of each edited to-file - 4. get a diff between the edited to-file and the processed content - 5. convert the diff into chunks, keeping original and reformatted content for each + 4. apply all re-formatter modifications if the re-formatter doesn't guarantee + preserving the abstract syntax tree (AST); otherwise do steps 5 to 10 + 5. get a diff between the edited to-file and the processed content + 6. convert the diff into chunks, keeping original and reformatted content for each chunk - 6. diff the given revisions (optionally with isort modifications) for each + 7. diff the given revisions (optionally with isort modifications) for each file - 7. extract line numbers in each edited to-file for changed lines - 8. choose processed content for each chunk if there were any changed lines inside + 8. extract line numbers in each edited to-file for changed lines + 9. choose processed content for each chunk if there were any changed lines inside the chunk in the edited to-file, or choose the chunk's original contents if no edits were done in that chunk - 9. verify that the resulting reformatted source code parses to an identical AST as - the original edited to-file - 9. write the reformatted source back to the original file or print the diff - 10. run linter subprocesses twice for all modified and unmodified files which are - mentioned on the command line: first establish a baseline by running against - ``rev1``, then get current linting status by running against the working tree - (steps 10.-12. are optional) - 11. create a mapping from line numbers of unmodified lines in the current versions - to corresponding line numbers in ``rev1`` - 12. hide linter messages which appear in the current versions and identically on - corresponding lines in ``rev1``, and show all other linter messages + 10. verify that the resulting reformatted source code parses to an identical AST as + the original edited to-file + 11. write the reformatted source back to the original file or print the diff :param argv: The command line arguments to the ``darker`` command :return: 1 if the ``--check`` argument was provided and at least one file was (or @@ -611,7 +613,7 @@ def main( # noqa: C901,PLR0912,PLR0915 workers=config["workers"], ), ): - # 10. A re-formatted Python file which produces an identical AST was + # 11. A re-formatted Python file which produces an identical AST was # created successfully - write an updated file or print the diff if # there were any changes to the original formatting_failures_on_modified_lines = True 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..d22dfea92 100644 --- a/src/darker/formatters/base_formatter.py +++ b/src/darker/formatters/base_formatter.py @@ -2,39 +2,54 @@ 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 + preserves_ast: bool 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 +75,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..1382c5c05 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,25 @@ 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 darker.formatters.formatter_config import BlackConfig + from black import FileMode as Mode + from black import TargetVersion -__all__ = ["Mode"] logger = logging.getLogger(__name__) @@ -74,14 +73,14 @@ 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" + preserves_ast = True def read_config(self, src: tuple[str, ...], args: Namespace) -> None: """Read Black configuration from ``pyproject.toml``. @@ -96,6 +95,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 +132,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 +158,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 +195,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..4f89132fd 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 @@ -16,11 +17,16 @@ class NoneFormatter(BaseFormatter): """A dummy code formatter plugin interface.""" name = "dummy reformat" + preserves_ast = True - 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/pyupgrade_config.py b/src/darker/formatters/pyupgrade_config.py new file mode 100644 index 000000000..3c363024c --- /dev/null +++ b/src/darker/formatters/pyupgrade_config.py @@ -0,0 +1,11 @@ +"""Pyupgrade code formatter plugin configuration type definitions.""" + +from __future__ import annotations + +from darker.formatters.formatter_config import FormatterConfig + + +class PyupgradeConfig(FormatterConfig, total=False): + """Type definition for configuration dictionaries of Black compatible formatters.""" + + target_version: tuple[int, int] diff --git a/src/darker/formatters/pyupgrade_formatter.py b/src/darker/formatters/pyupgrade_formatter.py new file mode 100644 index 000000000..67ef30b14 --- /dev/null +++ b/src/darker/formatters/pyupgrade_formatter.py @@ -0,0 +1,146 @@ +"""Re-format Python source code using Pyupgrade. + +In examples below, a simple two-line snippet is used. +Everything will upgraded by Pyupgrade to newer Python syntax, except the last line:: + + >>> from pathlib import Path + >>> from unittest.mock import Mock + >>> src = Path("dummy/file/path.py") + >>> src_content = TextDocument.from_lines( + ... [ + ... "from typing import List", + ... "ls: List[int] = [42]", + ... "print('success!')" + ... ] + ... ) + +First, `PyupgradeFormatter.run` uses Pyupgrade to upgrade the contents of a given file. +All lines are returned e.g.:: + + >>> from darker.formatters.pyupgrade_formatter import PyupgradeFormatter + >>> dst = PyupgradeFormatter().run(src_content, src) + >>> dst.lines + ('from typing import List', 'ls: list[int] = [42]', "print('success!')") + +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 io +import logging +import sys +from typing import TYPE_CHECKING + +from darker.formatters.base_formatter import BaseFormatter, HasConfig +from darker.formatters.formatter_config import validate_target_versions +from darker.formatters.pyupgrade_config import PyupgradeConfig +from darkgraylib.utils import TextDocument + +if TYPE_CHECKING: + from argparse import Namespace + from pathlib import Path + +logger = logging.getLogger(__name__) + + +class PyupgradeFormatter(BaseFormatter, HasConfig[PyupgradeConfig]): + """Pyupgrade code formatter plugin interface.""" + + config: PyupgradeConfig # type: ignore[assignment] + + name = "pyupgrade" + preserves_ast = False + + def run( + self, content: TextDocument, path_from_cwd: Path + ) -> TextDocument: # noqa: ARG002 + """Run the Pyupgrade code upgrader for the Python source code given as a string. + + :param content: The source code + :param path_from_cwd: The path to the file being upgraded, either absolute or + relative to the current working directory + :return: The upgraded content + + """ + # Collect relevant Pyupgrade configuration options from ``self.config`` in order + # to pass them to Pyupgrade. + 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 = min(target_versions_in) + else: + target_version = (3, 9) + + contents_for_pyupgrade = content.string_with_newline("\n") + dst_contents = _pyupgrade_format_stdin(contents_for_pyupgrade, target_version) + return TextDocument.from_str( + dst_contents, + encoding=content.encoding, + override_newline=content.newline, + ) + + def _read_cli_args(self, args: Namespace) -> None: + if getattr(args, "target_version", None): + self.config["target_version"] = ( + int(args.target_version[2]), + int(args.target_version[3:]), + ) + + +def _get_supported_target_versions() -> set[tuple[int, int]]: + """Get the supported target versions for Pyupgrade. + + Calls ``pyupgrade --help`` as a subprocess, looks for lines looking like + `` --py???-plus``, and returns the target versions as a set of int-tuples. + + """ + # Local import so Darker can be run also without pyupgrade installed + from pyupgrade._main import main # pylint: disable=import-outside-toplevel + + stdout = sys.stdout + sys.stdout = buf = io.StringIO() + try: + main(["--help"]) + except SystemExit: # expected from argparse + pass + finally: + sys.stdout = stdout + version_strs = ( + line[6:-5] + for line in buf.getvalue().splitlines() + if line.startswith(" --py") and line.endswith("-plus") + ) + return {(int(v[0]), int(v[1:])) for v in version_strs} + + +def _pyupgrade_format_stdin(contents: str, min_version: tuple[int, int]) -> str: + """Run the contents through ``pyupgrade format``. + + :param contents: The source code to be reformatted + :param min_version: The minimum Python version to target + :return: The reformatted source code + + """ + # Local imports so Darker can be run also without pyupgrade installed + from pyupgrade._data import Settings # pylint: disable=import-outside-toplevel + from pyupgrade._main import ( # pylint: disable=import-outside-toplevel + _fix_plugins, + _fix_tokens, + ) + + return _fix_tokens( + _fix_plugins(contents, settings=Settings(min_version=min_version)) + ) diff --git a/src/darker/formatters/ruff_formatter.py b/src/darker/formatters/ruff_formatter.py new file mode 100644 index 000000000..3a63b9f2d --- /dev/null +++ b/src/darker/formatters/ruff_formatter.py @@ -0,0 +1,210 @@ +"""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" + preserves_ast = True + + 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..cb66f7b87 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,23 @@ 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 +147,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 +227,7 @@ def test_filter_python_files( # pylint: disable=too-many-arguments paths = {tmp_path / name for name in names} for path in paths: path.touch() - black_config: BlackConfig = { + black_config: BlackCompatibleConfig = { "exclude": regex.compile(exclude) if exclude else DEFAULT_EXCLUDE_RE, "extend_exclude": regex.compile(extend_exclude) if extend_exclude else None, "force_exclude": regex.compile(force_exclude) if force_exclude else None, @@ -213,161 +251,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 +261,35 @@ def test_run_ignores_excludes(): "force_exclude": regex.compile(r".*"), } - result = formatter.run(src) + result = formatter.run(src, Path("a.py")) assert result.string == "a = 1\n" -@pytest.mark.parametrize( - "src_content, expect", - [ - ("", ""), - ("\n", "\n"), - ("\r\n", "\r\n"), - (" ", ""), - ("\t", ""), - (" \t", ""), - (" \t\n", "\n"), - (" \t\r\n", "\r\n"), - ], -) -def test_run_all_whitespace_input(src_content, expect): - """All-whitespace files are reformatted correctly""" - src = TextDocument.from_str(src_content) - - result = BlackFormatter().run(src) - - assert result.string == expect - - @pytest.mark.kwparametrize( dict(black_config={}), dict( - black_config={"target_version": "py37"}, + black_config={"target_version": (3, 7)}, expect_target_versions={TargetVersion.PY37}, ), dict( - black_config={"target_version": "py39"}, + black_config={"target_version": (3, 9)}, expect_target_versions={TargetVersion.PY39}, ), dict( - black_config={"target_version": {"py37"}}, + black_config={"target_version": {(3, 7)}}, expect_target_versions={TargetVersion.PY37}, ), dict( - black_config={"target_version": {"py39"}}, + black_config={"target_version": {(3, 9)}}, expect_target_versions={TargetVersion.PY39}, ), dict( - black_config={"target_version": {"py37", "py39"}}, + black_config={"target_version": {(3, 7), (3, 9)}}, expect_target_versions={TargetVersion.PY37, TargetVersion.PY39}, ), dict( - black_config={"target_version": {"py39", "py37"}}, + black_config={"target_version": {(3, 9), (3, 7)}}, expect_target_versions={TargetVersion.PY37, TargetVersion.PY39}, ), dict( @@ -472,14 +333,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..69ea925e7 --- /dev/null +++ b/src/darker/tests/test_formatters_black_compatible.py @@ -0,0 +1,144 @@ +"""Unit tests for Black compatible formatter plugins.""" + +# pylint: disable=use-dict-literal + +from argparse import Namespace +from pathlib import Path +from unittest.mock import patch + +import pytest + +from darker.formatters import ruff_formatter +from darker.formatters.black_formatter import BlackFormatter +from darker.formatters.ruff_formatter import RuffFormatter +from darkgraylib.testtools.helpers import raises_or_matches +from darkgraylib.utils import TextDocument + + +@pytest.mark.parametrize( + "formatter_setup", + [(BlackFormatter, "-"), (BlackFormatter, "_"), (RuffFormatter, "_")], +) +@pytest.mark.kwparametrize( + dict( + config_path=None, config_lines=["line-length = 79"], expect={"line_length": 79} + ), + dict( + config_path="custom.toml", + config_lines=["line-length = 99"], + expect={"line_length": 99}, + ), + dict(config_lines=[r"include = '\.pyi$'"], expect={}), + config_path=None, +) +def test_read_config_black_and_ruff( + tmpdir, formatter_setup, config_path, config_lines, expect +): + """``read_config()`` reads Black and Ruff config correctly from a TOML file.""" + formatter_class, option_name_delimiter = formatter_setup + # For Black, we test both hyphen and underscore delimited option names + config = "\n".join( # pylint: disable=duplicate-code + line.replace("-", option_name_delimiter) for line in config_lines + ) + tmpdir = Path(tmpdir) + src = tmpdir / "src.py" + toml = tmpdir / (config_path or "pyproject.toml") + section = formatter_class.config_section + toml.write_text(f"[{section}]\n{config}\n") + with raises_or_matches(expect, []): + formatter = formatter_class() + args = Namespace() + args.config = config_path and str(toml) + if config_path: + expect["config"] = str(toml) + + # pylint: disable=duplicate-code + formatter.read_config((str(src),), args) + + assert formatter.config == expect + + +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) +@pytest.mark.parametrize("encoding", ["utf-8", "iso-8859-1"]) +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +def test_run(formatter_class, encoding, newline): + """Running formatter through their plugin ``run`` method gives correct results.""" + src = TextDocument.from_lines( + [f"# coding: {encoding}", "print ( 'touché' )"], + encoding=encoding, + newline=newline, + ) + + result = formatter_class().run(src, Path("a.py")) + + assert result.lines == ( + f"# coding: {encoding}", + 'print("touché")', + ) + assert result.encoding == encoding + assert result.newline == newline + + +@pytest.mark.parametrize( + "formatter_setup", + [ + (BlackFormatter, "black.format_str"), + (RuffFormatter, "darker.formatters.ruff_formatter._ruff_format_stdin"), + ], +) +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +def test_run_always_uses_unix_newlines(formatter_setup, newline): + """Content is always passed to Black and Ruff with Unix newlines.""" + formatter_class, formatter_func_name = formatter_setup + src = TextDocument.from_str(f"print ( 'touché' ){newline}") + with patch(formatter_func_name) as formatter_func: + formatter_func.return_value = 'print("touché")\n' + + _ = formatter_class().run(src, Path("a.py")) + + (formatter_func_call,) = formatter_func.call_args_list + assert formatter_func_call.args[0] == "print ( 'touché' )\n" + + +@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter]) +@pytest.mark.parametrize( + ("src_content", "expect"), + [ + ("", ""), + ("\n", "\n"), + ("\r\n", "\r\n"), + (" ", ""), + ("\t", ""), + (" \t", ""), + (" \t\n", "\n"), + (" \t\r\n", "\r\n"), + ], +) +def test_run_all_whitespace_input(formatter_class, src_content, expect): + """All-whitespace files are reformatted correctly.""" + src = TextDocument.from_str(src_content) + + result = formatter_class().run(src, Path("a.py")) + + assert result.string == expect + + +@pytest.mark.kwparametrize( + dict(formatter_config={}, expect=[]), + dict(formatter_config={"line_length": 80}, expect=["--line-length=80"]), +) +def test_run_configuration(formatter_config, expect): + """`RuffFormatter.run` passes correct configuration to Ruff.""" + src = TextDocument.from_str("import os\n") + with patch.object(ruff_formatter, "_ruff_format_stdin") as format_stdin: + format_stdin.return_value = "import os\n" + formatter = RuffFormatter() + formatter.config = formatter_config + + formatter.run(src, Path("a.py")) + + format_stdin.assert_called_once_with( + "import os\n", + Path("a.py"), + ['--config=lint.ignore=["ISC001"]', *expect], + ) diff --git a/src/darker/tests/test_formatters_ruff.py b/src/darker/tests/test_formatters_ruff.py new file mode 100644 index 000000000..e9169cb1d --- /dev/null +++ b/src/darker/tests/test_formatters_ruff.py @@ -0,0 +1,66 @@ +"""Unit tests for `darker.formatters.ruff_formatter`.""" + +# pylint: disable=redefined-outer-name + +from subprocess import run # nosec +from textwrap import dedent +from unittest.mock import patch + +import pytest + +from darker.formatters import ruff_formatter + + +def test_get_supported_target_versions(): + """`ruff_formatter._get_supported_target_versions` runs Ruff, gets py versions.""" + with patch.object(ruff_formatter, "run") as run_mock: + run_mock.return_value.stdout = dedent( + """ + Default value: "py38" + Type: "py37" | "py38" | "py39" | "py310" | "py311" | "py312" + Example usage: + """ + ) + + # pylint: disable=protected-access + result = ruff_formatter._get_supported_target_versions() # noqa: SLF001 + + assert result == { + (3, 7): "py37", + (3, 8): "py38", + (3, 9): "py39", + (3, 10): "py310", + (3, 11): "py311", + (3, 12): "py312", + } + + +@pytest.fixture +def ruff(): + """Make a Ruff call and return the `subprocess.CompletedProcess` instance.""" + cmdline = [ + "ruff", + "format", + "--force-exclude", # apply `exclude =` from conffile even with stdin + "--stdin-filename=myfile.py", # allow to match exclude patterns + '--config=lint.ignore=["ISC001"]', + "-", + ] + return run( # noqa: S603 # nosec + cmdline, input="print( 1)\n", capture_output=True, check=False, text=True + ) + + +def test_ruff_returncode(ruff): + """A basic Ruff subprocess call returns a zero returncode.""" + assert ruff.returncode == 0 + + +def test_ruff_stderr(ruff): + """A basic Ruff subprocess call prints nothing on standard error.""" + assert ruff.stderr == "" + + +def test_ruff_stdout(ruff): + """A basic Ruff subprocess call prints the reformatted file on standard output.""" + assert ruff.stdout == "print(1)\n" diff --git a/src/darker/tests/test_main.py b/src/darker/tests/test_main.py index 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 diff --git a/stubs/pyupgrade/__init__.pyi b/stubs/pyupgrade/__init__.pyi new file mode 100644 index 000000000..e69de29bb diff --git a/stubs/pyupgrade/_data.pyi b/stubs/pyupgrade/_data.pyi new file mode 100644 index 000000000..c9837d99b --- /dev/null +++ b/stubs/pyupgrade/_data.pyi @@ -0,0 +1,15 @@ +"""Type stubs for bits used from `pyupgrade._data`. + +Can be removed if https://github.com/asottile/pyupgrade/issues/977 is resolved. + +""" + +from typing import NamedTuple + +Version = tuple[int, ...] + +class Settings(NamedTuple): + min_version: Version = ... + keep_percent_format: bool = ... + keep_mock: bool = ... + keep_runtime_typing: bool = ... diff --git a/stubs/pyupgrade/_main.pyi b/stubs/pyupgrade/_main.pyi new file mode 100644 index 000000000..e6b7734a9 --- /dev/null +++ b/stubs/pyupgrade/_main.pyi @@ -0,0 +1,13 @@ +"""Type stubs for bits used from `pyupgrade._main`. + +Can be removed if https://github.com/asottile/pyupgrade/issues/977 is resolved. + +""" + +from typing import Sequence + +from pyupgrade._data import Settings + +def _fix_plugins(contents_text: str, settings: Settings) -> str: ... +def _fix_tokens(contents_text: str) -> str: ... +def main(argv: Sequence[str] | None = None) -> int: ...