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
24 changes: 15 additions & 9 deletions aredis_om/model/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ def convert_timestamp_to_datetime(obj, model_fields):
# For Optional[T] which is Union[T, None], get the non-None type
args = getattr(field_type, "__args__", ())
non_none_types = [
arg for arg in args if arg is not type(None) # noqa: E721
arg
for arg in args
if arg is not type(None) # noqa: E721
]
if len(non_none_types) == 1:
field_type = non_none_types[0]
Expand All @@ -150,8 +152,13 @@ def convert_timestamp_to_datetime(obj, model_fields):
try:
if isinstance(value, str):
value = float(value)
# Use fromtimestamp to preserve local timezone behavior
dt = datetime.datetime.fromtimestamp(value)
# Return UTC-aware datetime for consistency.
# Timestamps are always UTC-referenced, so we return
# UTC-aware datetimes. Users can convert to their
# preferred timezone with dt.astimezone(tz).
dt = datetime.datetime.fromtimestamp(
value, datetime.timezone.utc
)
# If the field is specifically a date, convert to date
if field_type is datetime.date:
result[key] = dt.date()
Expand Down Expand Up @@ -255,7 +262,9 @@ def convert_base64_to_bytes(obj, model_fields):
# For Optional[T] which is Union[T, None], get the non-None type
args = getattr(field_type, "__args__", ())
non_none_types = [
arg for arg in args if arg is not type(None) # noqa: E721
arg
for arg in args
if arg is not type(None) # noqa: E721
]
if len(non_none_types) == 1:
field_type = non_none_types[0]
Expand Down Expand Up @@ -1524,8 +1533,7 @@ def expand_tag_value(value):
return "|".join([escaper.escape(str(v)) for v in value])
except TypeError:
log.debug(
"Escaping single non-iterable value used for an IN or "
"NOT_IN query: %s",
"Escaping single non-iterable value used for an IN or NOT_IN query: %s",
value,
)
return escaper.escape(str(value))
Expand Down Expand Up @@ -3352,9 +3360,7 @@ def schema_for_type(cls, name, typ: Any, field_info: PydanticFieldInfo):
field_info, "separator", SINGLE_VALUE_TAG_FIELD_SEPARATOR
)
if getattr(field_info, "full_text_search", False) is True:
schema = (
f"{name} TAG SEPARATOR {separator} " f"{name} AS {name}_fts TEXT"
)
schema = f"{name} TAG SEPARATOR {separator} {name} AS {name}_fts TEXT"
else:
schema = f"{name} TAG SEPARATOR {separator}"
elif issubclass(typ, RedisModel):
Expand Down
131 changes: 112 additions & 19 deletions tests/test_datetime_fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import datetime
from zoneinfo import ZoneInfo

import pytest

Expand Down Expand Up @@ -66,19 +67,28 @@ async def test_hash_model_datetime_conversion(redis):

# Verify the timestamp is approximately correct
expected_timestamp = test_dt.timestamp()
assert (
abs(timestamp - expected_timestamp) < 1
), f"Timestamp mismatch: got {timestamp}, expected {expected_timestamp}"
assert abs(timestamp - expected_timestamp) < 1, (
f"Timestamp mismatch: got {timestamp}, expected {expected_timestamp}"
)

# Retrieve the model to ensure conversion back works
retrieved = await HashModelWithDatetime.get(test_model.pk)
assert isinstance(retrieved.created_at, datetime.datetime)

# The datetime should be the same (within a small margin for floating point precision)
time_diff = abs((retrieved.created_at - test_dt).total_seconds())
assert (
time_diff < 1
), f"Datetime mismatch: got {retrieved.created_at}, expected {test_dt}"
# Verify the returned datetime is UTC-aware
assert retrieved.created_at.tzinfo is not None, (
"Retrieved datetime should be timezone-aware"
)
assert retrieved.created_at.tzinfo == datetime.timezone.utc, (
"Retrieved datetime should be in UTC"
)

# The datetime should represent the same instant in time
# Compare timestamps since one is naive and one is aware
time_diff = abs(retrieved.created_at.timestamp() - test_dt.timestamp())
assert time_diff < 1, (
f"Datetime mismatch: got {retrieved.created_at}, expected {test_dt}"
)

finally:
# Clean up
Expand All @@ -88,6 +98,43 @@ async def test_hash_model_datetime_conversion(redis):
pass


@py_test_mark_asyncio
async def test_hash_model_timezone_aware_datetime(redis):
"""Test that timezone-aware datetimes are stored and retrieved correctly."""
HashModelWithDatetime._meta.database = redis

# Create a timezone-aware datetime in a non-UTC timezone
pacific = ZoneInfo("America/Los_Angeles")
test_dt = datetime.datetime(2023, 6, 15, 10, 30, 0, tzinfo=pacific)

test_model = HashModelWithDatetime(name="tz_test", created_at=test_dt)

try:
await test_model.save()

# Retrieve the model
retrieved = await HashModelWithDatetime.get(test_model.pk)

# The retrieved datetime should be UTC-aware
assert retrieved.created_at.tzinfo == datetime.timezone.utc

