• 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

75.12
/src/python/pants/base/specs.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 os
7✔
7
from abc import ABC, abstractmethod
7✔
8
from collections.abc import Iterable, Iterator
7✔
9
from dataclasses import dataclass
7✔
10
from typing import ClassVar, Protocol, cast
7✔
11

12
from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
7✔
13
from pants.build_graph.address import Address
7✔
14
from pants.engine.fs import GlobExpansionConjunction, PathGlobs
7✔
15
from pants.util.dirutil import fast_relpath_optional, recursive_dirname
7✔
16
from pants.util.frozendict import FrozenDict
7✔
17

18

19
class Spec(ABC):
7✔
20
    """A specification for what Pants should operate on."""
21

22
    @abstractmethod
7✔
23
    def __str__(self) -> str:
7✔
24
        """The normalized string representation of this spec."""
25

26

27
class GlobSpecsProtocol(Protocol):
7✔
28
    def matches_target_residence_dir(self, residence_dir: str) -> bool:
7✔
29
        pass
×
30

31

32
@dataclass(frozen=True)
7✔
33
class AddressLiteralSpec(Spec):
7✔
34
    """A single target address.
35

36
    This may be one of:
37

38
    * A traditional address, like `dir:lib`.
39
    * A generated target address like `dir:lib#generated` or `dir#generated`.
40
    * A file address using disambiguation syntax like `dir/f.ext:lib`.
41
    """
42

43
    path_component: str
7✔
44
    target_component: str | None = None
7✔
45
    generated_component: str | None = None
7✔
46
    parameters: FrozenDict[str, str] = FrozenDict()
7✔
47

48
    def __str__(self) -> str:
7✔
49
        tgt = f":{self.target_component}" if self.target_component else ""
×
50
        generated = f"#{self.generated_component}" if self.generated_component else ""
×
51
        params = ""
×
52
        if self.parameters:
×
53
            rhs = ",".join(f"{k}={v}" for k, v in self.parameters.items())
×
54
            params = f"@{rhs}"
×
55
        return f"{self.path_component}{tgt}{generated}{params}"
×
56

57
    @property
7✔
58
    def is_directory_shorthand(self) -> bool:
7✔
59
        """Is in the format `path/to/dir`, which is shorthand for `path/to/dir:dir`."""
60
        return (
×
61
            self.target_component is None
62
            and self.generated_component is None
63
            and not self.parameters
64
        )
65

66
    def to_address(self) -> Address:
7✔
67
        return Address(
×
68
            self.path_component,
69
            target_name=self.target_component,
70
            generated_name=self.generated_component,
71
            parameters=dict(self.parameters),
72
        )
73

74

75
@dataclass(frozen=True)
7✔
76
class FileLiteralSpec(Spec):
7✔
77
    """A literal file name, e.g. `foo.py`.
78

79
    Matches:
80

81
    * Target-aware: all targets who include the file in their `source`/`sources` field.
82
    * Target-less: the file.
83
    """
84

85
    file: str
7✔
86

87
    def __str__(self) -> str:
7✔
88
        return self.file
×
89

90
    def to_glob(self) -> str:
7✔
91
        return self.file
×
92

93

94
@dataclass(frozen=True)
7✔
95
class FileGlobSpec(Spec):
7✔
96
    """A spec with a glob or globs, e.g. `*.py` and `**/*.java`.
97

98
    Matches:
99

100
    * Target-aware: all targets who include the file glob in their `source`/`sources` field.
101
    * Target-less: the files matching the glob.
102
    """
103

104
    glob: str
7✔
105

106
    def __str__(self) -> str:
7✔
107
        return self.glob
×
108

109
    def to_glob(self) -> str:
7✔
110
        return self.glob
×
111

112

113
@dataclass(frozen=True)
7✔
114
class DirLiteralSpec(Spec):
7✔
115
    """A literal dir path, e.g. `some/dir`.
116

117
    Matches:
118

119
    * Target-aware: (for now) the "default" target, i.e. whose `name=` matches the directory.
120
    * Target-less: all files in the directory.
121

122
    The empty string represents the build root.
123
    """
124

125
    directory: str
7✔
126

127
    matches_target_generators: ClassVar[bool] = False
7✔
128

129
    def __str__(self) -> str:
7✔
130
        return self.directory
