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

pantsbuild / pants / 18225878979

03 Oct 2025 02:59PM UTC coverage: 80.269% (-0.006%) from 80.275%
18225878979

Pull #22717

github

web-flow
Merge b17e03226 into f7b1ba442
Pull Request #22717: wip: run tests on mac arm

77232 of 96217 relevant lines covered (80.27%)

3.63 hits per line

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

74.87
/src/python/pants/backend/adhoc/code_quality_tool.py
1
# Copyright 2023 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3
from collections.abc import Iterable
1✔
4
from dataclasses import dataclass
1✔
5
from typing import ClassVar
1✔
6

7
from pants.core.goals.fix import Fix, FixFilesRequest, FixResult
1✔
8
from pants.core.goals.fmt import Fmt, FmtFilesRequest, FmtResult
1✔
9
from pants.core.goals.lint import Lint, LintFilesRequest, LintResult
1✔
10
from pants.core.util_rules.adhoc_process_support import (
1✔
11
    ToolRunner,
12
    ToolRunnerRequest,
13
    create_tool_runner,
14
    prepare_env_vars,
15
)
16
from pants.core.util_rules.adhoc_process_support import rules as adhoc_process_support_rules
1✔
17
from pants.core.util_rules.partitions import Partitions
1✔
18
from pants.engine.addresses import Addresses
1✔
19
from pants.engine.fs import PathGlobs
1✔
20
from pants.engine.goal import Goal
1✔
21
from pants.engine.internals.build_files import resolve_address
1✔
22
from pants.engine.internals.graph import resolve_targets
1✔
23
from pants.engine.internals.native_engine import (
1✔
24
    AddressInput,
25
    FilespecMatcher,
26
    MergeDigests,
27
    Snapshot,
28
)
29
from pants.engine.internals.selectors import concurrently
1✔
30
from pants.engine.intrinsics import digest_to_snapshot, execute_process, merge_digests
1✔
31
from pants.engine.process import FallibleProcessResult, Process
1✔
32
from pants.engine.rules import Rule, collect_rules, implicitly, rule
1✔
33
from pants.engine.target import (
1✔
34
    COMMON_TARGET_FIELDS,
35
    SpecialCasedDependencies,
36
    StringField,
37
    StringSequenceField,
38
    Target,
39
)
40
from pants.option.option_types import SkipOption
1✔
41
from pants.option.subsystem import Subsystem
1✔
42
from pants.util.frozendict import FrozenDict
1✔
43
from pants.util.strutil import Simplifier, help_text
1✔
44

45

46
class CodeQualityToolFileGlobIncludeField(StringSequenceField):
1✔
47
    alias: ClassVar[str] = "file_glob_include"
1✔
48
    required = True
1✔
49
    help = help_text(
1✔
50
        """
51
        Globs that identify files that can be processed by this tool
52

53
        A file matching any of the supplied globs is eligible for processing.
54
        Example: ["**/*.py"]
55
        """
56
    )
57

58

59
class CodeQualityToolFileGlobExcludeField(StringSequenceField):
1✔
60
    alias: ClassVar[str] = "file_glob_exclude"
1✔
61
    required = False
1✔
62
    default = ()
1✔
63
    help = help_text(
1✔
64
        """
65
        Globs matching files that should not be processed by this tool
66
        """
67
    )
68

69

70
class CodeQualityToolRunnableField(StringField):
1✔
71
    alias: ClassVar[str] = "runnable"
1✔
72
    required = True
1✔
73
    help = help_text(
1✔
74
        lambda: f"""
75
        Address to a target that can be invoked by the `run` goal (and does not set
76
        `run_in_sandbox_behavior=NOT_SUPPORTED`). This will be executed along with any arguments
77
        specified by `{CodeQualityToolArgumentsField.alias}`, in a sandbox with that target's transitive
78
        dependencies, along with the transitive dependencies specified by
79
        `{CodeQualityToolExecutionDependenciesField.alias}`.
80
        """
81
    )
82

83

84
class CodeQualityToolArgumentsField(StringSequenceField):
1✔
85
    alias: ClassVar[str] = "args"
1✔
86
    default = ()
1✔
87
    help = help_text(
1✔
88
        lambda: f"""
89
        Extra arguments to pass into the `{CodeQualityToolRunnableField.alias}` field
90
        before the list of source files
91
        """
92
    )
93

94

95
class CodeQualityToolExecutionDependenciesField(SpecialCasedDependencies):
1✔
96
    alias: ClassVar[str] = "execution_dependencies"
1✔
97
    required = False
1✔
98
    default = None
1✔
99

100
    help = help_text(
1✔
101
        """
102
        Additional dependencies that need to be available when running the tool.
103
        Typically used to point to config files.
104
        """
105
    )
106

107

