Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/fastapi/FAST003.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,38 @@ async def unknown_1(other: str = Depends(unknown_unresolved)): ...
async def unknown_2(other: str = Depends(unknown_not_function)): ...
@app.get("/things/{thing_id}")
async def unknown_3(other: str = Depends(unknown_imported)): ...


# Class dependencies
from pydantic import BaseModel
from dataclasses import dataclass

class PydanticParams(BaseModel):
my_id: int


class InitParams:
def __init__(self, my_id: int):
self.my_id = my_id


# Errors
@app.get("/{id}")
async def get_id_pydantic_full(
params: Annotated[PydanticParams, Depends(PydanticParams)],
): ...
@app.get("/{id}")
async def get_id_pydantic_short(params: Annotated[PydanticParams, Depends()]): ...
@app.get("/{id}")
async def get_id_init_not_annotated(params = Depends(InitParams)): ...


# No errors
@app.get("/{my_id}")
async def get_id_pydantic_full(
params: Annotated[PydanticParams, Depends(PydanticParams)],
): ...
@app.get("/{my_id}")
async def get_id_pydantic_short(params: Annotated[PydanticParams, Depends()]): ...
@app.get("/{my_id}")
async def get_id_init_not_annotated(params = Depends(InitParams)): ...
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,9 @@ enum Dependency<'a> {
/// A function defined in the same file, whose parameter names are as given.
Function(Vec<&'a str>),

/// A class defined in the same file, whose constructor parameter names are as given.
Class(Vec<&'a str>),

/// There are multiple `Depends()` calls.
///
/// Multiple `Depends` annotations aren't supported by fastapi and the exact behavior is
Expand All @@ -240,6 +243,7 @@ impl<'a> Dependency<'a> {
Self::Unknown => None,
Self::Multiple => None,
Self::Function(parameter_names) => Some(parameter_names.as_slice()),
Self::Class(parameter_names) => Some(parameter_names.as_slice()),
}
}
}
Expand Down Expand Up @@ -280,7 +284,14 @@ impl<'a> Dependency<'a> {
let mut dependencies = tuple.elts.iter().skip(1).filter_map(|metadata_element| {
let arguments = depends_arguments(metadata_element, semantic)?;

Self::from_depends_call(arguments, semantic)
// Arguments to `Depends` can be empty if the dependency is a class
// that FastAPI will call to create an instance of the class itself.
// https://fastapi.tiangolo.com/tutorial/dependencies/classes-as-dependencies/#shortcut
if arguments.is_empty() {
Self::from_dependency_name(tuple.elts.first()?.as_name_expr()?, semantic)
} else {
Self::from_depends_call(arguments, semantic)
}
});

let dependency = dependencies.next()?;
Expand All @@ -303,25 +314,68 @@ impl<'a> Dependency<'a> {
return None;
};

let Some(binding) = semantic.only_binding(name).map(|id| semantic.binding(id)) else {
return Some(Self::Unknown);
};
Self::from_dependency_name(name, semantic)
}

let BindingKind::FunctionDefinition(scope_id) = binding.kind else {
fn from_dependency_name(name: &'a ast::ExprName, semantic: &SemanticModel<'a>) -> Option<Self> {
let Some(binding) = semantic.only_binding(name).map(|id| semantic.binding(id)) else {
return Some(Self::Unknown);
};

let scope = &semantic.scopes[scope_id];
match binding.kind {
BindingKind::FunctionDefinition(scope_id) => {
let scope = &semantic.scopes[scope_id];

let ScopeKind::Function(function_def) = scope.kind else {
return Some(Self::Unknown);
};
let ScopeKind::Function(function_def) = scope.kind else {
return Some(Self::Unknown);
};

let parameter_names = non_posonly_non_variadic_parameters(function_def)
.map(|param| param.name().as_str())
.collect();
let parameter_names = non_posonly_non_variadic_parameters(function_def)
.map(|param| param.name().as_str())
.collect();

Some(Self::Function(parameter_names))
Some(Self::Function(parameter_names))
}
BindingKind::ClassDefinition(scope_id) => {
let scope = &semantic.scopes[scope_id];

let ScopeKind::Class(class_def) = scope.kind else {
return Some(Self::Unknown);
};

let parameter_names = if class_def
.bases()
.iter()
.any(|expr| is_pydantic_base_model(expr, semantic))
{
class_def
.body
.iter()
.filter_map(|stmt| {
stmt.as_ann_assign_stmt()
.and_then(|ann_assign| ann_assign.target.as_name_expr())
.map(|name| name.id.as_str())
})
.collect()
} else if let Some(init_def) = class_def
.body
.iter()
.filter_map(|stmt| stmt.as_function_def_stmt())
.find(|func_def| func_def.name.as_str() == "__init__")
{
// Skip `self` parameter
non_posonly_non_variadic_parameters(init_def)
.skip(1)
.map(|param| param.name().as_str())
.collect()
} else {
return None;
};

Some(Self::Class(parameter_names))
}
_ => Some(Self::Unknown),
}
}
}

