gha-utils 4.14.0__py3-none-any.whl → 4.14.2__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.

Potentially problematic release.


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

gha_utils/__init__.py CHANGED
@@ -17,4 +17,4 @@
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- __version__ = "4.14.0"
20
+ __version__ = "4.14.2"
gha_utils/cli.py CHANGED
@@ -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)
gha_utils/metadata.py CHANGED
@@ -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:
gha_utils/test_plan.py CHANGED
@@ -16,10 +16,13 @@
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import logging
19
20
  import re
21
+ import shlex
22
+ import sys
20
23
  from dataclasses import asdict, dataclass, field
21
24
  from pathlib import Path
22
- from subprocess import run
25
+ from subprocess import TimeoutExpired, run
23
26
  from typing import Generator, Sequence
24
27
 
25
28
  import yaml
@@ -33,131 +36,171 @@ class TestCase:
33
36
  cli_parameters: tuple[str, ...] | str = field(default_factory=tuple)
34
37
  """Parameters, arguments and options to pass to the CLI."""
35
38
 
39
+ timeout: float | str | None = None
36
40
  exit_code: int | str | None = None
37
41
  strip_ansi: bool = False
38
42
  output_contains: tuple[str, ...] | str = field(default_factory=tuple)
39
43
  stdout_contains: tuple[str, ...] | str = field(default_factory=tuple)
40
44
  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
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
47
57
 
48
58
  def __post_init__(self) -> None:
49
59
  """Normalize all fields."""
50
60
  for field_id, field_data in asdict(self).items():
51
- # Validates and normalize exit code.
61
+ # Validates and normalize integer properties.
52
62
  if field_id == "exit_code":
53
63
  if isinstance(field_data, str):
54
64
  field_data = int(field_data)
55
65
  elif field_data is not None and not isinstance(field_data, int):
56
66
  raise ValueError(f"exit_code is not an integer: {field_data}")
57
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.
58
79
  elif field_id == "strip_ansi":
59
80
  if not isinstance(field_data, bool):
60
81
  raise ValueError(f"strip_ansi is not a boolean: {field_data}")
61
82
 
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
83
  # Validates and normalize tuple of strings.
72
84
  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())
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())
86
103
 
87
- # Validates regexps.
88
- if field_data and "_regex_" in field_id:
104
+ # Validates fields containing one or more regexes.
105
+ if "_regex_" in field_id and field_data:
106
+ # Compile all regexes.
107
+ valid_regexes = []
89
108
  for regex in flatten((field_data,)):
90
109
  try:
91
- re.compile(regex)
110
+ # Let dots in regex match newlines.
111
+ valid_regexes.append(re.compile(regex, re.DOTALL))
92
112
  except re.error as ex:
93
113
  raise ValueError(
94
114
  f"Invalid regex in {field_id}: {regex}"
95
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)
96
124
 
97
125
  setattr(self, field_id, field_data)
98
126
 