108
class CodeQualityToolRunnableDependenciesField(SpecialCasedDependencies):
1✔
109
    alias: ClassVar[str] = "runnable_dependencies"
1✔
110
    required = False
1✔
111
    default = None
1✔
112

113
    help = help_text(
1✔
114
        lambda: f"""
115
        The runnable dependencies for this command.
116

117
        Dependencies specified here are those required to exist on the `PATH` to make the command
118
        complete successfully (interpreters specified in a `#!` command, etc). Note that these
119
        dependencies will be made available on the `PATH` with the name of the target.
120

121
        See also `{CodeQualityToolExecutionDependenciesField.alias}`.
122
        """
123
    )
124

125

126
class CodeQualityToolTarget(Target):
1✔
127
    alias: ClassVar[str] = "code_quality_tool"
1✔
128
    core_fields = (
1✔
129
        *COMMON_TARGET_FIELDS,
130
        CodeQualityToolRunnableField,
131
        CodeQualityToolArgumentsField,
132
        CodeQualityToolExecutionDependenciesField,
133
        CodeQualityToolRunnableDependenciesField,
134
        CodeQualityToolFileGlobIncludeField,
135
        CodeQualityToolFileGlobExcludeField,
136
    )
137

138
    help = help_text(
1✔
139
        lambda: f"""
140
        Configure a runnable to use as a linter, fixer or formatter
141

142
        Example BUILD file:
143

144
            {CodeQualityToolTarget.alias}(
145
                {CodeQualityToolRunnableField.alias}=":flake8_req",
146
                {CodeQualityToolExecutionDependenciesField.alias}=[":config_file"],
147
                {CodeQualityToolFileGlobIncludeField.alias}=["**/*.py"],
148
            )
149
        """
150
    )
151

152

153
@dataclass(frozen=True)
1✔
154
class CodeQualityToolAddressString:
1✔
155
    address: str
1✔
156

157

158
@dataclass(frozen=True)
1✔
159
class CodeQualityTool:
1✔
160
    runnable_address_str: str
1✔
161
    args: tuple[str, ...]
1✔
162
    execution_dependencies: tuple[str, ...]
1✔
163
    runnable_dependencies: tuple[str, ...]
1✔
164
    file_glob_include: tuple[str, ...]
1✔
165
    file_glob_exclude: tuple[str, ...]
1✔
166
    target: Target
1✔
167

168

169
@rule
1✔
170
async def find_code_quality_tool(request: CodeQualityToolAddressString) -> CodeQualityTool:
1✔
171
    tool_address = await resolve_address(
×
172
        **implicitly(
173
            {
174
                AddressInput.parse(
175
                    request.address, description_of_origin="code quality tool target"
176
                ): AddressInput
177
            }
178
        )
179
    )
180

181
    addresses = Addresses((tool_address,))
×
182
    addresses.expect_single()
×
183

184
    tool_targets = await resolve_targets(**implicitly({addresses: Addresses}))
×
185
    target = tool_targets[0]
×
186
    runnable_address_str = target[CodeQualityToolRunnableField].value
×
187
    if not runnable_address_str:
×
188
        raise Exception(f"Must supply a value for `runnable` for {request.address}.")
×
189

190
    return CodeQualityTool(
×
191
        runnable_address_str=runnable_address_str,
192
        execution_dependencies=target[CodeQualityToolExecutionDependenciesField].value or (),
193
        runnable_dependencies=target[CodeQualityToolRunnableDependenciesField].value or (),
194
        args=target[CodeQualityToolArgumentsField].value or (),
195
        file_glob_include=target[CodeQualityToolFileGlobIncludeField].value or (),
196
        file_glob_exclude=target[CodeQualityToolFileGlobExcludeField].value or (),
197
        target=target,
198
    )
199

200

201
@dataclass(frozen=True)
1✔
202
class CodeQualityToolBatch:
1✔
203
    runner: ToolRunner
1✔
204
    sources_snapshot: Snapshot
1✔
205
    output_files: tuple[str, ...]
1✔
206

207

208
@rule
1✔
209
async def process_files(batch: CodeQualityToolBatch) -> FallibleProcessResult:
1✔
210
    runner = batch.runner
×
211

212
    input_digest = await merge_digests(MergeDigests((runner.digest, batch.sources_snapshot.digest)))
×
213

214
    env_vars = await prepare_env_vars(
×
215
        runner.extra_env,
216
        (),
217
        extra_paths=runner.extra_paths,
218
        description_of_origin="code quality tool",
219
    )
220

221
    result = await execute_process(
×
222
        **implicitly(
223
            Process(
224
                argv=tuple(runner.args + batch.sources_snapshot.files),
225
                description="Running code quality tool",
226
                input_digest=input_digest,
227
                append_only_caches=runner.append_only_caches,
228
                immutable_input_digests=FrozenDict.frozen(runner.immutable_input_digests),
229
                env=env_vars,
230
                output_files=batch.output_files,
231
            )
232
        )
233
    )
