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
« 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
4from pydantic import (
5 BaseModel,
6 ValidationError,
7 conlist,
8 constr,
9 root_validator,
10 validator,
11)
12from pydantic.error_wrappers import ErrorWrapper
14from glotter.utils import indent, quote
16TransformationScalarFuncT = Callable[[str, str], Tuple[str, str]]
17TransformationDictFuncT = Callable[[List[str], str, str], Tuple[str, str]]
20class AutoGenParam(BaseModel):
21 """Object used to auto-generated a test parameter"""
23 name: str = ""
24 input: Optional[str] = None
25 expected: Any
27 @validator("expected")
28 def validate_expected(cls, value):
29 """
30 Validate expected value
32 :param value: Expected value
33 :return: Original expected value
34 :raises: :exc:`ValueError` if invalid expected value
35 """
37 if isinstance(value, dict):
38 if not value:
39 raise ValueError("too few items")
41 if len(value) > 1:
42 raise ValueError("too many items")
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")
65 return value
67 def get_pytest_param(self) -> str:
68 """
69 Get pytest parameter string
71 :return: pytest parameter string if name is not empty, empty string otherwise
72 """
74 if not self.name:
75 return ""
77 input_param = self.input
78 if isinstance(input_param, str):
79 input_param = quote(input_param)
81 expected_output = self.expected
82 if isinstance(expected_output, str):
83 expected_output = quote(expected_output)
85 return f"pytest.param({input_param}, {expected_output}, id={quote(self.name)}),\n"
88def _validate_str_list(cls, values, item_name: str = ""):
89 loc = ()
90 if item_name:
91 loc += (item_name,)
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 ]
102 if errors:
103 raise ValidationError(errors, model=cls)
106def _append_method_to_actual(method: str, actual_var: str, expected_var) -> Tuple[str, str]:
107 return f"{actual_var}.{method}()", expected_var
110def _append_method_to_expected(method: str, actual_var: str, expected_var: str) -> Tuple[str, str]:
111 return actual_var, f"{expected_var}.{method}()"
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)}, "")'
118 return actual_var, expected_var
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)})"
125 return actual_var, expected_var
128def _unique_sort(actual_var, expected_var):
129 return f"sorted(set({actual_var}))", f"sorted(set({expected_var}))"
132class AutoGenTest(BaseModel):
133 """Object used to auto-generated a test"""
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] = []
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 }
154 @validator("inputs", each_item=True, pre=True, always=True)
155 def validate_inputs(cls, value):
156 """
157 Validate each input
159 :param value: Input to validate
160 :return: Original input
161 :raises: :exc:`ValueError` if input invalid
162 """
164 if not isinstance(value, str):
165 raise ValueError("input is not a str")
167 return value
169 @validator("params", each_item=True, pre=True, always=True)
170 def validate_params(cls, value, values):
171 """
172 Validate each parameter
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 """
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 )
198 if "input" not in value:
199 errors.append(
200 ErrorWrapper(
201 ValueError("field is required when parameters required"),
202 loc="input",
203 )
204 )
206 if "expected" not in value:
207 errors.append(
208 ErrorWrapper(
209 ValueError("field is required when parameters required"),
210 loc="expected",
211 )
212 )
214 if errors:
215 raise ValidationError(errors, model=cls)
217 return value
219 @validator("transformations", each_item=True, pre=True)
220 def validate_transformation(cls, value):
221 """
222 Validate each transformation
224 :param value: Transformation to validate
225 :return: Original value
226 :raises: :exc:`ValueError` if invalid transformation
227 """
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}"')
237 _validate_str_list(cls, value[key], key)
238 else:
239 raise ValueError("str or dict type expected")
241 return value
243 def transform_vars(self) -> Tuple[str, str]:
244 """
245 Transform variables using the specified transformations
247 :return: Transformed actual and expected variables
248 """
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 )
263 return actual_var, expected_var
265 def get_pytest_params(self) -> str:
266 """
267 Get pytest parameters
269 :return: pytest parameters
270 """
272 if not self.requires_parameters:
273 return ""
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"""
287 def get_test_function_and_run(self, project_name_underscores: str) -> str:
288 """
289 Get test function and run command
291 :param project_name_underscores: Project name with underscores between each word
292 :return: Test function and run command
293 """
295 func_params = ""
296 run_param = ""
297 if self.requires_parameters:
298 func_params = "in_params, expected, "
299 run_param = "params=in_params"
301 return f"""\
302def test_{self.name}({func_params}{project_name_underscores}):
303 actual = {project_name_underscores}.run({run_param})
304"""
306 def get_expected_output(self, project_name_underscores: str) -> str:
307 """
308 Get test code that gets the expected output
310 :param project_name_underscores: Project name with underscores between each word
311 :return: Test code that gets the expected output
313 """
315 if self.requires_parameters:
316 return ""
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)
324 return f"expected = {expected_output}\n"
326 def generate_test(self, project_name_underscores: str) -> str:
327 """
328 Generate test code
330 :param project_name_underscores: Project name with underscores between each word
331 :return: Test code
332 """
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
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"
348 test_code = f"""\
349with open({project_name_underscores}.full_path, "r", encoding="utf-8") as file:
350 expected = file.read()
351"""
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"""
362 return test_code
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"""
375 test_code = ""
376 if actual_var != "actual":
377 test_code += f"actual = {actual_var}\n"
379 if expected_var != "expected":
380 test_code += f"expected = {expected_var}\n"
382 return f"{test_code}assert actual == expected\n"
385class AutoGenUseTests(BaseModel):
386 """Object used to specify what tests to use"""
388 name: str
389 search: constr(strict=True, regex="^[0-9a-zA-Z_]*$") = ""
390 replace: constr(strict=True, regex="^[0-9a-zA-Z_]*$") = ""
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
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 """
402 if "search" in values and "replace" not in values:
403 raise ValueError('"search" item specified without "replace" item')
405 if "search" not in values and "replace" in values:
406 raise ValueError('"replace" item specified without "search" item')
408 return values