@@ -7,6 +7,8 @@ LogPrivacy has two structured-data APIs:
77- ` to_safe_data() ` converts supported Python values into sanitized JSON-safe
88 data: ` None ` , booleans, finite numbers, strings, lists, and dictionaries with
99 string keys.
10+ - ` to_safe_data_with_result() ` does the same and additionally returns completeness
11+ metadata, limitation identifiers, and per-call statistics.
1012
1113Use ` to_safe_data() ` before structured logging, JSON responses, JSON files, or
1214anywhere arbitrary Python objects must not escape into output.
@@ -84,6 +86,52 @@ to_safe_data(Request("req-1", {"token": "abc123456789"}), adapters=adapters)
8486` AdapterRegistry.default() ` returns a fresh registry. Registering a converter in
8587one registry does not mutate another registry or global process state.
8688
89+ ### Dispatch Order
90+
91+ Adapter resolution runs ** before** the built-in structural handlers for
92+ ` Mapping ` , ` list ` /` tuple ` , and ` set ` /` frozenset ` . This means a custom subclass
93+ registered in the adapter registry always takes precedence over the native
94+ fallback, even if the subclass inherits from a built-in container type.
95+
96+ Dispatch order within ` _normalize ` :
97+
98+ 1 . Exact primitive types: ` None ` , ` bool ` , ` int ` , ` float ` , ` str ` , ` bytes ` ,
99+ ` bytearray ` , ` memoryview ` — always handled by the core pipeline, never
100+ intercepted by adapters.
101+ 2 . Adapter registry — MRO lookup first (no ` isinstance ` ), then virtual/abstract
102+ types (protected ` isinstance ` calls).
103+ 3 . Structural fallbacks — ` str ` subclasses, ` Mapping ` , ` list ` /` tuple ` ,
104+ ` set ` /` frozenset ` , dataclasses, ` Enum ` , ` Decimal ` , date/time types, ` UUID ` ,
105+ ` Path ` , exceptions.
106+ 4 . Unsupported — becomes ` [UNSUPPORTED:TypeName] ` .
107+
108+ ### Reserved Types
109+
110+ The following types are reserved for the core pipeline and cannot be used as
111+ adapter targets. Attempting to register them raises ` ValueError ` :
112+
113+ ` object ` , ` str ` , ` int ` , ` float ` , ` bool ` , ` bytes ` , ` bytearray ` , ` memoryview ` ,
114+ ` dict ` , ` list ` , ` tuple ` , ` set ` , ` frozenset ` .
115+
116+ Custom subclasses are fully supported:
117+
118+ ``` python
119+ class ExternalList (list ):
120+ pass
121+
122+ adapters.register(ExternalList, lambda v : {" items" : list (v), " source" : " external" })
123+ ```
124+
125+ ### Adapter Failures
126+
127+ If a converter raises, returns the original object, or if ` __instancecheck__ `
128+ raises during virtual-type resolution, the value becomes
129+ ` [UNSUPPORTED:TypeName] ` and ` adapter_error ` is added to limitations. The
130+ original value is never exposed.
131+
132+ Converters are never called on reserved primitive types regardless of how the
133+ registry was configured.
134+
87135## Field Rules
88136
89137` CleanerPolicy.sensitive_keys ` is still supported. For more control, add
@@ -115,13 +163,70 @@ Field rules support four actions:
115163
116164- ` mask ` : replace the value using the policy masking strategy;
117165- ` remove ` : keep the field but replace the value with ` [REMOVED] ` ;
118- - ` truncate ` : keep only ` max_chars ` from textual values, then sanitize that
119- preserved text;
166+ - ` truncate ` : sanitize the ** full** text first, then cut to ` max_chars ` and
167+ append ` [TRUNCATED] ` if needed. This order ensures secrets that fall after
168+ the cut point are never exposed in the output.
120169- ` block ` : raise ` LogBlockedError ` without including the blocked value.
121170
122- In this phase, ` truncate ` is intentionally limited to textual values
123- (` str ` , ` bytes ` , ` bytearray ` , and ` memoryview ` ). Non-text values become
124- ` [TRUNCATED] ` .
171+ ` truncate ` is limited to textual values (` str ` , ` bytes ` , ` bytearray ` , and
172+ ` memoryview ` ). Non-text values become ` [TRUNCATED] ` without sanitization
173+ (there is no text content to redact).
174+
175+ ### Field Rules on Exceptions
176+
177+ Field rules apply to the ` type ` and ` message ` fields produced when an exception
178+ is sanitized, giving the same protection as mappings and dataclasses:
179+
180+ ``` python
181+ from logprivacy import CleanerPolicy, FieldRule, to_safe_data
182+
183+ policy = CleanerPolicy.default().add_field_rules(
184+ FieldRule.exact(" message" , action = " remove" ),
185+ )
186+
187+ to_safe_data(ValueError (" secret token: abc123" ), policy = policy)
188+ # {"type": "ValueError", "message": "[REMOVED]"}
189+ ```
190+
191+ ## Rich Output with SafeDataResult
192+
193+ Use ` to_safe_data_with_result() ` when you need to know whether sanitization was
194+ complete, which limits were reached, or how many fields were redacted:
195+
196+ ``` python
197+ from logprivacy import to_safe_data_with_result
198+
199+ result = to_safe_data_with_result(payload)
200+
201+ if not result.complete:
202+ logger.warning(" partial sanitization: %s " , result.limitations)
203+
204+ log_event(result.cleaned)
205+ ```
206+
207+ ` SafeDataResult ` fields:
208+
209+ - ` cleaned ` — the sanitized value, equivalent to ` to_safe_data(payload) ` ;
210+ - ` complete ` — ` False ` when any traversal limit was reached or any branch could
211+ not be fully inspected;
212+ - ` limitations ` — a tuple of stable string identifiers for each limit hit:
213+ ` max_depth ` , ` max_items ` , ` iteration_error ` , ` representation_error ` ,
214+ ` adapter_error ` , ` unsupported_type ` , ` recursive_value ` ;
215+ - ` stats ` — a ` SafeDataStats ` dataclass with aggregate counters.
216+
217+ ` SafeDataStats ` fields:
218+
219+ | Field | Counts |
220+ | ---| ---|
221+ | ` masked ` | text findings redacted by the text pipeline plus structural mask actions and sensitive-key masking |
222+ | ` removed ` | fields replaced with ` [REMOVED] ` by a ` remove ` rule |
223+ | ` truncated ` | fields truncated by a ` truncate ` rule |
224+ | ` unsupported ` | values replaced with ` [UNSUPPORTED:TypeName] ` |
225+ | ` adapter_errors ` | converters that raised, returned self, or had ` __instancecheck__ ` raise during resolution |
226+ | ` field_rule_matches ` | total ` FieldRule ` matches across all fields |
227+
228+ Both ` SafeDataResult ` and ` SafeDataStats ` are frozen dataclasses. Neither retains
229+ any reference to the original value or raw converter output.
125230
126231## Safe JSON
127232
0 commit comments