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.
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/PKG-INFO +76 -5
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/README.md +71 -3
- pyobjtojson-0.9.1/pyobjtojson/__init__.py +384 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/pyobjtojson.egg-info/PKG-INFO +76 -5
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/pyproject.toml +8 -3
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/tests/test_circular_references.py +42 -6
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/tests/test_edge_cases.py +117 -2
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/tests/test_standard_types.py +32 -1
- pyobjtojson-0.8/pyobjtojson/__init__.py +0 -243
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/LICENSE +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/pyobjtojson.egg-info/SOURCES.txt +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/pyobjtojson.egg-info/dependency_links.txt +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/pyobjtojson.egg-info/requires.txt +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/pyobjtojson.egg-info/top_level.txt +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/setup.cfg +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/tests/test_basic_types.py +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/tests/test_custom_classes.py +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/tests/test_dataclasses.py +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9.1}/tests/test_pydantic.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyobjtojson
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
+
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.
|
|
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
|
-
"""
|
|
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
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
assert result["ref2"] == "
|
|
104
|
-
assert result["ref3"] == "
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|