diff --git a/langfuse/_utils/serializer.py b/langfuse/_utils/serializer.py index c2dad3312..92ab84924 100644 --- a/langfuse/_utils/serializer.py +++ b/langfuse/_utils/serializer.py @@ -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 @@ -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. diff --git a/langfuse/api/core/pydantic_utilities.py b/langfuse/api/core/pydantic_utilities.py index d2b7b51b6..478728ab5 100644 --- a/langfuse/api/core/pydantic_utilities.py +++ b/langfuse/api/core/pydantic_utilities.py @@ -2,6 +2,8 @@ # nopycln: file import datetime as dt +import types +import typing from collections import defaultdict from typing import ( Any, @@ -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] @@ -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]: diff --git a/tests/test_pydantic_compat.py b/tests/test_pydantic_compat.py new file mode 100644 index 000000000..5a686ab3c --- /dev/null +++ b/tests/test_pydantic_compat.py @@ -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 + )