gha-utils 4.14.0__tar.gz → 4.14.2__tar.gz

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.

Potentially problematic release.


This version of gha-utils might be problematic. Click here for more details.

Files changed (25) hide show
  1. {gha_utils-4.14.0 → gha_utils-4.14.2}/PKG-INFO +6 -3
  2. {gha_utils-4.14.0 → gha_utils-4.14.2}/gha_utils/__init__.py +1 -1
  3. {gha_utils-4.14.0 → gha_utils-4.14.2}/gha_utils/cli.py +21 -12
  4. {gha_utils-4.14.0 → gha_utils-4.14.2}/gha_utils/metadata.py +1 -1
  5. gha_utils-4.14.2/gha_utils/test_plan.py +274 -0
  6. {gha_utils-4.14.0 → gha_utils-4.14.2}/gha_utils.egg-info/PKG-INFO +6 -3
  7. {gha_utils-4.14.0 → gha_utils-4.14.2}/pyproject.toml +2 -2
  8. {gha_utils-4.14.0 → gha_utils-4.14.2}/readme.md +5 -2
  9. {gha_utils-4.14.0 → gha_utils-4.14.2}/tests/test_mailmap.py +2 -2
  10. gha_utils-4.14.2/tests/test_metadata.py +143 -0
  11. gha_utils-4.14.0/gha_utils/test_plan.py +0 -226
  12. gha_utils-4.14.0/tests/test_metadata.py +0 -89
  13. {gha_utils-4.14.0 → gha_utils-4.14.2}/gha_utils/__main__.py +0 -0
  14. {gha_utils-4.14.0 → gha_utils-4.14.2}/gha_utils/changelog.py +0 -0
  15. {gha_utils-4.14.0 → gha_utils-4.14.2}/gha_utils/mailmap.py +0 -0
  16. {gha_utils-4.14.0 → gha_utils-4.14.2}/gha_utils/matrix.py +0 -0
  17. {gha_utils-4.14.0 → gha_utils-4.14.2}/gha_utils/py.typed +0 -0
  18. {gha_utils-4.14.0 → gha_utils-4.14.2}/gha_utils.egg-info/SOURCES.txt +0 -0
  19. {gha_utils-4.14.0 → gha_utils-4.14.2}/gha_utils.egg-info/dependency_links.txt +0 -0
  20. {gha_utils-4.14.0 → gha_utils-4.14.2}/gha_utils.egg-info/entry_points.txt +0 -0
  21. {gha_utils-4.14.0 → gha_utils-4.14.2}/gha_utils.egg-info/requires.txt +0 -0
  22. {gha_utils-4.14.0 → gha_utils-4.14.2}/gha_utils.egg-info/top_level.txt +0 -0
  23. {gha_utils-4.14.0 → gha_utils-4.14.2}/setup.cfg +0 -0
  24. {gha_utils-4.14.0 → gha_utils-4.14.2}/tests/test_changelog.py +0 -0
  25. {gha_utils-4.14.0 → gha_utils-4.14.2}/tests/test_matrix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: gha-utils
3
- Version: 4.14.0
3
+ Version: 4.14.2
4
4
  Summary: ⚙️ CLI helpers for GitHub Actions + reuseable workflows
5
5
  Author-email: Kevin Deldycke <kevin@deldycke.com>
6
6
  Project-URL: Homepage, https://github.com/kdeldycke/workflows
@@ -103,7 +103,6 @@ Thanks to `uv`, you can install and run `gha-utils` in one command, without poll
103
103
 
104
104
  ```shell-session
105
105
  $ uvx gha-utils
106
- Installed 45 packages in 45ms
107
106
  Usage: gha-utils [OPTIONS] COMMAND [ARGS]...
108
107
 
109
108
  Options:
@@ -118,8 +117,11 @@ Options:
118
117
  utils/*.{toml,yaml,yml,json,ini,xml}]
119
118
  --show-params Show all CLI parameters, their provenance, defaults
120
119
  and value, then exit.
121
- -v, --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG.
120
+ --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG.
122
121
  [default: WARNING]
122
+ -v, --verbose Increase the default WARNING verbosity by one level
123
+ for each additional repetition of the option.
124
+ [default: 0]
123
125
  --version Show the version and exit.
124
126
  -h, --help Show this message and exit.
125
127
 
@@ -127,6 +129,7 @@ Commands:
127
129
  changelog Maintain a Markdown-formatted changelog
128
130
  mailmap-sync Update Git's .mailmap file with missing contributors
129
131
  metadata Output project metadata
132
+ test-plan Run a test plan from a file against a binary
130
133
  ```
131
134
 
