Coverage for glotter/auto_gen_test.py: 99%

170 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-09-13 19:09 +0000

1from functools import partial 

2from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple 

3 

4from pydantic import ( 

5 BaseModel, 

6 ValidationError, 

7 conlist, 

8 constr, 

9 root_validator, 

10 validator, 

11) 

12from pydantic.error_wrappers import ErrorWrapper 

13 

14from glotter.utils import indent, quote 

15 

16TransformationScalarFuncT = Callable[[str, str], Tuple[str, str]] 

17TransformationDictFuncT = Callable[[List[str], str, str], Tuple[str, str]] 

18 

19 

20class AutoGenParam(BaseModel): 

21 """Object used to auto-generated a test parameter""" 

22 

23 name: str = "" 

24 input: Optional[str] = None 

25 expected: Any 

26 

27 @validator("expected") 

28 def validate_expected(cls, value): 

29 """ 

30 Validate expected value 

31 

32 :param value: Expected value 

33 :return: Original expected value 

34 :raises: :exc:`ValueError` if invalid expected value 

35 """ 

36 

37 if isinstance(value, dict): 

38 if not value: 

39 raise ValueError("too few items") 

40 

41 if len(value) > 1: 

42 raise ValueError("too many items") 

43 

44 key, item = tuple(*value.items()) 

45 if key == "exec": 

46 if not isinstance(item, str): 

47 raise ValidationError( 

48 [ 

49 ErrorWrapper(ValueError("str type expected"), loc="exec"), 

50 ], 

51 model=cls, 

52 ) 

53 if not item: 

54 raise ValidationError( 

55 [ErrorWrapper(ValueError("value must not be empty"), loc="exec")], 

56 model=cls, 

57 ) 

58 elif key != "self": 

59 raise ValueError('invalid "expected" type') 

60 elif isinstance(value, list): 

61 _validate_str_list(cls, value) 

62 elif not isinstance(value, str): 

63 raise ValueError("str, list, or dict type expected") 

64 

65 return value 

66 

67 def get_pytest_param(self) -> str: 

68 """ 

69 Get pytest parameter string 

70 

71 :return: pytest parameter string if name is not empty, empty string otherwise 

72 """ 

73 

74 if not self.name: 

75 return "" 

76 

77 input_param = self.input 

78 if isinstance(input_param, str): 

79 input_param = quote(input_param) 

80 

81 expected_output = self.expected 

82 if isinstance(expected_output, str): 

83 expected_output = quote(expected_output) 

84 

85 return f"pytest.param({input_param}, {expected_output}, id={quote(self.name)}),\n" 

86 

87 

88def _validate_str_list(cls, values, item_name: str = ""): 

89 loc = () 

90 if item_name: 

91 loc += (item_name,) 

92 

93 if not isinstance(values, list): 

94 errors = [ErrorWrapper(ValueError("value is not a valid list"), loc=loc)] 

95 else: 

96 errors = [ 

97 ErrorWrapper(ValueError("str type expected"), loc=loc + (index,)) 

98 for index, value in enumerate(values) 

99 if not isinstance(value, str) 

100 ] 

101 

102 if errors: 

103 raise ValidationError(errors, model=cls) 

104 

105 

106def _append_method_to_actual(method: str, actual_var: str, expected_var) -> Tuple[str, str]: 

107 return f"{actual_var}.{method}()", expected_var 

108 

109 

110def _append_method_to_expected(method: str, actual_var: str, expected_var: str) -> Tuple[str, str]: 

111 return actual_var, f"{expected_var}.{method}()" 

112 

113 

114def _remove_chars(values: List[str], actual_var: str, expected_var: str) -> Tuple[str, str]: 

115 for value in values: 

116 actual_var += f'.replace({quote(value)}, "")' 

117 

118 return actual_var, expected_var 

119 

120 

121def _strip_chars(values: List[str], actual_var: str, expected_var: str) -> Tuple[str, str]: 

122 for value in values: 

123 actual_var += f".strip({quote(value)})" 

124 

125 return actual_var, expected_var 

126 

127 

128def _unique_sort(actual_var, expected_var): 

129 return f"sorted(set({actual_var}))", f"sorted(set({expected_var}))" 

130 

131 

132class AutoGenTest(BaseModel): 

133 """Object used to auto-generated a test""" 

134 

135 name: constr(strict=True, min_length=1, regex="^[a-zA-Z][0-9a-zA-Z_]*$") 

136 requires_parameters: bool = False 

137 inputs: conlist(str, min_items=1) = ["Input"] 

