• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pantsbuild / pants / 18517631058

15 Oct 2025 04:18AM UTC coverage: 69.207% (-11.1%) from 80.267%
18517631058

Pull #22745

github

web-flow
Merge 642a76ca1 into 99919310e
Pull Request #22745: [windows] Add windows support in the stdio crate.

53815 of 77759 relevant lines covered (69.21%)

2.42 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

68.39
/src/python/pants/core/goals/fix.py
1
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
7✔
5

6
import itertools
7✔
7
import logging
7✔
8
from collections import defaultdict
7✔
9
from collections.abc import Callable, Coroutine, Iterable, Iterator, Sequence
7✔
10
from dataclasses import dataclass
7✔
11
from typing import Any, ClassVar, NamedTuple, Protocol, TypeVar
7✔
12

13
from pants.base.specs import Specs
7✔
14
from pants.core.goals.lint import (
7✔
15
    AbstractLintRequest,
16
    LintFilesRequest,
17
    LintResult,
18
    LintTargetsRequest,
19
    _MultiToolGoalSubsystem,
20
    get_partitions_by_request_type,
21
)
22
from pants.core.goals.multi_tool_goal_helper import BatchSizeOption, OnlyOption
7✔
23
from pants.core.util_rules.partitions import PartitionerType, PartitionMetadataT
7✔
24
from pants.core.util_rules.partitions import Partitions as UntypedPartitions
7✔
25
from pants.engine.collection import Collection
7✔
26
from pants.engine.console import Console
7✔
27
from pants.engine.engine_aware import EngineAwareReturnType
7✔
28
from pants.engine.environment import EnvironmentName
7✔
29
from pants.engine.fs import MergeDigests, PathGlobs, Snapshot, SnapshotDiff, Workspace
7✔
30
from pants.engine.goal import Goal, GoalSubsystem
7✔
31
from pants.engine.intrinsics import digest_to_snapshot, merge_digests
7✔
32
from pants.engine.process import FallibleProcessResult, ProcessResult
7✔
33
from pants.engine.rules import collect_rules, concurrently, goal_rule, implicitly, rule
7✔
34
from pants.engine.unions import UnionMembership, UnionRule, distinct_union_type_per_subclass, union
7✔
35
from pants.option.option_types import BoolOption
7✔
36
from pants.util.collections import partition_sequentially
7✔
37
from pants.util.docutil import bin_name, doc_url
7✔
38
from pants.util.logging import LogLevel
7✔
39
from pants.util.ordered_set import FrozenOrderedSet
7✔
40
from pants.util.strutil import Simplifier, softwrap
7✔
41

42
logger = logging.getLogger(__name__)
7✔
43

44

45
@dataclass(frozen=True)
7✔
46
class FixResult(EngineAwareReturnType):
7✔
47
    input: Snapshot
7✔
48
    output: Snapshot
7✔
49
    stdout: str
7✔
50
    stderr: str
7✔
51
    tool_name: str
7✔
52

53
    @staticmethod
7✔
54
    async def create(
7✔
55
        request: AbstractFixRequest.Batch,
56
        process_result: ProcessResult | FallibleProcessResult,
57
        *,
58
        output_simplifier: Simplifier = Simplifier(),
59
    ) -> FixResult:
60
        return FixResult(
×
61
            input=request.snapshot,
62
            output=await digest_to_snapshot(process_result.output_digest),
63
            stdout=output_simplifier.simplify(process_result.stdout),
64
            stderr=output_simplifier.simplify(process_result.stderr),
65
            tool_name=request.tool_name,
66
        )
67

68
    def __post_init__(self):
7✔
69
        # NB: We debug log stdout/stderr because `message` doesn't log it.
70
        log = f"Output from {self.tool_name}"
1✔
71
        if self.stdout:
1✔
72
            log += f"\n{self.stdout}"
1✔
73
        if self.stderr:
1✔
74
            log += f"\n{self.stderr}"
1✔
75
        logger.debug(log)
1✔
76

77
    @property
7✔
78
    def did_change(self) -> bool:
7✔
79
        return self.output != self.input
7✔
80

81
    def level(self) -> LogLevel | None:
7✔
82
        return LogLevel.WARN if self.did_change else LogLevel.INFO
1✔
83

84
    def message(self) -> str | None:
