Skip to content

Commit 9f11bc7

Browse files
authored
Fix Python 3.14 dict iteration error in JsonModel (#764)
* 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 * 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. * Bump version to 1.0.4-beta
1 parent 51a38ff commit 9f11bc7

File tree

3 files changed

+208
-3
lines changed

3 files changed

+208
-3
lines changed

aredis_om/model/model.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3214,10 +3214,16 @@ def schema_for_fields(cls):
32143214

32153215
for name, field in model_fields.items():
32163216
fields[name] = field
3217-
for name, field in cls.__dict__.items():
3217+
# Check for redis-om FieldInfo objects in __dict__ that may have extra
3218+
# attributes (index, sortable, etc.) not captured in model_fields.
3219+
# We iterate over annotation keys and look up in __dict__ rather than
3220+
# iterating __dict__.items() directly to avoid Python 3.14+ errors
3221+
# when the dict is modified during class construction. See #763.
3222+
for name in cls.__annotations__:
3223+
field = cls.__dict__.get(name)
32183224
if isinstance(field, FieldInfo):
32193225
if not field.annotation:
3220-
field.annotation = cls.__annotations__.get(name)
3226+
field.annotation = cls.__annotations__[name]
32213227
fields[name] = field
32223228
for name, field in cls.__annotations__.items():
32233229
if name in fields:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "redis-om"
3-
version = "1.0.3-beta"
3+
version = "1.0.4-beta"
44
description = "Object mappings, and more, for Redis."
55
authors = ["Redis OSS <oss@redis.com>"]
66
maintainers = ["Redis OSS <oss@redis.com>"]

tests/test_json_model.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,3 +1693,202 @@ async def test_save_nx_with_pipeline(m, address):
16931693
fetched2 = await m.Member.get(member2.pk)
16941694
assert fetched1.first_name == "Andrew"
16951695
assert fetched2.first_name == "Kim"
1696+
1697+
1698+
@py_test_mark_asyncio
1699+
async def test_schema_for_fields_does_not_modify_dict_during_iteration(m):
1700+
"""
1701+
Regression test for GitHub issue #763.
1702+
1703+
In Python 3.14, iterating over cls.__dict__.items() directly can raise
1704+
RuntimeError: dictionary changed size during iteration. This test verifies
1705+
that JsonModel.schema_for_fields() works without raising this error by
1706+
iterating over annotation keys and looking up in __dict__ individually.
1707+
"""
1708+
# This should not raise RuntimeError on Python 3.14+
1709+
schema = m.Member.schema_for_fields()
1710+
1711+
# Verify the schema is generated correctly
1712+
assert isinstance(schema, list)
1713+
assert len(schema) > 0
1714+
1715+
# Verify schema contains expected fields
1716+
schema_str = " ".join(schema)
1717+
assert "first_name" in schema_str
1718+
assert "last_name" in schema_str
1719+
1720+
1721+
@py_test_mark_asyncio
1722+
async def test_schema_for_fields_with_indexed_fields(key_prefix, redis):
1723+
"""Test schema_for_fields includes all indexed field types correctly."""
1724+
1725+
class TestIndexedFields(JsonModel, index=True):
1726+
text_field: str = Field(index=True)
1727+
numeric_field: int = Field(index=True)
1728+
tag_field: str = Field(index=True)
1729+
sortable_field: str = Field(index=True, sortable=True)
1730+
fulltext_field: str = Field(full_text_search=True)
1731+
1732+
class Meta:
1733+
global_key_prefix = key_prefix
1734+
database = redis
1735+
1736+
schema = TestIndexedFields.schema_for_fields()
1737+
schema_str = " ".join(schema)
1738+
1739+
# All indexed fields should appear in schema
1740+
assert "text_field" in schema_str
1741+
assert "numeric_field" in schema_str
1742+
assert "tag_field" in schema_str
1743+
assert "sortable_field" in schema_str
1744+
assert "fulltext_field" in schema_str
1745+
assert "SORTABLE" in schema_str
1746+
1747+
1748+
@py_test_mark_asyncio
1749+
async def test_schema_for_fields_with_optional_fields(key_prefix, redis):
1750+
"""Test schema_for_fields handles Optional fields correctly."""
1751+
1752+
class TestOptionalFields(JsonModel, index=True):
1753+
required_field: str = Field(index=True)
1754+
optional_field: Optional[str] = Field(index=True, default=None)
1755+
optional_with_default: Optional[int] = Field(index=True, default=42)
1756+
1757+
class Meta:
1758+
global_key_prefix = key_prefix
1759+
database = redis
1760+
1761+
schema = TestOptionalFields.schema_for_fields()
1762+
schema_str = " ".join(schema)
1763+
1764+
assert "required_field" in schema_str
1765+
assert "optional_field" in schema_str
1766+
assert "optional_with_default" in schema_str
1767+
1768+
1769+
@py_test_mark_asyncio
1770+
async def test_schema_for_fields_with_inherited_fields(key_prefix, redis):
1771+
"""Test schema_for_fields correctly includes inherited fields."""
1772+
1773+
class BaseModel(JsonModel):
1774+
base_field: str = Field(index=True)
1775+
1776+
class Meta:
1777+
global_key_prefix = key_prefix
1778+
database = redis
1779+
1780+
class ChildModel(BaseModel, index=True):
1781+
child_field: str = Field(index=True)
1782+
1783+
schema = ChildModel.schema_for_fields()
1784+
schema_str = " ".join(schema)
1785+
1786+
# Both base and child fields should be in schema
1787+
assert "base_field" in schema_str
1788+
assert "child_field" in schema_str
1789+
1790+
1791+
@py_test_mark_asyncio
1792+
async def test_schema_for_fields_with_embedded_model(key_prefix, redis):
1793+
"""Test schema_for_fields handles embedded models."""
1794+
1795+
class EmbeddedAddress(EmbeddedJsonModel, index=True):
1796+
city: str = Field(index=True)
1797+
zip_code: str = Field(index=True)
1798+
1799+
class PersonWithAddress(JsonModel, index=True):
1800+
name: str = Field(index=True)
1801+
address: EmbeddedAddress
1802+
1803+
class Meta:
1804+
global_key_prefix = key_prefix
1805+
database = redis
1806+
1807+
schema = PersonWithAddress.schema_for_fields()
1808+
schema_str = " ".join(schema)
1809+
1810+
# Main field and embedded fields should be in schema
1811+
assert "name" in schema_str
1812+
assert "city" in schema_str or "address" in schema_str
1813+
1814+
1815+
@py_test_mark_asyncio
1816+
async def test_schema_for_fields_with_list_fields(key_prefix, redis):
1817+
"""Test schema_for_fields handles List[str] fields."""
1818+
1819+
class ModelWithList(JsonModel, index=True):
1820+
tags: List[str] = Field(index=True)
1821+
name: str = Field(index=True)
1822+
1823+
class Meta:
1824+
global_key_prefix = key_prefix
1825+
database = redis
1826+
1827+
schema = ModelWithList.schema_for_fields()
1828+
schema_str = " ".join(schema)
1829+
1830+
assert "tags" in schema_str
1831+
assert "name" in schema_str
1832+
1833+
1834+
@py_test_mark_asyncio
1835+
async def test_schema_for_fields_field_info_has_annotation(key_prefix, redis):
1836+
"""Test that FieldInfo objects have their annotations set correctly."""
1837+
from pydantic.fields import FieldInfo
1838+
1839+
class TestModel(JsonModel, index=True):
1840+
indexed_str: str = Field(index=True)
1841+
indexed_int: int = Field(index=True)
1842+
1843+
class Meta:
1844+
global_key_prefix = key_prefix
1845+
database = redis
1846+
1847+
# Call schema_for_fields to trigger field processing
1848+
TestModel.schema_for_fields()
1849+
1850+
# Check that model_fields have annotations
1851+
for name, field in TestModel.model_fields.items():
1852+
if name == "pk":
1853+
continue
1854+
assert field.annotation is not None, f"Field {name} should have annotation"
1855+
1856+
1857+
@py_test_mark_asyncio
1858+
async def test_schema_for_fields_with_primary_key(key_prefix, redis):
1859+
"""Test schema_for_fields handles custom primary keys."""
1860+
1861+
class ModelWithCustomPK(JsonModel, index=True):
1862+
custom_id: str = Field(primary_key=True, index=True)
1863+
name: str = Field(index=True)
1864+
1865+
class Meta:
1866+
global_key_prefix = key_prefix
1867+
database = redis
1868+
1869+
schema = ModelWithCustomPK.schema_for_fields()
1870+
schema_str = " ".join(schema)
1871+
1872+
assert "custom_id" in schema_str
1873+
assert "name" in schema_str
1874+
1875+
1876+
@py_test_mark_asyncio
1877+
async def test_schema_for_fields_with_case_sensitive(key_prefix, redis):
1878+
"""Test schema_for_fields respects case_sensitive option."""
1879+
1880+
class ModelWithCaseSensitive(JsonModel, index=True):
1881+
case_sensitive_field: str = Field(index=True, case_sensitive=True)
1882+
normal_field: str = Field(index=True)
1883+
1884+
class Meta:
1885+
global_key_prefix = key_prefix
1886+
database = redis
1887+
1888+
schema = ModelWithCaseSensitive.schema_for_fields()
1889+
schema_str = " ".join(schema)
1890+
1891+
assert "case_sensitive_field" in schema_str
1892+
assert "normal_field" in schema_str
1893+
# Case sensitive fields use CASESENSITIVE in schema
1894+
assert "CASESENSITIVE" in schema_str

0 commit comments

Comments
 (0)