chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
This directory should only contain unit tests that do not depend on running a Ray instance.
|
||||
@@ -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__]))
|
||||
@@ -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__]))
|
||||
@@ -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__]))
|
||||
Reference in New Issue
Block a user