Repository: pantsbuild/pants

Issue:
Support code formatting using Ruff
**Is your feature request related to a problem? Please describe.**

A Ruff formatter was [just released](https://astral.sh/blog/the-ruff-formatter) and it is _allegedly_ much more performant than Black according to the post. It would be nice if we could run formatting using Ruff via Pants.

**Describe the solution you'd like**

We should be able to use the Ruff formatter as part of the Ruff integration, as an alternative to Black.

**Describe alternatives you've considered**

N/A

**Additional context**

I'm happy to work on this feature myself!



Discussion:
It looks like I can't actually assign the issue to myself, but feel free to assign this issue to me!
Yayyy, finally, I've been waiting for this since I first saw Ruff. 
We already run some fixing via ruff no? So this shouldn't be hard?
I don't think it'll be too hard - we should be able to add on to the existing Ruff linter integration for this.

Resolving patch:
diff --git a/src/python/pants/backend/python/lint/ruff/rules.py b/src/python/pants/backend/python/lint/ruff/rules.py
--- a/src/python/pants/backend/python/lint/ruff/rules.py
+++ b/src/python/pants/backend/python/lint/ruff/rules.py
@@ -4,12 +4,13 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
-from typing import Any
+from typing import Any, Tuple
 
-from pants.backend.python.lint.ruff.subsystem import Ruff, RuffFieldSet
+from pants.backend.python.lint.ruff.subsystem import Ruff, RuffFieldSet, RuffMode
 from pants.backend.python.util_rules import pex
 from pants.backend.python.util_rules.pex import PexRequest, VenvPex, VenvPexProcess
 from pants.core.goals.fix import FixResult, FixTargetsRequest
+from pants.core.goals.fmt import FmtResult, FmtTargetsRequest
 from pants.core.goals.lint import LintResult, LintTargetsRequest
 from pants.core.util_rules.config_files import ConfigFiles, ConfigFilesRequest
 from pants.core.util_rules.partitions import PartitionerType
@@ -39,10 +40,16 @@ class RuffLintRequest(LintTargetsRequest):
     partitioner_type = PartitionerType.DEFAULT_SINGLE_PARTITION
 
 
+class RuffFormatRequest(FmtTargetsRequest):
+    field_set_type = RuffFieldSet
+    tool_subsystem = Ruff
+    partitioner_type = PartitionerType.DEFAULT_SINGLE_PARTITION
+
+
 @dataclass(frozen=True)
 class _RunRuffRequest:
     snapshot: Snapshot
-    is_fix: bool
+    mode: RuffMode
 
 
 @rule(level=LogLevel.DEBUG)
@@ -64,8 +71,20 @@ async def run_ruff(
     )
 
     conf_args = [f"--config={ruff.config}"] if ruff.config else []
+
+    extra_initial_args: Tuple[str, ...] = ("check",)
+    if request.mode == RuffMode.FORMAT:
+        extra_initial_args = ("format",)
+    elif request.mode == RuffMode.FIX:
+        extra_initial_args = (
+            "check",
+            "--fix",
+        )
+
     # `--force-exclude` applies file excludes from config to files provided explicitly
-    initial_args = ("--force-exclude",) + (("--fix",) if request.is_fix else ())
+    # The format argument must be passed before force-exclude if Ruff is used for formatting.
+    # For other cases, the flags should work the same regardless of the order.
+    initial_args = extra_initial_args + ("--force-exclude",)
 
     result = await Get(
         FallibleProcessResult,
@@ -84,7 +103,7 @@ async def run_ruff(
 @rule(desc="Fix with ruff", level=LogLevel.DEBUG)
 async def ruff_fix(request: RuffFixRequest.Batch, ruff: Ruff) -> FixResult:
     result = await Get(
-        FallibleProcessResult, _RunRuffRequest(snapshot=request.snapshot, is_fix=True)
+        FallibleProcessResult, _RunRuffRequest(snapshot=request.snapshot, mode=RuffMode.FIX)
     )
     return await FixResult.create(request, result)
 
@@ -95,15 +114,24 @@ async def ruff_lint(request: RuffLintRequest.Batch[RuffFieldSet, Any]) -> LintRe
         SourceFiles, SourceFilesRequest(field_set.source for field_set in request.elements)
     )
     result = await Get(
-        FallibleProcessResult, _RunRuffRequest(snapshot=source_files.snapshot, is_fix=False)
+        FallibleProcessResult, _RunRuffRequest(snapshot=source_files.snapshot, mode=RuffMode.LINT)
     )
     return LintResult.create(request, result)
 
 
+@rule(desc="Format with ruff", level=LogLevel.DEBUG)
+async def ruff_fmt(request: RuffFormatRequest.Batch, ruff: Ruff) -> FmtResult:
+    result = await Get(
+        FallibleProcessResult, _RunRuffRequest(snapshot=request.snapshot, mode=RuffMode.FORMAT)
+    )
+    return await FmtResult.create(request, result)
+
+
 def rules():
     return [
         *collect_rules(),
         *RuffFixRequest.rules(),
         *RuffLintRequest.rules(),
+        *RuffFormatRequest.rules(),
         *pex.rules(),
     ]
diff --git a/src/python/pants/backend/python/lint/ruff/subsystem.py b/src/python/pants/backend/python/lint/ruff/subsystem.py
--- a/src/python/pants/backend/python/lint/ruff/subsystem.py
+++ b/src/python/pants/backend/python/lint/ruff/subsystem.py
@@ -5,6 +5,7 @@
 
 import os.path
 from dataclasses import dataclass
+from enum import Enum
 from typing import Iterable
 
 from pants.backend.python.lint.ruff.skip_field import SkipRuffField
@@ -36,6 +37,12 @@ def opt_out(cls, tgt: Target) -> bool:
         return tgt.get(SkipRuffField).value
 
 
+class RuffMode(str, Enum):
+    FIX = "fix"
+    FORMAT = "fmt"
+    LINT = "lint"
+
+
 # --------------------------------------------------------------------------------------
 # Subsystem
 # --------------------------------------------------------------------------------------
@@ -44,16 +51,16 @@ def opt_out(cls, tgt: Target) -> bool:
 class Ruff(PythonToolBase):
     options_scope = "ruff"
     name = "Ruff"
-    help = "The Ruff Python formatter (https://github.com/charliermarsh/ruff)."
+    help = "The Ruff Python formatter (https://github.com/astral-sh/ruff)."
 
     default_main = ConsoleScript("ruff")
-    default_requirements = ["ruff>=0.0.213,<1"]
+    default_requirements = ["ruff>=0.1.2,<1"]
 
     register_interpreter_constraints = True
 
     default_lockfile_resource = ("pants.backend.python.lint.ruff", "ruff.lock")
 
-    skip = SkipOption("fmt", "lint")
+    skip = SkipOption("fmt", "fix", "lint")
     args = ArgsListOption(example="--exclude=foo --ignore=E501")
     config = FileOption(
         default=None,
@@ -61,7 +68,7 @@ class Ruff(PythonToolBase):
         help=softwrap(
             f"""
             Path to the `pyproject.toml` or `ruff.toml` file to use for configuration
-            (https://github.com/charliermarsh/ruff#configuration).
+            (https://github.com/astral-sh/ruff#configuration).
 
             Setting this option will disable `[{options_scope}].config_discovery`. Use
             this option if the config is located in a non-standard location.
@@ -83,7 +90,7 @@ class Ruff(PythonToolBase):
     )
 
     def config_request(self, dirs: Iterable[str]) -> ConfigFilesRequest:
-        # See https://github.com/charliermarsh/ruff#configuration for how ruff discovers
+        # See https://github.com/astral-sh/ruff#configuration for how ruff discovers
         # config files.
         return ConfigFilesRequest(
             specified=self.config,


Test patch:
diff --git a/src/python/pants/backend/python/lint/ruff/rules_integration_test.py b/src/python/pants/backend/python/lint/ruff/rules_integration_test.py
--- a/src/python/pants/backend/python/lint/ruff/rules_integration_test.py
+++ b/src/python/pants/backend/python/lint/ruff/rules_integration_test.py
@@ -9,12 +9,13 @@
 
 from pants.backend.python import target_types_rules
 from pants.backend.python.lint.ruff import skip_field
-from pants.backend.python.lint.ruff.rules import RuffFixRequest, RuffLintRequest
+from pants.backend.python.lint.ruff.rules import RuffFixRequest, RuffFormatRequest, RuffLintRequest
 from pants.backend.python.lint.ruff.rules import rules as ruff_rules
 from pants.backend.python.lint.ruff.subsystem import RuffFieldSet
 from pants.backend.python.lint.ruff.subsystem import rules as ruff_subsystem_rules
 from pants.backend.python.target_types import PythonSourcesGeneratorTarget
 from pants.core.goals.fix import FixResult
+from pants.core.goals.fmt import FmtResult
 from pants.core.goals.lint import LintResult
 from pants.core.util_rules import config_files
 from pants.core.util_rules.partitions import _EmptyMetadata
@@ -24,8 +25,9 @@
 from pants.testutil.python_interpreter_selection import all_major_minor_python_versions
 from pants.testutil.rule_runner import QueryRule, RuleRunner
 
-GOOD_FILE = 'a = "string without any placeholders"'
-BAD_FILE = 'a = f"string without any placeholders"'
+GOOD_FILE = 'a = "string without any placeholders"\n'
+BAD_FILE = 'a = f"string without any placeholders"\n'
+UNFORMATTED_FILE = 'a ="string without any placeholders"\n'
 
 
 @pytest.fixture
@@ -39,6 +41,7 @@ def rule_runner() -> RuleRunner:
             *target_types_rules.rules(),
             QueryRule(FixResult, [RuffFixRequest.Batch]),
             QueryRule(LintResult, [RuffLintRequest.Batch]),
+            QueryRule(FmtResult, [RuffFormatRequest.Batch]),
             QueryRule(SourceFiles, (SourceFilesRequest,)),
         ],
         target_types=[PythonSourcesGeneratorTarget],
@@ -50,7 +53,7 @@ def run_ruff(
     targets: list[Target],
     *,
     extra_args: list[str] | None = None,
-) -> tuple[FixResult, LintResult]:
+) -> tuple[FixResult, LintResult, FmtResult]:
     args = ["--backend-packages=pants.backend.python.lint.ruff", *(extra_args or ())]
     rule_runner.set_options(args, env_inherit={"PATH", "PYENV_ROOT", "HOME"})
 
@@ -79,8 +82,19 @@ def run_ruff(
             ),
         ],
     )
+    fmt_result = rule_runner.request(
+        FmtResult,
+        [
+            RuffFormatRequest.Batch(
+                "",
+                input_sources.snapshot.files,
+                partition_metadata=None,
+                snapshot=input_sources.snapshot,
+            )
+        ],
+    )
 
-    return fix_result, lint_result
+    return fix_result, lint_result, fmt_result
 
 
 @pytest.mark.platform_specific_behavior
@@ -91,7 +105,7 @@ def run_ruff(
 def test_passing(rule_runner: RuleRunner, major_minor_interpreter: str) -> None:
     rule_runner.write_files({"f.py": GOOD_FILE, "BUILD": "python_sources(name='t')"})
     tgt = rule_runner.get_target(Address("", target_name="t", relative_file_path="f.py"))
-    fix_result, lint_result = run_ruff(
+    fix_result, lint_result, fmt_result = run_ruff(
         rule_runner,
         [tgt],
         extra_args=[f"--python-interpreter-constraints=['=={major_minor_interpreter}.*']"],
@@ -101,17 +115,21 @@ def test_passing(rule_runner: RuleRunner, major_minor_interpreter: str) -> None:
     assert fix_result.stdout == ""
     assert not fix_result.did_change
     assert fix_result.output == rule_runner.make_snapshot({"f.py": GOOD_FILE})
+    assert not fmt_result.did_change
+    assert fmt_result.output == rule_runner.make_snapshot({"f.py": GOOD_FILE})
 
 
 def test_failing(rule_runner: RuleRunner) -> None:
     rule_runner.write_files({"f.py": BAD_FILE, "BUILD": "python_sources(name='t')"})
     tgt = rule_runner.get_target(Address("", target_name="t", relative_file_path="f.py"))
-    fix_result, lint_result = run_ruff(rule_runner, [tgt])
+    fix_result, lint_result, fmt_result = run_ruff(rule_runner, [tgt])
     assert lint_result.exit_code == 1
     assert fix_result.stdout == "Found 1 error (1 fixed, 0 remaining).\n"
     assert fix_result.stderr == ""
     assert fix_result.did_change
     assert fix_result.output == rule_runner.make_snapshot({"f.py": GOOD_FILE})
+    assert not fmt_result.did_change
+    assert fmt_result.output == rule_runner.make_snapshot({"f.py": BAD_FILE})
 
 
 def test_multiple_targets(rule_runner: RuleRunner) -> None:
@@ -119,19 +137,25 @@ def test_multiple_targets(rule_runner: RuleRunner) -> None:
         {
             "good.py": GOOD_FILE,
             "bad.py": BAD_FILE,
+            "unformatted.py": UNFORMATTED_FILE,
             "BUILD": "python_sources(name='t')",
         }
     )
     tgts = [
         rule_runner.get_target(Address("", target_name="t", relative_file_path="good.py")),
         rule_runner.get_target(Address("", target_name="t", relative_file_path="bad.py")),
+        rule_runner.get_target(Address("", target_name="t", relative_file_path="unformatted.py")),
     ]
-    fix_result, lint_result = run_ruff(rule_runner, tgts)
+    fix_result, lint_result, fmt_result = run_ruff(rule_runner, tgts)
     assert lint_result.exit_code == 1
     assert fix_result.output == rule_runner.make_snapshot(
-        {"good.py": GOOD_FILE, "bad.py": GOOD_FILE}
+        {"good.py": GOOD_FILE, "bad.py": GOOD_FILE, "unformatted.py": UNFORMATTED_FILE}
     )
     assert fix_result.did_change is True
+    assert fmt_result.output == rule_runner.make_snapshot(
+        {"good.py": GOOD_FILE, "bad.py": BAD_FILE, "unformatted.py": GOOD_FILE}
+    )
+    assert fmt_result.did_change is True
 
 
 @pytest.mark.parametrize(
@@ -163,6 +187,6 @@ def test_config_file(
     rel_file_path = file_path.relative_to(*file_path.parts[:1]) if spec_path else file_path
     addr = Address(spec_path, relative_file_path=str(rel_file_path))
     tgt = rule_runner.get_target(addr)
-    fix_result, lint_result = run_ruff(rule_runner, [tgt], extra_args=extra_args)
+    fix_result, lint_result, fmt_result = run_ruff(rule_runner, [tgt], extra_args=extra_args)
     assert lint_result.exit_code == bool(should_change)
     assert fix_result.did_change is should_change