Skip to content

Commit a20fa8d

Browse files
authored
nfpm: support chaining multiple InjectNfpmPackageFieldsRequests (sorted w/ simple priority integer) (#22864)
This extends then `pants.backend.experimental.nfpm` backend's plugin API, allowing multiple implementations of this polymorphic-rule plugin hook (before this change, only one implementation was allowed): - `inject_nfpm_package_fields(InjectNfpmPackageFieldsRequest) -> InjectedNfpmPackageFields` Unlike many polymorphic/union rules, each rule runs sequentially instead of concurrently. This is conceptually similar to middleware, because the request passed to each rule includes the results of the previous (lower priority) rule. So, one rule can override or extend fields that were injected in previous rules, not just the original fields from the BUILD-file-based `nfpm_*_package` target. I tried several approaches to sorting the request classes, but most were error prone or simply didn't work. Heuristic-based sorting proved to be complex and didn't work in all of my test cases (eg one heuristic was looking at the class's module to make pants-provided requests lower priority). A simple `priority` integer is far simpler and more reliable, so that's what this implements. Another thing that didn't work was adding `__lt__` as a `@classmethod` on the abstract `InjectNfpmPackageFieldsRequest`. That fails because `sorted()` requires an instance method, not a class method. The only way I found to add an `__lt__` method on a class is via a metaclass. So, this adds a `_PrioritizedSortableClassMetaclass` to `InjectNfpmPackageFieldsRequest` allowing for simple `OneRequest < TwoRequest` sorting of the class type like this: ```diff inject_nfpm_config_request_types = union_membership.get(InjectNfpmPackageFieldsRequest) applicable_inject_nfpm_config_request_types = tuple( - request - for request in inject_nfpm_config_request_types - if request.is_applicable(target) + sorted( + request_type + for request_type in inject_nfpm_config_request_types + if request_type.is_applicable(target) + ) ) ``` Then, to make subclasses higher priority than their parent classes, I added an `__init_subclass__` method that increases `cls.priority` (unless the subclass already has a different priority than the parent class). It's a little odd to use both a metaclass and `__init_subclass__`, but I did not like the idea of re-implementing the on-subclass-creation logic in our metaclass. `InjectNfpmPackageFieldsRequest` was initially modeled on the python backend's `SetupKwargsRequest` [1]. If the rule-chaining proves itself in the experimental `nfpm` backend, maybe we can move `_PrioritizedSortableClassMetaclass` out of the backend to somewhere in pants core, updating `SetupKwargsRequest` and related rules to make use of rule-chaining as well. [1] https://www.pantsbuild.org/stable/docs/writing-plugins/common-plugin-tasks/custom-python-artifact-kwargs
1 parent 874d069 commit a20fa8d

File tree

3 files changed

+185
-37
lines changed

3 files changed

+185
-37
lines changed

docs/notes/2.31.x.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ Pants no longer supports loading `pkg_resources`-style namespace packages for pl
4646

4747
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.
4848

49+
Added prioritized rule chaining for `InjectNfpmPackageFieldsRequest` polymorphic rule implementations. Before, only one request type was allowed to inject fields on each `nfpm_*_package` target. Now, the `InjectNfpmPackageFieldsRequest` subclasses can declare a priority (as `ClassVar[int]`) so that the rule runs after lower-priority requests and before higher-priority requests. By default, `InjectNfpmPackageFieldsRequest` implementations are priority 10, and any subclasses have that priority increased by 1 so that the subclass is higher priority than its parent class. Any implementations distributed with pants should have a lower priority (`< 10`) so that in-repo and external plugins can override the pants-provided defaults.
50+
4951
## Full Changelog
5052

5153
For the full changelog, see the individual GitHub Releases for this series: <https://github.com/pantsbuild/pants/releases>

src/python/pants/backend/nfpm/util_rules/inject_config.py

Lines changed: 86 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33

44
from __future__ import annotations
55

