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

854 lines
27 KiB
Python

import functools
import json
import logging
import numbers
import os
import random
import signal
import socket
import subprocess
import sys
import tempfile
import time
import uuid
from contextlib import contextmanager
from functools import wraps
from pathlib import Path
from typing import Iterator
from unittest import mock
import pytest
import requests
import mlflow
from mlflow.entities.logged_model import LoggedModel
from mlflow.tracking._model_registry import DEFAULT_AWAIT_MAX_SLEEP_SECONDS
from mlflow.tracking.artifact_utils import _download_artifact_from_uri
from mlflow.utils.os import is_windows
AWS_METADATA_IP = "169.254.169.254" # Used to fetch AWS Instance and User metadata.
def kill_process_tree(pid: int) -> None:
"""
Gracefully terminate or kill a process tree (children first, then parent).
"""
import psutil
try:
parent = psutil.Process(pid)
except psutil.NoSuchProcess:
return
# Kill children first to prevent the parent from spawning new processes
children = parent.children(recursive=True)
for child in children:
try:
child.terminate()
except psutil.NoSuchProcess:
pass
# Wait for children to terminate, then force-kill any that remain
_, still_alive = psutil.wait_procs(children, timeout=5)
for p in still_alive:
try:
p.kill()
except psutil.NoSuchProcess:
pass
# Finally, kill the parent
try:
parent.terminate()
parent.wait(timeout=5)
except psutil.NoSuchProcess:
pass
except psutil.TimeoutExpired:
parent.kill()
LOCALHOST = "127.0.0.1"
PROTOBUF_REQUIREMENT = "protobuf<4.0.0"
_logger = logging.getLogger(__name__)
def get_safe_port():
"""Returns an ephemeral port that is very likely to be free to bind to."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind((LOCALHOST, 0))
port = sock.getsockname()[1]
sock.close()
return port
def random_int(lo=1, hi=1e10):
return random.randint(int(lo), int(hi))
def random_str(size=12):
msg = (
"UUID4 generated strings have a high potential for collision at small sizes. "
"10 is set as the lower bounds for random string generation to prevent non-deterministic "
"test failures."
)
assert size >= 10, msg
return uuid.uuid4().hex[:size]
def random_file(ext):
return f"temp_test_{random_int()}.{ext}"
def expect_status_code(http_response, expected_code):
assert http_response.status_code == expected_code, (
f"Unexpected status code. {http_response.status_code} != {expected_code}, "
f"body: {http_response.text}"
)
def score_model_in_sagemaker_docker_container(
model_uri,
data,
content_type,
flavor="python_function",
activity_polling_timeout_seconds=500,
):
"""
Args:
model_uri: URI to the model to be served.
data: The data to send to the docker container for testing. This is either a
Pandas dataframe or string of the format specified by `content_type`.
content_type: The type of the data to send to the docker container for testing. This is
one of `mlflow.pyfunc.scoring_server.CONTENT_TYPES`.
flavor: Model flavor to be deployed.
activity_polling_timeout_seconds: The amount of time, in seconds, to wait before
declaring the scoring process to have failed.
"""
env = dict(os.environ)
env.update(LC_ALL="en_US.UTF-8", LANG="en_US.UTF-8")
port = get_safe_port()
scoring_cmd = (
f"mlflow deployments run-local -t sagemaker --name test -m {model_uri}"
f" -C image=mlflow-pyfunc -C port={port} --flavor {flavor}"
)
proc = _start_scoring_proc(
cmd=scoring_cmd.split(" "),
env=env,
)
with RestEndpoint(
proc, port, activity_polling_timeout_seconds, validate_version=False
) as endpoint:
return endpoint.invoke(data, content_type)
def pyfunc_generate_dockerfile(output_directory, model_uri=None, extra_args=None, env=None):
"""
Builds a dockerfile for the specified model.
Args:
output_directory: Output directory to generate Dockerfile and model artifacts
model_uri: URI of model, e.g. runs:/some-run-id/run-relative/path/to/model
extra_args: List of extra args to pass to `mlflow models build-docker` command
env: Environment variables to use.
"""
cmd = [
"mlflow",
"models",
"generate-dockerfile",
*(["-m", model_uri] if model_uri else []),
"-d",
output_directory,
]
if mlflow_home := os.environ.get("MLFLOW_HOME"):
cmd += ["--mlflow-home", mlflow_home]
if extra_args:
cmd += extra_args
subprocess.run(cmd, check=True, env=env)
def pyfunc_build_image(model_uri=None, extra_args=None, env=None):
"""
Builds a docker image containing the specified model, returning the name of the image.
Args:
model_uri: URI of model, e.g. runs:/some-run-id/run-relative/path/to/model
extra_args: List of extra args to pass to `mlflow models build-docker` command
env: Environment variables to pass to the subprocess building the image.
"""
name = uuid.uuid4().hex
cmd = [
sys.executable,
"-m",
"mlflow",
"models",
"build-docker",
*(["-m", model_uri] if model_uri else []),
"-n",
name,
]
if mlflow_home := os.environ.get("MLFLOW_HOME"):
cmd += ["--mlflow-home", mlflow_home]
if extra_args:
cmd += extra_args
# Docker image build occasionally fails on GitHub Actions while running `apt-get` due to
# transient network issues. Retry the build a few times as a workaround.
for _ in range(3):
p = subprocess.Popen(cmd, env=env)
if p.wait() == 0:
return name
time.sleep(5)
raise RuntimeError(f"Failed to build docker image to serve model from {model_uri}")
def pyfunc_serve_from_docker_image(image_name, host_port, extra_args=None):
"""
Serves a model from a docker container, exposing it as an endpoint at the specified port
on the host machine. Returns a handle (Popen object) to the server process.
"""
env = dict(os.environ)
env.update(LC_ALL="en_US.UTF-8", LANG="en_US.UTF-8")
scoring_cmd = ["docker", "run", "-p", f"{host_port}:8080", image_name]
if extra_args is not None:
scoring_cmd += extra_args
return _start_scoring_proc(cmd=scoring_cmd, env=env)
def pyfunc_serve_from_docker_image_with_env_override(
image_name, host_port, extra_args=None, extra_docker_run_options=None
):
"""
Serves a model from a docker container, exposing it as an endpoint at the specified port
on the host machine. Returns a handle (Popen object) to the server process.
"""
env = dict(os.environ)
env.update(LC_ALL="en_US.UTF-8", LANG="en_US.UTF-8")
scoring_cmd = [
"docker",
"run",
"-p",
f"{host_port}:8080",
*(extra_docker_run_options or []),
image_name,
]
if extra_args is not None:
scoring_cmd += extra_args
return _start_scoring_proc(cmd=scoring_cmd, env=env)
def pyfunc_serve_and_score_model(
model_uri,
data,
content_type,
activity_polling_timeout_seconds=500,
extra_args=None,
stdout=sys.stdout,
):
with pyfunc_scoring_endpoint(
model_uri,
extra_args=extra_args,
activity_polling_timeout_seconds=activity_polling_timeout_seconds,
stdout=stdout,
) as endpoint:
return endpoint.invoke(data, content_type)
@contextmanager
def pyfunc_scoring_endpoint(
model_uri, activity_polling_timeout_seconds=500, extra_args=None, stdout=sys.stdout
):
"""
Args:
model_uri: URI to the model to be served.
activity_polling_timeout_seconds: The amount of time, in seconds, to wait before
declaring the scoring process to have failed.
extra_args: A list of extra arguments to pass to the pyfunc scoring server command. For
example, passing ``extra_args=["--env-manager", "local"]`` will pass the
``--env-manager local`` flag to the scoring server to ensure that conda
environment activation is skipped.
stdout: The output stream to which standard output is redirected. Defaults to `sys.stdout`.
"""
env = dict(os.environ)
env.update(LC_ALL="en_US.UTF-8", LANG="en_US.UTF-8")
env.update(MLFLOW_TRACKING_URI=mlflow.get_tracking_uri())
env.update(MLFLOW_HOME=_get_mlflow_home())
port = get_safe_port()
scoring_cmd = [
sys.executable,
"-m",
"mlflow",
"models",
"serve",
"-m",
model_uri,
"-p",
str(port),
"--install-mlflow",
] + (extra_args or [])
with _start_scoring_proc(cmd=scoring_cmd, env=env, stdout=stdout, stderr=stdout) as proc:
try:
with RestEndpoint(proc, port, activity_polling_timeout_seconds) as endpoint:
yield endpoint
finally:
proc.terminate()
def _get_mlflow_home():
"""
Returns:
The path to the MLflow installation root directory.
"""
mlflow_module_path = os.path.dirname(os.path.abspath(mlflow.__file__))
# The MLflow root directory is one level about the mlflow module location
return os.path.join(mlflow_module_path, os.pardir)
def _start_scoring_proc(cmd, env, stdout=sys.stdout, stderr=sys.stderr):
if not is_windows():
return subprocess.Popen(
cmd,
stdout=stdout,
stderr=stderr,
text=True,
env=env,
# Assign the scoring process to a process group. All child processes of the
# scoring process will be assigned to this group as well. This allows child
# processes of the scoring process to be terminated successfully
preexec_fn=os.setsid,
)
else:
return subprocess.Popen(
cmd,
stdout=stdout,
stderr=stderr,
text=True,
env=env,
# On Windows, `os.setsid` and `preexec_fn` are unavailable
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
)
class RestEndpoint:
def __init__(self, proc, port, activity_polling_timeout_seconds=60 * 8, validate_version=True):
self._proc = proc
self._port = port
self._activity_polling_timeout_seconds = activity_polling_timeout_seconds
self._validate_version = validate_version
def __enter__(self):
ping_status = None
for i in range(self._activity_polling_timeout_seconds):
assert self._proc.poll() is None, "scoring process died"
time.sleep(1)
# noinspection PyBroadException
try:
ping_status = requests.get(url=f"http://localhost:{self._port}/ping")
_logger.info(f"connection attempt {i} server is up! ping status {ping_status}")
if ping_status.status_code == 200:
break
except Exception:
_logger.info(f"connection attempt {i} failed, server is not up yet")
if ping_status is None or ping_status.status_code != 200:
raise Exception("ping failed, server is not happy")
_logger.info(f"server up, ping status {ping_status}")
if self._validate_version:
resp_status = requests.get(url=f"http://localhost:{self._port}/version")
version = resp_status.text
_logger.info(f"mlflow server version {version}")
if version != mlflow.__version__:
raise Exception("version path is not returning correct mlflow version")
return self
def __exit__(self, tp, val, traceback):
if self._proc.poll() is None:
# Terminate the process group containing the scoring process.
# This will terminate all child processes of the scoring process
if not is_windows():
pgrp = os.getpgid(self._proc.pid)
os.killpg(pgrp, signal.SIGTERM)
else:
# https://stackoverflow.com/questions/47016723/windows-equivalent-for-spawning-and-killing-separate-process-group-in-python-3
self._proc.send_signal(signal.CTRL_BREAK_EVENT)
self._proc.kill()
def invoke(self, data, content_type):
import pandas as pd
from mlflow.pyfunc import scoring_server as pyfunc_scoring_server
if isinstance(data, pd.DataFrame):
if content_type == pyfunc_scoring_server.CONTENT_TYPE_CSV:
data = data.to_csv(index=False)
else:
assert content_type == pyfunc_scoring_server.CONTENT_TYPE_JSON
data = json.dumps({"dataframe_split": data.to_dict(orient="split")})
elif type(data) not in {str, dict}:
data = json.dumps({"instances": data})
return requests.post(
url=f"http://localhost:{self._port}/invocations",
data=data,
headers={"Content-Type": content_type},
)
@pytest.fixture(autouse=True)
def set_boto_credentials(monkeypatch):
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "NotARealAccessKey")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "NotARealSecretAccessKey")
monkeypatch.setenv("AWS_SESSION_TOKEN", "NotARealSessionToken")
def create_mock_response(status_code, text):
"""
Create a mock response object with the status_code and text
Args:
status_code: HTTP status code.
text: Message from the response.
Returns:
Mock HTTP Response.
"""
response = mock.MagicMock()
response.status_code = status_code
response.text = text
return response
def _read_lines(path):
with open(path) as f:
return f.read().splitlines()
def _compare_logged_code_paths(code_path: str, model_uri: str, flavor_name: str) -> None:
from mlflow.utils.model_utils import FLAVOR_CONFIG_CODE, _get_flavor_configuration
model_path = _download_artifact_from_uri(model_uri)
pyfunc_conf = _get_flavor_configuration(
model_path=model_path, flavor_name=mlflow.pyfunc.FLAVOR_NAME
)
flavor_conf = _get_flavor_configuration(model_path, flavor_name=flavor_name)
assert pyfunc_conf[mlflow.pyfunc.CODE] == flavor_conf[FLAVOR_CONFIG_CODE]
saved_code_path = os.path.join(model_path, pyfunc_conf[mlflow.pyfunc.CODE])
assert os.path.exists(saved_code_path)
with open(os.path.join(saved_code_path, os.path.basename(code_path))) as f1:
with open(code_path) as f2:
assert f1.read() == f2.read()
def _compare_conda_env_requirements(env_path, req_path):
from mlflow.utils.environment import _get_pip_deps
from mlflow.utils.yaml_utils import read_yaml
assert os.path.exists(req_path)
env_root, env_path = os.path.split(env_path)
custom_env_parsed = read_yaml(env_root, env_path)
requirements = _read_lines(req_path)
assert _get_pip_deps(custom_env_parsed) == requirements
def _get_deps_from_requirement_file(model_uri):
"""
Returns a list of pip dependencies for the model at `model_uri` and truncate the version number.
"""
from mlflow.utils.environment import _REQUIREMENTS_FILE_NAME
local_path = _download_artifact_from_uri(model_uri)
pip_packages = _read_lines(os.path.join(local_path, _REQUIREMENTS_FILE_NAME))
return [req.split("==")[0] if "==" in req else req for req in pip_packages]
def assert_register_model_called_with_local_model_path(
register_model_mock, model_uri, registered_model_name
):
register_model_call_args = register_model_mock.call_args
assert register_model_call_args.args == (model_uri, registered_model_name)
assert (
register_model_call_args.kwargs["await_registration_for"] == DEFAULT_AWAIT_MAX_SLEEP_SECONDS
)
local_model_path = register_model_call_args.kwargs["local_model_path"]
assert local_model_path.startswith(tempfile.gettempdir())
def _assert_pip_requirements(model_uri, requirements, constraints=None, strict=False):
"""
Loads the pip requirements (and optionally constraints) from `model_uri` and compares them
to `requirements` (and `constraints`).
If `strict` is True, evaluate `set(requirements) == set(loaded_requirements)`.
Otherwise, evaluate `set(requirements) <= set(loaded_requirements)`.
"""
from mlflow.utils.environment import (
_CONDA_ENV_FILE_NAME,
_CONSTRAINTS_FILE_NAME,
_REQUIREMENTS_FILE_NAME,
_get_pip_deps,
)
from mlflow.utils.yaml_utils import read_yaml
local_path = _download_artifact_from_uri(model_uri)
txt_reqs = _read_lines(os.path.join(local_path, _REQUIREMENTS_FILE_NAME))
conda_reqs = _get_pip_deps(read_yaml(local_path, _CONDA_ENV_FILE_NAME))
compare_func = set.__eq__ if strict else set.__le__
requirements = set(requirements)
assert compare_func(requirements, set(txt_reqs))
assert compare_func(requirements, set(conda_reqs))
if constraints is not None:
assert f"-c {_CONSTRAINTS_FILE_NAME}" in txt_reqs
assert f"-c {_CONSTRAINTS_FILE_NAME}" in conda_reqs
cons = _read_lines(os.path.join(local_path, _CONSTRAINTS_FILE_NAME))
assert compare_func(set(constraints), set(cons))
def _is_available_on_pypi(package, version=None, module=None):
"""
Returns True if the specified package version is available on PyPI.
Args:
package: The name of the package.
version: The version of the package. If None, defaults to the installed version.
module: The name of the top-level module provided by the package. For example,
if `package` is 'scikit-learn', `module` should be 'sklearn'. If None, defaults
to `package`.
"""
from mlflow.utils.requirements_utils import _get_installed_version
url = f"https://pypi.python.org/pypi/{package}/json"
for sec in range(3):
try:
time.sleep(sec)
resp = requests.get(url)
except requests.exceptions.ConnectionError:
continue
if resp.status_code == 404:
return False
if resp.status_code == 200:
break
else:
raise Exception(f"Failed to connect to {url}")
version = version or _get_installed_version(module or package)
dist_files = resp.json()["releases"].get(version)
return (
dist_files is not None # specified version exists
and (len(dist_files) > 0) # at least one distribution file exists
and not dist_files[0].get("yanked", False) # specified version is not yanked
)
def _is_importable(module_name):
try:
__import__(module_name)
return True
except ImportError:
return False
def allow_infer_pip_requirements_fallback_if(condition):
def decorator(f):
return pytest.mark.allow_infer_pip_requirements_fallback(f) if condition else f
return decorator
def mock_method_chain(mock_obj, methods, return_value=None, side_effect=None):
"""
Mock a chain of methods.
Examples
--------
>>> from unittest import mock
>>> m = mock.MagicMock()
>>> mock_method_chain(m, ["a", "b"], return_value=0)
>>> m.a().b()
0
>>> mock_method_chain(m, ["c.d", "e"], return_value=1)
>>> m.c.d().e()
1
>>> mock_method_chain(m, ["f"], side_effect=Exception("side_effect"))
>>> m.f()
Traceback (most recent call last):
...
Exception: side_effect
"""
length = len(methods)
for idx, method in enumerate(methods):
mock_obj = functools.reduce(getattr, method.split("."), mock_obj)
if idx != length - 1:
mock_obj = mock_obj.return_value
else:
mock_obj.return_value = return_value
mock_obj.side_effect = side_effect
class StartsWithMatcher:
def __init__(self, prefix):
self.prefix = prefix
def __eq__(self, other):
return isinstance(other, str) and other.startswith(self.prefix)
class AnyStringWith(str):
def __eq__(self, other):
return self in other
def assert_array_almost_equal(actual_array, desired_array, rtol=1e-6):
import numpy as np
elem0 = actual_array[0]
if isinstance(elem0, numbers.Number) or (
isinstance(elem0, (list, np.ndarray)) and isinstance(elem0[0], numbers.Number)
):
np.testing.assert_allclose(actual_array, desired_array, rtol=rtol)
else:
np.testing.assert_array_equal(actual_array, desired_array)
def _mlflow_major_version_string():
from mlflow.utils.environment import _generate_mlflow_version_pinning
return _generate_mlflow_version_pinning()
@contextmanager
def mock_http_request_200():
with mock.patch(
"mlflow.utils.rest_utils.http_request",
return_value=mock.MagicMock(status_code=200, text="{}"),
) as m:
yield m
def mock_http_200(f):
@functools.wraps(f)
@mock.patch(
"mlflow.utils.rest_utils.http_request",
return_value=mock.MagicMock(status_code=200, text="{}"),
)
def wrapper(*args, **kwargs):
return f(*args, **kwargs)
return wrapper
@contextmanager
def mock_http_request_403_200():
with mock.patch(
"mlflow.utils.rest_utils.http_request",
side_effect=[
mock.MagicMock(status_code=403, text='{"error_code": "ENDPOINT_NOT_FOUND"}'),
mock.MagicMock(status_code=200, text="{}"),
],
) as m:
yield m
def clear_hub_cache():
"""
Frees up disk space for cached huggingface transformers models and components.
This function will remove all files within the cache if the total size of objects exceeds
1 GB on disk. It is used only in CI testing to alleviate the disk burden on the runners as
they have limited allocated space and will terminate if the available disk space drops too low.
"""
try:
from huggingface_hub import scan_cache_dir
full_cache = scan_cache_dir()
cache_size_in_gb = full_cache.size_on_disk / 1000**3
if cache_size_in_gb > 1:
commits_to_purge = [
rev.commit_hash for repo in full_cache.repos for rev in repo.revisions
]
delete_strategy = full_cache.delete_revisions(*commits_to_purge)
delete_strategy.execute()
except ImportError:
# Local import check for mlflow-skinny not including huggingface_hub
pass
except Exception as e:
_logger.warning(f"Failed to clear cache: {e}", exc_info=True)
def flaky(max_tries=3):
"""
Annotation decorator for retrying flaky functions up to max_tries times, and raise the Exception
if it fails after max_tries attempts.
Args:
max_tries: Maximum number of times to retry the function.
Returns:
Decorated function.
"""
def flaky_test_func(test_func):
@wraps(test_func)
def decorated_func(*args, **kwargs):
for i in range(max_tries):
try:
return test_func(*args, **kwargs)
except Exception as e:
_logger.warning(f"Attempt {i + 1} failed with error: {e}")
if i == max_tries - 1:
raise
time.sleep(3)
return decorated_func
return flaky_test_func
@contextmanager
def start_mock_openai_server():
"""
Start a fake service that mimics the OpenAI endpoints such as /chat/completions.
Yields:
The base URL of the mock OpenAI server.
"""
port = get_safe_port()
script_path = Path(__file__).parent / "openai" / "mock_openai.py"
with subprocess.Popen([
sys.executable,
script_path,
"--host",
"localhost",
"--port",
str(port),
]) as proc:
try:
base_url = f"http://localhost:{port}"
for _ in range(10):
try:
resp = requests.get(f"{base_url}/health")
except requests.ConnectionError:
time.sleep(2)
continue
if resp.ok:
break
else:
proc.kill()
proc.wait()
raise RuntimeError("Failed to start mock OpenAI server")
yield base_url
finally:
proc.kill()
def _is_hf_hub_healthy() -> bool:
"""
Check if the Hugging Face Hub is healthy by attempting to load a small dataset.
"""
try:
import datasets
from huggingface_hub import HfApi
except ImportError:
# Cannot import datasets or huggingface_hub, so we assume the hub is healthy.
return True
try:
for dataset in HfApi().list_datasets(filter="size_categories:n<1K", limit=10):
# Gated datasets (e.g., https://huggingface.co/datasets/PatronusAI/TRAIL) require
# authentication to access.
if not dataset.gated:
datasets.load_dataset(dataset.id)
return True
return True
except requests.exceptions.RequestException:
return False
except Exception as e:
_logger.warning(f"Unexpected error while checking Hugging Face Hub health: {e}. ")
# For any other exceptions, we assume the hub is healthy.
return True
def _iter_pr_files() -> Iterator[str]:
if "GITHUB_ACTIONS" not in os.environ:
return
if os.environ.get("GITHUB_EVENT_NAME") != "pull_request":
return
with open(os.environ["GITHUB_EVENT_PATH"]) as f:
pr_data = json.load(f)
pull_number = pr_data["pull_request"]["number"]
repo = pr_data["repository"]["full_name"]
page = 1
per_page = 100
headers = {"Authorization": token} if (token := os.environ.get("GH_TOKEN")) else None
while True:
resp = requests.get(
f"https://api.github.com/repos/{repo}/pulls/{pull_number}/files",
params={"per_page": per_page, "page": page},
headers=headers,
)
try:
resp.raise_for_status()
except requests.exceptions.HTTPError as e:
_logger.warning(
f"Failed to fetch PR files: {e}. Skipping the check for Hugging Face Hub health."
)
return
files = [f["filename"] for f in resp.json()]
yield from files
if len(files) < per_page:
break
page += 1
@functools.lru_cache(maxsize=1)
def _should_skip_hf_test() -> bool:
if "CI" not in os.environ:
# This is not a CI run. Do not skip tests.
return False
if any(("huggingface" in f or "transformers" in f) for f in _iter_pr_files()):
# This PR modifies huggingface-related files. Do not skip tests.
return False
# Skip tests if the Hugging Face Hub is unhealthy.
return not _is_hf_hub_healthy()
def skip_if_hf_hub_unhealthy():
return pytest.mark.skipif(
_should_skip_hf_test(),
reason=(
"Skipping test because Hugging Face Hub is unhealthy. "
"See https://status.huggingface.co/ for more information."
),
)
def get_logged_model_by_name(name: str) -> LoggedModel | None:
"""
Get a logged model by name. If multiple logged models with
the same name exist, get the latest one.
Args:
name: The name of the logged model.
Returns:
The logged model.
"""
logged_models = mlflow.search_logged_models(
filter_string=f"name='{name}'", output_format="list", max_results=1
)
return logged_models[0] if len(logged_models) >= 1 else None