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

pantsbuild / pants / 19015773527

02 Nov 2025 05:33PM UTC coverage: 17.872% (-62.4%) from 80.3%
19015773527

Pull #22816

github

web-flow
Merge a12d75757 into 6c024e162
Pull Request #22816: Update Pants internal Python to 3.14

4 of 5 new or added lines in 3 files covered. (80.0%)

28452 existing lines in 683 files now uncovered.

9831 of 55007 relevant lines covered (17.87%)

0.18 hits per line

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

82.33
/src/python/pants/engine/internals/rule_visitor.py
1
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3
from __future__ import annotations
1✔
4

5
import ast
1✔
6
import inspect
1✔
7
import itertools
1✔
8
import logging
1✔
9
import os
1✔
10
import sys
1✔
11
import warnings
1✔
12
from collections.abc import Callable, Iterator, Sequence
1✔
13
from contextlib import contextmanager
1✔
14
from dataclasses import dataclass
1✔
15
from functools import partial
1✔
16
from types import ModuleType
1✔
17
from typing import Any, get_type_hints
1✔
18

19
import typing_extensions
1✔
20

21
from pants.base.exceptions import RuleTypeError
1✔
22
from pants.engine.internals.selectors import (
1✔
23
    Awaitable,
24
    AwaitableConstraints,
25
    Effect,
26
    GetParseError,
27
    MultiGet,
28
)
29
from pants.util.docutil import doc_url
1✔
30
from pants.util.memo import memoized
1✔
31
from pants.util.strutil import softwrap
1✔
32
# from pants.util.typing import patch_forward_ref
33

34
logger = logging.getLogger(__name__)
1✔
35
# patch_forward_ref()
36

37

38
def _get_starting_indent(source: str) -> int:
1✔
39
    """Used to remove leading indentation from `source` so ast.parse() doesn't raise an
40
    exception."""
41
    if source.startswith(" "):
1✔
42
        return sum(1 for _ in itertools.takewhile(lambda c: c in {" ", b" "}, source))
1✔
43
    return 0
1✔
44

45

46
def _node_str(node: Any) -> str:
1✔
UNCOV
47
    if isinstance(node, ast.Name):
×
UNCOV
48
        return node.id
×
UNCOV
49
    if isinstance(node, ast.Attribute):
×
UNCOV
50
        return ".".join([_node_str(node.value), node.attr])
×
UNCOV
51
    if isinstance(node, ast.Call):
×
UNCOV
52
        return _node_str(node.func)
×
UNCOV
53
    if sys.version_info[0:2] < (3, 8):
×
54
        if isinstance(node, ast.Str):
×
55
            return node.s
×
56
    else:
UNCOV
57
        if isinstance(node, ast.Constant):
×
UNCOV
58
            return str(node.value)
×
59
    return str(node)
×
60

61

62
PANTS_RULE_DESCRIPTORS_MODULE_KEY = "__pants_rule_descriptors__"
1✔
63

64

65
@dataclass(frozen=True)
1✔
66
class RuleDescriptor:
1✔
67
    """The data we glean about a rule by examining its AST.
68

69
    This will be lazily invoked in the first `@rule` decorator in a module. Therefore it will parse
70
    the AST *before* the module code is fully evaluated, and so the return type may not yet exist as
71
    a parsed type. So we store it here as a str and look it up later.
72
    """
73

74
    module_name: str
1✔
75
    rule_name: str
1✔
76
    return_type: str
1✔
77

78
    @property
1✔
79
    def rule_id(self) -> str:
1✔
80
        # TODO: Handle canonical_name/canonical_name_suffix?
81
        return f"{self.module_name}.{self.rule_name}"
1✔
82

83

84
def get_module_scope_rules(module: ModuleType) -> tuple[RuleDescriptor, ...]:
1✔
85
    """Get descriptors for @rules defined at the top level of the given module.
86

87
    We discover these top-level rules and rule helpers in the module by examining the AST.
88
    This means that while executing the `@rule` decorator of a rule1(), the descriptor of a rule2()
89
    defined later in the module is already known.  This allows rule1() and rule2() to be
90
    mutually recursive.
91

92
    Note that we don't support recursive rules defined dynamically in inner scopes.
93
    """
94
    descriptors = getattr(module, PANTS_RULE_DESCRIPTORS_MODULE_KEY, None)
