• 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

92.7
/src/python/pants/engine/rules.py
1
# Copyright 2016 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 functools
11✔
7
import inspect
11✔
8
import sys
11✔
9
from collections.abc import Callable, Coroutine, Iterable, Mapping, Sequence
11✔
10
from dataclasses import dataclass
11✔
11
from enum import Enum
11✔
12
from types import FrameType, ModuleType
11✔
13
from typing import (
11✔
14
    Any,
15
    NotRequired,
16
    Protocol,
17
    TypedDict,
18
    TypeVar,
19
    Unpack,
20
    cast,
21
    get_type_hints,
22
    overload,
23
)
24

25
from typing_extensions import ParamSpec
11✔
26

27
from pants.engine.engine_aware import SideEffecting
11✔
28
from pants.engine.internals.rule_visitor import collect_awaitables
11✔
29
from pants.engine.internals.selectors import AwaitableConstraints, Call
11✔
30
from pants.engine.internals.selectors import Effect as Effect  # noqa: F401
11✔
31
from pants.engine.internals.selectors import Get as Get  # noqa: F401
11✔
32
from pants.engine.internals.selectors import MultiGet as MultiGet  # noqa: F401
11✔
33
from pants.engine.internals.selectors import concurrently as concurrently  # noqa: F401
11✔
34
from pants.engine.unions import UnionRule
11✔
35
from pants.util.frozendict import FrozenDict
11✔
36
from pants.util.logging import LogLevel
11✔
37
from pants.util.ordered_set import FrozenOrderedSet, OrderedSet
11✔
38
from pants.util.strutil import softwrap
11✔
39

40
PANTS_RULES_MODULE_KEY = "__pants_rules__"
11✔
41

42

43
def implicitly(*args) -> dict[str, Any]:
11✔
44
    # NB: This function does not have a `TypedDict` return type, because the `@rule` decorator
45
    # cannot adjust the type of the `@rule` function to include a keyword argument (keyword
46
    # arguments are not supported by PEP-612).
47
    return {"__implicitly": args}
9✔
48

49

50
class RuleType(Enum):
11✔
51
    rule = "rule"
11✔
52
    goal_rule = "goal_rule"
11✔
53
    uncacheable_rule = "_uncacheable_rule"
11✔
54

55

56
P = ParamSpec("P")
11✔
57
R = TypeVar("R")
11✔
58
SyncRuleT = Callable[P, R]
11✔
59
AsyncRuleT = Callable[P, Coroutine[Any, Any, R]]
11✔
60
RuleDecorator = Callable[[SyncRuleT | AsyncRuleT], AsyncRuleT]
11✔
61

62

63
def _rule_call_trampoline(
11✔
64
    rule_id: str, output_type: type[Any], func: Callable[P, R]
65
) -> Callable[P, R]:
66
    @functools.wraps(func)  # type: ignore
11✔
67
    async def wrapper(*args, __implicitly: Sequence[Any] = (), **kwargs):
11✔
68
        call = Call(rule_id, output_type, args, *__implicitly)
11✔
69
        return await call
11✔
70

71
    return cast(Callable[P, R], wrapper)
11✔
72

73

74
def _make_rule(
11✔
75
    func_id: str,
76
    rule_type: RuleType,
77
    return_type: type[Any],
78
    parameter_types: dict[str, type[Any]],
79
    masked_types: Iterable[type[Any]],
80
    *,
81
    cacheable: bool,
82
    polymorphic: bool,
83
    canonical_name: str,
84
    desc: str | None,
85
    level: LogLevel,
86
) -> RuleDecorator:
87
    """A @decorator that declares that a particular static function may be used as a TaskRule.
88

89
    :param rule_type: The specific decorator used to declare the rule.
90
    :param return_type: The return/output type for the Rule. This must be a concrete Python type.
91
    :param parameter_types: A sequence of types that matches the number and order of arguments to
92
                            the decorated function.
93
    :param cacheable: Whether the results of executing the Rule should be cached as keyed by all of
94
                      its inputs.
95
    :param polymorphic: Whether the rule is an abstract base method for polymorphic dispatch via
96
                        a union type.
97
    """
