diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_11.pyi b/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_11.pyi new file mode 100644 index 0000000000000..86d8ceb36af19 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_11.pyi @@ -0,0 +1,16 @@ +"""Test case: strings used within calls within type annotations.""" + +from typing import Callable + +import bpy +from mypy_extensions import VarArg + +class LightShow(bpy.types.Operator): + label = "Create Character" + name = "lightshow.letter_creation" + + filepath: bpy.props.StringProperty(subtype="FILE_PATH") # OK + + +def f(x: Callable[[VarArg("os")], None]): # F821 + pass diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_26.py b/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_26.py new file mode 100644 index 0000000000000..f87819ef8004b --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_26.py @@ -0,0 +1,44 @@ +"""Tests for constructs allowed in `.pyi` stub files but not at runtime""" + +from typing import Optional, TypeAlias, Union + +__version__: str +__author__: str + +# Forward references: +MaybeCStr: TypeAlias = Optional[CStr] # valid in a `.pyi` stub file, not in a `.py` runtime file +MaybeCStr2: TypeAlias = Optional["CStr"] # always okay +CStr: TypeAlias = Union[C, str] # valid in a `.pyi` stub file, not in a `.py` runtime file +CStr2: TypeAlias = Union["C", str] # always okay + +# References to a class from inside the class: +class C: + other: C = ... # valid in a `.pyi` stub file, not in a `.py` runtime file + other2: "C" = ... # always okay + def from_str(self, s: str) -> C: ... # valid in a `.pyi` stub file, not in a `.py` runtime file + def from_str2(self, s: str) -> "C": ... # always okay + +# Circular references: +class A: + foo: B # valid in a `.pyi` stub file, not in a `.py` runtime file + foo2: "B" # always okay + bar: dict[str, B] # valid in a `.pyi` stub file, not in a `.py` runtime file + bar2: dict[str, "A"] # always okay + +class B: + foo: A # always okay + bar: dict[str, A] # always okay + +class Leaf: ... +class Tree(list[Tree | Leaf]): ... # valid in a `.pyi` stub file, not in a `.py` runtime file +class Tree2(list["Tree | Leaf"]): ... # always okay + +# Annotations are treated as assignments in .pyi files, but not in .py files +class MyClass: + foo: int + bar = foo # valid in a `.pyi` stub file, not in a `.py` runtime file + bar = "foo" # always okay + +baz: MyClass +eggs = baz # valid in a `.pyi` stub file, not in a `.py` runtime file +eggs = "baz" # always okay diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_26.pyi b/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_26.pyi new file mode 100644 index 0000000000000..f87819ef8004b --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_26.pyi @@ -0,0 +1,44 @@ +"""Tests for constructs allowed in `.pyi` stub files but not at runtime""" + +from typing import Optional, TypeAlias, Union + +__version__: str +__author__: str + +# Forward references: +MaybeCStr: TypeAlias = Optional[CStr] # valid in a `.pyi` stub file, not in a `.py` runtime file +MaybeCStr2: TypeAlias = Optional["CStr"] # always okay +CStr: TypeAlias = Union[C, str] # valid in a `.pyi` stub file, not in a `.py` runtime file +CStr2: TypeAlias = Union["C", str] # always okay + +# References to a class from inside the class: +class C: + other: C = ... # valid in a `.pyi` stub file, not in a `.py` runtime file + other2: "C" = ... # always okay + def from_str(self, s: str) -> C: ... # valid in a `.pyi` stub file, not in a `.py` runtime file + def from_str2(self, s: str) -> "C": ... # always okay + +# Circular references: +class A: + foo: B # valid in a `.pyi` stub file, not in a `.py` runtime file + foo2: "B" # always okay + bar: dict[str, B] # valid in a `.pyi` stub file, not in a `.py` runtime file + bar2: dict[str, "A"] # always okay + +class B: + foo: A # always okay + bar: dict[str, A] # always okay + +class Leaf: ... +class Tree(list[Tree | Leaf]): ... # valid in a `.pyi` stub file, not in a `.py` runtime file +class Tree2(list["Tree | Leaf"]): ... # always okay + +# Annotations are treated as assignments in .pyi files, but not in .py files +class MyClass: + foo: int + bar = foo # valid in a `.pyi` stub file, not in a `.py` runtime file + bar = "foo" # always okay + +baz: MyClass +eggs = baz # valid in a `.pyi` stub file, not in a `.py` runtime file +eggs = "baz" # always okay diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_27.py b/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_27.py new file mode 100644 index 0000000000000..f9004a6dea5af --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_27.py @@ -0,0 +1,35 @@ +"""Tests for constructs allowed when `__future__` annotations are enabled but not otherwise""" +from __future__ import annotations + +from typing import Optional, TypeAlias, Union + +__version__: str +__author__: str + +# References to a class from inside the class: +class C: + other: C = ... # valid when `__future__.annotations are enabled + other2: "C" = ... # always okay + def from_str(self, s: str) -> C: ... # valid when `__future__.annotations are enabled + def from_str2(self, s: str) -> "C": ... # always okay + +# Circular references: +class A: + foo: B # valid when `__future__.annotations are enabled + foo2: "B" # always okay + bar: dict[str, B] # valid when `__future__.annotations are enabled + bar2: dict[str, "A"] # always okay + +class B: + foo: A # always okay + bar: dict[str, A] # always okay + +# Annotations are treated as assignments in .pyi files, but not in .py files +class MyClass: + foo: int + bar = foo # Still invalid even when `__future__.annotations` are enabled + bar = "foo" # always okay + +baz: MyClass +eggs = baz # Still invalid even when `__future__.annotations` are enabled +eggs = "baz" # always okay diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_5.pyi b/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_5.pyi new file mode 100644 index 0000000000000..2e977ea150134 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_5.pyi @@ -0,0 +1,10 @@ +"""Test: inner class annotation.""" + +class RandomClass: + def bad_func(self) -> InnerClass: ... # F821 + def good_func(self) -> OuterClass.InnerClass: ... # Okay + +class OuterClass: + class InnerClass: ... + + def good_func(self) -> InnerClass: ... # Okay diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F822_0.pyi b/crates/ruff_linter/resources/test/fixtures/pyflakes/F822_0.pyi new file mode 100644 index 0000000000000..d0165e7083349 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F822_0.pyi @@ -0,0 +1,4 @@ +a = 1 +b: int # Considered a binding in a `.pyi` stub file, not in a `.py` runtime file + +__all__ = ["a", "b", "c"] # c is flagged as missing; b is not diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index 62ee66842b46d..85d2f5a5b48ac 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -1839,11 +1839,13 @@ impl<'a> Checker<'a> { flags.insert(BindingFlags::UNPACKED_ASSIGNMENT); } - // Match the left-hand side of an annotated assignment, like `x` in `x: int`. + // Match the left-hand side of an annotated assignment without a value, + // like `x` in `x: int`. N.B. In stub files, these should be viewed + // as assignments on par with statements such as `x: int = 5`. if matches!( parent, Stmt::AnnAssign(ast::StmtAnnAssign { value: None, .. }) - ) && !self.semantic.in_annotation() + ) && !(self.semantic.in_annotation() || self.source_type.is_stub()) { self.add_binding(id, expr.range(), BindingKind::Annotation, flags); return; diff --git a/crates/ruff_linter/src/rules/pyflakes/mod.rs b/crates/ruff_linter/src/rules/pyflakes/mod.rs index bd3adf28ecaea..c4950809b438d 100644 --- a/crates/ruff_linter/src/rules/pyflakes/mod.rs +++ b/crates/ruff_linter/src/rules/pyflakes/mod.rs @@ -130,12 +130,14 @@ mod tests { #[test_case(Rule::UndefinedName, Path::new("F821_3.py"))] #[test_case(Rule::UndefinedName, Path::new("F821_4.py"))] #[test_case(Rule::UndefinedName, Path::new("F821_5.py"))] + #[test_case(Rule::UndefinedName, Path::new("F821_5.pyi"))] #[test_case(Rule::UndefinedName, Path::new("F821_6.py"))] #[test_case(Rule::UndefinedName, Path::new("F821_7.py"))] #[test_case(Rule::UndefinedName, Path::new("F821_8.pyi"))] #[test_case(Rule::UndefinedName, Path::new("F821_9.py"))] #[test_case(Rule::UndefinedName, Path::new("F821_10.py"))] #[test_case(Rule::UndefinedName, Path::new("F821_11.py"))] + #[test_case(Rule::UndefinedName, Path::new("F821_11.pyi"))] #[test_case(Rule::UndefinedName, Path::new("F821_12.py"))] #[test_case(Rule::UndefinedName, Path::new("F821_13.py"))] #[test_case(Rule::UndefinedName, Path::new("F821_14.py"))] @@ -150,7 +152,11 @@ mod tests { #[test_case(Rule::UndefinedName, Path::new("F821_23.py"))] #[test_case(Rule::UndefinedName, Path::new("F821_24.py"))] #[test_case(Rule::UndefinedName, Path::new("F821_25.py"))] + #[test_case(Rule::UndefinedName, Path::new("F821_26.py"))] + #[test_case(Rule::UndefinedName, Path::new("F821_26.pyi"))] + #[test_case(Rule::UndefinedName, Path::new("F821_27.py"))] #[test_case(Rule::UndefinedExport, Path::new("F822_0.py"))] + #[test_case(Rule::UndefinedExport, Path::new("F822_0.pyi"))] #[test_case(Rule::UndefinedExport, Path::new("F822_1.py"))] #[test_case(Rule::UndefinedExport, Path::new("F822_2.py"))] #[test_case(Rule::UndefinedLocal, Path::new("F823.py"))] diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_11.pyi.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_11.pyi.snap new file mode 100644 index 0000000000000..0dc17e91619cb --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_11.pyi.snap @@ -0,0 +1,9 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F821_11.pyi:15:28: F821 Undefined name `os` + | +15 | def f(x: Callable[[VarArg("os")], None]): # F821 + | ^^ F821 +16 | pass + | diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_26.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_26.py.snap new file mode 100644 index 0000000000000..1da3d5fe060a2 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_26.py.snap @@ -0,0 +1,83 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F821_26.py:9:33: F821 Undefined name `CStr` + | + 8 | # Forward references: + 9 | MaybeCStr: TypeAlias = Optional[CStr] # valid in a `.pyi` stub file, not in a `.py` runtime file + | ^^^^ F821 +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 + | + +F821_26.py:11:25: F821 Undefined name `C` + | + 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 + | ^ F821 +12 | CStr2: TypeAlias = Union["C", str] # always okay + | + +F821_26.py:16:12: F821 Undefined name `C` + | +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 + | ^ F821 +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 + | + +F821_26.py:18:35: F821 Undefined name `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 + | ^ F821 +19 | def from_str2(self, s: str) -> "C": ... # always okay + | + +F821_26.py:23:10: F821 Undefined name `B` + | +21 | # Circular references: +22 | class A: +23 | foo: B # valid in a `.pyi` stub file, not in a `.py` runtime file + | ^ F821 +24 | foo2: "B" # always okay +25 | bar: dict[str, B] # valid in a `.pyi` stub file, not in a `.py` runtime file + | + +F821_26.py:25:20: F821 Undefined name `B` + | +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 + | ^ F821 +26 | bar2: dict[str, "A"] # always okay + | + +F821_26.py:33:17: F821 Undefined name `Tree` + | +32 | class Leaf: ... +33 | class Tree(list[Tree | Leaf]): ... # valid in a `.pyi` stub file, not in a `.py` runtime file + | ^^^^ F821 +34 | class Tree2(list["Tree | Leaf"]): ... # always okay + | + +F821_26.py:39:11: F821 Undefined name `foo` + | +37 | class MyClass: +38 | foo: int +39 | bar = foo # valid in a `.pyi` stub file, not in a `.py` runtime file + | ^^^ F821 +40 | bar = "foo" # always okay + | + +F821_26.py:43:8: F821 Undefined name `baz` + | +42 | baz: MyClass +43 | eggs = baz # valid in a `.pyi` stub file, not in a `.py` runtime file + | ^^^ F821 +44 | eggs = "baz" # always okay + | diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_26.pyi.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_26.pyi.snap new file mode 100644 index 0000000000000..d0b409f39ee0b --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_26.pyi.snap @@ -0,0 +1,4 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- + diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_27.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_27.py.snap new file mode 100644 index 0000000000000..de22fa9f7baf9 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_27.py.snap @@ -0,0 +1,19 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F821_27.py:30:11: F821 Undefined name `foo` + | +28 | class MyClass: +29 | foo: int +30 | bar = foo # Still invalid even when `__future__.annotations` are enabled + | ^^^ F821 +31 | bar = "foo" # always okay + | + +F821_27.py:34:8: F821 Undefined name `baz` + | +33 | baz: MyClass +34 | eggs = baz # Still invalid even when `__future__.annotations` are enabled + | ^^^ F821 +35 | eggs = "baz" # always okay + | diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_5.pyi.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_5.pyi.snap new file mode 100644 index 0000000000000..ff1ac3037e00c --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_5.pyi.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F821_5.pyi:4:27: F821 Undefined name `InnerClass` + | +3 | class RandomClass: +4 | def bad_func(self) -> InnerClass: ... # F821 + | ^^^^^^^^^^ F821 +5 | def good_func(self) -> OuterClass.InnerClass: ... # Okay + | diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_0.pyi.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_0.pyi.snap new file mode 100644 index 0000000000000..320ac6c37fd72 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F822_F822_0.pyi.snap @@ -0,0 +1,10 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +--- +F822_0.pyi:4:1: F822 Undefined name `c` in `__all__` + | +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 + | ^^^^^^^ F822 + |