6-
from abc import ABC, abstractmethod
6+
from abc import ABC, ABCMeta, abstractmethod
77
from collections.abc import Iterable
88
from dataclasses import dataclass
9+
from typing import Any, ClassVar, TypeVar, cast
910

1011
from pants.backend.nfpm.fields.scripts import NfpmPackageScriptsField
1112
from pants.engine.environment import EnvironmentName
@@ -16,6 +17,9 @@
1617
from pants.util.frozendict import FrozenDict
1718
from pants.util.strutil import softwrap
1819

20+
# NB: This TypeVar serves the same purpose here as in pants.engine.target
21+
_F = TypeVar("_F", bound=Field)
22+
1923

2024
@dataclass(frozen=True)
2125
class InjectedNfpmPackageFields:
@@ -71,27 +75,87 @@ def __init__(
7175
)
7276

7377

78+
class _PrioritizedSortableClassMetaclass(ABCMeta):
79+
"""This metaclass implements prioritized sorting of subclasses (not class instances)."""
80+
81+
priority: ClassVar[int]
82+
83+
def __lt__(self, other: Any) -> bool:
84+
"""Determine if this class is lower priority than `other` (when chaining request rules).
85+
86+
The rule that runs the lowest priority request goes first, and the rule that runs the
87+
highest priority request goes last. The results (the `injected_fields`) of lower priority
88+
rules can be overridden by higher priority rules. The last rule to run, the rule for the
89+
highest priority request class, can override any of the fields injected by lower priority
90+
request rules.
91+
"""
92+
if not isinstance(other, _PrioritizedSortableClassMetaclass):
93+
return NotImplemented
94+
if self.priority != other.priority:
95+
return self.priority < other.priority
96+
# other has same priority: fall back to name comparison (ensures deterministic sort)
97+
return (self.__module__, self.__qualname__) < (other.__module__, other.__qualname__)
98+
99+
74100
# Note: This only exists as a hook for additional logic for nFPM config generation, e.g. for plugin
75101
# authors. To resolve `InjectedNfpmPackageFields`, call `determine_injected_nfpm_package_fields`,
76102
# which handles running any custom implementations vs. using the default implementation.
77103
@union(in_scope_types=[EnvironmentName])
78104
@dataclass(frozen=True)
79-
class InjectNfpmPackageFieldsRequest(ABC):
105+
class InjectNfpmPackageFieldsRequest(ABC, metaclass=_PrioritizedSortableClassMetaclass):
80106
"""A request to inject nFPM config for nfpm_package_* targets.
81107
82108
By default, Pants will use the nfpm_package_* fields in the BUILD file unchanged to generate the
83109
nfpm.yaml config file for nFPM. To customize this, subclass `InjectNfpmPackageFieldsRequest`,
84110
register `UnionRule(InjectNfpmPackageFieldsRequest, MyCustomInjectNfpmPackageFieldsRequest)`,
85111
and add a rule that takes your subclass as a parameter and returns `InjectedNfpmPackageFields`.
112+
113+
The `target` attribute of this class holds the original target as defined in BUILD files.
114+
The `injected_fields` attribute of this class contains the results of any previous rule.
115+
`injected_fields` will be empty for the first rule in the chain. Subsequent rules can remove
116+
or replace fields injected by previous rules. The final rule in the chain returns the final
117+
`InjectedNfpmPackageFields` instance that is used to actually generate the nfpm config.
118+
In general, rules should include a copy of `request.injected_fields` in their return value
119+
with something like this:
120+
121+
address = request.target.address
122+
fields: list[Field] = list(request.injected_fields.values())
123+
fields.append(NfpmFoobarField("foobar", address))
124+
return InjectedNfpmPackageFields(fields, address=address)
125+
126+
Chaining rules like this allows pants to inject some fields, while allowing in-repo plugins
127+
to override or remove them.
86128
"""
87129

