diff --git a/crates/ruff_linter/resources/test/fixtures/fastapi/FAST001.py b/crates/ruff_linter/resources/test/fixtures/fastapi/FAST001.py new file mode 100644 index 0000000000000..0563e5c5f99f9 --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/fastapi/FAST001.py @@ -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 diff --git a/crates/ruff_linter/resources/test/fixtures/fastapi/FAST002.py b/crates/ruff_linter/resources/test/fixtures/fastapi/FAST002.py new file mode 100644 index 0000000000000..3473df3cac0fd --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/fastapi/FAST002.py @@ -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 diff --git a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs index be8ca358b8075..67f28b84ba94b 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/statement.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/statement.rs @@ -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); diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 4d4e5452ced49..412509c4e7909 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -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), diff --git a/crates/ruff_linter/src/registry.rs b/crates/ruff_linter/src/registry.rs index 35c797779ba98..4901c2e47f33d 100644 --- a/crates/ruff_linter/src/registry.rs +++ b/crates/ruff_linter/src/registry.rs @@ -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, diff --git a/crates/ruff_linter/src/rules/fastapi/mod.rs b/crates/ruff_linter/src/rules/fastapi/mod.rs new file mode 100644 index 0000000000000..f07de637955e2 --- /dev/null +++ b/crates/ruff_linter/src/rules/fastapi/mod.rs @@ -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(()) + } +} diff --git a/crates/ruff_linter/src/rules/fastapi/rules/fastapi_non_annotated_dependency.rs b/crates/ruff_linter/src/rules/fastapi/rules/fastapi_non_annotated_dependency.rs new file mode 100644 index 0000000000000..8c4691451f9e9 --- /dev/null +++ b/crates/ruff_linter/src/rules/fastapi/rules/fastapi_non_annotated_dependency.rs @@ -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); + } + } + } +} diff --git a/crates/ruff_linter/src/rules/fastapi/rules/fastapi_redundant_response_model.rs b/crates/ruff_linter/src/rules/fastapi/rules/fastapi_redundant_response_model.rs new file mode 100644 index 0000000000000..b2fcad67c9d60 --- /dev/null +++ b/crates/ruff_linter/src/rules/fastapi/rules/fastapi_redundant_response_model.rs @@ -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 +} diff --git a/crates/ruff_linter/src/rules/fastapi/rules/mod.rs b/crates/ruff_linter/src/rules/fastapi/rules/mod.rs new file mode 100644 index 0000000000000..678b7b236c415 --- /dev/null +++ b/crates/ruff_linter/src/rules/fastapi/rules/mod.rs @@ -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 + } +} diff --git a/crates/ruff_linter/src/rules/fastapi/snapshots/ruff_linter__rules__fastapi__tests__fast-api-non-annotated-dependency_FAST002.py.snap b/crates/ruff_linter/src/rules/fastapi/snapshots/ruff_linter__rules__fastapi__tests__fast-api-non-annotated-dependency_FAST002.py.snap new file mode 100644 index 0000000000000..0651f5f7005c4 --- /dev/null +++ b/crates/ruff_linter/src/rules/fastapi/snapshots/ruff_linter__rules__fastapi__tests__fast-api-non-annotated-dependency_FAST002.py.snap @@ -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 diff --git a/crates/ruff_linter/src/rules/fastapi/snapshots/ruff_linter__rules__fastapi__tests__fast-api-redundant-response-model_FAST001.py.snap b/crates/ruff_linter/src/rules/fastapi/snapshots/ruff_linter__rules__fastapi__tests__fast-api-redundant-response-model_FAST001.py.snap new file mode 100644 index 0000000000000..84b582e502da7 --- /dev/null +++ b/crates/ruff_linter/src/rules/fastapi/snapshots/ruff_linter__rules__fastapi__tests__fast-api-redundant-response-model_FAST001.py.snap @@ -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 | diff --git a/crates/ruff_linter/src/rules/mod.rs b/crates/ruff_linter/src/rules/mod.rs index f1eba35e85c3d..c9983ab416cc6 100644 --- a/crates/ruff_linter/src/rules/mod.rs +++ b/crates/ruff_linter/src/rules/mod.rs @@ -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; diff --git a/crates/ruff_python_semantic/src/model.rs b/crates/ruff_python_semantic/src/model.rs index 362af77507fe1..3fe72f4658322 100644 --- a/crates/ruff_python_semantic/src/model.rs +++ b/crates/ruff_python_semantic/src/model.rs @@ -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; } } diff --git a/ruff.schema.json b/ruff.schema.json index 29f54d5c2b36d..5815921917546 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -3066,6 +3066,11 @@ "FA10", "FA100", "FA102", + "FAST", + "FAST0", + "FAST00", + "FAST001", + "FAST002", "FBT", "FBT0", "FBT00",