Files
2026-07-13 13:22:34 +08:00

146 lines
4.6 KiB
Python

"""Session state for the ``@mlflow.test`` pytest plugin.
Tracks the single test run per pytest session and which test is currently
executing (for trace tagging).
"""
from __future__ import annotations
import datetime
import logging
import threading
import uuid
import mlflow
_logger = logging.getLogger(__name__)
TAG_TEST_NAME = "mlflow.test.name"
TAG_SESSION_ID = "mlflow.test.session_id"
TAG_CASE_ID = "mlflow.test.case_id"
_lock = threading.Lock()
_session_id: str | None = None
_run_id: str | None = None
_run_owned: bool = False
_any_test_failed: bool = False
_num_tests: int = 0
_total_duration_ms: int = 0
# True once the (opt-in) pytest plugin registered in this process; @mlflow.test
# checks it to fail loudly instead of silently doing nothing.
_plugin_active: bool = False
_current = threading.local()
def set_plugin_active() -> None:
global _plugin_active
_plugin_active = True
def is_plugin_active() -> bool:
return _plugin_active
def set_current_test(test_name: str | None, case_id: str | None = None) -> None:
_current.value = (test_name, case_id)
def current_test() -> tuple[str | None, str | None]:
return getattr(_current, "value", (None, None))
def reset(session_id: str | None = None) -> None:
global _session_id, _run_id, _run_owned, _any_test_failed, _num_tests, _total_duration_ms
if not session_id:
stamp = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
session_id = f"{stamp}-{uuid.uuid4().hex[:6]}"
with _lock:
_session_id = session_id
_run_id = None
_run_owned = False
_any_test_failed = False
_num_tests = 0
_total_duration_ms = 0
def session_id() -> str:
if _session_id is None:
reset()
return _session_id
def record_test(*, failed: bool, duration_ms: int) -> None:
"""Record the outcome and duration of one ``@mlflow.test``-marked test."""
global _any_test_failed, _num_tests, _total_duration_ms
with _lock:
_num_tests += 1
_total_duration_ms += duration_ms
if failed:
_any_test_failed = True
def ensure_run() -> str | None:
"""Open the test run, once per session. Thread-safe.
If a run is already active (e.g. opened by a user fixture), start a nested
child run rather than reusing/retagging the user's run -- we don't know what
that run is for, so we never mutate it.
"""
global _run_id, _run_owned
with _lock:
if _run_id is not None:
return _run_id
# Imported lazily: the plugin loads in every pytest run, so a
# module-level import of a PR-new symbol breaks collection against a
# version-skewed mlflow.
from mlflow.utils.mlflow_tags import MLFLOW_RUN_TYPE, MLFLOW_RUN_TYPE_TEST
tags = {MLFLOW_RUN_TYPE: MLFLOW_RUN_TYPE_TEST, TAG_SESSION_ID: session_id()}
try:
# Nest under a user-opened run rather than reusing/retagging it.
nested = mlflow.active_run() is not None
run = mlflow.start_run(nested=nested, tags=tags)
except Exception as e:
_logger.warning("mlflow.test: could not start test run: %s", e)
return None
_run_id = run.info.run_id
_run_owned = True
return _run_id
def finalize() -> None:
"""Emit the session telemetry event and end the test run.
Telemetry fires whenever a ``@mlflow.test`` ran (even if run creation
failed). The run status reflects only ``@mlflow.test`` outcomes, not other
tests that happen to run in the same pytest session. Runs once on the main
thread at ``pytest_sessionfinish``.
"""
global _run_id, _run_owned
with _lock:
if _num_tests > 0:
# Imported lazily and defensively: this plugin loads in every pytest
# run, so a module-level telemetry import would break collection if
# mlflow.telemetry is unavailable or version-skewed.
try:
from mlflow.telemetry.events import MlflowTestEvent
from mlflow.telemetry.track import _record_event
_record_event(
MlflowTestEvent,
{"num_tests": _num_tests},
duration_ms=_total_duration_ms,
)
except Exception as e:
_logger.debug("mlflow.test: skipped telemetry: %s", e)
if _run_owned and _run_id is not None:
status = "FAILED" if _any_test_failed else "FINISHED"
try:
mlflow.end_run(status=status)
except Exception as e:
_logger.warning("Failed to end run %s: %s", _run_id, e)
_run_id = None
_run_owned = False