diff --git a/src/logging_objects_with_schema/schema_applier.py b/src/logging_objects_with_schema/schema_applier.py index 487dbd2..29e0838 100644 --- a/src/logging_objects_with_schema/schema_applier.py +++ b/src/logging_objects_with_schema/schema_applier.py @@ -11,11 +11,20 @@ from .schema_loader import _CompiledSchema, _SchemaLeaf +def _safe_repr(value: Any) -> str: + """Represent diagnostic data without propagating user __repr__ failures.""" + try: + return repr(value) + except Exception: + # Do not format the value or exception again: either may have a broken repr. + return "" + + def _create_validation_error_dict(field: str, error: str, value: Any) -> dict[str, Any]: """Create a dict for a single validation error. - All values are wrapped in repr() to ensure any type is representable - and special characters cannot cause issues downstream. + All values use safe repr() formatting. If user code raises during repr(), + a constant placeholder preserves the validation diagnostic. Args: field: Field name that caused the validation error. @@ -23,12 +32,12 @@ def _create_validation_error_dict(field: str, error: str, value: Any) -> dict[st value: Invalid value that caused the error. Returns: - Dict with field, error, and value (all via repr() for safety). + Dict with field, error, and value as safely represented strings. """ return { - "field": repr(field), - "error": repr(error), - "value": repr(value), + "field": _safe_repr(field), + "error": _safe_repr(error), + "value": _safe_repr(value), } diff --git a/tests/private/test_schema_logger.py b/tests/private/test_schema_logger.py index d6ad39e..d0ccc2d 100644 --- a/tests/private/test_schema_logger.py +++ b/tests/private/test_schema_logger.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging from io import StringIO from pathlib import Path @@ -594,3 +595,64 @@ def emit(self, record): if len(messages) > 1: assert messages[1][0] == logging.ERROR assert "validation_errors" in messages[1][1] + + +@pytest.mark.parametrize("failure", ["raises", "non_string"]) +@pytest.mark.parametrize("case", ["wrong_type", "unknown", "empty_schema", "list"]) +def test_invalid_extra_with_broken_repr( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure: str, + case: str, +) -> None: + """Broken user repr must not prevent emission or aggregated diagnostics.""" + + class BrokenRepr: + def __repr__(self): + if failure == "raises": + raise RuntimeError("repr failed") + return 42 + + monkeypatch.chdir(tmp_path) + schema = { + "ServicePayload": { + "RequestID": {"type": "str", "source": "request_id"}, + "UserID": {"type": "int", "source": "user_id"}, + "Tags": {"type": "list", "source": "tags", "item_type": "str"}, + } + } + _write_schema(tmp_path, {} if case == "empty_schema" else schema) + value = BrokenRepr() + field = { + "wrong_type": "user_id", + "unknown": "unknown", + "empty_schema": "unknown", + "list": "tags", + }[case] + extra = {field: [value] if case == "list" else value} + if case != "empty_schema": + extra.update( + request_id="abc-123", user_id=None if case != "wrong_type" else value + ) + + logger = SchemaLogger("broken_repr", logging.INFO) + records = [] + + class CaptureHandler(logging.Handler): + def emit(self, record): + records.append(record) + + logger.addHandler(CaptureHandler()) + logger.info("main message", extra=extra) + + assert [record.levelno for record in records] == [logging.INFO, logging.ERROR] + assert records[0].getMessage() == "main message" + if case == "empty_schema": + assert not hasattr(records[0], "ServicePayload") + else: + assert records[0].ServicePayload == {"RequestID": "abc-123"} + errors = json.loads(records[1].getMessage())["validation_errors"] + problem = next(error for error in errors if error["field"] == repr(field)) + assert problem["value"] == "" + assert all(isinstance(value, str) for error in errors for value in error.values()) + assert len(errors) == (2 if case in {"unknown", "list"} else 1)