diff --git a/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap b/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap index dc4fad00a75d7b..4f7b3c3be3b23a 100644 --- a/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap +++ b/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap @@ -303,6 +303,7 @@ linter.flake8_type_checking.exempt_modules = [ ] linter.flake8_type_checking.runtime_required_base_classes = [] linter.flake8_type_checking.runtime_required_decorators = [] +linter.flake8_type_checking.runtime_required_decorator_classes = [] linter.flake8_type_checking.quote_annotations = false linter.flake8_unused_arguments.ignore_variadic_names = false linter.isort.required_imports = [] diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/runtime_evaluated_decorator_classes_1.py b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/runtime_evaluated_decorator_classes_1.py new file mode 100644 index 00000000000000..566d9e2f5af562 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/runtime_evaluated_decorator_classes_1.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import fastapi +from fastapi import FastAPI as Api + +from example import DecoratingClass + +if TYPE_CHECKING: + import datetime # TC004 + from array import array # TC004 + + import pathlib # TC004 + + import pyproj + +app1 = fastapi.FastAPI("First application") +app2 = Api("Second application") + +decorating_instance = DecoratingClass() + +@app1.put("/datetime") +def set_datetime(value: datetime.datetime): + pass + +@app2.get("/array") +def get_array() -> array: + pass + +@decorating_instance.decorator +def foo(path: pathlib.Path) -> None: + pass + +@decorating_instance +def bar(arg: pyproj.Transformer) -> None: + pass + +@DecoratingClass +def baz(arg: pyproj.Transformer) -> None: + pass diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/runtime_evaluated_decorator_classes_2.py b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/runtime_evaluated_decorator_classes_2.py new file mode 100644 index 00000000000000..3c6e66ddfe06e0 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/runtime_evaluated_decorator_classes_2.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import pandas +import pyproj + +import fastapi +from fastapi import FastAPI as Api +from example import DecoratingClass + +import numpy # TC002 + +app1 = fastapi.FastAPI("First application") +app2 = Api("Second application") + +decorating_instance = DecoratingClass() + + +@app1.get("/transformer") +def get_transformer() -> pyproj.Transformer: + pass + +@app2.put("/dataframe") +def set_dataframe(df: pandas.DataFrame): + pass + +@decorating_instance +def foo(x: pandas.DataFrame): + pass + +@DecoratingClass +def bar(x: numpy.ndarray): + pass diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/runtime_evaluated_decorator_classes_3.py b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/runtime_evaluated_decorator_classes_3.py new file mode 100644 index 00000000000000..37cb5f277db30e --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_type_checking/runtime_evaluated_decorator_classes_3.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import pathlib + +import fastapi +from fastapi import FastAPI as Api +from example import DecoratingClass + +from uuid import UUID # TC003 + +app1 = fastapi.FastAPI("First application") +app2 = Api("Second application") + +decorating_instance = DecoratingClass() + + +@app1.get("/path") +def get_path() -> pathlib.Path: + pass + +@app2.put("/pure_path") +def set_pure_path(df: pathlib.PurePath): + pass + +@decorating_instance +def foo(x: pathlib.PosixPath): + pass + +@DecoratingClass +def bar(x: UUID): + pass diff --git a/crates/ruff_linter/src/checkers/ast/annotation.rs b/crates/ruff_linter/src/checkers/ast/annotation.rs index 86d1ba50f8d81e..4922828a4ef550 100644 --- a/crates/ruff_linter/src/checkers/ast/annotation.rs +++ b/crates/ruff_linter/src/checkers/ast/annotation.rs @@ -48,6 +48,9 @@ impl AnnotationContext { if flake8_type_checking::helpers::runtime_required_function( function_def, &settings.flake8_type_checking.runtime_required_decorators, + &settings + .flake8_type_checking + .runtime_required_decorator_classes, semantic, ) => { @@ -85,6 +88,9 @@ impl AnnotationContext { if flake8_type_checking::helpers::runtime_required_function( function_def, &settings.flake8_type_checking.runtime_required_decorators, + &settings + .flake8_type_checking + .runtime_required_decorator_classes, semantic, ) { Self::RuntimeRequired diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs b/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs index e87c3506e93b82..2e30fe5b01014b 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs @@ -7,6 +7,7 @@ use ruff_python_ast::visitor::transformer::{walk_expr, Transformer}; use ruff_python_ast::{self as ast, Decorator, Expr}; use ruff_python_codegen::{Generator, Stylist}; use ruff_python_parser::typing::parse_type_annotation; +use ruff_python_semantic::analyze::typing::resolve_assignment; use ruff_python_semantic::{ analyze, Binding, BindingKind, Modules, NodeId, ResolvedReference, ScopeKind, SemanticModel, }; @@ -51,11 +52,16 @@ pub(crate) fn is_valid_runtime_import( pub(crate) fn runtime_required_function( function_def: &ast::StmtFunctionDef, decorators: &[String], + decorator_classes: &[String], semantic: &SemanticModel, ) -> bool { if runtime_required_decorators(&function_def.decorator_list, decorators, semantic) { return true; } + if runtime_required_decorator_classes(&function_def.decorator_list, decorator_classes, semantic) + { + return true; + } false } @@ -108,6 +114,35 @@ fn runtime_required_decorators( }) } +fn runtime_required_decorator_classes( + decorator_list: &[Decorator], + decorator_classes: &[String], + semantic: &SemanticModel, +) -> bool { + if decorator_classes.is_empty() { + return false; + } + + decorator_list.iter().any(|decorator| { + let expression = if let ast::Expr::Call(ast::ExprCall { func, .. }) = &decorator.expression + { + func + } else { + &decorator.expression + }; + + let ast::Expr::Attribute(ast::ExprAttribute { value, .. }) = expression else { + return false; + }; + + resolve_assignment(value, semantic).is_some_and(|qualified_name| { + decorator_classes.iter().any(|decorator_class| { + QualifiedName::from_dotted_name(decorator_class) == qualified_name + }) + }) + }) +} + /// Returns `true` if an annotation will be inspected at runtime by the `dataclasses` module. /// /// Specifically, detects whether an annotation is to either `dataclasses.InitVar` or diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs b/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs index ab95fe3ac56fd9..f03203fa53ba49 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/mod.rs @@ -237,6 +237,37 @@ mod tests { Ok(()) } + #[test_case( + Rule::RuntimeImportInTypeCheckingBlock, + Path::new("runtime_evaluated_decorator_classes_1.py") + )] + #[test_case( + Rule::TypingOnlyThirdPartyImport, + Path::new("runtime_evaluated_decorator_classes_2.py") + )] + #[test_case( + Rule::TypingOnlyStandardLibraryImport, + Path::new("runtime_evaluated_decorator_classes_3.py") + )] + fn runtime_evaluated_decorator_classes(rule_code: Rule, path: &Path) -> Result<()> { + let snapshot = format!("{}_{}", rule_code.as_ref(), path.to_string_lossy()); + let diagnostics = test_path( + Path::new("flake8_type_checking").join(path).as_path(), + &settings::LinterSettings { + flake8_type_checking: super::settings::Settings { + runtime_required_decorator_classes: vec![ + "fastapi.FastAPI".to_string(), + "example.DecoratingClass".to_string(), + ], + ..Default::default() + }, + ..settings::LinterSettings::for_rule(rule_code) + }, + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) + } + #[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("module/direct.py"))] #[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("module/import.py"))] #[test_case( diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs b/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs index 79418145abdb2e..cc49b12ac57ca7 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/rules/typing_only_runtime_import.rs @@ -33,10 +33,11 @@ use crate::rules::isort::{categorize, ImportSection, ImportType}; /// corresponding import to be moved into an `if TYPE_CHECKING:` block. /// /// If a class _requires_ that type annotations be available at runtime (as is -/// the case for Pydantic, SQLAlchemy, and other libraries), consider using -/// the [`lint.flake8-type-checking.runtime-evaluated-base-classes`] and -/// [`lint.flake8-type-checking.runtime-evaluated-decorators`] settings to mark them -/// as such. +/// the case for Pydantic, SQLAlchemy, FastAPI, and other libraries), consider +/// using the [`lint.flake8-type-checking.runtime-evaluated-base-classes`], +/// [`lint.flake8-type-checking.runtime-evaluated-decorators`], and +/// [`lint.flake8-type-checking.runtime-evaluated-decorator-classes`] settings +/// to mark them as such. /// /// ## Example /// ```python @@ -108,10 +109,11 @@ impl Violation for TypingOnlyFirstPartyImport { /// corresponding import to be moved into an `if TYPE_CHECKING:` block. /// /// If a class _requires_ that type annotations be available at runtime (as is -/// the case for Pydantic, SQLAlchemy, and other libraries), consider using -/// the [`lint.flake8-type-checking.runtime-evaluated-base-classes`] and -/// [`lint.flake8-type-checking.runtime-evaluated-decorators`] settings to mark them -/// as such. +/// the case for Pydantic, SQLAlchemy, FastAPI, and other libraries), consider +/// using the [`lint.flake8-type-checking.runtime-evaluated-base-classes`], +/// [`lint.flake8-type-checking.runtime-evaluated-decorators`], and +/// [`lint.flake8-type-checking.runtime-evaluated-decorator-classes`] settings +/// to mark them as such. /// /// ## Example /// ```python @@ -146,7 +148,8 @@ impl Violation for TypingOnlyFirstPartyImport { /// - `lint.typing-modules` /// /// ## References -/// - [PEP 563: Runtime annotation resolution and `TYPE_CHECKING`](https://peps.python.org/pep-0563/#runtime-annotation-resolution-and-type-checking) +/// - [PEP 563: Runtime annotation resolution and +/// `TYPE_CHECKING`](https://peps.python.org/pep-0563/#runtime-annotation-resolution-and-type-checking) #[derive(ViolationMetadata)] pub(crate) struct TypingOnlyThirdPartyImport { qualified_name: String, @@ -183,10 +186,11 @@ impl Violation for TypingOnlyThirdPartyImport { /// corresponding import to be moved into an `if TYPE_CHECKING:` block. /// /// If a class _requires_ that type annotations be available at runtime (as is -/// the case for Pydantic, SQLAlchemy, and other libraries), consider using -/// the [`lint.flake8-type-checking.runtime-evaluated-base-classes`] and -/// [`lint.flake8-type-checking.runtime-evaluated-decorators`] settings to mark them -/// as such. +/// the case for Pydantic, SQLAlchemy, FastAPI, and other libraries), consider +/// using the [`lint.flake8-type-checking.runtime-evaluated-base-classes`], +/// [`lint.flake8-type-checking.runtime-evaluated-decorators`], and +/// [`lint.flake8-type-checking.runtime-evaluated-decorator-classes`] settings +/// to mark them as such. /// /// ## Example /// ```python diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/settings.rs b/crates/ruff_linter/src/rules/flake8_type_checking/settings.rs index 11f5f87e500cf8..836782c0c1b9bf 100644 --- a/crates/ruff_linter/src/rules/flake8_type_checking/settings.rs +++ b/crates/ruff_linter/src/rules/flake8_type_checking/settings.rs @@ -10,6 +10,7 @@ pub struct Settings { pub exempt_modules: Vec, pub runtime_required_base_classes: Vec, pub runtime_required_decorators: Vec, + pub runtime_required_decorator_classes: Vec, pub quote_annotations: bool, } @@ -20,6 +21,7 @@ impl Default for Settings { exempt_modules: vec!["typing".to_string(), "typing_extensions".to_string()], runtime_required_base_classes: vec![], runtime_required_decorators: vec![], + runtime_required_decorator_classes: vec![], quote_annotations: false, } } @@ -35,6 +37,7 @@ impl Display for Settings { self.exempt_modules | array, self.runtime_required_base_classes | array, self.runtime_required_decorators | array, + self.runtime_required_decorator_classes | array, self.quote_annotations ] } diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__runtime-import-in-type-checking-block_runtime_evaluated_decorator_classes_1.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__runtime-import-in-type-checking-block_runtime_evaluated_decorator_classes_1.py.snap new file mode 100644 index 00000000000000..1c5a0a0313e199 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__runtime-import-in-type-checking-block_runtime_evaluated_decorator_classes_1.py.snap @@ -0,0 +1,74 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +snapshot_kind: text +--- +runtime_evaluated_decorator_classes_1.py:11:12: TC004 [*] Move import `datetime` out of type-checking block. Import is used for more than type hinting. + | +10 | if TYPE_CHECKING: +11 | import datetime # TC004 + | ^^^^^^^^ TC004 +12 | from array import array # TC004 + | + = help: Move out of type-checking block + +ℹ Unsafe fix +6 6 | from fastapi import FastAPI as Api +7 7 | +8 8 | from example import DecoratingClass + 9 |+import datetime +9 10 | +10 11 | if TYPE_CHECKING: +11 |- import datetime # TC004 +12 12 | from array import array # TC004 +13 13 | +14 14 | import pathlib # TC004 + +runtime_evaluated_decorator_classes_1.py:12:23: TC004 [*] Move import `array.array` out of type-checking block. Import is used for more than type hinting. + | +10 | if TYPE_CHECKING: +11 | import datetime # TC004 +12 | from array import array # TC004 + | ^^^^^ TC004 +13 | +14 | import pathlib # TC004 + | + = help: Move out of type-checking block + +ℹ Unsafe fix +6 6 | from fastapi import FastAPI as Api +7 7 | +8 8 | from example import DecoratingClass + 9 |+from array import array +9 10 | +10 11 | if TYPE_CHECKING: +11 12 | import datetime # TC004 +12 |- from array import array # TC004 +13 13 | +14 14 | import pathlib # TC004 +15 15 | + +runtime_evaluated_decorator_classes_1.py:14:12: TC004 [*] Move import `pathlib` out of type-checking block. Import is used for more than type hinting. + | +12 | from array import array # TC004 +13 | +14 | import pathlib # TC004 + | ^^^^^^^ TC004 +15 | +16 | import pyproj + | + = help: Move out of type-checking block + +ℹ Unsafe fix +6 6 | from fastapi import FastAPI as Api +7 7 | +8 8 | from example import DecoratingClass + 9 |+import pathlib +9 10 | +10 11 | if TYPE_CHECKING: +11 12 | import datetime # TC004 +12 13 | from array import array # TC004 +13 14 | +14 |- import pathlib # TC004 +15 15 | +16 16 | import pyproj +17 17 | diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__typing-only-standard-library-import_runtime_evaluated_decorator_classes_3.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__typing-only-standard-library-import_runtime_evaluated_decorator_classes_3.py.snap new file mode 100644 index 00000000000000..620798bbbcc187 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__typing-only-standard-library-import_runtime_evaluated_decorator_classes_3.py.snap @@ -0,0 +1,27 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +snapshot_kind: text +--- +runtime_evaluated_decorator_classes_3.py:9:18: TC003 [*] Move standard library import `uuid.UUID` into a type-checking block + | + 7 | from example import DecoratingClass + 8 | + 9 | from uuid import UUID # TC003 + | ^^^^ TC003 +10 | +11 | app1 = fastapi.FastAPI("First application") + | + = help: Move into type-checking block + +ℹ Unsafe fix +6 6 | from fastapi import FastAPI as Api +7 7 | from example import DecoratingClass +8 8 | +9 |-from uuid import UUID # TC003 + 9 |+from typing import TYPE_CHECKING + 10 |+ + 11 |+if TYPE_CHECKING: + 12 |+ from uuid import UUID +10 13 | +11 14 | app1 = fastapi.FastAPI("First application") +12 15 | app2 = Api("Second application") diff --git a/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__typing-only-third-party-import_runtime_evaluated_decorator_classes_2.py.snap b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__typing-only-third-party-import_runtime_evaluated_decorator_classes_2.py.snap new file mode 100644 index 00000000000000..c5aaa8e15103ac --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_type_checking/snapshots/ruff_linter__rules__flake8_type_checking__tests__typing-only-third-party-import_runtime_evaluated_decorator_classes_2.py.snap @@ -0,0 +1,27 @@ +--- +source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs +snapshot_kind: text +--- +runtime_evaluated_decorator_classes_2.py:10:8: TC002 [*] Move third-party import `numpy` into a type-checking block + | + 8 | from example import DecoratingClass + 9 | +10 | import numpy # TC002 + | ^^^^^ TC002 +11 | +12 | app1 = fastapi.FastAPI("First application") + | + = help: Move into type-checking block + +ℹ Unsafe fix +7 7 | from fastapi import FastAPI as Api +8 8 | from example import DecoratingClass +9 9 | +10 |-import numpy # TC002 + 10 |+from typing import TYPE_CHECKING + 11 |+ + 12 |+if TYPE_CHECKING: + 13 |+ import numpy +11 14 | +12 15 | app1 = fastapi.FastAPI("First application") +13 16 | app2 = Api("Second application") diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index 0d9fc739c557cf..76f3f07f8b6ae7 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -1828,6 +1828,19 @@ pub struct Flake8TypeCheckingOptions { )] pub runtime_evaluated_decorators: Option>, + /// Exempt classes and functions decorated with methods of any of the enumerated + /// decorator classes from being moved into type-checking blocks. + /// + /// Common examples include FastAPI's `fastapi.FastAPI` and `fastapi.Router`. + #[option( + default = "[]", + value_type = "list[str]", + example = r#" + runtime-evaluated-decorator_classes = ["fastapi.FastAPI", "fastapi.Router"] + "# + )] + pub runtime_evaluated_decorator_classes: Option>, + /// Whether to add quotes around type annotations, if doing so would allow /// the corresponding import to be moved into a type-checking block. /// @@ -1889,6 +1902,9 @@ impl Flake8TypeCheckingOptions { .unwrap_or_else(|| vec!["typing".to_string()]), runtime_required_base_classes: self.runtime_evaluated_base_classes.unwrap_or_default(), runtime_required_decorators: self.runtime_evaluated_decorators.unwrap_or_default(), + runtime_required_decorator_classes: self + .runtime_evaluated_decorator_classes + .unwrap_or_default(), quote_annotations: self.quote_annotations.unwrap_or_default(), } } diff --git a/ruff.schema.json b/ruff.schema.json index a55f658c9741eb..692481c3194d8e 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -1386,6 +1386,16 @@ "type": "string" } }, + "runtime-evaluated-decorator-classes": { + "description": "Exempt classes and functions decorated with methods of any of the enumerated decorator classes from being moved into type-checking blocks.\n\nCommon examples include FastAPI's `fastapi.FastAPI` and `fastapi.Router`.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "runtime-evaluated-decorators": { "description": "Exempt classes and functions decorated with any of the enumerated decorators from being moved into type-checking blocks.\n\nCommon examples include Pydantic's `@pydantic.validate_call` decorator (for functions) and attrs' `@attrs.define` decorator (for classes).", "type": [