85742ab165
Deploy Documentation / deploy (push) Has been cancelled
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, latest, Python 3.13) (push) Has been cancelled
Dashboard / Chromatic (push) Has been cancelled
CPU Test / Lint - fast (push) Has been cancelled
CPU Test / Lint - next (push) Has been cancelled
CPU Test / Lint - slow (push) Has been cancelled
CPU Test / Lint - JavaScript (push) Has been cancelled
CPU Test / Build documentation (push) Has been cancelled
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Others, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Store, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Weave, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Others, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Store, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Utilities, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (JavaScript) (push) Has been cancelled
423 lines
14 KiB
Python
423 lines
14 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import logging
|
||
import multiprocessing as mp
|
||
from multiprocessing.queues import Queue
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List
|
||
|
||
import pytest
|
||
|
||
from agentlightning.logging import _to_level_value # pyright: ignore[reportPrivateUsage]
|
||
from agentlightning.logging import (
|
||
DATE_FORMAT,
|
||
DEFAULT_FORMAT,
|
||
)
|
||
|
||
pytestmark = pytest.mark.utils
|
||
|
||
|
||
def _logging_worker(case: str, queue: Queue[Dict[str, Any]]) -> None:
|
||
"""
|
||
Runs in a separate process using spawn. It performs a specific logging
|
||
configuration scenario and returns a summary dict via the queue.
|
||
"""
|
||
import logging
|
||
import warnings
|
||
|
||
# Re-import inside the subprocess so everything is picklable & isolated
|
||
from agentlightning.logging import (
|
||
setup,
|
||
setup_module,
|
||
)
|
||
|
||
if case == "setup_module_plain_console":
|
||
logger = setup_module(
|
||
level="DEBUG",
|
||
name="agentlightning.test",
|
||
console=True,
|
||
color=False,
|
||
propagate=False,
|
||
)
|
||
|
||
handlers = logger.handlers
|
||
handler = handlers[0] if handlers else None
|
||
fmt = handler.formatter._fmt if handler and handler.formatter else None
|
||
datefmt = handler.formatter.datefmt if handler and handler.formatter else None
|
||
|
||
queue.put(
|
||
{
|
||
"logger_name": logger.name,
|
||
"logger_level": logger.level,
|
||
"num_handlers": len(handlers),
|
||
"handler_class": handler.__class__.__name__ if handler else None,
|
||
"handler_level": handler.level if handler else None,
|
||
"fmt": fmt,
|
||
"datefmt": datefmt,
|
||
}
|
||
)
|
||
|
||
elif case == "setup_module_color_rich":
|
||
# Rich variant: color=True uses RichHandler
|
||
logger = setup_module(
|
||
level="INFO",
|
||
name="agentlightning.rich",
|
||
console=True,
|
||
color=True,
|
||
propagate=False,
|
||
)
|
||
handlers = logger.handlers
|
||
handler = handlers[0] if handlers else None
|
||
|
||
queue.put(
|
||
{
|
||
"logger_name": logger.name,
|
||
"logger_level": logger.level,
|
||
"num_handlers": len(handlers),
|
||
"handler_class": handler.__class__.__name__ if handler else None,
|
||
"handler_has_formatter": handler.formatter is not None if handler else None,
|
||
}
|
||
)
|
||
|
||
elif case == "setup_with_submodules_apply_to_capture_warnings":
|
||
# Extra handler to attach via extra_handlers
|
||
stream = io.StringIO()
|
||
stream_handler = logging.StreamHandler(stream)
|
||
|
||
setup(
|
||
level="INFO",
|
||
console=False,
|
||
color=False,
|
||
propagate=False,
|
||
disable_existing_loggers=False,
|
||
capture_warnings=True,
|
||
submodule_levels={"agentlightning.io": "DEBUG"},
|
||
extra_handlers=[stream_handler],
|
||
apply_to=["external"],
|
||
)
|
||
|
||
base = logging.getLogger("agentlightning")
|
||
sub = logging.getLogger("agentlightning.io")
|
||
ext = logging.getLogger("external")
|
||
|
||
# Capture warnings via logging after capture_warnings=True
|
||
class ListHandler(logging.Handler):
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self.records: List[logging.LogRecord] = []
|
||
|
||
def emit(self, record: logging.LogRecord) -> None:
|
||
self.records.append(record)
|
||
|
||
lh = ListHandler()
|
||
wlog = logging.getLogger("py.warnings")
|
||
wlog.handlers.clear()
|
||
wlog.addHandler(lh)
|
||
wlog.setLevel(logging.WARNING)
|
||
wlog.propagate = False
|
||
|
||
warnings.warn("from warnings", UserWarning)
|
||
|
||
queue.put(
|
||
{
|
||
"base_level": base.level,
|
||
"base_num_handlers": len(base.handlers),
|
||
"extra_in_base": stream_handler in base.handlers,
|
||
"sub_level": sub.level,
|
||
"ext_level": ext.level,
|
||
"ext_handlers_same": base.handlers == ext.handlers,
|
||
"ext_propagate": ext.propagate,
|
||
"warnings_logged": len(lh.records),
|
||
}
|
||
)
|
||
|
||
elif case == "setup_with_console_and_extra_handler":
|
||
# Console + extra handler combination to test handler attachment
|
||
stream = io.StringIO()
|
||
extra_handler = logging.StreamHandler(stream)
|
||
|
||
setup(
|
||
level="WARNING",
|
||
console=True,
|
||
color=False,
|
||
propagate=False,
|
||
extra_handlers=[extra_handler],
|
||
)
|
||
|
||
base = logging.getLogger("agentlightning")
|
||
handler_classes = [h.__class__.__name__ for h in base.handlers]
|
||
has_extra = extra_handler in base.handlers
|
||
|
||
queue.put(
|
||
{
|
||
"base_level": base.level,
|
||
"num_handlers": len(base.handlers),
|
||
"handler_classes": handler_classes,
|
||
"has_extra": has_extra,
|
||
}
|
||
)
|
||
|
||
else:
|
||
queue.put({})
|
||
|
||
|
||
def _logging_worker_files_string(queue: Queue[Dict[str, Any]], base_dir: str) -> None:
|
||
"""
|
||
Runs in a separate spawned process and configures logging with a single
|
||
files=str path. Returns information about the attached FileHandler.
|
||
"""
|
||
import logging
|
||
import os
|
||
|
||
from agentlightning.logging import setup
|
||
|
||
log_path = os.path.join(base_dir, "logs", "agent.log")
|
||
|
||
setup(
|
||
level="INFO",
|
||
console=False,
|
||
color=False,
|
||
propagate=False,
|
||
files=log_path,
|
||
)
|
||
|
||
base = logging.getLogger("agentlightning")
|
||
file_handlers = [h for h in base.handlers if isinstance(h, logging.FileHandler)]
|
||
fh = file_handlers[0] if file_handlers else None
|
||
|
||
fmt = fh.formatter._fmt if fh and fh.formatter else None
|
||
datefmt = fh.formatter.datefmt if fh and fh.formatter else None
|
||
|
||
queue.put(
|
||
{
|
||
"logger_level": base.level,
|
||
"num_handlers": len(base.handlers),
|
||
"num_file_handlers": len(file_handlers),
|
||
"file_base": fh.baseFilename if fh else None,
|
||
"file_level": fh.level if fh else None,
|
||
"fmt": fmt,
|
||
"datefmt": datefmt,
|
||
}
|
||
)
|
||
|
||
|
||
def _logging_worker_files_mapping(queue: Queue[Dict[str, Any]], base_dir: str) -> None:
|
||
"""
|
||
Runs in a separate spawned process and configures logging with a files=dict
|
||
mapping, then calls setup twice to verify idempotent FileHandler attachment.
|
||
"""
|
||
import logging
|
||
import os
|
||
|
||
from agentlightning.logging import setup
|
||
|
||
base_log = os.path.join(base_dir, "agent.log")
|
||
external_log = os.path.join(base_dir, "external.log")
|
||
|
||
files_mapping: Dict[str, str] = {
|
||
"agentlightning": base_log,
|
||
"external": external_log,
|
||
}
|
||
|
||
def file_handlers(logger: logging.Logger) -> list[logging.FileHandler]:
|
||
return [h for h in logger.handlers if isinstance(h, logging.FileHandler)]
|
||
|
||
# First setup call
|
||
setup(
|
||
level="DEBUG",
|
||
console=False,
|
||
color=False,
|
||
propagate=False,
|
||
files=files_mapping,
|
||
)
|
||
|
||
base_logger = logging.getLogger("agentlightning")
|
||
ext_logger = logging.getLogger("external")
|
||
|
||
base_fh_first = file_handlers(base_logger)
|
||
ext_fh_first = file_handlers(ext_logger)
|
||
|
||
# Second setup call with the same mapping should not add duplicate FileHandlers
|
||
setup(
|
||
level="DEBUG",
|
||
console=False,
|
||
color=False,
|
||
propagate=False,
|
||
files=files_mapping,
|
||
)
|
||
|
||
base_fh_second = file_handlers(base_logger)
|
||
ext_fh_second = file_handlers(ext_logger)
|
||
|
||
queue.put(
|
||
{
|
||
"base_level": base_logger.level,
|
||
"ext_level": ext_logger.getEffectiveLevel(),
|
||
"base_first_count": len(base_fh_first),
|
||
"ext_first_count": len(ext_fh_first),
|
||
"base_second_count": len(base_fh_second),
|
||
"ext_second_count": len(ext_fh_second),
|
||
"base_file_first": base_fh_first[0].baseFilename if base_fh_first else None,
|
||
"ext_file_first": ext_fh_first[0].baseFilename if ext_fh_first else None,
|
||
"base_file_second": base_fh_second[0].baseFilename if base_fh_second else None,
|
||
"ext_file_second": ext_fh_second[0].baseFilename if ext_fh_second else None,
|
||
# For sanity: capture handler levels as well
|
||
"base_handler_level": base_fh_first[0].level if base_fh_first else None,
|
||
"ext_handler_level": ext_fh_first[0].level if ext_fh_first else None,
|
||
}
|
||
)
|
||
|
||
|
||
def _run_case(case: str) -> Dict[str, Any]:
|
||
"""Helper to run a scenario in a spawn’ed process and fetch the result."""
|
||
ctx = mp.get_context("spawn")
|
||
q: Queue[Dict[str, Any]] = ctx.Queue()
|
||
p = ctx.Process(target=_logging_worker, args=(case, q))
|
||
p.start()
|
||
result = q.get(timeout=10)
|
||
p.join(timeout=10)
|
||
assert p.exitcode == 0
|
||
return result
|
||
|
||
|
||
def test_to_level_value_int_and_str() -> None:
|
||
# direct, no multiprocessing needed
|
||
assert _to_level_value(logging.DEBUG) == logging.DEBUG
|
||
assert _to_level_value("info") == logging.INFO
|
||
assert _to_level_value("WARNING") == logging.WARNING
|
||
|
||
with pytest.raises(ValueError):
|
||
_to_level_value("not-a-level")
|
||
|
||
|
||
def test_setup_module_plain_console_spawn() -> None:
|
||
result = _run_case("setup_module_plain_console")
|
||
|
||
assert result["logger_name"] == "agentlightning.test"
|
||
assert result["logger_level"] == logging.DEBUG
|
||
|
||
# Console handler with plain formatter configured
|
||
assert result["num_handlers"] == 1
|
||
assert result["handler_class"].endswith("StreamHandler")
|
||
assert result["handler_level"] == logging.DEBUG
|
||
assert result["fmt"] == DEFAULT_FORMAT
|
||
assert result["datefmt"] == DATE_FORMAT
|
||
|
||
|
||
def test_setup_module_color_rich_spawn() -> None:
|
||
# Only run this test if rich is installed
|
||
pytest.importorskip("rich")
|
||
|
||
result = _run_case("setup_module_color_rich")
|
||
|
||
assert result["logger_name"] == "agentlightning.rich"
|
||
assert result["logger_level"] == logging.INFO
|
||
assert result["num_handlers"] == 1
|
||
# We can’t rely on full module path, just the class name
|
||
assert result["handler_class"].endswith("RichHandler")
|
||
|
||
|
||
def test_setup_with_submodules_apply_to_and_capture_warnings_spawn() -> None:
|
||
result = _run_case("setup_with_submodules_apply_to_capture_warnings")
|
||
|
||
# Base logger level and handler attachment
|
||
assert result["base_level"] == logging.INFO
|
||
assert result["base_num_handlers"] >= 1
|
||
assert result["extra_in_base"] is True
|
||
|
||
# Submodule level overridden
|
||
assert result["sub_level"] == logging.DEBUG
|
||
|
||
# apply_to logger mirrors base handlers & level, propagation disabled
|
||
assert result["ext_level"] == logging.INFO
|
||
assert result["ext_handlers_same"] is True
|
||
assert result["ext_propagate"] is False
|
||
|
||
# capture_warnings=True causes warnings.warn to go through logging
|
||
assert result["warnings_logged"] >= 1
|
||
|
||
|
||
def test_setup_with_console_and_extra_handler_spawn() -> None:
|
||
result = _run_case("setup_with_console_and_extra_handler")
|
||
|
||
# Level propagated to base logger
|
||
assert result["base_level"] == logging.WARNING
|
||
|
||
# Both console handler and extra handler should be attached
|
||
assert result["num_handlers"] >= 2
|
||
assert any(cls.endswith("StreamHandler") for cls in result["handler_classes"])
|
||
assert result["has_extra"] is True
|
||
|
||
|
||
def test_setup_files_string_spawn(tmp_path: Path) -> None:
|
||
"""
|
||
Verifies that passing files as a string attaches a single FileHandler with
|
||
the expected level and default formatter in a spawned process.
|
||
"""
|
||
ctx = mp.get_context("spawn")
|
||
q: Queue[Dict[str, Any]] = ctx.Queue()
|
||
p = ctx.Process(target=_logging_worker_files_string, args=(q, str(tmp_path)))
|
||
p.start()
|
||
result = q.get(timeout=10)
|
||
p.join(timeout=10)
|
||
assert p.exitcode == 0
|
||
|
||
assert result["logger_level"] == logging.INFO
|
||
# We expect at least one handler and exactly one FileHandler
|
||
assert result["num_handlers"] >= 1
|
||
assert result["num_file_handlers"] == 1
|
||
|
||
# Filename should be inside the tmp_path tree
|
||
assert str(tmp_path) in result["file_base"]
|
||
# FileHandler uses the base logger level
|
||
assert result["file_level"] == logging.INFO
|
||
|
||
# Default formatter applied by _ensure_file_handler
|
||
assert result["fmt"] == DEFAULT_FORMAT
|
||
assert result["datefmt"] == DATE_FORMAT
|
||
|
||
|
||
def test_setup_files_mapping_spawn(tmp_path: Path) -> None:
|
||
"""
|
||
Verifies that passing files as a mapping attaches FileHandlers to each
|
||
logger and that calling setup twice does not create duplicate handlers.
|
||
"""
|
||
ctx = mp.get_context("spawn")
|
||
q: Queue[Dict[str, Any]] = ctx.Queue()
|
||
p = ctx.Process(target=_logging_worker_files_mapping, args=(q, str(tmp_path)))
|
||
p.start()
|
||
result = q.get(timeout=10)
|
||
p.join(timeout=10)
|
||
assert p.exitcode == 0
|
||
|
||
# Base logger level is DEBUG
|
||
assert result["base_level"] == logging.DEBUG
|
||
|
||
# External's effective level is WARNING (inherited from root)
|
||
assert result["ext_level"] == logging.WARNING
|
||
|
||
# First setup: one FileHandler per logger
|
||
assert result["base_first_count"] == 1
|
||
assert result["ext_first_count"] == 1
|
||
|
||
# Second setup: still one FileHandler per logger (idempotence)
|
||
assert result["base_second_count"] == 1
|
||
assert result["ext_second_count"] == 1
|
||
|
||
# File paths are stable across calls
|
||
assert result["base_file_first"] == result["base_file_second"]
|
||
assert result["ext_file_first"] == result["ext_file_second"]
|
||
|
||
# Paths should live under tmp_path
|
||
assert str(tmp_path) in result["base_file_first"]
|
||
assert str(tmp_path) in result["ext_file_first"]
|
||
|
||
# Handler levels:
|
||
# - base handler uses the base logger level (DEBUG)
|
||
# - external handler uses external's effective level at creation (WARNING)
|
||
assert result["base_handler_level"] == logging.DEBUG
|
||
assert result["ext_handler_level"] == logging.WARNING
|