Coverage for glotter/auto_gen_test.py: 99%

170 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-04-12 02:25 +0000

1# pylint hates pydantic 

2# pylint: disable=E0213,E0611 

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

4from functools import partial 

5 

6from pydantic import ( 

7 BaseModel, 

8 validator, 

9 root_validator, 

10 constr, 

11 conlist, 

12 ValidationError, 

13) 

14from pydantic.error_wrappers import ErrorWrapper 

15 

16from glotter.utils import quote, indent 

17 

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

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

20 

21 

22class AutoGenParam(BaseModel): 

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

24 

25 name: str = "" 

26 input: Optional[str] = None 

27 expected: Any 

28 

29 @validator("expected") 

30 def validate_expected(cls, value): 

31 """ 

32 Validate expected value 

33 

34 :param value: Expected value 

35 :return: Original expected value 

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

37 """ 

38 

39 if isinstance(value, dict): 

40 if not value: 

41 raise ValueError("too few items") 

42 

43 if len(value) > 1: 

44 raise ValueError("too many items") 

45 

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

47 if key == "exec": 

48 if not isinstance(item, str): 

49 raise ValidationError( 

50 [ 

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

52 ], 

53 model=cls, 

54 ) 

55 if not item: 

56 raise ValidationError( 

57 [ 

58 ErrorWrapper( 

59 ValueError("value must not be empty"), loc="exec" 

60 ) 

61 ], 

62 model=cls, 

63 ) 

64 elif key != "self": 

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

66 elif isinstance(value, list): 

67 _validate_str_list(cls, value) 

68 elif not isinstance(value, str): 

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

70 

71 return value 

72 

73 def get_pytest_param(self) -> str: 

74 """ 

75 Get pytest parameter string 

76 

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

78 """ 

79 

80 if not self.name: 

81 return "" 

82 

83 input_param = self.input 

84 if isinstance(input_param, str): 

85 input_param = quote(input_param) 

86 

87 expected_output = self.expected 

88 if isinstance(expected_output, str): 

89 expected_output = quote(expected_output) 

90 

91 return ( 

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

93 ) 

94 

95 

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

97 loc = () 

98 if item_name: 

99 loc += (item_name,) 

100 

101 if not isinstance(values, list): 

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

103 else: 

104 errors = [ 

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

106 for index, value in enumerate(values) 

107 if not isinstance(value, str) 

108 ] 

109 

110 if errors: 

111 raise ValidationError(errors, model=cls) 

112 

113 

114def _append_method_to_actual( 

115 method: str, actual_var: str, expected_var 

116) -> Tuple[str, str]: 

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

118 

119 

120def _append_method_to_expected( 

121 method: str, actual_var: str, expected_var: str 

122) -> Tuple[str, str]: 

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

124 

125 

126def _remove_chars( 

127 values: List[str], actual_var: str, expected_var: str 

128) -> Tuple[str, str]: 

129 for value in values: 

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

131 

132 return actual_var, expected_var 

133 

134 

135def _strip_chars( 

136 values: List[str], actual_var: str, expected_var: str 

137) -> Tuple[str, str]: 

138 for value in values: 

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

140 

141 return actual_var, expected_var 

142 

143 

144def _unique_sort(actual_var, expected_var): 

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

146 

147 

148class AutoGenTest(BaseModel): 

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

150 

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

152 requires_parameters: bool = False 

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

154 params: conlist(AutoGenParam, min_items=1) 

155 transformations: List[Any] = [] 

156 

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

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

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

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

161 "any_order": _unique_sort, 

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

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

164 } 

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

166 "remove": _remove_chars, 

167 "strip": _strip_chars, 

168 } 

169 

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

171 def validate_inputs(cls, value): 

172 """ 

173 Validate each input 

174 

175 :param value: Input to validate 

176 :return: Original input 

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

178 """ 

179 

180 if not isinstance(value, str): 

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

182 

183 return value 

184 

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

186 def validate_params(cls, value, values): 

187 """ 

188 Validate each parameter 

189 

190 :param value: Parameter to validate 

191 :param values: Test item 

192 :return: Original parameter 

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

194 or empty name 

195 """ 

196 

197 if values.get("requires_parameters"): 

198 errors = [] 

199 if "name" not in value: 

200 errors.append( 

201 ErrorWrapper( 

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

203 loc="name", 

204 ) 

205 ) 

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

207 errors.append( 

208 ErrorWrapper( 

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

210 loc="name", 

211 ) 

212 ) 

