1277 lines
42 KiB
Python
1277 lines
42 KiB
Python
import cProfile
|
|
import inspect
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import posixpath
|
|
import pstats
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections import defaultdict
|
|
from contextlib import nullcontext
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterator
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
import requests
|
|
from opentelemetry import trace as trace_api
|
|
|
|
import mlflow
|
|
from mlflow.environment_variables import (
|
|
_MLFLOW_TESTING,
|
|
MLFLOW_ENABLE_ASYNC_TRACE_LOGGING,
|
|
MLFLOW_ENABLE_WORKSPACES,
|
|
MLFLOW_TRACKING_URI,
|
|
MLFLOW_WORKSPACE,
|
|
MLFLOW_WORKSPACE_STORE_URI,
|
|
)
|
|
from mlflow.telemetry.client import get_telemetry_client
|
|
from mlflow.tracing.display.display_handler import IPythonTraceDisplayHandler
|
|
from mlflow.tracing.export.inference_table import _TRACE_BUFFER
|
|
from mlflow.tracing.fluent import _set_last_active_trace_id
|
|
from mlflow.tracing.provider import get_current_otel_span
|
|
from mlflow.tracing.trace_manager import InMemoryTraceManager
|
|
from mlflow.utils import workspace_context, workspace_utils
|
|
from mlflow.utils.os import is_windows
|
|
from mlflow.version import IS_TRACING_SDK_ONLY, VERSION
|
|
|
|
from tests.autologging.fixtures import enable_test_mode
|
|
from tests.helper_functions import get_safe_port
|
|
from tests.tracing.helper import purge_traces
|
|
|
|
if not IS_TRACING_SDK_ONLY:
|
|
from mlflow.tracking._tracking_service.utils import _use_tracking_uri
|
|
from mlflow.tracking.fluent import (
|
|
_last_active_run_id,
|
|
_reset_last_logged_model_id,
|
|
clear_active_model,
|
|
)
|
|
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Pytest hooks and configuration from root conftest.py
|
|
def pytest_addoption(parser):
|
|
parser.addoption(
|
|
"--requires-ssh",
|
|
action="store_true",
|
|
dest="requires_ssh",
|
|
default=False,
|
|
help="Run tests decorated with 'requires_ssh' annotation. "
|
|
"These tests require keys to be configured locally "
|
|
"for SSH authentication.",
|
|
)
|
|
parser.addoption(
|
|
"--ignore-flavors",
|
|
action="store_true",
|
|
dest="ignore_flavors",
|
|
default=False,
|
|
help="Ignore tests for model flavors.",
|
|
)
|
|
parser.addoption(
|
|
"--splits",
|
|
default=None,
|
|
type=int,
|
|
help="The number of groups to split tests into.",
|
|
)
|
|
parser.addoption(
|
|
"--group",
|
|
default=None,
|
|
type=int,
|
|
help="The group of tests to run.",
|
|
)
|
|
parser.addoption(
|
|
"--serve-wheel",
|
|
action="store_true",
|
|
default=os.environ.get("CI", "false").lower() == "true",
|
|
help="Serve a wheel for the dev version of MLflow. True by default in CI, False otherwise.",
|
|
)
|
|
parser.addoption(
|
|
"--profile",
|
|
default=None,
|
|
help=(
|
|
"Comma-separated list of test nodeids to profile "
|
|
"(e.g., 'tests/foo.py::test_bar,tests/baz.py')"
|
|
),
|
|
)
|
|
|
|
|
|
def pytest_configure(config: pytest.Config):
|
|
config.addinivalue_line("markers", "requires_ssh")
|
|
config.addinivalue_line("markers", "notrackingurimock")
|
|
config.addinivalue_line("markers", "flaky: mark test as flaky to allow reruns")
|
|
config.addinivalue_line("markers", "allow_infer_pip_requirements_fallback")
|
|
config.addinivalue_line(
|
|
"markers", "do_not_disable_new_import_hook_firing_if_module_already_exists"
|
|
)
|
|
config.addinivalue_line("markers", "classification")
|
|
config.addinivalue_line("markers", "no_mock_requests_get")
|
|
|
|
labels = fetch_pr_labels() or []
|
|
if "fail-fast" in labels:
|
|
config.option.maxfail = 1
|
|
|
|
# Populate _profile_tests from CLI option and PR description
|
|
global _profile_tests
|
|
_profile_tests = set()
|
|
|
|
# Add tests from CLI --profile option
|
|
if profile_option := config.getoption("--profile"):
|
|
for nodeid in profile_option.split(","):
|
|
if nodeid := nodeid.strip():
|
|
_profile_tests.add(nodeid)
|
|
|
|
# Add tests from PR description
|
|
_profile_tests.update(fetch_profile_tests())
|
|
|
|
# Register SQLAlchemy LegacyAPIWarning filter only if sqlalchemy is available
|
|
try:
|
|
import sqlalchemy # noqa: F401
|
|
|
|
config.addinivalue_line("filterwarnings", "error::sqlalchemy.exc.LegacyAPIWarning")
|
|
except ImportError:
|
|
pass
|
|
|
|
|
|
@pytest.hookimpl(tryfirst=True)
|
|
def pytest_cmdline_main(config: pytest.Config):
|
|
if not_exists := [p for p in config.getoption("ignore") or [] if not os.path.exists(p)]:
|
|
raise pytest.UsageError(f"The following paths are ignored but do not exist: {not_exists}")
|
|
|
|
group = config.getoption("group")
|
|
splits = config.getoption("splits")
|
|
|
|
if splits is None and group is None:
|
|
return None
|
|
|
|
if splits and group is None:
|
|
raise pytest.UsageError("`--group` is required")
|
|
|
|
if group and splits is None:
|
|
raise pytest.UsageError("`--splits` is required")
|
|
|
|
if splits < 0:
|
|
raise pytest.UsageError("`--splits` must be >= 1")
|
|
|
|
if group < 1 or group > splits:
|
|
raise pytest.UsageError("`--group` must be between 1 and {splits}")
|
|
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class TestResult:
|
|
path: Path
|
|
test_name: str
|
|
execution_time: float
|
|
|
|
|
|
_test_results: list[TestResult] = []
|
|
|
|
|
|
@dataclass
|
|
class ProfileResult:
|
|
nodeid: str
|
|
stats: pstats.Stats
|
|
|
|
|
|
_profile_tests: set[str] = set()
|
|
_profile_results: list[ProfileResult] = []
|
|
|
|
|
|
def _to_gb(b: int) -> str:
|
|
return f"{b / 1024**3:.1f}"
|
|
|
|
|
|
@dataclass
|
|
class ResourceUsage:
|
|
mem_used_bytes: int = 0
|
|
mem_total_bytes: int = 0
|
|
disk_used_bytes: int = 0
|
|
disk_total_bytes: int = 0
|
|
|
|
@staticmethod
|
|
def _get_usage() -> tuple[int, int, int, int] | None:
|
|
try:
|
|
import psutil
|
|
except ImportError:
|
|
return None
|
|
|
|
mem = psutil.virtual_memory()
|
|
disk = psutil.disk_usage("/")
|
|
return mem.used, mem.total, disk.used, disk.total
|
|
|
|
def snapshot(self) -> None:
|
|
usage = self._get_usage()
|
|
if usage is None:
|
|
return
|
|
|
|
(
|
|
self.mem_used_bytes,
|
|
self.mem_total_bytes,
|
|
self.disk_used_bytes,
|
|
self.disk_total_bytes,
|
|
) = usage
|
|
|
|
def check(self) -> str | None:
|
|
usage = self._get_usage()
|
|
if usage is None:
|
|
return None
|
|
|
|
THRESHOLD = 500 * 1024 * 1024 # 0.5 GB
|
|
mu, _, du, _ = usage
|
|
parts: list[str] = []
|
|
mem_delta = mu - self.mem_used_bytes
|
|
if mem_delta >= THRESHOLD:
|
|
delta = _to_gb(mem_delta)
|
|
prev = _to_gb(self.mem_used_bytes)
|
|
curr = _to_gb(mu)
|
|
parts.append(f"MEM: +{delta} ({prev} -> {curr}) GB")
|
|
disk_delta = du - self.disk_used_bytes
|
|
if disk_delta >= THRESHOLD:
|
|
delta = _to_gb(disk_delta)
|
|
prev = _to_gb(self.disk_used_bytes)
|
|
curr = _to_gb(du)
|
|
parts.append(f"DISK: +{delta} ({prev} -> {curr}) GB")
|
|
if parts:
|
|
return ", ".join(parts)
|
|
|
|
def format(self) -> str:
|
|
mem_total = _to_gb(self.mem_total_bytes)
|
|
mem_used = _to_gb(self.mem_used_bytes)
|
|
disk_total = _to_gb(self.disk_total_bytes)
|
|
disk_used = _to_gb(self.disk_used_bytes)
|
|
return f"MEM {mem_used}/{mem_total} GB | DISK {disk_used}/{disk_total} GB"
|
|
|
|
|
|
_RESOURCE_HEAVY_TESTS: dict[str, str] = {} # test nodeid -> resource usage delta
|
|
_RESOURCE_USAGE = ResourceUsage()
|
|
|
|
|
|
def _should_profile_test(nodeid: str) -> bool:
|
|
if not _profile_tests:
|
|
return False
|
|
|
|
# Check for exact match first
|
|
if nodeid in _profile_tests:
|
|
return True
|
|
|
|
# Check for partial matches (e.g., file path matches)
|
|
for pattern in _profile_tests:
|
|
if nodeid.startswith(pattern):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _format_profile_stats(stats: pstats.Stats) -> str:
|
|
stream = io.StringIO()
|
|
stats.stream = stream
|
|
stats.sort_stats(pstats.SortKey.CUMULATIVE)
|
|
stats.print_stats(50) # Print top 50 functions
|
|
return stream.getvalue()
|
|
|
|
|
|
def fetch_profile_tests() -> set[str]:
|
|
"""
|
|
Returns the set of test nodeids to profile from the current pull request description.
|
|
Parses <!-- profile: --> markers from PR body.
|
|
"""
|
|
if "GITHUB_ACTIONS" not in os.environ:
|
|
return set()
|
|
|
|
if os.environ.get("GITHUB_EVENT_NAME") != "pull_request":
|
|
return set()
|
|
|
|
with open(os.environ["GITHUB_EVENT_PATH"]) as f:
|
|
pr_data = json.load(f)
|
|
pr_body = pr_data["pull_request"]["body"] or ""
|
|
|
|
# Match <!-- profile: ... --> blocks, supporting multiline content
|
|
pattern = r"<!--\s*profile:\s*(.*?)\s*-->"
|
|
matches = re.findall(pattern, pr_body, re.DOTALL)
|
|
|
|
nodeids = set()
|
|
for match in matches:
|
|
# Split by newlines and filter out empty lines
|
|
for line in match.strip().split("\n"):
|
|
if line := line.strip():
|
|
nodeids.add(line)
|
|
|
|
return nodeids
|
|
|
|
|
|
def pytest_sessionstart(session):
|
|
# Clear duration tracking state at the start of each session
|
|
_test_results.clear()
|
|
_profile_results.clear()
|
|
_RESOURCE_HEAVY_TESTS.clear()
|
|
_RESOURCE_USAGE.snapshot()
|
|
|
|
if IS_TRACING_SDK_ONLY:
|
|
return
|
|
|
|
import click
|
|
|
|
if uri := MLFLOW_TRACKING_URI.get():
|
|
click.echo(
|
|
click.style(
|
|
(
|
|
f"Environment variable {MLFLOW_TRACKING_URI} is set to {uri!r}, "
|
|
"which may interfere with tests."
|
|
),
|
|
fg="red",
|
|
)
|
|
)
|
|
|
|
|
|
def to_md_table(rows: list[list[str]]) -> str:
|
|
if not rows:
|
|
return ""
|
|
n = max(len(r) for r in rows)
|
|
rows = [r + [""] * (n - len(r)) for r in rows]
|
|
|
|
# Calculate column widths
|
|
widths = [max(len(row[i]) for row in rows) for i in range(n)]
|
|
|
|
def esc(s: str) -> str:
|
|
return s.replace("|", r"\|").replace("\n", "<br>")
|
|
|
|
# Format rows with proper padding
|
|
def format_row(row: list[str]) -> str:
|
|
cells = [esc(cell).ljust(width) for cell, width in zip(row, widths)]
|
|
return "| " + " | ".join(cells) + " |"
|
|
|
|
header = format_row(rows[0])
|
|
sep = "| " + " | ".join(["-" * w for w in widths]) + " |"
|
|
body = [format_row(row) for row in rows[1:]]
|
|
|
|
return "\n".join([header, sep, *body])
|
|
|
|
|
|
def generate_duration_stats() -> str:
|
|
"""Generate per-file duration statistics as markdown table."""
|
|
if not _test_results:
|
|
return ""
|
|
|
|
# Group results by file path
|
|
file_groups: defaultdict[Path, list[float]] = defaultdict(list)
|
|
for result in _test_results:
|
|
file_groups[result.path].append(result.execution_time)
|
|
|
|
rows = []
|
|
for path, test_times in file_groups.items():
|
|
rel_path = path.relative_to(Path.cwd()).as_posix()
|
|
total_dur = sum(test_times)
|
|
if total_dur < 1.0:
|
|
# Ignore files with total duration < 1s
|
|
continue
|
|
test_count = len(test_times)
|
|
min_test = min(test_times)
|
|
max_test = max(test_times)
|
|
avg_test = sum(test_times) / len(test_times)
|
|
|
|
rows.append((rel_path, total_dur, test_count, min_test, max_test, avg_test))
|
|
|
|
rows.sort(key=lambda r: r[1], reverse=True)
|
|
|
|
if not rows:
|
|
return ""
|
|
|
|
# Limit to top 30 files
|
|
rows = rows[:30]
|
|
|
|
# Prepare data for markdown table (headers + data rows)
|
|
table_rows = [["Rank", "File", "Duration", "Tests", "Min", "Max", "Avg"]]
|
|
for idx, (path, dur, count, min_, max_, avg_) in enumerate(rows, 1):
|
|
table_rows.append([
|
|
str(idx),
|
|
f"`{path}`",
|
|
f"{dur:.2f}s",
|
|
str(count),
|
|
f"{min_:.3f}s",
|
|
f"{max_:.3f}s",
|
|
f"{avg_:.3f}s",
|
|
])
|
|
|
|
return to_md_table(table_rows)
|
|
|
|
|
|
@pytest.hookimpl(tryfirst=True)
|
|
def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None):
|
|
"""
|
|
Custom test protocol that tracks test duration and supports rerunning failed tests
|
|
marked with @pytest.mark.flaky.
|
|
|
|
This is a simplified implementation inspired by pytest-rerunfailures:
|
|
https://github.com/pytest-dev/pytest-rerunfailures/blob/365dc54ba3069f55a870cda2c3e1e3c33c68f326/src/pytest_rerunfailures.py#L564-L619
|
|
|
|
Usage:
|
|
@pytest.mark.flaky(attempts=3)
|
|
def test_something():
|
|
# Will run up to 3 times total if it keeps failing
|
|
...
|
|
|
|
@pytest.mark.flaky(attempts=3, condition=sys.platform == "win32")
|
|
def test_windows_only_flaky():
|
|
...
|
|
"""
|
|
from _pytest.runner import runtestprotocol
|
|
|
|
# Check if we should enable flaky rerun logic
|
|
should_rerun = False
|
|
attempts = 1
|
|
if flaky_marker := item.get_closest_marker("flaky"):
|
|
condition = flaky_marker.kwargs.get("condition", True)
|
|
if condition:
|
|
should_rerun = True
|
|
attempts = flaky_marker.kwargs.get("attempts", 3)
|
|
|
|
# Check if we should profile this test
|
|
should_profile = _should_profile_test(item.nodeid)
|
|
profiler = cProfile.Profile() if should_profile else None
|
|
|
|
item.execution_count = 0
|
|
need_to_run = True
|
|
total_duration = 0.0
|
|
|
|
while need_to_run:
|
|
item.ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location)
|
|
item.execution_count += 1
|
|
start = time.perf_counter()
|
|
|
|
with profiler or nullcontext():
|
|
reports = runtestprotocol(item, nextitem=nextitem, log=False)
|
|
|
|
total_duration += time.perf_counter() - start
|
|
|
|
for report in reports:
|
|
if (
|
|
should_rerun
|
|
and report.when == "call"
|
|
and report.failed
|
|
and item.execution_count < attempts
|
|
):
|
|
report.outcome = "rerun"
|
|
item.ihook.pytest_runtest_logreport(report=report)
|
|
break
|
|
else:
|
|
item.ihook.pytest_runtest_logreport(report=report)
|
|
else:
|
|
# No rerun needed (passed or exhausted attempts), exit the loop
|
|
need_to_run = False
|
|
|
|
item.ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location)
|
|
|
|
# Store profile results
|
|
if profiler:
|
|
stats = pstats.Stats(profiler)
|
|
_profile_results.append(ProfileResult(nodeid=item.nodeid, stats=stats))
|
|
|
|
_test_results.append(
|
|
TestResult(path=item.path, test_name=item.name, execution_time=total_duration)
|
|
)
|
|
return True # Indicate that we handled this protocol
|
|
|
|
|
|
def pytest_runtest_setup(item):
|
|
markers = [mark.name for mark in item.iter_markers()]
|
|
if "requires_ssh" in markers and not item.config.getoption("--requires-ssh"):
|
|
pytest.skip("use `--requires-ssh` to run this test")
|
|
|
|
|
|
def fetch_pr_labels():
|
|
"""
|
|
Returns the labels associated with the current pull request.
|
|
"""
|
|
if "GITHUB_ACTIONS" not in os.environ:
|
|
return None
|
|
|
|
if os.environ.get("GITHUB_EVENT_NAME") != "pull_request":
|
|
return None
|
|
|
|
with open(os.environ["GITHUB_EVENT_PATH"]) as f:
|
|
pr_data = json.load(f)
|
|
return [label["name"] for label in pr_data["pull_request"]["labels"]]
|
|
|
|
|
|
@pytest.hookimpl(hookwrapper=True)
|
|
def pytest_report_teststatus(report: pytest.TestReport, config: pytest.Config):
|
|
outcome = yield
|
|
|
|
# Handle rerun outcome
|
|
if report.outcome == "rerun":
|
|
outcome.force_result(("rerun", "R", ("RERUN", {"yellow": True})))
|
|
return
|
|
|
|
if report.when == "call":
|
|
if delta := _RESOURCE_USAGE.check():
|
|
_RESOURCE_HEAVY_TESTS[report.nodeid] = delta
|
|
|
|
(*rest, status) = outcome.get_result()
|
|
_RESOURCE_USAGE.snapshot()
|
|
outcome.force_result((*rest, f"{status} | {_RESOURCE_USAGE.format()}"))
|
|
|
|
|
|
@pytest.hookimpl(hookwrapper=True)
|
|
def pytest_ignore_collect(collection_path, config):
|
|
outcome = yield
|
|
if not outcome.get_result() and config.getoption("ignore_flavors"):
|
|
# If not ignored by the default hook and `--ignore-flavors` specified
|
|
|
|
# Ignored files and directories must be included in dev/run-python-flavor-tests.sh
|
|
model_flavors = [
|
|
# Tests of flavor modules.
|
|
"tests/ag2",
|
|
"tests/agno",
|
|
"tests/anthropic",
|
|
"tests/autogen",
|
|
"tests/azureml",
|
|
"tests/bedrock",
|
|
"tests/catboost",
|
|
"tests/crewai",
|
|
"tests/dspy",
|
|
"tests/gemini",
|
|
"tests/groq",
|
|
"tests/h2o",
|
|
"tests/johnsnowlabs",
|
|
"tests/keras",
|
|
"tests/keras_core",
|
|
"tests/llama_index",
|
|
"tests/langchain",
|
|
"tests/langgraph",
|
|
"tests/lightgbm",
|
|
"tests/litellm",
|
|
"tests/mistral",
|
|
"tests/models",
|
|
"tests/onnx",
|
|
"tests/otel",
|
|
"tests/openai",
|
|
"tests/paddle",
|
|
"tests/pmdarima",
|
|
"tests/prophet",
|
|
"tests/pydantic_ai",
|
|
"tests/pyfunc",
|
|
"tests/pytorch",
|
|
"tests/strands",
|
|
"tests/haystack",
|
|
"tests/semantic_kernel",
|
|
"tests/sentence_transformers",
|
|
"tests/shap",
|
|
"tests/sklearn",
|
|
"tests/smolagents",
|
|
"tests/spacy",
|
|
"tests/spark",
|
|
"tests/statsmodels",
|
|
"tests/tensorflow",
|
|
"tests/transformers",
|
|
"tests/xgboost",
|
|
# Lazy loading test.
|
|
"tests/test_mlflow_lazily_imports_ml_packages.py",
|
|
# This test is included here because it imports many big libraries like tf, keras, etc.
|
|
"tests/tracking/fluent/test_fluent_autolog.py",
|
|
# Cross flavor autologging related tests.
|
|
"tests/autologging/test_autologging_safety_unit.py",
|
|
"tests/autologging/test_autologging_behaviors_unit.py",
|
|
"tests/autologging/test_autologging_behaviors_integration.py",
|
|
"tests/autologging/test_autologging_utils.py",
|
|
"tests/autologging/test_training_session.py",
|
|
]
|
|
|
|
relpath = os.path.relpath(str(collection_path))
|
|
relpath = relpath.replace(os.sep, posixpath.sep) # for Windows
|
|
|
|
if relpath in model_flavors:
|
|
outcome.force_result(True)
|
|
|
|
|
|
@pytest.hookimpl(trylast=True)
|
|
def pytest_collection_modifyitems(session, config, items):
|
|
# Executing `tests.server.test_prometheus_exporter` after `tests.server.test_handlers`
|
|
# results in an error because Flask >= 2.2.0 doesn't allow calling setup method such as
|
|
# `before_request` on the application after the first request. To avoid this issue,
|
|
# execute `tests.server.test_prometheus_exporter` first by reordering the test items.
|
|
items.sort(key=lambda item: item.module.__name__ != "tests.server.test_prometheus_exporter")
|
|
|
|
# Select the tests to run based on the group and splits
|
|
if (splits := config.getoption("--splits")) and (group := config.getoption("--group")):
|
|
items[:] = items[(group - 1) :: splits]
|
|
|
|
|
|
@pytest.hookimpl(hookwrapper=True)
|
|
def pytest_terminal_summary(terminalreporter, exitstatus, config):
|
|
yield
|
|
|
|
# Display per-file durations
|
|
if duration_stats := generate_duration_stats():
|
|
terminalreporter.write("\n")
|
|
header = "per-file durations (sorted)"
|
|
terminalreporter.write_sep("=", header)
|
|
terminalreporter.write(f"::group::{header}\n\n")
|
|
terminalreporter.write(duration_stats)
|
|
terminalreporter.write("\n\n::endgroup::\n")
|
|
terminalreporter.write("\n")
|
|
|
|
# Display profile results
|
|
if _profile_results:
|
|
terminalreporter.write("\n")
|
|
header = "profile results"
|
|
terminalreporter.write_sep("=", header)
|
|
terminalreporter.write(f"::group::{header}\n\n")
|
|
|
|
for profile_result in _profile_results:
|
|
terminalreporter.write(f"\nProfile for: {profile_result.nodeid}\n")
|
|
terminalreporter.write("-" * 80 + "\n")
|
|
formatted_stats = _format_profile_stats(profile_result.stats)
|
|
terminalreporter.write(formatted_stats)
|
|
terminalreporter.write("\n")
|
|
|
|
terminalreporter.write("::endgroup::\n")
|
|
terminalreporter.write("\n")
|
|
|
|
if (
|
|
# `uv run` was used to run tests
|
|
"UV" in os.environ
|
|
# Tests failed because of missing dependencies
|
|
and (errors := terminalreporter.stats.get("error"))
|
|
and any(re.search(r"ModuleNotFoundError|ImportError", str(e.longrepr)) for e in errors)
|
|
):
|
|
terminalreporter.write("\n")
|
|
terminalreporter.section("HINTS", yellow=True)
|
|
terminalreporter.write(
|
|
"To run tests with additional packages, use:\n"
|
|
" uv run --with <package> pytest ...\n\n"
|
|
"For multiple packages:\n"
|
|
" uv run --with '<package1>,<package2>' pytest ...\n\n",
|
|
yellow=True,
|
|
)
|
|
|
|
# If there are failed tests, display a command to run them
|
|
if failed_test_reports := terminalreporter.stats.get("failed", []):
|
|
if len(failed_test_reports) <= 30:
|
|
ids = [repr(report.nodeid) for report in failed_test_reports]
|
|
else:
|
|
# Use dict.fromkeys to preserve the order
|
|
ids = list(dict.fromkeys(report.fspath for report in failed_test_reports))
|
|
terminalreporter.section("command to run failed tests")
|
|
terminalreporter.write(" ".join(["pytest"] + ids))
|
|
terminalreporter.write("\n" * 2)
|
|
|
|
# If some tests failed at installing mlflow, we suggest using `--serve-wheel` flag.
|
|
# Some test cases try to install mlflow via pip e.g. model loading. They pins
|
|
# mlflow version to install based on local environment i.e. dev version ahead of
|
|
# the latest release, hence it's not found on PyPI. `--serve-wheel` flag was
|
|
# introduced to resolve this issue, which starts local PyPI server and serve
|
|
# an mlflow wheel based on local source code.
|
|
# Ref: https://github.com/mlflow/mlflow/pull/10247
|
|
msg = f"No matching distribution found for mlflow=={VERSION}"
|
|
for rep in failed_test_reports:
|
|
if any(msg in t for t in (rep.longreprtext, rep.capstdout, rep.capstderr)):
|
|
terminalreporter.section("HINTS", yellow=True)
|
|
terminalreporter.write(
|
|
f"Found test(s) that failed with {msg!r}. Adding"
|
|
" --serve-wheel` flag to your pytest command may help.\n\n",
|
|
yellow=True,
|
|
)
|
|
break
|
|
|
|
# Display resource-heavy tests
|
|
if _RESOURCE_HEAVY_TESTS:
|
|
terminalreporter.section("Resource-heavy tests", yellow=True)
|
|
for test_name, stats in _RESOURCE_HEAVY_TESTS.items():
|
|
terminalreporter.write(f"{test_name}: {stats}\n")
|
|
|
|
main_thread = threading.main_thread()
|
|
if threads := [t for t in threading.enumerate() if t is not main_thread]:
|
|
terminalreporter.section("Remaining threads", yellow=True)
|
|
for idx, thread in enumerate(threads, start=1):
|
|
terminalreporter.write(f"{idx}: {thread}\n")
|
|
|
|
# Uncomment this block to print tracebacks of non-daemon threads
|
|
# if non_daemon_threads := [t for t in threads if not t.daemon]:
|
|
# frames = sys._current_frames()
|
|
# terminalreporter.section("Tracebacks of non-daemon threads", yellow=True)
|
|
# for thread in non_daemon_threads:
|
|
# thread.join(timeout=1)
|
|
# if thread.is_alive() and (frame := frames.get(thread.ident)):
|
|
# terminalreporter.section(repr(thread), sep="~")
|
|
# terminalreporter.write("".join(traceback.format_stack(frame)))
|
|
|
|
try:
|
|
import psutil
|
|
except ImportError:
|
|
pass
|
|
else:
|
|
current_process = psutil.Process()
|
|
if children := current_process.children(recursive=True):
|
|
terminalreporter.section("Remaining child processes", yellow=True)
|
|
for idx, child in enumerate(children, start=1):
|
|
terminalreporter.write(f"{idx}: {child}\n")
|
|
|
|
|
|
# Test fixtures from tests/conftest.py
|
|
|
|
|
|
@pytest.fixture(autouse=IS_TRACING_SDK_ONLY, scope="session")
|
|
def remote_backend_for_tracing_sdk_test():
|
|
"""
|
|
A fixture to start a remote backend for testing mlflow-tracing package integration.
|
|
Since the tracing SDK has to be tested in an environment that has minimal dependencies,
|
|
we need to start a tracking backend in an isolated uv environment.
|
|
"""
|
|
port = get_safe_port()
|
|
# Start a remote backend to test mlflow-tracing package integration.
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
mlflow_root = os.path.dirname(os.path.dirname(__file__))
|
|
with subprocess.Popen(
|
|
[
|
|
"uv",
|
|
"run",
|
|
"--no-dev",
|
|
"--directory",
|
|
# Install from the dev version
|
|
mlflow_root,
|
|
"--with",
|
|
"setuptools<82", # setuptools 82+ removed pkg_resources
|
|
"--with",
|
|
"litellm", # Required for computing cost of LLM calls
|
|
"mlflow",
|
|
"server",
|
|
"--port",
|
|
str(port),
|
|
],
|
|
cwd=temp_dir,
|
|
) as process:
|
|
print("Starting mlflow server on port 5000") # noqa: T201
|
|
try:
|
|
for _ in range(60):
|
|
try:
|
|
response = requests.get(f"http://localhost:{port}")
|
|
if response.ok:
|
|
break
|
|
except requests.ConnectionError:
|
|
print("MLflow server is not responding yet.") # noqa: T201
|
|
time.sleep(1)
|
|
else:
|
|
raise RuntimeError("Failed to start server")
|
|
|
|
mlflow.set_tracking_uri(f"http://localhost:{port}")
|
|
|
|
yield
|
|
|
|
finally:
|
|
process.terminate()
|
|
|
|
|
|
@pytest.fixture(autouse=IS_TRACING_SDK_ONLY)
|
|
def tmp_experiment_for_tracing_sdk_test(monkeypatch):
|
|
# Generate a random experiment name
|
|
experiment_name = f"trace-unit-test-{uuid.uuid4().hex}"
|
|
experiment = mlflow.set_experiment(experiment_name)
|
|
|
|
# Reduce retries for speed up tests
|
|
monkeypatch.setenv("MLFLOW_HTTP_REQUEST_MAX_RETRIES", "1")
|
|
|
|
yield
|
|
|
|
purge_traces(experiment_id=experiment.experiment_id)
|
|
|
|
|
|
@pytest.fixture(autouse=not IS_TRACING_SDK_ONLY)
|
|
def tracking_uri_mock(db_uri: str, request: pytest.FixtureRequest) -> Iterator[str | None]:
|
|
if "notrackingurimock" not in request.keywords:
|
|
with _use_tracking_uri(db_uri):
|
|
yield db_uri
|
|
else:
|
|
yield None
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def disable_workspace_mode_by_default(monkeypatch):
|
|
"""
|
|
Ensure tests default to single-tenant mode regardless of the outer environment.
|
|
Individual tests can still opt in by setting ``MLFLOW_ENABLE_WORKSPACES`` explicitly.
|
|
"""
|
|
|
|
for env_var in (
|
|
MLFLOW_ENABLE_WORKSPACES,
|
|
MLFLOW_WORKSPACE,
|
|
MLFLOW_WORKSPACE_STORE_URI,
|
|
):
|
|
monkeypatch.delenv(env_var.name, raising=False)
|
|
|
|
if workspace_context is not None:
|
|
workspace_context.clear_server_request_workspace()
|
|
|
|
if workspace_utils is not None:
|
|
workspace_utils.set_workspace_store_uri(None)
|
|
|
|
yield
|
|
|
|
# Clear env vars at teardown to prevent leaking to subprocess servers.
|
|
# monkeypatch only tracks changes made through itself, so direct os.environ
|
|
# modifications (or those made by other code) would otherwise persist.
|
|
for env_var in (
|
|
MLFLOW_ENABLE_WORKSPACES,
|
|
MLFLOW_WORKSPACE,
|
|
MLFLOW_WORKSPACE_STORE_URI,
|
|
):
|
|
os.environ.pop(env_var.name, None)
|
|
|
|
if workspace_context is not None:
|
|
workspace_context.clear_server_request_workspace()
|
|
|
|
if workspace_utils is not None:
|
|
workspace_utils.set_workspace_store_uri(None)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_active_experiment_id():
|
|
yield
|
|
mlflow.tracking.fluent._active_experiment_id = None
|
|
os.environ.pop("MLFLOW_EXPERIMENT_ID", None)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_mlflow_uri():
|
|
yield
|
|
# Resetting these environment variables cause sqlalchemy store tests to run with a sqlite
|
|
# database instead of mysql/postgresql/mssql.
|
|
if "DISABLE_RESET_MLFLOW_URI_FIXTURE" not in os.environ:
|
|
os.environ.pop("MLFLOW_TRACKING_URI", None)
|
|
os.environ.pop("MLFLOW_REGISTRY_URI", None)
|
|
try:
|
|
from mlflow.tracking import set_registry_uri
|
|
|
|
# clean up the registry URI to avoid side effects
|
|
set_registry_uri(None)
|
|
except ImportError:
|
|
# tracing sdk does not have the registry module
|
|
pass
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_tracing():
|
|
"""
|
|
Reset the global state of the tracing feature.
|
|
|
|
This fixture is auto-applied for cleaning up the global state between tests
|
|
to avoid side effects.
|
|
"""
|
|
yield
|
|
|
|
# Reset OpenTelemetry and MLflow tracer setup
|
|
mlflow.tracing.reset()
|
|
|
|
# Clear other global state and singletons
|
|
_set_last_active_trace_id(None)
|
|
_TRACE_BUFFER.clear()
|
|
InMemoryTraceManager.reset()
|
|
IPythonTraceDisplayHandler._instance = None
|
|
|
|
# Reset opentelemetry tracer provider as well
|
|
trace_api._TRACER_PROVIDER_SET_ONCE._done = False
|
|
trace_api._TRACER_PROVIDER = None
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def disable_async_trace_logging(monkeypatch):
|
|
"""Disable async trace logging for all tests by default to avoid timing issues.
|
|
|
|
Tests that explicitly verify async behaviour should use the `async_logging_enabled`
|
|
fixture from tests/tracing/conftest.py, which overrides this setting.
|
|
"""
|
|
monkeypatch.setenv(MLFLOW_ENABLE_ASYNC_TRACE_LOGGING.name, "false")
|
|
|
|
|
|
def _is_span_active():
|
|
span = get_current_otel_span()
|
|
return (span is not None) and not isinstance(span, trace_api.NonRecordingSpan)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def validate_trace_finish():
|
|
"""
|
|
Validate all spans are finished and detached from the context by the end of the each test.
|
|
|
|
Leaked span is critical problem and also hard to find without an explicit check.
|
|
"""
|
|
# When the span is leaked, it causes confusing test failure in the subsequent tests. To avoid
|
|
# this and make the test failure more clear, we fail first here.
|
|
if _is_span_active():
|
|
pytest.skip(reason="A leaked active span is found before starting the test.")
|
|
|
|
yield
|
|
|
|
assert not _is_span_active(), (
|
|
"A span is still active at the end of the test. All spans must be finished "
|
|
"and detached from the context before the test ends. The leaked span context "
|
|
"may cause other subsequent tests to fail."
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True, scope="session")
|
|
def enable_test_mode_by_default_for_autologging_integrations():
|
|
"""
|
|
Run all MLflow tests in autologging test mode, ensuring that errors in autologging patch code
|
|
are raised and detected. For more information about autologging test mode, see the docstring
|
|
for :py:func:`mlflow.utils.autologging_utils._is_testing()`.
|
|
"""
|
|
yield from enable_test_mode()
|
|
|
|
|
|
@pytest.fixture(autouse=not IS_TRACING_SDK_ONLY)
|
|
def clean_up_leaked_runs():
|
|
"""
|
|
Certain test cases validate safety API behavior when runs are leaked. Leaked runs that
|
|
are not cleaned up between test cases may result in cascading failures that are hard to
|
|
debug. Accordingly, this fixture attempts to end any active runs it encounters and
|
|
throws an exception (which reported as an additional error in the pytest execution output).
|
|
"""
|
|
try:
|
|
yield
|
|
assert not mlflow.active_run(), (
|
|
"test case unexpectedly leaked a run. Run info: {}. Run data: {}".format(
|
|
mlflow.active_run().info, mlflow.active_run().data
|
|
)
|
|
)
|
|
finally:
|
|
while mlflow.active_run():
|
|
mlflow.end_run()
|
|
|
|
|
|
def _called_in_save_model():
|
|
for frame in inspect.stack()[::-1]:
|
|
if frame.function == "save_model":
|
|
return True
|
|
return False
|
|
|
|
|
|
@pytest.fixture(autouse=not IS_TRACING_SDK_ONLY)
|
|
def prevent_infer_pip_requirements_fallback(request):
|
|
"""
|
|
Prevents `mlflow.models.infer_pip_requirements` from falling back in `mlflow.*.save_model`
|
|
unless explicitly disabled via `pytest.mark.allow_infer_pip_requirements_fallback`.
|
|
"""
|
|
from mlflow.utils.environment import _INFER_PIP_REQUIREMENTS_GENERAL_ERROR_MESSAGE
|
|
|
|
def new_exception(msg, *_, **__):
|
|
if msg == _INFER_PIP_REQUIREMENTS_GENERAL_ERROR_MESSAGE and _called_in_save_model():
|
|
raise Exception(
|
|
"`mlflow.models.infer_pip_requirements` should not fall back in"
|
|
"`mlflow.*.save_model` during test"
|
|
)
|
|
|
|
if "allow_infer_pip_requirements_fallback" not in request.keywords:
|
|
with mock.patch("mlflow.utils.environment._logger.exception", new=new_exception):
|
|
yield
|
|
else:
|
|
yield
|
|
|
|
|
|
def _log_rmtree_error(func, path, exc_info):
|
|
_logger.warning("Failed to remove %s: %s", path, exc_info[1])
|
|
|
|
|
|
@pytest.fixture(autouse=not IS_TRACING_SDK_ONLY)
|
|
def clean_up_mlruns_directory(request):
|
|
"""
|
|
Clean up an `mlruns` directory on each test module teardown on CI to save the disk space.
|
|
"""
|
|
yield
|
|
|
|
# Only run this fixture on CI.
|
|
if "GITHUB_ACTIONS" not in os.environ:
|
|
return
|
|
|
|
mlruns_dir = os.path.join(request.config.rootpath, "mlruns")
|
|
if os.path.exists(mlruns_dir):
|
|
shutil.rmtree(mlruns_dir, onerror=_log_rmtree_error)
|
|
# In Docker, files may be owned by root. Try sudo as a fallback.
|
|
if not is_windows() and os.path.exists(mlruns_dir):
|
|
subprocess.run(["sudo", "rm", "-rf", mlruns_dir], check=False)
|
|
|
|
|
|
@pytest.fixture(autouse=not IS_TRACING_SDK_ONLY)
|
|
def clean_up_last_logged_model_id():
|
|
"""
|
|
Clean up the last logged model ID stored in a thread local var.
|
|
"""
|
|
_reset_last_logged_model_id()
|
|
|
|
|
|
@pytest.fixture(autouse=not IS_TRACING_SDK_ONLY)
|
|
def clean_up_last_active_run():
|
|
_last_active_run_id.set(None)
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=not IS_TRACING_SDK_ONLY)
|
|
def clean_up_envs():
|
|
"""
|
|
Clean up virtualenvs and conda environments created during tests to save disk space.
|
|
"""
|
|
yield
|
|
|
|
if "GITHUB_ACTIONS" in os.environ:
|
|
from mlflow.utils.virtualenv import _get_mlflow_virtualenv_root
|
|
|
|
shutil.rmtree(_get_mlflow_virtualenv_root(), ignore_errors=True)
|
|
if not is_windows():
|
|
conda_info = json.loads(subprocess.check_output(["conda", "info", "--json"], text=True))
|
|
root_prefix = conda_info["root_prefix"]
|
|
regex = re.compile(r"mlflow-\w{32,}")
|
|
for env in conda_info["envs"]:
|
|
if env == root_prefix:
|
|
continue
|
|
if regex.fullmatch(os.path.basename(env)):
|
|
shutil.rmtree(env, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def enable_mlflow_testing():
|
|
with pytest.MonkeyPatch.context() as mp:
|
|
mp.setenv(_MLFLOW_TESTING.name, "TRUE")
|
|
yield
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def _mock_databricks_host_metadata():
|
|
"""Prevent databricks-sdk from fetching host metadata during the test session.
|
|
|
|
databricks-sdk 0.101.0+ fetches /.well-known/databricks-config during
|
|
WorkspaceClient initialization, which causes timeouts with dummy hosts.
|
|
https://github.com/databricks/databricks-sdk-py/pull/1331
|
|
"""
|
|
with mock.patch("databricks.sdk.config.Config._resolve_host_metadata"):
|
|
yield
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def disable_uv_auto_detect():
|
|
with pytest.MonkeyPatch.context() as mp:
|
|
mp.setenv("MLFLOW_UV_AUTO_DETECT", "false")
|
|
yield
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=not IS_TRACING_SDK_ONLY)
|
|
def serve_wheel(request, tmp_path_factory):
|
|
"""
|
|
Models logged during tests have a dependency on the dev version of MLflow built from
|
|
source (e.g., mlflow==1.20.0.dev0) and cannot be served because the dev version is not
|
|
available on PyPI. This fixture serves a wheel for the dev version from a temporary
|
|
PEP 700-compliant Simple Repository running on localhost and appends the repository URL
|
|
to the `PIP_EXTRA_INDEX_URL` environment variable to make the wheel available to pip.
|
|
|
|
The server provides upload-time metadata so that uv's ``exclude-newer`` can correctly
|
|
resolve the local dev wheel.
|
|
"""
|
|
from tests.helper_functions import get_safe_port
|
|
from tests.simple_repository_server import SimpleRepositoryServer
|
|
|
|
if "COPILOT_AGENT_ACTION" in os.environ:
|
|
yield # pytest expects a generator fixture to yield
|
|
return
|
|
|
|
if not request.config.getoption("--serve-wheel"):
|
|
yield # pytest expects a generator fixture to yield
|
|
return
|
|
|
|
root = tmp_path_factory.mktemp("root")
|
|
mlflow_dir = root.joinpath("mlflow")
|
|
mlflow_dir.mkdir()
|
|
port = get_safe_port()
|
|
try:
|
|
repo_root = subprocess.check_output(
|
|
[
|
|
"git",
|
|
"rev-parse",
|
|
"--show-toplevel",
|
|
],
|
|
text=True,
|
|
).strip()
|
|
except subprocess.CalledProcessError:
|
|
# Some tests run in a Docker container where git is not installed.
|
|
# In this case, assume we're in the root of the repo.
|
|
repo_root = "."
|
|
|
|
subprocess.check_call(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"pip",
|
|
"wheel",
|
|
"--wheel-dir",
|
|
mlflow_dir,
|
|
"--no-deps",
|
|
repo_root,
|
|
],
|
|
)
|
|
with SimpleRepositoryServer(mlflow_dir, port) as server:
|
|
index_url = (
|
|
f"{url} {server.url}" if (url := os.environ.get("PIP_EXTRA_INDEX_URL")) else server.url
|
|
)
|
|
os.environ["PIP_EXTRA_INDEX_URL"] = index_url
|
|
# Set the `UV_INDEX` environment variable to allow fetching the wheel from the
|
|
# url when using `uv` as environment manager
|
|
os.environ["UV_INDEX"] = f"mlflow={server.url}"
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_s3_bucket():
|
|
"""
|
|
Creates a mock S3 bucket using moto
|
|
|
|
Returns:
|
|
The name of the mock bucket.
|
|
"""
|
|
import boto3
|
|
import moto
|
|
|
|
with moto.mock_s3():
|
|
bucket_name = "mock-bucket"
|
|
s3_client = boto3.client("s3")
|
|
s3_client.create_bucket(Bucket=bucket_name)
|
|
yield bucket_name
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_sqlite_uri(tmp_path):
|
|
path = tmp_path.joinpath("mlflow.db").as_uri()
|
|
return ("sqlite://" if is_windows() else "sqlite:////") + path[len("file://") :]
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_databricks_serving_with_tracing_env(monkeypatch):
|
|
monkeypatch.setenv("IS_IN_DB_MODEL_SERVING_ENV", "true")
|
|
monkeypatch.setenv("ENABLE_MLFLOW_TRACING", "true")
|
|
|
|
|
|
@pytest.fixture(params=[True, False])
|
|
def mock_is_in_databricks(request):
|
|
with mock.patch(
|
|
"mlflow.models.model.is_in_databricks_runtime", return_value=request.param
|
|
) as mock_databricks:
|
|
yield mock_databricks
|
|
|
|
|
|
@pytest.fixture(autouse=not IS_TRACING_SDK_ONLY)
|
|
def reset_active_model_context():
|
|
yield
|
|
clear_active_model()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_up_telemetry_threads():
|
|
yield
|
|
if client := get_telemetry_client():
|
|
client._clean_up()
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def cached_db(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
"""
|
|
Creates and caches a SQLite database to avoid repeated migrations for each test run.
|
|
|
|
This is a session-scoped fixture that creates the database once per test session.
|
|
Individual tests should copy this database to their own tmp_path to avoid conflicts.
|
|
"""
|
|
tmp_dir = tmp_path_factory.mktemp("sqlite_db")
|
|
db_path = tmp_dir / "mlflow.db"
|
|
|
|
if IS_TRACING_SDK_ONLY:
|
|
return db_path
|
|
|
|
try:
|
|
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
|
|
except ImportError:
|
|
return db_path
|
|
|
|
db_uri = f"sqlite:///{db_path}"
|
|
artifact_uri = (tmp_dir / "artifacts").as_uri()
|
|
store = SqlAlchemyStore(db_uri, artifact_uri)
|
|
store.engine.dispose()
|
|
|
|
return db_path
|
|
|
|
|
|
@pytest.fixture
|
|
def db_uri(cached_db: Path) -> Iterator[str]:
|
|
"""Returns a fresh SQLite URI for each test by copying the cached database."""
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_dir:
|
|
db_path = Path(tmp_dir) / "mlflow.db"
|
|
|
|
if not IS_TRACING_SDK_ONLY and cached_db.exists():
|
|
shutil.copy2(cached_db, db_path)
|
|
|
|
yield f"sqlite:///{db_path}"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def monkeypatch_module():
|
|
with pytest.MonkeyPatch.context() as mp:
|
|
yield mp
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_engine_map():
|
|
"""
|
|
Clear the SQLAlchemy engine cache in all stores between tests.
|
|
|
|
Each SQLAlchemy store caches engines by database URI to prevent connection pool leaks.
|
|
This fixture clears the cache between tests to ensure test isolation and prevent
|
|
engines from one test affecting another.
|
|
"""
|
|
try:
|
|
from mlflow.store.jobs.sqlalchemy_store import SqlAlchemyJobStore
|
|
from mlflow.store.model_registry.sqlalchemy_store import (
|
|
SqlAlchemyStore as ModelRegistrySqlAlchemyStore,
|
|
)
|
|
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
|
|
|
|
for store_class in [
|
|
SqlAlchemyStore,
|
|
ModelRegistrySqlAlchemyStore,
|
|
SqlAlchemyJobStore,
|
|
]:
|
|
with store_class._engine_map_lock:
|
|
while store_class._engine_map:
|
|
_, engine = store_class._engine_map.popitem()
|
|
engine.dispose()
|
|
except ImportError:
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_litellm_cost():
|
|
"""
|
|
Mock litellm.cost_per_token to calculate cost based on token counts.
|
|
|
|
Uses cost of 1.0 per input token and 2.0 per output token.
|
|
Returns (input_cost, output_cost) based on the token counts passed.
|
|
"""
|
|
try:
|
|
import litellm # noqa: F401
|
|
except ImportError:
|
|
# mock.patch will fail if litellm is not installed, e.g. tracing SDK test
|
|
yield None
|
|
return
|
|
|
|
def calculate_cost(model, prompt_tokens, completion_tokens, **kwargs):
|
|
input_cost = prompt_tokens * 1.0
|
|
output_cost = completion_tokens * 2.0
|
|
return (input_cost, output_cost)
|
|
|
|
with mock.patch("litellm.cost_per_token", side_effect=calculate_cost, create=True) as mock_cost:
|
|
yield mock_cost
|