structguru 1.2.2__tar.gz → 1.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {structguru-1.2.2 → structguru-1.2.3}/Cargo.lock +2 -2
  2. {structguru-1.2.2 → structguru-1.2.3}/Cargo.toml +1 -1
  3. {structguru-1.2.2 → structguru-1.2.3}/PKG-INFO +18 -1
  4. {structguru-1.2.2 → structguru-1.2.3}/README.md +17 -0
  5. {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-core/src/render.rs +3 -3
  6. {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-core/src/value.rs +3 -3
  7. {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-py/src/lib.rs +190 -93
  8. {structguru-1.2.2 → structguru-1.2.3}/pyproject.toml +1 -1
  9. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/__init__.py +1 -1
  10. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/core.py +7 -0
  11. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/stdlib.py +6 -0
  12. {structguru-1.2.2 → structguru-1.2.3}/LICENSE +0 -0
  13. {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-core/Cargo.toml +0 -0
  14. {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-core/src/clock.rs +0 -0
  15. {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-core/src/filter.rs +0 -0
  16. {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-core/src/lib.rs +0 -0
  17. {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-core/src/worker.rs +0 -0
  18. {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-py/Cargo.toml +0 -0
  19. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/_contextvars.py +0 -0
  20. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/_native_dispatch.py +0 -0
  21. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/_native_env.py +0 -0
  22. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/_runtime.py +0 -0
  23. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/config.py +0 -0
  24. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/exceptions.py +0 -0
  25. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/__init__.py +0 -0
  26. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/_stdlib_env.py +0 -0
  27. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/_util.py +0 -0
  28. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/asgi.py +0 -0
  29. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/celery.py +0 -0
  30. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/django.py +0 -0
  31. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/flask.py +0 -0
  32. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/grpc.py +0 -0
  33. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/httpx.py +0 -0
  34. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/requests.py +0 -0
  35. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/sentry.py +0 -0
  36. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/sqlalchemy.py +0 -0
  37. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/metrics.py +0 -0
  38. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/otel.py +0 -0
  39. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/py.typed +0 -0
  40. {structguru-1.2.2 → structguru-1.2.3}/src/structguru/redaction.py +0 -0
@@ -245,7 +245,7 @@ dependencies = [
245
245
 
246
246
  [[package]]
247
247
  name = "structguru-core"
248
- version = "1.2.2"
248
+ version = "1.2.3"
249
249
  dependencies = [
250
250
  "fancy-regex",
251
251
  "fastrand",
@@ -257,7 +257,7 @@ dependencies = [
257
257
 
258
258
  [[package]]
259
259
  name = "structguru-py"
260
- version = "1.2.2"
260
+ version = "1.2.3"
261
261
  dependencies = [
262
262
  "pyo3",
263
263
  "regex",
@@ -3,7 +3,7 @@ members = ["crates/structguru-core", "crates/structguru-py"]
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "1.2.2"
6
+ version = "1.2.3"
7
7
  edition = "2024"
8
8
  rust-version = "1.89"
9
9
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structguru
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -315,6 +315,23 @@ natively, off-thread. It is auto-enabled at import time. The runtime does not de
315
315
  exotic values (`datetime`, `UUID`, `Enum`, dataclasses) are converted natively
316
316
  in Rust.
317
317
 
318
+ A field value the renderer cannot represent never fails the record. It is
319
+ replaced by a marker so the message, level, remaining fields, and exception
320
+ traceback still ship:
321
+
322
+ - `<unsupported: WSGIRequest>` for any other object (`Decimal`, `bytes`, `set`,
323
+ `Path`, request objects, ...). The marker names the type only: structguru
324
+ never calls `str()`/`repr()` on an arbitrary object or reads its attributes,
325
+ so nothing it holds can leak into a log line.
326
+ - `<cycle: dict>` for a container that refers back to itself.
327
+ - `<max depth exceeded>` beyond 64 levels of nesting.
328
+
329
+ Integers outside the 64-bit range are written as JSON numbers, non-string
330
+ mapping keys are rendered as strings (`{200: 3}` becomes `{"200": 3}`), and
331
+ unpaired surrogates in text become U+FFFD. Markers are redacted like any other
332
+ string. The policy applies to native `logger` fields and to `extra=` fields
333
+ bridged from the standard library alike.
334
+
318
335
  ```python
319
336
  import structguru
320
337
 
@@ -271,6 +271,23 @@ natively, off-thread. It is auto-enabled at import time. The runtime does not de
271
271
  exotic values (`datetime`, `UUID`, `Enum`, dataclasses) are converted natively
272
272
  in Rust.
273
273
 
274
+ A field value the renderer cannot represent never fails the record. It is
275
+ replaced by a marker so the message, level, remaining fields, and exception
276
+ traceback still ship:
277
+
278
+ - `<unsupported: WSGIRequest>` for any other object (`Decimal`, `bytes`, `set`,
279
+ `Path`, request objects, ...). The marker names the type only: structguru
280
+ never calls `str()`/`repr()` on an arbitrary object or reads its attributes,
281
+ so nothing it holds can leak into a log line.
282
+ - `<cycle: dict>` for a container that refers back to itself.
283
+ - `<max depth exceeded>` beyond 64 levels of nesting.
284
+
285
+ Integers outside the 64-bit range are written as JSON numbers, non-string
286
+ mapping keys are rendered as strings (`{200: 3}` becomes `{"200": 3}`), and
287
+ unpaired surrogates in text become U+FFFD. Markers are redacted like any other
288
+ string. The policy applies to native `logger` fields and to `extra=` fields
289
+ bridged from the standard library alike.
290
+
274
291
  ```python
275
292
  import structguru
276
293
 
@@ -151,9 +151,9 @@ fn redact(value: &mut Value, keys: &[impl AsRef<str>]) {
151
151
  /// `Value::Raw` are not pattern-matched.
152
152
  ///
153
153
  /// `Value::Raw` is deliberately left untouched: it holds pre-serialized JSON
154
- /// for exotic Python leaves (datetime/UUID/Enum/...). Descending into it would
155
- /// require re-parsing the JSON and would break the verbatim-output contract.
156
- /// Key-based redaction still applies to a `Raw` value's *key* in its parent map.
154
+ /// (an integer outside the `i64` range). Descending into it would require
155
+ /// re-parsing the JSON and would break the verbatim-output contract. Key-based
156
+ /// redaction still applies to a `Raw` value's *key* in its parent map.
157
157
  ///
158
158
  /// `replacement` supports the regex crate's group expansion (`$1`, `${name}`;
159
159
  /// `$$` for a literal `$`), so look-behind-style patterns can be rewritten as
@@ -13,9 +13,9 @@ pub enum Value {
13
13
  Map(Vec<(String, Value)>),
14
14
  /// Pre-serialized, already-valid JSON emitted verbatim.
15
15
  ///
16
- /// Used for exotic Python leaves (datetime/date/UUID/Enum/...) that the
17
- /// boundary serializes via orjson for exact parity with the current
18
- /// renderer, rather than reproducing orjson's formatting in Rust.
16
+ /// Used for integers outside the `i64` range: the boundary emits their
17
+ /// decimal digits as a JSON number so the value stays lossless, as
18
+ /// `json.dumps` does, instead of rejecting the record.
19
19
  Raw(String),
20
20
  }
21
21
 
@@ -1,5 +1,5 @@
1
1
  use pyo3::IntoPyObjectExt;
2
- use pyo3::exceptions::{PyOverflowError, PyRecursionError, PyTypeError, PyValueError};
2
+ use pyo3::exceptions::{PyException, PyTypeError, PyValueError};
3
3
  use pyo3::prelude::*;
4
4
  use pyo3::types::{
5
5
  PyAny, PyBool, PyDict, PyDictMethods, PyFloat, PyInt, PyList, PyListMethods, PyString, PyTuple,
@@ -547,20 +547,29 @@ impl NativeFilter {
547
547
  }
548
548
  }
549
549
 
550
+ /// Marker emitted in place of a value nested deeper than `MAX_VALUE_DEPTH`.
551
+ const DEPTH_MARKER: &str = "<max depth exceeded>";
552
+
550
553
  fn convert_py_value(obj: &Bound<'_, PyAny>) -> PyResult<Value> {
551
554
  let mut containers = ContainerStack::with_capacity(8);
552
555
  convert_py_value_inner(obj, 1, &mut containers)
553
556
  }
554
557
 
558
+ /// Convert one Python object into an owned [`Value`].
559
+ ///
560
+ /// Conversion is total for value-shape problems: a value the renderer cannot
561
+ /// represent becomes a marker string — `<unsupported: T>`, `<cycle: T>`, or
562
+ /// `<max depth exceeded>` — instead of failing the whole record. A logging
563
+ /// call therefore never raises, and a bridged stdlib record is never lost,
564
+ /// because of one field. Only a `BaseException` that is not an `Exception`
565
+ /// (`KeyboardInterrupt`, `SystemExit`) still propagates.
555
566
  fn convert_py_value_inner(
556
567
  obj: &Bound<'_, PyAny>,
557
568
  depth: usize,
558
569
  containers: &mut ContainerStack,
559
570
  ) -> PyResult<Value> {
560
571
  if depth > MAX_VALUE_DEPTH {
561
- return Err(PyRecursionError::new_err(format!(
562
- "maximum conversion depth {MAX_VALUE_DEPTH} exceeded"
563
- )));
572
+ return Ok(Value::String(DEPTH_MARKER.to_owned()));
564
573
  }
565
574
 
566
575
  if obj.is_none() {
@@ -570,13 +579,12 @@ fn convert_py_value_inner(
570
579
  return Ok(Value::Bool(obj.extract()?));
571
580
  }
572
581
  if obj.is_exact_instance_of::<PyInt>() {
573
- return obj.extract::<i64>().map(Value::Int).map_err(|err| {
574
- if err.is_instance_of::<PyOverflowError>(obj.py()) {
575
- PyOverflowError::new_err("integer is outside the supported i64 range")
576
- } else {
577
- err
578
- }
579
- });
582
+ return match obj.extract::<i64>() {
583
+ Ok(value) => Ok(Value::Int(value)),
584
+ // Outside i64: emit the decimal digits verbatim as a JSON number so
585
+ // the value stays lossless, as `json.dumps` does.
586
+ Err(_) => recover(obj, convert_big_int(obj)),
587
+ };
580
588
  }
581
589
  if obj.is_exact_instance_of::<PyFloat>() {
582
590
  let value: f64 = obj.extract()?;
@@ -588,62 +596,149 @@ fn convert_py_value_inner(
588
596
  });
589
597
  }
590
598
  if let Ok(value) = obj.cast::<PyString>() {
591
- return Ok(Value::String(value.to_str()?.to_owned()));
599
+ return Ok(Value::String(string_to_owned(value)));
592
600
  }
593
601
  if let Ok(dict) = obj.cast::<PyDict>() {
594
- let container_id = enter_container(obj, containers)?;
595
- let mut entries = Vec::with_capacity(dict.len());
596
- for (key, value) in dict.iter() {
597
- let key = key
598
- .cast::<PyString>()
599
- .map_err(|_| PyTypeError::new_err("map keys must be strings"))?
600
- .to_str()?
601
- .to_owned();
602
- entries.push((key, convert_py_value_inner(&value, depth + 1, containers)?));
603
- }
602
+ let Some(container_id) = enter_container(obj, containers) else {
603
+ return Ok(marker("cycle", obj));
604
+ };
605
+ let result = convert_dict(dict, depth, containers);
604
606
  leave_container(container_id, containers);
605
- return Ok(Value::Map(entries));
607
+ return result;
606
608
  }
607
609
  if let Ok(list) = obj.cast::<PyList>() {
608
- let container_id = enter_container(obj, containers)?;
609
- let mut values = Vec::with_capacity(list.len());
610
- for item in list.iter() {
611
- values.push(convert_py_value_inner(&item, depth + 1, containers)?);
612
- }
610
+ let Some(container_id) = enter_container(obj, containers) else {
611
+ return Ok(marker("cycle", obj));
612
+ };
613
+ let result = convert_items(list.iter(), list.len(), depth, containers);
613
614
  leave_container(container_id, containers);
614
- return Ok(Value::List(values));
615
+ return result;
615
616
  }
616
617
  if let Ok(tuple) = obj.cast::<PyTuple>() {
617
- let container_id = enter_container(obj, containers)?;
618
- let mut values = Vec::with_capacity(tuple.len());
619
- for item in tuple.iter() {
620
- values.push(convert_py_value_inner(&item, depth + 1, containers)?);
621
- }
618
+ let Some(container_id) = enter_container(obj, containers) else {
619
+ return Ok(marker("cycle", obj));
620
+ };
621
+ let result = convert_items(tuple.iter(), tuple.len(), depth, containers);
622
622
  leave_container(container_id, containers);
623
- return Ok(Value::List(values));
623
+ return result;
624
624
  }
625
625
 
626
- // Exotic leaves: handle datetime/date/UUID/Enum/dataclass natively by
627
- // delegating to the Python object's own serialization methods, which
628
- // produce byte-identical output to orjson for the parity-tested cases.
629
- // Genuinely unsupported types (Decimal/bytes/set/timedelta/Path) raise
630
- // TypeError, matching the orjson rejection contract.
631
- convert_exotic_leaf(obj, depth, containers)
626
+ // Exotic leaves (datetime/date/UUID/Enum/dataclass) delegate to the
627
+ // object's own Python conversion. A conversion that raises — a duck-typed
628
+ // `isoformat()` that fails, a dataclass field whose access raises — must
629
+ // not lose the record either, so it collapses to the unsupported marker.
630
+ recover(obj, convert_exotic_leaf(obj, depth, containers))
631
+ }
632
+
633
+ /// Map a Python `Exception` raised while converting `obj` to the
634
+ /// `<unsupported: T>` marker. Anything that is not an `Exception`
635
+ /// (`KeyboardInterrupt`, `SystemExit`) propagates untouched.
636
+ fn recover(obj: &Bound<'_, PyAny>, result: PyResult<Value>) -> PyResult<Value> {
637
+ match result {
638
+ Err(err) if err.is_instance_of::<PyException>(obj.py()) => Ok(marker("unsupported", obj)),
639
+ other => other,
640
+ }
641
+ }
642
+
643
+ /// `<kind: TypeName>` — the text of a fallback marker for `obj`.
644
+ fn marker_text(kind: &str, obj: &Bound<'_, PyAny>) -> String {
645
+ format!("<{kind}: {}>", type_name(obj))
646
+ }
647
+
648
+ fn marker(kind: &str, obj: &Bound<'_, PyAny>) -> Value {
649
+ Value::String(marker_text(kind, obj))
650
+ }
651
+
652
+ fn type_name(obj: &Bound<'_, PyAny>) -> String {
653
+ obj.get_type()
654
+ .name()
655
+ .map(|name| name.to_string())
656
+ .unwrap_or_else(|_| "unknown".to_owned())
657
+ }
658
+
659
+ /// Copy a Python string, replacing unpaired surrogates (not representable in
660
+ /// UTF-8, so `to_str` rejects them) with U+FFFD instead of failing the record.
661
+ fn string_to_owned(value: &Bound<'_, PyString>) -> String {
662
+ match value.to_str() {
663
+ Ok(text) => text.to_owned(),
664
+ Err(_) => value.to_string_lossy().into_owned(),
665
+ }
666
+ }
667
+
668
+ fn convert_big_int(obj: &Bound<'_, PyAny>) -> PyResult<Value> {
669
+ // `str()` of an exact int is `-?[0-9]+`, which is valid JSON. It can still
670
+ // raise `ValueError` past `sys.get_int_max_str_digits()`; `recover` turns
671
+ // that into the marker.
672
+ let digits = obj.str()?;
673
+ Ok(Value::Raw(string_to_owned(&digits)))
674
+ }
675
+
676
+ fn convert_dict(
677
+ dict: &Bound<'_, PyDict>,
678
+ depth: usize,
679
+ containers: &mut ContainerStack,
680
+ ) -> PyResult<Value> {
681
+ let mut entries = Vec::with_capacity(dict.len());
682
+ for (key, value) in dict.iter() {
683
+ let key = convert_map_key(&key, depth, containers)?;
684
+ entries.push((key, convert_py_value_inner(&value, depth + 1, containers)?));
685
+ }
686
+ Ok(Value::Map(entries))
687
+ }
688
+
689
+ /// Render a mapping key as the string JSON requires.
690
+ ///
691
+ /// Strings are used as-is. Any other key goes through the value conversion: a
692
+ /// string result (Enum, datetime, UUID) is used directly, a scalar result uses
693
+ /// its JSON text (`1`, `1.5`, `true`, `null`), and a container result — a
694
+ /// tuple, a dataclass — becomes `<unsupported: T>`.
695
+ fn convert_map_key(
696
+ key: &Bound<'_, PyAny>,
697
+ depth: usize,
698
+ containers: &mut ContainerStack,
699
+ ) -> PyResult<String> {
700
+ if let Ok(text) = key.cast::<PyString>() {
701
+ return Ok(string_to_owned(text));
702
+ }
703
+ Ok(match convert_py_value_inner(key, depth + 1, containers)? {
704
+ Value::String(text) => text,
705
+ Value::List(_) | Value::Map(_) => marker_text("unsupported", key),
706
+ scalar => scalar
707
+ .to_json_string()
708
+ .unwrap_or_else(|_| marker_text("unsupported", key)),
709
+ })
710
+ }
711
+
712
+ fn convert_items<'py>(
713
+ items: impl Iterator<Item = Bound<'py, PyAny>>,
714
+ capacity: usize,
715
+ depth: usize,
716
+ containers: &mut ContainerStack,
717
+ ) -> PyResult<Value> {
718
+ let mut values = Vec::with_capacity(capacity);
719
+ for item in items {
720
+ values.push(convert_py_value_inner(&item, depth + 1, containers)?);
721
+ }
722
+ Ok(Value::List(values))
632
723
  }
633
724
 
634
725
  /// Handle exotic Python leaves (datetime, date, UUID, Enum, dataclass) natively
635
- /// without crossing into orjson. Falls back to `TypeError` for unsupported types,
636
- /// matching orjson's default rejection behavior.
726
+ /// without crossing into orjson. Anything else becomes `<unsupported: T>`.
727
+ ///
728
+ /// Every probe looks at the object's *type*, never the instance: an instance
729
+ /// lookup would run an arbitrary `__getattr__` — Django's `LazyObject`
730
+ /// evaluates itself, an ORM proxy may hit the database — for every unsupported
731
+ /// object that reaches the renderer.
637
732
  fn convert_exotic_leaf(
638
733
  obj: &Bound<'_, PyAny>,
639
734
  depth: usize,
640
735
  containers: &mut ContainerStack,
641
736
  ) -> PyResult<Value> {
642
- // Enum: has a `.value` attribute and its type's `__class__` has `__members__`.
643
- // Detect via getattr("value") + checking the *type* has __members__ (Enum
644
- // metaclass marker). This avoids misdetecting objects that happen to have a
645
- // `value` attribute but aren't enums.
646
- if obj.hasattr("value")? && obj.get_type().hasattr("__members__")? {
737
+ let type_object = obj.get_type();
738
+
739
+ // Enum: the EnumType metaclass exposes `__members__` on the class; the
740
+ // member's payload is its `.value`.
741
+ if type_object.hasattr("__members__")? {
647
742
  let value = obj.getattr("value")?;
648
743
  // Recurse with depth + 1 like every other container path: an enum whose
649
744
  // `.value` cycles back (e.g. a member whose `_value_` is itself) would
@@ -652,71 +747,73 @@ fn convert_exotic_leaf(
652
747
  return convert_py_value_inner(&value, depth + 1, containers);
653
748
  }
654
749
 
655
- // datetime.datetime / datetime.date: have an .isoformat() method.
656
- // (datetime is a subclass of date, so both are covered.)
657
- if obj.hasattr("isoformat")? {
750
+ // datetime.datetime / datetime.date / datetime.time: `isoformat()` gives
751
+ // the ISO 8601 text. (datetime is a subclass of date, so both are covered.)
752
+ if type_object.hasattr("isoformat")? {
658
753
  let iso = obj.call_method0("isoformat")?;
659
- let s = iso.extract::<&str>()?.to_owned();
660
- return Ok(Value::String(s));
754
+ return Ok(Value::String(string_to_owned(iso.cast::<PyString>()?)));
661
755
  }
662
756
 
663
757
  // uuid.UUID: has both .hex and .int attributes; str() gives canonical form.
664
- if obj.hasattr("hex")? && obj.hasattr("int")? {
665
- // Verify it's actually from the uuid module to avoid misdetecting objects
666
- // that happen to have both attributes.
667
- let type_name = obj
668
- .get_type()
669
- .name()
670
- .map(|n| n.to_string())
671
- .unwrap_or_default();
672
- if type_name == "UUID" {
673
- let s = obj.str()?.to_str()?.to_owned();
674
- return Ok(Value::String(s));
675
- }
758
+ // The type name check avoids misdetecting objects that happen to have both.
759
+ if type_object.hasattr("hex")? && type_object.hasattr("int")? && type_name(obj) == "UUID" {
760
+ return Ok(Value::String(string_to_owned(&obj.str()?)));
676
761
  }
677
762
 
678
763
  // dataclass: use dataclasses.fields() rather than __dict__, so slots=True
679
764
  // dataclasses and inherited fields follow Python's canonical field order.
680
- if obj.hasattr("__dataclass_fields__")? {
681
- let dataclasses = obj.py().import("dataclasses")?;
682
- let fields = dataclasses.call_method1("fields", (obj,))?;
683
- let fields = fields.cast::<PyTuple>()?;
684
- let container_id = enter_container(obj, containers)?;
685
- let mut entries = Vec::with_capacity(fields.len());
686
- for field in fields.iter() {
687
- let key = field.getattr("name")?.extract::<String>()?;
688
- let value = obj.getattr(key.as_str())?;
689
- entries.push((key, convert_py_value_inner(&value, depth + 1, containers)?));
690
- }
765
+ if type_object.hasattr("__dataclass_fields__")? {
766
+ let Some(container_id) = enter_container(obj, containers) else {
767
+ return Ok(marker("cycle", obj));
768
+ };
769
+ let result = convert_dataclass(obj, depth, containers);
691
770
  leave_container(container_id, containers);
692
- return Ok(Value::Map(entries));
771
+ return result;
693
772
  }
694
773
 
695
- // Unsupported type — raise TypeError, matching orjson's rejection of
696
- // Decimal/bytes/bytearray/set/frozenset/timedelta/Path/etc.
697
- let type_name = obj
698
- .get_type()
699
- .name()
700
- .map(|n| n.to_string())
701
- .unwrap_or_else(|_| "unknown".to_owned());
702
- Err(PyTypeError::new_err(format!(
703
- "Object of type {type_name} is not serializable"
704
- )))
774
+ // Unsupported type (Decimal/bytes/set/timedelta/Path/request objects/...):
775
+ // a type marker, never `str()`/`repr()`, so nothing the object holds —
776
+ // headers, cookies, bodies — can leak into the log line.
777
+ Ok(marker("unsupported", obj))
778
+ }
779
+
780
+ fn convert_dataclass(
781
+ obj: &Bound<'_, PyAny>,
782
+ depth: usize,
783
+ containers: &mut ContainerStack,
784
+ ) -> PyResult<Value> {
785
+ let dataclasses = obj.py().import("dataclasses")?;
786
+ let fields = dataclasses.call_method1("fields", (obj,))?;
787
+ let fields = fields.cast::<PyTuple>()?;
788
+ let mut entries = Vec::with_capacity(fields.len());
789
+ for field in fields.iter() {
790
+ let key = field.getattr("name")?.extract::<String>()?;
791
+ let value = obj.getattr(key.as_str())?;
792
+ entries.push((key, convert_py_value_inner(&value, depth + 1, containers)?));
793
+ }
794
+ Ok(Value::Map(entries))
705
795
  }
706
796
 
707
- fn enter_container(obj: &Bound<'_, PyAny>, containers: &mut ContainerStack) -> PyResult<usize> {
797
+ /// Push `obj` onto the container stack, or return `None` when it is already on
798
+ /// the current path (a cycle). Every `Some` must be paired with
799
+ /// [`leave_container`] before the caller returns, including on error paths,
800
+ /// so a recovered failure cannot leave a stale entry that later reports a
801
+ /// false cycle.
802
+ fn enter_container(obj: &Bound<'_, PyAny>, containers: &mut ContainerStack) -> Option<usize> {
708
803
  let container_id = obj.as_ptr() as usize;
709
804
  if containers.contains(&container_id) {
710
- return Err(PyValueError::new_err(
711
- "cycle detected while converting Python value",
712
- ));
805
+ return None;
713
806
  }
714
807
  containers.push(container_id);
715
- Ok(container_id)
808
+ Some(container_id)
716
809
  }
717
810
 
718
811
  fn leave_container(container_id: usize, containers: &mut ContainerStack) {
719
- debug_assert_eq!(containers.pop(), Some(container_id));
812
+ // Pop outside the assertion: release builds compile `debug_assert_eq!` out
813
+ // entirely, and a pop that never ran made every repeated reference — the
814
+ // same dict in two list slots — look like a cycle.
815
+ let popped = containers.pop();
816
+ debug_assert_eq!(popped, Some(container_id));
720
817
  }
721
818
 
722
819
  fn value_to_py<'py>(py: Python<'py>, value: &Value) -> PyResult<Bound<'py, PyAny>> {
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "structguru"
7
- version = "1.2.2"
7
+ version = "1.2.3"
8
8
  description = "Native structured logging with a loguru-style Python API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -19,7 +19,7 @@ from structguru.metrics import MetricProcessor
19
19
  from structguru.otel import add_otel_context
20
20
  from structguru.redaction import DEFAULT_SENSITIVE_KEYS
21
21
 
22
- __version__ = "1.2.2"
22
+ __version__ = "1.2.3"
23
23
 
24
24
  __all__ = [
25
25
  "DEFAULT_SENSITIVE_KEYS",
@@ -268,6 +268,13 @@ class Logger:
268
268
  * ``contextualize()`` — add request-scoped context via *contextvars*.
269
269
  * ``add()`` / ``remove()`` — manage logging handlers (sinks).
270
270
  * ``opt()`` — include exception info or stack traces for one call.
271
+
272
+ Field values are rendered natively: JSON scalars, mappings, sequences,
273
+ ``datetime``/``date``, ``UUID``, ``Enum`` (by value), and dataclasses. A
274
+ value outside that set never fails the call; it is replaced by a marker
275
+ naming its type (``<unsupported: PosixPath>``), a self-referencing
276
+ container by ``<cycle: dict>``, and nesting beyond 64 levels by
277
+ ``<max depth exceeded>``.
271
278
  """
272
279
 
273
280
  name: str | None = None
@@ -104,6 +104,12 @@ class StructguruHandler(logging.Handler):
104
104
  ``exc_info`` and ``stack_info`` are carried through. The already-formatted
105
105
  message is passed verbatim (no brace re-formatting), so literal ``{...}`` in
106
106
  a message is never misinterpreted.
107
+
108
+ An ``extra=`` value the native renderer cannot represent — Django's
109
+ ``log_response()`` attaches the raw request as ``extra["request"]``, for
110
+ example — is replaced by a ``<unsupported: WSGIRequest>`` marker rather than
111
+ failing the record, at any nesting depth. The record and its ``extra``
112
+ objects are never modified, so later handlers still see the originals.
107
113
  """
108
114
 
109
115
  def __init__(self, level: int = logging.NOTSET) -> None:
File without changes