# The actual instant in time should be the same
# (comparing timestamps ensures we're comparing the same moment)
assert abs(retrieved.created_at.timestamp() - test_dt.timestamp()) < 1

# Converting the retrieved UTC datetime to Pacific should give us
# the original time
retrieved_pacific = retrieved.created_at.astimezone(pacific)
assert retrieved_pacific.hour == test_dt.hour
assert retrieved_pacific.minute == test_dt.minute

finally:
try:
await HashModelWithDatetime.db().delete(test_model.key())
except Exception:
pass


@pytest.mark.skipif(not has_redis_json(), reason="Redis JSON not available")
@py_test_mark_asyncio
async def test_json_model_datetime_conversion(redis):
Expand All @@ -110,29 +157,75 @@ async def test_json_model_datetime_conversion(redis):

print(f"Stored value: {created_at_value} (type: {type(created_at_value)})")

assert isinstance(
created_at_value, (int, float)
), f"Expected timestamp, got: {created_at_value} ({type(created_at_value)})"
assert isinstance(created_at_value, (int, float)), (
f"Expected timestamp, got: {created_at_value} ({type(created_at_value)})"
)

# Verify the timestamp is approximately correct
expected_timestamp = test_dt.timestamp()
assert (
abs(created_at_value - expected_timestamp) < 1
), f"Timestamp mismatch: got {created_at_value}, expected {expected_timestamp}"
assert abs(created_at_value - expected_timestamp) < 1, (
f"Timestamp mismatch: got {created_at_value}, expected {expected_timestamp}"
)

# Retrieve the model to ensure conversion back works
retrieved = await JsonModelWithDatetime.get(test_model.pk)
assert isinstance(retrieved.created_at, datetime.datetime)

# The datetime should be the same (within a small margin for floating point precision)
time_diff = abs((retrieved.created_at - test_dt).total_seconds())
assert (
time_diff < 1
), f"Datetime mismatch: got {retrieved.created_at}, expected {test_dt}"
# Verify the returned datetime is UTC-aware
assert retrieved.created_at.tzinfo is not None, (
"Retrieved datetime should be timezone-aware"
)
assert retrieved.created_at.tzinfo == datetime.timezone.utc, (
"Retrieved datetime should be in UTC"
)

# The datetime should represent the same instant in time
# Compare timestamps since one is naive and one is aware
time_diff = abs(retrieved.created_at.timestamp() - test_dt.timestamp())
assert time_diff < 1, (
f"Datetime mismatch: got {retrieved.created_at}, expected {test_dt}"
)

finally:
# Clean up
try:
await JsonModelWithDatetime.db().delete(test_model.key())
except Exception:
pass


@pytest.mark.skipif(not has_redis_json(), reason="Redis JSON not available")
@py_test_mark_asyncio
async def test_json_model_timezone_aware_datetime(redis):
"""Test that timezone-aware datetimes are stored and retrieved correctly."""
JsonModelWithDatetime._meta.database = redis

# Create a timezone-aware datetime in a non-UTC timezone
pacific = ZoneInfo("America/Los_Angeles")
test_dt = datetime.datetime(2023, 6, 15, 10, 30, 0, tzinfo=pacific)

test_model = JsonModelWithDatetime(name="tz_test", created_at=test_dt)

try:
await test_model.save()

# Retrieve the model
retrieved = await JsonModelWithDatetime.get(test_model.pk)

# The retrieved datetime should be UTC-aware
assert retrieved.created_at.tzinfo == datetime.timezone.utc

# The actual instant in time should be the same
assert abs(retrieved.created_at.timestamp() - test_dt.timestamp()) < 1

# Converting the retrieved UTC datetime to Pacific should give us
# the original time
retrieved_pacific = retrieved.created_at.astimezone(pacific)
assert retrieved_pacific.hour == test_dt.hour
assert retrieved_pacific.minute == test_dt.minute

finally:
try:
await JsonModelWithDatetime.db().delete(test_model.key())
except Exception:
pass
6 changes: 3 additions & 3 deletions tests/test_json_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,8 @@ async def test_recursive_query_expression_resolution(members, m):
async def test_recursive_query_field_resolution(members, m):
member1, _, _ = members
member1.address.note = m.Note(
description="Weird house", created_on=datetime.datetime.now()
description="Weird house",
created_on=datetime.datetime.now(datetime.timezone.utc),
)
await member1.save()
actual = await m.Member.find(
Expand All @@ -554,7 +555,7 @@ async def test_recursive_query_field_resolution(members, m):
m.Order(
items=[m.Item(price=10.99, name="Ball")],
total=10.99,
created_on=datetime.datetime.now(),
created_on=datetime.datetime.now(datetime.timezone.utc),
)
]
await member1.save()
Expand Down Expand Up @@ -1323,7 +1324,6 @@ class Game(JsonModel, index=True):

@py_test_mark_asyncio
async def test_model_validate_uses_default_values():

class ChildCls:
def __init__(self, first_name: str, other_name: str):
self.first_name = first_name
Expand Down
Loading