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=)' # 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=)' # 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=)' # 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=)' # 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__]))