7✔
85
        message = "made changes." if self.did_change else "made no changes."
1✔
86

87
        # NB: Instead of printing out `stdout` and `stderr`, we just print a list of files which
88
        # were changed/added/removed. We do this for two reasons:
89
        #   1. This is run as part of both `fmt`/`fix` and `lint`, and we want consistent output between both
90
        #   2. Different tools have different stdout/stderr. This way is consistent across all tools.
91
        if self.did_change:
1✔
92
            snapshot_diff = SnapshotDiff.from_snapshots(self.input, self.output)
1✔
93
            output = "".join(
1✔
94
                f"\n  {file}"
95
                for file in itertools.chain(
96
                    snapshot_diff.changed_files,
97
                    snapshot_diff.their_unique_files,  # added files
98
                    snapshot_diff.our_unique_files,  # removed files
99
                    # NB: there is no rename detection, so a rename will list
100
                    # both the old filename (removed) and the new filename (added).
101
                )
102
            )
103
        else:
104
            output = ""
1✔
105

106
        return f"{self.tool_name} {message}{output}"
1✔
107

108
    def cacheable(self) -> bool:
7✔
109
        """Is marked uncacheable to ensure that it always renders."""
110
        return False
×
111

112

113
Partitions = UntypedPartitions[str, PartitionMetadataT]
7✔
114

115

116
@union
7✔
117
class AbstractFixRequest(AbstractLintRequest):
7✔
118
    is_fixer = True
7✔
119

120
    # Enable support for re-using this request's rule in `lint`, where the success/failure of the linter corresponds to
121
    # whether the rule's output matches the input (i.e. whether the tool made changes or not).
122
    #
123
    # If you set this to `False`, you'll need to provide the following `UnionRule` with a custom class,
124
    # as well as their corresponding implementation rules:
125
    #   - `UnionRule(AbstractLintRequest, cls)`
126
    #   - `UnionRule(AbstractLintRequest.Batch, cls)`
127
    #
128
    # !!! Setting this to `False` should be exceedingly rare, as the default implementation handles two important things:
129
    #   - Re-use of the exact same process in `fix` as in `lint`, so runs like `pants fix lint` use
130
    #     cached/memoized results in `lint`. This pattern is commonly used by developers locally.
131
    #   - Ensuring that `pants lint` is checking that the file(s) are actually fixed. It's easy to forget to provide the
132
    #     `lint` implementation (which is used usually in CI, as opposed to `fix`), which allows files to be merged
133
    #     into the default branch un-fixed. (Fun fact, this happened in the Pants codebase before this inheritance existed
134
    #     and was the catalysts for this design).
135
    # The case for disabling this is when the `fix` implementation fixes a strict subset of some `lint` implementation, where
136
    # the check for is-this-fixed in the `lint` implementation isn't possible.
137
    # As an example, let's say tool `cruft` has `cruft lint` which lints for A, B and C. It also has `cruft lint --fix` which fixes A.
138
    # Tthere's no way to not check for `A` in `cruft lint`. Since you're already going to provide a `lint` implementation
139
    # which corresponds to `cruft lint`, there's no point in running `cruft check --fix` in `lint` as it's already covered by
140
    # `cruft lint`.
141
    enable_lint_rules: ClassVar[bool] = True
7✔
142

143
    @distinct_union_type_per_subclass(in_scope_types=[EnvironmentName])
7✔
144
    @dataclass(frozen=True)
7✔
145
    class Batch(AbstractLintRequest.Batch):
7✔
146
        snapshot: Snapshot
7✔
147

148
        @property
7✔
149
        def files(self) -> tuple[str, ...]:
7✔
150
            return tuple(FrozenOrderedSet(self.elements))
×
151

152
    @classmethod
7✔
153
    def _get_rules(cls) -> Iterable[UnionRule]:
7✔
154
        if cls.enable_lint_rules:
7✔
155
            yield from super()._get_rules()
7✔
156
        yield UnionRule(AbstractFixRequest, cls)
7✔
157
        yield UnionRule(AbstractFixRequest.Batch, cls.Batch)
7✔
158

159

160
class FixTargetsRequest(AbstractFixRequest, LintTargetsRequest):
7✔
161
    @classmethod
7✔
162
    def _get_rules(cls) -> Iterable:
