pyobjtojson 0.9__tar.gz → 0.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyobjtojson
3
- Version: 0.9
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
@@ -251,7 +251,7 @@ obj_to_json(data)
251
251
 
252
252
  ## API Reference
253
253
 
254
- ### `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")`
255
255
 
256
256
  Returns a cycle-free structure (nested dictionaries/lists) that is JSON-serializable.
257
257
 
@@ -259,10 +259,33 @@ Returns a cycle-free structure (nested dictionaries/lists) that is JSON-serializ
259
259
  - `obj` (Any): The object to serialize to JSON-like structures.
260
260
  - `check_circular` (bool, optional): If True (default), detect and mark circular references as `"<circular reference>"`.
261
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`.
262
268
 
263
269
  **Returns:**
264
270
  - `dict | list | Any`: A JSON-serializable structure.
265
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
+
266
289
  ## Type Hints
267
290
 
268
291
  **pyobjtojson** is fully typed and passes strict mypy checking. This provides:
@@ -226,7 +226,7 @@ obj_to_json(data)
226
226
 
227
227
  ## API Reference
228
228
 
229
- ### `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")`
230
230
 
231
231
  Returns a cycle-free structure (nested dictionaries/lists) that is JSON-serializable.
232
232
 
@@ -234,10 +234,33 @@ Returns a cycle-free structure (nested dictionaries/lists) that is JSON-serializ
234
234
  - `obj` (Any): The object to serialize to JSON-like structures.
235
235
  - `check_circular` (bool, optional): If True (default), detect and mark circular references as `"<circular reference>"`.
