|
1 |
| -"""Expand kuttl tests from templates.""" |
2 |
| -import logging |
3 |
| -import os |
4 |
| -import re |
5 |
| -import sys |
6 |
| -from argparse import ArgumentParser, Namespace |
7 |
| -from dataclasses import dataclass, field |
8 |
| -from itertools import product, chain |
9 |
| -from os import walk, path, makedirs |
10 |
| -from shutil import copy2 |
11 |
| -from typing import Dict, List, TypeVar, Type, Tuple |
12 |
| - |
13 |
| -from jinja2 import Environment, FileSystemLoader |
14 |
| -from yaml import safe_load |
15 |
| - |
16 |
| -__version_info__ = (0, 0, '6-dev') |
17 |
| -__version__ = ".".join(map(str, __version_info__)) |
18 |
| - |
19 |
| - |
20 |
| -def ansible_lookup(loc: str, what: str) -> str: |
21 |
| - """ |
22 |
| - Lookup an environment variable (what) and return it's contents if any. |
23 |
| - Simulates the Ansible `lookup()` function which is made available by Ansible Jinja templates. |
24 |
| - Raises an exception if `loc` is not `env`. |
25 |
| - """ |
26 |
| - if loc != 'env': |
27 |
| - raise ValueError("Can only lookup() in 'env'") |
28 |
| - result = "" |
29 |
| - try: |
30 |
| - result = os.environ[what] |
31 |
| - except KeyError: |
32 |
| - pass |
33 |
| - return result |
34 |
| - |
35 |
| - |
36 |
| -@dataclass |
37 |
| -class TestCase: |
38 |
| - """Test case definition.""" |
39 |
| - name: str |
40 |
| - values: Dict[str, str] |
41 |
| - tid: str = field(init=False) |
42 |
| - |
43 |
| - def __post_init__(self): |
44 |
| - self.tid = "_".join( |
45 |
| - chain( |
46 |
| - [self.name], |
47 |
| - ["-".join([x, self.values.get(x)]) |
48 |
| - for x in self.values.keys()], |
49 |
| - ) |
50 |
| - ) |
51 |
| - |
52 |
| - def expand(self, template_dir: str, target_dir: str) -> None: |
53 |
| - """Expand test case.""" |
54 |
| - logging.info("Expanding test case id [%s]", self.tid) |
55 |
| - td_root = path.join(template_dir, self.name) |
56 |
| - tc_root = path.join(target_dir, self.name, self.tid) |
57 |
| - _mkdir_ignore_exists(tc_root) |
58 |
| - test_env = Environment( |
59 |
| - loader=FileSystemLoader(path.join(template_dir, self.name)), |
60 |
| - trim_blocks=True |
61 |
| - ) |
62 |
| - test_env.globals['lookup'] = ansible_lookup |
63 |
| - sub_level: int = 0 |
64 |
| - for root, dirs, files in walk(td_root): |
65 |
| - sub_level += 1 |
66 |
| - if sub_level == 5: |
67 |
| - # Sanity check |
68 |
| - raise ValueError("Maximum recursive level (5) reached.") |
69 |
| - for dir_name in dirs: |
70 |
| - _mkdir_ignore_exists( |
71 |
| - path.join(tc_root, root[len(td_root) + 1:], dir_name)) |
72 |
| - for file_name in files: |
73 |
| - source = path.join(root, file_name) |
74 |
| - dest = "" |
75 |
| - f_mode = os.stat(source).st_mode |
76 |
| - if file_name.endswith(".j2"): |
77 |
| - logging.debug("Render template %s to %s", file_name, dest) |
78 |
| - dest = path.join( |
79 |
| - tc_root, root[len(td_root) + 1:], file_name[:-3:]) |
80 |
| - self._expand_template(file_name, dest, test_env) |
81 |
| - else: |
82 |
| - dest = path.join( |
83 |
| - tc_root, root[len(td_root) + 1:], file_name) |
84 |
| - logging.debug("Copy file %s to %s", file_name, dest) |
85 |
| - copy2(source, dest) |
86 |
| - # restore file permissions (especially the executable bit is important here) |
87 |
| - logging.debug("Update file mode for %s", dest) |
88 |
| - os.chmod(dest, f_mode) |
89 |
| - |
90 |
| - def _expand_template(self, template_file: str, dest: str, env: Environment) -> None: |
91 |
| - logging.debug("Expanding template %s", template_file) |
92 |
| - template = env.get_template(template_file) |
93 |
| - with open(dest, encoding="utf8", mode="w") as stream: |
94 |
| - print( |
95 |
| - template.render({"test_scenario": {"values": self.values}}), file=stream) |
96 |
| - |
97 |
| - |
98 |
| -@dataclass |
99 |
| -class TestDimension: |
100 |
| - """Test dimension.""" |
101 |
| - name: str |
102 |
| - values: List[str] |
103 |
| - |
104 |
| - def expand(self) -> List[Tuple[str, str]]: |
105 |
| - """Return a list of tuples in the form of (<dimension>, <value>)""" |
106 |
| - return [(self.name, v) for v in self.values] |
107 |
| - |
108 |
| - |
109 |
| -@dataclass |
110 |
| -class TestDefinition: |
111 |
| - """Test case definition.""" |
112 |
| - name: str |
113 |
| - dimensions: List[str] |
114 |
| - |
115 |
| - |
116 |
| -TTestSuite = TypeVar( # pylint: disable=invalid-name |
117 |
| - "TTestSuite", bound="TestSuite") |
118 |
| - |
119 |
| - |
120 |
| -@dataclass(frozen=True) |
121 |
| -class TestSuite: |
122 |
| - """Test suite template.""" |
123 |
| - source: str = field() |
124 |
| - test_cases: List[TestCase] = field(default_factory=list) |
125 |
| - |
126 |
| - def __post_init__(self) -> None: |
127 |
| - with open(self.source, encoding="utf8") as stream: |
128 |
| - tin = safe_load(stream) |
129 |
| - dimensions = [ |
130 |
| - TestDimension(d["name"], d["values"]) for d in tin["dimensions"] |
131 |
| - ] |
132 |
| - test_def = [ |
133 |
| - TestDefinition(t["name"], t["dimensions"]) for t in tin["tests"] |
134 |
| - ] |
135 |
| - self.test_cases.extend(self._build(dimensions, test_def)) |
136 |
| - |
137 |
| - @classmethod |
138 |
| - def _build( |
139 |
| - cls: Type[TTestSuite], dims: List[TestDimension], tests: List[TestDefinition] |
140 |
| - ) -> List[TestCase]: |
141 |
| - """ |
142 |
| - >>> TestSuite._build([TestDimension(name='trino', values=['234', '235'])], |
143 |
| - ... [TestDefinition(name='smoke', dimensions=['trino'])]) |
144 |
| - [TestCase(name='smoke', values={'trino': '234'}, name='smoke_trino-234'), \ |
145 |
| - TestCase(name='smoke', values={'trino': '235'}, name='smoke_trino-235')] |
146 |
| - """ |
147 |
| - result = [] |
148 |
| - for test in tests: |
149 |
| - used_dims = [d for d in dims if d.name in test.dimensions] |
150 |
| - expanded_test_dims: List[List[Tuple[str, str]]] = [ |
151 |
| - d.expand() for d in used_dims |
152 |
| - ] |
153 |
| - for tc_dim in product(*expanded_test_dims): |
154 |
| - result.append(TestCase(name=test.name, values=dict(tc_dim))) |
155 |
| - return result |
156 |
| - |
157 |
| - def __repr__(self) -> str: |
158 |
| - return f"TestSuite(source={self.source})" |
159 |
| - |
160 |
| - def expand(self, template_dir: str, output_dir: str, kuttl_tests: str) -> int: |
161 |
| - """Expand test suite.""" |
162 |
| - logging.info("Expanding test suite from %s", self.source) |
163 |
| - self._sanity_checks(template_dir, kuttl_tests) |
164 |
| - _mkdir_ignore_exists(output_dir) |
165 |
| - self._expand_kuttl_tests(output_dir, kuttl_tests) |
166 |
| - for test_case in self.test_cases: |
167 |
| - test_case.expand(template_dir, output_dir) |
168 |
| - return 0 |
169 |
| - |
170 |
| - def _sanity_checks(self, template_dir: str, kuttl_tests: str) -> None: |
171 |
| - for test_case in self.test_cases: |
172 |
| - td_root = path.join(template_dir, test_case.name) |
173 |
| - if not path.isdir(td_root): |
174 |
| - raise ValueError( |
175 |
| - f"Test definition directory not found [{td_root}]") |
176 |
| - if not path.isfile(kuttl_tests): |
177 |
| - raise ValueError( |
178 |
| - f"Kuttl test config template not found [{kuttl_tests}]") |
179 |
| - |
180 |
| - def _expand_kuttl_tests(self, output_dir: str, kuttl_tests: str) -> None: |
181 |
| - env = Environment(loader=FileSystemLoader(path.dirname(kuttl_tests))) |
182 |
| - kt_base_name = path.basename(kuttl_tests) |
183 |
| - template = env.get_template(kt_base_name) |
184 |
| - kt_dest_name = re.sub(r"\.j(inja)?2$", "", kt_base_name) |
185 |
| - # Compatibility warning: Assume output_dir ends with 'tests' and remove |
186 |
| - # it from the destination file |
187 |
| - dest = path.join(path.dirname(output_dir), kt_dest_name) |
188 |
| - kuttl_vars = { |
189 |
| - "testinput": { |
190 |
| - "tests": [{"name": tn} for tn in {tc.name for tc in self.test_cases}] |
191 |
| - } |
192 |
| - } |
193 |
| - logging.debug("kuttl vars %s", kuttl_vars) |
194 |
| - with open(dest, encoding="utf8", mode="w") as stream: |
195 |
| - print(template.render(kuttl_vars), file=stream) |
196 |
| - |
197 |
| - |
198 |
| -def parse_cli_args() -> Namespace: |
199 |
| - """Parse command line args.""" |
200 |
| - parser = ArgumentParser( |
201 |
| - description="Kuttl test expander for the Stackable Data Platform") |
202 |
| - parser.add_argument( |
203 |
| - "-v", |
204 |
| - "--version", |
205 |
| - help="Display application version", |
206 |
| - action='version', |
207 |
| - version=f'%(prog)s {__version__}' |
208 |
| - ) |
209 |
| - |
210 |
| - parser.add_argument( |
211 |
| - "-i", |
212 |
| - "--test_definition", |
213 |
| - help="Test definition file.", |
214 |
| - type=str, |
215 |
| - required=False, |
216 |
| - default="tests/test-definition.yaml", |
217 |
| - ) |
218 |
| - parser.add_argument( |
219 |
| - "-t", |
220 |
| - "--template_dir", |
221 |
| - help="Folder with test templates.", |
222 |
| - type=str, |
223 |
| - required=False, |
224 |
| - default="tests/templates/kuttl", |
225 |
| - ) |
226 |
| - parser.add_argument( |
227 |
| - "-o", |
228 |
| - "--output_dir", |
229 |
| - help="Output folder for the expanded test cases.", |
230 |
| - type=str, |
231 |
| - required=False, |
232 |
| - default="tests/_work", |
233 |
| - ) |
234 |
| - |
235 |
| - parser.add_argument( |
236 |
| - "-l", |
237 |
| - "--log_level", |
238 |
| - help="Set log level.", |
239 |
| - type=str, |
240 |
| - required=False, |
241 |
| - choices=["debug", "info"], |
242 |
| - default="info", |
243 |
| - ) |
244 |
| - |
245 |
| - parser.add_argument( |
246 |
| - "-k", |
247 |
| - "--kuttl_test", |
248 |
| - help="Kuttl test suite definition file.", |
249 |
| - type=str, |
250 |
| - required=False, |
251 |
| - default="tests/kuttl-test.yaml.jinja2", |
252 |
| - ) |
253 |
| - |
254 |
| - return parser.parse_args() |
255 |
| - |
256 |
| - |
257 |
| -def _cli_log_level(cli_arg: str) -> int: |
258 |
| - if cli_arg == "debug": |
259 |
| - return logging.DEBUG |
260 |
| - return logging.INFO |
261 |
| - |
262 |
| - |
263 |
| -def _mkdir_ignore_exists(dir_name: str) -> None: |
264 |
| - try: |
265 |
| - logging.debug("Creating directory %s", dir_name) |
266 |
| - makedirs(dir_name) |
267 |
| - except FileExistsError: |
268 |
| - pass |
269 |
| - |
270 |
| - |
271 |
| -def main() -> int: |
272 |
| - """Main""" |
273 |
| - cli_args = parse_cli_args() |
274 |
| - logging.basicConfig( |
275 |
| - encoding="utf-8", level=_cli_log_level(cli_args.log_level)) |
276 |
| - test_suite = TestSuite(cli_args.test_definition) |
277 |
| - # Compatibility warning: add 'tests' to output_dir |
278 |
| - output_dir = path.join(cli_args.output_dir, "tests") |
279 |
| - return test_suite.expand(cli_args.template_dir, output_dir, cli_args.kuttl_test) |
280 |
| - |
281 |
| - |
282 |
| -if __name__ == "__main__": |
283 |
| - sys.exit(main()) |
| 1 | +"""Test suite expander for Kuttl tests.""" |
0 commit comments