Repository navigation
v1.2.3
structguru v1.2.3
A correctness release. Three defects are fixed: a field value the native renderer could not represent failed the whole record — raising TypeError from a native logging call, and turning a bridged stdlib record such as Django's HTTP error log into a --- Logging error --- diagnostic on stderr; type detection probed the instance, so a lazily evaluated object was evaluated as a side effect of being logged; and the published wheels reported any value reachable twice as a cycle. No breaking changes; upgrade in place from any v1.x.
Fixes
-
A field value the renderer cannot represent no longer loses the record. The Rust converter raised
TypeErrorfor any leaf it did not recognize — aPath, aDecimal,bytes, a request object — and that exception took the whole event with it. From a native call it surfaced to the caller, sologger.info("upload", path=Path("/tmp/x"))raised inside application code. From the stdlib bridge it went toHandler.handleError(), which prints a traceback to stderr instead of emitting the JSON event. Django hits this on every HTTP error:django.utils.log.log_response()attaches the rawWSGIRequestasextra["request"], so the one record that mattered — the 500, with its traceback — was the one that never rendered.install_stdlib_bridge(level="INFO") log_response("%s: %s", "Internal Server Error", request.path, response=HttpResponse(status=500), request=request, exception=exc) # before: --- Logging error --- ... TypeError: Object of type WSGIRequest is not serializable # after: {"request":"<unsupported: WSGIRequest>","status_code":500,"exception":"Traceback (most recent call last): ...", # "logger":"django.request","level":"ERROR",...,"message":"Internal Server Error: /repro"}
Conversion is now total at the rendering boundary, so native
bind()fields and bridgedextra=fields follow one policy. An unsupported object becomes<unsupported: WSGIRequest>— the type name only.str()andrepr()are never called on an arbitrary object, so headers, cookies, and bodies cannot leak into a log line. A container that refers back to itself becomes<cycle: dict>, and nesting beyond 64 levels becomes<max depth exceeded>. The markers apply at any depth inside mappings and sequences, are redacted by key and by pattern like any other string, and the record and itsextraobjects are left untouched for other handlers. A conversion that raises — a duck-typedisoformat()that fails, a dataclass field whose access raises — falls back the same way; onlyKeyboardInterruptandSystemExitstill propagate.Three adjacent rejections became representations rather than markers: integers outside the 64-bit range are written as JSON numbers, non-string mapping keys are rendered as strings (
{200: 3}becomes{"200": 3}), and text with unpaired surrogates is written with U+FFFD. Code that caught theTypeErrorfrom a logging call no longer sees one. -
Logging an object never evaluates it.
Enum,datetime,UUID, and dataclass detection probed the instance withhasattr(), which runs the object's__getattr__. For Django'sLazyObject—request.user— that is_setup()and a database query, triggered by a log call that was about to reject the object anyway. Detection now inspects the class; an instance attribute is read only after its type has matched. -
Release builds no longer report repeated references as cycles. The cycle tracker popped a container off its path stack inside a
debug_assert_eq!, which release builds compile out entirely, so the pop never ran in the published wheels. Any value reachable twice — the same dict in two list slots, one settings object referenced from two fields — was rejected as a cycle, and the record was lost.shared = {"x": 1} logger.info("shape", items=[shared, shared]) # before: ValueError: cycle detected while converting Python value # after: {"items":[{"x":1},{"x":1}],...,"message":"shape"}
The pop now runs unconditionally and the assertion only checks its result. A genuine cycle is still detected on the current path and rendered as
<cycle: list>.
Test coverage
- 20 new Python test cases pin the fixed behavior: markers for unsupported objects at the top level and inside containers, cycles, the depth limit, shared references, big integers, non-string keys, surrogates, failing conversions, no instance probing, and
KeyboardInterruptpropagation; all four renderer branches — JSON and console, with and without a compiled redaction configuration — with redaction around the markers; the bridge under eitherlogging.raiseExceptionsvalue, with nested and cyclic extras, and with an untouched record; and the Djangolog_response()reproduction end to end — 508 Python tests and 67 Rust tests in total.