Files
ray-project--ray/python/ray/tests/test_runtime_env_uv_run.py
2026-07-13 13:17:40 +08:00

609 lines
19 KiB
Python

import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
from uv import find_uv_bin
import ray
from ray._private.test_utils import (
format_web_url,
wait_until_server_available,
)
from ray.exceptions import RaySystemError
PYPROJECT_TOML = """
[project]
name = "test"
version = "0.1"
dependencies = [
"emoji",
]
requires-python = ">=3.9"
"""
@pytest.fixture(scope="function")
def tmp_working_dir():
"""A test fixture, which writes a pyproject.toml."""
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
script_file = path / "pyproject.toml"
with script_file.open(mode="w") as f:
f.write(PYPROJECT_TOML)
yield tmp_dir
@pytest.mark.skipif(sys.platform == "win32", reason="Not ported to Windows yet.")
def test_uv_run_simple(shutdown_only):
runtime_env = {
"py_executable": f"{find_uv_bin()} run --with emoji --no-project",
}
ray.init(runtime_env=runtime_env)
@ray.remote
def emojize():
import emoji
return emoji.emojize("Ray rocks :thumbs_up:")
assert ray.get(emojize.remote()) == "Ray rocks 👍"
@pytest.mark.skipif(sys.platform == "win32", reason="Not ported to Windows yet.")
def test_uv_run_pyproject(shutdown_only, tmp_working_dir):
tmp_dir = tmp_working_dir
ray.init(
runtime_env={
"working_dir": tmp_dir,
# We want to run in the system environment so the current installation of Ray can be found here
"py_executable": f"env PYTHONPATH={':'.join(sys.path)} {find_uv_bin()} run --python-preference=only-system",
}
)
@ray.remote
def emojize():
import emoji
return emoji.emojize("Ray rocks :thumbs_up:")
assert ray.get(emojize.remote()) == "Ray rocks 👍"
@pytest.mark.skipif(sys.platform == "win32", reason="Not ported to Windows yet.")
def test_uv_run_editable(shutdown_only, tmp_working_dir):
tmp_dir = tmp_working_dir
subprocess.run(
["git", "clone", "https://github.com/carpedm20/emoji/", "emoji_copy"],
cwd=tmp_dir,
)
subprocess.run(
["git", "reset", "--hard", "08c5cc4789d924ad4215e2fb2ee8f0b19a0d421f"],
cwd=Path(tmp_dir) / "emoji_copy",
)
subprocess.run(
[find_uv_bin(), "add", "--editable", "./emoji_copy"],
cwd=tmp_dir,
)
# Now edit the package
content = ""
with open(Path(tmp_dir) / "emoji_copy" / "emoji" / "core.py") as f:
content = f.read()
content = content.replace(
"return pattern.sub(replace, string)", 'return "The package was edited"'
)
with open(Path(tmp_dir) / "emoji_copy" / "emoji" / "core.py", "w") as f:
f.write(content)
ray.init(
runtime_env={
"working_dir": tmp_dir,
# We want to run in the system environment so the current installation of Ray can be found here
"py_executable": f"env PYTHONPATH={':'.join(sys.path)} {find_uv_bin()} run --python-preference=only-system",
}
)
@ray.remote
def emojize():
import emoji
return emoji.emojize("Ray rocks :thumbs_up:")
assert ray.get(emojize.remote()) == "The package was edited"
@pytest.mark.skipif(sys.platform == "win32", reason="Not ported to Windows yet.")
def test_uv_run_runtime_env_hook():
import ray._private.runtime_env.uv_runtime_env_hook
def check_uv_run(
cmd, runtime_env, expected_output, subprocess_kwargs=None, expected_error=None
):
subprocess_kwargs = subprocess_kwargs or {}
env = dict(subprocess_kwargs.get("env") or os.environ)
env["UV_PYTHON"] = sys.executable
result = subprocess.run(
cmd + [json.dumps(runtime_env)],
capture_output=True,
**{**subprocess_kwargs, "env": env},
)
output = result.stdout.strip().decode()
if result.returncode != 0:
assert expected_error, result.stderr.decode()
assert expected_error in result.stderr.decode()
else:
assert json.loads(output) == expected_output
script = ray._private.runtime_env.uv_runtime_env_hook.__file__
check_uv_run(
cmd=[find_uv_bin(), "run", "--no-project", script],
runtime_env={},
expected_output={
"py_executable": f"{find_uv_bin()} run --no-project --python {sys.executable} python",
"working_dir": os.getcwd(),
},
)
check_uv_run(
cmd=[find_uv_bin(), "run", "--no-project", "--directory", "/tmp", script],
runtime_env={},
expected_output={
"py_executable": f"{find_uv_bin()} run --no-project --python {sys.executable} python",
"working_dir": os.path.realpath("/tmp"),
},
)
check_uv_run(
[find_uv_bin(), "run", "--no-project", script],
{"working_dir": "/some/path"},
{
"py_executable": f"{find_uv_bin()} run --no-project --python {sys.executable} python",
"working_dir": "/some/path",
},
)
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_dir = Path(tmp_dir).resolve()
with open(tmp_dir / "pyproject.toml", "w") as file:
file.write("[project]\n")
file.write('name = "test"\n')
file.write('version = "0.1"\n')
file.write('dependencies = ["psutil"]\n')
check_uv_run(
cmd=[find_uv_bin(), "run", script],
runtime_env={},
expected_output={
"py_executable": f"{find_uv_bin()} run --python {sys.executable} python",
"working_dir": f"{tmp_dir}",
},
subprocess_kwargs={"cwd": tmp_dir},
)
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_dir = Path(tmp_dir).resolve()
os.makedirs(tmp_dir / "cwd")
requirements = tmp_dir / "requirements.txt"
with open(requirements, "w") as file:
file.write("psutil\n")
check_uv_run(
cmd=[find_uv_bin(), "run", "--with-requirements", requirements, script],
runtime_env={},
expected_output={
"py_executable": f"{find_uv_bin()} run --with-requirements {requirements} --python {sys.executable} python",
"working_dir": f"{tmp_dir}",
},
subprocess_kwargs={"cwd": tmp_dir},
)
# Check things fail if there is a pyproject.toml upstream of the current working directory
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_dir = Path(tmp_dir).resolve()
os.makedirs(tmp_dir / "cwd")
with open(tmp_dir / "pyproject.toml", "w") as file:
file.write("[project]\n")
file.write('name = "test"\n')
file.write('version = "0.1"\n')
file.write('dependencies = ["psutil"]\n')
check_uv_run(
cmd=[find_uv_bin(), "run", script],
runtime_env={},
expected_output=None,
subprocess_kwargs={"cwd": tmp_dir / "cwd"},
expected_error="Make sure the pyproject.toml file is in the working directory.",
)
# Check things fail if there is a requirements.txt upstream to the current working directory
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_dir = Path(tmp_dir).resolve()
os.makedirs(tmp_dir / "cwd")
with open(tmp_dir / "requirements.txt", "w") as file:
file.write("psutil\n")
check_uv_run(
cmd=[
find_uv_bin(),
"run",
"--with-requirements",
tmp_dir / "requirements.txt",
script,
],
runtime_env={},
expected_output=None,
subprocess_kwargs={"cwd": tmp_dir / "cwd"},
expected_error="Make sure the requirements file is in the working directory.",
)
# Make sure the runtime environment hook gives the appropriate error message
# when combined with the 'pip' or 'uv' environment.
for runtime_env in [{"uv": ["emoji"]}, {"pip": ["emoji"]}]:
check_uv_run(
cmd=[find_uv_bin(), "run", "--no-project", script],
runtime_env=runtime_env,
expected_output=None,
expected_error="You are using the 'pip' or 'uv' runtime environments together with 'uv run'.",
)
# Check without uv run
subprocess.check_output([sys.executable, script, "{}"]).strip().decode() == "{}"
# Check in the case that there is one more level of subprocess indirection between
# the "uv run" process and the process that checks the environment
check_uv_run(
cmd=[find_uv_bin(), "run", "--no-project", script],
runtime_env={},
expected_output={
"py_executable": f"{find_uv_bin()} run --no-project --python {sys.executable} python",
"working_dir": os.getcwd(),
},
subprocess_kwargs={
"env": {**os.environ, "RAY_TEST_UV_ADD_SUBPROCESS_INDIRECTION": "1"}
},
)
# Check in the case that the script is started with multiprocessing spawn
check_uv_run(
cmd=[find_uv_bin(), "run", "--no-project", script],
runtime_env={},
expected_output={
"py_executable": f"{find_uv_bin()} run --no-project --python {sys.executable} python",
"working_dir": os.getcwd(),
},
subprocess_kwargs={
"env": {**os.environ, "RAY_TEST_UV_MULTIPROCESSING_SPAWN": "1"}
},
)
# Check in the case that a module is used for "uv run" (-m or --module)
check_uv_run(
cmd=[
find_uv_bin(),
"run",
"--no-project",
"-m",
"ray._private.runtime_env.uv_runtime_env_hook",
],
runtime_env={},
expected_output={
"py_executable": f"{find_uv_bin()} run --no-project --python {sys.executable} python",
"working_dir": os.getcwd(),
},
)
# Check in the case that a module is use for "uv run" and there is
# an argument immediately behind it
check_uv_run(
cmd=[
find_uv_bin(),
"run",
"--no-project",
"-m",
"ray._private.runtime_env.uv_runtime_env_hook",
"--extra-args",
],
runtime_env={},
expected_output={
"py_executable": f"{find_uv_bin()} run --no-project --python {sys.executable} python",
"working_dir": os.getcwd(),
},
)
def test_uv_run_parser():
from ray._private.runtime_env.uv_runtime_env_hook import (
_create_uv_run_parser,
_parse_args,
)
parser = _create_uv_run_parser()
options, command = _parse_args(parser, ["script.py"])
assert command == ["script.py"]
options, command = _parse_args(parser, ["--with", "requests", "example.py"])
assert options.with_packages == ["requests"]
assert command == ["example.py"]
options, command = _parse_args(parser, ["--python", "3.10", "example.py"])
assert options.python == "3.10"
assert command == ["example.py"]
options, command = _parse_args(
parser, ["--no-project", "script.py", "some", "args"]
)
assert options.no_project
assert command == ["script.py", "some", "args"]
options, command = _parse_args(
parser, ["--isolated", "-m", "module_name", "--extra-args"]
)
assert options.module == "module_name"
assert options.isolated
assert command == ["--extra-args"]
options, command = _parse_args(
parser,
[
"--isolated",
"--extra",
"vllm",
"-m",
"my_module.submodule",
"--model",
"Qwen/Qwen3-32B",
],
)
assert options.isolated
assert options.extras == ["vllm"]
assert options.module == "my_module.submodule"
assert command == ["--model", "Qwen/Qwen3-32B"]
@pytest.mark.skipif(sys.platform == "win32", reason="Not ported to Windows yet.")
def test_uv_run_runtime_env_hook_e2e(shutdown_only, temp_dir):
tmp_dir = Path(temp_dir)
script = f"""
import json
import ray
import os
@ray.remote
def f():
import emoji
return {{"working_dir_files": os.listdir(os.getcwd())}}
with open("{tmp_dir / "output.txt"}", "w") as out:
json.dump(ray.get(f.remote()), out)
"""
working_dir = tmp_dir / "working_dir"
working_dir.mkdir(parents=True, exist_ok=True)
script_file = working_dir / "script.py"
with open(script_file, "w") as f:
f.write(script)
f.close()
subprocess.run(
[
find_uv_bin(),
"run",
# We want to run in the system environment so the current installation of Ray can be found here
"--python-preference=only-system",
"--with",
"emoji",
"--no-project",
str(script_file),
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={
"PYTHONPATH": ":".join(sys.path),
"PATH": os.environ["PATH"],
},
cwd=working_dir,
check=True,
)
with open(tmp_dir / "output.txt") as f:
assert json.load(f) == {"working_dir_files": os.listdir(working_dir)}
@pytest.mark.skipif(sys.platform == "win32", reason="Not ported to Windows yet.")
@pytest.mark.parametrize(
"ray_start_cluster_head_with_env_vars",
[
{
"include_dashboard": True,
}
],
indirect=True,
)
def test_uv_run_runtime_env_hook_e2e_job(
ray_start_cluster_head_with_env_vars, temp_dir
):
cluster = ray_start_cluster_head_with_env_vars
assert wait_until_server_available(cluster.webui_url) is True
webui_url = format_web_url(cluster.webui_url)
tmp_dir = Path(temp_dir)
script = f"""
import json
import ray
import os
@ray.remote
def f():
import emoji
return {{"working_dir_files": os.listdir(os.getcwd())}}
with open("{tmp_dir / "output.txt"}", "w") as out:
json.dump(ray.get(f.remote()), out)
"""
working_dir = tmp_dir / "working_dir"
working_dir.mkdir(parents=True, exist_ok=True)
script_file = working_dir / "script.py"
with open(script_file, "w") as f:
f.write(script)
f.close()
requirements_file = working_dir / "requirements.txt"
with open(requirements_file, "w") as f:
f.write("emoji\n")
f.close()
# Test job submission
runtime_env_json = (
'{"env_vars": {"PYTHONPATH": "' + ":".join(sys.path) + '"}, "working_dir": "."}'
)
subprocess.run(
[
"ray",
"job",
"submit",
"--runtime-env-json",
runtime_env_json,
"--",
find_uv_bin(),
"run",
"--with-requirements",
str(requirements_file),
"--no-project",
str(script_file),
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={
"PATH": os.environ["PATH"],
"RAY_ADDRESS": webui_url,
},
cwd=working_dir,
check=True,
)
with open(tmp_dir / "output.txt") as f:
assert json.load(f) == {"working_dir_files": os.listdir(working_dir)}
@pytest.mark.skipif(sys.platform == "win32", reason="Not ported to Windows yet.")
@pytest.mark.parametrize(
"call_ray_start",
[{"cmd": "ray start --head --num-cpus=1 --ray-client-server-port=50052"}],
indirect=True,
)
def test_uv_run_ray_client_mode(call_ray_start, tmp_working_dir):
"""Test that UV environment works with Ray Client mode.
This verifies the fix for: https://github.com/ray-project/ray/issues/57991
The test verifies that when connecting via Ray Client, the client-side UV
hook correctly detects the UV environment and propagates the configuration
(py_executable, working_dir) to the cluster workers.
Architecture:
- Ray cluster runs in separate subprocess (started by call_ray_start fixture)
- Ray Client server is built-in (via --ray-client-server-port flag)
- This test process runs under 'uv run' and connects as a client
- UV hook detects UV environment in client process and propagates config
This is a cleaner approach than running cluster and client in the same process.
"""
from ray._private.runtime_env.uv_runtime_env_hook import _get_uv_run_cmdline
from ray.job_config import JobConfig
# Verify test is running under UV (prerequisite for this test)
uv_cmdline = _get_uv_run_cmdline()
if uv_cmdline is None:
pytest.skip("Test must run under 'uv run'")
tmp_dir = tmp_working_dir
# Connect as Ray Client from UV environment
# The UV hook will detect the 'uv run' process and set py_executable
ray.init(
"ray://localhost:50052", # Connect to the client server started by fixture
job_config=JobConfig(
runtime_env={
"working_dir": tmp_dir,
# Use system environment to find Ray installation
"env_vars": {"PYTHONPATH": ":".join(sys.path)},
}
),
)
try:
# Verify UV config was applied to runtime_env
context = ray.get_context()
conn_info = context.client_worker.connection_info()
runtime_env = conn_info.get("job_config", {}).get("runtime_env", {})
assert "py_executable" in runtime_env, "UV hook should set py_executable"
assert (
"uv run" in runtime_env["py_executable"]
), "py_executable should contain 'uv run'"
@ray.remote
def emojize():
import emoji
return emoji.emojize("Ray rocks :thumbs_up:")
# This should work because UV hook detected and propagated UV config
result = ray.get(emojize.remote())
assert (
result == "Ray rocks 👍"
), "UV should have installed emoji package on workers"
finally:
ray.shutdown()
@pytest.mark.skipif(sys.platform == "win32", reason="Not ported to Windows yet.")
def test_uv_run_worker_startup_failed(shutdown_only, monkeypatch):
"""Test that exception is raised when pop worker always fails
This test uses an invalid py_executable command that will fail during worker startup,
causing the worker registration to time out multiple times and eventually raise
RaySystemError.
"""
with monkeypatch.context() as m:
# Set environment variables to speed up the test (only for this test context)
# Reduce timeout to 3 seconds and max retries to 3
m.setenv("RAY_worker_register_timeout_seconds", "3")
m.setenv("RAY_pop_worker_max_retries", "3")
# Use an invalid command that will fail
runtime_env = {
"py_executable": f"{find_uv_bin()} run --with nonexistent-package-xyz123 --no-project",
}
ray.init(runtime_env=runtime_env)
@ray.remote
def failing_task():
return "should not succeed"
# The task should raise RaySystemError
with pytest.raises(RaySystemError) as exc_info:
ray.get(failing_task.remote(), timeout=30)
# Assert that the exception was actually raised and contains expected message
assert exc_info.type is RaySystemError
assert "Failed to startup worker" in str(exc_info.value)
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))