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.
- {structguru-1.2.2 → structguru-1.2.3}/Cargo.lock +2 -2
- {structguru-1.2.2 → structguru-1.2.3}/Cargo.toml +1 -1
- {structguru-1.2.2 → structguru-1.2.3}/PKG-INFO +18 -1
- {structguru-1.2.2 → structguru-1.2.3}/README.md +17 -0
- {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-core/src/render.rs +3 -3
- {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-core/src/value.rs +3 -3
- {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-py/src/lib.rs +190 -93
- {structguru-1.2.2 → structguru-1.2.3}/pyproject.toml +1 -1
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/__init__.py +1 -1
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/core.py +7 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/stdlib.py +6 -0
- {structguru-1.2.2 → structguru-1.2.3}/LICENSE +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-core/Cargo.toml +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-core/src/clock.rs +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-core/src/filter.rs +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-core/src/lib.rs +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-core/src/worker.rs +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/crates/structguru-py/Cargo.toml +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/_contextvars.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/_native_dispatch.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/_native_env.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/_runtime.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/config.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/exceptions.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/__init__.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/_stdlib_env.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/_util.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/asgi.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/celery.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/django.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/flask.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/grpc.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/httpx.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/requests.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/sentry.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/integrations/sqlalchemy.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/metrics.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/otel.py +0 -0
- {structguru-1.2.2 → structguru-1.2.3}/src/structguru/py.typed +0 -0
- {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.
|
|
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.
|
|
260
|
+
version = "1.2.3"
|
|
261
261
|
dependencies = [
|
|
262
262
|
"pyo3",
|
|
263
263
|
"regex",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: structguru
|
|
3
|
-
Version: 1.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
|
-
///
|
|
155
|
-
///
|
|
156
|
-
///
|
|
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
|
|
17
|
-
///
|
|
18
|
-
///
|
|
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::{
|
|
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
|
|
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>()
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
|
607
|
+
return result;
|
|
606
608
|
}
|
|
607
609
|
if let Ok(list) = obj.cast::<PyList>() {
|
|
608
|
-
let container_id = enter_container(obj, containers)
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
|
615
|
+
return result;
|
|
615
616
|
}
|
|
616
617
|
if let Ok(tuple) = obj.cast::<PyTuple>() {
|
|
617
|
-
let container_id = enter_container(obj, containers)
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
|
623
|
+
return result;
|
|
624
624
|
}
|
|
625
625
|
|
|
626
|
-
// Exotic leaves
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
|
|
631
|
-
|
|
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.
|
|
636
|
-
///
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
if
|
|
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
|
|
656
|
-
// (datetime is a subclass of date, so both are covered.)
|
|
657
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
|
681
|
-
let
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
let
|
|
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
|
|
771
|
+
return result;
|
|
693
772
|
}
|
|
694
773
|
|
|
695
|
-
// Unsupported type
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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
|
|
711
|
-
"cycle detected while converting Python value",
|
|
712
|
-
));
|
|
805
|
+
return None;
|
|
713
806
|
}
|
|
714
807
|
containers.push(container_id);
|
|
715
|
-
|
|
808
|
+
Some(container_id)
|
|
716
809
|
}
|
|
717
810
|
|
|
718
811
|
fn leave_container(container_id: usize, containers: &mut ContainerStack) {
|
|
719
|
-
debug_assert_eq
|
|
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>> {
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|