213 

214 if "input" not in value: 

215 errors.append( 

216 ErrorWrapper( 

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

218 loc="input", 

219 ) 

220 ) 

221 

222 if "expected" not in value: 

223 errors.append( 

224 ErrorWrapper( 

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

226 loc="expected", 

227 ) 

228 ) 

229 

230 if errors: 

231 raise ValidationError(errors, model=cls) 

232 

233 return value 

234 

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

236 def validate_transformation(cls, value): 

237 """ 

238 Validate each transformation 

239 

240 :param value: Transformation to validate 

241 :return: Original value 

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

243 """ 

244 

245 if isinstance(value, str): 

246 if value not in cls.SCALAR_TRANSFORMATION_FUNCS: 

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

248 elif isinstance(value, dict): 

249 key = str(*value) 

250 if key not in cls.DICT_TRANSFORMATION_FUNCS: 

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

252 

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

254 else: 

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

256 

257 return value 

258 

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

260 """ 

261 Transform variables using the specified transformations 

262 

263 :return: Transformed actual and expected variables 

264 """ 

265 

266 actual_var = "actual" 

267 expected_var = "expected" 

268 for transfomation in self.transformations: 

269 if isinstance(transfomation, str): 

270 actual_var, expected_var = self.SCALAR_TRANSFORMATION_FUNCS[ 

271 transfomation 

272 ](actual_var, expected_var) 

273 else: 

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

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

276 item, actual_var, expected_var 

277 ) 

278 

279 return actual_var, expected_var 

280 

281 def get_pytest_params(self) -> str: 

282 """ 

283 Get pytest parameters 

284 

285 :return: pytest parameters 

286 """ 

287 

288 if not self.requires_parameters: 

289 return "" 

290 

291 pytest_params = "".join( 

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

293 ).strip() 

294 return f"""\ 

295@pytest.mark.parametrize( 

296 ("in_params", "expected"), 

297 [ 

298 {pytest_params} 

299 ] 

300) 

301""" 

302 

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

304 """ 

305 Get test function and run command 

306 

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

308 :return: Test function and run command 

309 """ 

310 

311 func_params = "" 

312 run_param = "" 

313 if self.requires_parameters: 

314 func_params = "in_params, expected, " 

315 run_param = "params=in_params" 

316 

317 return f"""\ 

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

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

320""" 

321 

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

323 """ 

324 Get test code that gets the expected output 

325 

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

327 :return: Test code that gets the expected output 

328 

329 """ 

330 

331 if self.requires_parameters: 

332 return "" 

333 

334 expected_output = self.params[0].expected 

335 if isinstance(expected_output, str): 

336 expected_output = quote(expected_output) 

337 elif isinstance(expected_output, dict): 

338 return _get_expected_file(project_name_underscores, expected_output) 

339 

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

341 

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

343 """ 

344 Generate test code 

345 

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

347 :return: Test code 

348 """ 

349 

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

351 test_code += self.get_pytest_params() 

352 test_code += self.get_test_function_and_run(project_name_underscores) 

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

354 actual_var, expected_var = self.transform_vars() 

355 test_code += indent( 

356 _get_assert(actual_var, expected_var, self.params[0].expected), 4 

357 ) 

358 return test_code 

359 

360 

361def _get_expected_file( 

362 project_name_underscores: str, expected_output: Dict[str, str] 

363) -> str: 

364 if "exec" in expected_output: 

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

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

367 

368 test_code = f"""\ 

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

370 expected = file.read() 

371""" 

372 

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

374 test_code += """\ 

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

376if diff_len > 0: 

377 expected += "\\n" 

378elif diff_len < 0: 

379 actual += "\\n" 

380""" 

381 

382 return test_code 

383 

384 

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

386 if isinstance(expected_output, list): 

387 return f"""\ 

388actual_list = {actual_var} 

389expected_list = {expected_var} 

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

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

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

393""" 

394 

395 test_code = "" 

396 if actual_var != "actual": 

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

398 

399 if expected_var != "expected": 

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

401 

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

403 

404 

405class AutoGenUseTests(BaseModel): 

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

407 

408 name: str 

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

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

411 

412 @root_validator(pre=True) 

413 def validate_search_with_replace(cls, values): 

414 """ 

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

416 

417 :param values: Values to validate 

418 :return: Original values 

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

420 """ 

421 

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

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

424 

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

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

427 

428 return values