| # 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)) |