gha-utils 4.24.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gha_utils/py.typed ADDED
File without changes
gha_utils/test_plan.py ADDED
@@ -0,0 +1,352 @@
1
+ # Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
2
+ #
3
+ # This program is Free Software; you can redistribute it and/or
4
+ # modify it under the terms of the GNU General Public License
5
+ # as published by the Free Software Foundation; either version 2
6
+ # of the License, or (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program; if not, write to the Free Software
15
+ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import re
22
+ import shlex
23
+ import sys
24
+ from collections.abc import Sequence
25
+ from dataclasses import asdict, dataclass, field
26
+ from pathlib import Path
27
+ from shutil import which
28
+ from subprocess import TimeoutExpired, run
29
+
30
+ import yaml
31
+ from boltons.iterutils import flatten
32
+ from boltons.strutils import strip_ansi
33
+ from click_extra.testing import (
34
+ args_cleanup,
35
+ regex_fullmatch_line_by_line,
36
+ render_cli_run,
37
+ )
38
+ from extra_platforms import Group, current_os
39
+
40
+ TYPE_CHECKING = False
41
+ if TYPE_CHECKING:
42
+ from collections.abc import Generator
43
+
44
+ from extra_platforms._types import _TNestedReferences
45
+
46
+
47
+ class SkippedTest(Exception):
48
+ """Raised when a test case should be skipped."""
49
+
50
+ pass
51
+
52
+
53
+ def _split_args(cli: str) -> list[str]:
54
+ """Split a string or sequence of strings into a tuple of arguments.
55
+
56
+ .. todo::
57
+ Evaluate better Windows CLI parsing with:
58
+ `w32lex <https://github.com/maxpat78/w32lex>`_.
59
+ """
60
+ if sys.platform == "win32":
61
+ return cli.split()
62
+ # For Unix platforms, we have the dedicated shlex module.
63
+ else:
64
+ return shlex.split(cli)
65
+
66
+
67
+ @dataclass(order=True)
68
+ class CLITestCase:
69
+ cli_parameters: tuple[str, ...] | str = field(default_factory=tuple)
70
+ """Parameters, arguments and options to pass to the CLI."""
71
+
72
+ skip_platforms: _TNestedReferences = field(default_factory=tuple)
73
+ only_platforms: _TNestedReferences = field(default_factory=tuple)
74
+ timeout: float | str | None = None
75
+ exit_code: int | str | None = None
76
+ strip_ansi: bool = False
77
+ output_contains: tuple[str, ...] | str = field(default_factory=tuple)
78
+ stdout_contains: tuple[str, ...] | str = field(default_factory=tuple)
79
+ stderr_contains: tuple[str, ...] | str = field(default_factory=tuple)
80
+ output_regex_matches: tuple[re.Pattern | str, ...] | str = field(
81
+ default_factory=tuple
82
+ )
83
+ stdout_regex_matches: tuple[re.Pattern | str, ...] | str = field(
84
+ default_factory=tuple
85
+ )
86
+ stderr_regex_matches: tuple[re.Pattern | str, ...] | str = field(
87
+ default_factory=tuple
88
+ )
89
+ output_regex_fullmatch: re.Pattern | str | None = None
90
+ stdout_regex_fullmatch: re.Pattern | str | None = None
91
+ stderr_regex_fullmatch: re.Pattern | str | None = None
92
+
93
+ execution_trace: str | None = None
94
+ """User-friendly rendering of the CLI command execution and its output."""
95
+
96
+ def __post_init__(self) -> None:
97
+ """Normalize all fields."""
98
+ for field_id, field_data in asdict(self).items():
99
+ # Validates and normalize integer properties.
100
+ if field_id == "exit_code":
101
+ if isinstance(field_data, str):
102
+ field_data = int(field_data)
103
+ elif field_data is not None and not isinstance(field_data, int):
104
+ raise ValueError(f"exit_code is not an integer: {field_data}")
105
+
106
+ # Validates and normalize float properties.
107
+ elif field_id == "timeout":
108
+ if isinstance(field_data, str):
109
+ field_data = float(field_data)
110
+ elif field_data is not None and not isinstance(field_data, float):
111
+ raise ValueError(f"timeout is not a float: {field_data}")
112
+ # Timeout can only be unset or positive.
113
+ if field_data and field_data < 0:
114
+ raise ValueError(f"timeout is negative: {field_data}")
115
+
116
+ # Validates and normalize boolean properties.
117
+ elif field_id == "strip_ansi":
118
+ if not isinstance(field_data, bool):
119
+ raise ValueError(f"strip_ansi is not a boolean: {field_data}")
120
+
121
+ # Validates and normalize tuple of strings.
122
+ else:
123
+ if field_data:
124
+ # Wraps single string and other types into a tuple.
125
+ if isinstance(field_data, str) or not isinstance(
126
+ field_data, Sequence
127
+ ):
128
+ # CLI parameters provided as a long string needs to be split so
129
+ # that each argument is a separate item in the final tuple.
130
+ if field_id == "cli_parameters":
131
+ field_data = _split_args(field_data)
132
+ else:
133
+ field_data = (field_data,)
134
+
135
+ for item in field_data:
136
+ if not isinstance(item, str):
137
+ raise ValueError(f"Invalid string in {field_id}: {item}")
138
+ # Ignore blank value.
139
+ field_data = tuple(i for i in field_data if i.strip())
140
+
141
+ # Normalize any mishmash of platform and group IDs into a set of platforms.
142
+ if field_id.endswith("_platforms") and field_data:
143
+ field_data = frozenset(Group._extract_platforms(field_data))
144
+
145
+ # Validates fields containing one or more regexes.
146
+ if "_regex_" in field_id and field_data:
147
+ # Compile all regexes.
148
+ valid_regexes = []
149
+ for regex in flatten((field_data,)):
150
+ try:
151
+ # Let dots in regex match newlines.
152
+ valid_regexes.append(re.compile(regex, re.DOTALL))
153
+ except re.error as ex:
154
+ raise ValueError(
155
+ f"Invalid regex in {field_id}: {regex}"
156
+ ) from ex
157
+ # Normalize single regex to a single element.
158
+ if field_id.endswith("_fullmatch"):
159
+ if valid_regexes:
160
+ field_data = valid_regexes.pop()
161
+ else:
162
+ field_data = None
163
+ else:
164
+ field_data = tuple(valid_regexes)
165
+
166
+ setattr(self, field_id, field_data)
167
+
168
+ def run_cli_test(
169
+ self,
170
+ command: Path | str,
171
+ additional_skip_platforms: _TNestedReferences | None,
172
+ default_timeout: float | None,
173
+ ):
174
+ """Run a CLI command and check its output against the test case.
175
+
176
+ The provided ``command`` can be either:
177
+
178
+ - a path to a binary or script to execute;
179
+ - a command name to be searched in the ``PATH``,
180
+ - a command line with arguments to be parsed and executed by the shell.
181
+
182
+ .. todo::
183
+ Add support for environment variables.
184
+
185
+ .. todo::
186
+ Add support for proper mixed <stdout>/<stderr> stream as a single,
187
+ intertwined output.
188
+ """
189
+ if self.only_platforms:
190
+ if current_os() not in self.only_platforms: # type: ignore[operator]
191
+ raise SkippedTest(f"Test case only runs on platform: {current_os()}")
192
+
193
+ if current_os() in Group._extract_platforms(
194
+ self.skip_platforms, additional_skip_platforms
195
+ ):
196
+ raise SkippedTest(f"Skipping test case on platform: {current_os()}")
197
+
198
+ if self.timeout is None and default_timeout is not None:
199
+ logging.info(f"Set default test case timeout to {default_timeout} seconds")
200
+ self.timeout = default_timeout
201
+
202
+ # Separate the command into binary file path and arguments.
203
+ args = []
204
+ if isinstance(command, str):
205
+ args = _split_args(command)
206
+ command = args[0]
207
+ args = args[1:]
208
+ # Ensure the command to execute is in PATH.
209
+ if not which(command):
210
+ raise FileNotFoundError(f"Command not found in PATH: {command!r}")
211
+ # Resolve the command to an absolute path.
212
+ command = which(command) # type: ignore[assignment]
213
+ assert command is not None
214
+
215
+ # Check the binary exists and is executable.
216
+ binary = Path(command).resolve()
217
+ assert binary.exists()
218
+ assert binary.is_file()
219
+ assert os.access(binary, os.X_OK)
220
+
221
+ clean_args = args_cleanup(binary, args, self.cli_parameters)
222
+ logging.info(f"Run CLI command: {' '.join(clean_args)}")
223
+
224
+ try:
225
+ result = run(
226
+ clean_args,
227
+ capture_output=True,
228
+ timeout=self.timeout, # type: ignore[arg-type]
229
+ # XXX Do not force encoding to let CLIs figure out by
230
+ # themselves the contextual encoding to use. This avoid
231
+ # UnicodeDecodeError on output in Window's console which still
232
+ # defaults to legacy encoding (e.g. cp1252, cp932, etc...):
233
+ #
234
+ # Traceback (most recent call last):
235
+ # File "…\__main__.py", line 49, in <module>
236
+ # File "…\__main__.py", line 45, in main
237
+ # File "…\click\core.py", line 1157, in __call__
238
+ # File "…\click_extra\commands.py", line 347, in main
239
+ # File "…\click\core.py", line 1078, in main
240
+ # File "…\click_extra\commands.py", line 377, in invoke
241
+ # File "…\click\core.py", line 1688, in invoke
242
+ # File "…\click_extra\commands.py", line 377, in invoke
243
+ # File "…\click\core.py", line 1434, in invoke
244
+ # File "…\click\core.py", line 783, in invoke
245
+ # File "…\cloup\_context.py", line 47, in new_func
246
+ # File "…\mpm\cli.py", line 570, in managers
247
+ # File "…\mpm\output.py", line 187, in print_table
248
+ # File "…\click_extra\tabulate.py", line 97, in render_csv
249
+ # File "encodings\cp1252.py", line 19, in encode
250
+ # UnicodeEncodeError: 'charmap' codec can't encode character
251
+ # '\u2713' in position 128: character maps to <undefined>
252
+ #
253
+ # encoding="utf-8",
254
+ text=True,
255
+ )
256
+ except TimeoutExpired:
257
+ raise TimeoutError(
258
+ f"CLI timed out after {self.timeout} seconds: {' '.join(clean_args)}"
259
+ )
260
+
261
+ # Execution has been completed, save the output for user's inspection.
262
+ self.execution_trace = render_cli_run(clean_args, result)
263
+ for line in self.execution_trace.splitlines():
264
+ logging.info(line)
265
+
266
+ for field_id, field_data in asdict(self).items():
267
+ if field_id == "exit_code":
268
+ if field_data is not None:
269
+ logging.info(f"Test exit code, expecting: {field_data}")
270
+ if result.returncode != field_data:
271
+ raise AssertionError(
272
+ f"CLI exited with code {result.returncode}, "
273
+ f"expected {field_data}"
274
+ )
275
+ # The specific exit code matches, let's proceed to the next test.
276
+ continue
277
+
278
+ # Ignore non-output fields, and empty test cases.
279
+ elif not (
280
+ field_id.startswith(("output_", "stdout_", "stderr_")) and field_data
281
+ ):
282
+ continue
283
+
284
+ # Prepare output and name for comparison.
285
+ output = ""
286
+ name = ""
287
+ if field_id.startswith("output_"):
288
+ raise NotImplementedError("<stdout>/<stderr> output mix")
289
+ # output = result.output
290
+ # name = "output"
291
+ elif field_id.startswith("stdout_"):
292
+ output = result.stdout
293
+ name = "<stdout>"
294
+ elif field_id.startswith("stderr_"):
295
+ output = result.stderr
296
+ name = "<stderr>"
297
+
298
+ if self.strip_ansi:
299
+ logging.info(f"Strip ANSI sequences from {name}")
300
+ output = strip_ansi(output)
301
+
302
+ if field_id.endswith("_contains"):
303
+ for sub_string in field_data:
304
+ logging.info(f"Check if {name} contains {sub_string!r}")
305
+ if sub_string not in output:
306
+ raise AssertionError(f"{name} does not contain {sub_string!r}")
307
+
308
+ elif field_id.endswith("_regex_matches"):
309
+ for regex in field_data:
310
+ logging.info(f"Check if {name} matches {regex!r}")
311
+ if not regex.search(output):
312
+ raise AssertionError(f"{name} does not match regex {regex}")
313
+
314
+ elif field_id.endswith("_regex_fullmatch"):
315
+ regex_fullmatch_line_by_line(field_data, output)
316
+
317
+
318
+ DEFAULT_TEST_PLAN: list[CLITestCase] = [
319
+ # Output the version of the CLI.
320
+ CLITestCase(cli_parameters="--version"),
321
+ # Test combination of version and verbosity.
322
+ CLITestCase(cli_parameters=("--verbosity", "DEBUG", "--version")),
323
+ # Test help output.
324
+ CLITestCase(cli_parameters="--help"),
325
+ ]
326
+
327
+
328
+ def parse_test_plan(plan_string: str | None) -> Generator[CLITestCase, None, None]:
329
+ if not plan_string:
330
+ raise ValueError("Empty test plan")
331
+
332
+ plan = yaml.full_load(plan_string)
333
+
334
+ # Validates test plan structure.
335
+ if not plan:
336
+ raise ValueError("Empty test plan")
337
+ if not isinstance(plan, list):
338
+ raise ValueError(f"Test plan is not a list: {plan}")
339
+
340
+ directives = frozenset(CLITestCase.__dataclass_fields__.keys())
341
+
342
+ for index, test_case in enumerate(plan):
343
+ # Validates test case structure.
344
+ if not isinstance(test_case, dict):
345
+ raise ValueError(f"Test case #{index + 1} is not a dict: {test_case}")
346
+ if not directives.issuperset(test_case):
347
+ raise ValueError(
348
+ f"Test case #{index + 1} contains invalid directives:"
349
+ f"{set(test_case) - directives}"
350
+ )
351
+
352
+ yield CLITestCase(**test_case)