[red-knot] Remove Type::Unbound (#13980)

<!--
Thank you for contributing to Ruff! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->

## Summary

- Remove `Type::Unbound`
- Handle (potential) unboundness as a concept orthogonal to the type
system (see new `Symbol` type)
- Improve existing and add new diagnostics related to (potential)
unboundness

closes #13671 

## Test Plan

- Update existing markdown-based tests
- Add new tests for added/modified functionality
This commit is contained in:
David Peter 2024-10-31 20:05:53 +01:00 committed by GitHub
parent d1189c20df
commit 53fa32a389
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 767 additions and 516 deletions

View file

@ -33,6 +33,8 @@ b: tuple[int] = (42,)
c: tuple[str, int] = ("42", 42)
d: tuple[tuple[str, str], tuple[int, int]] = (("foo", "foo"), (42, 42))
e: tuple[str, ...] = ()
# TODO: we should not emit this error
# error: [call-possibly-unbound-method] "Method `__class_getitem__` of type `Literal[tuple]` is possibly unbound"
f: tuple[str, *tuple[int, ...], bytes] = ("42", b"42")
g: tuple[str, Unpack[tuple[int, ...]], bytes] = ("42", b"42")
h: tuple[list[int], list[int]] = ([], [])

View file

@ -80,9 +80,11 @@ class Foo:
return 42
f = Foo()
# TODO: We should emit an `unsupported-operator` error here, possibly with the information
# that `Foo.__iadd__` may be unbound as additional context.
f += "Hello, world!"
# TODO should emit a diagnostic warning that `Foo` might not have an `__iadd__` method
reveal_type(f) # revealed: int
```

View file

@ -6,11 +6,20 @@
x = foo # error: [unresolved-reference] "Name `foo` used when not defined"
foo = 1
# error: [unresolved-reference]
# revealed: Unbound
# No error `unresolved-reference` diagnostic is reported for `x`. This is
# desirable because we would get a lot of cascading errors even though there
# is only one root cause (the unbound variable `foo`).
# revealed: Unknown
reveal_type(x)
```
Note: in this particular example, one could argue that the most likely error would
be a wrong order of the `x`/`foo` definitions, and so it could be desirable to infer
`Literal[1]` for the type of `x`. On the other hand, there might be a variable `fob`
a little higher up in this file, and the actual error might have been just a typo.
Inferring `Unknown` thus seems like the safest option.
## Unbound class variable
Name lookups within a class scope fall back to globals, but lookups of class attributes don't.
@ -30,3 +39,22 @@ class C:
reveal_type(C.x) # revealed: Literal[2]
reveal_type(C.y) # revealed: Literal[1]
```
## Possibly unbound in class and global scope
```py
def bool_instance() -> bool:
return True
if bool_instance():
x = "abc"
class C:
if bool_instance():
x = 1
# error: [possibly-unresolved-reference]
y = x
reveal_type(C.y) # revealed: Literal[1] | Literal["abc"]
```

View file

@ -12,11 +12,11 @@ def bool_instance() -> bool:
if bool_instance() or (x := 1):
# error: [possibly-unresolved-reference]
reveal_type(x) # revealed: Unbound | Literal[1]
reveal_type(x) # revealed: Literal[1]
if bool_instance() and (x := 1):
# error: [possibly-unresolved-reference]
reveal_type(x) # revealed: Unbound | Literal[1]
reveal_type(x) # revealed: Literal[1]
```
## First expression is always evaluated
@ -36,14 +36,14 @@ if (x := 1) and bool_instance():
```py
if True or (x := 1):
# TODO: infer that the second arm is never executed so type should be just "Unbound".
# TODO: infer that the second arm is never executed, and raise `unresolved-reference`.
# error: [possibly-unresolved-reference]
reveal_type(x) # revealed: Unbound | Literal[1]
reveal_type(x) # revealed: Literal[1]
if True and (x := 1):
# TODO: infer that the second arm is always executed so type should be just "Literal[1]".
# TODO: infer that the second arm is always executed, do not raise a diagnostic
# error: [possibly-unresolved-reference]
reveal_type(x) # revealed: Unbound | Literal[1]
reveal_type(x) # revealed: Literal[1]
```
## Later expressions can always use variables from earlier expressions
@ -55,7 +55,7 @@ def bool_instance() -> bool:
bool_instance() or (x := 1) or reveal_type(x) # revealed: Literal[1]
# error: [unresolved-reference]
bool_instance() or reveal_type(y) or (y := 1) # revealed: Unbound
bool_instance() or reveal_type(y) or (y := 1) # revealed: Unknown
```
## Nested expressions
@ -65,14 +65,14 @@ def bool_instance() -> bool:
return True
if bool_instance() or ((x := 1) and bool_instance()):
# error: "Name `x` used when possibly not defined"
reveal_type(x) # revealed: Unbound | Literal[1]
# error: [possibly-unresolved-reference]
reveal_type(x) # revealed: Literal[1]
if ((y := 1) and bool_instance()) or bool_instance():
reveal_type(y) # revealed: Literal[1]
# error: [possibly-unresolved-reference]
if (bool_instance() and (z := 1)) or reveal_type(z): # revealed: Unbound | Literal[1]
if (bool_instance() and (z := 1)) or reveal_type(z): # revealed: Literal[1]
# error: [possibly-unresolved-reference]
reveal_type(z) # revealed: Unbound | Literal[1]
reveal_type(z) # revealed: Literal[1]
```

View file

@ -37,11 +37,11 @@ x = y
reveal_type(x) # revealed: Literal[3, 4, 5]
# revealed: Unbound | Literal[2]
# revealed: Literal[2]
# error: [possibly-unresolved-reference]
reveal_type(r)
# revealed: Unbound | Literal[5]
# revealed: Literal[5]
# error: [possibly-unresolved-reference]
reveal_type(s)
```

View file

@ -21,7 +21,7 @@ match 0:
case 2:
y = 3
# revealed: Unbound | Literal[2, 3]
# revealed: Literal[2, 3]
# error: [possibly-unresolved-reference]
reveal_type(y)
```

View file

@ -41,7 +41,12 @@ except EXCEPTIONS as f:
## Dynamic exception types
```py
def foo(x: type[AttributeError], y: tuple[type[OSError], type[RuntimeError]], z: tuple[type[BaseException], ...]):
# TODO: we should not emit these `call-possibly-unbound-method` errors for `tuple.__class_getitem__`
def foo(
x: type[AttributeError],
y: tuple[type[OSError], type[RuntimeError]], # error: [call-possibly-unbound-method]
z: tuple[type[BaseException], ...], # error: [call-possibly-unbound-method]
):
try:
help()
except x as e:

View file

@ -12,11 +12,10 @@ if flag:
x = y # error: [possibly-unresolved-reference]
# revealed: Unbound | Literal[3]
# error: [possibly-unresolved-reference]
# revealed: Literal[3]
reveal_type(x)
# revealed: Unbound | Literal[3]
# revealed: Literal[3]
# error: [possibly-unresolved-reference]
reveal_type(y)
```
@ -40,11 +39,10 @@ if flag:
y: int = 3
x = y # error: [possibly-unresolved-reference]
# revealed: Unbound | Literal[3]
# error: [possibly-unresolved-reference]
# revealed: Literal[3]
reveal_type(x)
# revealed: Unbound | Literal[3]
# revealed: Literal[3]
# error: [possibly-unresolved-reference]
reveal_type(y)
```
@ -58,6 +56,24 @@ reveal_type(x) # revealed: Literal[3]
reveal_type(y) # revealed: int
```
## Maybe undeclared
Importing a possibly undeclared name still gives us its declared type:
```py path=maybe_undeclared.py
def bool_instance() -> bool:
return True
if bool_instance():
x: int
```
```py
from maybe_undeclared import x
reveal_type(x) # revealed: int
```
## Reimport
```py path=c.py

View file

@ -17,8 +17,8 @@ async def foo():
async for x in Iterator():
pass
# TODO: should reveal `Unbound | Unknown` because `__aiter__` is not defined
# revealed: Unbound | @Todo
# TODO: should reveal `Unknown` because `__aiter__` is not defined
# revealed: @Todo
# error: [possibly-unresolved-reference]
reveal_type(x)
```
@ -40,6 +40,6 @@ async def foo():
pass
# error: [possibly-unresolved-reference]
# revealed: Unbound | @Todo
# revealed: @Todo
reveal_type(x)
```

View file

@ -14,7 +14,7 @@ class IntIterable:
for x in IntIterable():
pass
# revealed: Unbound | int
# revealed: int
# error: [possibly-unresolved-reference]
reveal_type(x)
```
@ -87,7 +87,7 @@ class OldStyleIterable:
for x in OldStyleIterable():
pass
# revealed: Unbound | int
# revealed: int
# error: [possibly-unresolved-reference]
reveal_type(x)
```
@ -98,7 +98,7 @@ reveal_type(x)
for x in (1, "a", b"foo"):
pass
# revealed: Unbound | Literal[1] | Literal["a"] | Literal[b"foo"]
# revealed: Literal[1] | Literal["a"] | Literal[b"foo"]
# error: [possibly-unresolved-reference]
reveal_type(x)
```
@ -120,7 +120,7 @@ class NotIterable:
for x in NotIterable(): # error: "Object of type `NotIterable` is not iterable"
pass
# revealed: Unbound | Unknown
# revealed: Unknown
# error: [possibly-unresolved-reference]
reveal_type(x)
```
@ -240,7 +240,7 @@ def coinflip() -> bool:
# TODO: we should emit a diagnostic here (it might not be iterable)
for x in Test() if coinflip() else 42:
reveal_type(x) # revealed: int | Unknown
reveal_type(x) # revealed: int
```
## Union type as iterable where one union element has invalid `__iter__` method
@ -261,9 +261,9 @@ class Test2:
def coinflip() -> bool:
return True
# TODO: we should emit a diagnostic here (it might not be iterable)
# error: "Object of type `Test | Test2` is not iterable"
for x in Test() if coinflip() else Test2():
reveal_type(x) # revealed: int | Unknown
reveal_type(x) # revealed: Unknown
```
## Union type as iterator where one union element has no `__next__` method
@ -277,7 +277,7 @@ class Test:
def __iter__(self) -> TestIter | int:
return TestIter()
# TODO: we should emit a diagnostic here (it might not be iterable)
# error: [not-iterable] "Object of type `Test` is not iterable"
for x in Test():
reveal_type(x) # revealed: int | Unknown
reveal_type(x) # revealed: Unknown
```

View file

@ -19,7 +19,7 @@ reveal_type(__path__) # revealed: @Todo
# TODO: this should probably be added to typeshed; not sure why it isn't?
# error: [unresolved-reference]
# revealed: Unbound
# revealed: Unknown
reveal_type(__doc__)
class X:
@ -34,15 +34,15 @@ module globals; these are excluded:
```py path=unbound_dunders.py
# error: [unresolved-reference]
# revealed: Unbound
# revealed: Unknown
reveal_type(__getattr__)
# error: [unresolved-reference]
# revealed: Unbound
# revealed: Unknown
reveal_type(__dict__)
# error: [unresolved-reference]
# revealed: Unbound
# revealed: Unknown
reveal_type(__init__)
```
@ -61,10 +61,10 @@ reveal_type(typing.__init__) # revealed: Literal[__init__]
# These come from `builtins.object`, not `types.ModuleType`:
# TODO: we don't currently understand `types.ModuleType` as inheriting from `object`;
# these should not reveal `Unbound`:
reveal_type(typing.__eq__) # revealed: Unbound
reveal_type(typing.__class__) # revealed: Unbound
reveal_type(typing.__module__) # revealed: Unbound
# these should not reveal `Unknown`:
reveal_type(typing.__eq__) # revealed: Unknown
reveal_type(typing.__class__) # revealed: Unknown
reveal_type(typing.__module__) # revealed: Unknown
# TODO: needs support for attribute access on instances, properties and generics;
# should be `dict[str, Any]`
@ -78,7 +78,7 @@ where we know exactly which module we're dealing with:
```py path=__getattr__.py
import typing
reveal_type(typing.__getattr__) # revealed: Unbound
reveal_type(typing.__getattr__) # revealed: Unknown
```
## `types.ModuleType.__dict__` takes precedence over global variable `__dict__`
@ -120,7 +120,7 @@ if returns_bool():
__name__ = 1
reveal_type(__file__) # revealed: Literal[42]
reveal_type(__name__) # revealed: str | Literal[1]
reveal_type(__name__) # revealed: Literal[1] | str
```
## Conditionally global or `ModuleType` attribute, with annotation
@ -137,5 +137,5 @@ if returns_bool():
__name__: int = 1
reveal_type(__file__) # revealed: Literal[42]
reveal_type(__name__) # revealed: str | Literal[1]
reveal_type(__name__) # revealed: Literal[1] | str
```

View file

@ -68,8 +68,8 @@ if flag:
else:
class Spam: ...
# error: [call-non-callable] "Method `__class_getitem__` of type `Literal[__class_getitem__] | Unbound` is not callable on object of type `Literal[Spam, Spam]`"
# revealed: str | Unknown
# error: [call-possibly-unbound-method] "Method `__class_getitem__` of type `Literal[Spam, Spam]` is possibly unbound"
# revealed: str
reveal_type(Spam[42])
```

View file

@ -46,7 +46,7 @@ with context_expr as f:
```py
class Manager: ...
# error: [invalid-context-manager] "Object of type Manager cannot be used with `with` because it doesn't implement `__enter__` and `__exit__`"
# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because it doesn't implement `__enter__` and `__exit__`"
with Manager():
...
```
@ -57,7 +57,7 @@ with Manager():
class Manager:
def __exit__(self, exc_tpe, exc_value, traceback): ...
# error: [invalid-context-manager] "Object of type Manager cannot be used with `with` because it doesn't implement `__enter__`"
# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because it doesn't implement `__enter__`"
with Manager():
...
```
@ -68,7 +68,7 @@ with Manager():
class Manager:
def __enter__(self): ...
# error: [invalid-context-manager] "Object of type Manager cannot be used with `with` because it doesn't implement `__exit__`"
# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because it doesn't implement `__exit__`"
with Manager():
...
```
@ -81,7 +81,7 @@ class Manager:
def __exit__(self, exc_tpe, exc_value, traceback): ...
# error: [invalid-context-manager] "Object of type Manager cannot be used with `with` because the method `__enter__` of type Literal[42] is not callable"
# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because the method `__enter__` of type `Literal[42]` is not callable"
with Manager():
...
```
@ -94,12 +94,12 @@ class Manager:
__exit__ = 32
# error: [invalid-context-manager] "Object of type Manager cannot be used with `with` because the method `__exit__` of type Literal[32] is not callable"
# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because the method `__exit__` of type `Literal[32]` is not callable"
with Manager():
...
```
## Context expression with non-callable union variants
## Context expression with possibly-unbound union variants
```py
def coinflip() -> bool:
@ -115,10 +115,10 @@ class NotAContextManager: ...
context_expr = Manager1() if coinflip() else NotAContextManager()
# error: [invalid-context-manager] "Object of type Manager1 | NotAContextManager cannot be used with `with` because the method `__enter__` of type Literal[__enter__] | Unbound is not callable"
# error: [invalid-context-manager] "Object of type Manager1 | NotAContextManager cannot be used with `with` because the method `__exit__` of type Literal[__exit__] | Unbound is not callable"
# error: [invalid-context-manager] "Object of type `Manager1 | NotAContextManager` cannot be used with `with` because the method `__enter__` is possibly unbound"
# error: [invalid-context-manager] "Object of type `Manager1 | NotAContextManager` cannot be used with `with` because the method `__exit__` is possibly unbound"
with context_expr as f:
reveal_type(f) # revealed: str | Unknown
reveal_type(f) # revealed: str
```
## Context expression with "sometimes" callable `__enter__` method
@ -134,7 +134,7 @@ class Manager:
def __exit__(self, *args): ...
# error: [invalid-context-manager] "Object of type `Manager` cannot be used with `with` because the method `__enter__` is possibly unbound"
with Manager() as f:
# TODO: This should emit an error that `__enter__` is possibly unbound.
reveal_type(f) # revealed: str
```