×
131

132
    def matches_target_residence_dir(self, residence_dir: str) -> bool:
7✔
133
        return residence_dir == self.directory
1✔
134

135
    def to_glob(self) -> str:
7✔
136
        return os.path.join(self.directory, "*")
1✔
137

138

139
@dataclass(frozen=True)
7✔
140
class DirGlobSpec(Spec):
7✔
141
    """E.g. `some/dir:`.
142

143
    Matches:
144

145
    * Target-aware: all targets "resident" in a directory, i.e. defined there or generated into
146
      the dir.
147
    * Target-less: all files in the directory.
148

149
    The empty string represents the build root.
150
    """
151

152
    directory: str
7✔
153

154
    matches_target_generators: ClassVar[bool] = True
7✔
155

156
    def __str__(self) -> str:
7✔
157
        return f"{self.directory}:"
×
158

159
    def matches_target_residence_dir(self, residence_dir: str) -> bool:
7✔
160
        return residence_dir == self.directory
1✔
161

162
    def to_glob(self) -> str:
7✔
163
        return os.path.join(self.directory, "*")
1✔
164

165

166
@dataclass(frozen=True)
7✔
167
class RecursiveGlobSpec(Spec):
7✔
168
    """E.g. `some/dir::`.
169

170
    Matches:
171

172
    * Target-aware: all targets "resident" in the directory and below, meaning they are defined
173
      there or generated there.
174
    * Target-less: all files in the directory and below.
175

176
    The empty string represents the build root.
177
    """
178

179
    directory: str
7✔
180

181
    matches_target_generators: ClassVar[bool] = True
7✔
182

183
    def __str__(self) -> str:
7✔
184
        return f"{self.directory}::"
×
185

186
    def matches_target_residence_dir(self, residence_dir: str) -> bool:
7✔
187
        return fast_relpath_optional(residence_dir, self.directory) is not None
1✔
188

189
    def to_glob(self) -> str:
7✔
190
        return os.path.join(self.directory, "**")
1✔
191

192

193
@dataclass(frozen=True)
7✔
194
class AncestorGlobSpec(Spec):
7✔
195
    """E.g. `some/dir^`.
196

197
    Matches:
198

199
    * Target-aware: all targets "resident" in the directory and above, meaning they are defined
200
      there or generated there.
201
    * Target-less: all files in the directory and above.
202

203
    The empty string represents the build root.
204
    """
205

206
    directory: str
7✔
207

208
    matches_target_generators: ClassVar[bool] = True
7✔
209

210
    def __str__(self) -> str:
7✔
211
        return f"{self.directory}^"
×
212

213
    def matches_target_residence_dir(self, residence_dir: str) -> bool:
7✔
214
        return fast_relpath_optional(self.directory, residence_dir) is not None
1✔
215

216

217
def _create_path_globs(
7✔
218
    globs: Iterable[str],
219
    unmatched_glob_behavior: GlobMatchErrorBehavior,
220
    *,
221
    description_of_origin: str,
222
) -> PathGlobs:
223
    return PathGlobs(
1✔
224
        globs=globs,
225
        glob_match_error_behavior=unmatched_glob_behavior,
226
        # We validate that _every_ glob is valid.
227
        conjunction=GlobExpansionConjunction.all_match,
228
        description_of_origin=(
229
            None
230
            if unmatched_glob_behavior == GlobMatchErrorBehavior.ignore
231
            else description_of_origin
232
        ),
233
    )
234

235

236
@dataclass(frozen=True)
7✔
237
class RawSpecs:
7✔
238
    """Convert the specs into matching targets and files.
239

240
    Unlike `Specs`, this does not consider include vs. ignore specs. It simply matches all relevant
241
    targets/files.
242

243
    When you want to operate on what the user specified, use `Specs`. Otherwise, you can use
244
    either `Specs` or `RawSpecs` in rules, e.g. to find what targets exist in a directory.
245
    """
246

247
    description_of_origin: str
7✔
248

249
    address_literals: tuple[AddressLiteralSpec, ...] = ()
7✔
250
    file_literals: tuple[FileLiteralSpec, ...] = ()
7✔
251
    file_globs: tuple[FileGlobSpec, ...] = ()
7✔
252
    dir_literals: tuple[DirLiteralSpec, ...] = ()
