444 lines
16 KiB
Python
444 lines
16 KiB
Python
import cgi
|
|
import os
|
|
import pathlib
|
|
import subprocess
|
|
import tempfile
|
|
from contextlib import contextmanager
|
|
from io import BytesIO
|
|
from typing import NamedTuple
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
import mlflow
|
|
from mlflow import MlflowClient
|
|
from mlflow.artifacts import download_artifacts
|
|
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
|
|
from mlflow.utils.os import is_windows
|
|
|
|
from tests.helper_functions import LOCALHOST, get_safe_port, kill_process_tree
|
|
from tests.tracking.integration_test_utils import _await_server_up_or_die
|
|
|
|
|
|
@contextmanager
|
|
def _launch_server(host, port, backend_store_uri, default_artifact_root, artifacts_destination):
|
|
extra_cmd = [] if is_windows() else ["--gunicorn-opts", "--log-level debug"]
|
|
cmd = [
|
|
"mlflow",
|
|
"server",
|
|
"--host",
|
|
host,
|
|
"--port",
|
|
str(port),
|
|
"--backend-store-uri",
|
|
backend_store_uri,
|
|
"--default-artifact-root",
|
|
default_artifact_root,
|
|
"--artifacts-destination",
|
|
artifacts_destination,
|
|
*extra_cmd,
|
|
]
|
|
with subprocess.Popen(cmd) as process:
|
|
try:
|
|
_await_server_up_or_die(port)
|
|
yield process
|
|
finally:
|
|
kill_process_tree(process.pid)
|
|
|
|
|
|
class ArtifactsServer(NamedTuple):
|
|
backend_store_uri: str
|
|
default_artifact_root: str
|
|
artifacts_destination: str
|
|
url: str
|
|
process: subprocess.Popen
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def artifacts_server():
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
port = get_safe_port()
|
|
backend_store_uri = f"sqlite:///{os.path.join(tmpdir, 'mlruns.db')}"
|
|
artifacts_destination = os.path.join(tmpdir, "mlartifacts")
|
|
url = f"http://{LOCALHOST}:{port}"
|
|
default_artifact_root = f"{url}/api/2.0/mlflow-artifacts/artifacts"
|
|
# Initialize the database before launching the server process
|
|
s = SqlAlchemyStore(backend_store_uri, default_artifact_root)
|
|
s.engine.dispose()
|
|
with _launch_server(
|
|
LOCALHOST,
|
|
port,
|
|
backend_store_uri,
|
|
default_artifact_root,
|
|
("file:///" + artifacts_destination if is_windows() else artifacts_destination),
|
|
) as process:
|
|
yield ArtifactsServer(
|
|
backend_store_uri, default_artifact_root, artifacts_destination, url, process
|
|
)
|
|
|
|
|
|
def read_file(path):
|
|
with open(path) as f:
|
|
return f.read()
|
|
|
|
|
|
def upload_file(path, url, headers=None):
|
|
with open(path, "rb") as f:
|
|
requests.put(url, data=f, headers=headers).raise_for_status()
|
|
|
|
|
|
def download_file(url, local_path, headers=None):
|
|
with requests.get(url, stream=True, headers=headers) as r:
|
|
r.raise_for_status()
|
|
assert r.headers["X-Content-Type-Options"] == "nosniff"
|
|
assert "Content-Type" in r.headers
|
|
assert "Content-Disposition" in r.headers
|
|
with open(local_path, "wb") as f:
|
|
for chunk in r.iter_content(chunk_size=8192):
|
|
f.write(chunk)
|
|
return r
|
|
|
|
|
|
def test_mlflow_artifacts_rest_apis(artifacts_server, tmp_path):
|
|
default_artifact_root = artifacts_server.default_artifact_root
|
|
artifacts_destination = artifacts_server.artifacts_destination
|
|
|
|
# Upload artifacts
|
|
file_a = tmp_path.joinpath("a.txt")
|
|
file_a.write_text("0")
|
|
upload_file(file_a, f"{default_artifact_root}/a.txt")
|
|
assert os.path.exists(os.path.join(artifacts_destination, "a.txt"))
|
|
assert read_file(os.path.join(artifacts_destination, "a.txt")) == "0"
|
|
|
|
file_b = tmp_path.joinpath("b.txt")
|
|
file_b.write_text("1")
|
|
upload_file(file_b, f"{default_artifact_root}/dir/b.txt")
|
|
assert os.path.join(artifacts_destination, "dir", "b.txt")
|
|
assert read_file(os.path.join(artifacts_destination, "dir", "b.txt")) == "1"
|
|
|
|
# Download artifacts
|
|
local_dir = tmp_path.joinpath("folder")
|
|
local_dir.mkdir()
|
|
local_path_a = local_dir.joinpath("a.txt")
|
|
download_file(f"{default_artifact_root}/a.txt", local_path_a)
|
|
assert read_file(local_path_a) == "0"
|
|
|
|
local_path_b = local_dir.joinpath("b.txt")
|
|
download_file(f"{default_artifact_root}/dir/b.txt", local_path_b)
|
|
assert read_file(local_path_b) == "1"
|
|
|
|
# List artifacts
|
|
resp = requests.get(default_artifact_root)
|
|
assert resp.json() == {
|
|
"files": [
|
|
{"path": "a.txt", "is_dir": False, "file_size": 1},
|
|
{"path": "dir", "is_dir": True},
|
|
]
|
|
}
|
|
resp = requests.get(default_artifact_root, params={"path": "dir"})
|
|
assert resp.json() == {"files": [{"path": "b.txt", "is_dir": False, "file_size": 1}]}
|
|
|
|
|
|
def test_log_artifact(artifacts_server, tmp_path):
|
|
url = artifacts_server.url
|
|
artifacts_destination = artifacts_server.artifacts_destination
|
|
mlflow.set_tracking_uri(url)
|
|
|
|
tmp_path = tmp_path.joinpath("a.txt")
|
|
tmp_path.write_text("0")
|
|
|
|
with mlflow.start_run() as run:
|
|
mlflow.log_artifact(tmp_path)
|
|
|
|
experiment_id = "0"
|
|
run_artifact_root = os.path.join(
|
|
artifacts_destination, experiment_id, run.info.run_id, "artifacts"
|
|
)
|
|
dest_path = os.path.join(run_artifact_root, tmp_path.name)
|
|
assert os.path.exists(dest_path)
|
|
assert read_file(dest_path) == "0"
|
|
|
|
with mlflow.start_run() as run:
|
|
mlflow.log_artifact(tmp_path, artifact_path="artifact_path")
|
|
|
|
run_artifact_root = os.path.join(
|
|
artifacts_destination, experiment_id, run.info.run_id, "artifacts"
|
|
)
|
|
dest_path = os.path.join(run_artifact_root, "artifact_path", tmp_path.name)
|
|
assert os.path.exists(dest_path)
|
|
assert read_file(dest_path) == "0"
|
|
|
|
|
|
def test_log_artifacts(artifacts_server, tmp_path):
|
|
url = artifacts_server.url
|
|
mlflow.set_tracking_uri(url)
|
|
|
|
tmp_path.joinpath("a.txt").write_text("0")
|
|
d = tmp_path.joinpath("dir")
|
|
d.mkdir()
|
|
d.joinpath("b.txt").write_text("1")
|
|
|
|
with mlflow.start_run() as run:
|
|
mlflow.log_artifacts(tmp_path)
|
|
|
|
client = MlflowClient()
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert sorted(artifacts) == ["a.txt", "dir"]
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id, "dir")]
|
|
assert artifacts == ["dir/b.txt"]
|
|
|
|
# With `artifact_path`
|
|
with mlflow.start_run() as run:
|
|
mlflow.log_artifacts(tmp_path, artifact_path="artifact_path")
|
|
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert artifacts == ["artifact_path"]
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id, "artifact_path")]
|
|
assert sorted(artifacts) == ["artifact_path/a.txt", "artifact_path/dir"]
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id, "artifact_path/dir")]
|
|
assert artifacts == ["artifact_path/dir/b.txt"]
|
|
|
|
|
|
def test_list_artifacts(artifacts_server, tmp_path):
|
|
url = artifacts_server.url
|
|
mlflow.set_tracking_uri(url)
|
|
|
|
tmp_path_a = tmp_path.joinpath("a.txt")
|
|
tmp_path_a.write_text("0")
|
|
tmp_path_b = tmp_path.joinpath("b.txt")
|
|
tmp_path_b.write_text("1")
|
|
client = MlflowClient()
|
|
with mlflow.start_run() as run:
|
|
assert client.list_artifacts(run.info.run_id) == []
|
|
mlflow.log_artifact(tmp_path_a)
|
|
mlflow.log_artifact(tmp_path_b, "dir")
|
|
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id)]
|
|
assert sorted(artifacts) == ["a.txt", "dir"]
|
|
artifacts = [a.path for a in client.list_artifacts(run.info.run_id, "dir")]
|
|
assert artifacts == ["dir/b.txt"]
|
|
|
|
|
|
def test_download_artifacts(artifacts_server, tmp_path):
|
|
url = artifacts_server.url
|
|
mlflow.set_tracking_uri(url)
|
|
|
|
tmp_path_a = tmp_path.joinpath("a.txt")
|
|
tmp_path_a.write_text("0")
|
|
tmp_path_b = tmp_path.joinpath("b.txt")
|
|
tmp_path_b.write_text("1")
|
|
with mlflow.start_run() as run:
|
|
mlflow.log_artifact(tmp_path_a)
|
|
mlflow.log_artifact(tmp_path_b, "dir")
|
|
|
|
dest_path = download_artifacts(run_id=run.info.run_id, artifact_path="")
|
|
assert sorted(os.listdir(dest_path)) == ["a.txt", "dir"]
|
|
assert read_file(os.path.join(dest_path, "a.txt")) == "0"
|
|
dest_path = download_artifacts(run_id=run.info.run_id, artifact_path="dir")
|
|
assert os.listdir(dest_path) == ["b.txt"]
|
|
assert read_file(os.path.join(dest_path, "b.txt")) == "1"
|
|
|
|
|
|
def is_github_actions():
|
|
return "GITHUB_ACTIONS" in os.environ
|
|
|
|
|
|
@pytest.mark.skipif(is_windows(), reason="This example doesn't work on Windows")
|
|
def test_mlflow_artifacts_example(tmp_path):
|
|
root = pathlib.Path(mlflow.__file__).parents[1]
|
|
# On GitHub Actions, remove generated images to save disk space
|
|
rmi_option = "--rmi all" if is_github_actions() else ""
|
|
cmd = f"""
|
|
err=0
|
|
trap 'err=1' ERR
|
|
./build.sh
|
|
docker compose run -v ${{PWD}}/example.py:/app/example.py client python example.py
|
|
docker compose logs
|
|
docker compose down {rmi_option} --volumes --remove-orphans
|
|
test $err = 0
|
|
"""
|
|
script_path = tmp_path.joinpath("test.sh")
|
|
script_path.write_text(cmd)
|
|
subprocess.run(
|
|
["bash", script_path],
|
|
check=True,
|
|
cwd=os.path.join(root, "examples", "mlflow_artifacts"),
|
|
)
|
|
|
|
|
|
def test_rest_tracking_api_list_artifacts_with_proxied_artifacts(artifacts_server, tmp_path):
|
|
def list_artifacts_via_rest_api(url, run_id, path=None):
|
|
if path:
|
|
resp = requests.get(url, params={"run_id": run_id, "path": path})
|
|
else:
|
|
resp = requests.get(url, params={"run_id": run_id})
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
url = artifacts_server.url
|
|
mlflow.set_tracking_uri(url)
|
|
api = f"{url}/api/2.0/mlflow/artifacts/list"
|
|
|
|
tmp_path_a = tmp_path.joinpath("a.txt")
|
|
tmp_path_a.write_text("0")
|
|
tmp_path_b = tmp_path.joinpath("b.txt")
|
|
tmp_path_b.write_text("1")
|
|
mlflow.set_experiment("rest_list_api_test")
|
|
with mlflow.start_run() as run:
|
|
mlflow.log_artifact(tmp_path_a)
|
|
mlflow.log_artifact(tmp_path_b, "dir")
|
|
|
|
list_artifacts_response = list_artifacts_via_rest_api(url=api, run_id=run.info.run_id)
|
|
assert list_artifacts_response.get("files") == [
|
|
{"path": "a.txt", "is_dir": False, "file_size": 1},
|
|
{"path": "dir", "is_dir": True},
|
|
]
|
|
assert list_artifacts_response.get("root_uri") == run.info.artifact_uri
|
|
|
|
nested_list_artifacts_response = list_artifacts_via_rest_api(
|
|
url=api, run_id=run.info.run_id, path="dir"
|
|
)
|
|
assert nested_list_artifacts_response.get("files") == [
|
|
{"path": "dir/b.txt", "is_dir": False, "file_size": 1},
|
|
]
|
|
assert list_artifacts_response.get("root_uri") == run.info.artifact_uri
|
|
|
|
|
|
def test_rest_get_artifact_api_proxied_with_artifacts(artifacts_server, tmp_path):
|
|
url = artifacts_server.url
|
|
mlflow.set_tracking_uri(url)
|
|
tmp_path_a = tmp_path.joinpath("a.txt")
|
|
tmp_path_a.write_text("abcdefg")
|
|
|
|
mlflow.set_experiment("rest_get_artifact_api_test")
|
|
with mlflow.start_run() as run:
|
|
mlflow.log_artifact(tmp_path_a)
|
|
|
|
get_artifact_response = requests.get(
|
|
url=f"{url}/get-artifact", params={"run_id": run.info.run_id, "path": "a.txt"}
|
|
)
|
|
get_artifact_response.raise_for_status()
|
|
assert get_artifact_response.text == "abcdefg"
|
|
|
|
|
|
def test_rest_get_model_version_artifact_api_proxied_artifact_root(artifacts_server):
|
|
url = artifacts_server.url
|
|
artifact_file = pathlib.Path(artifacts_server.artifacts_destination, "a.txt")
|
|
artifact_file.parent.mkdir(exist_ok=True, parents=True)
|
|
artifact_file.write_text("abcdefg")
|
|
|
|
name = "GetModelVersionTest"
|
|
mlflow_client = MlflowClient(artifacts_server.backend_store_uri)
|
|
mlflow_client.create_registered_model(name)
|
|
# An artifact root with scheme http, https, or mlflow-artifacts is a proxied artifact root
|
|
mlflow_client.create_model_version(name, "mlflow-artifacts:", 1)
|
|
|
|
get_model_version_artifact_response = requests.get(
|
|
url=f"{url}/model-versions/get-artifact",
|
|
params={"name": name, "version": "1", "path": "a.txt"},
|
|
)
|
|
get_model_version_artifact_response.raise_for_status()
|
|
assert get_model_version_artifact_response.text == "abcdefg"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("filename", "expected_mime_type"),
|
|
[
|
|
("a.txt", "text/plain"),
|
|
("b.pkl", "application/octet-stream"),
|
|
("c.png", "image/png"),
|
|
("d.pdf", "application/pdf"),
|
|
("MLmodel", "text/plain"),
|
|
("mlproject", "text/plain"),
|
|
],
|
|
)
|
|
def test_mime_type_for_download_artifacts_api(
|
|
artifacts_server, tmp_path, filename, expected_mime_type
|
|
):
|
|
default_artifact_root = artifacts_server.default_artifact_root
|
|
url = artifacts_server.url
|
|
test_file = tmp_path.joinpath(filename)
|
|
test_file.touch()
|
|
upload_file(test_file, f"{default_artifact_root}/dir/{filename}")
|
|
download_response = download_file(f"{default_artifact_root}/dir/{filename}", test_file)
|
|
|
|
_, params = cgi.parse_header(download_response.headers["Content-Disposition"])
|
|
assert params["filename"] == filename
|
|
assert download_response.headers["Content-Type"] == expected_mime_type
|
|
|
|
mlflow.set_tracking_uri(url)
|
|
with mlflow.start_run() as run:
|
|
mlflow.log_artifact(test_file)
|
|
artifact_response = requests.get(
|
|
url=f"{url}/get-artifact", params={"run_id": run.info.run_id, "path": filename}
|
|
)
|
|
artifact_response.raise_for_status()
|
|
_, params = cgi.parse_header(artifact_response.headers["Content-Disposition"])
|
|
assert params["filename"] == filename
|
|
assert artifact_response.headers["Content-Type"] == expected_mime_type
|
|
assert artifact_response.headers["X-Content-Type-Options"] == "nosniff"
|
|
|
|
|
|
def test_rest_get_artifact_api_log_image(artifacts_server):
|
|
url = artifacts_server.url
|
|
mlflow.set_tracking_uri(url)
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
image = np.random.randint(0, 256, size=(100, 100, 3), dtype=np.uint8)
|
|
|
|
with mlflow.start_run() as run:
|
|
mlflow.log_image(image, key="dog", step=20, timestamp=100, synchronous=True)
|
|
|
|
artifact_list_response = requests.get(
|
|
url=f"{url}/ajax-api/2.0/mlflow/artifacts/list",
|
|
params={"path": "images", "run_id": run.info.run_id},
|
|
)
|
|
artifact_list_response.raise_for_status()
|
|
|
|
for file in artifact_list_response.json()["files"]:
|
|
path = file["path"]
|
|
get_artifact_response = requests.get(
|
|
url=f"{url}/get-artifact", params={"run_id": run.info.run_id, "path": path}
|
|
)
|
|
get_artifact_response.raise_for_status()
|
|
assert (
|
|
"attachment; filename=dog+step+20+timestamp+100"
|
|
in get_artifact_response.headers["Content-Disposition"]
|
|
)
|
|
if path.endswith("png"):
|
|
loaded_image = np.asarray(
|
|
Image.open(BytesIO(get_artifact_response.content)), dtype=np.uint8
|
|
)
|
|
np.testing.assert_array_equal(loaded_image, image)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("filename", "requested_mime_type", "responded_mime_type"),
|
|
[
|
|
("b.pkl", "text/html", "application/octet-stream"),
|
|
("c.png", "text/html", "image/png"),
|
|
("d.pdf", "text/html", "application/pdf"),
|
|
],
|
|
)
|
|
def test_server_overrides_requested_mime_type(
|
|
artifacts_server, tmp_path, filename, requested_mime_type, responded_mime_type
|
|
):
|
|
default_artifact_root = artifacts_server.default_artifact_root
|
|
test_file = tmp_path.joinpath(filename)
|
|
test_file.touch()
|
|
upload_file(
|
|
test_file,
|
|
f"{default_artifact_root}/dir/{filename}",
|
|
)
|
|
download_response = download_file(
|
|
f"{default_artifact_root}/dir/{filename}",
|
|
test_file,
|
|
headers={"Accept": requested_mime_type},
|
|
)
|
|
|
|
_, params = cgi.parse_header(download_response.headers["Content-Disposition"])
|
|
assert params["filename"] == filename
|
|
assert download_response.headers["Content-Type"] == responded_mime_type
|