Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/reflex-base/news/6755.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Compare timezone-aware datetime Vars by their instants instead of their serialized UTC offsets.
72 changes: 64 additions & 8 deletions packages/reflex-base/src/reflex_base/vars/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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())",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve microsecond precision in datetime comparisons

When either datetime has sub-millisecond precision, converting through Date.getTime() drops the microseconds that Reflex already serializes (for example, 2024-01-01 00:00:00.000001+00:00 and .000999+00:00 both become the same millisecond timestamp in JavaScript). In that scenario == now renders true and ordering comparisons render false even though the Python datetime instants differ, so apps comparing high-precision timestamps can get incorrect results.

Useful? React with 👍 / 👎.

bool,
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

Expand Down
20 changes: 19 additions & 1 deletion tests/integration/tests_playwright/test_datetime_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@


def DatetimeOperationsApp():
from datetime import datetime
from datetime import datetime, timedelta, timezone

import reflex as rx

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)

Expand All @@ -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"),
)


Expand Down Expand Up @@ -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")
34 changes: 34 additions & 0 deletions tests/units/test_var.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()"
Expand All @@ -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"),
[
Expand Down
Loading