88130
target: Target
131+
injected_fields: FrozenDict[type[Field], Field]
132+
133+
# Classes in pants-provided backends should be priority<10 so that in-repo and external
134+
# plugins are higher priority by default. This way, the fields that get injected by default
135+
# can be overridden as needed in the in-repo or external plugins.
136+
priority: ClassVar[int] = 10
137+
138+
def __init_subclass__(cls, **kwargs) -> None:
139+
super().__init_subclass__(**kwargs)
140+
if cls.priority == getattr(super(), "priority", cls.priority):
141+
# subclasses are higher priority than their parent class (unless set otherwise).
142+
cls.priority += 1
89143

90144
@classmethod
91145
@abstractmethod
92146
def is_applicable(cls, target: Target) -> bool:
93147
"""Whether to use this InjectNfpmPackageFieldsRequest implementation for this target."""
94148

149+
def get_field(self, field: type[_F]) -> _F:
150+
"""Get a `Field` from `injected_fields` (returned by earlier rules) or from the target.
151+
152+
This will throw a KeyError if the `Field` is not registered on the target (unless an earlier
153+
rule added it to `injected_fields` which might be disallowed in the future).
154+
"""
155+
if field in self.injected_fields:
156+
return cast(_F, self.injected_fields[field])
157+
return self.target[field]
158+
95159