7✔
163
        yield from cls.partitioner_type.default_rules(cls, by_file=True)
7✔
164
        yield from (
7✔
165
            rule
166
            for rule in super()._get_rules()
167
            # NB: We don't want to yield `lint.py`'s default partitioner
168
            if isinstance(rule, UnionRule)
169
        )
170
        yield UnionRule(FixTargetsRequest.PartitionRequest, cls.PartitionRequest)
7✔
171

172

173
class FixFilesRequest(AbstractFixRequest, LintFilesRequest):
7✔
174
    @classmethod
7✔
175
    def _get_rules(cls) -> Iterable:
7✔
176
        if cls.partitioner_type is not PartitionerType.CUSTOM:
6✔
177
            raise ValueError(
×
178
                "Pants does not provide default partitioners for `FixFilesRequest`."
179
                + " You will need to provide your own partitioner rule."
180
            )
181

182
        yield from super()._get_rules()
6✔
183
        yield UnionRule(FixFilesRequest.PartitionRequest, cls.PartitionRequest)
6✔
184

185

186
class _FixBatchElement(NamedTuple):
7✔
187
    request_type: type[AbstractFixRequest.Batch]
7✔
188
    tool_name: str
7✔
189
    files: tuple[str, ...]
7✔
190
    key: Any
7✔
191

192

193
class _FixBatchRequest(Collection[_FixBatchElement]):
7✔
194
    """Request to sequentially fix all the elements in the given batch."""
195

196

197
@dataclass(frozen=True)
7✔
198
class _FixBatchResult:
7✔
199
    results: tuple[FixResult, ...]
7✔
200

201
    @property
7✔
202
    def did_change(self) -> bool:
7✔
203
        return any(result.did_change for result in self.results)
×
204

205

206
class FixSubsystem(GoalSubsystem):
7✔
207
    name = "fix"
7✔
208
    help = softwrap(
7✔
209
        f"""
210
        Autofix source code.
211

212
        This goal runs tools that make 'semantic' changes to source code, where the meaning of the
213
        code may change.
214

215
        See also:
216

217
        - [The `fmt` goal]({doc_url("reference/goals/fix")} will run code-editing tools that may make only
218
          syntactic changes, not semantic ones. The `fix` includes running these `fmt` tools by
219
          default (see [the `skip_formatters` option](#skip_formatters) to control this).
220

221
        - [The `lint` goal]({doc_url("reference/goals/lint")}) will validate code is formatted, by running these
222
          fixers and checking there's no change.
223

224
        - Documentation about formatters for various ecosystems, such as:
225
          [Python]({doc_url("docs/python/overview/linters-and-formatters")}), [JVM]({doc_url("jvm/java-and-scala#lint-and-format")}),
226
          [SQL]({doc_url("docs/sql#enable-sqlfluff-linter")})
227
        """
228
    )
229

230
    @classmethod
7✔
231
    def activated(cls, union_membership: UnionMembership) -> bool:
7✔
232
        return AbstractFixRequest in union_membership
×
233

234
    only = OnlyOption("fixer", "autoflake", "pyupgrade")
7✔
235
    skip_formatters = BoolOption(
7✔
236
        default=False,
237
        help=softwrap(
238
            f"""
239
            If true, skip running all formatters.
240

241
            FYI: when running `{bin_name()} fix fmt ::`, there should be diminishing performance
242
            benefit to using this flag. Pants attempts to reuse the results from `fmt` when running
243
            `fix` where possible.
244
            """
245
        ),
246
    )
247
    batch_size = BatchSizeOption(uppercase="Fixer", lowercase="fixer")
7✔
248

249

250
class Fix(Goal):
7✔
251
    subsystem_cls = FixSubsystem
7✔
252
    environment_behavior = Goal.EnvironmentBehavior.LOCAL_ONLY
7✔
253

254

255
async def _write_files(workspace: Workspace, batched_results: Iterable[_FixBatchResult]):
7✔
256
    if any(batched_result.did_change for batched_result in batched_results):
×
257
        # NB: this will fail if there are any conflicting changes, which we want to happen rather
258
        # than silently having one result override the other. In practice, this should never
259
        # happen due to us grouping each file's tools into a single digest.
