5a558eb09e
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled
427 lines
14 KiB
Python
427 lines
14 KiB
Python
import asyncio
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
from typing import Any, List, Union
|
|
|
|
from opik.evaluation.metrics import base_metric, score_result
|
|
|
|
|
|
class DummyMetric(base_metric.BaseMetric):
|
|
def score(
|
|
self, *args: Any, **kwargs: Any
|
|
) -> Union[score_result.ScoreResult, List[score_result.ScoreResult]]:
|
|
return score_result.ScoreResult(
|
|
value=0.5, name=self.name, reason="Test metric score"
|
|
)
|
|
|
|
|
|
class MyCustomMetric(base_metric.BaseMetric):
|
|
"""Same as the example in the docstring of BaseMetric."""
|
|
|
|
def __init__(self, name: str, track: bool = True):
|
|
super().__init__(name=name, track=track)
|
|
|
|
def score(self, input: str, output: str, **ignored_kwargs: Any):
|
|
# Add your logic here
|
|
return score_result.ScoreResult(
|
|
value=0, name=self.name, reason="Optional reason for the score"
|
|
)
|
|
|
|
|
|
def test_base_metric_score_default_name():
|
|
metric = DummyMetric()
|
|
|
|
assert metric.name == "DummyMetric"
|
|
assert metric.track is True
|
|
|
|
actual_result = metric.score()
|
|
|
|
expected_result = score_result.ScoreResult(
|
|
name="DummyMetric", value=0.5, reason="Test metric score"
|
|
)
|
|
assert actual_result == expected_result
|
|
|
|
|
|
def test_base_metric_custom_name():
|
|
metric = DummyMetric(name="custom_name", project_name="test_project")
|
|
|
|
assert metric.name == "custom_name"
|
|
assert metric.track is True
|
|
|
|
actual_result = metric.score()
|
|
|
|
expected_result = score_result.ScoreResult(
|
|
name="custom_name", value=0.5, reason="Test metric score"
|
|
)
|
|
assert actual_result == expected_result
|
|
|
|
|
|
def test_my_custom_metric_example():
|
|
metric = MyCustomMetric("some_name", track=False)
|
|
|
|
assert metric.name == "some_name"
|
|
assert metric.track is False
|
|
|
|
actual_result = metric.score("some_input_data", "some_output_data")
|
|
|
|
expected_result = score_result.ScoreResult(
|
|
name="some_name", value=0, reason="Optional reason for the score"
|
|
)
|
|
assert actual_result == expected_result
|
|
|
|
|
|
def test_base_metric_project_name_with_track_false_raises_error():
|
|
with pytest.raises(
|
|
ValueError, match="project_name can be set only when `track` is set to True"
|
|
):
|
|
DummyMetric(track=False, project_name="test_project")
|
|
|
|
|
|
def test_base_metric_ascore_returns_expected_result():
|
|
metric = DummyMetric()
|
|
actual_result = asyncio.run(metric.ascore())
|
|
|
|
expected_result = score_result.ScoreResult(
|
|
name="DummyMetric", value=0.5, reason="Test metric score"
|
|
)
|
|
assert actual_result == expected_result
|
|
|
|
|
|
class TestLightweightOpikPackage:
|
|
"""Tests for the _opik lightweight package and sys.modules patching.
|
|
|
|
These run in subprocesses to get a clean module state.
|
|
"""
|
|
|
|
def test_opik_lightweight_import_does_not_load_heavy_modules(self):
|
|
"""Verify that importing from _opik stays lightweight.
|
|
|
|
The _opik package must only use stdlib modules. If this test fails,
|
|
someone added a dependency to _opik that pulls in heavy packages.
|
|
|
|
HOW TO FIX: Remove the heavy import from _opik/. The _opik package
|
|
must only depend on stdlib (abc, dataclasses, typing).
|
|
"""
|
|
code = """
|
|
import sys
|
|
|
|
from _opik import BaseMetric, ScoreResult
|
|
|
|
# Verify basic functionality works
|
|
class SimpleMetric(BaseMetric):
|
|
def score(self, **kwargs):
|
|
return ScoreResult(name="simple", value=1.0)
|
|
|
|
metric = SimpleMetric()
|
|
result = metric.score()
|
|
assert result.name == "simple"
|
|
assert result.value == 1.0
|
|
|
|
# Only these opik-related modules should be loaded
|
|
ALLOWED = {"_opik", "_opik._base_metric", "_opik._score_result"}
|
|
loaded = {m for m in sys.modules if m.startswith(("opik", "_opik"))}
|
|
unexpected = sorted(loaded - ALLOWED)
|
|
|
|
if unexpected:
|
|
print("FAIL")
|
|
print(
|
|
"Lightweight _opik import loaded unexpected modules.\\n"
|
|
"The _opik package must only use stdlib.\\n"
|
|
"Unexpected modules:\\n " + "\\n ".join(unexpected)
|
|
)
|
|
sys.exit(1)
|
|
|
|
print("LIGHTWEIGHT_OK")
|
|
"""
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", code],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode == 0, (
|
|
f"Lightweight _opik loaded unexpected modules.\n"
|
|
f"See stdout for details:\n{result.stdout}\n{result.stderr}"
|
|
)
|
|
assert "LIGHTWEIGHT_OK" in result.stdout
|
|
|
|
def test_sys_modules_patch_intercepts_opik_imports(self):
|
|
"""Verify the sys.modules patching works like scoring_runner.py does.
|
|
|
|
User code does `from opik.evaluation.metrics import BaseMetric` and
|
|
it should resolve to the lightweight _opik.BaseMetric without
|
|
triggering the real opik import.
|
|
|
|
HOW TO FIX if this fails: Check that _opik._base_metric.BaseMetric
|
|
and _opik._score_result.ScoreResult match the interface expected by
|
|
opik.evaluation.metrics.BaseMetric users.
|
|
"""
|
|
code = """
|
|
import sys
|
|
import types
|
|
|
|
import _opik._base_metric
|
|
import _opik._score_result
|
|
|
|
# Patch sys.modules the same way scoring_runner.py does
|
|
for name in ["opik", "opik.evaluation", "opik.evaluation.metrics"]:
|
|
stub = types.ModuleType(name)
|
|
stub.__path__ = []
|
|
sys.modules[name] = stub
|
|
|
|
sys.modules["opik.evaluation.metrics.base_metric"] = _opik._base_metric
|
|
sys.modules["opik.evaluation.metrics.score_result"] = _opik._score_result
|
|
sys.modules["opik.evaluation.metrics"].base_metric = _opik._base_metric
|
|
sys.modules["opik.evaluation.metrics"].score_result = _opik._score_result
|
|
sys.modules["opik.evaluation.metrics"].BaseMetric = _opik._base_metric.BaseMetric
|
|
sys.modules["opik.evaluation.metrics"].ScoreResult = _opik._score_result.ScoreResult
|
|
|
|
# Now simulate what user code does
|
|
from opik.evaluation.metrics import BaseMetric
|
|
from opik.evaluation.metrics.score_result import ScoreResult
|
|
|
|
class UserMetric(BaseMetric):
|
|
def score(self, output="", **kwargs):
|
|
return ScoreResult(name="user_metric", value=0.75, reason="test")
|
|
|
|
metric = UserMetric()
|
|
result = metric.score(output="hello")
|
|
assert result.name == "user_metric"
|
|
assert result.value == 0.75
|
|
assert result.reason == "test"
|
|
|
|
# Verify heavy opik modules were NOT loaded
|
|
heavy = [m for m in sys.modules if m.startswith("opik.") and m not in {
|
|
"opik.evaluation", "opik.evaluation.metrics",
|
|
"opik.evaluation.metrics.base_metric", "opik.evaluation.metrics.score_result",
|
|
}]
|
|
if heavy:
|
|
print("FAIL")
|
|
print(f"Patching did not prevent heavy imports: {heavy}")
|
|
sys.exit(1)
|
|
|
|
print("PATCH_OK")
|
|
"""
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", code],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode == 0, (
|
|
f"sys.modules patching failed.\n"
|
|
f"See stdout for details:\n{result.stdout}\n{result.stderr}"
|
|
)
|
|
assert "PATCH_OK" in result.stdout
|
|
|
|
def test_submodule_import_of_non_stubbed_opik_child_triggers_real_load(self):
|
|
"""Verify the meta-path finder fallback resolves non-stubbed `opik.*` submodules.
|
|
|
|
The stubs installed in `sys.modules` for `opik`, `opik.evaluation`, and
|
|
`opik.evaluation.metrics` have `__path__ = []`. That stops `PathFinder`
|
|
from resolving submodules like `opik.evaluation.metrics.conversation`
|
|
that are not pre-registered — and Python's import machinery does not
|
|
consult `__getattr__` for dotted-path resolution, so the attribute
|
|
fallback never fires. `scoring_runner.py` installs a `find_spec`
|
|
hook on the stub so that any non-stubbed `opik.*` submodule import
|
|
triggers the real opik load and then resolves through the standard
|
|
finders.
|
|
|
|
HOW TO FIX if this fails: Check `_FallbackModule.find_spec` in
|
|
`apps/opik-sandbox-executor-python/scoring_runner.py` — it must
|
|
return a spec for `opik.*` names when `_stubs` is non-empty.
|
|
"""
|
|
code = """
|
|
import importlib.util
|
|
import sys
|
|
import types
|
|
from typing import Any
|
|
|
|
import _opik._base_metric
|
|
import _opik._score_result
|
|
|
|
_stubs = {}
|
|
|
|
|
|
def _load_real_opik():
|
|
if not _stubs:
|
|
return
|
|
for name, stub in _stubs.items():
|
|
if sys.modules.get(name) is stub:
|
|
del sys.modules[name]
|
|
if stub in sys.meta_path:
|
|
sys.meta_path.remove(stub)
|
|
_stubs.clear()
|
|
import opik # noqa: F401
|
|
|
|
|
|
class _FallbackModule(types.ModuleType):
|
|
def __getattr__(self, name):
|
|
_load_real_opik()
|
|
return getattr(sys.modules[self.__name__], name)
|
|
|
|
def find_spec(self, fullname, path, target=None):
|
|
if not _stubs or not (fullname == "opik" or fullname.startswith("opik.")):
|
|
return None
|
|
_load_real_opik()
|
|
return importlib.util.find_spec(fullname)
|
|
|
|
|
|
for _name in ["opik", "opik.evaluation", "opik.evaluation.metrics"]:
|
|
stub = _FallbackModule(_name)
|
|
stub.__path__ = []
|
|
sys.modules[_name] = stub
|
|
_stubs[_name] = stub
|
|
|
|
sys.modules["opik.evaluation.metrics.base_metric"] = _opik._base_metric
|
|
sys.modules["opik.evaluation.metrics.score_result"] = _opik._score_result
|
|
sys.modules["opik.evaluation.metrics"].base_metric = _opik._base_metric
|
|
sys.modules["opik.evaluation.metrics"].score_result = _opik._score_result
|
|
|
|
sys.meta_path.insert(0, _stubs["opik"])
|
|
|
|
# Before first real access, the stubs are in place.
|
|
assert _stubs, "stubs should be installed before user code runs"
|
|
|
|
# User code imports a non-stubbed submodule — this goes through find_spec.
|
|
from opik.evaluation.metrics.conversation import conversation_thread_metric, types as conv_types
|
|
|
|
# The finder should have triggered the real opik load.
|
|
assert not _stubs, "real opik load should have cleared the stubs"
|
|
assert "opik.evaluation.metrics.conversation" in sys.modules
|
|
assert conversation_thread_metric.__file__.endswith(
|
|
"opik/evaluation/metrics/conversation/conversation_thread_metric.py"
|
|
)
|
|
assert conv_types.__file__.endswith("opik/evaluation/metrics/conversation/types.py")
|
|
|
|
# The class is really usable.
|
|
cls = conversation_thread_metric.ConversationThreadMetric
|
|
assert callable(cls)
|
|
|
|
print("SUBMODULE_OK")
|
|
"""
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", code],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode == 0, (
|
|
f"Submodule import through fallback finder failed.\n"
|
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
|
)
|
|
assert "SUBMODULE_OK" in result.stdout
|
|
|
|
def test_lightweight_path_stays_lightweight_with_finder_installed(self):
|
|
"""Verify the finder doesn't disturb the fast path.
|
|
|
|
When user code uses only `BaseMetric` / `ScoreResult`, the finder
|
|
must stay out of the way — no real opik import should happen.
|
|
|
|
HOW TO FIX if this fails: The `find_spec` early-exit on missing
|
|
stubs (or on non-`opik.*` names) is likely broken — it's claiming
|
|
names it shouldn't.
|
|
"""
|
|
code = """
|
|
import importlib.util
|
|
import sys
|
|
import types
|
|
from typing import Any
|
|
|
|
import _opik._base_metric
|
|
import _opik._score_result
|
|
|
|
_stubs = {}
|
|
|
|
|
|
def _load_real_opik():
|
|
if not _stubs:
|
|
return
|
|
for name, stub in _stubs.items():
|
|
if sys.modules.get(name) is stub:
|
|
del sys.modules[name]
|
|
if stub in sys.meta_path:
|
|
sys.meta_path.remove(stub)
|
|
_stubs.clear()
|
|
import opik # noqa: F401
|
|
|
|
|
|
class _FallbackModule(types.ModuleType):
|
|
def __getattr__(self, name):
|
|
_load_real_opik()
|
|
return getattr(sys.modules[self.__name__], name)
|
|
|
|
def find_spec(self, fullname, path, target=None):
|
|
if not _stubs or not (fullname == "opik" or fullname.startswith("opik.")):
|
|
return None
|
|
_load_real_opik()
|
|
return importlib.util.find_spec(fullname)
|
|
|
|
|
|
for _name in ["opik", "opik.evaluation", "opik.evaluation.metrics"]:
|
|
stub = _FallbackModule(_name)
|
|
stub.__path__ = []
|
|
sys.modules[_name] = stub
|
|
_stubs[_name] = stub
|
|
|
|
sys.modules["opik.evaluation.metrics.base_metric"] = _opik._base_metric
|
|
sys.modules["opik.evaluation.metrics.score_result"] = _opik._score_result
|
|
sys.modules["opik.evaluation.metrics"].base_metric = _opik._base_metric
|
|
sys.modules["opik.evaluation.metrics"].score_result = _opik._score_result
|
|
sys.modules["opik.evaluation.metrics"].BaseMetric = _opik._base_metric.BaseMetric
|
|
sys.modules["opik.evaluation.metrics"].ScoreResult = _opik._score_result.ScoreResult
|
|
|
|
sys.meta_path.insert(0, _stubs["opik"])
|
|
|
|
# Simulate user code that only touches the lightweight path.
|
|
from opik.evaluation.metrics import BaseMetric
|
|
from opik.evaluation.metrics.score_result import ScoreResult
|
|
|
|
class UserMetric(BaseMetric):
|
|
def score(self, **kwargs):
|
|
return ScoreResult(name="user", value=0.42)
|
|
|
|
assert UserMetric().score().value == 0.42
|
|
|
|
# Real opik must not have been loaded.
|
|
heavy = [m for m in sys.modules if m.startswith("opik.") and m not in {
|
|
"opik.evaluation", "opik.evaluation.metrics",
|
|
"opik.evaluation.metrics.base_metric", "opik.evaluation.metrics.score_result",
|
|
}]
|
|
if heavy:
|
|
print("FAIL")
|
|
print(f"Lightweight path loaded unexpected modules: {heavy}")
|
|
sys.exit(1)
|
|
assert _stubs, "stubs should still be in place — finder should not have triggered"
|
|
|
|
print("LIGHT_PATH_OK")
|
|
"""
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", code],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode == 0, (
|
|
f"Finder disturbed the lightweight path.\n"
|
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
|
)
|
|
assert "LIGHT_PATH_OK" in result.stdout
|
|
|
|
def test_opik_base_metric_is_subclass_of_lightweight(self):
|
|
"""Verify that opik.evaluation.metrics.BaseMetric subclasses _opik.BaseMetric.
|
|
|
|
This ensures isinstance() works across both import paths.
|
|
"""
|
|
from _opik import BaseMetric as LightweightBaseMetric
|
|
from opik.evaluation.metrics import BaseMetric as FullBaseMetric
|
|
|
|
assert issubclass(FullBaseMetric, LightweightBaseMetric)
|
|
|
|
def test_score_result_is_same_class(self):
|
|
"""Verify that ScoreResult is the same class from both paths."""
|
|
from _opik import ScoreResult as LightweightScoreResult
|
|
from opik.evaluation.metrics.score_result import (
|
|
ScoreResult as FullScoreResult,
|
|
)
|
|
|
|
assert LightweightScoreResult is FullScoreResult
|