96160
@rule(polymorphic=True)
97161
async def inject_nfpm_package_fields(
@@ -115,38 +179,33 @@ async def determine_injected_nfpm_package_fields(
115179
wrapper: NfpmPackageTargetWrapper, union_membership: UnionMembership
116180
) -> InjectedNfpmPackageFields:
117181
target = wrapper.target
118-
inject_nfpm_config_requests = union_membership.get(InjectNfpmPackageFieldsRequest)
119-
applicable_inject_nfpm_config_requests = tuple(
120-
request for request in inject_nfpm_config_requests if request.is_applicable(target)
182+
inject_nfpm_config_request_types = union_membership.get(InjectNfpmPackageFieldsRequest)
183+
184+
# Requests are sorted (w/ priority ClassVar) before chaining the rules that take them.
185+
applicable_inject_nfpm_config_request_types = tuple(
186+
sorted(
187+
request_type
188+
for request_type in inject_nfpm_config_request_types
189+
if request_type.is_applicable(target)
190+
)
121191
)
122192

123193
# If no provided implementations, fall back to our default implementation that simply returns
124194
# what the user explicitly specified in the BUILD file.
125-
if not applicable_inject_nfpm_config_requests:
195+
if not applicable_inject_nfpm_config_request_types:
126196
return InjectedNfpmPackageFields((), address=target.address)
127197

128-
if len(applicable_inject_nfpm_config_requests) > 1:
129-
possible_requests = sorted(
130-
plugin.__name__ for plugin in applicable_inject_nfpm_config_requests
198+
injected: InjectedNfpmPackageFields
199+
injected_fields: FrozenDict[type[Field], Field] = FrozenDict()
200+
for request_type in applicable_inject_nfpm_config_request_types:
201+
chained_request: InjectNfpmPackageFieldsRequest = request_type(target, injected_fields) # type: ignore[abstract]
202+
injected = await inject_nfpm_package_fields(
203+
**implicitly({chained_request: InjectNfpmPackageFieldsRequest})
131204
)
132-
raise ValueError(
133-
softwrap(
134-
f"""
135-
Multiple registered `InjectNfpmPackageFieldsRequest`s can work on the target
136-
{target.address}, and it's ambiguous which to use: {possible_requests}
137-
138-
Please activate fewer implementations, or make the classmethod `is_applicable()`
139-
more precise so that only one implementation is applicable for this target.
140-
"""
141-
)
142-
)
143-
inject_nfpm_config_request_type = applicable_inject_nfpm_config_requests[0]
144-
inject_nfpm_config_request: InjectNfpmPackageFieldsRequest = inject_nfpm_config_request_type(
145-
target
146-
) # type: ignore[abstract]
147-
return await inject_nfpm_package_fields(
148-
**implicitly({inject_nfpm_config_request: InjectNfpmPackageFieldsRequest})
149-
)
205+
injected_fields = injected.field_values
206+
207+
# return the last result in the chain of results
208+
return injected
150209

151210

152211
def rules():

src/python/pants/backend/nfpm/util_rules/inject_config_test.py

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
from __future__ import annotations
55

66
from textwrap import dedent
7+
from typing import Any
78

89
import pytest
910

11+
from pants.backend.nfpm.fields.all import NfpmPlatformField
1012
from pants.backend.nfpm.fields.version import NfpmVersionField, NfpmVersionReleaseField
1113
from pants.backend.nfpm.target_types import target_types as nfpm_target_types
1214
from pants.backend.nfpm.target_types_rules import rules as nfpm_target_types_rules
@@ -24,24 +26,104 @@
2426
_PKG_NAME = "pkg"
2527

2628

27-
class PluginInjectNfpmPackageFieldsRequest(InjectNfpmPackageFieldsRequest):
29+
class PluginInjectFieldsRequest(InjectNfpmPackageFieldsRequest):
2830
@classmethod
2931
def is_applicable(cls, _) -> bool:
3032
return True
3133

3234

3335
@rule
3436
async def inject_nfpm_package_fields_plugin(
35-
request: PluginInjectNfpmPackageFieldsRequest,
37+
request: PluginInjectFieldsRequest,
3638
) -> InjectedNfpmPackageFields:
3739
address = request.target.address
38-
fields: list[Field] = [
39-
NfpmVersionField("9.8.7-dev+git", address),
40-
NfpmVersionReleaseField(6, address),
41-
]
40+
# preserve fields from earlier rules in chain
41+
fields: list[Field] = list(request.injected_fields.values())
42+
if NfpmVersionField not in request.injected_fields:
43+
fields.extend(
44+
[
45+
NfpmVersionField("9.8.7-dev+git", address),
46+
NfpmVersionReleaseField(6, address),
47+
]
48+
)
4249
return InjectedNfpmPackageFields(fields, address=address)
4350

4451

52+
class SubclassPluginInjectFieldsRequest(PluginInjectFieldsRequest):
53+
pass
54+
55+
56+
class AnotherSubclassPluginInjectFieldsRequest(PluginInjectFieldsRequest):
57+
pass
58+
59+
60+
@rule
61+
async def inject_nfpm_package_fields_subclass(
62+
request: SubclassPluginInjectFieldsRequest,
63+
) -> InjectedNfpmPackageFields:
64+
address = request.target.address
65+
# preserve fields from earlier rules in chain
66+
fields: list[Field] = list(request.injected_fields.values())
67+
if not fields or NfpmVersionReleaseField in request.injected_fields:
68+
release = 0
69+
if NfpmVersionReleaseField in request.injected_fields:
70+
old_release = request.injected_fields[NfpmVersionReleaseField].value
71+
assert old_release is not None
72+
release = 10 + old_release
73+
fields.append(NfpmVersionReleaseField(release, address))
74+
return InjectedNfpmPackageFields(fields, address=address)
75+
76+
77+
class HighPriorityInjectFieldsRequest(InjectNfpmPackageFieldsRequest):
78+
priority = 100
79+
80+
@classmethod
81+
def is_applicable(cls, _) -> bool:
82+
return True
83+
84+
85+
@rule
86+
async def inject_nfpm_package_fields_high_priority(
87+
request: HighPriorityInjectFieldsRequest,
88+
) -> InjectedNfpmPackageFields:
89+
address = request.target.address
90+
# preserve fields from earlier rules in chain
91+
fields: list[Field] = list(request.injected_fields.values())
92+
if not fields or NfpmVersionField not in request.injected_fields:
93+
fields.extend(
94+
[
95+
NfpmVersionField("9.9.9-dev+git", address),
96+
NfpmVersionReleaseField(9, address),
97+
]
98+
)
99+
# This high priority implementation wants to force Platform to always be "foobar"
100+
# even if a lower priority rule injected NfpmPlatformField with a different value.
101+
fields.append(NfpmPlatformField("foobar", address))
102+
return InjectedNfpmPackageFields(fields, address=address)
103+
104+
105+
@pytest.mark.parametrize(
106+
"a,b,expected_lt",
107+
(
108+
# HighPriority* > SubclassPlugin* > Plugin*
109+
(HighPriorityInjectFieldsRequest, SubclassPluginInjectFieldsRequest, False),
110+
(HighPriorityInjectFieldsRequest, PluginInjectFieldsRequest, False),
111+
(SubclassPluginInjectFieldsRequest, HighPriorityInjectFieldsRequest, True),
112+
(SubclassPluginInjectFieldsRequest, PluginInjectFieldsRequest, False),
113+
(PluginInjectFieldsRequest, HighPriorityInjectFieldsRequest, True),
114+
(PluginInjectFieldsRequest, SubclassPluginInjectFieldsRequest, True),
115+
# Subclass* > AnotherSubclass* : with same priority, sort uses name of module+class
116+
(SubclassPluginInjectFieldsRequest, AnotherSubclassPluginInjectFieldsRequest, False),
117+
(AnotherSubclassPluginInjectFieldsRequest, SubclassPluginInjectFieldsRequest, True),
118+
# self is equal which is not less than
119+
(HighPriorityInjectFieldsRequest, HighPriorityInjectFieldsRequest, False),
120+
),
121+
)
122+
def test_nfpm_package_fields_request_class_sort(a: Any, b: Any, expected_lt):
123+
ret = a < b
124+
assert ret == expected_lt, f"a.priority={a.priority} b.priority={b.priority}"
125+
126+
45127
@pytest.fixture
46128
def rule_runner() -> RuleRunner:
47129
rule_runner = RuleRunner(
@@ -52,7 +134,11 @@ def rule_runner() -> RuleRunner:
52134
*nfpm_target_types_rules(),
53135
*nfpm_inject_config_rules(),
54136
inject_nfpm_package_fields_plugin,
55-
UnionRule(InjectNfpmPackageFieldsRequest, PluginInjectNfpmPackageFieldsRequest),
137+
inject_nfpm_package_fields_subclass,
138+
inject_nfpm_package_fields_high_priority,
139+
UnionRule(InjectNfpmPackageFieldsRequest, PluginInjectFieldsRequest),
140+
UnionRule(InjectNfpmPackageFieldsRequest, SubclassPluginInjectFieldsRequest),
141+
UnionRule(InjectNfpmPackageFieldsRequest, HighPriorityInjectFieldsRequest),
56142
QueryRule(InjectedNfpmPackageFields, (NfpmPackageTargetWrapper,)),
57143
],
58144
)
@@ -90,6 +176,7 @@ def test_determine_injected_nfpm_package_fields(rule_runner: RuleRunner, package
90176
target = rule_runner.get_target(Address("", target_name=_PKG_NAME))
91177
result = rule_runner.request(InjectedNfpmPackageFields, [NfpmPackageTargetWrapper(target)])
92178
field_values = result.field_values
93-
assert len(field_values) == 2
94-
assert field_values[NfpmVersionField].value == "9.8.7-dev+git"
95-
assert field_values[NfpmVersionReleaseField].value == 6
179+
assert len(field_values) == 3
180+
assert field_values[NfpmVersionField].value == "9.8.7-dev+git" # (Plugin*)
181+
assert field_values[NfpmVersionReleaseField].value == 16 # 6 (Plugin*) + 10 (SubclassPlugin*)
182+
assert field_values[NfpmPlatformField].value == "foobar" # (HighPriority*)

0 commit comments

Comments
 (0)