Skip to content

Commit 711af0d

Browse files
[refurb] Manual timezone monkeypatching (FURB162) (#16113)
Co-authored-by: Micha Reiser <[email protected]>
1 parent d8e3fcc commit 711af0d

File tree

8 files changed

+606
-0
lines changed

8 files changed

+606
-0
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from datetime import datetime
2+
3+
date = ""
4+
5+
6+
### Errors
7+
8+
datetime.fromisoformat(date.replace("Z", "+00:00"))
9+
datetime.fromisoformat(date.replace("Z", "-00:" "00"))
10+
11+
datetime.fromisoformat(date[:-1] + "-00")
12+
datetime.fromisoformat(date[:-1:] + "-0000")
13+
14+
datetime.fromisoformat(date.strip("Z") + """+0"""
15+
"""0""")
16+
datetime.fromisoformat(date.rstrip("Z") + "+\x30\60" '\u0030\N{DIGIT ZERO}')
17+
18+
datetime.fromisoformat(
19+
# Preserved
20+
( # Preserved
21+
date
22+
).replace("Z", "+00")
23+
)
24+
25+
datetime.fromisoformat(
26+
(date
27+
# Preserved
28+
)
29+
.
30+
rstrip("Z"
31+
# Unsafe
32+
) + "-00" # Preserved
33+
)
34+
35+
datetime.fromisoformat(
36+
( # Preserved
37+
date
38+
).strip("Z") + "+0000"
39+
)
40+
41+
datetime.fromisoformat(
42+
(date
43+
# Preserved
44+
)
45+
[ # Unsafe
46+
:-1
47+
] + "-00"
48+
)
49+
50+
51+
# Edge case
52+
datetime.fromisoformat("Z2025-01-01T00:00:00Z".strip("Z") + "+00:00")
53+
54+
55+
### No errors
56+
57+
datetime.fromisoformat(date.replace("Z"))
58+
datetime.fromisoformat(date.replace("Z", "+0000"), foo)
59+
datetime.fromisoformat(date.replace("Z", "-0000"), foo = " bar")
60+
61+
datetime.fromisoformat(date.replace("Z", "-00", lorem = ipsum))
62+
datetime.fromisoformat(date.replace("Z", -0000))
63+
64+
datetime.fromisoformat(date.replace("z", "+00"))
65+
datetime.fromisoformat(date.replace("Z", "0000"))
66+
67+
datetime.fromisoformat(date.replace("Z", "-000"))
68+
69+
datetime.fromisoformat(date.rstrip("Z") + f"-00")
70+
datetime.fromisoformat(date[:-1] + "-00" + '00')
71+
72+
datetime.fromisoformat(date[:-1] * "-00"'00')
73+
74+
datetime.fromisoformat(date[-1:] + "+00")
75+
datetime.fromisoformat(date[-1::1] + "+00")

crates/ruff_linter/src/checkers/ast/analyze/expression.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,6 +1176,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
11761176
if checker.enabled(Rule::ExcInfoOutsideExceptHandler) {
11771177
flake8_logging::rules::exc_info_outside_except_handler(checker, call);
11781178
}
1179+
if checker.enabled(Rule::FromisoformatReplaceZ) {
1180+
refurb::rules::fromisoformat_replace_z(checker, call);
1181+
}
11791182
}
11801183
Expr::Dict(dict) => {
11811184
if checker.any_enabled(&[

crates/ruff_linter/src/codes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,6 +1111,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
11111111
(Refurb, "156") => (RuleGroup::Preview, rules::refurb::rules::HardcodedStringCharset),
11121112
(Refurb, "157") => (RuleGroup::Preview, rules::refurb::rules::VerboseDecimalConstructor),
11131113
(Refurb, "161") => (RuleGroup::Stable, rules::refurb::rules::BitCount),
1114+
(Refurb, "162") => (RuleGroup::Preview, rules::refurb::rules::FromisoformatReplaceZ),
11141115
(Refurb, "163") => (RuleGroup::Stable, rules::refurb::rules::RedundantLogBase),
11151116
(Refurb, "164") => (RuleGroup::Preview, rules::refurb::rules::UnnecessaryFromFloat),
11161117
(Refurb, "166") => (RuleGroup::Preview, rules::refurb::rules::IntOnSlicedStr),

crates/ruff_linter/src/rules/refurb/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ mod tests {
5050
#[test_case(Rule::SortedMinMax, Path::new("FURB192.py"))]
5151
#[test_case(Rule::SliceToRemovePrefixOrSuffix, Path::new("FURB188.py"))]
5252
#[test_case(Rule::SubclassBuiltin, Path::new("FURB189.py"))]
53+
#[test_case(Rule::FromisoformatReplaceZ, Path::new("FURB162.py"))]
5354
fn rules(rule_code: Rule, path: &Path) -> Result<()> {
5455
let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy());
5556
let diagnostics = test_path(
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
2+
use ruff_macros::{derive_message_formats, ViolationMetadata};
3+
use ruff_python_ast::parenthesize::parenthesized_range;
4+
use ruff_python_ast::{
5+
Expr, ExprAttribute, ExprBinOp, ExprCall, ExprStringLiteral, ExprSubscript, ExprUnaryOp,
6+
Number, Operator, UnaryOp,
7+
};
8+
use ruff_python_semantic::SemanticModel;
9+
use ruff_text_size::{Ranged, TextRange};
10+
11+
use crate::checkers::ast::Checker;
12+
use crate::settings::types::PythonVersion;
13+
14+
/// ## What it does
15+
/// Checks for `datetime.fromisoformat()` calls
16+
/// where the only argument is an inline replacement
17+
/// of `Z` with a zero offset timezone.
18+
///
19+
/// ## Why is this bad?
20+
/// On Python 3.11 and later, `datetime.fromisoformat()` can handle most [ISO 8601][iso-8601]
21+
/// formats including ones affixed with `Z`, so such an operation is unnecessary.
22+
///
23+
/// More information on unsupported formats
24+
/// can be found in [the official documentation][fromisoformat].
25+
///
26+
/// ## Example
27+
///
28+
/// ```python
29+
/// from datetime import datetime
30+
///
31+
///
32+
/// date = "2025-01-01T00:00:00Z"
33+
///
34+
/// datetime.fromisoformat(date.replace("Z", "+00:00"))
35+
/// datetime.fromisoformat(date[:-1] + "-00")
36+
/// datetime.fromisoformat(date.strip("Z", "-0000"))
37+
/// datetime.fromisoformat(date.rstrip("Z", "-00:00"))
38+
/// ```
39+
///
40+
/// Use instead:
41+
///
42+
/// ```python
43+
/// from datetime import datetime
44+
///
45+
///
46+
/// date = "2025-01-01T00:00:00Z"
47+
///
48+
/// datetime.fromisoformat(date)
49+
/// ```
50+
///
51+
/// ## Fix safety
52+
/// The fix is always marked as unsafe,
53+
/// as it might change the program's behaviour.
54+
///
55+
/// For example, working code might become non-working:
56+
///
57+
/// ```python
58+
/// d = "Z2025-01-01T00:00:00Z" # Note the leading `Z`
59+
///
60+
/// datetime.fromisoformat(d.strip("Z") + "+00:00") # Fine
61+
/// datetime.fromisoformat(d) # Runtime error
62+
/// ```
63+
///
64+
/// ## References
65+
/// * [What’s New In Python 3.11 &sect; `datetime`](https://docs.python.org/3/whatsnew/3.11.html#datetime)
66+
/// * [`fromisoformat`](https://docs.python.org/3/library/datetime.html#datetime.date.fromisoformat)
67+
///
68+
/// [iso-8601]: https://www.iso.org/obp/ui/#iso:std:iso:8601
69+
/// [fromisoformat]: https://docs.python.org/3/library/datetime.html#datetime.date.fromisoformat
70+
#[derive(ViolationMetadata)]
71+
pub(crate) struct FromisoformatReplaceZ;
72+
73+
impl AlwaysFixableViolation for FromisoformatReplaceZ {
74+
#[derive_message_formats]
75+
fn message(&self) -> String {
76+
r#"Unnecessary timezone replacement with zero offset"#.to_string()
77+
}
78+
79+
fn fix_title(&self) -> String {
80+
"Remove `.replace()` call".to_string()
81+
}
82+
}
83+
84+
/// FURB162
85+
pub(crate) fn fromisoformat_replace_z(checker: &Checker, call: &ExprCall) {
86+
if checker.settings.target_version < PythonVersion::Py311 {
87+
return;
88+
}
89+
90+
let (func, arguments) = (&*call.func, &call.arguments);
91+
92+
if !arguments.keywords.is_empty() {
93+
return;
94+
}
95+
96+
let [argument] = &*arguments.args else {
97+
return;
98+
};
99+
100+
if !func_is_fromisoformat(func, checker.semantic()) {
101+
return;
102+
}
103+
104+
let Some(replace_time_zone) = ReplaceTimeZone::from_expr(argument) else {
105+
return;
106+
};
107+
108+
if !is_zero_offset_timezone(replace_time_zone.zero_offset.value.to_str()) {
109+
return;
110+
}
111+
112+
let value_full_range = parenthesized_range(
113+
replace_time_zone.date.into(),
114+
replace_time_zone.parent.into(),
115+
checker.comment_ranges(),
116+
checker.source(),
117+
)
118+
.unwrap_or(replace_time_zone.date.range());
119+
120+
let range_to_remove = TextRange::new(value_full_range.end(), argument.end());
121+
122+
let diagnostic = Diagnostic::new(FromisoformatReplaceZ, argument.range());
123+
let fix = Fix::unsafe_edit(Edit::range_deletion(range_to_remove));
124+
125+
checker.report_diagnostic(diagnostic.with_fix(fix));
126+
}
127+
128+
fn func_is_fromisoformat(func: &Expr, semantic: &SemanticModel) -> bool {
129+
semantic
130+
.resolve_qualified_name(func)
131+
.is_some_and(|qualified_name| {
132+
matches!(
133+
qualified_name.segments(),
134+
["datetime", "datetime", "fromisoformat"]
135+
)
136+
})
137+
}
138+
139+
/// A `datetime.replace` call that replaces the timezone with a zero offset.
140+
struct ReplaceTimeZone<'a> {
141+
/// The date expression
142+
date: &'a Expr,
143+
/// The `date` expression's parent.
144+
parent: &'a Expr,
145+
/// The zero offset string literal
146+
zero_offset: &'a ExprStringLiteral,
147+
}
148+
149+
impl<'a> ReplaceTimeZone<'a> {
150+
fn from_expr(expr: &'a Expr) -> Option<Self> {
151+
match expr {
152+
Expr::Call(call) => Self::from_call(call),
153+
Expr::BinOp(bin_op) => Self::from_bin_op(bin_op),
154+
_ => None,
155+
}
156+
}
157+
158+
/// Returns `Some` if the call expression is a call to `str.replace` and matches `date.replace("Z", "+00:00")`
159+
fn from_call(call: &'a ExprCall) -> Option<Self> {
160+
let arguments = &call.arguments;
161+
162+
if !arguments.keywords.is_empty() {
163+
return None;
164+
};
165+
166+
let ExprAttribute { value, attr, .. } = call.func.as_attribute_expr()?;
167+
168+
if attr != "replace" {
169+
return None;
170+
}
171+
172+
let [z, Expr::StringLiteral(zero_offset)] = &*arguments.args else {
173+
return None;
174+
};
175+
176+
if !is_upper_case_z_string(z) {
177+
return None;
178+
}
179+
180+
Some(Self {
181+
date: &**value,
182+
parent: &*call.func,
183+
zero_offset,
184+
})
185+
}
186+
187+
/// Returns `Some` for binary expressions matching `date[:-1] + "-00"` or
188+
/// `date.strip("Z") + "+00"`
189+
fn from_bin_op(bin_op: &'a ExprBinOp) -> Option<Self> {
190+
let ExprBinOp {
191+
left, op, right, ..
192+
} = bin_op;
193+
194+
if *op != Operator::Add {
195+
return None;
196+
}
197+
198+
let (date, parent) = match &**left {
199+
Expr::Call(call) => strip_z_date(call)?,
200+
Expr::Subscript(subscript) => (slice_minus_1_date(subscript)?, &**left),
201+
_ => return None,
202+
};
203+
204+
Some(Self {
205+
date,
206+
parent,
207+
zero_offset: right.as_string_literal_expr()?,
208+
})
209+
}
210+
}
211+
212+
/// Returns `Some` if `call` is a call to `date.strip("Z")`.
213+
///
214+
/// It returns the value of the `date` argument and its parent.
215+
fn strip_z_date(call: &ExprCall) -> Option<(&Expr, &Expr)> {
216+
let ExprCall {
217+
func, arguments, ..
218+
} = call;
219+
220+
let Expr::Attribute(ExprAttribute { value, attr, .. }) = &**func else {
221+
return None;
222+
};
223+
224+
if !matches!(attr.as_str(), "strip" | "rstrip") {
225+
return None;
226+
}
227+
228+
if !arguments.keywords.is_empty() {
229+
return None;
230+
}
231+
232+
let [z] = &*arguments.args else {
233+
return None;
234+
};
235+
236+
if !is_upper_case_z_string(z) {
237+
return None;
238+
}
239+
240+
Some((value, func))
241+
}
242+
243+
/// Returns `Some` if this is a subscribt with the form `date[:-1] + "-00"`.
244+
fn slice_minus_1_date(subscript: &ExprSubscript) -> Option<&Expr> {
245+
let ExprSubscript { value, slice, .. } = subscript;
246+
let slice = slice.as_slice_expr()?;
247+
248+
if slice.lower.is_some() || slice.step.is_some() {
249+
return None;
250+
}
251+
252+
let Some(ExprUnaryOp {
253+
operand,
254+
op: UnaryOp::USub,
255+
..
256+
}) = slice.upper.as_ref()?.as_unary_op_expr()
257+
else {
258+
return None;
259+
};
260+
261+
let Number::Int(int) = &operand.as_number_literal_expr()?.value else {
262+
return None;
263+
};
264+
265+
if *int != 1 {
266+
return None;
267+
}
268+
269+
Some(value)
270+
}
271+
272+
fn is_upper_case_z_string(expr: &Expr) -> bool {
273+
expr.as_string_literal_expr()
274+
.is_some_and(|string| string.value.to_str() == "Z")
275+
}
276+
277+
fn is_zero_offset_timezone(value: &str) -> bool {
278+
matches!(
279+
value,
280+
"+00:00" | "+0000" | "+00" | "-00:00" | "-0000" | "-00"
281+
)
282+
}

0 commit comments

Comments
 (0)