Skip to content

Commit

Permalink
Merge pull request #744 from akaihola/ruff-plugin
Browse files Browse the repository at this point in the history
Add support for Ruff as a code-formatter
  • Loading branch information
akaihola authored Jan 2, 2025
2 parents d3f96a2 + 2beb0ad commit 816b06f
Show file tree
Hide file tree
Showing 18 changed files with 830 additions and 170 deletions.
7 changes: 6 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ You can enable additional features with command line options:
- ``-f`` / ``--flynt``: Also convert string formatting to use f-strings using the
``flynt`` package

If you only want to run those tools without reformatting with Black,
use the ``--formatter=none`` option.

*New in version 1.1.0:* The ``-L`` / ``--lint`` option.

*New in version 1.2.2:* Package available in conda-forge_.
Expand All @@ -176,6 +179,8 @@ You can enable additional features with command line options:
*New in version 3.0.0:* Removed the ``-L`` / ``--lint`` functionality and moved it into
the Graylint_ package.

*New in version 3.0.0:* The ``--formatter`` option.

.. _Conda: https://conda.io/
.. _conda-forge: https://conda-forge.org/

Expand Down Expand Up @@ -373,7 +378,7 @@ The following `command line arguments`_ can also be used to modify the defaults:
versions that should be supported by Black's output. [default: per-file auto-
detection]
--formatter FORMATTER
[black\|none] Formatter to use for reformatting code. [default: black]
[black\|none\|ruff] Formatter to use for reformatting code. [default: black]