234
    return result
×
235

236

237
@rule
1✔
238
async def runner_request_for_code_quality_tool(
1✔
239
    cqt: CodeQualityTool,
240
) -> ToolRunnerRequest:
241
    return ToolRunnerRequest(
×
242
        runnable_address_str=cqt.runnable_address_str,
243
        args=cqt.args,
244
        execution_dependencies=cqt.execution_dependencies,
245
        runnable_dependencies=cqt.runnable_dependencies,
246
        target=cqt.target,
247
    )
248

249

250
_name_to_supported_goal = {g.name: g for g in (Lint, Fmt, Fix)}
1✔
251

252

253
class CodeQualityToolUnsupportedGoalError(Exception):
1✔
254
    """Raised when a rule builder is instantiated for an unrecognized or unsupported goal."""
255

256

257
@dataclass
1✔
258
class CodeQualityToolRuleBuilder:
1✔
259
    goal: str
1✔
260
    target: str
1✔
261
    name: str
1✔
262
    scope: str
1✔
263

264
    @property
1✔
265
    def goal_type(self) -> type[Goal]:
1✔
266
        return _name_to_supported_goal[self.goal]
1✔
267

268
    def __post_init__(self):
1✔
269
        if self.goal not in _name_to_supported_goal:
1✔
270
            raise CodeQualityToolUnsupportedGoalError(
1✔
271
                f"""goal must be one of {sorted(_name_to_supported_goal)}"""
272
            )
273

274
    def rules(self) -> Iterable[Rule]:
1✔
275
        if self.goal_type is Fmt:
1✔
276
            return self._build_fmt_rules()
1✔
277
        elif self.goal_type is Fix:
1✔
278
            return self._build_fix_rules()
1✔
279
        elif self.goal_type is Lint:
1✔
280
            return self._build_lint_rules()
1✔
281
        else:
282
            raise ValueError(f"Unsupported goal for code quality tool: {self.goal}")
×
283

284
    def _build_lint_rules(self) -> Iterable[Rule]:
1✔
285
        class CodeQualityToolInstance(Subsystem):
1✔
286
            options_scope = self.scope
1✔
287
            name = self.name
1✔
288
            help = f"{self.goal.capitalize()} with {self.name}. Tool defined in {self.target}"
1✔
289

290
            skip = SkipOption("lint")
1✔
291

292
        class CodeQualityProcessingRequest(LintFilesRequest):
1✔
293
            tool_subsystem = CodeQualityToolInstance
1✔
294

295
        @rule(canonical_name_suffix=self.scope)
1✔
296
        async def partition_inputs(
1✔
297
            request: CodeQualityProcessingRequest.PartitionRequest,
298
            subsystem: CodeQualityToolInstance,
299
        ) -> Partitions:
300
            if subsystem.skip:
×
301
                return Partitions()
×
302

303
            cqt = await find_code_quality_tool(CodeQualityToolAddressString(address=self.target))
×
304

305
            matching_filepaths = FilespecMatcher(
×
306
                includes=cqt.file_glob_include,
307
                excludes=cqt.file_glob_exclude,
308
            ).matches(request.files)
309

310
            return Partitions.single_partition(sorted(matching_filepaths))
×
311

312
        @rule(canonical_name_suffix=self.scope)
1✔
313
        async def run_code_quality(request: CodeQualityProcessingRequest.Batch) -> LintResult:
1✔
314
            sources_snapshot, code_quality_tool_runner = await concurrently(
×
315
                digest_to_snapshot(**implicitly(PathGlobs(request.elements))),
316
                create_tool_runner(**implicitly(CodeQualityToolAddressString(address=self.target))),
317
            )
318

319
            proc_result = await process_files(
×
320
                CodeQualityToolBatch(
321
                    runner=code_quality_tool_runner,
322
                    sources_snapshot=sources_snapshot,
323
                    output_files=(),
324
                ),
325
            )
326

327
            return LintResult.create(request, process_result=proc_result)
×
328

329
        namespace = dict(locals())
1✔
330

331
        return [
1✔
332
            *collect_rules(namespace),
333
            *CodeQualityProcessingRequest.rules(),
334
        ]
335

336
    def _build_fmt_rules(self) -> Iterable[Rule]:
1✔
337
        class CodeQualityToolInstance(Subsystem):
1✔
338
            options_scope = self.scope
1✔
339
            name = self.name
1✔
340
            help = f"{self.goal.capitalize()} with {self.name}. Tool defined in {self.target}"
1✔
341

342
            skip = SkipOption("lint", "fmt")
1✔
343

344
        class CodeQualityProcessingRequest(FmtFilesRequest):
1✔
345
            tool_subsystem = CodeQualityToolInstance
1✔
346