7✔
253
    dir_globs: tuple[DirGlobSpec, ...] = ()
7✔
254
    recursive_globs: tuple[RecursiveGlobSpec, ...] = ()
7✔
255
    ancestor_globs: tuple[AncestorGlobSpec, ...] = ()
7✔
256

257
    unmatched_glob_behavior: GlobMatchErrorBehavior = GlobMatchErrorBehavior.error
7✔
258
    filter_by_global_options: bool = False
7✔
259
    from_change_detection: bool = False
7✔
260

261
    @classmethod
7✔
262
    def create(
7✔
263
        cls,
264
        specs: Iterable[Spec],
265
        *,
266
        description_of_origin: str,
267
        unmatched_glob_behavior: GlobMatchErrorBehavior = GlobMatchErrorBehavior.error,
268
        filter_by_global_options: bool = False,
269
        from_change_detection: bool = False,
270
    ) -> RawSpecs:
271
        """Create from a heterogeneous iterable of Spec objects.
272

273
        If the `Spec` objects are already separated by type, prefer using the class's constructor
274
        directly.
275
        """
276

277
        address_literals = []
7✔
278
        file_literals = []
7✔
279
        file_globs = []
7✔
280
        dir_literals = []
7✔
281
        dir_globs = []
7✔
282
        recursive_globs = []
7✔
283
        ancestor_globs = []
7✔
284
        for spec in specs:
7✔
285
            if isinstance(spec, AddressLiteralSpec):
7✔
286
                address_literals.append(spec)
5✔
287
            elif isinstance(spec, FileLiteralSpec):
7✔
288
                file_literals.append(spec)
3✔
289
            elif isinstance(spec, FileGlobSpec):
5✔
290
                file_globs.append(spec)
1✔
291
            elif isinstance(spec, DirLiteralSpec):
5✔
292
                dir_literals.append(spec)
2✔
293
            elif isinstance(spec, DirGlobSpec):
4✔
294
                dir_globs.append(spec)
×
295
            elif isinstance(spec, RecursiveGlobSpec):
4✔
296
                recursive_globs.append(spec)
4✔
297
            elif isinstance(spec, AncestorGlobSpec):
×
298
                ancestor_globs.append(spec)
×
299
            else:
300
                raise AssertionError(f"Unexpected type of Spec: {repr(spec)}")
×
301
        return RawSpecs(
7✔
302
            address_literals=tuple(address_literals),
303
            file_literals=tuple(file_literals),
304
            file_globs=tuple(file_globs),
305
            dir_literals=tuple(dir_literals),
306
            dir_globs=tuple(dir_globs),
307
            recursive_globs=tuple(recursive_globs),
308
            ancestor_globs=tuple(ancestor_globs),
309
            description_of_origin=description_of_origin,
310
            unmatched_glob_behavior=unmatched_glob_behavior,
311
            filter_by_global_options=filter_by_global_options,
312
            from_change_detection=from_change_detection,
313
        )
314

315
    def __bool__(self) -> bool:
7✔
316
        return bool(
×
317
            self.address_literals
318
            or self.file_literals
319
            or self.file_globs
320
            or self.dir_literals
321
            or self.dir_globs
322
            or self.recursive_globs
323
            or self.ancestor_globs
324
        )
325

326
    def to_specs_paths_path_globs(self) -> PathGlobs:
7✔
327
        """`PathGlobs` to find all files from the specs, independent of targets."""
328
        relevant_specs: Iterable[
×
329
            FileLiteralSpec | FileGlobSpec | DirLiteralSpec | DirGlobSpec | RecursiveGlobSpec
330
        ] = (
331
            *self.file_literals,
332
            *self.file_globs,
333
            *self.dir_literals,
334
            *self.dir_globs,
335
            *self.recursive_globs,
336
        )
337
        return _create_path_globs(
×
338
            (spec.to_glob() for spec in relevant_specs),
339
            (
340
                GlobMatchErrorBehavior.ignore
341
                if self.from_change_detection
342
                else self.unmatched_glob_behavior
343
            ),
344
            description_of_origin=self.description_of_origin,
345
        )
346

347

348
@dataclass(frozen=True)
7✔
349
class RawSpecsWithoutFileOwners:
7✔
350
    """The subset of `RawSpecs` that do not use the `Owners` rule to match targets.
351

352
    This exists to work around a cycle in the rule graph. Usually, consumers should use RawSpecs.
353
    """
