Coverage for tests/unit/misc/test_func.py: 96%
175 statements
« prev ^ index » next coverage.py v7.6.1, created at 2025-04-04 03:33 -0600
« prev ^ index » next coverage.py v7.6.1, created at 2025-04-04 03:33 -0600
1from __future__ import annotations
2import sys
3from typing import Dict, Optional, Tuple
4import pytest
5from muutils.errormode import ErrorMode
6from muutils.misc.func import (
7 is_none,
8 process_kwarg,
9 replace_kwarg,
10 typed_lambda,
11 validate_kwarg,
12)
15def test_process_kwarg_with_kwarg_passed() -> None:
16 @process_kwarg("x", typed_lambda(lambda x: x * 2, (int,), int))
17 def func(x: int = 1) -> int:
18 return x
20 assert func(x=3) == 6
23def test_process_kwarg_without_kwarg() -> None:
24 @process_kwarg("x", typed_lambda(lambda x: x * 2, (int,), int))
25 def func(x: int = 1) -> int:
26 return x
28 assert func() == 1
31def test_validate_kwarg_valid() -> None:
32 @validate_kwarg(
33 "y",
34 typed_lambda(lambda y: y > 0, (int,), bool),
35 "Value for {kwarg_name} must be positive, got {value}",
36 )
37 def func(y: int = 1) -> int:
38 return y
40 assert func(y=5) == 5
43def test_validate_kwarg_invalid_with_description() -> None:
44 @validate_kwarg(
45 "y", lambda y: y > 0, "Value for {kwarg_name} must be positive, got {value}"
46 )
47 def func(y: int = 1) -> int:
48 return y
50 with pytest.raises(ValueError, match="Value for y must be positive, got -3"):
51 func(y=-3)
54def test_replace_kwarg_replaces_value() -> None:
55 @replace_kwarg("z", is_none, "replaced")
56 def func(z: Optional[str] = None) -> str:
57 return z # type: ignore
59 assert func(z=None) == "replaced"
62def test_replace_kwarg_preserves_non_default() -> None:
63 @replace_kwarg("z", is_none, "replaced")
64 def func(z: Optional[str] = None) -> Optional[str]:
65 return z
67 assert func(z="hello") == "hello"
68 assert func(z=None) == "replaced"
69 assert func() is None
72def test_replace_kwarg_when_kwarg_not_passed() -> None:
73 @replace_kwarg("z", is_none, "replaced", replace_if_missing=True)
74 def func(z: Optional[str] = None) -> Optional[str]:
75 return z
77 assert func() == "replaced"
78 assert func(z=None) == "replaced"
79 assert func(z="hello") == "hello"
82def test_process_kwarg_processor_raises_exception() -> None:
83 """Test that if the processor lambda raises an exception, it propagates."""
85 @process_kwarg("x", lambda x: 1 / 0)
86 def func(x: int = 1) -> int:
87 return x
89 with pytest.raises(ZeroDivisionError):
90 func(x=5)
93def test_process_kwarg_with_positional_argument() -> None:
94 """Test that process_kwarg doesn't affect arguments passed positionally."""
96 @process_kwarg("x", typed_lambda(lambda x: x + 5, (int,), int))
97 def func(x: int) -> int:
98 return x
100 # Passing argument positionally; since it's not in kwargs, it won't be processed.
101 result: int = func(3)
102 assert result == 3
105@pytest.mark.skipif(
106 sys.version_info < (3, 10), reason="need `python >= 3.10` for `types.NoneType`"
107)
108def test_process_kwarg_processor_returns_none() -> None:
109 """Test that if the processor returns None, the function receives None."""
111 if sys.version_info >= (3, 10):
112 from types import NoneType
114 @process_kwarg("x", typed_lambda(lambda x: None, (int,), NoneType))
115 def func(x: Optional[int] = 5) -> Optional[int]:
116 return x
118 result: Optional[int] = func(x=100)
119 assert result is None
122# --- Additional tests for validate_kwarg ---
125def test_validate_kwarg_with_positional_argument() -> None:
126 """Test that validate_kwarg does not validate positional arguments.
128 Since the decorator checks only kwargs, positional arguments will bypass validation.
129 """
131 @validate_kwarg("x", lambda x: x > 0, "x must be > 0, got {value}")
132 def func(x: int) -> int:
133 return x
135 # x is passed positionally, so not in kwargs; validation is skipped.
136 result: int = func(0)
137 assert result == 0
140def test_validate_kwarg_with_none_value() -> None:
141 """Test validate_kwarg when None is passed and validator rejects None."""
143 @validate_kwarg("x", lambda x: x is not None, "x should not be None")
144 def func(x: Optional[int] = 1) -> Optional[int]:
145 return x
147 with pytest.raises(ValueError, match="x should not be None"):
148 func(x=None)
151def test_validate_kwarg_always_fail() -> None:
152 """Test that a validator that always fails triggers an error."""
154 @validate_kwarg("x", lambda x: False, "always fail")
155 def func(x: int = 1) -> int:
156 return x
158 with pytest.raises(ValueError, match="always fail"):
159 func(x=10)
162def test_validate_kwarg_multiple_kwargs() -> None:
163 """Test that validate_kwarg only validates the specified kwarg among multiple arguments."""
165 @validate_kwarg("y", lambda y: y < 100, "y must be < 100, got {value}")
166 def func(x: int, y: int = 1) -> Tuple[int, int]:
167 return (x, y)
169 # Valid case:
170 result: Tuple[int, int] = func(5, y=50)
171 assert result == (5, 50)
173 # Invalid y value (passed via kwargs)
174 with pytest.raises(ValueError, match="y must be < 100, got 150"):
175 func(5, y=150)
178def test_validate_kwarg_action_warn_multiple_calls() -> None:
179 """Test that when action is 'warn', multiple failures emit warnings without raising exceptions."""
181 @validate_kwarg(
182 "num",
183 lambda val: val > 0,
184 "num must be > 0, got {value}",
185 action=ErrorMode.EXCEPT,
186 )
187 def only_positive(num: int) -> int:
188 return num
190 # A good value should not trigger a warning.
191 assert only_positive(num=10) == 10
193 # Check that a warning is emitted for a bad value.
194 with pytest.raises(ValueError, match="num must be > 0, got -5"):
195 only_positive(num=-5)
198# --- Additional tests for replace_kwarg ---
201def test_replace_kwarg_with_positional_argument() -> None:
202 """Test that replace_kwarg does not act on positional arguments."""
204 @replace_kwarg(
205 "x",
206 typed_lambda(lambda x: x == 0, (int,), bool),
207 99,
208 )
209 def func(x: int) -> int:
210 return x
212 # Passing argument positionally; no replacement happens because it's not in kwargs.
213 result: int = func(0)
214 assert result == 0
217def test_replace_kwarg_no_if_missing() -> None:
218 """Test replace_kwarg with mutable types as default and replacement values."""
219 replacement_dict: Dict[str, int] = {"a": 1}
221 @replace_kwarg("d", is_none, replacement_dict)
222 def func(d: Optional[Dict[str, int]] = None) -> Optional[Dict[str, int]]:
223 return d
225 assert func() != replacement_dict
226 assert func(d=None) == replacement_dict
227 assert func(d={"a": 2}) != replacement_dict
230def test_replace_kwarg_if_missing() -> None:
231 """Test replace_kwarg with mutable types as default and replacement values."""
232 replacement_dict: Dict[str, int] = {"a": 1}
234 @replace_kwarg("d", is_none, replacement_dict, replace_if_missing=True)
235 def func(d: Optional[Dict[str, int]] = None) -> Optional[Dict[str, int]]:
236 return d
238 assert func() == replacement_dict
239 assert func(d=None) == replacement_dict
240 assert func(d={"a": 2}) != replacement_dict
243# --- Combined Decorator Tests ---
246def test_combined_decorators_with_missing_kwarg() -> None:
247 """Test that combined decorators do nothing if the target kwarg is missing.
249 Because the kwarg is not in kwargs, neither process_kwarg nor validate_kwarg acts.
250 """
252 @process_kwarg("x", typed_lambda(lambda x: x + 5, (int,), int))
253 @validate_kwarg("x", lambda x: x < 50, "x too high: {value}")
254 def func(x: int = 10) -> int:
255 return x
257 result: int = func()
258 assert result == 10
261def test_combined_decorators_with_positional_argument() -> None:
262 """Test combined decorators when the argument is passed positionally.
264 Since the argument is not in kwargs, the decorators do not trigger.
265 """
267 @validate_kwarg("x", lambda x: x < 50, "x too high: {value}")
268 @process_kwarg("x", typed_lambda(lambda x: x + 5, (int,), int))
269 def func(x: int = 10) -> int:
270 return x
272 result: int = func(45)
273 assert result == 45
276# --- Metadata Preservation Tests for All Decorators ---
279def test_process_kwarg_preserves_metadata() -> None:
280 """Test that process_kwarg preserves function metadata (__name__ and __doc__)."""
282 @process_kwarg("x", lambda x: x)
283 def func(x: int = 0) -> int:
284 """Process docstring."""
285 return x
287 assert func.__name__ == "func"
288 assert func.__doc__ == "Process docstring."
291def test_validate_kwarg_preserves_metadata() -> None:
292 """Test that validate_kwarg preserves function metadata (__name__ and __doc__)."""
294 @validate_kwarg("x", lambda x: x > 0)
295 def func(x: int = 1) -> int:
296 """Validate docstring."""
297 return x
299 assert func.__name__ == "func"
300 assert func.__doc__ == "Validate docstring."
303def test_replace_kwarg_preserves_metadata() -> None:
304 """Test that replace_kwarg preserves function metadata (__name__ and __doc__)."""
306 @replace_kwarg("x", is_none, 100)
307 def func(x: Optional[int] = None) -> Optional[int]:
308 """Replace docstring."""
309 return x
311 assert func.__name__ == "func"
312 assert func.__doc__ == "Replace docstring."
315def test_typed_lambda_int_int_to_int() -> None:
316 adder = typed_lambda(lambda x, y: x + y, (int, int), int)
317 result: int = adder(10, 20)
318 assert result == 30
319 assert adder.__annotations__ == {"x": int, "y": int, "return": int}
322def test_typed_lambda_int_str_to_str() -> None:
323 concat = typed_lambda(lambda x, s: str(x) + s, (int, str), str)
324 result: str = concat(3, "_apples")
325 assert result == "3_apples"
326 assert concat.__annotations__ == {"x": int, "s": str, "return": str}
329def test_typed_lambda_mismatched_params() -> None:
330 with pytest.raises(ValueError):
331 _ = typed_lambda(lambda x, y: x + y, (int,), int) # type: ignore[misc]
334def test_typed_lambda_runtime_behavior() -> None:
335 add_three = typed_lambda(
336 lambda x, y, z: x + y + z,
337 (int, int, int),
338 int,
339 )
340 result: int = add_three(1, 2, 3)
341 assert result == 6
344def test_typed_lambda_annotations_check() -> None:
345 any_lambda = typed_lambda(lambda a, b, c: [a, b, c], (str, float, bool), list)
346 expected = {
347 "a": str,
348 "b": float,
349 "c": bool,
350 "return": list,
351 }
352 assert any_lambda.__annotations__ == expected