132
135
  ```shell-session
@@ -17,4 +17,4 @@
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- __version__ = "4.14.0"
20
+ __version__ = "4.14.2"
@@ -27,7 +27,7 @@ import click
27
27
  from click_extra import (
28
28
  Choice,
29
29
  Context,
30
- IntRange,
30
+ FloatRange,
31
31
  argument,
32
32
  echo,
33
33
  extra_group,
@@ -279,29 +279,38 @@ def mailmap_sync(ctx, source, create_if_missing, destination_mailmap):
279
279
  # `file_path` type.
280
280
  type=click.Path(exists=True, executable=True, resolve_path=True),
281
281
  required=True,
282
- help="Path to the binary to test.",
282
+ metavar="FILE_PATH",
283
+ help="Path to the binary file to test.",
283
284
  )
284
285
  @option(
285
286
  "--plan",
286
287
  type=file_path(exists=True, readable=True, resolve_path=True),
287
- help="Test plan in YAML.",
288
+ metavar="FILE_PATH",
289
+ help="Path to the test plan file in YAML. If not provided, a default test "
290
+ "plan will be executed.",
288
291
  )
289
292
  @option(
290
293
  "-t",
291
294
  "--timeout",
292
- type=IntRange(min=0),
293
- default=60,
294
- help="Set maximum duration in seconds for each CLI call.",
295
+ # Timeout passed to subprocess.run() is a float that is silently clamped to
296
+ # 0.0 is negative values are provided, so we mimic this behavior here:
297
+ # https://github.com/python/cpython/blob/5740b95076b57feb6293cda4f5504f706a7d622d/Lib/subprocess.py#L1596-L1597
298
+ type=FloatRange(min=0, clamp=True),
299
+ metavar="SECONDS",
300
+ help="Set the default timeout for each CLI call, if not specified in the "
301
+ "test plan.",
295
302
  )
296
- def test_plan(binary, plan, timeout):
303
+ def test_plan(binary: Path, plan: Path | None, timeout: float | None) -> None:
297
304
  # Load test plan from workflow input, or use a default one.
298
305
  if plan:
299
- logging.debug(f"Read test plan from {plan}")
306
+ logging.info(f"Read test plan from {plan}")
300
307
  test_plan = parse_test_plan(plan)
301
308
  else:
302
- logging.warning(f"No test plan provided. Default to: {DEFAULT_TEST_PLAN}")
303
- test_plan = DEFAULT_TEST_PLAN
309
+ logging.warning("No test plan provided: use default test plan.")
310
+ test_plan = DEFAULT_TEST_PLAN # type: ignore[assignment]
311
+ logging.debug(f"Test plan: {test_plan}")
304
312
 
305
313
  for index, test_case in enumerate(test_plan):
306
- logging.info(f"Run test #{index}")
307
- test_case.check_cli_test(binary, timeout=timeout)
314
+ logging.info(f"Run test #{index + 1}")
315
+ logging.debug(f"Test case parameters: {test_case}")
316
+ test_case.check_cli_test(binary, default_timeout=timeout)
@@ -1105,7 +1105,7 @@ class Metadata:
1105
1105
  ).format(**variations)
1106
1106
  matrix.add_includes(bin_name_include)
1107
1107
 
1108
- return Matrix(matrix)
1108
+ return matrix
1109
1109
 
1110
1110
  @cached_property
1111
1111
  def release_notes(self) -> str | None:
@@ -0,0 +1,274 @@
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 re
21
+ import shlex
22
+ import sys
23
+ from dataclasses import asdict, dataclass, field
24
+ from pathlib import Path
25
+ from subprocess import TimeoutExpired, run
26
+ from typing import Generator, Sequence
27
+
28
+ import yaml
29
+ from boltons.iterutils import flatten
30
+ from boltons.strutils import strip_ansi
31
+ from click_extra.testing import args_cleanup, print_cli_run
32
+
33
+
34
+ @dataclass(order=True)
35
+ class TestCase:
36
+ cli_parameters: tuple[str, ...] | str = field(default_factory=tuple)
37
+ """Parameters, arguments and options to pass to the CLI."""
38
+
39
+ timeout: float | str | None = None
40
+ exit_code: int | str | None = None
41
+ strip_ansi: bool = False
42
+ output_contains: tuple[str, ...] | str = field(default_factory=tuple)
43
+ stdout_contains: tuple[str, ...] | str = field(default_factory=tuple)
44
+ stderr_contains: tuple[str, ...] | str = field(default_factory=tuple)
45
+ output_regex_matches: tuple[re.Pattern | str, ...] | str = field(
46
+ default_factory=tuple
47
+ )
48
+ stdout_regex_matches: tuple[re.Pattern | str, ...] | str = field(
49
+ default_factory=tuple
50
+ )
51
+ stderr_regex_matches: tuple[re.Pattern | str, ...] | str = field(
52
+ default_factory=tuple
53
+ )
54
+ output_regex_fullmatch: re.Pattern | str | None = None
55
+ stdout_regex_fullmatch: re.Pattern | str | None = None
56
+ stderr_regex_fullmatch: re.Pattern | str | None = None
57
+
58
+ def __post_init__(self) -> None:
59
+ """Normalize all fields."""
60
+ for field_id, field_data in asdict(self).items():
61
+ # Validates and normalize integer properties.
62
+ if field_id == "exit_code":
63
+ if isinstance(field_data, str):
64
+ field_data = int(field_data)
65
+ elif field_data is not None and not isinstance(field_data, int):
66
+ raise ValueError(f"exit_code is not an integer: {field_data}")
67
+
68
+ # Validates and normalize float properties.
69
+ elif field_id == "timeout":
70
+ if isinstance(field_data, str):
71
+ field_data = float(field_data)
72
+ elif field_data is not None and not isinstance(field_data, float):
73
+ raise ValueError(f"timeout is not a float: {field_data}")
74
+ # Timeout can only be unset or positive.
75
+ if field_data and field_data < 0:
76
+ raise ValueError(f"timeout is negative: {field_data}")
77
+
78
+ # Validates and normalize boolean properties.
79
+ elif field_id == "strip_ansi":
80
+ if not isinstance(field_data, bool):
81
+ raise ValueError(f"strip_ansi is not a boolean: {field_data}")
82
+
83
+ # Validates and normalize tuple of strings.
84
+ else:
85
+ if field_data:
86
+ # Wraps single string and other types into a tuple.
87
+ if isinstance(field_data, str) or not isinstance(
88
+ field_data, Sequence
89
+ ):
90
+ # CLI parameters needs to be split on Unix-like systems.
91
+ # XXX If we need the same for Windows, have a look at:
92
+ # https://github.com/maxpat78/w32lex
93
+ if field_id == "cli_parameters" and sys.platform != "win32":
94
+ field_data = tuple(shlex.split(field_data))
95
+ else:
96
+ field_data = (field_data,)
97
+
98
+ for item in field_data:
99
+ if not isinstance(item, str):
100
+ raise ValueError(f"Invalid string in {field_id}: {item}")
101
+ # Ignore blank value.
102
+ field_data = tuple(i for i in field_data if i.strip())
103
+
104
+ # Validates fields containing one or more regexes.
105
+ if "_regex_" in field_id and field_data:
106
+ # Compile all regexes.
107
+ valid_regexes = []
108
+ for regex in flatten((field_data,)):
109
+ try:
110
+ # Let dots in regex match newlines.
111
+ valid_regexes.append(re.compile(regex, re.DOTALL))
112
+ except re.error as ex:
113
+ raise ValueError(
114
+ f"Invalid regex in {field_id}: {regex}"
115
+ ) from ex
116
+ # Normalize single regex to a single element.
117
+ if field_id.endswith("_fullmatch"):
118
+ if valid_regexes:
119
+ field_data = valid_regexes.pop()
120
+ else:
121
+ field_data = None
122
+ else:
123
+ field_data = tuple(valid_regexes)
124
+
125
+ setattr(self, field_id, field_data)
126
+
127
+ def check_cli_test(self, binary: str | Path, default_timeout: float | None):
128
+ """Run a CLI command and check its output against the test case.
129
+
130
+ ..todo::
131
+ Add support for environment variables.
132
+
133
+ ..todo::
134
+ Add support for proper mixed stdout/stderr stream as a single,
135
+ intertwined output.
136
+ """
137
+ if self.timeout is None and default_timeout is not None:
138
+ logging.info(f"Set default test case timeout to {default_timeout} seconds")
139
+ self.timeout = default_timeout
140
+
141
+ clean_args = args_cleanup(binary, self.cli_parameters)
142
+ try:
143
+ result = run(
144
+ clean_args,
145
+ capture_output=True,
146
+ timeout=self.timeout, # type: ignore[arg-type]
147
+ # XXX Do not force encoding to let CLIs figure out by
148
+ # themselves the contextual encoding to use. This avoid
149
+ # UnicodeDecodeError on output in Window's console which still
150
+ # defaults to legacy encoding (e.g. cp1252, cp932, etc...):
151
+ #
152
+ # Traceback (most recent call last):
153
+ # File "…\__main__.py", line 49, in <module>
154
+ # File "…\__main__.py", line 45, in main
155
+ # File "…\click\core.py", line 1157, in __call__
156
+ # File "…\click_extra\commands.py", line 347, in main
157
+ # File "…\click\core.py", line 1078, in main
158
+ # File "…\click_extra\commands.py", line 377, in invoke
159
+ # File "…\click\core.py", line 1688, in invoke
160
+ # File "…\click_extra\commands.py", line 377, in invoke
161
+ # File "…\click\core.py", line 1434, in invoke
162
+ # File "…\click\core.py", line 783, in invoke
163
+ # File "…\cloup\_context.py", line 47, in new_func
164
+ # File "…\mpm\cli.py", line 570, in managers
165
+ # File "…\mpm\output.py", line 187, in print_table
166
+ # File "…\click_extra\tabulate.py", line 97, in render_csv
167
+ # File "encodings\cp1252.py", line 19, in encode
168
+ # UnicodeEncodeError: 'charmap' codec can't encode character
169
+ # '\u2713' in position 128: character maps to <undefined>
170
+ #
171
+ # encoding="utf-8",
172
+ text=True,
173
+ )
174
+ except TimeoutExpired:
175
+ raise TimeoutError(
176
+ f"CLI timed out after {self.timeout} seconds: {' '.join(clean_args)}"
177
+ )
178
+
179
+ print_cli_run(clean_args, result)
180
+
181
+ for field_id, field_data in asdict(self).items():
182
+ if field_id == "exit_code":
183
+ if field_data is not None:
184
+ logging.info(f"Test exit code, expecting: {field_data}")
185
+ if result.returncode != field_data:
186
+ raise AssertionError(
187
+ f"CLI exited with code {result.returncode}, "
188
+ f"expected {field_data}"
189
+ )
190
+ # The specific exit code matches, let's proceed to the next test.
191
+ continue
192
+
193
+ # Ignore non-output fields, and empty test cases.
194
+ elif not (
195
+ field_id.startswith(("output_", "stdout_", "stderr_")) and field_data
196
+ ):
197
+ continue
198
+
199
+ # Prepare output and name for comparison.
200
+ output = ""
201
+ name = ""
202
+ if field_id.startswith("output_"):
203
+ raise NotImplementedError("<stdout>/<stderr> output mix")
204
+ # output = result.output
205
+ # name = "output"
206
+ elif field_id.startswith("stdout_"):
207
+ output = result.stdout
208
+ name = "<stdout>"
209
+ elif field_id.startswith("stderr_"):
210
+ output = result.stderr
211
+ name = "<stderr>"
212
+
213
+ if self.strip_ansi:
214
+ logging.info(f"Strip ANSI escape sequences from CLI's {name}")
215
+ output = strip_ansi(output)
216
+
217
+ if field_id.endswith("_contains"):
218
+ for sub_string in field_data:
219
+ logging.info(f"Check if CLI's {name} contains: {sub_string!r}")
220
+ if sub_string not in output:
221
+ raise AssertionError(
222
+ f"CLI's {name} does not contain {sub_string!r}"
223
+ )
224
+
225
+ elif field_id.endswith("_regex_matches"):
226
+ for regex in field_data:
227
+ logging.info(f"Check if CLI's {name} matches: {sub_string!r}")
228
+ if not regex.search(output):
229
+ raise AssertionError(
230
+ f"CLI's {name} does not match regex {regex}"
231
+ )
232
+
233
+ elif field_id.endswith("_regex_fullmatch"):
234
+ regex = field_data
235
+ if not regex.fullmatch(output):
236
+ raise AssertionError(
237
+ f"CLI's {name} does not fully match regex {regex}"
238
+ )
239
+
240
+ logging.info("All tests passed for CLI.")
241
+
242
+
243
+ DEFAULT_TEST_PLAN = (
244
+ # Output the version of the CLI.
245
+ TestCase(cli_parameters="--version"),
246
+ # Test combination of version and verbosity.
247
+ TestCase(cli_parameters=("--verbosity", "DEBUG", "--version")),
248
+ # Test help output.
249
+ TestCase(cli_parameters="--help"),
250
+ )
251
+
252
+
253
+ def parse_test_plan(plan_path: Path) -> Generator[TestCase, None, None]:
254
+ plan = yaml.full_load(plan_path.read_text(encoding="UTF-8"))
255
+
256
+ # Validates test plan structure.
257
+ if not plan:
258
+ raise ValueError(f"Empty test plan file {plan_path}")
259
+ if not isinstance(plan, list):
260
+ raise ValueError(f"Test plan is not a list: {plan}")
261
+
262
+ directives = frozenset(TestCase.__dataclass_fields__.keys())
263
+
264
+ for index, test_case in enumerate(plan):
265
+ # Validates test case structure.
266
+ if not isinstance(test_case, dict):
267
+ raise ValueError(f"Test case #{index + 1} is not a dict: {test_case}")
268
+ if not directives.issuperset(test_case):
269
+ raise ValueError(
270
+ f"Test case #{index + 1} contains invalid directives:"
271
+ f"{set(test_case) - directives}"
272
+ )
273
+
274
+ yield TestCase(**test_case)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: gha-utils
3
- Version: 4.14.0
3
+ Version: 4.14.2
4
4
  Summary: ⚙️ CLI helpers for GitHub Actions + reuseable workflows
5
5
  Author-email: Kevin Deldycke <kevin@deldycke.com>
6
6
  Project-URL: Homepage, https://github.com/kdeldycke/workflows
@@ -103,7 +103,6 @@ Thanks to `uv`, you can install and run `gha-utils` in one command, without poll
103
103
 
104
104
  ```shell-session
