Skip to content

v1.2.3

Choose a tag to compare

@kidoz kidoz released this 05 Sep 22:59
· 155 commits to main since this release

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 TypeError for any leaf it did not recognize — a Path, a Decimal, bytes, a request object — and that exception took the whole event with it. From a native call it surfaced to the caller, so logger.info("upload", path=Path("/tmp/x")) raised inside application code. From the stdlib bridge it went to Handler.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 raw WSGIRequest as extra["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 bridged extra= fields follow one policy. An unsupported object becomes <unsupported: WSGIRequest> — the type name only. str() and repr() 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 its extra objects are left untouched for other handlers. A conversion that raises — a duck-typed isoformat() that fails, a dataclass field whose access raises — falls back the same way; only KeyboardInterrupt and SystemExit still 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 the TypeError from a logging call no longer sees one.

  • Logging an object never evaluates it. Enum, datetime, UUID, and dataclass detection probed the instance with hasattr(), which runs the object's __getattr__. For Django's LazyObject — 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 KeyboardInterrupt propagation; all four renderer branches — JSON and console, with and without a compiled redaction configuration — with redaction around the markers; the bridge under either logging.raiseExceptions value, with nested and cyclic extras, and with an untouched record; and the Django log_response() reproduction end to end — 508 Python tests and 67 Rust tests in total.