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

pantsbuild / pants / 20830576679

08 Jan 2026 08:22PM UTC coverage: 80.266% (-0.008%) from 80.274%
20830576679

Pull #22989

github

web-flow
Merge 60b650c99 into 0d471f924
Pull Request #22989: add a backend for running codespell a linter

152 of 192 new or added lines in 3 files covered. (79.17%)

6 existing lines in 2 files now uncovered.

78941 of 98349 relevant lines covered (80.27%)

3.09 hits per line

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

44.29
/src/python/pants/backend/tools/codespell/rules.py
1
# Copyright 2026 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3

4
from __future__ import annotations
1✔
5

6
import os
1✔
7
from collections import defaultdict
1✔
8
from dataclasses import dataclass
1✔
9
from typing import Any
1✔
10

11
from pants.backend.python.util_rules.pex import PexProcess, create_pex
1✔
12
from pants.backend.tools.codespell.subsystem import Codespell
1✔
13
from pants.core.goals.lint import LintFilesRequest, LintResult
1✔
14
from pants.core.util_rules.config_files import (
1✔
15
    ConfigFiles,
16
    ConfigFilesRequest,
17
    GatherConfigFilesByDirectoriesRequest,
18
    find_config_file,
19
    gather_config_files_by_workspace_dir,
20
)
21
from pants.core.util_rules.partitions import Partition, Partitions
1✔
22
from pants.engine.fs import DigestSubset, MergeDigests, PathGlobs
1✔
23
from pants.engine.internals.native_engine import FilespecMatcher, Snapshot
1✔
24
from pants.engine.intrinsics import digest_to_snapshot, execute_process, merge_digests
1✔
25
from pants.engine.rules import collect_rules, concurrently, implicitly, rule
1✔
26
from pants.util.dirutil import group_by_dir
1✔
27
from pants.util.logging import LogLevel
1✔
28
from pants.util.strutil import pluralize
1✔
29

30

31
class CodespellRequest(LintFilesRequest):
1✔
32
    tool_subsystem = Codespell  # type: ignore[assignment]
1✔
33

34

35
@dataclass(frozen=True)
1✔
36
class PartitionInfo:
1✔
37
    config_snapshot: Snapshot | None
1✔
38
    # If True, this partition has no .codespellrc ancestor and should try
39
    # to discover setup.cfg/pyproject.toml at runtime
40
    discover_root_config: bool = False
1✔
41

42
    @property
1✔
43
    def description(self) -> str:
1✔
NEW
44
        if self.config_snapshot:
×
NEW
45
            return self.config_snapshot.files[0]
×
NEW
46
        elif self.discover_root_config:
×
NEW
47
            return "<root config discovery>"
×
48
        else:
NEW
49
            return "<default>"
×
50

51

52
@rule
1✔
53
async def partition_inputs(
1✔
54
    request: CodespellRequest.PartitionRequest, codespell: Codespell
55
) -> Partitions[Any, PartitionInfo]:
NEW
56
    if codespell.skip:
×
NEW
57
        return Partitions()
×
58

NEW
59
    matching_filepaths = FilespecMatcher(
×
60
        includes=codespell.file_glob_include, excludes=codespell.file_glob_exclude
61
    ).matches(request.files)
62

63
    # First, find .codespellrc files for partitioning
NEW
64
    config_files = await gather_config_files_by_workspace_dir(
×
65
        GatherConfigFilesByDirectoriesRequest(
66
            tool_name=codespell.name,
67
            config_filename=codespell.config_file_name,
68
            filepaths=tuple(sorted(matching_filepaths)),
69
            orphan_filepath_behavior=codespell.orphan_files_behavior,
70
        )
71
    )
72

NEW
73
    default_source_files: set[str] = set()
×
NEW
74
    source_files_by_config_file: dict[str, set[str]] = defaultdict(set)
×
NEW
75
    for source_dir, files_in_source_dir in group_by_dir(matching_filepaths).items():
×
NEW
76
        files = (os.path.join(source_dir, name) for name in files_in_source_dir)
×
NEW
77
        if source_dir in config_files.source_dir_to_config_file:
×
NEW
78
            config_file = config_files.source_dir_to_config_file[source_dir]
