blob: 61362c13c373ed59a9e38df75f4f44c3f1176804 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import dataclasses
import inspect
from typing import Any
import pytest
from tvm_ffi.utils.kwargs_wrapper import make_kwargs_wrapper, make_kwargs_wrapper_from_signature
def test_basic_wrapper() -> None:
"""Test basic wrapper functionality with various argument combinations."""
def target(*args: Any) -> int:
return sum(args)
# No defaults - all required
wrapper = make_kwargs_wrapper(target, ["a", "b", "c"])
assert wrapper(1, 2, 3) == 6
assert wrapper(a=1, b=2, c=3) == 6
assert wrapper(1, b=2, c=3) == 6
# Single default argument
wrapper = make_kwargs_wrapper(target, ["a", "b", "c"], arg_defaults=(10,))
assert wrapper(1, 2) == 13 # c=10
assert wrapper(1, 2, 3) == 6 # c=3 explicit
assert wrapper(1, 2, c=5) == 8 # c=5 via keyword
# Multiple defaults (right-aligned)
wrapper = make_kwargs_wrapper(target, ["a", "b", "c"], arg_defaults=(20, 30))
assert wrapper(1) == 51 # b=20, c=30
assert wrapper(1, 2) == 33 # b=2, c=30
assert wrapper(1, 2, 3) == 6 # all explicit
# All defaults
wrapper = make_kwargs_wrapper(target, ["a", "b", "c"], arg_defaults=(1, 2, 3))
assert wrapper() == 6
assert wrapper(10) == 15
assert wrapper(10, 20, 30) == 60
# Bound methods
class Calculator:
def __init__(self, base: int) -> None:
self.base = base
def add(self, a: int, b: int) -> int:
return self.base + a + b
calc = Calculator(100)
wrapper = make_kwargs_wrapper(calc.add, ["a", "b"], arg_defaults=(5,))
assert wrapper(1) == 106
def test_keyword_only_arguments() -> None:
"""Test wrapper with keyword-only arguments."""
def target(*args: Any) -> int:
return sum(args)
# Optional keyword-only arguments (with defaults)
wrapper = make_kwargs_wrapper(
target,
["a", "b"],
arg_defaults=(),
kwonly_names=["c", "d"],
kwonly_defaults={"c": 100, "d": 200},
)
assert wrapper(1, 2) == 303 # c=100, d=200
assert wrapper(1, 2, c=10) == 213 # d=200
assert wrapper(1, 2, c=10, d=20) == 33
wrapper = make_kwargs_wrapper(
target, ["a", "b"], arg_defaults=(), kwonly_names=["c", "d"], kwonly_defaults={}
)
assert wrapper(1, 2, c=10, d=20) == 33 # c and d are required
wrapper = make_kwargs_wrapper(
target,
["a", "b"],
arg_defaults=(),
kwonly_names=["c", "d"],
kwonly_defaults={"d": 100},
)
assert wrapper(1, 2, c=10) == 113 # c required, d=100
assert wrapper(1, 2, c=10, d=20) == 33 # both explicit
wrapper = make_kwargs_wrapper(
target,
["a", "b", "c"],
arg_defaults=(10,),
kwonly_names=["d", "e"],
kwonly_defaults={"d": 20, "e": 30},
)
assert wrapper(1, 2) == 63 # c=10, d=20, e=30
assert wrapper(1, 2, 5, d=15) == 53 # c=5 explicit, e=30
def test_validation_errors() -> None:
"""Test input validation and error handling."""
target = lambda *args: sum(args)
# Duplicate positional argument names
with pytest.raises(ValueError, match="Duplicate argument names found"):
make_kwargs_wrapper(target, ["a", "b", "a"])
# Duplicate keyword-only argument names
with pytest.raises(ValueError, match="Duplicate keyword-only argument names found"):
make_kwargs_wrapper(target, ["a"], kwonly_names=["b", "c", "b"])
# Invalid argument name types
with pytest.raises(TypeError, match="Argument name must be a string"):
make_kwargs_wrapper(target, ["a", 123]) # ty: ignore[invalid-argument-type]
# Invalid Python identifiers
with pytest.raises(ValueError, match="not a valid Python identifier"):
make_kwargs_wrapper(target, ["a", "b-c"])
# Python keywords cannot be used as parameter names
with pytest.raises(
ValueError, match="is a Python keyword and cannot be used as a parameter name"
):
make_kwargs_wrapper(target, ["a", "if"])
# arg_defaults not a tuple
with pytest.raises(TypeError, match="arg_defaults must be a tuple"):
make_kwargs_wrapper(target, ["a", "b"], arg_defaults=[10]) # ty: ignore[invalid-argument-type]
# arg_defaults too long
with pytest.raises(ValueError, match=r"arg_defaults has .* values but only"):
make_kwargs_wrapper(target, ["a"], arg_defaults=(1, 2, 3))
# Overlap between positional and keyword-only
with pytest.raises(ValueError, match="cannot be both positional and keyword-only"):
make_kwargs_wrapper(target, ["a", "b"], kwonly_names=["b"])
# kwonly_defaults key not in kwonly_names
with pytest.raises(ValueError, match="not in kwonly_names"):
make_kwargs_wrapper(target, ["a", "b"], kwonly_names=["c"], kwonly_defaults={"d": 10})
# Internal name conflict
with pytest.raises(ValueError, match="conflict with internal names"):
make_kwargs_wrapper(target, ["__i_target_func", "b"])
def test_special_default_values() -> None:
"""Test wrapper with special default values like None and objects."""
def target(a: Any, b: Any, c: Any) -> tuple[Any, Any, Any]:
return (a, b, c)
# None as default
wrapper = make_kwargs_wrapper(target, ["a", "b", "c"], arg_defaults=(None, None))
assert wrapper(1) == (1, None, None)
# Complex objects as defaults (verify object reference is preserved)
default_list = [1, 2, 3]
wrapper = make_kwargs_wrapper(target, ["a", "b", "c"], arg_defaults=(default_list, None))
result = wrapper(1)
assert result[1] is default_list
def test_wrapper_with_signature() -> None:
"""Test make_kwargs_wrapper_from_signature."""
target = lambda *args: sum(args)
def source_func(a: Any, b: Any, c: int = 10, d: int = 20) -> None:
"""Source function documentation."""
pass
sig = inspect.signature(source_func)
wrapper = make_kwargs_wrapper_from_signature(target, sig)
assert wrapper(1, 2) == 33 # 1 + 2 + 10 + 20
assert wrapper(1, 2, 3) == 26 # 1 + 2 + 3 + 20
assert wrapper(1, 2, 3, 4) == 10 # 1 + 2 + 3 + 4
# Test metadata preservation when prototype is provided
wrapper_with_metadata = make_kwargs_wrapper_from_signature(target, sig, source_func)
assert wrapper_with_metadata.__name__ == "source_func" # ty: ignore[unresolved-attribute]
assert wrapper_with_metadata.__doc__ == "Source function documentation."
# With keyword-only arguments
def source_kwonly(a: Any, b: Any, *, c: int = 10, d: int = 20) -> None:
pass
wrapper = make_kwargs_wrapper_from_signature(target, inspect.signature(source_kwonly))
assert wrapper(1, 2) == 33
assert wrapper(1, 2, c=5, d=6) == 14
# With required keyword-only arguments
def source_required_kwonly(a: Any, b: Any, *, c: Any, d: int = 20) -> None:
pass
wrapper = make_kwargs_wrapper_from_signature(target, inspect.signature(source_required_kwonly))
assert wrapper(1, 2, c=10) == 33 # c required, d=20
assert wrapper(1, 2, c=10, d=5) == 18 # both explicit
# Reject *args and **kwargs
def with_varargs(a: Any, *args: Any) -> None:
pass
with pytest.raises(ValueError, match=r"\*args not supported"):
make_kwargs_wrapper_from_signature(target, inspect.signature(with_varargs))
def with_kwargs(a: Any, **kwargs: Any) -> None:
pass
with pytest.raises(ValueError, match=r"\*\*kwargs not supported"):
make_kwargs_wrapper_from_signature(target, inspect.signature(with_kwargs))
# Test exclude_arg_names - ignore certain arguments from the signature
def source_with_skip(a: Any, b: Any, c: int = 10, d: int = 20) -> None:
pass
wrapper = make_kwargs_wrapper_from_signature(
target, inspect.signature(source_with_skip), exclude_arg_names=["c"]
)
# c is ignored, so wrapper should only have a, b, d
assert wrapper(1, 2) == 23 # 1 + 2 + 20 (d=20)
assert wrapper(1, 2, d=5) == 8 # 1 + 2 + 5
# Test ignoring multiple arguments
wrapper = make_kwargs_wrapper_from_signature(
target, inspect.signature(source_with_skip), exclude_arg_names=["b", "d"]
)
# b and d are ignored, so wrapper should only have a, c
assert wrapper(1) == 11 # 1 + 10 (c=10)
assert wrapper(1, c=5) == 6 # 1 + 5
# Test ignoring keyword-only arguments
def source_kwonly_skip(a: Any, b: Any, *, c: int = 10, d: int = 20) -> None:
pass
wrapper = make_kwargs_wrapper_from_signature(
target, inspect.signature(source_kwonly_skip), exclude_arg_names=["c"]
)
# c is skipped, so wrapper should only have a, b, d
assert wrapper(1, 2) == 23 # 1 + 2 + 20 (d=20)
assert wrapper(1, 2, d=5) == 8 # 1 + 2 + 5
# Test excluding a non-existent argument (should be silently ignored)
wrapper = make_kwargs_wrapper_from_signature(
target, inspect.signature(source_with_skip), exclude_arg_names=["non_existent"]
)
# Should be the same as no exclusion
assert wrapper(1, 2) == 33 # 1 + 2 + 10 + 20
assert wrapper(1, 2, 3, 4) == 10 # 1 + 2 + 3 + 4
# Test excluding both existing and non-existent arguments
wrapper = make_kwargs_wrapper_from_signature(
target,
inspect.signature(source_with_skip),
exclude_arg_names=["c", "non_existent", "also_missing"],
)
# Only c should be excluded, non-existent names are ignored
assert wrapper(1, 2) == 23 # 1 + 2 + 20 (d=20, c excluded)
assert wrapper(1, 2, d=5) == 8 # 1 + 2 + 5
def test_exception_propagation() -> None:
"""Test that exceptions from the target function are properly propagated."""
def raising_func(a: int, b: int, c: str) -> int:
if a == 0:
raise ValueError("a cannot be zero")
if b < 0:
raise RuntimeError(f"b must be non-negative, got {b}")
if c != "valid":
raise TypeError(f"c must be 'valid', got {c!r}")
return a + b
# Test with positional defaults
wrapper = make_kwargs_wrapper(raising_func, ["a", "b", "c"], arg_defaults=(10, "valid"))
assert wrapper(5) == 15
with pytest.raises(ValueError, match="a cannot be zero"):
wrapper(0)
with pytest.raises(RuntimeError, match="b must be non-negative"):
wrapper(1, -5)
# Test with keyword-only arguments
wrapper_kwonly = make_kwargs_wrapper(
raising_func,
["a"],
kwonly_names=["b", "c"],
kwonly_defaults={"b": 10, "c": "valid"},
)
assert wrapper_kwonly(5) == 15
with pytest.raises(ValueError, match="a cannot be zero"):
wrapper_kwonly(0)
with pytest.raises(RuntimeError, match="b must be non-negative"):
wrapper_kwonly(5, b=-5)
with pytest.raises(TypeError, match="c must be 'valid'"):
wrapper_kwonly(5, c="invalid")
def test_metadata_preservation() -> None:
"""Test that function metadata is preserved when prototype is provided."""
def my_function(x: int, y: int = 10) -> int:
"""Document the function."""
return x + y
target = lambda *args: sum(args)
wrapper = make_kwargs_wrapper(target, ["x", "y"], arg_defaults=(10,), prototype=my_function)
assert wrapper.__name__ == "my_function" # ty: ignore[unresolved-attribute]
assert wrapper.__doc__ == "Document the function."
assert wrapper.__annotations__ == my_function.__annotations__
assert wrapper(5) == 15
def test_optimized_default_types() -> None:
"""Test that None, bool, and str defaults work correctly.
This test verifies the optimization where None and bool defaults are
directly embedded in the generated signature, while str defaults use
the MISSING sentinel for safety.
"""
def target(*args: Any) -> tuple[Any, ...]:
return args
# Test None default (should be optimized - directly embedded)
wrapper = make_kwargs_wrapper(target, ["a", "b", "c"], arg_defaults=(None,))
assert wrapper(1, 2) == (1, 2, None)
assert wrapper(1, 2, 3) == (1, 2, 3)
assert wrapper(1, 2, c=None) == (1, 2, None)
# Test bool defaults (should be optimized - directly embedded)
wrapper = make_kwargs_wrapper(target, ["a", "flag", "debug"], arg_defaults=(True, False))
assert wrapper(1) == (1, True, False)
assert wrapper(1, False) == (1, False, False)
assert wrapper(1, flag=False, debug=True) == (1, False, True)
# Test str default (should use MISSING sentinel for safety)
wrapper = make_kwargs_wrapper(target, ["a", "b", "name"], arg_defaults=("default",))
assert wrapper(1, 2) == (1, 2, "default")
assert wrapper(1, 2, "custom") == (1, 2, "custom")
assert wrapper(1, 2, name="custom") == (1, 2, "custom")
# Test keyword-only with None, bool, and str
wrapper = make_kwargs_wrapper(
target,
["a"],
kwonly_names=["b", "flag", "name"],
kwonly_defaults={"b": None, "flag": True, "name": "default"},
)
assert wrapper(1) == (1, None, True, "default")
assert wrapper(1, b=2) == (1, 2, True, "default")
assert wrapper(1, flag=False) == (1, None, False, "default")
assert wrapper(1, name="custom") == (1, None, True, "custom")
assert wrapper(1, b=2, flag=False, name="test") == (1, 2, False, "test")
def test_map_dataclass_to_tuple() -> None:
"""Test map_dataclass_to_tuple in make_kwargs_wrapper."""
@dataclasses.dataclass
class Config:
x: int
y: int
@dataclasses.dataclass
class Nested:
value: int
cfg: Config
def target(*args: Any) -> tuple[Any, ...]:
return args
# Basic: one dataclass arg converted
wrapper = make_kwargs_wrapper(target, ["a", "cfg"], map_dataclass_to_tuple=["cfg"])
result = wrapper(1, Config(x=10, y=20))
assert result == (1, (10, 20))
# Dataclass passed as keyword argument
result = wrapper(a=1, cfg=Config(x=3, y=4))
assert result == (1, (3, 4))
# Multiple dataclass args
wrapper = make_kwargs_wrapper(target, ["a", "b"], map_dataclass_to_tuple=["a", "b"])
result = wrapper(Config(x=1, y=2), Config(x=3, y=4))
assert result == ((1, 2), (3, 4))
# Nested dataclass (auto-recursion via type annotations)
wrapper = make_kwargs_wrapper(target, ["a", "nested"], map_dataclass_to_tuple=["nested"])
result = wrapper(1, Nested(value=5, cfg=Config(x=10, y=20)))
assert result == (1, (5, (10, 20)))
# Mixed: some args converted, others not
wrapper = make_kwargs_wrapper(target, ["a", "cfg", "b"], map_dataclass_to_tuple=["cfg"])
result = wrapper(1, Config(x=10, y=20), 3)
assert result == (1, (10, 20), 3)
# With defaults
default_cfg = Config(x=0, y=0)
wrapper = make_kwargs_wrapper(
target, ["a", "cfg"], arg_defaults=(default_cfg,), map_dataclass_to_tuple=["cfg"]
)
result = wrapper(1)
assert result == (1, (0, 0))
result = wrapper(1, Config(x=5, y=6))
assert result == (1, (5, 6))
# With keyword-only dataclass arg
wrapper = make_kwargs_wrapper(
target,
["a"],
kwonly_names=["cfg"],
kwonly_defaults={"cfg": Config(x=0, y=0)},
map_dataclass_to_tuple=["cfg"],
)
result = wrapper(1)
assert result == (1, (0, 0))
result = wrapper(1, cfg=Config(x=7, y=8))
assert result == (1, (7, 8))
# Empty list: no conversion
wrapper = make_kwargs_wrapper(target, ["a", "b"], map_dataclass_to_tuple=[])
cfg = Config(x=1, y=2)
result = wrapper(1, cfg)
assert result == (1, cfg)
assert result[1] is cfg # not converted
# Works with make_kwargs_wrapper_from_signature
def source_func(a: int, cfg: Config) -> None:
pass
wrapper = make_kwargs_wrapper_from_signature(
target, inspect.signature(source_func), map_dataclass_to_tuple=["cfg"]
)
result = wrapper(1, Config(x=10, y=20))
assert result == (1, (10, 20))