From f3fad4035895c34e36772fd19ca0722fafce5d20 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 14 Jan 2026 16:17:36 -0800 Subject: [PATCH 1/3] Fix Python 3.14 dict iteration error in schema_for_fields In Python 3.14, iterating over cls.__dict__.items() directly can raise RuntimeError: dictionary changed size during iteration. This fix creates a copy of the dictionary before iteration to prevent this error. Fixes #763 --- aredis_om/model/model.py | 2 +- tests/test_json_model.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/aredis_om/model/model.py b/aredis_om/model/model.py index be5d2de6..197bbbd1 100644 --- a/aredis_om/model/model.py +++ b/aredis_om/model/model.py @@ -3214,7 +3214,7 @@ def schema_for_fields(cls): for name, field in model_fields.items(): fields[name] = field - for name, field in cls.__dict__.items(): + for name, field in dict(cls.__dict__).items(): if isinstance(field, FieldInfo): if not field.annotation: field.annotation = cls.__annotations__.get(name) diff --git a/tests/test_json_model.py b/tests/test_json_model.py index a40179e7..1144051e 100644 --- a/tests/test_json_model.py +++ b/tests/test_json_model.py @@ -1693,3 +1693,27 @@ async def test_save_nx_with_pipeline(m, address): fetched2 = await m.Member.get(member2.pk) assert fetched1.first_name == "Andrew" assert fetched2.first_name == "Kim" + + +@py_test_mark_asyncio +async def test_schema_for_fields_does_not_modify_dict_during_iteration(m): + """ + Regression test for GitHub issue #763. + + In Python 3.14, iterating over cls.__dict__.items() directly can raise + RuntimeError: dictionary changed size during iteration. This test verifies + that JsonModel.schema_for_fields() works without raising this error by + ensuring we create a copy of the dict before iteration. + """ + # This should not raise RuntimeError on Python 3.14+ + # The fix is to use dict(cls.__dict__).items() instead of cls.__dict__.items() + schema = m.Member.schema_for_fields() + + # Verify the schema is generated correctly + assert isinstance(schema, list) + assert len(schema) > 0 + + # Verify schema contains expected fields + schema_str = " ".join(schema) + assert "first_name" in schema_str + assert "last_name" in schema_str From c2f1e7a47245939336a10d5f146f5fd778af67f6 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Thu, 15 Jan 2026 07:12:10 -0800 Subject: [PATCH 2/3] Use annotation-key iteration instead of dict copy Instead of copying cls.__dict__ to avoid Python 3.14 iteration errors, iterate over cls.__annotations__ keys and look up values individually. This avoids the memory overhead of copying the entire __dict__. Also adds comprehensive tests for schema_for_fields edge cases. --- aredis_om/model/model.py | 10 ++- tests/test_json_model.py | 179 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 4 deletions(-) diff --git a/aredis_om/model/model.py b/aredis_om/model/model.py index 197bbbd1..e2ff9840 100644 --- a/aredis_om/model/model.py +++ b/aredis_om/model/model.py @@ -3214,10 +3214,16 @@ def schema_for_fields(cls): for name, field in model_fields.items(): fields[name] = field - for name, field in dict(cls.__dict__).items(): + # Check for redis-om FieldInfo objects in __dict__ that may have extra + # attributes (index, sortable, etc.) not captured in model_fields. + # We iterate over annotation keys and look up in __dict__ rather than + # iterating __dict__.items() directly to avoid Python 3.14+ errors + # when the dict is modified during class construction. See #763. + for name in cls.__annotations__: + field = cls.__dict__.get(name) if isinstance(field, FieldInfo): if not field.annotation: - field.annotation = cls.__annotations__.get(name) + field.annotation = cls.__annotations__[name] fields[name] = field for name, field in cls.__annotations__.items(): if name in fields: diff --git a/tests/test_json_model.py b/tests/test_json_model.py index 1144051e..d3537b0d 100644 --- a/tests/test_json_model.py +++ b/tests/test_json_model.py @@ -1703,10 +1703,9 @@ async def test_schema_for_fields_does_not_modify_dict_during_iteration(m): In Python 3.14, iterating over cls.__dict__.items() directly can raise RuntimeError: dictionary changed size during iteration. This test verifies that JsonModel.schema_for_fields() works without raising this error by - ensuring we create a copy of the dict before iteration. + iterating over annotation keys and looking up in __dict__ individually. """ # This should not raise RuntimeError on Python 3.14+ - # The fix is to use dict(cls.__dict__).items() instead of cls.__dict__.items() schema = m.Member.schema_for_fields() # Verify the schema is generated correctly @@ -1717,3 +1716,179 @@ async def test_schema_for_fields_does_not_modify_dict_during_iteration(m): schema_str = " ".join(schema) assert "first_name" in schema_str assert "last_name" in schema_str + + +@py_test_mark_asyncio +async def test_schema_for_fields_with_indexed_fields(key_prefix, redis): + """Test schema_for_fields includes all indexed field types correctly.""" + + class TestIndexedFields(JsonModel, index=True): + text_field: str = Field(index=True) + numeric_field: int = Field(index=True) + tag_field: str = Field(index=True) + sortable_field: str = Field(index=True, sortable=True) + fulltext_field: str = Field(full_text_search=True) + + class Meta: + global_key_prefix = key_prefix + database = redis + + schema = TestIndexedFields.schema_for_fields() + schema_str = " ".join(schema) + + # All indexed fields should appear in schema + assert "text_field" in schema_str + assert "numeric_field" in schema_str + assert "tag_field" in schema_str + assert "sortable_field" in schema_str + assert "fulltext_field" in schema_str + assert "SORTABLE" in schema_str + + +@py_test_mark_asyncio +async def test_schema_for_fields_with_optional_fields(key_prefix, redis): + """Test schema_for_fields handles Optional fields correctly.""" + + class TestOptionalFields(JsonModel, index=True): + required_field: str = Field(index=True) + optional_field: Optional[str] = Field(index=True, default=None) + optional_with_default: Optional[int] = Field(index=True, default=42) + + class Meta: + global_key_prefix = key_prefix + database = redis + + schema = TestOptionalFields.schema_for_fields() + schema_str = " ".join(schema) + + assert "required_field" in schema_str + assert "optional_field" in schema_str + assert "optional_with_default" in schema_str + + +@py_test_mark_asyncio +async def test_schema_for_fields_with_inherited_fields(key_prefix, redis): + """Test schema_for_fields correctly includes inherited fields.""" + + class BaseModel(JsonModel): + base_field: str = Field(index=True) + + class Meta: + global_key_prefix = key_prefix + database = redis + + class ChildModel(BaseModel, index=True): + child_field: str = Field(index=True) + + schema = ChildModel.schema_for_fields() + schema_str = " ".join(schema) + + # Both base and child fields should be in schema + assert "base_field" in schema_str + assert "child_field" in schema_str + + +@py_test_mark_asyncio +async def test_schema_for_fields_with_embedded_model(key_prefix, redis): + """Test schema_for_fields handles embedded models.""" + + class EmbeddedAddress(EmbeddedJsonModel, index=True): + city: str = Field(index=True) + zip_code: str = Field(index=True) + + class PersonWithAddress(JsonModel, index=True): + name: str = Field(index=True) + address: EmbeddedAddress + + class Meta: + global_key_prefix = key_prefix + database = redis + + schema = PersonWithAddress.schema_for_fields() + schema_str = " ".join(schema) + + # Main field and embedded fields should be in schema + assert "name" in schema_str + assert "city" in schema_str or "address" in schema_str + + +@py_test_mark_asyncio +async def test_schema_for_fields_with_list_fields(key_prefix, redis): + """Test schema_for_fields handles List[str] fields.""" + + class ModelWithList(JsonModel, index=True): + tags: List[str] = Field(index=True) + name: str = Field(index=True) + + class Meta: + global_key_prefix = key_prefix + database = redis + + schema = ModelWithList.schema_for_fields() + schema_str = " ".join(schema) + + assert "tags" in schema_str + assert "name" in schema_str + + +@py_test_mark_asyncio +async def test_schema_for_fields_field_info_has_annotation(key_prefix, redis): + """Test that FieldInfo objects have their annotations set correctly.""" + from pydantic.fields import FieldInfo + + class TestModel(JsonModel, index=True): + indexed_str: str = Field(index=True) + indexed_int: int = Field(index=True) + + class Meta: + global_key_prefix = key_prefix + database = redis + + # Call schema_for_fields to trigger field processing + TestModel.schema_for_fields() + + # Check that model_fields have annotations + for name, field in TestModel.model_fields.items(): + if name == "pk": + continue + assert field.annotation is not None, f"Field {name} should have annotation" + + +@py_test_mark_asyncio +async def test_schema_for_fields_with_primary_key(key_prefix, redis): + """Test schema_for_fields handles custom primary keys.""" + + class ModelWithCustomPK(JsonModel, index=True): + custom_id: str = Field(primary_key=True, index=True) + name: str = Field(index=True) + + class Meta: + global_key_prefix = key_prefix + database = redis + + schema = ModelWithCustomPK.schema_for_fields() + schema_str = " ".join(schema) + + assert "custom_id" in schema_str + assert "name" in schema_str + + +@py_test_mark_asyncio +async def test_schema_for_fields_with_case_sensitive(key_prefix, redis): + """Test schema_for_fields respects case_sensitive option.""" + + class ModelWithCaseSensitive(JsonModel, index=True): + case_sensitive_field: str = Field(index=True, case_sensitive=True) + normal_field: str = Field(index=True) + + class Meta: + global_key_prefix = key_prefix + database = redis + + schema = ModelWithCaseSensitive.schema_for_fields() + schema_str = " ".join(schema) + + assert "case_sensitive_field" in schema_str + assert "normal_field" in schema_str + # Case sensitive fields use CASESENSITIVE in schema + assert "CASESENSITIVE" in schema_str From 921e1e63c22562a88c182bbe2a2b6669a5df7c77 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Thu, 15 Jan 2026 08:07:25 -0800 Subject: [PATCH 3/3] Bump version to 1.0.4-beta --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 697fb869..d1c4ba5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "redis-om" -version = "1.0.3-beta" +version = "1.0.4-beta" description = "Object mappings, and more, for Redis." authors = ["Redis OSS "] maintainers = ["Redis OSS "]