×
NEW
79
            source_files_by_config_file[config_file].update(files)
×
80
        else:
NEW
81
            default_source_files.update(files)
×
82

NEW
83
    config_file_snapshots = await concurrently(
×
84
        digest_to_snapshot(
85
            **implicitly(DigestSubset(config_files.snapshot.digest, PathGlobs([config_file])))
86
        )
87
        for config_file in source_files_by_config_file
88
    )
89

NEW
90
    return Partitions(
×
91
        (
92
            *(
93
                Partition(tuple(sorted(files)), PartitionInfo(config_snapshot=config_snapshot))
94
                for files, config_snapshot in zip(
95
                    source_files_by_config_file.values(), config_file_snapshots
96
                )
97
            ),
98
            *(
99
                (
100
                    Partition(
101
                        tuple(sorted(default_source_files)),
102
                        PartitionInfo(config_snapshot=None, discover_root_config=True),
103
                    ),
104
                )
105
                if default_source_files
106
                else ()
107
            ),
108
        )
109
    )
110

111

112
@rule(desc="Lint with codespell", level=LogLevel.DEBUG)
1✔
113
async def run_codespell(
1✔
114
    request: CodespellRequest.Batch[str, PartitionInfo],
115
    codespell: Codespell,
116
) -> LintResult:
NEW
117
    partition_info = request.partition_metadata
×
118

NEW
119
    codespell_pex_get = create_pex(codespell.to_pex_request())
×
120

121
    # If this partition has no .codespellrc, try to discover setup.cfg/pyproject.toml at root
NEW
122
    root_config: ConfigFiles | None = None
×
NEW
123
    if partition_info.discover_root_config:
×
NEW
124
        codespell_pex, root_config = await concurrently(
×
125
            codespell_pex_get,
126
            find_config_file(
127
                ConfigFilesRequest(
128
                    discovery=True,
129
                    check_existence=[".codespellrc"],
130
                    check_content={
131
                        "setup.cfg": b"[codespell]",
132
                        "pyproject.toml": b"[tool.codespell]",
133
                    },
134
                )
135
            ),
136
        )
137
    else:
NEW
138
        codespell_pex = await codespell_pex_get
×
139

NEW
140
    snapshot = await digest_to_snapshot(**implicitly(PathGlobs(request.elements)))
×
141

142
    # Determine which config to use and which flag to pass
143
    # - .codespellrc and setup.cfg use --config (INI format)
144
    # - pyproject.toml uses --toml (TOML format)
NEW
145
    config_snapshot = partition_info.config_snapshot
×
NEW
146
    config_args: tuple[str, ...] = ()
×
147

NEW
148
    if config_snapshot is not None:
×
149
        # We have a .codespellrc from directory-based discovery
NEW
150
        config_args = ("--config", config_snapshot.files[0])
×
NEW
151
    elif root_config is not None and root_config.snapshot.files:
×
152
        # We found a config at root
NEW
153
        config_file = root_config.snapshot.files[0]
×
NEW
154
        config_snapshot = root_config.snapshot
×
NEW
155
        if config_file.endswith("pyproject.toml"):
×
NEW
156
            config_args = ("--toml", config_file)
×
157
        else:
158
            # .codespellrc or setup.cfg use --config
NEW
159
            config_args = ("--config", config_file)
×
160

NEW
161
    input_digest = await merge_digests(
×
162
        MergeDigests(
163
            (
164
                snapshot.digest,
165
                codespell_pex.digest,
166
                *((config_snapshot.digest,) if config_snapshot else ()),
167
            )
168
        )
169
    )
170

NEW
171
    process_result = await execute_process(
×
172
        **implicitly(
173
            PexProcess(
174
                codespell_pex,
175
                argv=(
176
                    *config_args,
177
                    *codespell.args,
178
                    *snapshot.files,
179
                ),
180
                input_digest=input_digest,
181
                description=f"Run codespell on {pluralize(len(snapshot.files), 'file')}.",
182
                level=LogLevel.DEBUG,
183
            )
184
        )
185
    )
NEW
186
    return LintResult.create(request, process_result)
×
187

188

189
def rules():
1✔
190
    return [*collect_rules(), *CodespellRequest.rules()]
1✔
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