354

355
    description_of_origin: str
7✔
356

357
    address_literals: tuple[AddressLiteralSpec, ...] = ()
7✔
358
    dir_literals: tuple[DirLiteralSpec, ...] = ()
7✔
359
    dir_globs: tuple[DirGlobSpec, ...] = ()
7✔
360
    recursive_globs: tuple[RecursiveGlobSpec, ...] = ()
7✔
361
    ancestor_globs: tuple[AncestorGlobSpec, ...] = ()
7✔
362

363
    unmatched_glob_behavior: GlobMatchErrorBehavior = GlobMatchErrorBehavior.error
7✔
364
    filter_by_global_options: bool = False
7✔
365

366
    @classmethod
7✔
367
    def from_raw_specs(cls, specs: RawSpecs) -> RawSpecsWithoutFileOwners:
7✔
368
        return RawSpecsWithoutFileOwners(
1✔
369
            address_literals=specs.address_literals,
370
            dir_literals=specs.dir_literals,
371
            dir_globs=specs.dir_globs,
372
            recursive_globs=specs.recursive_globs,
373
            ancestor_globs=specs.ancestor_globs,
374
            description_of_origin=specs.description_of_origin,
375
            unmatched_glob_behavior=specs.unmatched_glob_behavior,
376
            filter_by_global_options=specs.filter_by_global_options,
377
        )
378

379
    def glob_specs(
7✔
380
        self,
381
    ) -> Iterator[DirLiteralSpec | DirGlobSpec | RecursiveGlobSpec | AncestorGlobSpec]:
382
        yield from self.dir_literals
×
383
        yield from self.dir_globs
×
384
        yield from self.recursive_globs
×
385
        yield from self.ancestor_globs
×
386

387
    def to_build_file_path_globs_tuple(
7✔
388
        self, *, build_patterns: Iterable[str], build_ignore_patterns: Iterable[str]
389
    ) -> tuple[PathGlobs, PathGlobs]:
390
        """Returns `PathGlobs` for the actual BUILD files, along with another solely used to
391
        validate that the directories exist.
392

393
        The second `PathGlobs` is necessary so that we can error on directories that don't actually
394
        exist, yet we don't error if the directory simply has no targets. See
395
        https://github.com/pantsbuild/pants/issues/15558.
396
        """
397
        build_includes: set[str] = set()
1✔
398
        validation_includes: set[str] = set()
1✔
399
        for spec in (*self.dir_literals, *self.dir_globs, *self.ancestor_globs):
1✔
400
            spec = cast("DirLiteralSpec | DirGlobSpec | AncestorGlobSpec", spec)
1✔
401
            validation_includes.add(
1✔
402
                spec.to_glob()
403
                if isinstance(spec, (DirLiteralSpec, DirGlobSpec))
404
                else os.path.join(spec.directory, "*")
405
            )
406
            build_includes.update(
1✔
407
                os.path.join(f, pattern)
408
                for pattern in build_patterns
409
                for f in recursive_dirname(spec.directory)
410
            )
411

412
        for recursive_spec in self.recursive_globs:
1✔
413
            validation_includes.add(recursive_spec.to_glob())
1✔
414
            for pattern in build_patterns:
1✔
415
                build_includes.update(
1✔
416
                    os.path.join(f, pattern) for f in recursive_dirname(recursive_spec.directory)
417
                )
418
                build_includes.add(os.path.join(recursive_spec.directory, "**", pattern))
1✔
419

420
        ignores = (f"!{p}" for p in build_ignore_patterns)
1✔
421
        build_path_globs = PathGlobs((*build_includes, *ignores))
1✔
422
        validation_path_globs = (
1✔
423
            PathGlobs(())
424
            if self.unmatched_glob_behavior == GlobMatchErrorBehavior.ignore
425
            else _create_path_globs(
426
                (*validation_includes, *ignores),
427
                self.unmatched_glob_behavior,
428
                description_of_origin=self.description_of_origin,
429
            )
430
        )
431
        return build_path_globs, validation_path_globs
1✔
432

433

