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

1275 lines
40 KiB
Python

import _thread
import logging
import os
import queue
import re
import sys
import threading
import time
from unittest.mock import Mock, patch
import numpy as np
import pytest
from pydantic import BaseModel
import ray
import ray.cloudpickle as cloudpickle
import ray.util.client.server.server as ray_client_server
from ray._common.network_utils import build_address
from ray._common.test_utils import run_string_as_driver
from ray._private.client_mode_hook import (
client_mode_should_convert,
disable_client_hook,
enable_client_mode,
)
from ray.job_config import JobConfig
from ray.tests.client_test_utils import (
create_remote_signal_actor,
run_wrapped_actor_creation,
)
from ray.tests.conftest import call_ray_start_context
from ray.util.client import _apply_uv_hook_for_client
from ray.util.client.common import OBJECT_TRANSFER_CHUNK_SIZE, ClientObjectRef
from ray.util.client.ray_client_helpers import (
ray_start_client_server,
ray_start_client_server_for_address,
)
# Note on the structure of tests in this file:
# All of the tests in this file reuse the same instance of Ray, which is
# started by the `call_ray_start_shared` fixture. This is to avoid constantly
# starting and stopping Ray, which can lead to flakiness or weird interactions
# between the Ray processes from different instances.
# Use `ray_start_client_server_for_address(call_ray_start_shared)` to start
# a client server that connects drivers to this the shared instance.
# Use `ray.init(SHARED_CLIENT_SERVER_ADDRESS)` to start a client connection
# directly against the default proxy server.
# Client server port of the shared Ray instance
SHARED_CLIENT_SERVER_PORT = 25555
SHARED_CLIENT_SERVER_ADDRESS = (
f"ray://{build_address('localhost', SHARED_CLIENT_SERVER_PORT)}"
)
@pytest.fixture(scope="module")
def call_ray_start_shared(request):
request = Mock()
request.param = (
"ray start --head --min-worker-port=0 --max-worker-port=0 --port 0 "
f"--ray-client-server-port={SHARED_CLIENT_SERVER_PORT}"
)
with call_ray_start_context(request) as address:
yield address
@pytest.mark.parametrize("connect_to_client", [False, True])
def test_client_context_manager(call_ray_start_shared, connect_to_client):
if connect_to_client:
with (
ray_start_client_server_for_address(call_ray_start_shared),
enable_client_mode(),
):
# Client mode is on.
assert client_mode_should_convert()
# We're connected to Ray client.
assert ray.util.client.ray.is_connected()
else:
assert not client_mode_should_convert()
assert not ray.util.client.ray.is_connected()
def test_client_thread_safe(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
# Set num_cpus to zero to ensure that both tasks can always be scheduled
@ray.remote(num_cpus=0)
def block():
print("blocking run")
time.sleep(99)
@ray.remote(num_cpus=0)
def fast():
print("fast run")
return "ok"
class Blocker(threading.Thread):
def __init__(self):
threading.Thread.__init__(self)
self.daemon = True
def run(self):
ray.get(block.remote(), timeout=20)
b = Blocker()
b.start()
time.sleep(1)
# Can concurrently execute the get.
assert ray.get(fast.remote(), timeout=5) == "ok"
b.join()
def test_client_mode_hook_thread_safe(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared):
with enable_client_mode():
assert client_mode_should_convert()
lock = threading.Lock()
lock.acquire()
q = queue.Queue()
def disable():
with disable_client_hook():
q.put(client_mode_should_convert())
lock.acquire()
q.put(client_mode_should_convert())
t = threading.Thread(target=disable)
t.start()
assert client_mode_should_convert()
lock.release()
t.join()
assert q.get() is False, "Threaded disable_client_hook failed to disable"
assert q.get() is True, "Threaded disable_client_hook failed to re-enable"
def test_interrupt_ray_get(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
@ray.remote
def block():
print("blocking run")
time.sleep(99)
@ray.remote
def fast():
print("fast run")
time.sleep(1)
return "ok"
class Interrupt(threading.Thread):
def run(self):
time.sleep(2)
_thread.interrupt_main()
it = Interrupt()
it.start()
with pytest.raises(KeyboardInterrupt):
ray.get(block.remote())
# Assert we can still get new items after the interrupt.
assert ray.get(fast.remote()) == "ok"
def test_get_list(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
@ray.remote
def f():
return "OK"
assert ray.get([]) == []
assert ray.get([f.remote()]) == ["OK"]
get_count = 0
get_stub = ray.worker.server.GetObject
# ray.get() uses unary-unary RPC. Mock the server handler to count
# the number of requests received.
def get(req, metadata=None):
nonlocal get_count
get_count += 1
return get_stub(req, metadata=metadata)
ray.worker.server.GetObject = get
refs = [f.remote() for _ in range(100)]
assert ray.get(refs) == ["OK" for _ in range(100)]
# Only 1 RPC should be sent.
assert get_count == 1
def test_real_ray_fallback(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
@ray.remote
def get_nodes_real():
import ray as real_ray
return real_ray.nodes()
nodes = ray.get(get_nodes_real.remote())
assert len(nodes) == 1, nodes
@ray.remote
def get_nodes():
# Can access the full Ray API in remote methods.
return ray.nodes()
nodes = ray.get(get_nodes.remote())
assert len(nodes) == 1, nodes
def test_nested_function(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
@ray.remote
def g():
@ray.remote
def f():
return "OK"
return ray.get(f.remote())
assert ray.get(g.remote()) == "OK"
def test_put_get(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
objectref = ray.put("hello world")
print(objectref)
retval = ray.get(objectref)
assert retval == "hello world"
# Make sure ray.put(1) == 1 is False and does not raise an exception.
objectref = ray.put(1)
assert not objectref == 1
# Make sure it returns True when necessary as well.
assert objectref == ClientObjectRef(objectref.id)
# Assert output is correct type.
list_put = ray.put([1, 2, 3])
assert isinstance(list_put, ClientObjectRef)
assert ray.get(list_put) == [1, 2, 3]
def test_put_failure_get(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
class DeSerializationFailure:
def __getstate__(self):
return ""
def __setstate__(self, i):
raise ZeroDivisionError
dsf = DeSerializationFailure()
with pytest.raises(ZeroDivisionError):
ray.put(dsf)
# Ensure Ray Client is still connected
assert ray.get(ray.put(100)) == 100
def test_wait(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
objectref = ray.put("hello world")
ready, remaining = ray.wait([objectref])
assert remaining == []
retval = ray.get(ready[0])
assert retval == "hello world"
objectref2 = ray.put(5)
ready, remaining = ray.wait([objectref, objectref2])
assert (ready, remaining) == ([objectref], [objectref2]) or (
ready,
remaining,
) == ([objectref2], [objectref])
ready_retval = ray.get(ready[0])
remaining_retval = ray.get(remaining[0])
assert (ready_retval, remaining_retval) == ("hello world", 5) or (
ready_retval,
remaining_retval,
) == (5, "hello world")
with pytest.raises(Exception):
# Reference not in the object store.
ray.wait([ClientObjectRef(b"blabla")])
with pytest.raises(TypeError):
ray.wait("blabla")
with pytest.raises(TypeError):
ray.wait(ClientObjectRef("blabla"))
with pytest.raises(TypeError):
ray.wait(["blabla"])
def test_remote_functions(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
SignalActor = create_remote_signal_actor(ray)
signaler = SignalActor.remote()
@ray.remote
def plus2(x):
return x + 2
@ray.remote
def fact(x):
print(x, type(fact))
if x <= 0:
return 1
# This hits the "nested tasks" issue
# https://github.com/ray-project/ray/issues/3644
# So we're on the right track!
return ray.get(fact.remote(x - 1)) * x
ref2 = plus2.remote(234)
# `236`
assert ray.get(ref2) == 236
ref3 = fact.remote(20)
# `2432902008176640000`
assert ray.get(ref3) == 2_432_902_008_176_640_000
# Reuse the cached ClientRemoteFunc object
ref4 = fact.remote(5)
assert ray.get(ref4) == 120
# Test ray.wait()
ref5 = fact.remote(10)
# should return ref2, ref3, ref4
res = ray.wait([ref5, ref2, ref3, ref4], num_returns=3)
assert [ref2, ref3, ref4] == res[0]
assert [ref5] == res[1]
assert ray.get(res[0]) == [236, 2_432_902_008_176_640_000, 120]
# should return ref2, ref3, ref4, ref5
res = ray.wait([ref2, ref3, ref4, ref5], num_returns=4)
assert [ref2, ref3, ref4, ref5] == res[0]
assert [] == res[1]
all_vals = ray.get(res[0])
assert all_vals == [236, 2_432_902_008_176_640_000, 120, 3628800]
# Timeout 0 on ray.wait leads to immediate return
# (not indefinite wait for first return as with timeout None):
unready_ref = signaler.wait.remote()
res = ray.wait([unready_ref], timeout=0)
# Not ready.
assert res[0] == [] and len(res[1]) == 1
ray.get(signaler.send.remote())
ready_ref = signaler.wait.remote()
# Ready.
res = ray.wait([ready_ref], timeout=10)
assert len(res[0]) == 1 and res[1] == []
def test_function_calling_function(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
@ray.remote
def g():
return "OK"
@ray.remote
def f():
print(f, g)
return ray.get(g.remote())
print(f, type(f))
assert ray.get(f.remote()) == "OK"
def test_basic_actor(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
@ray.remote
class HelloActor:
def __init__(self):
self.count = 0
def say_hello(self, whom):
self.count += 1
return "Hello " + whom, self.count
@ray.method(num_returns=2)
def say_hi(self, whom):
self.count += 1
return "Hi " + whom, self.count
actor = HelloActor.remote()
s, count = ray.get(actor.say_hello.remote("you"))
assert s == "Hello you"
assert count == 1
ref = actor.say_hello.remote("world")
s, count = ray.get(ref)
assert s == "Hello world"
assert count == 2
r1, r2 = actor.say_hi.remote("ray")
assert ray.get(r1) == "Hi ray"
assert ray.get(r2) == 3
def test_pass_handles(call_ray_start_shared):
"""Test that passing client handles to actors and functions to remote actors
in functions (on the server or raylet side) works transparently to the
caller.
"""
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
@ray.remote
class ExecActor:
def exec(self, f, x):
return ray.get(f.remote(x))
def exec_exec(self, actor, f, x):
return ray.get(actor.exec.remote(f, x))
@ray.remote
def fact(x):
out = 1
while x > 0:
out = out * x
x -= 1
return out
@ray.remote
def func_exec(f, x):
return ray.get(f.remote(x))
@ray.remote
def func_actor_exec(actor, f, x):
return ray.get(actor.exec.remote(f, x))
@ray.remote
def sneaky_func_exec(obj, x):
return ray.get(obj["f"].remote(x))
@ray.remote
def sneaky_actor_exec(obj, x):
return ray.get(obj["actor"].exec.remote(obj["f"], x))
def local_fact(x):
if x <= 0:
return 1
return x * local_fact(x - 1)
assert ray.get(fact.remote(7)) == local_fact(7)
assert ray.get(func_exec.remote(fact, 8)) == local_fact(8)
test_obj = {}
test_obj["f"] = fact
assert ray.get(sneaky_func_exec.remote(test_obj, 5)) == local_fact(5)
actor_handle = ExecActor.remote()
assert ray.get(actor_handle.exec.remote(fact, 7)) == local_fact(7)
assert ray.get(func_actor_exec.remote(actor_handle, fact, 10)) == local_fact(10)
second_actor = ExecActor.remote()
assert ray.get(
actor_handle.exec_exec.remote(second_actor, fact, 9)
) == local_fact(9)
test_actor_obj = {}
test_actor_obj["actor"] = second_actor
test_actor_obj["f"] = fact
assert ray.get(sneaky_actor_exec.remote(test_actor_obj, 4)) == local_fact(4)
def test_basic_log_stream(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
log_msgs = []
def test_log(level, msg):
log_msgs.append(msg)
ray.worker.log_client.log = test_log
ray.worker.log_client.set_logstream_level(logging.DEBUG)
# Allow some time to propagate
time.sleep(1)
x = ray.put("Foo")
assert ray.get(x) == "Foo"
time.sleep(1)
logs_with_id = [msg for msg in log_msgs if msg.find(x.id.hex()) >= 0]
assert len(logs_with_id) >= 2, logs_with_id
assert any((msg.find("get") >= 0 for msg in logs_with_id)), logs_with_id
assert any((msg.find("put") >= 0 for msg in logs_with_id)), logs_with_id
def test_stdout_log_stream(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
log_msgs = []
def test_log(level, msg):
log_msgs.append(msg)
ray.worker.log_client.stdstream = test_log
@ray.remote
def print_on_stderr_and_stdout(s):
print(s)
print(s, file=sys.stderr)
time.sleep(1)
# ray.get to ensure that the remote function has finished executing
ray.get(print_on_stderr_and_stdout.remote("Hello world"))
time.sleep(3)
num_hello = 0
for msg in log_msgs:
if "Hello world" in msg:
num_hello += 1
assert num_hello == 2, f"Invalid logs: {log_msgs}"
def test_serializing_exceptions(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
with pytest.raises(ValueError, match="Failed to look up actor with name 'abc'"):
ray.get_actor("abc")
def test_invalid_task(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
with pytest.raises(TypeError):
@ray.remote(runtime_env="invalid value")
def f():
return 1
def test_create_remote_before_start(call_ray_start_shared):
"""Creates remote objects (as though in a library) before
starting the client.
"""
from ray.util.client import ray
@ray.remote
class Returner:
def doit(self):
return "foo"
@ray.remote
def f(x):
return x + 20
# Prints in verbose tests
print("Created remote functions")
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
assert ray.get(f.remote(3)) == 23
a = Returner.remote()
assert ray.get(a.doit.remote()) == "foo"
# Regression test for https://github.com/ray-project/ray/pull/51683
def test_runtime_env_py_executable(ray_start_regular):
"""Test that Ray Client works with a custom py_executable."""
with ray_start_client_server(
ray_init_kwargs={"runtime_env": {"py_executable": sys.executable + " -q"}}
) as ray:
@ray.remote
def f():
return "hi"
assert ray.get(f.remote()) == "hi"
def test_basic_named_actor(call_ray_start_shared):
"""Test that ray.get_actor() can create and return a detached actor."""
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
@ray.remote
class Accumulator:
def __init__(self):
self.x = 0
def inc(self):
self.x += 1
def get(self):
return self.x
@ray.method(num_returns=2)
def half(self):
return self.x / 2, self.x / 2
# Create the actor
actor = Accumulator.options(name="test_acc").remote()
actor.inc.remote()
actor.inc.remote()
# Make sure the get_actor call works
new_actor = ray.get_actor("test_acc")
new_actor.inc.remote()
assert ray.get(new_actor.get.remote()) == 3
del actor
actor = Accumulator.options(name="test_acc2", lifetime="detached").remote()
actor.inc.remote()
del actor
detatched_actor = ray.get_actor("test_acc2")
for i in range(5):
detatched_actor.inc.remote()
assert ray.get(detatched_actor.get.remote()) == 6
h1, h2 = ray.get(detatched_actor.half.remote())
assert h1 == 3
assert h2 == 3
def test_error_serialization(call_ray_start_shared):
"""Test that errors will be serialized properly."""
fake_path = os.path.join(os.path.dirname(__file__), "not_a_real_file")
with pytest.raises(FileNotFoundError):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
@ray.remote
def g():
with open(fake_path, "r") as f:
f.read()
# Raises a FileNotFoundError
ray.get(g.remote())
def test_internal_kv(call_ray_start_shared):
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
assert ray._internal_kv_initialized()
assert not ray._internal_kv_put("apple", "b")
assert ray._internal_kv_put("apple", "asdf")
assert ray._internal_kv_put("apple", "b")
assert ray._internal_kv_get("apple") == b"b"
assert ray._internal_kv_put("apple", "asdf", overwrite=True)
assert ray._internal_kv_get("apple") == b"asdf"
assert ray._internal_kv_list("a") == [b"apple"]
ray._internal_kv_del("apple")
assert ray._internal_kv_get("apple") is None
def test_startup_retry(call_ray_start_shared):
from ray.util.client import ray as ray_client
ray_client._inside_client_test = True
with pytest.raises(ConnectionError):
ray_client.connect("localhost:50051", connection_retries=1)
def run_client():
ray_client.connect("localhost:50051")
ray_client.disconnect()
thread = threading.Thread(target=run_client, daemon=True)
thread.start()
time.sleep(3)
server = ray_client_server.serve("localhost", 50051)
thread.join()
server.stop(0)
ray_client._inside_client_test = False
def test_dataclient_server_drop(call_ray_start_shared):
from ray.util.client import ray as ray_client
ray_client._inside_client_test = True
@ray_client.remote
def f(x):
time.sleep(4)
return x
def stop_server(server):
time.sleep(2)
server.stop(0)
server = ray_client_server.serve("localhost", 50051)
ray_client.connect("localhost:50051")
thread = threading.Thread(target=stop_server, args=(server,))
thread.start()
x = f.remote(2)
with pytest.raises(ConnectionError):
_ = ray_client.get(x)
thread.join()
ray_client.disconnect()
ray_client._inside_client_test = False
# Wait for f(x) to finish before ray.shutdown() in the fixture
time.sleep(3)
@pytest.mark.parametrize("set_enable_auto_connect", [True], indirect=True)
def test_client_gpu_ids(call_ray_start_shared, set_enable_auto_connect):
with enable_client_mode():
# No client connection.
with pytest.raises(Exception) as e:
ray.get_gpu_ids()
assert (
str(e.value) == "Ray Client is not connected."
" Please connect by calling `ray.init`."
)
with ray_start_client_server_for_address(call_ray_start_shared):
# Now have a client connection.
assert ray.get_gpu_ids() == []
def test_client_serialize_addon(call_ray_start_shared):
class User(BaseModel):
name: str
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
assert ray.get(ray.put(User(name="ray"))).name == "ray"
object_ref_cleanup_script = f"""
import ray
ray.init("ray://localhost:{SHARED_CLIENT_SERVER_PORT}")
@ray.remote
def f():
return 42
@ray.remote
class SomeClass:
pass
obj_ref = f.remote()
actor_ref = SomeClass.remote()
"""
def test_object_ref_cleanup(call_ray_start_shared):
# Checks no error output when running the script in
# object_ref_cleanup_script
# See https://github.com/ray-project/ray/issues/17968 for details
with ray_start_client_server_for_address(call_ray_start_shared):
result = run_string_as_driver(object_ref_cleanup_script)
assert "Error in sys.excepthook:" not in result
assert "AttributeError: 'NoneType' object has no " not in result
assert "Exception ignored in" not in result
def test_wrapped_actor_creation(call_ray_start_shared, shutdown_only):
"""
When the client schedules an actor, the server will load a separate
copy of the actor class if it's defined in a separate file. This
means that modifications to the client's copy of the actor class
aren't propagated to the server. Currently, tracing logic modifies
the signatures of actor methods to pass around metadata when ray.remote
is applied to an actor class. However, if a user does something like:
class SomeActor:
def __init__(self):
pass
def decorate_actor():
RemoteActor = ray.remote(SomeActor)
...
Then the SomeActor class will have its signatures modified on the client
side, but not on the server side, since ray.remote was applied inside of
the function instead of directly on the actor. Note if it were directly
applied to the actor then the signature would be modified when the server
imports the class.
"""
ray.init(SHARED_CLIENT_SERVER_ADDRESS)
run_wrapped_actor_creation()
@pytest.mark.parametrize("use_client", [True, False])
def test_init_requires_no_resources(call_ray_start_shared, use_client, shutdown_only):
if not use_client:
address = call_ray_start_shared
ray.init(address)
else:
ray.init(SHARED_CLIENT_SERVER_ADDRESS)
@ray.remote(num_cpus=0)
def f():
pass
ray.get(f.remote())
def test_object_ref_release(call_ray_start_shared, shutdown_only):
ray.init(SHARED_CLIENT_SERVER_ADDRESS)
a = ray.put("Hello")
ray.shutdown()
ray.init(SHARED_CLIENT_SERVER_ADDRESS)
del a
with disable_client_hook():
ref_cnt = ray.util.client.ray.get_context().client_worker.reference_count
assert all(v > 0 for v in ref_cnt.values())
def test_empty_objects(call_ray_start_shared):
"""
Tests that client works with "empty" objects. Sanity check, since put requests
will fail if the serialized version of an object consists of zero bytes.
"""
objects = [0, b"", "", [], np.array(()), {}, set(), None]
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
for obj in objects:
ref = ray.put(obj)
if isinstance(obj, np.ndarray):
assert np.array_equal(ray.get(ref), obj)
else:
assert ray.get(ref) == obj
def test_large_remote_call(call_ray_start_shared):
"""
Test remote calls with large (multiple chunk) arguments
"""
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
@ray.remote
def f(large_obj):
return large_obj.shape
@ray.remote
def f2(*args):
assert args[0] == 123
return args[1].shape
@ray.remote
def f3(*args, **kwargs):
assert args[0] == "a"
assert args[1] == "b"
return kwargs["large_obj"].shape
# 1024x1024x16 f64's =~ 128 MiB. Chunking size is 64 MiB, so guarantees
# that transferring argument requires multiple chunks.
assert OBJECT_TRANSFER_CHUNK_SIZE < 2**20 * 128
large_obj = np.random.random((1024, 1024, 16))
assert ray.get(f.remote(large_obj)) == (1024, 1024, 16)
assert ray.get(f2.remote(123, large_obj)) == (1024, 1024, 16)
assert ray.get(f3.remote("a", "b", large_obj=large_obj)) == (1024, 1024, 16)
@ray.remote
class SomeActor:
def __init__(self, large_obj):
self.inner = large_obj
def some_method(self, large_obj):
return large_obj.shape == self.inner.shape
a = SomeActor.remote(large_obj)
assert ray.get(a.some_method.remote(large_obj))
def test_ignore_reinit(call_ray_start_shared, shutdown_only):
ctx1 = ray.init(SHARED_CLIENT_SERVER_ADDRESS)
ctx2 = ray.init(SHARED_CLIENT_SERVER_ADDRESS, ignore_reinit_error=True)
assert ctx1 == ctx2
def test_client_actor_missing_field(call_ray_start_shared):
"""
Tests that trying to access methods that don't exist for an actor
raises the correct exception.
"""
class SomeSuperClass:
def parent_func(self):
return 24
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
@ray.remote
class SomeClass(SomeSuperClass):
def child_func(self):
return 42
handle = SomeClass.remote()
assert ray.get(handle.parent_func.remote()) == 24
assert ray.get(handle.child_func.remote()) == 42
with pytest.raises(AttributeError):
# We should raise attribute error when accessing a non-existent func
_ = SomeClass.nonexistent_func
def test_serialize_client_actor_handle(call_ray_start_shared):
"""
Test that client actor handles can be serialized. This is needed since
some objects like datasets keep a handle to actors.
See https://github.com/ray-project/ray/issues/31581 for more context
"""
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
@ray.remote
class SomeClass:
def __init__(self, value):
self.value = value
def get_value(self):
return self.value
handle = SomeClass.remote(1234)
serialized = cloudpickle.dumps(handle)
deserialized = cloudpickle.loads(serialized)
assert ray.get(deserialized.get_value.remote()) == 1234
def test_actor_streaming_returns_error_message(call_ray_start_shared):
"""
num_returns="streaming" is not supported with Ray Client.
"""
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
@ray.remote
class Actor:
def stream(self):
yield "hi"
a = Actor.remote()
with pytest.raises(
RuntimeError,
match=re.escape(
'Streaming actor methods (num_returns="streaming") are '
"not currently supported when using Ray Client."
),
):
a.stream.options(num_returns="streaming").remote()
def test_get_runtime_context_gcs_client(call_ray_start_shared):
"""
Tests get_runtime_context gcs_client
"""
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
context = ray.get_runtime_context()
assert context.gcs_address, "gcs_address not set"
def test_get_runtime_context_session_name_client(call_ray_start_shared):
"""
Tests get_runtime_context get_session_name in client mode
"""
with ray_start_client_server_for_address(call_ray_start_shared) as ray:
context = ray.get_runtime_context()
session_name = context.get_session_name()
assert isinstance(session_name, str), "session_name should be a string"
assert len(session_name) > 0, "session_name should not be empty"
@ray.remote
def verify_session_name(expected_session_name):
rtc = ray.get_runtime_context()
assert isinstance(rtc.get_session_name(), str)
assert rtc.get_session_name() == expected_session_name
return True
# Verify session name is consistent across driver and remote tasks
ray.get(verify_session_name.remote(session_name))
def test_internal_kv_in_proxy_mode(call_ray_start_shared):
ray.init(SHARED_CLIENT_SERVER_ADDRESS)
client_api = ray.util.client.ray
client_api._internal_kv_put(b"key", b"val")
assert client_api._internal_kv_get(b"key") == b"val"
assert client_api._internal_kv_del(b"key") == 1
assert client_api._internal_kv_get(b"key") is None
@pytest.mark.parametrize(
"uv_cmdline,runtime_env,expected_has_py_exec,expected_in_py_exec",
[
# UV detected - should set py_executable
(
["uv", "run", "--no-project", "--locked", "--python", "3.11", "script.py"],
{"env_vars": {"TEST": "value"}},
True,
["uv run", "--locked"],
),
# No UV detected - should return runtime_env unchanged
(
None,
{"env_vars": {"TEST": "value"}},
False,
[],
),
# Empty runtime_env - should still detect UV
(
["uv", "run", "--no-project", "--python", "3.11", "script.py"],
{},
True,
["uv run"],
),
],
ids=["uv_detected_with_flags", "no_uv_detected", "empty_runtime_env"],
)
def test_ray_client_uv_hook_detection(
uv_cmdline, runtime_env, expected_has_py_exec, expected_in_py_exec
):
"""Test that _apply_uv_hook_for_client correctly detects and applies UV config.
Related to: https://github.com/ray-project/ray/issues/57991
"""
with patch(
"ray._private.runtime_env.uv_runtime_env_hook._get_uv_run_cmdline"
) as mock_uv:
mock_uv.return_value = uv_cmdline
result = _apply_uv_hook_for_client(runtime_env)
if expected_has_py_exec:
assert "py_executable" in result
for expected_str in expected_in_py_exec:
assert expected_str in result["py_executable"]
# Check original settings preserved
for key, value in runtime_env.items():
assert result[key] == value
else:
assert "py_executable" not in result
assert result == runtime_env
def test_ray_client_uv_hook_none_runtime_env():
"""Test UV hook handles None runtime_env gracefully.
Related to: https://github.com/ray-project/ray/issues/57991
"""
with patch(
"ray._private.runtime_env.uv_runtime_env_hook._get_uv_run_cmdline"
) as mock_uv:
mock_uv.return_value = None
result = _apply_uv_hook_for_client(None)
assert result is None
def test_ray_client_uv_hook_error_handling():
"""Test that hook failures propagate as exceptions.
Related to: https://github.com/ray-project/ray/issues/57991
"""
with patch(
"ray._private.runtime_env.uv_runtime_env_hook.hook",
side_effect=RuntimeError("mock error"),
):
original = {"env_vars": {"TEST": "value"}}
with pytest.raises(RuntimeError, match="RAY_ENABLE_UV_RUN_RUNTIME_ENV=0"):
_apply_uv_hook_for_client(original)
def test_ray_client_uv_hook_feature_flag_disabled():
"""Test that UV hook can be disabled via feature flag.
Related to: https://github.com/ray-project/ray/issues/57991
"""
with patch("ray._private.ray_constants.RAY_ENABLE_UV_RUN_RUNTIME_ENV", False):
original = {"env_vars": {"TEST": "value"}}
result = _apply_uv_hook_for_client(original)
assert result == original
assert "py_executable" not in result
@pytest.mark.parametrize(
"user_py_executable",
["/custom/python", "python3.11", "uv run --custom-flags"],
ids=["custom_path", "python_version", "uv_with_custom_flags"],
)
def test_ray_client_uv_respects_user_py_executable(user_py_executable):
"""Test that user-provided py_executable takes precedence over UV.
When a user explicitly sets py_executable in their runtime_env, the UV hook
should respect that choice and not override it.
Related to: https://github.com/ray-project/ray/issues/57991
"""
with patch(
"ray._private.runtime_env.uv_runtime_env_hook._get_uv_run_cmdline"
) as mock_uv:
# UV is detected
mock_uv.return_value = [
"uv",
"run",
"--no-project",
"--python",
"3.11",
"script.py",
]
# User explicitly provides their own py_executable
runtime_env = {
"py_executable": user_py_executable,
"working_dir": "/tmp/test",
}
result = _apply_uv_hook_for_client(runtime_env)
# User's py_executable should be preserved, not overridden by UV
assert result["py_executable"] == user_py_executable
assert result["working_dir"] == "/tmp/test"
def test_ray_client_uv_hook_with_existing_runtime_env():
"""Test UV hook correctly merges with existing runtime_env settings.
Related to: https://github.com/ray-project/ray/issues/57991
"""
with patch(
"ray._private.runtime_env.uv_runtime_env_hook._get_uv_run_cmdline"
) as mock_uv:
mock_uv.return_value = ["uv", "run", "--no-project", "script.py"]
# Existing runtime_env with other settings should be preserved
runtime_env = {
"env_vars": {"MY_VAR": "value"},
"excludes": ["*.pyc", "__pycache__"],
}
result = _apply_uv_hook_for_client(runtime_env)
assert "py_executable" in result
assert result["env_vars"]["MY_VAR"] == "value"
assert result["excludes"] == ["*.pyc", "__pycache__"]
def test_ray_client_uv_hook_with_job_config_runtime_env():
"""Test UV hook works when runtime_env is in JobConfig.
Tests the code path where runtime_env comes from job_config.runtime_env
instead of ray_init_kwargs["runtime_env"]. This simulates what happens
when a user passes JobConfig(runtime_env={...}) to ray.util.connect().
Related to: https://github.com/ray-project/ray/issues/57991
"""
# Create a JobConfig with runtime_env
job_config = JobConfig(runtime_env={"env_vars": {"TEST_VAR": "test_value"}})
with patch(
"ray._private.runtime_env.uv_runtime_env_hook._get_uv_run_cmdline"
) as mock_uv:
mock_uv.return_value = [
"uv",
"run",
"--no-project",
"--python",
"3.11",
"script.py",
]
# Apply UV hook to job_config.runtime_env (as done in connect())
result = _apply_uv_hook_for_client(job_config.runtime_env)
# Verify UV was applied
assert "py_executable" in result
assert "uv run" in result["py_executable"]
assert "--python" in result["py_executable"]
assert "3.11" in result["py_executable"]
assert result["env_vars"]["TEST_VAR"] == "test_value"
@pytest.mark.parametrize(
"working_dir_uri",
[
"s3://bucket/project.zip",
"https://example.com/project.zip",
"gs://bucket/project.tar.gz",
],
ids=["s3_uri", "https_uri", "gcs_uri"],
)
def test_ray_client_uv_hook_with_remote_working_dir(working_dir_uri):
"""Test UV hook works with remote URI working_dir.
Ray supports remote URIs for working_dir. UV hook should handle them gracefully.
Related to: https://github.com/ray-project/ray/issues/57991
"""
with patch(
"ray._private.runtime_env.uv_runtime_env_hook._get_uv_run_cmdline"
) as mock_uv:
mock_uv.return_value = ["uv", "run", "--no-project", "script.py"]
runtime_env = {"working_dir": working_dir_uri}
result = _apply_uv_hook_for_client(runtime_env)
assert "py_executable" in result
assert result["working_dir"] == working_dir_uri
def test_ray_client_uv_hook_updates_job_config():
"""Test that UV hook modifications are propagated to job_config.
When runtime_env comes from job_config.runtime_env, the UV hook's
modifications (like py_executable) must be written back to job_config
because _server_init() reads from job_config.runtime_env.
Related to: https://github.com/ray-project/ray/issues/57991
"""
job_config = JobConfig(runtime_env={"env_vars": {"TEST_VAR": "test_value"}})
with patch(
"ray._private.runtime_env.uv_runtime_env_hook._get_uv_run_cmdline"
) as mock_uv:
mock_uv.return_value = [
"uv",
"run",
"--no-project",
"--python",
"3.11",
"script.py",
]
# Apply UV hook
result = _apply_uv_hook_for_client(job_config.runtime_env)
# Verify UV modifications are in the result
assert "py_executable" in result
assert "uv run" in result["py_executable"]
# Simulate what connect() does: update job_config with modified runtime_env
job_config.set_runtime_env(result)
# Verify job_config was updated
assert (
"py_executable" in job_config.runtime_env
), "UV modifications must be in job_config.runtime_env for _server_init"
assert job_config.runtime_env["env_vars"]["TEST_VAR"] == "test_value"
def test_ray_client_uv_hook_skipped_with_user_py_executable():
"""Test that UV hook is completely skipped when user provides py_executable.
When user provides py_executable, the UV hook should not run at all to avoid
unintended side effects like auto-setting working_dir (which triggers directory
upload for Ray Client).
Related to: https://github.com/ray-project/ray/issues/57991
"""
with patch(
"ray._private.runtime_env.uv_runtime_env_hook._get_uv_run_cmdline"
) as mock_uv, patch(
"ray._private.runtime_env.uv_runtime_env_hook.hook"
) as mock_hook:
mock_uv.return_value = [
"uv",
"run",
"--no-project",
"--python",
"3.11",
"script.py",
]
# User provides py_executable (no working_dir)
runtime_env = {"py_executable": "/custom/python"}
result = _apply_uv_hook_for_client(runtime_env)
# Hook should NOT be called at all
mock_hook.assert_not_called()
# Result should be unchanged (no working_dir auto-set)
assert result["py_executable"] == "/custom/python"
assert "working_dir" not in result
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))