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
6 changes: 3 additions & 3 deletions .github/workflows/checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Python environment
Expand All @@ -68,7 +68,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
os: [ubuntu-24.04, windows-2025]

steps:
Expand All @@ -93,7 +93,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
os: [ubuntu-24.04, windows-2025]
shard: [0, 1, 2, 3, 4, 5]

Expand Down
22 changes: 17 additions & 5 deletions py/nox_session_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,31 @@

CONSTRAINTS = {
"test_ai_sdk": {"min_python": "3.12"},
"test_litellm": {"versions": {"latest": {"min_python": "3.11"}}},
# Python 3.15 gaps below are upstream packaging, not SDK code: dependencies
# without cp315 wheels whose sdists cannot build yet (mostly PyO3 < 0.27
# extensions such as fastuuid, ormsgpack, jiter 0.10, pydantic-core 2.41),
# or packages that declare Requires-Python < 3.15. Drop each cap once the
# session installs cleanly on 3.15.
"test_litellm": {"versions": {"latest": {"min_python": "3.11", "max_python_exclusive": "3.15"}}}, # fastuuid
"test_livekit_agents": {"max_python_exclusive": "3.14", "ci_platforms": ["linux"]},
"test_pipecat": {"min_python": "3.11", "max_python_exclusive": "3.14"},
"test_agentscope": {"versions": {"latest": {"min_python": "3.11"}}},
"test_pydantic_ai_integration": {"versions": {"latest": {"min_python": "3.11"}}},
"test_pydantic_ai_logfire": {"versions": {"latest": {"min_python": "3.11"}}},
"test_pydantic_ai_wrap_openai": {"versions": {"latest": {"min_python": "3.11"}}},
"test_dspy": {"versions": {"latest": {"min_python": "3.11"}}},
"test_dspy": {"versions": {"latest": {"min_python": "3.11", "max_python_exclusive": "3.15"}}}, # fastuuid
"test_crewai": {"max_python_exclusive": "3.14"},
"test_deepagents": {"min_python": "3.11"},
"test_transformers": {"versions": {"not_latest": {"max_python_exclusive": "3.13"}}},
"test_harbor": {"min_python": "3.12"},
"test_deepagents": {"min_python": "3.11", "max_python_exclusive": "3.15"}, # ormsgpack
"test_transformers": {
"max_python_exclusive": "3.15", # sentencepiece
"versions": {"not_latest": {"max_python_exclusive": "3.13"}},
},
"test_harbor": {"min_python": "3.12", "max_python_exclusive": "3.15"}, # fastuuid
"test_otel": {"versions": {"not_latest_below": {"version": "1.28.0", "skip_from_python": "3.14"}}},
"test_instructor": {"max_python_exclusive": "3.15"}, # jiter 0.10
"test_langchain": {"max_python_exclusive": "3.15"}, # ormsgpack
"test_openrouter": {"versions": {"latest": {"max_python_exclusive": "3.15"}}}, # pydantic-core 2.41
"test_openai_ddtrace": {"max_python_exclusive": "3.15"}, # ddtrace requires Python < 3.15
}


Expand Down
30 changes: 29 additions & 1 deletion py/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ def test_btx_openai(session, version):

