chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,42 @@
# TODO(hjiang): All existing pythons are not using bazel as build system, which leads to missing BUILD file and targets.
# Revisit if we decide to support bazel build in the future.
load("@rules_python//python:defs.bzl", "py_library")
package(default_visibility = ["//visibility:public"])
py_library(
name = "validation",
srcs = ["validation.py"],
)
py_library(
name = "utils",
srcs = ["utils.py"],
)
py_library(
name = "virtualenv_utils",
srcs = ["virtualenv_utils.py"],
deps = [
":utils",
],
)
py_library(
name = "dependency_utils",
srcs = ["dependency_utils.py"],
deps = [
":utils",
],
)
py_library(
name = "uv",
srcs = ["uv.py"],
deps = [
":dependency_utils",
":utils",
":virtualenv_utils",
],
)
@@ -0,0 +1,3 @@
# List of files to exclude from the Ray directory when using runtime_env for
# Ray development. These are not necessary in the Ray workers.
RAY_WORKER_DEV_EXCLUDES = ["raylet", "gcs_server", "cpp/", "tests/", "core/src"]
@@ -0,0 +1,334 @@
#!/usr/bin/env python
from __future__ import with_statement
import logging
import optparse
import os
import os.path
import re
import shutil
import subprocess
import sys
import itertools
__version__ = "0.5.7"
logger = logging.getLogger()
env_bin_dir = "bin"
if sys.platform == "win32":
env_bin_dir = "Scripts"
_WIN32 = True
else:
_WIN32 = False
class UserError(Exception):
pass
def _dirmatch(path, matchwith):
"""Check if path is within matchwith's tree.
>>> _dirmatch('/home/foo/bar', '/home/foo/bar')
True
>>> _dirmatch('/home/foo/bar/', '/home/foo/bar')
True
>>> _dirmatch('/home/foo/bar/etc', '/home/foo/bar')
True
>>> _dirmatch('/home/foo/bar2', '/home/foo/bar')
False
>>> _dirmatch('/home/foo/bar2/etc', '/home/foo/bar')
False
"""
matchlen = len(matchwith)
if path.startswith(matchwith) and path[matchlen : matchlen + 1] in [os.sep, ""]:
return True
return False
def _virtualenv_sys(venv_path):
"""obtain version and path info from a virtualenv."""
executable = os.path.join(venv_path, env_bin_dir, "python")
if _WIN32:
env = os.environ.copy()
else:
env = {}
# Must use "executable" as the first argument rather than as the
# keyword argument "executable" to get correct value from sys.path
p = subprocess.Popen(
[
executable,
"-c",
"import sys;"
'print ("%d.%d" % (sys.version_info.major, sys.version_info.minor));'
'print ("\\n".join(sys.path));',
],
env=env,
stdout=subprocess.PIPE,
)
stdout, err = p.communicate()
assert not p.returncode and stdout
lines = stdout.decode("utf-8").splitlines()
return lines[0], list(filter(bool, lines[1:]))
def clone_virtualenv(src_dir, dst_dir):
if not os.path.exists(src_dir):
raise UserError("src dir %r does not exist" % src_dir)
if os.path.exists(dst_dir):
raise UserError("dest dir %r exists" % dst_dir)
# sys_path = _virtualenv_syspath(src_dir)
logger.info("cloning virtualenv '%s' => '%s'..." % (src_dir, dst_dir))
shutil.copytree(
src_dir, dst_dir, symlinks=True, ignore=shutil.ignore_patterns("*.pyc")
)
version, sys_path = _virtualenv_sys(dst_dir)
logger.info("fixing scripts in bin...")
fixup_scripts(src_dir, dst_dir, version)
has_old = lambda s: any(i for i in s if _dirmatch(i, src_dir)) # noqa: E731
if has_old(sys_path):
# only need to fix stuff in sys.path if we have old
# paths in the sys.path of new python env. right?
logger.info("fixing paths in sys.path...")
fixup_syspath_items(sys_path, src_dir, dst_dir)
v_sys = _virtualenv_sys(dst_dir)
remaining = has_old(v_sys[1])
assert not remaining, v_sys
fix_symlink_if_necessary(src_dir, dst_dir)
def fix_symlink_if_necessary(src_dir, dst_dir):
# sometimes the source virtual environment has symlinks that point to itself
# one example is $OLD_VIRTUAL_ENV/local/lib points to $OLD_VIRTUAL_ENV/lib
# this function makes sure
# $NEW_VIRTUAL_ENV/local/lib will point to $NEW_VIRTUAL_ENV/lib
# usually this goes unnoticed unless one tries to upgrade a package though pip,
# so this bug is hard to find.
logger.info("scanning for internal symlinks that point to the original virtual env")
for dirpath, dirnames, filenames in os.walk(dst_dir):
for a_file in itertools.chain(filenames, dirnames):
full_file_path = os.path.join(dirpath, a_file)
if os.path.islink(full_file_path):
target = os.path.realpath(full_file_path)
if target.startswith(src_dir):
new_target = target.replace(src_dir, dst_dir)
logger.debug("fixing symlink in %s" % (full_file_path,))
os.remove(full_file_path)
os.symlink(new_target, full_file_path)
def fixup_scripts(old_dir, new_dir, version, rewrite_env_python=False):
bin_dir = os.path.join(new_dir, env_bin_dir)
root, dirs, files = next(os.walk(bin_dir))
pybinre = re.compile(r"pythonw?([0-9]+(\.[0-9]+(\.[0-9]+)?)?)?$")
for file_ in files:
filename = os.path.join(root, file_)
if file_ in ["python", "python%s" % version, "activate_this.py"]:
continue
elif file_.startswith("python") and pybinre.match(file_):
# ignore other possible python binaries
continue
elif file_.endswith(".pyc"):
# ignore compiled files
continue
elif file_ == "activate" or file_.startswith("activate."):
fixup_activate(os.path.join(root, file_), old_dir, new_dir)
elif os.path.islink(filename):
fixup_link(filename, old_dir, new_dir)
elif os.path.isfile(filename):
fixup_script_(
root,
file_,
old_dir,
new_dir,
version,
rewrite_env_python=rewrite_env_python,
)
def fixup_script_(root, file_, old_dir, new_dir, version, rewrite_env_python=False):
old_shebang = "#!%s/bin/python" % os.path.normcase(os.path.abspath(old_dir))
new_shebang = "#!%s/bin/python" % os.path.normcase(os.path.abspath(new_dir))
env_shebang = "#!/usr/bin/env python"
filename = os.path.join(root, file_)
with open(filename, "rb") as f:
if f.read(2) != b"#!":
# no shebang
return
f.seek(0)
lines = f.readlines()
if not lines:
# warn: empty script
return
def rewrite_shebang(version=None):
logger.debug("fixing %s" % filename)
shebang = new_shebang
if version:
shebang = shebang + version
shebang = (shebang + "\n").encode("utf-8")
with open(filename, "wb") as f:
f.write(shebang)
f.writelines(lines[1:])
try:
bang = lines[0].decode("utf-8").strip()
except UnicodeDecodeError:
# binary file
return
# This takes care of the scheme in which shebang is of type
# '#!/venv/bin/python3' while the version of system python
# is of type 3.x e.g. 3.5.
short_version = bang[len(old_shebang) :]
if not bang.startswith("#!"):
return
elif bang == old_shebang:
rewrite_shebang()
elif bang.startswith(old_shebang) and bang[len(old_shebang) :] == version:
rewrite_shebang(version)
elif (
bang.startswith(old_shebang)
and short_version
and bang[len(old_shebang) :] == short_version
):
rewrite_shebang(short_version)
elif rewrite_env_python and bang.startswith(env_shebang):
if bang == env_shebang:
rewrite_shebang()
elif bang[len(env_shebang) :] == version:
rewrite_shebang(version)
else:
# can't do anything
return
def fixup_activate(filename, old_dir, new_dir):
logger.debug("fixing %s" % filename)
with open(filename, "rb") as f:
data = f.read().decode("utf-8")
data = data.replace(old_dir, new_dir)
with open(filename, "wb") as f:
f.write(data.encode("utf-8"))
def fixup_link(filename, old_dir, new_dir, target=None):
logger.debug("fixing %s" % filename)
if target is None:
target = os.readlink(filename)
origdir = os.path.dirname(os.path.abspath(filename)).replace(new_dir, old_dir)
if not os.path.isabs(target):
target = os.path.abspath(os.path.join(origdir, target))
rellink = True
else:
rellink = False
if _dirmatch(target, old_dir):
if rellink:
# keep relative links, but don't keep original in case it
# traversed up out of, then back into the venv.
# so, recreate a relative link from absolute.
target = target[len(origdir) :].lstrip(os.sep)
else:
target = target.replace(old_dir, new_dir, 1)
# else: links outside the venv, replaced with absolute path to target.
_replace_symlink(filename, target)
def _replace_symlink(filename, newtarget):
tmpfn = "%s.new" % filename
os.symlink(newtarget, tmpfn)
os.rename(tmpfn, filename)
def fixup_syspath_items(syspath, old_dir, new_dir):
for path in syspath:
if not os.path.isdir(path):
continue
path = os.path.normcase(os.path.abspath(path))
if _dirmatch(path, old_dir):
path = path.replace(old_dir, new_dir, 1)
if not os.path.exists(path):
continue
elif not _dirmatch(path, new_dir):
continue
root, dirs, files = next(os.walk(path))
for file_ in files:
filename = os.path.join(root, file_)
if filename.endswith(".pth"):
fixup_pth_file(filename, old_dir, new_dir)
elif filename.endswith(".egg-link"):
fixup_egglink_file(filename, old_dir, new_dir)
def fixup_pth_file(filename, old_dir, new_dir):
logger.debug("fixup_pth_file %s" % filename)
with open(filename, "r") as f:
lines = f.readlines()
has_change = False
for num, line in enumerate(lines):
line = (line.decode("utf-8") if hasattr(line, "decode") else line).strip()
if not line or line.startswith("#") or line.startswith("import "):
continue
elif _dirmatch(line, old_dir):
lines[num] = line.replace(old_dir, new_dir, 1)
has_change = True
if has_change:
with open(filename, "w") as f:
payload = os.linesep.join([line.strip() for line in lines]) + os.linesep
f.write(payload)
def fixup_egglink_file(filename, old_dir, new_dir):
logger.debug("fixing %s" % filename)
with open(filename, "rb") as f:
link = f.read().decode("utf-8").strip()
if _dirmatch(link, old_dir):
link = link.replace(old_dir, new_dir, 1)
with open(filename, "wb") as f:
link = (link + "\n").encode("utf-8")
f.write(link)
def main():
parser = optparse.OptionParser(
"usage: %prog [options] /path/to/existing/venv /path/to/cloned/venv"
)
parser.add_option(
"-v", action="count", dest="verbose", default=False, help="verbosity"
)
options, args = parser.parse_args()
try:
old_dir, new_dir = args
except ValueError:
print("virtualenv-clone %s" % (__version__,))
parser.error("not enough arguments given.")
old_dir = os.path.realpath(old_dir)
new_dir = os.path.realpath(new_dir)
loglevel = (logging.WARNING, logging.INFO, logging.DEBUG)[min(2, options.verbose)]
logging.basicConfig(level=loglevel, format="%(message)s")
try:
clone_virtualenv(old_dir, new_dir)
except UserError:
e = sys.exc_info()[1]
parser.error(str(e))
if __name__ == "__main__":
main()
@@ -0,0 +1,266 @@
import argparse
import logging
import os
import socket
import sys
import ray
import ray._private.ray_constants as ray_constants
from ray._common.utils import (
get_or_create_event_loop,
)
from ray._private import logging_utils
from ray._private.authentication.http_token_authentication import (
get_token_auth_middleware,
)
from ray._private.process_watcher import create_check_raylet_task
from ray._raylet import RUNTIME_ENV_AGENT_PORT_NAME, GcsClient, persist_port
from ray.core.generated import (
runtime_env_agent_pb2,
)
def import_libs():
my_dir = os.path.abspath(os.path.dirname(__file__))
sys.path.insert(0, os.path.join(my_dir, "thirdparty_files")) # for aiohttp
sys.path.insert(0, my_dir) # for runtime_env_agent and runtime_env_consts
import_libs()
import aiohttp # noqa: E402
import runtime_env_consts # noqa: E402
from aiohttp import web # noqa: E402
from runtime_env_agent import RuntimeEnvAgent # noqa: E402
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Runtime env agent.")
parser.add_argument(
"--node-id",
required=True,
type=str,
help="the unique ID of this node.",
)
parser.add_argument(
"--node-ip-address",
required=True,
type=str,
help="the IP address of this node.",
)
parser.add_argument(
"--runtime-env-agent-port",
required=True,
type=int,
default=None,
help="The port on which the runtime env agent will receive HTTP requests.",
)
parser.add_argument(
"--session-dir",
required=True,
type=str,
default=None,
help="The path of this ray session directory.",
)
parser.add_argument(
"--gcs-address", required=True, type=str, help="The address (ip:port) of GCS."
)
parser.add_argument(
"--cluster-id-hex", required=True, type=str, help="The cluster id in hex."
)
parser.add_argument(
"--runtime-env-dir",
required=True,
type=str,
default=None,
help="Specify the path of the resource directory used by runtime_env.",
)
parser.add_argument(
"--logging-level",
required=False,
type=lambda s: logging.getLevelName(s.upper()),
default=ray_constants.LOGGER_LEVEL,
choices=ray_constants.LOGGER_LEVEL_CHOICES,
help=ray_constants.LOGGER_LEVEL_HELP,
)
parser.add_argument(
"--logging-format",
required=False,
type=str,
default=ray_constants.LOGGER_FORMAT,
help=ray_constants.LOGGER_FORMAT_HELP,
)
parser.add_argument(
"--logging-filename",
required=False,
type=str,
default=runtime_env_consts.RUNTIME_ENV_AGENT_LOG_FILENAME,
help="Specify the name of log file, "
'log to stdout if set empty, default is "{}".'.format(
runtime_env_consts.RUNTIME_ENV_AGENT_LOG_FILENAME
),
)
parser.add_argument(
"--logging-rotate-bytes",
required=True,
type=int,
help="Specify the max bytes for rotating log file",
)
parser.add_argument(
"--logging-rotate-backup-count",
required=True,
type=int,
help="Specify the backup count of rotated log file",
)
parser.add_argument(
"--log-dir",
required=True,
type=str,
default=None,
help="Specify the path of log directory.",
)
parser.add_argument(
"--temp-dir",
required=True,
type=str,
default=None,
help="Specify the path of the temporary directory use by Ray process.",
)
parser.add_argument(
"--stdout-filepath",
required=False,
type=str,
default="",
help="The filepath to dump runtime env agent stdout.",
)
parser.add_argument(
"--stderr-filepath",
required=False,
type=str,
default="",
help="The filepath to dump runtime env agent stderr.",
)
args = parser.parse_args()
# Disable log rotation for windows platform.
logging_rotation_bytes = args.logging_rotate_bytes if sys.platform != "win32" else 0
logging_rotation_backup_count = (
args.logging_rotate_backup_count if sys.platform != "win32" else 1
)
logging_params = dict(
logging_level=args.logging_level,
logging_format=args.logging_format,
log_dir=args.log_dir,
filename=args.logging_filename,
max_bytes=logging_rotation_bytes,
backup_count=logging_rotation_backup_count,
)
# Setup stdout/stderr redirect files if redirection enabled.
logging_utils.redirect_stdout_stderr_if_needed(
args.stdout_filepath,
args.stderr_filepath,
logging_rotation_bytes,
logging_rotation_backup_count,
)
gcs_client = GcsClient(address=args.gcs_address, cluster_id=args.cluster_id_hex)
agent = RuntimeEnvAgent(
runtime_env_dir=args.runtime_env_dir,
logging_params=logging_params,
gcs_client=gcs_client,
temp_dir=args.temp_dir,
address=args.node_ip_address,
runtime_env_agent_port=args.runtime_env_agent_port,
)
ray._raylet.setproctitle(ray_constants.AGENT_PROCESS_TYPE_RUNTIME_ENV_AGENT)
# POST /get_or_create_runtime_env
# body is serialzied protobuf GetOrCreateRuntimeEnvRequest
# reply is serialzied protobuf GetOrCreateRuntimeEnvReply
async def get_or_create_runtime_env(request: web.Request) -> web.Response:
data = await request.read()
request = runtime_env_agent_pb2.GetOrCreateRuntimeEnvRequest()
request.ParseFromString(data)
reply = await agent.GetOrCreateRuntimeEnv(request)
return web.Response(
body=reply.SerializeToString(), content_type="application/octet-stream"
)
# POST /delete_runtime_env_if_possible
# body is serialzied protobuf DeleteRuntimeEnvIfPossibleRequest
# reply is serialzied protobuf DeleteRuntimeEnvIfPossibleReply
async def delete_runtime_env_if_possible(request: web.Request) -> web.Response:
data = await request.read()
request = runtime_env_agent_pb2.DeleteRuntimeEnvIfPossibleRequest()
request.ParseFromString(data)
reply = await agent.DeleteRuntimeEnvIfPossible(request)
return web.Response(
body=reply.SerializeToString(), content_type="application/octet-stream"
)
# POST /get_runtime_envs_info
# body is serialzied protobuf GetRuntimeEnvsInfoRequest
# reply is serialzied protobuf GetRuntimeEnvsInfoReply
async def get_runtime_envs_info(request: web.Request) -> web.Response:
data = await request.read()
request = runtime_env_agent_pb2.GetRuntimeEnvsInfoRequest()
request.ParseFromString(data)
reply = await agent.GetRuntimeEnvsInfo(request)
return web.Response(
body=reply.SerializeToString(), content_type="application/octet-stream"
)
app = web.Application(middlewares=[get_token_auth_middleware(aiohttp)])
app.router.add_post("/get_or_create_runtime_env", get_or_create_runtime_env)
app.router.add_post(
"/delete_runtime_env_if_possible", delete_runtime_env_if_possible
)
app.router.add_post("/get_runtime_envs_info", get_runtime_envs_info)
loop = get_or_create_event_loop()
check_raylet_task = None
if sys.platform not in ["win32", "cygwin"]:
def parent_dead_callback(msg):
agent._logger.info(
"Raylet is dead! Exiting Runtime Env Agent. "
f"addr: {args.node_ip_address}, "
f"port: {args.runtime_env_agent_port}\n"
f"{msg}"
)
# No need to await this task.
check_raylet_task = create_check_raylet_task(
args.log_dir, gcs_client, parent_dead_callback, loop
)
port = args.runtime_env_agent_port or 0
infos = socket.getaddrinfo(args.node_ip_address, port, type=socket.SOCK_STREAM)
family, socktype, proto, _, sockaddr = infos[0]
sock = socket.socket(family, socktype, proto)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(sockaddr)
bound_port = sock.getsockname()[1]
persist_port(
args.session_dir,
args.node_id,
RUNTIME_ENV_AGENT_PORT_NAME,
bound_port,
)
try:
web.run_app(app, sock=sock, loop=loop)
except SystemExit as e:
agent._logger.info(f"SystemExit! {e}")
# We have to poke the task exception, or there's an error message
# "task exception was never retrieved".
if check_raylet_task is not None:
check_raylet_task.exception()
sys.exit(e.code)
@@ -0,0 +1,620 @@
import asyncio
import logging
import os
import time
import traceback
from collections import defaultdict
from dataclasses import dataclass
from typing import Callable, Dict, List, Set, Tuple
import ray
import ray._private.runtime_env.agent.runtime_env_consts as runtime_env_consts
from ray._common.utils import get_or_create_event_loop
from ray._private.ray_constants import (
DEFAULT_RUNTIME_ENV_TIMEOUT_SECONDS,
)
from ray._private.ray_logging import setup_component_logger
from ray._private.runtime_env.conda import CondaPlugin
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.default_impl import get_image_uri_plugin_cls
from ray._private.runtime_env.image_uri import ContainerPlugin
from ray._private.runtime_env.java_jars import JavaJarsPlugin
from ray._private.runtime_env.nsight import NsightPlugin
from ray._private.runtime_env.pip import PipPlugin
from ray._private.runtime_env.plugin import (
RuntimeEnvPlugin,
RuntimeEnvPluginManager,
create_for_plugin_if_needed,
)
from ray._private.runtime_env.py_executable import PyExecutablePlugin
from ray._private.runtime_env.py_modules import PyModulesPlugin
from ray._private.runtime_env.rocprof_sys import RocProfSysPlugin
from ray._private.runtime_env.uv import UvPlugin
from ray._private.runtime_env.working_dir import WorkingDirPlugin
from ray._raylet import GcsClient
from ray.core.generated import runtime_env_agent_pb2
from ray.core.generated.runtime_env_common_pb2 import (
RuntimeEnvState as ProtoRuntimeEnvState,
)
from ray.runtime_env import RuntimeEnv, RuntimeEnvConfig
default_logger = logging.getLogger(__name__)
# TODO(edoakes): this is used for unit tests. We should replace it with a
# better pluggability mechanism once available.
SLEEP_FOR_TESTING_S = os.environ.get("RAY_RUNTIME_ENV_SLEEP_FOR_TESTING_S")
@dataclass
class CreatedEnvResult:
# Whether or not the env was installed correctly.
success: bool
# If success is True, will be a serialized RuntimeEnvContext
# If success is False, will be an error message.
result: str
# The time to create a runtime env in ms.
creation_time_ms: int
# e.g., "working_dir"
UriType = str
class ReferenceTable:
"""
The URI reference table which is used for GC.
When the reference count is decreased to zero,
the URI should be removed from this table and
added to cache if needed.
"""
def __init__(
self,
uris_parser: Callable[[RuntimeEnv], Tuple[str, UriType]],
unused_uris_callback: Callable[[List[Tuple[str, UriType]]], None],
unused_runtime_env_callback: Callable[[str], None],
):
# Runtime Environment reference table. The key is serialized runtime env and
# the value is reference count.
self._runtime_env_reference: Dict[str, int] = defaultdict(int)
# URI reference table. The key is URI parsed from runtime env and the value
# is reference count.
self._uri_reference: Dict[str, int] = defaultdict(int)
self._uris_parser = uris_parser
self._unused_uris_callback = unused_uris_callback
self._unused_runtime_env_callback = unused_runtime_env_callback
# send the `DeleteRuntimeEnvIfPossible` RPC when the client exits. The URI won't
# be leaked now because the reference count will be reset to zero when the job
# finished.
self._reference_exclude_sources: Set[str] = {
"client_server",
}
def _increase_reference_for_uris(self, uris):
default_logger.debug(f"Increase reference for uris {uris}.")
for uri, _ in uris:
self._uri_reference[uri] += 1
def _decrease_reference_for_uris(self, uris):
default_logger.debug(f"Decrease reference for uris {uris}.")
unused_uris = list()
for uri, uri_type in uris:
if self._uri_reference[uri] > 0:
self._uri_reference[uri] -= 1
if self._uri_reference[uri] == 0:
unused_uris.append((uri, uri_type))
del self._uri_reference[uri]
else:
default_logger.warning(f"URI {uri} does not exist.")
if unused_uris:
default_logger.info(f"Unused uris {unused_uris}.")
self._unused_uris_callback(unused_uris)
return unused_uris
def _increase_reference_for_runtime_env(self, serialized_env: str):
default_logger.debug(f"Increase reference for runtime env {serialized_env}.")
self._runtime_env_reference[serialized_env] += 1
def _decrease_reference_for_runtime_env(self, serialized_env: str):
"""Decrease reference count for the given [serialized_env]. Throw exception if we cannot decrement reference."""
default_logger.debug(f"Decrease reference for runtime env {serialized_env}.")
unused = False
if self._runtime_env_reference[serialized_env] > 0:
self._runtime_env_reference[serialized_env] -= 1
if self._runtime_env_reference[serialized_env] == 0:
unused = True
del self._runtime_env_reference[serialized_env]
else:
default_logger.warning(f"Runtime env {serialized_env} does not exist.")
raise ValueError(
f"{serialized_env} cannot decrement reference since the reference count is 0"
)
if unused:
default_logger.info(f"Unused runtime env {serialized_env}.")
self._unused_runtime_env_callback(serialized_env)
def increase_reference(
self, runtime_env: RuntimeEnv, serialized_env: str, source_process: str
) -> None:
if source_process in self._reference_exclude_sources:
return
self._increase_reference_for_runtime_env(serialized_env)
uris = self._uris_parser(runtime_env)
self._increase_reference_for_uris(uris)
def decrease_reference(
self, runtime_env: RuntimeEnv, serialized_env: str, source_process: str
) -> None:
"""Decrease reference count for runtime env and uri. Throw exception if decrement reference count fails."""
if source_process in self._reference_exclude_sources:
return
self._decrease_reference_for_runtime_env(serialized_env)
uris = self._uris_parser(runtime_env)
self._decrease_reference_for_uris(uris)
@property
def runtime_env_refs(self) -> Dict[str, int]:
"""Return the runtime_env -> ref count mapping.
Returns:
The mapping of serialized runtime env -> ref count.
"""
return self._runtime_env_reference
class RuntimeEnvAgent:
"""An RPC server to create and delete runtime envs.
Attributes:
dashboard_agent: The DashboardAgent object contains global config.
"""
def __init__(
self,
runtime_env_dir: str,
logging_params: dict,
gcs_client: GcsClient,
temp_dir: str,
address: str,
runtime_env_agent_port: int,
):
"""Initialize the runtime env agent.
Args:
runtime_env_dir: Directory used to store runtime env resources.
logging_params: Keyword arguments forwarded to
:func:`setup_component_logger` to configure the agent logger.
gcs_client: GCS client used to fetch package data.
temp_dir: Temporary directory used by plugins (e.g. container plugin).
address: IP address that the agent is listening on, used for logging.
runtime_env_agent_port: Port that the agent is listening on, used for
logging.
"""
super().__init__()
self._logger = default_logger
self._logging_params = logging_params
self._logger = setup_component_logger(
logger_name=default_logger.name, **self._logging_params
)
# Don't propagate logs to the root logger, because these logs
# might contain sensitive information. Instead, these logs should
# be confined to the runtime env agent log file `self.LOG_FILENAME`.
self._logger.propagate = False
self._logger.info("Starting runtime env agent at pid %s", os.getpid())
self._logger.info(f"Parent raylet pid is {os.environ.get('RAY_RAYLET_PID')}")
self._runtime_env_dir = runtime_env_dir
self._per_job_logger_cache = dict()
# Cache the results of creating envs to avoid repeatedly calling into
# conda and other slow calls.
self._env_cache: Dict[str, CreatedEnvResult] = dict()
# Maps a serialized runtime env to a lock that is used
# to prevent multiple concurrent installs of the same env.
self._env_locks: Dict[str, asyncio.Lock] = dict()
self._gcs_client = gcs_client
self._pip_plugin = PipPlugin(self._runtime_env_dir)
self._uv_plugin = UvPlugin(self._runtime_env_dir)
self._conda_plugin = CondaPlugin(self._runtime_env_dir)
self._py_modules_plugin = PyModulesPlugin(
self._runtime_env_dir, self._gcs_client
)
self._py_executable_plugin = PyExecutablePlugin()
self._java_jars_plugin = JavaJarsPlugin(self._runtime_env_dir, self._gcs_client)
self._working_dir_plugin = WorkingDirPlugin(
self._runtime_env_dir, self._gcs_client
)
self._container_plugin = ContainerPlugin(temp_dir)
# TODO(jonathan-anyscale): change the plugin to ProfilerPlugin
# and unify with nsight and other profilers.
self._nsight_plugin = NsightPlugin(self._runtime_env_dir)
self._rocprof_sys_plugin = RocProfSysPlugin(self._runtime_env_dir)
self._image_uri_plugin = get_image_uri_plugin_cls()(temp_dir)
# TODO(architkulkarni): "base plugins" and third-party plugins should all go
# through the same code path. We should never need to refer to
# self._xxx_plugin, we should just iterate through self._plugins.
self._base_plugins: List[RuntimeEnvPlugin] = [
self._working_dir_plugin,
self._uv_plugin,
self._pip_plugin,
self._conda_plugin,
self._py_modules_plugin,
self._py_executable_plugin,
self._java_jars_plugin,
self._container_plugin,
self._nsight_plugin,
self._rocprof_sys_plugin,
self._image_uri_plugin,
]
self._plugin_manager = RuntimeEnvPluginManager()
for plugin in self._base_plugins:
self._plugin_manager.add_plugin(plugin)
self._reference_table = ReferenceTable(
self.uris_parser,
self.unused_uris_processor,
self.unused_runtime_env_processor,
)
self._logger.info(
"Listening to address %s, port %d", address, runtime_env_agent_port
)
try:
self._node_ip = ray.util.get_node_ip_address()
self._node_prefix = f"[Node {self._node_ip}] "
except Exception as e:
self._logger.warning(f"Failed to get node IP address, using fallback: {e}")
self._node_prefix = "[Node unknown] "
def uris_parser(self, runtime_env: RuntimeEnv):
result = list()
for name, plugin_setup_context in self._plugin_manager.plugins.items():
plugin = plugin_setup_context.class_instance
uris = plugin.get_uris(runtime_env)
for uri in uris:
result.append((uri, UriType(name)))
return result
def unused_uris_processor(self, unused_uris: List[Tuple[str, UriType]]) -> None:
for uri, uri_type in unused_uris:
self._plugin_manager.plugins[str(uri_type)].uri_cache.mark_unused(uri)
def unused_runtime_env_processor(self, unused_runtime_env: str) -> None:
def delete_runtime_env():
del self._env_cache[unused_runtime_env]
self._logger.info(
"Runtime env %s removed from env-level cache.", unused_runtime_env
)
if unused_runtime_env in self._env_cache:
if not self._env_cache[unused_runtime_env].success:
loop = get_or_create_event_loop()
# Cache the bad runtime env result by ttl seconds.
loop.call_later(
runtime_env_consts.BAD_RUNTIME_ENV_CACHE_TTL_SECONDS,
delete_runtime_env,
)
else:
delete_runtime_env()
def get_or_create_logger(self, job_id: bytes, log_files: List[str]):
job_id = job_id.decode()
if job_id not in self._per_job_logger_cache:
params = self._logging_params.copy()
params["filename"] = [f"runtime_env_setup-{job_id}.log", *log_files]
params["logger_name"] = f"runtime_env_{job_id}"
params["propagate"] = False
per_job_logger = setup_component_logger(**params)
self._per_job_logger_cache[job_id] = per_job_logger
return self._per_job_logger_cache[job_id]
async def GetOrCreateRuntimeEnv(self, request):
self._logger.debug(
f"Got request from {request.source_process} to increase "
"reference for runtime env: "
f"{request.serialized_runtime_env}."
)
async def _setup_runtime_env(
runtime_env: RuntimeEnv,
runtime_env_config: RuntimeEnvConfig,
):
log_files = runtime_env_config.get("log_files", [])
# Use a separate logger for each job.
per_job_logger = self.get_or_create_logger(request.job_id, log_files)
context = RuntimeEnvContext(env_vars=runtime_env.env_vars())
# Warn about unrecognized fields in the runtime env.
for name, _ in runtime_env.plugins():
if name not in self._plugin_manager.plugins:
per_job_logger.warning(
f"runtime_env field {name} is not recognized by "
"Ray and will be ignored. In the future, unrecognized "
"fields in the runtime_env will raise an exception."
)
# Creates each runtime env URI by their priority. `working_dir` is special
# because it needs to be created before other plugins. All other plugins are
# created in the priority order (smaller priority value -> earlier to
# create), with a special environment variable being set to the working dir.
# ${RAY_RUNTIME_ENV_CREATE_WORKING_DIR}
# First create working dir...
working_dir_ctx = self._plugin_manager.plugins[WorkingDirPlugin.name]
await create_for_plugin_if_needed(
runtime_env,
working_dir_ctx.class_instance,
working_dir_ctx.uri_cache,
context,
per_job_logger,
)
# Then within the working dir, create the other plugins.
working_dir_uri_or_none = runtime_env.working_dir_uri()
with self._working_dir_plugin.with_working_dir_env(working_dir_uri_or_none):
"""Run setup for each plugin unless it has already been cached."""
for (
plugin_setup_context
) in self._plugin_manager.sorted_plugin_setup_contexts():
plugin = plugin_setup_context.class_instance
if plugin.name != WorkingDirPlugin.name:
uri_cache = plugin_setup_context.uri_cache
await create_for_plugin_if_needed(
runtime_env, plugin, uri_cache, context, per_job_logger
)
return context
async def _create_runtime_env_with_retry(
runtime_env: RuntimeEnv,
setup_timeout_seconds: int,
runtime_env_config: RuntimeEnvConfig,
) -> Tuple[bool, str, str]:
"""Create runtime env with retry times. This function won't raise exceptions.
Args:
runtime_env: The instance of RuntimeEnv class.
setup_timeout_seconds: The timeout of runtime environment creation for
each attempt.
runtime_env_config: The configuration for the runtime environment.
Returns:
Tuple[bool, str, str]: A tuple containing:
- result (bool): Whether the creation was successful
- runtime_env_context (str): The serialized context if successful, None otherwise
- error_message (str): Error message if failed, None otherwise
"""
self._logger.info(
f"Creating runtime env: {serialized_env} with timeout "
f"{setup_timeout_seconds} seconds."
)
num_retries = runtime_env_consts.RUNTIME_ENV_RETRY_TIMES
error_message = None
serialized_context = None
for i in range(num_retries):
# Only sleep when retrying.
if i != 0:
await asyncio.sleep(
runtime_env_consts.RUNTIME_ENV_RETRY_INTERVAL_MS / 1000
)
try:
runtime_env_setup_task = _setup_runtime_env(
runtime_env, runtime_env_config
)
runtime_env_context = await asyncio.wait_for(
runtime_env_setup_task, timeout=setup_timeout_seconds
)
serialized_context = runtime_env_context.serialize()
error_message = None
break
except Exception as e:
err_msg = f"Failed to create runtime env {serialized_env}."
self._logger.exception(err_msg)
error_message = "".join(
traceback.format_exception(type(e), e, e.__traceback__)
)
if isinstance(e, asyncio.TimeoutError):
hint = (
f"Failed to install runtime_env within the "
f"timeout of {setup_timeout_seconds} seconds. Consider "
"increasing the timeout in the runtime_env config. "
"For example: \n"
' runtime_env={"config": {"setup_timeout_seconds":'
" 1800}, ...}\n"
"If not provided, the default timeout is "
f"{DEFAULT_RUNTIME_ENV_TIMEOUT_SECONDS} seconds. "
)
error_message = hint + error_message
if error_message:
self._logger.error(
"runtime_env creation failed %d times, giving up.",
num_retries,
)
return False, None, error_message
else:
self._logger.info(
"Successfully created runtime env: %s, context: %s",
serialized_env,
serialized_context,
)
return True, serialized_context, None
try:
serialized_env = request.serialized_runtime_env
runtime_env = RuntimeEnv.deserialize(serialized_env)
except Exception as e:
self._logger.exception(
"[Increase] Failed to parse runtime env: " f"{serialized_env}"
)
error_message = "".join(
traceback.format_exception(type(e), e, e.__traceback__)
)
return runtime_env_agent_pb2.GetOrCreateRuntimeEnvReply(
status=runtime_env_agent_pb2.AGENT_RPC_STATUS_FAILED,
error_message=f"{self._node_prefix}{error_message}",
)
# Increase reference
self._reference_table.increase_reference(
runtime_env, serialized_env, request.source_process
)
if serialized_env not in self._env_locks:
# async lock to prevent the same env being concurrently installed
self._env_locks[serialized_env] = asyncio.Lock()
async with self._env_locks[serialized_env]:
if serialized_env in self._env_cache:
serialized_context = self._env_cache[serialized_env]
result = self._env_cache[serialized_env]
if result.success:
context = result.result
self._logger.info(
"Runtime env already created "
f"successfully. Env: {serialized_env}, "
f"context: {context}"
)
return runtime_env_agent_pb2.GetOrCreateRuntimeEnvReply(
status=runtime_env_agent_pb2.AGENT_RPC_STATUS_OK,
serialized_runtime_env_context=context,
)
else:
error_message = result.result
self._logger.info(
"Runtime env already failed. "
f"Env: {serialized_env}, "
f"err: {error_message}"
)
# Recover the reference.
self._reference_table.decrease_reference(
runtime_env, serialized_env, request.source_process
)
return runtime_env_agent_pb2.GetOrCreateRuntimeEnvReply(
status=runtime_env_agent_pb2.AGENT_RPC_STATUS_FAILED,
error_message=f"{self._node_prefix}{error_message}",
)
if SLEEP_FOR_TESTING_S:
self._logger.info(f"Sleeping for {SLEEP_FOR_TESTING_S}s.")
time.sleep(int(SLEEP_FOR_TESTING_S))
runtime_env_config = RuntimeEnvConfig.from_proto(request.runtime_env_config)
# accroding to the document of `asyncio.wait_for`,
# None means disable timeout logic
setup_timeout_seconds = (
None
if runtime_env_config["setup_timeout_seconds"] == -1
else runtime_env_config["setup_timeout_seconds"]
)
start = time.perf_counter()
(
successful,
serialized_context,
error_message,
) = await _create_runtime_env_with_retry(
runtime_env,
setup_timeout_seconds,
runtime_env_config,
)
creation_time_ms = int(round((time.perf_counter() - start) * 1000, 0))
if not successful:
# Recover the reference.
self._reference_table.decrease_reference(
runtime_env, serialized_env, request.source_process
)
# Add the result to env cache.
self._env_cache[serialized_env] = CreatedEnvResult(
successful,
serialized_context if successful else error_message,
creation_time_ms,
)
# Reply the RPC
return runtime_env_agent_pb2.GetOrCreateRuntimeEnvReply(
status=runtime_env_agent_pb2.AGENT_RPC_STATUS_OK
if successful
else runtime_env_agent_pb2.AGENT_RPC_STATUS_FAILED,
serialized_runtime_env_context=serialized_context,
error_message=f"{self._node_prefix}{error_message}"
if not successful
else "",
)
async def DeleteRuntimeEnvIfPossible(self, request):
self._logger.info(
f"Got request from {request.source_process} to decrease "
"reference for runtime env: "
f"{request.serialized_runtime_env}."
)
try:
runtime_env = RuntimeEnv.deserialize(request.serialized_runtime_env)
except Exception as e:
self._logger.exception(
"[Decrease] Failed to parse runtime env: "
f"{request.serialized_runtime_env}"
)
error_message = "".join(
traceback.format_exception(type(e), e, e.__traceback__)
)
return runtime_env_agent_pb2.GetOrCreateRuntimeEnvReply(
status=runtime_env_agent_pb2.AGENT_RPC_STATUS_FAILED,
error_message=f"{self._node_prefix}{error_message}",
)
try:
self._reference_table.decrease_reference(
runtime_env, request.serialized_runtime_env, request.source_process
)
except Exception as e:
return runtime_env_agent_pb2.DeleteRuntimeEnvIfPossibleReply(
status=runtime_env_agent_pb2.AGENT_RPC_STATUS_FAILED,
error_message=f"{self._node_prefix}Failed to decrement reference for runtime env for {str(e)}",
)
return runtime_env_agent_pb2.DeleteRuntimeEnvIfPossibleReply(
status=runtime_env_agent_pb2.AGENT_RPC_STATUS_OK
)
async def GetRuntimeEnvsInfo(self, request):
"""Return the runtime env information of the node."""
# TODO(sang): Currently, it only includes runtime_env information.
# We should include the URI information which includes,
# URIs
# Caller
# Ref counts
# Cache information
# Metrics (creation time & success)
# Deleted URIs
limit = request.limit if request.HasField("limit") else -1
runtime_env_states = defaultdict(ProtoRuntimeEnvState)
runtime_env_refs = self._reference_table.runtime_env_refs
for runtime_env, ref_cnt in runtime_env_refs.items():
runtime_env_states[runtime_env].runtime_env = runtime_env
runtime_env_states[runtime_env].ref_cnt = ref_cnt
for runtime_env, result in self._env_cache.items():
runtime_env_states[runtime_env].runtime_env = runtime_env
runtime_env_states[runtime_env].success = result.success
if not result.success:
runtime_env_states[runtime_env].error = result.result
runtime_env_states[runtime_env].creation_time_ms = result.creation_time_ms
reply = runtime_env_agent_pb2.GetRuntimeEnvsInfoReply()
count = 0
for runtime_env_state in runtime_env_states.values():
if limit != -1 and count >= limit:
break
count += 1
reply.runtime_env_states.append(runtime_env_state)
reply.total = len(runtime_env_states)
return reply
@@ -0,0 +1,20 @@
import ray._private.ray_constants as ray_constants
RUNTIME_ENV_RETRY_TIMES = ray_constants.env_integer("RUNTIME_ENV_RETRY_TIMES", 3)
RUNTIME_ENV_RETRY_INTERVAL_MS = ray_constants.env_integer(
"RUNTIME_ENV_RETRY_INTERVAL_MS", 1000
)
# Cache TTL for bad runtime env. After this time, delete the cache and retry to create
# runtime env if needed.
BAD_RUNTIME_ENV_CACHE_TTL_SECONDS = ray_constants.env_integer(
"BAD_RUNTIME_ENV_CACHE_TTL_SECONDS", 60 * 10
)
RUNTIME_ENV_LOG_FILENAME = "runtime_env.log"
RUNTIME_ENV_AGENT_PORT_PREFIX = "RUNTIME_ENV_AGENT_PORT_PREFIX:"
RUNTIME_ENV_AGENT_LOG_FILENAME = "runtime_env_agent.log"
RUNTIME_ENV_AGENT_CHECK_PARENT_INTERVAL_S_ENV_NAME = (
"RAY_RUNTIME_ENV_AGENT_CHECK_PARENT_INTERVAL_S" # noqa
)
+400
View File
@@ -0,0 +1,400 @@
import hashlib
import json
import logging
import os
import runpy
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
from filelock import FileLock
import ray
from ray._common.utils import (
get_or_create_event_loop,
try_to_create_directory,
)
from ray._private.runtime_env.conda_utils import (
create_conda_env_if_needed,
delete_conda_env,
get_conda_activate_commands,
get_conda_envs,
get_conda_info_json,
)
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.packaging import Protocol, parse_uri
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray._private.runtime_env.validation import parse_and_validate_conda
from ray._private.utils import (
get_directory_size_bytes,
get_master_wheel_url,
get_release_wheel_url,
get_wheel_filename,
)
default_logger = logging.getLogger(__name__)
_WIN32 = os.name == "nt"
def _resolve_current_ray_path() -> str:
# When ray is built from source with pip install -e,
# ray.__file__ returns .../python/ray/__init__.py and this function returns
# ".../python".
# When ray is installed from a prebuilt binary, ray.__file__ returns
# .../site-packages/ray/__init__.py and this function returns
# ".../site-packages".
return os.path.split(os.path.split(ray.__file__)[0])[0]
def _get_ray_setup_spec():
"""Find the Ray setup_spec from the currently running Ray.
This function works even when Ray is built from source with pip install -e.
"""
ray_source_python_path = _resolve_current_ray_path()
setup_py_path = os.path.join(ray_source_python_path, "setup.py")
return runpy.run_path(setup_py_path)["setup_spec"]
def _resolve_install_from_source_ray_dependencies():
"""Find the Ray dependencies when Ray is installed from source."""
deps = (
_get_ray_setup_spec().install_requires + _get_ray_setup_spec().extras["default"]
)
# Remove duplicates
return list(set(deps))
def _inject_ray_to_conda_site(
conda_path, logger: Optional[logging.Logger] = default_logger
):
"""Write the current Ray site package directory to a new site"""
if _WIN32:
python_binary = os.path.join(conda_path, "python")
else:
python_binary = os.path.join(conda_path, "bin/python")
site_packages_path = (
subprocess.check_output(
[
python_binary,
"-c",
"import sysconfig; print(sysconfig.get_paths()['purelib'])",
]
)
.decode()
.strip()
)
ray_path = _resolve_current_ray_path()
logger.warning(
f"Injecting {ray_path} to environment site-packages {site_packages_path} "
"because _inject_current_ray flag is on."
)
maybe_ray_dir = os.path.join(site_packages_path, "ray")
if os.path.isdir(maybe_ray_dir):
logger.warning(f"Replacing existing ray installation with {ray_path}")
shutil.rmtree(maybe_ray_dir)
# See usage of *.pth file at
# https://docs.python.org/3/library/site.html
with open(os.path.join(site_packages_path, "ray_shared.pth"), "w") as f:
f.write(ray_path)
def _current_py_version():
return ".".join(map(str, sys.version_info[:3])) # like 3.6.10
def current_ray_pip_specifier(
logger: Optional[logging.Logger] = default_logger,
) -> Optional[str]:
"""The pip requirement specifier for the running version of Ray.
Args:
logger: Logger used to warn when the running Ray version cannot be
detected (e.g. when running a source build).
Returns:
A string which can be passed to `pip install` to install the
currently running Ray version, or None if running on a version
built from source locally (likely if you are developing Ray).
Examples:
Returns "https://s3-us-west-2.amazonaws.com/ray-wheels/[..].whl"
if running a stable release, a nightly or a specific commit
"""
if os.environ.get("RAY_CI_POST_WHEEL_TESTS"):
# Running in Buildkite CI after the wheel has been built.
# Wheels are at in the ray/.whl directory, but use relative path to
# allow for testing locally if needed.
return os.path.join(
Path(ray.__file__).resolve().parents[2], ".whl", get_wheel_filename()
)
elif ray.__commit__ == "{{RAY_COMMIT_SHA}}":
# Running on a version built from source locally.
if os.environ.get("RAY_RUNTIME_ENV_LOCAL_DEV_MODE") != "1":
logger.warning(
"Current Ray version could not be detected, most likely "
"because you have manually built Ray from source. To use "
"runtime_env in this case, set the environment variable "
"RAY_RUNTIME_ENV_LOCAL_DEV_MODE=1."
)
return None
elif "dev" in ray.__version__:
# Running on a nightly wheel.
return get_master_wheel_url()
else:
return get_release_wheel_url()
def inject_dependencies(
conda_dict: Dict[Any, Any],
py_version: str,
pip_dependencies: Optional[List[str]] = None,
) -> Dict[Any, Any]:
"""Add Ray, Python and (optionally) extra pip dependencies to a conda dict.
Args:
conda_dict: A dict representing the JSON-serialized conda
environment YAML file. This dict will be modified and returned.
py_version: A string representing a Python version to inject
into the conda dependencies, e.g. "3.7.7"
pip_dependencies: A list of pip dependencies that
will be prepended to the list of pip dependencies in
the conda dict. If the conda dict does not already have a "pip"
field, one will be created.
Returns:
The modified dict. (Note: the input argument conda_dict is modified
and returned.)
"""
if pip_dependencies is None:
pip_dependencies = []
if conda_dict.get("dependencies") is None:
conda_dict["dependencies"] = []
# Inject Python dependency.
deps = conda_dict["dependencies"]
# Add current python dependency. If the user has already included a
# python version dependency, conda will raise a readable error if the two
# are incompatible, e.g:
# ResolvePackageNotFound: - python[version='3.5.*,>=3.6']
deps.append(f"python={py_version}")
if "pip" not in deps:
deps.append("pip")
# Insert pip dependencies.
found_pip_dict = False
for dep in deps:
if isinstance(dep, dict) and dep.get("pip") and isinstance(dep["pip"], list):
dep["pip"] = pip_dependencies + dep["pip"]
found_pip_dict = True
break
if not found_pip_dict:
deps.append({"pip": pip_dependencies})
return conda_dict
def _get_conda_env_hash(conda_dict: Dict) -> str:
# Set `sort_keys=True` so that different orderings yield the same hash.
serialized_conda_spec = json.dumps(conda_dict, sort_keys=True)
hash = hashlib.sha1(serialized_conda_spec.encode("utf-8")).hexdigest()
return hash
def get_uri(runtime_env: Dict) -> Optional[str]:
"""Return `"conda://<hashed_dependencies>"`, or None if no GC required."""
conda = runtime_env.get("conda")
if conda is not None:
if isinstance(conda, str):
# User-preinstalled conda env. We don't garbage collect these, so
# we don't track them with URIs.
uri = None
elif isinstance(conda, dict):
uri = f"conda://{_get_conda_env_hash(conda_dict=conda)}"
else:
raise TypeError(
"conda field received by RuntimeEnvAgent must be "
f"str or dict, not {type(conda).__name__}."
)
else:
uri = None
return uri
def _get_conda_dict_with_ray_inserted(
runtime_env: "RuntimeEnv", # noqa: F821
logger: Optional[logging.Logger] = default_logger,
) -> Dict[str, Any]:
"""Returns the conda spec with the Ray and `python` dependency inserted."""
conda_dict = json.loads(runtime_env.conda_config())
assert conda_dict is not None
ray_pip = current_ray_pip_specifier(logger=logger)
if ray_pip:
extra_pip_dependencies = [ray_pip, "ray[default]"]
elif runtime_env.get_extension("_inject_current_ray"):
extra_pip_dependencies = _resolve_install_from_source_ray_dependencies()
else:
extra_pip_dependencies = []
conda_dict = inject_dependencies(
conda_dict, _current_py_version(), extra_pip_dependencies
)
return conda_dict
class CondaPlugin(RuntimeEnvPlugin):
name = "conda"
def __init__(self, resources_dir: str):
self._resources_dir = os.path.join(resources_dir, "conda")
try_to_create_directory(self._resources_dir)
# It is not safe for multiple processes to install conda envs
# concurrently, even if the envs are different, so use a global
# lock for all conda installs and deletions.
# See https://github.com/ray-project/ray/issues/17086
self._installs_and_deletions_file_lock = os.path.join(
self._resources_dir, "ray-conda-installs-and-deletions.lock"
)
# A set of named conda environments (instead of yaml or dict)
# that are validated to exist.
# NOTE: It has to be only used within the same thread, which
# is an event loop.
# Also, we don't need to GC this field because it is pretty small.
self._validated_named_conda_env = set()
def _get_path_from_hash(self, hash: str) -> str:
"""Generate a path from the hash of a conda or pip spec.
The output path also functions as the name of the conda environment
when using the `--prefix` option to `conda create` and `conda remove`.
Example output:
/tmp/ray/session_2021-11-03_16-33-59_356303_41018/runtime_resources
/conda/ray-9a7972c3a75f55e976e620484f58410c920db091
"""
return os.path.join(self._resources_dir, hash)
def get_uris(self, runtime_env: "RuntimeEnv") -> List[str]: # noqa: F821
"""Return the conda URI from the RuntimeEnv if it exists, else return []."""
conda_uri = runtime_env.conda_uri()
if conda_uri:
return [conda_uri]
return []
def delete_uri(
self, uri: str, logger: Optional[logging.Logger] = default_logger
) -> int:
"""Delete URI and return the number of bytes deleted."""
logger.info(f"Got request to delete URI {uri}")
protocol, hash = parse_uri(uri)
if protocol != Protocol.CONDA:
raise ValueError(
"CondaPlugin can only delete URIs with protocol "
f"conda. Received protocol {protocol}, URI {uri}"
)
conda_env_path = self._get_path_from_hash(hash)
local_dir_size = get_directory_size_bytes(conda_env_path)
with FileLock(self._installs_and_deletions_file_lock):
successful = delete_conda_env(prefix=conda_env_path, logger=logger)
if not successful:
logger.warning(f"Error when deleting conda env {conda_env_path}. ")
return 0
return local_dir_size
async def create(
self,
uri: Optional[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger = default_logger,
) -> int:
if not runtime_env.has_conda():
return 0
def _create():
result = parse_and_validate_conda(runtime_env.get("conda"))
if isinstance(result, str):
# The conda env name is given.
# In this case, we only verify if the given
# conda env exists.
# If the env is already validated, do nothing.
if result in self._validated_named_conda_env:
return 0
conda_info = get_conda_info_json()
envs = get_conda_envs(conda_info)
# We accept `result` as a conda name or full path.
if not any(result == env[0] or result == env[1] for env in envs):
raise ValueError(
f"The given conda environment '{result}' "
f"from the runtime env {runtime_env} doesn't "
"exist from the output of `conda info --json`. "
"You can only specify an env that already exists. "
f"Please make sure to create an env {result} "
)
self._validated_named_conda_env.add(result)
return 0
logger.debug(
"Setting up conda for runtime_env: " f"{runtime_env.serialize()}"
)
protocol, hash = parse_uri(uri)
conda_env_name = self._get_path_from_hash(hash)
conda_dict = _get_conda_dict_with_ray_inserted(runtime_env, logger=logger)
logger.info(f"Setting up conda environment with {runtime_env}")
with FileLock(self._installs_and_deletions_file_lock):
try:
conda_yaml_file = os.path.join(
self._resources_dir, "environment.yml"
)
with open(conda_yaml_file, "w") as file:
yaml.dump(conda_dict, file)
create_conda_env_if_needed(
conda_yaml_file, prefix=conda_env_name, logger=logger
)
finally:
os.remove(conda_yaml_file)
if runtime_env.get_extension("_inject_current_ray"):
_inject_ray_to_conda_site(conda_path=conda_env_name, logger=logger)
logger.info(f"Finished creating conda environment at {conda_env_name}")
return get_directory_size_bytes(conda_env_name)
loop = get_or_create_event_loop()
return await loop.run_in_executor(None, _create)
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
if not runtime_env.has_conda():
return
if runtime_env.conda_env_name():
conda_env_name = runtime_env.conda_env_name()
else:
protocol, hash = parse_uri(runtime_env.conda_uri())
conda_env_name = self._get_path_from_hash(hash)
context.py_executable = "python"
context.command_prefix += get_conda_activate_commands(conda_env_name)
@@ -0,0 +1,285 @@
import hashlib
import json
import logging
import os
import shutil
import subprocess
from typing import List, Optional, Tuple, Union
"""Utilities for conda. Adapted from https://github.com/mlflow/mlflow."""
# Name of environment variable indicating a path to a conda installation. Ray
# will default to running "conda" if unset.
RAY_CONDA_HOME = "RAY_CONDA_HOME"
_WIN32 = os.name == "nt"
def get_conda_activate_commands(conda_env_name: str) -> List[str]:
"""
Get a list of commands to run to silently activate the given conda env.
"""
# Checking for newer conda versions
if not _WIN32 and ("CONDA_EXE" in os.environ or RAY_CONDA_HOME in os.environ):
conda_path = get_conda_bin_executable("conda")
activate_conda_env = [
".",
f"{os.path.dirname(conda_path)}/../etc/profile.d/conda.sh",
"&&",
]
activate_conda_env += ["conda", "activate", conda_env_name]
else:
activate_path = get_conda_bin_executable("activate")
if not _WIN32:
# Use bash command syntax
activate_conda_env = ["source", activate_path, conda_env_name]
else:
conda_path = get_conda_bin_executable("conda")
activate_conda_env = [conda_path, "activate", conda_env_name]
return activate_conda_env + ["1>&2", "&&"]
def get_conda_bin_executable(executable_name: str) -> str:
"""
Return path to the specified executable, assumed to be discoverable within
a conda installation.
The conda home directory (expected to contain a 'bin' subdirectory on
linux) is configurable via the ``RAY_CONDA_HOME`` environment variable. If
``RAY_CONDA_HOME`` is unspecified, try the ``CONDA_EXE`` environment
variable set by activating conda. If neither is specified, this method
returns `executable_name`.
"""
conda_home = os.environ.get(RAY_CONDA_HOME)
if conda_home:
if _WIN32:
candidate = os.path.join(conda_home, "%s.exe" % executable_name)
if os.path.exists(candidate):
return candidate
candidate = os.path.join(conda_home, "%s.bat" % executable_name)
if os.path.exists(candidate):
return candidate
else:
return os.path.join(conda_home, "bin/%s" % executable_name)
else:
conda_home = "."
# Use CONDA_EXE as per https://github.com/conda/conda/issues/7126
if "CONDA_EXE" in os.environ:
conda_bin_dir = os.path.dirname(os.environ["CONDA_EXE"])
if _WIN32:
candidate = os.path.join(conda_home, "%s.exe" % executable_name)
if os.path.exists(candidate):
return candidate
candidate = os.path.join(conda_home, "%s.bat" % executable_name)
if os.path.exists(candidate):
return candidate
else:
return os.path.join(conda_bin_dir, executable_name)
if _WIN32:
return executable_name + ".bat"
return executable_name
def _get_conda_env_name(conda_env_path: str) -> str:
conda_env_contents = open(conda_env_path).read()
return "ray-%s" % hashlib.sha1(conda_env_contents.encode("utf-8")).hexdigest()
def create_conda_env_if_needed(
conda_yaml_file: str, prefix: str, logger: Optional[logging.Logger] = None
) -> None:
"""
Given a conda YAML, creates a conda environment containing the required
dependencies if such a conda environment doesn't already exist.
Args:
conda_yaml_file: The path to a conda `environment.yml` file.
prefix: Directory to install the environment into via
the `--prefix` option to conda create. This also becomes the name
of the conda env; i.e. it can be passed into `conda activate` and
`conda remove`
logger: Logger used to surface progress and errors; defaults to the
module logger when not provided.
"""
if logger is None:
logger = logging.getLogger(__name__)
conda_path = get_conda_bin_executable("conda")
try:
exec_cmd([conda_path, "--help"], throw_on_error=False)
except (EnvironmentError, FileNotFoundError):
raise ValueError(
f"Could not find Conda executable at '{conda_path}'. "
"Ensure Conda is installed as per the instructions at "
"https://conda.io/projects/conda/en/latest/"
"user-guide/install/index.html. "
"You can also configure Ray to look for a specific "
f"Conda executable by setting the {RAY_CONDA_HOME} "
"environment variable to the path of the Conda executable."
)
_, stdout, _ = exec_cmd([conda_path, "env", "list", "--json"])
envs = json.loads(stdout[stdout.index("{") :])["envs"]
if prefix in envs:
logger.info(f"Conda environment {prefix} already exists.")
return
create_cmd = [
conda_path,
"env",
"create",
"--file",
conda_yaml_file,
"--prefix",
prefix,
]
logger.info(f"Creating conda environment {prefix}")
exit_code, output = exec_cmd_stream_to_logger(create_cmd, logger)
if exit_code != 0:
if os.path.exists(prefix):
shutil.rmtree(prefix)
raise RuntimeError(
f"Failed to install conda environment {prefix}:\nOutput:\n{output}"
)
def delete_conda_env(prefix: str, logger: Optional[logging.Logger] = None) -> bool:
if logger is None:
logger = logging.getLogger(__name__)
logger.info(f"Deleting conda environment {prefix}")
conda_path = get_conda_bin_executable("conda")
delete_cmd = [conda_path, "remove", "-p", prefix, "--all", "-y"]
exit_code, output = exec_cmd_stream_to_logger(delete_cmd, logger)
if exit_code != 0:
logger.debug(f"Failed to delete conda environment {prefix}:\n{output}")
return False
return True
def get_conda_env_list() -> list:
"""
Get conda env list in full paths.
"""
conda_path = get_conda_bin_executable("conda")
try:
exec_cmd([conda_path, "--help"], throw_on_error=False)
except EnvironmentError:
raise ValueError(f"Could not find Conda executable at {conda_path}.")
_, stdout, _ = exec_cmd([conda_path, "env", "list", "--json"])
envs = json.loads(stdout)["envs"]
return envs
def get_conda_info_json() -> dict:
"""
Get `conda info --json` output.
Returns dict of conda info. See [1] for more details. We mostly care about these
keys:
- `conda_prefix`: str The path to the conda installation.
- `envs`: List[str] absolute paths to conda environments.
[1] https://github.com/conda/conda/blob/main/conda/cli/main_info.py
"""
conda_path = get_conda_bin_executable("conda")
try:
exec_cmd([conda_path, "--help"], throw_on_error=False)
except EnvironmentError:
raise ValueError(f"Could not find Conda executable at {conda_path}.")
_, stdout, _ = exec_cmd([conda_path, "info", "--json"])
return json.loads(stdout)
def get_conda_envs(conda_info: dict) -> List[Tuple[str, str]]:
"""
Gets the conda environments, as a list of (name, path) tuples.
"""
prefix = conda_info["conda_prefix"]
ret = []
for env in conda_info["envs"]:
if env == prefix:
ret.append(("base", env))
else:
ret.append((os.path.basename(env), env))
return ret
class ShellCommandException(Exception):
pass
def exec_cmd(
cmd: List[str], throw_on_error: bool = True, logger: Optional[logging.Logger] = None
) -> Union[int, Tuple[int, str, str]]:
"""
Runs a command as a child process.
A convenience wrapper for running a command from a Python script.
Note on the return value: A tuple of the exit code,
standard output and standard error is returned.
Args:
cmd: the command to run, as a list of strings
throw_on_error: if true, raises an Exception if the exit code of the
program is nonzero
logger: Unused; retained for API compatibility.
Returns:
A tuple of (exit_code, stdout, stderr) from the child process.
"""
child = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stdin=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
(stdout, stderr) = child.communicate()
exit_code = child.wait()
if throw_on_error and exit_code != 0:
raise ShellCommandException(
"Non-zero exit code: %s\n\nSTDOUT:\n%s\n\nSTDERR:%s"
% (exit_code, stdout, stderr)
)
return exit_code, stdout, stderr
def exec_cmd_stream_to_logger(
cmd: List[str], logger: logging.Logger, n_lines: int = 50, **kwargs
) -> Tuple[int, str]:
"""Runs a command as a child process, streaming output to the logger.
The last n_lines lines of output are also returned (stdout and stderr).
"""
if "env" in kwargs and _WIN32 and "PATH" not in [x.upper() for x in kwargs.keys]:
raise ValueError("On windows, Popen requires 'PATH' in 'env'")
child = subprocess.Popen(
cmd,
universal_newlines=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
**kwargs,
)
last_n_lines = []
with child.stdout:
for line in iter(child.stdout.readline, b""):
exit_code = child.poll()
if exit_code is not None:
break
line = line.strip()
if not line:
continue
last_n_lines.append(line.strip())
last_n_lines = last_n_lines[-n_lines:]
logger.info(line.strip())
exit_code = child.wait()
return exit_code, "\n".join(last_n_lines)
@@ -0,0 +1,28 @@
# Env var set by job manager to pass runtime env and metadata to subprocess
RAY_JOB_CONFIG_JSON_ENV_VAR = "RAY_JOB_CONFIG_JSON_ENV_VAR"
# The plugin config which should be loaded when ray cluster starts.
# It is a json formatted config,
# e.g. [{"class": "xxx.xxx.xxx_plugin", "priority": 10}].
RAY_RUNTIME_ENV_PLUGINS_ENV_VAR = "RAY_RUNTIME_ENV_PLUGINS"
# The field name of plugin class in the plugin config.
RAY_RUNTIME_ENV_CLASS_FIELD_NAME = "class"
# The field name of priority in the plugin config.
RAY_RUNTIME_ENV_PRIORITY_FIELD_NAME = "priority"
# The default priority of runtime env plugin.
RAY_RUNTIME_ENV_PLUGIN_DEFAULT_PRIORITY = 10
# The minimum priority of runtime env plugin.
RAY_RUNTIME_ENV_PLUGIN_MIN_PRIORITY = 0
# The maximum priority of runtime env plugin.
RAY_RUNTIME_ENV_PLUGIN_MAX_PRIORITY = 100
# The schema files or directories of plugins which should be loaded in workers.
RAY_RUNTIME_ENV_PLUGIN_SCHEMAS_ENV_VAR = "RAY_RUNTIME_ENV_PLUGIN_SCHEMAS"
# The file suffix of runtime env plugin schemas.
RAY_RUNTIME_ENV_PLUGIN_SCHEMA_SUFFIX = ".json"
+108
View File
@@ -0,0 +1,108 @@
import json
import logging
import os
import shlex
import subprocess
import sys
from typing import Dict, List, Optional
from ray._private.services import get_ray_jars_dir
from ray._private.utils import update_envs
from ray.core.generated.common_pb2 import Language
from ray.util.annotations import DeveloperAPI
logger = logging.getLogger(__name__)
@DeveloperAPI
class RuntimeEnvContext:
"""A context used to describe the created runtime env."""
def __init__(
self,
command_prefix: List[str] = None,
env_vars: Dict[str, str] = None,
py_executable: Optional[str] = None,
override_worker_entrypoint: Optional[str] = None,
java_jars: List[str] = None,
):
self.command_prefix = command_prefix or []
self.env_vars = env_vars or {}
self.py_executable = py_executable or sys.executable
self.override_worker_entrypoint: Optional[str] = override_worker_entrypoint
self.java_jars = java_jars or []
def serialize(self) -> str:
return json.dumps(self.__dict__)
@staticmethod
def deserialize(json_string):
return RuntimeEnvContext(**json.loads(json_string))
def exec_worker(self, passthrough_args: List[str], language: Language):
update_envs(self.env_vars)
if language == Language.PYTHON and sys.platform == "win32":
executable = [self.py_executable]
elif language == Language.PYTHON:
executable = ["exec", self.py_executable]
elif language == Language.JAVA:
executable = ["java"]
ray_jars = os.path.join(get_ray_jars_dir(), "*")
local_java_jars = []
for java_jar in self.java_jars:
local_java_jars.append(f"{java_jar}/*")
local_java_jars.append(java_jar)
class_path_args = ["-cp", ray_jars + ":" + str(":".join(local_java_jars))]
passthrough_args = class_path_args + passthrough_args
elif sys.platform == "win32":
executable = []
else:
executable = ["exec"]
# By default, raylet uses the path to default_worker.py on host.
# However, the path to default_worker.py inside the container
# can be different. We need the user to specify the path to
# default_worker.py inside the container.
if self.override_worker_entrypoint:
logger.debug(
f"Changing the worker entrypoint from {passthrough_args[0]} to "
f"{self.override_worker_entrypoint}."
)
passthrough_args[0] = self.override_worker_entrypoint
if sys.platform == "win32":
def quote(s):
s = s.replace("&", "%26")
return s
passthrough_args = [quote(s) for s in passthrough_args]
cmd = [*self.command_prefix, *executable, *passthrough_args]
logger.debug(f"Exec'ing worker with command: {cmd}")
subprocess.Popen(cmd, shell=True).wait()
else:
# We use shlex to do the necessary shell escape
# of special characters in passthrough_args.
passthrough_args = [shlex.quote(s) for s in passthrough_args]
cmd = [*self.command_prefix, *executable, *passthrough_args]
# TODO(SongGuyang): We add this env to command for macOS because it doesn't
# work for the C++ process of `os.execvp`. We should find a better way to
# fix it.
MACOS_LIBRARY_PATH_ENV_NAME = "DYLD_LIBRARY_PATH"
if MACOS_LIBRARY_PATH_ENV_NAME in os.environ:
cmd.insert(
0,
f"{MACOS_LIBRARY_PATH_ENV_NAME}="
f"{os.environ[MACOS_LIBRARY_PATH_ENV_NAME]}",
)
logger.debug(f"Exec'ing worker with command: {cmd}")
# PyCharm will monkey patch the os.execvp at
# .pycharm_helpers/pydev/_pydev_bundle/pydev_monkey.py
# The monkey patched os.execvp function has a different
# signature. So, we use os.execvp("executable", args=[])
# instead of os.execvp(file="executable", args=[])
os.execvp("bash", args=["bash", "-c", " ".join(cmd)])
@@ -0,0 +1,5 @@
from ray._private.runtime_env.image_uri import ImageURIPlugin
def get_image_uri_plugin_cls():
return ImageURIPlugin
@@ -0,0 +1,118 @@
"""Util functions to manage dependency requirements."""
import logging
import os
import tempfile
from contextlib import asynccontextmanager
from typing import List, Optional, Tuple
from ray._private.runtime_env import virtualenv_utils
from ray._private.runtime_env.utils import check_output_cmd
INTERNAL_PIP_FILENAME = "ray_runtime_env_internal_pip_requirements.txt"
MAX_INTERNAL_PIP_FILENAME_TRIES = 100
def gen_requirements_txt(requirements_file: str, pip_packages: List[str]):
"""Dump [pip_packages] to the given [requirements_file] for later env setup."""
with open(requirements_file, "w") as file:
for line in pip_packages:
file.write(line + "\n")
@asynccontextmanager
async def check_ray(python: str, cwd: str, logger: logging.Logger):
"""A context manager to check ray is not overwritten.
Currently, we only check ray version and path. It works for virtualenv,
- ray is in Python's site-packages.
- ray is overwritten during yield.
- ray is in virtualenv's site-packages.
"""
async def _get_ray_version_and_path() -> Tuple[str, str]:
with tempfile.TemporaryDirectory(
prefix="check_ray_version_tempfile"
) as tmp_dir:
ray_version_path = os.path.join(tmp_dir, "ray_version.txt")
check_ray_cmd = [
python,
"-c",
"""
import ray
with open(r"{ray_version_path}", "wt") as f:
f.write(ray.__version__)
f.write(" ")
f.write(ray.__path__[0])
""".format(
ray_version_path=ray_version_path
),
]
if virtualenv_utils._WIN32:
env = os.environ.copy()
else:
env = {}
output = await check_output_cmd(
check_ray_cmd, logger=logger, cwd=cwd, env=env
)
logger.info(f"try to write ray version information in: {ray_version_path}")
with open(ray_version_path, "rt") as f:
output = f.read()
# print after import ray may have  endings, so we strip them by *_
ray_version, ray_path, *_ = [s.strip() for s in output.split()]
return ray_version, ray_path
version, path = await _get_ray_version_and_path()
yield
actual_version, actual_path = await _get_ray_version_and_path()
if actual_version != version:
raise RuntimeError(
"Changing the ray version is not allowed: \n"
f" current version: {actual_version}, "
f" expect version: {version}, "
f" current path: {actual_path}, "
f" expect path: {path}, "
"Please ensure the dependencies in the runtime_env pip field "
"do not install a different version of Ray."
)
if actual_path != path:
logger.info(
f"Detected new Ray package with the same version at {actual_path} (vs system {path})."
)
def get_requirements_file(target_dir: str, pip_list: Optional[List[str]]) -> str:
"""Returns the path to the requirements file to use for this runtime env.
If pip_list is not None, we will check if the internal pip filename is in any of
the entries of pip_list. If so, we will append numbers to the end of the
filename until we find one that doesn't conflict. This prevents infinite
recursion if the user specifies the internal pip filename in their pip list.
Args:
target_dir: The directory to store the requirements file in.
pip_list: A list of pip requirements specified by the user.
Returns:
The path to the requirements file to use for this runtime env.
"""
def filename_in_pip_list(filename: str) -> bool:
for pip_entry in pip_list:
if filename in pip_entry:
return True
return False
filename = INTERNAL_PIP_FILENAME
if pip_list is not None:
i = 1
while filename_in_pip_list(filename) and i < MAX_INTERNAL_PIP_FILENAME_TRIES:
filename = f"{INTERNAL_PIP_FILENAME}.{i}"
i += 1
if i == MAX_INTERNAL_PIP_FILENAME_TRIES:
raise RuntimeError(
"Could not find a valid filename for the internal "
"pip requirements file. Please specify a different "
"pip list in your runtime env."
)
return os.path.join(target_dir, filename)
@@ -0,0 +1,229 @@
import asyncio
import logging
import os
import tempfile
from typing import List, Optional
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
default_logger = logging.getLogger(__name__)
async def _create_impl(image_uri: str, logger: logging.Logger):
# Pull image if it doesn't exist
# Also get path to `default_worker.py` inside the image.
with tempfile.TemporaryDirectory() as tmpdir:
os.chmod(tmpdir, 0o777)
result_file = os.path.join(tmpdir, "worker_path.txt")
get_worker_path_script = """
import ray._private.workers.default_worker as dw
with open('/shared/worker_path.txt', 'w') as f:
f.write(dw.__file__)
"""
cmd = [
"podman",
"run",
"--rm",
"-v",
f"{tmpdir}:/shared:Z",
image_uri,
"python",
"-c",
get_worker_path_script,
]
logger.info("Pulling image %s", image_uri)
process = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise RuntimeError(
f"Podman command failed: cmd={cmd}, returncode={process.returncode}, stdout={stdout.decode()}, stderr={stderr.decode()}"
)
if not os.path.exists(result_file):
raise FileNotFoundError(
f"Worker path file not created when getting worker path for image {image_uri}"
)
with open(result_file, "r") as f:
worker_path = f.read().strip()
if not worker_path.endswith(".py"):
raise ValueError(
f"Invalid worker path inferred in image {image_uri}: {worker_path}"
)
logger.info(f"Inferred worker path in image {image_uri}: {worker_path}")
return worker_path
def _modify_context_impl(
image_uri: str,
worker_path: str,
run_options: Optional[List[str]],
context: RuntimeEnvContext,
logger: logging.Logger,
ray_tmp_dir: str,
):
context.override_worker_entrypoint = worker_path
container_driver = "podman"
container_command = [
container_driver,
"run",
"-v",
ray_tmp_dir + ":" + ray_tmp_dir,
"--cgroup-manager=cgroupfs",
"--network=host",
"--pid=host",
"--ipc=host",
# NOTE(zcin): Mounted volumes in rootless containers are
# owned by the user `root`. The user on host (which will
# usually be `ray` if this is being run in a ray docker
# image) who started the container is mapped using user
# namespaces to the user `root` in a rootless container. In
# order for the Ray Python worker to access the mounted ray
# tmp dir, we need to use keep-id mode which maps the user
# as itself (instead of as `root`) into the container.
# https://www.redhat.com/sysadmin/rootless-podman-user-namespace-modes
"--userns=keep-id",
]
# Environment variables to set in container
env_vars = dict()
# Propagate all host environment variables that have the prefix "RAY_"
# This should include RAY_RAYLET_PID
for env_var_name, env_var_value in os.environ.items():
if env_var_name.startswith("RAY_"):
env_vars[env_var_name] = env_var_value
# Support for runtime_env['env_vars']
env_vars.update(context.env_vars)
# Set environment variables
for env_var_name, env_var_value in env_vars.items():
container_command.append("--env")
container_command.append(f"{env_var_name}='{env_var_value}'")
# The RAY_JOB_ID environment variable is needed for the default worker.
# It won't be set at the time setup() is called, but it will be set
# when worker command is executed, so we use RAY_JOB_ID=$RAY_JOB_ID
# for the container start command
container_command.append("--env")
container_command.append("RAY_JOB_ID=$RAY_JOB_ID")
if run_options:
container_command.extend(run_options)
# TODO(chenk008): add resource limit
container_command.append("--entrypoint")
container_command.append("python")
container_command.append(image_uri)
# Example:
# podman run -v /tmp/ray:/tmp/ray
# --cgroup-manager=cgroupfs --network=host --pid=host --ipc=host
# --userns=keep-id --env RAY_RAYLET_PID=23478 --env RAY_JOB_ID=$RAY_JOB_ID
# --entrypoint python rayproject/ray:nightly-py39
container_command_str = " ".join(container_command)
logger.info(f"Starting worker in container with prefix {container_command_str}")
context.py_executable = container_command_str
class ImageURIPlugin(RuntimeEnvPlugin):
"""Starts worker in a container of a custom image."""
name = "image_uri"
@staticmethod
def get_compatible_keys():
return {"image_uri", "config", "env_vars"}
def __init__(self, ray_tmp_dir: str):
self._ray_tmp_dir = ray_tmp_dir
async def create(
self,
uri: Optional[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger,
) -> float:
if not runtime_env.image_uri():
return
self.worker_path = await _create_impl(runtime_env.image_uri(), logger)
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
if not runtime_env.image_uri():
return
_modify_context_impl(
runtime_env.image_uri(),
self.worker_path,
[],
context,
logger,
self._ray_tmp_dir,
)
class ContainerPlugin(RuntimeEnvPlugin):
"""Starts worker in container."""
name = "container"
def __init__(self, ray_tmp_dir: str):
self._ray_tmp_dir = ray_tmp_dir
async def create(
self,
uri: Optional[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger,
) -> float:
if not runtime_env.has_py_container() or not runtime_env.py_container_image():
return
self.worker_path = await _create_impl(runtime_env.py_container_image(), logger)
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
if not runtime_env.has_py_container() or not runtime_env.py_container_image():
return
if runtime_env.py_container_worker_path():
logger.warning(
"You are using `container.worker_path`, but the path to "
"`default_worker.py` is now automatically detected from the image. "
"`container.worker_path` is deprecated and will be removed in future "
"versions."
)
_modify_context_impl(
runtime_env.py_container_image(),
runtime_env.py_container_worker_path() or self.worker_path,
runtime_env.py_container_run_options(),
context,
logger,
self._ray_tmp_dir,
)
@@ -0,0 +1,104 @@
import logging
import os
from typing import Dict, List, Optional
from ray._common.utils import try_to_create_directory
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.packaging import (
delete_package,
download_and_unpack_package,
get_local_dir_from_uri,
is_jar_uri,
)
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray._private.utils import get_directory_size_bytes
from ray._raylet import GcsClient
from ray.exceptions import RuntimeEnvSetupError
default_logger = logging.getLogger(__name__)
class JavaJarsPlugin(RuntimeEnvPlugin):
name = "java_jars"
def __init__(self, resources_dir: str, gcs_client: GcsClient):
self._resources_dir = os.path.join(resources_dir, "java_jars_files")
self._gcs_client = gcs_client
try_to_create_directory(self._resources_dir)
def _get_local_dir_from_uri(self, uri: str):
return get_local_dir_from_uri(uri, self._resources_dir)
def delete_uri(
self, uri: str, logger: Optional[logging.Logger] = default_logger
) -> int:
"""Delete URI and return the number of bytes deleted."""
local_dir = get_local_dir_from_uri(uri, self._resources_dir)
local_dir_size = get_directory_size_bytes(local_dir)
deleted = delete_package(uri, self._resources_dir)
if not deleted:
logger.warning(f"Tried to delete nonexistent URI: {uri}.")
return 0
return local_dir_size
def get_uris(self, runtime_env: dict) -> List[str]:
return runtime_env.java_jars()
async def _download_jars(
self, uri: str, logger: Optional[logging.Logger] = default_logger
):
"""Download a jar URI."""
try:
jar_file = await download_and_unpack_package(
uri, self._resources_dir, self._gcs_client, logger=logger
)
except Exception as e:
raise RuntimeEnvSetupError(
"Failed to download jar file: {}".format(e)
) from e
module_dir = self._get_local_dir_from_uri(uri)
logger.debug(f"Succeeded to download jar file {jar_file} .")
return module_dir
async def create(
self,
uri: str,
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
) -> int:
if not uri:
return 0
if is_jar_uri(uri):
module_dir = await self._download_jars(uri=uri, logger=logger)
else:
try:
module_dir = await download_and_unpack_package(
uri, self._resources_dir, self._gcs_client, logger=logger
)
except Exception as e:
raise RuntimeEnvSetupError(
"Failed to download jar file: {}".format(e)
) from e
return get_directory_size_bytes(module_dir)
def modify_context(
self,
uris: List[str],
runtime_env_dict: Dict,
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
for uri in uris:
module_dir = self._get_local_dir_from_uri(uri)
if not module_dir.exists():
raise ValueError(
f"Local directory {module_dir} for URI {uri} does "
"not exist on the cluster. Something may have gone wrong while "
"downloading, unpacking or installing the java jar files."
)
context.java_jars.append(str(module_dir))
+149
View File
@@ -0,0 +1,149 @@
import asyncio
import copy
import logging
import os
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from ray._common.utils import (
try_to_create_directory,
)
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray.exceptions import RuntimeEnvSetupError
default_logger = logging.getLogger(__name__)
# Nsight options used when runtime_env={"_nsight": "default"}
NSIGHT_DEFAULT_CONFIG = {
"t": "cuda,cudnn,cublas,nvtx",
"o": "'worker_process_%p'",
"stop-on-exit": "true",
}
def parse_nsight_config(nsight_config: Dict[str, str]) -> List[str]:
"""
Function to convert dictionary of nsight options into
nsight command line
The function returns:
- List[str]: nsys profile cmd line split into list of str
"""
nsight_cmd = ["nsys", "profile"]
for option, option_val in nsight_config.items():
# option standard based on
# https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html
if len(option) > 1:
nsight_cmd.append(f"--{option}={option_val}")
else:
nsight_cmd += [f"-{option}", option_val]
return nsight_cmd
class NsightPlugin(RuntimeEnvPlugin):
name = "_nsight"
def __init__(self, resources_dir: str):
self.nsight_cmd = []
# replace this with better way to get logs dir
session_dir, runtime_dir = os.path.split(resources_dir)
self._nsight_dir = Path(session_dir) / "logs" / "nsight"
try_to_create_directory(self._nsight_dir)
async def _check_nsight_script(
self, nsight_config: Dict[str, str]
) -> Tuple[bool, str]:
"""
Function to validate if nsight_config is a valid nsight profile options
Args:
nsight_config: dictionary mapping nsight option to it's value
Returns:
a tuple consists of a boolean indicating if the nsight_config
is valid option and an error message if the nsight_config is invalid
"""
# use empty as nsight report test filename
nsight_config_copy = copy.deepcopy(nsight_config)
nsight_config_copy["o"] = str(Path(self._nsight_dir) / "empty")
nsight_cmd = parse_nsight_config(nsight_config_copy)
try:
nsight_cmd = nsight_cmd + [sys.executable, "-c", '""']
process = await asyncio.create_subprocess_exec(
*nsight_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
error_msg = stderr.strip() if stderr.strip() != "" else stdout.strip()
# cleanup test.nsys-rep file
clean_up_cmd = ["rm", f"{nsight_config_copy['o']}.nsys-rep"]
cleanup_process = await asyncio.create_subprocess_exec(
*clean_up_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
_, _ = await cleanup_process.communicate()
if process.returncode == 0:
return True, None
else:
return False, error_msg
except FileNotFoundError:
return False, ("nsight is not installed")
async def create(
self,
uri: Optional[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger = default_logger,
) -> int:
nsight_config = runtime_env.nsight()
if not nsight_config:
return 0
if nsight_config and sys.platform != "linux":
raise RuntimeEnvSetupError(
"Nsight CLI is only available in Linux.\n"
"More information can be found in "
"https://docs.nvidia.com/nsight-compute/NsightComputeCli/index.html"
)
if isinstance(nsight_config, str):
if nsight_config == "default":
nsight_config = NSIGHT_DEFAULT_CONFIG
else:
raise RuntimeEnvSetupError(
f"Unsupported nsight config: {nsight_config}. "
"The supported config is 'default' or "
"Dictionary of nsight options"
)
is_valid_nsight_cmd, error_msg = await self._check_nsight_script(nsight_config)
if not is_valid_nsight_cmd:
logger.warning(error_msg)
raise RuntimeEnvSetupError(
"nsight profile failed to run with the following "
f"error message:\n {error_msg}"
)
# add set output path to logs dir
nsight_config["o"] = str(
Path(self._nsight_dir) / nsight_config.get("o", NSIGHT_DEFAULT_CONFIG["o"])
)
self.nsight_cmd = parse_nsight_config(nsight_config)
return 0
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
logger.info("Running nsight profiler")
context.py_executable = " ".join(self.nsight_cmd) + " python"
File diff suppressed because it is too large Load Diff
+422
View File
@@ -0,0 +1,422 @@
import asyncio
import hashlib
import json
import logging
import os
import re
import shutil
import sys
from asyncio import create_task, get_running_loop
from typing import Dict, List, Optional
from ray._common.utils import try_to_create_directory
from ray._private.runtime_env import dependency_utils, virtualenv_utils
from ray._private.runtime_env.packaging import Protocol, parse_uri
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray._private.runtime_env.utils import check_output_cmd
from ray._private.utils import get_directory_size_bytes
default_logger = logging.getLogger(__name__)
# Matches unresolved environment variable placeholders such as
# ${RAY_RUNTIME_ENV_CREATE_WORKING_DIR} or $VAR. Such placeholders are
# expanded by the runtime env agent only after the driver-side hash is
# computed, so any path containing one is not a real path on the driver
# and must not be opened during hash computation.
_ENV_VAR_PATTERN = re.compile(r"\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*")
def _parse_requirements_file(file_path: str) -> List[str]:
packages = []
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
# Strip whitespace and remove inline comments (preceded by a space)
line = line.split(" #")[0].strip()
if not line or line.startswith("#"):
continue
packages.append(line)
return packages
def _get_pip_hash(pip_dict: Dict) -> str:
pip_dict_copy = pip_dict.copy()
# Using a list as a stack for iterative processing to handle nested requirements.
# Each item is a tuple (package_spec, parent_dir), where parent_dir is the directory
# of the file that contained this package spec (None for top-level packages)
packages_to_process = [
(pkg, None) for pkg in reversed(pip_dict_copy.get("packages", []))
]
expanded_packages = []
# Track visited files using absolute paths to prevent circular references
visited_files = set()
while packages_to_process:
pkg, parent_dir = packages_to_process.pop()
file_path = None
if pkg.startswith("-r"):
file_path = pkg[2:].lstrip()
elif pkg.startswith("--requirement"):
file_path = pkg[len("--requirement") :].lstrip()
if file_path.startswith("="):
file_path = file_path[1:].lstrip()
else:
expanded_packages.append(pkg)
continue
if file_path is not None:
# If the path contains an unresolved environment variable
# placeholder (e.g. ${RAY_RUNTIME_ENV_CREATE_WORKING_DIR}),
# we cannot open it on the driver. Keep the original spec
# string in the hash input so the URI still reflects the user
# input, and let the runtime env agent expand and read the
# file on the worker side.
if _ENV_VAR_PATTERN.search(file_path):
expanded_packages.append(pkg)
continue
if parent_dir and not os.path.isabs(file_path):
file_path = os.path.join(parent_dir, file_path)
try:
abs_file_path = os.path.abspath(file_path)
except Exception:
default_logger.warning(f"Invalid path: {file_path}")
continue
if abs_file_path in visited_files:
default_logger.warning(
f"Skipping circular reference to {abs_file_path}"
)
continue
visited_files.add(abs_file_path)
file_dir = os.path.dirname(abs_file_path)
packages_from_file = _parse_requirements_file(abs_file_path)
packages_to_process.extend(
[(p, file_dir) for p in reversed(packages_from_file)]
)
pip_dict_copy["packages"] = expanded_packages
serialized_pip_spec = json.dumps(pip_dict_copy, sort_keys=True)
hash_val = hashlib.sha1(serialized_pip_spec.encode("utf-8")).hexdigest()
return hash_val
def get_uri(runtime_env: Dict) -> Optional[str]:
"""Return `"pip://<hashed_dependencies>"`, or None if no GC required."""
pip = runtime_env.get("pip")
if pip is not None:
if isinstance(pip, dict):
uri = "pip://" + _get_pip_hash(pip_dict=pip)
elif isinstance(pip, list):
uri = "pip://" + _get_pip_hash(pip_dict=dict(packages=pip))
else:
raise TypeError(
"pip field received by RuntimeEnvAgent must be "
f"list or dict, not {type(pip).__name__}."
)
else:
uri = None
return uri
class PipProcessor:
def __init__(
self,
target_dir: str,
runtime_env: "RuntimeEnv", # noqa: F821
logger: Optional[logging.Logger] = default_logger,
):
try:
import virtualenv # noqa: F401 ensure virtualenv exists.
except ImportError:
raise RuntimeError(
f"Please install virtualenv "
f"`{sys.executable} -m pip install virtualenv`"
f"to enable pip runtime env."
)
logger.debug("Setting up pip for runtime_env: %s", runtime_env)
self._target_dir = target_dir
self._runtime_env = runtime_env
self._logger = logger
self._pip_config = self._runtime_env.pip_config()
self._pip_env = os.environ.copy()
self._pip_env.update(self._runtime_env.env_vars())
@classmethod
async def _ensure_pip_version(
cls,
path: str,
pip_version: Optional[str],
cwd: str,
pip_env: Dict,
logger: logging.Logger,
):
"""Run the pip command to reinstall pip to the specified version."""
if not pip_version:
return
python = virtualenv_utils.get_virtualenv_python(path)
# Ensure pip version.
pip_reinstall_cmd = [
python,
"-m",
"pip",
"install",
"--disable-pip-version-check",
f"pip{pip_version}",
]
logger.info("Installing pip with version %s", pip_version)
await check_output_cmd(pip_reinstall_cmd, logger=logger, cwd=cwd, env=pip_env)
async def _pip_check(
self,
path: str,
pip_check: bool,
cwd: str,
pip_env: Dict,
logger: logging.Logger,
):
"""Run the pip check command to check python dependency conflicts.
If exists conflicts, the exit code of pip check command will be non-zero.
"""
if not pip_check:
logger.info("Skip pip check.")
return
python = virtualenv_utils.get_virtualenv_python(path)
await check_output_cmd(
[python, "-m", "pip", "check", "--disable-pip-version-check"],
logger=logger,
cwd=cwd,
env=pip_env,
)
logger.info("Pip check on %s successfully.", path)
async def _install_pip_packages(
self,
path: str,
pip_packages: List[str],
cwd: str,
pip_env: Dict,
logger: logging.Logger,
):
virtualenv_path = virtualenv_utils.get_virtualenv_path(path)
python = virtualenv_utils.get_virtualenv_python(path)
# TODO(fyrestone): Support -i, --no-deps, --no-cache-dir, ...
pip_requirements_file = dependency_utils.get_requirements_file(
path, pip_packages
)
# Avoid blocking the event loop.
loop = get_running_loop()
await loop.run_in_executor(
None,
dependency_utils.gen_requirements_txt,
pip_requirements_file,
pip_packages,
)
# Install all dependencies
# The default options for pip install are
#
# --disable-pip-version-check
# Don't periodically check PyPI to determine whether a new version
# of pip is available for download.
#
# --no-cache-dir
# Disable the cache, the pip runtime env is a one-time installation,
# and we don't need to handle the pip cache broken.
#
# Allow users to specify their own options to install packages via `pip`.
pip_install_cmd = [
python,
"-m",
"pip",
"install",
"-r",
pip_requirements_file,
]
pip_opt_list = self._pip_config.get(
"pip_install_options", ["--disable-pip-version-check", "--no-cache-dir"]
)
pip_install_cmd.extend(pip_opt_list)
logger.info("Installing python requirements to %s", virtualenv_path)
await check_output_cmd(pip_install_cmd, logger=logger, cwd=cwd, env=pip_env)
async def _run(self):
path = self._target_dir
logger = self._logger
pip_packages = self._pip_config["packages"]
# We create an empty directory for exec cmd so that the cmd will
# run more stable. e.g. if cwd has ray, then checking ray will
# look up ray in cwd instead of site packages.
exec_cwd = os.path.join(path, "exec_cwd")
os.makedirs(exec_cwd, exist_ok=True)
try:
await virtualenv_utils.create_or_get_virtualenv(path, exec_cwd, logger)
python = virtualenv_utils.get_virtualenv_python(path)
async with dependency_utils.check_ray(python, exec_cwd, logger):
# Ensure pip version.
await self._ensure_pip_version(
path,
self._pip_config.get("pip_version", None),
exec_cwd,
self._pip_env,
logger,
)
# Install pip packages.
await self._install_pip_packages(
path,
pip_packages,
exec_cwd,
self._pip_env,
logger,
)
# Check python environment for conflicts.
await self._pip_check(
path,
self._pip_config.get("pip_check", False),
exec_cwd,
self._pip_env,
logger,
)
except Exception:
logger.info("Delete incomplete virtualenv: %s", path)
shutil.rmtree(path, ignore_errors=True)
logger.exception("Failed to install pip packages.")
raise
def __await__(self):
return self._run().__await__()
class PipPlugin(RuntimeEnvPlugin):
name = "pip"
def __init__(self, resources_dir: str):
self._pip_resources_dir = os.path.join(resources_dir, "pip")
self._creating_task = {}
# Maps a URI to a lock that is used to prevent multiple concurrent
# installs of the same virtualenv, see #24513
self._create_locks: Dict[str, asyncio.Lock] = {}
# Key: created hashes. Value: size of the pip dir.
self._created_hash_bytes: Dict[str, int] = {}
try_to_create_directory(self._pip_resources_dir)
def _get_path_from_hash(self, hash_val: str) -> str:
"""Generate a path from the hash of a pip spec.
Example output:
/tmp/ray/session_2021-11-03_16-33-59_356303_41018/runtime_resources
/pip/ray-9a7972c3a75f55e976e620484f58410c920db091
"""
return os.path.join(self._pip_resources_dir, hash_val)
def get_uris(self, runtime_env: "RuntimeEnv") -> List[str]: # noqa: F821
"""Return the pip URI from the RuntimeEnv if it exists, else return []."""
pip_uri = runtime_env.pip_uri()
if pip_uri:
return [pip_uri]
return []
def delete_uri(
self, uri: str, logger: Optional[logging.Logger] = default_logger
) -> int:
"""Delete URI and return the number of bytes deleted."""
logger.info("Got request to delete pip URI %s", uri)
protocol, hash_val = parse_uri(uri)
if protocol != Protocol.PIP:
raise ValueError(
"PipPlugin can only delete URIs with protocol "
f"pip. Received protocol {protocol}, URI {uri}"
)
# Cancel running create task.
task = self._creating_task.pop(hash_val, None)
if task is not None:
task.cancel()
del self._created_hash_bytes[hash_val]
pip_env_path = self._get_path_from_hash(hash_val)
local_dir_size = get_directory_size_bytes(pip_env_path)
del self._create_locks[uri]
try:
shutil.rmtree(pip_env_path)
except OSError as e:
logger.warning(f"Error when deleting pip env {pip_env_path}: {str(e)}")
return 0
return local_dir_size
async def create(
self,
uri: str,
runtime_env: "RuntimeEnv", # noqa: F821
context: "RuntimeEnvContext", # noqa: F821
logger: Optional[logging.Logger] = default_logger,
) -> int:
if not runtime_env.has_pip():
return 0
protocol, hash_val = parse_uri(uri)
target_dir = self._get_path_from_hash(hash_val)
async def _create_for_hash():
await PipProcessor(
target_dir,
runtime_env,
logger,
)
loop = get_running_loop()
return await loop.run_in_executor(
None, get_directory_size_bytes, target_dir
)
if uri not in self._create_locks:
# async lock to prevent the same virtualenv being concurrently installed
self._create_locks[uri] = asyncio.Lock()
async with self._create_locks[uri]:
if hash_val in self._created_hash_bytes:
return self._created_hash_bytes[hash_val]
self._creating_task[hash_val] = task = create_task(_create_for_hash())
task.add_done_callback(lambda _: self._creating_task.pop(hash_val, None))
pip_dir_bytes = await task
self._created_hash_bytes[hash_val] = pip_dir_bytes
return pip_dir_bytes
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: "RuntimeEnvContext", # noqa: F821
logger: logging.Logger = default_logger,
):
if not runtime_env.has_pip():
return
# PipPlugin only uses a single URI.
uri = uris[0]
# Update py_executable.
protocol, hash_val = parse_uri(uri)
target_dir = self._get_path_from_hash(hash_val)
virtualenv_python = virtualenv_utils.get_virtualenv_python(target_dir)
if not os.path.exists(virtualenv_python):
raise ValueError(
f"Local directory {target_dir} for URI {uri} does "
"not exist on the cluster. Something may have gone wrong while "
"installing the runtime_env `pip` packages."
)
context.py_executable = virtualenv_python
context.command_prefix += virtualenv_utils.get_virtualenv_activate_command(
target_dir
)
+265
View File
@@ -0,0 +1,265 @@
import json
import logging
import os
from abc import ABC
from typing import Any, Dict, List, Optional, Type
from ray._common.utils import import_attr
from ray._private.runtime_env.constants import (
RAY_RUNTIME_ENV_CLASS_FIELD_NAME,
RAY_RUNTIME_ENV_PLUGIN_DEFAULT_PRIORITY,
RAY_RUNTIME_ENV_PLUGIN_MAX_PRIORITY,
RAY_RUNTIME_ENV_PLUGIN_MIN_PRIORITY,
RAY_RUNTIME_ENV_PLUGINS_ENV_VAR,
RAY_RUNTIME_ENV_PRIORITY_FIELD_NAME,
)
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.uri_cache import URICache
from ray.util.annotations import DeveloperAPI
default_logger = logging.getLogger(__name__)
@DeveloperAPI
class RuntimeEnvPlugin(ABC):
"""Abstract base class for runtime environment plugins."""
name: str = None
priority: int = RAY_RUNTIME_ENV_PLUGIN_DEFAULT_PRIORITY
@staticmethod
def validate(runtime_env_dict: dict) -> None:
"""Validate user entry for this plugin.
The method is invoked upon installation of runtime env.
Args:
runtime_env_dict: The user-supplied runtime environment dict.
Raises:
ValueError: If the validation fails.
"""
pass
def get_uris(self, runtime_env: "RuntimeEnv") -> List[str]: # noqa: F821
return []
async def create(
self,
uri: Optional[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger,
) -> float:
"""Create and install the runtime environment.
Gets called in the runtime env agent at install time. The URI can be
used as a caching mechanism.
Args:
uri: A URI uniquely describing this resource.
runtime_env: The RuntimeEnv object.
context: Auxiliary information supplied by Ray.
logger: A logger to log messages during the context modification.
Returns:
float: The disk space taken up by this plugin installation for this
environment. e.g. for working_dir, this downloads the files to the
local node.
"""
return 0
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger,
) -> None:
"""Modify context to change worker startup behavior.
For example, you can use this to prepend "cd <dir>" command to worker
startup, or add new environment variables.
Args:
uris: The URIs used by this resource.
runtime_env: The RuntimeEnv object.
context: Auxiliary information supplied by Ray.
logger: A logger to log messages during the context modification.
"""
return
def delete_uri(self, uri: str, logger: logging.Logger) -> float:
"""Delete the runtime environment given uri.
Args:
uri: A URI uniquely describing this resource.
logger: The logger used to log messages during the deletion.
Returns:
float: The amount of space reclaimed by the deletion.
"""
return 0
class PluginSetupContext:
def __init__(
self,
name: str,
class_instance: RuntimeEnvPlugin,
priority: int,
uri_cache: URICache,
):
self.name = name
self.class_instance = class_instance
self.priority = priority
self.uri_cache = uri_cache
class RuntimeEnvPluginManager:
"""This manager is used to load plugins in runtime env agent."""
def __init__(self):
self.plugins: Dict[str, PluginSetupContext] = {}
plugin_config_str = os.environ.get(RAY_RUNTIME_ENV_PLUGINS_ENV_VAR)
if plugin_config_str:
plugin_configs = json.loads(plugin_config_str)
self.load_plugins(plugin_configs)
def validate_plugin_class(self, plugin_class: Type[RuntimeEnvPlugin]) -> None:
if not issubclass(plugin_class, RuntimeEnvPlugin):
raise RuntimeError(
f"Invalid runtime env plugin class {plugin_class}. "
"The plugin class must inherit "
"ray._private.runtime_env.plugin.RuntimeEnvPlugin."
)
if not plugin_class.name:
raise RuntimeError(f"No valid name in runtime env plugin {plugin_class}.")
if plugin_class.name in self.plugins:
raise RuntimeError(
f"The name of runtime env plugin {plugin_class} conflicts "
f"with {self.plugins[plugin_class.name]}.",
)
def validate_priority(self, priority: Any) -> None:
if (
not isinstance(priority, int)
or priority < RAY_RUNTIME_ENV_PLUGIN_MIN_PRIORITY
or priority > RAY_RUNTIME_ENV_PLUGIN_MAX_PRIORITY
):
raise RuntimeError(
f"Invalid runtime env priority {priority}, "
"it should be an integer between "
f"{RAY_RUNTIME_ENV_PLUGIN_MIN_PRIORITY} "
f"and {RAY_RUNTIME_ENV_PLUGIN_MAX_PRIORITY}."
)
def load_plugins(self, plugin_configs: List[Dict]) -> None:
"""Load runtime env plugins and create URI caches for them."""
for plugin_config in plugin_configs:
if (
not isinstance(plugin_config, dict)
or RAY_RUNTIME_ENV_CLASS_FIELD_NAME not in plugin_config
):
raise RuntimeError(
f"Invalid runtime env plugin config {plugin_config}, "
"it should be a object which contains the "
f"{RAY_RUNTIME_ENV_CLASS_FIELD_NAME} field."
)
plugin_class = import_attr(plugin_config[RAY_RUNTIME_ENV_CLASS_FIELD_NAME])
self.validate_plugin_class(plugin_class)
# The priority should be an integer between 0 and 100.
# The default priority is 10. A smaller number indicates a
# higher priority and the plugin will be set up first.
if RAY_RUNTIME_ENV_PRIORITY_FIELD_NAME in plugin_config:
priority = plugin_config[RAY_RUNTIME_ENV_PRIORITY_FIELD_NAME]
else:
priority = plugin_class.priority
self.validate_priority(priority)
class_instance = plugin_class()
self.plugins[plugin_class.name] = PluginSetupContext(
plugin_class.name,
class_instance,
priority,
self.create_uri_cache_for_plugin(class_instance),
)
def add_plugin(self, plugin: RuntimeEnvPlugin) -> None:
"""Add a plugin to the manager and create a URI cache for it.
Args:
plugin: The class instance of the plugin.
"""
plugin_class = type(plugin)
self.validate_plugin_class(plugin_class)
self.validate_priority(plugin_class.priority)
self.plugins[plugin_class.name] = PluginSetupContext(
plugin_class.name,
plugin,
plugin_class.priority,
self.create_uri_cache_for_plugin(plugin),
)
def create_uri_cache_for_plugin(self, plugin: RuntimeEnvPlugin) -> URICache:
"""Create a URI cache for a plugin.
Args:
plugin: The plugin instance whose URIs the cache will manage.
Returns:
The created URI cache for the plugin.
"""
# Set the max size for the cache. Defaults to 10 GB.
cache_size_env_var = f"RAY_RUNTIME_ENV_{plugin.name}_CACHE_SIZE_GB".upper()
cache_size_bytes = int(
(1024**3) * float(os.environ.get(cache_size_env_var, 10))
)
return URICache(plugin.delete_uri, cache_size_bytes)
def sorted_plugin_setup_contexts(self) -> List[PluginSetupContext]:
"""Get the sorted plugin setup contexts, sorted by increasing priority.
Returns:
The sorted plugin setup contexts.
"""
return sorted(self.plugins.values(), key=lambda x: x.priority)
async def create_for_plugin_if_needed(
runtime_env: "RuntimeEnv", # noqa: F821
plugin: RuntimeEnvPlugin,
uri_cache: URICache,
context: RuntimeEnvContext,
logger: logging.Logger = default_logger,
):
"""Set up the environment using the plugin if not already set up and cached."""
if plugin.name not in runtime_env or runtime_env[plugin.name] is None:
return
plugin.validate(runtime_env)
uris = plugin.get_uris(runtime_env)
if not uris:
logger.debug(
f"No URIs for runtime env plugin {plugin.name}; "
"create always without checking the cache."
)
await plugin.create(None, runtime_env, context, logger=logger)
for uri in uris:
if uri not in uri_cache:
logger.debug(f"Cache miss for URI {uri}.")
size_bytes = await plugin.create(uri, runtime_env, context, logger=logger)
uri_cache.add(uri, size_bytes, logger=logger)
else:
logger.info(
f"Runtime env {plugin.name} {uri} is already installed "
"and will be reused. Search "
"all runtime_env_setup-*.log to find the corresponding setup log."
)
uri_cache.mark_used(uri, logger=logger)
plugin.modify_context(uris, runtime_env, context, logger)
@@ -0,0 +1,97 @@
import json
import logging
import os
from typing import List
import jsonschema
from ray._private.runtime_env.constants import (
RAY_RUNTIME_ENV_PLUGIN_SCHEMA_SUFFIX,
RAY_RUNTIME_ENV_PLUGIN_SCHEMAS_ENV_VAR,
)
logger = logging.getLogger(__name__)
class RuntimeEnvPluginSchemaManager:
"""This manager is used to load plugin json schemas."""
default_schema_path = os.path.join(
os.path.dirname(__file__), "../../runtime_env/schemas"
)
schemas = {}
loaded = False
@classmethod
def _load_schemas(cls, schema_paths: List[str]):
for schema_path in schema_paths:
try:
with open(schema_path) as f:
schema = json.load(f)
except json.decoder.JSONDecodeError:
logger.error("Invalid runtime env schema %s, skip it.", schema_path)
continue
except OSError:
logger.error("Cannot open runtime env schema %s, skip it.", schema_path)
continue
if "title" not in schema:
logger.error(
"No valid title in runtime env schema %s, skip it.", schema_path
)
continue
if schema["title"] in cls.schemas:
logger.error(
"The 'title' of runtime env schema %s conflicts with %s, skip it.",
schema_path,
cls.schemas[schema["title"]],
)
continue
cls.schemas[schema["title"]] = schema
@classmethod
def _load_default_schemas(cls):
schema_json_files = list()
for root, _, files in os.walk(cls.default_schema_path):
for f in files:
if f.endswith(RAY_RUNTIME_ENV_PLUGIN_SCHEMA_SUFFIX):
schema_json_files.append(os.path.join(root, f))
logger.debug(
f"Loading the default runtime env schemas: {schema_json_files}."
)
cls._load_schemas(schema_json_files)
@classmethod
def _load_schemas_from_env_var(cls):
# The format of env var:
# "/path/to/env_1_schema.json,/path/to/env_2_schema.json,/path/to/schemas_dir/"
schema_paths = os.environ.get(RAY_RUNTIME_ENV_PLUGIN_SCHEMAS_ENV_VAR)
if schema_paths:
schema_json_files = list()
for path in schema_paths.split(","):
if path.endswith(RAY_RUNTIME_ENV_PLUGIN_SCHEMA_SUFFIX):
schema_json_files.append(path)
elif os.path.isdir(path):
for root, _, files in os.walk(path):
for f in files:
if f.endswith(RAY_RUNTIME_ENV_PLUGIN_SCHEMA_SUFFIX):
schema_json_files.append(os.path.join(root, f))
logger.info(
f"Loading the runtime env schemas from env var: {schema_json_files}."
)
cls._load_schemas(schema_json_files)
@classmethod
def validate(cls, name, instance):
if not cls.loaded:
# Load the schemas lazily.
cls._load_default_schemas()
cls._load_schemas_from_env_var()
cls.loaded = True
# if no schema matches, skip the validation.
if name in cls.schemas:
jsonschema.validate(instance=instance, schema=cls.schemas[name])
@classmethod
def clear(cls):
cls.schemas.clear()
cls.loaded = False
+315
View File
@@ -0,0 +1,315 @@
import enum
import os
from urllib.parse import urlparse
RAY_RUNTIME_ENV_HTTP_USER_AGENT_ENV_VAR = "RAY_RUNTIME_ENV_HTTP_USER_AGENT"
RAY_RUNTIME_ENV_BEARER_TOKEN_ENV_VAR = "RAY_RUNTIME_ENV_BEARER_TOKEN"
_DEFAULT_HTTP_USER_AGENT = "ray-runtime-env-curl/1.0"
class ProtocolsProvider:
_MISSING_DEPENDENCIES_WARNING = (
"Note that these must be preinstalled "
"on all nodes in the Ray cluster; it is not "
"sufficient to install them in the runtime_env."
)
@classmethod
def get_protocols(cls):
return {
# For packages dynamically uploaded and managed by the GCS.
"gcs",
# For conda environments installed locally on each node.
"conda",
# For pip environments installed locally on each node.
"pip",
# For uv environments install locally on each node.
"uv",
# Remote http path, assumes everything packed in one zip file.
"http",
# Remote https path, assumes everything packed in one zip file.
"https",
# Remote s3 path, assumes everything packed in one zip file.
"s3",
# Remote google storage path, assumes everything packed in one zip file.
"gs",
# Remote azure blob storage path, assumes everything packed in one zip file.
"azure",
# Remote Azure Blob File System Secure path, assumes everything packed in one zip file.
"abfss",
# File storage path, assumes everything packed in one zip file.
"file",
}
@classmethod
def get_remote_protocols(cls):
return {"http", "https", "s3", "gs", "azure", "abfss", "file"}
@classmethod
def _handle_s3_protocol(cls):
"""Set up S3 protocol handling.
Returns:
tuple: (open_file function, transport_params)
Raises:
ImportError: If required dependencies are not installed.
"""
try:
import boto3
from smart_open import open as open_file
except ImportError:
raise ImportError(
"You must `pip install smart_open[s3]` "
"to fetch URIs in s3 bucket. " + cls._MISSING_DEPENDENCIES_WARNING
)
# Create S3 client, falling back to unsigned for public buckets
session = boto3.Session()
# session.get_credentials() will return None if no credentials can be found.
if session.get_credentials():
# If credentials are found, use a standard signed client.
s3_client = session.client("s3")
else:
# No credentials found, fall back to an unsigned client for public buckets.
from botocore import UNSIGNED
from botocore.config import Config
s3_client = boto3.client("s3", config=Config(signature_version=UNSIGNED))
transport_params = {"client": s3_client}
return open_file, transport_params
@classmethod
def _handle_gs_protocol(cls):
"""Set up Google Cloud Storage protocol handling.
Returns:
tuple: (open_file function, transport_params)
Raises:
ImportError: If required dependencies are not installed.
"""
try:
from google.cloud import storage # noqa: F401
from smart_open import open as open_file
except ImportError:
raise ImportError(
"You must `pip install smart_open[gcs]` "
"to fetch URIs in Google Cloud Storage bucket."
+ cls._MISSING_DEPENDENCIES_WARNING
)
return open_file, None
@classmethod
def _handle_azure_protocol(cls):
"""Set up Azure blob storage protocol handling.
Returns:
tuple: (open_file function, transport_params)
Raises:
ImportError: If required dependencies are not installed.
ValueError: If required environment variables are not set.
"""
try:
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient # noqa: F401
from smart_open import open as open_file
except ImportError:
raise ImportError(
"You must `pip install azure-storage-blob azure-identity smart_open[azure]` "
"to fetch URIs in Azure Blob Storage. "
+ cls._MISSING_DEPENDENCIES_WARNING
)
# Define authentication variable
azure_storage_account_name = os.getenv("AZURE_STORAGE_ACCOUNT")
if not azure_storage_account_name:
raise ValueError(
"Azure Blob Storage authentication requires "
"AZURE_STORAGE_ACCOUNT environment variable to be set."
)
account_url = f"https://{azure_storage_account_name}.blob.core.windows.net/"
transport_params = {
"client": BlobServiceClient(
account_url=account_url, credential=DefaultAzureCredential()
)
}
return open_file, transport_params
@classmethod
def _handle_abfss_protocol(cls):
"""Set up Azure Blob File System Secure (ABFSS) protocol handling.
Returns:
tuple: (open_file function, transport_params)
Raises:
ImportError: If required dependencies are not installed.
ValueError: If the ABFSS URI format is invalid.
"""
try:
import adlfs
from azure.identity import DefaultAzureCredential
except ImportError:
raise ImportError(
"You must `pip install adlfs azure-identity` "
"to fetch URIs in Azure Blob File System Secure. "
+ cls._MISSING_DEPENDENCIES_WARNING
)
def open_file(uri, mode, *, transport_params=None):
# Parse and validate the ABFSS URI
parsed = urlparse(uri)
# Validate ABFSS URI format: abfss://container@account.dfs.core.windows.net/path
if not parsed.netloc or "@" not in parsed.netloc:
raise ValueError(
f"Invalid ABFSS URI format - missing container@account: {uri}"
)
container_part, hostname_part = parsed.netloc.split("@", 1)
# Validate container name (must be non-empty)
if not container_part:
raise ValueError(
f"Invalid ABFSS URI format - empty container name: {uri}"
)
# Validate hostname format
if not hostname_part or not hostname_part.endswith(".dfs.core.windows.net"):
raise ValueError(
f"Invalid ABFSS URI format - invalid hostname (must end with .dfs.core.windows.net): {uri}"
)
# Extract and validate account name
azure_storage_account_name = hostname_part.split(".")[0]
if not azure_storage_account_name:
raise ValueError(
f"Invalid ABFSS URI format - empty account name: {uri}"
)
# Handle ABFSS URI with adlfs
filesystem = adlfs.AzureBlobFileSystem(
account_name=azure_storage_account_name,
credential=DefaultAzureCredential(),
)
return filesystem.open(uri, mode)
return open_file, None
@classmethod
def _http_headers(cls) -> dict:
headers = {
"User-Agent": os.environ.get(
RAY_RUNTIME_ENV_HTTP_USER_AGENT_ENV_VAR, _DEFAULT_HTTP_USER_AGENT
),
"Accept": "*/*",
}
bearer_token = os.environ.get(RAY_RUNTIME_ENV_BEARER_TOKEN_ENV_VAR)
if bearer_token:
headers["Authorization"] = f"Bearer {bearer_token}"
return headers
@classmethod
def _handle_http_protocol(cls):
"""Set up HTTP/HTTPS protocol handling with curl-like headers."""
try:
from smart_open import open as smart_open_open
except ImportError:
raise ImportError(
"You must `pip install smart_open` to fetch HTTP/HTTPS URIs. "
+ cls._MISSING_DEPENDENCIES_WARNING
)
def open_file(uri, mode, *, transport_params=None):
params = {
"headers": cls._http_headers(),
"timeout": 60,
}
if transport_params:
params.update(transport_params)
return smart_open_open(uri, mode, transport_params=params)
return open_file, None
@classmethod
def download_remote_uri(cls, protocol: str, source_uri: str, dest_file: str):
"""Download file from remote URI to destination file.
Args:
protocol: The protocol to use for downloading (e.g., 's3', 'https').
source_uri: The source URI to download from.
dest_file: The destination file path to save to.
Raises:
ImportError: If required dependencies for the protocol are not installed.
"""
assert protocol in cls.get_remote_protocols()
tp = None
open_file = None
if protocol == "file":
source_uri = source_uri[len("file://") :]
def open_file(uri, mode, *, transport_params=None):
return open(uri, mode)
elif protocol in ("http", "https"):
open_file, tp = cls._handle_http_protocol()
elif protocol == "s3":
open_file, tp = cls._handle_s3_protocol()
elif protocol == "gs":
open_file, tp = cls._handle_gs_protocol()
elif protocol == "azure":
open_file, tp = cls._handle_azure_protocol()
elif protocol == "abfss":
open_file, tp = cls._handle_abfss_protocol()
else:
try:
from smart_open import open as open_file
except ImportError:
raise ImportError(
"You must `pip install smart_open` "
f"to fetch {protocol.upper()} URIs. "
+ cls._MISSING_DEPENDENCIES_WARNING
)
with open_file(source_uri, "rb", transport_params=tp) as fin:
with open(dest_file, "wb") as fout:
fout.write(fin.read())
Protocol = enum.Enum(
"Protocol",
{protocol.upper(): protocol for protocol in ProtocolsProvider.get_protocols()},
)
@classmethod
def _remote_protocols(cls):
# Returns a list of protocols that support remote storage
# These protocols should only be used with paths that end in
# ".zip", ".whl", ".tar.gz", or ".tgz"
return [
cls[protocol.upper()] for protocol in ProtocolsProvider.get_remote_protocols()
]
Protocol.remote_protocols = _remote_protocols
def _download_remote_uri(self, source_uri, dest_file):
return ProtocolsProvider.download_remote_uri(self.value, source_uri, dest_file)
Protocol.download_remote_uri = _download_remote_uri
@@ -0,0 +1,45 @@
import logging
from typing import List, Optional
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
default_logger = logging.getLogger(__name__)
class PyExecutablePlugin(RuntimeEnvPlugin):
"""This plugin allows running Ray workers with a custom Python executable.
You can use it with
`ray.init(runtime_env={"py_executable": "<command> <args>"})`. If you specify
a `working_dir` in the runtime environment, the executable will have access
to the working directory, for example, to a requirements.txt for a package manager,
a script for a debugger, or the executable could be a shell script in the
working directory. You can also use this plugin to run worker processes
in a custom profiler or use a custom Python interpreter or `python` with
custom arguments.
"""
name = "py_executable"
def __init__(self):
pass
async def create(
self,
uri: Optional[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger = default_logger,
) -> int:
return 0
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
logger.info("Running py_executable plugin")
context.py_executable = runtime_env.py_executable()
@@ -0,0 +1,242 @@
import logging
import os
from pathlib import Path
from types import ModuleType
from typing import Any, Dict, List, Optional
from ray._common.utils import try_to_create_directory
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.packaging import (
Protocol,
delete_package,
download_and_unpack_package,
get_local_dir_from_uri,
get_uri_for_directory,
get_uri_for_file,
get_uri_for_package,
install_wheel_package,
is_whl_uri,
package_exists,
parse_uri,
upload_package_if_needed,
upload_package_to_gcs,
)
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray._private.runtime_env.working_dir import set_pythonpath_in_context
from ray._private.utils import get_directory_size_bytes
from ray._raylet import GcsClient
from ray.exceptions import RuntimeEnvSetupError
default_logger = logging.getLogger(__name__)
def _check_is_uri(s: str) -> bool:
try:
protocol, path = parse_uri(s)
except ValueError:
protocol, path = None, None
supported_extensions = (".zip", ".whl", ".tar.gz", ".tgz")
if protocol in Protocol.remote_protocols() and not any(
path.endswith(ext) for ext in supported_extensions
):
raise ValueError(
"Only .zip, .whl, .tar.gz, and .tgz files supported for remote URIs."
)
return protocol is not None
def upload_py_modules_if_needed(
runtime_env: Dict[str, Any],
include_gitignore: bool,
scratch_dir: Optional[str] = None,
logger: Optional[logging.Logger] = default_logger,
upload_fn=None,
) -> Dict[str, Any]:
"""Uploads the entries in py_modules and replaces them with a list of URIs.
For each entry that is already a URI, this is a no-op.
"""
py_modules = runtime_env.get("py_modules")
if py_modules is None:
return runtime_env
if not isinstance(py_modules, list):
raise TypeError(
"py_modules must be a List of local paths, imported modules, or "
f"URIs, got {type(py_modules)}."
)
py_modules_uris = []
for module in py_modules:
if isinstance(module, str):
# module_path is a local path or a URI.
module_path = module
elif isinstance(module, Path):
module_path = str(module)
elif isinstance(module, ModuleType):
if not hasattr(module, "__path__"):
# This is a single-file module.
module_path = module.__file__
else:
# NOTE(edoakes): Python allows some installed Python packages to
# be split into multiple directories. We could probably handle
# this, but it seems tricky & uncommon. If it's a problem for
# users, we can add this support on demand.
if len(module.__path__) > 1:
raise ValueError(
"py_modules only supports modules whose __path__"
" has length 1 or those who are single-file."
)
[module_path] = module.__path__
else:
raise TypeError(
"py_modules must be a list of file paths, URIs, "
f"or imported modules, got {type(module)}."
)
if _check_is_uri(module_path):
module_uri = module_path
else:
# module_path is a local path.
if Path(module_path).is_dir() or Path(module_path).suffix == ".py":
is_dir = Path(module_path).is_dir()
excludes = runtime_env.get("excludes", None)
if is_dir:
module_uri = get_uri_for_directory(
module_path,
include_gitignore=include_gitignore,
excludes=excludes,
)
else:
module_uri = get_uri_for_file(module_path)
if upload_fn is None:
if scratch_dir is None:
scratch_dir = os.getcwd()
try:
upload_package_if_needed(
module_uri,
scratch_dir,
module_path,
include_gitignore=include_gitignore,
include_parent_dir=is_dir,
excludes=excludes,
logger=logger,
)
except Exception as e:
from ray.util.spark.utils import is_in_databricks_runtime
if is_in_databricks_runtime():
raise RuntimeEnvSetupError(
f"Failed to upload module {module_path} to the Ray "
f"cluster, please ensure there are only files under "
f"the module path, notebooks under the path are "
f"not allowed, original exception: {e}"
) from e
raise RuntimeEnvSetupError(
f"Failed to upload module {module_path} to the Ray "
f"cluster: {e}"
) from e
else:
upload_fn(module_path, excludes=excludes)
elif Path(module_path).suffix == ".whl":
module_uri = get_uri_for_package(Path(module_path))
if upload_fn is None:
if not package_exists(module_uri):
try:
upload_package_to_gcs(
module_uri, Path(module_path).read_bytes()
)
except Exception as e:
raise RuntimeEnvSetupError(
f"Failed to upload {module_path} to the Ray "
f"cluster: {e}"
) from e
else:
upload_fn(module_path, excludes=None, is_file=True)
else:
raise ValueError(
"py_modules entry must be a .py file, "
"a directory, or a .whl file; "
f"got {module_path}"
)
py_modules_uris.append(module_uri)
# TODO(architkulkarni): Expose a single URI for py_modules. This plugin
# should internally handle the "sub-URIs", the individual modules.
runtime_env["py_modules"] = py_modules_uris
return runtime_env
class PyModulesPlugin(RuntimeEnvPlugin):
name = "py_modules"
def __init__(self, resources_dir: str, gcs_client: GcsClient):
self._resources_dir = os.path.join(resources_dir, "py_modules_files")
self._gcs_client = gcs_client
try_to_create_directory(self._resources_dir)
def _get_local_dir_from_uri(self, uri: str):
return get_local_dir_from_uri(uri, self._resources_dir)
def delete_uri(
self, uri: str, logger: Optional[logging.Logger] = default_logger
) -> int:
"""Delete URI and return the number of bytes deleted."""
logger.info("Got request to delete pymodule URI %s", uri)
local_dir = get_local_dir_from_uri(uri, self._resources_dir)
local_dir_size = get_directory_size_bytes(local_dir)
deleted = delete_package(uri, self._resources_dir)
if not deleted:
logger.warning(f"Tried to delete nonexistent URI: {uri}.")
return 0
return local_dir_size
def get_uris(self, runtime_env) -> List[str]:
return runtime_env.py_modules()
async def create(
self,
uri: str,
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
) -> int:
module_dir = await download_and_unpack_package(
uri, self._resources_dir, self._gcs_client, logger=logger
)
if is_whl_uri(uri):
wheel_uri = module_dir
module_dir = self._get_local_dir_from_uri(uri)
await install_wheel_package(
wheel_uri=wheel_uri, target_dir=module_dir, logger=logger
)
return get_directory_size_bytes(module_dir)
def modify_context(
self,
uris: List[str],
runtime_env_dict: Dict,
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
module_dirs = []
for uri in uris:
module_dir = self._get_local_dir_from_uri(uri)
if not module_dir.exists():
raise ValueError(
f"Local directory {module_dir} for URI {uri} does "
"not exist on the cluster. Something may have gone wrong while "
"downloading, unpacking or installing the py_modules files."
)
module_dirs.append(str(module_dir))
set_pythonpath_in_context(os.pathsep.join(module_dirs), context)
@@ -0,0 +1,173 @@
import asyncio
import copy
import logging
import os
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from ray._common.utils import try_to_create_directory
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray.exceptions import RuntimeEnvSetupError
default_logger = logging.getLogger(__name__)
# rocprof-sys config used when runtime_env={"_rocprof_sys": "default"}
# Refer to the following link for more information on rocprof-sys options
# https://rocm.docs.amd.com/projects/rocprofiler-systems/en/docs-6.4.0/how-to/understanding-rocprof-sys-output.html
ROCPROFSYS_DEFAULT_CONFIG = {
"env": {
"ROCPROFSYS_TIME_OUTPUT": "false",
"ROCPROFSYS_OUTPUT_PREFIX": "worker_process_%p",
},
"args": {
"F": "true",
},
}
def parse_rocprof_sys_config(
rocprof_sys_config: Dict[str, str]
) -> Tuple[List[str], List[str]]:
"""
Function to convert dictionary of rocprof-sys options into
rocprof-sys-python command line
The function returns:
- List[str]: rocprof-sys-python cmd line split into list of str
"""
rocprof_sys_cmd = ["rocprof-sys-python"]
rocprof_sys_env = {}
if "args" in rocprof_sys_config:
# Parse rocprof-sys arg options
for option, option_val in rocprof_sys_config["args"].items():
# option standard based on
# https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html
if len(option) > 1:
rocprof_sys_cmd.append(f"--{option}={option_val}")
else:
rocprof_sys_cmd += [f"-{option}", option_val]
if "env" in rocprof_sys_config:
rocprof_sys_env = rocprof_sys_config["env"]
rocprof_sys_cmd.append("--")
return rocprof_sys_cmd, rocprof_sys_env
class RocProfSysPlugin(RuntimeEnvPlugin):
name = "_rocprof_sys"
def __init__(self, resources_dir: str):
self.rocprof_sys_cmd = []
self.rocprof_sys_env = {}
# replace this with better way to get logs dir
session_dir, runtime_dir = os.path.split(resources_dir)
self._rocprof_sys_dir = Path(session_dir) / "logs" / "rocprof_sys"
try_to_create_directory(self._rocprof_sys_dir)
async def _check_rocprof_sys_script(
self, rocprof_sys_config: Dict[str, str]
) -> Tuple[bool, str]:
"""
Function to validate if rocprof_sys_config is a valid rocprof_sys profile options
Args:
rocprof_sys_config: dictionary mapping rocprof_sys option to it's value
Returns:
a tuple consists of a boolean indicating if the rocprof_sys_config
is valid option and an error message if the rocprof_sys_config is invalid
"""
# use empty as rocprof_sys report test filename
test_folder = str(Path(self._rocprof_sys_dir) / "test")
rocprof_sys_cmd, rocprof_sys_env = parse_rocprof_sys_config(rocprof_sys_config)
rocprof_sys_env_copy = copy.deepcopy(rocprof_sys_env)
rocprof_sys_env_copy["ROCPROFSYS_OUTPUT_PATH"] = test_folder
rocprof_sys_env_copy.update(os.environ)
try_to_create_directory(test_folder)
# Create a test python file to run rocprof_sys
with open(f"{test_folder}/test.py", "w") as f:
f.write("import time\n")
try:
rocprof_sys_cmd = rocprof_sys_cmd + [f"{test_folder}/test.py"]
process = await asyncio.create_subprocess_exec(
*rocprof_sys_cmd,
env=rocprof_sys_env_copy,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
error_msg = stderr.strip() if stderr.strip() != "" else stdout.strip()
# cleanup temp file
clean_up_cmd = ["rm", "-r", test_folder]
cleanup_process = await asyncio.create_subprocess_exec(
*clean_up_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
_, _ = await cleanup_process.communicate()
if process.returncode == 0:
return True, None
else:
return False, error_msg
except FileNotFoundError:
return False, ("rocprof_sys is not installed")
async def create(
self,
uri: Optional[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: logging.Logger = default_logger,
) -> int:
rocprof_sys_config = runtime_env.rocprof_sys()
if not rocprof_sys_config:
return 0
if rocprof_sys_config and sys.platform != "linux":
raise RuntimeEnvSetupError("rocprof-sys CLI is only available in Linux.\n")
if isinstance(rocprof_sys_config, str):
if rocprof_sys_config == "default":
rocprof_sys_config = ROCPROFSYS_DEFAULT_CONFIG
else:
raise RuntimeEnvSetupError(
f"Unsupported rocprof_sys config: {rocprof_sys_config}. "
"The supported config is 'default' or "
"Dictionary of rocprof_sys options"
)
is_valid_rocprof_sys_config, error_msg = await self._check_rocprof_sys_script(
rocprof_sys_config
)
if not is_valid_rocprof_sys_config:
logger.warning(error_msg)
raise RuntimeEnvSetupError(
"rocprof-sys profile failed to run with the following "
f"error message:\n {error_msg}"
)
# add set output path to logs dir
if "env" not in rocprof_sys_config:
rocprof_sys_config["env"] = {}
rocprof_sys_config["env"]["ROCPROFSYS_OUTPUT_PATH"] = str(
Path(self._rocprof_sys_dir)
)
self.rocprof_sys_cmd, self.rocprof_sys_env = parse_rocprof_sys_config(
rocprof_sys_config
)
return 0
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
logger.info("Running rocprof-sys profiler")
context.py_executable = " ".join(self.rocprof_sys_cmd)
context.env_vars.update(self.rocprof_sys_env)
@@ -0,0 +1,261 @@
import base64
import logging
import os
import traceback
from typing import Any, Callable, Dict, Optional, Union
import ray
import ray._private.ray_constants as ray_constants
import ray.cloudpickle as pickle
from ray._common.utils import load_class
from ray._private.function_manager import build_setup_hook_export_entry
from ray.runtime_env import RuntimeEnv
logger = logging.getLogger(__name__)
RUNTIME_ENV_FUNC_IDENTIFIER = "ray_runtime_env_func::"
def get_import_export_timeout():
return int(
os.environ.get(
ray_constants.RAY_WORKER_PROCESS_SETUP_HOOK_LOAD_TIMEOUT_ENV_VAR, "60"
)
)
def decode_function_key(key: bytes) -> str:
# b64encode only includes A-Z, a-z, 0-9, + and / characters
return RUNTIME_ENV_FUNC_IDENTIFIER + base64.b64encode(key).decode()
def _encode_function_key(key: str) -> bytes:
assert key.startswith(RUNTIME_ENV_FUNC_IDENTIFIER)
return base64.b64decode(key[len(RUNTIME_ENV_FUNC_IDENTIFIER) :])
def _raise_setup_hook_conflict(existing_hook_value: str, setup_hook_desc: str) -> None:
raise RuntimeError(
"Conflicting worker_process_setup_hook: the setup hook env "
f"var is already set to '{existing_hook_value}', but "
f"runtime_env specifies {setup_hook_desc}."
)
def export_setup_func_callable(
runtime_env: Union[Dict[str, Any], RuntimeEnv],
setup_func: Callable,
worker: "ray.Worker",
) -> Union[Dict[str, Any], RuntimeEnv]:
assert isinstance(setup_func, Callable)
try:
key = worker.function_actor_manager.export_setup_func(
setup_func, timeout=get_import_export_timeout()
)
except Exception as e:
raise ray.exceptions.RuntimeEnvSetupError(
"Failed to export the setup function."
) from e
env_vars = runtime_env.get("env_vars", {})
assert ray_constants.WORKER_PROCESS_SETUP_HOOK_ENV_VAR not in env_vars, (
f"The env var, {ray_constants.WORKER_PROCESS_SETUP_HOOK_ENV_VAR}, "
"is not permitted because it is reserved for the internal use."
)
env_vars[ray_constants.WORKER_PROCESS_SETUP_HOOK_ENV_VAR] = decode_function_key(key)
runtime_env["env_vars"] = env_vars
# Note: This field is no-op. We don't have a plugin for the setup hook
# because we can implement it simply using an env var.
# This field is just for the observability purpose, so we store
# the name of the method.
runtime_env["worker_process_setup_hook"] = setup_func.__name__
return runtime_env
def export_setup_func_module(
runtime_env: Union[Dict[str, Any], RuntimeEnv],
setup_func_module: str,
) -> Union[Dict[str, Any], RuntimeEnv]:
assert isinstance(setup_func_module, str)
env_vars = runtime_env.get("env_vars", {})
assert ray_constants.WORKER_PROCESS_SETUP_HOOK_ENV_VAR not in env_vars, (
f"The env var, {ray_constants.WORKER_PROCESS_SETUP_HOOK_ENV_VAR}, "
"is not permitted because it is reserved for the internal use."
)
env_vars[ray_constants.WORKER_PROCESS_SETUP_HOOK_ENV_VAR] = setup_func_module
runtime_env["env_vars"] = env_vars
return runtime_env
def _check_setup_hook_consistency(
existing_hook_value: str,
setup_func: Union[Callable, str],
worker: "ray.Worker",
) -> None:
"""Validate that an already-set hook env var is consistent with setup_func.
When the env var is already populated (e.g. inherited from a job supervisor),
we compare it against the `worker_process_setup_hook` field in the runtime_env
to detect silent mismatches.
Args:
existing_hook_value: The value of the existing hook env var.
setup_func: The setup function or module path.
worker: The worker instance.
Raises:
RuntimeError: If a conflict between the existing env var and setup_func is detected.
"""
if isinstance(setup_func, Callable):
try:
_encode_function_key(existing_hook_value)
except Exception:
_raise_setup_hook_conflict(
existing_hook_value, f"callable '{setup_func.__name__}'"
)
_check_callable_hooks_match(existing_hook_value, setup_func, worker)
elif isinstance(setup_func, str):
try:
_encode_function_key(existing_hook_value)
existing_is_callable_ref = True
except Exception:
existing_is_callable_ref = False
if existing_is_callable_ref or existing_hook_value != setup_func:
_raise_setup_hook_conflict(existing_hook_value, f"'{setup_func}'")
def _check_callable_hooks_match(
existing_hook_value: str,
setup_func: Callable,
worker: "ray.Worker",
) -> None:
"""Verify a callable produces the same GCS key as the existing env var."""
_, _, expected_key = build_setup_hook_export_entry(
setup_func, worker.current_job_id.binary()
)
expected_env_value = decode_function_key(expected_key)
if existing_hook_value != expected_env_value:
_raise_setup_hook_conflict(
existing_hook_value, f"callable '{setup_func.__name__}'"
)
def upload_worker_process_setup_hook_if_needed(
runtime_env: Union[Dict[str, Any], RuntimeEnv],
worker: "ray.Worker",
) -> Union[Dict[str, Any], RuntimeEnv]:
"""Uploads the worker_process_setup_hook to GCS with a key.
runtime_env["worker_process_setup_hook"] is converted to a decoded key
that can load the worker setup hook function from GCS.
i.e., you can use internalKV.Get(runtime_env["worker_process_setup_hook])
to access the worker setup hook from GCS.
Args:
runtime_env: The runtime_env. The value will be modified
when returned.
worker: ray.worker instance.
Returns:
The modified runtime_env with the setup hook processed into an env var.
"""
setup_func = runtime_env.get("worker_process_setup_hook")
if setup_func is None:
return runtime_env
env_vars = runtime_env.get("env_vars", {})
existing_hook = env_vars.get(ray_constants.WORKER_PROCESS_SETUP_HOOK_ENV_VAR)
if existing_hook is not None:
# A setup hook is already populated (e.g. inherited from job supervisor).
# Validate that it is consistent with the current worker_process_setup_hook.
_check_setup_hook_consistency(existing_hook, setup_func, worker)
return runtime_env
if isinstance(setup_func, Callable):
return export_setup_func_callable(runtime_env, setup_func, worker)
elif isinstance(setup_func, str):
return export_setup_func_module(runtime_env, setup_func)
else:
raise TypeError(
"worker_process_setup_hook must be a function, " f"got {type(setup_func)}."
)
def load_and_execute_setup_hook(
worker_process_setup_hook_key: str,
) -> Optional[str]:
"""Load the setup hook from a given key and execute.
Args:
worker_process_setup_hook_key: The key to import the setup hook
from GCS.
Returns:
An error message if it fails. None if it succeeds.
"""
assert worker_process_setup_hook_key is not None
if not worker_process_setup_hook_key.startswith(RUNTIME_ENV_FUNC_IDENTIFIER):
return load_and_execute_setup_hook_module(worker_process_setup_hook_key)
else:
return load_and_execute_setup_hook_func(worker_process_setup_hook_key)
def load_and_execute_setup_hook_module(
worker_process_setup_hook_key: str,
) -> Optional[str]:
try:
setup_func = load_class(worker_process_setup_hook_key)
setup_func()
return None
except Exception:
error_message = (
"Failed to execute the setup hook method, "
f"{worker_process_setup_hook_key} "
"from ``ray.init(runtime_env="
f"{{'worker_process_setup_hook': {worker_process_setup_hook_key}}})``. "
"Please make sure the given module exists and is available "
"from ray workers. For more details, see the error trace below.\n"
f"{traceback.format_exc()}"
)
return error_message
def load_and_execute_setup_hook_func(
worker_process_setup_hook_key: str,
) -> Optional[str]:
worker = ray._private.worker.global_worker
assert worker.connected
func_manager = worker.function_actor_manager
try:
worker_setup_func_info = func_manager.fetch_registered_method(
_encode_function_key(worker_process_setup_hook_key),
timeout=get_import_export_timeout(),
)
except Exception:
error_message = (
"Failed to import setup hook within "
f"{get_import_export_timeout()} seconds.\n"
f"{traceback.format_exc()}"
)
return error_message
try:
setup_func = pickle.loads(worker_setup_func_info.function)
except Exception:
error_message = (
"Failed to deserialize the setup hook method.\n" f"{traceback.format_exc()}"
)
return error_message
try:
setup_func()
except Exception:
error_message = (
f"Failed to execute the setup hook method. Function name:"
f"{worker_setup_func_info.function_name}\n"
f"{traceback.format_exc()}"
)
return error_message
return None
@@ -0,0 +1,115 @@
import logging
from typing import Callable, Optional, Set
default_logger = logging.getLogger(__name__)
DEFAULT_MAX_URI_CACHE_SIZE_BYTES = (1024**3) * 10 # 10 GB
class URICache:
"""Caches URIs up to a specified total size limit.
URIs are represented by strings. Each URI has an associated size on disk.
When a URI is added to the URICache, it is marked as "in use".
When a URI is no longer in use, the user of this class should call
`mark_unused` to signal that the URI is safe for deletion.
URIs in the cache can be marked as "in use" by calling `mark_used`.
Deletion of URIs on disk does not occur until the size limit is exceeded.
When this happens, URIs that are not in use are deleted randomly until the
size limit is satisfied, or there are no more URIs that are not in use.
It is possible for the total size on disk to exceed the size limit if all
the URIs are in use.
"""
def __init__(
self,
delete_fn: Optional[Callable[[str, logging.Logger], int]] = None,
max_total_size_bytes: int = DEFAULT_MAX_URI_CACHE_SIZE_BYTES,
debug_mode: bool = False,
):
# Maps URIs to the size in bytes of their corresponding disk contents.
self._used_uris: Set[str] = set()
self._unused_uris: Set[str] = set()
if delete_fn is None:
self._delete_fn = lambda uri, logger: 0
else:
self._delete_fn = delete_fn
# Total size of both used and unused URIs in the cache.
self._total_size_bytes = 0
self.max_total_size_bytes = max_total_size_bytes
# Used in `self._check_valid()` for testing.
self._debug_mode = debug_mode
def mark_unused(self, uri: str, logger: logging.Logger = default_logger):
"""Mark a URI as unused and okay to be deleted."""
if uri not in self._used_uris:
logger.info(f"URI {uri} is already unused.")
else:
self._unused_uris.add(uri)
self._used_uris.remove(uri)
logger.info(f"Marked URI {uri} unused.")
self._evict_if_needed(logger)
self._check_valid()
def mark_used(self, uri: str, logger: logging.Logger = default_logger):
"""Mark a URI as in use. URIs in use will not be deleted."""
if uri in self._used_uris:
return
elif uri in self._unused_uris:
self._used_uris.add(uri)
self._unused_uris.remove(uri)
else:
raise ValueError(
f"Got request to mark URI {uri} used, but this "
"URI is not present in the cache."
)
logger.info(f"Marked URI {uri} used.")
self._check_valid()
def add(self, uri: str, size_bytes: int, logger: logging.Logger = default_logger):
"""Add a URI to the cache and mark it as in use."""
if uri in self._unused_uris:
self._unused_uris.remove(uri)
self._used_uris.add(uri)
self._total_size_bytes += size_bytes
self._evict_if_needed(logger)
self._check_valid()
logger.info(f"Added URI {uri} with size {size_bytes}")
def get_total_size_bytes(self) -> int:
return self._total_size_bytes
def _evict_if_needed(self, logger: logging.Logger = default_logger):
"""Evict unused URIs (if they exist) until total size <= max size."""
while (
self._unused_uris
and self.get_total_size_bytes() > self.max_total_size_bytes
):
# TODO(architkulkarni): Evict least recently used URI instead
arbitrary_unused_uri = next(iter(self._unused_uris))
self._unused_uris.remove(arbitrary_unused_uri)
num_bytes_deleted = self._delete_fn(arbitrary_unused_uri, logger)
self._total_size_bytes -= num_bytes_deleted
logger.info(
f"Deleted URI {arbitrary_unused_uri} with size " f"{num_bytes_deleted}."
)
def _check_valid(self):
"""(Debug mode only) Check "used" and "unused" sets are disjoint."""
if self._debug_mode:
assert self._used_uris & self._unused_uris == set()
def __contains__(self, uri):
return uri in self._used_uris or uri in self._unused_uris
def __repr__(self):
return str(self.__dict__)
+117
View File
@@ -0,0 +1,117 @@
import asyncio
import itertools
import logging
import subprocess
import textwrap
import types
from typing import List
class SubprocessCalledProcessError(subprocess.CalledProcessError):
"""The subprocess.CalledProcessError with stripped stdout."""
LAST_N_LINES = 50
def __init__(self, *args, cmd_index=None, **kwargs):
self.cmd_index = cmd_index
super().__init__(*args, **kwargs)
@staticmethod
def _get_last_n_line(str_data: str, last_n_lines: int) -> str:
if last_n_lines < 0:
return str_data
lines = str_data.strip().split("\n")
return "\n".join(lines[-last_n_lines:])
def __str__(self):
str_list = (
[]
if self.cmd_index is None
else [f"Run cmd[{self.cmd_index}] failed with the following details."]
)
str_list.append(super().__str__())
out = {
"stdout": self.stdout,
"stderr": self.stderr,
}
for name, s in out.items():
if s:
subtitle = f"Last {self.LAST_N_LINES} lines of {name}:"
last_n_line_str = self._get_last_n_line(s, self.LAST_N_LINES).strip()
str_list.append(
f"{subtitle}\n{textwrap.indent(last_n_line_str, ' ' * 4)}"
)
return "\n".join(str_list)
async def check_output_cmd(
cmd: List[str],
*,
logger: logging.Logger,
cmd_index_gen: types.GeneratorType = itertools.count(1),
**kwargs,
) -> str:
"""Run command with arguments and return its output.
If the return code was non-zero it raises a CalledProcessError. The
CalledProcessError object will have the return code in the returncode
attribute and any output in the output attribute.
Args:
cmd: The cmdline should be a sequence of program arguments or else
a single string or path-like object. The program to execute is
the first item in cmd.
logger: The logger instance.
cmd_index_gen: The cmd index generator, default is itertools.count(1).
**kwargs: All arguments are passed to the create_subprocess_exec.
Returns:
The stdout of cmd.
Raises:
CalledProcessError: If the return code of cmd is not 0.
"""
cmd_index = next(cmd_index_gen)
logger.info("Run cmd[%s] %s", cmd_index, repr(cmd))
proc = None
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
**kwargs,
)
# Use communicate instead of polling stdout:
# * Avoid deadlocks due to streams pausing reading or writing and blocking the
# child process. Please refer to:
# https://docs.python.org/3/library/asyncio-subprocess.html#asyncio.asyncio.subprocess.Process.stderr
# * Avoid mixing multiple outputs of concurrent cmds.
stdout, _ = await proc.communicate()
except asyncio.exceptions.CancelledError as e:
# since Python 3.9, when cancelled, the inner process needs to throw as it is
# for asyncio to timeout properly https://bugs.python.org/issue40607
raise e
except BaseException as e:
raise RuntimeError(f"Run cmd[{cmd_index}] got exception.") from e
else:
stdout = stdout.decode("utf-8")
if stdout:
logger.info("Output of cmd[%s]: %s", cmd_index, stdout)
else:
logger.info("No output for cmd[%s]", cmd_index)
if proc.returncode != 0:
raise SubprocessCalledProcessError(
proc.returncode, cmd, output=stdout, cmd_index=cmd_index
)
return stdout
finally:
if proc is not None:
# Kill process.
try:
proc.kill()
except ProcessLookupError:
pass
# Wait process exit.
await proc.wait()
+344
View File
@@ -0,0 +1,344 @@
"""Util class to install packages via uv."""
import asyncio
import hashlib
import json
import logging
import os
import shutil
import sys
from asyncio import create_task, get_running_loop
from typing import Dict, List, Optional
from ray._common.utils import try_to_create_directory
from ray._private.runtime_env import dependency_utils, virtualenv_utils
from ray._private.runtime_env.packaging import Protocol, parse_uri
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray._private.runtime_env.utils import check_output_cmd
from ray._private.utils import get_directory_size_bytes
default_logger = logging.getLogger(__name__)
def _get_uv_hash(uv_dict: Dict) -> str:
"""Get a deterministic hash value for `uv` related runtime envs."""
serialized_uv_spec = json.dumps(uv_dict, sort_keys=True)
hash_val = hashlib.sha1(serialized_uv_spec.encode("utf-8")).hexdigest()
return hash_val
def get_uri(runtime_env: Dict) -> Optional[str]:
"""Return `"uv://<hashed_dependencies>"`, or None if no GC required."""
uv = runtime_env.get("uv")
if uv is not None:
if isinstance(uv, dict):
uri = "uv://" + _get_uv_hash(uv_dict=uv)
elif isinstance(uv, list):
uri = "uv://" + _get_uv_hash(uv_dict=dict(packages=uv))
else:
raise TypeError(
"uv field received by RuntimeEnvAgent must be "
f"list or dict, not {type(uv).__name__}."
)
else:
uri = None
return uri
class UvProcessor:
def __init__(
self,
target_dir: str,
runtime_env: "RuntimeEnv", # noqa: F821
logger: Optional[logging.Logger] = default_logger,
):
try:
import virtualenv # noqa: F401 ensure virtualenv exists.
except ImportError:
raise RuntimeError(
f"Please install virtualenv "
f"`{sys.executable} -m pip install virtualenv`"
f"to enable uv runtime env."
)
logger.debug("Setting up uv for runtime_env: %s", runtime_env)
self._target_dir = target_dir
# An empty directory is created to execute cmd.
self._exec_cwd = os.path.join(self._target_dir, "exec_cwd")
self._runtime_env = runtime_env
self._logger = logger
self._uv_config = self._runtime_env.uv_config()
self._uv_env = os.environ.copy()
self._uv_env.update(self._runtime_env.env_vars())
async def _install_uv(
self, path: str, cwd: str, pip_env: dict, logger: logging.Logger
):
"""Before package install, make sure the required version `uv` (if specifieds)
is installed.
"""
virtualenv_path = virtualenv_utils.get_virtualenv_path(path)
python = virtualenv_utils.get_virtualenv_python(path)
def _get_uv_exec_to_install() -> str:
"""Get `uv` executable with version to install."""
uv_version = self._uv_config.get("uv_version", None)
if uv_version:
return f"uv{uv_version}"
# Use default version.
return "uv"
uv_install_cmd = [
python,
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-cache-dir",
_get_uv_exec_to_install(),
]
logger.info("Installing package uv to %s", virtualenv_path)
await check_output_cmd(uv_install_cmd, logger=logger, cwd=cwd, env=pip_env)
async def _check_uv_existence(
self, path: str, cwd: str, env: dict, logger: logging.Logger
) -> bool:
"""Check and return the existence of `uv` in virtual env."""
python = virtualenv_utils.get_virtualenv_python(path)
check_existence_cmd = [
python,
"-m",
"uv",
"--version",
]
try:
# If `uv` doesn't exist, exception will be thrown.
await check_output_cmd(check_existence_cmd, logger=logger, cwd=cwd, env=env)
return True
except Exception:
return False
async def _uv_check(sef, python: str, cwd: str, logger: logging.Logger) -> None:
"""Check virtual env dependency compatibility.
If any incompatibility detected, exception will be thrown.
param:
python: the path for python executable within virtual environment.
"""
cmd = [python, "-m", "uv", "pip", "check"]
await check_output_cmd(
cmd,
logger=logger,
cwd=cwd,
)
async def _install_uv_packages(
self,
path: str,
uv_packages: List[str],
cwd: str,
pip_env: Dict,
logger: logging.Logger,
):
"""Install required python packages via `uv`."""
virtualenv_path = virtualenv_utils.get_virtualenv_path(path)
python = virtualenv_utils.get_virtualenv_python(path)
# TODO(fyrestone): Support -i, --no-deps, --no-cache-dir, ...
requirements_file = dependency_utils.get_requirements_file(path, uv_packages)
# Check existence for `uv` and see if we could skip `uv` installation.
uv_exists = await self._check_uv_existence(path, cwd, pip_env, logger)
# Install uv, which acts as the default package manager.
if (not uv_exists) or (self._uv_config.get("uv_version", None) is not None):
await self._install_uv(path, cwd, pip_env, logger)
# Avoid blocking the event loop.
loop = get_running_loop()
await loop.run_in_executor(
None, dependency_utils.gen_requirements_txt, requirements_file, uv_packages
)
# Install all dependencies.
#
# Difference with pip:
# 1. `--disable-pip-version-check` has no effect for uv.
uv_install_cmd = [
python,
"-m",
"uv",
"pip",
"install",
"-r",
requirements_file,
]
uv_opt_list = self._uv_config.get("uv_pip_install_options", ["--no-cache"])
if uv_opt_list:
uv_install_cmd += uv_opt_list
logger.info("Installing python requirements to %s", virtualenv_path)
await check_output_cmd(uv_install_cmd, logger=logger, cwd=cwd, env=pip_env)
# Check python environment for conflicts.
if self._uv_config.get("uv_check", False):
await self._uv_check(python, cwd, logger)
async def _run(self):
path = self._target_dir
logger = self._logger
uv_packages = self._uv_config["packages"]
# We create an empty directory for exec cmd so that the cmd will
# run more stable. e.g. if cwd has ray, then checking ray will
# look up ray in cwd instead of site packages.
os.makedirs(self._exec_cwd, exist_ok=True)
try:
await virtualenv_utils.create_or_get_virtualenv(
path, self._exec_cwd, logger
)
python = virtualenv_utils.get_virtualenv_python(path)
async with dependency_utils.check_ray(python, self._exec_cwd, logger):
# Install packages with uv.
await self._install_uv_packages(
path,
uv_packages,
self._exec_cwd,
self._uv_env,
logger,
)
except Exception:
logger.info("Delete incomplete virtualenv: %s", path)
shutil.rmtree(path, ignore_errors=True)
logger.exception("Failed to install uv packages.")
raise
def __await__(self):
return self._run().__await__()
class UvPlugin(RuntimeEnvPlugin):
name = "uv"
def __init__(self, resources_dir: str):
self._uv_resource_dir = os.path.join(resources_dir, "uv")
self._creating_task = {}
# Maps a URI to a lock that is used to prevent multiple concurrent
# installs of the same virtualenv, see #24513
self._create_locks: Dict[str, asyncio.Lock] = {}
# Key: created hashes. Value: size of the uv dir.
self._created_hash_bytes: Dict[str, int] = {}
try_to_create_directory(self._uv_resource_dir)
def _get_path_from_hash(self, hash_val: str) -> str:
"""Generate a path from the hash of a uv spec.
Example output:
/tmp/ray/session_2021-11-03_16-33-59_356303_41018/runtime_resources
/uv/ray-9a7972c3a75f55e976e620484f58410c920db091
"""
return os.path.join(self._uv_resource_dir, hash_val)
def get_uris(self, runtime_env: "RuntimeEnv") -> List[str]: # noqa: F821
"""Return the uv URI from the RuntimeEnv if it exists, else return []."""
uv_uri = runtime_env.uv_uri()
if uv_uri:
return [uv_uri]
return []
def delete_uri(
self, uri: str, logger: Optional[logging.Logger] = default_logger
) -> int:
"""Delete URI and return the number of bytes deleted."""
logger.info("Got request to delete uv URI %s", uri)
protocol, hash_val = parse_uri(uri)
if protocol != Protocol.UV:
raise ValueError(
"UvPlugin can only delete URIs with protocol "
f"uv. Received protocol {protocol}, URI {uri}"
)
# Cancel running create task.
task = self._creating_task.pop(hash_val, None)
if task is not None:
task.cancel()
del self._created_hash_bytes[hash_val]
uv_env_path = self._get_path_from_hash(hash_val)
local_dir_size = get_directory_size_bytes(uv_env_path)
del self._create_locks[uri]
try:
shutil.rmtree(uv_env_path)
except OSError as e:
logger.warning(f"Error when deleting uv env {uv_env_path}: {str(e)}")
return 0
return local_dir_size
async def create(
self,
uri: str,
runtime_env: "RuntimeEnv", # noqa: F821
context: "RuntimeEnvContext", # noqa: F821
logger: Optional[logging.Logger] = default_logger,
) -> int:
if not runtime_env.has_uv():
return 0
protocol, hash_val = parse_uri(uri)
target_dir = self._get_path_from_hash(hash_val)
async def _create_for_hash():
await UvProcessor(
target_dir,
runtime_env,
logger,
)
loop = get_running_loop()
return await loop.run_in_executor(
None, get_directory_size_bytes, target_dir
)
if uri not in self._create_locks:
# async lock to prevent the same virtualenv being concurrently installed
self._create_locks[uri] = asyncio.Lock()
async with self._create_locks[uri]:
if hash_val in self._created_hash_bytes:
return self._created_hash_bytes[hash_val]
self._creating_task[hash_val] = task = create_task(_create_for_hash())
task.add_done_callback(lambda _: self._creating_task.pop(hash_val, None))
uv_dir_bytes = await task
self._created_hash_bytes[hash_val] = uv_dir_bytes
return uv_dir_bytes
def modify_context(
self,
uris: List[str],
runtime_env: "RuntimeEnv", # noqa: F821
context: "RuntimeEnvContext", # noqa: F821
logger: logging.Logger = default_logger,
):
if not runtime_env.has_uv():
return
# UvPlugin only uses a single URI.
uri = uris[0]
# Update py_executable.
protocol, hash_val = parse_uri(uri)
target_dir = self._get_path_from_hash(hash_val)
virtualenv_python = virtualenv_utils.get_virtualenv_python(target_dir)
if not os.path.exists(virtualenv_python):
raise ValueError(
f"Local directory {target_dir} for URI {uri} does "
"not exist on the cluster. Something may have gone wrong while "
"installing the runtime_env `uv` packages."
)
context.py_executable = virtualenv_python
context.command_prefix += virtualenv_utils.get_virtualenv_activate_command(
target_dir
)
@@ -0,0 +1,452 @@
import argparse
import copy
import optparse
import os
import pathlib
import platform
import sys
import urllib.parse
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import psutil
def _is_path(path_or_uri: str) -> bool:
"""Returns True if uri_or_path is a path and False otherwise.
IMPORTANT: This is a duplicate of ray._private.path_utils.is_path().
Why we can't import from path_utils:
- This hook runs via `uv run --no-project uv_runtime_env_hook.py` in test scenarios
- UV creates a minimal environment without dependencies installed yet
- Importing from ray._private.path_utils triggers the full Ray import chain:
ray._private.path_utils → ray/__init__.py → ray._private.worker →
ray.widgets → ray.widgets.util → packaging.version
- The 'packaging' module is not available in the minimal UV environment,
causing: ModuleNotFoundError: No module named 'packaging.version'
This duplicate implementation uses only stdlib (pathlib, urllib.parse)
to avoid the dependency issue. If you modify this function, ensure you
also update ray._private.path_utils.is_path() to keep them in sync.
"""
if not isinstance(path_or_uri, str):
raise TypeError(f"path_or_uri must be a string, got {type(path_or_uri)}.")
parsed_path = pathlib.Path(path_or_uri)
parsed_uri = urllib.parse.urlparse(path_or_uri)
if isinstance(parsed_path, pathlib.PurePosixPath):
return not parsed_uri.scheme
elif isinstance(parsed_path, pathlib.PureWindowsPath):
return parsed_uri.scheme == parsed_path.drive.strip(":").lower()
else:
# this should never happen
raise TypeError(f"Unsupported path type: {type(parsed_path).__name__}")
def _create_uv_run_parser():
"""Create and return the argument parser for 'uv run' command."""
parser = optparse.OptionParser(prog="uv run", add_help_option=False)
# Disable interspersed args to stop parsing when we hit the first
# argument that is not recognized by the parser.
parser.disable_interspersed_args()
# Main options group
main_group = optparse.OptionGroup(parser, "Main options")
main_group.add_option("--extra", action="append", dest="extras")
main_group.add_option("--all-extras", action="store_true")
main_group.add_option("--no-extra", action="append", dest="no_extras")
main_group.add_option("--no-dev", action="store_true")
main_group.add_option("--group", action="append", dest="groups")
main_group.add_option("--no-group", action="append", dest="no_groups")
main_group.add_option("--no-default-groups", action="store_true")
main_group.add_option("--only-group", action="append", dest="only_groups")
main_group.add_option("--all-groups", action="store_true")
main_group.add_option("-m", "--module")
main_group.add_option("--only-dev", action="store_true")
main_group.add_option("--no-editable", action="store_true")
main_group.add_option("--exact", action="store_true")
main_group.add_option("--env-file", action="append", dest="env_files")
main_group.add_option("--no-env-file", action="store_true")
parser.add_option_group(main_group)
# With options
with_group = optparse.OptionGroup(parser, "With options")
with_group.add_option("--with", action="append", dest="with_packages")
with_group.add_option("--with-editable", action="append", dest="with_editable")
with_group.add_option(
"--with-requirements", action="append", dest="with_requirements"
)
parser.add_option_group(with_group)
# Environment options
env_group = optparse.OptionGroup(parser, "Environment options")
env_group.add_option("--isolated", action="store_true")
env_group.add_option("--active", action="store_true")
env_group.add_option("--no-sync", action="store_true")
env_group.add_option("--locked", action="store_true")
env_group.add_option("--frozen", action="store_true")
parser.add_option_group(env_group)
# Script options
script_group = optparse.OptionGroup(parser, "Script options")
script_group.add_option("-s", "--script", action="store_true")
script_group.add_option("--gui-script", action="store_true")
parser.add_option_group(script_group)
# Workspace options
workspace_group = optparse.OptionGroup(parser, "Workspace options")
workspace_group.add_option("--all-packages", action="store_true")
workspace_group.add_option("--package")
workspace_group.add_option("--no-project", action="store_true")
parser.add_option_group(workspace_group)
# Index options
index_group = optparse.OptionGroup(parser, "Index options")
index_group.add_option("--index", action="append", dest="indexes")
index_group.add_option("--default-index")
index_group.add_option("-i", "--index-url")
index_group.add_option(
"--extra-index-url", action="append", dest="extra_index_urls"
)
index_group.add_option("-f", "--find-links", action="append", dest="find_links")
index_group.add_option("--no-index", action="store_true")
index_group.add_option(
"--index-strategy",
type="choice",
choices=["first-index", "unsafe-first-match", "unsafe-best-match"],
)
index_group.add_option(
"--keyring-provider", type="choice", choices=["disabled", "subprocess"]
)
parser.add_option_group(index_group)
# Resolver options
resolver_group = optparse.OptionGroup(parser, "Resolver options")
resolver_group.add_option("-U", "--upgrade", action="store_true")
resolver_group.add_option(
"-P", "--upgrade-package", action="append", dest="upgrade_packages"
)
resolver_group.add_option(
"--resolution", type="choice", choices=["highest", "lowest", "lowest-direct"]
)
resolver_group.add_option(
"--prerelease",
type="choice",
choices=[
"disallow",
"allow",
"if-necessary",
"explicit",
"if-necessary-or-explicit",
],
)
resolver_group.add_option(
"--fork-strategy", type="choice", choices=["fewest", "requires-python"]
)
resolver_group.add_option("--exclude-newer")
resolver_group.add_option("--no-sources", action="store_true")
parser.add_option_group(resolver_group)
# Installer options
installer_group = optparse.OptionGroup(parser, "Installer options")
installer_group.add_option("--reinstall", action="store_true")
installer_group.add_option(
"--reinstall-package", action="append", dest="reinstall_packages"
)
installer_group.add_option(
"--link-mode", type="choice", choices=["clone", "copy", "hardlink", "symlink"]
)
installer_group.add_option("--compile-bytecode", action="store_true")
parser.add_option_group(installer_group)
# Build options
build_group = optparse.OptionGroup(parser, "Build options")
build_group.add_option(
"-C", "--config-setting", action="append", dest="config_settings"
)
build_group.add_option("--no-build-isolation", action="store_true")
build_group.add_option(
"--no-build-isolation-package",
action="append",
dest="no_build_isolation_packages",
)
build_group.add_option("--no-build", action="store_true")
build_group.add_option(
"--no-build-package", action="append", dest="no_build_packages"
)
build_group.add_option("--no-binary", action="store_true")
build_group.add_option(
"--no-binary-package", action="append", dest="no_binary_packages"
)
parser.add_option_group(build_group)
# Cache options
cache_group = optparse.OptionGroup(parser, "Cache options")
cache_group.add_option("-n", "--no-cache", action="store_true")
cache_group.add_option("--cache-dir")
cache_group.add_option("--refresh", action="store_true")
cache_group.add_option(
"--refresh-package", action="append", dest="refresh_packages"
)
parser.add_option_group(cache_group)
# Python options
python_group = optparse.OptionGroup(parser, "Python options")
python_group.add_option("-p", "--python")
python_group.add_option("--managed-python", action="store_true")
python_group.add_option("--no-managed-python", action="store_true")
python_group.add_option("--no-python-downloads", action="store_true")
# note: the following is a legacy option and will be removed at some point
# https://github.com/astral-sh/uv/pull/12246
python_group.add_option(
"--python-preference",
type="choice",
choices=["only-managed", "managed", "system", "only-system"],
)
parser.add_option_group(python_group)
# Global options
global_group = optparse.OptionGroup(parser, "Global options")
global_group.add_option("-q", "--quiet", action="count", default=0)
global_group.add_option("-v", "--verbose", action="count", default=0)
global_group.add_option(
"--color", type="choice", choices=["auto", "always", "never"]
)
global_group.add_option("--native-tls", action="store_true")
global_group.add_option("--offline", action="store_true")
global_group.add_option(
"--allow-insecure-host", action="append", dest="insecure_hosts"
)
global_group.add_option("--no-progress", action="store_true")
global_group.add_option("--directory")
global_group.add_option("--project")
global_group.add_option("--config-file")
global_group.add_option("--no-config", action="store_true")
parser.add_option_group(global_group)
return parser
def _parse_args(
parser: optparse.OptionParser, args: List[str]
) -> Tuple[optparse.Values, List[str]]:
"""
Parse the command-line options found in 'args'.
Replacement for parser.parse_args that handles unknown arguments
by keeping them in the command list instead of erroring and
discarding them.
"""
parser.rargs = args
parser.largs = []
options = parser.get_default_values()
try:
parser._process_args(parser.largs, parser.rargs, options)
except optparse.BadOptionError as err:
# If we hit an argument that is not recognized, we put it
# back into the unconsumed arguments
parser.rargs = [err.opt_str] + parser.rargs
return options, parser.rargs
def _check_working_dir_files(
uv_run_args: optparse.Values, runtime_env: Dict[str, Any]
) -> None:
"""
Check that the files required by uv are local to the working_dir. This catches
the most common cases of how things are different in Ray, i.e. not the whole file
system will be available on the workers, only the working_dir.
The function won't return anything, it just raises a RuntimeError if there is an error.
"""
working_dir = Path(runtime_env["working_dir"]).resolve()
# Check if the requirements.txt file is in the working_dir
if uv_run_args.with_requirements:
for requirements_file in uv_run_args.with_requirements:
if not Path(requirements_file).resolve().is_relative_to(working_dir):
raise RuntimeError(
f"You specified --with-requirements={uv_run_args.with_requirements} but "
f"the requirements file is not in the working_dir {runtime_env['working_dir']}, "
"so the workers will not have access to the file. Make sure "
"the requirements file is in the working directory. "
"You can do so by specifying --directory in 'uv run', by changing the current "
"working directory before running 'uv run', or by using the 'working_dir' "
"parameter of the runtime_environment."
)
# Check if the pyproject.toml file is in the working_dir
pyproject = None
if uv_run_args.no_project:
pyproject = None
elif uv_run_args.project:
pyproject = Path(uv_run_args.project)
else:
# Walk up the directory tree until pyproject.toml is found
current_path = Path.cwd().resolve()
while current_path != current_path.parent:
if (current_path / "pyproject.toml").exists():
pyproject = Path(current_path / "pyproject.toml")
break
current_path = current_path.parent
if pyproject and not pyproject.resolve().is_relative_to(working_dir):
raise RuntimeError(
f"Your {pyproject.resolve()} is not in the working_dir {runtime_env['working_dir']}, "
"so the workers will not have access to the file. Make sure "
"the pyproject.toml file is in the working directory. "
"You can do so by specifying --directory in 'uv run', by changing the current "
"working directory before running 'uv run', or by using the 'working_dir' "
"parameter of the runtime_environment."
)
def _get_uv_run_cmdline() -> Optional[List[str]]:
"""
Return the command line of the first ancestor process that was run with
"uv run" and None if there is no such ancestor.
uv spawns the python process as a child process, so we first check the
parent process command line. We also check our parent's parents since
the Ray driver might be run as a subprocess of the 'uv run' process.
"""
parents = psutil.Process().parents()
for parent in parents:
try:
cmdline = parent.cmdline()
if (
len(cmdline) > 1
and os.path.basename(cmdline[0]) == "uv"
and cmdline[1] == "run"
):
return cmdline
except psutil.NoSuchProcess:
continue
except psutil.AccessDenied:
continue
return None
def hook(runtime_env: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Hook that detects if the driver is run in 'uv run' and sets the runtime environment accordingly."""
runtime_env = copy.deepcopy(runtime_env) or {}
cmdline = _get_uv_run_cmdline()
if not cmdline:
# This means the driver was not run in a 'uv run' environment -- in this case
# we leave the runtime environment unchanged
return runtime_env
# First check that the "uv" and "pip" runtime environments are not used.
if "uv" in runtime_env or "pip" in runtime_env:
raise RuntimeError(
"You are using the 'pip' or 'uv' runtime environments together with "
"'uv run'. These are not compatible since 'uv run' will run the workers "
"in an isolated environment -- please add the 'pip' or 'uv' dependencies to your "
"'uv run' environment e.g. by including them in your pyproject.toml."
)
# Extract the arguments uv_run_args of 'uv run' that are not part of the command.
args_to_parse = cmdline[2:] # Remove 'uv run' prefix
original_length = len(
args_to_parse
) # Save before parsing (parser modifies in-place)
parser = _create_uv_run_parser()
(options, command) = _parse_args(parser, args_to_parse)
# Calculate how many arguments were consumed by the parser.
# Since disable_interspersed_args() is set, parsing stops at the first
# unrecognized argument (the command), so all consumed args are uv options.
args_consumed = original_length - len(command)
uv_run_args = cmdline[: 2 + args_consumed]
# Remove the "--directory" argument since it has already been taken into
# account when setting the current working directory of the current process.
# Also remove the "--module" argument, since the default_worker.py is
# invoked as a script and not as a module.
parser = argparse.ArgumentParser()
parser.add_argument("--directory")
parser.add_argument("-m", "--module")
_, remaining_uv_run_args = parser.parse_known_args(uv_run_args)
# Pin the worker Python for `uv run` unless the driver already specified one.
#
# Without this, uv may resolve a different Python than the Ray driver (e.g. 3.12 instead of 3.11),
# which causes Ray to fail with a Python version mismatch. (See https://github.com/ray-project/ray/issues/59639)
# order of precedence:
# 1. options.python (driver specified)
# 2. env_vars["UV_PYTHON"]
# 3. current os.environ["UV_PYTHON"]
# 4. platform.python_version() (since uv run uses the same Python as the driver)
if not options.python:
env_vars = runtime_env.get("env_vars") or {}
uv_python = (
env_vars.get("UV_PYTHON")
or os.environ.get("UV_PYTHON")
or platform.python_version()
)
remaining_uv_run_args = remaining_uv_run_args + ["--python", uv_python]
# Append "python" to the end so that when Ray adds "-m default_worker.py",
# it becomes "uv run --python X.Y.Z python -m default_worker.py"
remaining_uv_run_args = remaining_uv_run_args + ["python"]
runtime_env["py_executable"] = " ".join(remaining_uv_run_args)
# If the user specified a working_dir, we always honor it, otherwise
# use the same working_dir that uv run would use
if "working_dir" not in runtime_env:
runtime_env["working_dir"] = os.getcwd()
# Validate that pyproject.toml and requirements files are within working_dir
# This prevents runtime errors on workers when files are not accessible
# Only validate for local paths - remote URIs will be downloaded by Ray
working_dir = runtime_env["working_dir"]
if _is_path(working_dir):
_check_working_dir_files(options, runtime_env)
return runtime_env
# This __main__ is used for unit testing if the runtime_env_hook picks up the
# right settings.
if __name__ == "__main__":
import json
test_parser = argparse.ArgumentParser()
test_parser.add_argument("--extra-args", action="store_true")
test_parser.add_argument("runtime_env")
args = test_parser.parse_args()
# If the env variable is set, add one more level of subprocess indirection
if os.environ.get("RAY_TEST_UV_ADD_SUBPROCESS_INDIRECTION") == "1":
import subprocess
env = os.environ.copy()
env.pop("RAY_TEST_UV_ADD_SUBPROCESS_INDIRECTION")
subprocess.check_call([sys.executable] + sys.argv, env=env)
sys.exit(0)
# If the following env variable is set, we use multiprocessing
# spawn to start the subprocess, since it uses a different way to
# modify the command line than subprocess.check_call
if os.environ.get("RAY_TEST_UV_MULTIPROCESSING_SPAWN") == "1":
import multiprocessing
multiprocessing.set_start_method("spawn")
pool = multiprocessing.Pool(processes=1)
runtime_env = json.loads(args.runtime_env)
print(json.dumps(pool.apply(hook, (runtime_env,))))
sys.exit(0)
# We purposefully modify sys.argv here to make sure the hook is robust
# against such modification.
sys.argv.pop(1)
runtime_env = json.loads(args.runtime_env)
print(json.dumps(hook(runtime_env)))
@@ -0,0 +1,466 @@
import logging
import sys
from collections import OrderedDict
from pathlib import Path
from typing import Dict, List, Optional, Union
import yaml
from ray._private.path_utils import is_path
from ray._private.runtime_env.packaging import parse_path
logger = logging.getLogger(__name__)
def validate_path(path: str) -> None:
"""Parse the path to ensure it is well-formed and exists."""
parse_path(path)
def validate_uri(uri: str):
try:
from ray._private.runtime_env.packaging import Protocol, parse_uri
protocol, path = parse_uri(uri)
except ValueError:
raise ValueError(
f"{uri} is not a valid URI. Passing directories or modules to "
"be dynamically uploaded is only supported at the job level "
"(i.e., passed to `ray.init`)."
)
supported_extensions = (".zip", ".whl", ".tar.gz", ".tgz")
if protocol in Protocol.remote_protocols() and not any(
path.endswith(ext) for ext in supported_extensions
):
raise ValueError(
"Only .zip, .whl, .tar.gz, and .tgz files supported for remote URIs."
)
def _handle_local_deps_requirement_file(requirements_file: str):
"""Read the given [requirements_file], and return all required dependencies."""
requirements_path = Path(requirements_file)
if not requirements_path.is_file():
raise ValueError(f"{requirements_path} is not a valid file")
return requirements_path.read_text().strip().split("\n")
def validate_py_modules_uris(py_modules_uris: List[str]) -> List[str]:
"""Parses and validates a 'py_modules' option.
Expects py_modules to be a list of URIs.
"""
if not isinstance(py_modules_uris, list):
raise TypeError(
"`py_modules` must be a list of strings, got " f"{type(py_modules_uris)}."
)
for module in py_modules_uris:
if not isinstance(module, str):
raise TypeError("`py_module` must be a string, got " f"{type(module)}.")
validate_uri(module)
def parse_and_validate_py_modules(py_modules: List[str]) -> List[str]:
"""Parses and validates a 'py_modules' option.
Expects py_modules to be a list of local paths or URIs.
"""
if not isinstance(py_modules, list):
raise TypeError(
"`py_modules` must be a list of strings, got " f"{type(py_modules)}."
)
for module in py_modules:
if not isinstance(module, str):
raise TypeError("`py_module` must be a string, got " f"{type(module)}.")
if is_path(module):
validate_path(module)
else:
validate_uri(module)
return py_modules
def validate_working_dir_uri(working_dir_uri: str) -> str:
"""Parses and validates a 'working_dir' option."""
if not isinstance(working_dir_uri, str):
raise TypeError(
"`working_dir` must be a string, got " f"{type(working_dir_uri)}."
)
validate_uri(working_dir_uri)
def parse_and_validate_working_dir(working_dir: str) -> str:
"""Parses and validates a 'working_dir' option.
This can be a URI or a path.
"""
assert working_dir is not None
if not isinstance(working_dir, str):
raise TypeError("`working_dir` must be a string, got " f"{type(working_dir)}.")
if is_path(working_dir):
validate_path(working_dir)
else:
validate_uri(working_dir)
return working_dir
def parse_and_validate_conda(conda: Union[str, dict]) -> Union[str, dict]:
"""Parses and validates a user-provided 'conda' option.
Conda can be one of three cases:
1) A dictionary describing the env. This is passed through directly.
2) A string referring to the name of a preinstalled conda env.
3) A string pointing to a local conda YAML file. This is detected
by looking for a '.yaml' or '.yml' suffix. In this case, the file
will be read as YAML and passed through as a dictionary.
"""
assert conda is not None
if sys.platform == "win32":
logger.warning(
"runtime environment support is experimental on Windows. "
"If you run into issues please file a report at "
"https://github.com/ray-project/ray/issues."
)
result = conda
if isinstance(conda, str):
file_path = Path(conda)
if file_path.suffix in (".yaml", ".yml"):
if not file_path.is_file():
raise ValueError(f"Can't find conda YAML file {file_path}.")
try:
result = yaml.safe_load(file_path.read_text())
except Exception as e:
raise ValueError(f"Failed to read conda file {file_path}: {e}.")
elif file_path.is_absolute():
if not file_path.is_dir():
raise ValueError(f"Can't find conda env directory {file_path}.")
result = str(file_path)
elif isinstance(conda, dict):
result = conda
else:
raise TypeError(
"runtime_env['conda'] must be of type str or " f"dict, got {type(conda)}."
)
return result
def parse_and_validate_uv(uv: Union[str, List[str], Dict]) -> Optional[Dict]:
"""Parses and validates a user-provided 'uv' option.
The value of the input 'uv' field can be one of two cases:
1) A List[str] describing the requirements. This is passed through.
Example usage: ["tensorflow", "requests"]
2) a string containing the path to a local pip “requirements.txt” file.
3) A python dictionary that has one field:
a) packages (required, List[str]): a list of uv packages, it same as 1).
b) uv_check (optional, bool): whether to enable pip check at the end of uv
install, default to False.
c) uv_version (optional, str): user provides a specific uv to use; if
unspecified, default version of uv will be used.
d) uv_pip_install_options (optional, List[str]): user-provided options for
`uv pip install` command, default to ["--no-cache"].
The returned parsed value will be a list of packages. If a Ray library
(e.g. "ray[serve]") is specified, it will be deleted and replaced by its
dependencies (e.g. "uvicorn", "requests").
"""
assert uv is not None
if sys.platform == "win32":
logger.warning(
"runtime environment support is experimental on Windows. "
"If you run into issues please file a report at "
"https://github.com/ray-project/ray/issues."
)
result: str = ""
if isinstance(uv, str):
uv_list = _handle_local_deps_requirement_file(uv)
result = dict(packages=uv_list, uv_check=False)
elif isinstance(uv, list) and all(isinstance(dep, str) for dep in uv):
result = dict(packages=uv, uv_check=False)
elif isinstance(uv, dict):
if set(uv.keys()) - {
"packages",
"uv_check",
"uv_version",
"uv_pip_install_options",
}:
raise ValueError(
"runtime_env['uv'] can only have these fields: "
"packages, uv_check, uv_version and uv_pip_install_options, but got: "
f"{list(uv.keys())}"
)
if "packages" not in uv:
raise ValueError(
f"runtime_env['uv'] must include field 'packages', but got {uv}"
)
if "uv_check" in uv and not isinstance(uv["uv_check"], bool):
raise TypeError(
"runtime_env['uv']['uv_check'] must be of type bool, "
f"got {type(uv['uv_check'])}"
)
if "uv_version" in uv and not isinstance(uv["uv_version"], str):
raise TypeError(
"runtime_env['uv']['uv_version'] must be of type str, "
f"got {type(uv['uv_version'])}"
)
if "uv_pip_install_options" in uv:
if not isinstance(uv["uv_pip_install_options"], list):
raise TypeError(
"runtime_env['uv']['uv_pip_install_options'] must be of type "
f"list[str] got {type(uv['uv_pip_install_options'])}"
)
# Check each item in installation option.
for idx, cur_opt in enumerate(uv["uv_pip_install_options"]):
if not isinstance(cur_opt, str):
raise TypeError(
"runtime_env['uv']['uv_pip_install_options'] must be of type "
f"list[str] got {type(cur_opt)} for {idx}-th item."
)
result = uv.copy()
result["uv_check"] = uv.get("uv_check", False)
result["uv_pip_install_options"] = uv.get(
"uv_pip_install_options", ["--no-cache"]
)
if not isinstance(uv["packages"], list):
raise ValueError(
"runtime_env['uv']['packages'] must be of type list, "
f"got: {type(uv['packages'])}"
)
else:
raise TypeError(
"runtime_env['uv'] must be of type " f"List[str], or dict, got {type(uv)}"
)
# Deduplicate packages for package lists.
result["packages"] = list(OrderedDict.fromkeys(result["packages"]))
if len(result["packages"]) == 0:
result = None
logger.debug(f"Rewrote runtime_env `uv` field from {uv} to {result}.")
return result
def parse_and_validate_pip(pip: Union[str, List[str], Dict]) -> Optional[Dict]:
"""Parses and validates a user-provided 'pip' option.
The value of the input 'pip' field can be one of two cases:
1) A List[str] describing the requirements. This is passed through.
2) A string pointing to a local requirements file. In this case, the
file contents will be read split into a list.
3) A python dictionary that has three fields:
a) packages (required, List[str]): a list of pip packages, it same as 1).
b) pip_check (optional, bool): whether to enable pip check at the end of pip
install, default to False.
c) pip_version (optional, str): the version of pip, ray will spell
the package name 'pip' in front of the `pip_version` to form the final
requirement string, the syntax of a requirement specifier is defined in
full in PEP 508.
d) pip_install_options (optional, List[str]): user-provided options for
`pip install` command, defaults to ["--disable-pip-version-check", "--no-cache-dir"].
The returned parsed value will be a list of pip packages. If a Ray library
(e.g. "ray[serve]") is specified, it will be deleted and replaced by its
dependencies (e.g. "uvicorn", "requests").
"""
assert pip is not None
result = None
if sys.platform == "win32":
logger.warning(
"runtime environment support is experimental on Windows. "
"If you run into issues please file a report at "
"https://github.com/ray-project/ray/issues."
)
if isinstance(pip, str):
# We have been given a path to a requirements.txt file.
pip_list = _handle_local_deps_requirement_file(pip)
result = dict(
packages=pip_list,
pip_check=False,
)
elif isinstance(pip, list) and all(isinstance(dep, str) for dep in pip):
result = dict(packages=pip, pip_check=False)
elif isinstance(pip, dict):
if set(pip.keys()) - {
"packages",
"pip_check",
"pip_install_options",
"pip_version",
}:
raise ValueError(
"runtime_env['pip'] can only have these fields: "
"packages, pip_check, pip_install_options and pip_version, but got: "
f"{list(pip.keys())}"
)
if "pip_check" in pip and not isinstance(pip["pip_check"], bool):
raise TypeError(
"runtime_env['pip']['pip_check'] must be of type bool, "
f"got {type(pip['pip_check'])}"
)
if "pip_version" in pip:
if not isinstance(pip["pip_version"], str):
raise TypeError(
"runtime_env['pip']['pip_version'] must be of type str, "
f"got {type(pip['pip_version'])}"
)
if "pip_install_options" in pip:
if not isinstance(pip["pip_install_options"], list):
raise TypeError(
"runtime_env['pip']['pip_install_options'] must be of type "
f"list[str] got {type(pip['pip_install_options'])}"
)
# Check each item in installation option.
for idx, cur_opt in enumerate(pip["pip_install_options"]):
if not isinstance(cur_opt, str):
raise TypeError(
"runtime_env['pip']['pip_install_options'] must be of type "
f"list[str] got {type(cur_opt)} for {idx}-th item."
)
result = pip.copy()
# Contrary to pip_check, we do not insert the default value of pip_install_options.
# This is to maintain backwards compatibility with ray==2.0.1
result["pip_check"] = pip.get("pip_check", False)
if "packages" not in pip:
raise ValueError(
f"runtime_env['pip'] must include field 'packages', but got {pip}"
)
elif isinstance(pip["packages"], str):
result["packages"] = _handle_local_deps_requirement_file(pip["packages"])
elif not isinstance(pip["packages"], list):
raise ValueError(
"runtime_env['pip']['packages'] must be of type str of list, "
f"got: {type(pip['packages'])}"
)
else:
raise TypeError(
"runtime_env['pip'] must be of type str or " f"List[str], got {type(pip)}"
)
# Eliminate duplicates to prevent `pip install` from erroring. Use
# OrderedDict to preserve the order of the list. This makes the output
# deterministic and easier to debug, because pip install can have
# different behavior depending on the order of the input.
result["packages"] = list(OrderedDict.fromkeys(result["packages"]))
if len(result["packages"]) == 0:
result = None
logger.debug(f"Rewrote runtime_env `pip` field from {pip} to {result}.")
return result
def parse_and_validate_container(container: List[str]) -> List[str]:
"""Parses and validates a user-provided 'container' option.
This is passed through without validation (for now).
"""
assert container is not None
return container
def parse_and_validate_excludes(excludes: List[str]) -> List[str]:
"""Parses and validates a user-provided 'excludes' option.
This is validated to verify that it is of type List[str].
If an empty list is passed, we return `None` for consistency.
"""
assert excludes is not None
if isinstance(excludes, list) and len(excludes) == 0:
return None
if isinstance(excludes, list) and all(isinstance(path, str) for path in excludes):
return excludes
else:
raise TypeError(
"runtime_env['excludes'] must be of type "
f"List[str], got {type(excludes)}"
)
def parse_and_validate_env_vars(env_vars: Dict[str, str]) -> Optional[Dict[str, str]]:
"""Parses and validates a user-provided 'env_vars' option.
This is validated to verify that all keys and vals are strings.
If an empty dictionary is passed, we return `None` for consistency.
Args:
env_vars: A dictionary of environment variables to set in the
runtime environment.
Returns:
The validated env_vars dictionary, or None if it was empty.
Raises:
TypeError: If the env_vars is not a dictionary of strings. The error message
will include the type of the invalid value.
"""
assert env_vars is not None
if len(env_vars) == 0:
return None
if not isinstance(env_vars, dict):
raise TypeError(
"runtime_env['env_vars'] must be of type "
f"Dict[str, str], got {type(env_vars)}"
)
for key, val in env_vars.items():
if not isinstance(key, str):
raise TypeError(
"runtime_env['env_vars'] must be of type "
f"Dict[str, str], but the key {key} is of type {type(key)}"
)
if not isinstance(val, str):
raise TypeError(
"runtime_env['env_vars'] must be of type "
f"Dict[str, str], but the value {val} is of type {type(val)}"
)
return env_vars
# Dictionary mapping runtime_env options with the function to parse and
# validate them.
OPTION_TO_VALIDATION_FN = {
"py_modules": parse_and_validate_py_modules,
"working_dir": parse_and_validate_working_dir,
"excludes": parse_and_validate_excludes,
"conda": parse_and_validate_conda,
"pip": parse_and_validate_pip,
"uv": parse_and_validate_uv,
"env_vars": parse_and_validate_env_vars,
"container": parse_and_validate_container,
}
# RuntimeEnv can be created with local paths
# for these options. However, after the packages
# for these options have been uploaded to GCS,
# they must be URIs. These functions provide the ability
# to validate that these options only contain well-formed URIs.
OPTION_TO_NO_PATH_VALIDATION_FN = {
"working_dir": validate_working_dir_uri,
"py_modules": validate_py_modules_uris,
}
@@ -0,0 +1,110 @@
"""Utils to detect runtime environment."""
import logging
import os
import sys
from typing import List
from ray._private.runtime_env.utils import check_output_cmd
_WIN32 = os.name == "nt"
def is_in_virtualenv() -> bool:
# virtualenv <= 16.7.9 sets the real_prefix,
# virtualenv > 16.7.9 & venv set the base_prefix.
# So, we check both of them here.
# https://github.com/pypa/virtualenv/issues/1622#issuecomment-586186094
return hasattr(sys, "real_prefix") or (
hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix
)
def get_virtualenv_path(target_dir: str) -> str:
"""Get virtual environment path."""
return os.path.join(target_dir, "virtualenv")
def get_virtualenv_python(target_dir: str) -> str:
virtualenv_path = get_virtualenv_path(target_dir)
if _WIN32:
return os.path.join(virtualenv_path, "Scripts", "python.exe")
else:
return os.path.join(virtualenv_path, "bin", "python")
def get_virtualenv_activate_command(target_dir: str) -> List[str]:
"""Get the command to activate virtual environment."""
virtualenv_path = get_virtualenv_path(target_dir)
if _WIN32:
cmd = [os.path.join(virtualenv_path, "Scripts", "activate.bat")]
else:
cmd = ["source", os.path.join(virtualenv_path, "bin/activate")]
return cmd + ["1>&2", "&&"]
async def create_or_get_virtualenv(path: str, cwd: str, logger: logging.Logger):
"""Create or get a virtualenv from path."""
python = sys.executable
virtualenv_path = os.path.join(path, "virtualenv")
virtualenv_app_data_path = os.path.join(path, "virtualenv_app_data")
if _WIN32:
current_python_dir = sys.prefix
env = os.environ.copy()
else:
current_python_dir = os.path.abspath(
os.path.join(os.path.dirname(python), "..")
)
env = {}
if is_in_virtualenv():
# virtualenv-clone homepage:
# https://github.com/edwardgeorge/virtualenv-clone
# virtualenv-clone Usage:
# virtualenv-clone /path/to/existing/venv /path/to/cloned/ven
# or
# python -m clonevirtualenv /path/to/existing/venv /path/to/cloned/ven
clonevirtualenv = os.path.join(os.path.dirname(__file__), "_clonevirtualenv.py")
create_venv_cmd = [
python,
clonevirtualenv,
current_python_dir,
virtualenv_path,
]
logger.info("Cloning virtualenv %s to %s", current_python_dir, virtualenv_path)
else:
# virtualenv options:
# https://virtualenv.pypa.io/en/latest/cli_interface.html
#
# --app-data
# --reset-app-data
# Set an empty separated app data folder for current virtualenv.
#
# --no-periodic-update
# Disable the periodic (once every 14 days) update of the embedded
# wheels.
#
# --system-site-packages
# Inherit site packages.
#
# --no-download
# Never download the latest pip/setuptools/wheel from PyPI.
create_venv_cmd = [
python,
"-m",
"virtualenv",
"--app-data",
virtualenv_app_data_path,
"--reset-app-data",
"--no-periodic-update",
"--system-site-packages",
"--no-download",
virtualenv_path,
]
logger.info(
"Creating virtualenv at %s, current python dir %s",
virtualenv_path,
virtualenv_path,
)
await check_output_cmd(create_venv_cmd, logger=logger, cwd=cwd, env=env)
@@ -0,0 +1,278 @@
import logging
import os
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import ray._private.ray_constants as ray_constants
from ray._common.utils import try_to_create_directory
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.packaging import (
Protocol,
delete_package,
download_and_unpack_package,
get_local_dir_from_uri,
get_uri_for_directory,
get_uri_for_package,
parse_uri,
upload_package_if_needed,
upload_package_to_gcs,
)
from ray._private.runtime_env.plugin import RuntimeEnvPlugin
from ray._private.utils import get_directory_size_bytes
from ray._raylet import GcsClient
from ray.exceptions import RuntimeEnvSetupError
from ray.util.debug import log_once
default_logger = logging.getLogger(__name__)
_WIN32 = os.name == "nt"
_LOG_ONCE_DEFAULT_EXCLUDE_PREFIX = "runtime_env_default_exclude:"
def upload_working_dir_if_needed(
runtime_env: Dict[str, Any],
include_gitignore: bool,
scratch_dir: Optional[str] = None,
logger: Optional[logging.Logger] = default_logger,
upload_fn: Optional[Callable[[str, Optional[List[str]], bool], None]] = None,
) -> Dict[str, Any]:
"""Uploads the working_dir and replaces it with a URI.
If the working_dir is already a URI, this is a no-op.
Excludes are combined from:
- .gitignore and .rayignore files in the working_dir
- runtime_env["excludes"] field
- RAY_RUNTIME_ENV_DEFAULT_EXCLUDES constant, overridable via
RAY_OVERRIDE_RUNTIME_ENV_DEFAULT_EXCLUDES environment variable
"""
working_dir = runtime_env.get("working_dir")
if working_dir is None:
return runtime_env
if not isinstance(working_dir, str) and not isinstance(working_dir, Path):
raise TypeError(
"working_dir must be a string or Path (either a local path "
f"or remote URI), got {type(working_dir)}."
)
if isinstance(working_dir, Path):
working_dir = str(working_dir)
# working_dir is already a URI -- just pass it through.
try:
protocol, path = parse_uri(working_dir)
except ValueError:
protocol, path = None, None
if protocol is not None:
supported_extensions = (".zip", ".tar.gz", ".tgz")
if protocol in Protocol.remote_protocols() and not any(
path.endswith(ext) for ext in supported_extensions
):
raise ValueError(
"Only .zip, .tar.gz, and .tgz files supported for remote URIs."
)
return runtime_env
default_excludes = ray_constants.get_runtime_env_default_excludes()
user_excludes = runtime_env.get("excludes") or []
excludes = default_excludes + list(user_excludes)
# TODO(ricardo): 2026-01-07 Remove these warnings in a few releases. Added in
# case users rely on these directories being uploaded with their working_dir
# since this change would be difficult to debug.
logger = logger or default_logger
working_dir_path = Path(working_dir)
for d in default_excludes:
if (working_dir_path / d).exists() and log_once(
f"{_LOG_ONCE_DEFAULT_EXCLUDE_PREFIX}{d}"
):
logger.warning(
"Directory %r is now ignored by default when packaging the working "
"directory. To disable this behavior, set "
"the `RAY_OVERRIDE_RUNTIME_ENV_DEFAULT_EXCLUDES=''` environment "
"variable.",
d,
)
try:
working_dir_uri = get_uri_for_directory(
working_dir,
include_gitignore=include_gitignore,
excludes=excludes,
)
except ValueError: # working_dir is not a directory
package_path = Path(working_dir)
supported_local = (
package_path.suffix == ".zip"
or package_path.suffix == ".tgz"
or package_path.name.endswith(".tar.gz")
)
if not package_path.exists() or not supported_local:
raise ValueError(
f"directory {package_path} must be an existing "
"directory or a supported archive (.zip, .tar.gz, .tgz)"
)
pkg_uri = get_uri_for_package(package_path)
if upload_fn is not None:
upload_fn(working_dir, excludes=excludes, is_file=True)
else:
try:
upload_package_to_gcs(pkg_uri, package_path.read_bytes())
except Exception as e:
raise RuntimeEnvSetupError(
f"Failed to upload package {package_path} to the Ray cluster: {e}"
) from e
runtime_env["working_dir"] = pkg_uri
return runtime_env
if upload_fn is None:
if scratch_dir is None:
scratch_dir = os.getcwd()
try:
upload_package_if_needed(
working_dir_uri,
scratch_dir,
working_dir,
include_parent_dir=False,
excludes=excludes,
include_gitignore=include_gitignore,
logger=logger,
)
except Exception as e:
raise RuntimeEnvSetupError(
f"Failed to upload working_dir {working_dir} to the Ray cluster: {e}"
) from e
else:
upload_fn(working_dir, excludes=excludes)
runtime_env["working_dir"] = working_dir_uri
return runtime_env
def set_pythonpath_in_context(python_path: str, context: RuntimeEnvContext):
"""Insert the path as the first entry in PYTHONPATH in the runtime env.
This is compatible with users providing their own PYTHONPATH in env_vars,
and is also compatible with the existing PYTHONPATH in the cluster.
The import priority is as follows:
this python_path arg > env_vars PYTHONPATH > existing cluster env PYTHONPATH.
"""
if "PYTHONPATH" in context.env_vars:
python_path += os.pathsep + context.env_vars["PYTHONPATH"]
if "PYTHONPATH" in os.environ:
python_path += os.pathsep + os.environ["PYTHONPATH"]
context.env_vars["PYTHONPATH"] = python_path
class WorkingDirPlugin(RuntimeEnvPlugin):
name = "working_dir"
# Note working_dir is not following the priority order of other plugins. Instead
# it's specially treated to happen before all other plugins.
priority = 5
def __init__(self, resources_dir: str, gcs_client: GcsClient):
self._resources_dir = os.path.join(resources_dir, "working_dir_files")
self._gcs_client = gcs_client
try_to_create_directory(self._resources_dir)
def delete_uri(
self, uri: str, logger: Optional[logging.Logger] = default_logger
) -> int:
"""Delete URI and return the number of bytes deleted."""
logger.info("Got request to delete working dir URI %s", uri)
local_dir = get_local_dir_from_uri(uri, self._resources_dir)
local_dir_size = get_directory_size_bytes(local_dir)
deleted = delete_package(uri, self._resources_dir)
if not deleted:
logger.warning(f"Tried to delete nonexistent URI: {uri}.")
return 0
return local_dir_size
def get_uris(self, runtime_env: "RuntimeEnv") -> List[str]: # noqa: F821
working_dir_uri = runtime_env.working_dir()
if working_dir_uri != "":
return [working_dir_uri]
return []
async def create(
self,
uri: Optional[str],
runtime_env: dict,
context: RuntimeEnvContext,
logger: logging.Logger = default_logger,
) -> int:
local_dir = await download_and_unpack_package(
uri,
self._resources_dir,
self._gcs_client,
logger=logger,
overwrite=True,
)
return get_directory_size_bytes(local_dir)
def modify_context(
self,
uris: List[str],
runtime_env_dict: Dict,
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger,
):
if not uris:
return
# WorkingDirPlugin uses a single URI.
uri = uris[0]
local_dir = get_local_dir_from_uri(uri, self._resources_dir)
if not local_dir.exists():
raise ValueError(
f"Local directory {local_dir} for URI {uri} does "
"not exist on the cluster. Something may have gone wrong while "
"downloading or unpacking the working_dir."
)
if not _WIN32:
context.command_prefix += ["cd", str(local_dir), "&&"]
else:
# Include '/d' incase temp folder is on different drive than Ray install.
context.command_prefix += ["cd", "/d", f"{local_dir}", "&&"]
set_pythonpath_in_context(python_path=str(local_dir), context=context)
@contextmanager
def with_working_dir_env(self, uri):
"""
If uri is not None, add the local working directory to the environment variable
as "RAY_RUNTIME_ENV_CREATE_WORKING_DIR". This is useful for other plugins to
create their environment with reference to the working directory. For example
`pip -r ${RAY_RUNTIME_ENV_CREATE_WORKING_DIR}/requirements.txt`
The environment variable is removed after the context manager exits.
"""
if uri is None:
yield
else:
local_dir = get_local_dir_from_uri(uri, self._resources_dir)
if not local_dir.exists():
raise ValueError(
f"Local directory {local_dir} for URI {uri} does "
"not exist on the cluster. Something may have gone wrong while "
"downloading or unpacking the working_dir."
)
key = ray_constants.RAY_RUNTIME_ENV_CREATE_WORKING_DIR_ENV_VAR
prev = os.environ.get(key)
# Windows backslash paths are weird. When it's passed to the env var, and
# when Pip expands it, the backslashes are interpreted as escape characters
# and messes up the whole path. So we convert it to forward slashes.
# This works at least for all Python applications, including pip.
os.environ[key] = local_dir.as_posix()
try:
yield
finally:
if prev is None:
del os.environ[key]
else:
os.environ[key] = prev