98

99
    is_goal_cls = getattr(return_type, "__goal__", False)
11✔
100
    if rule_type == RuleType.rule and is_goal_cls:
11✔
UNCOV
101
        raise TypeError(
×
102
            "An `@rule` that returns a `Goal` must instead be declared with `@goal_rule`."
103
        )
104
    if rule_type == RuleType.goal_rule and not is_goal_cls:
11✔
UNCOV
105
        raise TypeError("An `@goal_rule` must return a subclass of `engine.goal.Goal`.")
×
106

107
    def wrapper(original_func):
11✔
108
        if not inspect.isfunction(original_func):
11✔
109
            raise ValueError("The @rule decorator must be applied innermost of all decorators.")
×
110

111
        # Set our own custom `__line_number__` dunder so that the engine may visualize the line number.
112
        original_func.__line_number__ = original_func.__code__.co_firstlineno
11✔
113
        original_func.rule_id = canonical_name
11✔
114

115
        awaitables = FrozenOrderedSet(collect_awaitables(original_func))
11✔
116

117
        validate_requirements(func_id, parameter_types, awaitables, cacheable)
11✔
118
        func = _rule_call_trampoline(canonical_name, return_type, original_func)
11✔
119

120
        # NB: The named definition of the rule ends up wrapped in a trampoline to handle memoization
121
        # and implicit arguments for direct by-name calls. But the `TaskRule` takes a reference to
122
        # the original unwrapped function, which avoids the need for a special protocol when the
123
        # engine invokes a @rule under memoization.
124
        func.rule = TaskRule(
11✔
125
            return_type,
126
            FrozenDict(parameter_types),
127
            awaitables,
128
            masked_types,
129
            original_func,
130
            canonical_name=canonical_name,
131
            desc=desc,
132
            level=level,
133
            cacheable=cacheable,
134
            polymorphic=polymorphic,
135
        )
136
        return func
11✔
137

138
    return wrapper
11✔
139

140

141
class InvalidTypeAnnotation(TypeError):
11✔
142
    """Indicates an incorrect type annotation for an `@rule`."""
143

144

145
class UnrecognizedRuleArgument(TypeError):
11✔
146
    """Indicates an unrecognized keyword argument to a `@rule`."""
147

148

149
class MissingTypeAnnotation(TypeError):
11✔
150
    """Indicates a missing type annotation for an `@rule`."""
151

152

153
class MissingReturnTypeAnnotation(InvalidTypeAnnotation):
11✔
154
    """Indicates a missing return type annotation for an `@rule`."""
155

156

157
class MissingParameterTypeAnnotation(InvalidTypeAnnotation):
11✔
158
    """Indicates a missing parameter type annotation for an `@rule`."""
159

160

161
class DuplicateRuleError(TypeError):
11✔
162
    """Invalid to overwrite `@rule`s using the same name in the same module."""
163

164

165
def _ensure_type_annotation(
11✔
166
    *,
167
    type_annotation: type[Any] | None,
168
    name: str,
169
    raise_type: type[InvalidTypeAnnotation],
170
) -> type[Any]:
171
    if type_annotation is None:
11✔
UNCOV
172
        raise raise_type(f"{name} is missing a type annotation.")
×
173
    if not isinstance(type_annotation, type):
11✔
UNCOV
174
        raise raise_type(
×
175
            f"The annotation for {name} must be a type, got {type_annotation} of type {type(type_annotation)}."
176
        )
177
    return type_annotation
11✔
178

179