To change default values for these options for a given project,
add a ``[tool.darker]`` section to ``pyproject.toml`` in the project's root directory,
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ target-version = "py38"
select = ["ALL"]
ignore = [
"A002", # builtin-argument-shadowing
"ANN101", # Missing type annotation for `self` in method
"COM812", # Trailing comma missing
"D203", # One blank line required before class docstring
"D213", # Multi-line docstring summary should start at the second line
Expand Down
7 changes: 7 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ darker =
[options.entry_points]
darker.formatter =
black = darker.formatters.black_formatter:BlackFormatter
ruff = darker.formatters.ruff_formatter:RuffFormatter
none = darker.formatters.none_formatter:NoneFormatter
console_scripts =
darker = darker.__main__:main_with_error_handling
Expand Down Expand Up @@ -96,8 +97,14 @@ ignore =
D400
# D415 First line should end with a period, question mark, or exclamation point
D415
# E203 Whitespace before ':'
E203
# E231 missing whitespace after ','
E231
# E501 Line too long (82 > 79 characters)
E501
# E701 Multiple statements on one line (colon)
E701
# W503 line break before binary operator
W503
# Darglint options when run as a Flake8 plugin:
Expand Down
2 changes: 1 addition & 1 deletion src/darker/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ def _maybe_reformat_single_file(
if glob_any(relpath_in_rev2, exclude):
# File was excluded by Black configuration, don't reformat
return fstringified
return formatter.run(fstringified)
return formatter.run(fstringified, relpath_in_rev2)


def _drop_changes_on_unedited_lines(
Expand Down
35 changes: 26 additions & 9 deletions src/darker/formatters/base_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,53 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Pattern
from typing import TYPE_CHECKING, Generic, Pattern, TypeVar

from darker.files import find_pyproject_toml
from darker.formatters.formatter_config import FormatterConfig

if TYPE_CHECKING:
from argparse import Namespace
from pathlib import Path

from darker.formatters.formatter_config import FormatterConfig
from darkgraylib.utils import TextDocument


class BaseFormatter:
T = TypeVar("T", bound=FormatterConfig)


class HasConfig(Generic[T]): # pylint: disable=too-few-public-methods
"""Base class for code re-formatters."""

def __init__(self) -> None:
"""Initialize the code re-formatter plugin base class."""
self.config: FormatterConfig = {}
self.config = {} # type: ignore[var-annotated]


class BaseFormatter(HasConfig[FormatterConfig]):
"""Base class for code re-formatters."""

name: str

def read_config(self, src: tuple[str, ...], args: Namespace) -> None:
"""Read the formatter configuration from a configuration file
If not implemented by the subclass, this method does nothing, so the formatter
has no configuration options.
"""Read code re-formatter configuration from a configuration file.
:param src: The source code files and directories to be processed by Darker
:param args: Command line arguments
"""
config_path = args.config or find_pyproject_toml(src)
if config_path:
self._read_config_file(config_path)
self._read_cli_args(args)

def run(self, content: TextDocument) -> TextDocument:
def run(self, content: TextDocument, path_from_cwd: Path) -> TextDocument:
"""Reformat the content."""
raise NotImplementedError

def _read_cli_args(self, args: Namespace) -> None:
pass

def get_config_path(self) -> str | None:
"""Get the path of the configuration file."""
return None
Expand All @@ -60,3 +74,6 @@ def __eq__(self, other: object) -> bool:
if not isinstance(other, BaseFormatter):
return NotImplemented
return type(self) is type(other) and self.config == other.config

def _read_config_file(self, config_path: str) -> None:
pass
69 changes: 34 additions & 35 deletions src/darker/formatters/black_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")')
Expand All @@ -40,19 +40,23 @@
from typing import TYPE_CHECKING, TypedDict

from darker.files import find_pyproject_toml
from darker.formatters.base_formatter import BaseFormatter
from darker.formatters.base_formatter import BaseFormatter, HasConfig
from darker.formatters.formatter_config import (
BlackCompatibleConfig,
read_black_compatible_cli_args,
validate_target_versions,
)
from darkgraylib.config import ConfigurationError
from darkgraylib.utils import TextDocument

if TYPE_CHECKING:
from argparse import Namespace
from pathlib import Path
from typing import Pattern

from black import FileMode as Mode
from black import TargetVersion

from darker.formatters.formatter_config import BlackConfig


logger = logging.getLogger(__name__)

Expand All @@ -68,14 +72,13 @@ class BlackModeAttributes(TypedDict, total=False):
preview: bool


class BlackFormatter(BaseFormatter):
class BlackFormatter(BaseFormatter, HasConfig[BlackCompatibleConfig]):
"""Black code formatter plugin interface."""

def __init__(self) -> None: # pylint: disable=super-init-not-called
"""Initialize the Black code re-formatter plugin."""
self.config: BlackConfig = {}
config: BlackCompatibleConfig # type: ignore[assignment]

name = "black"
config_section = "tool.black"

def read_config(self, src: tuple[str, ...], args: Namespace) -> None:
"""Read Black configuration from ``pyproject.toml``.
Expand Down Expand Up @@ -114,10 +117,15 @@ def _read_config_file(self, config_path: str) -> None: # noqa: C901
if "target_version" in raw_config:
target_version = raw_config["target_version"]
if isinstance(target_version, str):
self.config["target_version"] = target_version
self.config["target_version"] = (
int(target_version[2]),
int(target_version[3:]),
)
elif isinstance(target_version, list):
# Convert TOML list to a Python set
self.config["target_version"] = set(target_version)
# Convert TOML list to a Python set of int-tuples
self.config["target_version"] = {
(int(v[2]), int(v[3:])) for v in target_version
}
else:
message = (
f"Invalid target-version = {target_version!r} in {config_path}"
Expand All @@ -135,23 +143,16 @@ def _read_config_file(self, config_path: str) -> None: # noqa: C901
)

def _read_cli_args(self, args: Namespace) -> None:
if args.config:
self.config["config"] = args.config
if getattr(args, "line_length", None):
self.config["line_length"] = args.line_length
if getattr(args, "target_version", None):
self.config["target_version"] = {args.target_version}
if getattr(args, "skip_string_normalization", None) is not None:
self.config["skip_string_normalization"] = args.skip_string_normalization
if getattr(args, "skip_magic_trailing_comma", None) is not None:
self.config["skip_magic_trailing_comma"] = args.skip_magic_trailing_comma
if getattr(args, "preview", None):
self.config["preview"] = args.preview

def run(self, content: TextDocument) -> TextDocument:
return read_black_compatible_cli_args(args, self.config)

def run(
self, content: TextDocument, path_from_cwd: Path # noqa: ARG002
) -> TextDocument:
"""Run the Black code re-formatter for the Python source code given as a string.
:param content: The source code
:param path_from_cwd: The path to the source code file being reformatted, either
absolute or relative to the current working directory
:return: The reformatted content
"""
Expand Down Expand Up @@ -191,15 +192,13 @@ def _make_black_options(self) -> Mode:
if "line_length" in self.config:
mode["line_length"] = self.config["line_length"]
if "target_version" in self.config:
if isinstance(self.config["target_version"], set):
target_versions_in = self.config["target_version"]
else:
target_versions_in = {self.config["target_version"]}
all_target_versions = {tgt_v.name.lower(): tgt_v for tgt_v in TargetVersion}
bad_target_versions = target_versions_in - set(all_target_versions)
if bad_target_versions:
message = f"Invalid target version(s) {bad_target_versions}"
raise ConfigurationError(message)
all_target_versions = {
(int(tgt_v.name[2]), int(tgt_v.name[3:])): tgt_v
for tgt_v in TargetVersion
}
target_versions_in = validate_target_versions(
self.config["target_version"], all_target_versions
)
mode["target_versions"] = {
all_target_versions[n] for n in target_versions_in
}
Expand Down
49 changes: 45 additions & 4 deletions src/darker/formatters/formatter_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion src/darker/formatters/none_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

if TYPE_CHECKING:
from argparse import Namespace
from pathlib import Path

from darkgraylib.utils import TextDocument

Expand All @@ -17,10 +18,14 @@ class NoneFormatter(BaseFormatter):

name = "dummy reformat"

def run(self, content: TextDocument) -> TextDocument:
def run(
self, content: TextDocument, path_from_cwd: Path # noqa: ARG002
) -> TextDocument:
"""Return the Python source code unmodified.
:param content: The source code
:param path_from_cwd: The path to the source code file being reformatted, either
absolute or relative to the current working directory
:return: The source code unmodified
"""
Expand Down
Loading

0 comments on commit 816b06f

Please sign in to comment.