• 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

98.88
/src/python/pants/base/deprecated.py
1
# Copyright 2015 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 inspect
11✔
7
import logging
11✔
8
from collections.abc import Callable
11✔
9
from functools import wraps
11✔
10
from typing import Any, TypeVar
11✔
11

12
from packaging.version import InvalidVersion, Version
11✔
13

14
from pants.util.memo import memoized, memoized_method
11✔
15
from pants.version import PANTS_SEMVER
11✔
16

17
logger = logging.getLogger(__name__)
11✔
18

19

20
class DeprecationError(Exception):
11✔
21
    """The base exception type thrown for any form of @deprecation application error."""
22

23

24
class MissingSemanticVersionError(DeprecationError):
11✔
25
    """Indicates the required removal_version was not supplied."""
26

27

28
class BadSemanticVersionError(DeprecationError):
11✔
29
    """Indicates the supplied removal_version was not a valid semver string."""
30

31

32
class NonDevSemanticVersionError(DeprecationError):
11✔
33
    """Indicates the supplied removal_version was not a pre-release version."""
34

35

36
class InvalidSemanticVersionOrderingError(DeprecationError):
11✔
37
    """Indicates that multiple semantic version strings were provided in an inconsistent
38
    ordering."""
39

40

41
class BadDecoratorNestingError(DeprecationError):
11✔
42
    """Indicates the @deprecated decorator was innermost in a sequence of layered decorators."""
43

44

45
class CodeRemovedError(Exception):
11✔
46
    """Indicates that the removal_version is not in the future.
47

48
    I.e., that the option/function/module with that removal_version has already been removed.
49

50
    Note that the code in question may not actually have been excised from the codebase yet, but
51
    it may be at any time.
52
    """
53

54

55
def is_deprecation_active(start_version: str | None) -> bool:
11✔
56
    return start_version is None or Version(start_version) <= PANTS_SEMVER
3✔
57

58

59
def get_deprecated_tense(removal_version: str) -> str:
11✔
60
    """Provides the grammatical tense for a given deprecated version vs the current version."""
61
    return "is scheduled to be" if (Version(removal_version) >= PANTS_SEMVER) else "was"
11✔
62

63

64
@memoized_method
11✔
65
def validate_deprecation_semver(version_string: str, version_description: str) -> Version:
11✔
66
    """Validates that version_string is a valid semver.
67

68
    If so, returns that semver. Raises an error otherwise.
69

70
    :param version_string: A pantsbuild.pants version which affects some deprecated entity.
71
    :param version_description: A string used in exception messages to describe what the
72
        `version_string` represents.
73
    :raises DeprecationError: if the version_string parameter is invalid.
74
    """
75
    if version_string is None:
11✔
76
        raise MissingSemanticVersionError(f"The {version_description} must be provided.")
1✔
77
    if not isinstance(version_string, str):
11✔
78
        raise BadSemanticVersionError(
1✔
79
            f"The {version_description} must be a version string but was {version_string} with "
80
            f"type {type(version_string)}."
81
        )
82

83
    try:
11✔
84
        v = Version(version_string)
11✔
85
    except InvalidVersion as e:
1✔
86
        raise BadSemanticVersionError(
1✔
87
            f"The given {version_description} {version_string} is not a valid version: {repr(e)}"
88
        )
89

90
    # NB: packaging.Version will see versions like 1.a.0 as 1a0 and as "valid".
91
    # We explicitly want our versions to be of the form x.y.z.
92
    if len(v.base_version.split(".")) != 3:
11✔
93
        raise BadSemanticVersionError(
1✔
94
            f"The given {version_description} is not a valid version: "
95
            f"{version_description}. Expecting the format `x.y.z.dev0`"
96
        )
97
    if not v.is_prerelease:
11✔
98
        raise NonDevSemanticVersionError(
1✔
99
            f"The given {version_description} is not a dev version: {version_string}\n"
100
            "Features should generally be removed in the first `dev` release of a release "
101
            "cycle."
102
        )
103
    return v
11✔
104

105

106
@memoized
11✔
107
def warn_or_error(
11✔
108
    removal_version: str,
109
    entity: str,
110
    hint: str | None,
111
    *,
112
    start_version: str | None = None,
113
    print_warning: bool = True,
114
    stacklevel: int = 0,
115
    context: int = 1,
116
) -> None:
117
    """Check the removal_version against the current Pants version.
118

119
    When choosing a removal version, there is a natural tension between the code-base, which benefits
120
    from short deprecation cycles, and the user-base which may prefer to deal with deprecations less
121
    frequently. As a rule of thumb, if the hint message can fully convey corrective action
122
    succinctly and you judge the impact to be on the small side (effects custom tasks as opposed to
123
    effecting BUILD files), lean towards the next release version as the removal version; otherwise,
124
    consider initiating a discussion to win consensus on a reasonable removal version.
125

126
    Issues a warning if the removal version is > current Pants version or an error otherwise.
127

128
    :param removal_version: The pantsbuild.pants version at which the deprecated entity will
129
        be/was removed.
130
    :param entity: A short description of the deprecated entity, e.g. "using an INI config file".
131
    :param hint: How to migrate.
132
    :param start_version: The pantsbuild.pants version at which the entity will
133
        begin to display a deprecation warning. This must be less than the `removal_version`. If
134
        not provided, the deprecation warning is always displayed.
135
    :param print_warning: Whether to print a warning for deprecations *before* their removal.
136
        If this flag is off, an exception will still be raised for options past their deprecation
137
        date.
138
    :param stacklevel: How far up the call stack to go for blame. Use 0 to disable.
139
    :param context: How many lines of source context to include.
140
    :raises DeprecationError: if the removal_version parameter is invalid.
141
    :raises CodeRemovedError: if the current version is later than the version marked for removal.
142
    """
143
    removal_semver = validate_deprecation_semver(removal_version, "removal version")
11✔
144
    if start_version:
11✔
145
        start_semver = validate_deprecation_semver(start_version, "deprecation start version")
1✔
146
        if start_semver >= removal_semver:
1✔
147
            raise InvalidSemanticVersionOrderingError(
1✔
148
                f"The deprecation start version {start_version} must be less than "
149
                f"the end version {removal_version}."
150
            )
151
        elif PANTS_SEMVER < start_semver:
1✔
UNCOV
152
            return
×
153

154
    msg = (
11✔
155
        f"DEPRECATED: {entity} {get_deprecated_tense(removal_version)} removed in version "
156
        f"{removal_version}."
157
    )
158
    if stacklevel > 0:
11✔
159
        # Get stack frames, ignoring those for internal/builtin code.
160
        frames = [frame for frame in inspect.stack(context) if frame.index is not None]
1✔
161
        if stacklevel < len(frames):
1✔
162
            frame = frames[stacklevel]
1✔
163
            code_context = "    ".join(frame.code_context) if frame.code_context else ""
1✔
164
            msg += f"\n ==> {frame.filename}:{frame.lineno}\n    {code_context}"
1✔
165
    if hint:
11✔
166
        msg += f"\n\n{hint}"
11✔
167

168
    if removal_semver <= PANTS_SEMVER:
11✔
169
        raise CodeRemovedError(msg)
1✔
170
    if print_warning:
11✔
171
        logger.warning(msg)
1✔
172

173

174
def deprecated_conditional(
11✔
175
    predicate: Callable[[], bool],
176
    removal_version: str,
177
    entity: str,
178
    hint: str | None,
179
    *,
180
    start_version: str | None = None,
181
) -> None:
182
    """Mark something as deprecated if the predicate is true."""
183
    validate_deprecation_semver(removal_version, "removal version")
1✔
184
    if predicate():
1✔
185
        warn_or_error(removal_version, entity, hint, start_version=start_version)
1✔
186

187

188
ReturnType = TypeVar("ReturnType")
11✔
189

190

191
def deprecated(
11✔
192
    removal_version: str,
193
    hint: str | None = None,
194
    *,
195
    start_version: str | None = None,
196
) -> Callable[[Callable[..., ReturnType]], Callable[..., ReturnType]]:
197
    """Mark a function or method as deprecated."""
198
    validate_deprecation_semver(removal_version, "removal version")
1✔
199

200
    def decorator(func):
1✔
201
        if not inspect.isfunction(func):
1✔
202
            raise BadDecoratorNestingError(
1✔
203
                "The @deprecated decorator must be applied innermost of all decorators."
204
            )
205

206
        @wraps(func)
1✔
207
        def wrapper(*args, **kwargs):
1✔
208
            warn_or_error(
1✔
209
                removal_version,
210
                f"{func.__module__}.{func.__qualname__}()",
211
                hint,
212
                start_version=start_version,
213
                stacklevel=3,
214
                context=3,
215
            )
216
            return func(*args, **kwargs)
1✔
217

218
        return wrapper
1✔
219

220
    return decorator
1✔
221

222

223
def deprecated_module(
11✔
224
    removal_version: str, hint: str | None, *, start_version: str | None = None
225
) -> None:
226
    """Mark an entire module as deprecated.
227

228
    Add a call to this at the top of the deprecated module.
229
    """
230
    warn_or_error(removal_version, "module", hint, start_version=start_version)
1✔
231

232

233
# TODO: old_container and new_container are both `OptionValueContainer`, but that causes a dep
234
#  cycle.
235
def resolve_conflicting_options(
11✔
236
    *,
237
    old_option: str,
238
    new_option: str,
239
    old_scope: str,
240
    new_scope: str,
241
    old_container: Any,
242
    new_container: Any,
243
) -> Any:
244
    """Utility for resolving an option that's been migrated to a new location.
245

246
    This will check if either option was explicitly configured, and if so, use that. If both were
247
    configured, it will error. Otherwise, it will use the default value for the new, preferred
248
    option.
249

250
    The option names should use snake_case, rather than --kebab-case.
251
    """
252
    old_configured = not old_container.is_default(old_option)
1✔
253
    new_configured = not new_container.is_default(new_option)
1✔
254
    if old_configured and new_configured:
1✔
255

256
        def format_option(*, scope: str, option: str) -> str:
1✔
257
            scope_preamble = "--" if scope == "" else f"--{scope}-"
1✔
258
            return f"`{scope_preamble}{option}`".replace("_", "-")
1✔
259

260
        old_display = format_option(scope=old_scope, option=old_option)
1✔
261
        new_display = format_option(scope=new_scope, option=new_option)
1✔
262
        raise ValueError(
1✔
263
            f"Conflicting options used. You used the new, preferred {new_display}, but also "
264
            f"used the deprecated {old_display}.\n\nPlease use only one of these "
265
            f"(preferably {new_display})."
266
        )
267
    if old_configured:
1✔
268
        return old_container.get(old_option)
1✔
269
    return new_container.get(new_option)
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

© 2025 Coveralls, Inc