138 params: conlist(AutoGenParam, min_items=1) 

139 transformations: List[Any] = [] 

140 

141 SCALAR_TRANSFORMATION_FUNCS: ClassVar[Dict[str, TransformationScalarFuncT]] = { 

142 "strip": partial(_append_method_to_actual, "strip"), 

143 "splitlines": partial(_append_method_to_actual, "splitlines"), 

144 "lower": partial(_append_method_to_actual, "lower"), 

145 "any_order": _unique_sort, 

146 "strip_expected": partial(_append_method_to_expected, "strip"), 

147 "splitlines_expected": partial(_append_method_to_expected, "splitlines"), 

148 } 

149 DICT_TRANSFORMATION_FUNCS: ClassVar[Dict[str, TransformationDictFuncT]] = { 

150 "remove": _remove_chars, 

151 "strip": _strip_chars, 

152 } 

153 

154 @validator("inputs", each_item=True, pre=True, always=True) 

155 def validate_inputs(cls, value): 

156 """ 

157 Validate each input 

158 

159 :param value: Input to validate 

160 :return: Original input 

161 :raises: :exc:`ValueError` if input invalid 

162 """ 

163 

164 if not isinstance(value, str): 

165 raise ValueError("input is not a str") 

166 

167 return value 

168 

169 @validator("params", each_item=True, pre=True, always=True) 

170 def validate_params(cls, value, values): 

171 """ 

172 Validate each parameter 

173 

174 :param value: Parameter to validate 

175 :param values: Test item 

176 :return: Original parameter 

177 :raises: :exc:`ValueError` if project requires parameters but no input, no name, 

178 or empty name 

179 """ 

180 

181 if values.get("requires_parameters"): 

182 errors = [] 

183 if "name" not in value: 

184 errors.append( 

185 ErrorWrapper( 

186 ValueError("field is required when parameters required"), 

187 loc="name", 

188 ) 

189 ) 

190 elif isinstance(value["name"], str) and not value["name"]: 

191 errors.append( 

192 ErrorWrapper( 

193 ValueError("value must not be empty when parameters required"), 

194 loc="name", 

195 ) 

196 ) 

197 

198 if "input" not in value: 

199 errors.append( 

200 ErrorWrapper( 

201 ValueError("field is required when parameters required"), 

202 loc="input", 

203 ) 

204 ) 

205 

206 if "expected" not in value: 

207 errors.append( 

208 ErrorWrapper( 

209 ValueError("field is required when parameters required"), 

210 loc="expected", 

211 ) 

212 ) 

213 

214 if errors: 

215 raise ValidationError(errors, model=cls) 

216 

217 return value 

218 

219 @validator("transformations", each_item=True, pre=True) 

220 def validate_transformation(cls, value): 

221 """ 

222 Validate each transformation 

223 

224 :param value: Transformation to validate 

225 :return: Original value 

226 :raises: :exc:`ValueError` if invalid transformation 

227 """ 

228 

229 if isinstance(value, str): 

230 if value not in cls.SCALAR_TRANSFORMATION_FUNCS: 

231 raise ValueError(f'invalid transformation "{value}"') 

232 elif isinstance(value, dict): 

233 key = str(*value) 

234 if key not in cls.DICT_TRANSFORMATION_FUNCS: 

235 raise ValueError(f'invalid transformation "{key}"') 

236 

237 _validate_str_list(cls, value[key], key) 

238 else: 

239 raise ValueError("str or dict type expected") 

240 

241 return value 

242 

243 def transform_vars(self) -> Tuple[str, str]: 

244 """ 

245 Transform variables using the specified transformations 

246 

247 :return: Transformed actual and expected variables 

248 """ 

249 

250 actual_var = "actual" 

251 expected_var = "expected" 

252 for transfomation in self.transformations: 

253 if isinstance(transfomation, str): 

254 actual_var, expected_var = self.SCALAR_TRANSFORMATION_FUNCS[transfomation]( 

255 actual_var, expected_var 

256 ) 

257 else: 

258 key, item = tuple(*transfomation.items()) 

259 actual_var, expected_var = self.DICT_TRANSFORMATION_FUNCS[key]( 

260 item, actual_var, expected_var 

261 ) 

262 

263 return actual_var, expected_var 

264 

265 def get_pytest_params(self) -> str: 

266 """ 

267 Get pytest parameters 

268 

269 :return: pytest parameters 

270 """ 

271 

272 if not self.requires_parameters: 

273 return "" 

274 

275 pytest_params = "".join( 

276 indent(param.get_pytest_param(), 8) for param in self.params 

277 ).strip() 

