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

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) 

13 

14# TYPING: pyright really hates our decorators lol 

15# pyright: reportCallIssue=false, reportArgumentType=false 

16 

17 

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 

22 

23 assert func(x=3) == 6 

24 

25 

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 

30 

31 assert func() == 1 

32 

33 

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 

42 

43 assert func(y=5) == 5 

44 

45 

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 

52 

53 with pytest.raises(ValueError, match="Value for y must be positive, got -3"): 

54 func(y=-3) 

55 

56 

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 

61 

62 assert func(z=None) == "replaced" 

63 

64 

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 

69 

70 assert func(z="hello") == "hello" 

71 assert func(z=None) == "replaced" 

72 assert func() is None 

73 

74 

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 

79 

80 assert func() == "replaced" 

81 assert func(z=None) == "replaced" 

82 assert func(z="hello") == "hello" 

83 

84 

85def test_process_kwarg_processor_raises_exception() -> None: 

86 """Test that if the processor lambda raises an exception, it propagates.""" 

87 

88 @process_kwarg("x", lambda x: 1 / 0) 

89 def func(x: int = 1) -> int: 

90 return x 

91 

92 with pytest.raises(ZeroDivisionError): 

93 func(x=5) 

94 

95 

96def test_process_kwarg_with_positional_argument() -> None: 

97 """Test that process_kwarg doesn't affect arguments passed positionally.""" 

98 

99 @process_kwarg("x", typed_lambda(lambda x: x + 5, (int,), int)) 

100 def func(x: int) -> int: 

101 return x 

102 

103 # Passing argument positionally; since it's not in kwargs, it won't be processed. 

104 result: int = func(3) 

105 assert result == 3 

106 

107 

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.""" 

113 

114 if sys.version_info >= (3, 10): 

115 from types import NoneType 

116 

117 @process_kwarg("x", typed_lambda(lambda x: None, (int,), NoneType)) 

118 def func(x: Optional[int] = 5) -> Optional[int]: 

119 return x 

120 

121 result: Optional[int] = func(x=100) 

122 assert result is None 

123 

124 

125# --- Additional tests for validate_kwarg --- 

126 

127 

128def test_validate_kwarg_with_positional_argument() -> None: 

129 """Test that validate_kwarg does not validate positional arguments. 

130 

131 Since the decorator checks only kwargs, positional arguments will bypass validation. 

132 """ 

133 

134 @validate_kwarg("x", lambda x: x > 0, "x must be > 0, got {value}") 

135 def func(x: int) -> int: 

136 return x 

137 

138 # x is passed positionally, so not in kwargs; validation is skipped. 

139 result: int = func(0) 

140 assert result == 0 

141 

142 

143def test_validate_kwarg_with_none_value() -> None: 

144 """Test validate_kwarg when None is passed and validator rejects None.""" 

145 

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 

149 

150 with pytest.raises(ValueError, match="x should not be None"): 

151 func(x=None) 

152 

153 

154def test_validate_kwarg_always_fail() -> None: 

155 """Test that a validator that always fails triggers an error.""" 

156 

157 @validate_kwarg("x", lambda x: False, "always fail") 

158 def func(x: int = 1) -> int: 

159 return x 

160 

161 with pytest.raises(ValueError, match="always fail"): 

162 func(x=10) 

163 

164 

165def test_validate_kwarg_multiple_kwargs() -> None: 

166 """Test that validate_kwarg only validates the specified kwarg among multiple arguments.""" 

167 

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) 

171 

172 # Valid case: 

173 result: Tuple[int, int] = func(5, y=50) 

174 assert result == (5, 50) 

175 

176 # Invalid y value (passed via kwargs) 

177 with pytest.raises(ValueError, match="y must be < 100, got 150"): 

178 func(5, y=150) 

179 

180 

181def test_validate_kwarg_action_warn_multiple_calls() -> None: 

182 """Test that when action is 'warn', multiple failures emit warnings without raising exceptions.""" 

183 

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 

192 

193 # A good value should not trigger a warning. 

194 assert only_positive(num=10) == 10 

195 

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) 

199 

200 

201# --- Additional tests for replace_kwarg --- 

202 

203 

204def test_replace_kwarg_with_positional_argument() -> None: 

205 """Test that replace_kwarg does not act on positional arguments.""" 

206 

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 

214 

215 # Passing argument positionally; no replacement happens because it's not in kwargs. 

216 result: int = func(0) 

217 assert result == 0 

218 

219 

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} 

223 

224 @replace_kwarg("d", is_none, replacement_dict) 

225 def func(d: Optional[Dict[str, int]] = None) -> Optional[Dict[str, int]]: 

226 return d 

227 

228 assert func() != replacement_dict 

229 assert func(d=None) == replacement_dict 

230 assert func(d={"a": 2}) != replacement_dict 

231 

232 

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} 

236 

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 

240 

241 assert func() == replacement_dict 

242 assert func(d=None) == replacement_dict 

243 assert func(d={"a": 2}) != replacement_dict 

244 

245 

246# --- Combined Decorator Tests --- 

247 

248 

249def test_combined_decorators_with_missing_kwarg() -> None: 

250 """Test that combined decorators do nothing if the target kwarg is missing. 

251 

252 Because the kwarg is not in kwargs, neither process_kwarg nor validate_kwarg acts. 

253 """ 

254 

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 

259 

260 result: int = func() 

261 assert result == 10 

262 

263 

264def test_combined_decorators_with_positional_argument() -> None: 

265 """Test combined decorators when the argument is passed positionally. 

266 

267 Since the argument is not in kwargs, the decorators do not trigger. 

268 """ 

269 

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 

274 

275 result: int = func(45) 

276 assert result == 45 

277 

278 

279# --- Metadata Preservation Tests for All Decorators --- 

280 

281 

282def test_process_kwarg_preserves_metadata() -> None: 

283 """Test that process_kwarg preserves function metadata (__name__ and __doc__).""" 

284 

285 @process_kwarg("x", lambda x: x) 

286 def func(x: int = 0) -> int: 

287 """Process docstring.""" 

288 return x 

289 

290 assert func.__name__ == "func" 

291 assert func.__doc__ == "Process docstring." 

292 

293 

294def test_validate_kwarg_preserves_metadata() -> None: 

295 """Test that validate_kwarg preserves function metadata (__name__ and __doc__).""" 

296 

297 @validate_kwarg("x", lambda x: x > 0) 

298 def func(x: int = 1) -> int: 

299 """Validate docstring.""" 

300 return x 

301 

302 assert func.__name__ == "func" 

303 assert func.__doc__ == "Validate docstring." 

304 

305 

306def test_replace_kwarg_preserves_metadata() -> None: 

307 """Test that replace_kwarg preserves function metadata (__name__ and __doc__).""" 

308 

309 @replace_kwarg("x", is_none, 100) 

310 def func(x: Optional[int] = None) -> Optional[int]: 

311 """Replace docstring.""" 

312 return x 

313 

314 assert func.__name__ == "func" 

315 assert func.__doc__ == "Replace docstring." 

316 

317 

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} 

323 

324 

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} 

330 

331 

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] 

335 

336 

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 

345 

346 

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