diff --git a/aredis_om/model/model.py b/aredis_om/model/model.py index b0586280..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] @@ -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() @@ -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] @@ -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)) @@ -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): diff --git a/tests/test_datetime_fix.py b/tests/test_datetime_fix.py index 8f8533c1..43968685 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 @@ -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 @@ -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): @@ -110,25 +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) - # 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 @@ -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 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