pyobjtojson 0.8__tar.gz → 0.9.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyobjtojson
3
- Version: 0.8
3
+ Version: 0.9.1
4
4
  Summary: A Python library that simplifies serializing any Python object to JSON-friendly structures, gracefully handling circular references.
5
5
  Author-email: "Carlos A. Planchón" <carlosandresplanchonprestes@gmail.com>
6
6
  License-Expression: MIT
@@ -8,9 +8,12 @@ Project-URL: Repository, https://github.com/carlosplanchon/pyobjtojson.git
8
8
  Keywords: json,serialization,circular references,pydantic,dataclasses
9
9
  Classifier: Intended Audience :: Developers
10
10
  Classifier: Topic :: Software Development :: Libraries
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
11
14
  Classifier: Programming Language :: Python :: 3.13
12
15
  Classifier: Programming Language :: Python :: 3.14
13
- Requires-Python: >=3.13
16
+ Requires-Python: >=3.10
14
17
  Description-Content-Type: text/markdown
15
18
  License-File: LICENSE
16
19
  Provides-Extra: dev
@@ -34,12 +37,14 @@ A lightweight Python library that simplifies the process of serializing **any**
34
37
  Works seamlessly with dictionaries, lists, custom classes, dataclasses, and Pydantic models (including both `model_dump()` from v2 and `dict()` from v1).
35
38
  - **Extended Standard Types Support**
36
39
  Native support for `datetime`, `date`, `time`, `UUID`, `Decimal`, `bytes`, `Enum`, `Path`, `set`, and `frozenset`.
40
+ - **JSON-Safe Dictionary Keys**
41
+ Non-string keys (`UUID`, `datetime`, `Enum`, tuples, and other objects) are converted so the returned structure always survives `json.dumps`.
37
42
  - **Full Type Hints Support**
38
43
  Complete type annotations for better IDE autocomplete, type checking with mypy, and improved code documentation.
39
44
  - **Non-Intrusive Serialization**
40
45
  No special inheritance or overrides needed. Uses reflection and standard Python methods (`__dict__`, `asdict()`, `to_dict()`, etc.) where available.
41
46
  - **Easy to Integrate**
42
- Just call `obj_to_json()` on your data structure—no additional configuration required.
47
+ Just call `obj_to_json()` on your data structure. No additional configuration required.
43
48
 
44
49
  ### DeepWiki Docs: [https://deepwiki.com/carlosplanchon/pyobjtojson](https://deepwiki.com/carlosplanchon/pyobjtojson)
45
50
 
@@ -63,7 +68,12 @@ data = {
63
68
  "nested": {"inner_key": "inner_value"}
64
69
  }
65
70
 
66
- json_obj = obj_to_json(data) # Using json.dumps kwargs
71
+ # obj_to_json returns a JSON-serializable structure (nested dicts, lists and
72
+ # primitives), not a JSON string. Pass it to json.dumps() when you need text:
73
+ json_obj = obj_to_json(data)
74
+
75
+ import json
76
+ json_text = json.dumps(json_obj)
67
77
  ```
68
78
 
69
79
  **Output** (example):
@@ -201,9 +211,47 @@ obj_to_json(data)
201
211
  - **Path** → string representation
202
212
  - **set, frozenset** → sorted lists
203
213
 
214
+ ### 5. Dictionary Keys
215
+
216
+ JSON object keys must be strings, so **pyobjtojson** normalizes non-string keys
217
+ to keep the result compatible with `json.dumps`:
218
+
219
+ - `str`, `int`, `float`, `bool`, and `None` keys are kept as-is (`json.dumps`
220
+ already coerces the non-string primitives to strings itself).
221
+ - Typed keys such as `UUID`, `datetime`, `Enum`, `Decimal`, and `Path` are
222
+ converted to their natural scalar form (e.g. `UUID` → string, `datetime` →
223
+ ISO string), respecting `decimal_as_float`.
224
+ - Any remaining composite key (a tuple, `frozenset`, or custom object) is
225
+ stringified as a last resort.
226
+
227
+ ```python
228
+ from uuid import UUID
229
+ from pyobjtojson import obj_to_json
230
+
231
+ data = {
232
+ UUID("12345678-1234-5678-1234-567812345678"): "by uuid",
233
+ (1, 2): "by tuple",
234
+ 42: "by int",
235
+ }
236
+
237
+ obj_to_json(data)
238
+ ```
239
+
240
+ **Output**:
241
+ ```json
242
+ {
243
+ "12345678-1234-5678-1234-567812345678": "by uuid",
244
+ "[1, 2]": "by tuple",
245
+ "42": "by int"
246
+ }
247
+ ```
248
+
249
+ > **Note:** If two distinct keys normalize to the same string, the last one
250
+ > wins. This mirrors how JSON itself collapses duplicate keys.
251
+
204
252
  ## API Reference
205
253
 
206
- ### `obj_to_json(obj, check_circular=True, decimal_as_float=True)`
254
+ ### `obj_to_json(obj, check_circular=True, decimal_as_float=True, non_finite="null")`
207
255
 
208
256
  Returns a cycle-free structure (nested dictionaries/lists) that is JSON-serializable.
209
257
 
@@ -211,10 +259,33 @@ Returns a cycle-free structure (nested dictionaries/lists) that is JSON-serializ
211
259
  - `obj` (Any): The object to serialize to JSON-like structures.
212
260
  - `check_circular` (bool, optional): If True (default), detect and mark circular references as `"<circular reference>"`.
213
261
  - `decimal_as_float` (bool, optional): If True (default), convert `Decimal` to `float`. If False, convert to string for high precision.
262
+ - `non_finite` (str, optional): How to represent non-finite floats (`inf`, `-inf`, `nan`), which have no JSON literal. One of:
263
+ - `"null"` (default): convert to `None`, matching JavaScript's `JSON.stringify`.
264
+ - `"string"`: convert to `"Infinity"`, `"-Infinity"`, or `"NaN"`.
265
+ - `"keep"`: leave the float as-is. Note this is **not** valid JSON and will raise with `json.dumps(..., allow_nan=False)`.
266
+
267
+ An unknown value raises `ValueError`.
214
268
 
215
269
  **Returns:**
216
270
  - `dict | list | Any`: A JSON-serializable structure.
217
271
 
272
+ #### Non-finite floats
273
+
274
+ `inf`, `-inf`, and `nan` are valid Python floats but have no representation in
275
+ the JSON spec. Left untouched they break `json.dumps(..., allow_nan=False)` and
276
+ produce the non-standard `Infinity`/`NaN` tokens that strict parsers reject. By
277
+ default **pyobjtojson** converts them to `null` so the output is always valid,
278
+ portable JSON:
279
+
280
+ ```python
281
+ from pyobjtojson import obj_to_json
282
+
283
+ data = {"ratio": float("inf"), "value": float("nan"), "ok": 1.5}
284
+
285
+ obj_to_json(data) # {"ratio": None, "value": None, "ok": 1.5}
286
+ obj_to_json(data, non_finite="string") # {"ratio": "Infinity", "value": "NaN", "ok": 1.5}
287
+ ```
288
+
218
289
  ## Type Hints
219
290
 
220
291
  **pyobjtojson** is fully typed and passes strict mypy checking. This provides:
@@ -12,12 +12,14 @@ A lightweight Python library that simplifies the process of serializing **any**
12
12
  Works seamlessly with dictionaries, lists, custom classes, dataclasses, and Pydantic models (including both `model_dump()` from v2 and `dict()` from v1).
13
13
  - **Extended Standard Types Support**
14
14
  Native support for `datetime`, `date`, `time`, `UUID`, `Decimal`, `bytes`, `Enum`, `Path`, `set`, and `frozenset`.
15
+ - **JSON-Safe Dictionary Keys**
16
+ Non-string keys (`UUID`, `datetime`, `Enum`, tuples, and other objects) are converted so the returned structure always survives `json.dumps`.
15
17
  - **Full Type Hints Support**
16
18
  Complete type annotations for better IDE autocomplete, type checking with mypy, and improved code documentation.
17
19
  - **Non-Intrusive Serialization**
18
20
  No special inheritance or overrides needed. Uses reflection and standard Python methods (`__dict__`, `asdict()`, `to_dict()`, etc.) where available.
19
21
  - **Easy to Integrate**
20
- Just call `obj_to_json()` on your data structure—no additional configuration required.
22
+ Just call `obj_to_json()` on your data structure. No additional configuration required.
21
23
 
22
24
  ### DeepWiki Docs: [https://deepwiki.com/carlosplanchon/pyobjtojson](https://deepwiki.com/carlosplanchon/pyobjtojson)
23
25
 
@@ -41,7 +43,12 @@ data = {
41
43
  "nested": {"inner_key": "inner_value"}
42
44
  }
43
45
 
44
- json_obj = obj_to_json(data) # Using json.dumps kwargs
46
+ # obj_to_json returns a JSON-serializable structure (nested dicts, lists and
47
+ # primitives), not a JSON string. Pass it to json.dumps() when you need text:
48
+ json_obj = obj_to_json(data)
49
+
50
+ import json
51
+ json_text = json.dumps(json_obj)
45
52
  ```
