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
17 changes: 8 additions & 9 deletions crates/red_knot_python_semantic/resources/mdtest/protocols.md
Original file line number Diff line number Diff line change
Expand Up @@ -1199,23 +1199,25 @@ static_assert(is_assignable_to(HasGetAttrAndSetAttr, XAsymmetricProperty)) # er

## Narrowing of protocols

<!-- snapshot-diagnostics -->

By default, a protocol class cannot be used as the second argument to `isinstance()` or
`issubclass()`, and a type checker must emit an error on such calls. However, we still narrow the
type inside these branches (this matches the behaviour of other type checkers):

```py
from typing import Protocol
from typing_extensions import Protocol, reveal_type

class HasX(Protocol):
x: int

def f(arg: object, arg2: type):
if isinstance(arg, HasX): # error
if isinstance(arg, HasX): # error: [invalid-argument-type]
reveal_type(arg) # revealed: HasX
else:
reveal_type(arg) # revealed: ~HasX

if issubclass(arg2, HasX): # error
if issubclass(arg2, HasX): # error: [invalid-argument-type]
reveal_type(arg2) # revealed: type[HasX]
else:
reveal_type(arg2) # revealed: type & ~type[HasX]
Expand Down Expand Up @@ -1250,10 +1252,10 @@ class OnlyMethodMembers(Protocol):
def method(self) -> None: ...

def f(arg1: type, arg2: type):
if issubclass(arg1, OnlyMethodMembers): # error
reveal_type(arg1) # revealed: type[OnlyMethodMembers]
if issubclass(arg1, RuntimeCheckableHasX): # TODO: should emit an error here (has non-method members)
reveal_type(arg1) # revealed: type[RuntimeCheckableHasX]
else:
reveal_type(arg1) # revealed: type & ~type[OnlyMethodMembers]
reveal_type(arg1) # revealed: type & ~type[RuntimeCheckableHasX]

if issubclass(arg2, OnlyMethodMembers): # no error!
reveal_type(arg2) # revealed: type[OnlyMethodMembers]
Expand Down Expand Up @@ -1289,8 +1291,6 @@ def _(some_list: list, some_tuple: tuple[int, str], some_sized: Sized):

Add tests for:

- Assignments without declarations in protocol class bodies. And various weird ways of creating
attributes in a class body or instance method. [Example mypy tests][mypy_weird_protocols].
- More tests for protocols inside `type[]`. [Spec reference][protocols_inside_type_spec].
- Protocols with instance-method members
- Protocols with `@classmethod` and `@staticmethod`
Expand All @@ -1313,7 +1313,6 @@ Add tests for:

[mypy_protocol_docs]: https://mypy.readthedocs.io/en/stable/protocols.html#protocols-and-structural-subtyping
[mypy_protocol_tests]: https://github.com/python/mypy/blob/master/test-data/unit/check-protocols.test
[mypy_weird_protocols]: https://github.com/python/mypy/blob/a3ce6d5307e99a1b6c181eaa7c5cf134c53b7d8b/test-data/unit/check-protocols.test#L2131-L2132
[protocol conformance tests]: https://github.com/python/typing/tree/main/conformance/tests
[protocols_inside_type_spec]: https://typing.python.org/en/latest/spec/protocol.html#type-and-class-objects-vs-protocols
[recursive_protocols_spec]: https://typing.python.org/en/latest/spec/protocol.html#recursive-protocols
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
---
source: crates/red_knot_test/src/lib.rs
expression: snapshot
---
---
mdtest name: protocols.md - Protocols - Narrowing of protocols
mdtest path: crates/red_knot_python_semantic/resources/mdtest/protocols.md
---

# Python source files

## mdtest_snippet.py

```
1 | from typing_extensions import Protocol, reveal_type
2 |
3 | class HasX(Protocol):
4 | x: int
5 |
6 | def f(arg: object, arg2: type):
7 | if isinstance(arg, HasX): # error: [invalid-argument-type]
8 | reveal_type(arg) # revealed: HasX
9 | else:
10 | reveal_type(arg) # revealed: ~HasX
11 |
12 | if issubclass(arg2, HasX): # error: [invalid-argument-type]
13 | reveal_type(arg2) # revealed: type[HasX]
14 | else:
15 | reveal_type(arg2) # revealed: type & ~type[HasX]
16 | from typing import runtime_checkable
17 |
18 | @runtime_checkable
19 | class RuntimeCheckableHasX(Protocol):
20 | x: int
21 |
22 | def f(arg: object):
23 | if isinstance(arg, RuntimeCheckableHasX): # no error!
24 | reveal_type(arg) # revealed: RuntimeCheckableHasX
25 | else:
26 | reveal_type(arg) # revealed: ~RuntimeCheckableHasX
27 | @runtime_checkable
28 | class OnlyMethodMembers(Protocol):
29 | def method(self) -> None: ...
30 |
31 | def f(arg1: type, arg2: type):
32 | if issubclass(arg1, RuntimeCheckableHasX): # TODO: should emit an error here (has non-method members)
33 | reveal_type(arg1) # revealed: type[RuntimeCheckableHasX]
34 | else:
35 | reveal_type(arg1) # revealed: type & ~type[RuntimeCheckableHasX]
36 |
37 | if issubclass(arg2, OnlyMethodMembers): # no error!
38 | reveal_type(arg2) # revealed: type[OnlyMethodMembers]
39 | else:
40 | reveal_type(arg2) # revealed: type & ~type[OnlyMethodMembers]
```

# Diagnostics

```
error: lint:invalid-argument-type: Class `HasX` cannot be used as the second argument to `isinstance`
--> /src/mdtest_snippet.py:7:8
|
6 | def f(arg: object, arg2: type):
7 | if isinstance(arg, HasX): # error: [invalid-argument-type]
| ^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime
8 | reveal_type(arg) # revealed: HasX
9 | else:
|
info: `HasX` is declared as a protocol class, but it is not declared as runtime-checkable
--> /src/mdtest_snippet.py:3:7
|
1 | from typing_extensions import Protocol, reveal_type
2 |
3 | class HasX(Protocol):
| ^^^^^^^^^^^^^^ `HasX` declared here
4 | x: int
|
info: A protocol class can only be used in `isinstance` checks if it is decorated with `@typing.runtime_checkable` or `@typing_extensions.runtime_checkable`
info: See https://docs.python.org/3/library/typing.html#typing.runtime_checkable

