Files
ray-project--ray/python/ray/util/client/__init__.py
T
2026-07-13 13:17:40 +08:00

425 lines
16 KiB
Python

import logging
import os
import threading
from typing import Any, Dict, List, Optional, Tuple
import ray._private.ray_constants as ray_constants
from ray._common.network_utils import build_address, get_localhost_ip
from ray._private.client_mode_hook import (
_explicitly_disable_client_mode,
_explicitly_enable_client_mode,
)
from ray._private.ray_logging import setup_logger
from ray._private.utils import check_version_info
from ray.job_config import JobConfig
from ray.util.annotations import DeveloperAPI
logger = logging.getLogger(__name__)
def _apply_uv_hook_for_client(
runtime_env: Optional[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
"""Apply UV runtime env hook on client side before connection.
UV (https://docs.astral.sh/uv/) is a modern Python package manager that
manages dependencies via pyproject.toml and uv.lock files. This function
detects when the client is running under 'uv run' and automatically
propagates the UV configuration to cluster workers so they can install
the same dependencies.
How it works:
1. Detects 'uv run' in the parent process tree
2. Extracts UV command-line arguments (e.g., --python, --locked)
3. Sets py_executable to 'uv run [args]' in runtime_env
4. Workers will use this UV command to install dependencies
Precedence rules:
- If user provides py_executable, UV hook is skipped entirely to avoid
unintended side effects (e.g., auto-setting working_dir)
- User-provided working_dir is preserved when UV hook runs
- Other runtime_env settings are merged with UV config
Feature flag:
Controlled by RAY_ENABLE_UV_RUN_RUNTIME_ENV constant (default: enabled)
Args:
runtime_env: The runtime environment dict to potentially modify.
Can be None if no runtime_env was specified.
Returns:
Modified runtime_env dict with UV configuration if detected,
otherwise the original runtime_env unchanged. Returns None if
input was None.
Raises:
RuntimeError: If UV environment is detected but configuration is invalid
(e.g., pyproject.toml not in working_dir, conflicting runtime_env).
Validation errors fail fast to provide clear feedback.
Note:
ImportError and other environmental errors are caught and logged,
allowing connection to proceed without UV propagation.
Example:
Client running under: uv run --python 3.11 my_script.py
>>> runtime_env = {"working_dir": "/tmp/myapp"}
>>> result = _apply_uv_hook_for_client(runtime_env)
>>> result
{'working_dir': '/tmp/myapp', 'py_executable': 'uv run --python 3.11'}
See Also:
- Issue: https://github.com/ray-project/ray/issues/57991
- UV docs: https://docs.astral.sh/uv/
"""
if not ray_constants.RAY_ENABLE_UV_RUN_RUNTIME_ENV:
return runtime_env
# If user provided py_executable, skip UV hook entirely to avoid side effects
# (e.g., auto-setting working_dir which triggers unwanted directory upload)
if runtime_env and "py_executable" in runtime_env:
logger.debug(
"User-provided py_executable found, skipping UV hook to avoid "
"unintended runtime_env modifications"
)
return runtime_env
# Import hook here (not at module level) to:
# 1. Avoid circular import issues with ray._private modules
# 2. Only load UV hook code when feature flag is enabled
from ray._private.runtime_env.uv_runtime_env_hook import hook
try:
result = hook(runtime_env)
except Exception as e:
raise RuntimeError(
f"Failed to apply UV runtime env hook for Ray Client: {e} "
"If you want the driver to use UV without propagating to workers, "
"set RAY_ENABLE_UV_RUN_RUNTIME_ENV=0."
) from e
if "py_executable" in result:
# UV environment was detected and applied by the hook
logger.debug(
f"UV environment detected for Ray Client: "
f"py_executable={result['py_executable']}"
)
return result
return runtime_env
class _ClientContext:
def __init__(self):
from ray.util.client.api import _ClientAPI
self.api = _ClientAPI()
self.client_worker = None
self._server = None
self._connected_with_init = False
self._inside_client_test = False
def connect(
self,
conn_str: str,
job_config: JobConfig = None,
secure: bool = False,
metadata: List[Tuple[str, str]] = None,
connection_retries: int = 3,
namespace: str = None,
*,
ignore_version: bool = False,
_credentials: Optional["grpc.ChannelCredentials"] = None, # noqa: F821
ray_init_kwargs: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Connect the Ray Client to a server.
Args:
conn_str: Connection string, in the form "[host]:port"
job_config: The job config of the server.
secure: Whether to use a TLS secured gRPC channel
metadata: gRPC metadata to send on connect
connection_retries: number of connection attempts to make
namespace: The namespace to connect to.
ignore_version: whether to ignore Python or Ray version mismatches.
This should only be used for debugging purposes.
_credentials: Optional gRPC channel credentials for secure connection.
ray_init_kwargs: Optional additional keyword arguments for ray.init().
Returns:
Dictionary of connection info, e.g., {"num_clients": 1}.
"""
# Delay imports until connect to avoid circular imports.
from ray.util.client.worker import Worker
if self.client_worker is not None:
if self._connected_with_init:
return
raise Exception("ray.init() called, but ray client is already connected")
if not self._inside_client_test:
# If we're calling a client connect specifically and we're not
# currently in client mode, ensure we are.
_explicitly_enable_client_mode()
if namespace is not None:
job_config = job_config or JobConfig()
job_config.set_ray_namespace(namespace)
logging_level = ray_constants.LOGGER_LEVEL
logging_format = ray_constants.LOGGER_FORMAT
if ray_init_kwargs is None:
ray_init_kwargs = {}
# Apply UV hook client-side before connection.
# UV detection must happen on client side where 'uv run' process exists.
# See: https://github.com/ray-project/ray/issues/57991
#
# Runtime env can come from two sources:
# 1. ray_init_kwargs["runtime_env"] - directly passed to connect()
# 2. job_config.runtime_env - passed via JobConfig object
# We need to handle both sources and update them appropriately after UV hook.
runtime_env = ray_init_kwargs.get("runtime_env")
if runtime_env is None and job_config and job_config.runtime_env is not None:
runtime_env = job_config.runtime_env
runtime_env = _apply_uv_hook_for_client(runtime_env)
if runtime_env is not None:
# Update both ray_init_kwargs and job_config with UV modifications.
# This is necessary because _server_init() reads runtime_env from
# job_config.runtime_env, not from ray_init_kwargs["runtime_env"].
ray_init_kwargs["runtime_env"] = runtime_env
if job_config:
job_config.set_runtime_env(runtime_env)
# NOTE(architkulkarni): Custom env_hook is not supported with Ray Client.
# However, UV hook is now applied client-side above.
ray_init_kwargs["_skip_env_hook"] = True
if ray_init_kwargs.get("logging_level") is not None:
logging_level = ray_init_kwargs["logging_level"]
if ray_init_kwargs.get("logging_format") is not None:
logging_format = ray_init_kwargs["logging_format"]
setup_logger(logging_level, logging_format)
try:
self.client_worker = Worker(
conn_str,
secure=secure,
_credentials=_credentials,
metadata=metadata,
connection_retries=connection_retries,
)
self.api.worker = self.client_worker
self.client_worker._server_init(job_config, ray_init_kwargs)
conn_info = self.client_worker.connection_info()
self._check_versions(conn_info, ignore_version)
self._register_serializers()
return conn_info
except Exception:
self.disconnect()
raise
def _register_serializers(self):
"""Register the custom serializer addons at the client side.
The server side should have already registered the serializers via
regular worker's serialization_context mechanism.
"""
import ray.util.serialization_addons
from ray.util.serialization import StandaloneSerializationContext
ctx = StandaloneSerializationContext()
ray.util.serialization_addons.apply(ctx)
def _check_versions(self, conn_info: Dict[str, Any], ignore_version: bool) -> None:
# conn_info has "python_version" and "ray_version" so it can be used to compare.
ignore_version = ignore_version or ("RAY_IGNORE_VERSION_MISMATCH" in os.environ)
check_version_info(
conn_info,
"Ray Client",
raise_on_mismatch=not ignore_version,
python_version_match_level="minor",
)
def disconnect(self):
"""Disconnect the Ray Client."""
from ray.util.client.api import _ClientAPI
if self.client_worker is not None:
self.client_worker.close()
self.api = _ClientAPI()
self.client_worker = None
# remote can be called outside of a connection, which is why it
# exists on the same API layer as connect() itself.
def remote(self, *args: Any, **kwargs: Any):
"""remote is the hook stub passed on to replace `ray.remote`.
This sets up remote functions or actors, as the decorator,
but does not execute them.
Args:
*args: opaque arguments forwarded to ``_ClientAPI.remote``.
**kwargs: opaque keyword arguments forwarded to ``_ClientAPI.remote``.
Returns:
A client-side stub for the remote function or actor, or a
decorator that produces one when applied.
"""
return self.api.remote(*args, **kwargs)
def __getattr__(self, key: str):
if self.is_connected():
return getattr(self.api, key)
elif key in ["is_initialized", "_internal_kv_initialized"]:
# Client is not connected, thus Ray is not considered initialized.
return lambda: False
else:
raise Exception(
"Ray Client is not connected. Please connect by calling `ray.init`."
)
def is_connected(self) -> bool:
if self.client_worker is None:
return False
return self.client_worker.is_connected()
def init(self, *args, **kwargs):
if self._server is not None:
raise Exception("Trying to start two instances of ray via client")
import ray.util.client.server.server as ray_client_server
server_handle, address_info = ray_client_server.init_and_serve(
get_localhost_ip(), 50051, *args, **kwargs
)
self._server = server_handle.grpc_server
self.connect(build_address(get_localhost_ip(), 50051))
self._connected_with_init = True
return address_info
def shutdown(self, _exiting_interpreter=False):
self.disconnect()
import ray.util.client.server.server as ray_client_server
if self._server is None:
return
ray_client_server.shutdown_with_server(self._server, _exiting_interpreter)
self._server = None
# All connected context will be put here
# This struct will be guarded by a lock for thread safety
_all_contexts = set()
_lock = threading.Lock()
# This is the default context which is used when allow_multiple is not True
_default_context = _ClientContext()
@DeveloperAPI
class RayAPIStub:
"""This class stands in as the replacement API for the `import ray` module.
Much like the ray module, this mostly delegates the work to the
_client_worker. As parts of the ray API are covered, they are piped through
here or on the client worker API.
"""
def __init__(self):
self._cxt = threading.local()
self._cxt.handler = _default_context
self._inside_client_test = False
def get_context(self):
try:
return self._cxt.__getattribute__("handler")
except AttributeError:
self._cxt.handler = _default_context
return self._cxt.handler
def set_context(self, cxt):
old_cxt = self.get_context()
if cxt is None:
self._cxt.handler = _ClientContext()
else:
self._cxt.handler = cxt
return old_cxt
def is_default(self):
return self.get_context() == _default_context
def connect(self, *args, **kw_args):
self.get_context()._inside_client_test = self._inside_client_test
conn = self.get_context().connect(*args, **kw_args)
global _lock, _all_contexts
with _lock:
_all_contexts.add(self._cxt.handler)
return conn
def disconnect(self, *args, **kw_args):
global _lock, _all_contexts, _default_context
with _lock:
if _default_context == self.get_context():
for cxt in _all_contexts:
cxt.disconnect(*args, **kw_args)
_all_contexts = set()
else:
self.get_context().disconnect(*args, **kw_args)
if self.get_context() in _all_contexts:
_all_contexts.remove(self.get_context())
if len(_all_contexts) == 0:
_explicitly_disable_client_mode()
def remote(self, *args, **kwargs):
return self.get_context().remote(*args, **kwargs)
def __getattr__(self, name):
return self.get_context().__getattr__(name)
def is_connected(self, *args, **kwargs):
return self.get_context().is_connected(*args, **kwargs)
def init(self, *args, **kwargs):
ret = self.get_context().init(*args, **kwargs)
global _lock, _all_contexts
with _lock:
_all_contexts.add(self._cxt.handler)
return ret
def shutdown(self, *args, **kwargs):
global _lock, _all_contexts
with _lock:
if _default_context == self.get_context():
for cxt in _all_contexts:
cxt.shutdown(*args, **kwargs)
_all_contexts = set()
else:
self.get_context().shutdown(*args, **kwargs)
if self.get_context() in _all_contexts:
_all_contexts.remove(self.get_context())
if len(_all_contexts) == 0:
_explicitly_disable_client_mode()
ray = RayAPIStub()
@DeveloperAPI
def num_connected_contexts():
"""Return the number of client connections active."""
global _lock, _all_contexts
with _lock:
return len(_all_contexts)
# Someday we might add methods in this module so that someone who
# tries to `import ray_client as ray` -- as a module, instead of
# `from ray_client import ray` -- as the API stub
# still gets expected functionality. This is the way the ray package
# worked in the past.
#
# This really calls for PEP 562: https://www.python.org/dev/peps/pep-0562/
# But until Python 3.6 is EOL, here we are.