Skip to content

Commit 06b7fc3

Browse files
authored
fix(analyzer): recognize declaration-only Python stubs (#801)
1 parent bc9ee49 commit 06b7fc3

2 files changed

Lines changed: 410 additions & 12 deletions

File tree

skylos/visitors/base.py

Lines changed: 132 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,78 @@ def _has_signature_stub_body(
239239
)
240240

241241

242+
def _is_stub_value(node: ast.AST | None) -> bool:
243+
"""Recognize declaration values, not expressions that run an implementation."""
244+
if node is None or isinstance(node, ast.Constant):
245+
return True
246+
return (
247+
isinstance(node, ast.UnaryOp)
248+
and isinstance(node.op, (ast.UAdd, ast.USub))
249+
and isinstance(node.operand, ast.Constant)
250+
and isinstance(node.operand.value, (int, float, complex))
251+
)
252+
253+
254+
def _is_stub_declaration(node: ast.AST) -> bool:
255+
"""Recognize non-concrete declarations in a Python type stub."""
256+
if isinstance(node, ast.AnnAssign):
257+
return isinstance(node.target, ast.Name) and _is_stub_value(node.value)
258+
if isinstance(node, ast.Assign):
259+
return (
260+
all(isinstance(target, ast.Name) for target in node.targets)
261+
and isinstance(node.value, ast.Constant)
262+
and node.value.value is Ellipsis
263+
)
264+
if isinstance(node, ast.If):
265+
# Stubs may select declarations by version/platform or TYPE_CHECKING.
266+
# Calls, comprehensions and assignment expressions are not such guards.
267+
condition_nodes = (
268+
ast.Name,
269+
ast.Attribute,
270+
ast.Subscript,
271+
ast.Slice,
272+
ast.Tuple,
273+
ast.List,
274+
ast.Constant,
275+
ast.Compare,
276+
ast.BoolOp,
277+
ast.UnaryOp,
278+
ast.Load,
279+
ast.cmpop,
280+
ast.And,
281+
ast.Or,
282+
ast.Not,
283+
ast.UAdd,
284+
ast.USub,
285+
)
286+
return all(
287+
isinstance(part, condition_nodes) for part in ast.walk(node.test)
288+
) and all(
289+
_is_stub_declaration(statement) for statement in [*node.body, *node.orelse]
290+
)
291+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
292+
body = node.body
293+
if (
294+
body
295+
and isinstance(body[0], ast.Expr)
296+
and isinstance(body[0].value, ast.Constant)
297+
and isinstance(body[0].value.value, str)
298+
):
299+
body = body[1:]
300+
if isinstance(node, ast.ClassDef):
301+
return all(_is_stub_declaration(statement) for statement in body)
302+
return (
303+
len(body) == 1
304+
and isinstance(body[0], ast.Expr)
305+
and _is_stub_declaration(body[0])
306+
)
307+
return (
308+
isinstance(node, ast.Expr)
309+
and isinstance(node.value, ast.Constant)
310+
and node.value.value is Ellipsis
311+
)
312+
313+
242314
def _module_binds_name(module: ast.Module, target_name: str) -> bool:
243315
class BindingProbe(ast.NodeVisitor):
244316
def __init__(self) -> None:
@@ -462,6 +534,8 @@ class Visitor(ast.NodeVisitor):
462534
def __init__(self, mod: str, file: Union[Path, str]) -> None:
463535
self.mod = mod
464536
self.file = file
537+
self._is_type_stub = Path(file).suffix.lower() == ".pyi"
538+
self._stub_binding_lines: dict[str, int] = {}
465539
self.defs = []
466540
self.refs = []
467541
self.cls = None
@@ -533,6 +607,17 @@ def __init__(self, mod: str, file: Union[Path, str]) -> None:
533607
def add_def(
534608
self, name: str, t: str, line: int, node: Optional[ast.AST] = None, **extra: Any
535609
) -> None:
610+
# Omit declaration candidates rather than adding synthetic references:
611+
# .py and .pyi siblings share qualified names in the merged symbol map.
612+
if (
613+
self._is_type_stub
614+
and not self.current_function_scope
615+
and t in {"class", "function", "method"}
616+
and node is not None
617+
and _is_stub_declaration(node)
618+
):
619+
self._stub_binding_lines[name] = line
620+
return
536621
found = False
537622
for d in self.defs:
538623
if d.name == name:
@@ -584,13 +669,11 @@ def visit_Module(self, node: ast.Module) -> None:
584669

585670
def qual(self, name: str) -> str:
586671
if name in self.alias:
587-
if self.mod:
588-
local_name = f"{self.mod}.{name}"
589-
if any(d.name == local_name for d in self.defs):
590-
return local_name
591-
else:
592-
if any(d.name == name for d in self.defs):
593-
return name
672+
local_name = f"{self.mod}.{name}" if self.mod else name
673+
if local_name in self._stub_binding_lines or any(
674+
d.name == local_name for d in self.defs
675+
):
676+
return local_name
594677
return self.alias[name]
595678

596679
if name in PYTHON_BUILTINS:
@@ -880,6 +963,11 @@ def _local_binding_shadows_alias(
880963
candidates.append(".".join(filter(None, [self.mod, self.cls, name])))
881964
candidates.append(f"{self.mod}.{name}" if self.mod else name)
882965

966+
if any(
967+
candidate in self._stub_binding_lines and candidate != current_definition
968+
for candidate in candidates
969+
):
970+
return True
883971
return any(
884972
d.name in candidates
885973
and d.name != current_definition
@@ -930,6 +1018,17 @@ def _alias_binding_is_active(
9301018
),
9311019
default=-1,
9321020
)
1021+
latest_local_line = max(
1022+
latest_local_line,
1023+
max(
1024+
(
1025+
self._stub_binding_lines.get(candidate, -1)
1026+
for candidate in candidates
1027+
if candidate != current_definition
1028+
),
1029+
default=-1,
1030+
),
1031+
)
9331032
return alias_line > latest_local_line
9341033

9351034
def _is_numba_overload_decorator(
@@ -1693,8 +1792,13 @@ def visit_Assign(self, node: ast.Assign) -> None:
16931792
if isinstance(t, ast.Name):
16941793
self.pattern_tracker.f_string_patterns[t.id] = pattern
16951794

1795+
declaration_only = (
1796+
self._is_type_stub
1797+
and not self.current_function_scope
1798+
and _is_stub_declaration(node)
1799+
)
16961800
for target in node.targets:
1697-
self._process_target_for_def(target)
1801+
self._process_target_for_def(target, declaration_only=declaration_only)
16981802

16991803
if isinstance(node.value, ast.Dict):
17001804
self._track_dict_dispatch(node)
@@ -1760,7 +1864,14 @@ def _define(t):
17601864
if in_typeddict and is_class_body and is_annotation_only:
17611865
return
17621866

1763-
self.add_def(var_name, "variable", t.lineno)
1867+
if (
1868+
self._is_type_stub
1869+
and not self.current_function_scope
1870+
and _is_stub_declaration(node)
1871+
):
1872+
self._stub_binding_lines[var_name] = t.lineno
1873+
else:
1874+
self.add_def(var_name, "variable", t.lineno)
17641875

17651876
if (
17661877
self._dataclass_stack
@@ -1815,7 +1926,11 @@ def visit_AugAssign(self, node: ast.AugAssign) -> None:
18151926
self.visit(node.value)
18161927

18171928
def _process_target_for_def(
1818-
self, target_node: ast.expr, _in_tuple_unpack: bool = False
1929+
self,
1930+
target_node: ast.expr,
1931+
_in_tuple_unpack: bool = False,
1932+
*,
1933+
declaration_only: bool = False,
18191934
) -> None:
18201935
if isinstance(target_node, ast.Name):
18211936
name_simple = target_node.id
@@ -1827,7 +1942,10 @@ def _process_target_for_def(
18271942
return
18281943

18291944
var_name = self._compute_variable_name(name_simple)
1830-
self.add_def(var_name, "variable", target_node.lineno)
1945+
if declaration_only:
1946+
self._stub_binding_lines[var_name] = target_node.lineno
1947+
else:
1948+
self.add_def(var_name, "variable", target_node.lineno)
18311949

18321950
if self.current_function_scope and self.local_var_maps:
18331951
self.local_var_maps[-1][name_simple] = var_name
@@ -1841,7 +1959,9 @@ def _process_target_for_def(
18411959

18421960
elif isinstance(target_node, (ast.Tuple, ast.List)):
18431961
for elt in target_node.elts:
1844-
self._process_target_for_def(elt, _in_tuple_unpack=True)
1962+
self._process_target_for_def(
1963+
elt, _in_tuple_unpack=True, declaration_only=declaration_only
1964+
)
18451965

18461966
def _finalize_dunder_all_exports(self, statements: list[ast.stmt]) -> None:
18471967
export_names: set[str] | None = None

0 commit comments

Comments
 (0)