Skip to content

Commit 4b06669

Browse files
authored
F821, F822: fix false positive for .pyi files; add more test coverage for .pyi files (#10341)
This PR fixes the following false positive in a `.pyi` stub file: ```py x: int y = x # F821 currently emitted here, but shouldn't be in a stub file ``` In a `.py` file, this is invalid regardless of whether `from __future__ import annotations` is enabled or not. In a `.pyi` stub file, however, it's always valid, as an annotation counts as a binding in a stub file even if no value is assigned to the variable. I also added more test coverage for `.pyi` stub files in various edge cases where ruff's behaviour is currently correct, but where `.pyi` stub files do slightly different things to `.py` files.
1 parent 06284c3 commit 4b06669

14 files changed

+298
-2
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Test case: strings used within calls within type annotations."""
2+
3+
from typing import Callable
4+
5+
import bpy
6+
from mypy_extensions import VarArg
7+
8+
class LightShow(bpy.types.Operator):
9+
label = "Create Character"
10+
name = "lightshow.letter_creation"
11+
12+
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # OK
13+
14+
15+
def f(x: Callable[[VarArg("os")], None]): # F821
16+
pass
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Tests for constructs allowed in `.pyi` stub files but not at runtime"""
2+
3+
from typing import Optional, TypeAlias, Union
4+
5+
__version__: str
6+
__author__: str
7+
8+
# Forward references:
9+
MaybeCStr: TypeAlias = Optional[CStr] # valid in a `.pyi` stub file, not in a `.py` runtime file
10+
MaybeCStr2: TypeAlias = Optional["CStr"] # always okay
11+
CStr: TypeAlias = Union[C, str] # valid in a `.pyi` stub file, not in a `.py` runtime file
12+
CStr2: TypeAlias = Union["C", str] # always okay
13+
14+
# References to a class from inside the class:
15+
class C:
16+
other: C = ... # valid in a `.pyi` stub file, not in a `.py` runtime file
17+
other2: "C" = ... # always okay
18+
def from_str(self, s: str) -> C: ... # valid in a `.pyi` stub file, not in a `.py` runtime file
19+
def from_str2(self, s: str) -> "C": ... # always okay
20+
21+
# Circular references:
22+
class A:
23+
foo: B # valid in a `.pyi` stub file, not in a `.py` runtime file
24+
foo2: "B" # always okay
25+
bar: dict[str, B] # valid in a `.pyi` stub file, not in a `.py` runtime file
26+
bar2: dict[str, "A"] # always okay
27+
28+
class B:
29+
foo: A # always okay
30+
bar: dict[str, A] # always okay
31+
32+
class Leaf: ...
33+
class Tree(list[Tree | Leaf]): ... # valid in a `.pyi` stub file, not in a `.py` runtime file
34+
class Tree2(list["Tree | Leaf"]): ... # always okay
35+
36+
# Annotations are treated as assignments in .pyi files, but not in .py files
37+
class MyClass:
38+
foo: int
39+
bar = foo # valid in a `.pyi` stub file, not in a `.py` runtime file
40+
bar = "foo" # always okay
41+
42+
baz: MyClass
43+
eggs = baz # valid in a `.pyi` stub file, not in a `.py` runtime file
44+
eggs = "baz" # always okay
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Tests for constructs allowed in `.pyi` stub files but not at runtime"""
2+
3+
from typing import Optional, TypeAlias, Union
4+
5+
__version__: str
6+
__author__: str
7+
8+
# Forward references:
9+
MaybeCStr: TypeAlias = Optional[CStr] # valid in a `.pyi` stub file, not in a `.py` runtime file
10+
MaybeCStr2: TypeAlias = Optional["CStr"] # always okay
11+
CStr: TypeAlias = Union[C, str] # valid in a `.pyi` stub file, not in a `.py` runtime file
12+
CStr2: TypeAlias = Union["C", str] # always okay
13+
14+
# References to a class from inside the class:
15+
class C:
16+
other: C = ... # valid in a `.pyi` stub file, not in a `.py` runtime file
17+
other2: "C" = ... # always okay
18+
def from_str(self, s: str) -> C: ... # valid in a `.pyi` stub file, not in a `.py` runtime file
19+
def from_str2(self, s: str) -> "C": ... # always okay
20+
21+
# Circular references:
22+
class A:
23+
foo: B # valid in a `.pyi` stub file, not in a `.py` runtime file
24+
foo2: "B" # always okay
25+
bar: dict[str, B] # valid in a `.pyi` stub file, not in a `.py` runtime file
26+
bar2: dict[str, "A"] # always okay
27+
28+
class B:
29+
foo: A # always okay
30+
bar: dict[str, A] # always okay
31+
32+
class Leaf: ...
33+
class Tree(list[Tree | Leaf]): ... # valid in a `.pyi` stub file, not in a `.py` runtime file
34+
class Tree2(list["Tree | Leaf"]): ... # always okay
35+
36+
# Annotations are treated as assignments in .pyi files, but not in .py files
37+
class MyClass:
38+
foo: int
39+
bar = foo # valid in a `.pyi` stub file, not in a `.py` runtime file
40+
bar = "foo" # always okay
41+
42+
baz: MyClass
43+
eggs = baz # valid in a `.pyi` stub file, not in a `.py` runtime file
44+
eggs = "baz" # always okay
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Tests for constructs allowed when `__future__` annotations are enabled but not otherwise"""
2+
from __future__ import annotations
3+
4+
from typing import Optional, TypeAlias, Union
5+
6+
__version__: str
7+
__author__: str
8+
9+
# References to a class from inside the class:
10+
class C:
11+
other: C = ... # valid when `__future__.annotations are enabled
12+
other2: "C" = ... # always okay
13+
def from_str(self, s: str) -> C: ... # valid when `__future__.annotations are enabled
14+
def from_str2(self, s: str) -> "C": ... # always okay
15+
16+
# Circular references:
17+
class A:
18+
foo: B # valid when `__future__.annotations are enabled
19+
foo2: "B" # always okay
20+
bar: dict[str, B] # valid when `__future__.annotations are enabled
21+
bar2: dict[str, "A"] # always okay
22+
23+
class B:
24+
foo: A # always okay
25+
bar: dict[str, A] # always okay
26+
27+
# Annotations are treated as assignments in .pyi files, but not in .py files
28+
class MyClass:
29+
foo: int
30+
bar = foo # Still invalid even when `__future__.annotations` are enabled
31+
bar = "foo" # always okay
32+
33+
baz: MyClass
34+
eggs = baz # Still invalid even when `__future__.annotations` are enabled
35+
eggs = "baz" # always okay
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Test: inner class annotation."""
2+
3+
class RandomClass:
4+
def bad_func(self) -> InnerClass: ... # F821
5+
def good_func(self) -> OuterClass.InnerClass: ... # Okay
6+
7+
class OuterClass:
8+
class InnerClass: ...
9+
10+
def good_func(self) -> InnerClass: ... # Okay
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
a = 1
2+
b: int # Considered a binding in a `.pyi` stub file, not in a `.py` runtime file
3+
4+
__all__ = ["a", "b", "c"] # c is flagged as missing; b is not

crates/ruff_linter/src/checkers/ast/mod.rs

+4-2
Original file line numberDiff line numberDiff line change
@@ -1839,11 +1839,13 @@ impl<'a> Checker<'a> {
18391839
flags.insert(BindingFlags::UNPACKED_ASSIGNMENT);
18401840
}
18411841

1842-
// Match the left-hand side of an annotated assignment, like `x` in `x: int`.
1842+
// Match the left-hand side of an annotated assignment without a value,
1843+
// like `x` in `x: int`. N.B. In stub files, these should be viewed
1844+
// as assignments on par with statements such as `x: int = 5`.
18431845
if matches!(
18441846
parent,
18451847
Stmt::AnnAssign(ast::StmtAnnAssign { value: None, .. })
1846-
) && !self.semantic.in_annotation()
1848+
) && !(self.semantic.in_annotation() || self.source_type.is_stub())
18471849
{
18481850
self.add_binding(id, expr.range(), BindingKind::Annotation, flags);
18491851
return;

crates/ruff_linter/src/rules/pyflakes/mod.rs

+6
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,14 @@ mod tests {
130130
#[test_case(Rule::UndefinedName, Path::new("F821_3.py"))]
131131
#[test_case(Rule::UndefinedName, Path::new("F821_4.py"))]
132132
#[test_case(Rule::UndefinedName, Path::new("F821_5.py"))]
133+
#[test_case(Rule::UndefinedName, Path::new("F821_5.pyi"))]
133134
#[test_case(Rule::UndefinedName, Path::new("F821_6.py"))]
134135
#[test_case(Rule::UndefinedName, Path::new("F821_7.py"))]
135136
#[test_case(Rule::UndefinedName, Path::new("F821_8.pyi"))]
136137
#[test_case(Rule::UndefinedName, Path::new("F821_9.py"))]
137138
#[test_case(Rule::UndefinedName, Path::new("F821_10.py"))]
138139
#[test_case(Rule::UndefinedName, Path::new("F821_11.py"))]
140+
#[test_case(Rule::UndefinedName, Path::new("F821_11.pyi"))]
139141
#[test_case(Rule::UndefinedName, Path::new("F821_12.py"))]
140142
#[test_case(Rule::UndefinedName, Path::new("F821_13.py"))]
141143
#[test_case(Rule::UndefinedName, Path::new("F821_14.py"))]
@@ -150,7 +152,11 @@ mod tests {
150152
#[test_case(Rule::UndefinedName, Path::new("F821_23.py"))]
151153
#[test_case(Rule::UndefinedName, Path::new("F821_24.py"))]
152154
#[test_case(Rule::UndefinedName, Path::new("F821_25.py"))]
155+
#[test_case(Rule::UndefinedName, Path::new("F821_26.py"))]
156+
#[test_case(Rule::UndefinedName, Path::new("F821_26.pyi"))]
157+
#[test_case(Rule::UndefinedName, Path::new("F821_27.py"))]
153158
#[test_case(Rule::UndefinedExport, Path::new("F822_0.py"))]
159+
#[test_case(Rule::UndefinedExport, Path::new("F822_0.pyi"))]
154160
#[test_case(Rule::UndefinedExport, Path::new("F822_1.py"))]
155161
#[test_case(Rule::UndefinedExport, Path::new("F822_2.py"))]
156162
#[test_case(Rule::UndefinedLocal, Path::new("F823.py"))]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
source: crates/ruff_linter/src/rules/pyflakes/mod.rs
3+
---
4+
F821_11.pyi:15:28: F821 Undefined name `os`
5+
|
6+
15 | def f(x: Callable[[VarArg("os")], None]): # F821
7+
| ^^ F821
8+
16 | pass
9+
|
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
---
2+
source: crates/ruff_linter/src/rules/pyflakes/mod.rs
3+
---
4+
F821_26.py:9:33: F821 Undefined name `CStr`
5+
|
6+
8 | # Forward references:
7+
9 | MaybeCStr: TypeAlias = Optional[CStr] # valid in a `.pyi` stub file, not in a `.py` runtime file
8+
| ^^^^ F821
9+
10 | MaybeCStr2: TypeAlias = Optional["CStr"] # always okay
10+
11 | CStr: TypeAlias = Union[C, str] # valid in a `.pyi` stub file, not in a `.py` runtime file
11+
|
12+
13+
F821_26.py:11:25: F821 Undefined name `C`
14+
|
15+
9 | MaybeCStr: TypeAlias = Optional[CStr] # valid in a `.pyi` stub file, not in a `.py` runtime file
16+
10 | MaybeCStr2: TypeAlias = Optional["CStr"] # always okay
17+
11 | CStr: TypeAlias = Union[C, str] # valid in a `.pyi` stub file, not in a `.py` runtime file
18+
| ^ F821
19+
12 | CStr2: TypeAlias = Union["C", str] # always okay
20+
|
21+
22+
F821_26.py:16:12: F821 Undefined name `C`
23+
|
24+
14 | # References to a class from inside the class:
25+
15 | class C:
26+
16 | other: C = ... # valid in a `.pyi` stub file, not in a `.py` runtime file
27+
| ^ F821
28+
17 | other2: "C" = ... # always okay
29+
18 | def from_str(self, s: str) -> C: ... # valid in a `.pyi` stub file, not in a `.py` runtime file
30+
|
31+
32+
F821_26.py:18:35: F821 Undefined name `C`
33+
|
34+
16 | other: C = ... # valid in a `.pyi` stub file, not in a `.py` runtime file
35+
17 | other2: "C" = ... # always okay
36+
18 | def from_str(self, s: str) -> C: ... # valid in a `.pyi` stub file, not in a `.py` runtime file
37+
| ^ F821
38+
19 | def from_str2(self, s: str) -> "C": ... # always okay
39+
|
40+
41+
F821_26.py:23:10: F821 Undefined name `B`
42+
|
43+
21 | # Circular references:
44+
22 | class A:
45+
23 | foo: B # valid in a `.pyi` stub file, not in a `.py` runtime file
46+
| ^ F821
47+
24 | foo2: "B" # always okay
48+
25 | bar: dict[str, B] # valid in a `.pyi` stub file, not in a `.py` runtime file
49+
|
50+
51+
F821_26.py:25:20: F821 Undefined name `B`
52+
|
53+
23 | foo: B # valid in a `.pyi` stub file, not in a `.py` runtime file
54+
24 | foo2: "B" # always okay
55+
25 | bar: dict[str, B] # valid in a `.pyi` stub file, not in a `.py` runtime file
56+
| ^ F821
57+
26 | bar2: dict[str, "A"] # always okay
58+
|
59+
60+
F821_26.py:33:17: F821 Undefined name `Tree`
61+
|
62+
32 | class Leaf: ...
63+
33 | class Tree(list[Tree | Leaf]): ... # valid in a `.pyi` stub file, not in a `.py` runtime file
64+
| ^^^^ F821
65+
34 | class Tree2(list["Tree | Leaf"]): ... # always okay
66+
|
67+
68+
F821_26.py:39:11: F821 Undefined name `foo`
69+
|
70+
37 | class MyClass:
71+
38 | foo: int
72+
39 | bar = foo # valid in a `.pyi` stub file, not in a `.py` runtime file
73+
| ^^^ F821
74+
40 | bar = "foo" # always okay
75+
|
76+
77+
F821_26.py:43:8: F821 Undefined name `baz`
78+
|
79+
42 | baz: MyClass
80+
43 | eggs = baz # valid in a `.pyi` stub file, not in a `.py` runtime file
81+
| ^^^ F821
82+
44 | eggs = "baz" # always okay
83+
|
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
source: crates/ruff_linter/src/rules/pyflakes/mod.rs
3+
---
4+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
source: crates/ruff_linter/src/rules/pyflakes/mod.rs
3+
---
4+
F821_27.py:30:11: F821 Undefined name `foo`
5+
|
6+
28 | class MyClass:
7+
29 | foo: int
8+
30 | bar = foo # Still invalid even when `__future__.annotations` are enabled
9+
| ^^^ F821
10+
31 | bar = "foo" # always okay
11+
|
12+
13+
F821_27.py:34:8: F821 Undefined name `baz`
14+
|
15+
33 | baz: MyClass
16+
34 | eggs = baz # Still invalid even when `__future__.annotations` are enabled
17+
| ^^^ F821
18+
35 | eggs = "baz" # always okay
19+
|
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
source: crates/ruff_linter/src/rules/pyflakes/mod.rs
3+
---
4+
F821_5.pyi:4:27: F821 Undefined name `InnerClass`
5+
|
6+
3 | class RandomClass:
7+
4 | def bad_func(self) -> InnerClass: ... # F821
8+
| ^^^^^^^^^^ F821
9+
5 | def good_func(self) -> OuterClass.InnerClass: ... # Okay
10+
|
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
source: crates/ruff_linter/src/rules/pyflakes/mod.rs
3+
---
4+
F822_0.pyi:4:1: F822 Undefined name `c` in `__all__`
5+
|
6+
2 | b: int # Considered a binding in a `.pyi` stub file, not in a `.py` runtime file
7+
3 |
8+
4 | __all__ = ["a", "b", "c"] # c is flagged as missing; b is not
9+
| ^^^^^^^ F822
10+
|

0 commit comments

Comments
 (0)