105
105
  $ uvx gha-utils
106
- Installed 45 packages in 45ms
107
106
  Usage: gha-utils [OPTIONS] COMMAND [ARGS]...
108
107
 
109
108
  Options:
@@ -118,8 +117,11 @@ Options:
118
117
  utils/*.{toml,yaml,yml,json,ini,xml}]
119
118
  --show-params Show all CLI parameters, their provenance, defaults
120
119
  and value, then exit.
121
- -v, --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG.
120
+ --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG.
122
121
  [default: WARNING]
122
+ -v, --verbose Increase the default WARNING verbosity by one level
123
+ for each additional repetition of the option.
124
+ [default: 0]
123
125
  --version Show the version and exit.
124
126
  -h, --help Show this message and exit.
125
127
 
@@ -127,6 +129,7 @@ Commands:
127
129
  changelog Maintain a Markdown-formatted changelog
128
130
  mailmap-sync Update Git's .mailmap file with missing contributors
129
131
  metadata Output project metadata
132
+ test-plan Run a test plan from a file against a binary
130
133
  ```
131
134
 
132
135
  ```shell-session
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  # Docs: https://packaging.python.org/en/latest/guides/writing-pyproject-toml/
3
3
  name = "gha-utils"
4
- version = "4.14.0"
4
+ version = "4.14.2"
5
5
  # Python versions and their status: https://devguide.python.org/versions/
6
6
  requires-python = ">= 3.10"
7
7
  description = "⚙️ CLI helpers for GitHub Actions + reuseable workflows"
@@ -138,7 +138,7 @@ addopts = [
138
138
  xfail_strict = true
139
139
 
140
140
  [tool.bumpversion]
141
- current_version = "4.14.0"
141
+ current_version = "4.14.2"
142
142
  allow_dirty = true
143
143
  ignore_missing_files = true
144
144
 
@@ -36,7 +36,6 @@ Thanks to `uv`, you can install and run `gha-utils` in one command, without poll
36
36
 
37
37
  ```shell-session
38
38
  $ uvx gha-utils
39
- Installed 45 packages in 45ms
40
39
  Usage: gha-utils [OPTIONS] COMMAND [ARGS]...
41
40
 
42
41
  Options:
@@ -51,8 +50,11 @@ Options:
51
50
  utils/*.{toml,yaml,yml,json,ini,xml}]
52
51
  --show-params Show all CLI parameters, their provenance, defaults
53
52
  and value, then exit.
54
- -v, --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG.
53
+ --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG.
55
54
  [default: WARNING]
55
+ -v, --verbose Increase the default WARNING verbosity by one level
56
+ for each additional repetition of the option.
57
+ [default: 0]
56
58
  --version Show the version and exit.
57
59
  -h, --help Show this message and exit.
58
60
 
@@ -60,6 +62,7 @@ Commands:
60
62
  changelog Maintain a Markdown-formatted changelog
61
63
  mailmap-sync Update Git's .mailmap file with missing contributors
62
64
  metadata Output project metadata
65
+ test-plan Run a test plan from a file against a binary
63
66
  ```
64
67
 
65
68
  ```shell-session
@@ -27,7 +27,7 @@ def test_remove_header():
27
27
  # Generated by gha-utils mailmap-sync v4.4.3 - https://github.com/kdeldycke/workflows
28
28
  # Timestamp: 2024-08-12T08:15:41.083405
29
29
  # Format is:
30
- # Preferred Name <preferred e-mail> Other Name <other e-mail>
30
+ # Preferred Name <preferred e-mail> Other Name <other e-mail>
31
31
  #
32
32
  # Reference: https://git-scm.com/docs/git-blame#_mapping_authors
33
33
 
@@ -36,7 +36,7 @@ def test_remove_header():
36
36
 
37
37
  assert remove_header(content) == dedent("""\
38
38
  # Format is:
39
- # Preferred Name <preferred e-mail> Other Name <other e-mail>
39
+ # Preferred Name <preferred e-mail> Other Name <other e-mail>
40
40
  #
41
41
  # Reference: https://git-scm.com/docs/git-blame#_mapping_authors
42
42
 
@@ -0,0 +1,143 @@
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 re
20
+
21
+ from gha_utils.metadata import Dialects, Metadata
22
+
23
+
24
+ def test_metadata_github_format():
25
+ metadata = Metadata()
26
+
27
+ assert re.fullmatch(
28
+ (
29
+ r"new_commits=\n"
30
+ r"release_commits=\n"
31
+ r"gitignore_exists=true\n"
32
+ r"python_files=[\S ]*\n"
33
+ r"doc_files=[\S ]*\n"
34
+ r"is_python_project=true\n"
35
+ r"package_name=gha-utils\n"
36
+ r"blacken_docs_params=--target-version py310 --target-version py311 "
37
+ r"--target-version py312 --target-version py313\n"
38
+ r"mypy_params=--python-version 3\.10\n"
39
+ r"current_version=\n"
40
+ r"released_version=\n"
41
+ r"is_sphinx=false\n"
42
+ r"active_autodoc=false\n"
43
+ r"release_notes=\n"
44
+ r"new_commits_matrix=\n"
45
+ r"release_commits_matrix=\n"
46
+ r'nuitka_matrix=\{"os": \["ubuntu-24\.04", "ubuntu-24\.04-arm", '
47
+ r'"macos-15", "macos-13", "windows-2022"\], '
48
+ r'"entry_point": \["gha-utils"\], "commit": \["[a-z0-9]+"\], '
49
+ r'"include": \[\{"entry_point": "gha-utils", '
50
+ r'"cli_id": "gha-utils", "module_id": "gha_utils\.__main__", '
51
+ r'"callable_id": "main", '
52
+ r'"module_path": "gha_utils(/|\\\\)__main__\.py"\}, '
53
+ r'\{"commit": "[a-z0-9]+", "short_sha": "[a-z0-9]+", '
54
+ r'"current_version": "[0-9\.]+"\}, \{"os": "ubuntu-24\.04", '
55
+ r'"platform_id": "linux", "arch": "x64", "extension": "bin"\}, '
56
+ r'\{"os": "ubuntu-24\.04-arm", "platform_id": "linux", '
57
+ r'"arch": "arm64", "extension": "bin"\}, \{"os": "macos-15", '
58
+ r'"platform_id": "macos", "arch": "arm64", "extension": "bin"\}, '
59
+ r'\{"os": "macos-13", "platform_id": "macos", "arch": "x64", '
60
+ r'"extension": "bin"\}, \{"os": "windows-2022", '
61
+ r'"platform_id": "windows", "arch": "x64", "extension": "exe"\}, '
62
+ r'\{"os": "ubuntu-24\.04", "entry_point": "gha-utils", '
63
+ r'"commit": "[a-z0-9]+", '
64
+ r'"bin_name": "gha-utils-linux-x64-build-[a-z0-9]+\.bin"\}, '
65
+ r'\{"os": "ubuntu-24\.04-arm", "entry_point": "gha-utils", '
66
+ r'"commit": "[a-z0-9]+", '
67
+ r'"bin_name": "gha-utils-linux-arm64-build-[a-z0-9]+\.bin"\}, '
68
+ r'\{"os": "macos-15", "entry_point": "gha-utils", '
69
+ r'"commit": "[a-z0-9]+", '
70
+ r'"bin_name": "gha-utils-macos-arm64-build-[a-z0-9]+\.bin"\}, '
71
+ r'\{"os": "macos-13", "entry_point": "gha-utils", '
72
+ r'"commit": "[a-z0-9]+", '
73
+ r'"bin_name": "gha-utils-macos-x64-build-[a-z0-9]+\.bin"\}, '
74
+ r'\{"os": "windows-2022", "entry_point": "gha-utils", '
75
+ r'"commit": "[a-z0-9]+", '
76
+ r'"bin_name": "gha-utils-windows-x64-build-[a-z0-9]+\.exe"\}\]\}\n'
77
+ ),
78
+ metadata.dump(Dialects.github),
79
+ )
80
+
81
+
82
+ def test_metadata_plain_format():
83
+ metadata = Metadata()
84
+
85
+ assert re.fullmatch(
86
+ (
87
+ r"\{"
88
+ r"'new_commits': None, "
89
+ r"'release_commits': None, "
90
+ r"'gitignore_exists': True, "
91
+ r"'python_files': <generator object Metadata\.python_files at \S+>, "
92
+ r"'doc_files': <generator object Metadata\.doc_files at \S+>, "
93
+ r"'is_python_project': True, "
94
+ r"'package_name': 'gha-utils', "
95
+ r"'blacken_docs_params': \("
96
+ r"'--target-version py310', "
97
+ r"'--target-version py311', "
98
+ r"'--target-version py312', "
99
+ r"'--target-version py313'\), "
100
+ r"'mypy_params': '--python-version 3\.10', "
101
+ r"'current_version': None, "
102
+ r"'released_version': None, "
103
+ r"'is_sphinx': False, "
104
+ r"'active_autodoc': False, "
105
+ r"'release_notes': None, "
106
+ r"'new_commits_matrix': None, "
107
+ r"'release_commits_matrix': None, "
108
+ r"'nuitka_matrix': <Matrix: \{"
109
+ r"'os': \('ubuntu-24\.04', 'ubuntu-24\.04-arm', "
110
+ r"'macos-15', 'macos-13', 'windows-2022'\), "
111
+ r"'entry_point': \('gha-utils',\), "
112
+ r"'commit': \('[a-z0-9]+',\)\}; "
113
+ r"include=\(\{'entry_point': 'gha-utils', 'cli_id': 'gha-utils', "
114
+ r"'module_id': 'gha_utils\.__main__', 'callable_id': 'main', "
115
+ r"'module_path': 'gha_utils(/|\\\\)__main__\.py'\}, "
116
+ r"\{'commit': '[a-z0-9]+', 'short_sha': '[a-z0-9]+', "
117
+ r"'current_version': '[0-9\.]+'\}, \{'os': 'ubuntu-24\.04', "
118
+ r"'platform_id': 'linux', 'arch': 'x64', 'extension': 'bin'}, "
119
+ r"{'os': 'ubuntu-24\.04-arm', 'platform_id': 'linux', "
120
+ r"'arch': 'arm64', 'extension': 'bin'\}, \{'os': 'macos-15', "
121
+ r"'platform_id': 'macos', 'arch': 'arm64', 'extension': 'bin'\}, "
122
+ r"\{'os': 'macos-13', 'platform_id': 'macos', 'arch': 'x64', "
123
+ r"'extension': 'bin'\}, \{'os': 'windows-2022', 'platform_id': "
124
+ r"'windows', 'arch': 'x64', 'extension': 'exe'\}, "
125
+ r"\{'os': 'ubuntu-24\.04', 'entry_point': 'gha-utils', "
126
+ r"'commit': '[a-z0-9]+', "
127
+ r"'bin_name': 'gha-utils-linux-x64-build-[a-z0-9]+\.bin'\}, "
128
+ r"\{'os': 'ubuntu-24\.04-arm', 'entry_point': 'gha-utils', "
129
+ r"'commit': '[a-z0-9]+', "
130
+ r"'bin_name': 'gha-utils-linux-arm64-build-[a-z0-9]+\.bin'\}, "
131
+ r"\{'os': 'macos-15', 'entry_point': 'gha-utils', "
132
+ r"'commit': '[a-z0-9]+', "
133
+ r"'bin_name': 'gha-utils-macos-arm64-build-[a-z0-9]+\.bin'\}, "
134
+ r"\{'os': 'macos-13', 'entry_point': 'gha-utils', "
135
+ r"'commit': '[a-z0-9]+', 'bin_name': "
136
+ r"'gha-utils-macos-x64-build-[a-z0-9]+\.bin'\}, "
137
+ r"\{'os': 'windows-2022', 'entry_point': 'gha-utils', "
138
+ r"'commit': '[a-z0-9]+', "
139
+ r"'bin_name': 'gha-utils-windows-x64-build-[a-z0-9]+\.exe'\}\); "
140
+ r"exclude=\(\)>\}"
141
+ ),
142
+ metadata.dump(Dialects.plain),
143
+ )
@@ -1,226 +0,0 @@
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 re
20
- from dataclasses import asdict, dataclass, field
21
- from pathlib import Path
22
- from subprocess import run
23
- from typing import Generator, Sequence
24
-
25
- import yaml
26
- from boltons.iterutils import flatten
27
- from boltons.strutils import strip_ansi
28
- from click_extra.testing import args_cleanup, print_cli_run
29
-
30
-
31
- @dataclass(order=True)
32
- class TestCase:
33
- cli_parameters: tuple[str, ...] | str = field(default_factory=tuple)
34
- """Parameters, arguments and options to pass to the CLI."""
35
-
36
- exit_code: int | str | None = None
37
- strip_ansi: bool = False
38
- output_contains: tuple[str, ...] | str = field(default_factory=tuple)
39
- stdout_contains: tuple[str, ...] | str = field(default_factory=tuple)
40
- stderr_contains: tuple[str, ...] | str = field(default_factory=tuple)
41
- output_regex_matches: tuple[str, ...] | str = field(default_factory=tuple)
42
- stdout_regex_matches: tuple[str, ...] | str = field(default_factory=tuple)
43
- stderr_regex_matches: tuple[str, ...] | str = field(default_factory=tuple)
44
- output_regex_fullmatch: str | None = None
45
- stdout_regex_fullmatch: str | None = None
46
- stderr_regex_fullmatch: str | None = None
47
-
48
- def __post_init__(self) -> None:
49
- """Normalize all fields."""
50
- for field_id, field_data in asdict(self).items():
51
- # Validates and normalize exit code.
52
- if field_id == "exit_code":
53
- if isinstance(field_data, str):
54
- field_data = int(field_data)
55
- elif field_data is not None and not isinstance(field_data, int):
56
- raise ValueError(f"exit_code is not an integer: {field_data}")
57
-
58
- elif field_id == "strip_ansi":
59
- if not isinstance(field_data, bool):
60
- raise ValueError(f"strip_ansi is not a boolean: {field_data}")
61
-
62
- # Validates and normalize regex fullmatch fields.
63
- elif field_id.endswith("_fullmatch"):
64
- if field_data:
65
- if not isinstance(field_data, str):
66
- raise ValueError(f"{field_id} is not a string: {field_data}")
67
- # Normalize empty strings to None.
68
- else:
69
- field_data = None
70
-
71
- # Validates and normalize tuple of strings.
72
- else:
73
- # Wraps single string into a tuple.
74
- if isinstance(field_data, str):
75
- field_data = (field_data,)
76
- if not isinstance(field_data, Sequence):
77
- raise ValueError(
78
- f"{field_id} is not a tuple or a list: {field_data}"
79
- )
80
- if not all(isinstance(i, str) for i in field_data):
81
- raise ValueError(
82
- f"{field_id} contains non-string elements: {field_data}"
83
- )
84
- # Ignore blank value.
85
- field_data = tuple(i.strip() for i in field_data if i.strip())
86
-
87
- # Validates regexps.
88
- if field_data and "_regex_" in field_id:
89
- for regex in flatten((field_data,)):
90
- try:
91
- re.compile(regex)
92
- except re.error as ex:
93
- raise ValueError(
94
- f"Invalid regex in {field_id}: {regex}"
95
- ) from ex
96
-
97
- setattr(self, field_id, field_data)
98
-
99
- def check_cli_test(self, binary: str | Path, timeout: int | None = None):
100
- """Run a CLI command and check its output against the test case.
101
-
102
- ..todo::
103
- Add support for environment variables.
104
-
105
- ..todo::
106
- Add support for ANSI code stripping.
107
-
108
- ..todo::
109
- Add support for proper mixed stdout/stderr stream as a single,
110
- intertwined output.
111
- """
112
- clean_args = args_cleanup(binary, self.cli_parameters)
113
- result = run(
114
- clean_args,
115
- capture_output=True,
116
- timeout=timeout,
117
- # XXX Do not force encoding to let CLIs figure out by themselves the
118
- # contextual encoding to use. This avoid UnicodeDecodeError on output in
119
- # Window's console which still defaults to legacy encoding (e.g. cp1252,
120
- # cp932, etc...):
121
- #
122
- # Traceback (most recent call last):
123
- # File "…\__main__.py", line 49, in <module>
124
- # File "…\__main__.py", line 45, in main
125
- # File "…\click\core.py", line 1157, in __call__
126
- # File "…\click_extra\commands.py", line 347, in main
127
- # File "…\click\core.py", line 1078, in main
128
- # File "…\click_extra\commands.py", line 377, in invoke
129
- # File "…\click\core.py", line 1688, in invoke
130
- # File "…\click_extra\commands.py", line 377, in invoke
131
- # File "…\click\core.py", line 1434, in invoke
132
- # File "…\click\core.py", line 783, in invoke
133
- # File "…\cloup\_context.py", line 47, in new_func
134
- # File "…\mpm\cli.py", line 570, in managers
135
- # File "…\mpm\output.py", line 187, in print_table
136
- # File "…\click_extra\tabulate.py", line 97, in render_csv
137
- # File "encodings\cp1252.py", line 19, in encode
138
- # UnicodeEncodeError: 'charmap' codec can't encode character
139
- # '\u2713' in position 128: character maps to <undefined>
140
- #
141
- # encoding="utf-8",
142
- text=True,
143
- )
144
- print_cli_run(clean_args, result)
145
-
146
- for field_id, field_data in asdict(self).items():
147
- if field_id == "cli_parameters" or (not field_data and field_data != 0):
148
- continue
149
-
150
- if field_id == "exit_code":
151
- if result.returncode != field_data:
152
- raise AssertionError(
153
- f"CLI exited with code {result.returncode}, "
154
- f"expected {field_data}"
155
- )
156
-
157
- output = ""
158
- name = ""
159
- if field_id.startswith("output_"):
160
- raise NotImplementedError("Output mixing <stdout>/<stderr>")
161
- # output = result.output
162
- # name = "output"
163
- elif field_id.startswith("stdout_"):
164
- output = result.stdout
165
- name = "<stdout>"
166
- elif field_id.startswith("stderr_"):
167
- output = result.stderr
168
- name = "<stderr>"
169
-
170
- if self.strip_ansi:
171
- output = strip_ansi(output)
172
-
173
- if field_id.endswith("_contains"):
174
- for sub_string in field_data:
175
- if sub_string not in output:
176
- raise AssertionError(
177
- f"CLI's {name} does not contain {sub_string!r}"
178
- )
179
-
180
- elif field_id.endswith("_regex_matches"):
181
- for regex in field_data:
182
- if not re.search(regex, output):
183
- raise AssertionError(
184
- f"CLI's {name} does not match regex {regex!r}"
185
- )
186
-
187
- elif field_id.endswith("_regex_fullmatch"):
188
- regex = field_data
189
- if not re.fullmatch(regex, output):
190
- raise AssertionError(
191
- f"CLI's {name} does not fully match regex {regex!r}"
192
- )
193
-
194
-
195
- DEFAULT_TEST_PLAN = (
196
- # Output the version of the CLI.
197
- TestCase(cli_parameters="--version"),
198
- # Test combination of version and verbosity.
199
- TestCase(cli_parameters=("--verbosity", "DEBUG", "--version")),
200
- # Test help output.
201
- TestCase(cli_parameters="--help"),
202
- )
203
-
204
-
205
- def parse_test_plan(plan_path: Path) -> Generator[TestCase, None, None]:
206
- plan = yaml.full_load(plan_path.read_text(encoding="UTF-8"))
207
-
208
- # Validates test plan structure.
209
- if not plan:
210
- raise ValueError(f"Empty test plan file {plan_path}")
211
- if not isinstance(plan, list):
212
- raise ValueError(f"Test plan is not a list: {plan}")
213
-
214
- directives = frozenset(TestCase.__dataclass_fields__.keys())
215
-
216
- for index, test_case in enumerate(plan):
217
- # Validates test case structure.
218
- if not isinstance(test_case, dict):
219
- raise ValueError(f"Test case #{index + 1} is not a dict: {test_case}")
220
- if not directives.issuperset(test_case):
221
- raise ValueError(
222
- f"Test case #{index + 1} contains invalid directives:"
223
- f"{set(test_case) - directives}"
224
- )
225
-
226
- yield TestCase(**test_case)
@@ -1,89 +0,0 @@
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 re
20
-
21
- from gha_utils.metadata import Dialects, Metadata
22
-
23
-
24
- def test_metadata_github_format():
25
- metadata = Metadata()
26
-
27
- assert re.fullmatch(
28
- (
29
- r"new_commits=\n"
30
- r"release_commits=\n"
31
- r"gitignore_exists=true\n"
32
- r"python_files=[\S ]*\n"
33
- r"doc_files=[\S ]*\n"
34
- r"is_python_project=true\n"
35
- r"package_name=gha-utils\n"
36
- r"blacken_docs_params=--target-version py310 --target-version py311 "
37
- r"--target-version py312 --target-version py313\n"
38
- r"mypy_params=--python-version 3\.10\n"
39
- r"current_version=\n"
40
- r"released_version=\n"
41
- r"is_sphinx=false\n"
42
- r"active_autodoc=false\n"
43
- r"release_notes=\n"
44
- r"new_commits_matrix=\n"
45
- r"release_commits_matrix=\n"
46
- r'nuitka_matrix=\{"os": \["ubuntu-24\.04", "ubuntu-24\.04-arm", '
47
- r'"macos-15", "macos-13", "windows-2022"\], '
48
- r'"entry_point": \["gha-utils"\], '
49
- r'"commit": \["[a-z0-9]+"\]\}\n'
50
- ),
51
- metadata.dump(Dialects.github),
52
- )
53
-
54
-
55
- def test_metadata_plain_format():
56
- metadata = Metadata()
57
-
58
- assert re.fullmatch(
59
- (
60
- r"\{"
61
- r"'new_commits': None, "
62
- r"'release_commits': None, "
63
- r"'gitignore_exists': True, "
64
- r"'python_files': <generator object Metadata\.python_files at \S+>, "
65
- r"'doc_files': <generator object Metadata\.doc_files at \S+>, "
66
- r"'is_python_project': True, "
67
- r"'package_name': 'gha-utils', "
68
- r"'blacken_docs_params': \("
69
- r"'--target-version py310', "
70
- r"'--target-version py311', "
71
- r"'--target-version py312', "
72
- r"'--target-version py313'\), "
73
- r"'mypy_params': '--python-version 3\.10', "
74
- r"'current_version': None, "
75
- r"'released_version': None, "
76
- r"'is_sphinx': False, "
77
- r"'active_autodoc': False, "
78
- r"'release_notes': None, "
79
- r"'new_commits_matrix': None, "
80
- r"'release_commits_matrix': None, "
81
- r"'nuitka_matrix': <Matrix: \{"
82
- r"'os': \('ubuntu-24\.04', 'ubuntu-24\.04-arm', "
83
- r"'macos-15', 'macos-13', 'windows-2022'\), "
84
- r"'entry_point': \('gha-utils',\), "
85
- r"'commit': \('[a-z0-9]+',\)\}; "
86
- r"include=\(\); exclude=\(\)>\}"
87
- ),
88
- metadata.dump(Dialects.plain),
89
- )
File without changes