1✔
95
    if descriptors is None:
1✔
96
        descriptors = []
1✔
97
        for node in ast.iter_child_nodes(ast.parse(inspect.getsource(module))):
1✔
98
            if isinstance(node, ast.AsyncFunctionDef) and isinstance(node.returns, ast.Name):
1✔
99
                descriptors.append(RuleDescriptor(module.__name__, node.name, node.returns.id))
1✔
100
        descriptors = tuple(descriptors)
1✔
101
        setattr(module, PANTS_RULE_DESCRIPTORS_MODULE_KEY, descriptors)
1✔
102

103
    return descriptors
1✔
104

105

106
class _TypeStack:
1✔
107
    """The types and rules that a @rule can refer to in its input/outputs, or its awaitables.
108

109
    We construct this data through a mix of inspection of types already parsed by Python,
110
    and descriptors we infer from the AST. This allows us to support mutual recursion between
111
    rules defined in the same module (the @rule descriptor of the earlier rule can know enough
112
    about the later rule it calls to set up its own awaitables correctly).
113

114
    This logic is necessarily heuristic. It works for well-behaved code, but may be defeated
115
    by metaprogramming, aliasing, shadowing and so on.
116
    """
117

118
    def __init__(self, func: Callable) -> None:
1✔
119
        self._stack: list[dict[str, Any]] = []
1✔
120
        self.root = sys.modules[func.__module__]
1✔
121

122
        # We fall back to descriptors last, so that we get parsed objects whenever possible,
123
        # as those are less susceptible to limitations of the heuristics.
124
        self.push({descr.rule_name: descr for descr in get_module_scope_rules(self.root)})
1✔
125
        self.push(self.root)
1✔
126
        self._push_function_closures(func)
1✔
127
        # Rule args will be pushed later, as we handle them.
128

129
    def __getitem__(self, name: str) -> Any:
1✔
130
        for ns in reversed(self._stack):
1✔
131
            if name in ns:
1✔
132
                return ns[name]
1✔
133
        return self.root.__builtins__.get(name, None)
1✔
134

135
    def __setitem__(self, name: str, value: Any) -> None:
1✔
136
        self._stack[-1][name] = value
1✔
137

138
    def _push_function_closures(self, func: Callable) -> None:
1✔
139
        try:
1✔
140
            closurevars = [c for c in inspect.getclosurevars(func) if isinstance(c, dict)]
1✔
UNCOV
141
        except ValueError:
×
UNCOV
142
            return
×
143

144
        for closures in closurevars:
1✔
145
            self.push(closures)
1✔
146

147
    def push(self, frame: object) -> None:
1✔
148
        ns = dict(frame if isinstance(frame, dict) else frame.__dict__)
1✔
149
        self._stack.append(ns)
1✔
150

151
    def pop(self) -> None:
1✔
152
        assert len(self._stack) > 1
1✔
153
        self._stack.pop()
1✔
154

155

156
def _lookup_annotation(obj: Any, attr: str) -> Any:
1✔
157
    """Get type associated with a particular attribute on object. This can get hairy, especially on
158
    Python <3.10.
159

160
    https://docs.python.org/3/howto/annotations.html#accessing-the-annotations-dict-of-an-object-in-python-3-9-and-older
161
    """
162
    if hasattr(obj, attr):
1✔
163
        return getattr(obj, attr)
1✔
164
    else:
165
        try:
1✔
166
            return get_type_hints(obj).get(attr)
1✔
167
        except (NameError, TypeError):
1✔
168
            return None
1✔
169

170

171
def _lookup_return_type(func: Callable, check: bool = False) -> Any:
1✔
172
    ret = _lookup_annotation(func, "return")
1✔
173
    typ = typing_extensions.get_origin(ret)
1✔
174
    if isinstance(typ, type):
1✔
175
        args = typing_extensions.get_args(ret)
1✔
176
        if issubclass(typ, (list, set, tuple)):
1✔
177
            return tuple(args)
1✔
178
    if check and ret is None:
1✔
UNCOV
179
        func_file = inspect.getsourcefile(func)
×
UNCOV
180
        func_line = func.__code__.co_firstlineno
×
UNCOV
181
        raise TypeError(
×
182
            f"Failed to look up return type hint for `{func.__name__}` in {func_file}:{func_line}"
183
        )