236
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`.
237
243
 
238
244
  **Returns:**
239
245
  - `dict | list | Any`: A JSON-serializable structure.
240
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
+
241
264
  ## Type Hints
242
265
 
243
266
  **pyobjtojson** is fully typed and passes strict mypy checking. This provides:
@@ -2,6 +2,7 @@
2
2
 
3
3
  import base64
4
4
  import dataclasses
5
+ import math
5
6
  from collections.abc import Mapping, Sequence
6
7
  from datetime import datetime, date, time
7
8
  from decimal import Decimal
@@ -11,12 +12,42 @@ from typing import Any
11
12
  from uuid import UUID
12
13
 
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
+
14
44
  def _serialize_for_json(
15
45
  obj: Any,
16
46
  visited: set[int],
17
47
  check_circular: bool = True,
18
48
  _skip_circular_check: bool = False,
19
- decimal_as_float: bool = True
49
+ decimal_as_float: bool = True,
50
+ non_finite: str = "null"
20
51
  ) -> Any:
21
52
  """
22
53
  Internal recursion logic that can handle circular references
@@ -29,6 +60,8 @@ def _serialize_for_json(
29
60
  :param _skip_circular_check: Internal flag to skip adding intermediate
30
61
  conversion results to visited set.
31
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".
32
65
  :return: A JSON-serializable structure, or a string if it cannot be
33
66
  converted more structurally.
34
67
  """
@@ -40,6 +73,12 @@ def _serialize_for_json(
40
73
  if isinstance(obj, Enum):
41
74
  return obj.value
42
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
+
43
82
  # If it's None, bool, int, float, or str, it's already JSON-serializable.
44
83
  if obj is None or isinstance(obj, (bool, int, float, str)):
45
84
  return obj
@@ -61,7 +100,12 @@ def _serialize_for_json(
61
100
  # Decimal → float or string
62
101
  if isinstance(obj, Decimal):
63
102
  if decimal_as_float:
64
- return float(obj)
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
65
109
  return str(obj)
66
110
 
67
111
  # bytes, bytearray → base64
@@ -86,9 +130,9 @@ def _serialize_for_json(
86
130
  # If circular checking is enabled, see if this object is already on the
87
131
  # current traversal path. We track whether *this* frame added the id so we
88
132
  # 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.
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.
92
136
  added_to_visited = False
93
137
  if check_circular is True and not _skip_circular_check:
94
138
  if obj_id in visited:
@@ -106,12 +150,12 @@ def _serialize_for_json(
106
150
  # reject the returned structure even though the values are fine.
107
151
  json_key = _serialize_key(
108
152
  key, visited, check_circular=check_circular,
109
- decimal_as_float=decimal_as_float
153
+ decimal_as_float=decimal_as_float, non_finite=non_finite
110
154
  )
111
155
  try:
112
156
  result_dict[json_key] = _serialize_for_json(
113
157
  value, visited, check_circular=check_circular,
114
- decimal_as_float=decimal_as_float
158
+ decimal_as_float=decimal_as_float, non_finite=non_finite
115
159
  )
116
160
  except Exception as exc:
117
161
  result_dict[json_key] = f"<serialization error: {exc}>"
@@ -127,7 +171,8 @@ def _serialize_for_json(
127
171
  obj=item,
128
172
  visited=visited,
129
173
  check_circular=check_circular,
130
- decimal_as_float=decimal_as_float
174
+ decimal_as_float=decimal_as_float,
175
+ non_finite=non_finite
131
176
  )
132
177
  )
133
178
  except Exception as exc:
@@ -146,7 +191,8 @@ def _serialize_for_json(
146
191
  visited=visited,
147
192
  check_circular=check_circular,
148
193
  _skip_circular_check=True,
149
- decimal_as_float=decimal_as_float
194
+ decimal_as_float=decimal_as_float,
195
+ non_finite=non_finite
150
196
  )
151
197
  except Exception:
152
198
  # Fall through to next check if this fails
@@ -162,7 +208,8 @@ def _serialize_for_json(
162
208
  visited=visited,
163
209
  check_circular=check_circular,
164
210
  _skip_circular_check=True,
165
- decimal_as_float=decimal_as_float
211
+ decimal_as_float=decimal_as_float,
212
+ non_finite=non_finite
166
213
  )
167
214
  except Exception:
168
215
  # Fall through to next check if this fails
@@ -180,7 +227,8 @@ def _serialize_for_json(
180
227
  visited=visited,
181
228
  check_circular=check_circular,
182
229
  _skip_circular_check=True,
183
- decimal_as_float=decimal_as_float
230
+ decimal_as_float=decimal_as_float,
231
+ non_finite=non_finite
184
232
  )
185
233
  except Exception:
186
234
  # Fall through to next check if this fails
@@ -196,7 +244,8 @@ def _serialize_for_json(
196
244
  visited=visited,
197
245
  check_circular=check_circular,
198
246
  _skip_circular_check=True,
199
- decimal_as_float=decimal_as_float
247
+ decimal_as_float=decimal_as_float,
248
+ non_finite=non_finite
200
249
  )
201
250
  except Exception:
202
251
  # Fall through to next check if this fails
@@ -211,7 +260,8 @@ def _serialize_for_json(
211
260
  visited=visited,
212
261
  check_circular=check_circular,
213
262
  _skip_circular_check=True,
214
- decimal_as_float=decimal_as_float
263
+ decimal_as_float=decimal_as_float,
264
+ non_finite=non_finite
215
265
  )
216
266
  except Exception:
217
267
  # Fall through to next check if this fails
@@ -236,7 +286,8 @@ def _serialize_key(
236
286
  key: Any,
237
287
  visited: set[int],
238
288
  check_circular: bool = True,
239
- decimal_as_float: bool = True
289
+ decimal_as_float: bool = True,
290
+ non_finite: str = "null"
240
291
  ) -> Any:
241
292
  """
242
293
  Convert a mapping key into something ``json.dumps`` accepts as an object
@@ -254,10 +305,14 @@ def _serialize_key(
254
305
  :param visited: The traversal-path set shared with the value serializer.
255
306
  :param check_circular: Whether cycle detection is enabled.
256
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).
257
309
  :return: A ``json.dumps``-compatible key.
258
310
  """
259
311
  # 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").
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)
261
316
  if key is None or isinstance(key, (str, bool, int, float)):
262
317
  return key
263
318
 
@@ -267,7 +322,8 @@ def _serialize_key(
267
322
  obj=key,
268
323
  visited=visited,
269
324
  check_circular=check_circular,
270
- decimal_as_float=decimal_as_float
325
+ decimal_as_float=decimal_as_float,
326
+ non_finite=non_finite
271
327
  )
272
328
  except Exception as exc:
273
329
  return f"<unserializable key: {exc}>"
@@ -286,7 +342,8 @@ def _serialize_key(
286
342
  def obj_to_json(
287
343
  obj: Any,
288
344
  check_circular: bool = True,
289
- decimal_as_float: bool = True
345
+ decimal_as_float: bool = True,
346
+ non_finite: str = "null"
290
347
  ) -> Any:
291
348
  """
292
349
  Public-facing function that starts with a fresh visited set
@@ -306,12 +363,22 @@ def obj_to_json(
306
363
  :param check_circular: If True, detect and mark circular references.
307
364
  :param decimal_as_float: If True, convert Decimal to float; otherwise to string.
308
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).
309
371
  :return: A JSON-serializable structure (dict, list, str, int, float, bool, None).
310
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
+ )
311
377
  visited: set[int] = set()
312
378
  return _serialize_for_json(
313
379
  obj=obj,
314
380
  visited=visited,
315
381
  check_circular=check_circular,
316
- decimal_as_float=decimal_as_float
382
+ decimal_as_float=decimal_as_float,
383
+ non_finite=non_finite
317
384
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyobjtojson
3
- Version: 0.9
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
@@ -251,7 +251,7 @@ obj_to_json(data)
251
251
 
252
252
  ## API Reference
253
253
 
254
- ### `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")`
255
255
 
256
256
  Returns a cycle-free structure (nested dictionaries/lists) that is JSON-serializable.
257
257
 
@@ -259,10 +259,33 @@ Returns a cycle-free structure (nested dictionaries/lists) that is JSON-serializ
259
259
  - `obj` (Any): The object to serialize to JSON-like structures.
260
260
  - `check_circular` (bool, optional): If True (default), detect and mark circular references as `"<circular reference>"`.
261
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`.
262
268
 
263
269
  **Returns:**
264
270
  - `dict | list | Any`: A JSON-serializable structure.
265
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
+
266
289
  ## Type Hints
267
290
 
268
291
  **pyobjtojson** is fully typed and passes strict mypy checking. This provides:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyobjtojson"
7
- version = "0.9"
7
+ version = "0.9.1"
8
8
  authors = [
9
9
  {name = "Carlos A. Planchón", email = "carlosandresplanchonprestes@gmail.com"},
10
10
  ]
@@ -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
 
@@ -238,3 +239,63 @@ class TestEdgeCases:
238
239
  }
239
240
  result = obj_to_json(obj)
240
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")
File without changes
File without changes