@nox.session()
def test_openai_ddtrace(session):
_skip_if_incompatible(session)
_install_test_deps(session)
_install_matrix_dep(session, "openai", LATEST)
_install_group_locked(session, "test-openai-ddtrace")
Expand Down Expand Up @@ -670,6 +671,7 @@ def test_google_adk(session, version):
@nox.session()
@nox.parametrize("version", LANGCHAIN_VERSIONS, ids=LANGCHAIN_VERSIONS)
def test_langchain(session, version):
_skip_if_incompatible(session)
_install_test_deps(session)
_install_matrix_dep(session, "langchain-core", version)
_install_group_locked(session, "test-langchain")
Expand Down Expand Up @@ -716,6 +718,7 @@ def test_llamaindex(session, version):
@nox.parametrize("version", OPENROUTER_VERSIONS, ids=OPENROUTER_VERSIONS)
def test_openrouter(session, version):
"""Test the native OpenRouter SDK integration."""
_skip_if_incompatible(session)
_install_test_deps(session)
_install_matrix_dep(session, "openrouter", version)
_run_tests(session, f"{INTEGRATION_DIR}/openrouter/test_openrouter.py", version=version)
Expand Down Expand Up @@ -925,10 +928,27 @@ def pylint(session):
"pipecat-ai": (3, 11),
"pydantic-ai-integration": (3, 11),
}
# Latest pins that cannot install on newer Pythons yet (see the matching
# caps in nox_session_constraints.py), and the integration whose files
# pylint then cannot resolve imports for.
maximum_python_exclusive = {
"pipecat-ai": ((3, 14), None),
"dspy": ((3, 15), "dspy"),
"harbor": ((3, 15), None),
"instructor": ((3, 15), "instructor"),
"litellm": ((3, 15), "litellm"),
"livekit-agents": ((3, 15), "livekit_agents"),
"openrouter": ((3, 15), "openrouter"),
"transformers": ((3, 15), None),
}
unlintable_integrations = set()
for package in _VENDOR_TABLE:
if sys.version_info[:2] < minimum_python.get(package, (3, 10)):
continue
if package == "pipecat-ai" and sys.version_info >= (3, 14):
max_python, integration = maximum_python_exclusive.get(package, (None, None))
if max_python and sys.version_info[:2] >= max_python:
if integration:
unlintable_integrations.add(integration)
continue
if package == "crewai":
continue
Expand All @@ -951,6 +971,14 @@ def pylint(session):
# The lint group skips crewai to avoid vulnerable transitive chromadb
# versions, so skip the matching example too.
files = [f for f in files if not f.startswith("../examples/crewai/")]
for integration in sorted(unlintable_integrations):
files = [
f
for f in files
if not f.startswith(f"src/{INTEGRATION_DIR}/{integration}/")
and f != f"src/{INTEGRATION_DIR}/auto_test_scripts/test_auto_{integration}.py"
and not f.startswith(f"../examples/{integration}/")
]
session.run("pylint", "--errors-only", *files)


Expand Down
23 changes: 15 additions & 8 deletions py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ test-litellm = [
# litellm requires openai<=1.99.9 (ResponseTextConfig removal in 1.100.0)
"openai==1.99.9",
"fastapi==0.135.3",
"orjson==3.11.8",
"orjson==3.11.8; python_version<'3.15'",
# 3.11.8 has no cp315 wheels and its sdist fails on current stable Rust.
"orjson==3.12.0; python_version>='3.15'",
]