180
PUBLIC_RULE_DECORATOR_ARGUMENTS = {
11✔
181
    "canonical_name",
182
    "canonical_name_suffix",
183
    "desc",
184
    "level",
185
    "polymorphic",
186
}
187
# We aren't sure if these'll stick around or be removed at some point, so they are "private"
188
# and should only be used in Pants' codebase.
189
PRIVATE_RULE_DECORATOR_ARGUMENTS = {
11✔
190
    # Allows callers to override the type Pants will use for the params listed.
191
    #
192
    # It is assumed (but not enforced) that the provided type is a subclass of the annotated type.
193
    # (We assume but not enforce since this is likely to be used with unions, which has the same
194
    # assumption between the union base and its members).
195
    "_param_type_overrides",
196
    # Allows callers to prevent the given list of types from being included in the identity of
197
    # a @rule. Although the type may be in scope for callers, it will not be consumable in the
198
    # `@rule` which declares the type masked.
199
    "_masked_types",
200
}
201
# We don't want @rule-writers to use 'rule_type' or 'cacheable' as kwargs directly,
202
# but rather set them implicitly based on the rule annotation.
203
# So we leave it out of PUBLIC_RULE_DECORATOR_ARGUMENTS.
204
IMPLICIT_PRIVATE_RULE_DECORATOR_ARGUMENTS = {"rule_type", "cacheable"}
11✔
205

206

207
class RuleDecoratorKwargs(TypedDict):
11✔
208
    """Public-facing @rule kwargs used in the codebase."""
209

210
    canonical_name: NotRequired[str]
11✔
211

212
    canonical_name_suffix: NotRequired[str]
11✔
213

214
    desc: NotRequired[str]
11✔
215
    """The rule's description as it appears in stacktraces/debugging. For goal rules, defaults to the goal name."""
11✔
216

217
    level: NotRequired[LogLevel]
11✔
218
    """The logging level applied to this rule. Defaults to TRACE."""
11✔
219

220
    polymorphic: NotRequired[bool]
11✔
221
    """Whether this rule represents an abstract method for a union.
222

223
    A polymorphic rule can only be called by name, and must have a single input type that is a
224
    union base type (plus other non-union arguments as needed). Execution will be dispatched to the
225
    @rule with the same signature with the union base type replaced by one of its member types.
226

227
    E.g., given
228

229
    ```
230
    @rule(polymorphic=True)
231
    async def base_rule(arg: UnionBase, other_arg: OtherType) -> OutputType
232
        ...
233

234
    @rule(polymorphic=True)
235
    async def derived_rule(arg: UnionMember, other_arg: OtherType) -> OutputType
236
       ...
237

238
    ```
239

240
    And an arg of type UnionMember, then
241

242
    `await base_rule(arg, other_arg)`
243

244
    will invoke `derived_rule(arg, other_arg)`
245

246
    This is the call-by-name equivalent of Get(OutputType, UnionBase, union_member_instance).
247
    """
248

249
    _masked_types: NotRequired[Iterable[type[Any]]]
11✔
250
    """Unstable. Internal Pants usage only."""
11✔
251

252
    _param_type_overrides: NotRequired[dict[str, type[Any]]]
11✔
253
    """Unstable. Internal Pants usage only."""
11✔
254

255

256
class _RuleDecoratorKwargs(RuleDecoratorKwargs):
11✔
257
    """Internal/Implicit @rule kwargs (not for use outside rules.py)"""
258

259
    rule_type: RuleType
11✔
260
    """The decorator used to declare the rule (see rules.py:_make_rule(...))"""
11✔
261

262
    cacheable: bool
11✔
263
    """Whether the results of this rule should be cached.
11✔
264
    Typically true for rules, false for goal_rules (see rules.py:_make_rule(...))
265
    """
266

267

268
def rule_decorator(
11✔
269
    func: SyncRuleT | AsyncRuleT, **kwargs: Unpack[_RuleDecoratorKwargs]
270
) -> AsyncRuleT:
271
    if not inspect.isfunction(func):
11✔
272
        raise ValueError("The @rule decorator expects to be placed on a function.")
×
273

274
    if (
11✔
275
        len(
276
            set(kwargs)
277
            - PUBLIC_RULE_DECORATOR_ARGUMENTS
278
            - PRIVATE_RULE_DECORATOR_ARGUMENTS
279
            - IMPLICIT_PRIVATE_RULE_DECORATOR_ARGUMENTS
280
        )
281
        != 0
282
    ):