184
    return ret
1✔
185

186

187
def _returns_awaitable(func: Any) -> bool:
1✔
188
    if not callable(func):
1✔
189
        return False
1✔
190
    ret = _lookup_return_type(func)
1✔
191
    if not isinstance(ret, tuple):
1✔
192
        ret = (ret,)
1✔
193
    return any(issubclass(r, Awaitable) for r in ret if isinstance(r, type))
1✔
194

195

196
class _AwaitableCollector(ast.NodeVisitor):
1✔
197
    def __init__(self, func: Callable):
1✔
198
        self.func = func
1✔
199
        source = inspect.getsource(func) or "<string>"
1✔
200
        beginning_indent = _get_starting_indent(source)
1✔
201
        if beginning_indent:
1✔
202
            source = "\n".join(line[beginning_indent:] for line in source.split("\n"))
1✔
203

204
        self.source_file = inspect.getsourcefile(func) or "<unknown>"
1✔
205

206
        self.types = _TypeStack(func)
1✔
207
        self.awaitables: list[AwaitableConstraints] = []
1✔
208
        self.visit(ast.parse(source))
1✔
209

210
    def _format(self, node: ast.AST, msg: str) -> str:
1✔
UNCOV
211
        lineno: str = "<unknown>"
×
UNCOV
212
        if isinstance(node, (ast.expr, ast.stmt)):
×
UNCOV
213
            lineno = str(node.lineno + self.func.__code__.co_firstlineno - 1)
×
UNCOV
214
        return f"{self.source_file}:{lineno}: {msg}"
×
215

216
    def _lookup(self, attr: ast.expr) -> Any:
1✔
217
        names = []
1✔
218
        while isinstance(attr, ast.Attribute):
1✔
219
            names.append(attr.attr)
1✔
220
            attr = attr.value
1✔
221
        # NB: attr could be a constant, like `",".join()`
222
        id = getattr(attr, "id", None)
1✔
223
        if id is not None:
1✔
224
            names.append(id)
1✔
225

226
        if not names:
1✔
227
            return attr
1✔
228

229
        name = names.pop()
1✔
230
        result = self.types[name]
1✔
231
        while result is not None and names:
1✔
232
            result = _lookup_annotation(result, names.pop())
1✔
233
        return result
1✔
234

235
    def _missing_type_error(self, node: ast.AST, context: str) -> str:
1✔
UNCOV
236
        mod = self.types.root.__name__
×
UNCOV
237
        return self._format(
×
238
            node,
239
            softwrap(
240
                f"""
241
                Could not resolve type for `{_node_str(node)}` in module {mod}.
242

243
                {context}
244
                """
245
            ),
246
        )
247

248
    def _check_constraint_arg_type(self, resolved: Any, node: ast.AST) -> type:
1✔
249
        if resolved is None:
1✔
UNCOV
250
            raise RuleTypeError(
×
251
                self._missing_type_error(
252
                    node, context="This may be a limitation of the Pants rule type inference."
253
                )
254
            )
255
        elif not isinstance(resolved, type):
1✔
UNCOV
256
            raise RuleTypeError(
×
257
                self._format(
258
                    node,
259
                    f"Expected a type, but got: {type(resolved).__name__} {_node_str(resolved)!r}",
260
                )
261
            )
262
        return resolved
1✔
263

264
    def _get_inputs(self, input_nodes: Sequence[Any]) -> tuple[Sequence[Any], list[Any]]:
1✔
265
        if not input_nodes:
1✔
266
            return input_nodes, []
1✔
267
        if len(input_nodes) != 1:
1✔
UNCOV
268
            return input_nodes, [self._lookup(input_nodes[0])]
×
269

270
        input_constructor = input_nodes[0]
1✔
271
        if isinstance(input_constructor, ast.Call):
1✔
272
            cls_or_func = self._lookup(input_constructor.func)
1✔
273
            try:
1✔
274
                type_ = (
1✔
275
                    _lookup_return_type(cls_or_func, check=True)
276
                    if not isinstance(cls_or_func, type)
277
                    else cls_or_func
278
                )
UNCOV
279
            except TypeError as e:
×
UNCOV
280
                raise RuleTypeError(self._missing_type_error(input_constructor, str(e))) from e
