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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyobjtojson
3
- Version: 0.8
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.13
16
+ Requires-Python: >=3.10
14
17
  Description-Content-Type: text/markdown
15
18
  License-File: LICENSE
16
19
  Provides-Extra: dev
@@ -34,12 +37,14 @@ A lightweight Python library that simplifies the process of serializing **any**
34
37
  Works seamlessly with dictionaries, lists, custom classes, dataclasses, and Pydantic models (including both `model_dump()` from v2 and `dict()` from v1).
35
38
  - **Extended Standard Types Support**
36
39
  Native support for `datetime`, `date`, `time`, `UUID`, `Decimal`, `bytes`, `Enum`, `Path`, `set`, and `frozenset`.
40
+ - **JSON-Safe Dictionary Keys**
41
+ Non-string keys (`UUID`, `datetime`, `Enum`, tuples, and other objects) are converted so the returned structure always survives `json.dumps`.
37
42
  - **Full Type Hints Support**
38
43
  Complete type annotations for better IDE autocomplete, type checking with mypy, and improved code documentation.
39
44
  - **Non-Intrusive Serialization**
40
45
  No special inheritance or overrides needed. Uses reflection and standard Python methods (`__dict__`, `asdict()`, `to_dict()`, etc.) where available.
41
46
  - **Easy to Integrate**
42
- Just call `obj_to_json()` on your data structure—no additional configuration required.
47
+ Just call `obj_to_json()` on your data structure. No additional configuration required.
43
48
 
44
49
  ### DeepWiki Docs: [https://deepwiki.com/carlosplanchon/pyobjtojson](https://deepwiki.com/carlosplanchon/pyobjtojson)
45
50
 
@@ -63,7 +68,12 @@ data = {
63
68
  "nested": {"inner_key": "inner_value"}
64
69
  }
65
70
 
66
- json_obj = obj_to_json(data) # Using json.dumps kwargs
71
+ # obj_to_json returns a JSON-serializable structure (nested dicts, lists and
72
+ # primitives), not a JSON string. Pass it to json.dumps() when you need text:
73
+ json_obj = obj_to_json(data)
74
+
75
+ import json
76
+ json_text = json.dumps(json_obj)
67
77
  ```
68
78
 
69
79
  **Output** (example):
@@ -201,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—no additional configuration required.
22
+ Just call `obj_to_json()` on your data structure. No additional configuration required.
21
23
 
22
24
  ### DeepWiki Docs: [https://deepwiki.com/carlosplanchon/pyobjtojson](https://deepwiki.com/carlosplanchon/pyobjtojson)
23
25
 
@@ -41,7 +43,12 @@ data = {
41
43
  "nested": {"inner_key": "inner_value"}
42
44
  }
43
45
 
44
- json_obj = obj_to_json(data) # Using json.dumps kwargs
46
+ # obj_to_json returns a JSON-serializable structure (nested dicts, lists and
47
+ # primitives), not a JSON string. Pass it to json.dumps() when you need text:
48
+ json_obj = obj_to_json(data)
49
+
50
+ import json
51
+ json_text = json.dumps(json_obj)
45
52
  ```
46
53
 
47
54
  **Output** (example):
@@ -179,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.8
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.13
16
+ Requires-Python: >=3.10
14
17
  Description-Content-Type: text/markdown
15
18
  License-File: LICENSE
16
19
  Provides-Extra: dev
@@ -34,12 +37,14 @@ A lightweight Python library that simplifies the process of serializing **any**
34
37
  Works seamlessly with dictionaries, lists, custom classes, dataclasses, and Pydantic models (including both `model_dump()` from v2 and `dict()` from v1).
35
38
  - **Extended Standard Types Support**
36
39
  Native support for `datetime`, `date`, `time`, `UUID`, `Decimal`, `bytes`, `Enum`, `Path`, `set`, and `frozenset`.
40
+ - **JSON-Safe Dictionary Keys**
41
+ Non-string keys (`UUID`, `datetime`, `Enum`, tuples, and other objects) are converted so the returned structure always survives `json.dumps`.
37
42
  - **Full Type Hints Support**
38
43
  Complete type annotations for better IDE autocomplete, type checking with mypy, and improved code documentation.
39
44
  - **Non-Intrusive Serialization**
40
45
  No special inheritance or overrides needed. Uses reflection and standard Python methods (`__dict__`, `asdict()`, `to_dict()`, etc.) where available.
41
46
  - **Easy to Integrate**
42
- Just call `obj_to_json()` on your data structure—no additional configuration required.
47
+ Just call `obj_to_json()` on your data structure. No additional configuration required.
43
48
 
44
49
  ### DeepWiki Docs: [https://deepwiki.com/carlosplanchon/pyobjtojson](https://deepwiki.com/carlosplanchon/pyobjtojson)
45
50
 
@@ -63,7 +68,12 @@ data = {
63
68
  "nested": {"inner_key": "inner_value"}
64
69
  }
65
70
 
66
- json_obj = obj_to_json(data) # Using json.dumps kwargs
71
+ # obj_to_json returns a JSON-serializable structure (nested dicts, lists and
72
+ # primitives), not a JSON string. Pass it to json.dumps() when you need text:
73
+ json_obj = obj_to_json(data)
74
+
75
+ import json
76
+ json_text = json.dumps(json_obj)
67
77
  ```
68
78
 
69
79
  **Output** (example):
@@ -201,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.8"
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"
13
+ requires-python = ">=3.10"
14
14
 
15
15
  license = "MIT"
16
16
  license-files = ["LICENSE"]
@@ -18,6 +18,9 @@ license-files = ["LICENSE"]
18
18
  classifiers = [
19
19
  "Intended Audience :: Developers",
20
20
  "Topic :: Software Development :: Libraries",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
21
24
  "Programming Language :: Python :: 3.13",
22
25
  "Programming Language :: Python :: 3.14"
23
26
  ]
@@ -53,13 +56,15 @@ addopts = [
53
56
  ]
54
57
 
55
58
  [tool.mypy]
56
- python_version = "3.13"
59
+ python_version = "3.10"
57
60
  strict = true
58
61
  warn_return_any = true
59
62
  warn_unused_configs = true
60
63
  disallow_untyped_defs = true
64
+ disallow_any_unimported = false
61
65
  show_error_codes = true
62
66
  show_column_numbers = true
67
+ pretty = true
63
68
  follow_imports = "normal"
64
69
  ignore_missing_imports = true
65
70
  exclude = ["tests/", ".venv/", "build/", "dist/"]
@@ -87,7 +87,8 @@ class TestCircularReferences:
87
87
  assert result["next"]["next"]["next"]["next"] == "<circular reference>"
88
88
 
89
89
  def test_multiple_references_same_object(self):
90
- """Test that same object referenced multiple times (not circular)."""
90
+ """Same object referenced several times in sibling branches is a DAG,
91
+ not a cycle, and every reference must be serialized in full."""
91
92
  shared = {"shared": "data"}
92
93
  container = {
93
94
  "ref1": shared,
@@ -97,8 +98,43 @@ class TestCircularReferences:
97
98
 
98
99
  result = obj_to_json(container, check_circular=True)
99
100
 
100
- # First reference should be serialized normally
101
- assert result["ref1"]["shared"] == "data"
102
- # Subsequent references to the same object are marked as circular
103
- assert result["ref2"] == "<circular reference>"
104
- assert result["ref3"] == "<circular reference>"
101
+ # None of these is a cycle: the shared object is not an ancestor of
102
+ # itself, so each reference is serialized normally.
103
+ assert result["ref1"] == {"shared": "data"}
104
+ assert result["ref2"] == {"shared": "data"}
105
+ assert result["ref3"] == {"shared": "data"}
106
+
107
+ def test_shared_object_in_list(self):
108
+ """The same instance repeated in a list is not circular."""
109
+ item = {"n": 1}
110
+
111
+ result = obj_to_json([item, item, item], check_circular=True)
112
+
113
+ assert result == [{"n": 1}, {"n": 1}, {"n": 1}]
114
+
115
+ def test_diamond_shared_reference(self):
116
+ """A diamond-shaped DAG (shared node reached by two paths) is not a
117
+ cycle and must be fully serialized on every path."""
118
+ shared = {"x": 1}
119
+ container = {
120
+ "left": shared,
121
+ "right": {"nested": shared},
122
+ }
123
+
124
+ result = obj_to_json(container, check_circular=True)
125
+
126
+ assert result["left"] == {"x": 1}
127
+ assert result["right"]["nested"] == {"x": 1}
128
+
129
+ def test_sibling_reuse_is_not_flagged_but_cycle_is(self):
130
+ """Reusing an object across siblings is fine; a genuine back-reference
131
+ into an ancestor is still detected."""
132
+ shared = {"v": 1}
133
+ node = {"a": shared, "b": shared}
134
+ node["self"] = node # real cycle back into an ancestor
135
+
136
+ result = obj_to_json(node, check_circular=True)
137
+
138
+ assert result["a"] == {"v": 1}
139
+ assert result["b"] == {"v": 1}
140
+ assert result["self"] == "<circular reference>"
@@ -122,16 +122,70 @@ class TestEdgeCases:
122
122
  assert result == obj
123
123
 
124
124
  def test_dict_with_non_string_keys(self):
125
- """Test dict with non-string keys (should work as-is)."""
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
- assert result[(3, 4)] == "tuple key"
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
- """Test int-based Enum."""
202
+ """IntEnum must serialize to its plain underlying value.
203
+
204
+ ``== 3`` alone is not enough: an IntEnum member compares equal to its
205
+ int value, so we also assert the result is a plain int and not the
206
+ enum member itself. The plain-primitive fast-path used to intercept
207
+ IntEnum/StrEnum before the Enum branch and return the member as-is.
208
+ """
202
209
  result = obj_to_json(Priority.HIGH)
203
210
  assert result == 3
211
+ assert type(result) is int
212
+ assert not isinstance(result, Enum)
213
+
214
+ def test_string_enum_returns_plain_str(self):
215
+ """A plain Enum with string values yields a plain str, not the member."""
216
+ result = obj_to_json(Status.ACTIVE)
217
+ assert type(result) is str
218
+ assert not isinstance(result, Enum)
219
+
220
+ @pytest.mark.skipif(
221
+ sys.version_info < (3, 11),
222
+ reason="StrEnum was added in Python 3.11"
223
+ )
224
+ def test_str_enum(self):
225
+ """StrEnum must serialize to its plain underlying str value."""
226
+ from enum import StrEnum
227
+
228
+ class Color(StrEnum):
229
+ RED = "red"
230
+
231
+ result = obj_to_json(Color.RED)
232
+ assert result == "red"
233
+ assert type(result) is str
234
+ assert not isinstance(result, Enum)
204
235
 
205
236
  def test_enum_in_dict(self):
206
237
  """Test Enum in dictionary."""
@@ -1,243 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- import base64
4
- import dataclasses
5
- from collections.abc import Mapping, Sequence
6
- from datetime import datetime, date, time
7
- from decimal import Decimal
8
- from enum import Enum
9
- from pathlib import Path
10
- from typing import Any
11
- from uuid import UUID
12
-
13
-
14
- def _serialize_for_json(
15
- obj: Any,
16
- visited: set[int],
17
- check_circular: bool = True,
18
- _skip_circular_check: bool = False,
19
- decimal_as_float: bool = True
20
- ) -> Any:
21
- """
22
- Internal recursion logic that can handle circular references
23
- using `visited`. This includes careful exception handling so
24
- partial failures don't break the entire serialization.
25
-
26
- :param obj: The object to serialize.
27
- :param visited: A set used to track visited objects (for cycle detection).
28
- :param check_circular: Whether to check for and mark circular references.
29
- :param _skip_circular_check: Internal flag to skip adding intermediate
30
- conversion results to visited set.
31
- :param decimal_as_float: If True, convert Decimal to float; otherwise to string.
32
- :return: A JSON-serializable structure, or a string if it cannot be
33
- converted more structurally.
34
- """
35
-
36
- # If it's None, bool, int, float, or str, it's already JSON-serializable.
37
- if obj is None or isinstance(obj, (bool, int, float, str)):
38
- return obj
39
-
40
- # === Standard Python types support ===
41
-
42
- # datetime, date, time → ISO format
43
- if isinstance(obj, datetime):
44
- return obj.isoformat()
45
- if isinstance(obj, date):
46
- return obj.isoformat()
47
- if isinstance(obj, time):
48
- return obj.isoformat()
49
-
50
- # UUID → string
51
- if isinstance(obj, UUID):
52
- return str(obj)
53
-
54
- # Decimal → float or string
55
- if isinstance(obj, Decimal):
56
- if decimal_as_float:
57
- return float(obj)
58
- return str(obj)
59
-
60
- # bytes, bytearray → base64
61
- if isinstance(obj, (bytes, bytearray)):
62
- return base64.b64encode(bytes(obj)).decode('utf-8')
63
-
64
- # Enum → underlying value
65
- if isinstance(obj, Enum):
66
- return obj.value
67
-
68
- # Path → string
69
- if isinstance(obj, Path):
70
- return str(obj)
71
-
72
- # set, frozenset → list
73
- if isinstance(obj, (set, frozenset)):
74
- try:
75
- # Try to sort if elements are comparable
76
- return sorted(list(obj))
77
- except TypeError:
78
- # If not sortable, convert to list without sorting
79
- return list(obj)
80
-
81
- obj_id = id(obj)
82
-
83
- # If circular checking is enabled, see if we've already
84
- # visited this object.
85
- if check_circular is True and not _skip_circular_check:
86
- if obj_id in visited:
87
- return "<circular reference>"
88
- visited.add(obj_id)
89
-
90
- # Handle Mapping (like dict). Build a new dict item by item,
91
- # catching errors.
92
- if isinstance(obj, Mapping):
93
- result_dict: dict[Any, Any] = {}
94
- for key, value in obj.items():
95
- try:
96
- result_dict[key] = _serialize_for_json(
97
- value, visited, check_circular=check_circular,
98
- decimal_as_float=decimal_as_float
99
- )
100
- except Exception as exc:
101
- result_dict[key] = f"<serialization error: {exc}>"
102
- return result_dict
103
-
104
- # Handle Sequence (like list/tuple), but not string.
105
- if isinstance(obj, Sequence) and not isinstance(obj, str):
106
- result_list: list[Any] = []
107
- for index, item in enumerate(obj):
108
- try:
109
- result_list.append(
110
- _serialize_for_json(
111
- obj=item,
112
- visited=visited,
113
- check_circular=check_circular,
114
- decimal_as_float=decimal_as_float
115
- )
116
- )
117
- except Exception as exc:
118
- result_list.append(f"<serialization error at index {index}: {exc}>")
119
- return result_list
120
-
121
- # Try Pydantic v2 model_dump(), but fall back if it fails
122
- if hasattr(obj, "model_dump") and callable(obj.model_dump):
123
- try:
124
- model_data = obj.model_dump()
125
- # Skip circular check for the intermediate dict to avoid false positives
126
- # when Python reuses memory addresses
127
- return _serialize_for_json(
128
- obj=model_data,
129
- visited=visited,
130
- check_circular=check_circular,
131
- _skip_circular_check=True,
132
- decimal_as_float=decimal_as_float
133
- )
134
- except Exception:
135
- # Fall through to next check if this fails
136
- ...
137
-
138
- # Try Pydantic v1 .dict(), but fall back if it fails
139
- if hasattr(obj, "dict") and callable(obj.dict):
140
- try:
141
- dict_data = obj.dict()
142
- # Skip circular check for the intermediate dict
143
- return _serialize_for_json(
144
- obj=dict_data,
145
- visited=visited,
146
- check_circular=check_circular,
147
- _skip_circular_check=True,
148
- decimal_as_float=decimal_as_float
149
- )
150
- except Exception:
151
- # Fall through to next check if this fails
152
- ...
153
-
154
- # If it's a dataclass, convert it using asdict(), but handle exceptions
155
- if dataclasses.is_dataclass(obj):
156
- try:
157
- # is_dataclass can return True for both instances and types,
158
- # but we only process instances here
159
- dc_data = dataclasses.asdict(obj) # type: ignore[arg-type]
160
- # Skip circular check for the intermediate dict
161
- return _serialize_for_json(
162
- obj=dc_data,
163
- visited=visited,
164
- check_circular=check_circular,
165
- _skip_circular_check=True,
166
- decimal_as_float=decimal_as_float
167
- )
168
- except Exception:
169
- # Fall through to next check if this fails
170
- ...
171
-
172
- # If there's a custom .to_dict() method, try that
173
- if hasattr(obj, "to_dict") and callable(obj.to_dict):
174
- try:
175
- custom_dict_data = obj.to_dict()
176
- # Skip circular check for the intermediate dict
177
- return _serialize_for_json(
178
- obj=custom_dict_data,
179
- visited=visited,
180
- check_circular=check_circular,
181
- _skip_circular_check=True,
182
- decimal_as_float=decimal_as_float
183
- )
184
- except Exception:
185
- # Fall through to next check if this fails
186
- ...
187
-
188
- # If the object has a __dict__, recurse into that
189
- if hasattr(obj, "__dict__"):
190
- try:
191
- # Skip circular check for the __dict__ to avoid false positives
192
- return _serialize_for_json(
193
- obj=obj.__dict__,
194
- visited=visited,
195
- check_circular=check_circular,
196
- _skip_circular_check=True,
197
- decimal_as_float=decimal_as_float
198
- )
199
- except Exception:
200
- # Fall through to next check if this fails
201
- pass
202
-
203
- # Last resort: convert to string, but even this can fail
204
- # if __str__ is broken
205
- try:
206
- return str(obj)
207
- except Exception as exc:
208
- # If that fails, return a generic serialization error.
209
- return f"<serialization error: {exc}>"
210
-
211
-
212
- def obj_to_json(
213
- obj: Any,
214
- check_circular: bool = True,
215
- decimal_as_float: bool = True
216
- ) -> Any:
217
- """
218
- Public-facing function that starts with a fresh visited set
219
- to handle cycles (if `check_circular=True`). Calls the internal
220
- _serialize_for_json.
221
-
222
- Supports standard Python types including:
223
- - datetime, date, time (converted to ISO format)
224
- - UUID (converted to string)
225
- - Decimal (converted to float or string based on decimal_as_float)
226
- - bytes, bytearray (converted to base64)
227
- - Enum (converted to underlying value)
228
- - Path (converted to string)
229
- - set, frozenset (converted to sorted list)
230
-
231
- :param obj: The object to serialize to JSON-like structures.
232
- :param check_circular: If True, detect and mark circular references.
233
- :param decimal_as_float: If True, convert Decimal to float; otherwise to string.
234
- Default is True.
235
- :return: A JSON-serializable structure (dict, list, str, int, float, bool, None).
236
- """
237
- visited: set[int] = set()
238
- return _serialize_for_json(
239
- obj=obj,
240
- visited=visited,
241
- check_circular=check_circular,
242
- decimal_as_float=decimal_as_float
243
- )
File without changes
File without changes