diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 4b7659d2c..08face41b 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -151,12 +151,24 @@ def _default_retry_config() -> Retry: return Retry(**retry_params) # type: ignore -def _serialize_json(obj: Any) -> Any: - if isinstance(obj, datetime.datetime): - return obj.isoformat() - if isinstance(obj, uuid.UUID): - return str(obj) +_PRIMITIVE_TYPES = (str, int, float, bool, tuple, list, dict) +_MAX_DEPTH = 3 + + +def _serialize_json(obj: Any, depth: int = 0) -> Any: try: + if isinstance(obj, datetime.datetime): + return obj.isoformat() + if isinstance(obj, uuid.UUID): + return str(obj) + if obj is None or isinstance(obj, _PRIMITIVE_TYPES): + return obj + if isinstance(obj, set): + return list(obj) + if isinstance(obj, bytes): + return obj.decode("utf-8") + if depth >= _MAX_DEPTH: + return repr(obj) serialization_methods = [ ("model_dump_json", True), # Pydantic V2 ("json", True), # Pydantic V1 @@ -178,15 +190,17 @@ def _serialize_json(obj: Any) -> Any: if dataclasses.is_dataclass(obj): # Regular dataclass return dataclasses.asdict(obj) - try: - if hasattr(obj, "__slots__"): - return {slot: getattr(obj, slot) for slot in obj.__slots__} - else: - return vars(obj) - except Exception as e: - logger.debug(f"Failed to serialize {type(obj)} to JSON using vars: {e}") + if hasattr(obj, "__slots__"): + all_attrs = {slot: getattr(obj, slot, None) for slot in obj.__slots__} + elif hasattr(obj, "__dict__"): + all_attrs = vars(obj) + else: return repr(obj) - except Exception as e: + return { + k: _serialize_json(v, depth=depth + 1) if v is not obj else repr(v) + for k, v in all_attrs.items() + } + except BaseException as e: logger.debug(f"Failed to serialize {type(obj)} to JSON: {e}") return repr(obj) diff --git a/python/tests/unit_tests/test_client.py b/python/tests/unit_tests/test_client.py index f2ceeee78..0e5302183 100644 --- a/python/tests/unit_tests/test_client.py +++ b/python/tests/unit_tests/test_client.py @@ -2,15 +2,17 @@ import asyncio import dataclasses import gc +import itertools import json import os +import threading import time import uuid import weakref from datetime import datetime from enum import Enum from io import BytesIO -from typing import NamedTuple, Optional +from typing import Any, NamedTuple, Optional from unittest import mock from unittest.mock import MagicMock, patch @@ -459,6 +461,17 @@ class MyClass: def __init__(self, x: int) -> None: self.x = x self.y = "y" + self.a_list = [1, 2, 3] + self.a_tuple = (1, 2, 3) + self.a_set = {1, 2, 3} + self.a_dict = {"foo": "bar"} + self.my_bytes = b"foo" + + class ClassWithTee: + def __init__(self) -> None: + tee_a, tee_b = itertools.tee(range(10)) + self.tee_a = tee_a + self.tee_b = tee_b class MyClassWithSlots: __slots__ = ["x", "y"] @@ -497,10 +510,30 @@ class AttrDict: current_time = datetime.now() class NestedClass: - __slots__ = ["person"] + __slots__ = ["person", "lock"] def __init__(self) -> None: self.person = Person(name="foo") + self.lock = [threading.Lock()] + + class CyclicClass: + def __init__(self) -> None: + self.cyclic = self + + def __repr__(self) -> str: + return "SoCyclic" + + class CyclicClass2: + def __init__(self) -> None: + self.cyclic: Any = None + self.other: Any = None + + def __repr__(self) -> str: + return "SoCyclic2" + + cycle_2 = CyclicClass2() + cycle_2.cyclic = CyclicClass2() + cycle_2.cyclic.other = cycle_2 class MyNamedTuple(NamedTuple): foo: str @@ -510,6 +543,7 @@ class MyNamedTuple(NamedTuple): "uid": uid, "time": current_time, "my_class": MyClass(1), + "class_with_tee": ClassWithTee(), "my_slotted_class": MyClassWithSlots(1), "my_dataclass": MyDataclass("foo", 1), "my_enum": MyEnum.FOO, @@ -523,13 +557,26 @@ class MyNamedTuple(NamedTuple): "nested_class": NestedClass(), "attr_dict": AttrDict(foo="foo", bar=1), "named_tuple": MyNamedTuple(foo="foo", bar=1), + "cyclic": CyclicClass(), + "cyclic2": cycle_2, } res = json.loads(json.dumps(to_serialize, default=_serialize_json)) expected = { "uid": str(uid), "time": current_time.isoformat(), - "my_class": {"x": 1, "y": "y"}, + "my_class": { + "x": 1, + "y": "y", + "a_list": [1, 2, 3], + "a_tuple": [1, 2, 3], + "a_set": [1, 2, 3], + "a_dict": {"foo": "bar"}, + "my_bytes": "foo", + }, + "class_with_tee": lambda val: all( + ["_tee object" in val[key] for key in ["tee_a", "tee_b"]] + ), "my_slotted_class": {"x": 1, "y": "y"}, "my_dataclass": {"foo": "foo", "bar": 1}, "my_enum": "foo", @@ -540,11 +587,22 @@ class MyNamedTuple(NamedTuple): "a_str": "foo", "an_int": 1, "a_float": 1.1, - "nested_class": {"person": {"name": "foo"}}, + "nested_class": ( + lambda val: val["person"] == {"name": "foo"} + and "_thread.lock object" in str(val.get("lock")) + ), "attr_dict": {"foo": "foo", "bar": 1}, "named_tuple": ["foo", 1], + "cyclic": {"cyclic": "SoCyclic"}, + # We don't really care about this case just want to not err + "cyclic2": lambda _: True, } - assert res == expected + assert set(expected) == set(res) + for k, v in expected.items(): + if callable(v): + assert v(res[k]) + else: + assert res[k] == v def test_host_url() -> None: