Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/notes/2.31.x.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ Go now compiles with trimpath to strip sandbox paths from output, allowing for r

Pants no longer supports loading `pkg_resources`-style namespace packages for plugins. Instead, just use ["native namespace packages"](https://packaging.python.org/en/latest/guides/packaging-namespace-packages/#native-namespace-packages) as per [PEP 420](https://peps.python.org/pep-0420/).

#### nFPM backend

Added a new rule to help in-repo plugins implement the `inject_nfpm_package_fields(InjectNfpmPackageFieldsRequest) -> InjectedNfpmPackageFields` polymorphic rule. The `get_package_field_sets_for_nfpm_content_file_deps` rule (in the `pants.backend.nfpm.util_rules.contents` module) collects selected `PackageFieldSet`s from the contents of an `nfpm_*_package` so that the packages can be analyzed to inject things like package requirements.
Comment on lines +45 to +47
Copy link
Member Author

@cognifloyd cognifloyd Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I debated labeling this PR with release-notes:not-required [CI] PR doesn't require mention in release notes
I decided to add this release notes entry because the plugin API was originally included in the release notes and it is a separate rule meant for use in in-repo/external plugins.

It will also be used in pants in a rule that should be added in #22861 (or a PR that gets split off of that one), but it is separate so that I can reuse this tested rule in in-repo/external plugins as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think when in doubt, put it in the notes, so this is great.


## Full Changelog

For the full changelog, see the individual GitHub Releases for this series: <https://github.com/pantsbuild/pants/releases>
8 changes: 6 additions & 2 deletions src/python/pants/backend/nfpm/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass

from pants.backend.nfpm.field_sets import (
Expand All @@ -14,6 +15,7 @@
)
from pants.backend.nfpm.field_sets import rules as field_sets_rules
from pants.backend.nfpm.subsystem import NfpmSubsystem
from pants.backend.nfpm.util_rules.contents import rules as contents_rules
from pants.backend.nfpm.util_rules.generate_config import (
NfpmPackageConfigRequest,
generate_nfpm_yaml,
Expand All @@ -34,7 +36,8 @@
from pants.engine.intrinsics import create_digest, digest_to_snapshot, merge_digests, remove_prefix
from pants.engine.platform import Platform
from pants.engine.process import Process, execute_process_or_raise
from pants.engine.rules import collect_rules, implicitly, rule
from pants.engine.rules import Rule, collect_rules, implicitly, rule
from pants.engine.unions import UnionRule
from pants.util.logging import LogLevel


Expand Down Expand Up @@ -157,12 +160,13 @@ async def package_nfpm_rpm_package(field_set: NfpmRpmPackageFieldSet) -> BuiltPa
return built_package


def rules():
def rules() -> Iterable[Rule | UnionRule]:
Copy link
Member Author

@cognifloyd cognifloyd Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added typehint is not strictly required by this PR. It's a minor consistency improvement (+4 -2 lines), and I'm already editing this file (+2 lines), so I included it here.

return [
*package.rules(),
*field_sets_rules(),
*inject_config_rules(),
*generate_config_rules(),
*sandbox_rules(),
*contents_rules(),
*collect_rules(),
]
88 changes: 88 additions & 0 deletions src/python/pants/backend/nfpm/util_rules/contents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Copyright 2025 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass

from pants.backend.nfpm.field_sets import NfpmContentFileFieldSet
from pants.core.goals.package import PackageFieldSet, TraverseIfNotPackageTarget
from pants.engine.addresses import Address, Addresses
from pants.engine.internals.graph import find_valid_field_sets
from pants.engine.internals.graph import transitive_targets as get_transitive_targets
from pants.engine.rules import Rule, collect_rules, implicitly, rule
from pants.engine.target import (
FieldSetsPerTarget,
FieldSetsPerTargetRequest,
TransitiveTargets,
TransitiveTargetsRequest,
)
from pants.engine.unions import UnionMembership, UnionRule


@dataclass(frozen=True)
class GetPackageFieldSetsForNfpmContentFileDepsRequest:
addresses: Addresses
field_set_types: tuple[type[PackageFieldSet], ...]

def __init__(
self, addresses: Iterable[Address], field_set_types: Iterable[type[PackageFieldSet]]
):
object.__setattr__(self, "addresses", Addresses(addresses))
object.__setattr__(self, "field_set_types", tuple(field_set_types))


@dataclass(frozen=True)
class PackageFieldSetsForNfpmContentFileDeps:
nfpm_content_file_targets: TransitiveTargets
package_field_sets: FieldSetsPerTarget


@rule
async def get_package_field_sets_for_nfpm_content_file_deps(
request: GetPackageFieldSetsForNfpmContentFileDepsRequest,
union_membership: UnionMembership,
) -> PackageFieldSetsForNfpmContentFileDeps:
def transitive_targets_request(roots: Iterable[Address]):
return TransitiveTargetsRequest(
tuple(roots),
should_traverse_deps_predicate=TraverseIfNotPackageTarget(
roots=tuple(roots),
union_membership=union_membership,
),
)

transitive_targets: TransitiveTargets = await get_transitive_targets(
transitive_targets_request(request.addresses), **implicitly()
)
content_file_transitive_targets: TransitiveTargets = await get_transitive_targets(
transitive_targets_request(
[
tgt.address
for tgt in transitive_targets.dependencies
if NfpmContentFileFieldSet.is_applicable(tgt)
]
),
**implicitly(),
)
package_field_sets: FieldSetsPerTarget = await find_valid_field_sets(
FieldSetsPerTargetRequest(
PackageFieldSet, # has to be a union parent
[
tgt
for tgt in content_file_transitive_targets.dependencies
if any(
field_set_type.is_applicable(tgt) for field_set_type in request.field_set_types
)
],
),
**implicitly(),
)
return PackageFieldSetsForNfpmContentFileDeps(
content_file_transitive_targets, package_field_sets
)


def rules() -> Iterable[Rule | UnionRule]:
return collect_rules()
219 changes: 219 additions & 0 deletions src/python/pants/backend/nfpm/util_rules/contents_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
# Copyright 2025 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import annotations

from textwrap import dedent

import pytest

from pants.backend.nfpm.dependency_inference import rules as nfpm_dependency_inference_rules
from pants.backend.nfpm.field_sets import NFPM_CONTENT_FIELD_SET_TYPES, NfpmContentFieldSet
from pants.backend.nfpm.target_types import target_types as nfpm_target_types
from pants.backend.nfpm.target_types_rules import rules as nfpm_target_types_rules
from pants.backend.nfpm.util_rules.contents import (
GetPackageFieldSetsForNfpmContentFileDepsRequest,
PackageFieldSetsForNfpmContentFileDeps,
)
from pants.backend.nfpm.util_rules.contents import rules as nfpm_contents_rules
from pants.backend.nfpm.util_rules.generate_config import rules as nfpm_generate_config_rules
from pants.backend.nfpm.util_rules.inject_config import rules as nfpm_inject_config_rules
from pants.core.target_types import ArchiveFieldSet, ArchiveTarget, FilesGeneratorTarget, FileTarget
from pants.core.target_types import rules as core_target_type_rules
from pants.engine.addresses import Address
from pants.engine.rules import QueryRule
from pants.engine.unions import UnionRule
from pants.testutil.rule_runner import RuleRunner

_PKG_NAME = "pkg"
_PKG_VERSION = "3.2.1"


@pytest.fixture
def rule_runner() -> RuleRunner:
rule_runner = RuleRunner(
target_types=[
ArchiveTarget,
FileTarget,
FilesGeneratorTarget,
*nfpm_target_types(),
],
rules=[
*core_target_type_rules(),
*nfpm_target_types_rules(),
*nfpm_dependency_inference_rules(),
*nfpm_generate_config_rules(),
*nfpm_inject_config_rules(),
*nfpm_contents_rules(),
*(
UnionRule(NfpmContentFieldSet, field_set_type)
for field_set_type in NFPM_CONTENT_FIELD_SET_TYPES
),
QueryRule(
PackageFieldSetsForNfpmContentFileDeps,
(GetPackageFieldSetsForNfpmContentFileDepsRequest,),
),
],
)
rule_runner.set_options([], env_inherit={"PATH", "PYENV_ROOT", "HOME"})
return rule_runner


@pytest.mark.parametrize(
("packager",),
(
("apk",),
("archlinux",),
("deb",),
("rpm",),
),
)
def test_get_package_field_sets_for_nfpm_content_file_deps(rule_runner: RuleRunner, packager: str):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied a bunch of this from the other tests in this directory. The tests are not as DRY as I would like, but moving the rule_runner.write_files calls to a fixture in conftest.py could make the tests harder to follow. Sharing logic across tests is not always straightforward.

description = f"A {packager} package"
rule_runner.write_files(
{
"BUILD": dedent(
f"""
nfpm_{packager}_package(
name="{_PKG_NAME}",
description="{description}",
package_name="{_PKG_NAME}",
version="{_PKG_VERSION}",
{"" if packager != "deb" else 'maintainer="Foo Bar <[email protected]>",'}
dependencies=[
"contents:files",
"contents:file",
"package:package",
"package:output_path_package",
],
)
"""
),
"package/BUILD": dedent(
"""
file(
name="file",
source="archive-contents.txt",
)
archive(
name="archive",
format="tar",
files=[":file"],
)
nfpm_content_file(
name="package",
src="archive.tar",
dst="/opt/foo/archive.tar",
dependencies=[":archive"],
)
archive(
name="output_path_archive",
format="tar",
output_path="relative_to_build_root.tar",
files=[":file"],
)
nfpm_content_file(
name="output_path_package",
src="relative_to_build_root.tar",
dst="/opt/foo/relative_to_build_root.tar",
dependencies=[":output_path_archive"],
)
"""
),
"package/archive-contents.txt": "",
"contents/BUILD": dedent(
f"""
file(
name="unrelated_file",
source="should.not.be.in.digest.txt",
)
file(
name="sandbox_file",
source="sandbox-file.txt",
)
nfpm_content_files(
name="files",
files=[
("sandbox-file.txt", "/usr/share/{_PKG_NAME}/{_PKG_NAME}.{_PKG_VERSION}/installed-file.txt"),
("sandbox-file.txt", "/etc/{_PKG_NAME}/installed-file.txt"),
],
dependencies=[":sandbox_file"],
)
nfpm_content_file(
name="file",
source="some-executable",
dst="/usr/bin/some-executable",
)
nfpm_content_symlinks(
name="symlinks",
symlinks=(
("some-executable", "/usr/bin/new-relative-symlinked-exe"),
("/usr/bin/some-executable", "/usr/bin/new-absolute-symlinked-exe"),
),
overrides={{
"/usr/bin/new-relative-symlinked-exe": dict(file_group="special-group"),
}},
)
nfpm_content_symlink(
name="symlink",
src="/usr/bin/some-executable",
dst="/usr/sbin/sbin-executable",
)
nfpm_content_dirs(
name="dirs",
dirs=["/usr/share/{_PKG_NAME}"],
overrides={{
"/usr/share/{_PKG_NAME}": dict(file_group="special-group"),
}},
)
nfpm_content_dir(
name="dir",
dst="/etc/{_PKG_NAME}",
file_mode=0o700,
)
"""
),
"contents/sandbox-file.txt": "",
"contents/some-executable": "",
}
)
address = Address("", target_name=_PKG_NAME)

result = rule_runner.request(
PackageFieldSetsForNfpmContentFileDeps,
[
GetPackageFieldSetsForNfpmContentFileDepsRequest([address], [ArchiveFieldSet]),
],
)

content_file_tgts = result.nfpm_content_file_targets.roots
assert len(content_file_tgts) == 5
assert {tgt.address for tgt in content_file_tgts} == {
Address("package", target_name="package"),
Address("package", target_name="output_path_package"),
Address("contents", target_name="file"),
Address("contents", target_name="files", generated_name="/etc/pkg/installed-file.txt"),
Address(
"contents",
target_name="files",
generated_name="/usr/share/pkg/pkg.3.2.1/installed-file.txt",
),
}

content_file_deps = result.nfpm_content_file_targets.dependencies
assert len(content_file_deps) == 3
assert {tgt.address for tgt in content_file_deps} == {
Address("package", target_name="archive"),
Address("package", target_name="output_path_archive"),
Address("contents", target_name="sandbox_file"),
}

pkg_field_sets = result.package_field_sets
assert len(pkg_field_sets.collection) == 2
assert [len(collection) for collection in pkg_field_sets.collection] == [1, 1]

assert len(pkg_field_sets.field_sets) == 2
assert {tgt.address for tgt in pkg_field_sets.field_sets} == {
Address("package", target_name="archive"),
Address("package", target_name="output_path_archive"),
}
Loading