434
@dataclass(frozen=True)
7✔
435
class RawSpecsWithOnlyFileOwners:
7✔
436
    """The subset of `RawSpecs` that require using the `Owners` rule to match targets.
437

438
    This exists to work around a cycle in the rule graph. Usually, consumers should use RawSpecs.
439
    """
440

441
    description_of_origin: str
7✔
442

443
    file_literals: tuple[FileLiteralSpec, ...] = ()
7✔
444
    file_globs: tuple[FileGlobSpec, ...] = ()
7✔
445

446
    unmatched_glob_behavior: GlobMatchErrorBehavior = GlobMatchErrorBehavior.error
7✔
447
    filter_by_global_options: bool = False
7✔
448
    from_change_detection: bool = False
7✔
449

450
    @classmethod
7✔
451
    def from_raw_specs(cls, specs: RawSpecs) -> RawSpecsWithOnlyFileOwners:
7✔
452
        return RawSpecsWithOnlyFileOwners(
×
453
            description_of_origin=specs.description_of_origin,
454
            file_literals=specs.file_literals,
455
            file_globs=specs.file_globs,
456
            unmatched_glob_behavior=specs.unmatched_glob_behavior,
457
            filter_by_global_options=specs.filter_by_global_options,
458
            from_change_detection=specs.from_change_detection,
459
        )
460

461
    def path_globs_for_spec(self, spec: FileLiteralSpec | FileGlobSpec) -> PathGlobs:
7✔
462
        """Generate PathGlobs for the specific spec."""
463
        unmatched_glob_behavior = (
×
464
            GlobMatchErrorBehavior.ignore
465
            if self.from_change_detection
466
            else self.unmatched_glob_behavior
467
        )
468
        return _create_path_globs(
×
469
            (spec.to_glob(),),
470
            unmatched_glob_behavior,
471
            description_of_origin=self.description_of_origin,
472
        )
473

474
    def all_specs(self) -> Iterator[FileLiteralSpec | FileGlobSpec]:
7✔
475
        yield from self.file_literals
×
476
        yield from self.file_globs
×
477

478
    def __bool__(self) -> bool:
7✔
479
        return bool(self.file_literals or self.file_globs)
×
480

481

482
@dataclass(frozen=True)
7✔
483
class Specs:
7✔
484
    """The specs provided by the user for what to run on.
485

486
    The `ignores` will filter out all relevant `includes`.
487

488
    If your rule does not need to consider includes vs. ignores, e.g. to find all targets in a
489
    directory, you can directly use `RawSpecs`.
490
    """
491

492
    includes: RawSpecs
7✔
493
    ignores: RawSpecs
7✔
494

495
    def __bool__(self) -> bool:
7✔
496
        return bool(self.includes) or bool(self.ignores)
×
497

498
    @classmethod
7✔
499
    def empty(self) -> Specs:
7✔
500
        return Specs(
×
501
            RawSpecs(description_of_origin="<not used>"),
502
            RawSpecs(description_of_origin="<not used>"),
503
        )
504

505
    def arguments_provided_description(self) -> str | None:
7✔
506
        """A description of what the user specified, e.g. 'target arguments'."""
507
        specs_descriptions = []
×
508
        if self.includes.address_literals or self.ignores.address_literals:
×
509
            specs_descriptions.append("target")
×
510
        if (
×
511
            self.includes.file_literals
512
            or self.includes.file_globs
513
            or self.ignores.file_literals
514
            or self.ignores.file_globs
515
        ):
516
            specs_descriptions.append("file")
×
517
        if self.includes.dir_literals or self.ignores.dir_literals:
×
518
            specs_descriptions.append("directory")
×
519
        if (
×
520
            self.includes.dir_globs
521
            or self.includes.recursive_globs
522
            or self.includes.ancestor_globs
523
            or self.ignores.dir_globs
524
            or self.ignores.recursive_globs
525
            or self.ignores.ancestor_globs
526
        ):
527
            specs_descriptions.append("glob")
×
528

529
        if not specs_descriptions:
×
530
            return None
×
531
        if len(specs_descriptions) == 1:
×
532
            return f"{specs_descriptions[0]} arguments"
×
533
        if len(specs_descriptions) == 2:
×
534
            return " and ".join(specs_descriptions) + " arguments"
×
535
        return ", ".join(specs_descriptions[:-1]) + f", and {specs_descriptions[-1]} arguments"
×
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