Skip to content

Commit 74e6282

Browse files
authored
Merge branch 'main' into claude/issue-159-20251028-1941
2 parents fb36e40 + 23f4a9c commit 74e6282

File tree

6 files changed

+219
-2
lines changed

6 files changed

+219
-2
lines changed

key-value/key-value-aio/src/key_value/aio/adapters/pydantic/base.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from abc import ABC, abstractmethod
23
from collections.abc import Sequence
34
from typing import Any, Generic, SupportsFloat, TypeVar, overload
@@ -9,6 +10,8 @@
910

1011
from key_value.aio.protocols.key_value import AsyncKeyValue
1112

13+
logger = logging.getLogger(__name__)
14+
1215
T = TypeVar("T")
1316

1417

@@ -57,6 +60,17 @@ def _validate_model(self, value: dict[str, Any]) -> T | None:
5760
if self._raise_on_validation_error:
5861
msg = f"Invalid {self._get_model_type_name()} payload: missing 'items' wrapper"
5962
raise DeserializationError(msg)
63+
64+
# Log the missing 'items' wrapper when not raising
65+
logger.error(
66+
"Missing 'items' wrapper for list %s",
67+
self._get_model_type_name(),
68+
extra={
69+
"model_type": self._get_model_type_name(),
70+
"error": "missing 'items' wrapper",
71+
},
72+
exc_info=False,
73+
)
6074
return None
6175
return self._type_adapter.validate_python(value["items"])
6276

@@ -66,6 +80,19 @@ def _validate_model(self, value: dict[str, Any]) -> T | None:
6680
details = e.errors(include_input=False)
6781
msg = f"Invalid {self._get_model_type_name()}: {details}"
6882
raise DeserializationError(msg) from e
83+
84+
# Log the validation error when not raising
85+
error_details = e.errors(include_input=False)
86+
logger.error(
87+
"Validation failed for %s",
88+
self._get_model_type_name(),
89+
extra={
90+
"model_type": self._get_model_type_name(),
91+
"error_count": len(error_details),
92+
"errors": error_details,
93+
},
94+
exc_info=True,
95+
)
6996
return None
7097

7198
def _serialize_model(self, value: T) -> dict[str, Any]:

key-value/key-value-aio/src/key_value/aio/stores/elasticsearch/store.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from collections.abc import Sequence
23
from datetime import datetime
34
from typing import Any, overload
@@ -39,6 +40,8 @@
3940
raise ImportError(msg) from e
4041

4142

43+
logger = logging.getLogger(__name__)
44+
4245
DEFAULT_INDEX_PREFIX = "kv_store"
4346

