diff --git a/src/uipath/core/serialization/__init__.py b/src/uipath/core/serialization/__init__.py index aa26e75..301969f 100644 --- a/src/uipath/core/serialization/__init__.py +++ b/src/uipath/core/serialization/__init__.py @@ -1,5 +1,5 @@ """Serialization utilities for converting Python objects to various formats.""" -from .json import serialize_defaults, serialize_json +from .json import _sanitize_nan, serialize_defaults, serialize_json -__all__ = ["serialize_defaults", "serialize_json"] +__all__ = ["_sanitize_nan", "serialize_defaults", "serialize_json"] diff --git a/src/uipath/core/serialization/json.py b/src/uipath/core/serialization/json.py index 0208454..253fa49 100644 --- a/src/uipath/core/serialization/json.py +++ b/src/uipath/core/serialization/json.py @@ -1,6 +1,7 @@ """JSON serialization utilities for converting Python objects to JSON formats.""" import json +import math from dataclasses import asdict, is_dataclass from datetime import datetime, timezone from enum import Enum @@ -123,12 +124,31 @@ def serialize_defaults( return str(obj) +def _sanitize_nan(obj: Any) -> Any: + """Recursively replace NaN/Infinity floats with None for RFC 8259 compliance. + + Python's json.dumps() outputs bare NaN/Infinity tokens which are invalid JSON. + Strict parsers (.NET, Go, Rust) reject these, causing downstream failures. + """ + if isinstance(obj, float): + if math.isnan(obj) or math.isinf(obj): + return None + return obj + if isinstance(obj, dict): + return {k: _sanitize_nan(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_sanitize_nan(item) for item in obj] + return obj + + def serialize_json(obj: Any) -> str: """Serialize Python object to JSON string. This is a convenience function that wraps json.dumps() with serialize_defaults() as the default handler for non-JSON-serializable types. + NaN and Infinity float values are converted to null for RFC 8259 compliance. + Args: obj: The object to serialize to JSON @@ -147,4 +167,4 @@ def serialize_json(obj: Any) -> str: >>> serialize_json(task) '{"name": "Review PR", "created": "2024-01-15T10:30:00"}' """ - return json.dumps(obj, default=serialize_defaults) + return json.dumps(_sanitize_nan(obj), default=serialize_defaults, allow_nan=False) diff --git a/tests/serialization/test_json.py b/tests/serialization/test_json.py index 663b292..2da6fb1 100644 --- a/tests/serialization/test_json.py +++ b/tests/serialization/test_json.py @@ -1,6 +1,7 @@ """Tests for serialization utilities.""" import json +import math from collections import namedtuple from dataclasses import dataclass from datetime import datetime, timezone @@ -12,6 +13,7 @@ from pydantic import BaseModel from uipath.core.serialization import serialize_json +from uipath.core.serialization.json import _sanitize_nan def _has_tzdata() -> bool: @@ -612,3 +614,95 @@ def __str__(self) -> str: # Seventh sublist: Booleans and None assert parsed[6] == [True, False, None] + + +class TestNanSanitization: + """Tests for NaN/Infinity sanitization in serialize_json.""" + + def test_nan_becomes_null(self) -> None: + result = serialize_json({"value": float("nan")}) + parsed = json.loads(result) + assert parsed["value"] is None + + def test_positive_infinity_becomes_null(self) -> None: + result = serialize_json({"value": float("inf")}) + parsed = json.loads(result) + assert parsed["value"] is None + + def test_negative_infinity_becomes_null(self) -> None: + result = serialize_json({"value": float("-inf")}) + parsed = json.loads(result) + assert parsed["value"] is None + + def test_normal_floats_preserved(self) -> None: + result = serialize_json({"value": 3.14, "zero": 0.0, "neg": -1.5}) + parsed = json.loads(result) + assert parsed["value"] == 3.14 + assert parsed["zero"] == 0.0 + assert parsed["neg"] == -1.5 + + def test_nested_nan_in_dict(self) -> None: + data = {"outer": {"inner": float("nan"), "ok": 1.0}} + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["outer"]["inner"] is None + assert parsed["outer"]["ok"] == 1.0 + + def test_nan_in_list(self) -> None: + data = [1.0, float("nan"), float("inf"), 3.0] + result = serialize_json(data) + parsed = json.loads(result) + assert parsed == [1.0, None, None, 3.0] + + def test_nan_in_deeply_nested_structure(self) -> None: + data = {"a": [{"b": [float("nan"), {"c": float("inf")}]}]} + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["a"][0]["b"][0] is None + assert parsed["a"][0]["b"][1]["c"] is None + + def test_output_is_valid_json(self) -> None: + """Verify the output parses correctly with strict JSON parser.""" + data = { + "nan": float("nan"), + "inf": float("inf"), + "neg_inf": float("-inf"), + "normal": 42.0, + "nested": [float("nan"), {"key": float("inf")}], + } + result = serialize_json(data) + # json.loads with default settings rejects NaN tokens + # so successful parse confirms valid JSON + parsed = json.loads(result, parse_constant=None) + assert isinstance(parsed, dict) + + def test_non_float_types_unchanged(self) -> None: + data = {"str": "hello", "int": 42, "bool": True, "none": None} + result = serialize_json(data) + parsed = json.loads(result) + assert parsed == data + + def test_sanitize_nan_preserves_tuple_as_list(self) -> None: + result = _sanitize_nan((1.0, float("nan"), 3.0)) + assert result == [1.0, None, 3.0] + + def test_empty_structures(self) -> None: + assert _sanitize_nan({}) == {} + assert _sanitize_nan([]) == [] + assert _sanitize_nan(()) == [] + + def test_mixed_with_pydantic_model(self) -> None: + """NaN inside a dict alongside Pydantic models.""" + + class Score(BaseModel): + name: str + value: float + + data = { + "model": Score(name="test", value=1.0), + "raw_nan": float("nan"), + } + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["model"]["name"] == "test" + assert parsed["raw_nan"] is None