mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-16 00:20:22 +00:00
[fastapi
] Implement FAST001
(fastapi-redundant-response-model
) and FAST002
(fastapi-non-annotated-dependency
) (#11579)
## Summary Implements ruff specific role for fastapi routes, and its autofix. ## Test Plan `cargo test` / `cargo insta review`
This commit is contained in:
parent
82355712c3
commit
053243635c
14 changed files with 1009 additions and 5 deletions
110
crates/ruff_linter/resources/test/fixtures/fastapi/FAST001.py
vendored
Normal file
110
crates/ruff_linter/resources/test/fixtures/fastapi/FAST001.py
vendored
Normal file
|
@ -0,0 +1,110 @@
|
|||
from typing import List, Dict
|
||||
|
||||
from fastapi import FastAPI, APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
# Errors
|
||||
|
||||
|
||||
@app.post("/items/", response_model=Item)
|
||||
async def create_item(item: Item) -> Item:
|
||||
return item
|
||||
|
||||
|
||||
@app.post("/items/", response_model=list[Item])
|
||||
async def create_item(item: Item) -> list[Item]:
|
||||
return item
|
||||
|
||||
|
||||
@app.post("/items/", response_model=List[Item])
|
||||
async def create_item(item: Item) -> List[Item]:
|
||||
return item
|
||||
|
||||
|
||||
@app.post("/items/", response_model=Dict[str, Item])
|
||||
async def create_item(item: Item) -> Dict[str, Item]:
|
||||
return item
|
||||
|
||||
|
||||
@app.post("/items/", response_model=str)
|
||||
async def create_item(item: Item) -> str:
|
||||
return item
|
||||
|
||||
|
||||
@app.get("/items/", response_model=Item)
|
||||
async def create_item(item: Item) -> Item:
|
||||
return item
|
||||
|
||||
|
||||
@app.get("/items/", response_model=Item)
|
||||
@app.post("/items/", response_model=Item)
|
||||
async def create_item(item: Item) -> Item:
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/items/", response_model=Item)
|
||||
async def create_item(item: Item) -> Item:
|
||||
return item
|
||||
|
||||
|
||||
# OK
|
||||
|
||||
|
||||
async def create_item(item: Item) -> Item:
|
||||
return item
|
||||
|
||||
|
||||
@app("/items/", response_model=Item)
|
||||
async def create_item(item: Item) -> Item:
|
||||
return item
|
||||
|
||||
|
||||
@cache
|
||||
async def create_item(item: Item) -> Item:
|
||||
return item
|
||||
|
||||
|
||||
@app.post("/items/", response_model=str)
|
||||
async def create_item(item: Item) -> Item:
|
||||
return item
|
||||
|
||||
|
||||
@app.post("/items/")
|
||||
async def create_item(item: Item) -> Item:
|
||||
return item
|
||||
|
||||
|
||||
@app.post("/items/", response_model=str)
|
||||
async def create_item(item: Item):
|
||||
return item
|
||||
|
||||
|
||||
@app.post("/items/", response_model=list[str])
|
||||
async def create_item(item: Item) -> Dict[str, Item]:
|
||||
return item
|
||||
|
||||
|
||||
@app.post("/items/", response_model=list[str])
|
||||
async def create_item(item: Item) -> list[str, str]:
|
||||
return item
|
||||
|
||||
|
||||
@app.post("/items/", response_model=Dict[str, int])
|
||||
async def create_item(item: Item) -> Dict[str, str]:
|
||||
return item
|
||||
|
||||
|
||||
app = None
|
||||
|
||||
|
||||
@app.post("/items/", response_model=Item)
|
||||
async def create_item(item: Item) -> Item:
|
||||
return item
|
68
crates/ruff_linter/resources/test/fixtures/fastapi/FAST002.py
vendored
Normal file
68
crates/ruff_linter/resources/test/fixtures/fastapi/FAST002.py
vendored
Normal file
|
@ -0,0 +1,68 @@
|
|||
from fastapi import (
|
||||
FastAPI,
|
||||
APIRouter,
|
||||
Query,
|
||||
Path,
|
||||
Body,
|
||||
Cookie,
|
||||
Header,
|
||||
File,
|
||||
Form,
|
||||
Depends,
|
||||
Security,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Errors
|
||||
|
||||
@app.get("/items/")
|
||||
def get_items(
|
||||
current_user: User = Depends(get_current_user),
|
||||
some_security_param: str = Security(get_oauth2_user),
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
@app.post("/stuff/")
|
||||
def do_stuff(
|
||||
some_query_param: str | None = Query(default=None),
|
||||
some_path_param: str = Path(),
|
||||
some_body_param: str = Body("foo"),
|
||||
some_cookie_param: str = Cookie(),
|
||||
some_header_param: int = Header(default=5),
|
||||
some_file_param: UploadFile = File(),
|
||||
some_form_param: str = Form(),
|
||||
):
|
||||
# do stuff
|
||||
pass
|
||||
|
||||
|
||||
# Unchanged
|
||||
|
||||
|
||||
@app.post("/stuff/")
|
||||
def do_stuff(
|
||||
no_default: Body("foo"),
|
||||
no_type_annotation=str,
|
||||
no_fastapi_default: str = BaseModel(),
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
# OK
|
||||
|
||||
@app.post("/stuff/")
|
||||
def do_stuff(
|
||||
some_path_param: Annotated[str, Path()],
|
||||
some_cookie_param: Annotated[str, Cookie()],
|
||||
some_file_param: Annotated[UploadFile, File()],
|
||||
some_form_param: Annotated[str, Form()],
|
||||
some_query_param: Annotated[str | None, Query()] = None,
|
||||
some_body_param: Annotated[str, Body()] = "foo",
|
||||
some_header_param: Annotated[int, Header()] = 5,
|
||||
):
|
||||
pass
|
|
@ -8,11 +8,11 @@ use ruff_text_size::Ranged;
|
|||
use crate::checkers::ast::Checker;
|
||||
use crate::registry::Rule;
|
||||
use crate::rules::{
|
||||
airflow, flake8_async, flake8_bandit, flake8_boolean_trap, flake8_bugbear, flake8_builtins,
|
||||
flake8_debugger, flake8_django, flake8_errmsg, flake8_import_conventions, flake8_pie,
|
||||
flake8_pyi, flake8_pytest_style, flake8_raise, flake8_return, flake8_simplify, flake8_slots,
|
||||
flake8_tidy_imports, flake8_type_checking, mccabe, pandas_vet, pep8_naming, perflint,
|
||||
pycodestyle, pyflakes, pygrep_hooks, pylint, pyupgrade, refurb, ruff, tryceratops,
|
||||
airflow, fastapi, flake8_async, flake8_bandit, flake8_boolean_trap, flake8_bugbear,
|
||||
flake8_builtins, flake8_debugger, flake8_django, flake8_errmsg, flake8_import_conventions,
|
||||
flake8_pie, flake8_pyi, flake8_pytest_style, flake8_raise, flake8_return, flake8_simplify,
|
||||
flake8_slots, flake8_tidy_imports, flake8_type_checking, mccabe, pandas_vet, pep8_naming,
|
||||
perflint, pycodestyle, pyflakes, pygrep_hooks, pylint, pyupgrade, refurb, ruff, tryceratops,
|
||||
};
|
||||
use crate::settings::types::PythonVersion;
|
||||
|
||||
|
@ -88,6 +88,12 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
|
|||
if checker.enabled(Rule::DjangoNonLeadingReceiverDecorator) {
|
||||
flake8_django::rules::non_leading_receiver_decorator(checker, decorator_list);
|
||||
}
|
||||
if checker.enabled(Rule::FastApiRedundantResponseModel) {
|
||||
fastapi::rules::fastapi_redundant_response_model(checker, function_def);
|
||||
}
|
||||
if checker.enabled(Rule::FastApiNonAnnotatedDependency) {
|
||||
fastapi::rules::fastapi_non_annotated_dependency(checker, function_def);
|
||||
}
|
||||
if checker.enabled(Rule::AmbiguousFunctionName) {
|
||||
if let Some(diagnostic) = pycodestyle::rules::ambiguous_function_name(name) {
|
||||
checker.diagnostics.push(diagnostic);
|
||||
|
|
|
@ -912,6 +912,10 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
|
|||
(Numpy, "003") => (RuleGroup::Stable, rules::numpy::rules::NumpyDeprecatedFunction),
|
||||
(Numpy, "201") => (RuleGroup::Stable, rules::numpy::rules::Numpy2Deprecation),
|
||||
|
||||
// fastapi
|
||||
(FastApi, "001") => (RuleGroup::Preview, rules::fastapi::rules::FastApiRedundantResponseModel),
|
||||
(FastApi, "002") => (RuleGroup::Preview, rules::fastapi::rules::FastApiNonAnnotatedDependency),
|
||||
|
||||
// pydoclint
|
||||
(Pydoclint, "501") => (RuleGroup::Preview, rules::pydoclint::rules::DocstringMissingException),
|
||||
(Pydoclint, "502") => (RuleGroup::Preview, rules::pydoclint::rules::DocstringExtraneousException),
|
||||
|
@ -947,6 +951,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
|
|||
(Ruff, "030") => (RuleGroup::Preview, rules::ruff::rules::AssertWithPrintMessage),
|
||||
(Ruff, "100") => (RuleGroup::Stable, rules::ruff::rules::UnusedNOQA),
|
||||
(Ruff, "101") => (RuleGroup::Preview, rules::ruff::rules::RedirectedNOQA),
|
||||
|
||||
(Ruff, "200") => (RuleGroup::Stable, rules::ruff::rules::InvalidPyprojectToml),
|
||||
#[cfg(any(feature = "test-rules", test))]
|
||||
(Ruff, "900") => (RuleGroup::Stable, rules::ruff::rules::StableTestRule),
|
||||
|
|
|
@ -193,6 +193,9 @@ pub enum Linter {
|
|||
/// NumPy-specific rules
|
||||
#[prefix = "NPY"]
|
||||
Numpy,
|
||||
/// [FastAPI](https://pypi.org/project/fastapi/)
|
||||
#[prefix = "FAST"]
|
||||
FastApi,
|
||||
/// [Airflow](https://pypi.org/project/apache-airflow/)
|
||||
#[prefix = "AIR"]
|
||||
Airflow,
|
||||
|
|
27
crates/ruff_linter/src/rules/fastapi/mod.rs
Normal file
27
crates/ruff_linter/src/rules/fastapi/mod.rs
Normal file
|
@ -0,0 +1,27 @@
|
|||
//! FastAPI-specific rules.
|
||||
pub(crate) mod rules;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::convert::AsRef;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use test_case::test_case;
|
||||
|
||||
use crate::registry::Rule;
|
||||
use crate::test::test_path;
|
||||
use crate::{assert_messages, settings};
|
||||
|
||||
#[test_case(Rule::FastApiRedundantResponseModel, Path::new("FAST001.py"))]
|
||||
#[test_case(Rule::FastApiNonAnnotatedDependency, Path::new("FAST002.py"))]
|
||||
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
|
||||
let snapshot = format!("{}_{}", rule_code.as_ref(), path.to_string_lossy());
|
||||
let diagnostics = test_path(
|
||||
Path::new("fastapi").join(path).as_path(),
|
||||
&settings::LinterSettings::for_rule(rule_code),
|
||||
)?;
|
||||
assert_messages!(snapshot, diagnostics);
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
|
||||
use ruff_macros::{derive_message_formats, violation};
|
||||
use ruff_python_ast as ast;
|
||||
use ruff_python_ast::helpers::map_callable;
|
||||
use ruff_python_semantic::Modules;
|
||||
use ruff_text_size::Ranged;
|
||||
|
||||
use crate::checkers::ast::Checker;
|
||||
use crate::importer::ImportRequest;
|
||||
use crate::rules::fastapi::rules::is_fastapi_route;
|
||||
use crate::settings::types::PythonVersion;
|
||||
|
||||
/// ## What it does
|
||||
/// Identifies FastAPI routes with deprecated uses of `Depends`.
|
||||
///
|
||||
/// ## Why is this bad?
|
||||
/// The FastAPI documentation recommends the use of `Annotated` for defining
|
||||
/// route dependencies and parameters, rather than using `Depends` directly
|
||||
/// with a default value.
|
||||
///
|
||||
/// This approach is also suggested for various route parameters, including Body and Cookie, as it helps ensure consistency and clarity in defining dependencies and parameters.
|
||||
///
|
||||
/// ## Example
|
||||
///
|
||||
/// ```python
|
||||
/// from fastapi import Depends, FastAPI
|
||||
///
|
||||
/// app = FastAPI()
|
||||
///
|
||||
///
|
||||
/// async def common_parameters(q: str | None = None, skip: int = 0, limit: int = 100):
|
||||
/// return {"q": q, "skip": skip, "limit": limit}
|
||||
///
|
||||
///
|
||||
/// @app.get("/items/")
|
||||
/// async def read_items(commons: dict = Depends(common_parameters)):
|
||||
/// return commons
|
||||
/// ```
|
||||
///
|
||||
/// Use instead:
|
||||
///
|
||||
/// ```python
|
||||
/// from typing import Annotated
|
||||
///
|
||||
/// from fastapi import Depends, FastAPI
|
||||
///
|
||||
/// app = FastAPI()
|
||||
///
|
||||
///
|
||||
/// async def common_parameters(q: str | None = None, skip: int = 0, limit: int = 100):
|
||||
/// return {"q": q, "skip": skip, "limit": limit}
|
||||
///
|
||||
///
|
||||
/// @app.get("/items/")
|
||||
/// async def read_items(commons: Annotated[dict, Depends(common_parameters)]):
|
||||
/// return commons
|
||||
/// ```
|
||||
|
||||
#[violation]
|
||||
pub struct FastApiNonAnnotatedDependency;
|
||||
|
||||
impl AlwaysFixableViolation for FastApiNonAnnotatedDependency {
|
||||
#[derive_message_formats]
|
||||
fn message(&self) -> String {
|
||||
format!("FastAPI dependency without `Annotated`")
|
||||
}
|
||||
|
||||
fn fix_title(&self) -> String {
|
||||
"Replace with `Annotated`".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// RUF103
|
||||
pub(crate) fn fastapi_non_annotated_dependency(
|
||||
checker: &mut Checker,
|
||||
function_def: &ast::StmtFunctionDef,
|
||||
) {
|
||||
if !checker.semantic().seen_module(Modules::FASTAPI) {
|
||||
return;
|
||||
}
|
||||
if !is_fastapi_route(function_def, checker.semantic()) {
|
||||
return;
|
||||
}
|
||||
for parameter in &function_def.parameters.args {
|
||||
if let (Some(annotation), Some(default)) =
|
||||
(¶meter.parameter.annotation, ¶meter.default)
|
||||
{
|
||||
if checker
|
||||
.semantic()
|
||||
.resolve_qualified_name(map_callable(default))
|
||||
.is_some_and(|qualified_name| {
|
||||
matches!(
|
||||
qualified_name.segments(),
|
||||
[
|
||||
"fastapi",
|
||||
"Query"
|
||||
| "Path"
|
||||
| "Body"
|
||||
| "Cookie"
|
||||
| "Header"
|
||||
| "File"
|
||||
| "Form"
|
||||
| "Depends"
|
||||
| "Security"
|
||||
]
|
||||
)
|
||||
})
|
||||
{
|
||||
let mut diagnostic =
|
||||
Diagnostic::new(FastApiNonAnnotatedDependency, parameter.range);
|
||||
|
||||
diagnostic.try_set_fix(|| {
|
||||
let module = if checker.settings.target_version >= PythonVersion::Py39 {
|
||||
"typing"
|
||||
} else {
|
||||
"typing_extensions"
|
||||
};
|
||||
let (import_edit, binding) = checker.importer().get_or_import_symbol(
|
||||
&ImportRequest::import_from(module, "Annotated"),
|
||||
function_def.start(),
|
||||
checker.semantic(),
|
||||
)?;
|
||||
let content = format!(
|
||||
"{}: {}[{}, {}]",
|
||||
parameter.parameter.name.id,
|
||||
binding,
|
||||
checker.locator().slice(annotation.range()),
|
||||
checker.locator().slice(default.range())
|
||||
);
|
||||
let parameter_edit = Edit::range_replacement(content, parameter.range());
|
||||
Ok(Fix::unsafe_edits(import_edit, [parameter_edit]))
|
||||
});
|
||||
|
||||
checker.diagnostics.push(diagnostic);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,158 @@
|
|||
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Fix};
|
||||
use ruff_macros::{derive_message_formats, violation};
|
||||
use ruff_python_ast::{Decorator, Expr, ExprCall, Keyword, StmtFunctionDef};
|
||||
use ruff_python_semantic::{Modules, SemanticModel};
|
||||
use ruff_text_size::Ranged;
|
||||
|
||||
use crate::checkers::ast::Checker;
|
||||
use crate::fix::edits::{remove_argument, Parentheses};
|
||||
use crate::rules::fastapi::rules::is_fastapi_route_decorator;
|
||||
|
||||
/// ## What it does
|
||||
/// Checks for FastAPI routes that use the optional `response_model` parameter
|
||||
/// with the same type as the return type.
|
||||
///
|
||||
/// ## Why is this bad?
|
||||
/// FastAPI routes automatically infer the response model type from the return
|
||||
/// type, so specifying it explicitly is redundant.
|
||||
///
|
||||
/// The `response_model` parameter is used to override the default response
|
||||
/// model type. For example, `response_model` can be used to specify that
|
||||
/// a non-serializable response type should instead be serialized via an
|
||||
/// alternative type.
|
||||
///
|
||||
/// For more information, see the [FastAPI documentation](https://fastapi.tiangolo.com/tutorial/response-model/).
|
||||
///
|
||||
/// ## Example
|
||||
///
|
||||
/// ```python
|
||||
/// from fastapi import FastAPI
|
||||
/// from pydantic import BaseModel
|
||||
///
|
||||
/// app = FastAPI()
|
||||
///
|
||||
///
|
||||
/// class Item(BaseModel):
|
||||
/// name: str
|
||||
///
|
||||
///
|
||||
/// @app.post("/items/", response_model=Item)
|
||||
/// async def create_item(item: Item) -> Item:
|
||||
/// return item
|
||||
/// ```
|
||||
///
|
||||
/// Use instead:
|
||||
///
|
||||
/// ```python
|
||||
/// from fastapi import FastAPI
|
||||
/// from pydantic import BaseModel
|
||||
///
|
||||
/// app = FastAPI()
|
||||
///
|
||||
///
|
||||
/// class Item(BaseModel):
|
||||
/// name: str
|
||||
///
|
||||
///
|
||||
/// @app.post("/items/")
|
||||
/// async def create_item(item: Item) -> Item:
|
||||
/// return item
|
||||
/// ```
|
||||
|
||||
#[violation]
|
||||
pub struct FastApiRedundantResponseModel;
|
||||
|
||||
impl AlwaysFixableViolation for FastApiRedundantResponseModel {
|
||||
#[derive_message_formats]
|
||||
fn message(&self) -> String {
|
||||
format!("FastAPI route with redundant `response_model` argument")
|
||||
}
|
||||
|
||||
fn fix_title(&self) -> String {
|
||||
"Remove argument".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// RUF102
|
||||
pub(crate) fn fastapi_redundant_response_model(
|
||||
checker: &mut Checker,
|
||||
function_def: &StmtFunctionDef,
|
||||
) {
|
||||
if !checker.semantic().seen_module(Modules::FASTAPI) {
|
||||
return;
|
||||
}
|
||||
for decorator in &function_def.decorator_list {
|
||||
let Some((call, response_model_arg)) =
|
||||
check_decorator(function_def, decorator, checker.semantic())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut diagnostic =
|
||||
Diagnostic::new(FastApiRedundantResponseModel, response_model_arg.range());
|
||||
diagnostic.try_set_fix(|| {
|
||||
remove_argument(
|
||||
response_model_arg,
|
||||
&call.arguments,
|
||||
Parentheses::Preserve,
|
||||
checker.locator().contents(),
|
||||
)
|
||||
.map(Fix::unsafe_edit)
|
||||
});
|
||||
checker.diagnostics.push(diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_decorator<'a>(
|
||||
function_def: &StmtFunctionDef,
|
||||
decorator: &'a Decorator,
|
||||
semantic: &'a SemanticModel,
|
||||
) -> Option<(&'a ExprCall, &'a Keyword)> {
|
||||
let call = is_fastapi_route_decorator(decorator, semantic)?;
|
||||
let response_model_arg = call.arguments.find_keyword("response_model")?;
|
||||
let return_value = function_def.returns.as_ref()?;
|
||||
if is_identical_types(&response_model_arg.value, return_value, semantic) {
|
||||
Some((call, response_model_arg))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_identical_types(
|
||||
response_model_arg: &Expr,
|
||||
return_value: &Expr,
|
||||
semantic: &SemanticModel,
|
||||
) -> bool {
|
||||
if let (Some(response_mode_name_expr), Some(return_value_name_expr)) = (
|
||||
response_model_arg.as_name_expr(),
|
||||
return_value.as_name_expr(),
|
||||
) {
|
||||
return semantic.resolve_name(response_mode_name_expr)
|
||||
== semantic.resolve_name(return_value_name_expr);
|
||||
}
|
||||
if let (Some(response_mode_subscript), Some(return_value_subscript)) = (
|
||||
response_model_arg.as_subscript_expr(),
|
||||
return_value.as_subscript_expr(),
|
||||
) {
|
||||
return is_identical_types(
|
||||
&response_mode_subscript.value,
|
||||
&return_value_subscript.value,
|
||||
semantic,
|
||||
) && is_identical_types(
|
||||
&response_mode_subscript.slice,
|
||||
&return_value_subscript.slice,
|
||||
semantic,
|
||||
);
|
||||
}
|
||||
if let (Some(response_mode_tuple), Some(return_value_tuple)) = (
|
||||
response_model_arg.as_tuple_expr(),
|
||||
return_value.as_tuple_expr(),
|
||||
) {
|
||||
return response_mode_tuple.elts.len() == return_value_tuple.elts.len()
|
||||
&& response_mode_tuple
|
||||
.elts
|
||||
.iter()
|
||||
.zip(return_value_tuple.elts.iter())
|
||||
.all(|(x, y)| is_identical_types(x, y, semantic));
|
||||
}
|
||||
false
|
||||
}
|
44
crates/ruff_linter/src/rules/fastapi/rules/mod.rs
Normal file
44
crates/ruff_linter/src/rules/fastapi/rules/mod.rs
Normal file
|
@ -0,0 +1,44 @@
|
|||
pub(crate) use fastapi_non_annotated_dependency::*;
|
||||
pub(crate) use fastapi_redundant_response_model::*;
|
||||
|
||||
mod fastapi_non_annotated_dependency;
|
||||
mod fastapi_redundant_response_model;
|
||||
|
||||
use ruff_python_ast::{Decorator, ExprCall, StmtFunctionDef};
|
||||
use ruff_python_semantic::analyze::typing::resolve_assignment;
|
||||
use ruff_python_semantic::SemanticModel;
|
||||
|
||||
/// Returns `true` if the function is a FastAPI route.
|
||||
pub(crate) fn is_fastapi_route(function_def: &StmtFunctionDef, semantic: &SemanticModel) -> bool {
|
||||
return function_def
|
||||
.decorator_list
|
||||
.iter()
|
||||
.any(|decorator| is_fastapi_route_decorator(decorator, semantic).is_some());
|
||||
}
|
||||
|
||||
/// Returns `true` if the decorator is indicative of a FastAPI route.
|
||||
pub(crate) fn is_fastapi_route_decorator<'a>(
|
||||
decorator: &'a Decorator,
|
||||
semantic: &'a SemanticModel,
|
||||
) -> Option<&'a ExprCall> {
|
||||
let call = decorator.expression.as_call_expr()?;
|
||||
let decorator_method = call.func.as_attribute_expr()?;
|
||||
let method_name = &decorator_method.attr;
|
||||
|
||||
if !matches!(
|
||||
method_name.as_str(),
|
||||
"get" | "post" | "put" | "delete" | "patch" | "options" | "head" | "trace"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let qualified_name = resolve_assignment(&decorator_method.value, semantic)?;
|
||||
if matches!(
|
||||
qualified_name.segments(),
|
||||
["fastapi", "FastAPI" | "APIRouter"]
|
||||
) {
|
||||
Some(call)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
|
@ -0,0 +1,263 @@
|
|||
---
|
||||
source: crates/ruff_linter/src/rules/fastapi/mod.rs
|
||||
---
|
||||
FAST002.py:24:5: FAST002 [*] FastAPI dependency without `Annotated`
|
||||
|
|
||||
22 | @app.get("/items/")
|
||||
23 | def get_items(
|
||||
24 | current_user: User = Depends(get_current_user),
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FAST002
|
||||
25 | some_security_param: str = Security(get_oauth2_user),
|
||||
26 | ):
|
||||
|
|
||||
= help: Replace with `Annotated`
|
||||
|
||||
ℹ Unsafe fix
|
||||
12 12 | Security,
|
||||
13 13 | )
|
||||
14 14 | from pydantic import BaseModel
|
||||
15 |+from typing import Annotated
|
||||
15 16 |
|
||||
16 17 | app = FastAPI()
|
||||
17 18 | router = APIRouter()
|
||||
--------------------------------------------------------------------------------
|
||||
21 22 |
|
||||
22 23 | @app.get("/items/")
|
||||
23 24 | def get_items(
|
||||
24 |- current_user: User = Depends(get_current_user),
|
||||
25 |+ current_user: Annotated[User, Depends(get_current_user)],
|
||||
25 26 | some_security_param: str = Security(get_oauth2_user),
|
||||
26 27 | ):
|
||||
27 28 | pass
|
||||
|
||||
FAST002.py:25:5: FAST002 [*] FastAPI dependency without `Annotated`
|
||||
|
|
||||
23 | def get_items(
|
||||
24 | current_user: User = Depends(get_current_user),
|
||||
25 | some_security_param: str = Security(get_oauth2_user),
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FAST002
|
||||
26 | ):
|
||||
27 | pass
|
||||
|
|
||||
= help: Replace with `Annotated`
|
||||
|
||||
ℹ Unsafe fix
|
||||
12 12 | Security,
|
||||
13 13 | )
|
||||
14 14 | from pydantic import BaseModel
|
||||
15 |+from typing import Annotated
|
||||
15 16 |
|
||||
16 17 | app = FastAPI()
|
||||
17 18 | router = APIRouter()
|
||||
--------------------------------------------------------------------------------
|
||||
22 23 | @app.get("/items/")
|
||||
23 24 | def get_items(
|
||||
24 25 | current_user: User = Depends(get_current_user),
|
||||
25 |- some_security_param: str = Security(get_oauth2_user),
|
||||
26 |+ some_security_param: Annotated[str, Security(get_oauth2_user)],
|
||||
26 27 | ):
|
||||
27 28 | pass
|
||||
28 29 |
|
||||
|
||||
FAST002.py:32:5: FAST002 [*] FastAPI dependency without `Annotated`
|
||||
|
|
||||
30 | @app.post("/stuff/")
|
||||
31 | def do_stuff(
|
||||
32 | some_query_param: str | None = Query(default=None),
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FAST002
|
||||
33 | some_path_param: str = Path(),
|
||||
34 | some_body_param: str = Body("foo"),
|
||||
|
|
||||
= help: Replace with `Annotated`
|
||||
|
||||
ℹ Unsafe fix
|
||||
12 12 | Security,
|
||||
13 13 | )
|
||||
14 14 | from pydantic import BaseModel
|
||||
15 |+from typing import Annotated
|
||||
15 16 |
|
||||
16 17 | app = FastAPI()
|
||||
17 18 | router = APIRouter()
|
||||
--------------------------------------------------------------------------------
|
||||
29 30 |
|
||||
30 31 | @app.post("/stuff/")
|
||||
31 32 | def do_stuff(
|
||||
32 |- some_query_param: str | None = Query(default=None),
|
||||
33 |+ some_query_param: Annotated[str | None, Query(default=None)],
|
||||
33 34 | some_path_param: str = Path(),
|
||||
34 35 | some_body_param: str = Body("foo"),
|
||||
35 36 | some_cookie_param: str = Cookie(),
|
||||
|
||||
FAST002.py:33:5: FAST002 [*] FastAPI dependency without `Annotated`
|
||||
|
|
||||
31 | def do_stuff(
|
||||
32 | some_query_param: str | None = Query(default=None),
|
||||
33 | some_path_param: str = Path(),
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FAST002
|
||||
34 | some_body_param: str = Body("foo"),
|
||||
35 | some_cookie_param: str = Cookie(),
|
||||
|
|
||||
= help: Replace with `Annotated`
|
||||
|
||||
ℹ Unsafe fix
|
||||
12 12 | Security,
|
||||
13 13 | )
|
||||
14 14 | from pydantic import BaseModel
|
||||
15 |+from typing import Annotated
|
||||
15 16 |
|
||||
16 17 | app = FastAPI()
|
||||
17 18 | router = APIRouter()
|
||||
--------------------------------------------------------------------------------
|
||||
30 31 | @app.post("/stuff/")
|
||||
31 32 | def do_stuff(
|
||||
32 33 | some_query_param: str | None = Query(default=None),
|
||||
33 |- some_path_param: str = Path(),
|
||||
34 |+ some_path_param: Annotated[str, Path()],
|
||||
34 35 | some_body_param: str = Body("foo"),
|
||||
35 36 | some_cookie_param: str = Cookie(),
|
||||
36 37 | some_header_param: int = Header(default=5),
|
||||
|
||||
FAST002.py:34:5: FAST002 [*] FastAPI dependency without `Annotated`
|
||||
|
|
||||
32 | some_query_param: str | None = Query(default=None),
|
||||
33 | some_path_param: str = Path(),
|
||||
34 | some_body_param: str = Body("foo"),
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FAST002
|
||||
35 | some_cookie_param: str = Cookie(),
|
||||
36 | some_header_param: int = Header(default=5),
|
||||
|
|
||||
= help: Replace with `Annotated`
|
||||
|
||||
ℹ Unsafe fix
|
||||
12 12 | Security,
|
||||
13 13 | )
|
||||
14 14 | from pydantic import BaseModel
|
||||
15 |+from typing import Annotated
|
||||
15 16 |
|
||||
16 17 | app = FastAPI()
|
||||
17 18 | router = APIRouter()
|
||||
--------------------------------------------------------------------------------
|
||||
31 32 | def do_stuff(
|
||||
32 33 | some_query_param: str | None = Query(default=None),
|
||||
33 34 | some_path_param: str = Path(),
|
||||
34 |- some_body_param: str = Body("foo"),
|
||||
35 |+ some_body_param: Annotated[str, Body("foo")],
|
||||
35 36 | some_cookie_param: str = Cookie(),
|
||||
36 37 | some_header_param: int = Header(default=5),
|
||||
37 38 | some_file_param: UploadFile = File(),
|
||||
|
||||
FAST002.py:35:5: FAST002 [*] FastAPI dependency without `Annotated`
|
||||
|
|
||||
33 | some_path_param: str = Path(),
|
||||
34 | some_body_param: str = Body("foo"),
|
||||
35 | some_cookie_param: str = Cookie(),
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FAST002
|
||||
36 | some_header_param: int = Header(default=5),
|
||||
37 | some_file_param: UploadFile = File(),
|
||||
|
|
||||
= help: Replace with `Annotated`
|
||||
|
||||
ℹ Unsafe fix
|
||||
12 12 | Security,
|
||||
13 13 | )
|
||||
14 14 | from pydantic import BaseModel
|
||||
15 |+from typing import Annotated
|
||||
15 16 |
|
||||
16 17 | app = FastAPI()
|
||||
17 18 | router = APIRouter()
|
||||
--------------------------------------------------------------------------------
|
||||
32 33 | some_query_param: str | None = Query(default=None),
|
||||
33 34 | some_path_param: str = Path(),
|
||||
34 35 | some_body_param: str = Body("foo"),
|
||||
35 |- some_cookie_param: str = Cookie(),
|
||||
36 |+ some_cookie_param: Annotated[str, Cookie()],
|
||||
36 37 | some_header_param: int = Header(default=5),
|
||||
37 38 | some_file_param: UploadFile = File(),
|
||||
38 39 | some_form_param: str = Form(),
|
||||
|
||||
FAST002.py:36:5: FAST002 [*] FastAPI dependency without `Annotated`
|
||||
|
|
||||
34 | some_body_param: str = Body("foo"),
|
||||
35 | some_cookie_param: str = Cookie(),
|
||||
36 | some_header_param: int = Header(default=5),
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FAST002
|
||||
37 | some_file_param: UploadFile = File(),
|
||||
38 | some_form_param: str = Form(),
|
||||
|
|
||||
= help: Replace with `Annotated`
|
||||
|
||||
ℹ Unsafe fix
|
||||
12 12 | Security,
|
||||
13 13 | )
|
||||
14 14 | from pydantic import BaseModel
|
||||
15 |+from typing import Annotated
|
||||
15 16 |
|
||||
16 17 | app = FastAPI()
|
||||
17 18 | router = APIRouter()
|
||||
--------------------------------------------------------------------------------
|
||||
33 34 | some_path_param: str = Path(),
|
||||
34 35 | some_body_param: str = Body("foo"),
|
||||
35 36 | some_cookie_param: str = Cookie(),
|
||||
36 |- some_header_param: int = Header(default=5),
|
||||
37 |+ some_header_param: Annotated[int, Header(default=5)],
|
||||
37 38 | some_file_param: UploadFile = File(),
|
||||
38 39 | some_form_param: str = Form(),
|
||||
39 40 | ):
|
||||
|
||||
FAST002.py:37:5: FAST002 [*] FastAPI dependency without `Annotated`
|
||||
|
|
||||
35 | some_cookie_param: str = Cookie(),
|
||||
36 | some_header_param: int = Header(default=5),
|
||||
37 | some_file_param: UploadFile = File(),
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FAST002
|
||||
38 | some_form_param: str = Form(),
|
||||
39 | ):
|
||||
|
|
||||
= help: Replace with `Annotated`
|
||||
|
||||
ℹ Unsafe fix
|
||||
12 12 | Security,
|
||||
13 13 | )
|
||||
14 14 | from pydantic import BaseModel
|
||||
15 |+from typing import Annotated
|
||||
15 16 |
|
||||
16 17 | app = FastAPI()
|
||||
17 18 | router = APIRouter()
|
||||
--------------------------------------------------------------------------------
|
||||
34 35 | some_body_param: str = Body("foo"),
|
||||
35 36 | some_cookie_param: str = Cookie(),
|
||||
36 37 | some_header_param: int = Header(default=5),
|
||||
37 |- some_file_param: UploadFile = File(),
|
||||
38 |+ some_file_param: Annotated[UploadFile, File()],
|
||||
38 39 | some_form_param: str = Form(),
|
||||
39 40 | ):
|
||||
40 41 | # do stuff
|
||||
|
||||
FAST002.py:38:5: FAST002 [*] FastAPI dependency without `Annotated`
|
||||
|
|
||||
36 | some_header_param: int = Header(default=5),
|
||||
37 | some_file_param: UploadFile = File(),
|
||||
38 | some_form_param: str = Form(),
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FAST002
|
||||
39 | ):
|
||||
40 | # do stuff
|
||||
|
|
||||
= help: Replace with `Annotated`
|
||||
|
||||
ℹ Unsafe fix
|
||||
12 12 | Security,
|
||||
13 13 | )
|
||||
14 14 | from pydantic import BaseModel
|
||||
15 |+from typing import Annotated
|
||||
15 16 |
|
||||
16 17 | app = FastAPI()
|
||||
17 18 | router = APIRouter()
|
||||
--------------------------------------------------------------------------------
|
||||
35 36 | some_cookie_param: str = Cookie(),
|
||||
36 37 | some_header_param: int = Header(default=5),
|
||||
37 38 | some_file_param: UploadFile = File(),
|
||||
38 |- some_form_param: str = Form(),
|
||||
39 |+ some_form_param: Annotated[str, Form()],
|
||||
39 40 | ):
|
||||
40 41 | # do stuff
|
||||
41 42 | pass
|
|
@ -0,0 +1,174 @@
|
|||
---
|
||||
source: crates/ruff_linter/src/rules/fastapi/mod.rs
|
||||
---
|
||||
FAST001.py:17:22: FAST001 [*] FastAPI route with redundant `response_model` argument
|
||||
|
|
||||
17 | @app.post("/items/", response_model=Item)
|
||||
| ^^^^^^^^^^^^^^^^^^^ FAST001
|
||||
18 | async def create_item(item: Item) -> Item:
|
||||
19 | return item
|
||||
|
|
||||
= help: Remove argument
|
||||
|
||||
ℹ Unsafe fix
|
||||
14 14 | # Errors
|
||||
15 15 |
|
||||
16 16 |
|
||||
17 |-@app.post("/items/", response_model=Item)
|
||||
17 |+@app.post("/items/")
|
||||
18 18 | async def create_item(item: Item) -> Item:
|
||||
19 19 | return item
|
||||
20 20 |
|
||||
|
||||
FAST001.py:22:22: FAST001 [*] FastAPI route with redundant `response_model` argument
|
||||
|
|
||||
22 | @app.post("/items/", response_model=list[Item])
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^ FAST001
|
||||
23 | async def create_item(item: Item) -> list[Item]:
|
||||
24 | return item
|
||||
|
|
||||
= help: Remove argument
|
||||
|
||||
ℹ Unsafe fix
|
||||
19 19 | return item
|
||||
20 20 |
|
||||
21 21 |
|
||||
22 |-@app.post("/items/", response_model=list[Item])
|
||||
22 |+@app.post("/items/")
|
||||
23 23 | async def create_item(item: Item) -> list[Item]:
|
||||
24 24 | return item
|
||||
25 25 |
|
||||
|
||||
FAST001.py:27:22: FAST001 [*] FastAPI route with redundant `response_model` argument
|
||||
|
|
||||
27 | @app.post("/items/", response_model=List[Item])
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^ FAST001
|
||||
28 | async def create_item(item: Item) -> List[Item]:
|
||||
29 | return item
|
||||
|
|
||||
= help: Remove argument
|
||||
|
||||
ℹ Unsafe fix
|
||||
24 24 | return item
|
||||
25 25 |
|
||||
26 26 |
|
||||
27 |-@app.post("/items/", response_model=List[Item])
|
||||
27 |+@app.post("/items/")
|
||||
28 28 | async def create_item(item: Item) -> List[Item]:
|
||||
29 29 | return item
|
||||
30 30 |
|
||||
|
||||
FAST001.py:32:22: FAST001 [*] FastAPI route with redundant `response_model` argument
|
||||
|
|
||||
32 | @app.post("/items/", response_model=Dict[str, Item])
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FAST001
|
||||
33 | async def create_item(item: Item) -> Dict[str, Item]:
|
||||
34 | return item
|
||||
|
|
||||
= help: Remove argument
|
||||
|
||||
ℹ Unsafe fix
|
||||
29 29 | return item
|
||||
30 30 |
|
||||
31 31 |
|
||||
32 |-@app.post("/items/", response_model=Dict[str, Item])
|
||||
32 |+@app.post("/items/")
|
||||
33 33 | async def create_item(item: Item) -> Dict[str, Item]:
|
||||
34 34 | return item
|
||||
35 35 |
|
||||
|
||||
FAST001.py:37:22: FAST001 [*] FastAPI route with redundant `response_model` argument
|
||||
|
|
||||
37 | @app.post("/items/", response_model=str)
|
||||
| ^^^^^^^^^^^^^^^^^^ FAST001
|
||||
38 | async def create_item(item: Item) -> str:
|
||||
39 | return item
|
||||
|
|
||||
= help: Remove argument
|
||||
|
||||
ℹ Unsafe fix
|
||||
34 34 | return item
|
||||
35 35 |
|
||||
36 36 |
|
||||
37 |-@app.post("/items/", response_model=str)
|
||||
37 |+@app.post("/items/")
|
||||
38 38 | async def create_item(item: Item) -> str:
|
||||
39 39 | return item
|
||||
40 40 |
|
||||
|
||||
FAST001.py:42:21: FAST001 [*] FastAPI route with redundant `response_model` argument
|
||||
|
|
||||
42 | @app.get("/items/", response_model=Item)
|
||||
| ^^^^^^^^^^^^^^^^^^^ FAST001
|
||||
43 | async def create_item(item: Item) -> Item:
|
||||
44 | return item
|
||||
|
|
||||
= help: Remove argument
|
||||
|
||||
ℹ Unsafe fix
|
||||
39 39 | return item
|
||||
40 40 |
|
||||
41 41 |
|
||||
42 |-@app.get("/items/", response_model=Item)
|
||||
42 |+@app.get("/items/")
|
||||
43 43 | async def create_item(item: Item) -> Item:
|
||||
44 44 | return item
|
||||
45 45 |
|
||||
|
||||
FAST001.py:47:21: FAST001 [*] FastAPI route with redundant `response_model` argument
|
||||
|
|
||||
47 | @app.get("/items/", response_model=Item)
|
||||
| ^^^^^^^^^^^^^^^^^^^ FAST001
|
||||
48 | @app.post("/items/", response_model=Item)
|
||||
49 | async def create_item(item: Item) -> Item:
|
||||
|
|
||||
= help: Remove argument
|
||||
|
||||
ℹ Unsafe fix
|
||||
44 44 | return item
|
||||
45 45 |
|
||||
46 46 |
|
||||
47 |-@app.get("/items/", response_model=Item)
|
||||
47 |+@app.get("/items/")
|
||||
48 48 | @app.post("/items/", response_model=Item)
|
||||
49 49 | async def create_item(item: Item) -> Item:
|
||||
50 50 | return item
|
||||
|
||||
FAST001.py:48:22: FAST001 [*] FastAPI route with redundant `response_model` argument
|
||||
|
|
||||
47 | @app.get("/items/", response_model=Item)
|
||||
48 | @app.post("/items/", response_model=Item)
|
||||
| ^^^^^^^^^^^^^^^^^^^ FAST001
|
||||
49 | async def create_item(item: Item) -> Item:
|
||||
50 | return item
|
||||
|
|
||||
= help: Remove argument
|
||||
|
||||
ℹ Unsafe fix
|
||||
45 45 |
|
||||
46 46 |
|
||||
47 47 | @app.get("/items/", response_model=Item)
|
||||
48 |-@app.post("/items/", response_model=Item)
|
||||
48 |+@app.post("/items/")
|
||||
49 49 | async def create_item(item: Item) -> Item:
|
||||
50 50 | return item
|
||||
51 51 |
|
||||
|
||||
FAST001.py:53:24: FAST001 [*] FastAPI route with redundant `response_model` argument
|
||||
|
|
||||
53 | @router.get("/items/", response_model=Item)
|
||||
| ^^^^^^^^^^^^^^^^^^^ FAST001
|
||||
54 | async def create_item(item: Item) -> Item:
|
||||
55 | return item
|
||||
|
|
||||
= help: Remove argument
|
||||
|
||||
ℹ Unsafe fix
|
||||
50 50 | return item
|
||||
51 51 |
|
||||
52 52 |
|
||||
53 |-@router.get("/items/", response_model=Item)
|
||||
53 |+@router.get("/items/")
|
||||
54 54 | async def create_item(item: Item) -> Item:
|
||||
55 55 | return item
|
||||
56 56 |
|
|
@ -1,6 +1,7 @@
|
|||
#![allow(clippy::useless_format)]
|
||||
pub mod airflow;
|
||||
pub mod eradicate;
|
||||
pub mod fastapi;
|
||||
pub mod flake8_2020;
|
||||
pub mod flake8_annotations;
|
||||
pub mod flake8_async;
|
||||
|
|
|
@ -1238,6 +1238,7 @@ impl<'a> SemanticModel<'a> {
|
|||
"dataclasses" => self.seen.insert(Modules::DATACLASSES),
|
||||
"datetime" => self.seen.insert(Modules::DATETIME),
|
||||
"django" => self.seen.insert(Modules::DJANGO),
|
||||
"fastapi" => self.seen.insert(Modules::FASTAPI),
|
||||
"logging" => self.seen.insert(Modules::LOGGING),
|
||||
"mock" => self.seen.insert(Modules::MOCK),
|
||||
"numpy" => self.seen.insert(Modules::NUMPY),
|
||||
|
@ -1824,6 +1825,7 @@ bitflags! {
|
|||
const BUILTINS = 1 << 18;
|
||||
const CONTEXTVARS = 1 << 19;
|
||||
const ANYIO = 1 << 20;
|
||||
const FASTAPI = 1 << 21;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
5
ruff.schema.json
generated
5
ruff.schema.json
generated
|
@ -3066,6 +3066,11 @@
|
|||
"FA10",
|
||||
"FA100",
|
||||
"FA102",
|
||||
"FAST",
|
||||
"FAST0",
|
||||
"FAST00",
|
||||
"FAST001",
|
||||
"FAST002",
|
||||
"FBT",
|
||||
"FBT0",
|
||||
"FBT00",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue