diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_datetimez/DTZ901.py b/crates/ruff_linter/resources/test/fixtures/flake8_datetimez/DTZ901.py new file mode 100644 index 0000000000000..704ab974023bd --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/flake8_datetimez/DTZ901.py @@ -0,0 +1,30 @@ +import datetime + + +# Error +datetime.datetime.max +datetime.datetime.min + +datetime.datetime.max.replace(year=...) +datetime.datetime.min.replace(hour=...) + + +# No error +datetime.datetime.max.replace(tzinfo=...) +datetime.datetime.min.replace(tzinfo=...) + + +from datetime import datetime + + +# Error +datetime.max +datetime.min + +datetime.max.replace(year=...) +datetime.min.replace(hour=...) + + +# No error +datetime.max.replace(tzinfo=...) +datetime.min.replace(tzinfo=...) diff --git a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs index ad748fee33ae1..afed7febc5855 100644 --- a/crates/ruff_linter/src/checkers/ast/analyze/expression.rs +++ b/crates/ruff_linter/src/checkers/ast/analyze/expression.rs @@ -339,6 +339,9 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) { if checker.enabled(Rule::SixPY3) { flake8_2020::rules::name_or_attribute(checker, expr); } + if checker.enabled(Rule::DatetimeMinMax) { + flake8_datetimez::rules::datetime_max_min(checker, expr); + } if checker.enabled(Rule::BannedApi) { flake8_tidy_imports::rules::banned_attribute_access(checker, expr); } diff --git a/crates/ruff_linter/src/codes.rs b/crates/ruff_linter/src/codes.rs index 58dc217e1388b..c303ec2d21b2f 100644 --- a/crates/ruff_linter/src/codes.rs +++ b/crates/ruff_linter/src/codes.rs @@ -706,6 +706,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> { (Flake8Datetimez, "007") => (RuleGroup::Stable, rules::flake8_datetimez::rules::CallDatetimeStrptimeWithoutZone), (Flake8Datetimez, "011") => (RuleGroup::Stable, rules::flake8_datetimez::rules::CallDateToday), (Flake8Datetimez, "012") => (RuleGroup::Stable, rules::flake8_datetimez::rules::CallDateFromtimestamp), + (Flake8Datetimez, "901") => (RuleGroup::Preview, rules::flake8_datetimez::rules::DatetimeMinMax), // pygrep-hooks (PygrepHooks, "001") => (RuleGroup::Removed, rules::pygrep_hooks::rules::Eval), diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/mod.rs b/crates/ruff_linter/src/rules/flake8_datetimez/mod.rs index f803eb3e3c638..5489e4a31b3fa 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/mod.rs @@ -9,6 +9,7 @@ mod tests { use test_case::test_case; use crate::registry::Rule; + use crate::settings::types::PreviewMode; use crate::test::test_path; use crate::{assert_messages, settings}; @@ -30,4 +31,18 @@ mod tests { assert_messages!(snapshot, diagnostics); Ok(()) } + + #[test_case(Rule::DatetimeMinMax, Path::new("DTZ901.py"))] + fn preview_rules(rule_code: Rule, path: &Path) -> Result<()> { + let snapshot = format!("{}_{}", rule_code.noqa_code(), path.to_string_lossy()); + let diagnostics = test_path( + Path::new("flake8_datetimez").join(path).as_path(), + &settings::LinterSettings { + preview: PreviewMode::Enabled, + ..settings::LinterSettings::for_rule(rule_code) + }, + )?; + assert_messages!(snapshot, diagnostics); + Ok(()) + } } diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/rules/datetime_min_max.rs b/crates/ruff_linter/src/rules/flake8_datetimez/rules/datetime_min_max.rs new file mode 100644 index 0000000000000..a8227c27698d8 --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_datetimez/rules/datetime_min_max.rs @@ -0,0 +1,114 @@ +use std::fmt::{Display, Formatter}; + +use ruff_diagnostics::{Diagnostic, Violation}; +use ruff_macros::{derive_message_formats, violation}; +use ruff_python_ast::{Expr, ExprAttribute, ExprCall}; +use ruff_python_semantic::{Modules, SemanticModel}; +use ruff_text_size::Ranged; + +use crate::checkers::ast::Checker; + +/// ## What it does +/// Checks for uses of `datetime.datetime.max` and `datetime.datetime.min`. +/// +/// ## Why is this bad? +/// `datetime.max` and `datetime.min` are non-timezone-aware datetime objects. +/// +/// As such, operations on `datetime.max` and `datetime.min` may behave +/// unexpectedly, as in: +/// +/// ```python +/// # Timezone: UTC-14 +/// datetime.max.timestamp() # ValueError: year 10000 is out of range +/// datetime.min.timestamp() # ValueError: year 0 is out of range +/// ``` +/// +/// ## Example +/// ```python +/// datetime.max +/// ``` +/// +/// Use instead: +/// ```python +/// datetime.max.replace(tzinfo=datetime.UTC) +/// ``` +#[violation] +pub struct DatetimeMinMax { + min_max: MinMax, +} + +impl Violation for DatetimeMinMax { + #[derive_message_formats] + fn message(&self) -> String { + let DatetimeMinMax { min_max } = self; + format!("Use of `datetime.datetime.{min_max}` without timezone information") + } + + fn fix_title(&self) -> Option { + let DatetimeMinMax { min_max } = self; + Some(format!( + "Replace with `datetime.datetime.{min_max}.replace(tzinfo=...)`" + )) + } +} + +/// DTZ901 +pub(crate) fn datetime_max_min(checker: &mut Checker, expr: &Expr) { + let semantic = checker.semantic(); + + if !semantic.seen_module(Modules::DATETIME) { + return; + } + + let Some(qualified_name) = semantic.resolve_qualified_name(expr) else { + return; + }; + + let min_max = match qualified_name.segments() { + ["datetime", "datetime", "min"] => MinMax::Min, + ["datetime", "datetime", "max"] => MinMax::Max, + _ => return, + }; + + if followed_by_replace_tzinfo(checker.semantic()) { + return; + } + + checker + .diagnostics + .push(Diagnostic::new(DatetimeMinMax { min_max }, expr.range())); +} + +/// Check if the current expression has the pattern `foo.replace(tzinfo=bar)`. +fn followed_by_replace_tzinfo(semantic: &SemanticModel) -> bool { + let Some(parent) = semantic.current_expression_parent() else { + return false; + }; + let Some(grandparent) = semantic.current_expression_grandparent() else { + return false; + }; + + match (parent, grandparent) { + (Expr::Attribute(ExprAttribute { attr, .. }), Expr::Call(ExprCall { arguments, .. })) => { + attr.as_str() == "replace" && arguments.find_keyword("tzinfo").is_some() + } + _ => false, + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +enum MinMax { + /// `datetime.datetime.min` + Min, + /// `datetime.datetime.max` + Max, +} + +impl Display for MinMax { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + MinMax::Min => write!(f, "min"), + MinMax::Max => write!(f, "max"), + } + } +} diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/rules/helpers.rs b/crates/ruff_linter/src/rules/flake8_datetimez/rules/helpers.rs index a580c9bc95e76..551c082b101b4 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/rules/helpers.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/rules/helpers.rs @@ -8,10 +8,10 @@ pub(super) enum DatetimeModuleAntipattern { NonePassedToTzArgument, } -/// Check if the parent expression is a call to `astimezone`. This assumes that -/// the current expression is a `datetime.datetime` object. +/// Check if the parent expression is a call to `astimezone`. +/// This assumes that the current expression is a `datetime.datetime` object. pub(super) fn parent_expr_is_astimezone(checker: &Checker) -> bool { - checker.semantic().current_expression_parent().is_some_and( |parent| { + checker.semantic().current_expression_parent().is_some_and(|parent| { matches!(parent, Expr::Attribute(ExprAttribute { attr, .. }) if attr.as_str() == "astimezone") }) } diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/rules/mod.rs b/crates/ruff_linter/src/rules/flake8_datetimez/rules/mod.rs index 87646ca77565a..c119a97760dc9 100644 --- a/crates/ruff_linter/src/rules/flake8_datetimez/rules/mod.rs +++ b/crates/ruff_linter/src/rules/flake8_datetimez/rules/mod.rs @@ -7,6 +7,7 @@ pub(crate) use call_datetime_today::*; pub(crate) use call_datetime_utcfromtimestamp::*; pub(crate) use call_datetime_utcnow::*; pub(crate) use call_datetime_without_tzinfo::*; +pub(crate) use datetime_min_max::*; mod call_date_fromtimestamp; mod call_date_today; @@ -17,4 +18,5 @@ mod call_datetime_today; mod call_datetime_utcfromtimestamp; mod call_datetime_utcnow; mod call_datetime_without_tzinfo; +mod datetime_min_max; mod helpers; diff --git a/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ901_DTZ901.py.snap b/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ901_DTZ901.py.snap new file mode 100644 index 0000000000000..966ee8bc8951c --- /dev/null +++ b/crates/ruff_linter/src/rules/flake8_datetimez/snapshots/ruff_linter__rules__flake8_datetimez__tests__DTZ901_DTZ901.py.snap @@ -0,0 +1,78 @@ +--- +source: crates/ruff_linter/src/rules/flake8_datetimez/mod.rs +--- +DTZ901.py:5:1: DTZ901 Use of `datetime.datetime.max` without timezone information + | +4 | # Error +5 | datetime.datetime.max + | ^^^^^^^^^^^^^^^^^^^^^ DTZ901 +6 | datetime.datetime.min + | + = help: Replace with `datetime.datetime.max.replace(tzinfo=...)` + +DTZ901.py:6:1: DTZ901 Use of `datetime.datetime.min` without timezone information + | +4 | # Error +5 | datetime.datetime.max +6 | datetime.datetime.min + | ^^^^^^^^^^^^^^^^^^^^^ DTZ901 +7 | +8 | datetime.datetime.max.replace(year=...) + | + = help: Replace with `datetime.datetime.min.replace(tzinfo=...)` + +DTZ901.py:8:1: DTZ901 Use of `datetime.datetime.max` without timezone information + | +6 | datetime.datetime.min +7 | +8 | datetime.datetime.max.replace(year=...) + | ^^^^^^^^^^^^^^^^^^^^^ DTZ901 +9 | datetime.datetime.min.replace(hour=...) + | + = help: Replace with `datetime.datetime.max.replace(tzinfo=...)` + +DTZ901.py:9:1: DTZ901 Use of `datetime.datetime.min` without timezone information + | +8 | datetime.datetime.max.replace(year=...) +9 | datetime.datetime.min.replace(hour=...) + | ^^^^^^^^^^^^^^^^^^^^^ DTZ901 + | + = help: Replace with `datetime.datetime.min.replace(tzinfo=...)` + +DTZ901.py:21:1: DTZ901 Use of `datetime.datetime.max` without timezone information + | +20 | # Error +21 | datetime.max + | ^^^^^^^^^^^^ DTZ901 +22 | datetime.min + | + = help: Replace with `datetime.datetime.max.replace(tzinfo=...)` + +DTZ901.py:22:1: DTZ901 Use of `datetime.datetime.min` without timezone information + | +20 | # Error +21 | datetime.max +22 | datetime.min + | ^^^^^^^^^^^^ DTZ901 +23 | +24 | datetime.max.replace(year=...) + | + = help: Replace with `datetime.datetime.min.replace(tzinfo=...)` + +DTZ901.py:24:1: DTZ901 Use of `datetime.datetime.max` without timezone information + | +22 | datetime.min +23 | +24 | datetime.max.replace(year=...) + | ^^^^^^^^^^^^ DTZ901 +25 | datetime.min.replace(hour=...) + | + = help: Replace with `datetime.datetime.max.replace(tzinfo=...)` + +DTZ901.py:25:1: DTZ901 Use of `datetime.datetime.min` without timezone information + | +24 | datetime.max.replace(year=...) +25 | datetime.min.replace(hour=...) + | ^^^^^^^^^^^^ DTZ901 + | + = help: Replace with `datetime.datetime.min.replace(tzinfo=...)` diff --git a/ruff.schema.json b/ruff.schema.json index e5be3b35a9057..113cfedb8f4b3 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -3023,6 +3023,9 @@ "DTZ01", "DTZ011", "DTZ012", + "DTZ9", + "DTZ90", + "DTZ901", "E", "E1", "E10",