Skip to content
Merged
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
12 changes: 6 additions & 6 deletions langfuse/_utils/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,12 @@ def _default_inner(self, obj: Any) -> Any:
# Check if numpy is available and if the object is a numpy scalar
# If so, convert it to a Python scalar using the item() method
if np is not None and isinstance(obj, np.generic):
return obj.item()
return self.default(obj.item())

# Check if numpy is available and if the object is a numpy array
# If so, convert it to a Python list using the tolist() method
if np is not None and isinstance(obj, np.ndarray):
return obj.tolist()
return self.default(obj.tolist())

if isinstance(obj, float) and math.isnan(obj):
return "NaN"
Expand All @@ -93,7 +93,7 @@ def _default_inner(self, obj: Any) -> Any:
return str(obj)

if isinstance(obj, enum.Enum):
return obj.value
return self.default(obj.value)

if isinstance(obj, Queue):
return type(obj).__name__
Expand Down Expand Up @@ -127,7 +127,7 @@ def _default_inner(self, obj: Any) -> Any:
return f"<{type(obj).__name__}>"

if is_dataclass(obj):
return asdict(obj) # type: ignore
return self.default(asdict(obj)) # type: ignore

if isinstance(obj, BaseModel):
obj.model_rebuild()
Comment thread
hassiebp marked this conversation as resolved.
Expand All @@ -144,10 +144,10 @@ def _default_inner(self, obj: Any) -> Any:

# if langchain is not available, the Serializable type is NoneType
if Serializable is not type(None) and isinstance(obj, Serializable): # type: ignore
return obj.to_json()
return self.default(obj.to_json())

if isinstance(obj, (tuple, set, frozenset)):
return list(obj)
return [self.default(item) for item in obj]

if isinstance(obj, dict):
return {self.default(k): self.default(v) for k, v in obj.items()}
Expand Down
112 changes: 104 additions & 8 deletions tests/unit/test_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,16 @@ def test_infinity_floats():
assert serializer.encode(float("-inf")) == '"-Infinity"'


def _reject_json_constant(token):
# json.loads accepts bare NaN/Infinity by default; reject them the way
# the ingestion server's strict parser does.
raise ValueError(f"invalid JSON constant emitted: {token}")
Comment thread
hassiebp marked this conversation as resolved.


def _strict_loads(encoded: str):
return json.loads(encoded, parse_constant=_reject_json_constant)


def test_pydantic_model_with_non_finite_floats():
# Non-finite floats nested inside a pydantic model must be converted to
# safe string tokens rather than emitted as bare NaN/Infinity, which are
Expand All @@ -213,14 +223,7 @@ class ModelWithFloats(BaseModel):
finite=1.5,
)
serializer = EventSerializer()
encoded = serializer.encode(model)

# Must be strict JSON: json.loads accepts bare NaN/Infinity by default, so
# use parse_constant to reject them the way the ingestion server does.
def _reject(token):
raise ValueError(f"invalid JSON constant emitted: {token}")

parsed = json.loads(encoded, parse_constant=_reject)
parsed = _strict_loads(serializer.encode(model))
assert parsed == {
"nan": "NaN",
"inf": "Infinity",
Expand All @@ -229,6 +232,99 @@ def _reject(token):
}


def test_tuple_set_frozenset_with_non_finite_floats():
serializer = EventSerializer()

assert _strict_loads(serializer.encode((float("nan"), 1.0, float("inf")))) == [
"NaN",
1.0,
"Infinity",
]
assert _strict_loads(serializer.encode({float("nan")})) == ["NaN"]
assert _strict_loads(serializer.encode(frozenset([float("-inf")]))) == ["-Infinity"]


def test_dataclass_with_non_finite_floats():
@dataclass
class Point:
x: float
y: float

serializer = EventSerializer()
parsed = _strict_loads(serializer.encode(Point(float("nan"), float("inf"))))
assert parsed == {"x": "NaN", "y": "Infinity"}


def test_enum_with_non_finite_float_value():
class NonFiniteEnum(Enum):
NAN = float("nan")
INF = float("inf")

serializer = EventSerializer()
assert _strict_loads(serializer.encode(NonFiniteEnum.NAN)) == "NaN"
assert _strict_loads(serializer.encode(NonFiniteEnum.INF)) == "Infinity"


def test_numpy_array_and_generic_with_non_finite_floats(monkeypatch):
# Numpy is an optional runtime dependency; fake the types EventSerializer
# checks for so the ndarray / generic branches are covered without numpy.
class FakeGeneric:
def __init__(self, value):
self._value = value

def item(self):
return self._value

class FakeNdarray:
def __init__(self, data):
self._data = data

def tolist(self):
return self._data

class FakeNP:
generic = FakeGeneric
ndarray = FakeNdarray

from langfuse._utils import serializer as serializer_mod

monkeypatch.setattr(serializer_mod, "np", FakeNP)

serializer = EventSerializer()
assert _strict_loads(
serializer.encode(FakeNdarray([float("nan"), 1.0, float("inf")]))
) == ["NaN", 1.0, "Infinity"]
assert _strict_loads(
serializer.encode(FakeNdarray([[float("nan"), 1.0], [float("-inf"), 2.0]]))
) == [["NaN", 1.0], ["-Infinity", 2.0]]
assert _strict_loads(serializer.encode(FakeGeneric(float("nan")))) == "NaN"
assert _strict_loads(serializer.encode(FakeGeneric(float("inf")))) == "Infinity"


def test_langchain_serializable_to_json_with_non_finite_floats(monkeypatch):
# langchain_core.Serializable is a BaseModel, so real messages take the
# pydantic path. Patch the type EventSerializer checks so the to_json()
# branch is covered independently.
class FakeSerializable:
def to_json(self):
return {"score": float("nan"), "ok": 1.5}

from langfuse._utils import serializer as serializer_mod

monkeypatch.setattr(serializer_mod, "Serializable", FakeSerializable)

parsed = _strict_loads(EventSerializer().encode(FakeSerializable()))
assert parsed == {"score": "NaN", "ok": 1.5}


def test_tuple_with_js_unsafe_integer():
# The same conversion branches that leaked non-finite floats also skip
# JS-safe integer coercion unless values are routed back through default().
unsafe = 2**53
serializer = EventSerializer()
assert _strict_loads(serializer.encode((unsafe,))) == [str(unsafe)]


def test_slots():
class SlotClass:
__slots__ = ["field"]
Expand Down