46
53
 
47
54
  **Output** (example):
@@ -179,9 +186,47 @@ obj_to_json(data)
179
186
  - **Path** → string representation
180
187
  - **set, frozenset** → sorted lists
181
188
 
189
+ ### 5. Dictionary Keys
190
+
191
+ JSON object keys must be strings, so **pyobjtojson** normalizes non-string keys
192
+ to keep the result compatible with `json.dumps`:
193
+
194
+ - `str`, `int`, `float`, `bool`, and `None` keys are kept as-is (`json.dumps`
195
+ already coerces the non-string primitives to strings itself).
196
+ - Typed keys such as `UUID`, `datetime`, `Enum`, `Decimal`, and `Path` are
197
+ converted to their natural scalar form (e.g. `UUID` → string, `datetime` →
198
+ ISO string), respecting `decimal_as_float`.
199
+ - Any remaining composite key (a tuple, `frozenset`, or custom object) is
200
+ stringified as a last resort.
201
+
202
+ ```python
203
+ from uuid import UUID
204
+ from pyobjtojson import obj_to_json
205
+
206
+ data = {
207
+ UUID("12345678-1234-5678-1234-567812345678"): "by uuid",
208
+ (1, 2): "by tuple",
209
+ 42: "by int",
210
+ }
211
+
212
+ obj_to_json(data)
213
+ ```
214
+
215
+ **Output**:
216
+ ```json
217
+ {
218
+ "12345678-1234-5678-1234-567812345678": "by uuid",
219
+ "[1, 2]": "by tuple",
220
+ "42": "by int"
221
+ }
222
+ ```
223
+
224
+ > **Note:** If two distinct keys normalize to the same string, the last one
225
+ > wins. This mirrors how JSON itself collapses duplicate keys.
226
+
182
227
  ## API Reference
183
228
 
184
- ### `obj_to_json(obj, check_circular=True, decimal_as_float=True)`
229
+ ### `obj_to_json(obj, check_circular=True, decimal_as_float=True, non_finite="null")`
185
230
 
186
231
  Returns a cycle-free structure (nested dictionaries/lists) that is JSON-serializable.
187
232
 
@@ -189,10 +234,33 @@ Returns a cycle-free structure (nested dictionaries/lists) that is JSON-serializ
189
234
  - `obj` (Any): The object to serialize to JSON-like structures.
190
235
  - `check_circular` (bool, optional): If True (default), detect and mark circular references as `"<circular reference>"`.
191
236
  - `decimal_as_float` (bool, optional): If True (default), convert `Decimal` to `float`. If False, convert to string for high precision.
237
+ - `non_finite` (str, optional): How to represent non-finite floats (`inf`, `-inf`, `nan`), which have no JSON literal. One of:
238
+ - `"null"` (default): convert to `None`, matching JavaScript's `JSON.stringify`.
239
+ - `"string"`: convert to `"Infinity"`, `"-Infinity"`, or `"NaN"`.
240
+ - `"keep"`: leave the float as-is. Note this is **not** valid JSON and will raise with `json.dumps(..., allow_nan=False)`.
241
+
242
+ An unknown value raises `ValueError`.
192
243
 
193
244
  **Returns:**
194
245
  - `dict | list | Any`: A JSON-serializable structure.
195
246
 
247
+ #### Non-finite floats
248
+
249
+ `inf`, `-inf`, and `nan` are valid Python floats but have no representation in
250
+ the JSON spec. Left untouched they break `json.dumps(..., allow_nan=False)` and
251
+ produce the non-standard `Infinity`/`NaN` tokens that strict parsers reject. By
252
+ default **pyobjtojson** converts them to `null` so the output is always valid,
253
+ portable JSON:
254
+
255
+ ```python
256
+ from pyobjtojson import obj_to_json
257
+
258
+ data = {"ratio": float("inf"), "value": float("nan"), "ok": 1.5}
259
+
260
+ obj_to_json(data) # {"ratio": None, "value": None, "ok": 1.5}
261
+ obj_to_json(data, non_finite="string") # {"ratio": "Infinity", "value": "NaN", "ok": 1.5}
262
+ ```
263
+
196
264
  ## Type Hints
197
265
 
