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

pantsbuild / pants / 19529437518

20 Nov 2025 07:44AM UTC coverage: 78.884% (-1.4%) from 80.302%
19529437518

push

github

web-flow
nfpm.native_libs: Add RPM package depends from packaged pex_binaries (#22899)

## PR Series Overview

This is the second in a series of PRs that introduces a new backend:
`pants.backend.npm.native_libs`
Initially, the backend will be available as:
`pants.backend.experimental.nfpm.native_libs`

I proposed this new backend (originally named `bindeps`) in discussion
#22396.

This backend will inspect ELF bin/lib files (like `lib*.so`) in packaged
contents (for this PR series, only in `pex_binary` targets) to identify
package dependency metadata and inject that metadata on the relevant
`nfpm_deb_package` or `nfpm_rpm_package` targets. Effectively, it will
provide an approximation of these native packager features:
- `rpm`: `rpmdeps` + `elfdeps`
- `deb`: `dh_shlibdeps` + `dpkg-shlibdeps` (These substitute
`${shlibs:Depends}` in debian control files have)

### Goal: Host-agnostic package builds

This pants backend is designed to be host-agnostic, like
[nFPM](https://nfpm.goreleaser.com/).

Native packaging tools are often restricted to a single release of a
single distro. Unlike native package builders, this new pants backend
does not use any of those distro-specific or distro-release-specific
utilities or local package databases. This new backend should be able to
run (help with building deb and rpm packages) anywhere that pants can
run (MacOS, rpm linux distros, deb linux distros, other linux distros,
docker, ...).

### Previous PRs in series

- #22873

## PR Overview

This PR adds rules in `nfpm.native_libs` to add package dependency
metadata to `nfpm_rpm_package`. The 2 new rules are:

- `inject_native_libs_dependencies_in_package_fields`:

    - An implementation of the polymorphic rule `inject_nfpm_package_fields`.
      This rule is low priority (`priority = 2`) so that in-repo plugins can
      override/augment what it injects. (See #22864)

    - Rule logic overview:
        - find any pex_binaries that will be packaged in an `nfpm_rpm_package`
   ... (continued)

96 of 118 new or added lines in 3 files covered. (81.36%)

910 existing lines in 53 files now uncovered.

73897 of 93678 relevant lines covered (78.88%)

3.21 hits per line

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

73.24
/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
11✔
5

6
import os
11✔
7
from abc import ABC, abstractmethod
11✔
8
from collections.abc import Iterable, Iterator
11✔
9
from dataclasses import dataclass
11✔
10
from typing import ClassVar, Protocol, cast
11✔
11

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

18

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

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

26

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

31

32
@dataclass(frozen=True)
11✔
33
class AddressLiteralSpec(Spec):
11✔
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
11✔
44
    target_component: str | None = None
11✔
45
    generated_component: str | None = None
11✔
46
    parameters: FrozenDict[str, str] = FrozenDict()
11✔
47

48
    def __str__(self) -> str:
11✔
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
11✔
58
    def is_directory_shorthand(self) -> bool:
11✔
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:
11✔
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)
11✔
76
class FileLiteralSpec(Spec):
11✔
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
11✔
86

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

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

93

94
@dataclass(frozen=True)
11✔
95
class FileGlobSpec(Spec):
11✔
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
11✔
105

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

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

112

113
@dataclass(frozen=True)
11✔
114
class DirLiteralSpec(Spec):
11✔
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
11✔
126

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

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

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

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

138

139
@dataclass(frozen=True)
11✔
140
class DirGlobSpec(Spec):
11✔
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
11✔
153

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

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

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

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

165

166
@dataclass(frozen=True)
11✔
167
class RecursiveGlobSpec(Spec):
11✔
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
11✔
180

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

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

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

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

192

193
@dataclass(frozen=True)
11✔
194
class AncestorGlobSpec(Spec):
11✔
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
11✔
207

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

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

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

216

217
def _create_path_globs(
11✔
218
    globs: Iterable[str],
219
    unmatched_glob_behavior: GlobMatchErrorBehavior,
220
    *,
221
    description_of_origin: str,
222
) -> PathGlobs:
UNCOV
223
    return PathGlobs(
×
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)
11✔
237
class RawSpecs:
11✔
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
11✔
248

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

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

261
    @classmethod
11✔
262
    def create(
11✔
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 = []
11✔
278
        file_literals = []
11✔
279
        file_globs = []
11✔
280
        dir_literals = []
11✔
281
        dir_globs = []
11✔
282
        recursive_globs = []
11✔
283
        ancestor_globs = []
11✔
284
        for spec in specs:
11✔
285
            if isinstance(spec, AddressLiteralSpec):
11✔
286
                address_literals.append(spec)
10✔
287
            elif isinstance(spec, FileLiteralSpec):
10✔
288
                file_literals.append(spec)
7✔
289
            elif isinstance(spec, FileGlobSpec):
8✔
290
                file_globs.append(spec)
2✔
291
            elif isinstance(spec, DirLiteralSpec):
8✔
292
                dir_literals.append(spec)
3✔
293
            elif isinstance(spec, DirGlobSpec):
7✔
294
                dir_globs.append(spec)
1✔
295
            elif isinstance(spec, RecursiveGlobSpec):
7✔
296
                recursive_globs.append(spec)
7✔
297
            elif isinstance(spec, AncestorGlobSpec):
1✔
298
                ancestor_globs.append(spec)
1✔
299
            else:
300
                raise AssertionError(f"Unexpected type of Spec: {repr(spec)}")
×
301
        return RawSpecs(
11✔
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:
11✔
316
        return bool(
1✔
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:
11✔
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)
11✔
349
class RawSpecsWithoutFileOwners:
11✔
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
11✔
356

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

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

366
    @classmethod
11✔
367
    def from_raw_specs(cls, specs: RawSpecs) -> RawSpecsWithoutFileOwners:
11✔
368
        return RawSpecsWithoutFileOwners(
2✔
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(
11✔
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(
11✔
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
        """
UNCOV
397
        build_includes: set[str] = set()
×
UNCOV
398
        validation_includes: set[str] = set()
×
UNCOV
399
        for spec in (*self.dir_literals, *self.dir_globs, *self.ancestor_globs):
×
UNCOV
400
            spec = cast("DirLiteralSpec | DirGlobSpec | AncestorGlobSpec", spec)
×
UNCOV
401
            validation_includes.add(
×
402
                spec.to_glob()
403
                if isinstance(spec, (DirLiteralSpec, DirGlobSpec))
404
                else os.path.join(spec.directory, "*")
405
            )
UNCOV
406
            build_includes.update(
×
407
                os.path.join(f, pattern)
408
                for pattern in build_patterns
409
                for f in recursive_dirname(spec.directory)
410
            )
411

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

UNCOV
420
        ignores = (f"!{p}" for p in build_ignore_patterns)
×
UNCOV
421
        build_path_globs = PathGlobs((*build_includes, *ignores))
×
UNCOV
422
        validation_path_globs = (
×
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
        )
UNCOV
431
        return build_path_globs, validation_path_globs
×
432

433

434
@dataclass(frozen=True)
11✔
435
class RawSpecsWithOnlyFileOwners:
11✔
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
11✔
442

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

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

450
    @classmethod
11✔
451
    def from_raw_specs(cls, specs: RawSpecs) -> RawSpecsWithOnlyFileOwners:
11✔
452
        return RawSpecsWithOnlyFileOwners(
1✔
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:
11✔
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]:
11✔
475
        yield from self.file_literals
×
476
        yield from self.file_globs
×
477

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

481

482
@dataclass(frozen=True)
11✔
483
class Specs:
11✔
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
11✔
493
    ignores: RawSpecs
11✔
494

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

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

505
    def arguments_provided_description(self) -> str | None:
11✔
506
        """A description of what the user specified, e.g. 'target arguments'."""
507
        specs_descriptions = []
1✔
508
        if self.includes.address_literals or self.ignores.address_literals:
1✔
509
            specs_descriptions.append("target")
1✔
510
        if (
1✔
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")
1✔
517
        if self.includes.dir_literals or self.ignores.dir_literals:
1✔
518
            specs_descriptions.append("directory")
×
519
        if (
1✔
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:
1✔
530
            return None
×
531
        if len(specs_descriptions) == 1:
1✔
532
            return f"{specs_descriptions[0]} arguments"
1✔
533
        if len(specs_descriptions) == 2:
1✔
534
            return " and ".join(specs_descriptions) + " arguments"
1✔
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