test-agno = [
Expand Down Expand Up @@ -280,7 +282,7 @@ test-types = [
# -- Lint deps -----------------------------------------------------------------

lint = [
"pylint==4.0.5",
"pylint==4.1.2",
"pyperf==2.10.0",
# Packages below are needed so pylint can resolve every SDK import.
# Versions are unpinned (unless already constrained) so the resolver
Expand All @@ -301,14 +303,19 @@ lint = [
# onnxruntime 1.24.3 dropped cp310 wheels. Constrain the transitive
# resolution on Python 3.10 so pylint can still build its environment.
"onnxruntime<1.24; python_version<'3.11'",
"dspy",
# Python 3.15 markers below: these pull dependencies with no cp315 wheels
# whose sdists cannot build yet (fastuuid via litellm, jiter<0.11 via
# instructor, livekit-blingfire via livekit-agents).
"dspy; python_version<'3.15'",
"google-adk",
"google-genai",
"google-cloud-discoveryengine",
"instructor",
"litellm>=1.104.2",
"livekit-agents>=1.8.5",
"livekit-plugins-openai",
"instructor; python_version<'3.15'",
"litellm>=1.104.2; python_version<'3.15'",
# Fork the resolution so 3.15 is not stuck on instructor's jiter<0.11.
"jiter>=0.11; python_version>='3.15'",
"livekit-agents>=1.8.5; python_version<'3.15'",
"livekit-plugins-openai; python_version<'3.15'",
# Keep these available to pylint without installing CrewAI (whose
# transitive chromadb versions are unsuitable for shared CI).
"opentelemetry-instrumentation-openai==0.59.1",
Expand Down Expand Up @@ -350,7 +357,7 @@ dev = [
{include-group = "api-codegen"},
"nox==2026.2.9",
"pre-commit==4.6.2",
"pylint==4.0.5",
"pylint==4.1.2",
"pyperf==2.10.0",
]

Expand Down
20 changes: 18 additions & 2 deletions py/src/braintrust/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import inspect
import re
import sys
import types
from abc import ABC, abstractmethod
from collections.abc import Iterable
from typing import Any, ClassVar
Expand Down Expand Up @@ -622,7 +623,8 @@ def detect_version(cls, module: Any) -> str | None:
for distribution_name in cls.distribution_names:
try:
return importlib.metadata.version(distribution_name)
except importlib.metadata.PackageNotFoundError:
# FileNotFoundError: Python 3.15's MetadataNotFound for a .dist-info without METADATA.
except (importlib.metadata.PackageNotFoundError, FileNotFoundError):
continue
return detect_module_version(module, cls.import_names)

Expand Down Expand Up @@ -710,11 +712,18 @@ def _import_first_available(import_names: Iterable[str]) -> Any | None:
return None


# PEP 810 (Python 3.15+): modules can hold unreified lazy-import proxies in
# their ``__dict__``. google-cloud-discoveryengine's GAPIC ``__init__`` ships
# its client re-exports this way via ``__lazy_modules__``.
_LAZY_IMPORT_TYPE = getattr(types, "LazyImportType", None)


def _resolve_attr_path(root: Any, path: str) -> Any | None:
current = root
for part in path.split("."):
parent = current
try:
current = inspect.getattr_static(current, part)
current = inspect.getattr_static(parent, part)
except AttributeError:
# ``getattr_static`` never invokes a module-level ``__getattr__``,
# so PEP 562 lazy re-exports look absent. openai >= 3.16.1 ships
Expand All @@ -727,4 +736,11 @@ def _resolve_attr_path(root: Any, path: str) -> Any | None:
current = getattr(current, part)
except AttributeError:
return None
if _LAZY_IMPORT_TYPE is not None and type(current) is _LAZY_IMPORT_TYPE:
# ``getattr_static`` returns the proxy itself; a plain getattr on
# the module reifies it, like any normal access would.
try:
current = getattr(parent, part)
except (AttributeError, ImportError):
return None
return current
3 changes: 2 additions & 1 deletion py/src/braintrust/integrations/pydantic_ai/patchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,4 +224,5 @@ def wrap_model_classes() -> bool:
)
if not ModelClassesPatcher.applies(None, None):
return False
return ClassScanPatcher.patch.__func__(ModelClassesPatcher, None, None)
# pylint 4.1 miscounts the explicit cls passed to the unbound classmethod.
return ClassScanPatcher.patch.__func__(ModelClassesPatcher, None, None) # pylint: disable=too-many-function-args
41 changes: 41 additions & 0 deletions py/src/braintrust/integrations/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,47 @@ def value(self): # pragma: no cover - must never be invoked
assert isinstance(resolved, property)


@pytest.fixture
def pep810_lazy_package(tmp_path, monkeypatch):
"""Import a package whose re-exports are PEP 810 lazy imports (Python 3.15+).

google-cloud-discoveryengine ships its GAPIC ``__init__`` this way: the
module ``__dict__`` holds unreified ``types.LazyImportType`` proxies until
the names are first loaded.
"""
package_dir = tmp_path / "bt_pep810_sdk"
package_dir.mkdir()
(package_dir / "__init__.py").write_text(
"__lazy_modules__ = {'bt_pep810_sdk.client', 'bt_pep810_sdk.broken'}\n"
"from .client import Client\n"
"from .broken import Broken\n"
)
(package_dir / "client.py").write_text("class Client:\n def create(self):\n return 'created'\n")
(package_dir / "broken.py").write_text("raise ImportError('broken optional dependency')\n")
monkeypatch.syspath_prepend(str(tmp_path))
for name in ("bt_pep810_sdk", "bt_pep810_sdk.client", "bt_pep810_sdk.broken"):
monkeypatch.delitem(sys.modules, name, raising=False)

module = importlib.import_module("bt_pep810_sdk")
assert type(module.__dict__["Client"]) is types.LazyImportType # pylint: disable=no-member
return module


@pytest.mark.skipif(sys.version_info < (3, 15), reason="PEP 810 lazy imports require Python 3.15+")
def test_resolve_attr_path_reifies_pep810_lazy_imports(pep810_lazy_package):
client_cls = _resolve_attr_path(pep810_lazy_package, "Client")

assert inspect.isclass(client_cls)
assert client_cls.__module__ == "bt_pep810_sdk.client"
assert _resolve_attr_path(pep810_lazy_package, "Client.create") is client_cls.__dict__["create"]


@pytest.mark.skipif(sys.version_info < (3, 15), reason="PEP 810 lazy imports require Python 3.15+")
def test_resolve_attr_path_returns_none_when_pep810_lazy_import_fails(pep810_lazy_package):
assert _resolve_attr_path(pep810_lazy_package, "Broken") is None
assert _resolve_attr_path(pep810_lazy_package, "Broken.method") is None


def test_resolve_attr_path_walks_real_submodules():
assert _resolve_attr_path(sys.modules["braintrust.integrations.base"], "BasePatcher") is not None

Expand Down
38 changes: 37 additions & 1 deletion py/src/braintrust/integrations/test_versioning.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,40 @@
from braintrust.integrations.versioning import version_satisfies
import sys
import types

import pytest
from braintrust.integrations.base import BaseIntegration
from braintrust.integrations.versioning import detect_module_version, version_satisfies


@pytest.fixture
def dist_info_without_metadata(tmp_path, monkeypatch):
"""A .dist-info left without METADATA, e.g. by an interrupted install."""
(tmp_path / "bt_fake_provider-1.0.dist-info").mkdir()
monkeypatch.syspath_prepend(str(tmp_path))
module = types.ModuleType("bt_fake_provider")
module.__version__ = "1.2.3"
return module


_requires_metadata_not_found = pytest.mark.skipif(
sys.version_info < (3, 15), reason="importlib.metadata raises MetadataNotFound on Python 3.15+"
)


@_requires_metadata_not_found
def test_detect_module_version_falls_back_when_metadata_file_missing(dist_info_without_metadata):
assert detect_module_version(dist_info_without_metadata, ("bt_fake_provider",)) == "1.2.3"


@_requires_metadata_not_found
def test_integration_detect_version_falls_back_when_metadata_file_missing(dist_info_without_metadata):
class FakeIntegration(BaseIntegration):
name = "bt_fake_provider"
import_names = ("bt_fake_provider",)
distribution_names = ("bt-fake-provider",)
patchers = ()

assert FakeIntegration.detect_version(dist_info_without_metadata) == "1.2.3"


def test_version_satisfies_handles_prereleases():
Expand Down
3 changes: 2 additions & 1 deletion py/src/braintrust/integrations/versioning.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ def detect_module_version(module: Any, import_names: tuple[str, ...]) -> str | N
seen.add(candidate)
try:
return importlib.metadata.version(candidate)
except importlib.metadata.PackageNotFoundError:
# FileNotFoundError: Python 3.15's MetadataNotFound for a .dist-info without METADATA.
except (importlib.metadata.PackageNotFoundError, FileNotFoundError):
continue

version = getattr(module, "__version__", None)
Expand Down
3 changes: 2 additions & 1 deletion py/src/braintrust/span_origin.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ def merge_span_origin_context(
def _sdk_version() -> str:
try:
return importlib.metadata.version("braintrust")
except importlib.metadata.PackageNotFoundError:
# FileNotFoundError: Python 3.15's MetadataNotFound for a .dist-info without METADATA.
except (importlib.metadata.PackageNotFoundError, FileNotFoundError):
return "unknown"


Expand Down
21 changes: 21 additions & 0 deletions py/src/braintrust/test_span_origin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import importlib.metadata
import sys
from unittest.mock import patch

import pytest
from braintrust import span_origin


Expand All @@ -13,3 +16,21 @@ def test_sdk_version_is_cached():
version.assert_called_once_with("braintrust")
finally:
span_origin._sdk_version.cache_clear()


@pytest.mark.skipif(sys.version_info < (3, 15), reason="importlib.metadata.MetadataNotFound is new in Python 3.15")
def test_sdk_version_tolerates_dist_info_without_metadata():
# On 3.15, a lone .dist-info without METADATA (e.g. an interrupted install)
# raises MetadataNotFound, a FileNotFoundError rather than a
# PackageNotFoundError. The SDK version lookup runs on every span.
span_origin._sdk_version.cache_clear()
try:
with patch(
"braintrust.span_origin.importlib.metadata.version",
side_effect=importlib.metadata.MetadataNotFound( # pylint: disable=no-member
"No package metadata was found."
),
):
assert span_origin.merge_span_origin_context({}, "test", None)["span_origin"]["version"] == "unknown"
finally:
span_origin._sdk_version.cache_clear()
Loading
Loading