Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## [1.0.3-rc.1](https://github.com/disafronov/python-logging-objects-with-schema/compare/v1.0.2...v1.0.3-rc.1) (2026-10-25)

### Bug Fixes

* guard validation diagnostics against broken repr ([192fbed](https://github.com/disafronov/python-logging-objects-with-schema/commit/192fbede5fc4b407fe1dad12c5f9ddb8a1e6608d))

## [1.0.2](https://github.com/disafronov/python-logging-objects-with-schema/compare/v1.0.1...v1.0.2) (2026-10-25)

### Bug Fixes
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "uv_build"

[project]
name = "logging-objects-with-schema"
version = "1.0.2"
version = "1.0.3rc1"
description = "Schema-aware Python logging with strict extra validation and schema-driven field projection."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
21 changes: 15 additions & 6 deletions src/logging_objects_with_schema/schema_applier.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,33 @@
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 "<unrepresentable value>"


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.
error: Error description.
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),
}


Expand Down
62 changes: 62 additions & 0 deletions tests/private/test_schema_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
import logging
from io import StringIO
from pathlib import Path
Expand Down Expand Up @@ -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"] == "<unrepresentable 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)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading