[fastapi] Fix false positives for path parameters that FastAPI doesn't recognize (FAST003) (#20687)

## Summary

Fixes #20680

---------

Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
This commit is contained in:
Dan Parizher 2025-10-09 16:10:21 -04:00 committed by GitHub
parent db91ac7dce
commit 537ec5f012
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 46 additions and 27 deletions

View file

@ -227,3 +227,32 @@ async def read_thing(query: str):
@app.get("/things/{ thing_id : str }")
async def read_thing(query: str):
return {"query": query}
# https://github.com/astral-sh/ruff/issues/20680
# These should NOT trigger FAST003 because FastAPI doesn't recognize them as path parameters
# Non-ASCII characters in parameter name
@app.get("/f1/{用户身份}")
async def f1():
return locals()
# Space in parameter name
@app.get("/f2/{x: str}")
async def f2():
return locals()
# Non-ASCII converter
@app.get("/f3/{complex_number:}")
async def f3():
return locals()
# Mixed non-ASCII characters
@app.get("/f4/{用户_id}")
async def f4():
return locals()
# Space in parameter name with converter
@app.get("/f5/{param: int}")
async def f5():
return locals()

View file

@ -1,12 +1,12 @@
use std::iter::Peekable;
use std::ops::Range;
use std::str::CharIndices;
use std::sync::LazyLock;
use regex::{CaptureMatches, Regex};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast as ast;
use ruff_python_ast::{Arguments, Expr, ExprCall, ExprSubscript, Parameter, ParameterWithDefault};
use ruff_python_semantic::{BindingKind, Modules, ScopeKind, SemanticModel};
use ruff_python_stdlib::identifiers::is_identifier;
use ruff_text_size::{Ranged, TextSize};
use crate::Fix;
@ -165,11 +165,6 @@ pub(crate) fn fastapi_unused_path_parameter(
// Check if any of the path parameters are not in the function signature.
for (path_param, range) in path_params {
// Ignore invalid identifiers (e.g., `user-id`, as opposed to `user_id`)
if !is_identifier(path_param) {
continue;
}
// If the path parameter is already in the function or the dependency signature,
// we don't need to do anything.
if named_args.contains(&path_param) {
@ -461,15 +456,19 @@ fn parameter_alias<'a>(parameter: &'a Parameter, semantic: &SemanticModel) -> Op
/// the parameter name. For example, `/{x}` is a valid parameter, but `/{ x }` is treated literally.
#[derive(Debug)]
struct PathParamIterator<'a> {
input: &'a str,
chars: Peekable<CharIndices<'a>>,
inner: CaptureMatches<'a, 'a>,
}
impl<'a> PathParamIterator<'a> {
fn new(input: &'a str) -> Self {
PathParamIterator {
input,
chars: input.char_indices().peekable(),
/// Matches the Starlette pattern for path parameters with optional converters from
/// <https://github.com/Kludex/starlette/blob/e18637c68e36d112b1983bc0c8b663681e6a4c50/starlette/routing.py#L121>
static FASTAPI_PATH_PARAM_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\{([a-zA-Z_][a-zA-Z0-9_]*)(?::[a-zA-Z_][a-zA-Z0-9_]*)?\}").unwrap()
});
Self {
inner: FASTAPI_PATH_PARAM_REGEX.captures_iter(input),
}
}
}
@ -478,19 +477,10 @@ impl<'a> Iterator for PathParamIterator<'a> {
type Item = (&'a str, Range<usize>);
fn next(&mut self) -> Option<Self::Item> {
while let Some((start, c)) = self.chars.next() {
if c == '{' {
if let Some((end, _)) = self.chars.by_ref().find(|&(_, ch)| ch == '}') {
let param_content = &self.input[start + 1..end];
// We ignore text after a colon, since those are path converters
// See also: https://fastapi.tiangolo.com/tutorial/path-params/?h=path#path-convertor
let param_name_end = param_content.find(':').unwrap_or(param_content.len());
let param_name = &param_content[..param_name_end];
return Some((param_name, start..end + 1));
}
}
}
None
self.inner
.next()
// Extract the first capture group (the path parameter), but return the range of the
// whole match (everything in braces and including the braces themselves).
.and_then(|capture| Some((capture.get(1)?.as_str(), capture.get(0)?.range())))
}
}