Files
microsoft--agent-lightning/tests/test_config.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:44:17 +08:00

609 lines
23 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
# type: ignore
"""
This file is not carefully reviewed.
It is to ensure the *somewhat* correctness of the code in agentlightning/config.py.
It can contain logically erroroneous expected values.
Please do not use the file as a reference for the expected behavior of config.
"""
import argparse
import inspect
import sys
from typing import TypeVar # Added for completeness if testing TypeVars directly
from typing import _GenericAlias # type: ignore
from typing import (
Any,
Callable,
Dict,
List,
Optional,
Tuple,
Type,
Union,
get_args,
get_origin,
)
from unittest import mock # For mock.patch.object, mock.call, MagicMock etc.
import pytest
from agentlightning import config
# TypeVar as used in the original code
CliConfigurable = Any
_C = TypeVar("_C", bound=CliConfigurable)
# --- Helper classes for testing ---
class SimpleConfig:
def __init__(self, name: str, value: int = 10):
self.name = name
self.value = value
class ComplexConfig:
def __init__(
self,
text: str,
is_active: bool,
count: Optional[int] = None,
items: List[str] = [], # Default mutable is usually discouraged, but for testing CLI it's okay.
maybe_text: Optional[str] = "default_text",
optional_list: Optional[List[int]] = None,
list_of_optionals: List[Optional[str]] = [],
any_param: Any = None,
untyped_param="untyped_default", # Parameter without type hint but with a default
):
self.text = text
self.is_active = is_active
self.count = count
self.items = items
self.maybe_text = maybe_text
self.optional_list = optional_list
self.list_of_optionals = list_of_optionals
self.any_param = any_param
self.untyped_param = untyped_param
class NoInitParamsConfig:
def __init__(self):
pass
class OnlySelfConfig: # For completeness, though __init__ without params is similar
def __init__(self):
pass
class RequiredOnlyConfig:
def __init__(self, req_str: str, req_int: int):
self.req_str = req_str
self.req_int = req_int
class OptionalNoDefaultConfig:
def __init__(self, opt_val: Optional[str]): # No __init__ default
self.opt_val = opt_val
# --- Tests for nullable_str ---
@pytest.mark.parametrize(
"input_val, expected_output",
[
("none", None),
("None", None),
("NONE", None),
("null", None),
("Null", None),
("NULL", None),
("~", None),
("nil", None),
("Nil", None),
("NIL", None),
("actual_string", "actual_string"),
("", ""),
(" ", " "),
("NoneValue", "NoneValue"), # Should not convert if not exact keyword
],
)
def test_nullable_str(input_val: str, expected_output: Optional[str]) -> None:
"""Tests the nullable_str function for various inputs."""
assert config.nullable_str(input_val) == expected_output
# --- Tests for _str_to_bool ---
@pytest.mark.parametrize(
"input_val, expected_output",
[
("yes", True),
("Yes", True),
("YES", True),
("true", True),
("True", True),
("TRUE", True),
("t", True),
("T", True),
("y", True),
("Y", True),
("1", True),
(True, True), # Direct bool pass-through
("no", False),
("No", False),
("NO", False),
("false", False),
("False", False),
("FALSE", False),
("f", False),
("F", False),
("n", False),
("N", False),
("0", False),
(False, False), # Direct bool pass-through
],
)
def test_str_to_bool_valid(input_val: Union[str, bool], expected_output: bool) -> None:
"""Tests _str_to_bool with valid boolean string representations."""
assert config._str_to_bool(input_val) == expected_output
@pytest.mark.parametrize("invalid_input", ["maybe", "2", "", " ", "trueish", "falsey"])
def test_str_to_bool_invalid(invalid_input: str) -> None:
"""Tests _str_to_bool with invalid inputs, expecting ArgumentTypeError."""
with pytest.raises(argparse.ArgumentTypeError):
config._str_to_bool(invalid_input)
# --- Tests for _get_param_type_details ---
@pytest.mark.parametrize(
"annotation, expected_core_type, expected_is_optional, expected_is_list",
[
(str, str, False, False),
(int, int, False, False),
(bool, bool, False, False),
(Any, Any, False, False),
(Optional[str], str, True, False),
(Union[str, None], str, True, False), # Equivalent to Optional[str]
(Optional[int], int, True, False),
(List[str], List[str], False, True), # core_type is List[str]
(List[int], List[int], False, True),
(Optional[List[str]], List[str], True, True), # core_type is List[str], outer is Optional
(Union[List[str], None], List[str], True, True),
(List[Optional[str]], List[Optional[str]], False, True), # core_type is List[Optional[str]]
(Optional[List[Optional[int]]], List[Optional[int]], True, True),
(inspect.Parameter.empty, inspect.Parameter.empty, False, False), # No annotation
(Union[int, str], Union[int, str], False, False), # Non-optional Union
(Tuple[str, int], Tuple[str, int], False, False), # Tuple is not a List
(Callable[[int], str], Callable[[int], str], False, False),
(Dict[str, int], Dict[str, int], False, False),
(List[Any], List[Any], False, True),
(Optional[List[Any]], List[Any], True, True),
],
)
def test_get_param_type_details(
annotation: Any, expected_core_type: Any, expected_is_optional: bool, expected_is_list: bool
) -> None:
"""Tests _get_param_type_details for various type annotations."""
core_type, is_optional, is_list = config._get_param_type_details(annotation)
# Handle comparison for complex types like Union by comparing origin and args
if get_origin(expected_core_type) is Union and get_origin(core_type) is Union:
assert get_origin(core_type) == get_origin(expected_core_type)
# Sort args for comparison as order doesn't matter in Union and get_args might not preserve input order
assert sorted(map(str, get_args(core_type))) == sorted(map(str, get_args(expected_core_type)))
else:
assert core_type == expected_core_type
assert is_optional == expected_is_optional
assert is_list == expected_is_list
# --- Tests for _determine_argparse_type_and_nargs ---
@pytest.mark.parametrize(
"core_param_type, is_param_list, expected_kwargs",
[
# List cases
(List[str], True, {"nargs": "*", "type": str}),
(List[int], True, {"nargs": "*", "type": int}),
(List[float], True, {"nargs": "*", "type": float}),
(List[bool], True, {"nargs": "*", "type": config._str_to_bool}),
(List[Optional[str]], True, {"nargs": "*", "type": config.nullable_str}),
(List[Any], True, {"nargs": "*", "type": str}), # Defaults to str for List[Any] items
# Non-list cases
(str, False, {"type": str}),
(int, False, {"type": int}),
(float, False, {"type": float}),
(bool, False, {"type": config._str_to_bool}),
(Optional[str], False, {"type": config.nullable_str}), # Handled by re-checking details
(Union[str, None], False, {"type": config.nullable_str}), # Equivalent to Optional[str]
(Any, False, {"type": str}), # No specific argparse type for Any
(inspect.Parameter.empty, False, {"type": str}), # No specific type for unannotated
# List of complex types (e.g. List[Dict]), type of item is treated as string
(List[Dict[str, int]], True, {"nargs": "*", "type": str}),
],
)
def test_determine_argparse_type_and_nargs(
core_param_type: Any, is_param_list: bool, expected_kwargs: Dict[str, Any]
) -> None:
"""Tests _determine_argparse_type_and_nargs for type and nargs mapping."""
assert config._determine_argparse_type_and_nargs(core_param_type, is_param_list) == expected_kwargs
# --- Tests for _build_help_string ---
@pytest.mark.parametrize(
"cls_name, param_name, core_type, is_optional, is_list, expected_help",
[
("C", "p", str, False, False, "For C: 'p'. Inferred type: str."),
("C", "p", int, True, False, "For C: 'p'. Inferred type: Optional[int]."),
("C", "p_list", List[str], False, True, "For C: 'p_list'. Inferred type: List[str]."),
("C", "p_opt_list", List[int], True, True, "For C: 'p_opt_list'. Inferred type: Optional[List[int]]."),
(
"C",
"p_list_opt",
List[Optional[str]],
False,
True,
"For C: 'p_list_opt'. Inferred type: List[Optional[str]].",
),
("C", "p_any", Any, False, False, "For C: 'p_any'. Inferred type: Any."),
("C", "p_opt_any", Any, True, False, "For C: 'p_opt_any'. Inferred type: Optional[Any]."),
("C", "p_list_any", List[Any], False, True, "For C: 'p_list_any'. Inferred type: List[Any]."),
("C", "p_empty", inspect.Parameter.empty, False, False, "For C: 'p_empty'. Inferred type: Any."),
],
)
def test_build_help_string(
cls_name: str, param_name: str, core_type: Any, is_optional: bool, is_list: bool, expected_help: str
) -> None:
"""Tests _build_help_string for generating correct help messages."""
assert config._build_help_string(cls_name, param_name, core_type, is_optional, is_list) == expected_help
# --- Tests for _add_argument_for_parameter ---
@pytest.fixture
def mock_parser():
"""Fixture to create a mock ArgumentParser."""
parser = mock.MagicMock(spec=argparse.ArgumentParser)
parser.add_argument = mock.MagicMock()
return parser
def get_param_obj(cls, param_name):
"""Helper to get an inspect.Parameter object from a class's __init__."""
sig = inspect.signature(cls.__init__)
return sig.parameters[param_name]
@pytest.mark.parametrize(
"param_name, cls, expected_cli_name_part, expected_argparse_kwargs_subset",
[
("name", SimpleConfig, "simpleconfig.name", {"type": str, "required": True}),
("value", SimpleConfig, "simpleconfig.value", {"type": int, "default": 10}),
("text", ComplexConfig, "complexconfig.text", {"type": str, "required": True}),
("is_active", ComplexConfig, "complexconfig.is-active", {"type": config._str_to_bool, "required": True}),
(
"count",
ComplexConfig,
"complexconfig.count",
{"type": config.nullable_int, "default": None},
), # Optional[int]=None
("items", ComplexConfig, "complexconfig.items", {"type": str, "nargs": "*", "default": []}),
(
"maybe_text",
ComplexConfig,
"complexconfig.maybe-text",
{"type": config.nullable_str, "default": "default_text"},
),
("optional_list", ComplexConfig, "complexconfig.optional-list", {"type": int, "nargs": "*", "default": None}),
(
"list_of_optionals",
ComplexConfig,
"complexconfig.list-of-optionals",
{"type": config.nullable_str, "nargs": "*", "default": []},
),
(
"any_param",
ComplexConfig,
"complexconfig.any-param",
{"default": None},
), # Type Any implies no specific argparse type
(
"untyped_param",
ComplexConfig,
"complexconfig.untyped-param",
{"type": str, "default": "untyped_default"},
), # No type hint
(
"opt_val",
OptionalNoDefaultConfig,
"optionalnodefaultconfig.opt-val",
{"type": config.nullable_str, "default": None},
),
],
)
def test_add_argument_for_parameter(
mock_parser, param_name, cls, expected_cli_name_part, expected_argparse_kwargs_subset
):
"""Tests _add_argument_for_parameter for correct argument configuration."""
param_obj = get_param_obj(cls, param_name)
dest_name = f"{cls.__name__.lower()}_{param_name}"
config._add_argument_for_parameter(mock_parser, cls, param_name, param_obj, dest_name)
# Build expected CLI argument name (e.g., --simpleconfig.name)
expected_cli_arg_name = f"--{expected_cli_name_part.replace('_', '-')}"
# Check that add_argument was called
assert mock_parser.add_argument.called
# Get the actual call arguments
actual_call_args, actual_call_kwargs = mock_parser.add_argument.call_args
assert actual_call_args[0] == expected_cli_arg_name
assert actual_call_kwargs["dest"] == dest_name
# Check for the presence and correctness of specified kwargs
for key, expected_value in expected_argparse_kwargs_subset.items():
assert key in actual_call_kwargs, f"Key '{key}' not in argparse kwargs for {param_name}"
assert actual_call_kwargs[key] == expected_value, f"Value for key '{key}' incorrect for {param_name}"
# Check 'required' status carefully
if expected_argparse_kwargs_subset.get("required", False):
assert actual_call_kwargs.get("required") is True
else: # Not required or default makes it not required
assert not actual_call_kwargs.get("required", False) # Either 'required' is False or not present
# Check help string was generated
assert "help" in actual_call_kwargs
assert isinstance(actual_call_kwargs["help"], str)
# --- Tests for _add_arguments_for_class ---
def test_add_arguments_for_class(mock_parser: Any) -> None:
"""Tests _add_arguments_for_class by checking calls to _add_argument_for_parameter."""
class_arg_configs_maps = {}
with mock.patch("agentlightning.config._add_argument_for_parameter") as mock_add_param_func:
config._add_arguments_for_class(mock_parser, ComplexConfig, class_arg_configs_maps)
assert ComplexConfig in class_arg_configs_maps
init_params = inspect.signature(ComplexConfig.__init__).parameters
expected_calls = 0
for p_name, p_obj in init_params.items():
if p_name == "self":
continue
expected_calls += 1
dest = f"complexconfig_{p_name}"
assert class_arg_configs_maps[ComplexConfig][p_name] == dest
# Check if mock_add_param_func was called with these specific arguments
found_call = any(
call_args[0] == mock_parser
and call_args[1] == ComplexConfig
and call_args[2] == p_name
and call_args[3] == p_obj
and call_args[4] == dest
for call_args, _ in mock_add_param_func.call_args_list
)
assert found_call, f"Expected call for {p_name} not found or with wrong arguments."
assert mock_add_param_func.call_count == expected_calls
def test_add_arguments_for_class_no_init_params(mock_parser: Any) -> None:
"""Tests _add_arguments_for_class with a class having no __init__ parameters."""
class_arg_configs_maps = {}
with mock.patch("agentlightning.config._add_argument_for_parameter") as mock_add_param_func:
config._add_arguments_for_class(mock_parser, NoInitParamsConfig, class_arg_configs_maps)
mock_add_param_func.assert_not_called()
assert class_arg_configs_maps[NoInitParamsConfig] == {}
def test_create_argument_parser() -> None:
"""Tests _create_argument_parser for basic parser properties."""
parser = config._create_argument_parser()
assert isinstance(parser, argparse.ArgumentParser)
assert parser.description == "CLI configurator for application components."
# The formatter_class is a type, so compare types
assert type(parser.formatter_class) == type(argparse.ArgumentDefaultsHelpFormatter)
def test_instantiate_classes() -> None:
"""Tests _instantiate_classes with various argument types and defaults."""
parsed_args = argparse.Namespace(
simpleconfig_name="TestName",
simpleconfig_value=99,
complexconfig_text="Hello",
complexconfig_is_active=True,
complexconfig_count=None, # Explicitly None
complexconfig_items=["item1", "item2"],
# maybe_text not provided, should use its default "default_text" from __init__
# Argparse default for complexconfig_maybe_text would be "default_text"
complexconfig_maybe_text="default_text", # Simulate argparse providing the default
complexconfig_optional_list=[1, 2, 3],
complexconfig_list_of_optionals=["a", None, "b"],
complexconfig_any_param=object(),
complexconfig_untyped_param="custom_untyped",
)
# This simulates that 'complexconfig_maybe_text' was given a default by add_argument
# and thus present in parsed_args.
# If not provided on CLI and having __init__ default, argparse gives it default.
class_arg_configs_maps = {
SimpleConfig: {"name": "simpleconfig_name", "value": "simpleconfig_value"},
ComplexConfig: {
p: f"complexconfig_{p}" for p in inspect.signature(ComplexConfig.__init__).parameters if p != "self"
},
}
instances = config._instantiate_classes(parsed_args, (SimpleConfig, ComplexConfig), class_arg_configs_maps)
assert len(instances) == 2
sc, cc = instances
assert isinstance(sc, SimpleConfig)
assert sc.name == "TestName"
assert sc.value == 99
assert isinstance(cc, ComplexConfig)
assert cc.text == "Hello"
assert cc.is_active is True
assert cc.count is None
assert cc.items == ["item1", "item2"]
assert cc.maybe_text == "default_text" # From __init__ via argparse default
assert cc.optional_list == [1, 2, 3]
assert cc.list_of_optionals == ["a", None, "b"]
assert cc.any_param is parsed_args.complexconfig_any_param
assert cc.untyped_param == "custom_untyped"
def test_instantiate_classes_error_handling(caplog):
"""Tests error logging during class instantiation failure."""
class FailingConfig:
def __init__(self, param):
raise ValueError("Instantiaion failed")
parsed_args = argparse.Namespace(failingconfig_param="value")
class_arg_configs_maps = {FailingConfig: {"param": "failingconfig_param"}}
with pytest.raises(ValueError, match="Instantiaion failed"):
config._instantiate_classes(parsed_args, (FailingConfig,), class_arg_configs_maps)
assert "Error instantiating FailingConfig" in caplog.text
assert "{'param': 'value'}" in caplog.text # constructor_args
assert "Error: Instantiaion failed" in caplog.text
# --- Integration Tests for lightning_cli ---
def run_lightning_cli(classes_to_configure, cli_args_list):
"""Helper to run lightning_cli with mocked sys.argv."""
# Prepend a dummy program name to cli_args_list for sys.argv
with mock.patch.object(sys, "argv", ["test_program.py"] + cli_args_list):
result = config.lightning_cli(*classes_to_configure)
if not isinstance(result, tuple):
return (result,)
return result
def test_lightning_cli_no_classes():
"""Tests lightning_cli with no classes provided."""
assert run_lightning_cli([], []) == tuple()
def test_lightning_cli_simple_config():
"""Tests lightning_cli with a simple class and various argument scenarios."""
# Only required arg
(sc1,) = run_lightning_cli([SimpleConfig], ["--simpleconfig.name", "MyName"])
assert sc1.name == "MyName"
assert sc1.value == 10 # Default from __init__
# All args
(sc2,) = run_lightning_cli([SimpleConfig], ["--simpleconfig.name", "Another", "--simpleconfig.value", "77"])
assert sc2.name == "Another"
assert sc2.value == 77
def test_lightning_cli_complex_config_types():
"""Tests lightning_cli with ComplexConfig, checking various type conversions."""
cli_args = [
"--complexconfig.text",
"CLI Text",
"--complexconfig.is-active",
"yes",
"--complexconfig.count",
"123",
"--complexconfig.items",
"apple",
"banana",
"--complexconfig.maybe-text",
"None", # Test nullable_str
"--complexconfig.optional-list",
"10",
"20",
"30",
"--complexconfig.list-of-optionals",
"first",
"nil",
"third",
"null",
"--complexconfig.any-param",
"AnythingGoes",
"--complexconfig.untyped-param",
"FromCLI",
]
(cc,) = run_lightning_cli([ComplexConfig], cli_args)
assert cc.text == "CLI Text"
assert cc.is_active is True
assert cc.count == 123
assert cc.items == ["apple", "banana"]
assert cc.maybe_text is None
assert cc.optional_list == [10, 20, 30]
assert cc.list_of_optionals == ["first", None, "third", None]
assert cc.any_param == "AnythingGoes"
assert cc.untyped_param == "FromCLI"
def test_lightning_cli_complex_config_defaults():
"""Tests that __init__ defaults are used if CLI args are not provided."""
# Only provide required arguments for ComplexConfig
cli_args = ["--complexconfig.text", "Minimal", "--complexconfig.is-active", "f"]
(cc,) = run_lightning_cli([ComplexConfig], cli_args)
assert cc.text == "Minimal"
assert cc.is_active is False
assert cc.count is None # Default from __init__
assert cc.items == [] # Default from __init__
assert cc.maybe_text == "default_text" # Default from __init__
assert cc.optional_list is None # Default from __init__
assert cc.list_of_optionals == [] # Default from __init__
assert cc.any_param is None # Default from __init__
assert cc.untyped_param == "untyped_default" # Default from __init__
def test_lightning_cli_multiple_classes():
"""Tests configuring multiple classes simultaneously."""
cli_args = [
"--simpleconfig.name",
"SC_Multi",
"--simpleconfig.value",
"5",
"--complexconfig.text",
"CC_Multi",
"--complexconfig.is-active",
"true",
"--complexconfig.maybe-text",
"NotNone",
]
sc, cc = run_lightning_cli([SimpleConfig, ComplexConfig], cli_args)
assert isinstance(sc, SimpleConfig)
assert sc.name == "SC_Multi"
assert sc.value == 5
assert isinstance(cc, ComplexConfig)
assert cc.text == "CC_Multi"
assert cc.is_active is True
assert cc.maybe_text == "NotNone"
assert cc.items == [] # Default
def test_lightning_cli_missing_required_arg_exits(capsys):
"""Tests that argparse exits if a required argument is missing."""
with pytest.raises(SystemExit) as e:
run_lightning_cli([RequiredOnlyConfig], ["--requiredonlyconfig.req-int", "123"])
assert e.value.code != 0 # Argparse exits with non-zero for error
captured = capsys.readouterr() # Capture stderr
assert "the following arguments are required: --requiredonlyconfig.req-str" in captured.err
def test_lightning_cli_optional_no_default_behavior():
"""Tests Optional parameter without __init__ default."""
# Not provided: should become None
(cfg1,) = run_lightning_cli([OptionalNoDefaultConfig], [])
assert cfg1.opt_val is None
# Provided as "None" via nullable_str
(cfg2,) = run_lightning_cli([OptionalNoDefaultConfig], ["--optionalnodefaultconfig.opt-val", "None"])
assert cfg2.opt_val is None
# Provided with a value
(cfg3,) = run_lightning_cli([OptionalNoDefaultConfig], ["--optionalnodefaultconfig.opt-val", "ActualValue"])
assert cfg3.opt_val == "ActualValue"