Skip to content

Commit bdaa5b1

Browse files
authored
Fix bytes fields failing with UnicodeDecodeError (#783)
Bytes fields containing non-UTF8 data (e.g., binary files, images) caused UnicodeDecodeError because jsonable_encoder called bytes.decode() without specifying an encoding. Solution: Use base64 encoding for bytes fields before JSON serialization, and decode back to bytes on retrieval. This follows the same pattern as datetime timestamp conversion. Fixes #779
1 parent 700e512 commit bdaa5b1

3 files changed

Lines changed: 287 additions & 1 deletion

File tree

aredis_om/model/model.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,113 @@ def convert_timestamp_to_datetime(obj, model_fields):
216216
return obj
217217

218218

219+
def convert_bytes_to_base64(obj):
220+
"""Convert bytes objects to base64-encoded strings for storage.
221+
222+
This is necessary because Redis JSON and the jsonable_encoder cannot
223+
handle arbitrary binary data. Base64 encoding ensures all byte values
224+
(0-255) can be safely stored and retrieved.
225+
"""
226+
import base64
227+
228+
if isinstance(obj, dict):
229+
return {key: convert_bytes_to_base64(value) for key, value in obj.items()}
230+
elif isinstance(obj, list):
231+
return [convert_bytes_to_base64(item) for item in obj]
232+
elif isinstance(obj, bytes):
233+
return base64.b64encode(obj).decode("ascii")
234+
else:
235+
return obj
236+
237+
238+
def convert_base64_to_bytes(obj, model_fields):
239+
"""Convert base64-encoded strings back to bytes based on model field types."""
240+
import base64
241+
242+
if isinstance(obj, dict):
243+
result = {}
244+
for key, value in obj.items():
245+
if key in model_fields:
246+
field_info = model_fields[key]
247+
field_type = (
248+
field_info.annotation if hasattr(field_info, "annotation") else None
249+
)
250+
251+
# Handle Optional types - extract the inner type
252+
if hasattr(field_type, "__origin__") and field_type.__origin__ is Union:
253+
# For Optional[T] which is Union[T, None], get the non-None type
254+
args = getattr(field_type, "__args__", ())
255+
non_none_types = [
256+
arg for arg in args if arg is not type(None) # noqa: E721
257+
]
258+
if len(non_none_types) == 1:
259+
field_type = non_none_types[0]
260+
261+
# Handle bytes fields
262+
if field_type is bytes and isinstance(value, str):
263+
try:
264+
result[key] = base64.b64decode(value)
265+
except (ValueError, TypeError):
266+
# If it's not valid base64, keep original value
267+
result[key] = value
268+
# Handle nested models - check if it's a model with fields
269+
elif isinstance(value, dict):
270+
try:
271+
if (
272+
isinstance(field_type, type)
273+
and hasattr(field_type, "model_fields")
274+
and field_type.model_fields
275+
):
276+
result[key] = convert_base64_to_bytes(
277+
value, field_type.model_fields
278+
)
279+
else:
280+
result[key] = convert_base64_to_bytes(value, {})
281+
except (TypeError, AttributeError):
282+
result[key] = convert_base64_to_bytes(value, {})
283+
# Handle lists that might contain nested models
284+
elif isinstance(value, list):
285+
# Try to extract the inner type from List[SomeModel]
286+
inner_type = None
287+
if (
288+
hasattr(field_type, "__origin__")
289+
and field_type.__origin__ in (list, List)
290+
and hasattr(field_type, "__args__")
291+
and field_type.__args__
292+
):
293+
inner_type = field_type.__args__[0]
294+
295+
if inner_type is not None:
296+
try:
297+
if (
298+
isinstance(inner_type, type)
299+
and hasattr(inner_type, "model_fields")
300+
and inner_type.model_fields
301+
):
302+
result[key] = [
303+
convert_base64_to_bytes(item, inner_type.model_fields)
304+
if isinstance(item, dict)
305+
else item
306+
for item in value
307+
]
308+
else:
309+
result[key] = convert_base64_to_bytes(value, {})
310+
except (TypeError, AttributeError):
311+
result[key] = convert_base64_to_bytes(value, {})
312+
else:
313+
result[key] = convert_base64_to_bytes(value, {})
314+
else:
315+
result[key] = convert_base64_to_bytes(value, {})
316+
else:
317+
# For keys not in model_fields, still recurse but with empty field info
318+
result[key] = convert_base64_to_bytes(value, {})
319+
return result
320+
elif isinstance(obj, list):
321+
return [convert_base64_to_bytes(item, model_fields) for item in obj]
322+
else:
323+
return obj
324+
325+
219326
class PartialModel:
220327
"""A partial model instance that only contains certain fields.
221328
@@ -2558,10 +2665,14 @@ def to_string(s):
25582665
json_fields = convert_timestamp_to_datetime(
25592666
json_fields, cls.model_fields
25602667
)
2668+
# Convert base64 strings back to bytes for bytes fields
2669+
json_fields = convert_base64_to_bytes(json_fields, cls.model_fields)
25612670
doc = cls(**json_fields)
25622671
else:
25632672
# Convert timestamps back to datetime objects
25642673
fields = convert_timestamp_to_datetime(fields, cls.model_fields)
2674+
# Convert base64 strings back to bytes for bytes fields
2675+
fields = convert_base64_to_bytes(fields, cls.model_fields)
25652676
doc = cls(**fields)
25662677

25672678
docs.append(doc)
@@ -2752,9 +2863,10 @@ async def save(
27522863
self.check()
27532864
db = self._get_db(pipeline)
27542865

2755-
# Get model data and convert datetime objects first
2866+
# Get model data and apply conversions in the correct order
27562867
document = self.model_dump()
27572868
document = convert_datetime_to_timestamp(document)
2869+
document = convert_bytes_to_base64(document)
27582870

27592871
# Then apply jsonable encoding for other types
27602872
document = jsonable_encoder(document)
@@ -2854,6 +2966,8 @@ async def get(cls: Type["Model"], pk: Any) -> "Model":
28542966
try:
28552967
# Convert timestamps back to datetime objects before validation
28562968
document = convert_timestamp_to_datetime(document, cls.model_fields)
2969+
# Convert base64 strings back to bytes for bytes fields
2970+
document = convert_base64_to_bytes(document, cls.model_fields)
28572971
result = cls.model_validate(document)
28582972
except TypeError as e:
28592973
log.warning(
@@ -2865,6 +2979,8 @@ async def get(cls: Type["Model"], pk: Any) -> "Model":
28652979
document = decode_redis_value(document, cls.Meta.encoding)
28662980
# Convert timestamps back to datetime objects after decoding
28672981
document = convert_timestamp_to_datetime(document, cls.model_fields)
2982+
# Convert base64 strings back to bytes for bytes fields
2983+
document = convert_base64_to_bytes(document, cls.model_fields)
28682984
result = cls.model_validate(document)
28692985
return result
28702986

@@ -3126,6 +3242,8 @@ async def save(
31263242
data = self.model_dump()
31273243
# Convert datetime objects to timestamps for proper indexing
31283244
data = convert_datetime_to_timestamp(data)
3245+
# Convert bytes to base64 strings for safe JSON storage
3246+
data = convert_bytes_to_base64(data)
31293247
# Apply JSON encoding for complex types (Enums, UUIDs, Sets, etc.)
31303248
data = jsonable_encoder(data)
31313249

@@ -3199,6 +3317,8 @@ async def get(cls: Type["Model"], pk: Any) -> "Model":
31993317
raise NotFoundError
32003318
# Convert timestamps back to datetime objects before validation
32013319
document_data = convert_timestamp_to_datetime(document_data, cls.model_fields)
3320+
# Convert base64 strings back to bytes for bytes fields
3321+
document_data = convert_base64_to_bytes(document_data, cls.model_fields)
32023322
return cls.model_validate(document_data)
32033323

32043324
@classmethod

tests/test_hash_model.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1467,3 +1467,71 @@ async def test_save_nx_with_pipeline_raises_error(m):
14671467
async with m.Member.db().pipeline(transaction=True) as pipe:
14681468
with pytest.raises(ValueError, match="Cannot use nx or xx with pipeline"):
14691469
await member.save(pipeline=pipe, nx=True)
1470+
1471+
1472+
1473+
1474+
@py_test_mark_asyncio
1475+
async def test_bytes_field_with_binary_data(key_prefix, redis):
1476+
"""Test that bytes fields can store arbitrary binary data including non-UTF8 bytes.
1477+
1478+
Regression test for GitHub issue #779: bytes fields failed with UnicodeDecodeError
1479+
when storing actual binary data (non-UTF8 bytes).
1480+
"""
1481+
1482+
class FileHash(HashModel, index=True):
1483+
filename: str = Field(index=True)
1484+
content: bytes
1485+
1486+
class Meta:
1487+
global_key_prefix = key_prefix
1488+
database = redis
1489+
1490+
await Migrator().run()
1491+
1492+
# Test with binary data that is NOT valid UTF-8 (PNG header)
1493+
binary_content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
1494+
1495+
f = FileHash(filename="image.png", content=binary_content)
1496+
await f.save()
1497+
1498+
# Retrieve and verify
1499+
retrieved = await FileHash.get(f.pk)
1500+
assert retrieved.content == binary_content
1501+
assert retrieved.filename == "image.png"
1502+
1503+
# Test with null bytes and other non-printable characters
1504+
null_content = b"\x00\x01\x02\x03\xff\xfe\xfd"
1505+
f2 = FileHash(filename="binary.bin", content=null_content)
1506+
await f2.save()
1507+
1508+
retrieved2 = await FileHash.get(f2.pk)
1509+
assert retrieved2.content == null_content
1510+
1511+
1512+
@py_test_mark_asyncio
1513+
async def test_optional_bytes_field(key_prefix, redis):
1514+
"""Test that Optional[bytes] fields work correctly."""
1515+
from typing import Optional
1516+
1517+
class Attachment(HashModel, index=True):
1518+
name: str = Field(index=True)
1519+
data: Optional[bytes] = None
1520+
1521+
class Meta:
1522+
global_key_prefix = key_prefix
1523+
database = redis
1524+
1525+
await Migrator().run()
1526+
1527+
# Without data
1528+
a1 = Attachment(name="empty")
1529+
await a1.save()
1530+
r1 = await Attachment.get(a1.pk)
1531+
assert r1.data is None
1532+
1533+
# With binary data
1534+
a2 = Attachment(name="with_data", data=b"\x89PNG\x00\xff")
1535+
await a2.save()
1536+
r2 = await Attachment.get(a2.pk)
1537+
assert r2.data == b"\x89PNG\x00\xff"

tests/test_json_model.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1892,3 +1892,101 @@ class Meta:
18921892
assert "normal_field" in schema_str
18931893
# Case sensitive fields use CASESENSITIVE in schema
18941894
assert "CASESENSITIVE" in schema_str
1895+
1896+
1897+
1898+
1899+
@py_test_mark_asyncio
1900+
async def test_bytes_field_with_binary_data(key_prefix, redis):
1901+
"""Test that bytes fields can store arbitrary binary data including non-UTF8 bytes.
1902+
1903+
Regression test for GitHub issue #779: bytes fields failed with UnicodeDecodeError
1904+
when storing actual binary data (non-UTF8 bytes).
1905+
"""
1906+
1907+
class FileJson(JsonModel, index=True):
1908+
filename: str = Field(index=True)
1909+
content: bytes
1910+
1911+
class Meta:
1912+
global_key_prefix = key_prefix
1913+
database = redis
1914+
1915+
await Migrator().run()
1916+
1917+
# Test with binary data that is NOT valid UTF-8 (PNG header)
1918+
binary_content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
1919+
1920+
f = FileJson(filename="image.png", content=binary_content)
1921+
await f.save()
1922+
1923+
# Retrieve and verify
1924+
retrieved = await FileJson.get(f.pk)
1925+
assert retrieved.content == binary_content
1926+
assert retrieved.filename == "image.png"
1927+
1928+
# Test with null bytes and other non-printable characters
1929+
null_content = b"\x00\x01\x02\x03\xff\xfe\xfd"
1930+
f2 = FileJson(filename="binary.bin", content=null_content)
1931+
await f2.save()
1932+
1933+
retrieved2 = await FileJson.get(f2.pk)
1934+
assert retrieved2.content == null_content
1935+
1936+
1937+
@py_test_mark_asyncio
1938+
async def test_optional_bytes_field(key_prefix, redis):
1939+
"""Test that Optional[bytes] fields work correctly."""
1940+
from typing import Optional
1941+
1942+
class Attachment(JsonModel, index=True):
1943+
name: str = Field(index=True)
1944+
data: Optional[bytes] = None
1945+
1946+
class Meta:
1947+
global_key_prefix = key_prefix
1948+
database = redis
1949+
1950+
await Migrator().run()
1951+
1952+
# Without data
1953+
a1 = Attachment(name="empty")
1954+
await a1.save()
1955+
r1 = await Attachment.get(a1.pk)
1956+
assert r1.data is None
1957+
1958+
# With binary data
1959+
a2 = Attachment(name="with_data", data=b"\x89PNG\x00\xff")
1960+
await a2.save()
1961+
r2 = await Attachment.get(a2.pk)
1962+
assert r2.data == b"\x89PNG\x00\xff"
1963+
1964+
1965+
@py_test_mark_asyncio
1966+
async def test_bytes_field_in_embedded_model(key_prefix, redis):
1967+
"""Test that bytes fields work in embedded models."""
1968+
1969+
class FileData(EmbeddedJsonModel):
1970+
content: bytes
1971+
mime_type: str
1972+
1973+
class Document(JsonModel, index=True):
1974+
name: str = Field(index=True)
1975+
file: FileData
1976+
1977+
class Meta:
1978+
global_key_prefix = key_prefix
1979+
database = redis
1980+
1981+
await Migrator().run()
1982+
1983+
binary_content = b"\x89PNG\r\n\x1a\n\x00\x00"
1984+
doc = Document(
1985+
name="test.png",
1986+
file=FileData(content=binary_content, mime_type="image/png"),
1987+
)
1988+
await doc.save()
1989+
1990+
retrieved = await Document.get(doc.pk)
1991+
assert retrieved.file.content == binary_content
1992+
assert retrieved.file.mime_type == "image/png"

0 commit comments

Comments
 (0)