UNCOV
283
        raise UnrecognizedRuleArgument(
×
284
            f"`@rule`s and `@goal_rule`s only accept the following keyword arguments: {PUBLIC_RULE_DECORATOR_ARGUMENTS}"
285
        )
286

287
    rule_type = kwargs["rule_type"]
11✔
288
    cacheable = kwargs["cacheable"]
11✔
289
    polymorphic = kwargs.get("polymorphic", False)
11✔
290
    masked_types: tuple[type, ...] = tuple(kwargs.get("_masked_types", ()))
11✔
291
    param_type_overrides: dict[str, type] = kwargs.get("_param_type_overrides", {})
11✔
292

293
    func_id = f"@rule {func.__module__}:{func.__name__}"
11✔
294
    type_hints = get_type_hints(func)
11✔
295
    return_type = _ensure_type_annotation(
11✔
296
        type_annotation=type_hints.get("return"),
297
        name=f"{func_id} return",
298
        raise_type=MissingReturnTypeAnnotation,
299
    )
300

301
    func_params = inspect.signature(func).parameters
11✔
302
    for parameter in param_type_overrides:
11✔
303
        if parameter not in func_params:
11✔
UNCOV
304
            raise ValueError(
×
305
                f"Unknown parameter name in `param_type_overrides`: {parameter}."
306
                + f" Parameter names: '{', '.join(func_params)}'"
307
            )
308

309
    parameter_types = {
11✔
310
        parameter: _ensure_type_annotation(
311
            type_annotation=param_type_overrides.get(parameter, type_hints.get(parameter)),
312
            name=f"{func_id} parameter {parameter}",
313
            raise_type=MissingParameterTypeAnnotation,
314
        )
315
        for parameter in func_params
316
    }
317
    is_goal_cls = getattr(return_type, "__goal__", False)
11✔
318

319
    # Set a default canonical name if one is not explicitly provided to the module and name of the
320
    # function that implements it, plus an optional suffix. This is used as the workunit name.
321
    # The suffix is a convenient way to disambiguate multiple rules registered dynamically from the
322
    # same static code (by overriding the inferred param types in the @rule decorator).
323
    # TODO: It is not yet clear how dynamically registered rules whose names are generated
324
    #  with a suffix will work in practice with the new call-by-name semantics.
325
    #  For now the suffix serves to ensure unique names. Whether they are useful is another matter.
326
    suffix = kwargs.get("canonical_name_suffix", "")
11✔
327
    effective_name = kwargs.get(
11✔
328
        "canonical_name",
329
        f"{func.__module__}.{func.__qualname__}{('_' + suffix) if suffix else ''}".replace(
330
            ".<locals>", ""
331
        ),
332
    )
333

334
    # Set a default description, which is used in the dynamic UI and stacktraces.
335
    effective_desc = kwargs.get("desc")
11✔
336
    if effective_desc is None and is_goal_cls:
11✔
337
        effective_desc = f"`{return_type.name}` goal"
11✔
338

339
    effective_level = kwargs.get("level", LogLevel.TRACE)
11✔
340
    if not isinstance(effective_level, LogLevel):  # type: ignore[unused-ignore]
11✔
341
        raise ValueError(
×
342
            "Expected to receive a value of type LogLevel for the level "
343
            f"argument, but got: {effective_level}"
344
        )
345

346
    module = sys.modules[func.__module__]
11✔
347
    pants_rules = getattr(module, PANTS_RULES_MODULE_KEY, None)
11✔
348
    if pants_rules is None:
11✔
349
        pants_rules = {}
11✔
350
        setattr(module, PANTS_RULES_MODULE_KEY, pants_rules)
11✔
351

352
    if effective_name not in pants_rules:
11✔
353
        pants_rules[effective_name] = func
11✔
354
    else:
355
        prev_func = pants_rules[effective_name]
11✔
356
        if prev_func.__code__ != func.__code__:
11✔
UNCOV
357
            raise DuplicateRuleError(
×
358
                softwrap(
359
                    f"""
360
                    Redeclaring rule {effective_name} with {func} at line
361
                    {func.__code__.co_firstlineno}, previously defined by {prev_func} at line
362
                    {prev_func.__code__.co_firstlineno}.
363
                    """
364
                )
365
            )
366

367
    return _make_rule(
11✔
368
        func_id,
369
        rule_type,
370
        return_type,
371
        parameter_types,
372
        masked_types,
373
        cacheable=cacheable,
374
        polymorphic=polymorphic,
375
        canonical_name=effective_name,
376
        desc=effective_desc,
377
        level=effective_level,
378
    )(func)
379

380

381
def validate_requirements(
11✔
382
    func_id: str,
383
    parameter_types: dict[str, type],
384
    awaitables: tuple[AwaitableConstraints, ...],
385
    cacheable: bool,
386
) -> None:
387
    # TODO: Technically this will also fire for an @_uncacheable_rule, but we don't expose those as
388
    # part of the API, so it's OK for these errors not to mention them.
389
    for ty in parameter_types.values():
11✔
390
        if cacheable and issubclass(ty, SideEffecting):
11✔
UNCOV
391
            raise ValueError(
×
392
                f"A `@rule` that is not a @goal_rule ({func_id}) may not have "
393
                f"a side-effecting parameter: {ty}."
394
            )
395
    for awaitable in awaitables:
11✔
396
        input_type_side_effecting = [
11✔
397
            it for it in awaitable.input_types if issubclass(it, SideEffecting)
398
        ]
399
        if input_type_side_effecting and not awaitable.is_effect:
11✔
400
            raise ValueError(
×
401
                f"A `Get` may not request side-effecting types ({input_type_side_effecting}). "
402
                f"Use `Effect` instead: `{awaitable}`."
403
            )
404
        if not input_type_side_effecting and awaitable.is_effect:
11✔
405
            raise ValueError(
×
406
                f"An `Effect` should not be used with pure types ({awaitable.input_types}). "
407
                f"Use `Get` instead: `{awaitable}`."
408
            )
409
        if cacheable and awaitable.is_effect:
11✔
410
            raise ValueError(
×
411
                f"A `@rule` that is not a @goal_rule ({func_id}) may not use an "
412
                f"Effect: `{awaitable}`."
413
            )
414

415

416
def inner_rule(*args, **kwargs) -> AsyncRuleT | RuleDecorator:
11✔
417
    if len(args) == 1 and inspect.isfunction(args[0]):
11✔
418
        return rule_decorator(*args, **kwargs)
11✔
419
    else:
420

421
        def wrapper(*args):
11✔
422
            return rule_decorator(*args, **kwargs)
11✔
423

424
        return wrapper
11✔
425

426

427
F = TypeVar("F", bound=Callable[..., Any | Coroutine[Any, Any, Any]])
11✔
428

429

430
@overload
11✔
431
def rule(**kwargs: Unpack[RuleDecoratorKwargs]) -> Callable[[F], F]:
11✔
432
    """Handles decorator factories of the form `@rule(foo=..., bar=...)`
433
    https://mypy.readthedocs.io/en/stable/generics.html#decorator-factories.
434

435
    Note: This needs to be the first rule, otherwise MyPy goes nuts
436
    """
437
    ...
438

439

440
@overload
11✔
441
def rule(_func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, Coroutine[Any, Any, R]]:
11✔
442
    """Handles bare @rule decorators on async functions.
443

444
    Usage of Coroutine[...] (vs Awaitable[...]) is intentional, as `MultiGet`/`concurrently` use
445
    coroutines directly.
446
    """
447
    ...
448

449

450
@overload
11✔
451
def rule(_func: Callable[P, R]) -> Callable[P, Coroutine[Any, Any, R]]:
11✔
452
    """Handles bare @rule decorators on non-async functions It's debatable whether we should even
453
    have non-async @rule functions, but keeping this to not break the world for plugin authors.
454

455
    Usage of Coroutine[...] (vs Awaitable[...]) is intentional, as `MultiGet`/`concurrently` use
456
    coroutines directly.
457
    """