×
281
            return [input_constructor.func], [type_]
1✔
282
        elif isinstance(input_constructor, ast.Dict):
1✔
283
            return input_constructor.values, [self._lookup(v) for v in input_constructor.values]
1✔
284
        else:
285
            return input_nodes, [self._lookup(n) for n in input_nodes]
1✔
286

287
    def _get_legacy_awaitable(self, call_node: ast.Call, is_effect: bool) -> AwaitableConstraints:
1✔
UNCOV
288
        get_args = call_node.args
×
UNCOV
289
        parse_error = partial(GetParseError, get_args=get_args, source_file_name=self.source_file)
×
290

UNCOV
291
        if len(get_args) not in (1, 2, 3):
×
292
            # TODO: fix parse error message formatting... (TODO: create ticket)
UNCOV
293
            raise parse_error(
×
294
                self._format(
295
                    call_node,
296
                    f"Expected one to three arguments, but got {len(get_args)} arguments.",
297
                )
298
            )
299

UNCOV
300
        output_node = get_args[0]
×
UNCOV
301
        output_type = self._lookup(output_node)
×
302

UNCOV
303
        input_nodes, input_types = self._get_inputs(get_args[1:])
×
304

UNCOV
305
        return AwaitableConstraints(
×
306
            None,
307
            self._check_constraint_arg_type(output_type, output_node),
308
            0,
309
            tuple(
310
                self._check_constraint_arg_type(input_type, input_node)
311
                for input_type, input_node in zip(input_types, input_nodes)
312
            ),
313
            is_effect,
314
        )
315

316
    def _get_byname_awaitable(
1✔
317
        self, rule_id: str, rule_func: Callable | RuleDescriptor, call_node: ast.Call
318
    ) -> AwaitableConstraints:
319
        parse_error = partial(
1✔
320
            GetParseError, get_args=call_node.args, source_file_name=self.source_file
321
        )
322

323
        if isinstance(rule_func, RuleDescriptor):
1✔
324
            # At this point we expect the return type to be defined, so its source code
325
            # must precede that of the rule invoking the awaitable that returns it.
326
            output_type = self.types[rule_func.return_type]
1✔
327
        else:
328
            output_type = _lookup_return_type(rule_func, check=True)
1✔
329

330
        # To support explicit positional arguments, we record the number passed positionally.
331
        # TODO: To support keyword arguments, we would additionally need to begin recording the
332
        # argument names of kwargs. But positional-only callsites can avoid those allocations.
333
        explicit_args_arity = len(call_node.args)
1✔
334

335
        input_types: tuple[type, ...]
336
        if not call_node.keywords:
1✔
337
            input_types = ()
1✔
338
        elif (
1✔
339
            len(call_node.keywords) == 1
340
            and not call_node.keywords[0].arg
341
            and isinstance(implicitly_call := call_node.keywords[0].value, ast.Call)
342
            and self._lookup(implicitly_call.func).__name__ == "implicitly"
343
        ):
344
            input_nodes, input_type_nodes = self._get_inputs(implicitly_call.args)
1✔
345
            input_types = tuple(
1✔
346
                self._check_constraint_arg_type(input_type, input_node)
347
                for input_type, input_node in zip(input_type_nodes, input_nodes)
348
            )
349
        else:
350
            raise parse_error(
×
351
                self._format(
352
                    call_node,
353
                    "Expected an `**implicitly(..)` application as the only keyword input.",
354
                )
355
            )
356

357
        return AwaitableConstraints(
1✔
358
            rule_id,
359
            output_type,
360
            explicit_args_arity,
361
            input_types,
362
            # TODO: Extract this from the callee? Currently only intrinsics can be Effects, so need
363
            # to figure out their new syntax first.
364
            is_effect=False,
365
        )
366

367
    def visit_Call(self, call_node: ast.Call) -> None:
1✔
368
        func = self._lookup(call_node.func)
1✔
369
        if func is not None:
1✔
370
            if isinstance(func, type) and issubclass(func, Awaitable):
1✔
371
                # Is a `Get`/`Effect`.
UNCOV
372
                self.awaitables.append(
×
373
                    self._get_legacy_awaitable(call_node, is_effect=issubclass(func, Effect))
374
                )
UNCOV
375
                if os.environ.get("PANTS_DISABLE_GETS", "").lower() in {"1", "t", "true"}:
