Skip to content

Commit df694ca

Browse files
authored
[FastAPI] Avoid introducing invalid syntax in fix for fast-api-non-annotated-dependency (FAST002) (#13133)
1 parent 2e75cfb commit df694ca

File tree

3 files changed

+187
-57
lines changed

3 files changed

+187
-57
lines changed

crates/ruff_linter/resources/test/fixtures/fastapi/FAST002.py

+29-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
router = APIRouter()
1818

1919

20-
# Errors
20+
# Fixable errors
2121

2222
@app.get("/items/")
2323
def get_items(
@@ -40,6 +40,34 @@ def do_stuff(
4040
# do stuff
4141
pass
4242

43+
@app.get("/users/")
44+
def get_users(
45+
skip: int,
46+
limit: int,
47+
current_user: User = Depends(get_current_user),
48+
):
49+
pass
50+
51+
@app.get("/users/")
52+
def get_users(
53+
current_user: User = Depends(get_current_user),
54+
skip: int = 0,
55+
limit: int = 10,
56+
):
57+
pass
58+
59+
60+
61+
# Non fixable errors
62+
63+
@app.get("/users/")
64+
def get_users(
65+
skip: int = 0,
66+
limit: int = 10,
67+
current_user: User = Depends(get_current_user),
68+
):
69+
pass
70+
4371

4472
# Unchanged
4573

Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
1+
use ruff_diagnostics::{Diagnostic, Edit, Fix, FixAvailability, Violation};
22
use ruff_macros::{derive_message_formats, violation};
33
use ruff_python_ast as ast;
44
use ruff_python_ast::helpers::map_callable;
@@ -59,14 +59,16 @@ use crate::settings::types::PythonVersion;
5959
#[violation]
6060
pub struct FastApiNonAnnotatedDependency;
6161

62-
impl AlwaysFixableViolation for FastApiNonAnnotatedDependency {
62+
impl Violation for FastApiNonAnnotatedDependency {
63+
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;
64+
6365
#[derive_message_formats]
6466
fn message(&self) -> String {
6567
format!("FastAPI dependency without `Annotated`")
6668
}
6769

68-
fn fix_title(&self) -> String {
69-
"Replace with `Annotated`".to_string()
70+
fn fix_title(&self) -> Option<String> {
71+
Some("Replace with `Annotated`".to_string())
7072
}
7173
}
7274

@@ -75,64 +77,95 @@ pub(crate) fn fastapi_non_annotated_dependency(
7577
checker: &mut Checker,
7678
function_def: &ast::StmtFunctionDef,
7779
) {
78-
if !checker.semantic().seen_module(Modules::FASTAPI) {
79-
return;
80-
}
81-
if !is_fastapi_route(function_def, checker.semantic()) {
80+
if !checker.semantic().seen_module(Modules::FASTAPI)
81+
|| !is_fastapi_route(function_def, checker.semantic())
82+
{
8283
return;
8384
}
85+
86+
let mut updatable_count = 0;
87+
let mut has_non_updatable_default = false;
88+
let total_params = function_def.parameters.args.len();
89+
8490
for parameter in &function_def.parameters.args {
91+
let needs_update = matches!(
92+
(&parameter.parameter.annotation, &parameter.default),
93+
(Some(_annotation), Some(default)) if is_fastapi_dependency(checker, default)
94+
);
95+
96+
if needs_update {
97+
updatable_count += 1;
98+
// Determine if it's safe to update this parameter:
99+
// - if all parameters are updatable its safe.
100+
// - if we've encountered a non-updatable parameter with a default value, it's no longer
101+
// safe. (https://github.com/astral-sh/ruff/issues/12982)
102+
let safe_to_update = updatable_count == total_params || !has_non_updatable_default;
103+
create_diagnostic(checker, parameter, safe_to_update);
104+
} else if parameter.default.is_some() {
105+
has_non_updatable_default = true;
106+
}
107+
}
108+
}
109+
110+
fn is_fastapi_dependency(checker: &Checker, expr: &ast::Expr) -> bool {
111+
checker
112+
.semantic()
113+
.resolve_qualified_name(map_callable(expr))
114+
.is_some_and(|qualified_name| {
115+
matches!(
116+
qualified_name.segments(),
117+
[
118+
"fastapi",
119+
"Query"
120+
| "Path"
121+
| "Body"
122+
| "Cookie"
123+
| "Header"
124+
| "File"
125+
| "Form"
126+
| "Depends"
127+
| "Security"
128+
]
129+
)
130+
})
131+
}
132+
133+
fn create_diagnostic(
134+
checker: &mut Checker,
135+
parameter: &ast::ParameterWithDefault,
136+
safe_to_update: bool,
137+
) {
138+
let mut diagnostic = Diagnostic::new(FastApiNonAnnotatedDependency, parameter.range);
139+
140+
if safe_to_update {
85141
if let (Some(annotation), Some(default)) =
86142
(&parameter.parameter.annotation, &parameter.default)
87143
{
88-
if checker
89-
.semantic()
90-
.resolve_qualified_name(map_callable(default))
91-
.is_some_and(|qualified_name| {
92-
matches!(
93-
qualified_name.segments(),
94-
[
95-
"fastapi",
96-
"Query"
97-
| "Path"
98-
| "Body"
99-
| "Cookie"
100-
| "Header"
101-
| "File"
102-
| "Form"
103-
| "Depends"
104-
| "Security"
105-
]
106-
)
107-
})
108-
{
109-
let mut diagnostic =
110-
Diagnostic::new(FastApiNonAnnotatedDependency, parameter.range);
111-
112-
diagnostic.try_set_fix(|| {
113-
let module = if checker.settings.target_version >= PythonVersion::Py39 {
114-
"typing"
115-
} else {
116-
"typing_extensions"
117-
};
118-
let (import_edit, binding) = checker.importer().get_or_import_symbol(
119-
&ImportRequest::import_from(module, "Annotated"),
120-
function_def.start(),
121-
checker.semantic(),
122-
)?;
123-
let content = format!(
124-
"{}: {}[{}, {}]",
125-
parameter.parameter.name.id,
126-
binding,
127-
checker.locator().slice(annotation.range()),
128-
checker.locator().slice(default.range())
129-
);
130-
let parameter_edit = Edit::range_replacement(content, parameter.range());
131-
Ok(Fix::unsafe_edits(import_edit, [parameter_edit]))
132-
});
133-
134-
checker.diagnostics.push(diagnostic);
135-
}
144+
diagnostic.try_set_fix(|| {
145+
let module = if checker.settings.target_version >= PythonVersion::Py39 {
146+
"typing"
147+
} else {
148+
"typing_extensions"
149+
};
150+
let (import_edit, binding) = checker.importer().get_or_import_symbol(
151+
&ImportRequest::import_from(module, "Annotated"),
152+
parameter.range.start(),
153+
checker.semantic(),
154+
)?;
155+
let content = format!(
156+
"{}: {}[{}, {}]",
157+
parameter.parameter.name.id,
158+
binding,
159+
checker.locator().slice(annotation.range()),
160+
checker.locator().slice(default.range())
161+
);
162+
let parameter_edit = Edit::range_replacement(content, parameter.range);
163+
Ok(Fix::unsafe_edits(import_edit, [parameter_edit]))
164+
});
136165
}
166+
} else {
167+
diagnostic.fix = None;
137168
}
169+
170+
checker.diagnostics.push(diagnostic);
138171
}

crates/ruff_linter/src/rules/fastapi/snapshots/ruff_linter__rules__fastapi__tests__fast-api-non-annotated-dependency_FAST002.py.snap

+69
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,72 @@ FAST002.py:38:5: FAST002 [*] FastAPI dependency without `Annotated`
261261
39 40 | ):
262262
40 41 | # do stuff
263263
41 42 | pass
264+
265+
FAST002.py:47:5: FAST002 [*] FastAPI dependency without `Annotated`
266+
|
267+
45 | skip: int,
268+
46 | limit: int,
269+
47 | current_user: User = Depends(get_current_user),
270+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FAST002
271+
48 | ):
272+
49 | pass
273+
|
274+
= help: Replace with `Annotated`
275+
276+
Unsafe fix
277+
12 12 | Security,
278+
13 13 | )
279+
14 14 | from pydantic import BaseModel
280+
15 |+from typing import Annotated
281+
15 16 |
282+
16 17 | app = FastAPI()
283+
17 18 | router = APIRouter()
284+
--------------------------------------------------------------------------------
285+
44 45 | def get_users(
286+
45 46 | skip: int,
287+
46 47 | limit: int,
288+
47 |- current_user: User = Depends(get_current_user),
289+
48 |+ current_user: Annotated[User, Depends(get_current_user)],
290+
48 49 | ):
291+
49 50 | pass
292+
50 51 |
293+
294+
FAST002.py:53:5: FAST002 [*] FastAPI dependency without `Annotated`
295+
|
296+
51 | @app.get("/users/")
297+
52 | def get_users(
298+
53 | current_user: User = Depends(get_current_user),
299+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FAST002
300+
54 | skip: int = 0,
301+
55 | limit: int = 10,
302+
|
303+
= help: Replace with `Annotated`
304+
305+
Unsafe fix
306+
12 12 | Security,
307+
13 13 | )
308+
14 14 | from pydantic import BaseModel
309+
15 |+from typing import Annotated
310+
15 16 |
311+
16 17 | app = FastAPI()
312+
17 18 | router = APIRouter()
313+
--------------------------------------------------------------------------------
314+
50 51 |
315+
51 52 | @app.get("/users/")
316+
52 53 | def get_users(
317+
53 |- current_user: User = Depends(get_current_user),
318+
54 |+ current_user: Annotated[User, Depends(get_current_user)],
319+
54 55 | skip: int = 0,
320+
55 56 | limit: int = 10,
321+
56 57 | ):
322+
323+
FAST002.py:67:5: FAST002 FastAPI dependency without `Annotated`
324+
|
325+
65 | skip: int = 0,
326+
66 | limit: int = 10,
327+
67 | current_user: User = Depends(get_current_user),
328+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FAST002
329+
68 | ):
330+
69 | pass
331+
|
332+
= help: Replace with `Annotated`

0 commit comments

Comments
 (0)