diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F401_32.py b/crates/ruff_linter/resources/test/fixtures/pyflakes/F401_32.py new file mode 100644 index 0000000000000..8fc3319071480 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F401_32.py @@ -0,0 +1,8 @@ +from datetime import datetime +from typing import no_type_check + + +# No errors + +@no_type_check +def f(a: datetime, b: "datetime"): ... diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F722_1.py b/crates/ruff_linter/resources/test/fixtures/pyflakes/F722_1.py new file mode 100644 index 0000000000000..adf1861615fd7 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F722_1.py @@ -0,0 +1,16 @@ +from typing import no_type_check + + +# Errors + +@no_type_check +class C: + def f(self, arg: "this isn't python") -> "this isn't python either": + x: "this also isn't python" = 1 + + +# No errors + +@no_type_check +def f(arg: "this isn't python") -> "this isn't python either": + x: "this also isn't python" = 0 diff --git a/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_31.py b/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_31.py new file mode 100644 index 0000000000000..e4f22a21fa39d --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pyflakes/F821_31.py @@ -0,0 +1,16 @@ +import typing + + +# Errors + +@typing.no_type_check +class C: + def f(self, arg: "B") -> "S": + x: "B" = 1 + + +# No errors + +@typing.no_type_check +def f(arg: "A") -> "R": + x: "A" = 1 diff --git a/crates/ruff_linter/src/checkers/ast/analyze/unresolved_references.rs b/crates/ruff_linter/src/checkers/ast/analyze/unresolved_references.rs index 22bfd90568eee..2eace392dfc30 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/unresolved_references.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/unresolved_references.rs @@ -24,6 +24,10 @@ pub(crate) fn unresolved_references(checker: &mut Checker) { } } else { if checker.enabled(Rule::UndefinedName) { + if checker.semantic.in_no_type_check() { + continue; + } + // Avoid flagging if `NameError` is handled. if reference.exceptions().contains(Exceptions::NAME_ERROR) { continue; diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index 36a9972b1e465..138125851612b 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -449,6 +449,13 @@ impl<'a> Checker<'a> { match_fn(expr) } } + + /// Push `diagnostic` if the checker is not in a `@no_type_check` context. + pub(crate) fn push_type_diagnostic(&mut self, diagnostic: Diagnostic) { + if !self.semantic.in_no_type_check() { + self.diagnostics.push(diagnostic); + } + } } impl<'a> Visitor<'a> for Checker<'a> { @@ -724,6 +731,13 @@ impl<'a> Visitor<'a> for Checker<'a> { // deferred. for decorator in decorator_list { self.visit_decorator(decorator); + + if self + .semantic + .match_typing_expr(&decorator.expression, "no_type_check") + { + self.semantic.flags |= SemanticModelFlags::NO_TYPE_CHECK; + } } // Function annotations are always evaluated at runtime, unless future annotations @@ -2348,8 +2362,10 @@ impl<'a> Checker<'a> { } self.parsed_type_annotation = None; } else { + self.semantic.restore(snapshot); + if self.enabled(Rule::ForwardAnnotationSyntaxError) { - self.diagnostics.push(Diagnostic::new( + self.push_type_diagnostic(Diagnostic::new( pyflakes::rules::ForwardAnnotationSyntaxError { body: string_expr.value.to_string(), }, diff --git a/crates/ruff_linter/src/rules/pyflakes/mod.rs b/crates/ruff_linter/src/rules/pyflakes/mod.rs index 77cd2e94f83b8..66ae601b2548c 100644 --- a/crates/ruff_linter/src/rules/pyflakes/mod.rs +++ b/crates/ruff_linter/src/rules/pyflakes/mod.rs @@ -55,6 +55,7 @@ mod tests { #[test_case(Rule::UnusedImport, Path::new("F401_21.py"))] #[test_case(Rule::UnusedImport, Path::new("F401_22.py"))] #[test_case(Rule::UnusedImport, Path::new("F401_23.py"))] + #[test_case(Rule::UnusedImport, Path::new("F401_32.py"))] #[test_case(Rule::ImportShadowedByLoopVar, Path::new("F402.py"))] #[test_case(Rule::ImportShadowedByLoopVar, Path::new("F402.ipynb"))] #[test_case(Rule::UndefinedLocalWithImportStar, Path::new("F403.py"))] @@ -95,6 +96,7 @@ mod tests { #[test_case(Rule::ReturnOutsideFunction, Path::new("F706.py"))] #[test_case(Rule::DefaultExceptNotLast, Path::new("F707.py"))] #[test_case(Rule::ForwardAnnotationSyntaxError, Path::new("F722.py"))] + #[test_case(Rule::ForwardAnnotationSyntaxError, Path::new("F722_1.py"))] #[test_case(Rule::RedefinedWhileUnused, Path::new("F811_0.py"))] #[test_case(Rule::RedefinedWhileUnused, Path::new("F811_1.py"))] #[test_case(Rule::RedefinedWhileUnused, Path::new("F811_2.py"))] @@ -160,6 +162,7 @@ mod tests { #[test_case(Rule::UndefinedName, Path::new("F821_27.py"))] #[test_case(Rule::UndefinedName, Path::new("F821_28.py"))] #[test_case(Rule::UndefinedName, Path::new("F821_30.py"))] + #[test_case(Rule::UndefinedName, Path::new("F821_31.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"))] diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_32.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_32.py.snap new file mode 100644 index 0000000000000..a487e4ddb80cf --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F401_F401_32.py.snap @@ -0,0 +1,5 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +snapshot_kind: text +--- + diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F722_F722_1.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F722_F722_1.py.snap new file mode 100644 index 0000000000000..c7c943161e0b3 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F722_F722_1.py.snap @@ -0,0 +1,29 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +snapshot_kind: text +--- +F722_1.py:8:22: F722 Syntax error in forward annotation: `this isn't python` + | +6 | @no_type_check +7 | class C: +8 | def f(self, arg: "this isn't python") -> "this isn't python either": + | ^^^^^^^^^^^^^^^^^^^ F722 +9 | x: "this also isn't python" = 1 + | + +F722_1.py:8:46: F722 Syntax error in forward annotation: `this isn't python either` + | +6 | @no_type_check +7 | class C: +8 | def f(self, arg: "this isn't python") -> "this isn't python either": + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ F722 +9 | x: "this also isn't python" = 1 + | + +F722_1.py:9:12: F722 Syntax error in forward annotation: `this also isn't python` + | +7 | class C: +8 | def f(self, arg: "this isn't python") -> "this isn't python either": +9 | x: "this also isn't python" = 1 + | ^^^^^^^^^^^^^^^^^^^^^^^^ F722 + | diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_31.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_31.py.snap new file mode 100644 index 0000000000000..c2a81320152a6 --- /dev/null +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F821_F821_31.py.snap @@ -0,0 +1,53 @@ +--- +source: crates/ruff_linter/src/rules/pyflakes/mod.rs +snapshot_kind: text +--- +F821_31.py:8:23: F821 Undefined name `B` + | +6 | @typing.no_type_check +7 | class C: +8 | def f(self, arg: "B") -> "S": + | ^ F821 +9 | x: "B" = 1 + | + +F821_31.py:8:31: F821 Undefined name `S` + | +6 | @typing.no_type_check +7 | class C: +8 | def f(self, arg: "B") -> "S": + | ^ F821 +9 | x: "B" = 1 + | + +F821_31.py:9:13: F821 Undefined name `B` + | +7 | class C: +8 | def f(self, arg: "B") -> "S": +9 | x: "B" = 1 + | ^ F821 + | + +F821_31.py:15:13: F821 Undefined name `A` + | +14 | @typing.no_type_check +15 | def f(arg: "A") -> "R": + | ^ F821 +16 | x: "A" = 1 + | + +F821_31.py:15:21: F821 Undefined name `R` + | +14 | @typing.no_type_check +15 | def f(arg: "A") -> "R": + | ^ F821 +16 | x: "A" = 1 + | + +F821_31.py:16:9: F821 Undefined name `A` + | +14 | @typing.no_type_check +15 | def f(arg: "A") -> "R": +16 | x: "A" = 1 + | ^ F821 + | diff --git a/crates/ruff_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index f43cf24de1ec5..9bfdee6c8f858 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -1935,6 +1935,11 @@ impl<'a> SemanticModel<'a> { .intersects(SemanticModelFlags::ATTRIBUTE_DOCSTRING) } + /// Return `true` if the model is in a `@no_type_check` context. + pub const fn in_no_type_check(&self) -> bool { + self.flags.intersects(SemanticModelFlags::NO_TYPE_CHECK) + } + /// Return `true` if the model has traversed past the "top-of-file" import boundary. pub const fn seen_import_boundary(&self) -> bool { self.flags.intersects(SemanticModelFlags::IMPORT_BOUNDARY) @@ -2477,6 +2482,23 @@ bitflags! { /// ``` const ASSERT_STATEMENT = 1 << 29; + /// The model is in a [`@no_type_check`] context. + /// + /// This is used to skip type checking when the `@no_type_check` decorator is found. + /// + /// For example (adapted from [#13824]): + /// ```python + /// from typing import no_type_check + /// + /// @no_type_check + /// def fn(arg: "A") -> "R": + /// pass + /// ``` + /// + /// [no_type_check]: https://docs.python.org/3/library/typing.html#typing.no_type_check + /// [#13824]: https://github.com/astral-sh/ruff/issues/13824 + const NO_TYPE_CHECK = 1 << 30; + /// The context is in any type annotation. const ANNOTATION = Self::TYPING_ONLY_ANNOTATION.bits() | Self::RUNTIME_EVALUATED_ANNOTATION.bits() | Self::RUNTIME_REQUIRED_ANNOTATION.bits();