×
376
                    raise Exception("Get() is disabled!")
×
377
                else:
UNCOV
378
                    lineno = call_node.lineno + self.func.__code__.co_firstlineno - 1
×
UNCOV
379
                    warnings.warn(
×
380
                        "Get() is deprecated, and will be removed in Pants 2.31.0. "
381
                        f"Found a `Get() in {self.source_file}:{lineno}. See "
382
                        f"{doc_url('docs/writing-plugins/the-rules-api/migrating-gets')} for how "
383
                        "to migrate your plugins to use the new call-by-name idiom."
384
                    )
385
            elif (inspect.isfunction(func) or isinstance(func, RuleDescriptor)) and (
1✔
386
                rule_id := getattr(func, "rule_id", None)
387
            ) is not None:
388
                # Is a direct `@rule` call.
389
                self.awaitables.append(self._get_byname_awaitable(rule_id, func, call_node))
1✔
390
            elif inspect.iscoroutinefunction(func) or _returns_awaitable(func):
1✔
391
                # Is a call to a "rule helper".
392
                self.awaitables.extend(collect_awaitables(func))
1✔
393

394
        self.generic_visit(call_node)
1✔
395

396
    def visit_AsyncFunctionDef(self, rule: ast.AsyncFunctionDef) -> None:
1✔
397
        with self._visit_rule_args(rule.args):
1✔
398
            self.generic_visit(rule)
1✔
399

400
    def visit_FunctionDef(self, rule: ast.FunctionDef) -> None:
1✔
401
        with self._visit_rule_args(rule.args):
1✔
402
            self.generic_visit(rule)
1✔
403

404
    @contextmanager
1✔
405
    def _visit_rule_args(self, node: ast.arguments) -> Iterator[None]:
1✔
406
        self.types.push(
1✔
407
            {
408
                a.arg: self.types[a.annotation.id]
409
                for a in node.args
410
                if isinstance(a.annotation, ast.Name)
411
            }
412
        )
413
        try:
1✔
414
            yield
1✔
415
        finally:
416
            self.types.pop()
1✔
417

418
    def visit_Assign(self, assign_node: ast.Assign) -> None:
1✔
419
        awaitables_idx = len(self.awaitables)
1✔
420
        self.generic_visit(assign_node)
1✔
421
        collected_awaitables = self.awaitables[awaitables_idx:]
1✔
422
        value = None
1✔
423
        node: ast.AST = assign_node
1✔
424
        while True:
1✔
425
            if isinstance(node, (ast.Assign, ast.Await)):
1✔
426
                node = node.value
1✔
427
                continue
1✔
428
            if isinstance(node, ast.Call):
1✔
429
                f = self._lookup(node.func)
1✔
430
                if f is MultiGet:
1✔
431
                    value = tuple(get.output_type for get in collected_awaitables)
1✔
432
                elif f is not None:
1✔
433
                    value = _lookup_return_type(f)
1✔
434
            elif isinstance(node, (ast.Name, ast.Attribute)):
1✔
435
                value = self._lookup(node)
1✔
436
            break
1✔
437

438
        for tgt in assign_node.targets:
1✔
439
            if isinstance(tgt, ast.Name):
1✔
440
                names = [tgt.id]
1✔
441
                values = [value]
1✔
442
            elif isinstance(tgt, ast.Tuple):
1✔
443
                names = [el.id for el in tgt.elts if isinstance(el, ast.Name)]
1✔
444
                values = value or itertools.cycle([None])  # type: ignore[assignment]
1✔
445
            else:
446
                # subscript, etc..
447
                continue
1✔
448
            try:
1✔
449
                for name, value in zip(names, values):
1✔
450
                    self.types[name] = value
1✔
UNCOV
451
            except TypeError as e:
×
UNCOV
452
                logger.debug(
×
453
                    self._format(
454
                        node,
455
                        softwrap(
456
                            f"""
457
                            Rule visitor failed to inspect assignment expression for
458
                            {names} - {values}:
459

460
                            {e}
461
                            """
462
                        ),
463
                    )
464
                )
465

466

467
@memoized
1✔
468
def collect_awaitables(func: Callable) -> list[AwaitableConstraints]:
1✔
469
    return _AwaitableCollector(func).awaitables
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