278 return f"""\ 

279@pytest.mark.parametrize( 

280 ("in_params", "expected"), 

281 [ 

282 {pytest_params} 

283 ] 

284) 

285""" 

286 

287 def get_test_function_and_run(self, project_name_underscores: str) -> str: 

288 """ 

289 Get test function and run command 

290 

291 :param project_name_underscores: Project name with underscores between each word 

292 :return: Test function and run command 

293 """ 

294 

295 func_params = "" 

296 run_param = "" 

297 if self.requires_parameters: 

298 func_params = "in_params, expected, " 

299 run_param = "params=in_params" 

300 

301 return f"""\ 

302def test_{self.name}({func_params}{project_name_underscores}): 

303 actual = {project_name_underscores}.run({run_param}) 

304""" 

305 

306 def get_expected_output(self, project_name_underscores: str) -> str: 

307 """ 

308 Get test code that gets the expected output 

309 

310 :param project_name_underscores: Project name with underscores between each word 

311 :return: Test code that gets the expected output 

312 

313 """ 

314 

315 if self.requires_parameters: 

316 return "" 

317 

318 expected_output = self.params[0].expected 

319 if isinstance(expected_output, str): 

320 expected_output = quote(expected_output) 

321 elif isinstance(expected_output, dict): 

322 return _get_expected_file(project_name_underscores, expected_output) 

323 

324 return f"expected = {expected_output}\n" 

325 

326 def generate_test(self, project_name_underscores: str) -> str: 

327 """ 

328 Generate test code 

329 

330 :param project_name_underscores: Project name with underscores between each word 

331 :return: Test code 

332 """ 

333 

334 test_code = "@project_test(PROJECT_NAME)\n" 

335 test_code += self.get_pytest_params() 

336 test_code += self.get_test_function_and_run(project_name_underscores) 

337 test_code += indent(self.get_expected_output(project_name_underscores), 4) 

338 actual_var, expected_var = self.transform_vars() 

339 test_code += indent(_get_assert(actual_var, expected_var, self.params[0].expected), 4) 

340 return test_code 

341 

342 

343def _get_expected_file(project_name_underscores: str, expected_output: Dict[str, str]) -> str: 

344 if "exec" in expected_output: 

345 script = quote(expected_output["exec"]) 

346 return f"expected = {project_name_underscores}.exec({script})\n" 

347 

348 test_code = f"""\ 

349with open({project_name_underscores}.full_path, "r", encoding="utf-8") as file: 

350 expected = file.read() 

351""" 

352 

353 if "self" in expected_output: 353 ↛ 362line 353 didn't jump to line 362 because the condition on line 353 was always true

354 test_code += """\ 

355diff_len = len(actual) - len(expected) 

356if diff_len > 0: 

357 expected += "\\n" 

358elif diff_len < 0: 

359 actual += "\\n" 

360""" 

361 

362 return test_code 

363 

364 

365def _get_assert(actual_var: str, expected_var: str, expected_output) -> str: 

366 if isinstance(expected_output, list): 

367 return f"""\ 

368actual_list = {actual_var} 

369expected_list = {expected_var} 

370assert len(actual_list) == len(expected_list), "Length not equal" 

371for index in range(len(expected_list)): 

372 assert actual_list[index] == expected_list[index], f"Item {{index + 1}} is not equal" 

373""" 

374 

375 test_code = "" 

376 if actual_var != "actual": 

377 test_code += f"actual = {actual_var}\n" 

378 

379 if expected_var != "expected": 

380 test_code += f"expected = {expected_var}\n" 

381 

382 return f"{test_code}assert actual == expected\n" 

383 

384 

385class AutoGenUseTests(BaseModel): 

386 """Object used to specify what tests to use""" 

387 

388 name: str 

389 search: constr(strict=True, regex="^[0-9a-zA-Z_]*$") = "" 

390 replace: constr(strict=True, regex="^[0-9a-zA-Z_]*$") = "" 

391 

392 @root_validator(pre=True) 

393 def validate_search_with_replace(cls, values): 

394 """ 

395 Validate that if either search or replace is specified, both must be specified 

396 

397 :param values: Values to validate 

398 :return: Original values 

399 :raise: `exc`:ValueError if either search or replace is specified, both are specified 

400 """ 

401 

402 if "search" in values and "replace" not in values: 

403 raise ValueError('"search" item specified without "replace" item') 

404 

405 if "search" not in values and "replace" in values: 

406 raise ValueError('"replace" item specified without "search" item') 

407 

408 return values