4447
DEFAULT_MAPPING = {
@@ -289,7 +292,19 @@ async def _get_managed_entries(self, *, collection: str, keys: Sequence[str]) ->
289292
entries_by_id[doc_id] = None
290293
continue
291294

292-
entries_by_id[doc_id] = source_to_managed_entry(source=source)
295+
try:
296+
entries_by_id[doc_id] = source_to_managed_entry(source=source)
297+
except DeserializationError as e:
298+
logger.error(
299+
"Failed to deserialize Elasticsearch document in batch operation",
300+
extra={
301+
"collection": collection,
302+
"document_id": doc_id,
303+
"error": str(e),
304+
},
305+
exc_info=True,
306+
)
307+
entries_by_id[doc_id] = None
293308

294309
# Return entries in the same order as input keys
295310
return [entries_by_id.get(document_id) for document_id in document_ids]

key-value/key-value-aio/tests/adapters/test_pydantic.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import datetime, timezone
2+
from logging import LogRecord
23

34
import pytest
45
from inline_snapshot import snapshot
@@ -47,6 +48,27 @@ class Order(BaseModel):
4748
TEST_KEY_2: str = "test_key_2"
4849

4950

51+
def model_type_from_log_record(record: LogRecord) -> str:
52+
if not hasattr(record, "model_type"):
53+
msg = "Log record does not have a model_type attribute"
54+
raise ValueError(msg)
55+
return record.model_type # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportAttributeAccessIssue]
56+
57+
58+
def error_from_log_record(record: LogRecord) -> str:
59+
if not hasattr(record, "error"):
60+
msg = "Log record does not have an error attribute"
61+
raise ValueError(msg)
62+
return record.error # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportAttributeAccessIssue]
63+
64+
65+
def errors_from_log_record(record: LogRecord) -> list[str]:
66+
if not hasattr(record, "errors"):
67+
msg = "Log record does not have an errors attribute"
68+
raise ValueError(msg)
69+
return record.errors # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportAttributeAccessIssue]
70+
71+
5072
class TestPydanticAdapter:
5173
@pytest.fixture
5274
async def store(self) -> MemoryStore:
@@ -145,3 +167,53 @@ async def test_complex_adapter_with_list(self, product_list_adapter: PydanticAda
145167

146168
assert await product_list_adapter.delete(collection=TEST_COLLECTION, key=TEST_KEY)
147169
assert await product_list_adapter.get(collection=TEST_COLLECTION, key=TEST_KEY) is None
170+
171+
async def test_validation_error_logging(
172+
self, user_adapter: PydanticAdapter[User], updated_user_adapter: PydanticAdapter[UpdatedUser], caplog: pytest.LogCaptureFixture
173+
):
174+
"""Test that validation errors are logged when raise_on_validation_error=False."""
175+
import logging
176+
177+
# Store a User, then try to retrieve as UpdatedUser (missing is_admin field)
178+
await user_adapter.put(collection=TEST_COLLECTION, key=TEST_KEY, value=SAMPLE_USER)
179+
180+
with caplog.at_level(logging.ERROR):
181+
updated_user = await updated_user_adapter.get(collection=TEST_COLLECTION, key=TEST_KEY)
182+
183+
# Should return None due to validation failure
184+
assert updated_user is None
185+
186+
# Check that an error was logged
187+
assert len(caplog.records) == 1
188+
record = caplog.records[0]
189+
assert record.levelname == "ERROR"
190+
assert "Validation failed" in record.message
191+
assert model_type_from_log_record(record) == "Pydantic model"
192+
193+
errors = errors_from_log_record(record)
194+
assert len(errors) == 1
195+
assert "is_admin" in str(errors[0])
196+
197+
async def test_list_validation_error_logging(
198+
self, product_list_adapter: PydanticAdapter[list[Product]], store: MemoryStore, caplog: pytest.LogCaptureFixture
199+
):
200+
"""Test that missing 'items' wrapper is logged for list models."""
201+
import logging
202+
203+
# Manually store invalid data (missing 'items' wrapper)
204+
await store.put(collection=TEST_COLLECTION, key=TEST_KEY, value={"invalid": "data"})
205+
206+
with caplog.at_level(logging.ERROR):
207+
result = await product_list_adapter.get(collection=TEST_COLLECTION, key=TEST_KEY)
208+
209+
# Should return None due to missing 'items' wrapper
210+
assert result is None
211+
212+
# Check that an error was logged
213+
assert len(caplog.records) == 1
214+
record = caplog.records[0]
215+
assert record.levelname == "ERROR"
216+
assert "Missing 'items' wrapper" in record.message
217+
assert model_type_from_log_record(record) == "Pydantic model"
218+
error = error_from_log_record(record)
219+
assert "missing 'items' wrapper" in str(error)

key-value/key-value-sync/src/key_value/sync/code_gen/adapters/pydantic/base.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# WARNING: this file is auto-generated by 'build_sync_library.py'
22
# from the original file 'base.py'
33
# DO NOT CHANGE! Change the original file instead.
4+
import logging
45
from abc import ABC, abstractmethod
56
from collections.abc import Sequence
67
from typing import Any, Generic, SupportsFloat, TypeVar, overload
@@ -12,6 +13,8 @@
1213

1314
from key_value.sync.code_gen.protocols.key_value import KeyValue
1415

16+
logger = logging.getLogger(__name__)
17+
1518
T = TypeVar("T")
1619

1720

@@ -60,6 +63,14 @@ def _validate_model(self, value: dict[str, Any]) -> T | None:
6063
if self._raise_on_validation_error:
6164
msg = f"Invalid {self._get_model_type_name()} payload: missing 'items' wrapper"
6265
raise DeserializationError(msg)
66+
67+
# Log the missing 'items' wrapper when not raising
68+
logger.error(
69+
"Missing 'items' wrapper for list %s",
70+
self._get_model_type_name(),
71+
extra={"model_type": self._get_model_type_name(), "error": "missing 'items' wrapper"},
72+
exc_info=False,
73+
)
6374
return None
6475
return self._type_adapter.validate_python(value["items"])
6576

@@ -69,6 +80,15 @@ def _validate_model(self, value: dict[str, Any]) -> T | None:
6980
details = e.errors(include_input=False)
7081
msg = f"Invalid {self._get_model_type_name()}: {details}"
7182
raise DeserializationError(msg) from e
83+
84+
# Log the validation error when not raising
85+
error_details = e.errors(include_input=False)
86+
logger.error(
87+
"Validation failed for %s",
88+
self._get_model_type_name(),
89+
extra={"model_type": self._get_model_type_name(), "error_count": len(error_details), "errors": error_details},
90+
exc_info=True,
91+
)
7292
return None
7393

7494
def _serialize_model(self, value: T) -> dict[str, Any]:

key-value/key-value-sync/src/key_value/sync/code_gen/stores/elasticsearch/store.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# WARNING: this file is auto-generated by 'build_sync_library.py'
22
# from the original file 'store.py'
33
# DO NOT CHANGE! Change the original file instead.
4+
import logging
45
from collections.abc import Sequence
56
from datetime import datetime
67
from typing import Any, overload
@@ -37,6 +38,8 @@
3738
msg = "ElasticsearchStore requires py-key-value-aio[elasticsearch]"
3839
raise ImportError(msg) from e
3940

41+
logger = logging.getLogger(__name__)
42+
4043
DEFAULT_INDEX_PREFIX = "kv_store"
4144

4245
# You might think the `string` field should be a text/keyword field
@@ -249,7 +252,15 @@ def _get_managed_entries(self, *, collection: str, keys: Sequence[str]) -> list[
249252
entries_by_id[doc_id] = None
250253
continue
251254

252-
entries_by_id[doc_id] = source_to_managed_entry(source=source)
255+
try:
256+
entries_by_id[doc_id] = source_to_managed_entry(source=source)
257+
except DeserializationError as e:
258+
logger.error(
259+
"Failed to deserialize Elasticsearch document in batch operation",
260+
extra={"collection": collection, "document_id": doc_id, "error": str(e)},
261+
exc_info=True,
262+
)
263+
entries_by_id[doc_id] = None
253264

254265
# Return entries in the same order as input keys
255266
return [entries_by_id.get(document_id) for document_id in document_ids]

key-value/key-value-sync/tests/code_gen/adapters/test_pydantic.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# from the original file 'test_pydantic.py'
33
# DO NOT CHANGE! Change the original file instead.
44
from datetime import datetime, timezone
5+
from logging import LogRecord
56

67
import pytest
78
from inline_snapshot import snapshot
@@ -50,6 +51,27 @@ class Order(BaseModel):
5051
TEST_KEY_2: str = "test_key_2"
5152

5253

54+
def model_type_from_log_record(record: LogRecord) -> str:
55+
if not hasattr(record, "model_type"):
56+
msg = "Log record does not have a model_type attribute"
57+
raise ValueError(msg)
58+
return record.model_type # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportAttributeAccessIssue]
59+
60+
61+
def error_from_log_record(record: LogRecord) -> str:
62+
if not hasattr(record, "error"):
63+
msg = "Log record does not have an error attribute"
64+
raise ValueError(msg)
65+
return record.error # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportAttributeAccessIssue]
66+
67+
68+
def errors_from_log_record(record: LogRecord) -> list[str]:
69+
if not hasattr(record, "errors"):
70+
msg = "Log record does not have an errors attribute"
71+
raise ValueError(msg)
72+
return record.errors # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType, reportAttributeAccessIssue]
73+
74+
5375
class TestPydanticAdapter:
5476
@pytest.fixture
5577
def store(self) -> MemoryStore:
@@ -148,3 +170,53 @@ def test_complex_adapter_with_list(self, product_list_adapter: PydanticAdapter[l
148170

149171
assert product_list_adapter.delete(collection=TEST_COLLECTION, key=TEST_KEY)
150172
assert product_list_adapter.get(collection=TEST_COLLECTION, key=TEST_KEY) is None
173+
174+
def test_validation_error_logging(
175+
self, user_adapter: PydanticAdapter[User], updated_user_adapter: PydanticAdapter[UpdatedUser], caplog: pytest.LogCaptureFixture
176+
):
177+
"""Test that validation errors are logged when raise_on_validation_error=False."""
178+
import logging
179+
180+
# Store a User, then try to retrieve as UpdatedUser (missing is_admin field)
181+
user_adapter.put(collection=TEST_COLLECTION, key=TEST_KEY, value=SAMPLE_USER)
182+
183+
with caplog.at_level(logging.ERROR):
184+
updated_user = updated_user_adapter.get(collection=TEST_COLLECTION, key=TEST_KEY)
185+
186+
# Should return None due to validation failure
187+
assert updated_user is None
188+
189+
# Check that an error was logged
190+
assert len(caplog.records) == 1
191+
record = caplog.records[0]
192+
assert record.levelname == "ERROR"
193+
assert "Validation failed" in record.message
194+
assert model_type_from_log_record(record) == "Pydantic model"
195+
196+
errors = errors_from_log_record(record)
197+
assert len(errors) == 1
198+
assert "is_admin" in str(errors[0])
199+
200+
def test_list_validation_error_logging(
201+
self, product_list_adapter: PydanticAdapter[list[Product]], store: MemoryStore, caplog: pytest.LogCaptureFixture
202+
):
203+
"""Test that missing 'items' wrapper is logged for list models."""
204+
import logging
205+
206+
# Manually store invalid data (missing 'items' wrapper)
207+
store.put(collection=TEST_COLLECTION, key=TEST_KEY, value={"invalid": "data"})
208+
209+
with caplog.at_level(logging.ERROR):
210+
result = product_list_adapter.get(collection=TEST_COLLECTION, key=TEST_KEY)
211+
212+
# Should return None due to missing 'items' wrapper
213+
assert result is None
214+
215+
# Check that an error was logged
216+
assert len(caplog.records) == 1
217+
record = caplog.records[0]
218+
assert record.levelname == "ERROR"
219+
assert "Missing 'items' wrapper" in record.message
220+
assert model_type_from_log_record(record) == "Pydantic model"
221+
error = error_from_log_record(record)
222+
assert "missing 'items' wrapper" in str(error)

0 commit comments

Comments
 (0)