@@ -2,8 +2,8 @@ use ruff_diagnostics::{AlwaysFixableViolation, Applicability, Diagnostic, Edit,
22use ruff_macros:: { derive_message_formats, ViolationMetadata } ;
33use ruff_python_ast:: parenthesize:: parenthesized_range;
44use ruff_python_ast:: {
5- Arguments , Expr , ExprAttribute , ExprBinOp , ExprCall , ExprStringLiteral , ExprSubscript ,
6- ExprUnaryOp , Number , Operator , UnaryOp ,
5+ Expr , ExprAttribute , ExprBinOp , ExprCall , ExprStringLiteral , ExprSubscript , ExprUnaryOp ,
6+ Number , Operator , UnaryOp ,
77} ;
88use ruff_python_semantic:: SemanticModel ;
99use ruff_text_size:: { Ranged , TextRange } ;
@@ -88,102 +88,126 @@ pub(crate) fn fromisoformat_replace_z(checker: &Checker, call: &ExprCall) {
8888 return ;
8989 }
9090
91- let Some ( range_to_remove ) = range_to_remove ( argument, checker ) else {
91+ let Some ( replace_time_zone ) = ReplaceTimeZone :: from_expr ( argument) else {
9292 return ;
9393 } ;
9494
95+ if !is_zero_offset_timezone ( replace_time_zone. zero_offset . value . to_str ( ) ) {
96+ return ;
97+ }
98+
99+ let value_full_range = parenthesized_range (
100+ replace_time_zone. date . into ( ) ,
101+ replace_time_zone. parent . into ( ) ,
102+ checker. comment_ranges ( ) ,
103+ checker. source ( ) ,
104+ )
105+ . unwrap_or ( replace_time_zone. date . range ( ) ) ;
106+
107+ let range_to_remove = TextRange :: new ( value_full_range. end ( ) , argument. end ( ) ) ;
108+
95109 let applicability = if checker. comment_ranges ( ) . intersects ( range_to_remove) {
96110 Applicability :: Unsafe
97111 } else {
98112 Applicability :: Safe
99113 } ;
100114
101- let edit = Edit :: range_deletion ( range_to_remove) ;
102- let fix = Fix :: applicable_edit ( edit, applicability) ;
103-
104115 let diagnostic = Diagnostic :: new ( FromisoformatReplaceZ , argument. range ( ) ) ;
105116
106- checker. report_diagnostic ( diagnostic. with_fix ( fix) ) ;
117+ checker. report_diagnostic ( diagnostic. with_fix ( Fix :: applicable_edit (
118+ Edit :: range_deletion ( range_to_remove) ,
119+ applicability,
120+ ) ) ) ;
107121}
108122
109123fn func_is_fromisoformat ( func : & Expr , semantic : & SemanticModel ) -> bool {
110- let Some ( qualified_name) = semantic. resolve_qualified_name ( func) else {
111- return false ;
112- } ;
113-
114- if !matches ! (
115- qualified_name. segments( ) ,
116- [ "datetime" , "datetime" , "fromisoformat" ]
117- ) {
118- return false ;
119- }
120-
121- true
124+ semantic
125+ . resolve_qualified_name ( func)
126+ . is_some_and ( |qualified_name| {
127+ matches ! (
128+ qualified_name. segments( ) ,
129+ [ "datetime" , "datetime" , "fromisoformat" ]
130+ )
131+ } )
122132}
123133
124- fn range_to_remove ( expr : & Expr , checker : & Checker ) -> Option < TextRange > {
125- let ( date, parent, zero_offset) = match expr {
126- Expr :: Call ( ExprCall {
127- func, arguments, ..
128- } ) => replace_z_date_parent_and_offset ( func, arguments) ?,
134+ /// A `datetime.replace` call that replaces the timezone with a zero offset.
135+ struct ReplaceTimeZone < ' a > {
136+ /// The date expression
137+ date : & ' a Expr ,
138+ /// The `date` expression's parent.
139+ parent : & ' a Expr ,
140+ /// The zero offset string literal
141+ zero_offset : & ' a ExprStringLiteral ,
142+ }
129143
130- Expr :: BinOp ( ExprBinOp {
131- left, op, right, ..
132- } ) => {
133- if * op != Operator :: Add {
134- return None ;
135- }
136-
137- let ( date, parent) = match & * * left {
138- Expr :: Call ( call) => strip_z_date_and_parent ( call) ?,
139- Expr :: Subscript ( subscript) => ( slice_minus_1_date ( subscript) ?, & * * left) ,
140- _ => return None ,
141- } ;
142-
143- ( date, parent, right. as_string_literal_expr ( ) ?)
144+ impl < ' a > ReplaceTimeZone < ' a > {
145+ fn from_expr ( expr : & ' a Expr ) -> Option < Self > {
146+ match expr {
147+ Expr :: Call ( call) => Self :: from_call ( call) ,
148+ Expr :: BinOp ( bin_op) => Self :: from_bin_op ( bin_op) ,
149+ _ => None ,
144150 }
151+ }
145152
146- _ => return None ,
147- } ;
153+ /// Returns `Some` if the call expression is a call to `str.replace` and matches `date.replace("Z", "+00:00")`
154+ fn from_call ( call : & ' a ExprCall ) -> Option < Self > {
155+ let arguments = & call. arguments ;
148156
149- if !is_zero_offset_timezone ( zero_offset . value . to_str ( ) ) {
150- return None ;
151- }
157+ if !arguments . keywords . is_empty ( ) {
158+ return None ;
159+ } ;
152160
153- let comment_ranges = checker. comment_ranges ( ) ;
154- let source = checker. source ( ) ;
155- let value_full_range = parenthesized_range ( date. into ( ) , parent. into ( ) , comment_ranges, source)
156- . unwrap_or ( date. range ( ) ) ;
161+ let ExprAttribute { value, attr, .. } = call. func . as_attribute_expr ( ) ?;
157162
158- Some ( TextRange :: new ( value_full_range. end ( ) , expr. end ( ) ) )
159- }
163+ if attr != "replace" {
164+ return None ;
165+ }
160166
161- fn replace_z_date_parent_and_offset < ' a > (
162- func : & ' a Expr ,
163- arguments : & ' a Arguments ,
164- ) -> Option < ( & ' a Expr , & ' a Expr , & ' a ExprStringLiteral ) > {
165- if !arguments. keywords . is_empty ( ) {
166- return None ;
167- } ;
167+ let [ z, Expr :: StringLiteral ( zero_offset) ] = & * arguments. args else {
168+ return None ;
169+ } ;
168170
169- let ExprAttribute { value, attr, .. } = func. as_attribute_expr ( ) ?;
171+ if !is_upper_case_z_string ( z) {
172+ return None ;
173+ }
170174
171- if attr != "replace" {
172- return None ;
175+ Some ( Self {
176+ date : & * * value,
177+ parent : & * call. func ,
178+ zero_offset,
179+ } )
173180 }
174181
175- let [ z, Expr :: StringLiteral ( zero_offset) ] = & * arguments. args else {
176- return None ;
177- } ;
182+ /// Returns `Some` for binary expressions matching `date[:-1] + "-00"` or
183+ /// `date.strip("Z") + "+00"`
184+ fn from_bin_op ( bin_op : & ' a ExprBinOp ) -> Option < Self > {
185+ let ExprBinOp {
186+ left, op, right, ..
187+ } = bin_op;
178188
179- if ! is_upper_case_z_string ( z ) {
180- return None ;
181- }
189+ if * op != Operator :: Add {
190+ return None ;
191+ }
182192
183- Some ( ( & * * value, func, zero_offset) )
193+ let ( date, parent) = match & * * left {
194+ Expr :: Call ( call) => strip_z_date ( call) ?,
195+ Expr :: Subscript ( subscript) => ( slice_minus_1_date ( subscript) ?, & * * left) ,
196+ _ => return None ,
197+ } ;
198+
199+ Some ( Self {
200+ date,
201+ parent,
202+ zero_offset : right. as_string_literal_expr ( ) ?,
203+ } )
204+ }
184205}
185206
186- fn strip_z_date_and_parent ( call : & ExprCall ) -> Option < ( & Expr , & Expr ) > {
207+ /// Returns `Some` if `call` is a call to `date.strip("Z")`.
208+ ///
209+ /// It returns the value of the `date` argument and its parent.
210+ fn strip_z_date ( call : & ExprCall ) -> Option < ( & Expr , & Expr ) > {
187211 let ExprCall {
188212 func, arguments, ..
189213 } = call;
@@ -211,6 +235,7 @@ fn strip_z_date_and_parent(call: &ExprCall) -> Option<(&Expr, &Expr)> {
211235 Some ( ( value, func) )
212236}
213237
238+ /// Returns `Some` if this is a subscribt with the form `date[:-1] + "-00"`.
214239fn slice_minus_1_date ( subscript : & ExprSubscript ) -> Option < & Expr > {
215240 let ExprSubscript { value, slice, .. } = subscript;
216241 let slice = slice. as_slice_expr ( ) ?;
@@ -219,25 +244,29 @@ fn slice_minus_1_date(subscript: &ExprSubscript) -> Option<&Expr> {
219244 return None ;
220245 }
221246
222- let ExprUnaryOp { operand, op, .. } = slice. upper . as_ref ( ) ?. as_unary_op_expr ( ) ?;
247+ let Some ( ExprUnaryOp {
248+ operand,
249+ op : UnaryOp :: USub ,
250+ ..
251+ } ) = slice. upper . as_ref ( ) ?. as_unary_op_expr ( )
252+ else {
253+ return None ;
254+ } ;
223255
224256 let Number :: Int ( int) = & operand. as_number_literal_expr ( ) ?. value else {
225257 return None ;
226258 } ;
227259
228- if * op != UnaryOp :: USub || ! matches ! ( int . as_u8 ( ) , Some ( 1 ) ) {
260+ if * int != 1 {
229261 return None ;
230262 }
231263
232264 Some ( value)
233265}
234266
235267fn is_upper_case_z_string ( expr : & Expr ) -> bool {
236- let Expr :: StringLiteral ( string) = expr else {
237- return false ;
238- } ;
239-
240- string. value . to_str ( ) == "Z"
268+ expr. as_string_literal_expr ( )
269+ . is_some_and ( |string| string. value . to_str ( ) == "Z" )
241270}
242271
243272fn is_zero_offset_timezone ( value : & str ) -> bool {
0 commit comments