458
    ...
459

460

461
def rule(*args, **kwargs):
11✔
462
    return inner_rule(*args, **kwargs, rule_type=RuleType.rule, cacheable=True)
11✔
463

464

465
@overload
466
def goal_rule(func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, Coroutine[Any, Any, R]]: ...
467

468

469
@overload
470
def goal_rule(func: Callable[P, R]) -> Callable[P, Coroutine[Any, Any, R]]: ...
471

472

473
@overload
474
def goal_rule(
475
    *args, func: None = None, **kwargs: Any
476
) -> Callable[[SyncRuleT | AsyncRuleT], AsyncRuleT]: ...
477

478

479
def goal_rule(*args, **kwargs):
11✔
480
    if "level" not in kwargs:
11✔
481
        kwargs["level"] = LogLevel.DEBUG
11✔
482
    return inner_rule(
11✔
483
        *args,
484
        **kwargs,
485
        rule_type=RuleType.goal_rule,
486
        cacheable=False,
487
    )
488

489

490
@overload
491
def _uncacheable_rule(
492
    func: Callable[P, Coroutine[Any, Any, R]],
493
) -> Callable[P, Coroutine[Any, Any, R]]: ...
494

495

496
@overload
497
def _uncacheable_rule(func: Callable[P, R]) -> Callable[P, Coroutine[Any, Any, R]]: ...
498

499

500
@overload
501
def _uncacheable_rule(
502
    *args, func: None = None, **kwargs: Any
503
) -> Callable[[SyncRuleT | AsyncRuleT], AsyncRuleT]: ...
504

505

506
# This has a "private" name, as we don't (yet?) want it to be part of the rule API, at least
507
# until we figure out the implications, and have a handle on the semantics and use-cases.
508
def _uncacheable_rule(*args, **kwargs):
11✔
509
    return inner_rule(
11✔
510
        *args, **kwargs, rule_type=RuleType.uncacheable_rule, cacheable=False, polymorphic=False
511
    )
512

513

514
class Rule(Protocol):
11✔
515
    """Rules declare how to produce products for the product graph.
516

517
    A rule describes what dependencies must be provided to produce a particular product. They also
518
    act as factories for constructing the nodes within the graph.
519
    """
520

521
    @property
11✔
522
    def output_type(self):
11✔
523
        """An output `type` for the rule."""
524

525

526
def collect_rules(*namespaces: ModuleType | Mapping[str, Any]) -> Iterable[Rule]:
11✔
527
    """Collects all @rules in the given namespaces.
528

529
    If no namespaces are given, collects all the @rules in the caller's module namespace.
530
    """
531

532
    if not namespaces:
11✔
533
        currentframe = inspect.currentframe()
11✔
534
        assert isinstance(currentframe, FrameType)
11✔
535
        caller_frame = currentframe.f_back
11✔
536
        assert isinstance(caller_frame, FrameType)
11✔
537

538
        global_items = caller_frame.f_globals
11✔
539
        namespaces = (global_items,)
11✔
540

541
    def iter_rules():
11✔
542
        for namespace in namespaces:
11✔
543
            mapping = namespace.__dict__ if isinstance(namespace, ModuleType) else namespace
11✔
544
            for item in mapping.values():
11✔
545
                if not callable(item):
11✔
546
                    continue
11✔
547
                rule = getattr(item, "rule", None)
11✔
548
                if isinstance(rule, TaskRule):
11✔
549
                    for input in rule.parameters.values():
11✔
550
                        if getattr(input, "__subsystem__", False):
11✔
551
                            yield from input.rules()
11✔
552
                        if getattr(input, "__subsystem_environment_aware__", False):
11✔
553
                            yield from input.subsystem.rules()
11✔
554
                    if getattr(rule.output_type, "__goal__", False):
