Files
2026-07-13 13:17:40 +08:00

493 lines
16 KiB
Python

import os
import subprocess
import sys
import warnings
from unittest.mock import Mock, patch
import pytest
import ray
import ray.client_builder as client_builder
import ray.util.client.server.server as ray_client_server
from ray._common.test_utils import (
run_string_as_driver,
wait_for_condition,
)
from ray._private.test_utils import run_string_as_driver_nonblocking
from ray.util.state import list_workers
@pytest.mark.parametrize(
"address",
[
"localhost:1234",
"localhost:1234/url?params",
"1.2.3.4/cluster-1?test_param=param1?",
"",
],
)
def test_split_address(address):
assert client_builder._split_address(address) == ("ray", address)
specified_module = f"ray://{address}"
assert client_builder._split_address(specified_module) == ("ray", address)
specified_other_module = f"module://{address}"
assert client_builder._split_address(specified_other_module) == ("module", address)
non_url_compliant_module = f"module_test://{address}"
assert client_builder._split_address(non_url_compliant_module) == (
"module_test",
address,
)
@pytest.mark.parametrize(
"address", ["localhost", "1.2.3.4:1200", "ray://1.2.3.4:1200", "local", None]
)
def test_client(address):
builder = client_builder.client(address)
assert isinstance(builder, client_builder.ClientBuilder)
if address in ("local", None):
assert isinstance(builder, client_builder._LocalClientBuilder)
else:
assert type(builder) is client_builder.ClientBuilder
assert builder.address == address.replace("ray://", "")
def test_namespace(ray_start_cluster):
"""
Most of the "checks" in this test case rely on the fact that
`run_string_as_driver` will throw an exception if the driver string exits
with a non-zero exit code (e.g. when the driver scripts throws an
exception). Since all of these drivers start named, detached actors, the
most likely failure case would be a collision of named actors if they're
put in the same namespace.
This test checks that:
* When two drivers don't specify a namespace, they are placed in different
anonymous namespaces.
* When two drivers specify a namespace, they collide.
* The namespace name (as provided by the runtime context) is correct.
"""
cluster = ray_start_cluster
cluster.add_node(num_cpus=4, ray_client_server_port=50055)
cluster.wait_for_nodes(1)
template = """
import ray
ray.client("localhost:50055").namespace({namespace}).connect()
@ray.remote
class Foo:
def ping(self):
return "pong"
a = Foo.options(lifetime="detached", name="abc").remote()
ray.get(a.ping.remote())
print("Current namespace:", ray.get_runtime_context().namespace)
"""
anon_driver = template.format(namespace="None")
run_string_as_driver(anon_driver)
# This second run will fail if the actors don't run in separate anonymous
# namespaces.
run_string_as_driver(anon_driver)
run_in_namespace = template.format(namespace="'namespace'")
script_output = run_string_as_driver(run_in_namespace)
# The second run fails because the actors are run in the same namespace.
with pytest.raises(subprocess.CalledProcessError):
run_string_as_driver(run_in_namespace)
assert "Current namespace: namespace" in script_output
subprocess.check_output("ray stop --force", shell=True)
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
def test_start_local_cluster():
"""This tests that ray.client() starts a new local cluster when appropriate.
* Using `ray.client("local").connect() ` should always create a new
cluster.
* Using `ray.client().connect()` should create a new cluster if it doesn't
connect to an existing one that was started via `ray start --head`..
"""
driver_template = """
import ray
info = ray.client({address}).connect()
print("NODE_ID:", ray.get_runtime_context().get_node_id())
# Block.
while True:
time.sleep(1)
"""
def _get_node_id(p: subprocess.Popen) -> str:
l = p.stdout.readline().decode("ascii").strip()
assert "NODE_ID" in l
return l[len("NODE_ID: ") :]
p1, p2, p3 = None, None, None
unbuffered = {"PYTHONUNBUFFERED": "1"}
try:
# ray.client() should start a cluster if none is running.
p1 = run_string_as_driver_nonblocking(
driver_template.format(address=""), env=unbuffered
)
p1_node_id = _get_node_id(p1)
# ray.client("local") should always start a cluster.
p2 = run_string_as_driver_nonblocking(driver_template.format(address="'local'"))
p2_node_id = _get_node_id(p2)
# ray.client() shouldn't connect to a cluster started by ray.client() or
# ray.client("local").
p3 = run_string_as_driver_nonblocking(driver_template.format(address=""))
p3_node_id = _get_node_id(p3)
# Check that all three drivers started their own local clusters.
assert len({p1_node_id, p2_node_id, p3_node_id}) == 3
finally:
# Kill processes concurrently.
if p1 is not None:
p1.kill()
if p2 is not None:
p2.kill()
if p3 is not None:
p3.kill()
# Wait for processes to exit.
if p1 is not None:
p1.wait()
if p2 is not None:
p2.wait()
if p3 is not None:
p3.wait()
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
def test_connect_to_local_cluster(call_ray_start):
"""This tests that ray.client connects to a local cluster when appropriate.
* Using `ray.client("local").connect() ` should always create a new
cluster even if one is running.
* Using `ray.client().connect()` should connect to a local cluster that was
started with `ray start --head`.
"""
driver_template = """
import ray
info = ray.client({address}).connect()
print("NODE_ID:", ray.get_runtime_context().get_node_id())
"""
def _get_node_id(p: subprocess.Popen) -> str:
l = p.stdout.readline().decode("ascii").strip()
assert "NODE_ID" in l
return l[len("NODE_ID: ") :]
existing_node_id = ray.get_runtime_context().get_node_id()
p1, p2 = None, None
unbuffered = {"PYTHONUNBUFFERED": "1"}
try:
# ray.client() should connect to the running cluster.
p1 = run_string_as_driver_nonblocking(
driver_template.format(address=""), env=unbuffered
)
assert _get_node_id(p1) == existing_node_id
# ray.client("local") should always start a cluster.
p2 = run_string_as_driver_nonblocking(driver_template.format(address="'local'"))
assert _get_node_id(p2) != existing_node_id
finally:
# Kill processes concurrently.
if p1 is not None:
p1.kill()
if p2 is not None:
p2.kill()
# Wait for processes to exit.
if p1 is not None:
p1.wait()
if p2 is not None:
p2.wait()
def test_non_existent_modules():
exception = None
try:
ray.client("badmodule://address")
except RuntimeError as e:
exception = e
assert exception is not None, "Bad Module did not raise RuntimeException"
assert "does not exist" in str(exception)
def test_module_lacks_client_builder():
mock_importlib = Mock()
def mock_import_module(module_string):
if module_string == "ray":
return ray
else:
# Mock() does not have a `ClientBuilder` in its scope
return Mock()
mock_importlib.import_module = mock_import_module
with patch("ray.client_builder.importlib", mock_importlib):
assert isinstance(ray.client(""), ray.ClientBuilder)
assert isinstance(ray.client("ray://"), ray.ClientBuilder)
exception = None
try:
ray.client("othermodule://")
except RuntimeError as e:
exception = e
assert (
exception is not None
), "Module without ClientBuilder did not raise RuntimeError"
assert "does not have ClientBuilder" in str(exception)
@pytest.mark.parametrize("address", ["http://127.0.0.1:8265", "https://127.0.0.1:8265"])
def test_http_address_error_message(address):
"""Test that a helpful error is raised when RAY_ADDRESS is set to HTTP/HTTPS."""
exception = None
try:
ray.client(address)
except ValueError as e:
exception = e
assert exception is not None, f"Address {address} did not raise ValueError"
assert "RAY_ADDRESS" in str(exception)
assert "GCS address" in str(exception)
@pytest.mark.skipif(sys.platform == "win32", reason="RC Proxy is Flaky on Windows.")
def test_disconnect(call_ray_stop_only, set_enable_auto_connect):
subprocess.check_output(
"ray start --head --ray-client-server-port=25555", shell=True
)
with ray.client("localhost:25555").namespace("n1").connect():
# Connect via Ray Client
namespace = ray.get_runtime_context().namespace
assert namespace == "n1"
assert ray.util.client.ray.is_connected()
with pytest.raises(ray.exceptions.RaySystemError):
ray.put(300)
with ray.client(None).namespace("n1").connect():
# Connect Directly via Driver
namespace = ray.get_runtime_context().namespace
assert namespace == "n1"
assert not ray.util.client.ray.is_connected()
with pytest.raises(ray.exceptions.RaySystemError):
ray.put(300)
ctx = ray.client("localhost:25555").namespace("n1").connect()
# Connect via Ray Client
namespace = ray.get_runtime_context().namespace
assert namespace == "n1"
assert ray.util.client.ray.is_connected()
ctx.disconnect()
# Check idempotency
ctx.disconnect()
with pytest.raises(ray.exceptions.RaySystemError):
ray.put(300)
@pytest.mark.skipif(sys.platform == "win32", reason="RC Proxy is Flaky on Windows.")
def test_address_resolution(call_ray_stop_only):
subprocess.check_output(
"ray start --head --ray-client-server-port=50055", shell=True
)
with ray.client("localhost:50055").connect():
assert ray.util.client.ray.is_connected()
try:
os.environ["RAY_ADDRESS"] = "local"
with ray.client("localhost:50055").connect():
# client(...) takes precedence of RAY_ADDRESS=local
assert ray.util.client.ray.is_connected()
# This tries to call `ray.init(address="local") which creates a new Ray
# instance.
with ray.client(None).connect():
wait_for_condition(
lambda: len(ray._private.services.find_gcs_addresses()) == 2,
retry_interval_ms=1000,
)
finally:
if os.environ.get("RAY_ADDRESS"):
del os.environ["RAY_ADDRESS"]
ray.shutdown()
def mock_connect(*args, **kwargs):
"""
Force exit instead of actually attempting to connect
"""
raise ConnectionError
def has_client_deprecation_warn(warning: Warning, expected_replacement: str) -> bool:
"""
Returns true if expected_replacement is in the message of the passed
warning, and that the warning mentions deprecation.
"""
start = "Starting a connection through `ray.client` will be deprecated"
message = str(warning.message)
if start not in message:
return False
if expected_replacement not in message:
return False
return True
@pytest.mark.skipif(
sys.platform == "win32", reason="pip not supported in Windows runtime envs."
)
@pytest.mark.filterwarnings(
"default:Starting a connection through `ray.client` will be deprecated"
)
def test_client_deprecation_warn():
"""
Tests that calling ray.client directly raises a deprecation warning with
a copy pasteable replacement for the client().connect() call converted
to ray.init style.
"""
# Test warning when local client mode is used
with warnings.catch_warnings(record=True) as w:
ray.client().connect()
assert any(has_client_deprecation_warn(warning, "ray.init()") for warning in w)
ray.shutdown()
with warnings.catch_warnings(record=True) as w:
ray.client().namespace("nmspc").env({"pip": ["requests"]}).connect()
expected = (
'ray.init(namespace="nmspc", runtime_env=<your_runtime_env>)' # noqa E501
)
assert any(
has_client_deprecation_warn(warning, expected) for warning in w # noqa E501
)
ray.shutdown()
server = ray_client_server.serve("localhost", 50055)
# Test warning when namespace and runtime env aren't specified
with warnings.catch_warnings(record=True) as w:
with ray.client("localhost:50055").connect():
pass
assert any(
has_client_deprecation_warn(warning, 'ray.init("ray://localhost:50055")')
for warning in w
)
# Test warning when just namespace specified
with warnings.catch_warnings(record=True) as w:
with ray.client("localhost:50055").namespace("nmspc").connect():
pass
assert any(
has_client_deprecation_warn(
warning, 'ray.init("ray://localhost:50055", namespace="nmspc")'
)
for warning in w
)
# Test that passing namespace through env doesn't add namespace to the
# replacement
with (
warnings.catch_warnings(record=True) as w,
patch.dict(os.environ, {"RAY_NAMESPACE": "aksdj"}),
):
with ray.client("localhost:50055").connect():
pass
assert any(
has_client_deprecation_warn(warning, 'ray.init("ray://localhost:50055")')
for warning in w
)
# Skip actually connecting on these, since updating the runtime env is
# time consuming
with patch("ray.util.client_connect.connect", mock_connect):
# Test warning when just runtime_env specified
with warnings.catch_warnings(record=True) as w:
try:
ray.client("localhost:50055").env({"pip": ["requests"]}).connect()
except ConnectionError:
pass
expected = 'ray.init("ray://localhost:50055", runtime_env=<your_runtime_env>)' # noqa E501
assert any(has_client_deprecation_warn(warning, expected) for warning in w)
# Test warning works if both runtime env and namespace specified
with warnings.catch_warnings(record=True) as w:
try:
ray.client("localhost:50055").namespace("nmspc").env(
{"pip": ["requests"]}
).connect()
except ConnectionError:
pass
expected = 'ray.init("ray://localhost:50055", namespace="nmspc", runtime_env=<your_runtime_env>)' # noqa E501
assert any(has_client_deprecation_warn(warning, expected) for warning in w)
# We don't expect namespace to appear in the warning message, since
# it was configured through an env var
with (
warnings.catch_warnings(record=True) as w,
patch.dict(os.environ, {"RAY_NAMESPACE": "abcdef"}),
):
try:
ray.client("localhost:50055").env({"pip": ["requests"]}).connect()
except ConnectionError:
pass
expected = 'ray.init("ray://localhost:50055", runtime_env=<your_runtime_env>)' # noqa E501
assert any(has_client_deprecation_warn(warning, expected) for warning in w)
# cleanup
server.stop(0)
subprocess.check_output("ray stop --force", shell=True)
@pytest.mark.parametrize(
"call_ray_start",
[
"ray start --head --num-cpus=2 --min-worker-port=0 --max-worker-port=0 "
"--port 0 --ray-client-server-port=50056"
],
indirect=True,
)
def test_task_use_prestarted_worker(call_ray_start):
ray.init("ray://localhost:50056")
assert (
len(
list_workers(
filters=[("worker_type", "!=", "DRIVER")], raise_on_missing_output=False
)
)
== 2
)
@ray.remote(num_cpus=2)
def f():
return 42
assert ray.get(f.remote()) == 42
assert (
len(
list_workers(
filters=[("worker_type", "!=", "DRIVER")], raise_on_missing_output=False
)
)
== 2
)
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))