pyobjtojson 0.8__tar.gz → 0.9__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}/PKG-INFO +52 -4
- {pyobjtojson-0.8 → pyobjtojson-0.9}/README.md +47 -2
- pyobjtojson-0.9/pyobjtojson/__init__.py +317 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9}/pyobjtojson.egg-info/PKG-INFO +52 -4
- {pyobjtojson-0.8 → pyobjtojson-0.9}/pyproject.toml +8 -3
- {pyobjtojson-0.8 → pyobjtojson-0.9}/tests/test_circular_references.py +42 -6
- {pyobjtojson-0.8 → pyobjtojson-0.9}/tests/test_edge_cases.py +56 -2
- {pyobjtojson-0.8 → pyobjtojson-0.9}/tests/test_standard_types.py +32 -1
- pyobjtojson-0.8/pyobjtojson/__init__.py +0 -243
- {pyobjtojson-0.8 → pyobjtojson-0.9}/LICENSE +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9}/pyobjtojson.egg-info/SOURCES.txt +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9}/pyobjtojson.egg-info/dependency_links.txt +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9}/pyobjtojson.egg-info/requires.txt +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9}/pyobjtojson.egg-info/top_level.txt +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9}/setup.cfg +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9}/tests/test_basic_types.py +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9}/tests/test_custom_classes.py +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9}/tests/test_dataclasses.py +0 -0
- {pyobjtojson-0.8 → pyobjtojson-0.9}/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
|
|
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,6 +211,44 @@ 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
254
|
### `obj_to_json(obj, check_circular=True, decimal_as_float=True)`
|
|
@@ -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,6 +186,44 @@ 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
229
|
### `obj_to_json(obj, check_circular=True, decimal_as_float=True)`
|
|
@@ -0,0 +1,317 @@
|
|
|
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
|
+
# Enum → underlying value. This must run before the primitive fast-path
|
|
37
|
+
# below: IntEnum/StrEnum members are also int/str instances, so the
|
|
38
|
+
# fast-path would otherwise return the enum member itself instead of its
|
|
39
|
+
# documented `.value`.
|
|
40
|
+
if isinstance(obj, Enum):
|
|
41
|
+
return obj.value
|
|
42
|
+
|
|
43
|
+
# If it's None, bool, int, float, or str, it's already JSON-serializable.
|
|
44
|
+
if obj is None or isinstance(obj, (bool, int, float, str)):
|
|
45
|
+
return obj
|
|
46
|
+
|
|
47
|
+
# === Standard Python types support ===
|
|
48
|
+
|
|
49
|
+
# datetime, date, time → ISO format
|
|
50
|
+
if isinstance(obj, datetime):
|
|
51
|
+
return obj.isoformat()
|
|
52
|
+
if isinstance(obj, date):
|
|
53
|
+
return obj.isoformat()
|
|
54
|
+
if isinstance(obj, time):
|
|
55
|
+
return obj.isoformat()
|
|
56
|
+
|
|
57
|
+
# UUID → string
|
|
58
|
+
if isinstance(obj, UUID):
|
|
59
|
+
return str(obj)
|
|
60
|
+
|
|
61
|
+
# Decimal → float or string
|
|
62
|
+
if isinstance(obj, Decimal):
|
|
63
|
+
if decimal_as_float:
|
|
64
|
+
return float(obj)
|
|
65
|
+
return str(obj)
|
|
66
|
+
|
|
67
|
+
# bytes, bytearray → base64
|
|
68
|
+
if isinstance(obj, (bytes, bytearray)):
|
|
69
|
+
return base64.b64encode(bytes(obj)).decode('utf-8')
|
|
70
|
+
|
|
71
|
+
# Path → string
|
|
72
|
+
if isinstance(obj, Path):
|
|
73
|
+
return str(obj)
|
|
74
|
+
|
|
75
|
+
# set, frozenset → list
|
|
76
|
+
if isinstance(obj, (set, frozenset)):
|
|
77
|
+
try:
|
|
78
|
+
# Try to sort if elements are comparable
|
|
79
|
+
return sorted(list(obj))
|
|
80
|
+
except TypeError:
|
|
81
|
+
# If not sortable, convert to list without sorting
|
|
82
|
+
return list(obj)
|
|
83
|
+
|
|
84
|
+
obj_id = id(obj)
|
|
85
|
+
|
|
86
|
+
# If circular checking is enabled, see if this object is already on the
|
|
87
|
+
# current traversal path. We track whether *this* frame added the id so we
|
|
88
|
+
# can remove it again on the way out (see the `finally` below). Keeping
|
|
89
|
+
# `visited` as the active path — rather than every object ever seen — is
|
|
90
|
+
# what makes this real cycle detection: a shared sub-object referenced from
|
|
91
|
+
# two sibling branches (a DAG, not a cycle) must not be flagged circular.
|
|
92
|
+
added_to_visited = False
|
|
93
|
+
if check_circular is True and not _skip_circular_check:
|
|
94
|
+
if obj_id in visited:
|
|
95
|
+
return "<circular reference>"
|
|
96
|
+
visited.add(obj_id)
|
|
97
|
+
added_to_visited = True
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
# Handle Mapping (like dict). Build a new dict item by item,
|
|
101
|
+
# catching errors.
|
|
102
|
+
if isinstance(obj, Mapping):
|
|
103
|
+
result_dict: dict[Any, Any] = {}
|
|
104
|
+
for key, value in obj.items():
|
|
105
|
+
# Keys must also be JSON-compatible, otherwise json.dumps would
|
|
106
|
+
# reject the returned structure even though the values are fine.
|
|
107
|
+
json_key = _serialize_key(
|
|
108
|
+
key, visited, check_circular=check_circular,
|
|
109
|
+
decimal_as_float=decimal_as_float
|
|
110
|
+
)
|
|
111
|
+
try:
|
|
112
|
+
result_dict[json_key] = _serialize_for_json(
|
|
113
|
+
value, visited, check_circular=check_circular,
|
|
114
|
+
decimal_as_float=decimal_as_float
|
|
115
|
+
)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
result_dict[json_key] = f"<serialization error: {exc}>"
|
|
118
|
+
return result_dict
|
|
119
|
+
|
|
120
|
+
# Handle Sequence (like list/tuple), but not string.
|
|
121
|
+
if isinstance(obj, Sequence) and not isinstance(obj, str):
|
|
122
|
+
result_list: list[Any] = []
|
|
123
|
+
for index, item in enumerate(obj):
|
|
124
|
+
try:
|
|
125
|
+
result_list.append(
|
|
126
|
+
_serialize_for_json(
|
|
127
|
+
obj=item,
|
|
128
|
+
visited=visited,
|
|
129
|
+
check_circular=check_circular,
|
|
130
|
+
decimal_as_float=decimal_as_float
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
result_list.append(f"<serialization error at index {index}: {exc}>")
|
|
135
|
+
return result_list
|
|
136
|
+
|
|
137
|
+
# Try Pydantic v2 model_dump(), but fall back if it fails
|
|
138
|
+
if hasattr(obj, "model_dump") and callable(obj.model_dump):
|
|
139
|
+
try:
|
|
140
|
+
model_data = obj.model_dump()
|
|
141
|
+
# Skip re-adding the intermediate dict to `visited`; this object
|
|
142
|
+
# is already on the path, so its converted form must not be
|
|
143
|
+
# treated as a separate node.
|
|
144
|
+
return _serialize_for_json(
|
|
145
|
+
obj=model_data,
|
|
146
|
+
visited=visited,
|
|
147
|
+
check_circular=check_circular,
|
|
148
|
+
_skip_circular_check=True,
|
|
149
|
+
decimal_as_float=decimal_as_float
|
|
150
|
+
)
|
|
151
|
+
except Exception:
|
|
152
|
+
# Fall through to next check if this fails
|
|
153
|
+
...
|
|
154
|
+
|
|
155
|
+
# Try Pydantic v1 .dict(), but fall back if it fails
|
|
156
|
+
if hasattr(obj, "dict") and callable(obj.dict):
|
|
157
|
+
try:
|
|
158
|
+
dict_data = obj.dict()
|
|
159
|
+
# Skip circular check for the intermediate dict
|
|
160
|
+
return _serialize_for_json(
|
|
161
|
+
obj=dict_data,
|
|
162
|
+
visited=visited,
|
|
163
|
+
check_circular=check_circular,
|
|
164
|
+
_skip_circular_check=True,
|
|
165
|
+
decimal_as_float=decimal_as_float
|
|
166
|
+
)
|
|
167
|
+
except Exception:
|
|
168
|
+
# Fall through to next check if this fails
|
|
169
|
+
...
|
|
170
|
+
|
|
171
|
+
# If it's a dataclass, convert it using asdict(), but handle exceptions
|
|
172
|
+
if dataclasses.is_dataclass(obj):
|
|
173
|
+
try:
|
|
174
|
+
# is_dataclass can return True for both instances and types,
|
|
175
|
+
# but we only process instances here
|
|
176
|
+
dc_data = dataclasses.asdict(obj) # type: ignore[arg-type]
|
|
177
|
+
# Skip circular check for the intermediate dict
|
|
178
|
+
return _serialize_for_json(
|
|
179
|
+
obj=dc_data,
|
|
180
|
+
visited=visited,
|
|
181
|
+
check_circular=check_circular,
|
|
182
|
+
_skip_circular_check=True,
|
|
183
|
+
decimal_as_float=decimal_as_float
|
|
184
|
+
)
|
|
185
|
+
except Exception:
|
|
186
|
+
# Fall through to next check if this fails
|
|
187
|
+
...
|
|
188
|
+
|
|
189
|
+
# If there's a custom .to_dict() method, try that
|
|
190
|
+
if hasattr(obj, "to_dict") and callable(obj.to_dict):
|
|
191
|
+
try:
|
|
192
|
+
custom_dict_data = obj.to_dict()
|
|
193
|
+
# Skip circular check for the intermediate dict
|
|
194
|
+
return _serialize_for_json(
|
|
195
|
+
obj=custom_dict_data,
|
|
196
|
+
visited=visited,
|
|
197
|
+
check_circular=check_circular,
|
|
198
|
+
_skip_circular_check=True,
|
|
199
|
+
decimal_as_float=decimal_as_float
|
|
200
|
+
)
|
|
201
|
+
except Exception:
|
|
202
|
+
# Fall through to next check if this fails
|
|
203
|
+
...
|
|
204
|
+
|
|
205
|
+
# If the object has a __dict__, recurse into that
|
|
206
|
+
if hasattr(obj, "__dict__"):
|
|
207
|
+
try:
|
|
208
|
+
# Skip circular check for the __dict__ to avoid false positives
|
|
209
|
+
return _serialize_for_json(
|
|
210
|
+
obj=obj.__dict__,
|
|
211
|
+
visited=visited,
|
|
212
|
+
check_circular=check_circular,
|
|
213
|
+
_skip_circular_check=True,
|
|
214
|
+
decimal_as_float=decimal_as_float
|
|
215
|
+
)
|
|
216
|
+
except Exception:
|
|
217
|
+
# Fall through to next check if this fails
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
# Last resort: convert to string, but even this can fail
|
|
221
|
+
# if __str__ is broken
|
|
222
|
+
try:
|
|
223
|
+
return str(obj)
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
# If that fails, return a generic serialization error.
|
|
226
|
+
return f"<serialization error: {exc}>"
|
|
227
|
+
finally:
|
|
228
|
+
# Leave the traversal path: this object is no longer an ancestor of
|
|
229
|
+
# whatever we serialize next, so remove it to allow legitimate reuse
|
|
230
|
+
# of the same instance elsewhere in the tree.
|
|
231
|
+
if added_to_visited:
|
|
232
|
+
visited.discard(obj_id)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _serialize_key(
|
|
236
|
+
key: Any,
|
|
237
|
+
visited: set[int],
|
|
238
|
+
check_circular: bool = True,
|
|
239
|
+
decimal_as_float: bool = True
|
|
240
|
+
) -> Any:
|
|
241
|
+
"""
|
|
242
|
+
Convert a mapping key into something ``json.dumps`` accepts as an object
|
|
243
|
+
key: ``str``, ``int``, ``float``, ``bool`` or ``None``.
|
|
244
|
+
|
|
245
|
+
``json.dumps`` already coerces int/float/bool/None keys to strings itself,
|
|
246
|
+
so those (and plain strings) pass through unchanged. Any other key is run
|
|
247
|
+
through the normal value serializer so that common non-string keys become
|
|
248
|
+
their natural scalar form (``UUID`` → str, ``datetime`` → ISO string,
|
|
249
|
+
``Enum`` → its value, ``Decimal`` → float/str). If the result is still a
|
|
250
|
+
composite (e.g. a tuple serialized to a list), it is stringified as a last
|
|
251
|
+
resort so the returned structure always survives ``json.dumps``.
|
|
252
|
+
|
|
253
|
+
:param key: The mapping key to serialize.
|
|
254
|
+
:param visited: The traversal-path set shared with the value serializer.
|
|
255
|
+
:param check_circular: Whether cycle detection is enabled.
|
|
256
|
+
:param decimal_as_float: If True, convert Decimal to float; otherwise string.
|
|
257
|
+
:return: A ``json.dumps``-compatible key.
|
|
258
|
+
"""
|
|
259
|
+
# json.dumps natively accepts these as object keys, so leave them as-is to
|
|
260
|
+
# preserve its default behavior (e.g. int key 1 -> "1").
|
|
261
|
+
if key is None or isinstance(key, (str, bool, int, float)):
|
|
262
|
+
return key
|
|
263
|
+
|
|
264
|
+
# Reuse the value machinery so typed keys become their natural scalar form.
|
|
265
|
+
try:
|
|
266
|
+
serialized = _serialize_for_json(
|
|
267
|
+
obj=key,
|
|
268
|
+
visited=visited,
|
|
269
|
+
check_circular=check_circular,
|
|
270
|
+
decimal_as_float=decimal_as_float
|
|
271
|
+
)
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
return f"<unserializable key: {exc}>"
|
|
274
|
+
|
|
275
|
+
if serialized is None or isinstance(serialized, (str, bool, int, float)):
|
|
276
|
+
return serialized
|
|
277
|
+
|
|
278
|
+
# Composite results (lists/dicts from e.g. a tuple or a custom object)
|
|
279
|
+
# can't be object keys; fall back to a string form.
|
|
280
|
+
try:
|
|
281
|
+
return str(serialized)
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
return f"<unserializable key: {exc}>"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def obj_to_json(
|
|
287
|
+
obj: Any,
|
|
288
|
+
check_circular: bool = True,
|
|
289
|
+
decimal_as_float: bool = True
|
|
290
|
+
) -> Any:
|
|
291
|
+
"""
|
|
292
|
+
Public-facing function that starts with a fresh visited set
|
|
293
|
+
to handle cycles (if `check_circular=True`). Calls the internal
|
|
294
|
+
_serialize_for_json.
|
|
295
|
+
|
|
296
|
+
Supports standard Python types including:
|
|
297
|
+
- datetime, date, time (converted to ISO format)
|
|
298
|
+
- UUID (converted to string)
|
|
299
|
+
- Decimal (converted to float or string based on decimal_as_float)
|
|
300
|
+
- bytes, bytearray (converted to base64)
|
|
301
|
+
- Enum (converted to underlying value)
|
|
302
|
+
- Path (converted to string)
|
|
303
|
+
- set, frozenset (converted to sorted list)
|
|
304
|
+
|
|
305
|
+
:param obj: The object to serialize to JSON-like structures.
|
|
306
|
+
:param check_circular: If True, detect and mark circular references.
|
|
307
|
+
:param decimal_as_float: If True, convert Decimal to float; otherwise to string.
|
|
308
|
+
Default is True.
|
|
309
|
+
:return: A JSON-serializable structure (dict, list, str, int, float, bool, None).
|
|
310
|
+
"""
|
|
311
|
+
visited: set[int] = set()
|
|
312
|
+
return _serialize_for_json(
|
|
313
|
+
obj=obj,
|
|
314
|
+
visited=visited,
|
|
315
|
+
check_circular=check_circular,
|
|
316
|
+
decimal_as_float=decimal_as_float
|
|
317
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyobjtojson
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9
|
|
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,6 +211,44 @@ 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
254
|
### `obj_to_json(obj, check_circular=True, decimal_as_float=True)`
|
|
@@ -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"
|
|
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>"
|
|
@@ -122,16 +122,70 @@ class TestEdgeCases:
|
|
|
122
122
|
assert result == obj
|
|
123
123
|
|
|
124
124
|
def test_dict_with_non_string_keys(self):
|
|
125
|
-
"""
|
|
125
|
+
"""Non-string keys must yield a json.dumps-compatible structure.
|
|
126
|
+
|
|
127
|
+
int/float/bool/None keys are kept as-is (json.dumps coerces them to
|
|
128
|
+
strings itself); other keys are stringified so the result never breaks
|
|
129
|
+
json.dumps.
|
|
130
|
+
"""
|
|
126
131
|
obj = {
|
|
127
132
|
1: "one",
|
|
128
133
|
2: "two",
|
|
129
134
|
(3, 4): "tuple key"
|
|
130
135
|
}
|
|
131
136
|
result = obj_to_json(obj)
|
|
137
|
+
|
|
138
|
+
# Primitive keys pass through unchanged.
|
|
132
139
|
assert result[1] == "one"
|
|
133
140
|
assert result[2] == "two"
|
|
134
|
-
|
|
141
|
+
# The tuple key is no longer a valid JSON key, so it is stringified.
|
|
142
|
+
assert (3, 4) not in result
|
|
143
|
+
assert result["[3, 4]"] == "tuple key"
|
|
144
|
+
|
|
145
|
+
def test_output_with_non_string_keys_is_json_dumpable(self):
|
|
146
|
+
"""The whole point of the fix: json.dumps must not raise."""
|
|
147
|
+
import json
|
|
148
|
+
|
|
149
|
+
obj = {(1, 2): "x", frozenset({9}): "y", 5: "z"}
|
|
150
|
+
result = obj_to_json(obj)
|
|
151
|
+
|
|
152
|
+
# Must not raise TypeError: keys must be str, int, float, bool or None.
|
|
153
|
+
json.dumps(result)
|
|
154
|
+
|
|
155
|
+
def test_typed_keys_are_serialized(self):
|
|
156
|
+
"""Common typed keys become their natural scalar string form."""
|
|
157
|
+
from uuid import UUID
|
|
158
|
+
from datetime import datetime, date
|
|
159
|
+
from enum import Enum
|
|
160
|
+
from pathlib import Path
|
|
161
|
+
|
|
162
|
+
class Color(Enum):
|
|
163
|
+
RED = "red"
|
|
164
|
+
|
|
165
|
+
obj = {
|
|
166
|
+
UUID(int=0): "uuid",
|
|
167
|
+
datetime(2024, 1, 15, 10, 30): "dt",
|
|
168
|
+
date(2024, 1, 15): "date",
|
|
169
|
+
Color.RED: "enum",
|
|
170
|
+
Path("/tmp/x"): "path",
|
|
171
|
+
}
|
|
172
|
+
result = obj_to_json(obj)
|
|
173
|
+
|
|
174
|
+
assert result["00000000-0000-0000-0000-000000000000"] == "uuid"
|
|
175
|
+
assert result["2024-01-15T10:30:00"] == "dt"
|
|
176
|
+
assert result["2024-01-15"] == "date"
|
|
177
|
+
assert result["red"] == "enum"
|
|
178
|
+
assert result["/tmp/x"] == "path"
|
|
179
|
+
|
|
180
|
+
def test_decimal_key_respects_decimal_as_float(self):
|
|
181
|
+
"""Decimal keys follow the same decimal_as_float option as values."""
|
|
182
|
+
from decimal import Decimal
|
|
183
|
+
|
|
184
|
+
as_float = obj_to_json({Decimal("9.99"): "v"})
|
|
185
|
+
assert as_float[9.99] == "v"
|
|
186
|
+
|
|
187
|
+
as_str = obj_to_json({Decimal("9.99"): "v"}, decimal_as_float=False)
|
|
188
|
+
assert as_str["9.99"] == "v"
|
|
135
189
|
|
|
136
190
|
def test_broken_str_method(self):
|
|
137
191
|
"""Test object with broken __str__ method."""
|
|
@@ -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
|