From 9411aa2f5aa958dea83bdc84949dca04e90210ba Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 24 Feb 2026 08:43:35 -0800 Subject: [PATCH 1/4] fix: Return UTC-aware datetimes from unmarshalling (#807) Previously, datetime fields were unmarshalled using datetime.fromtimestamp(value) which returns a naive datetime in the server's local timezone. This caused: - Non-deterministic behavior depending on server timezone - Inability to compare retrieved datetimes with timezone-aware datetimes - Time jumps around daylight savings transitions This fix changes unmarshalling to use datetime.fromtimestamp(value, timezone.utc) which returns a UTC-aware datetime. This follows the standard ORM pattern of storing UTC and returning UTC-aware datetimes. BREAKING CHANGE: Retrieved datetime fields are now UTC-aware instead of naive local time. Code that compared retrieved datetimes with naive datetimes will need to either: 1. Make the comparison datetime UTC-aware, or 2. Use .timestamp() for comparison Fixes #807 --- aredis_om/model/model.py | 9 +++- tests/test_datetime_fix.py | 101 +++++++++++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/aredis_om/model/model.py b/aredis_om/model/model.py index b0586280..e817a9ec 100644 --- a/aredis_om/model/model.py +++ b/aredis_om/model/model.py @@ -150,8 +150,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() diff --git a/tests/test_datetime_fix.py b/tests/test_datetime_fix.py index 8f8533c1..d70c7cfb 100644 --- a/tests/test_datetime_fix.py +++ b/tests/test_datetime_fix.py @@ -3,6 +3,7 @@ """ import datetime +from zoneinfo import ZoneInfo import pytest @@ -74,8 +75,17 @@ async def test_hash_model_datetime_conversion(redis): 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()) + # 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}" @@ -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): @@ -124,8 +171,17 @@ async def test_json_model_datetime_conversion(redis): 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()) + # 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}" @@ -136,3 +192,40 @@ async def test_json_model_datetime_conversion(redis): 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 From 1d1e5e28047738e95118059dd69f70bd53c9f3d6 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 24 Feb 2026 08:54:11 -0800 Subject: [PATCH 2/4] style: Format test file with ruff --- tests/test_datetime_fix.py | 54 +++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/test_datetime_fix.py b/tests/test_datetime_fix.py index d70c7cfb..43968685 100644 --- a/tests/test_datetime_fix.py +++ b/tests/test_datetime_fix.py @@ -67,28 +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) # 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" + 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}" + assert time_diff < 1, ( + f"Datetime mismatch: got {retrieved.created_at}, expected {test_dt}" + ) finally: # Clean up @@ -157,34 +157,34 @@ 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) # 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" + 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}" + assert time_diff < 1, ( + f"Datetime mismatch: got {retrieved.created_at}, expected {test_dt}" + ) finally: # Clean up From 61fcb9735fdeeff124b9293460996ae885d4fba5 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 24 Feb 2026 08:58:31 -0800 Subject: [PATCH 3/4] style: Format model.py with ruff --- aredis_om/model/model.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/aredis_om/model/model.py b/aredis_om/model/model.py index e817a9ec..042deaf0 100644 --- a/aredis_om/model/model.py +++ b/aredis_om/model/model.py @@ -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] @@ -260,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] @@ -1529,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)) @@ -3357,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): From ac17c538eb605c655cbdb4ac8fe1db6d36afa940 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 24 Feb 2026 09:06:09 -0800 Subject: [PATCH 4/4] fix: Make test use UTC-aware datetime after fix --- tests/test_json_model.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_json_model.py b/tests/test_json_model.py index a79d777c..b1dd76df 100644 --- a/tests/test_json_model.py +++ b/tests/test_json_model.py @@ -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( @@ -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() @@ -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