Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[flake8-type-checking] Improve flexibility of runtime-evaluated-decorators #15204

Merged
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import fastapi
from fastapi import FastAPI as Api

if TYPE_CHECKING:
import datetime # TC004
from array import array # TC004

app = fastapi.FastAPI("First application")

class AppContainer:
app = Api("Second application")

app_container = AppContainer()

@app.put("/datetime")
def set_datetime(value: datetime.datetime):
pass

@app_container.app.get("/array")
def get_array() -> array:
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from __future__ import annotations

import pathlib # OK
from datetime import date # OK

from module.app import app, app_container

@app.get("/path")
def get_path() -> pathlib.Path:
pass

@app_container.app.put("/date")
def set_date(d: date):
pass
23 changes: 21 additions & 2 deletions crates/ruff_linter/src/rules/flake8_type_checking/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,31 @@ fn runtime_required_decorators(
}

decorator_list.iter().any(|decorator| {
let expression = map_callable(&decorator.expression);
semantic
.resolve_qualified_name(map_callable(&decorator.expression))
// First try to resolve the qualified name normally for cases like:
// ```python
// from mymodule import app
//
// @app.get(...)
// def test(): ...
// ```
.resolve_qualified_name(expression)
// If we can't resolve the name, then try resolving the assignment
// in order to support cases like:
// ```python
// from fastapi import FastAPI
//
// app = FastAPI()
//
// @app.get(...)
// def test(): ...
// ```
.or_else(|| analyze::typing::resolve_assignment(expression, semantic))
.is_some_and(|qualified_name| {
decorators
.iter()
.any(|base_class| QualifiedName::from_dotted_name(base_class) == qualified_name)
.any(|decorator| QualifiedName::from_dotted_name(decorator) == qualified_name)
})
})
}
Expand Down
27 changes: 27 additions & 0 deletions crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,33 @@ mod tests {
Ok(())
}

#[test_case(Rule::RuntimeImportInTypeCheckingBlock, Path::new("module/app.py"))]
#[test_case(Rule::TypingOnlyStandardLibraryImport, Path::new("module/routes.py"))]
fn decorator_same_file(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_decorators: vec![
"fastapi.FastAPI.get".to_string(),
"fastapi.FastAPI.put".to_string(),
"module.app.AppContainer.app.get".to_string(),
"module.app.AppContainer.app.put".to_string(),
"module.app.app.get".to_string(),
"module.app.app.put".to_string(),
"module.app.app_container.app.get".to_string(),
"module.app.app_container.app.put".to_string(),
],
..Default::default()
},
..settings::LinterSettings::for_rule(rule_code)
},
)?;
assert_messages!(snapshot, diagnostics);
Ok(())
}

#[test_case(
r"
from __future__ import annotations
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
---
app.py:9:12: TC004 [*] Move import `datetime` out of type-checking block. Import is used for more than type hinting.
|
8 | if TYPE_CHECKING:
9 | import datetime # TC004
| ^^^^^^^^ TC004
10 | from array import array # TC004
|
= help: Move out of type-checking block

Unsafe fix
4 4 |
5 5 | import fastapi
6 6 | from fastapi import FastAPI as Api
7 |+import datetime
7 8 |
8 9 | if TYPE_CHECKING:
9 |- import datetime # TC004
10 10 | from array import array # TC004
11 11 |
12 12 | app = fastapi.FastAPI("First application")

app.py:10:23: TC004 [*] Move import `array.array` out of type-checking block. Import is used for more than type hinting.
|
8 | if TYPE_CHECKING:
9 | import datetime # TC004
10 | from array import array # TC004
| ^^^^^ TC004
11 |
12 | app = fastapi.FastAPI("First application")
|
= help: Move out of type-checking block

Unsafe fix
4 4 |
5 5 | import fastapi
6 6 | from fastapi import FastAPI as Api
7 |+from array import array
7 8 |
8 9 | if TYPE_CHECKING:
9 10 | import datetime # TC004
10 |- from array import array # TC004
11 11 |
12 12 | app = fastapi.FastAPI("First application")
13 13 |
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/rules/flake8_type_checking/mod.rs
---

8 changes: 8 additions & 0 deletions crates/ruff_python_ast/src/name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,14 @@ impl<'a> QualifiedName<'a> {
inner.push(member);
Self(inner)
}

/// Extends the qualified name using the given members.
#[must_use]
pub fn extend_members<T: IntoIterator<Item = &'a str>>(self, members: T) -> Self {
let mut inner = self.0;
inner.extend(members);
Self(inner)
}
}

impl Display for QualifiedName<'_> {
Expand Down
1 change: 1 addition & 0 deletions crates/ruff_python_semantic/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ is-macro = { workspace = true }
rustc-hash = { workspace = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
smallvec = { workspace = true }

[dev-dependencies]
ruff_python_parser = { workspace = true }
Expand Down
35 changes: 28 additions & 7 deletions crates/ruff_python_semantic/src/analyze/typing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use ruff_python_stdlib::typing::{
is_standard_library_generic, is_standard_library_generic_member, is_standard_library_literal,
};
use ruff_text_size::Ranged;
use smallvec::{smallvec, SmallVec};

use crate::analyze::type_inference::{NumberLike, PythonType, ResolvedPythonType};
use crate::model::SemanticModel;
Expand Down Expand Up @@ -974,23 +975,43 @@ fn find_parameter<'a>(
/// ```
///
/// This function will return `["asyncio", "get_running_loop"]` for the `loop` binding.
///
/// This function will also automatically expand attribute accesses, so given:
/// ```python
/// from module import AppContainer
///
/// container = AppContainer()
/// container.app.get(...)
/// ```
///
/// This function will return `["module", "AppContainer", "app", "get"]` for the
/// attribute access `container.app.get`.
pub fn resolve_assignment<'a>(
expr: &'a Expr,
semantic: &'a SemanticModel<'a>,
) -> Option<QualifiedName<'a>> {
let name = expr.as_name_expr()?;
// Resolve any attribute chain.
let mut head_expr = expr;
let mut reversed_tail: SmallVec<[_; 4]> = smallvec![];
while let Expr::Attribute(ast::ExprAttribute { value, attr, .. }) = head_expr {
head_expr = value;
reversed_tail.push(attr.as_str());
}

// Resolve the left-most name, e.g. `foo` in `foo.bar.baz` to a qualified name,
// then append the attributes.
let name = head_expr.as_name_expr()?;
let binding_id = semantic.resolve_name(name)?;
let statement = semantic.binding(binding_id).statement(semantic)?;
match statement {
Stmt::Assign(ast::StmtAssign { value, .. }) => {
let ast::ExprCall { func, .. } = value.as_call_expr()?;
semantic.resolve_qualified_name(func)
}
Stmt::AnnAssign(ast::StmtAnnAssign {
Stmt::Assign(ast::StmtAssign { value, .. })
| Stmt::AnnAssign(ast::StmtAnnAssign {
value: Some(value), ..
}) => {
let ast::ExprCall { func, .. } = value.as_call_expr()?;
semantic.resolve_qualified_name(func)

let qualified_name = semantic.resolve_qualified_name(func)?;
Some(qualified_name.extend_members(reversed_tail.into_iter().rev()))
}
_ => None,
}
Expand Down
15 changes: 15 additions & 0 deletions crates/ruff_workspace/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1819,6 +1819,21 @@ pub struct Flake8TypeCheckingOptions {
///
/// Common examples include Pydantic's `@pydantic.validate_call` decorator
/// (for functions) and attrs' `@attrs.define` decorator (for classes).
///
/// This also supports framework decorators like FastAPI's `fastapi.FastAPI.get`
/// which will work across assignments in the same module.
///
/// For example:
/// ```python
/// import fastapi
///
/// app = FastAPI("app")
///
/// @app.get("/home")
/// def home() -> str: ...
/// ```
///
/// Here `app.get` will correctly be identified as `fastapi.FastAPI.get`.
#[option(
default = "[]",
value_type = "list[str]",
Expand Down
2 changes: 1 addition & 1 deletion ruff.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading