Skip to content

Commit 5c548dc

Browse files
[flake8-datetimez] Usages of datetime.max/datetime.min (DTZ901) (#14288)
## Summary Resolves #13217. ## Test Plan `cargo nextest run` and `cargo insta test`. --------- Co-authored-by: Charlie Marsh <[email protected]>
1 parent bd30701 commit 5c548dc

File tree

9 files changed

+249
-3
lines changed

9 files changed

+249
-3
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import datetime
2+
3+
4+
# Error
5+
datetime.datetime.max
6+
datetime.datetime.min
7+
8+
datetime.datetime.max.replace(year=...)
9+
datetime.datetime.min.replace(hour=...)
10+
11+
12+
# No error
13+
datetime.datetime.max.replace(tzinfo=...)
14+
datetime.datetime.min.replace(tzinfo=...)
15+
16+
17+
from datetime import datetime
18+
19+
20+
# Error
21+
datetime.max
22+
datetime.min
23+
24+
datetime.max.replace(year=...)
25+
datetime.min.replace(hour=...)
26+
27+
28+
# No error
29+
datetime.max.replace(tzinfo=...)
30+
datetime.min.replace(tzinfo=...)

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,9 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
339339
if checker.enabled(Rule::SixPY3) {
340340
flake8_2020::rules::name_or_attribute(checker, expr);
341341
}
342+
if checker.enabled(Rule::DatetimeMinMax) {
343+
flake8_datetimez::rules::datetime_max_min(checker, expr);
344+
}
342345
if checker.enabled(Rule::BannedApi) {
343346
flake8_tidy_imports::rules::banned_attribute_access(checker, expr);
344347
}

crates/ruff_linter/src/codes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
706706
(Flake8Datetimez, "007") => (RuleGroup::Stable, rules::flake8_datetimez::rules::CallDatetimeStrptimeWithoutZone),
707707
(Flake8Datetimez, "011") => (RuleGroup::Stable, rules::flake8_datetimez::rules::CallDateToday),
708708
(Flake8Datetimez, "012") => (RuleGroup::Stable, rules::flake8_datetimez::rules::CallDateFromtimestamp),
709+
(Flake8Datetimez, "901") => (RuleGroup::Preview, rules::flake8_datetimez::rules::DatetimeMinMax),
709710

710711
// pygrep-hooks
711712
(PygrepHooks, "001") => (RuleGroup::Removed, rules::pygrep_hooks::rules::Eval),

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod tests {
99
use test_case::test_case;
1010

1111
use crate::registry::Rule;
12+
use crate::settings::types::PreviewMode;
1213
use crate::test::test_path;
1314
use crate::{assert_messages, settings};
1415

@@ -30,4 +31,18 @@ mod tests {
3031
assert_messages!(snapshot, diagnostics);
3132
Ok(())
3233
}
34+
35+
#[test_case(Rule::DatetimeMinMax, Path::new("DTZ901.py"))]
36+
fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> {
37+
let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy());
38+
let diagnostics = test_path(
39+
Path::new("flake8_datetimez").join(path).as_path(),
40+
&settings::LinterSettings {
41+
preview: PreviewMode::Enabled,
42+
..settings::LinterSettings::for_rule(rule_code)
43+
},
44+
)?;
45+
assert_messages!(snapshot, diagnostics);
46+
Ok(())
47+
}
3348
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
use std::fmt::{Display, Formatter};
2+
3+
use ruff_diagnostics::{Diagnostic, Violation};
4+
use ruff_macros::{derive_message_formats, violation};
5+
use ruff_python_ast::{Expr, ExprAttribute, ExprCall};
6+
use ruff_python_semantic::{Modules, SemanticModel};
7+
use ruff_text_size::Ranged;
8+
9+
use crate::checkers::ast::Checker;
10+
11+
/// ## What it does
12+
/// Checks for uses of `datetime.datetime.max` and `datetime.datetime.min`.
13+
///
14+
/// ## Why is this bad?
15+
/// `datetime.max` and `datetime.min` are non-timezone-aware datetime objects.
16+
///
17+
/// As such, operations on `datetime.max` and `datetime.min` may behave
18+
/// unexpectedly, as in:
19+
///
20+
/// ```python
21+
/// # Timezone: UTC-14
22+
/// datetime.max.timestamp() # ValueError: year 10000 is out of range
23+
/// datetime.min.timestamp() # ValueError: year 0 is out of range
24+
/// ```
25+
///
26+
/// ## Example
27+
/// ```python
28+
/// datetime.max
29+
/// ```
30+
///
31+
/// Use instead:
32+
/// ```python
33+
/// datetime.max.replace(tzinfo=datetime.UTC)
34+
/// ```
35+
#[violation]
36+
pub struct DatetimeMinMax {
37+
min_max: MinMax,
38+
}
39+
40+
impl Violation for DatetimeMinMax {
41+
#[derive_message_formats]
42+
fn message(&self) -> String {
43+
let DatetimeMinMax { min_max } = self;
44+
format!("Use of `datetime.datetime.{min_max}` without timezone information")
45+
}
46+
47+
fn fix_title(&self) -> Option<String> {
48+
let DatetimeMinMax { min_max } = self;
49+
Some(format!(
50+
"Replace with `datetime.datetime.{min_max}.replace(tzinfo=...)`"
51+
))
52+
}
53+
}
54+
55+
/// DTZ901
56+
pub(crate) fn datetime_max_min(checker: &mut Checker, expr: &Expr) {
57+
let semantic = checker.semantic();
58+
59+
if !semantic.seen_module(Modules::DATETIME) {
60+
return;
61+
}
62+
63+
let Some(qualified_name) = semantic.resolve_qualified_name(expr) else {
64+
return;
65+
};
66+
67+
let min_max = match qualified_name.segments() {
68+
["datetime", "datetime", "min"] => MinMax::Min,
69+
["datetime", "datetime", "max"] => MinMax::Max,
70+
_ => return,
71+
};
72+
73+
if followed_by_replace_tzinfo(checker.semantic()) {
74+
return;
75+
}
76+
77+
checker
78+
.diagnostics
79+
.push(Diagnostic::new(DatetimeMinMax { min_max }, expr.range()));
80+
}
81+
82+
/// Check if the current expression has the pattern `foo.replace(tzinfo=bar)`.
83+
fn followed_by_replace_tzinfo(semantic: &SemanticModel) -> bool {
84+
let Some(parent) = semantic.current_expression_parent() else {
85+
return false;
86+
};
87+
let Some(grandparent) = semantic.current_expression_grandparent() else {
88+
return false;
89+
};
90+
91+
match (parent, grandparent) {
92+
(Expr::Attribute(ExprAttribute { attr, .. }), Expr::Call(ExprCall { arguments, .. })) => {
93+
attr.as_str() == "replace" && arguments.find_keyword("tzinfo").is_some()
94+
}
95+
_ => false,
96+
}
97+
}
98+
99+
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
100+
enum MinMax {
101+
/// `datetime.datetime.min`
102+
Min,
103+
/// `datetime.datetime.max`
104+
Max,
105+
}
106+
107+
impl Display for MinMax {
108+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
109+
match self {
110+
MinMax::Min => write!(f, "min"),
111+
MinMax::Max => write!(f, "max"),
112+
}
113+
}
114+
}

crates/ruff_linter/src/rules/flake8_datetimez/rules/helpers.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ pub(super) enum DatetimeModuleAntipattern {
88
NonePassedToTzArgument,
99
}
1010

11-
/// Check if the parent expression is a call to `astimezone`. This assumes that
12-
/// the current expression is a `datetime.datetime` object.
11+
/// Check if the parent expression is a call to `astimezone`.
12+
/// This assumes that the current expression is a `datetime.datetime` object.
1313
pub(super) fn parent_expr_is_astimezone(checker: &Checker) -> bool {
14-
checker.semantic().current_expression_parent().is_some_and( |parent| {
14+
checker.semantic().current_expression_parent().is_some_and(|parent| {
1515
matches!(parent, Expr::Attribute(ExprAttribute { attr, .. }) if attr.as_str() == "astimezone")
1616
})
1717
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub(crate) use call_datetime_today::*;
77
pub(crate) use call_datetime_utcfromtimestamp::*;
88
pub(crate) use call_datetime_utcnow::*;
99
pub(crate) use call_datetime_without_tzinfo::*;
10+
pub(crate) use datetime_min_max::*;
1011

1112
mod call_date_fromtimestamp;
1213
mod call_date_today;
@@ -17,4 +18,5 @@ mod call_datetime_today;
1718
mod call_datetime_utcfromtimestamp;
1819
mod call_datetime_utcnow;
1920
mod call_datetime_without_tzinfo;
21+
mod datetime_min_max;
2022
mod helpers;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
---
2+
source: crates/ruff_linter/src/rules/flake8_datetimez/mod.rs
3+
---
4+
DTZ901.py:5:1: DTZ901 Use of `datetime.datetime.max` without timezone information
5+
|
6+
4 | # Error
7+
5 | datetime.datetime.max
8+
| ^^^^^^^^^^^^^^^^^^^^^ DTZ901
9+
6 | datetime.datetime.min
10+
|
11+
= help: Replace with `datetime.datetime.max.replace(tzinfo=...)`
12+
13+
DTZ901.py:6:1: DTZ901 Use of `datetime.datetime.min` without timezone information
14+
|
15+
4 | # Error
16+
5 | datetime.datetime.max
17+
6 | datetime.datetime.min
18+
| ^^^^^^^^^^^^^^^^^^^^^ DTZ901
19+
7 |
20+
8 | datetime.datetime.max.replace(year=...)
21+
|
22+
= help: Replace with `datetime.datetime.min.replace(tzinfo=...)`
23+
24+
DTZ901.py:8:1: DTZ901 Use of `datetime.datetime.max` without timezone information
25+
|
26+
6 | datetime.datetime.min
27+
7 |
28+
8 | datetime.datetime.max.replace(year=...)
29+
| ^^^^^^^^^^^^^^^^^^^^^ DTZ901
30+
9 | datetime.datetime.min.replace(hour=...)
31+
|
32+
= help: Replace with `datetime.datetime.max.replace(tzinfo=...)`
33+
34+
DTZ901.py:9:1: DTZ901 Use of `datetime.datetime.min` without timezone information
35+
|
36+
8 | datetime.datetime.max.replace(year=...)
37+
9 | datetime.datetime.min.replace(hour=...)
38+
| ^^^^^^^^^^^^^^^^^^^^^ DTZ901
39+
|
40+
= help: Replace with `datetime.datetime.min.replace(tzinfo=...)`
41+
42+
DTZ901.py:21:1: DTZ901 Use of `datetime.datetime.max` without timezone information
43+
|
44+
20 | # Error
45+
21 | datetime.max
46+
| ^^^^^^^^^^^^ DTZ901
47+
22 | datetime.min
48+
|
49+
= help: Replace with `datetime.datetime.max.replace(tzinfo=...)`
50+
51+
DTZ901.py:22:1: DTZ901 Use of `datetime.datetime.min` without timezone information
52+
|
53+
20 | # Error
54+
21 | datetime.max
55+
22 | datetime.min
56+
| ^^^^^^^^^^^^ DTZ901
57+
23 |
58+
24 | datetime.max.replace(year=...)
59+
|
60+
= help: Replace with `datetime.datetime.min.replace(tzinfo=...)`
61+
62+
DTZ901.py:24:1: DTZ901 Use of `datetime.datetime.max` without timezone information
63+
|
64+
22 | datetime.min
65+
23 |
66+
24 | datetime.max.replace(year=...)
67+
| ^^^^^^^^^^^^ DTZ901
68+
25 | datetime.min.replace(hour=...)
69+
|
70+
= help: Replace with `datetime.datetime.max.replace(tzinfo=...)`
71+
72+
DTZ901.py:25:1: DTZ901 Use of `datetime.datetime.min` without timezone information
73+
|
74+
24 | datetime.max.replace(year=...)
75+
25 | datetime.min.replace(hour=...)
76+
| ^^^^^^^^^^^^ DTZ901
77+
|
78+
= help: Replace with `datetime.datetime.min.replace(tzinfo=...)`

ruff.schema.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)