```

```
info: revealed-type: Revealed type
--> /src/mdtest_snippet.py:8:9
|
6 | def f(arg: object, arg2: type):
7 | if isinstance(arg, HasX): # error: [invalid-argument-type]
8 | reveal_type(arg) # revealed: HasX
| ^^^^^^^^^^^^^^^^ `HasX`
9 | else:
10 | reveal_type(arg) # revealed: ~HasX
|

```

```
info: revealed-type: Revealed type
--> /src/mdtest_snippet.py:10:9
|
8 | reveal_type(arg) # revealed: HasX
9 | else:
10 | reveal_type(arg) # revealed: ~HasX
| ^^^^^^^^^^^^^^^^ `~HasX`
11 |
12 | if issubclass(arg2, HasX): # error: [invalid-argument-type]
|

```

```
error: lint:invalid-argument-type: Class `HasX` cannot be used as the second argument to `issubclass`
--> /src/mdtest_snippet.py:12:8
|
10 | reveal_type(arg) # revealed: ~HasX
11 |
12 | if issubclass(arg2, HasX): # error: [invalid-argument-type]
| ^^^^^^^^^^^^^^^^^^^^^^ This call will raise `TypeError` at runtime
13 | reveal_type(arg2) # revealed: type[HasX]
14 | else:
|
info: `HasX` is declared as a protocol class, but it is not declared as runtime-checkable
--> /src/mdtest_snippet.py:3:7
|
1 | from typing_extensions import Protocol, reveal_type
2 |
3 | class HasX(Protocol):
| ^^^^^^^^^^^^^^ `HasX` declared here
4 | x: int
|
info: A protocol class can only be used in `issubclass` checks if it is decorated with `@typing.runtime_checkable` or `@typing_extensions.runtime_checkable`
info: See https://docs.python.org/3/library/typing.html#typing.runtime_checkable

```

```
info: revealed-type: Revealed type
--> /src/mdtest_snippet.py:13:9
|
12 | if issubclass(arg2, HasX): # error: [invalid-argument-type]
13 | reveal_type(arg2) # revealed: type[HasX]
| ^^^^^^^^^^^^^^^^^ `type[HasX]`
14 | else:
15 | reveal_type(arg2) # revealed: type & ~type[HasX]
|

```

```
info: revealed-type: Revealed type
--> /src/mdtest_snippet.py:15:9
|
13 | reveal_type(arg2) # revealed: type[HasX]
14 | else:
15 | reveal_type(arg2) # revealed: type & ~type[HasX]
| ^^^^^^^^^^^^^^^^^ `type & ~type[HasX]`
16 | from typing import runtime_checkable
|

```

```
info: revealed-type: Revealed type
--> /src/mdtest_snippet.py:24:9
|
22 | def f(arg: object):
23 | if isinstance(arg, RuntimeCheckableHasX): # no error!
24 | reveal_type(arg) # revealed: RuntimeCheckableHasX
| ^^^^^^^^^^^^^^^^ `RuntimeCheckableHasX`
25 | else:
26 | reveal_type(arg) # revealed: ~RuntimeCheckableHasX
|

