244 lines
7.2 KiB
Python
244 lines
7.2 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from mlflow.pytest import session as _session
|
|
from mlflow.tracking import MlflowClient
|
|
from mlflow.utils.mlflow_tags import MLFLOW_RUN_TYPE, MLFLOW_RUN_TYPE_TEST
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end: run pytest in a subprocess (its own session) and verify a single
|
|
# test run is created in the tracking store with the right tags. Marked tests
|
|
# run only here, never in this shared suite, so they don't leak an active run.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_GENERATED_TEST = """
|
|
import mlflow
|
|
from mlflow.pytest import session
|
|
from mlflow.genai.scorers import scorer
|
|
|
|
|
|
@scorer
|
|
def always_pass(*, outputs):
|
|
return True
|
|
|
|
|
|
@mlflow.test
|
|
def test_marked_one():
|
|
# Identity is the full test id; set for the running marked test.
|
|
name, case_id = session.current_test()
|
|
assert name.endswith("::test_marked_one")
|
|
assert case_id is None
|
|
mlflow.genai.evaluate(
|
|
data=[{"inputs": {"text": "hi"}, "outputs": "hi"}],
|
|
scorers=[always_pass],
|
|
)
|
|
|
|
|
|
@mlflow.test()
|
|
def test_marked_called():
|
|
# The called form @mlflow.test() works too.
|
|
mlflow.genai.evaluate(
|
|
data=[{"inputs": {"text": "bye"}, "outputs": "bye"}],
|
|
scorers=[always_pass],
|
|
)
|
|
"""
|
|
|
|
|
|
def _run_pytest(
|
|
tmp_path: Path, file_name: str, *, enable_plugin: bool = True
|
|
) -> tuple[subprocess.CompletedProcess, str]:
|
|
"""Run pytest on ``file_name`` in a subprocess against a sqlite store.
|
|
|
|
The plugin is opt-in (no pytest11 entry point), so it is enabled explicitly
|
|
with ``-p mlflow.pytest.plugin`` unless ``enable_plugin=False``. Returns the
|
|
completed process and the tracking URI so the caller can inspect the runs
|
|
that were actually persisted.
|
|
"""
|
|
tracking_uri = f"sqlite:///{tmp_path / 'mlflow.db'}"
|
|
env = {**os.environ, "MLFLOW_TRACKING_URI": tracking_uri}
|
|
cmd = [sys.executable, "-m", "pytest", file_name, "-p", "no:cacheprovider", "-q"]
|
|
if enable_plugin:
|
|
cmd += ["-p", "mlflow.pytest.plugin"]
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=tmp_path,
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
return result, tracking_uri
|
|
|
|
|
|
def _test_runs(tracking_uri: str):
|
|
client = MlflowClient(tracking_uri=tracking_uri)
|
|
experiment_ids = [e.experiment_id for e in client.search_experiments()]
|
|
runs = client.search_runs(experiment_ids=experiment_ids) if experiment_ids else []
|
|
return [r for r in runs if r.data.tags.get(MLFLOW_RUN_TYPE) == MLFLOW_RUN_TYPE_TEST]
|
|
|
|
|
|
def test_pytest_run_creates_single_mlflow_run(tmp_path: Path):
|
|
test_file = tmp_path / "test_generated.py"
|
|
test_file.write_text(_GENERATED_TEST)
|
|
|
|
result, tracking_uri = _run_pytest(tmp_path, test_file.name)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
|
|
# Two @mlflow.test cases share one session-scoped run, not one run each.
|
|
test_runs = _test_runs(tracking_uri)
|
|
assert len(test_runs) == 1
|
|
assert test_runs[0].info.status == "FINISHED"
|
|
assert _session.TAG_SESSION_ID in test_runs[0].data.tags
|
|
|
|
|
|
def test_unmarked_pytest_run_creates_no_test_run(tmp_path: Path):
|
|
test_file = tmp_path / "test_plain.py"
|
|
test_file.write_text("def test_plain():\n assert True\n")
|
|
|
|
result, tracking_uri = _run_pytest(tmp_path, test_file.name)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
|
|
# No @mlflow.test marker -> the plugin never opens a run.
|
|
assert _test_runs(tracking_uri) == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Run status reflects only @mlflow.test outcomes.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_MARKED_FAILS = """
|
|
import mlflow
|
|
from mlflow.genai.scorers import scorer
|
|
|
|
|
|
@scorer
|
|
def always_pass(*, outputs):
|
|
return True
|
|
|
|
|
|
@mlflow.test
|
|
def test_marked_fails():
|
|
mlflow.genai.evaluate(
|
|
data=[{"inputs": {"text": "hi"}, "outputs": "hi"}],
|
|
scorers=[always_pass],
|
|
)
|
|
assert False
|
|
"""
|
|
|
|
_ONLY_UNMARKED_FAILS = """
|
|
import mlflow
|
|
from mlflow.genai.scorers import scorer
|
|
|
|
|
|
@scorer
|
|
def always_pass(*, outputs):
|
|
return True
|
|
|
|
|
|
@mlflow.test
|
|
def test_marked_passes():
|
|
mlflow.genai.evaluate(
|
|
data=[{"inputs": {"text": "hi"}, "outputs": "hi"}],
|
|
scorers=[always_pass],
|
|
)
|
|
|
|
|
|
def test_unmarked_fails():
|
|
assert False
|
|
"""
|
|
|
|
|
|
def test_run_marked_failed_when_a_marked_test_fails(tmp_path: Path):
|
|
test_file = tmp_path / "test_marked_fails.py"
|
|
test_file.write_text(_MARKED_FAILS)
|
|
|
|
result, tracking_uri = _run_pytest(tmp_path, test_file.name)
|
|
assert result.returncode != 0
|
|
|
|
test_runs = _test_runs(tracking_uri)
|
|
assert len(test_runs) == 1
|
|
assert test_runs[0].info.status == "FAILED"
|
|
|
|
|
|
def test_run_finished_when_only_unmarked_test_fails(tmp_path: Path):
|
|
test_file = tmp_path / "test_only_unmarked_fails.py"
|
|
test_file.write_text(_ONLY_UNMARKED_FAILS)
|
|
|
|
result, tracking_uri = _run_pytest(tmp_path, test_file.name)
|
|
assert result.returncode != 0 # the unmarked test failed
|
|
|
|
# Run status reflects only @mlflow.test outcomes -> the marked test passed.
|
|
test_runs = _test_runs(tracking_uri)
|
|
assert len(test_runs) == 1
|
|
assert test_runs[0].info.status == "FINISHED"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parametrized marked test: case_id comes from pytest's callspec id, even when
|
|
# the param value contains brackets.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PARAMETRIZED = """
|
|
import mlflow
|
|
import pytest
|
|
from mlflow.pytest import session
|
|
from mlflow.genai.scorers import scorer
|
|
|
|
|
|
@scorer
|
|
def always_pass(*, outputs):
|
|
return True
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["a", "[", "]"])
|
|
@mlflow.test
|
|
def test_param(value):
|
|
name, case_id = session.current_test()
|
|
assert "::test_param[" in name
|
|
assert case_id == value
|
|
mlflow.genai.evaluate(
|
|
data=[{"inputs": {"text": value}, "outputs": value}],
|
|
scorers=[always_pass],
|
|
)
|
|
"""
|
|
|
|
|
|
def test_parametrized_marked_test_captures_case_id(tmp_path: Path):
|
|
test_file = tmp_path / "test_param.py"
|
|
test_file.write_text(_PARAMETRIZED)
|
|
|
|
result, tracking_uri = _run_pytest(tmp_path, test_file.name)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
|
|
# All three cases share the one session run.
|
|
assert len(_test_runs(tracking_uri)) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The plugin is opt-in: a marked test without it fails loudly with instructions
|
|
# instead of silently running without run/trace management.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_MARKED_MINIMAL = """
|
|
import mlflow
|
|
|
|
|
|
@mlflow.test
|
|
def test_marked():
|
|
pass
|
|
"""
|
|
|
|
|
|
def test_marked_test_fails_loudly_when_plugin_not_enabled(tmp_path: Path):
|
|
test_file = tmp_path / "test_no_plugin.py"
|
|
test_file.write_text(_MARKED_MINIMAL)
|
|
|
|
result, tracking_uri = _run_pytest(tmp_path, test_file.name, enable_plugin=False)
|
|
assert result.returncode != 0
|
|
assert "pytest_plugins" in result.stdout
|
|
assert "mlflow.pytest.plugin" in result.stdout
|
|
assert _test_runs(tracking_uri) == []
|