260
        merged_digest = await merge_digests(
×
261
            MergeDigests(
262
                batched_result.results[-1].output.digest for batched_result in batched_results
263
            )
264
        )
265
        workspace.write_digest(merged_digest)
×
266

267

268
def _print_results(
7✔
269
    console: Console,
270
    results: Iterable[FixResult],
271
):
272
    if results:
×
273
        console.print_stderr("")
×
274

275
    # We group all results for the same tool so that we can give one final status in the
276
    # summary. This is only relevant if there were multiple results because of
277
    # `--per-file-caching`.
278
    tool_to_results = defaultdict(set)
×
279
    for result in results:
×
280
        tool_to_results[result.tool_name].add(result)
×
281

282
    for tool, results in sorted(tool_to_results.items()):
×
283
        if any(result.did_change for result in results):
×
284
            sigil = console.sigil_succeeded_with_edits()
×
285
            status = "made changes"
×
286
        else:
287
            sigil = console.sigil_succeeded()
×
288
            status = "made no changes"
×
289
        console.print_stderr(f"{sigil} {tool} {status}.")
×
290

291

292
_CoreRequestType = TypeVar("_CoreRequestType", bound=AbstractFixRequest)
7✔
293
_TargetPartitioner = TypeVar("_TargetPartitioner", bound=FixTargetsRequest.PartitionRequest)
7✔
294
_FilePartitioner = TypeVar("_FilePartitioner", bound=FixFilesRequest.PartitionRequest)
7✔
295
_GoalT = TypeVar("_GoalT", bound=Goal)
7✔
296

297

298
class _BatchableMultiToolGoalSubsystem(_MultiToolGoalSubsystem, Protocol):
7✔
299
    batch_size: BatchSizeOption
7✔
300

301

302
@rule(polymorphic=True)
7✔
303
async def fix_batch(batch: AbstractFixRequest.Batch) -> FixResult:
7✔
304
    raise NotImplementedError()
×
305

306

307
@rule
7✔
308
async def fix_batch_sequential(
7✔
309
    request: _FixBatchRequest,
310
) -> _FixBatchResult:
311
    current_snapshot = await digest_to_snapshot(
×
312
        **implicitly({PathGlobs(request[0].files): PathGlobs})
313
    )
314

315
    results = []
×
316
    for request_type, tool_name, files, key in request:
×
317
        batch = request_type(tool_name, files, key, current_snapshot)
×
318
        result = await fix_batch(  # noqa: PNT30: this is inherently sequential
×
319
            **implicitly({batch: AbstractFixRequest.Batch})
320
        )
321
        results.append(result)
×
322

323
        assert set(result.output.files) == set(batch.files), (
×
324
            f"Expected {result.output.files} to match {batch.files}"
325
        )
326
        current_snapshot = result.output
×
327
    return _FixBatchResult(tuple(results))
×
328

329

330
async def _do_fix(
7✔
331
    core_request_types: Iterable[type[_CoreRequestType]],
332
    target_partitioners: Iterable[type[_TargetPartitioner]],
333
    file_partitioners: Iterable[type[_FilePartitioner]],
334
    goal_cls: type[_GoalT],
335
    subsystem: _BatchableMultiToolGoalSubsystem,
336
    specs: Specs,
337
    workspace: Workspace,
338
    console: Console,
339
    make_targets_partition_request_get: Callable[
340
        [_TargetPartitioner], Coroutine[Any, Any, Partitions]
341
    ],
342
    make_files_partition_request_get: Callable[[_FilePartitioner], Coroutine[Any, Any, Partitions]],
343
) -> _GoalT:
344
    partitions_by_request_type = await get_partitions_by_request_type(
×
345
        core_request_types,
346
        target_partitioners,
347
        file_partitioners,
348
        subsystem,
349
        specs,
350
        make_targets_partition_request_get,
351
        make_files_partition_request_get,
352
    )
353

354
    if not partitions_by_request_type:
×
355
        return goal_cls(exit_code=0)
×
356

357
    def batch_by_size(files: Iterable[str]) -> Iterator[tuple[str, ...]]:
×
358
        batches = partition_sequentially(
×
359
            files,
360
            key=lambda x: str(x),
361
            size_target=subsystem.batch_size,
362
            size_max=4 * subsystem.batch_size,
363
        )
364
        for batch in batches:
