From 9411aa2f5aa958dea83bdc84949dca04e90210ba Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 24 Feb 2026 08:43:35 -0800 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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 From c6be10611f4db056cbe3550b997748767750229b Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 6 Mar 2026 09:22:07 -0800 Subject: [PATCH 5/8] fix: preserve date round-trips across timezones --- aredis_om/model/model.py | 7 ++++-- tests/test_datetime_date_fix.py | 39 ++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/aredis_om/model/model.py b/aredis_om/model/model.py index 042deaf0..cc109f9f 100644 --- a/aredis_om/model/model.py +++ b/aredis_om/model/model.py @@ -115,8 +115,11 @@ def convert_datetime_to_timestamp(obj): elif isinstance(obj, datetime.datetime): return obj.timestamp() elif isinstance(obj, datetime.date): - # Convert date to datetime at midnight and get timestamp - dt = datetime.datetime.combine(obj, datetime.time.min) + # Date values represent calendar days, so normalize to UTC midnight + # to avoid timezone-dependent day shifts on round-trip conversion. + dt = datetime.datetime.combine( + obj, datetime.time.min, tzinfo=datetime.timezone.utc + ) return dt.timestamp() else: return obj diff --git a/tests/test_datetime_date_fix.py b/tests/test_datetime_date_fix.py index 9a3424f7..7134fc9b 100644 --- a/tests/test_datetime_date_fix.py +++ b/tests/test_datetime_date_fix.py @@ -3,11 +3,18 @@ """ import datetime +import os +import time import pytest from aredis_om import Field -from aredis_om.model.model import HashModel, JsonModel +from aredis_om.model.model import ( + HashModel, + JsonModel, + convert_datetime_to_timestamp, + convert_timestamp_to_datetime, +) # We need to run this check as sync code (during tests) even in async mode # because we call it in the top-level module scope. @@ -32,6 +39,36 @@ class Meta: global_key_prefix = "test_date_fix" +@pytest.mark.skipif(not hasattr(time, "tzset"), reason="time.tzset not available") +def test_date_timestamp_round_trip_is_tz_independent(): + """Date values should round-trip without shifting across local timezones.""" + original_tz = os.environ.get("TZ") + try: + os.environ["TZ"] = "Asia/Karachi" # UTC+5 to expose local-midnight bugs + time.tzset() + + test_date = datetime.date(2023, 1, 1) + timestamp = convert_datetime_to_timestamp(test_date) + + # Stored timestamp should represent midnight UTC for that calendar date. + expected = datetime.datetime( + 2023, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc + ).timestamp() + assert timestamp == expected + + restored = convert_timestamp_to_datetime( + {"birth_date": timestamp}, + {"birth_date": HashModelWithDate.model_fields["birth_date"]}, + ) + assert restored["birth_date"] == test_date + finally: + if original_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = original_tz + time.tzset() + + @py_test_mark_asyncio async def test_hash_model_date_conversion(redis): """Test date conversion in HashModel.""" From 7f1af56d612538eb8175520b81c09c2663c874fb Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 6 Mar 2026 10:03:52 -0800 Subject: [PATCH 6/8] chore: keep sync output formatter-clean in CI --- .../data/builtin/datetime_migration.py | 10 ++++++---- .../migrations/schema/legacy_migrator.py | 3 ++- make_sync.py | 19 +++++++++++++------ tests/test_datetime_date_fix.py | 6 +++--- tests/test_find_query.py | 6 ++---- tests/test_hash_model.py | 13 ++++++------- 6 files changed, 32 insertions(+), 25 deletions(-) diff --git a/aredis_om/model/migrations/data/builtin/datetime_migration.py b/aredis_om/model/migrations/data/builtin/datetime_migration.py index 2500fa56..612d029a 100644 --- a/aredis_om/model/migrations/data/builtin/datetime_migration.py +++ b/aredis_om/model/migrations/data/builtin/datetime_migration.py @@ -180,9 +180,9 @@ def __init__(self): self.converted_fields = 0 self.skipped_fields = 0 self.failed_conversions = 0 - self.errors: List[Tuple[str, str, str, Exception]] = ( - [] - ) # (key, field, value, error) + self.errors: List[ + Tuple[str, str, str, Exception] + ] = [] # (key, field, value, error) def add_conversion_error(self, key: str, field: str, value: Any, error: Exception): """Record a conversion error.""" @@ -393,7 +393,9 @@ async def save_progress( } await self.redis.set( - self.state_key, json.dumps(state_data), ex=86400 # Expire after 24 hours + self.state_key, + json.dumps(state_data), + ex=86400, # Expire after 24 hours ) async def load_progress(self) -> Dict[str, Any]: diff --git a/aredis_om/model/migrations/schema/legacy_migrator.py b/aredis_om/model/migrations/schema/legacy_migrator.py index 518d5f25..6e0fc71f 100644 --- a/aredis_om/model/migrations/schema/legacy_migrator.py +++ b/aredis_om/model/migrations/schema/legacy_migrator.py @@ -52,7 +52,8 @@ def import_submodules(root_module_name: str): ) for loader, module_name, is_pkg in pkgutil.walk_packages( - root_module.__path__, root_module.__name__ + "." # type: ignore + root_module.__path__, + root_module.__name__ + ".", # type: ignore ): importlib.import_module(module_name) diff --git a/make_sync.py b/make_sync.py index 94c585d4..18eb1440 100644 --- a/make_sync.py +++ b/make_sync.py @@ -1,6 +1,7 @@ import os import re import shutil +import subprocess from pathlib import Path import unasync @@ -116,25 +117,31 @@ def remove_run_async_call(match): # Post-process model.py to fix async imports for sync version model_file = Path(__file__).absolute().parent / "redis_om/model/model.py" if model_file.exists(): - with open(model_file, 'r') as f: + with open(model_file, "r") as f: content = f.read() # Fix supports_hash_field_expiration to check sync Redis class # The unasync replacement doesn't work for dotted attribute access content = content.replace( - 'redis_lib.asyncio.Redis', - 'redis_lib.Redis' + "redis_lib.asyncio.Redis", + "redis_lib.Redis", ) # Fix Pipeline import: redis.asyncio.client -> redis.client content = content.replace( - 'from redis.asyncio.client import Pipeline', - 'from redis.client import Pipeline' + "from redis.asyncio.client import Pipeline", + "from redis.client import Pipeline", ) - with open(model_file, 'w') as f: + with open(model_file, "w") as f: f.write(content) + # Ensure generated sync code is formatter-clean for CI lint checks. + subprocess.run( + ["ruff", "format", str(redis_om_dir), str(tests_sync_dir)], + check=True, + ) + if __name__ == "__main__": main() diff --git a/tests/test_datetime_date_fix.py b/tests/test_datetime_date_fix.py index 7134fc9b..98540d4c 100644 --- a/tests/test_datetime_date_fix.py +++ b/tests/test_datetime_date_fix.py @@ -130,9 +130,9 @@ async def test_json_model_date_conversion(redis): # The birth_date field should be stored as a timestamp (number) birth_date_value = raw_data.get("birth_date") - assert isinstance( - birth_date_value, (int, float) - ), f"Expected timestamp, got: {birth_date_value} ({type(birth_date_value)})" + assert isinstance(birth_date_value, (int, float)), ( + f"Expected timestamp, got: {birth_date_value} ({type(birth_date_value)})" + ) # Retrieve the model to ensure conversion back works retrieved = await JsonModelWithDate.get(test_model.pk) diff --git a/tests/test_find_query.py b/tests/test_find_query.py index 80cf6be7..98f2e2a7 100644 --- a/tests/test_find_query.py +++ b/tests/test_find_query.py @@ -428,10 +428,8 @@ async def test_find_query_monster(m): ~( ((m.Member.first_name == "Andrew") | (m.Member.age < 40)) & ( - ( - m.Member.last_name.contains("oo") - | ~(m.Member.email.startswith("z")) - ) + m.Member.last_name.contains("oo") + | ~(m.Member.email.startswith("z")) ) ) ], diff --git a/tests/test_hash_model.py b/tests/test_hash_model.py index 8c4c7e52..ba328751 100644 --- a/tests/test_hash_model.py +++ b/tests/test_hash_model.py @@ -1013,7 +1013,6 @@ class Child(Model, HashModel, 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 @@ -1242,9 +1241,9 @@ async def test_values_type_conversion(members, m): # Should be dictionary with proper types assert isinstance(result, dict) assert isinstance(result["first_name"], str) - assert isinstance( - result["age"], int - ), f"Expected int, got {type(result['age'])} with value {result['age']}" + assert isinstance(result["age"], int), ( + f"Expected int, got {type(result['age'])} with value {result['age']}" + ) @py_test_mark_asyncio @@ -1261,9 +1260,9 @@ async def test_only_type_conversion(members, m): assert isinstance(result, PartialModel) assert isinstance(result.first_name, str) - assert isinstance( - result.age, int - ), f"Expected int, got {type(result.age)} with value {result.age}" + assert isinstance(result.age, int), ( + f"Expected int, got {type(result.age)} with value {result.age}" + ) @py_test_mark_asyncio From b2bb0050646823dafe035cf2a21f5e8ace2ba6e9 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 6 Mar 2026 10:07:46 -0800 Subject: [PATCH 7/8] chore: align lint with current ruff rules --- aredis_om/connections.py | 1 + aredis_om/model/encoders.py | 1 + aredis_om/model/migrations/__init__.py | 1 + aredis_om/model/migrations/data/__init__.py | 1 + aredis_om/model/migrations/data/base.py | 1 + .../model/migrations/data/builtin/__init__.py | 1 + .../data/builtin/datetime_migration.py | 1 + aredis_om/model/migrations/schema/__init__.py | 1 + .../migrations/schema/legacy_migrator.py | 1 + aredis_om/model/model.py | 24 +++++++++---------- aredis_om/model/types.py | 1 + make_sync.py | 14 +++++++++++ tests/conftest.py | 1 + tests/test_benchmarks.py | 1 + tests/test_bug_fixes.py | 1 + tests/test_examples.py | 1 + tests/test_find_query.py | 1 + tests/test_hash_field_expiration.py | 1 + tests/test_hash_model.py | 1 + tests/test_json_model.py | 1 + tests/test_knn_expression.py | 1 + tests/test_oss_redis_features.py | 1 + tests/test_pydantic_integrations.py | 1 + tests/test_redisvl_integration.py | 1 + tests/test_tag_separator.py | 1 + 25 files changed, 49 insertions(+), 12 deletions(-) diff --git a/aredis_om/connections.py b/aredis_om/connections.py index b618f6fd..a8e693e2 100644 --- a/aredis_om/connections.py +++ b/aredis_om/connections.py @@ -2,6 +2,7 @@ from . import redis + URL = os.environ.get("REDIS_OM_URL", None) diff --git a/aredis_om/model/encoders.py b/aredis_om/model/encoders.py index 60453822..cf493d12 100644 --- a/aredis_om/model/encoders.py +++ b/aredis_om/model/encoders.py @@ -33,6 +33,7 @@ from pydantic import BaseModel + try: from pydantic.deprecated.json import ENCODERS_BY_TYPE from pydantic_core import PydanticUndefined diff --git a/aredis_om/model/migrations/__init__.py b/aredis_om/model/migrations/__init__.py index 2a1cdd53..18c7ef71 100644 --- a/aredis_om/model/migrations/__init__.py +++ b/aredis_om/model/migrations/__init__.py @@ -18,6 +18,7 @@ SchemaMigrator, ) + __all__ = [ # Data migrations "BaseMigration", diff --git a/aredis_om/model/migrations/data/__init__.py b/aredis_om/model/migrations/data/__init__.py index 0e857927..a393a88c 100644 --- a/aredis_om/model/migrations/data/__init__.py +++ b/aredis_om/model/migrations/data/__init__.py @@ -8,4 +8,5 @@ from .base import BaseMigration, DataMigrationError from .migrator import DataMigrator + __all__ = ["BaseMigration", "DataMigrationError", "DataMigrator"] diff --git a/aredis_om/model/migrations/data/base.py b/aredis_om/model/migrations/data/base.py index 96365215..51529bfd 100644 --- a/aredis_om/model/migrations/data/base.py +++ b/aredis_om/model/migrations/data/base.py @@ -9,6 +9,7 @@ import time from typing import Any, Dict, List + try: import psutil except ImportError: diff --git a/aredis_om/model/migrations/data/builtin/__init__.py b/aredis_om/model/migrations/data/builtin/__init__.py index 8b17ac13..be379215 100644 --- a/aredis_om/model/migrations/data/builtin/__init__.py +++ b/aredis_om/model/migrations/data/builtin/__init__.py @@ -11,4 +11,5 @@ DatetimeFieldMigration, ) + __all__ = ["DatetimeFieldMigration", "DatetimeFieldDetector", "ConversionFailureMode"] diff --git a/aredis_om/model/migrations/data/builtin/datetime_migration.py b/aredis_om/model/migrations/data/builtin/datetime_migration.py index 612d029a..e4c0607c 100644 --- a/aredis_om/model/migrations/data/builtin/datetime_migration.py +++ b/aredis_om/model/migrations/data/builtin/datetime_migration.py @@ -16,6 +16,7 @@ from ..base import BaseMigration, DataMigrationError + log = logging.getLogger(__name__) diff --git a/aredis_om/model/migrations/schema/__init__.py b/aredis_om/model/migrations/schema/__init__.py index eebcba47..6a0c28bf 100644 --- a/aredis_om/model/migrations/schema/__init__.py +++ b/aredis_om/model/migrations/schema/__init__.py @@ -12,6 +12,7 @@ from .legacy_migrator import MigrationAction, MigrationError, Migrator, SchemaDetector from .migrator import SchemaMigrator + __all__ = [ # Primary API "BaseSchemaMigration", diff --git a/aredis_om/model/migrations/schema/legacy_migrator.py b/aredis_om/model/migrations/schema/legacy_migrator.py index 6e0fc71f..f2de2a57 100644 --- a/aredis_om/model/migrations/schema/legacy_migrator.py +++ b/aredis_om/model/migrations/schema/legacy_migrator.py @@ -18,6 +18,7 @@ import redis + log = logging.getLogger(__name__) diff --git a/aredis_om/model/model.py b/aredis_om/model/model.py index cc109f9f..dde77e56 100644 --- a/aredis_om/model/model.py +++ b/aredis_om/model/model.py @@ -22,15 +22,14 @@ Type, TypeVar, Union, -) -from typing import get_args as typing_get_args -from typing import ( no_type_check, ) +from typing import get_args as typing_get_args from more_itertools import ichunked from pydantic import BaseModel + try: from pydantic import ConfigDict, TypeAdapter, field_validator @@ -73,6 +72,7 @@ from .token_escaper import TokenEscaper from .types import Coordinates, CoordinateType, GeoFilter + model_registry = {} _T = TypeVar("_T") Model = TypeVar("Model", bound="RedisModel") @@ -648,10 +648,10 @@ def embedded(cls): def is_supported_container_type(typ: Optional[type]) -> bool: # TODO: Wait, why don't we support indexing sets? - if typ == list or typ == tuple or typ == Literal: + if typ is list or typ is tuple or typ is Literal: return True unwrapped = get_origin(typ) - return unwrapped == list or unwrapped == tuple or unwrapped == Literal + return unwrapped is list or unwrapped is tuple or unwrapped is Literal def validate_model_fields(model: Type["RedisModel"], field_values: Dict[str, Any]): @@ -1068,7 +1068,7 @@ def _validate_deep_field_path(self, field_path: str): field_type, RedisModel ): current_model = field_type - elif field_type == dict: + elif field_type is dict: # Dict fields - we can't validate nested paths, just accept them return else: @@ -1101,7 +1101,7 @@ def _validate_deep_field_path(self, field_path: str): field_type, RedisModel ): current_model = field_type - elif field_type == dict: + elif field_type is dict: return # Can't validate further into dict else: raise QueryNotSupportedError( @@ -1186,18 +1186,18 @@ def _convert_projected_fields(self, raw_data: Dict[str, str]) -> Dict[str, Any]: field_type = getattr(field_info, "type_", str) # Handle common type conversions directly for efficiency - if field_type == int: + if field_type is int: converted_data[field_name] = int(raw_value) - elif field_type == float: + elif field_type is float: converted_data[field_name] = float(raw_value) - elif field_type == bool: + elif field_type is bool: # Redis may store bool as "True"/"False" or "1"/"0" converted_data[field_name] = raw_value.lower() in ( "true", "1", "yes", ) - elif field_type == str: + elif field_type is str: converted_data[field_name] = raw_value else: # For complex types, keep as string (could be enhanced later) @@ -1243,7 +1243,7 @@ def _has_complex_projected_fields(self) -> bool: field_type = getattr(field_info, "annotation", None) # Check for dict fields - if field_type == dict: + if field_type is dict: return True # Check for embedded models (subclasses of RedisModel) diff --git a/aredis_om/model/types.py b/aredis_om/model/types.py index 6b481d1c..448d723e 100644 --- a/aredis_om/model/types.py +++ b/aredis_om/model/types.py @@ -1,5 +1,6 @@ from typing import Annotated, Any, Literal, Tuple, Union + try: from pydantic import BeforeValidator, PlainSerializer from pydantic_extra_types.coordinate import Coordinate diff --git a/make_sync.py b/make_sync.py index 18eb1440..151b1df1 100644 --- a/make_sync.py +++ b/make_sync.py @@ -136,6 +136,20 @@ def remove_run_async_call(match): with open(model_file, "w") as f: f.write(content) + # Fix duplicated import introduced by Async->sync class replacement. + redisvl_file = Path(__file__).absolute().parent / "redis_om/redisvl.py" + if redisvl_file.exists(): + with open(redisvl_file, "r") as f: + content = f.read() + + content = content.replace( + "from redisvl.index import SearchIndex, SearchIndex", + "from redisvl.index import SearchIndex", + ) + + with open(redisvl_file, "w") as f: + f.write(content) + # Ensure generated sync code is formatter-clean for CI lint checks. subprocess.run( ["ruff", "format", str(redis_om_dir), str(tests_sync_dir)], diff --git a/tests/conftest.py b/tests/conftest.py index 570a5f64..9c8c96e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ from aredis_om import get_redis_connection + TEST_PREFIX = "redis-om:testing" diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py index 48bc3b5a..af853ec2 100644 --- a/tests/test_benchmarks.py +++ b/tests/test_benchmarks.py @@ -17,6 +17,7 @@ from aredis_om import EmbeddedJsonModel, Field, HashModel, JsonModel, Migrator + # Skip if pytest-benchmark is not installed pytest.importorskip("pytest_benchmark") diff --git a/tests/test_bug_fixes.py b/tests/test_bug_fixes.py index ced3931d..eeb28445 100644 --- a/tests/test_bug_fixes.py +++ b/tests/test_bug_fixes.py @@ -15,6 +15,7 @@ from .conftest import py_test_mark_asyncio + if not has_redisearch(): pytestmark = pytest.mark.skip diff --git a/tests/test_examples.py b/tests/test_examples.py index 08027c42..4582e4b8 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -22,6 +22,7 @@ from .conftest import py_test_mark_asyncio + # ============================================================ # ENUMS # ============================================================ diff --git a/tests/test_find_query.py b/tests/test_find_query.py index 98f2e2a7..22da377b 100644 --- a/tests/test_find_query.py +++ b/tests/test_find_query.py @@ -31,6 +31,7 @@ from .conftest import py_test_mark_asyncio + if not has_redis_json(): pytestmark = pytest.mark.skip diff --git a/tests/test_hash_field_expiration.py b/tests/test_hash_field_expiration.py index 96bb4ad1..300ebc9e 100644 --- a/tests/test_hash_field_expiration.py +++ b/tests/test_hash_field_expiration.py @@ -26,6 +26,7 @@ from .conftest import py_test_mark_asyncio + if not has_redisearch(): pytestmark = pytest.mark.skip diff --git a/tests/test_hash_model.py b/tests/test_hash_model.py index ba328751..787750d0 100644 --- a/tests/test_hash_model.py +++ b/tests/test_hash_model.py @@ -33,6 +33,7 @@ from .conftest import py_test_mark_asyncio + if not has_redisearch(): pytestmark = pytest.mark.skip diff --git a/tests/test_json_model.py b/tests/test_json_model.py index b1dd76df..b439f6ec 100644 --- a/tests/test_json_model.py +++ b/tests/test_json_model.py @@ -35,6 +35,7 @@ from .conftest import py_test_mark_asyncio + if not has_redis_json(): pytestmark = pytest.mark.skip diff --git a/tests/test_knn_expression.py b/tests/test_knn_expression.py index b8cf29e9..758e2d9d 100644 --- a/tests/test_knn_expression.py +++ b/tests/test_knn_expression.py @@ -14,6 +14,7 @@ from .conftest import py_test_mark_asyncio + if not has_redis_json(): pytestmark = pytest.mark.skip diff --git a/tests/test_oss_redis_features.py b/tests/test_oss_redis_features.py index 89630201..d9d267fa 100644 --- a/tests/test_oss_redis_features.py +++ b/tests/test_oss_redis_features.py @@ -12,6 +12,7 @@ from .conftest import py_test_mark_asyncio + today = datetime.date.today() diff --git a/tests/test_pydantic_integrations.py b/tests/test_pydantic_integrations.py index fdd42db7..82077735 100644 --- a/tests/test_pydantic_integrations.py +++ b/tests/test_pydantic_integrations.py @@ -10,6 +10,7 @@ from aredis_om import Field, HashModel, Migrator from tests._compat import EmailStr, ValidationError + today = datetime.date.today() diff --git a/tests/test_redisvl_integration.py b/tests/test_redisvl_integration.py index e4fe0131..7788f7ad 100644 --- a/tests/test_redisvl_integration.py +++ b/tests/test_redisvl_integration.py @@ -18,6 +18,7 @@ from .conftest import py_test_mark_asyncio + if not has_redis_json(): pytestmark = pytest.mark.skip diff --git a/tests/test_tag_separator.py b/tests/test_tag_separator.py index 16ae34db..f96b365a 100644 --- a/tests/test_tag_separator.py +++ b/tests/test_tag_separator.py @@ -19,6 +19,7 @@ from .conftest import py_test_mark_asyncio + if not has_redisearch(): pytestmark = pytest.mark.skip From 56d5f659b27075ebc368f8e659149e8ebf71a037 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 6 Mar 2026 10:16:01 -0800 Subject: [PATCH 8/8] fix: normalize date query timestamps to UTC --- aredis_om/model/model.py | 6 ++++-- tests/test_datetime_date_fix.py | 35 ++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/aredis_om/model/model.py b/aredis_om/model/model.py index dde77e56..07f6de14 100644 --- a/aredis_om/model/model.py +++ b/aredis_om/model/model.py @@ -1582,8 +1582,10 @@ def convert_numeric_value(v): if isinstance(v, datetime.date) and not isinstance( v, datetime.datetime ): - # Convert date to datetime at midnight - v = datetime.datetime.combine(v, datetime.time.min) + # Use UTC midnight so query conversion matches storage conversion. + v = datetime.datetime.combine( + v, datetime.time.min, tzinfo=datetime.timezone.utc + ) v = v.timestamp() return v diff --git a/tests/test_datetime_date_fix.py b/tests/test_datetime_date_fix.py index 98540d4c..4f61b1a4 100644 --- a/tests/test_datetime_date_fix.py +++ b/tests/test_datetime_date_fix.py @@ -8,7 +8,7 @@ import pytest -from aredis_om import Field +from aredis_om import Field, Migrator from aredis_om.model.model import ( HashModel, JsonModel, @@ -111,6 +111,39 @@ async def test_hash_model_date_conversion(redis): pass +@pytest.mark.skipif(not hasattr(time, "tzset"), reason="time.tzset not available") +@py_test_mark_asyncio +async def test_hash_model_date_query_is_tz_independent(redis): + """Date equality queries should match UTC-normalized stored timestamps.""" + original_tz = os.environ.get("TZ") + HashModelWithDate._meta.database = redis + test_model = None + + try: + os.environ["TZ"] = "Asia/Karachi" + time.tzset() + + await Migrator().run() + + test_date = datetime.date(2023, 1, 1) + test_model = HashModelWithDate(name="query-test", birth_date=test_date) + await test_model.save() + + results = await HashModelWithDate.find( + HashModelWithDate.birth_date == test_date + ).all() + + assert test_model.pk in {m.pk for m in results} + finally: + if original_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = original_tz + time.tzset() + if test_model is not None: + await HashModelWithDate.db().delete(test_model.key()) + + @pytest.mark.skipif(not has_redis_json(), reason="Redis JSON not available") @py_test_mark_asyncio async def test_json_model_date_conversion(redis):