198
266
  **pyobjtojson** is fully typed and passes strict mypy checking. This provides:
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import base64
4
+ import dataclasses
5
+ import math
6
+ from collections.abc import Mapping, Sequence
7
+ from datetime import datetime, date, time
8
+ from decimal import Decimal
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from uuid import UUID
13
+
14
+
15
+ # Allowed policies for representing non-finite floats (inf/-inf/nan).
16
+ _NON_FINITE_MODES = ("null", "string", "keep")
17
+
18
+
19
+ def _non_finite_repr(value: float, non_finite: str) -> Any:
20
+ """
21
+ Represent a non-finite float (``inf``, ``-inf`` or ``nan``) according to
22
+ the chosen policy. JSON has no literal for these values, so leaving them
23
+ as-is yields output that either breaks ``json.dumps(..., allow_nan=False)``
24
+ or produces the non-standard ``Infinity``/``NaN`` tokens that strict
25
+ parsers reject.
26
+
27
+ - ``"null"``: return ``None`` (matches JavaScript's ``JSON.stringify``).
28
+ - ``"string"``: return ``"Infinity"``, ``"-Infinity"`` or ``"NaN"``.
29
+ - ``"keep"``: return the float unchanged (legacy behavior).
30
+
31
+ :param value: A non-finite float.
32
+ :param non_finite: One of the policies above.
33
+ :return: The chosen JSON-compatible representation.
34
+ """
35
+ if non_finite == "null":
36
+ return None
37
+ if non_finite == "string":
38
+ if value != value: # NaN is the only value not equal to itself
39
+ return "NaN"
40
+ return "Infinity" if value > 0 else "-Infinity"
41
+ return value # "keep"
42
+
43
+
44
+ def _serialize_for_json(
45
+ obj: Any,
46
+ visited: set[int],
47
+ check_circular: bool = True,
48
+ _skip_circular_check: bool = False,
49
+ decimal_as_float: bool = True,
50
+ non_finite: str = "null"
51
+ ) -> Any:
52
+ """
53
+ Internal recursion logic that can handle circular references
54
+ using `visited`. This includes careful exception handling so
55
+ partial failures don't break the entire serialization.
56
+
57
+ :param obj: The object to serialize.
58
+ :param visited: A set used to track visited objects (for cycle detection).
59
+ :param check_circular: Whether to check for and mark circular references.
60
+ :param _skip_circular_check: Internal flag to skip adding intermediate
61
+ conversion results to visited set.
62
+ :param decimal_as_float: If True, convert Decimal to float; otherwise to string.
63
+ :param non_finite: How to represent non-finite floats (inf/-inf/nan):
64
+ "null", "string", or "keep".
65
+ :return: A JSON-serializable structure, or a string if it cannot be
66
+ converted more structurally.
67
+ """
68
+
69
+ # Enum → underlying value. This must run before the primitive fast-path
70
+ # below: IntEnum/StrEnum members are also int/str instances, so the
71
+ # fast-path would otherwise return the enum member itself instead of its
72
+ # documented `.value`.
73
+ if isinstance(obj, Enum):
74
+ return obj.value
75
+
76
+ # Non-finite floats (inf, -inf, nan) have no JSON representation. Route
77
+ # them through the chosen policy before the primitive fast-path returns
78
+ # them verbatim. bool/int are always finite, so only float needs this.
79
+ if isinstance(obj, float) and not math.isfinite(obj):
80
+ return _non_finite_repr(obj, non_finite)
81
+
82
+ # If it's None, bool, int, float, or str, it's already JSON-serializable.
83
+ if obj is None or isinstance(obj, (bool, int, float, str)):
84
+ return obj
85
+
86
+ # === Standard Python types support ===
87
+
88
+ # datetime, date, time → ISO format
89
+ if isinstance(obj, datetime):
90
+ return obj.isoformat()
91
+ if isinstance(obj, date):
92
+ return obj.isoformat()
93
+ if isinstance(obj, time):
94
+ return obj.isoformat()
95
+
96
+ # UUID → string
97
+ if isinstance(obj, UUID):
98
+ return str(obj)
99
+
100
+ # Decimal → float or string
101
+ if isinstance(obj, Decimal):
102
+ if decimal_as_float:
103
+ # A Decimal can also be non-finite (Decimal("inf")/Decimal("nan")),
104
+ # so apply the same policy once converted to float.
105
+ as_float = float(obj)
106
+ if not math.isfinite(as_float):
107
+ return _non_finite_repr(as_float, non_finite)
108
+ return as_float
109
+ return str(obj)
110
+
111
+ # bytes, bytearray → base64
112
+ if isinstance(obj, (bytes, bytearray)):
113
+ return base64.b64encode(bytes(obj)).decode('utf-8')
114
+
115
+ # Path → string
116
+ if isinstance(obj, Path):
117
+ return str(obj)
118
+
119
+ # set, frozenset → list
120
+ if isinstance(obj, (set, frozenset)):
121
+ try:
122
+ # Try to sort if elements are comparable
123
+ return sorted(list(obj))
124
+ except TypeError:
125
+ # If not sortable, convert to list without sorting
126
+ return list(obj)
127
+
128
+ obj_id = id(obj)
129
+
130
+ # If circular checking is enabled, see if this object is already on the
131
+ # current traversal path. We track whether *this* frame added the id so we
132
+ # can remove it again on the way out (see the `finally` below). Keeping
133
+ # `visited` as the active path (not every object ever seen) is what makes
134
+ # this real cycle detection: a shared sub-object referenced from two
135
+ # sibling branches (a DAG, not a cycle) must not be flagged circular.
136
+ added_to_visited = False
137
+ if check_circular is True and not _skip_circular_check:
138
+ if obj_id in visited:
139
+ return "<circular reference>"
140
+ visited.add(obj_id)
141
+ added_to_visited = True
142
+
143
+ try:
144
+ # Handle Mapping (like dict). Build a new dict item by item,
145
+ # catching errors.
146
+ if isinstance(obj, Mapping):
147
+ result_dict: dict[Any, Any] = {}
148
+ for key, value in obj.items():
149
+ # Keys must also be JSON-compatible, otherwise json.dumps would
150
+ # reject the returned structure even though the values are fine.
151
+ json_key = _serialize_key(
152
+ key, visited, check_circular=check_circular,
153
+ decimal_as_float=decimal_as_float, non_finite=non_finite
154
+ )
155
+ try:
156
+ result_dict[json_key] = _serialize_for_json(
157
+ value, visited, check_circular=check_circular,
158
+ decimal_as_float=decimal_as_float, non_finite=non_finite
159
+ )
160
+ except Exception as exc:
161
+ result_dict[json_key] = f"<serialization error: {exc}>"
162
+ return result_dict
163
+
164
+ # Handle Sequence (like list/tuple), but not string.
165
+ if isinstance(obj, Sequence) and not isinstance(obj, str):
166
+ result_list: list[Any] = []
167
+ for index, item in enumerate(obj):
168
+ try:
169
+ result_list.append(
170
+ _serialize_for_json(
171
+ obj=item,
172
+ visited=visited,
173
+ check_circular=check_circular,
174
+ decimal_as_float=decimal_as_float,
175
+ non_finite=non_finite
176
+ )
177
+ )
178
+ except Exception as exc:
179
+ result_list.append(f"<serialization error at index {index}: {exc}>")
180
+ return result_list
181
+
182
+ # Try Pydantic v2 model_dump(), but fall back if it fails
183
+ if hasattr(obj, "model_dump") and callable(obj.model_dump):
184
+ try:
185
+ model_data = obj.model_dump()
186
+ # Skip re-adding the intermediate dict to `visited`; this object
187
+ # is already on the path, so its converted form must not be
188
+ # treated as a separate node.
189
+ return _serialize_for_json(
190
+ obj=model_data,
191
+ visited=visited,
192
+ check_circular=check_circular,
193
+ _skip_circular_check=True,
194
+ decimal_as_float=decimal_as_float,
195
+ non_finite=non_finite
196
+ )
197
+ except Exception:
198
+ # Fall through to next check if this fails
199
+ ...
200
+
201
+ # Try Pydantic v1 .dict(), but fall back if it fails
202
+ if hasattr(obj, "dict") and callable(obj.dict):
203
+ try:
204
+ dict_data = obj.dict()
205
+ # Skip circular check for the intermediate dict
206
+ return _serialize_for_json(
207
+ obj=dict_data,
208
+ visited=visited,
209
+ check_circular=check_circular,
210
+ _skip_circular_check=True,
211
+ decimal_as_float=decimal_as_float,
212
+ non_finite=non_finite
213
+ )
214
+ except Exception:
215
+ # Fall through to next check if this fails
216
+ ...
217
+
218
+ # If it's a dataclass, convert it using asdict(), but handle exceptions
219
+ if dataclasses.is_dataclass(obj):
220
+ try:
221
+ # is_dataclass can return True for both instances and types,
222
+ # but we only process instances here
223
+ dc_data = dataclasses.asdict(obj) # type: ignore[arg-type]
224
+ # Skip circular check for the intermediate dict
225
+ return _serialize_for_json(
226
+ obj=dc_data,
227
+ visited=visited,
228
+ check_circular=check_circular,
229
+ _skip_circular_check=True,
230
+ decimal_as_float=decimal_as_float,
231
+ non_finite=non_finite
232
+ )
233
+ except Exception:
234
+ # Fall through to next check if this fails
235
+ ...
236
+
237
+ # If there's a custom .to_dict() method, try that
238
+ if hasattr(obj, "to_dict") and callable(obj.to_dict):
239
+ try:
240
+ custom_dict_data = obj.to_dict()
241
+ # Skip circular check for the intermediate dict
242
+ return _serialize_for_json(
243
+ obj=custom_dict_data,
244
+ visited=visited,
245
+ check_circular=check_circular,
246
+ _skip_circular_check=True,
247
+ decimal_as_float=decimal_as_float,
248
+ non_finite=non_finite
249
+ )
250
+ except Exception:
251
+ # Fall through to next check if this fails
252
+ ...
253
+
254
+ # If the object has a __dict__, recurse into that
255
+ if hasattr(obj, "__dict__"):
256
+ try:
257
+ # Skip circular check for the __dict__ to avoid false positives
258
+ return _serialize_for_json(
259
+ obj=obj.__dict__,
260
+ visited=visited,
261
+ check_circular=check_circular,
262
+ _skip_circular_check=True,
263
+ decimal_as_float=decimal_as_float,
264
+ non_finite=non_finite
265
+ )
266
+ except Exception:
267
+ # Fall through to next check if this fails
268
+ pass
269
+
270
+ # Last resort: convert to string, but even this can fail
271
+ # if __str__ is broken
272
+ try:
273
+ return str(obj)
274
+ except Exception as exc:
275
+ # If that fails, return a generic serialization error.
276
+ return f"<serialization error: {exc}>"
277
+ finally:
278
+ # Leave the traversal path: this object is no longer an ancestor of
279
+ # whatever we serialize next, so remove it to allow legitimate reuse
280
+ # of the same instance elsewhere in the tree.
281
+ if added_to_visited:
282
+ visited.discard(obj_id)
283
+
284
+
285
+ def _serialize_key(
286
+ key: Any,
287
+ visited: set[int],
288
+ check_circular: bool = True,
289
+ decimal_as_float: bool = True,
290
+ non_finite: str = "null"
291
+ ) -> Any:
292
+ """
293
+ Convert a mapping key into something ``json.dumps`` accepts as an object
294
+ key: ``str``, ``int``, ``float``, ``bool`` or ``None``.
295
+
296
+ ``json.dumps`` already coerces int/float/bool/None keys to strings itself,
297
+ so those (and plain strings) pass through unchanged. Any other key is run
298
+ through the normal value serializer so that common non-string keys become
299
+ their natural scalar form (``UUID`` → str, ``datetime`` → ISO string,
300
+ ``Enum`` → its value, ``Decimal`` → float/str). If the result is still a
301
+ composite (e.g. a tuple serialized to a list), it is stringified as a last
302
+ resort so the returned structure always survives ``json.dumps``.
303
+
304
+ :param key: The mapping key to serialize.
305
+ :param visited: The traversal-path set shared with the value serializer.
306
+ :param check_circular: Whether cycle detection is enabled.
307
+ :param decimal_as_float: If True, convert Decimal to float; otherwise string.
308
+ :param non_finite: Policy for non-finite float keys (see _serialize_for_json).
309
+ :return: A ``json.dumps``-compatible key.
310
+ """
311
+ # json.dumps natively accepts these as object keys, so leave them as-is to
312
+ # preserve its default behavior (e.g. int key 1 -> "1"). A non-finite float
313
+ # key is the one exception: it must follow the non_finite policy.
314
+ if isinstance(key, float) and not math.isfinite(key):
315
+ return _non_finite_repr(key, non_finite)
316
+ if key is None or isinstance(key, (str, bool, int, float)):
317
+ return key
318
+
319
+ # Reuse the value machinery so typed keys become their natural scalar form.
320
+ try:
321
+ serialized = _serialize_for_json(
322
+ obj=key,
323
+ visited=visited,
324
+ check_circular=check_circular,
325
+ decimal_as_float=decimal_as_float,
326
+ non_finite=non_finite
327
+ )
328
+ except Exception as exc:
329
+ return f"<unserializable key: {exc}>"
330
+
331
+ if serialized is None or isinstance(serialized, (str, bool, int, float)):
332
+ return serialized
333
+
334
+ # Composite results (lists/dicts from e.g. a tuple or a custom object)
335
+ # can't be object keys; fall back to a string form.
336
+ try:
337
+ return str(serialized)
338
+ except Exception as exc:
339
+ return f"<unserializable key: {exc}>"
340
+
341
+
342
+ def obj_to_json(
343
+ obj: Any,
344
+ check_circular: bool = True,
345
+ decimal_as_float: bool = True,
346
+ non_finite: str = "null"
347
+ ) -> Any:
348
+ """
349
+ Public-facing function that starts with a fresh visited set
350
+ to handle cycles (if `check_circular=True`). Calls the internal
351
+ _serialize_for_json.
352
+
353
+ Supports standard Python types including:
354
+ - datetime, date, time (converted to ISO format)
355
+ - UUID (converted to string)
356
+ - Decimal (converted to float or string based on decimal_as_float)
357
+ - bytes, bytearray (converted to base64)
358
+ - Enum (converted to underlying value)
359
+ - Path (converted to string)
360
+ - set, frozenset (converted to sorted list)
361
+
362
+ :param obj: The object to serialize to JSON-like structures.
363
+ :param check_circular: If True, detect and mark circular references.
364
+ :param decimal_as_float: If True, convert Decimal to float; otherwise to string.
365
+ Default is True.
366
+ :param non_finite: How to represent non-finite floats (inf/-inf/nan), which
367
+ have no JSON literal. One of:
368
+ - "null" (default): convert to None, matching JavaScript.
369
+ - "string": convert to "Infinity"/"-Infinity"/"NaN".
370
+ - "keep": leave the float as-is (not valid JSON).
371
+ :return: A JSON-serializable structure (dict, list, str, int, float, bool, None).
372
+ """
373
+ if non_finite not in _NON_FINITE_MODES:
374
+ raise ValueError(
375
+ f"non_finite must be one of {_NON_FINITE_MODES}, got {non_finite!r}"
376
+ )
377
+ visited: set[int] = set()
378
+ return _serialize_for_json(
379
+ obj=obj,
380
+ visited=visited,
381
+ check_circular=check_circular,
382
+ decimal_as_float=decimal_as_float,
383
+ non_finite=non_finite
384
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyobjtojson
3
- Version: 0.8
3
+ Version: 0.9.1
4
4
  Summary: A Python library that simplifies serializing any Python object to JSON-friendly structures, gracefully handling circular references.
5
5
  Author-email: "Carlos A. Planchón" <carlosandresplanchonprestes@gmail.com>
6
6
  License-Expression: MIT
@@ -8,9 +8,12 @@ Project-URL: Repository, https://github.com/carlosplanchon/pyobjtojson.git
8
8
  Keywords: json,serialization,circular references,pydantic,dataclasses
9
9
  Classifier: Intended Audience :: Developers
10
10
  Classifier: Topic :: Software Development :: Libraries
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
11
14
  Classifier: Programming Language :: Python :: 3.13
12
15
  Classifier: Programming Language :: Python :: 3.14
13
- Requires-Python: >=3.13
16
+ Requires-Python: >=3.10
14
17
  Description-Content-Type: text/markdown
15
18
  License-File: LICENSE
16
19
  Provides-Extra: dev
@@ -34,12 +37,14 @@ A lightweight Python library that simplifies the process of serializing **any**
34
37
  Works seamlessly with dictionaries, lists, custom classes, dataclasses, and Pydantic models (including both `model_dump()` from v2 and `dict()` from v1).
35
38
  - **Extended Standard Types Support**
36
39
  Native support for `datetime`, `date`, `time`, `UUID`, `Decimal`, `bytes`, `Enum`, `Path`, `set`, and `frozenset`.
40
+ - **JSON-Safe Dictionary Keys**
41
+ Non-string keys (`UUID`, `datetime`, `Enum`, tuples, and other objects) are converted so the returned structure always survives `json.dumps`.
37
42
  - **Full Type Hints Support**
38
43
  Complete type annotations for better IDE autocomplete, type checking with mypy, and improved code documentation.
39
44
  - **Non-Intrusive Serialization**
40
45
  No special inheritance or overrides needed. Uses reflection and standard Python methods (`__dict__`, `asdict()`, `to_dict()`, etc.) where available.
41
46
  - **Easy to Integrate**
42
- Just call `obj_to_json()` on your data structure—no additional configuration required.
47
+ Just call `obj_to_json()` on your data structure. No additional configuration required.
43
48
 
44
49
  ### DeepWiki Docs: [https://deepwiki.com/carlosplanchon/pyobjtojson](https://deepwiki.com/carlosplanchon/pyobjtojson)
45
50
 
@@ -63,7 +68,12 @@ data = {
63
68
  "nested": {"inner_key": "inner_value"}
64
69
  }
65
70
 
66
- json_obj = obj_to_json(data) # Using json.dumps kwargs
71
+ # obj_to_json returns a JSON-serializable structure (nested dicts, lists and
72
+ # primitives), not a JSON string. Pass it to json.dumps() when you need text:
73
+ json_obj = obj_to_json(data)
74
+
75
+ import json
76
+ json_text = json.dumps(json_obj)
67
77
  ```
68
78
 
69
79
  **Output** (example):
@@ -201,9 +211,47 @@ obj_to_json(data)
201
211
  - **Path** → string representation
202
212
  - **set, frozenset** → sorted lists
203
213
 
214
+ ### 5. Dictionary Keys
215
+
216
+ JSON object keys must be strings, so **pyobjtojson** normalizes non-string keys
217
+ to keep the result compatible with `json.dumps`:
218
+
219
+ - `str`, `int`, `float`, `bool`, and `None` keys are kept as-is (`json.dumps`
220
+ already coerces the non-string primitives to strings itself).
221
+ - Typed keys such as `UUID`, `datetime`, `Enum`, `Decimal`, and `Path` are
222
+ converted to their natural scalar form (e.g. `UUID` → string, `datetime` →
223
+ ISO string), respecting `decimal_as_float`.
224
+ - Any remaining composite key (a tuple, `frozenset`, or custom object) is
225
+ stringified as a last resort.
226
+
227
+ ```python
228
+ from uuid import UUID
229
+ from pyobjtojson import obj_to_json
230
+
231
+ data = {
232
+ UUID("12345678-1234-5678-1234-567812345678"): "by uuid",
233
+ (1, 2): "by tuple",
234
+ 42: "by int",
235
+ }
236
+
237
+ obj_to_json(data)
238
+ ```
239
+
240
+ **Output**:
241
+ ```json
242
+ {
243
+ "12345678-1234-5678-1234-567812345678": "by uuid",
244
+ "[1, 2]": "by tuple",
245
+ "42": "by int"
246
+ }
247
+ ```
248
+
249
+ > **Note:** If two distinct keys normalize to the same string, the last one
250
+ > wins. This mirrors how JSON itself collapses duplicate keys.
251
+
204
252
  ## API Reference
205
253
 
206
- ### `obj_to_json(obj, check_circular=True, decimal_as_float=True)`
254
+ ### `obj_to_json(obj, check_circular=True, decimal_as_float=True, non_finite="null")`
207
255
 
208
256
  Returns a cycle-free structure (nested dictionaries/lists) that is JSON-serializable.
209
257
 
@@ -211,10 +259,33 @@ Returns a cycle-free structure (nested dictionaries/lists) that is JSON-serializ
211
259
  - `obj` (Any): The object to serialize to JSON-like structures.
212
260
  - `check_circular` (bool, optional): If True (default), detect and mark circular references as `"<circular reference>"`.
213
261
  - `decimal_as_float` (bool, optional): If True (default), convert `Decimal` to `float`. If False, convert to string for high precision.
262
+ - `non_finite` (str, optional): How to represent non-finite floats (`inf`, `-inf`, `nan`), which have no JSON literal. One of:
263
+ - `"null"` (default): convert to `None`, matching JavaScript's `JSON.stringify`.
264
+ - `"string"`: convert to `"Infinity"`, `"-Infinity"`, or `"NaN"`.
265
+ - `"keep"`: leave the float as-is. Note this is **not** valid JSON and will raise with `json.dumps(..., allow_nan=False)`.
266
+
267
+ An unknown value raises `ValueError`.
214
268
 
215
269
  **Returns:**
216
270
  - `dict | list | Any`: A JSON-serializable structure.
217
271
 
272
+ #### Non-finite floats
273
+
274
+ `inf`, `-inf`, and `nan` are valid Python floats but have no representation in
275
+ the JSON spec. Left untouched they break `json.dumps(..., allow_nan=False)` and
276
+ produce the non-standard `Infinity`/`NaN` tokens that strict parsers reject. By
277
+ default **pyobjtojson** converts them to `null` so the output is always valid,
278
+ portable JSON:
279
+
280
+ ```python
281
+ from pyobjtojson import obj_to_json
282
+
283
+ data = {"ratio": float("inf"), "value": float("nan"), "ok": 1.5}
284
+
285
+ obj_to_json(data) # {"ratio": None, "value": None, "ok": 1.5}
286
+ obj_to_json(data, non_finite="string") # {"ratio": "Infinity", "value": "NaN", "ok": 1.5}
287
+ ```
288
+
218
289
  ## Type Hints
219
290
 
220
291
  **pyobjtojson** is fully typed and passes strict mypy checking. This provides:
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyobjtojson"
7
- version = "0.8"
7
+ version = "0.9.1"
8
8
  authors = [
9
9
  {name = "Carlos A. Planchón", email = "carlosandresplanchonprestes@gmail.com"},
10
10
  ]
11
11
  description = "A Python library that simplifies serializing any Python object to JSON-friendly structures, gracefully handling circular references."
12
12
 
13
- requires-python = ">=3.13"
13
+ requires-python = ">=3.10"
14
14
 
15
15
  license = "MIT"
16
16
  license-files = ["LICENSE"]
@@ -18,6 +18,9 @@ license-files = ["LICENSE"]
18
18
  classifiers = [
19
19
  "Intended Audience :: Developers",
20
20
  "Topic :: Software Development :: Libraries",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
21
24
  "Programming Language :: Python :: 3.13",
22
25
  "Programming Language :: Python :: 3.14"
23
26
  ]
@@ -53,13 +56,15 @@ addopts = [
53
56
  ]
54
57
 
55
58
  [tool.mypy]
56
- python_version = "3.13"
59
+ python_version = "3.10"
57
60
  strict = true
58
61
  warn_return_any = true
59
62
  warn_unused_configs = true
60
63
  disallow_untyped_defs = true
64
+ disallow_any_unimported = false
61
65
  show_error_codes = true
62
66
  show_column_numbers = true
67
+ pretty = true
63
68
  follow_imports = "normal"
64
69
  ignore_missing_imports = true
65
70
  exclude = ["tests/", ".venv/", "build/", "dist/"]
@@ -87,7 +87,8 @@ class TestCircularReferences:
87
87
  assert result["next"]["next"]["next"]["next"] == "<circular reference>"
88
88
 
89
89
  def test_multiple_references_same_object(self):
90
- """Test that same object referenced multiple times (not circular)."""
90
+ """Same object referenced several times in sibling branches is a DAG,
91
+ not a cycle, and every reference must be serialized in full."""
91
92
  shared = {"shared": "data"}
92
93
  container = {
93
94
  "ref1": shared,
@@ -97,8 +98,43 @@ class TestCircularReferences:
97
98
 
98
99
  result = obj_to_json(container, check_circular=True)
99
100
 
100
- # First reference should be serialized normally
101
- assert result["ref1"]["shared"] == "data"
102
- # Subsequent references to the same object are marked as circular
103
- assert result["ref2"] == "<circular reference>"
104
- assert result["ref3"] == "<circular reference>"
101
+ # None of these is a cycle: the shared object is not an ancestor of
102
+ # itself, so each reference is serialized normally.
103
+ assert result["ref1"] == {"shared": "data"}
104
+ assert result["ref2"] == {"shared": "data"}
105
+ assert result["ref3"] == {"shared": "data"}
106
+
107
+ def test_shared_object_in_list(self):
108
+ """The same instance repeated in a list is not circular."""
109
+ item = {"n": 1}
110
+
111
+ result = obj_to_json([item, item, item], check_circular=True)
112
+
113
+ assert result == [{"n": 1}, {"n": 1}, {"n": 1}]
114
+
115
+ def test_diamond_shared_reference(self):
116
+ """A diamond-shaped DAG (shared node reached by two paths) is not a
117
+ cycle and must be fully serialized on every path."""
118
+ shared = {"x": 1}
119
+ container = {
120
+ "left": shared,
121
+ "right": {"nested": shared},
122
+ }
123
+
124
+ result = obj_to_json(container, check_circular=True)
125
+
126
+ assert result["left"] == {"x": 1}
127
+ assert result["right"]["nested"] == {"x": 1}
128
+
129
+ def test_sibling_reuse_is_not_flagged_but_cycle_is(self):
130
+ """Reusing an object across siblings is fine; a genuine back-reference
131
+ into an ancestor is still detected."""
132
+ shared = {"v": 1}
133
+ node = {"a": shared, "b": shared}
134
+ node["self"] = node # real cycle back into an ancestor
135
+
136
+ result = obj_to_json(node, check_circular=True)
137
+
138
+ assert result["a"] == {"v": 1}
139
+ assert result["b"] == {"v": 1}
140
+ assert result["self"] == "<circular reference>"
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  """Tests for edge cases and error handling."""
3
3
 
4
+ import json
4
5
  import pytest
5
6
  from pyobjtojson import obj_to_json
6
7
 
@@ -122,16 +123,70 @@ class TestEdgeCases:
122
123
  assert result == obj
123
124
 
124
125
  def test_dict_with_non_string_keys(self):
125
- """Test dict with non-string keys (should work as-is)."""
126
+ """Non-string keys must yield a json.dumps-compatible structure.
127
+
128
+ int/float/bool/None keys are kept as-is (json.dumps coerces them to
129
+ strings itself); other keys are stringified so the result never breaks
130
+ json.dumps.
131
+ """
126
132
  obj = {
127
133
  1: "one",
128
134
  2: "two",
129
135
  (3, 4): "tuple key"
130
136
  }
131
137
  result = obj_to_json(obj)
138
+
139
+ # Primitive keys pass through unchanged.
132
140
  assert result[1] == "one"
133
141
  assert result[2] == "two"
134
- assert result[(3, 4)] == "tuple key"
142
+ # The tuple key is no longer a valid JSON key, so it is stringified.
143
+ assert (3, 4) not in result
144
+ assert result["[3, 4]"] == "tuple key"
145
+
146
+ def test_output_with_non_string_keys_is_json_dumpable(self):
147
+ """The whole point of the fix: json.dumps must not raise."""
148
+ import json
149
+
150
+ obj = {(1, 2): "x", frozenset({9}): "y", 5: "z"}
151
+ result = obj_to_json(obj)
152
+
153
+ # Must not raise TypeError: keys must be str, int, float, bool or None.
154
+ json.dumps(result)
155
+
156
+ def test_typed_keys_are_serialized(self):
157
+ """Common typed keys become their natural scalar string form."""
158
+ from uuid import UUID
159
+ from datetime import datetime, date
160
+ from enum import Enum
161
+ from pathlib import Path
162
+
163
+ class Color(Enum):
164
+ RED = "red"
165
+
166
+ obj = {
167
+ UUID(int=0): "uuid",
168
+ datetime(2024, 1, 15, 10, 30): "dt",
169
+ date(2024, 1, 15): "date",
170
+ Color.RED: "enum",
171
+ Path("/tmp/x"): "path",
172
+ }
173
+ result = obj_to_json(obj)
174
+
175
+ assert result["00000000-0000-0000-0000-000000000000"] == "uuid"
176
+ assert result["2024-01-15T10:30:00"] == "dt"
177
+ assert result["2024-01-15"] == "date"
178
+ assert result["red"] == "enum"
179
+ assert result["/tmp/x"] == "path"
180
+
181
+ def test_decimal_key_respects_decimal_as_float(self):
182
+ """Decimal keys follow the same decimal_as_float option as values."""
183
+ from decimal import Decimal
184
+
185
+ as_float = obj_to_json({Decimal("9.99"): "v"})
186
+ assert as_float[9.99] == "v"
187
+
188
+ as_str = obj_to_json({Decimal("9.99"): "v"}, decimal_as_float=False)
189
+ assert as_str["9.99"] == "v"
135
190
 
136
191
  def test_broken_str_method(self):
137
192
  """Test object with broken __str__ method."""
@@ -184,3 +239,63 @@ class TestEdgeCases:
184
239
  }
185
240
  result = obj_to_json(obj)
186
241
  assert result == obj
242
+
243
+
244
+ class TestNonFiniteFloats:
245
+ """Test the `non_finite` policy for inf/-inf/nan floats."""
246
+
247
+ def test_default_is_null(self):
248
+ """By default non-finite floats become None, producing valid JSON."""
249
+ result = obj_to_json(
250
+ {"a": float("inf"), "b": float("-inf"), "c": float("nan")}
251
+ )
252
+ assert result == {"a": None, "b": None, "c": None}
253
+ # The whole point: strict json.dumps must not raise.
254
+ json.dumps(result, allow_nan=False)
255
+
256
+ def test_finite_floats_untouched(self):
257
+ """Finite floats are never altered by the policy."""
258
+ obj = {"x": 1.5, "y": -3.0, "z": 0.0}
259
+ assert obj_to_json(obj) == obj
260
+
261
+ def test_string_mode(self):
262
+ """String mode yields the standard-looking token strings."""
263
+ result = obj_to_json(
264
+ {"pos": float("inf"), "neg": float("-inf"), "nan": float("nan")},
265
+ non_finite="string",
266
+ )
267
+ assert result == {"pos": "Infinity", "neg": "-Infinity", "nan": "NaN"}
268
+ json.dumps(result, allow_nan=False)
269
+
270
+ def test_keep_mode_preserves_legacy_behavior(self):
271
+ """Keep mode returns the floats unchanged (not strict-JSON valid)."""
272
+ result = obj_to_json({"a": float("inf")}, non_finite="keep")
273
+ assert result["a"] == float("inf")
274
+ assert isinstance(result["a"], float)
275
+
276
+ def test_non_finite_decimal(self):
277
+ """Non-finite Decimal values follow the same policy once floated."""
278
+ from decimal import Decimal
279
+
280
+ assert obj_to_json(Decimal("Infinity")) is None
281
+ assert obj_to_json(Decimal("NaN"), non_finite="string") == "NaN"
282
+ # With decimal_as_float=False the Decimal is stringified anyway.
283
+ assert obj_to_json(Decimal("Infinity"), decimal_as_float=False) == "Infinity"
284
+
285
+ def test_non_finite_dict_key(self):
286
+ """A non-finite float used as a dict key follows the policy too."""
287
+ assert obj_to_json(
288
+ {float("inf"): "v"}, non_finite="string"
289
+ ) == {"Infinity": "v"}
290
+ result = obj_to_json({float("nan"): "v"}) # default null
291
+ assert result == {None: "v"}
292
+ json.dumps(result) # None key becomes "null", still valid JSON
293
+
294
+ def test_non_finite_in_list(self):
295
+ """Non-finite values inside sequences are handled."""
296
+ assert obj_to_json([1.0, float("nan"), float("inf")]) == [1.0, None, None]
297
+
298
+ def test_invalid_mode_raises(self):
299
+ """An unknown policy is a programming error and raises ValueError."""
300
+ with pytest.raises(ValueError, match="non_finite must be one of"):
301
+ obj_to_json({"a": 1}, non_finite="bogus")
@@ -2,6 +2,7 @@
2
2
  """Tests for standard Python types support (datetime, UUID, Decimal, etc)."""
3
3
 
4
4
  import base64
5
+ import sys
5
6
  import pytest
6
7
  from datetime import datetime, date, time
7
8
  from decimal import Decimal
@@ -198,9 +199,39 @@ class TestEnum:
198
199
  assert result == "active"
199
200
 
200
201
  def test_int_enum(self):
201
- """Test int-based Enum."""
202
+ """IntEnum must serialize to its plain underlying value.
203
+
204
+ ``== 3`` alone is not enough: an IntEnum member compares equal to its
205
+ int value, so we also assert the result is a plain int and not the
206
+ enum member itself. The plain-primitive fast-path used to intercept
207
+ IntEnum/StrEnum before the Enum branch and return the member as-is.
208
+ """
202
209
  result = obj_to_json(Priority.HIGH)
203
210
  assert result == 3
211
+ assert type(result) is int
212
+ assert not isinstance(result, Enum)
213
+
214
+ def test_string_enum_returns_plain_str(self):
215
+ """A plain Enum with string values yields a plain str, not the member."""
216
+ result = obj_to_json(Status.ACTIVE)
217
+ assert type(result) is str
218
+ assert not isinstance(result, Enum)
219
+
220
+ @pytest.mark.skipif(
221
+ sys.version_info < (3, 11),
222
+ reason="StrEnum was added in Python 3.11"
223
+ )
224
+ def test_str_enum(self):
225
+ """StrEnum must serialize to its plain underlying str value."""
226
+ from enum import StrEnum
227
+
228
+ class Color(StrEnum):
229
+ RED = "red"
230
+
231
+ result = obj_to_json(Color.RED)
232
+ assert result == "red"
233
+ assert type(result) is str
234
+ assert not isinstance(result, Enum)
204
235
 
205
236
  def test_enum_in_dict(self):
206
237
  """Test Enum in dictionary."""
@@ -1,243 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- import base64
4
- import dataclasses
5
- from collections.abc import Mapping, Sequence
6
- from datetime import datetime, date, time
7
- from decimal import Decimal
8
- from enum import Enum
9
- from pathlib import Path
10
- from typing import Any
11
- from uuid import UUID
12
-
13
-
14
- def _serialize_for_json(
15
- obj: Any,
16
- visited: set[int],
17
- check_circular: bool = True,
18
- _skip_circular_check: bool = False,
19
- decimal_as_float: bool = True
20
- ) -> Any:
21
- """
22
- Internal recursion logic that can handle circular references
23
- using `visited`. This includes careful exception handling so
24
- partial failures don't break the entire serialization.
25
-
26
- :param obj: The object to serialize.
27
- :param visited: A set used to track visited objects (for cycle detection).
28
- :param check_circular: Whether to check for and mark circular references.
29
- :param _skip_circular_check: Internal flag to skip adding intermediate
30
- conversion results to visited set.
31
- :param decimal_as_float: If True, convert Decimal to float; otherwise to string.
32
- :return: A JSON-serializable structure, or a string if it cannot be
33
- converted more structurally.
34
- """
35
-
36
- # If it's None, bool, int, float, or str, it's already JSON-serializable.
37
- if obj is None or isinstance(obj, (bool, int, float, str)):
38
- return obj
39
-
40
- # === Standard Python types support ===
41
-
42
- # datetime, date, time → ISO format
43
- if isinstance(obj, datetime):
44
- return obj.isoformat()
45
- if isinstance(obj, date):
46
- return obj.isoformat()
47
- if isinstance(obj, time):
48
- return obj.isoformat()
49
-
50
- # UUID → string
51
- if isinstance(obj, UUID):
52
- return str(obj)
53
-
54
- # Decimal → float or string
55
- if isinstance(obj, Decimal):
56
- if decimal_as_float:
57
- return float(obj)
58
- return str(obj)
59
-
60
- # bytes, bytearray → base64
61
- if isinstance(obj, (bytes, bytearray)):
62
- return base64.b64encode(bytes(obj)).decode('utf-8')
63
-
64
- # Enum → underlying value
65
- if isinstance(obj, Enum):
66
- return obj.value
67
-
68
- # Path → string
69
- if isinstance(obj, Path):
70
- return str(obj)
71
-
72
- # set, frozenset → list
73
- if isinstance(obj, (set, frozenset)):
74
- try:
75
- # Try to sort if elements are comparable
76
- return sorted(list(obj))
77
- except TypeError:
78
- # If not sortable, convert to list without sorting
79
- return list(obj)
80
-
81
- obj_id = id(obj)
82
-
83
- # If circular checking is enabled, see if we've already
84
- # visited this object.
85
- if check_circular is True and not _skip_circular_check:
86
- if obj_id in visited:
87
- return "<circular reference>"
88
- visited.add(obj_id)
89
-
90
- # Handle Mapping (like dict). Build a new dict item by item,
91
- # catching errors.
92
- if isinstance(obj, Mapping):
93
- result_dict: dict[Any, Any] = {}
94
- for key, value in obj.items():
95
- try:
96
- result_dict[key] = _serialize_for_json(
97
- value, visited, check_circular=check_circular,
98
- decimal_as_float=decimal_as_float
99
- )
100
- except Exception as exc:
101
- result_dict[key] = f"<serialization error: {exc}>"
102
- return result_dict
103
-
104
- # Handle Sequence (like list/tuple), but not string.
105
- if isinstance(obj, Sequence) and not isinstance(obj, str):
106
- result_list: list[Any] = []
107
- for index, item in enumerate(obj):
108
- try:
109
- result_list.append(
110
- _serialize_for_json(
111
- obj=item,
112
- visited=visited,
113
- check_circular=check_circular,
114
- decimal_as_float=decimal_as_float
115
- )
116
- )
117
- except Exception as exc:
118
- result_list.append(f"<serialization error at index {index}: {exc}>")
119
- return result_list
120
-
121
- # Try Pydantic v2 model_dump(), but fall back if it fails
122
- if hasattr(obj, "model_dump") and callable(obj.model_dump):
123
- try:
124
- model_data = obj.model_dump()
125
- # Skip circular check for the intermediate dict to avoid false positives
126
- # when Python reuses memory addresses
127
- return _serialize_for_json(
128
- obj=model_data,
129
- visited=visited,
130
- check_circular=check_circular,
131
- _skip_circular_check=True,
132
- decimal_as_float=decimal_as_float
133
- )
134
- except Exception:
135
- # Fall through to next check if this fails
136
- ...
137
-
138
- # Try Pydantic v1 .dict(), but fall back if it fails
139
- if hasattr(obj, "dict") and callable(obj.dict):
140
- try:
141
- dict_data = obj.dict()
142
- # Skip circular check for the intermediate dict
143
- return _serialize_for_json(
144
- obj=dict_data,
145
- visited=visited,
146
- check_circular=check_circular,
147
- _skip_circular_check=True,
148
- decimal_as_float=decimal_as_float
149
- )
150
- except Exception:
151
- # Fall through to next check if this fails
152
- ...
153
-
154
- # If it's a dataclass, convert it using asdict(), but handle exceptions
155
- if dataclasses.is_dataclass(obj):
156
- try:
157
- # is_dataclass can return True for both instances and types,
158
- # but we only process instances here
159
- dc_data = dataclasses.asdict(obj) # type: ignore[arg-type]
160
- # Skip circular check for the intermediate dict
161
- return _serialize_for_json(
162
- obj=dc_data,
163
- visited=visited,
164
- check_circular=check_circular,
165
- _skip_circular_check=True,
166
- decimal_as_float=decimal_as_float
167
- )
168
- except Exception:
169
- # Fall through to next check if this fails
170
- ...
171
-
172
- # If there's a custom .to_dict() method, try that
173
- if hasattr(obj, "to_dict") and callable(obj.to_dict):
174
- try:
175
- custom_dict_data = obj.to_dict()
176
- # Skip circular check for the intermediate dict
177
- return _serialize_for_json(
178
- obj=custom_dict_data,
179
- visited=visited,
180
- check_circular=check_circular,
181
- _skip_circular_check=True,
182
- decimal_as_float=decimal_as_float
183
- )
184
- except Exception:
185
- # Fall through to next check if this fails
186
- ...
187
-
188
- # If the object has a __dict__, recurse into that
189
- if hasattr(obj, "__dict__"):
190
- try:
191
- # Skip circular check for the __dict__ to avoid false positives
192
- return _serialize_for_json(
193
- obj=obj.__dict__,
194
- visited=visited,
195
- check_circular=check_circular,
196
- _skip_circular_check=True,
197
- decimal_as_float=decimal_as_float
198
- )
199
- except Exception:
200
- # Fall through to next check if this fails
201
- pass
202
-
203
- # Last resort: convert to string, but even this can fail
204
- # if __str__ is broken
205
- try:
206
- return str(obj)
207
- except Exception as exc:
208
- # If that fails, return a generic serialization error.
209
- return f"<serialization error: {exc}>"
210
-
211
-
212
- def obj_to_json(
213
- obj: Any,
214
- check_circular: bool = True,
215
- decimal_as_float: bool = True
216
- ) -> Any:
217
- """
218
- Public-facing function that starts with a fresh visited set
219
- to handle cycles (if `check_circular=True`). Calls the internal
220
- _serialize_for_json.
221
-
222
- Supports standard Python types including:
223
- - datetime, date, time (converted to ISO format)
224
- - UUID (converted to string)
225
- - Decimal (converted to float or string based on decimal_as_float)
226
- - bytes, bytearray (converted to base64)
227
- - Enum (converted to underlying value)
228
- - Path (converted to string)
229
- - set, frozenset (converted to sorted list)
230
-
231
- :param obj: The object to serialize to JSON-like structures.
232
- :param check_circular: If True, detect and mark circular references.
233
- :param decimal_as_float: If True, convert Decimal to float; otherwise to string.
234
- Default is True.
235
- :return: A JSON-serializable structure (dict, list, str, int, float, bool, None).
236
- """
237
- visited: set[int] = set()
238
- return _serialize_for_json(
239
- obj=obj,
240
- visited=visited,
241
- check_circular=check_circular,
242
- decimal_as_float=decimal_as_float
243
- )
File without changes
File without changes