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
« 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
6from pydantic import (
7 BaseModel,
8 validator,
9 root_validator,
10 constr,
11 conlist,
12 ValidationError,
13)
14from pydantic.error_wrappers import ErrorWrapper
16from glotter.utils import quote, indent
18TransformationScalarFuncT = Callable[[str, str], Tuple[str, str]]
19TransformationDictFuncT = Callable[[List[str], str, str], Tuple[str, str]]
22class AutoGenParam(BaseModel):
23 """Object used to auto-generated a test parameter"""
25 name: str = ""
26 input: Optional[str] = None
27 expected: Any
29 @validator("expected")
30 def validate_expected(cls, value):
31 """
32 Validate expected value
34 :param value: Expected value
35 :return: Original expected value
36 :raises: :exc:`ValueError` if invalid expected value
37 """
39 if isinstance(value, dict):
40 if not value:
41 raise ValueError("too few items")
43 if len(value) > 1:
44 raise ValueError("too many items")
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")
71 return value
73 def get_pytest_param(self) -> str:
74 """
75 Get pytest parameter string
77 :return: pytest parameter string if name is not empty, empty string otherwise
78 """
80 if not self.name:
81 return ""
83 input_param = self.input
84 if isinstance(input_param, str):
85 input_param = quote(input_param)
87 expected_output = self.expected
88 if isinstance(expected_output, str):
89 expected_output = quote(expected_output)
91 return (
92 f"pytest.param({input_param}, {expected_output}, id={quote(self.name)}),\n"
93 )
96def _validate_str_list(cls, values, item_name: str = ""):
97 loc = ()
98 if item_name:
99 loc += (item_name,)
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 ]
110 if errors:
111 raise ValidationError(errors, model=cls)
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
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}()"
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)}, "")'
132 return actual_var, expected_var
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)})"
141 return actual_var, expected_var
144def _unique_sort(actual_var, expected_var):
145 return f"sorted(set({actual_var}))", f"sorted(set({expected_var}))"
148class AutoGenTest(BaseModel):
149 """Object used to auto-generated a test"""
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] = []
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 }
170 @validator("inputs", each_item=True, pre=True, always=True)
171 def validate_inputs(cls, value):
172 """
173 Validate each input
175 :param value: Input to validate
176 :return: Original input
177 :raises: :exc:`ValueError` if input invalid
178 """
180 if not isinstance(value, str):
181 raise ValueError("input is not a str")
183 return value
185 @validator("params", each_item=True, pre=True, always=True)
186 def validate_params(cls, value, values):
187 """
188 Validate each parameter
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 """
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 )
214 if "input" not in value:
215 errors.append(
216 ErrorWrapper(
217 ValueError("field is required when parameters required"),
218 loc="input",
219 )
220 )
222 if "expected" not in value:
223 errors.append(
224 ErrorWrapper(
225 ValueError("field is required when parameters required"),
226 loc="expected",
227 )
228 )
230 if errors:
231 raise ValidationError(errors, model=cls)
233 return value
235 @validator("transformations", each_item=True, pre=True)
236 def validate_transformation(cls, value):
237 """
238 Validate each transformation
240 :param value: Transformation to validate
241 :return: Original value
242 :raises: :exc:`ValueError` if invalid transformation
243 """
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}"')
253 _validate_str_list(cls, value[key], key)
254 else:
255 raise ValueError("str or dict type expected")
257 return value
259 def transform_vars(self) -> Tuple[str, str]:
260 """
261 Transform variables using the specified transformations
263 :return: Transformed actual and expected variables
264 """
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 )
279 return actual_var, expected_var
281 def get_pytest_params(self) -> str:
282 """
283 Get pytest parameters
285 :return: pytest parameters
286 """
288 if not self.requires_parameters:
289 return ""
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"""
303 def get_test_function_and_run(self, project_name_underscores: str) -> str:
304 """
305 Get test function and run command
307 :param project_name_underscores: Project name with underscores between each word
308 :return: Test function and run command
309 """
311 func_params = ""
312 run_param = ""
313 if self.requires_parameters:
314 func_params = "in_params, expected, "
315 run_param = "params=in_params"
317 return f"""\
318def test_{self.name}({func_params}{project_name_underscores}):
319 actual = {project_name_underscores}.run({run_param})
320"""
322 def get_expected_output(self, project_name_underscores: str) -> str:
323 """
324 Get test code that gets the expected output
326 :param project_name_underscores: Project name with underscores between each word
327 :return: Test code that gets the expected output
329 """
331 if self.requires_parameters:
332 return ""
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)
340 return f"expected = {expected_output}\n"
342 def generate_test(self, project_name_underscores: str) -> str:
343 """
344 Generate test code
346 :param project_name_underscores: Project name with underscores between each word
347 :return: Test code
348 """
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
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"
368 test_code = f"""\
369with open({project_name_underscores}.full_path, "r", encoding="utf-8") as file:
370 expected = file.read()
371"""
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"""
382 return test_code
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"""
395 test_code = ""
396 if actual_var != "actual":
397 test_code += f"actual = {actual_var}\n"
399 if expected_var != "expected":
400 test_code += f"expected = {expected_var}\n"
402 return f"{test_code}assert actual == expected\n"
405class AutoGenUseTests(BaseModel):
406 """Object used to specify what tests to use"""
408 name: str
409 search: constr(strict=True, regex="^[0-9a-zA-Z_]*$") = ""
410 replace: constr(strict=True, regex="^[0-9a-zA-Z_]*$") = ""
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
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 """
422 if "search" in values and "replace" not in values:
423 raise ValueError('"search" item specified without "replace" item')
425 if "search" not in values and "replace" in values:
426 raise ValueError('"replace" item specified without "search" item')
428 return values