Files
ray-project--ray/python/ray/train/v2/tests/test_logging.py
T
2026-07-13 13:17:40 +08:00

416 lines
16 KiB
Python

import builtins
import contextlib
import io
import logging
import os
import sys
import pytest
import ray
from ray.runtime_context import get_runtime_context
from ray.train.v2._internal.logging import LoggingManager
from ray.train.v2._internal.logging.logging import TrainLogLevelFilter
from ray.train.v2._internal.logging.patch_print import patch_print_function
from ray.train.v2.api.config import LoggingConfig, RunConfig
from ray.train.v2.tests.util import (
DummyTrainContext,
create_dummy_run_context,
create_dummy_train_context,
)
# ============================================================
# Fixtures
# ============================================================
@pytest.fixture(name="worker_logging")
def worker_logging_fixture():
root_logger = logging.getLogger()
original_root_handlers = root_logger.handlers[:]
original_root_level = root_logger.level
train_logger = logging.getLogger("ray.train")
original_train_handlers = train_logger.handlers[:]
original_train_level = train_logger.level
original_print = builtins.print
yield
root_logger.handlers = original_root_handlers
root_logger.setLevel(original_root_level)
train_logger.handlers = original_train_handlers
train_logger.setLevel(original_train_level)
builtins.print = original_print
@pytest.fixture(name="controller_logging")
def controller_logging_fixture():
train_logger = logging.getLogger("ray.train")
original_handlers = train_logger.handlers[:]
original_level = train_logger.level
yield
train_logger.handlers = original_handlers
train_logger.setLevel(original_level)
@pytest.fixture(autouse=True)
def ray_start():
ray.init()
yield
ray.shutdown()
# ============================================================
# Helpers
# ============================================================
def get_log_directory() -> str:
global_node = ray._private.worker._global_node
session_dir = global_node.get_session_dir_path()
return os.path.join(session_dir, "logs", "train")
def get_file_contents(file_name: str) -> str:
log_path = os.path.join(get_log_directory(), file_name)
with open(log_path, encoding="utf-8") as file:
return file.read()
def file_exists(file_name: str) -> bool:
log_path = os.path.join(get_log_directory(), file_name)
return os.path.exists(log_path)
def _create_run_context_with_log_level(log_level):
return create_dummy_run_context(
run_config=RunConfig(
name="test", logging_config=LoggingConfig(log_level=log_level)
)
)
def _create_train_context_with_log_level(log_level):
ctx = DummyTrainContext()
ctx.train_run_context = _create_run_context_with_log_level(log_level)
return ctx
# ============================================================
# Validation tests
# ============================================================
def test_invalid_log_level_string_raises():
"""Test that an invalid log level string raises ValueError."""
with pytest.raises(ValueError, match="Invalid log_level"):
LoggingConfig(log_level="INVALID_LEVEL")
def test_invalid_log_level_type_raises():
"""Test that a non-string log level raises ValueError."""
with pytest.raises(ValueError, match="Invalid log_level"):
LoggingConfig(log_level=10)
def test_log_level_normalized_to_uppercase():
"""Test that log_level is normalized to uppercase after construction."""
config = LoggingConfig(log_level="debug")
assert config.log_level == "DEBUG"
# ============================================================
# Config structure tests
# ============================================================
def test_default_log_level_is_info(controller_logging):
"""Test that the default log level filter is INFO when nothing is configured."""
context = create_dummy_run_context()
config = LoggingManager._get_controller_logger_config_dict(context)
assert config["filters"]["train_log_level_filter"]["log_level"] == "INFO"
def test_log_level_filter_on_controller_handlers(controller_logging):
"""Test that the log level filter is attached to console and app handlers
but not to sys handlers on the controller."""
context = _create_run_context_with_log_level("WARNING")
config = LoggingManager._get_controller_logger_config_dict(context)
assert config["filters"]["train_log_level_filter"]["log_level"] == "WARNING"
assert "train_log_level_filter" in config["handlers"]["console"]["filters"]
assert (
"train_log_level_filter"
in config["handlers"]["file_train_app_controller"]["filters"]
)
assert "train_log_level_filter" not in config["handlers"][
"file_train_sys_controller"
].get("filters", [])
def test_log_level_filter_on_worker_handlers(worker_logging):
"""Test that the log level filter is attached to console and app handlers
but not to sys handlers on the worker."""
context = _create_train_context_with_log_level("WARNING")
config = LoggingManager._get_worker_logger_config_dict(context)
assert config["filters"]["train_log_level_filter"]["log_level"] == "WARNING"
assert "train_log_level_filter" in config["handlers"]["console"]["filters"]
assert (
"train_log_level_filter"
in config["handlers"]["file_train_app_worker"]["filters"]
)
assert "train_log_level_filter" not in config["handlers"][
"file_train_sys_worker"
].get("filters", [])
def test_no_handler_level_set(controller_logging):
"""Test that no handler has an explicit 'level' key. Log level filtering
is done entirely through TrainLogLevelFilter."""
context = _create_run_context_with_log_level("DEBUG")
config = LoggingManager._get_controller_logger_config_dict(context)
for handler_name, handler_config in config["handlers"].items():
assert (
"level" not in handler_config
), f"Handler '{handler_name}' should not have an explicit level"
def test_ray_train_logger_propagate_false(worker_logging):
"""Test that the ray.train logger has propagate=False after configuration,
which is the foundation for isolation from the ray and root loggers."""
context = create_dummy_run_context()
config = LoggingManager._get_controller_logger_config_dict(context)
assert config["loggers"]["ray.train"]["propagate"] is False
context = _create_train_context_with_log_level("INFO")
config = LoggingManager._get_worker_logger_config_dict(context)
assert config["loggers"]["ray.train"]["propagate"] is False
# ============================================================
# TrainLogLevelFilter unit tests
# ============================================================
def test_filter_blocks_low_level_train_logs():
"""Test that the filter blocks ray.train logs below the configured level."""
f = TrainLogLevelFilter("WARNING")
record = logging.LogRecord("ray.train.foo", logging.INFO, "", 0, "msg", (), None)
assert not f.filter(record)
def test_filter_passes_high_level_train_logs():
"""Test that the filter passes ray.train logs at or above the configured level."""
f = TrainLogLevelFilter("WARNING")
record = logging.LogRecord("ray.train.foo", logging.WARNING, "", 0, "msg", (), None)
assert f.filter(record)
def test_filter_passes_user_logs_regardless_of_level():
"""Test that non-ray.train logs (user application logs) always pass through."""
f = TrainLogLevelFilter("WARNING")
record = logging.LogRecord("myapp", logging.DEBUG, "", 0, "msg", (), None)
assert f.filter(record)
# ============================================================
# Controller log routing tests
# ============================================================
def test_controller_no_logs_without_configuration(controller_logging):
"""Test that no log files are created when logging is not configured."""
worker_id = get_runtime_context().get_worker_id()
train_logger = logging.getLogger("ray.train.spam")
train_logger.info("ham")
assert not file_exists(f"ray-train-sys-controller-{worker_id}.log")
assert not file_exists(f"ray-train-app-controller-{worker_id}.log")
def test_controller_train_logs_routed_to_all_files(controller_logging):
"""Test that ray.train INFO logs appear in both sys and app controller files."""
LoggingManager.configure_controller_logger(create_dummy_run_context())
worker_id = get_runtime_context().get_worker_id()
train_logger = logging.getLogger("ray.train.spam")
train_logger.info("info_message")
sys_contents = get_file_contents(f"ray-train-sys-controller-{worker_id}.log")
app_contents = get_file_contents(f"ray-train-app-controller-{worker_id}.log")
assert "info_message" in sys_contents
assert "info_message" in app_contents
def test_controller_debug_only_in_sys_when_log_level_warning(controller_logging):
"""Test that DEBUG ray.train logs appear in the sys file but are filtered
from the app file when log_level is WARNING."""
context = _create_run_context_with_log_level("WARNING")
LoggingManager.configure_controller_logger(context)
worker_id = get_runtime_context().get_worker_id()
train_logger = logging.getLogger("ray.train.spam")
train_logger.debug("debug_message")
train_logger.warning("warning_message")
sys_contents = get_file_contents(f"ray-train-sys-controller-{worker_id}.log")
app_contents = get_file_contents(f"ray-train-app-controller-{worker_id}.log")
# Sys file captures everything
assert "debug_message" in sys_contents
assert "warning_message" in sys_contents
# App file only captures WARNING and above
assert "debug_message" not in app_contents
assert "warning_message" in app_contents
# ============================================================
# Worker log routing tests
# ============================================================
def test_worker_no_logs_without_configuration(worker_logging):
"""Test that no log files are created when logging is not configured."""
worker_id = get_runtime_context().get_worker_id()
train_logger = logging.getLogger("ray.train.spam")
train_logger.info("ham")
assert not file_exists(f"ray-train-sys-worker-{worker_id}.log")
assert not file_exists(f"ray-train-app-worker-{worker_id}.log")
def test_worker_train_logs_routed_to_all_files(worker_logging):
"""Test that ray.train INFO logs appear in both sys and app worker files."""
LoggingManager.configure_worker_logger(create_dummy_train_context())
worker_id = get_runtime_context().get_worker_id()
train_logger = logging.getLogger("ray.train.spam")
train_logger.info("info_message")
sys_contents = get_file_contents(f"ray-train-sys-worker-{worker_id}.log")
app_contents = get_file_contents(f"ray-train-app-worker-{worker_id}.log")
assert "info_message" in sys_contents
assert "info_message" in app_contents
def test_worker_debug_only_in_sys_when_log_level_warning(worker_logging):
"""Test that DEBUG ray.train logs appear in the sys file but are filtered
from the app file when log_level is WARNING."""
context = _create_train_context_with_log_level("WARNING")
LoggingManager.configure_worker_logger(context)
worker_id = get_runtime_context().get_worker_id()
train_logger = logging.getLogger("ray.train.spam")
train_logger.debug("debug_message")
train_logger.warning("warning_message")
sys_contents = get_file_contents(f"ray-train-sys-worker-{worker_id}.log")
app_contents = get_file_contents(f"ray-train-app-worker-{worker_id}.log")
# Sys file captures everything
assert "debug_message" in sys_contents
assert "warning_message" in sys_contents
# App file only captures WARNING and above
assert "debug_message" not in app_contents
assert "warning_message" in app_contents
def test_worker_user_logs_in_app_not_sys(worker_logging):
"""Test that user application logs (root logger) appear in the app file
but not in the sys file, and are not filtered by log_level."""
context = _create_train_context_with_log_level("WARNING")
LoggingManager.configure_worker_logger(context)
worker_id = get_runtime_context().get_worker_id()
# Emit a ray.train log to ensure the sys file is created
train_logger = logging.getLogger("ray.train.spam")
train_logger.info("train_log")
root_logger = logging.getLogger()
root_logger.info("user_info_message")
app_contents = get_file_contents(f"ray-train-app-worker-{worker_id}.log")
sys_contents = get_file_contents(f"ray-train-sys-worker-{worker_id}.log")
# User logs appear in app file, unaffected by log_level
assert "user_info_message" in app_contents
# User logs do not appear in sys file (sys is only for ray.train)
assert "user_info_message" not in sys_contents
def test_worker_print_redirect(worker_logging):
"""Test that print statements are captured in the worker app log file."""
LoggingManager.configure_worker_logger(create_dummy_train_context())
patch_print_function()
worker_id = get_runtime_context().get_worker_id()
print("ham")
log_contents = get_file_contents(f"ray-train-app-worker-{worker_id}.log")
assert "ham" in log_contents, log_contents
assert "ham\\n" not in log_contents, log_contents
def test_worker_app_print_redirect_stdout(worker_logging):
"""Test that redirect_stdout works with the print patch.
Libraries like smart_open use contextlib.redirect_stdout() to capture
output at import time. The patched print should not intercept calls when
stdout has been redirected to a different target.
"""
LoggingManager.configure_worker_logger(create_dummy_train_context())
patch_print_function()
stdout_buf = io.StringIO()
with contextlib.redirect_stdout(stdout_buf):
print("captured text")
assert "captured text" in stdout_buf.getvalue()
stderr_buf = io.StringIO()
with contextlib.redirect_stderr(stderr_buf):
print("captured error", file=sys.stderr)
assert "captured error" in stderr_buf.getvalue()
# ============================================================
# Isolation from ray.init tests
# ============================================================
def test_ray_init_log_level_does_not_affect_train(controller_logging):
"""Test that ray.init(logging_config=LoggingConfig(log_level=...))
does not change the ray.train logger configuration."""
ray.shutdown()
ray.init(logging_config=ray.LoggingConfig(log_level="WARNING"))
context = create_dummy_run_context()
config = LoggingManager._get_controller_logger_config_dict(context)
# ray.train logger should still be DEBUG regardless of ray.init setting
assert config["loggers"]["ray.train"]["level"] == "DEBUG"
# Default Train filter should be INFO, not WARNING
assert config["filters"]["train_log_level_filter"]["log_level"] == "INFO"
def test_train_log_level_does_not_affect_ray_logger(worker_logging):
"""Test that setting ray.train LoggingConfig does not change the ray logger."""
ray_logger = logging.getLogger("ray")
original_level = ray_logger.level
context = _create_train_context_with_log_level("DEBUG")
LoggingManager.configure_worker_logger(context)
assert ray_logger.level == original_level
@pytest.mark.parametrize("log_level", ["DEBUG", "WARNING", "ERROR"])
def test_train_log_level_does_not_affect_root_logger_level(worker_logging, log_level):
"""Test that the root logger level is always INFO regardless of log_level."""
context = _create_train_context_with_log_level(log_level)
config = LoggingManager._get_worker_logger_config_dict(context)
assert config["root"]["level"] == "INFO"
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))