99
- def check_cli_test(self, binary: str | Path, timeout: int | None = None):
127
+ def check_cli_test(self, binary: str | Path, default_timeout: float | None):
100
128
  """Run a CLI command and check its output against the test case.
101
129
 
102
130
  ..todo::
103
131
  Add support for environment variables.
104
132
 
105
- ..todo::
106
- Add support for ANSI code stripping.
107
-
108
133
  ..todo::
109
134
  Add support for proper mixed stdout/stderr stream as a single,
110
135
  intertwined output.
111
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
+
112
141
  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
- )
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
+
144
179
  print_cli_run(clean_args, result)
145
180
 
146
181
  for field_id, field_data in asdict(self).items():
147
- if field_id == "cli_parameters" or (not field_data and field_data != 0):
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.
148
191
  continue
149
192
 
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
- )
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
156
198
 
199
+ # Prepare output and name for comparison.
157
200
  output = ""
158
201
  name = ""
159
202
  if field_id.startswith("output_"):
160
- raise NotImplementedError("Output mixing <stdout>/<stderr>")
203
+ raise NotImplementedError("<stdout>/<stderr> output mix")
161
204
  # output = result.output
162
205
  # name = "output"
163
206
  elif field_id.startswith("stdout_"):
@@ -168,10 +211,12 @@ class TestCase:
168
211
  name = "<stderr>"
169
212
 
170
213
  if self.strip_ansi:
214
+ logging.info(f"Strip ANSI escape sequences from CLI's {name}")
171
215
  output = strip_ansi(output)
172
216
 
173
217
  if field_id.endswith("_contains"):
174
218
  for sub_string in field_data:
219
+ logging.info(f"Check if CLI's {name} contains: {sub_string!r}")
175
220
  if sub_string not in output:
176
221
  raise AssertionError(
177
222
  f"CLI's {name} does not contain {sub_string!r}"
@@ -179,18 +224,21 @@ class TestCase:
179
224
 
180
225
  elif field_id.endswith("_regex_matches"):
181
226
  for regex in field_data:
182
- if not re.search(regex, output):
227
+ logging.info(f"Check if CLI's {name} matches: {sub_string!r}")
228
+ if not regex.search(output):
183
229
  raise AssertionError(
184
- f"CLI's {name} does not match regex {regex!r}"
230
+ f"CLI's {name} does not match regex {regex}"
185
231
  )
186
232
 
187
233
  elif field_id.endswith("_regex_fullmatch"):
188
234
  regex = field_data
189
- if not re.fullmatch(regex, output):
235
+ if not regex.fullmatch(output):
190
236
  raise AssertionError(
191
- f"CLI's {name} does not fully match regex {regex!r}"
237
+ f"CLI's {name} does not fully match regex {regex}"
192
238
  )
193
239
 
240
+ logging.info("All tests passed for CLI.")
241
+
194
242
 
195
243
  DEFAULT_TEST_PLAN = (
196
244
  # Output the version of the CLI.
@@ -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
@@ -0,0 +1,14 @@
1
+ gha_utils/__init__.py,sha256=dR8lNlVY_u847eZ0CTxmll40WHAkfXzWXN26hheu824,866
2
+ gha_utils/__main__.py,sha256=Dck9BjpLXmIRS83k0mghAMcYVYiMiFLltQdfRuMSP_Q,1703
3
+ gha_utils/changelog.py,sha256=oahY88A9FRV14f1JSFKIiYrN_TS7Jo3QlljXqJbeuaE,5892
4
+ gha_utils/cli.py,sha256=Gq2pGpIOmvXtsexmg6nwN2ZuZ1lpHrJ5A43CeuDjK68,10985
5
+ gha_utils/mailmap.py,sha256=naUqJYJnE3fLTjju1nd6WMm7ODiSaI2SHuJxRtmaFWs,6269
6
+ gha_utils/matrix.py,sha256=_afJD0K-xZLNxwykVnUhD0Gj9cdO0Z43g3VHa-q_tkI,11941
7
+ gha_utils/metadata.py,sha256=Oumkmo4-O549hxlw8zaTWIPkITpUxUjpulj_MXn6fjA,48649
8
+ gha_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ gha_utils/test_plan.py,sha256=B6EiiGWVTrNS7jSIO9EE0kebccx10Fn0ZPapJTOOyGA,11928
10
+ gha_utils-4.14.2.dist-info/METADATA,sha256=uPoJ9lj1TvWEt1Izsipp-4R6bsQ-gwHjnqwILdpL0fQ,20514
11
+ gha_utils-4.14.2.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
12
+ gha_utils-4.14.2.dist-info/entry_points.txt,sha256=8bJOwQYf9ZqsLhBR6gUCzvwLNI9f8tiiBrJ3AR0EK4o,54
13
+ gha_utils-4.14.2.dist-info/top_level.txt,sha256=C94Blb61YkkyPBwCdM3J_JPDjWH0lnKa5nGZeZ5M6yE,10
14
+ gha_utils-4.14.2.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- gha_utils/__init__.py,sha256=XhzcFkFs63M5SvQuWYvvNyqSQ8Z5nylmJq0ga2xTJGo,866
2
- gha_utils/__main__.py,sha256=Dck9BjpLXmIRS83k0mghAMcYVYiMiFLltQdfRuMSP_Q,1703
3
- gha_utils/changelog.py,sha256=oahY88A9FRV14f1JSFKIiYrN_TS7Jo3QlljXqJbeuaE,5892
4
- gha_utils/cli.py,sha256=J6cqO-LlVXmLq0Z5Mmpv34ySbvVzVPqU1-c7iHqqITA,10348
5
- gha_utils/mailmap.py,sha256=naUqJYJnE3fLTjju1nd6WMm7ODiSaI2SHuJxRtmaFWs,6269
6
- gha_utils/matrix.py,sha256=_afJD0K-xZLNxwykVnUhD0Gj9cdO0Z43g3VHa-q_tkI,11941
7
- gha_utils/metadata.py,sha256=YbWPxNwWxcTMj67q6I4adFXgLF11YBv6urAzNopWYHE,48657
8
- gha_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- gha_utils/test_plan.py,sha256=6Anw8Aa7rlIdJH4XdfZddWjh_Q2VK7Ehq4UJ0cHAt2c,9467
10
- gha_utils-4.14.0.dist-info/METADATA,sha256=aLM8hgjGOfr2tLuparflwcPmYp21mKW8bKSp3BNMMc8,20288
11
- gha_utils-4.14.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
12
- gha_utils-4.14.0.dist-info/entry_points.txt,sha256=8bJOwQYf9ZqsLhBR6gUCzvwLNI9f8tiiBrJ3AR0EK4o,54
13
- gha_utils-4.14.0.dist-info/top_level.txt,sha256=C94Blb61YkkyPBwCdM3J_JPDjWH0lnKa5nGZeZ5M6yE,10
14
- gha_utils-4.14.0.dist-info/RECORD,,