347
        @rule(canonical_name_suffix=self.scope)
1✔
348
        async def partition_inputs(
1✔
349
            request: CodeQualityProcessingRequest.PartitionRequest,
350
            subsystem: CodeQualityToolInstance,
351
        ) -> Partitions:
352
            if subsystem.skip:
×
353
                return Partitions()
×
354

355
            cqt = await find_code_quality_tool(CodeQualityToolAddressString(address=self.target))
×
356

357
            matching_filepaths = FilespecMatcher(
×
358
                includes=cqt.file_glob_include,
359
                excludes=cqt.file_glob_exclude,
360
            ).matches(request.files)
361

362
            return Partitions.single_partition(sorted(matching_filepaths))
×
363

364
        @rule(canonical_name_suffix=self.scope)
1✔
365
        async def run_code_quality(request: CodeQualityProcessingRequest.Batch) -> FmtResult:
1✔
366
            sources_snapshot = request.snapshot
×
367

368
            cqt = await find_code_quality_tool(CodeQualityToolAddressString(address=self.target))
×
369
            code_quality_tool_runner_request = await runner_request_for_code_quality_tool(cqt)
×
370
            code_quality_tool_runner = await create_tool_runner(code_quality_tool_runner_request)
×
371

372
            proc_result = await process_files(
×
373
                CodeQualityToolBatch(
374
                    runner=code_quality_tool_runner,
375
                    sources_snapshot=sources_snapshot,
376
                    output_files=request.files,
377
                ),
378
            )
379

380
            output = await digest_to_snapshot(proc_result.output_digest)
×
381

382
            return FmtResult(
×
383
                input=request.snapshot,
384
                output=output,
385
                stdout=Simplifier().simplify(proc_result.stdout),
386
                stderr=Simplifier().simplify(proc_result.stderr),
387
                tool_name=request.tool_name,
388
            )
389

390
        namespace = dict(locals())
1✔
391

392
        return [
1✔
393
            *collect_rules(namespace),
394
            *CodeQualityProcessingRequest.rules(),
395
        ]
396

397
    def _build_fix_rules(self) -> Iterable[Rule]:
1✔
398
        class CodeQualityToolInstance(Subsystem):
1✔
399
            options_scope = self.scope
1✔
400
            name = self.name
1✔
401
            help = f"{self.goal.capitalize()} with {self.name}. Tool defined in {self.target}"
1✔
402

403
            skip = SkipOption("lint", "fmt", "fix")
1✔
404

405
        class CodeQualityProcessingRequest(FixFilesRequest):
1✔
406
            tool_subsystem = CodeQualityToolInstance
1✔
407

408
        @rule(canonical_name_suffix=self.scope)
1✔
409
        async def partition_inputs(
1✔
410
            request: CodeQualityProcessingRequest.PartitionRequest,
411
            subsystem: CodeQualityToolInstance,
412
        ) -> Partitions:
413
            if subsystem.skip:
×
414
                return Partitions()
×
415

416
            cqt = await find_code_quality_tool(CodeQualityToolAddressString(address=self.target))
×
417

418
            matching_filepaths = FilespecMatcher(
×
419
                includes=cqt.file_glob_include,
420
                excludes=cqt.file_glob_exclude,
421
            ).matches(request.files)
422

423
            return Partitions.single_partition(sorted(matching_filepaths))
×
424

425
        @rule(canonical_name_suffix=self.scope)
1✔
426
        async def run_code_quality(request: CodeQualityProcessingRequest.Batch) -> FixResult:
1✔
427
            sources_snapshot = request.snapshot
×
428

429
            cqt = await find_code_quality_tool(CodeQualityToolAddressString(address=self.target))
×
430
            code_quality_tool_runner_request = await runner_request_for_code_quality_tool(cqt)
×
431
            code_quality_tool_runner = await create_tool_runner(code_quality_tool_runner_request)
×
432

433
            proc_result = await process_files(
×
434
                CodeQualityToolBatch(
435
                    runner=code_quality_tool_runner,
436
                    sources_snapshot=sources_snapshot,
437
                    output_files=request.files,
438
                ),
439
            )
440

441
            output = await digest_to_snapshot(proc_result.output_digest)
×
442

443
            return FixResult(
×
444
                input=request.snapshot,
445
                output=output,
446
                stdout=Simplifier().simplify(proc_result.stdout),
447
                stderr=Simplifier().simplify(proc_result.stderr),
448
                tool_name=request.tool_name,
449
            )
450

451
        namespace = dict(locals())
1✔
452

453
        return [
1✔
454
            *collect_rules(namespace),
455
            *CodeQualityProcessingRequest.rules(),
456
        ]
457

458

459
def base_rules():
1✔
460
    return [
1✔
461
        *collect_rules(),
462
        *adhoc_process_support_rules(),
463
    ]
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

© 2026 Coveralls, Inc