×
365
            yield tuple(batch)
×
366

367
    def _make_disjoint_batch_requests() -> Iterable[_FixBatchRequest]:
×
368
        partition_infos: Iterable[tuple[type[AbstractFixRequest], Any]]
369
        files: Sequence[str]
370

371
        partition_infos_by_files = defaultdict(list)
×
372
        for request_type, partitions_list in partitions_by_request_type.items():
×
373
            for partitions in partitions_list:
×
374
                for partition in partitions:
×
375
                    for file in partition.elements:
×
376
                        partition_infos_by_files[file].append((request_type, partition.metadata))
×
377

378
        files_by_partition_info = defaultdict(list)
×
379
        for file, partition_infos in partition_infos_by_files.items():
×
380
            deduped_partition_infos = FrozenOrderedSet(partition_infos)
×
381
            files_by_partition_info[deduped_partition_infos].append(file)
×
382

383
        for partition_infos, files in files_by_partition_info.items():
×
384
            for batch in batch_by_size(files):
×
385
                yield _FixBatchRequest(
×
386
                    _FixBatchElement(
387
                        request_type.Batch,
388
                        request_type.tool_name,
389
                        batch,
390
                        partition_metadata,
391
                    )
392
                    for request_type, partition_metadata in partition_infos
393
                )
394

395
    all_results = await concurrently(
×
396
        fix_batch_sequential(request) for request in _make_disjoint_batch_requests()
397
    )
398

399
    individual_results = list(
×
400
        itertools.chain.from_iterable(result.results for result in all_results)
401
    )
402

403
    await _write_files(workspace, all_results)
×
404
    _print_results(console, individual_results)
×
405

406
    # Since the rules to produce FixResult should use ProcessResult, rather than
407
    # FallibleProcessResult, we assume that there were no failures.
408
    return goal_cls(exit_code=0)
×
409

410

411
@rule(polymorphic=True)
7✔
412
async def partition_targets(req: FixTargetsRequest.PartitionRequest) -> Partitions:
7✔
413
    raise NotImplementedError()
×
414

415

416
@rule(polymorphic=True)
7✔
417
async def partition_files(req: FixFilesRequest.PartitionRequest) -> Partitions:
7✔
418
    raise NotImplementedError()
×
419

420

421
@goal_rule
7✔
422
async def fix(
7✔
423
    console: Console,
424
    specs: Specs,
425
    fix_subsystem: FixSubsystem,
426
    workspace: Workspace,
427
    union_membership: UnionMembership,
428
) -> Fix:
429
    return await _do_fix(
×
430
        sorted(
431
            (
432
                request_type
433
                for request_type in union_membership.get(AbstractFixRequest)
434
                if not (request_type.is_formatter and fix_subsystem.skip_formatters)
435
            ),
436
            # NB: We sort the core request types so that fixers are first. This is to ensure that, between
437
            # fixers and formatters, re-running isn't necessary due to tool conflicts (re-running may
438
            # still be necessary within formatters). This is because fixers are expected to modify
439
            # code irrespective of formatting, and formatters aren't expected to be modifying the code
440
            # in a way that needs to be fixed.
441
            key=lambda request_type: request_type.is_fixer,
442
            reverse=True,
443
        ),
444
        union_membership.get(FixTargetsRequest.PartitionRequest),
445
        union_membership.get(FixFilesRequest.PartitionRequest),
446
        Fix,
447
        fix_subsystem,
448
        specs,
449
        workspace,
450
        console,
451
        lambda request_type: partition_targets(
452
            **implicitly({request_type: FixTargetsRequest.PartitionRequest})
453
        ),
454
        lambda request_type: partition_files(
455
            **implicitly({request_type: FixFilesRequest.PartitionRequest})
456
        ),
457
    )
458

459

460
@rule(level=LogLevel.DEBUG)
7✔
461
async def convert_fix_result_to_lint_result(fix_result: FixResult) -> LintResult:
7✔
462
    return LintResult(
×
463
        1 if fix_result.did_change else 0,
464
        fix_result.stdout,
465
        fix_result.stderr,
466
        linter_name=fix_result.tool_name,
467
        _render_message=False,  # Don't re-render the message
468
    )
469

470

471
def rules():
7✔
472
    return collect_rules()
3✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc