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

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 

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 

19 

20 assert func(x=3) == 6 

21 

22 

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 

27 

28 assert func() == 1 

29 

30 

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 

39 

40 assert func(y=5) == 5 

41 

42 

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 

49 

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

51 func(y=-3) 

52 

53 

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 

58 

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

60 

61 

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 

66 

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

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

69 assert func() is None 

70 

71 

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 

76 

77 assert func() == "replaced" 

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

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

80 

81 

82def test_process_kwarg_processor_raises_exception() -> None: 

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

84 

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

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

87 return x 

88 

89 with pytest.raises(ZeroDivisionError): 

90 func(x=5) 

91 

92 

93def test_process_kwarg_with_positional_argument() -> None: 

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

95 

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

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

98 return x 

99 

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

101 result: int = func(3) 

102 assert result == 3 

103 

104 

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

110 

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

112 from types import NoneType 

113 

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

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

116 return x 

117 

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

119 assert result is None 

120 

121 

122# --- Additional tests for validate_kwarg --- 

123 

124 

125def test_validate_kwarg_with_positional_argument() -> None: 

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

127 

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

129 """ 

130 

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

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

133 return x 

134 

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

136 result: int = func(0) 

137 assert result == 0 

138 

139 

140def test_validate_kwarg_with_none_value() -> None: 

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

142 

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 

146 

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

148 func(x=None) 

149 

150 

151def test_validate_kwarg_always_fail() -> None: 

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

153 

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

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

156 return x 

157 

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

159 func(x=10) 

160 

161 

162def test_validate_kwarg_multiple_kwargs() -> None: 

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

164 

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) 

168 

169 # Valid case: 

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

171 assert result == (5, 50) 

172 

173 # Invalid y value (passed via kwargs) 

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

175 func(5, y=150) 

176 

177 

178def test_validate_kwarg_action_warn_multiple_calls() -> None: 

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

180 

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 

189 

190 # A good value should not trigger a warning. 

191 assert only_positive(num=10) == 10 

192 

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) 

196 

197 

198# --- Additional tests for replace_kwarg --- 

199 

200 

201def test_replace_kwarg_with_positional_argument() -> None: 

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

203 

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 

211 

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

213 result: int = func(0) 

214 assert result == 0 

215 

216 

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} 

220 

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

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

223 return d 

224 

225 assert func() != replacement_dict 

226 assert func(d=None) == replacement_dict 

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

228 

229 

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} 

233 

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 

237 

238 assert func() == replacement_dict 

239 assert func(d=None) == replacement_dict 

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

241 

242 

243# --- Combined Decorator Tests --- 

244 

245 

246def test_combined_decorators_with_missing_kwarg() -> None: 

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

248 

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

250 """ 

251 

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 

256 

257 result: int = func() 

258 assert result == 10 

259 

260 

261def test_combined_decorators_with_positional_argument() -> None: 

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

263 

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

265 """ 

266 

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 

271 

272 result: int = func(45) 

273 assert result == 45 

274 

275 

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

277 

278 

279def test_process_kwarg_preserves_metadata() -> None: 

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

281 

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

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

284 """Process docstring.""" 

285 return x 

286 

287 assert func.__name__ == "func" 

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

289 

290 

291def test_validate_kwarg_preserves_metadata() -> None: 

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

293 

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

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

296 """Validate docstring.""" 

297 return x 

298 

299 assert func.__name__ == "func" 

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

301 

302 

303def test_replace_kwarg_preserves_metadata() -> None: 

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

305 

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

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

308 """Replace docstring.""" 

309 return x 

310 

311 assert func.__name__ == "func" 

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

313 

314 

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} 

320 

321 

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} 

327 

328 

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] 

332 

333 

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 

342 

343 

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