Files
ray-project--ray/python/ray/serve/tests/unit/test_cli.py
T
2026-07-13 13:17:40 +08:00

195 lines
6.3 KiB
Python

import json
import os
import pathlib
import sys
from tempfile import NamedTemporaryFile
from typing import Dict, Optional
from unittest.mock import Mock, patch
import click
import pytest
import yaml
from click.testing import CliRunner
from ray.serve.schema import ServeApplicationSchema
from ray.serve.scripts import build, convert_args_to_dict, deploy
def test_convert_args_to_dict():
assert convert_args_to_dict(tuple()) == {}
with pytest.raises(
click.ClickException, match="Invalid application argument 'bad_arg'"
):
convert_args_to_dict(("bad_arg",))
with pytest.raises(
click.ClickException, match="Invalid application argument 'bad_arg='"
):
convert_args_to_dict(("bad_arg=",))
assert convert_args_to_dict(("key1=val1", "key2=val2", "key3=nested=val")) == {
"key1": "val1",
"key2": "val2",
"key3": "nested=val",
}
class FakeServeSubmissionClient:
def __init__(self):
self._deployed_config: Optional[Dict] = None
@property
def deployed_config(self) -> Optional[Dict]:
return self._deployed_config
def deploy_applications(self, config: Dict):
self._deployed_config = config
@pytest.fixture
def fake_serve_client() -> FakeServeSubmissionClient:
fake_client = FakeServeSubmissionClient()
with patch(
"ray.serve.scripts.ServeSubmissionClient",
new=Mock(return_value=fake_client),
):
yield fake_client
class TestDeploy:
def test_deploy_basic(self, fake_serve_client):
runner = CliRunner()
result = runner.invoke(deploy, ["my_module:my_app"])
assert result.exit_code == 0, result.output
assert fake_serve_client.deployed_config["applications"] == [
ServeApplicationSchema(
import_path="my_module:my_app",
args={},
runtime_env={},
).model_dump(exclude_unset=True)
]
def test_deploy_with_name(self, fake_serve_client):
runner = CliRunner()
result = runner.invoke(deploy, ["my_module:my_app", "--name", "test-name"])
assert result.exit_code == 0, result.output
assert fake_serve_client.deployed_config["applications"] == [
ServeApplicationSchema(
import_path="my_module:my_app",
name="test-name",
args={},
runtime_env={},
).model_dump(exclude_unset=True)
]
def test_deploy_with_args(self, fake_serve_client):
runner = CliRunner()
result = runner.invoke(deploy, ["my_module:my_app", "arg1=val1", "arg2=val2"])
assert result.exit_code == 0, result.output
assert fake_serve_client.deployed_config["applications"] == [
ServeApplicationSchema(
import_path="my_module:my_app",
args={"arg1": "val1", "arg2": "val2"},
runtime_env={},
).model_dump(exclude_unset=True)
]
@pytest.mark.skipif(sys.platform == "win32", reason="Tempfile not working.")
@pytest.mark.parametrize(
"runtime_env",
[
{"env_vars": {"hi": "123"}},
{
"working_dir": "s3://some_bucket/pkg.zip",
"py_modules": ["s3://some_other_bucket/pkg.zip"],
},
],
)
@pytest.mark.parametrize("use_json", [False, True])
def test_deploy_with_runtime_env(
self, fake_serve_client, runtime_env: Dict, use_json: bool
):
runner = CliRunner()
with NamedTemporaryFile("w") as f:
if use_json:
runtime_env_args = ["--runtime-env-json", json.dumps(runtime_env)]
else:
yaml.dump(runtime_env, f, default_flow_style=False)
runtime_env_args = ["--runtime-env", f.name]
result = runner.invoke(deploy, ["my_module:my_app"] + runtime_env_args)
assert result.exit_code == 0, result.output
assert fake_serve_client.deployed_config["applications"] == [
ServeApplicationSchema(
import_path="my_module:my_app",
args={},
runtime_env=runtime_env,
).model_dump(exclude_unset=True)
]
class TestEnumSerialization:
"""Test that enum representer correctly serializes enums in YAML dumps."""
def test_build_command_with_enum_serialization(self):
"""Test that serve build correctly serializes AggregationFunction enum."""
runner = CliRunner()
with NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
f.write(
"from ray import serve\n"
"from ray.serve.config import AggregationFunction, AutoscalingConfig\n\n"
"@serve.deployment(\n"
" autoscaling_config=AutoscalingConfig(\n"
" min_replicas=1,\n"
" max_replicas=2,\n"
" aggregation_function=AggregationFunction.MEAN,\n"
" )\n"
")\n"
"def test_deployment():\n"
" return 'ok'\n\n"
"app = test_deployment.bind()\n"
)
temp_path = f.name
output_path = None
try:
import_path = f"{pathlib.Path(temp_path).stem}:app"
with NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False
) as output_file:
output_path = output_file.name
result = runner.invoke(
build,
[
import_path,
"--app-dir",
str(pathlib.Path(temp_path).parent),
"--output-path",
output_path,
],
)
assert result.exit_code == 0, result.output
with open(output_path, "r") as f:
config = yaml.safe_load(f)
agg_func = config["applications"][0]["deployments"][0][
"autoscaling_config"
]["aggregation_function"]
assert agg_func == "mean"
assert isinstance(agg_func, str)
finally:
os.unlink(temp_path)
if output_path:
os.unlink(output_path)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))