From 69ca079a2d6d76794bac621be9f292bfd9761796 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:51:24 +0530 Subject: [PATCH] fix(vars): compare timezone-aware datetimes by instant Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com> --- packages/reflex-base/news/6755.bugfix.md | 1 + .../src/reflex_base/vars/datetime.py | 72 ++++++++++++++++--- .../test_datetime_operations.py | 20 +++++- tests/units/test_var.py | 34 +++++++++ 4 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 packages/reflex-base/news/6755.bugfix.md diff --git a/packages/reflex-base/news/6755.bugfix.md b/packages/reflex-base/news/6755.bugfix.md new file mode 100644 index 00000000000..a0d49f61537 --- /dev/null +++ b/packages/reflex-base/news/6755.bugfix.md @@ -0,0 +1 @@ +Compare timezone-aware datetime Vars by their instants instead of their serialized UTC offsets. diff --git a/packages/reflex-base/src/reflex_base/vars/datetime.py b/packages/reflex-base/src/reflex_base/vars/datetime.py index e001b94be98..d824a73798c 100644 --- a/packages/reflex-base/src/reflex_base/vars/datetime.py +++ b/packages/reflex-base/src/reflex_base/vars/datetime.py @@ -36,6 +36,34 @@ def raise_var_type_error(): class DateTimeVar(Var[DATETIME_T], python_types=(datetime, date)): """A variable that holds a datetime or date object.""" + __hash__ = Var.__hash__ + + def __eq__(self, other: Any) -> BooleanVar: + """Equal comparison. + + Args: + other: The other datetime to compare. + + Returns: + The result of the comparison. + """ + if not isinstance(other, DATETIME_TYPES): + return super().__eq__(other) + return date_eq_operation(self, other) + + def __ne__(self, other: Any) -> BooleanVar: + """Not equal comparison. + + Args: + other: The other datetime to compare. + + Returns: + The result of the comparison. + """ + if not isinstance(other, DATETIME_TYPES): + return super().__ne__(other) + return date_ne_operation(self, other) + def __lt__(self, other: datetime_types | DateTimeVar) -> BooleanVar: """Less than comparison. @@ -89,6 +117,34 @@ def __ge__(self, other: datetime_types | DateTimeVar) -> BooleanVar: return date_ge_operation(self, other) +@var_operation +def date_eq_operation(lhs: DateTimeVar | Any, rhs: DateTimeVar | Any): + """Equal comparison. + + Args: + lhs: The left-hand side of the operation. + rhs: The right-hand side of the operation. + + Returns: + The result of the operation. + """ + return date_compare_operation(lhs, rhs, "===") + + +@var_operation +def date_ne_operation(lhs: DateTimeVar | Any, rhs: DateTimeVar | Any): + """Not equal comparison. + + Args: + lhs: The left-hand side of the operation. + rhs: The right-hand side of the operation. + + Returns: + The result of the operation. + """ + return date_compare_operation(lhs, rhs, "!==") + + @var_operation def date_gt_operation(lhs: DateTimeVar | Any, rhs: DateTimeVar | Any): """Greater than comparison. @@ -100,7 +156,7 @@ def date_gt_operation(lhs: DateTimeVar | Any, rhs: DateTimeVar | Any): Returns: The result of the operation. """ - return date_compare_operation(rhs, lhs, strict=True) + return date_compare_operation(lhs, rhs, ">") @var_operation @@ -114,7 +170,7 @@ def date_lt_operation(lhs: DateTimeVar | Any, rhs: DateTimeVar | Any): Returns: The result of the operation. """ - return date_compare_operation(lhs, rhs, strict=True) + return date_compare_operation(lhs, rhs, "<") @var_operation @@ -128,7 +184,7 @@ def date_le_operation(lhs: DateTimeVar | Any, rhs: DateTimeVar | Any): Returns: The result of the operation. """ - return date_compare_operation(lhs, rhs) + return date_compare_operation(lhs, rhs, "<=") @var_operation @@ -142,26 +198,26 @@ def date_ge_operation(lhs: DateTimeVar | Any, rhs: DateTimeVar | Any): Returns: The result of the operation. """ - return date_compare_operation(rhs, lhs) + return date_compare_operation(lhs, rhs, ">=") def date_compare_operation( lhs: DateTimeVar[DATETIME_T] | Any, rhs: DateTimeVar[DATETIME_T] | Any, - strict: bool = False, + operator: str, ) -> CustomVarOperationReturn[bool]: - """Check if the value is less than the other value. + """Compare datetime values by their timestamps. Args: lhs: The left-hand side of the operation. rhs: The right-hand side of the operation. - strict: Whether to use strict comparison. + operator: The comparison operator. Returns: The result of the operation. """ return var_operation_return( - f"({lhs} {'<' if strict else '<='} {rhs})", + f"(new Date({lhs}).getTime() {operator} new Date({rhs}).getTime())", bool, ) diff --git a/tests/integration/tests_playwright/test_datetime_operations.py b/tests/integration/tests_playwright/test_datetime_operations.py index 66d2f88ee6c..deee8b19052 100644 --- a/tests/integration/tests_playwright/test_datetime_operations.py +++ b/tests/integration/tests_playwright/test_datetime_operations.py @@ -7,7 +7,7 @@ def DatetimeOperationsApp(): - from datetime import datetime + from datetime import datetime, timedelta, timezone import reflex as rx @@ -15,6 +15,9 @@ class DtOperationsState(rx.State): date1: datetime = datetime(2021, 1, 1) date2: datetime = datetime(2031, 1, 1) date3: datetime = datetime(2021, 1, 1) + date4: datetime = datetime(2021, 1, 1, tzinfo=timezone.utc) + date5: datetime = datetime(2021, 1, 1, 1, tzinfo=timezone(timedelta(hours=1))) + date6: datetime = datetime(2021, 1, 1, 1, tzinfo=timezone(timedelta(hours=2))) app = rx.App(_state=DtOperationsState) @@ -38,6 +41,13 @@ def index(): rx.text(DtOperationsState.date1 <= DtOperationsState.date3, id="1_le_3"), rx.text(DtOperationsState.date1 > DtOperationsState.date3, id="1_gt_3"), rx.text(DtOperationsState.date1 >= DtOperationsState.date3, id="1_ge_3"), + rx.text("Operations with timezone offsets"), + rx.text(DtOperationsState.date4 == DtOperationsState.date5, id="4_eq_5"), + rx.text(DtOperationsState.date4 != DtOperationsState.date5, id="4_neq_5"), + rx.text(DtOperationsState.date6 < DtOperationsState.date4, id="6_lt_4"), + rx.text(DtOperationsState.date4 <= DtOperationsState.date5, id="4_le_5"), + rx.text(DtOperationsState.date4 > DtOperationsState.date6, id="4_gt_6"), + rx.text(DtOperationsState.date4 >= DtOperationsState.date5, id="4_ge_5"), ) @@ -85,3 +95,11 @@ def test_datetime_operations(datetime_operations_app: AppHarness, page: Page): expect(page.locator("id=1_le_3")).to_have_text("true") expect(page.locator("id=1_gt_3")).to_have_text("false") expect(page.locator("id=1_ge_3")).to_have_text("true") + + # Check comparisons normalize timezone offsets + expect(page.locator("id=4_eq_5")).to_have_text("true") + expect(page.locator("id=4_neq_5")).to_have_text("false") + expect(page.locator("id=6_lt_4")).to_have_text("true") + expect(page.locator("id=4_le_5")).to_have_text("true") + expect(page.locator("id=4_gt_6")).to_have_text("true") + expect(page.locator("id=4_ge_5")).to_have_text("true") diff --git a/tests/units/test_var.py b/tests/units/test_var.py index 1bb03eae9a1..5be2d032951 100644 --- a/tests/units/test_var.py +++ b/tests/units/test_var.py @@ -1,9 +1,11 @@ import decimal import json import math +import operator as op import re import typing from collections.abc import Mapping, Sequence +from datetime import datetime, timedelta, timezone from typing import cast import pytest @@ -366,6 +368,7 @@ def test_basic_operations(TestObj): str(LiteralArrayVar.create(["1", "2", "3"]).reverse()) == '["1", "2", "3"].slice().reverse()' ) + assert ( str(Var(_js_expr="foo")._var_set_state("state").to(list).reverse()) == "state.foo.slice().reverse()" @@ -374,6 +377,37 @@ def test_basic_operations(TestObj): assert str(Var(_js_expr="foo", _var_type=str).js_type()) == "(typeof(foo))" +@pytest.mark.parametrize( + ("operation", "operator"), + [ + (op.eq, "==="), + (op.ne, "!=="), + (op.lt, "<"), + (op.le, "<="), + (op.gt, ">"), + (op.ge, ">="), + ], +) +def test_datetime_comparison_uses_timestamps(operation, operator): + lhs = v(datetime(2024, 1, 1, 1, tzinfo=timezone(timedelta(hours=1)))) + rhs = datetime(2024, 1, 1, tzinfo=timezone.utc) + + assert str(operation(lhs, rhs)) == ( + f'(new Date("2024-01-01 01:00:00+01:00").getTime() {operator} ' + 'new Date("2024-01-01 00:00:00+00:00").getTime())' + ) + + +def test_datetime_equality_with_other_type_uses_default_comparison(): + value = v(datetime(2024, 1, 1)) + expected_equality = ( + '("2024-01-01 00:00:00"?.valueOf?.() === "2024-01-01 00:00:00"?.valueOf?.())' + ) + + assert str(value == "2024-01-01 00:00:00") == expected_equality + assert str(value != "2024-01-01 00:00:00") == f"!({expected_equality})" + + @pytest.mark.parametrize( ("var", "expected"), [