Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Fix stub classes with references to themselves in their
base classes, such as `os._ScandirIterator` in typeshed (#402)
- Fix type narrowing on the `else` case of `issubclass()`
(#401)
- Fix indexing a list with an index typed as a
Expand Down
4 changes: 4 additions & 0 deletions pyanalyze/stubs/_pyanalyze_tests-stubs/recursion.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from typing import ContextManager, AnyStr

class _ScandirIterator(ContextManager[_ScandirIterator[AnyStr]]):
def close(self) -> None: ...
14 changes: 14 additions & 0 deletions pyanalyze/test_typeshed.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,20 @@ def capybara(unannotated):
TypedValue(TextIO) | TypedValue(BinaryIO),
)

@assert_passes()
def test_recursive_base(self):
from typing import Any, ContextManager

def capybara():
from _pyanalyze_tests.recursion import _ScandirIterator

def want_cm(cm: ContextManager[Any]) -> None:
pass

def f(x: _ScandirIterator):
want_cm(x)
len(x) # E: incompatible_argument


class Parent(Generic[T]):
pass
Expand Down
20 changes: 18 additions & 2 deletions pyanalyze/typeshed.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ class TypeshedFinder:
_attribute_cache: Dict[Tuple[str, str, bool], Value] = field(
default_factory=dict, repr=False, init=False
)
_active_infos: List[typeshed_client.resolver.ResolvedName] = field(
default_factory=list, repr=False, init=False
)

@classmethod
def make(cls, options: Options, *, verbose: bool = False) -> "TypeshedFinder":
Expand Down Expand Up @@ -858,7 +861,7 @@ def _parse_call_assignment(

def make_synthetic_type(self, module: str, info: typeshed_client.NameInfo) -> Value:
fq_name = f"{module}.{info.name}"
bases = self.get_bases_for_fq_name(fq_name)
bases = self._get_bases_from_info(info, module)
typ = TypedValue(fq_name)
if bases is not None:
if any(
Expand Down Expand Up @@ -906,6 +909,19 @@ def _make_td_value(self, field: Value, total: bool) -> Tuple[bool, Value]:

def _value_from_info(
self, info: typeshed_client.resolver.ResolvedName, module: str
) -> Value:
# This guard against infinite recursion if a type refers to itself
# (real-world example: os._ScandirIterator).
if info in self._active_infos:
return AnyValue(AnySource.inference)
self._active_infos.append(info)
try:
return self._value_from_info_inner(info, module)
finally:
self._active_infos.pop()

def _value_from_info_inner(
self, info: typeshed_client.resolver.ResolvedName, module: str
) -> Value:
if isinstance(info, typeshed_client.ImportedInfo):
return self._value_from_info(info.info, ".".join(info.source_module))
Expand Down Expand Up @@ -943,7 +959,7 @@ def _value_from_info(
return val
if info.ast.value:
return self._parse_expr(info.ast.value, module)
elif isinstance(info.ast, ast.FunctionDef):
elif isinstance(info.ast, (ast.FunctionDef, ast.AsyncFunctionDef)):
sig = self._get_signature_from_info(info, None, fq_name, module)
if sig is not None:
return CallableValue(sig)
Expand Down