```

```
info: revealed-type: Revealed type
--> /src/mdtest_snippet.py:26:9
|
24 | reveal_type(arg) # revealed: RuntimeCheckableHasX
25 | else:
26 | reveal_type(arg) # revealed: ~RuntimeCheckableHasX
| ^^^^^^^^^^^^^^^^ `~RuntimeCheckableHasX`
27 | @runtime_checkable
28 | class OnlyMethodMembers(Protocol):
|

```

```
info: revealed-type: Revealed type
--> /src/mdtest_snippet.py:33:9
|
31 | def f(arg1: type, arg2: type):
32 | if issubclass(arg1, RuntimeCheckableHasX): # TODO: should emit an error here (has non-method members)
33 | reveal_type(arg1) # revealed: type[RuntimeCheckableHasX]
| ^^^^^^^^^^^^^^^^^ `type[RuntimeCheckableHasX]`
34 | else:
35 | reveal_type(arg1) # revealed: type & ~type[RuntimeCheckableHasX]
|

```

```
info: revealed-type: Revealed type
--> /src/mdtest_snippet.py:35:9
|
33 | reveal_type(arg1) # revealed: type[RuntimeCheckableHasX]
34 | else:
35 | reveal_type(arg1) # revealed: type & ~type[RuntimeCheckableHasX]
| ^^^^^^^^^^^^^^^^^ `type & ~type[RuntimeCheckableHasX]`
36 |
37 | if issubclass(arg2, OnlyMethodMembers): # no error!
|

```

```
info: revealed-type: Revealed type
--> /src/mdtest_snippet.py:38:9
|
37 | if issubclass(arg2, OnlyMethodMembers): # no error!
38 | reveal_type(arg2) # revealed: type[OnlyMethodMembers]
| ^^^^^^^^^^^^^^^^^ `type[OnlyMethodMembers]`
39 | else:
40 | reveal_type(arg2) # revealed: type & ~type[OnlyMethodMembers]
|

```

```
info: revealed-type: Revealed type
--> /src/mdtest_snippet.py:40:9
|
38 | reveal_type(arg2) # revealed: type[OnlyMethodMembers]
39 | else:
40 | reveal_type(arg2) # revealed: type & ~type[OnlyMethodMembers]
| ^^^^^^^^^^^^^^^^^ `type & ~type[OnlyMethodMembers]`
|

```
6 changes: 4 additions & 2 deletions crates/red_knot_python_semantic/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6231,9 +6231,11 @@ impl<'db> FunctionType<'db> {

/// Non-exhaustive enumeration of known functions (e.g. `builtins.reveal_type`, ...) that might
/// have special behavior.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, strum_macros::EnumString)]
#[derive(
Debug, Copy, Clone, PartialEq, Eq, Hash, strum_macros::EnumString, strum_macros::IntoStaticStr,
)]
#[strum(serialize_all = "snake_case")]
#[cfg_attr(test, derive(strum_macros::EnumIter, strum_macros::IntoStaticStr))]
#[cfg_attr(test, derive(strum_macros::EnumIter))]
pub enum KnownFunction {
/// `builtins.isinstance`
#[strum(serialize = "isinstance")]
Expand Down
19 changes: 16 additions & 3 deletions crates/red_knot_python_semantic/src/types/class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -629,12 +629,20 @@ impl<'db> ClassLiteralType<'db> {
.collect()
}

/// Is this class final?
pub(super) fn is_final(self, db: &'db dyn Db) -> bool {
fn known_function_decorators(
self,
db: &'db dyn Db,
) -> impl Iterator<Item = KnownFunction> + 'db {
self.decorators(db)
.iter()
.filter_map(|deco| deco.into_function_literal())
.any(|decorator| decorator.is_known(db, KnownFunction::Final))
.filter_map(|decorator| decorator.known(db))
}

/// Is this class final?
pub(super) fn is_final(self, db: &'db dyn Db) -> bool {
self.known_function_decorators(db)
.contains(&KnownFunction::Final)
}

/// Attempt to resolve the [method resolution order] ("MRO") for this class.
Expand Down Expand Up @@ -1837,6 +1845,11 @@ impl<'db> ProtocolClassLiteral<'db> {

cached_protocol_members(db, *self)
}

pub(super) fn is_runtime_checkable(self, db: &'db dyn Db) -> bool {
self.known_function_decorators(db)
.contains(&KnownFunction::RuntimeCheckable)
}
}

impl<'db> Deref for ProtocolClassLiteral<'db> {
Expand Down
Loading
Loading