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
+63
View File
@@ -0,0 +1,63 @@
load("@rules_python//python:defs.bzl", "py_library", "py_test")
load("//bazel:python.bzl", "py_test_run_all_subdirectory")
py_library(
name = "conftest",
srcs = ["conftest.py"],
)
py_test_run_all_subdirectory(
size = "small",
include = glob(["test_*.py"]),
# These tests require data files, so they have their own targets below.
exclude = [
"test_cli_output_context.py",
"test_runtime_env_validation.py",
],
extra_srcs = [],
tags = [
"exclusive",
"small_size_python_tests",
"team:core",
],
deps = [
":conftest",
"//:ray_lib",
],
)
py_test(
name = "test_cli_output_context",
size = "small",
srcs = ["test_cli_output_context.py"],
data = [
"//python/ray/autoscaler:cli_output_context_test_data",
],
tags = [
"exclusive",
"small_size_python_tests",
"team:core",
],
deps = [
":conftest",
"//:ray_lib",
],
)
py_test(
name = "test_runtime_env_validation",
size = "small",
srcs = ["test_runtime_env_validation.py"],
data = glob([
"test_runtime_env_validation_*_schema.json",
]),
tags = [
"exclusive",
"small_size_python_tests",
"team:core",
],
deps = [
":conftest",
"//:ray_lib",
],
)
+1
View File
@@ -0,0 +1 @@
This directory should only contain unit tests that do not depend on running a Ray instance.
+11
View File
@@ -0,0 +1,11 @@
import pytest
import ray
@pytest.fixture(autouse=True)
def disallow_ray_init(monkeypatch):
def raise_on_init():
raise RuntimeError("Unit tests should not depend on Ray being initialized.")
monkeypatch.setattr(ray, "init", raise_on_init)
@@ -0,0 +1,150 @@
"""Tests for CLI output context disambiguation (ray start / ray up).
These tests directly import and call the output helper functions from
``cli_output_helpers``, capturing ``cli_logger`` calls via mocks.
No Ray C++ runtime (``_raylet``) is required. The helpers module is
imported by path to avoid triggering ``import ray``.
Validates that:
1. ``print_next_steps_context_note`` emits "head node" and "cluster network".
2. ``print_head_node_context_separator`` emits "head node" and "cluster network".
3. ``USEFUL_COMMANDS_HEADING`` contains "local machine".
4. The head-node separator is skipped when ``ray start`` did not run.
5. Neither helper references ``ray up`` (valid for direct ``ray start`` use).
"""
import importlib.util
import os
import unittest
from unittest.mock import MagicMock
# ---------------------------------------------------------------------------
# Import cli_output_helpers by file path so we bypass ``import ray`` entirely.
# ---------------------------------------------------------------------------
_HELPERS_PATH = os.path.join(
os.path.dirname(__file__),
os.pardir,
os.pardir,
"autoscaler",
"_private",
"cli_output_helpers.py",
)
_spec = importlib.util.spec_from_file_location(
"cli_output_helpers", os.path.abspath(_HELPERS_PATH)
)
_helpers = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_helpers)
print_next_steps_context_note = _helpers.print_next_steps_context_note
print_head_node_context_separator = _helpers.print_head_node_context_separator
print_head_node_context_separator_if_needed = (
_helpers.print_head_node_context_separator_if_needed
)
USEFUL_COMMANDS_HEADING = _helpers.USEFUL_COMMANDS_HEADING
def _capture_printed_text(helper_fn):
"""Call *helper_fn(cli_logger, cf)* with mocks and return all text
that was passed to ``cli_logger.print`` as a single concatenated string.
``cf.dimmed`` is mocked as an identity function so the raw text
passes through unchanged.
"""
mock_logger = MagicMock()
mock_cf = MagicMock()
# cf.dimmed should return its argument so we can inspect the raw text
mock_cf.dimmed = lambda x: x
helper_fn(mock_logger, mock_cf)
parts = []
for c in mock_logger.print.call_args_list:
args, _ = c
parts.extend(str(a) for a in args)
return "\n".join(parts)
class TestNextStepsContextNote(unittest.TestCase):
"""Tests for ``print_next_steps_context_note``."""
def test_mentions_head_node(self):
text = _capture_printed_text(print_next_steps_context_note)
self.assertIn("head node", text)
def test_mentions_cluster_network(self):
text = _capture_printed_text(print_next_steps_context_note)
self.assertIn("cluster network", text)
def test_calls_newline(self):
mock_logger = MagicMock()
mock_cf = MagicMock()
mock_cf.dimmed = lambda x: x
print_next_steps_context_note(mock_logger, mock_cf)
mock_logger.newline.assert_called()
def test_does_not_mention_ray_up(self):
text = _capture_printed_text(print_next_steps_context_note)
self.assertNotIn("ray up", text)
class TestHeadNodeContextSeparator(unittest.TestCase):
"""Tests for ``print_head_node_context_separator``."""
def test_mentions_head_node(self):
text = _capture_printed_text(print_head_node_context_separator)
self.assertIn("head node", text)
def test_mentions_cluster_network(self):
text = _capture_printed_text(print_head_node_context_separator)
self.assertIn("cluster network", text)
def test_prints_ascii_separator(self):
text = _capture_printed_text(print_head_node_context_separator)
# Should contain a run of ASCII dashes as a visual separator
self.assertIn("----", text)
def test_calls_newline(self):
mock_logger = MagicMock()
mock_cf = MagicMock()
mock_cf.dimmed = lambda x: x
print_head_node_context_separator(mock_logger, mock_cf)
mock_logger.newline.assert_called()
class TestUsefulCommandsHeading(unittest.TestCase):
"""Tests for the ``USEFUL_COMMANDS_HEADING`` constant."""
def test_contains_local_machine(self):
self.assertIn("local machine", USEFUL_COMMANDS_HEADING)
def test_does_not_use_old_label(self):
# The old heading was exactly "Useful commands:" with no qualifier
self.assertNotEqual(USEFUL_COMMANDS_HEADING, "Useful commands:")
class TestHeadNodeContextGating(unittest.TestCase):
"""Tests for gating the head-node context separator."""
def test_skips_separator_when_ray_start_commands_are_empty(self):
mock_logger = MagicMock()
mock_cf = MagicMock()
mock_cf.dimmed = lambda x: x
print_head_node_context_separator_if_needed([], mock_logger, mock_cf)
mock_logger.print.assert_not_called()
mock_logger.newline.assert_not_called()
def test_prints_separator_when_ray_start_commands_exist(self):
text = _capture_printed_text(
lambda cli_logger, cf: print_head_node_context_separator_if_needed(
["ray start --head"], cli_logger, cf
)
)
self.assertIn("head node", text)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,200 @@
"""Tests for @ray.remote and @ray.method decorator validation."""
import pytest
import ray
class TestRemoteNumReturns:
"""Test num_returns validation for @ray.remote decorator."""
def test_num_returns_negative_raises_error(self):
"""Test that num_returns < 0 raises ValueError at decoration time."""
# Option validation happens before validate_num_returns, so it raises
# a different error message, but still validates that negative values fail fast.
with pytest.raises(ValueError, match="non-negative integer"):
@ray.remote(num_returns=-1)
def f():
return 1
def test_num_returns_streaming_with_non_generator_raises_error(self):
"""Test that num_returns='streaming' with non-generator raises ValueError."""
with pytest.raises(
ValueError, match="num_returns='streaming' can only be used with generator"
):
@ray.remote(num_returns="streaming")
def f():
return 1
def test_num_returns_dynamic_with_non_generator_raises_error(self):
"""Test that num_returns='dynamic' with non-generator raises ValueError."""
with pytest.raises(
ValueError, match="num_returns='dynamic' can only be used with generator"
):
@ray.remote(num_returns="dynamic")
def f():
return 1
def test_num_returns_streaming_with_generator_succeeds(self):
"""Test that num_returns='streaming' with generator function succeeds."""
@ray.remote(num_returns="streaming")
def generator_func():
for i in range(3):
yield i
def test_num_returns_dynamic_with_generator_succeeds(self):
"""Test that num_returns='dynamic' with generator function succeeds."""
@ray.remote(num_returns="dynamic")
def generator_func():
for i in range(3):
yield i
def test_num_returns_streaming_with_async_generator_succeeds(self):
"""Test that num_returns='streaming' with async generator function succeeds."""
@ray.remote(num_returns="streaming")
async def async_generator_func():
for i in range(3):
yield i
def test_num_returns_positive_integer_succeeds(self):
"""Test that num_returns with positive integer succeeds."""
@ray.remote(num_returns=2)
def f():
return 1, 2
def test_num_returns_zero_succeeds(self):
"""Test that num_returns=0 succeeds."""
@ray.remote(num_returns=0)
def f():
return
def test_num_returns_none_succeeds(self):
"""Test that num_returns=None succeeds."""
@ray.remote(num_returns=None)
def f():
return 1
def test_num_returns_default_succeeds(self):
"""Test that default num_returns (not specified) succeeds."""
@ray.remote
def f():
return 1
class TestMethodNumReturns:
"""Test num_returns validation for @ray.method decorator."""
def test_num_returns_negative_raises_error(self):
"""Test that num_returns < 0 raises ValueError at decoration time."""
with pytest.raises(ValueError, match="num_returns must be >= 0"):
@ray.remote
class TestActor:
@ray.method(num_returns=-1)
def method(self):
return 1
def test_num_returns_streaming_with_non_generator_raises_error(self):
"""Test that num_returns='streaming' with non-generator raises ValueError."""
with pytest.raises(
ValueError, match="num_returns='streaming' can only be used with generator"
):
@ray.remote
class TestActor:
@ray.method(num_returns="streaming")
def method(self):
return 1
def test_num_returns_dynamic_with_non_generator_raises_error(self):
"""Test that num_returns='dynamic' with non-generator raises ValueError."""
with pytest.raises(
ValueError, match="num_returns='dynamic' can only be used with generator"
):
@ray.remote
class TestActor:
@ray.method(num_returns="dynamic")
def method(self):
return 1
def test_num_returns_streaming_with_generator_succeeds(self):
"""Test that num_returns='streaming' with generator method succeeds."""
@ray.remote
class TestActor:
@ray.method(num_returns="streaming")
def generator_method(self):
for i in range(3):
yield i
def test_num_returns_dynamic_with_generator_succeeds(self):
"""Test that num_returns='dynamic' with generator method succeeds."""
@ray.remote
class TestActor:
@ray.method(num_returns="dynamic")
def generator_method(self):
for i in range(3):
yield i
def test_num_returns_streaming_with_async_generator_succeeds(self):
"""Test that num_returns='streaming' with async generator method succeeds."""
@ray.remote
class TestActor:
@ray.method(num_returns="streaming")
async def async_generator_method(self):
for i in range(3):
yield i
def test_num_returns_positive_integer_succeeds(self):
"""Test that num_returns with positive integer succeeds."""
@ray.remote
class TestActor:
@ray.method(num_returns=2)
def method(self):
return 1, 2
def test_num_returns_zero_succeeds(self):
"""Test that num_returns=0 succeeds."""
@ray.remote
class TestActor:
@ray.method(num_returns=0)
def method(self):
return
def test_num_returns_none_succeeds(self):
"""Test that num_returns=None succeeds."""
@ray.remote
class TestActor:
@ray.method(num_returns=None)
def method(self):
return 1
def test_num_returns_default_succeeds(self):
"""Test that default num_returns (not specified) succeeds."""
@ray.remote
class TestActor:
def method(self):
return 1
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,188 @@
# coding: utf-8
"""Unit tests for ray._private.test_utils.get_system_metric_for_component."""
import sys
from unittest.mock import MagicMock, patch
import pytest
import requests
from ray._private.test_utils import get_system_metric_for_component
SUCCESS_JSON = {
"data": {
"result": [
{"value": [1234567890, "42.0"]},
{"value": [1234567890, "13.5"]},
]
}
}
def _make_resp(status_code: int, json_data=None, text: str = "") -> MagicMock:
resp = MagicMock(spec=requests.Response)
resp.status_code = status_code
resp.text = text
if json_data is not None:
resp.json.return_value = json_data
# Mimic real Response: raise_for_status() raises HTTPError for 4xx/5xx and
# sets e.response to the response object.
if status_code >= 400:
resp.raise_for_status.side_effect = requests.exceptions.HTTPError(
f"{status_code} Error", response=resp
)
return resp
@pytest.fixture
def mock_global_node():
"""Stub out ray._private.worker._global_node so the helper can build a
session_name without a real cluster."""
fake_node = MagicMock()
fake_node.get_session_dir_path.return_value = "/tmp/ray/session_2026-01-01_test"
with patch("ray._private.worker._global_node", fake_node):
yield fake_node
@pytest.fixture
def no_sleep():
"""Don't actually sleep during retry backoff."""
with patch("ray._private.test_utils.time.sleep") as mock_sleep:
yield mock_sleep
def test_success_first_try(mock_global_node, no_sleep):
with patch("ray._private.test_utils.requests.get") as mock_get:
mock_get.return_value = _make_resp(200, json_data=SUCCESS_JSON)
result = get_system_metric_for_component("ray_x", "gcs", "http://prom")
assert result == [42.0, 13.5]
assert mock_get.call_count == 1
no_sleep.assert_not_called()
def test_500_500_200_retries_then_succeeds(mock_global_node, no_sleep):
with patch("ray._private.test_utils.requests.get") as mock_get:
mock_get.side_effect = [
_make_resp(500, text="boom 1"),
_make_resp(500, text="boom 2"),
_make_resp(200, json_data=SUCCESS_JSON),
]
result = get_system_metric_for_component("ray_x", "gcs", "http://prom")
assert result == [42.0, 13.5]
assert mock_get.call_count == 3
assert no_sleep.call_count == 2
# Exponential backoff: 1s, 2s
assert [c.args[0] for c in no_sleep.call_args_list] == [1.0, 2.0]
def test_all_500s_raises_with_full_context(mock_global_node, no_sleep):
with patch("ray._private.test_utils.requests.get") as mock_get:
mock_get.return_value = _make_resp(500, text="prometheus exploded")
with pytest.raises(RuntimeError) as exc_info:
get_system_metric_for_component(
"ray_component_uss_bytes", "dashboard", "http://prom-server"
)
msg = str(exc_info.value)
assert "last_status=500" in msg
assert "http://prom-server" in msg
assert "ray_component_uss_bytes" in msg
assert "dashboard" in msg
assert "prometheus exploded" in msg
assert mock_get.call_count == 3
# Two sleeps between the three failed attempts
assert no_sleep.call_count == 2
def test_500_body_truncated_to_500_chars(mock_global_node, no_sleep):
huge = "x" * 5000
with patch("ray._private.test_utils.requests.get") as mock_get:
mock_get.return_value = _make_resp(500, text=huge)
with pytest.raises(RuntimeError) as exc_info:
get_system_metric_for_component("ray_x", "gcs", "http://prom")
msg = str(exc_info.value)
# The truncated body (500 chars) must appear; the full 5000-char body must not
assert "x" * 500 in msg
assert "x" * 501 not in msg
def test_mixed_failures_then_success(mock_global_node, no_sleep):
with patch("ray._private.test_utils.requests.get") as mock_get:
mock_get.side_effect = [
_make_resp(503, text="unavailable"),
requests.exceptions.ConnectionError("connection refused"),
_make_resp(200, json_data=SUCCESS_JSON),
]
result = get_system_metric_for_component("ray_x", "gcs", "http://prom")
assert result == [42.0, 13.5]
assert mock_get.call_count == 3
assert no_sleep.call_count == 2
@pytest.mark.parametrize("status_code", [400, 401, 403, 404, 422])
def test_4xx_not_retried(mock_global_node, no_sleep, status_code):
with patch("ray._private.test_utils.requests.get") as mock_get:
mock_get.return_value = _make_resp(status_code, text="bad query")
with pytest.raises(RuntimeError) as exc_info:
get_system_metric_for_component("ray_x", "gcs", "http://prom")
msg = str(exc_info.value)
assert f"last_status={status_code}" in msg
assert "bad query" in msg
assert mock_get.call_count == 1
no_sleep.assert_not_called()
def test_all_timeouts_raises_with_error_class(mock_global_node, no_sleep):
with patch("ray._private.test_utils.requests.get") as mock_get:
mock_get.side_effect = requests.exceptions.Timeout("timed out")
with pytest.raises(RuntimeError) as exc_info:
get_system_metric_for_component("ray_x", "gcs", "http://prom")
msg = str(exc_info.value)
assert "last_error" in msg
assert "Timeout" in msg
assert "http://prom" in msg
assert mock_get.call_count == 3
assert no_sleep.call_count == 2
def test_max_attempts_kwarg_respected(mock_global_node, no_sleep):
with patch("ray._private.test_utils.requests.get") as mock_get:
mock_get.return_value = _make_resp(500, text="boom")
with pytest.raises(RuntimeError):
get_system_metric_for_component(
"ray_x", "gcs", "http://prom", max_attempts=5
)
assert mock_get.call_count == 5
assert no_sleep.call_count == 4
def test_request_timeout_kwarg_passed_through(mock_global_node, no_sleep):
with patch("ray._private.test_utils.requests.get") as mock_get:
mock_get.return_value = _make_resp(200, json_data=SUCCESS_JSON)
get_system_metric_for_component(
"ray_x", "gcs", "http://prom", request_timeout_s=7.5
)
assert mock_get.call_args.kwargs.get("timeout") == 7.5
def test_backoff_base_kwarg(mock_global_node, no_sleep):
with patch("ray._private.test_utils.requests.get") as mock_get:
mock_get.side_effect = [
_make_resp(500, text="boom"),
_make_resp(500, text="boom"),
_make_resp(200, json_data=SUCCESS_JSON),
]
get_system_metric_for_component(
"ray_x", "gcs", "http://prom", backoff_base_s=0.5
)
# 0.5 * 2^0 = 0.5, 0.5 * 2^1 = 1.0
assert [c.args[0] for c in no_sleep.call_args_list] == [0.5, 1.0]
def test_empty_result_returns_empty_list(mock_global_node, no_sleep):
with patch("ray._private.test_utils.requests.get") as mock_get:
mock_get.return_value = _make_resp(200, json_data={"data": {"result": []}})
result = get_system_metric_for_component("ray_x", "gcs", "http://prom")
assert result == []
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,239 @@
"""Unit tests for JobConfig._validate_runtime_env with _client_job flag.
Tests that when _client_job=True, local path validation is skipped in
_validate_runtime_env(). This is necessary because Ray Client jobs upload
the working_dir to GCS on the client side, so the server-side JobConfig
may contain paths that look local but are actually already uploaded.
"""
import pickle
import tempfile
from unittest.mock import patch
import pytest
from ray.job_config import JobConfig
@pytest.mark.parametrize(
"runtime_env_key,path_type,client_job,should_raise",
[
("working_dir", "local", True, False),
("working_dir", "local", False, True),
("working_dir", "gcs", True, False),
("working_dir", "gcs", False, False),
("py_modules", "local", True, False),
("py_modules", "local", False, True),
],
ids=[
"working_dir_local_with_client_job",
"working_dir_local_without_client_job_fails",
"working_dir_gcs_with_client_job",
"working_dir_gcs_without_client_job",
"py_modules_local_with_client_job",
"py_modules_local_without_client_job_fails",
],
)
def test_path_validation(runtime_env_key, path_type, client_job, should_raise):
"""Test that _client_job flag controls local path validation."""
with tempfile.TemporaryDirectory() as tmp_dir:
if path_type == "gcs":
path_value = "gcs://_ray_pkg_abc123.zip"
else:
path_value = tmp_dir
# py_modules needs to be a list
if runtime_env_key == "py_modules":
runtime_env = {runtime_env_key: [path_value]}
else:
runtime_env = {runtime_env_key: path_value}
config = JobConfig(runtime_env=runtime_env, _client_job=client_job)
if should_raise:
with pytest.raises(ValueError, match="not a valid URI"):
config._validate_runtime_env()
else:
result = config._validate_runtime_env()
assert result is not None
@pytest.mark.parametrize(
"method,value,expected",
[
("constructor", None, False),
("constructor", True, True),
("constructor", False, False),
("from_json", True, True),
("from_json", False, False),
("from_json", None, False),
],
ids=[
"default_is_false",
"constructor_set_true",
"constructor_set_false",
"from_json_with_true",
"from_json_with_false",
"from_json_defaults_false",
],
)
def test_client_job_flag_initialization(method, value, expected):
"""Test _client_job flag initialization via constructor and from_json."""
if method == "constructor":
if value is None:
config = JobConfig()
else:
config = JobConfig(_client_job=value)
else: # from_json
if value is None:
config = JobConfig.from_json({})
else:
config = JobConfig.from_json({"client_job": value})
assert config._client_job is expected
@pytest.mark.parametrize("method", ["constructor", "from_json"])
def test_code_search_path_must_be_list_or_tuple(method):
"""JobConfig should reject invalid code_search_path types consistently."""
expected_message = "The type of code search path is incorrect"
with pytest.raises(TypeError, match=expected_message):
if method == "constructor":
JobConfig(code_search_path="/tmp/code")
else:
JobConfig.from_json({"code_search_path": "/tmp/code"})
def test_validate_no_local_paths_not_called_for_client_job():
"""Verify _validate_no_local_paths is not called when _client_job=True."""
with tempfile.TemporaryDirectory() as tmp_dir:
config = JobConfig(
runtime_env={"working_dir": tmp_dir},
_client_job=True,
)
with patch(
"ray.runtime_env.runtime_env._validate_no_local_paths"
) as mock_validate:
config._validate_runtime_env()
mock_validate.assert_not_called()
def test_validate_no_local_paths_called_for_non_client_job():
"""Verify _validate_no_local_paths is called when _client_job=False."""
with tempfile.TemporaryDirectory() as tmp_dir:
config = JobConfig(
runtime_env={"working_dir": tmp_dir},
_client_job=False,
)
with patch(
"ray.runtime_env.runtime_env._validate_no_local_paths"
) as mock_validate:
mock_validate.return_value = None
try:
config._validate_runtime_env()
except ValueError:
pass # Expected to fail
mock_validate.assert_called_once()
def test_extracted_temp_path_scenario():
"""Simulate the actual bug: proxy extracts GCS package to temp path.
The proxy downloads gcs://_ray_pkg_xxx.zip and extracts it to
/tmp/ray/.../working_dir_files/_ray_pkg_xxx. The server-side job_config
then has this local path with _ray_pkg_ prefix, which would fail
validation without the _client_job flag.
"""
with tempfile.TemporaryDirectory(prefix="_ray_pkg_") as tmp_dir:
config = JobConfig(
runtime_env={"working_dir": tmp_dir},
_client_job=True,
)
config._validate_runtime_env() # Should not raise
@pytest.mark.parametrize(
"client_job,has_local_path,should_raise",
[
(True, False, False),
(False, False, False),
(True, True, False),
(False, True, True),
],
ids=[
"client_job_flag_survives_pickle",
"non_client_job_flag_survives_pickle",
"pickled_client_job_skips_validation",
"pickled_non_client_job_validates",
],
)
def test_pickle_roundtrip(client_job, has_local_path, should_raise):
"""Test that _client_job flag survives pickle and validation is preserved."""
with tempfile.TemporaryDirectory() as tmp_dir:
if has_local_path:
config = JobConfig(
runtime_env={"working_dir": tmp_dir},
_client_job=client_job,
)
else:
config = JobConfig(_client_job=client_job)
pickled = pickle.dumps(config)
unpickled = pickle.loads(pickled)
# Verify flag survives pickle
assert unpickled._client_job is client_job
# Test validation if runtime_env present
if has_local_path:
if should_raise:
with pytest.raises(ValueError, match="not a valid URI"):
unpickled._validate_runtime_env()
else:
unpickled._validate_runtime_env()
@pytest.mark.parametrize(
"client_job,path_type,should_raise",
[
(True, "gcs", False),
(False, "gcs", False),
(True, "local", False),
(False, "local", True),
],
ids=[
"serialize_client_job_gcs_uri",
"serialize_non_client_job_gcs_uri",
"serialize_client_job_local_path",
"serialize_non_client_job_local_path_fails",
],
)
def test_serialize_crash_path(client_job, path_type, should_raise):
"""Test _serialize() handles paths correctly based on _client_job flag.
This tests the actual crash path from the bug report. Without the fix,
_serialize() would raise "not a valid URI" for client jobs with local paths.
"""
with tempfile.TemporaryDirectory() as tmp_dir:
if path_type == "gcs":
path_value = "gcs://_ray_pkg_abc.zip"
else:
path_value = tmp_dir
config = JobConfig(
runtime_env={"working_dir": path_value},
_client_job=client_job,
)
if should_raise:
with pytest.raises(ValueError, match="not a valid URI"):
config._serialize()
else:
serialized = config._serialize()
assert serialized is not None
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
+132
View File
@@ -0,0 +1,132 @@
import os
import sys
import pytest
from ray._private.log_monitor import LogFileInfo
def _create_file_info(log_path):
return LogFileInfo(
filename=log_path,
size_when_last_opened=0,
file_position=0,
file_handle=None,
is_err_file=False,
job_id=None,
worker_pid=None,
)
@pytest.mark.skipif(
sys.platform == "win32", reason="Relies on POSIX truncate semantics"
)
def test_reopen_same_inode_truncation_seeks_beginning(tmp_path):
"""Truncating a file should rewind and reopen the reader on the same inode."""
log_path = tmp_path / "worker.log"
with open(log_path, "w") as f:
for i in range(100):
print(f"Log line {i}", file=f)
file_info = _create_file_info(log_path)
file_info.reopen_if_necessary()
for i in range(50):
line = file_info.file_handle.readline().strip()
assert line == f"Log line {i}".encode("utf-8")
original_inode = os.stat(log_path).st_ino
original_position = file_info.file_handle.tell()
file_info.file_position = original_position
with open(log_path, "w") as f:
print("Truncated log line 0", file=f)
assert os.stat(log_path).st_ino == original_inode
assert os.path.getsize(log_path) < original_position
file_info.reopen_if_necessary()
assert file_info.file_position == 0
assert file_info.file_handle.tell() == 0
assert file_info.size_when_last_opened == os.path.getsize(log_path)
assert file_info.file_handle.readline().strip() == b"Truncated log line 0"
file_info.file_handle.close()
@pytest.mark.skipif(
sys.platform == "win32", reason="Relies on POSIX truncate semantics"
)
def test_reopen_same_inode_truncation_with_rewrite_larger_than_position(tmp_path):
"""Truncation should rewind even if rewritten content exceeds the old position."""
log_path = tmp_path / "worker.log"
with open(log_path, "w") as f:
for i in range(100):
print(f"Old log line {i}", file=f)
file_info = _create_file_info(log_path)
file_info.reopen_if_necessary()
for i in range(10):
line = file_info.file_handle.readline().strip()
assert line == f"Old log line {i}".encode("utf-8")
original_inode = os.stat(log_path).st_ino
original_position = file_info.file_handle.tell()
original_size = file_info.size_when_last_opened
file_info.file_position = original_position
with open(log_path, "w") as f:
for i in range(20):
print(f"New log line {i}", file=f)
new_size = os.path.getsize(log_path)
assert os.stat(log_path).st_ino == original_inode
assert original_position < new_size < original_size
file_info.reopen_if_necessary()
assert file_info.file_position == 0
assert file_info.file_handle.tell() == 0
assert file_info.size_when_last_opened == new_size
assert file_info.file_handle.readline().strip() == b"New log line 0"
file_info.file_handle.close()
def test_reopen_same_inode_growth_keeps_size_when_last_opened(tmp_path):
"""Growing a file in place should not hide unread data after a close/reopen cycle."""
log_path = tmp_path / "worker.log"
with open(log_path, "w") as f:
for i in range(20):
print(f"Log line {i}", file=f)
file_info = _create_file_info(log_path)
file_info.reopen_if_necessary()
original_size = file_info.size_when_last_opened
for i in range(5):
line = file_info.file_handle.readline().strip()
assert line == f"Log line {i}".encode("utf-8")
file_info.file_position = file_info.file_handle.tell()
with open(log_path, "a") as f:
for i in range(20, 30):
print(f"Log line {i}", file=f)
assert os.path.getsize(log_path) > original_size
file_info.reopen_if_necessary()
assert file_info.size_when_last_opened == original_size
file_info.file_handle.close()
if __name__ == "__main__":
sys.exit(pytest.main(["-vv", __file__]))
@@ -0,0 +1,48 @@
import re
import sys
import pytest
from ray._raylet import NodeID
from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy
def assert_invalid_node_id(node_id_value):
node_id_str = (
node_id_value if isinstance(node_id_value, str) else node_id_value.hex()
)
expected_msg = re.escape(
f"Invalid node_id '{node_id_str}'. Node ID must be a valid "
"hex string. To get a list of all nodes and their IDs in your cluster, "
"use ray.nodes(). See https://docs.ray.io/en/latest/ray-core/miscellaneous.html"
"#node-information for more details."
)
with pytest.raises(ValueError, match=expected_msg):
NodeAffinitySchedulingStrategy(node_id=node_id_value, soft=False)
def test_node_affinity_scheduling_strategy_invalid_attributes():
valid_hex = NodeID.from_random().hex()
with pytest.raises(
ValueError,
match="_spill_on_unavailable cannot be set when soft is False. "
"Please set soft to True to use _spill_on_unavailable.",
):
NodeAffinitySchedulingStrategy(
node_id=valid_hex, soft=False, _spill_on_unavailable=True
)
with pytest.raises(
ValueError,
match="_fail_on_unavailable cannot be set when soft is True. "
"Please set soft to False to use _fail_on_unavailable.",
):
NodeAffinitySchedulingStrategy(
node_id=valid_hex, soft=True, _fail_on_unavailable=True
)
assert_invalid_node_id("invalid_node_id")
assert_invalid_node_id(NodeID.nil())
if __name__ == "__main__":
sys.exit(pytest.main(["-vv", __file__]))
@@ -0,0 +1,357 @@
import json
import sys
from unittest.mock import patch
import pytest
import ray._private.ray_constants as ray_constants
from ray._common.constants import HEAD_NODE_RESOURCE_NAME, NODE_ID_PREFIX
from ray._private.accelerators import AcceleratorManager
from ray._private.resource_and_label_spec import ResourceAndLabelSpec
class FakeAcceleratorManager(AcceleratorManager):
"""Minimal fake Acceleratormanager for testing."""
# Configure these values to test different resource resolution paths.
def __init__(
self,
resource_name,
accelerator_type,
num_accelerators,
additional_resources=None,
visible_ids=None,
):
self._resource_name = resource_name
self._accelerator_type = accelerator_type
self._num_accelerators = num_accelerators
self._additional_resources = additional_resources
self._visible_ids = visible_ids
def get_current_node_num_accelerators(self) -> int:
return self._num_accelerators
def get_current_process_visible_accelerator_ids(self):
if self._visible_ids is not None:
return [str(i) for i in range(self._visible_ids)]
return [str(i) for i in range(self._num_accelerators)]
def get_resource_name(self) -> str:
return self._resource_name
def get_current_node_accelerator_type(self) -> str:
return self._accelerator_type
def get_visible_accelerator_ids_env_var(self) -> str:
return "CUDA_VISIBLE_DEVICES"
def get_current_node_additional_resources(self):
return self._additional_resources or {}
def set_current_process_visible_accelerator_ids(self, ids):
pass
def validate_resource_request_quantity(self, quantity: int) -> None:
pass
def test_resource_and_label_spec_resolves_with_params():
"""Validate that ResourceAndLabelSpec resolve() respects passed in
Ray Params rather than overriding with auto-detection/system defaults."""
# Create ResourceAndLabelSpec with args from RayParams.
spec = ResourceAndLabelSpec(
num_cpus=8,
num_gpus=2,
memory=10 * 1024**3,
object_store_memory=5 * 1024**3,
resources={"TPU": 42},
labels={"ray.io/market-type": "spot"},
)
spec.resolve(is_head=False)
# Verify that explicit Ray Params values are preserved.
assert spec.num_cpus == 8
assert spec.num_gpus == 2
assert spec.memory == 10 * 1024**3
assert spec.object_store_memory == 5 * 1024**3
assert spec.resources["TPU"] == 42
assert any(key.startswith(NODE_ID_PREFIX) for key in spec.resources)
assert spec.labels["ray.io/market-type"] == "spot"
assert spec.resolved()
def test_resource_and_label_spec_resolves_auto_detect(monkeypatch):
"""Validate that ResourceAndLabelSpec resolve() fills out defaults detected from
system when Params not passed."""
monkeypatch.setattr("ray._private.utils.get_num_cpus", lambda: 4) # 4 cpus
monkeypatch.setattr(
"ray._common.utils.get_system_memory", lambda: 16 * 1024**3
) # 16GB
monkeypatch.setattr(
"ray._private.utils.estimate_available_memory", lambda: 8 * 1024**3
) # 8GB
monkeypatch.setattr(
"ray._private.utils.get_shared_memory_bytes", lambda: 4 * 1024**3
) # 4GB
spec = ResourceAndLabelSpec()
spec.resolve(is_head=True)
assert spec.resolved()
# Validate all fields are set based on defaults or calls to system.
assert spec.num_cpus == 4
assert spec.num_gpus == 0
assert isinstance(spec.labels, dict)
assert HEAD_NODE_RESOURCE_NAME in spec.resources
assert any(key.startswith(NODE_ID_PREFIX) for key in spec.resources.keys())
if sys.platform == "darwin":
# Object store memory is capped at 2GB on macOS.
expected_object_store = 2 * 1024**3
else:
# object_store_memory = 8GB * DEFAULT_OBJECT_STORE_MEMORY_PROPORTION
expected_object_store = int(
8 * 1024**3 * ray_constants.DEFAULT_OBJECT_STORE_MEMORY_PROPORTION
)
assert spec.object_store_memory == expected_object_store
# memory is total available memory - object_store_memory
expected_memory = 8 * 1024**3 - expected_object_store
assert spec.memory == expected_memory
def test_env_resource_overrides_with_conflict(monkeypatch):
"""Validate that RESOURCES_ENVIRONMENT_VARIABLE overrides Ray Param resources."""
# Prepare environment overrides
env_resources = {
"CPU": 8,
"GPU": 4,
"TPU": 4,
}
monkeypatch.setenv(
ray_constants.RESOURCES_ENVIRONMENT_VARIABLE, json.dumps(env_resources)
)
ray_params_resources = {"TPU": 8, "B200": 4}
# num_cpus, num_gpus, and conflicting resources should override
spec = ResourceAndLabelSpec(
num_cpus=2,
num_gpus=1,
resources=ray_params_resources,
labels={},
)
spec.resolve(is_head=True)
# Environment overrides values take precedence after resolve
assert spec.num_cpus == 8
assert spec.num_gpus == 4
assert spec.resources["TPU"] == 4
assert spec.resources["B200"] == 4
def test_to_resource_dict_with_invalid_types():
"""Validate malformed resource values raise ValueError from to_resource_dict()."""
spec = ResourceAndLabelSpec(
num_cpus=1,
num_gpus=1,
memory=1_000,
object_store_memory=1_000,
resources={"INVALID": -5}, # Invalid
labels={},
)
spec.resolve(is_head=True, node_ip_address="127.0.0.1")
with pytest.raises(ValueError):
spec.to_resource_dict()
def test_resolve_memory_resources(monkeypatch):
"""Validate that resolve correctly sets system object_store memory and
raises ValueError when configured memory is too low."""
# object_store_memory capped at 95% of shm size to avoid low performance.
monkeypatch.setattr(
"ray._common.utils.get_system_memory", lambda: 2 * 1024**3
) # 2 GB
monkeypatch.setattr(
"ray._private.utils.estimate_available_memory", lambda: 1 * 1024**3
) # 2 GB
monkeypatch.setattr(
"ray._private.utils.get_shared_memory_bytes", lambda: 512 * 1024**2
) # 512 MB
spec1 = ResourceAndLabelSpec()
spec1.resolve(is_head=False)
max_shm = 512 * 1024**2 * 0.95
assert spec1.object_store_memory <= max_shm
assert spec1.memory > 0
# Low available memory for tasks/actors triggers ValueError.
monkeypatch.setattr(
"ray._common.utils.get_system_memory", lambda: 2 * 1024**3
) # 2 GB
monkeypatch.setattr(
"ray._private.utils.estimate_available_memory", lambda: 100 * 1024**2
) # 100 MB
monkeypatch.setattr(
"ray._private.utils.get_shared_memory_bytes", lambda: 50 * 1024**2
) # 50 MB
spec2 = ResourceAndLabelSpec()
with pytest.raises(ValueError, match="available for tasks and actors"):
spec2.resolve(is_head=False)
def test_resolve_raises_on_reserved_head_resource():
"""resolve should raise a ValueError if HEAD_NODE_RESOURCE_NAME is set in resources."""
spec = ResourceAndLabelSpec(resources={HEAD_NODE_RESOURCE_NAME: 1}, labels={})
with pytest.raises(ValueError, match=HEAD_NODE_RESOURCE_NAME):
spec.resolve(is_head=True)
def test_resolve_handles_no_accelerators():
"""Check resolve() is able to handle the no accelerators detected case."""
spec = ResourceAndLabelSpec()
# No accelerators are returned.
with patch(
"ray._private.accelerators.get_all_accelerator_resource_names",
return_value=[],
):
spec.resolve(is_head=False, node_ip_address="test")
# With no accelerators detected or num_gpus, GPU count should default to 0
# and the resources dictionary is unchanged.
assert spec.num_gpus == 0
assert spec.resources == {"node:test": 1}
assert spec.resolved()
def test_label_spec_resolve_merged_env_labels(monkeypatch):
"""Validate that LABELS_ENVIRONMENT_VARIABLE is merged into final labels."""
override_labels = {"autoscaler-override-label": "example"}
monkeypatch.setenv(
ray_constants.LABELS_ENVIRONMENT_VARIABLE, json.dumps(override_labels)
)
spec = ResourceAndLabelSpec()
spec.resolve(is_head=True)
assert any(key == "autoscaler-override-label" for key in spec.labels)
def test_merge_labels_populates_defaults(monkeypatch):
"""Ensure default labels (node type, market type, region, zone, accelerator) populate correctly."""
# Patch Ray K8s label environment vars
monkeypatch.setenv(ray_constants.LABELS_ENVIRONMENT_VARIABLE, "{}")
monkeypatch.setenv("RAY_NODE_TYPE_NAME", "worker-group-1")
monkeypatch.setenv("RAY_NODE_MARKET_TYPE", "spot")
monkeypatch.setenv("RAY_NODE_REGION", "us-west1")
monkeypatch.setenv("RAY_NODE_ZONE", "us-west1-a")
spec = ResourceAndLabelSpec()
# AcceleratorManager for node with 1 GPU
with patch(
"ray._private.accelerators.get_accelerator_manager_for_resource",
return_value=FakeAcceleratorManager("GPU", "A100", 1),
), patch(
"ray._private.accelerators.get_all_accelerator_resource_names",
return_value=["GPU"],
):
spec.resolve(is_head=False)
# Verify all default labels are present
assert spec.labels.get("ray.io/node-group") == "worker-group-1"
assert spec.labels.get("ray.io/market-type") == "spot"
assert spec.labels.get("ray.io/availability-region") == "us-west1"
assert spec.labels.get("ray.io/availability-zone") == "us-west1-a"
assert spec.labels.get("ray.io/accelerator-type") == "A100"
assert spec.resolved()
def test_resolve_raises_if_exceeds_visible_devices():
"""Check that ValueError is raised when requested accelerators exceed visible IDs."""
spec = ResourceAndLabelSpec()
spec.num_gpus = 3 # request 3 GPUs
with patch(
"ray._private.accelerators.get_accelerator_manager_for_resource",
return_value=FakeAcceleratorManager(
"GPU", "A100", num_accelerators=5, visible_ids=2
),
), patch(
"ray._private.accelerators.get_all_accelerator_resource_names",
return_value=["GPU"],
):
with pytest.raises(ValueError, match="Attempting to start raylet"):
spec.resolve(is_head=False)
def test_resolve_sets_accelerator_resources():
"""Verify that GPUs/TPU values are auto-detected and assigned properly."""
spec = ResourceAndLabelSpec()
# Mock a node with GPUs with 4 visible IDs
with patch(
"ray._private.accelerators.get_accelerator_manager_for_resource",
return_value=FakeAcceleratorManager("GPU", "A100", 4),
), patch(
"ray._private.accelerators.get_all_accelerator_resource_names",
return_value=["GPU"],
):
spec.resolve(is_head=False)
assert spec.num_gpus == 4
assert spec.resources.get("accelerator_type:A100") == 1
def test_respect_configured_num_gpus():
"""Ensure manually set num_gpus overrides differing auto-detected accelerator value."""
# Create a ResourceAndLabelSpec with num_gpus=2 from Ray Params.
spec = ResourceAndLabelSpec(num_gpus=2)
# Mock a node with GPUs with 4 visible IDs
with patch(
"ray._private.accelerators.get_accelerator_manager_for_resource",
return_value=FakeAcceleratorManager("GPU", "A100", 4),
), patch(
"ray._private.accelerators.get_all_accelerator_resource_names",
return_value=["GPU"],
):
spec.resolve(is_head=False)
assert spec.num_gpus == 2, (
f"Expected manually set num_gpus=2 to take precedence over auto-detected value, "
f"but got {spec.num_gpus}"
)
# Accelerator type key should be set in resources.
assert spec.resources.get("accelerator_type:A100") == 1
def test_resolve_sets_non_gpu_accelerator():
"""Verify that non-GPU accelerators are added to resources. Non-GPU accelerators
should not alter the value of num_gpus."""
spec = ResourceAndLabelSpec()
# Mock accelerator manager to return a TPU v6e accelerator
with patch(
"ray._private.accelerators.get_accelerator_manager_for_resource",
return_value=FakeAcceleratorManager("TPU", "TPU-v6e", 2, {"TPU-v6e-8-HEAD": 1}),
), patch(
"ray._private.accelerators.get_all_accelerator_resource_names",
return_value=["TPU"],
):
spec.resolve(is_head=False)
# num_gpus should default to 0
assert spec.num_gpus == 0
assert spec.resources["TPU"] == 2
assert spec.resources["TPU-v6e-8-HEAD"] == 1
# Accelerator type label is present
assert spec.labels.get("ray.io/accelerator-type") == "TPU-v6e"
assert spec.resolved()
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
+540
View File
@@ -0,0 +1,540 @@
import dataclasses
import json
import os
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from typing import Any, Dict
from unittest import mock
import pytest
import ray
import ray._private.ray_constants as ray_constants
from ray._private.runtime_env.uri_cache import URICache
from ray._private.runtime_env.utils import (
SubprocessCalledProcessError,
check_output_cmd,
)
from ray._private.test_utils import (
chdir,
)
from ray.runtime_env import RuntimeEnv
from ray.runtime_env.runtime_env import (
RuntimeEnvConfig,
_merge_runtime_env,
)
def test_runtime_env_merge():
# Both are None.
parent = None
child = None
assert _merge_runtime_env(parent, child) == {}
parent = {}
child = None
assert _merge_runtime_env(parent, child) == {}
parent = None
child = {}
assert _merge_runtime_env(parent, child) == {}
parent = {}
child = {}
assert _merge_runtime_env(parent, child) == {}
# Only parent is given.
parent = {"conda": ["requests"], "env_vars": {"A": "1"}}
child = None
assert _merge_runtime_env(parent, child) == parent
# Only child is given.
parent = None
child = {"conda": ["requests"], "env_vars": {"A": "1"}}
assert _merge_runtime_env(parent, child) == child
# Successful case.
parent = {"conda": ["requests"], "env_vars": {"A": "1"}}
child = {"pip": ["requests"], "env_vars": {"B": "2"}}
assert _merge_runtime_env(parent, child) == {
"conda": ["requests"],
"pip": ["requests"],
"env_vars": {"A": "1", "B": "2"},
}
# Failure case
parent = {"pip": ["requests"], "env_vars": {"A": "1"}}
child = {"pip": ["colors"], "env_vars": {"B": "2"}}
assert _merge_runtime_env(parent, child) is None
# Failure case (env_vars)
parent = {"pip": ["requests"], "env_vars": {"A": "1"}}
child = {"conda": ["requests"], "env_vars": {"A": "2"}}
assert _merge_runtime_env(parent, child) is None
# override = True
parent = {"pip": ["requests"], "env_vars": {"A": "1"}}
child = {"pip": ["colors"], "env_vars": {"B": "2"}}
assert _merge_runtime_env(parent, child, override=True) == {
"pip": ["colors"],
"env_vars": {"A": "1", "B": "2"},
}
# override = True + env vars
parent = {"pip": ["requests"], "env_vars": {"A": "1"}}
child = {"pip": ["colors"], "conda": ["requests"], "env_vars": {"A": "2"}}
assert _merge_runtime_env(parent, child, override=True) == {
"pip": ["colors"],
"env_vars": {"A": "2"},
"conda": ["requests"],
}
def test_current_py_version_supported():
"""Test that the running python version is supported.
This is run as a check in the Ray `runtime_env` `conda` code
before downloading the Ray wheel into the conda environment.
If Ray wheels are not available for this python version, then
the `conda` environment installation will fail.
When a new python version is added to the Ray wheels, please update
`ray_constants.RUNTIME_ENV_CONDA_PY_VERSIONS`. In a subsequent commit,
once wheels have been built for the new python version, please update
the tests test_get_wheel_filename, test_get_master_wheel_url, and
(after the first Ray release with the new python version)
test_get_release_wheel_url.
"""
py_version = sys.version_info[:2]
assert py_version in ray_constants.RUNTIME_ENV_CONDA_PY_VERSIONS
def test_compatible_with_dataclasses():
"""Test that the output of RuntimeEnv.to_dict() can be used as a dataclass field."""
config = RuntimeEnvConfig(setup_timeout_seconds=1)
runtime_env = RuntimeEnv(
pip={
"packages": ["tensorflow", "requests"],
"pip_check": False,
"pip_version": "==23.3.2;python_version=='3.9.16'",
},
env_vars={"FOO": "BAR"},
config=config,
)
@dataclass
class RuntimeEnvDataClass:
runtime_env: Dict[str, Any]
dataclasses.asdict(RuntimeEnvDataClass(runtime_env.to_dict()))
@dataclass
class RuntimeEnvConfigDataClass:
config: Dict[str, Any]
dataclasses.asdict(RuntimeEnvConfigDataClass(config.to_dict()))
@pytest.mark.parametrize("runtime_env_class", [dict, RuntimeEnv])
def test_container_option_serialize(runtime_env_class):
runtime_env = runtime_env_class(
container={"image": "ray:latest", "run_options": ["--name=test"]}
)
job_config = ray.job_config.JobConfig(runtime_env=runtime_env)
job_config_serialized = job_config._serialize()
# job_config_serialized is JobConfig protobuf serialized string,
# job_config.runtime_env_info.serialized_runtime_env
# has container_option info
assert job_config_serialized.count(b"ray:latest") == 1
assert job_config_serialized.count(b"--name=test") == 1
class TestURICache:
def test_zero_cache_size(self):
uris_to_sizes = {"5": 5, "3": 3}
def delete_fn(uri, logger):
return uris_to_sizes[uri]
cache = URICache(delete_fn, max_total_size_bytes=0, debug_mode=True)
cache.add("5", 5)
assert cache.get_total_size_bytes() == 5
cache.mark_unused("5")
assert cache.get_total_size_bytes() == 0
cache.add("3", 3)
cache.add("5", 5)
assert cache.get_total_size_bytes() == 8
cache.mark_unused("3")
cache.mark_unused("5")
assert cache.get_total_size_bytes() == 0
def test_nonzero_cache_size(self):
uris_to_sizes = {"a": 4, "b": 4, "c": 4}
def delete_fn(uri, logger):
return uris_to_sizes[uri]
cache = URICache(delete_fn, max_total_size_bytes=10, debug_mode=True)
cache.add("a", 4)
cache.add("b", 4)
cache.mark_unused("a")
assert "a" in cache
cache.add("c", 4)
# Now we have total size 12, which exceeds the max size 10.
assert cache.get_total_size_bytes() == 8
# "a" was the only unused URI, so it must have been deleted.
assert "b" and "c" in cache and "a" not in cache
def test_mark_used_nonadded_uri_error(self):
cache = URICache(debug_mode=True)
with pytest.raises(ValueError):
cache.mark_used("nonadded_uri")
def test_mark_used(self):
uris_to_sizes = {"a": 3, "b": 3, "big": 300}
def delete_fn(uri, logger):
return uris_to_sizes[uri]
cache = URICache(delete_fn, max_total_size_bytes=10, debug_mode=True)
cache.add("a", 3)
cache.add("b", 3)
cache.mark_unused("a")
cache.mark_unused("b")
assert "a" in cache and "b" in cache
assert cache.get_total_size_bytes() == 6
cache.mark_used("a")
cache.add("big", 300)
# We are over capacity and the only unused URI is "b", so we delete it
assert "a" in cache and "big" in cache and "b" not in cache
assert cache.get_total_size_bytes() == 303
cache.mark_unused("big")
assert "big" not in cache
assert cache.get_total_size_bytes() == 3
def test_many_URIs(self):
uris_to_sizes = {str(i): i for i in range(1000)}
def delete_fn(uri, logger):
return uris_to_sizes[uri]
cache = URICache(delete_fn, debug_mode=True)
for i in range(1000):
cache.add(str(i), i)
for i in range(1000):
cache.mark_unused(str(i))
for i in range(1000):
assert str(i) in cache
def test_delete_fn_called(self):
num_delete_fn_calls = 0
uris_to_sizes = {"a": 8, "b": 6, "c": 4, "d": 20}
def delete_fn(uri, logger):
nonlocal num_delete_fn_calls
num_delete_fn_calls += 1
return uris_to_sizes[uri]
cache = URICache(delete_fn, max_total_size_bytes=10, debug_mode=True)
cache.add("a", 8)
cache.add("b", 6)
cache.mark_unused("b")
# Total size is 14 > 10, so we need to delete "b".
assert num_delete_fn_calls == 1
cache.add("c", 4)
cache.mark_unused("c")
# Total size is 12 > 10, so we delete "c".
assert num_delete_fn_calls == 2
cache.mark_unused("a")
# Total size is 8 <= 10, so we shouldn't delete anything.
assert num_delete_fn_calls == 2
cache.add("d", 20)
# Total size is 28 > 10, so we delete "a".
assert num_delete_fn_calls == 3
cache.mark_unused("d")
# Total size is 20 > 10, so we delete "d".
assert num_delete_fn_calls == 4
@pytest.fixture
def enable_dev_mode(local_env_var_enabled, monkeypatch):
enabled = "1" if local_env_var_enabled else "0"
monkeypatch.setenv("RAY_RUNTIME_ENV_LOG_TO_DRIVER_ENABLED", enabled)
yield
def test_subprocess_error():
ex = SubprocessCalledProcessError
with pytest.raises(subprocess.SubprocessError) as e:
raise ex(123, "abc")
assert "test_out" not in str(e.value)
assert "test_err" not in str(e.value)
with pytest.raises(subprocess.SubprocessError) as e:
raise ex(123, "abc", stderr="test_err")
assert "test_out" not in str(e.value)
assert "test_err" in str(e.value)
with pytest.raises(subprocess.SubprocessError) as e:
raise ex(123, "abc", output="test_out")
assert "test_out" in str(e.value)
assert "test_err" not in str(e.value)
with pytest.raises(subprocess.SubprocessError) as e:
raise ex(123, "abc", output="test_out", stderr="test_err")
assert "test_out" in str(e.value)
assert "test_err" in str(e.value)
def test_subprocess_error_with_last_n_lines():
stdout = "1\n2\n3\n4\n5\n"
stderr = "5\n4\n3\n2\n1\n"
exception = SubprocessCalledProcessError(888, "abc", output=stdout, stderr=stderr)
exception.LAST_N_LINES = 3
exception_str = str(exception)
assert "cmd" not in exception_str
assert "Last 3 lines" in exception_str
s = "".join([s.strip() for s in exception_str.splitlines()])
assert "345" in s
assert "321" in s
@pytest.mark.asyncio
async def test_check_output_cmd():
cmd = "dir" if sys.platform.startswith("win") else "pwd"
logs = []
class _FakeLogger:
def __getattr__(self, item):
def _log(formatter, *args):
logs.append(formatter % args)
return _log
for _ in range(2):
output = await check_output_cmd([cmd], logger=_FakeLogger())
assert len(output) > 0
all_log_string = "\n".join(logs)
# Check the cmd index generator works.
assert "cmd[1]" in all_log_string
assert "cmd[2]" in all_log_string
# Test communicate fails.
with mock.patch(
"asyncio.subprocess.Process.communicate",
side_effect=Exception("fake exception"),
):
with pytest.raises(RuntimeError) as e:
await check_output_cmd([cmd], logger=_FakeLogger())
# Make sure the exception has cmd trace info.
assert "cmd[3]" in str(e.value)
# Test asyncio.create_subprocess_exec fails.
with pytest.raises(RuntimeError) as e:
await check_output_cmd(["not_exist_cmd"], logger=_FakeLogger())
# Make sure the exception has cmd trace info.
assert "cmd[4]" in str(e.value)
# Test returncode != 0.
with pytest.raises(SubprocessCalledProcessError) as e:
await check_output_cmd([cmd, "--abc"], logger=_FakeLogger())
# Make sure the exception has cmd trace info.
assert "cmd[5]" in str(e.value)
@pytest.mark.parametrize(
"option",
["pip_list", "pip_dict", "conda_name", "conda_dict", "container"],
)
def test_serialize_deserialize(option):
runtime_env = dict()
if option == "pip_list":
runtime_env["pip"] = ["pkg1", "pkg2"]
elif option == "pip_dict":
runtime_env["pip"] = {
"packages": ["pkg1", "pkg2"],
"pip_check": False,
"pip_version": "<22,>20",
}
elif option == "conda_name":
runtime_env["conda"] = "env_name"
elif option == "conda_dict":
runtime_env["conda"] = {"dependencies": ["dep1", "dep2"]}
elif option == "container":
runtime_env["container"] = {
"image": "anyscale/ray-ml:nightly-py38-cpu",
"worker_path": "/root/python/ray/_private/workers/default_worker.py",
"run_options": ["--cap-drop SYS_ADMIN", "--log-level=debug"],
}
else:
raise ValueError("unexpected option " + str(option))
typed_runtime_env = RuntimeEnv(**runtime_env)
serialized_runtime_env = typed_runtime_env.serialize()
cls_runtime_env = RuntimeEnv.deserialize(serialized_runtime_env)
cls_runtime_env_dict = cls_runtime_env.to_dict()
if "pip" in typed_runtime_env and isinstance(typed_runtime_env["pip"], list):
pip_config_in_cls_runtime_env = cls_runtime_env_dict.pop("pip")
pip_config_in_runtime_env = typed_runtime_env.pop("pip")
assert {
"packages": pip_config_in_runtime_env,
"pip_check": False,
} == pip_config_in_cls_runtime_env
assert cls_runtime_env_dict == typed_runtime_env
def test_runtime_env_interface():
# Test the interface related to working_dir
default_working_dir = "s3://bucket/key.zip"
modify_working_dir = "s3://bucket/key_A.zip"
runtime_env = RuntimeEnv(working_dir=default_working_dir)
runtime_env_dict = runtime_env.to_dict()
assert runtime_env.working_dir_uri() == default_working_dir
runtime_env["working_dir"] = modify_working_dir
runtime_env_dict["working_dir"] = modify_working_dir
assert runtime_env.working_dir_uri() == modify_working_dir
assert runtime_env.to_dict() == runtime_env_dict
runtime_env.pop("working_dir")
assert runtime_env.to_dict() == {}
# Test the interface related to py_modules
init_py_modules = ["s3://bucket/key_1.zip", "s3://bucket/key_2.zip"]
addition_py_modules = ["s3://bucket/key_3.zip", "s3://bucket/key_4.zip"]
runtime_env = RuntimeEnv(py_modules=init_py_modules)
runtime_env_dict = runtime_env.to_dict()
assert set(runtime_env.py_modules_uris()) == set(init_py_modules)
runtime_env["py_modules"].extend(addition_py_modules)
runtime_env_dict["py_modules"].extend(addition_py_modules)
assert set(runtime_env.py_modules_uris()) == set(
init_py_modules + addition_py_modules
)
assert runtime_env.to_dict() == runtime_env_dict
runtime_env.pop("py_modules")
assert runtime_env.to_dict() == {}
# Test the interface related to env_vars
init_env_vars = {"A": "a", "B": "b"}
update_env_vars = {"C": "c"}
runtime_env = RuntimeEnv(env_vars=init_env_vars)
runtime_env_dict = runtime_env.to_dict()
runtime_env["env_vars"].update(update_env_vars)
runtime_env_dict["env_vars"].update(update_env_vars)
init_env_vars_copy = init_env_vars.copy()
init_env_vars_copy.update(update_env_vars)
assert runtime_env["env_vars"] == init_env_vars_copy
assert runtime_env_dict == runtime_env.to_dict()
runtime_env.pop("env_vars")
assert runtime_env.to_dict() == {}
# Test the interface related to conda
conda_name = "conda"
modify_conda_name = "conda_A"
conda_config = {"dependencies": ["dep1", "dep2"]}
runtime_env = RuntimeEnv(conda=conda_name)
runtime_env_dict = runtime_env.to_dict()
assert runtime_env.has_conda()
assert runtime_env.conda_env_name() == conda_name
assert runtime_env.conda_config() is None
runtime_env["conda"] = modify_conda_name
runtime_env_dict["conda"] = modify_conda_name
assert runtime_env_dict == runtime_env.to_dict()
assert runtime_env.has_conda()
assert runtime_env.conda_env_name() == modify_conda_name
assert runtime_env.conda_config() is None
runtime_env["conda"] = conda_config
runtime_env_dict["conda"] = conda_config
assert runtime_env_dict == runtime_env.to_dict()
assert runtime_env.has_conda()
assert runtime_env.conda_env_name() is None
assert runtime_env.conda_config() == json.dumps(conda_config, sort_keys=True)
runtime_env.pop("conda")
assert runtime_env.to_dict() == {"_ray_commit": "{{RAY_COMMIT_SHA}}"}
# Test the interface related to pip
with tempfile.TemporaryDirectory() as tmpdir, chdir(tmpdir):
requirement_file = os.path.join(tmpdir, "requirements.txt")
requirement_packages = ["dep5", "dep6"]
with open(requirement_file, "wt") as f:
for package in requirement_packages:
f.write(package)
f.write("\n")
pip_packages = ["dep1", "dep2"]
addition_pip_packages = ["dep3", "dep4"]
runtime_env = RuntimeEnv(pip=pip_packages)
runtime_env_dict = runtime_env.to_dict()
assert runtime_env.has_pip()
assert set(runtime_env.pip_config()["packages"]) == set(pip_packages)
assert runtime_env.virtualenv_name() is None
runtime_env["pip"]["packages"].extend(addition_pip_packages)
runtime_env_dict["pip"]["packages"].extend(addition_pip_packages)
# The default value of pip_check is False
runtime_env_dict["pip"]["pip_check"] = False
assert runtime_env_dict == runtime_env.to_dict()
assert runtime_env.has_pip()
assert set(runtime_env.pip_config()["packages"]) == set(
pip_packages + addition_pip_packages
)
assert runtime_env.virtualenv_name() is None
runtime_env["pip"] = requirement_file
runtime_env_dict["pip"] = requirement_packages
assert runtime_env.has_pip()
assert set(runtime_env.pip_config()["packages"]) == set(requirement_packages)
assert runtime_env.virtualenv_name() is None
# The default value of pip_check is False
runtime_env_dict["pip"] = dict(
packages=runtime_env_dict["pip"], pip_check=False
)
assert runtime_env_dict == runtime_env.to_dict()
runtime_env.pop("pip")
assert runtime_env.to_dict() == {"_ray_commit": "{{RAY_COMMIT_SHA}}"}
# Test conflict
with pytest.raises(ValueError):
RuntimeEnv(pip=pip_packages, conda=conda_name)
runtime_env = RuntimeEnv(pip=pip_packages)
runtime_env["conda"] = conda_name
with pytest.raises(ValueError):
runtime_env.serialize()
# Test the interface related to container
container_init = {
"image": "anyscale/ray-ml:nightly-py38-cpu",
"run_options": ["--cap-drop SYS_ADMIN", "--log-level=debug"],
}
update_container = {"image": "test_modify"}
runtime_env = RuntimeEnv(container=container_init)
runtime_env_dict = runtime_env.to_dict()
assert runtime_env.has_py_container()
assert runtime_env.py_container_image() == container_init["image"]
assert runtime_env.py_container_run_options() == container_init["run_options"]
runtime_env["container"].update(update_container)
runtime_env_dict["container"].update(update_container)
container_copy = container_init
container_copy.update(update_container)
assert runtime_env_dict == runtime_env.to_dict()
assert runtime_env.has_py_container()
assert runtime_env.py_container_image() == container_copy["image"]
assert runtime_env.py_container_run_options() == container_copy["run_options"]
runtime_env.pop("container")
assert runtime_env.to_dict() == {}
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,45 @@
import sys
from unittest.mock import patch
import pytest
from ray._private.runtime_env import uv
class TestRuntimeEnv:
def uv_config(self):
return {"packages": ["requests"]}
def env_vars(self):
return {}
@pytest.fixture
def mock_install_uv():
with patch(
"ray._private.runtime_env.uv.UvProcessor._install_uv"
) as mock_install_uv:
mock_install_uv.return_value = None
yield mock_install_uv
@pytest.fixture
def mock_install_uv_packages():
with patch(
"ray._private.runtime_env.uv.UvProcessor._install_uv_packages"
) as mock_install_uv_packages:
mock_install_uv_packages.return_value = None
yield mock_install_uv_packages
@pytest.mark.asyncio
async def test_run(mock_install_uv, mock_install_uv_packages):
target_dir = "/tmp"
runtime_env = TestRuntimeEnv()
uv_processor = uv.UvProcessor(target_dir=target_dir, runtime_env=runtime_env)
await uv_processor._run()
if __name__ == "__main__":
sys.exit(pytest.main(["-vv", __file__]))
@@ -0,0 +1,804 @@
import os
import sys
import tempfile
from pathlib import Path
import jsonschema
import pytest
import yaml
from ray import job_config
from ray._private.runtime_env import validation
from ray._private.runtime_env.pip import _get_pip_hash
from ray._private.runtime_env.plugin_schema_manager import RuntimeEnvPluginSchemaManager
from ray._private.runtime_env.validation import (
parse_and_validate_conda,
parse_and_validate_excludes,
parse_and_validate_py_modules,
parse_and_validate_working_dir,
)
from ray.runtime_env import RuntimeEnv
from ray.runtime_env.runtime_env import (
_validate_no_local_paths,
)
_CONDA_DICT = {"dependencies": ["pip", {"pip": ["pip-install-test==0.5"]}]}
_PIP_LIST = ["requests==1.0.0", "pip-install-test"]
@pytest.fixture
def test_directory():
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
subdir = path / "subdir"
subdir.mkdir(parents=True)
requirements_file = subdir / "requirements.txt"
with requirements_file.open(mode="w") as f:
print("\n".join(_PIP_LIST), file=f)
good_conda_file = subdir / "good_conda_env.yaml"
with good_conda_file.open(mode="w") as f:
yaml.dump(_CONDA_DICT, f)
bad_conda_file = subdir / "bad_conda_env.yaml"
with bad_conda_file.open(mode="w") as f:
print("% this is not a YAML file %", file=f)
old_dir = os.getcwd()
os.chdir(tmp_dir)
yield subdir, requirements_file, good_conda_file, bad_conda_file
os.chdir(old_dir)
@pytest.fixture
def set_runtime_env_plugin_schemas(request):
runtime_env_plugin_schemas = getattr(request, "param", "0")
try:
os.environ["RAY_RUNTIME_ENV_PLUGIN_SCHEMAS"] = runtime_env_plugin_schemas
# Clear and reload schemas.
RuntimeEnvPluginSchemaManager.clear()
yield runtime_env_plugin_schemas
finally:
del os.environ["RAY_RUNTIME_ENV_PLUGIN_SCHEMAS"]
def test_key_with_value_none():
parsed_runtime_env = RuntimeEnv(pip=None)
assert parsed_runtime_env == {}
class TestValidateWorkingDir:
def test_validate_bad_path(self):
with pytest.raises(ValueError, match="a valid path"):
parse_and_validate_working_dir("/does/not/exist")
def test_validate_bad_uri(self):
with pytest.raises(ValueError, match="a valid URI"):
parse_and_validate_working_dir("unknown://abc")
def test_validate_invalid_type(self):
with pytest.raises(TypeError):
parse_and_validate_working_dir(1)
def test_validate_remote_invalid_extensions(self):
for uri in [
"https://some_domain.com/path/file",
"s3://bucket/file",
"gs://bucket/file",
]:
with pytest.raises(
ValueError,
match=r"Only \.zip, \.whl, \.tar\.gz, and \.tgz files supported for remote URIs\.",
):
parse_and_validate_working_dir(uri)
def test_validate_remote_valid_input(self):
for uri in [
"https://some_domain.com/path/file.zip",
"s3://bucket/file.zip",
"gs://bucket/file.zip",
"https://some_domain.com/path/file.tar.gz",
"s3://bucket/file.tar.gz",
"gs://bucket/file.tgz",
]:
working_dir = parse_and_validate_working_dir(uri)
assert working_dir == uri
def test_validate_path_valid_input(self, test_directory):
test_dir, _, _, _ = test_directory
valid_working_dir_path = str(test_dir)
working_dir = parse_and_validate_working_dir(str(valid_working_dir_path))
assert working_dir == valid_working_dir_path
class TestValidatePyModules:
def test_validate_not_a_list(self):
with pytest.raises(TypeError, match="must be a list of strings"):
parse_and_validate_py_modules(".")
def test_validate_bad_path(self):
with pytest.raises(ValueError, match="a valid path"):
parse_and_validate_py_modules(["/does/not/exist"])
def test_validate_bad_uri(self):
with pytest.raises(ValueError, match="a valid URI"):
parse_and_validate_py_modules(["unknown://abc"])
def test_validate_invalid_type(self):
with pytest.raises(TypeError):
parse_and_validate_py_modules([1])
def test_validate_remote_invalid_extension(self):
uris = [
"https://some_domain.com/path/file",
"s3://bucket/file",
"gs://bucket/file",
]
with pytest.raises(
ValueError,
match="Only .zip, .whl, .tar.gz, and .tgz files supported for remote URIs.",
):
parse_and_validate_py_modules(uris)
def test_validate_remote_valid_input(self):
uris = [
"https://some_domain.com/path/file.zip",
"s3://bucket/file.zip",
"gs://bucket/file.zip",
"https://some_domain.com/path/file.whl",
"s3://bucket/file.whl",
"gs://bucket/file.whl",
"https://some_domain.com/path/file.tar.gz",
"s3://bucket/file.tar.gz",
"gs://bucket/file.tgz",
]
py_modules = parse_and_validate_py_modules(uris)
assert py_modules == uris
def test_validate_path_valid_input(self, test_directory):
test_dir, _, _, _ = test_directory
paths = [str(test_dir)]
py_modules = parse_and_validate_py_modules(paths)
assert py_modules == paths
def test_validate_path_and_uri_valid_input(self, test_directory):
test_dir, _, _, _ = test_directory
uris_and_paths = [
str(test_dir),
"https://some_domain.com/path/file.zip",
"s3://bucket/file.zip",
"gs://bucket/file.zip",
"https://some_domain.com/path/file.whl",
"s3://bucket/file.whl",
"gs://bucket/file.whl",
]
py_modules = parse_and_validate_py_modules(uris_and_paths)
assert py_modules == uris_and_paths
class TestValidateExcludes:
def test_validate_excludes_invalid_types(self):
with pytest.raises(TypeError):
parse_and_validate_excludes(1)
with pytest.raises(TypeError):
parse_and_validate_excludes(True)
with pytest.raises(TypeError):
parse_and_validate_excludes("string")
with pytest.raises(TypeError):
parse_and_validate_excludes(["string", 1])
def test_validate_excludes_empty_list(self):
assert RuntimeEnv(excludes=[]) == {}
class TestValidateConda:
def test_validate_conda_invalid_types(self):
with pytest.raises(TypeError):
parse_and_validate_conda(1)
with pytest.raises(TypeError):
parse_and_validate_conda(True)
def test_validate_conda_str(self):
assert parse_and_validate_conda("my_env_name") == "my_env_name"
def test_validate_conda_invalid_path(self):
with pytest.raises(ValueError):
parse_and_validate_conda("../bad_path.yaml")
@pytest.mark.parametrize("absolute_path", [True, False])
def test_validate_conda_valid_file(self, test_directory, absolute_path):
_, _, good_conda_file, _ = test_directory
if absolute_path:
good_conda_file = good_conda_file.resolve()
assert parse_and_validate_conda(str(good_conda_file)) == _CONDA_DICT
@pytest.mark.parametrize("absolute_path", [True, False])
def test_validate_conda_invalid_file(self, test_directory, absolute_path):
_, _, _, bad_conda_file = test_directory
if absolute_path:
bad_conda_file = bad_conda_file.resolve()
with pytest.raises(ValueError):
parse_and_validate_conda(str(bad_conda_file))
def test_validate_conda_valid_dict(self):
assert parse_and_validate_conda(_CONDA_DICT) == _CONDA_DICT
class TestParsedRuntimeEnv:
def test_empty(self):
assert RuntimeEnv() == {}
def test_serialization(self):
env1 = RuntimeEnv(pip=["requests"], env_vars={"hi1": "hi1", "hi2": "hi2"})
env2 = RuntimeEnv(env_vars={"hi2": "hi2", "hi1": "hi1"}, pip=["requests"])
assert env1 == env2
serialized_env1 = env1.serialize()
serialized_env2 = env2.serialize()
# Key ordering shouldn't matter.
assert serialized_env1 == serialized_env2
deserialized_env1 = RuntimeEnv.deserialize(serialized_env1)
deserialized_env2 = RuntimeEnv.deserialize(serialized_env2)
assert env1 == deserialized_env1 == env2 == deserialized_env2
def test_reject_pip_and_conda(self):
with pytest.raises(ValueError):
RuntimeEnv(pip=["requests"], conda="env_name")
def test_ray_commit_injection(self):
# Should not be injected if no pip and conda.
result = RuntimeEnv(env_vars={"hi": "hi"})
assert "_ray_commit" not in result
# Should be injected if pip or conda present.
result = RuntimeEnv(pip=["requests"])
assert "_ray_commit" in result
result = RuntimeEnv(conda="env_name")
assert "_ray_commit" in result
# Should not override if passed.
result = RuntimeEnv(conda="env_name", _ray_commit="Blah")
assert result["_ray_commit"] == "Blah"
def test_inject_current_ray(self):
# Should not be injected if not provided by env var.
result = RuntimeEnv(env_vars={"hi": "hi"})
assert "_inject_current_ray" not in result
os.environ["RAY_RUNTIME_ENV_LOCAL_DEV_MODE"] = "1"
# Should be injected if provided by env var.
result = RuntimeEnv()
assert result["_inject_current_ray"]
# Should be preserved if passed.
result = RuntimeEnv(_inject_current_ray=False)
assert not result["_inject_current_ray"]
del os.environ["RAY_RUNTIME_ENV_LOCAL_DEV_MODE"]
class TestParseJobConfig:
def test_parse_runtime_env_from_json_env_variable(self):
job_config_json = {"runtime_env": {"working_dir": "uri://abc"}}
config = job_config.JobConfig.from_json(job_config_json)
assert config.runtime_env == job_config_json.get("runtime_env")
assert config.metadata == {}
schemas_dir = os.path.dirname(__file__)
test_env_1 = os.path.join(
os.path.dirname(__file__), "test_runtime_env_validation_1_schema.json"
)
test_env_2 = os.path.join(
os.path.dirname(__file__), "test_runtime_env_validation_2_schema.json"
)
test_env_invalid_path = os.path.join(
os.path.dirname(__file__), "test_runtime_env_validation_non_existent.json"
)
test_env_bad_json = os.path.join(
os.path.dirname(__file__), "test_runtime_env_validation_bad_schema.json"
)
@pytest.mark.parametrize(
"set_runtime_env_plugin_schemas",
[
schemas_dir,
f"{test_env_1},{test_env_2}",
# Test with an invalid JSON file first in the list
f"{test_env_bad_json},{test_env_1},{test_env_2}",
# Test with a non-existent JSON file
f"{test_env_invalid_path},{test_env_1},{test_env_2}",
],
indirect=True,
)
@pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.")
class TestValidateByJsonSchema:
def test_validate_pip(self, set_runtime_env_plugin_schemas):
runtime_env = RuntimeEnv()
runtime_env.set("pip", {"packages": ["requests"], "pip_check": True})
with pytest.raises(jsonschema.exceptions.ValidationError, match="pip_check"):
runtime_env.set("pip", {"packages": ["requests"], "pip_check": "1"})
runtime_env["pip"] = {"packages": ["requests"], "pip_check": True}
with pytest.raises(jsonschema.exceptions.ValidationError, match="pip_check"):
runtime_env["pip"] = {"packages": ["requests"], "pip_check": "1"}
def test_validate_working_dir(self, set_runtime_env_plugin_schemas):
runtime_env = RuntimeEnv()
runtime_env.set("working_dir", "https://abc/file.zip")
with pytest.raises(jsonschema.exceptions.ValidationError, match="working_dir"):
runtime_env.set("working_dir", ["https://abc/file.zip"])
runtime_env["working_dir"] = "https://abc/file.zip"
with pytest.raises(jsonschema.exceptions.ValidationError, match="working_dir"):
runtime_env["working_dir"] = ["https://abc/file.zip"]
def test_validate_test_env_1(self, set_runtime_env_plugin_schemas):
runtime_env = RuntimeEnv()
runtime_env.set("test_env_1", {"array": ["123"], "bool": True})
with pytest.raises(jsonschema.exceptions.ValidationError, match="bool"):
runtime_env.set("test_env_1", {"array": ["123"], "bool": "1"})
def test_validate_test_env_2(self, set_runtime_env_plugin_schemas):
runtime_env = RuntimeEnv()
runtime_env.set("test_env_2", "123")
with pytest.raises(jsonschema.exceptions.ValidationError, match="test_env_2"):
runtime_env.set("test_env_2", ["123"])
@pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.")
class TestRuntimeEnvPluginSchemaManager:
def test(self):
RuntimeEnvPluginSchemaManager.clear()
# No schemas when starts.
assert len(RuntimeEnvPluginSchemaManager.schemas) == 0
# When the `validate` is used first time, the schemas will be loaded lazily.
# The validation of pip is enabled.
with pytest.raises(jsonschema.exceptions.ValidationError, match="pip_check"):
RuntimeEnvPluginSchemaManager.validate(
"pip", {"packages": ["requests"], "pip_check": "123"}
)
# The validation of test_env_1 is disabled because we haven't set the env var.
RuntimeEnvPluginSchemaManager.validate(
"test_env_1", {"array": ["123"], "bool": "123"}
)
assert len(RuntimeEnvPluginSchemaManager.schemas) != 0
# Set the thirdparty schemas.
os.environ["RAY_RUNTIME_ENV_PLUGIN_SCHEMAS"] = schemas_dir
# clear the loaded schemas to make sure the schemas chould be reloaded next
# time.
RuntimeEnvPluginSchemaManager.clear()
assert len(RuntimeEnvPluginSchemaManager.schemas) == 0
# The validation of test_env_1 is enabled.
with pytest.raises(jsonschema.exceptions.ValidationError, match="bool"):
RuntimeEnvPluginSchemaManager.validate(
"test_env_1", {"array": ["123"], "bool": "123"}
)
class TestValidateUV:
def test_parse_and_validate_uv(self, test_directory):
# Valid case w/o duplication.
result = validation.parse_and_validate_uv({"packages": ["tensorflow"]})
assert result == {
"packages": ["tensorflow"],
"uv_check": False,
"uv_pip_install_options": ["--no-cache"],
}
# Valid case w/ duplication.
result = validation.parse_and_validate_uv(
{"packages": ["tensorflow", "tensorflow"]}
)
assert result == {
"packages": ["tensorflow"],
"uv_check": False,
"uv_pip_install_options": ["--no-cache"],
}
# Valid case, use `list` to represent necessary packages.
result = validation.parse_and_validate_uv(
["requests==1.0.0", "aiohttp", "ray[serve]"]
)
assert result == {
"packages": ["requests==1.0.0", "aiohttp", "ray[serve]"],
"uv_check": False,
}
# Invalid case, unsupport keys.
with pytest.raises(ValueError):
result = validation.parse_and_validate_uv({"random_key": "random_value"})
# Valid case w/ uv version.
result = validation.parse_and_validate_uv(
{"packages": ["tensorflow"], "uv_version": "==0.4.30"}
)
assert result == {
"packages": ["tensorflow"],
"uv_version": "==0.4.30",
"uv_check": False,
"uv_pip_install_options": ["--no-cache"],
}
# Valid requirement files.
_, requirements_file, _, _ = test_directory
requirements_file = requirements_file.resolve()
result = validation.parse_and_validate_uv(str(requirements_file))
assert result == {
"packages": ["requests==1.0.0", "pip-install-test"],
"uv_check": False,
}
# Invalid requiremnt files.
with pytest.raises(ValueError):
result = validation.parse_and_validate_uv("some random non-existent file")
# Invalid uv install options.
with pytest.raises(TypeError):
result = validation.parse_and_validate_uv(
{
"packages": ["tensorflow"],
"uv_version": "==0.4.30",
"uv_pip_install_options": [1],
}
)
# Valid uv install options.
result = validation.parse_and_validate_uv(
{
"packages": ["tensorflow"],
"uv_version": "==0.4.30",
"uv_pip_install_options": ["--no-cache"],
}
)
assert result == {
"packages": ["tensorflow"],
"uv_check": False,
"uv_pip_install_options": ["--no-cache"],
"uv_version": "==0.4.30",
}
class TestValidatePip:
def test_validate_pip_invalid_types(self):
with pytest.raises(TypeError):
validation.parse_and_validate_pip(1)
with pytest.raises(TypeError):
validation.parse_and_validate_pip(True)
def test_validate_pip_invalid_path(self):
with pytest.raises(ValueError):
validation.parse_and_validate_pip("../bad_path.txt")
@pytest.mark.parametrize("absolute_path", [True, False])
def test_validate_pip_valid_file(self, test_directory, absolute_path):
_, requirements_file, _, _ = test_directory
if absolute_path:
requirements_file = requirements_file.resolve()
result = validation.parse_and_validate_pip(str(requirements_file))
assert result["packages"] == _PIP_LIST
assert not result["pip_check"]
assert "pip_version" not in result
def test_validate_pip_valid_list(self):
result = validation.parse_and_validate_pip(_PIP_LIST)
assert result["packages"] == _PIP_LIST
assert not result["pip_check"]
assert "pip_version" not in result
def test_validate_ray(self):
result = validation.parse_and_validate_pip(["pkg1", "ray", "pkg2"])
assert result["packages"] == ["pkg1", "ray", "pkg2"]
assert not result["pip_check"]
assert "pip_version" not in result
def test_validate_pip_install_options(self):
# Happy path for non-empty pip_install_options
opts = ["--no-cache-dir", "--no-build-isolation", "--disable-pip-version-check"]
result = validation.parse_and_validate_pip(
{
"packages": ["pkg1", "ray", "pkg2"],
"pip_install_options": list(opts),
}
)
assert result["packages"] == ["pkg1", "ray", "pkg2"]
assert not result["pip_check"]
assert "pip_version" not in result
assert result["pip_install_options"] == opts
# Happy path for missing pip_install_options. No default value for field
# to maintain backwards compatibility with ray==2.0.1
result = validation.parse_and_validate_pip(
{
"packages": ["pkg1", "ray", "pkg2"],
}
)
assert "pip_install_options" not in result
with pytest.raises(TypeError) as e:
validation.parse_and_validate_pip(
{
"packages": ["pkg1", "ray", "pkg2"],
"pip_install_options": [False],
}
)
assert "pip_install_options" in str(e) and "must be of type list[str]" in str(e)
with pytest.raises(TypeError) as e:
validation.parse_and_validate_pip(
{
"packages": ["pkg1", "ray", "pkg2"],
"pip_install_options": None,
}
)
assert "pip_install_options" in str(e) and "must be of type list[str]" in str(e)
class TestGetPipHash:
def test_pip_hash_with_requirements_file(self, test_directory):
_, requirements_file, _, _ = test_directory
req_path = str(requirements_file)
pip_dict1 = {"packages": [f"-r {req_path}"]}
hash1 = _get_pip_hash(pip_dict1)
pip_dict2 = {"packages": ["requests==1.0.0", "pip-install-test"]}
hash2 = _get_pip_hash(pip_dict2)
assert hash1 == hash2
def test_pip_hash_changes_with_file_content(self, test_directory):
_, requirements_file, _, _ = test_directory
req_path = str(requirements_file)
pip_dict = {"packages": [f"-r {req_path}"]}
with open(req_path, "w") as f:
f.write("numpy==1.21.0\n")
hash1 = _get_pip_hash(pip_dict)
with open(req_path, "w") as f:
f.write("numpy==1.22.0\n")
hash2 = _get_pip_hash(pip_dict)
assert hash1 != hash2
def test_pip_hash_with_comments_and_empty_lines(self, test_directory):
_, requirements_file, _, _ = test_directory
req_path = str(requirements_file)
with open(req_path, "w") as f:
f.write(
"# This is a comment\nnumpy==1.21.0\n\n# Another comment\npandas==1.3.0\n"
)
pip_dict = {"packages": [f"-r {req_path}"]}
hash1 = _get_pip_hash(pip_dict)
pip_dict2 = {"packages": ["numpy==1.21.0", "pandas==1.3.0"]}
hash2 = _get_pip_hash(pip_dict2)
assert hash1 == hash2
def test_pip_hash_without_r(self):
pip_dict = {"packages": ["numpy==1.21.0", "pandas==1.3.0"]}
hash1 = _get_pip_hash(pip_dict)
pip_dict2 = {"packages": ["numpy==1.21.0", "pandas==1.3.0"]}
hash2 = _get_pip_hash(pip_dict2)
assert hash1 == hash2
def test_pip_hash_different_packages(self):
pip_dict = {"packages": ["numpy==1.21.0"]}
hash1 = _get_pip_hash(pip_dict)
pip_dict2 = {"packages": ["pandas==1.3.0"]}
hash2 = _get_pip_hash(pip_dict2)
assert hash1 != hash2
def test_pip_hash_with_pip_install_options(self, test_directory):
_, requirements_file, _, _ = test_directory
req_path = str(requirements_file)
pip_dict = {
"packages": [f"-r {req_path}"],
"pip_install_options": ["--no-cache-dir"],
}
hash1 = _get_pip_hash(pip_dict)
pip_dict2 = {
"packages": [f"-r {req_path}"],
"pip_install_options": ["--disable-pip-version-check"],
}
hash2 = _get_pip_hash(pip_dict2)
assert hash1 != hash2
def test_pip_hash_with_circular_reference(self):
with tempfile.TemporaryDirectory() as tmpdir:
# Create file A
file_a = os.path.join(tmpdir, "a.txt")
with open(file_a, "w") as f:
f.write("-r b.txt\nnumpy==1.21.0\n")
# Create file B
file_b = os.path.join(tmpdir, "b.txt")
with open(file_b, "w") as f:
f.write("-r a.txt\npandas==1.3.0\n")
pip_dict = {"packages": [f"-r {file_a}"]}
hash_val = _get_pip_hash(pip_dict)
assert isinstance(hash_val, str)
assert len(hash_val) == 40
def test_pip_hash_with_self_reference(self):
with tempfile.TemporaryDirectory() as tmpdir:
# Create self-referencing file
self_file = os.path.join(tmpdir, "self.txt")
with open(self_file, "w") as f:
f.write("-r self.txt\nnumpy==1.21.0\n")
pip_dict = {"packages": [f"-r {self_file}"]}
hash_val = _get_pip_hash(pip_dict)
assert isinstance(hash_val, str)
assert len(hash_val) == 40
def test_pip_hash_with_nested_relative_paths(self):
with tempfile.TemporaryDirectory() as tmpdir:
# Create directory structure
reqs_dir = os.path.join(tmpdir, "reqs")
os.makedirs(reqs_dir)
# Create base.txt in reqs directory
base_file = os.path.join(reqs_dir, "base.txt")
with open(base_file, "w") as f:
f.write("-r extras.txt\nnumpy==1.21.0\n")
# Create extras.txt in the same directory (relative path)
extras_file = os.path.join(reqs_dir, "extras.txt")
with open(extras_file, "w") as f:
f.write("pandas==1.3.0\n")
# Test with absolute path to base.txt
pip_dict = {"packages": [f"-r {base_file}"]}
hash1 = _get_pip_hash(pip_dict)
# Test with relative path from tmpdir
original_cwd = os.getcwd()
try:
os.chdir(tmpdir)
relative_base = os.path.relpath(base_file, tmpdir)
pip_dict2 = {"packages": [f"-r {relative_base}"]}
hash2 = _get_pip_hash(pip_dict2)
# Hashes should be the same regardless of how we reference the file
assert hash1 == hash2
finally:
os.chdir(original_cwd)
assert isinstance(hash1, str)
assert len(hash1) == 40
def test_pip_hash_with_nested_circular_relative_paths(self):
with tempfile.TemporaryDirectory() as tmpdir:
# Create directory structure
reqs_dir = os.path.join(tmpdir, "reqs")
os.makedirs(reqs_dir)
# Create circular references with relative paths
a_file = os.path.join(reqs_dir, "a.txt")
with open(a_file, "w") as f:
f.write("-r b.txt\nnumpy==1.21.0\n")
b_file = os.path.join(reqs_dir, "b.txt")
with open(b_file, "w") as f:
f.write("-r a.txt\npandas==1.3.0\n")
# This should not cause an infinite loop
pip_dict = {"packages": [f"-r {a_file}"]}
hash_val = _get_pip_hash(pip_dict)
assert isinstance(hash_val, str)
assert len(hash_val) == 40
def test_pip_hash_with_long_form_requirement(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write("numpy==1.21.0\n")
temp_file = f.name
try:
# Test with long-form --requirement flag
pip_dict = {"packages": [f"--requirement {temp_file}"]}
hash_val = _get_pip_hash(pip_dict)
assert isinstance(hash_val, str)
assert len(hash_val) == 40
finally:
os.unlink(temp_file)
def test_pip_hash_with_long_form_requirement_equals(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write("numpy==1.21.0\n")
temp_file = f.name
try:
# Test with long-form --requirement=file.txt format
pip_dict = {"packages": [f"--requirement={temp_file}"]}
hash_val = _get_pip_hash(pip_dict)
assert isinstance(hash_val, str)
assert len(hash_val) == 40
finally:
os.unlink(temp_file)
def test_pip_hash_with_invalid_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
# Test with non-existent file - should raise FileNotFoundError
non_existent_file = os.path.join(tmpdir, "non_existent.txt")
pip_dict = {"packages": [f"-r {non_existent_file}"]}
with pytest.raises(FileNotFoundError):
_get_pip_hash(pip_dict)
class TestValidateEnvVars:
def test_type_validation(self):
# Only strings allowed.
with pytest.raises(TypeError, match=".*Dict[str, str]*"):
validation.parse_and_validate_env_vars({"INT_ENV": 1})
with pytest.raises(TypeError, match=".*Dict[str, str]*"):
validation.parse_and_validate_env_vars({1: "hi"})
with pytest.raises(TypeError, match=".*value 123 is of type <class 'int'>*"):
validation.parse_and_validate_env_vars({"hi": 123})
with pytest.raises(TypeError, match=".*value True is of type <class 'bool'>*"):
validation.parse_and_validate_env_vars({"hi": True})
with pytest.raises(TypeError, match=".*key 1.23 is of type <class 'float'>*"):
validation.parse_and_validate_env_vars({1.23: "hi"})
def test_validate_no_local_paths_raises_exceptions_on_type_mismatch():
with pytest.raises(TypeError):
_validate_no_local_paths(1)
with pytest.raises(TypeError):
_validate_no_local_paths({})
def test_validate_no_local_paths_fails_if_local_working_dir():
with tempfile.TemporaryDirectory() as tmp_dir:
path = Path(tmp_dir)
working_dir = path / "working_dir"
working_dir.mkdir(parents=True)
working_dir_str = str(working_dir)
runtime_env = RuntimeEnv(working_dir=working_dir_str)
with pytest.raises(ValueError, match="not a valid URI"):
_validate_no_local_paths(runtime_env)
def test_validate_no_local_paths_fails_if_local_py_module():
with tempfile.NamedTemporaryFile(suffix=".whl") as tmp_file:
runtime_env = RuntimeEnv(py_modules=[tmp_file.name, "gcs://some_other_file"])
with pytest.raises(ValueError, match="not a valid URI"):
_validate_no_local_paths(runtime_env)
if __name__ == "__main__":
sys.exit(pytest.main(["-vv", __file__]))
@@ -0,0 +1,20 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "http://github.com/ray-project/ray/runtime_env/pip_schema.json",
"title": "test_env_1",
"type": "object",
"properties": {
"array": {
"type": "array",
"items": {
"type": "string"
}
},
"bool": {
"type": "boolean"
}
},
"required": [
"array"
]
}
@@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "http://github.com/ray-project/ray/runtime_env/working_dir_schema.json",
"title": "test_env_2",
"type": "string"
}
@@ -0,0 +1,4 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "http://github.com/ray-project/ray/runtime_env/working_dir_schema.json",
"type": "string"
@@ -0,0 +1,30 @@
"""Unit tests for working_dir runtime environment functionality."""
import sys
import pytest
from ray._private.ray_constants import get_runtime_env_default_excludes
ENV_VAR = "RAY_OVERRIDE_RUNTIME_ENV_DEFAULT_EXCLUDES"
class TestGetRuntimeEnvDefaultExcludes:
"""Tests for get_runtime_env_default_excludes()."""
def test_returns_defaults_when_env_var_not_set(self, monkeypatch):
monkeypatch.delenv(ENV_VAR, raising=False)
result = get_runtime_env_default_excludes()
assert ".git" in result and ".venv" in result
def test_empty_env_var_disables_defaults(self, monkeypatch):
monkeypatch.setenv(ENV_VAR, "")
assert get_runtime_env_default_excludes() == []
def test_custom_env_var_overrides_defaults(self, monkeypatch):
monkeypatch.setenv(ENV_VAR, "foo, bar ,baz")
assert get_runtime_env_default_excludes() == ["foo", "bar", "baz"]
if __name__ == "__main__":
sys.exit(pytest.main(["-vv", __file__]))