11✔
555
                        yield from rule.output_type.subsystem_cls.rules()
11✔
556
                    yield rule
11✔
557

558
    return list(iter_rules())
11✔
559

560

561
@dataclass(frozen=True)
11✔
562
class TaskRule:
11✔
563
    """A Rule that runs a task function when all of its input selectors are satisfied.
564

565
    NB: This API is not meant for direct consumption. To create a `TaskRule` you should always
566
    prefer the `@rule` constructor.
567
    """
568

569
    output_type: type[Any]
11✔
570
    parameters: FrozenDict[str, type[Any]]
11✔
571
    awaitables: tuple[AwaitableConstraints, ...]
11✔
572
    masked_types: tuple[type[Any], ...]
11✔
573
    func: Callable
11✔
574
    canonical_name: str
11✔
575
    desc: str | None = None
11✔
576
    level: LogLevel = LogLevel.TRACE
11✔
577
    cacheable: bool = True
11✔
578
    polymorphic: bool = False
11✔
579

580
    def __str__(self):
11✔
581
        return "(name={}, {}, {!r}, {}, gets={})".format(
×
582
            getattr(self, "name", "<not defined>"),
583
            self.output_type.__name__,
584
            self.parameters.values(),
585
            self.func.__name__,
586
            self.awaitables,
587
        )
588

589

590
@dataclass(frozen=True)
11✔
591
class QueryRule:
11✔
592
    """A QueryRule declares that a given set of Params will be used to request an output type.
593

594
    Every callsite to `Scheduler.product_request` should have a corresponding QueryRule to ensure
595
    that the relevant portions of the RuleGraph are generated.
596
    """
597

598
    output_type: type[Any]
11✔
599
    input_types: tuple[type[Any], ...]
11✔
600

601
    def __init__(self, output_type: type[Any], input_types: Iterable[type[Any]]) -> None:
11✔
602
        object.__setattr__(self, "output_type", output_type)
11✔
603
        object.__setattr__(self, "input_types", tuple(input_types))
11✔
604

605

606
@dataclass(frozen=True)
11✔
607
class RuleIndex:
11✔
608
    """Holds a normalized index of Rules used to instantiate Nodes."""
609

610
    rules: FrozenOrderedSet[TaskRule]
11✔
611
    queries: FrozenOrderedSet[QueryRule]
11✔
612
    union_rules: FrozenOrderedSet[UnionRule]
11✔
613

614
    @classmethod
11✔
615
    def create(cls, rule_entries: Iterable[Rule | UnionRule]) -> RuleIndex:
11✔
616
        """Creates a RuleIndex with tasks indexed by their output type."""
617
        rules: OrderedSet[TaskRule] = OrderedSet()
11✔
618
        queries: OrderedSet[QueryRule] = OrderedSet()
11✔
619
        union_rules: OrderedSet[UnionRule] = OrderedSet()
11✔
620

621
        for entry in rule_entries:
11✔
622
            if isinstance(entry, TaskRule):
11✔
623
                rules.add(entry)
11✔
624
            elif isinstance(entry, UnionRule):
11✔
625
                union_rules.add(entry)
11✔
626
            elif isinstance(entry, QueryRule):
11✔
627
                queries.add(entry)
11✔
628
            elif hasattr(entry, "__call__"):
11✔
629
                rule = getattr(entry, "rule", None)
11✔
630
                if rule is None:
11✔
631
                    raise TypeError(f"Expected function {entry} to be decorated with @rule.")
×
632
                rules.add(rule)
11✔
633
            else:
UNCOV
634
                raise TypeError(
×
635
                    f"Rule entry {entry} had an unexpected type: {type(entry)}. Rules either "
636
                    "extend Rule or UnionRule, or are static functions decorated with @rule."
637
                )
638

639
        return RuleIndex(
11✔
640
            rules=FrozenOrderedSet(rules),
641
            queries=FrozenOrderedSet(queries),
642
            union_rules=FrozenOrderedSet(union_rules),
643
        )
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