1- use ruff_diagnostics:: { AlwaysFixableViolation , Diagnostic , Edit , Fix } ;
1+ use ruff_diagnostics:: { Diagnostic , Edit , Fix , FixAvailability , Violation } ;
22use ruff_macros:: { derive_message_formats, violation} ;
33use ruff_python_ast as ast;
44use ruff_python_ast:: helpers:: map_callable;
@@ -59,14 +59,16 @@ use crate::settings::types::PythonVersion;
5959#[ violation]
6060pub 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}
0 commit comments