Expand All @@ -341,11 +395,17 @@ fn depends_arguments<'a>(expr: &'a Expr, semantic: &SemanticModel) -> Option<&'a
}

fn is_fastapi_depends(expr: &Expr, semantic: &SemanticModel) -> bool {
let Some(qualified_name) = semantic.resolve_qualified_name(expr) else {
return false;
};
semantic
.resolve_qualified_name(expr)
.is_some_and(|qualified_name| matches!(qualified_name.segments(), ["fastapi", "Depends"]))
}

matches!(qualified_name.segments(), ["fastapi", "Depends"])
fn is_pydantic_base_model(expr: &Expr, semantic: &SemanticModel) -> bool {
semantic
.resolve_qualified_name(expr)
.is_some_and(|qualified_name| {
matches!(qualified_name.segments(), ["pydantic", "BaseModel"])
})
}

/// Extract the expected in-route name for a given parameter, if it has an alias.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -380,3 +380,64 @@ FAST003.py:160:19: FAST003 [*] Parameter `thing_id` appears in route path, but n
162 162 |
163 163 |
164 164 | ### No errors

FAST003.py:197:12: FAST003 [*] Parameter `id` appears in route path, but not in `get_id_pydantic_full` signature
|
196 | # Errors
197 | @app.get("/{id}")
| ^^^^ FAST003
198 | async def get_id_pydantic_full(
199 | params: Annotated[PydanticParams, Depends(PydanticParams)],
|
= help: Add `id` to function signature

ℹ Unsafe fix
196 196 | # Errors
197 197 | @app.get("/{id}")
198 198 | async def get_id_pydantic_full(
199 |- params: Annotated[PydanticParams, Depends(PydanticParams)],
199 |+ params: Annotated[PydanticParams, Depends(PydanticParams)], id,
200 200 | ): ...
201 201 | @app.get("/{id}")
202 202 | async def get_id_pydantic_short(params: Annotated[PydanticParams, Depends()]): ...

FAST003.py:201:12: FAST003 [*] Parameter `id` appears in route path, but not in `get_id_pydantic_short` signature
|
199 | params: Annotated[PydanticParams, Depends(PydanticParams)],
200 | ): ...
201 | @app.get("/{id}")
| ^^^^ FAST003
202 | async def get_id_pydantic_short(params: Annotated[PydanticParams, Depends()]): ...
203 | @app.get("/{id}")
|
= help: Add `id` to function signature

ℹ Unsafe fix
199 199 | params: Annotated[PydanticParams, Depends(PydanticParams)],
200 200 | ): ...
201 201 | @app.get("/{id}")
202 |-async def get_id_pydantic_short(params: Annotated[PydanticParams, Depends()]): ...
202 |+async def get_id_pydantic_short(params: Annotated[PydanticParams, Depends()], id): ...
203 203 | @app.get("/{id}")
204 204 | async def get_id_init_not_annotated(params = Depends(InitParams)): ...
205 205 |

FAST003.py:203:12: FAST003 [*] Parameter `id` appears in route path, but not in `get_id_init_not_annotated` signature
|
201 | @app.get("/{id}")
202 | async def get_id_pydantic_short(params: Annotated[PydanticParams, Depends()]): ...
203 | @app.get("/{id}")
| ^^^^ FAST003
204 | async def get_id_init_not_annotated(params = Depends(InitParams)): ...
|
= help: Add `id` to function signature

ℹ Unsafe fix
201 201 | @app.get("/{id}")
202 202 | async def get_id_pydantic_short(params: Annotated[PydanticParams, Depends()]): ...
203 203 | @app.get("/{id}")
204 |-async def get_id_init_not_annotated(params = Depends(InitParams)): ...
204 |+async def get_id_init_not_annotated(id, params = Depends(InitParams)): ...
205 205 |
206 206 |
207 207 | # No errors
Loading