Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/uipath/core/serialization/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
22 changes: 21 additions & 1 deletion src/uipath/core/serialization/json.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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)
94 changes: 94 additions & 0 deletions tests/serialization/test_json.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Loading