Skip to content
Open
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
30 changes: 20 additions & 10 deletions langfuse/_utils/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,33 @@
from collections.abc import Sequence
from dataclasses import asdict, is_dataclass
from datetime import date, datetime
from functools import lru_cache
from json import JSONEncoder
from logging import getLogger
from pathlib import Path
from typing import Any
from typing import Any, Optional, Type
from uuid import UUID

from pydantic import BaseModel

from langfuse.media import LangfuseMedia

# Attempt to import Serializable
try:
from langchain_core.load.serializable import Serializable
except ImportError:
# If Serializable is not available, set it to a placeholder type
class Serializable: # type: ignore
pass

@lru_cache(maxsize=1)
def _get_langchain_serializable_type() -> Optional[Type[Any]]:
"""Best-effort lookup of LangChain's Serializable base class.

Import lazily to avoid import-time side effects from optional langchain
dependencies, including Python 3.14 warning-as-error failures triggered by
transitive `pydantic.v1` imports.
"""

try:
from langchain_core.load.serializable import Serializable

return Serializable
except Exception:
return None


# Attempt to import numpy
Expand Down Expand Up @@ -109,8 +119,8 @@ def default(self, obj: Any) -> Any:
if isinstance(obj, Path):
return str(obj)

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

# 64-bit integers might overflow the JavaScript safe integer range.
Expand Down
34 changes: 25 additions & 9 deletions langfuse/api/core/pydantic_utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

# nopycln: file
import datetime as dt
import types
import typing
from collections import defaultdict
from typing import (
Any,
Expand All @@ -20,21 +22,35 @@
)

import pydantic
import typing_extensions

IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.")

if IS_PYDANTIC_V2:
from pydantic.v1.datetime_parse import parse_date as parse_date
from pydantic.v1.datetime_parse import parse_datetime as parse_datetime
from pydantic.v1.fields import ModelField as ModelField
from pydantic.v1.json import ENCODERS_BY_TYPE as encoders_by_type # type: ignore[attr-defined]
from pydantic.v1.typing import get_args as get_args
from pydantic.v1.typing import get_origin as get_origin
from pydantic.v1.typing import is_literal_type as is_literal_type
from pydantic.v1.typing import is_union as is_union
from pydantic.fields import FieldInfo as FieldInfo

ModelField = FieldInfo # type: ignore[assignment]
encoders_by_type: Dict[Any, Callable[[Any], Any]] = {}
get_args = typing_extensions.get_args
get_origin = typing_extensions.get_origin

def parse_date(value: Any) -> dt.date:
return pydantic.TypeAdapter(dt.date).validate_python(value) # type: ignore[attr-defined]

def parse_datetime(value: Any) -> dt.datetime:
return pydantic.TypeAdapter(dt.datetime).validate_python(value) # type: ignore[attr-defined]

def is_literal_type(type_: Any) -> bool:
origin = typing_extensions.get_origin(type_)
return origin in (typing.Literal, typing_extensions.Literal)

def is_union(type_: Any) -> bool:
origin = typing_extensions.get_origin(type_)
return origin in (Union, types.UnionType)
else:
from pydantic.datetime_parse import parse_date as parse_date # type: ignore[no-redef]
from pydantic.datetime_parse import parse_datetime as parse_datetime # type: ignore[no-redef]
from pydantic.fields import FieldInfo as FieldInfo
from pydantic.fields import ModelField as ModelField # type: ignore[attr-defined, no-redef]
from pydantic.json import ENCODERS_BY_TYPE as encoders_by_type # type: ignore[no-redef]
from pydantic.typing import get_args as get_args # type: ignore[no-redef]
Expand Down Expand Up @@ -287,7 +303,7 @@ def decorator(func: AnyCallable) -> AnyCallable:
return decorator


PydanticField = Union[ModelField, pydantic.fields.FieldInfo]
PydanticField = Union[ModelField, FieldInfo]


def _get_model_fields(model: Type["Model"]) -> Mapping[str, PydanticField]:
Expand Down
25 changes: 25 additions & 0 deletions tests/test_pydantic_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

import subprocess
import sys
from datetime import date, datetime, timezone

from langfuse.api.core.pydantic_utilities import parse_date, parse_datetime


def test_import_langfuse_with_user_warnings_as_errors() -> None:
result = subprocess.run(
[sys.executable, "-W", "error::UserWarning", "-c", "import langfuse"],
capture_output=True,
text=True,
check=False,
)

assert result.returncode == 0, result.stderr or result.stdout


def test_parse_helpers_support_pydantic_v2() -> None:
assert parse_date("2024-01-02") == date(2024, 1, 2)
assert parse_datetime("2024-01-02T03:04:05Z") == datetime(
2024, 1, 2, 3, 4, 5, tzinfo=timezone.utc
)