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

pantsbuild / pants / 21803785359

08 Feb 2026 07:13PM UTC coverage: 43.3% (-37.0%) from 80.277%
21803785359

Pull #23085

github

web-flow
Merge 7c1cd926d into 40389cc58
Pull Request #23085: A helper method for indexing paths by source root

2 of 6 new or added lines in 1 file covered. (33.33%)

17114 existing lines in 539 files now uncovered.

26075 of 60219 relevant lines covered (43.3%)

0.43 hits per line

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

93.36
/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
1✔
5

6
import functools
1✔
7
import inspect
1✔
8
import sys
1✔
9
from collections.abc import Callable, Coroutine, Iterable, Mapping, Sequence
1✔
10
from dataclasses import dataclass
1✔
11
from enum import Enum
1✔
12
from types import FrameType, ModuleType
1✔
13
from typing import (
1✔
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
1✔
26

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

37
PANTS_RULES_MODULE_KEY = "__pants_rules__"
1✔
38

39

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

46

47
class RuleType(Enum):
1✔
48
    rule = "rule"
1✔
49
    goal_rule = "goal_rule"
1✔
50
    uncacheable_rule = "_uncacheable_rule"
1✔
51

52

53
P = ParamSpec("P")
1✔
54
R = TypeVar("R")
1✔
55
SyncRuleT = Callable[P, R]
1✔
56
AsyncRuleT = Callable[P, Coroutine[Any, Any, R]]
1✔
57
RuleDecorator = Callable[[SyncRuleT | AsyncRuleT], AsyncRuleT]
1✔
58

59

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

68
    return cast(Callable[P, R], wrapper)
1✔
69

70

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

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

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

104
    def wrapper(original_func):
1✔
105
        if not inspect.isfunction(original_func):
1✔
106
            raise ValueError("The @rule decorator must be applied innermost of all decorators.")
×
107

108
        # Set our own custom `__line_number__` dunder so that the engine may visualize the line number.
109
        original_func.__line_number__ = original_func.__code__.co_firstlineno
1✔
110
        original_func.rule_id = canonical_name
1✔
111

112
        awaitables = FrozenOrderedSet(collect_awaitables(original_func))
1✔
113

114
        validate_requirements(func_id, parameter_types, awaitables, cacheable)
1✔
115
        func = _rule_call_trampoline(canonical_name, return_type, original_func)
1✔
116

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

135
    return wrapper
1✔
136

137

138
class InvalidTypeAnnotation(TypeError):
1✔
139
    """Indicates an incorrect type annotation for an `@rule`."""
140

141

142
class UnrecognizedRuleArgument(TypeError):
1✔
143
    """Indicates an unrecognized keyword argument to a `@rule`."""
144

145

146
class MissingTypeAnnotation(TypeError):
1✔
147
    """Indicates a missing type annotation for an `@rule`."""
148

149

150
class MissingReturnTypeAnnotation(InvalidTypeAnnotation):
1✔
151
    """Indicates a missing return type annotation for an `@rule`."""
152

153

154
class MissingParameterTypeAnnotation(InvalidTypeAnnotation):
1✔
155
    """Indicates a missing parameter type annotation for an `@rule`."""
156

157

158
class DuplicateRuleError(TypeError):
1✔
159
    """Invalid to overwrite `@rule`s using the same name in the same module."""
160

161

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

176

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

203

204
class RuleDecoratorKwargs(TypedDict):
1✔
205
    """Public-facing @rule kwargs used in the codebase."""
206

207
    canonical_name: NotRequired[str]
1✔
208

209
    canonical_name_suffix: NotRequired[str]
1✔
210

211
    desc: NotRequired[str]
1✔
212
    """The rule's description as it appears in stacktraces/debugging. For goal rules, defaults to the goal name."""
1✔
213

214
    level: NotRequired[LogLevel]
1✔
215
    """The logging level applied to this rule. Defaults to TRACE."""
1✔
216

217
    polymorphic: NotRequired[bool]
1✔
218
    """Whether this rule represents an abstract method for a union.
219

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

224
    E.g., given
225

226
    ```
227
    @rule(polymorphic=True)
228
    async def base_rule(arg: UnionBase, other_arg: OtherType) -> OutputType
229
        ...
230

231
    @rule(polymorphic=True)
232
    async def derived_rule(arg: UnionMember, other_arg: OtherType) -> OutputType
233
       ...
234

235
    ```
236

237
    And an arg of type UnionMember, then
238

239
    `await base_rule(arg, other_arg)`
240

241
    will invoke `derived_rule(arg, other_arg)`
242
    """
243

244
    _masked_types: NotRequired[Iterable[type[Any]]]
1✔
245
    """Unstable. Internal Pants usage only."""
1✔
246

247
    _param_type_overrides: NotRequired[dict[str, type[Any]]]
1✔
248
    """Unstable. Internal Pants usage only."""
1✔
249

250

251
class _RuleDecoratorKwargs(RuleDecoratorKwargs):
1✔
252
    """Internal/Implicit @rule kwargs (not for use outside rules.py)"""
253

254
    rule_type: RuleType
1✔
255
    """The decorator used to declare the rule (see rules.py:_make_rule(...))"""
1✔
256

257
    cacheable: bool
1✔
258
    """Whether the results of this rule should be cached.
1✔
259
    Typically true for rules, false for goal_rules (see rules.py:_make_rule(...))
260
    """
261

262

263
def rule_decorator(
1✔
264
    func: SyncRuleT | AsyncRuleT, **kwargs: Unpack[_RuleDecoratorKwargs]
265
) -> AsyncRuleT:
266
    if not inspect.isfunction(func):
1✔
267
        raise ValueError("The @rule decorator expects to be placed on a function.")
×
268

269
    if (
1✔
270
        len(
271
            set(kwargs)
272
            - PUBLIC_RULE_DECORATOR_ARGUMENTS
273
            - PRIVATE_RULE_DECORATOR_ARGUMENTS
274
            - IMPLICIT_PRIVATE_RULE_DECORATOR_ARGUMENTS
275
        )
276
        != 0
277
    ):
UNCOV
278
        raise UnrecognizedRuleArgument(
×
279
            f"`@rule`s and `@goal_rule`s only accept the following keyword arguments: {PUBLIC_RULE_DECORATOR_ARGUMENTS}"
280
        )
281

282
    rule_type = kwargs["rule_type"]
1✔
283
    cacheable = kwargs["cacheable"]
1✔
284
    polymorphic = kwargs.get("polymorphic", False)
1✔
285
    masked_types: tuple[type, ...] = tuple(kwargs.get("_masked_types", ()))
1✔
286
    param_type_overrides: dict[str, type] = kwargs.get("_param_type_overrides", {})
1✔
287

288
    func_id = f"@rule {func.__module__}:{func.__name__}"
1✔
289
    type_hints = get_type_hints(func)
1✔
290
    return_type = _ensure_type_annotation(
1✔
291
        type_annotation=type_hints.get("return"),
292
        name=f"{func_id} return",
293
        raise_type=MissingReturnTypeAnnotation,
294
    )
295

296
    func_params = inspect.signature(func).parameters
1✔
297
    for parameter in param_type_overrides:
1✔
298
        if parameter not in func_params:
1✔
UNCOV
299
            raise ValueError(
×
300
                f"Unknown parameter name in `param_type_overrides`: {parameter}."
301
                + f" Parameter names: '{', '.join(func_params)}'"
302
            )
303

304
    parameter_types = {
1✔
305
        parameter: _ensure_type_annotation(
306
            type_annotation=param_type_overrides.get(parameter, type_hints.get(parameter)),
307
            name=f"{func_id} parameter {parameter}",
308
            raise_type=MissingParameterTypeAnnotation,
309
        )
310
        for parameter in func_params
311
    }
312
    is_goal_cls = getattr(return_type, "__goal__", False)
1✔
313

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

329
    # Set a default description, which is used in the dynamic UI and stacktraces.
330
    effective_desc = kwargs.get("desc")
1✔
331
    if effective_desc is None and is_goal_cls:
1✔
332
        effective_desc = f"`{return_type.name}` goal"
1✔
333

334
    effective_level = kwargs.get("level", LogLevel.TRACE)
1✔
335
    if not isinstance(effective_level, LogLevel):  # type: ignore[unused-ignore]
1✔
336
        raise ValueError(
×
337
            "Expected to receive a value of type LogLevel for the level "
338
            f"argument, but got: {effective_level}"
339
        )
340

341
    module = sys.modules[func.__module__]
1✔
342
    pants_rules = getattr(module, PANTS_RULES_MODULE_KEY, None)
1✔
343
    if pants_rules is None:
1✔
344
        pants_rules = {}
1✔
345
        setattr(module, PANTS_RULES_MODULE_KEY, pants_rules)
1✔
346

347
    if effective_name not in pants_rules:
1✔
348
        pants_rules[effective_name] = func
1✔
349
    else:
350
        prev_func = pants_rules[effective_name]
1✔
351
        if prev_func.__code__ != func.__code__:
1✔
UNCOV
352
            raise DuplicateRuleError(
×
353
                softwrap(
354
                    f"""
355
                    Redeclaring rule {effective_name} with {func} at line
356
                    {func.__code__.co_firstlineno}, previously defined by {prev_func} at line
357
                    {prev_func.__code__.co_firstlineno}.
358
                    """
359
                )
360
            )
361

362
    return _make_rule(
1✔
363
        func_id,
364
        rule_type,
365
        return_type,
366
        parameter_types,
367
        masked_types,
368
        cacheable=cacheable,
369
        polymorphic=polymorphic,
370
        canonical_name=effective_name,
371
        desc=effective_desc,
372
        level=effective_level,
373
    )(func)
374

375

376
def validate_requirements(
1✔
377
    func_id: str,
378
    parameter_types: dict[str, type],
379
    awaitables: tuple[AwaitableConstraints, ...],
380
    cacheable: bool,
381
) -> None:
382
    # TODO: Technically this will also fire for an @_uncacheable_rule, but we don't expose those as
383
    # part of the API, so it's OK for these errors not to mention them.
384
    for ty in parameter_types.values():
1✔
385
        if cacheable and issubclass(ty, SideEffecting):
1✔
UNCOV
386
            raise ValueError(
×
387
                f"A `@rule` that is not a @goal_rule ({func_id}) may not have "
388
                f"a side-effecting parameter: {ty}."
389
            )
390
    for awaitable in awaitables:
1✔
391
        input_type_side_effecting = [
1✔
392
            it for it in awaitable.input_types if issubclass(it, SideEffecting)
393
        ]
394
        if input_type_side_effecting:
1✔
395
            raise ValueError(
×
396
                f"A `@rule` may not request side-effecting types ({input_type_side_effecting})."
397
            )
398

399

400
def inner_rule(*args, **kwargs) -> AsyncRuleT | RuleDecorator:
1✔
401
    if len(args) == 1 and inspect.isfunction(args[0]):
1✔
402
        return rule_decorator(*args, **kwargs)
1✔
403
    else:
404

405
        def wrapper(*args):
1✔
406
            return rule_decorator(*args, **kwargs)
1✔
407

408
        return wrapper
1✔
409

410

411
F = TypeVar("F", bound=Callable[..., Any | Coroutine[Any, Any, Any]])
1✔
412

413

414
@overload
1✔
415
def rule(**kwargs: Unpack[RuleDecoratorKwargs]) -> Callable[[F], F]:
1✔
416
    """Handles decorator factories of the form `@rule(foo=..., bar=...)`
417
    https://mypy.readthedocs.io/en/stable/generics.html#decorator-factories.
418

419
    Note: This needs to be the first rule, otherwise MyPy goes nuts
420
    """
421
    ...
422

423

424
@overload
1✔
425
def rule(_func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, Coroutine[Any, Any, R]]:
1✔
426
    """Handles bare @rule decorators on async functions.
427

428
    Usage of Coroutine[...] (vs Awaitable[...]) is intentional, as `concurrently` uses coroutines
429
    directly.
430
    """
431
    ...
432

433

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

439
    Usage of Coroutine[...] (vs Awaitable[...]) is intentional, as `concurrently` uses coroutines
440
    directly.
441
    """
442
    ...
443

444

445
def rule(*args, **kwargs):
1✔
446
    return inner_rule(*args, **kwargs, rule_type=RuleType.rule, cacheable=True)
1✔
447

448

449
@overload
450
def goal_rule(func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, Coroutine[Any, Any, R]]: ...
451

452

453
@overload
454
def goal_rule(func: Callable[P, R]) -> Callable[P, Coroutine[Any, Any, R]]: ...
455

456

457
@overload
458
def goal_rule(
459
    *args, func: None = None, **kwargs: Any
460
) -> Callable[[SyncRuleT | AsyncRuleT], AsyncRuleT]: ...
461

462

463
def goal_rule(*args, **kwargs):
1✔
464
    if "level" not in kwargs:
1✔
465
        kwargs["level"] = LogLevel.DEBUG
1✔
466
    return inner_rule(
1✔
467
        *args,
468
        **kwargs,
469
        rule_type=RuleType.goal_rule,
470
        cacheable=False,
471
    )
472

473

474
@overload
475
def _uncacheable_rule(
476
    func: Callable[P, Coroutine[Any, Any, R]],
477
) -> Callable[P, Coroutine[Any, Any, R]]: ...
478

479

480
@overload
481
def _uncacheable_rule(func: Callable[P, R]) -> Callable[P, Coroutine[Any, Any, R]]: ...
482

483

484
@overload
485
def _uncacheable_rule(
486
    *args, func: None = None, **kwargs: Any
487
) -> Callable[[SyncRuleT | AsyncRuleT], AsyncRuleT]: ...
488

489

490
# This has a "private" name, as we don't (yet?) want it to be part of the rule API, at least
491
# until we figure out the implications, and have a handle on the semantics and use-cases.
492
def _uncacheable_rule(*args, **kwargs):
1✔
493
    return inner_rule(
1✔
494
        *args, **kwargs, rule_type=RuleType.uncacheable_rule, cacheable=False, polymorphic=False
495
    )
496

497

498
class Rule(Protocol):
1✔
499
    """Rules declare how to produce products for the product graph.
500

501
    A rule describes what dependencies must be provided to produce a particular product. They also
502
    act as factories for constructing the nodes within the graph.
503
    """
504

505
    @property
1✔
506
    def output_type(self):
1✔
507
        """An output `type` for the rule."""
508

509

510
def collect_rules(*namespaces: ModuleType | Mapping[str, Any]) -> Iterable[Rule]:
1✔
511
    """Collects all @rules in the given namespaces.
512

513
    If no namespaces are given, collects all the @rules in the caller's module namespace.
514
    """
515

516
    if not namespaces:
1✔
517
        currentframe = inspect.currentframe()
1✔
518
        assert isinstance(currentframe, FrameType)
1✔
519
        caller_frame = currentframe.f_back
1✔
520
        assert isinstance(caller_frame, FrameType)
1✔
521

522
        global_items = caller_frame.f_globals
1✔
523
        namespaces = (global_items,)
1✔
524

525
    def iter_rules():
1✔
526
        for namespace in namespaces:
1✔
527
            mapping = namespace.__dict__ if isinstance(namespace, ModuleType) else namespace
1✔
528
            for item in mapping.values():
1✔
529
                if not callable(item):
1✔
530
                    continue
1✔
531
                rule = getattr(item, "rule", None)
1✔
532
                if isinstance(rule, TaskRule):
1✔
533
                    for input in rule.parameters.values():
1✔
534
                        if getattr(input, "__subsystem__", False):
1✔
535
                            yield from input.rules()
1✔
536
                        if getattr(input, "__subsystem_environment_aware__", False):
1✔
537
                            yield from input.subsystem.rules()
1✔
538
                    if getattr(rule.output_type, "__goal__", False):
1✔
539
                        yield from rule.output_type.subsystem_cls.rules()
1✔
540
                    yield rule
1✔
541

542
    return list(iter_rules())
1✔
543

544

545
@dataclass(frozen=True)
1✔
546
class TaskRule:
1✔
547
    """A Rule that runs a task function when all of its input selectors are satisfied.
548

549
    NB: This API is not meant for direct consumption. To create a `TaskRule` you should always
550
    prefer the `@rule` constructor.
551
    """
552

553
    output_type: type[Any]
1✔
554
    parameters: FrozenDict[str, type[Any]]
1✔
555
    awaitables: tuple[AwaitableConstraints, ...]
1✔
556
    masked_types: tuple[type[Any], ...]
1✔
557
    func: Callable
1✔
558
    canonical_name: str
1✔
559
    desc: str | None = None
1✔
560
    level: LogLevel = LogLevel.TRACE
1✔
561
    cacheable: bool = True
1✔
562
    polymorphic: bool = False
1✔
563

564
    def __str__(self):
1✔
565
        return "(name={}, {}, {!r}, {}, gets={})".format(
×
566
            getattr(self, "name", "<not defined>"),
567
            self.output_type.__name__,
568
            self.parameters.values(),
569
            self.func.__name__,
570
            self.awaitables,
571
        )
572

573

574
@dataclass(frozen=True)
1✔
575
class QueryRule:
1✔
576
    """A QueryRule declares that a given set of Params will be used to request an output type.
577

578
    Every callsite to `Scheduler.product_request` should have a corresponding QueryRule to ensure
579
    that the relevant portions of the RuleGraph are generated.
580
    """
581

582
    output_type: type[Any]
1✔
583
    input_types: tuple[type[Any], ...]
1✔
584

585
    def __init__(self, output_type: type[Any], input_types: Iterable[type[Any]]) -> None:
1✔
586
        object.__setattr__(self, "output_type", output_type)
1✔
587
        object.__setattr__(self, "input_types", tuple(input_types))
1✔
588

589

590
@dataclass(frozen=True)
1✔
591
class RuleIndex:
1✔
592
    """Holds a normalized index of Rules used to instantiate Nodes."""
593

594
    rules: FrozenOrderedSet[TaskRule]
1✔
595
    queries: FrozenOrderedSet[QueryRule]
1✔
596
    union_rules: FrozenOrderedSet[UnionRule]
1✔
597

598
    @classmethod
1✔
599
    def create(cls, rule_entries: Iterable[Rule | UnionRule]) -> RuleIndex:
1✔
600
        """Creates a RuleIndex with tasks indexed by their output type."""
601
        rules: OrderedSet[TaskRule] = OrderedSet()
1✔
602
        queries: OrderedSet[QueryRule] = OrderedSet()
1✔
603
        union_rules: OrderedSet[UnionRule] = OrderedSet()
1✔
604

605
        for entry in rule_entries:
1✔
606
            if isinstance(entry, TaskRule):
1✔
607
                rules.add(entry)
1✔
608
            elif isinstance(entry, UnionRule):
1✔
609
                union_rules.add(entry)
1✔
610
            elif isinstance(entry, QueryRule):
1✔
611
                queries.add(entry)
1✔
612
            elif hasattr(entry, "__call__"):
1✔
613
                rule = getattr(entry, "rule", None)
1✔
614
                if rule is None:
1✔
615
                    raise TypeError(f"Expected function {entry} to be decorated with @rule.")
×
616
                rules.add(rule)
1✔
617
            else:
UNCOV
618
                raise TypeError(
×
619
                    f"Rule entry {entry} had an unexpected type: {type(entry)}. Rules either "
620
                    "extend Rule or UnionRule, or are static functions decorated with @rule."
621
                )
622

623
        return RuleIndex(
1✔
624
            rules=FrozenOrderedSet(rules),
625
            queries=FrozenOrderedSet(queries),
626
            union_rules=FrozenOrderedSet(union_rules),
627
        )
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

© 2026 Coveralls, Inc