Coverage for tests / unit / misc / test_func.py: 96%
175 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-18 02:51 -0700
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-18 02:51 -0700
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)
14# TYPING: pyright really hates our decorators lol
15# pyright: reportCallIssue=false, reportArgumentType=false
18def test_process_kwarg_with_kwarg_passed() -> None:
19 @process_kwarg("x", typed_lambda(lambda x: x * 2, (int,), int))
20 def func(x: int = 1) -> int:
21 return x
23 assert func(x=3) == 6
26def test_process_kwarg_without_kwarg() -> None:
27 @process_kwarg("x", typed_lambda(lambda x: x * 2, (int,), int))
28 def func(x: int = 1) -> int:
29 return x
31 assert func() == 1
34def test_validate_kwarg_valid() -> None:
35 @validate_kwarg(
36 "y",
37 typed_lambda(lambda y: y > 0, (int,), bool),
38 "Value for {kwarg_name} must be positive, got {value}",
39 )
40 def func(y: int = 1) -> int:
41 return y
43 assert func(y=5) == 5
46def test_validate_kwarg_invalid_with_description() -> None:
47 @validate_kwarg(
48 "y", lambda y: y > 0, "Value for {kwarg_name} must be positive, got {value}"
49 )
50 def func(y: int = 1) -> int:
51 return y
53 with pytest.raises(ValueError, match="Value for y must be positive, got -3"):
54 func(y=-3)
57def test_replace_kwarg_replaces_value() -> None:
58 @replace_kwarg("z", is_none, "replaced")
59 def func(z: Optional[str] = None) -> str:
60 return z # type: ignore
62 assert func(z=None) == "replaced"
65def test_replace_kwarg_preserves_non_default() -> None:
66 @replace_kwarg("z", is_none, "replaced")
67 def func(z: Optional[str] = None) -> Optional[str]:
68 return z
70 assert func(z="hello") == "hello"
71 assert func(z=None) == "replaced"
72 assert func() is None
75def test_replace_kwarg_when_kwarg_not_passed() -> None:
76 @replace_kwarg("z", is_none, "replaced", replace_if_missing=True)
77 def func(z: Optional[str] = None) -> Optional[str]:
78 return z
80 assert func() == "replaced"
81 assert func(z=None) == "replaced"
82 assert func(z="hello") == "hello"
85def test_process_kwarg_processor_raises_exception() -> None:
86 """Test that if the processor lambda raises an exception, it propagates."""
88 @process_kwarg("x", lambda x: 1 / 0)
89 def func(x: int = 1) -> int:
90 return x
92 with pytest.raises(ZeroDivisionError):
93 func(x=5)
96def test_process_kwarg_with_positional_argument() -> None:
97 """Test that process_kwarg doesn't affect arguments passed positionally."""
99 @process_kwarg("x", typed_lambda(lambda x: x + 5, (int,), int))
100 def func(x: int) -> int:
101 return x
103 # Passing argument positionally; since it's not in kwargs, it won't be processed.
104 result: int = func(3)
105 assert result == 3
108@pytest.mark.skipif(
109 sys.version_info < (3, 10), reason="need `python >= 3.10` for `types.NoneType`"
110)
111def test_process_kwarg_processor_returns_none() -> None:
112 """Test that if the processor returns None, the function receives None."""
114 if sys.version_info >= (3, 10):
115 from types import NoneType
117 @process_kwarg("x", typed_lambda(lambda x: None, (int,), NoneType))
118 def func(x: Optional[int] = 5) -> Optional[int]:
119 return x
121 result: Optional[int] = func(x=100)
122 assert result is None
125# --- Additional tests for validate_kwarg ---
128def test_validate_kwarg_with_positional_argument() -> None:
129 """Test that validate_kwarg does not validate positional arguments.
131 Since the decorator checks only kwargs, positional arguments will bypass validation.
132 """
134 @validate_kwarg("x", lambda x: x > 0, "x must be > 0, got {value}")
135 def func(x: int) -> int:
136 return x
138 # x is passed positionally, so not in kwargs; validation is skipped.
139 result: int = func(0)
140 assert result == 0
143def test_validate_kwarg_with_none_value() -> None:
144 """Test validate_kwarg when None is passed and validator rejects None."""
146 @validate_kwarg("x", lambda x: x is not None, "x should not be None")
147 def func(x: Optional[int] = 1) -> Optional[int]:
148 return x
150 with pytest.raises(ValueError, match="x should not be None"):
151 func(x=None)
154def test_validate_kwarg_always_fail() -> None:
155 """Test that a validator that always fails triggers an error."""
157 @validate_kwarg("x", lambda x: False, "always fail")
158 def func(x: int = 1) -> int:
159 return x
161 with pytest.raises(ValueError, match="always fail"):
162 func(x=10)
165def test_validate_kwarg_multiple_kwargs() -> None:
166 """Test that validate_kwarg only validates the specified kwarg among multiple arguments."""
168 @validate_kwarg("y", lambda y: y < 100, "y must be < 100, got {value}")
169 def func(x: int, y: int = 1) -> Tuple[int, int]:
170 return (x, y)
172 # Valid case:
173 result: Tuple[int, int] = func(5, y=50)
174 assert result == (5, 50)
176 # Invalid y value (passed via kwargs)
177 with pytest.raises(ValueError, match="y must be < 100, got 150"):
178 func(5, y=150)
181def test_validate_kwarg_action_warn_multiple_calls() -> None:
182 """Test that when action is 'warn', multiple failures emit warnings without raising exceptions."""
184 @validate_kwarg(
185 "num",
186 lambda val: val > 0,
187 "num must be > 0, got {value}",
188 action=ErrorMode.EXCEPT,
189 )
190 def only_positive(num: int) -> int:
191 return num
193 # A good value should not trigger a warning.
194 assert only_positive(num=10) == 10
196 # Check that a warning is emitted for a bad value.
197 with pytest.raises(ValueError, match="num must be > 0, got -5"):
198 only_positive(num=-5)
201# --- Additional tests for replace_kwarg ---
204def test_replace_kwarg_with_positional_argument() -> None:
205 """Test that replace_kwarg does not act on positional arguments."""
207 @replace_kwarg(
208 "x",
209 typed_lambda(lambda x: x == 0, (int,), bool),
210 99,
211 )
212 def func(x: int) -> int:
213 return x
215 # Passing argument positionally; no replacement happens because it's not in kwargs.
216 result: int = func(0)
217 assert result == 0
220def test_replace_kwarg_no_if_missing() -> None:
221 """Test replace_kwarg with mutable types as default and replacement values."""
222 replacement_dict: Dict[str, int] = {"a": 1}
224 @replace_kwarg("d", is_none, replacement_dict)
225 def func(d: Optional[Dict[str, int]] = None) -> Optional[Dict[str, int]]:
226 return d
228 assert func() != replacement_dict
229 assert func(d=None) == replacement_dict
230 assert func(d={"a": 2}) != replacement_dict
233def test_replace_kwarg_if_missing() -> None:
234 """Test replace_kwarg with mutable types as default and replacement values."""
235 replacement_dict: Dict[str, int] = {"a": 1}
237 @replace_kwarg("d", is_none, replacement_dict, replace_if_missing=True)
238 def func(d: Optional[Dict[str, int]] = None) -> Optional[Dict[str, int]]:
239 return d
241 assert func() == replacement_dict
242 assert func(d=None) == replacement_dict
243 assert func(d={"a": 2}) != replacement_dict
246# --- Combined Decorator Tests ---
249def test_combined_decorators_with_missing_kwarg() -> None:
250 """Test that combined decorators do nothing if the target kwarg is missing.
252 Because the kwarg is not in kwargs, neither process_kwarg nor validate_kwarg acts.
253 """
255 @process_kwarg("x", typed_lambda(lambda x: x + 5, (int,), int))
256 @validate_kwarg("x", lambda x: x < 50, "x too high: {value}")
257 def func(x: int = 10) -> int:
258 return x
260 result: int = func()
261 assert result == 10
264def test_combined_decorators_with_positional_argument() -> None:
265 """Test combined decorators when the argument is passed positionally.
267 Since the argument is not in kwargs, the decorators do not trigger.
268 """
270 @validate_kwarg("x", lambda x: x < 50, "x too high: {value}")
271 @process_kwarg("x", typed_lambda(lambda x: x + 5, (int,), int))
272 def func(x: int = 10) -> int:
273 return x
275 result: int = func(45)
276 assert result == 45
279# --- Metadata Preservation Tests for All Decorators ---
282def test_process_kwarg_preserves_metadata() -> None:
283 """Test that process_kwarg preserves function metadata (__name__ and __doc__)."""
285 @process_kwarg("x", lambda x: x)
286 def func(x: int = 0) -> int:
287 """Process docstring."""
288 return x
290 assert func.__name__ == "func"
291 assert func.__doc__ == "Process docstring."
294def test_validate_kwarg_preserves_metadata() -> None:
295 """Test that validate_kwarg preserves function metadata (__name__ and __doc__)."""
297 @validate_kwarg("x", lambda x: x > 0)
298 def func(x: int = 1) -> int:
299 """Validate docstring."""
300 return x
302 assert func.__name__ == "func"
303 assert func.__doc__ == "Validate docstring."
306def test_replace_kwarg_preserves_metadata() -> None:
307 """Test that replace_kwarg preserves function metadata (__name__ and __doc__)."""
309 @replace_kwarg("x", is_none, 100)
310 def func(x: Optional[int] = None) -> Optional[int]:
311 """Replace docstring."""
312 return x
314 assert func.__name__ == "func"
315 assert func.__doc__ == "Replace docstring."
318def test_typed_lambda_int_int_to_int() -> None:
319 adder = typed_lambda(lambda x, y: x + y, (int, int), int)
320 result: int = adder(10, 20)
321 assert result == 30
322 assert adder.__annotations__ == {"x": int, "y": int, "return": int}
325def test_typed_lambda_int_str_to_str() -> None:
326 concat = typed_lambda(lambda x, s: str(x) + s, (int, str), str)
327 result: str = concat(3, "_apples")
328 assert result == "3_apples"
329 assert concat.__annotations__ == {"x": int, "s": str, "return": str}
332def test_typed_lambda_mismatched_params() -> None:
333 with pytest.raises(ValueError):
334 _ = typed_lambda(lambda x, y: x + y, (int,), int) # type: ignore[misc]
337def test_typed_lambda_runtime_behavior() -> None:
338 add_three = typed_lambda(
339 lambda x, y, z: x + y + z,
340 (int, int, int),
341 int,
342 )
343 result: int = add_three(1, 2, 3)
344 assert result == 6
347def test_typed_lambda_annotations_check() -> None:
348 any_lambda = typed_lambda(lambda a, b, c: [a, b, c], (str, float, bool), list)
349 expected = {
350 "a": str,
351 "b": float,
352 "c": bool,
353 "return": list,
354 }
355 assert any_lambda.__annotations__ == expected