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

1622 lines
60 KiB
Python

import sys
import warnings
import pytest
from pydantic import ValidationError
import ray
from ray import cloudpickle, serve
from ray._common.utils import import_attr
from ray.serve._private.config import (
DeploymentConfig,
ReplicaConfig,
_proto_to_dict,
)
from ray.serve._private.constants import (
DEFAULT_AUTOSCALING_POLICY_NAME,
DEFAULT_GRPC_PORT,
DEFAULT_ROLLING_UPDATE_PERCENTAGE,
RAY_SERVE_ROUTER_RETRY_BACKOFF_MULTIPLIER,
RAY_SERVE_ROUTER_RETRY_INITIAL_BACKOFF_S,
RAY_SERVE_ROUTER_RETRY_MAX_BACKOFF_S,
)
from ray.serve._private.request_router import PowerOfTwoChoicesRequestRouter
from ray.serve._private.utils import DEFAULT
from ray.serve.autoscaling_policy import default_autoscaling_policy
from ray.serve.config import (
AutoscalingConfig,
ControllerOptions,
DeploymentActorConfig,
GangPlacementStrategy,
GangRuntimeFailurePolicy,
GangSchedulingConfig,
HTTPOptions,
ProxyLocation,
RequestRouterConfig,
gRPCOptions,
)
from ray.serve.generated.serve_pb2 import (
AutoscalingConfig as AutoscalingConfigProto,
DeploymentConfig as DeploymentConfigProto,
DeploymentLanguage,
)
from ray.serve.generated.serve_pb2_grpc import add_UserDefinedServiceServicer_to_server
from ray.serve.schema import (
DeploymentSchema,
HTTPOptionsSchema,
ServeApplicationSchema,
ServeDeploySchema,
)
fake_policy_return_value = 123
def fake_policy():
return fake_policy_return_value
class FakeRequestRouter:
...
@ray.remote
class _TestDummyActor:
"""Used for deployment_actors import path test."""
pass
@ray.remote
class _TestRayActor:
"""Used for deployment_actors proto roundtrip test (needs __ray_actor_class__)."""
def ping(self):
"""Dummy method to verify class is deserialized correctly."""
return "pong"
def test_autoscaling_config_validation():
# Check validation over publicly exposed options
with pytest.raises(ValidationError):
# min_replicas must be nonnegative
AutoscalingConfig(min_replicas=-1)
with pytest.raises(ValidationError):
# max_replicas must be positive
AutoscalingConfig(max_replicas=0)
# target_ongoing_requests must be nonnegative
with pytest.raises(ValidationError):
AutoscalingConfig(target_ongoing_requests=-1)
# max_replicas must be greater than or equal to min_replicas
# In Pydantic v2, ValueError in validators is wrapped in ValidationError
with pytest.raises(ValidationError):
AutoscalingConfig(min_replicas=100, max_replicas=1)
AutoscalingConfig(min_replicas=1, max_replicas=100)
AutoscalingConfig(min_replicas=10, max_replicas=10)
# initial_replicas must be greater than or equal to min_replicas
# In Pydantic v2, ValueError in validators is wrapped in ValidationError
with pytest.raises(ValidationError):
AutoscalingConfig(min_replicas=10, initial_replicas=1)
with pytest.raises(ValidationError):
AutoscalingConfig(min_replicas=10, initial_replicas=1, max_replicas=15)
AutoscalingConfig(min_replicas=5, initial_replicas=10, max_replicas=15)
AutoscalingConfig(min_replicas=5, initial_replicas=5, max_replicas=15)
# initial_replicas must be less than or equal to max_replicas
with pytest.raises(ValidationError):
AutoscalingConfig(initial_replicas=10, max_replicas=8)
with pytest.raises(ValidationError):
AutoscalingConfig(min_replicas=1, initial_replicas=10, max_replicas=8)
AutoscalingConfig(min_replicas=1, initial_replicas=4, max_replicas=5)
AutoscalingConfig(min_replicas=1, initial_replicas=5, max_replicas=5)
# Default values should not raise an error
default_autoscaling_config = AutoscalingConfig()
assert default_autoscaling_config.policy.is_default_policy_function() is True
non_default_autoscaling_config = AutoscalingConfig(
policy={"policy_function": "ray.serve.tests.unit.test_config:fake_policy"}
)
assert non_default_autoscaling_config.policy.is_default_policy_function() is False
# look_back_period_s must be greater than metrics_interval_s
with pytest.warns(FutureWarning):
AutoscalingConfig(look_back_period_s=5.0, metrics_interval_s=10.0)
with pytest.warns(FutureWarning):
AutoscalingConfig(look_back_period_s=10.0, metrics_interval_s=10.0)
AutoscalingConfig(look_back_period_s=30.0, metrics_interval_s=10.0)
AutoscalingConfig(look_back_period_s=20.0, metrics_interval_s=10.0)
def test_autoscaling_config_metrics_interval_s_deprecation_warning() -> None:
"""Test that the metrics_interval_s deprecation warning is raised."""
# Warning is raised if we set metrics_interval_s to a non-default value
with pytest.warns(DeprecationWarning):
AutoscalingConfig(metrics_interval_s=5)
# ... even if the AutoscalingConfig is instantiated implicitly via the @serve.deployment decorator
with pytest.warns(DeprecationWarning):
@serve.deployment(autoscaling_config={"metrics_interval_s": 5})
class Foo:
...
# ... or if it is deserialized from proto as part of a DeploymentConfig (presumably in the Serve Controller)
deployment_config_proto_bytes = DeploymentConfig(
autoscaling_config=AutoscalingConfig(metrics_interval_s=5)
).to_proto_bytes()
with pytest.warns(DeprecationWarning):
DeploymentConfig.from_proto_bytes(deployment_config_proto_bytes)
# Default settings should not raise a warning
with warnings.catch_warnings():
warnings.simplefilter("error")
AutoscalingConfig()
class TestDeploymentConfig:
def test_deployment_config_validation(self):
# Test config ignoring unknown keys (required for forward-compatibility)
DeploymentConfig(new_version_key=-1)
# Test num_replicas validation.
DeploymentConfig(num_replicas=1)
# Pydantic v2 uses different error type names
with pytest.raises(ValidationError, match="int_parsing"):
DeploymentConfig(num_replicas="hello")
with pytest.raises(ValidationError, match="greater_than_equal"):
DeploymentConfig(num_replicas=-1)
# Test dynamic default for max_ongoing_requests.
assert DeploymentConfig().max_ongoing_requests == 5
def test_max_constructor_retry_count_validation(self):
# Test max_constructor_retry_count validation.
DeploymentConfig(max_constructor_retry_count=1)
DeploymentConfig(max_constructor_retry_count=10)
# Pydantic v2 uses different error type names
with pytest.raises(ValidationError, match="int_parsing"):
DeploymentConfig(max_constructor_retry_count="hello")
with pytest.raises(ValidationError, match="greater_than"):
DeploymentConfig(max_constructor_retry_count=-1)
with pytest.raises(ValidationError, match="greater_than"):
DeploymentConfig(max_constructor_retry_count=0)
# Test default value
assert DeploymentConfig().max_constructor_retry_count == 20
def test_deployment_config_update(self):
b = DeploymentConfig(num_replicas=1, max_ongoing_requests=1)
# Test updating a key works.
b.num_replicas = 2
assert b.num_replicas == 2
# Check that not specifying a key doesn't update it.
assert b.max_ongoing_requests == 1
# Check that input is validated.
with pytest.raises(ValidationError):
b.num_replicas = "Hello"
with pytest.raises(ValidationError):
b.num_replicas = -1
def test_from_default(self):
"""Check from_default() method behavior."""
# Valid parameters
dc = DeploymentConfig.from_default(num_replicas=5, is_cross_language=True)
assert dc.num_replicas == 5
assert dc.is_cross_language is True
# Invalid parameters should raise TypeError
with pytest.raises(TypeError):
DeploymentConfig.from_default(num_replicas=5, is_xlang=True)
# Validation should still be performed
with pytest.raises(ValidationError):
DeploymentConfig.from_default(num_replicas="hello world")
def test_from_default_ignore_default(self):
"""Check that from_default() ignores DEFAULT.VALUE kwargs."""
# Valid parameter with DEFAULT.VALUE passed in should be ignored
DeploymentConfig.from_default(num_replicas=DEFAULT.VALUE)
def test_setting_and_getting_request_router_class(self):
"""Check that setting and getting request_router_class works."""
# The request_router_class path is derived from the class's __module__ attribute
request_router_path = (
f"{FakeRequestRouter.__module__}.{FakeRequestRouter.__name__}"
)
# Passing request_router_class as a class.
deployment_config = DeploymentConfig.from_default(
request_router_config=RequestRouterConfig(
request_router_class=FakeRequestRouter
)
)
assert (
deployment_config.request_router_config.request_router_class
== request_router_path
)
assert (
deployment_config.request_router_config.get_request_router_class()
== FakeRequestRouter
)
# Passing request_router_class as an import path.
deployment_config = DeploymentConfig.from_default(
request_router_config=RequestRouterConfig(
request_router_class=request_router_path
)
)
assert (
deployment_config.request_router_config.request_router_class
== request_router_path
)
assert (
deployment_config.request_router_config.get_request_router_class()
== FakeRequestRouter
)
# Not passing request_router_class should
# default to `PowerOfTwoChoicesRequestRouter`.
deployment_config = DeploymentConfig.from_default()
assert (
deployment_config.request_router_config.request_router_class
== "ray.serve._private.request_router:PowerOfTwoChoicesRequestRouter"
)
assert (
deployment_config.request_router_config.get_request_router_class()
== PowerOfTwoChoicesRequestRouter
)
def test_backoff_params_imperative(self):
"""Check that custom backoff params are set via the imperative path."""
custom_initial = 0.1
custom_multiplier = 3.0
custom_max = 2.0
deployment_config = DeploymentConfig.from_default(
request_router_config=RequestRouterConfig(
initial_backoff_s=custom_initial,
backoff_multiplier=custom_multiplier,
max_backoff_s=custom_max,
)
)
assert (
deployment_config.request_router_config.initial_backoff_s == custom_initial
)
assert (
deployment_config.request_router_config.backoff_multiplier
== custom_multiplier
)
assert deployment_config.request_router_config.max_backoff_s == custom_max
def test_backoff_params_defaults_imperative(self):
"""Check that backoff params use defaults when not specified."""
deployment_config = DeploymentConfig.from_default()
assert (
deployment_config.request_router_config.initial_backoff_s
== RAY_SERVE_ROUTER_RETRY_INITIAL_BACKOFF_S
)
assert (
deployment_config.request_router_config.backoff_multiplier
== RAY_SERVE_ROUTER_RETRY_BACKOFF_MULTIPLIER
)
assert (
deployment_config.request_router_config.max_backoff_s
== RAY_SERVE_ROUTER_RETRY_MAX_BACKOFF_S
)
def test_backoff_params_declarative_schema(self):
"""Check that backoff params can be set via the declarative schema."""
schema = DeploymentSchema(
name="test-deployment",
request_router_config=RequestRouterConfig(
initial_backoff_s=0.1,
backoff_multiplier=3.0,
max_backoff_s=2.0,
),
)
assert schema.request_router_config.initial_backoff_s == 0.1
assert schema.request_router_config.backoff_multiplier == 3.0
assert schema.request_router_config.max_backoff_s == 2.0
def test_deployment_actors_config(self):
"""Test deployment_actors config and proto roundtrip."""
@ray.remote
class DummyActor:
pass
actor_config = DeploymentActorConfig(
name="prefix_tree",
actor_class=DummyActor,
init_kwargs={"max_depth": 100},
actor_options={"num_cpus": 0.1},
)
config = DeploymentConfig(
num_replicas=1,
deployment_actors=[actor_config],
)
assert config.deployment_actors is not None
assert len(config.deployment_actors) == 1
assert config.deployment_actors[0].name == "prefix_tree"
assert isinstance(config.deployment_actors[0].actor_class, str)
assert config.deployment_actors[0]._serialized_actor_class
assert (
config.deployment_actors[0].get_actor_class().__ray_actor_class__.__name__
== "DummyActor"
)
assert config.deployment_actors[0].init_kwargs == {"max_depth": 100}
deserialized = DeploymentConfig.from_proto_bytes(config.to_proto_bytes())
assert deserialized.deployment_actors is not None
assert len(deserialized.deployment_actors) == 1
assert deserialized.deployment_actors[0].name == "prefix_tree"
assert isinstance(deserialized.deployment_actors[0].actor_class, str)
assert deserialized.deployment_actors[0]._serialized_actor_class
assert (
deserialized.deployment_actors[0]
.get_actor_class()
.__ray_actor_class__.__name__
== "DummyActor"
)
assert deserialized.deployment_actors[0].init_kwargs == {"max_depth": 100}
def test_deployment_actors_config_duplicate_names_raise(self):
"""Test that duplicate deployment_actor names raise ValueError."""
with pytest.raises(ValueError, match="unique names"):
DeploymentConfig(
num_replicas=1,
deployment_actors=[
DeploymentActorConfig(
name="dup",
actor_class=_TestDummyActor,
init_kwargs={},
),
DeploymentActorConfig(
name="dup",
actor_class=_TestDummyActor,
init_kwargs={},
),
],
)
def test_deployment_actors_config_import_path(self):
"""actor_class stays as string until _serialize_actor_class() is called
(happens in the build task where user code is importable).
"""
actor_config_str = DeploymentActorConfig(
name="actor_from_path",
actor_class="ray.serve.tests.unit.test_config._TestDummyActor",
init_kwargs={"max_depth": 50},
)
assert isinstance(actor_config_str.actor_class, str)
assert not actor_config_str._serialized_actor_class
# Simulate what build_serve_application does
actor_config_str._serialize_actor_class()
assert actor_config_str._serialized_actor_class
config_str = DeploymentConfig(
num_replicas=1,
deployment_actors=[actor_config_str],
)
proto = config_str.to_proto()
assert len(proto.deployment_actors) == 1
assert proto.deployment_actors[0].name == "actor_from_path"
assert proto.deployment_actors[0].actor_class_name != ""
deserialized_str = DeploymentConfig.from_proto_bytes(
config_str.to_proto_bytes()
)
resolved_str = deserialized_str.deployment_actors[0].get_actor_class()
assert (
resolved_str.__ray_actor_class__.__name__
== _TestDummyActor.__ray_actor_class__.__name__
)
def test_proto_roundtrip_preserves_actor_class(self):
"""DeploymentActorConfig survives proto serialization and can
reconstruct the actor class via get_actor_class().
"""
cfg = DeploymentActorConfig(
name="counter",
actor_class=_TestRayActor,
init_kwargs={},
)
dc = DeploymentConfig(num_replicas=1, deployment_actors=[cfg])
deserialized = DeploymentConfig.from_proto_bytes(dc.to_proto_bytes())
actor_cfg = deserialized.deployment_actors[0]
assert actor_cfg._serialized_actor_class
assert isinstance(actor_cfg.actor_class, str)
resolved = actor_cfg.get_actor_class()
assert resolved.__ray_actor_class__.__name__ == "_TestRayActor"
# Verify we can instantiate and invoke methods (class serialized properly)
underlying = resolved.__ray_actor_class__
instance = underlying()
assert instance.ping() == "pong"
class TestReplicaConfig:
def test_basic_validation(self):
class Class:
pass
def function(_):
pass
ReplicaConfig.create(Class)
ReplicaConfig.create(function)
with pytest.raises(TypeError):
ReplicaConfig.create(Class())
def test_ray_actor_options_validation(self):
class Class:
pass
ReplicaConfig.create(
Class,
tuple(),
dict(),
ray_actor_options={
"num_cpus": 1.0,
"num_gpus": 10,
"resources": {"abc": 1.0},
"memory": 1000000.0,
},
)
with pytest.raises(TypeError):
ReplicaConfig.create(Class, ray_actor_options=1.0)
with pytest.raises(TypeError):
ReplicaConfig.create(Class, ray_actor_options=False)
with pytest.raises(TypeError):
ReplicaConfig.create(Class, ray_actor_options={"num_cpus": "hello"})
with pytest.raises(ValueError):
ReplicaConfig.create(Class, ray_actor_options={"num_cpus": -1})
with pytest.raises(TypeError):
ReplicaConfig.create(Class, ray_actor_options={"num_gpus": "hello"})
with pytest.raises(ValueError):
ReplicaConfig.create(Class, ray_actor_options={"num_gpus": -1})
with pytest.raises(TypeError):
ReplicaConfig.create(Class, ray_actor_options={"memory": "hello"})
with pytest.raises(ValueError):
ReplicaConfig.create(Class, ray_actor_options={"memory": -1})
with pytest.raises(TypeError):
ReplicaConfig.create(Class, ray_actor_options={"resources": []})
disallowed_ray_actor_options = {
"max_concurrency",
"max_restarts",
"max_task_retries",
"name",
"namespace",
"lifetime",
"placement_group",
"placement_group_bundle_index",
"placement_group_capture_child_tasks",
"max_pending_calls",
"scheduling_strategy",
"get_if_exists",
"_metadata",
}
for option in disallowed_ray_actor_options:
with pytest.raises(ValueError):
ReplicaConfig.create(Class, ray_actor_options={option: None})
def test_max_replicas_per_node_validation(self):
class Class:
pass
ReplicaConfig.create(
Class,
tuple(),
dict(),
max_replicas_per_node=5,
)
# Invalid type
with pytest.raises(TypeError, match="Get invalid type"):
ReplicaConfig.create(
Class,
tuple(),
dict(),
max_replicas_per_node="1",
)
# Invalid: not in the range of [1, 100]
with pytest.raises(
ValueError,
match=r"Valid values are None or an integer in the range of \[1, 100\]",
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
max_replicas_per_node=0,
)
with pytest.raises(
ValueError,
match=r"Valid values are None or an integer in the range of \[1, 100\]",
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
max_replicas_per_node=110,
)
with pytest.raises(
ValueError,
match=r"Valid values are None or an integer in the range of \[1, 100\]",
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
max_replicas_per_node=-1,
)
def test_placement_group_options_validation(self):
class Class:
pass
# Specify placement_group_bundles without num_cpus or placement_group_strategy.
ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_bundles=[{"CPU": 1.0}],
)
# Specify placement_group_bundles with integer value.
ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_bundles=[{"CPU": 1}],
)
# Specify placement_group_bundles and placement_group_strategy.
ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_bundles=[{"CPU": 1.0}],
placement_group_strategy="STRICT_PACK",
)
# Specify placement_group_bundles and placement_group_strategy and num_cpus.
ReplicaConfig.create(
Class,
tuple(),
dict(),
ray_actor_options={"num_cpus": 1},
placement_group_bundles=[{"CPU": 1.0}],
placement_group_strategy="STRICT_PACK",
)
# Invalid: placement_group_strategy without placement_group_bundles.
with pytest.raises(
ValueError, match="`placement_group_bundles` must also be provided"
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_strategy="PACK",
)
# Invalid: unsupported placement_group_strategy.
with pytest.raises(
ValueError, match="Invalid placement group strategy FAKE_NEWS"
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_bundles=[{"CPU": 1.0}],
placement_group_strategy="FAKE_NEWS",
)
# Invalid: malformed placement_group_bundles.
with pytest.raises(
ValueError,
match=("Bundles must be a non-empty list of resource dictionaries."),
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_bundles=[{"CPU": "1.0"}],
)
# Invalid: invalid placement_group_bundles.
with pytest.raises(
ValueError,
match="cannot be an empty dictionary or resources with only 0",
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
ray_actor_options={"num_cpus": 0, "num_gpus": 0},
placement_group_bundles=[{"CPU": 0, "GPU": 0}],
)
# Invalid: replica actor does not fit in the first bundle (CPU).
with pytest.raises(
ValueError,
match=(
"the resource requirements for the actor must be a "
"subset of the first bundle."
),
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
ray_actor_options={"num_cpus": 1},
placement_group_bundles=[{"CPU": 0.1}],
)
# Invalid: replica actor does not fit in the first bundle (CPU).
with pytest.raises(
ValueError,
match=(
"the resource requirements for the actor must be a "
"subset of the first bundle."
),
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
ray_actor_options={"num_gpus": 1},
placement_group_bundles=[{"CPU": 1.0}],
)
# Invalid: replica actor does not fit in the first bundle (custom resource).
with pytest.raises(
ValueError,
match=(
"the resource requirements for the actor must be a "
"subset of the first bundle."
),
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
ray_actor_options={"resources": {"custom": 1}},
placement_group_bundles=[{"CPU": 1}],
)
def test_mutually_exclusive_max_replicas_per_node_and_placement_group_bundles(self):
class Class:
pass
ReplicaConfig.create(
Class,
tuple(),
dict(),
max_replicas_per_node=5,
)
ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_bundles=[{"CPU": 1.0}],
)
with pytest.raises(
ValueError,
match=(
"Setting max_replicas_per_node is not allowed when "
"placement_group_bundles is provided."
),
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
max_replicas_per_node=5,
placement_group_bundles=[{"CPU": 1.0}],
)
with pytest.raises(
ValueError,
match=(
"Setting max_replicas_per_node is not allowed when "
"placement_group_bundles is provided."
),
):
config = ReplicaConfig.create(Class, tuple(), dict())
config.update(
ray_actor_options={},
max_replicas_per_node=5,
placement_group_bundles=[{"CPU": 1.0}],
)
def test_replica_config_lazy_deserialization(self):
def f():
return "Check this out!"
f_serialized = cloudpickle.dumps(f)
config = ReplicaConfig(
"f", f_serialized, cloudpickle.dumps(()), cloudpickle.dumps({}), {}
)
assert config.serialized_deployment_def == f_serialized
assert config._deployment_def is None
assert config.serialized_init_args == cloudpickle.dumps(tuple())
assert config._init_args is None
assert config.serialized_init_kwargs == cloudpickle.dumps(dict())
assert config._init_kwargs is None
assert isinstance(config.ray_actor_options, dict)
assert isinstance(config.resource_dict, dict)
assert config.deployment_def() == "Check this out!"
assert config.init_args == tuple()
assert config.init_kwargs == dict()
def test_placement_group_bundle_label_selector_validation(self):
class Class:
pass
# Label selector provided without bundles
with pytest.raises(
ValueError,
match="If `placement_group_bundle_label_selector` is provided, `placement_group_bundles` must also be provided.",
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_bundle_label_selector=[{"gpu": "T4"}],
)
# bundle_label_selector list does not match bundles list length
with pytest.raises(
ValueError,
match="The length of `bundle_label_selector` should equal the length of `bundles`",
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_bundles=[{"CPU": 1}, {"CPU": 1}, {"CPU": 1}],
placement_group_bundle_label_selector=[{"gpu": "T4"}, {"gpu": "L4"}],
)
# Valid config - multiple bundles provided for one bundle_label_selector.
config = ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_bundles=[{"CPU": 1}, {"CPU": 1}, {"CPU": 1}],
placement_group_bundle_label_selector=[{"gpu": "T4"}],
)
assert config.placement_group_bundle_label_selector == [
{"gpu": "T4"},
{"gpu": "T4"},
{"gpu": "T4"},
]
# Valid config - multiple bundles and an equal number of bundle label selectors.
config = ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_bundles=[{"CPU": 1}, {"CPU": 1}],
placement_group_bundle_label_selector=[{"gpu": "T4"}, {"gpu": "L4"}],
)
assert config.placement_group_bundle_label_selector == [
{"gpu": "T4"},
{"gpu": "L4"},
]
def test_placement_group_fallback_strategy_validation(self):
class Class:
pass
# Validate that fallback strategy provided without bundles raises error.
with pytest.raises(
ValueError,
match="If `placement_group_fallback_strategy` is provided, `placement_group_bundles` must also be provided.",
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_fallback_strategy=[{"bundles": [{"CPU": 1}]}],
)
# Validate that fallback strategy is a list
with pytest.raises(
TypeError,
match="placement_group_fallback_strategy must be a list of dictionaries.",
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_bundles=[{"CPU": 1}],
placement_group_fallback_strategy="not_a_list",
)
# Fallback strategy list contains non-dict items
with pytest.raises(
TypeError,
match="placement_group_fallback_strategy entry at index 1 must be a dictionary.",
):
ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_bundles=[{"CPU": 1}],
placement_group_fallback_strategy=[
{"bundles": [{"CPU": 1}]},
"invalid_entry",
],
)
# Valid config
config = ReplicaConfig.create(
Class,
tuple(),
dict(),
placement_group_bundles=[{"CPU": 1}],
placement_group_fallback_strategy=[{"bundles": [{"CPU": 1}]}],
)
assert config.placement_group_fallback_strategy == [{"bundles": [{"CPU": 1}]}]
class TestAutoscalingConfig:
def test_target_ongoing_requests(self):
autoscaling_config = AutoscalingConfig()
assert autoscaling_config.get_target_ongoing_requests() == 2
autoscaling_config = AutoscalingConfig(target_ongoing_requests=7)
assert autoscaling_config.get_target_ongoing_requests() == 7
def test_scaling_factor(self):
autoscaling_config = AutoscalingConfig()
assert autoscaling_config.get_upscaling_factor() == 1
assert autoscaling_config.get_downscaling_factor() == 1
autoscaling_config = AutoscalingConfig(smoothing_factor=0.4)
assert autoscaling_config.get_upscaling_factor() == 0.4
assert autoscaling_config.get_downscaling_factor() == 0.4
autoscaling_config = AutoscalingConfig(upscale_smoothing_factor=0.4)
assert autoscaling_config.get_upscaling_factor() == 0.4
assert autoscaling_config.get_downscaling_factor() == 1
autoscaling_config = AutoscalingConfig(downscale_smoothing_factor=0.4)
assert autoscaling_config.get_upscaling_factor() == 1
assert autoscaling_config.get_downscaling_factor() == 0.4
autoscaling_config = AutoscalingConfig(
smoothing_factor=0.4,
upscale_smoothing_factor=0.1,
downscale_smoothing_factor=0.01,
)
assert autoscaling_config.get_upscaling_factor() == 0.1
assert autoscaling_config.get_downscaling_factor() == 0.01
autoscaling_config = AutoscalingConfig(
smoothing_factor=0.4,
upscaling_factor=0.5,
downscaling_factor=0.6,
)
assert autoscaling_config.get_upscaling_factor() == 0.5
assert autoscaling_config.get_downscaling_factor() == 0.6
autoscaling_config = AutoscalingConfig(
smoothing_factor=0.4,
upscale_smoothing_factor=0.1,
downscale_smoothing_factor=0.01,
upscaling_factor=0.5,
downscaling_factor=0.6,
)
assert autoscaling_config.get_upscaling_factor() == 0.5
assert autoscaling_config.get_downscaling_factor() == 0.6
class TestGangSchedulingConfig:
def test_gang_scheduling_config_validation(self):
"""Test GangSchedulingConfig field validation."""
with pytest.raises(ValidationError):
GangSchedulingConfig()
# gang_size must be >= 1
with pytest.raises(ValidationError):
GangSchedulingConfig(gang_size=0)
with pytest.raises(ValidationError):
GangSchedulingConfig(gang_size=-1)
config = GangSchedulingConfig(gang_size=1)
assert config.gang_size == 1
config = GangSchedulingConfig(gang_size=4)
assert config.gang_size == 4
def test_gang_scheduling_config_defaults(self):
"""Test GangSchedulingConfig default values."""
config = GangSchedulingConfig(gang_size=4)
assert config.gang_placement_strategy == GangPlacementStrategy.PACK
assert config.runtime_failure_policy == GangRuntimeFailurePolicy.RESTART_GANG
def test_gang_scheduling_config_custom_values(self):
"""Test GangSchedulingConfig with custom values."""
config = GangSchedulingConfig(
gang_size=8,
gang_placement_strategy=GangPlacementStrategy.SPREAD,
)
assert config.gang_size == 8
assert config.gang_placement_strategy == GangPlacementStrategy.SPREAD
assert config.runtime_failure_policy == GangRuntimeFailurePolicy.RESTART_GANG
def test_gang_placement_strategy_options(self):
"""Test all GangPlacementStrategy options are valid."""
for strategy in GangPlacementStrategy:
config = GangSchedulingConfig(gang_size=4, gang_placement_strategy=strategy)
assert config.gang_placement_strategy == strategy
def test_gang_runtime_failure_policy_options(self):
"""Test all GangRuntimeFailurePolicy options are valid."""
# RESTART_GANG should work.
config = GangSchedulingConfig(
gang_size=4,
runtime_failure_policy=GangRuntimeFailurePolicy.RESTART_GANG,
)
assert config.runtime_failure_policy == GangRuntimeFailurePolicy.RESTART_GANG
# RESTART_REPLICA is not yet implemented.
with pytest.raises(NotImplementedError):
GangSchedulingConfig(
gang_size=4,
runtime_failure_policy=GangRuntimeFailurePolicy.RESTART_REPLICA,
)
def test_gang_scheduling_config_via_decorator_error(self):
"""Test that gang_scheduling_config validation errors are raised."""
with pytest.raises(
ValueError, match="num_replicas.*must be a multiple of gang_size"
):
@serve.deployment(gang_scheduling_config=GangSchedulingConfig(gang_size=4))
def f():
return "test"
def test_gang_scheduling_config_scale_to_zero_rejected(self):
"""Test that min_replicas=0 is rejected with gang_scheduling_config."""
with pytest.raises(
ValueError,
match="Scale to zero isn't supported for gang-scheduled deployments",
):
@serve.deployment(
num_replicas="auto",
gang_scheduling_config=GangSchedulingConfig(gang_size=3),
autoscaling_config={"min_replicas": 0, "max_replicas": 9},
)
def f():
return "test"
def test_gang_scheduling_config_auto_num_replicas(self):
"""Test that num_replicas='auto' is allowed with gang_scheduling_config."""
@serve.deployment(
num_replicas="auto",
gang_scheduling_config=GangSchedulingConfig(gang_size=4),
autoscaling_config={"min_replicas": 4, "max_replicas": 8},
)
def f():
return "test"
assert f._deployment_config.autoscaling_config is not None
assert f._deployment_config.gang_scheduling_config.gang_size == 4
assert f._deployment_config.autoscaling_config.min_replicas == 4
assert f._deployment_config.autoscaling_config.max_replicas == 8
def test_gang_scheduling_config_auto_num_replicas_via_options(self):
"""Test that num_replicas='auto' works via .options() with gang config."""
@serve.deployment(
num_replicas=4,
gang_scheduling_config=GangSchedulingConfig(gang_size=4),
)
def f():
return "test"
f2 = f.options(
num_replicas="auto",
autoscaling_config={"min_replicas": 4, "max_replicas": 8},
)
assert f2._deployment_config.autoscaling_config is not None
assert f._deployment_config.gang_scheduling_config.gang_size == 4
assert f2._deployment_config.autoscaling_config.min_replicas == 4
assert f2._deployment_config.autoscaling_config.max_replicas == 8
def test_gang_scheduling_config_proto_roundtrip(self):
"""Test roundtrip serialization of GangSchedulingConfig through protobuf."""
# Test with gang_scheduling_config
config = DeploymentConfig(
num_replicas=8,
gang_scheduling_config=GangSchedulingConfig(
gang_size=4,
gang_placement_strategy=GangPlacementStrategy.SPREAD,
runtime_failure_policy=GangRuntimeFailurePolicy.RESTART_GANG,
),
)
deserialized = DeploymentConfig.from_proto_bytes(config.to_proto_bytes())
assert deserialized.gang_scheduling_config is not None
assert deserialized.gang_scheduling_config.gang_size == 4
assert (
deserialized.gang_scheduling_config.gang_placement_strategy
== GangPlacementStrategy.SPREAD
)
assert (
deserialized.gang_scheduling_config.runtime_failure_policy
== GangRuntimeFailurePolicy.RESTART_GANG
)
# Test without gang_scheduling_config
config = DeploymentConfig(num_replicas=2)
deserialized = DeploymentConfig.from_proto_bytes(config.to_proto_bytes())
assert deserialized.gang_scheduling_config is None
def test_gang_scheduling_config_via_decorator(self):
"""Test that gang_scheduling_config can be passed via @serve.deployment decorator."""
@serve.deployment(
num_replicas=8, gang_scheduling_config=GangSchedulingConfig(gang_size=4)
)
def f():
return "test"
# Verify the config is properly set
assert f._deployment_config.gang_scheduling_config is not None
assert f._deployment_config.gang_scheduling_config.gang_size == 4
def test_gang_scheduling_config_invalid_num_replicas_via_options(self):
@serve.deployment(
num_replicas=4, gang_scheduling_config=GangSchedulingConfig(gang_size=2)
)
def f():
pass
with pytest.raises(ValueError, match="must be a multiple of gang_size"):
f.options(num_replicas=5)
with pytest.raises(ValueError, match="must be a multiple of gang_size"):
f.options(num_replicas=3)
d = f.options(num_replicas=6)
assert d.num_replicas == 6
def test_gang_scheduling_config_invalid_gang_size_via_options(self):
@serve.deployment(
num_replicas=4, gang_scheduling_config=GangSchedulingConfig(gang_size=2)
)
def f():
pass
with pytest.raises(ValueError, match="must be a multiple of gang_size"):
f.options(gang_scheduling_config=GangSchedulingConfig(gang_size=3))
d = f.options(gang_scheduling_config=GangSchedulingConfig(gang_size=4))
assert d._deployment_config.gang_scheduling_config.gang_size == 4
def test_config_schemas_forward_compatible():
# Test configs ignoring unknown keys (required for forward-compatibility)
ServeDeploySchema(
http_options=HTTPOptionsSchema(
new_version_config_key="this config is from newer version of Ray"
),
applications=[
ServeApplicationSchema(
import_path="module.app",
deployments=[
DeploymentSchema(
name="deployment",
new_version_config_key="this config is from newer version"
" of Ray",
)
],
new_version_config_key="this config is from newer version of Ray",
),
],
new_version_config_key="this config is from newer version of Ray",
)
def test_http_options():
HTTPOptions()
# `middlewares` is removed: a non-empty list raises, but an empty list is
# a no-op (matches the prior warn-on-non-empty behavior) so internal
# normalization via model_copy/defaults does not break.
HTTPOptions(middlewares=[])
with pytest.raises(ValueError, match="`middlewares` in HTTPOptions"):
HTTPOptions(host="8.8.8.8", middlewares=[object()])
# `num_cpus` is removed: a non-zero value raises; 0 (the old default) is
# a no-op so default construction is unaffected.
HTTPOptions(num_cpus=0)
with pytest.raises(ValueError, match="`num_cpus` in HTTPOptions"):
HTTPOptions(num_cpus=2)
# Test configs ignoring unknown keys (required for forward-compatibility)
HTTPOptions(new_version_config_key="this config is from newer version of Ray")
# `location` is deprecated; it defaults to None (proxy_location is the
# authority). host=None still disables. Setting a non-None location warns;
# location=None is a no-op (internal model_dump roundtrips pass it) and must
# NOT warn.
assert HTTPOptions().location is None
assert HTTPOptions(host=None).location == ProxyLocation.Disabled
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
assert HTTPOptions(location=None).location is None
assert not any("`location` in HTTPOptions" in str(w.message) for w in caught)
with pytest.warns(DeprecationWarning, match="`location` in HTTPOptions"):
assert HTTPOptions(location=ProxyLocation.EveryNode).location == "EveryNode"
def test_with_proto():
# Test roundtrip
config = DeploymentConfig(num_replicas=100, max_ongoing_requests=16)
assert config == DeploymentConfig.from_proto_bytes(config.to_proto_bytes())
# Test user_config object
config = DeploymentConfig(user_config={"python": ("native", ["objects"])})
assert config == DeploymentConfig.from_proto_bytes(config.to_proto_bytes())
def test_rolling_update_percentage_proto_roundtrip():
"""Ensure `rolling_update_percentage` survives to_proto/from_proto.
Because the proto field is declared `optional double`, an explicit value
must round-trip losslessly, and an absent field (simulating an older
controller during a rolling upgrade) must fall back to the Python-level
default instead of a proto3 zero default.
"""
# Explicit non-default value survives the round-trip.
config = DeploymentConfig(rolling_update_percentage=0.5)
roundtripped = DeploymentConfig.from_proto_bytes(config.to_proto_bytes())
assert roundtripped.rolling_update_percentage == 0.5
# Simulate an older controller that didn't carry this field: start from a
# valid proto (so the other non-optional fields satisfy Pydantic's
# validators on deserialization) and clear just `rolling_update_percentage`.
# Clearing the optional field makes HasField() return False, mimicking a
# proto serialized before this field existed.
proto = DeploymentConfig().to_proto()
proto.ClearField("rolling_update_percentage")
assert not proto.HasField("rolling_update_percentage")
deserialized = DeploymentConfig.from_proto(proto)
assert deserialized.rolling_update_percentage == DEFAULT_ROLLING_UPDATE_PERCENTAGE
@pytest.mark.parametrize("use_deprecated_smoothing_factor", [True, False])
def test_zero_default_proto(use_deprecated_smoothing_factor):
# Test that options set to zero (protobuf default value) still retain their
# original value after being serialized and deserialized.
autoscaling_config = {
"min_replicas": 1,
"max_replicas": 2,
"downscale_delay_s": 0,
}
if use_deprecated_smoothing_factor:
autoscaling_config["smoothing_factor"] = 0.123
else:
autoscaling_config["upscaling_factor"] = 0.123
autoscaling_config["downscaling_factor"] = 0.123
config = DeploymentConfig(autoscaling_config=autoscaling_config)
serialized_config = config.to_proto_bytes()
deserialized_config = DeploymentConfig.from_proto_bytes(serialized_config)
new_delay_s = deserialized_config.autoscaling_config.downscale_delay_s
assert new_delay_s == 0
# Check that this test is not spuriously passing.
default_downscale_delay_s = AutoscalingConfig().downscale_delay_s
assert new_delay_s != default_downscale_delay_s
def test_grpc_options():
"""Test gRPCOptions.
When the gRPCOptions object is created, the default values are set correctly. When
the gRPCOptions object is created with user-specified values, the values are set
correctly. Also, if the user provided an invalid grpc_servicer_function, it
raises errors.
"""
default_grpc_options = gRPCOptions()
assert default_grpc_options.port == DEFAULT_GRPC_PORT
assert default_grpc_options.grpc_servicer_functions == []
assert default_grpc_options.grpc_servicer_func_callable == []
assert default_grpc_options.request_timeout_s is None
port = 9001
grpc_servicer_functions = [
"ray.serve.generated.serve_pb2_grpc.add_UserDefinedServiceServicer_to_server",
]
request_timeout_s = 1
grpc_options = gRPCOptions(
port=port,
grpc_servicer_functions=grpc_servicer_functions,
request_timeout_s=request_timeout_s,
)
assert grpc_options.port == port
assert grpc_options.grpc_servicer_functions == grpc_servicer_functions
assert grpc_options.grpc_servicer_func_callable == [
add_UserDefinedServiceServicer_to_server
]
assert grpc_options.request_timeout_s == request_timeout_s
# Import not found should raise ModuleNotFoundError.
grpc_servicer_functions = ["fake.service.that.does.not.exist"]
with pytest.raises(ModuleNotFoundError) as exception:
grpc_options = gRPCOptions(grpc_servicer_functions=grpc_servicer_functions)
_ = grpc_options.grpc_servicer_func_callable
assert "can't be imported!" in str(exception)
# Not callable should raise ValueError.
grpc_servicer_functions = ["ray.serve._private.constants.DEFAULT_HTTP_PORT"]
with pytest.raises(ValueError) as exception:
grpc_options = gRPCOptions(grpc_servicer_functions=grpc_servicer_functions)
_ = grpc_options.grpc_servicer_func_callable
assert "is not a callable function!" in str(exception)
class TestControllerOptions:
"""Validator + parsing coverage for ControllerOptions.
The runtime validator is intentionally strict: v0 only accepts
``runtime_env.env_vars``. Other ``runtime_env`` keys (pip, working_dir, ...)
would mutate the long-lived detached controller actor's dependencies and
are rejected with a message pointing at deployment-level runtime_env.
"""
def test_default_is_empty(self):
opts = ControllerOptions()
assert opts.runtime_env is None
def test_accepts_dict_from_model_validate(self):
opts = ControllerOptions.model_validate(
{"runtime_env": {"env_vars": {"X": "y"}}}
)
assert opts.runtime_env == {"env_vars": {"X": "y"}}
def test_accepts_env_vars_with_str_str(self):
opts = ControllerOptions(
runtime_env={
"env_vars": {
"RAY_SERVE_HAPROXY_TCP_NODELAY": "1",
"RAY_SERVE_HAPROXY_NBTHREAD": "16",
}
}
)
assert opts.runtime_env["env_vars"]["RAY_SERVE_HAPROXY_TCP_NODELAY"] == "1"
def test_accepts_empty_env_vars(self):
opts = ControllerOptions(runtime_env={"env_vars": {}})
assert opts.runtime_env == {"env_vars": {}}
def test_accepts_explicit_none(self):
opts = ControllerOptions(runtime_env=None)
assert opts.runtime_env is None
@pytest.mark.parametrize(
"disallowed_key, value",
[
("pip", ["numpy"]),
("working_dir", "/tmp"),
("py_modules", []),
("conda", "env.yaml"),
("container", {}),
# A future runtime_env key we'd want to land via an explicit
# API broadening, not by silently accepting it.
("nsight", "default"),
],
)
def test_rejects_non_env_vars_runtime_env_keys(self, disallowed_key, value):
with pytest.raises(ValidationError) as exc:
ControllerOptions(runtime_env={disallowed_key: value})
msg = str(exc.value)
assert "only supports ['env_vars']" in msg
assert disallowed_key in msg
def test_rejects_runtime_env_not_a_dict(self):
with pytest.raises(ValidationError):
ControllerOptions(runtime_env="env_vars=FOO")
def test_rejects_env_vars_not_a_dict(self):
with pytest.raises(ValidationError) as exc:
ControllerOptions(runtime_env={"env_vars": ["FOO=bar"]})
assert "env_vars must be a dict" in str(exc.value)
def test_rejects_env_vars_explicit_none(self):
# Explicit ``env_vars: null`` (e.g., from YAML) is distinct from the
# key being absent; reject it so a bad config fails locally instead of
# surfacing later from the Ray runtime_env layer.
with pytest.raises(ValidationError) as exc:
ControllerOptions(runtime_env={"env_vars": None})
assert "env_vars must be a dict" in str(exc.value)
@pytest.mark.parametrize(
"bad_value",
[1, 3.14, True, None, ["1"], {"nested": "value"}],
)
def test_rejects_non_str_env_var_values(self, bad_value):
with pytest.raises(ValidationError) as exc:
ControllerOptions(runtime_env={"env_vars": {"X": bad_value}})
assert "must be str" in str(exc.value)
@pytest.mark.parametrize("bad_key", ["", 1, None])
def test_rejects_bad_env_var_keys(self, bad_key):
with pytest.raises(ValidationError):
ControllerOptions(runtime_env={"env_vars": {bad_key: "v"}})
def test_rejects_extra_top_level_fields(self):
# extra="forbid" guards against typos and against silently accepting
# future fields without a validator update.
with pytest.raises(ValidationError):
ControllerOptions(runtimeenv={"env_vars": {}}) # missing underscore
def test_rejects_mixed_allowed_and_disallowed_runtime_env_keys(self):
with pytest.raises(ValidationError) as exc:
ControllerOptions(runtime_env={"env_vars": {"X": "y"}, "pip": ["numpy"]})
assert "pip" in str(exc.value)
def test_proxy_location_normalize():
assert ProxyLocation._normalize(None) is None
assert ProxyLocation._normalize(ProxyLocation.Disabled) == ProxyLocation.Disabled
assert ProxyLocation._normalize(ProxyLocation.HeadOnly) == ProxyLocation.HeadOnly
assert ProxyLocation._normalize(ProxyLocation.EveryNode) == ProxyLocation.EveryNode
assert ProxyLocation._normalize("Disabled") == ProxyLocation.Disabled
assert ProxyLocation._normalize("HeadOnly") == ProxyLocation.HeadOnly
assert ProxyLocation._normalize("EveryNode") == ProxyLocation.EveryNode
assert ProxyLocation._normalize("NoServer") == ProxyLocation.Disabled
with pytest.raises(ValueError):
ProxyLocation._normalize("Unknown")
with pytest.raises(TypeError):
ProxyLocation._normalize({"some_other_obj"})
@pytest.mark.parametrize(
"policy",
[
None,
{"policy_function": "ray.serve.tests.unit.test_config:fake_policy"},
{"policy_function": fake_policy},
],
)
def test_autoscaling_policy_serializations(policy):
"""Test that autoscaling policy can be serialized and deserialized.
This test checks that the autoscaling policy can be serialized and deserialized for
when the policy is a function, a string, or None (default).
"""
autoscaling_config = AutoscalingConfig()
if policy:
autoscaling_config = AutoscalingConfig(policy=policy)
config = DeploymentConfig.from_default(autoscaling_config=autoscaling_config)
deserialized_autoscaling_policy = DeploymentConfig.from_proto_bytes(
config.to_proto_bytes()
).autoscaling_config.policy.get_policy()
if policy is None:
# Compare function attributes instead of function objects since
# cloudpickle.register_pickle_by_value() causes deserialization to
# create a new function object rather than returning the same object
assert (
deserialized_autoscaling_policy.__name__
== default_autoscaling_policy.__name__
)
assert (
deserialized_autoscaling_policy.__module__
== default_autoscaling_policy.__module__
)
else:
# Compare function behavior instead of function objects
# since serialization/deserialization creates new function objects
assert deserialized_autoscaling_policy() == fake_policy()
def test_autoscaling_policy_import_fails_for_non_existing_policy():
"""Test that autoscaling policy will error out for non-existing policy.
This test will ensure non-existing policy will be caught. It can happen when we
moved the default policy or when user pass in a non-existing policy.
"""
# Right now we don't allow modifying the autoscaling policy, so this will not fail
policy = "i.dont.exist:fake_policy"
with pytest.raises(ModuleNotFoundError):
AutoscalingConfig(policy={"policy_function": policy})
def test_default_autoscaling_policy_import_path():
"""Test that default autoscaling policy can be imported."""
policy = import_attr(DEFAULT_AUTOSCALING_POLICY_NAME)
assert policy == default_autoscaling_policy
class TestGetControllerImpl:
"""White-box checks that ``get_controller_impl`` wires ``ControllerOptions``
into the controller actor class's default options.
These cover the path without starting a Ray cluster -- the live
end-to-end env-propagation test lives in
``tests/test_standalone.py::test_serve_start_controller_options``.
"""
def _default_options(self, controller_options=None):
from ray.serve._private.default_impl import get_controller_impl
return get_controller_impl(
controller_options=controller_options
)._default_options
def test_no_runtime_env_key_when_options_is_none(self):
opts = self._default_options(controller_options=None)
# Hardcoded fields stay; no runtime_env unless explicitly requested.
assert opts["name"] == "SERVE_CONTROLLER_ACTOR"
assert opts["namespace"] == "serve"
assert "runtime_env" not in opts
def test_no_runtime_env_key_when_runtime_env_is_none(self):
opts = self._default_options(ControllerOptions(runtime_env=None))
assert "runtime_env" not in opts
def test_env_vars_passthrough(self):
opts = self._default_options(
ControllerOptions(
runtime_env={
"env_vars": {
"RAY_SERVE_HAPROXY_TCP_NODELAY": "1",
"RAY_SERVE_HAPROXY_NBTHREAD": "16",
}
}
)
)
assert opts["runtime_env"]["env_vars"] == {
"RAY_SERVE_HAPROXY_TCP_NODELAY": "1",
"RAY_SERVE_HAPROXY_NBTHREAD": "16",
}
class TestProtoToDict:
def test_empty_fields(self):
"""Test _proto_to_dict() to deserialize protobuf with empty fields"""
proto = DeploymentConfigProto()
result = _proto_to_dict(proto)
# Defaults are filled.
assert result["num_replicas"] == 0
assert result["max_ongoing_requests"] == 0
assert result["user_config"] == b""
assert result["user_configured_option_names"] == []
# Nested profobufs don't exist.
assert "autoscaling_config" not in result
def test_non_empty_fields(self):
"""Test _proto_to_dict() to deserialize protobuf with non-empty fields"""
num_replicas = 111
max_ongoing_requests = 222
proto = DeploymentConfigProto(
num_replicas=num_replicas,
max_ongoing_requests=max_ongoing_requests,
)
result = _proto_to_dict(proto)
# Fields with non-empty values are filled correctly.
assert result["num_replicas"] == num_replicas
assert result["max_ongoing_requests"] == max_ongoing_requests
# Empty fields are continue to be filled with default values.
assert result["user_config"] == b""
def test_nested_protobufs(self):
"""Test _proto_to_dict() to deserialize protobuf with nested protobufs"""
num_replicas = 111
max_ongoing_requests = 222
min_replicas = 333
proto = DeploymentConfigProto(
num_replicas=num_replicas,
max_ongoing_requests=max_ongoing_requests,
autoscaling_config=AutoscalingConfigProto(
min_replicas=min_replicas,
),
)
result = _proto_to_dict(proto)
# Non-empty field is filled correctly.
assert result["num_replicas"] == num_replicas
assert result["max_ongoing_requests"] == max_ongoing_requests
# Nested protobuf is filled correctly.
assert result["autoscaling_config"]["min_replicas"] == min_replicas
def test_repeated_field(self):
"""Test _proto_to_dict() to deserialize protobuf with repeated field"""
user_configured_option_names = ["foo", "bar"]
config = DeploymentConfig.from_default(
user_configured_option_names=user_configured_option_names,
)
proto_bytes = config.to_proto_bytes()
proto = DeploymentConfigProto.FromString(proto_bytes)
result = _proto_to_dict(proto)
# Repeated field is filled correctly as list.
assert set(result["user_configured_option_names"]) == set(
user_configured_option_names
)
assert isinstance(result["user_configured_option_names"], list)
def test_enum_field(self):
"""Test _proto_to_dict() to deserialize protobuf with enum field"""
proto = DeploymentConfigProto(
deployment_language=DeploymentLanguage.JAVA,
)
result = _proto_to_dict(proto)
# Enum field is filled correctly.
assert result["deployment_language"] == DeploymentLanguage.JAVA
def test_optional_field(self):
"""Test _proto_to_dict() to deserialize protobuf with optional field"""
min_replicas = 1
proto = AutoscalingConfigProto(
min_replicas=min_replicas,
)
result = _proto_to_dict(proto)
# Non-empty field is filled correctly.
assert result["min_replicas"] == 1
# Empty field is filled correctly.
assert result["max_replicas"] == 0
# Optional field should not be filled.
assert "initial_replicas" not in result
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))