gha-utils 4.14.1__py3-none-any.whl → 4.15.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.

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.1"
20
+ __version__ = "4.15.0"
gha_utils/changelog.py CHANGED
@@ -18,15 +18,11 @@ from __future__ import annotations
18
18
 
19
19
  import logging
20
20
  import re
21
- import sys
22
21
  from functools import cached_property
23
22
  from pathlib import Path
24
23
  from textwrap import indent
25
24
 
26
- if sys.version_info >= (3, 11):
27
- import tomllib
28
- else:
29
- import tomli as tomllib # type: ignore[import-not-found]
25
+ import tomllib
30
26
 
31
27
 
32
28
  class Changelog:
gha_utils/cli.py CHANGED
@@ -19,11 +19,12 @@ from __future__ import annotations
19
19
  import logging
20
20
  import os
21
21
  import sys
22
+ from collections import Counter
22
23
  from datetime import datetime
23
24
  from pathlib import Path
24
25
  from typing import IO
25
26
 
26
- import click
27
+ from boltons.iterutils import unique
27
28
  from click_extra import (
28
29
  Choice,
29
30
  Context,
@@ -35,12 +36,14 @@ from click_extra import (
35
36
  option,
36
37
  pass_context,
37
38
  )
39
+ from click_extra.envvar import merge_envvar_ids
40
+ from extra_platforms import ALL_IDS
38
41
 
39
42
  from . import __version__
40
43
  from .changelog import Changelog
41
44
  from .mailmap import Mailmap
42
45
  from .metadata import Dialects, Metadata
43
- from .test_plan import DEFAULT_TEST_PLAN, parse_test_plan
46
+ from .test_plan import DEFAULT_TEST_PLAN, SkippedTest, parse_test_plan
44
47
 
45
48
 
46
49
  def is_stdout(filepath: Path) -> bool:
@@ -275,16 +278,38 @@ def mailmap_sync(ctx, source, create_if_missing, destination_mailmap):
275
278
  @gha_utils.command(short_help="Run a test plan from a file against a binary")
276
279
  @option(
277
280
  "--binary",
278
- # XXX Wait for https://github.com/janluke/cloup/issues/185 to use the
279
- # `file_path` type.
280
- type=click.Path(exists=True, executable=True, resolve_path=True),
281
+ type=file_path(exists=True, executable=True, resolve_path=True),
281
282
  required=True,
282
- help="Path to the binary to test.",
283
+ metavar="FILE_PATH",
284
+ help="Path to the binary file to test.",
283
285
  )
284
286
  @option(
287
+ "-F",
288
+ "--plan-file",
289
+ # TODO: remove deprecated --plan option to avoid confusion.
285
290
  "--plan",
286
291
  type=file_path(exists=True, readable=True, resolve_path=True),
287
- help="Test plan in YAML.",
292
+ multiple=True,
293
+ metavar="FILE_PATH",
294
+ help="Path to a test plan file in YAML. This option can be repeated to run "
295
+ "multiple test plans in sequence. If not provided, a default test plan will be "
296
+ "executed.",
297
+ )
298
+ @option(
299
+ "-E",
300
+ "--plan-envvar",
301
+ multiple=True,
302
+ metavar="ENVVAR_NAME",
303
+ help="Name of an environment variable containing a test plan in YAML. This "
304
+ "option can be repeated to collect multiple test plans.",
305
+ )
306
+ @option(
307
+ "-s",
308
+ "--skip-platform",
309
+ type=Choice(sorted(ALL_IDS), case_sensitive=False),
310
+ multiple=True,
311
+ help="Skip tests for the specified platforms. This option can be repeated to "
312
+ "skip multiple platforms.",
288
313
  )
289
314
  @option(
290
315
  "-t",
@@ -293,18 +318,58 @@ def mailmap_sync(ctx, source, create_if_missing, destination_mailmap):
293
318
  # 0.0 is negative values are provided, so we mimic this behavior here:
294
319
  # https://github.com/python/cpython/blob/5740b95076b57feb6293cda4f5504f706a7d622d/Lib/subprocess.py#L1596-L1597
295
320
  type=FloatRange(min=0, clamp=True),
321
+ metavar="SECONDS",
296
322
  help="Set the default timeout for each CLI call, if not specified in the "
297
323
  "test plan.",
298
324
  )
299
- def test_plan(binary: Path, plan: Path | None, timeout: float | None) -> None:
325
+ def test_plan(
326
+ binary: Path,
327
+ plan_file: tuple[Path, ...] | None,
328
+ plan_envvar: tuple[str, ...] | None,
329
+ skip_platform: tuple[str, ...] | None,
330
+ timeout: float | None,
331
+ ) -> None:
300
332
  # Load test plan from workflow input, or use a default one.
301
- if plan:
302
- logging.debug(f"Read test plan from {plan}")
303
- test_plan = parse_test_plan(plan)
333
+ test_list = []
334
+ if plan_file or plan_envvar:
335
+ for file in unique(plan_file):
336
+ logging.info(f"Get test plan from {file} file")
337
+ tests = list(parse_test_plan(file.read_text(encoding="UTF-8")))
338
+ logging.info(f"{len(tests)} test cases found.")
339
+ test_list.extend(tests)
340
+ for envvar_id in merge_envvar_ids(plan_envvar):
341
+ logging.info(f"Get test plan from {envvar_id!r} environment variable")
342
+ tests = list(parse_test_plan(os.getenv(envvar_id)))
343
+ logging.info(f"{len(tests)} test cases found.")
344
+ test_list.extend(tests)
345
+
304
346
  else:
305
- logging.warning(f"No test plan provided. Default to: {DEFAULT_TEST_PLAN}")
306
- test_plan = DEFAULT_TEST_PLAN # type: ignore[assignment]
347
+ logging.warning(
348
+ "No test plan provided through --plan-file/-F or --plan-envvar/-E options:"
349
+ " use default test plan."
350
+ )
351
+ test_list = DEFAULT_TEST_PLAN
352
+ logging.debug(f"Test plan: {test_list}")
353
+
354
+ stats = Counter(total=len(test_list), skipped=0, failed=0)
307
355
 
308
- for index, test_case in enumerate(test_plan):
309
- logging.info(f"Run test #{index}")
310
- test_case.check_cli_test(binary, default_timeout=timeout)
356
+ for index, test_case in enumerate(test_list):
357
+ logging.info(f"Run test #{index + 1}")
358
+ try:
359
+ logging.debug(f"Test case parameters: {test_case}")
360
+ test_case.run_cli_test(
361
+ binary, additional_skip_platforms=skip_platform, default_timeout=timeout
362
+ )
363
+ except SkippedTest as ex:
364
+ stats["skipped"] += 1
365
+ logging.warning(f"Test skipped: {ex}")
366
+ except Exception as ex:
367
+ stats["failed"] += 1
368
+ logging.error(f"Test failed: {ex}")
369
+
370
+ logging.info(
371
+ "Test plan results - "
372
+ + ", ".join((f"{k.title()}: {v}" for k, v in stats.items()))
373
+ )
374
+ if stats["failed"]:
375
+ sys.exit(1)
gha_utils/metadata.py CHANGED
@@ -49,7 +49,7 @@ release_commits_matrix={'commit': ['6f27db47612aaee06fdf08744b09a9f5f6c2'],
49
49
  nuitka_matrix={'entry_point': ['mpm'],
50
50
  'commit': ['346ce664f055fbd042a25ee0b7e96702e95',
51
51
  '6f27db47612aaee06fdf08744b09a9f5f6c2'],
52
- 'os': ['ubuntu-24.04', 'ubuntu-24.04-arm', 'macos-15', 'macos-13', 'windows-2022'],
52
+ 'os': ['ubuntu-24.04', 'ubuntu-24.04-arm', 'macos-15', 'macos-13', 'windows-2025'],
53
53
  'include': [{'entry_point': 'mpm',
54
54
  'cli_id': 'mpm',
55
55
  'module_id': 'meta_package_manager.__main__',
@@ -77,7 +77,7 @@ nuitka_matrix={'entry_point': ['mpm'],
77
77
  'platform_id': 'macos',
78
78
  'arch': 'x64',
79
79
  'extension': 'bin'},
80
- {'os': 'windows-2022',
80
+ {'os': 'windows-2025',
81
81
  'platform_id': 'windows',
82
82
  'arch': 'x64',
83
83
  'extension': 'exe'},
@@ -123,12 +123,12 @@ nuitka_matrix={'entry_point': ['mpm'],
123
123
  'bin_name': 'mpm-macos-x64-build-6f27db4.bin'},
124
124
  {'entry_point': 'mpm',
125
125
  'commit': '346ce664f055fbd042a25ee0b7e96702e95',
126
- 'os': 'windows-2022',
126
+ 'os': 'windows-2025',
127
127
  'arch': 'x64',
128
128
  'bin_name': 'mpm-windows-x64-build-346ce66.exe'},
129
129
  {'entry_point': 'mpm',
130
130
  'commit': '6f27db47612aaee06fdf08744b09a9f5f6c2',
131
- 'os': 'windows-2022',
131
+ 'os': 'windows-2025',
132
132
  'arch': 'x64',
133
133
  'bin_name': 'mpm-windows-x64-build-6f27db4.exe'}]}
134
134
  ```
@@ -147,22 +147,15 @@ import json
147
147
  import logging
148
148
  import os
149
149
  import re
150
- import sys
151
150
  from collections.abc import Iterable
151
+ from enum import StrEnum
152
152
  from functools import cached_property
153
153
  from pathlib import Path
154
154
  from random import randint
155
155
  from re import escape
156
156
  from typing import Any, Final, Iterator, cast
157
157
 
158
- if sys.version_info >= (3, 11):
159
- from enum import StrEnum
160
-
161
- import tomllib
162
- else:
163
- import tomli as tomllib # type: ignore[import-not-found]
164
- from backports.strenum import StrEnum # type: ignore[import-not-found]
165
-
158
+ import tomllib
166
159
  from bumpversion.config import get_configuration # type: ignore[import-untyped]
167
160
  from bumpversion.config.files import find_config_file # type: ignore[import-untyped]
168
161
  from bumpversion.show import resolve_name # type: ignore[import-untyped]
@@ -457,7 +450,7 @@ class Metadata:
457
450
  return matrix
458
451
 
459
452
  @cached_property
460
- def event_type(self) -> WorkflowEvent | None: # type: ignore[valid-type]
453
+ def event_type(self) -> WorkflowEvent | None:
461
454
  """Returns the type of event that triggered the workflow run.
462
455
 
463
456
  .. caution::
@@ -481,8 +474,8 @@ class Metadata:
481
474
  return None
482
475
 
483
476
  if bool(os.environ.get("GITHUB_BASE_REF")):
484
- return WorkflowEvent.pull_request # type: ignore[no-any-return]
485
- return WorkflowEvent.push # type: ignore[no-any-return]
477
+ return WorkflowEvent.pull_request
478
+ return WorkflowEvent.push
486
479
 
487
480
  @cached_property
488
481
  def commit_range(self) -> tuple[str, str] | None:
@@ -515,7 +508,7 @@ class Metadata:
515
508
  if not self.github_context or not self.event_type:
516
509
  return None
517
510
  # Pull request event.
518
- if self.event_type in ( # type: ignore[unreachable]
511
+ if self.event_type in (
519
512
  WorkflowEvent.pull_request,
520
513
  WorkflowEvent.pull_request_target,
521
514
  ):
@@ -880,7 +873,7 @@ class Metadata:
880
873
  "ubuntu-24.04-arm",
881
874
  "macos-15",
882
875
  "macos-13",
883
- "windows-2022",
876
+ "windows-2025",
884
877
  ],
885
878
  "include": [
886
879
  {
@@ -925,7 +918,7 @@ class Metadata:
925
918
  "extension": "bin",
926
919
  },
927
920
  {
928
- "os": "windows-2022",
921
+ "os": "windows-2025",
929
922
  "platform_id": "windows",
930
923
  "arch": "x64",
931
924
  "extension": "exe",
@@ -989,14 +982,14 @@ class Metadata:
989
982
  {
990
983
  "entry_point": "mpm",
991
984
  "commit": "346ce664f055fbd042a25ee0b7e96702e95",
992
- "os": "windows-2022",
985
+ "os": "windows-2025",
993
986
  "arch": "x64",
994
987
  "bin_name": "mpm-windows-x64-build-346ce66.exe",
995
988
  },
996
989
  {
997
990
  "entry_point": "mpm",
998
991
  "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
999
- "os": "windows-2022",
992
+ "os": "windows-2025",
1000
993
  "arch": "x64",
1001
994
  "bin_name": "mpm-windows-x64-build-6f27db4.exe",
1002
995
  },
@@ -1021,7 +1014,7 @@ class Metadata:
1021
1014
  "ubuntu-24.04-arm", # arm64
1022
1015
  "macos-15", # arm64
1023
1016
  "macos-13", # x64
1024
- "windows-2022", # x64
1017
+ "windows-2025", # x64
1025
1018
  ),
1026
1019
  )
1027
1020
 
@@ -1086,7 +1079,7 @@ class Metadata:
1086
1079
  "extension": "bin",
1087
1080
  },
1088
1081
  {
1089
- "os": "windows-2022",
1082
+ "os": "windows-2025",
1090
1083
  "platform_id": "windows",
1091
1084
  "arch": "x64",
1092
1085
  "extension": "exe",
@@ -1180,10 +1173,7 @@ class Metadata:
1180
1173
 
1181
1174
  return cast(str, value)
1182
1175
 
1183
- def dump(
1184
- self,
1185
- dialect: Dialects = Dialects.github, # type: ignore[valid-type]
1186
- ) -> str:
1176
+ def dump(self, dialect: Dialects = Dialects.github) -> str:
1187
1177
  """Returns all metadata in the specified format.
1188
1178
 
1189
1179
  Defaults to GitHub dialect.
gha_utils/test_plan.py CHANGED
@@ -18,6 +18,8 @@ from __future__ import annotations
18
18
 
19
19
  import logging
20
20
  import re
21
+ import shlex
22
+ import sys
21
23
  from dataclasses import asdict, dataclass, field
22
24
  from pathlib import Path
23
25
  from subprocess import TimeoutExpired, run
@@ -26,26 +28,41 @@ from typing import Generator, Sequence
26
28
  import yaml
27
29
  from boltons.iterutils import flatten
28
30
  from boltons.strutils import strip_ansi
29
- from click_extra.testing import args_cleanup, print_cli_run
31
+ from click_extra.testing import args_cleanup, render_cli_run
32
+ from extra_platforms import Group, _TNestedReferences, current_os
33
+
34
+
35
+ class SkippedTest(Exception):
36
+ """Raised when a test case should be skipped."""
37
+
38
+ pass
30
39
 
31
40
 
32
41
  @dataclass(order=True)
33
- class TestCase:
42
+ class CLITestCase:
34
43
  cli_parameters: tuple[str, ...] | str = field(default_factory=tuple)
35
44
  """Parameters, arguments and options to pass to the CLI."""
36
45
 
46
+ skip_platforms: _TNestedReferences = field(default_factory=tuple)
47
+ only_platforms: _TNestedReferences = field(default_factory=tuple)
37
48
  timeout: float | str | None = None
38
49
  exit_code: int | str | None = None
39
50
  strip_ansi: bool = False
40
51
  output_contains: tuple[str, ...] | str = field(default_factory=tuple)
41
52
  stdout_contains: tuple[str, ...] | str = field(default_factory=tuple)
42
53
  stderr_contains: tuple[str, ...] | str = field(default_factory=tuple)
43
- output_regex_matches: tuple[str, ...] | str = field(default_factory=tuple)
44
- stdout_regex_matches: tuple[str, ...] | str = field(default_factory=tuple)
45
- stderr_regex_matches: tuple[str, ...] | str = field(default_factory=tuple)
46
- output_regex_fullmatch: str | None = None
47
- stdout_regex_fullmatch: str | None = None
48
- stderr_regex_fullmatch: str | None = None
54
+ output_regex_matches: tuple[re.Pattern | str, ...] | str = field(
55
+ default_factory=tuple
56
+ )
57
+ stdout_regex_matches: tuple[re.Pattern | str, ...] | str = field(
58
+ default_factory=tuple
59
+ )
60
+ stderr_regex_matches: tuple[re.Pattern | str, ...] | str = field(
61
+ default_factory=tuple
62
+ )
63
+ output_regex_fullmatch: re.Pattern | str | None = None
64
+ stdout_regex_fullmatch: re.Pattern | str | None = None
65
+ stderr_regex_fullmatch: re.Pattern | str | None = None
49
66
 
50
67
  def __post_init__(self) -> None:
51
68
  """Normalize all fields."""
@@ -72,53 +89,78 @@ class TestCase:
72
89
  if not isinstance(field_data, bool):
73
90
  raise ValueError(f"strip_ansi is not a boolean: {field_data}")
74
91
 
75
- # Validates and normalize regex fullmatch fields.
76
- elif field_id.endswith("_fullmatch"):
77
- if field_data:
78
- if not isinstance(field_data, str):
79
- raise ValueError(f"{field_id} is not a string: {field_data}")
80
- # Normalize empty strings to None.
81
- else:
82
- field_data = None
83
-
84
92
  # Validates and normalize tuple of strings.
85
93
  else:
86
- # Wraps single string into a tuple.
87
- if isinstance(field_data, str):
88
- field_data = (field_data,)
89
- if not isinstance(field_data, Sequence):
90
- raise ValueError(
91
- f"{field_id} is not a tuple or a list: {field_data}"
92
- )
93
- if not all(isinstance(i, str) for i in field_data):
94
- raise ValueError(
95
- f"{field_id} contains non-string elements: {field_data}"
96
- )
97
- # Ignore blank value.
98
- field_data = tuple(i.strip() for i in field_data if i.strip())
99
-
100
- # Validates regexps.
101
- if field_data and "_regex_" in field_id:
94
+ if field_data:
95
+ # Wraps single string and other types into a tuple.
96
+ if isinstance(field_data, str) or not isinstance(
97
+ field_data, Sequence
98
+ ):
99
+ # CLI parameters needs to be split on Unix-like systems.
100
+ # XXX If we need the same for Windows, have a look at:
101
+ # https://github.com/maxpat78/w32lex
102
+ if field_id == "cli_parameters" and sys.platform != "win32":
103
+ field_data = tuple(shlex.split(field_data))
104
+ else:
105
+ field_data = (field_data,)
106
+
107
+ for item in field_data:
108
+ if not isinstance(item, str):
109
+ raise ValueError(f"Invalid string in {field_id}: {item}")
110
+ # Ignore blank value.
111
+ field_data = tuple(i for i in field_data if i.strip())
112
+
113
+ # Normalize any mishmash of platform and group IDs into a set of platforms.
114
+ if field_id.endswith("_platforms") and field_data:
115
+ field_data = frozenset(Group._extract_platforms(field_data))
116
+
117
+ # Validates fields containing one or more regexes.
118
+ if "_regex_" in field_id and field_data:
119
+ # Compile all regexes.
120
+ valid_regexes = []
102
121
  for regex in flatten((field_data,)):
103
122
  try:
104
- re.compile(regex)
123
+ # Let dots in regex match newlines.
124
+ valid_regexes.append(re.compile(regex, re.DOTALL))
105
125
  except re.error as ex:
106
126
  raise ValueError(
107
127
  f"Invalid regex in {field_id}: {regex}"
108
128
  ) from ex
129
+ # Normalize single regex to a single element.
130
+ if field_id.endswith("_fullmatch"):
131
+ if valid_regexes:
132
+ field_data = valid_regexes.pop()
133
+ else:
134
+ field_data = None
135
+ else:
136
+ field_data = tuple(valid_regexes)
109
137
 
110
138
  setattr(self, field_id, field_data)
111
139
 
112
- def check_cli_test(self, binary: str | Path, default_timeout: float | None):
140
+ def run_cli_test(
141
+ self,
142
+ binary: str | Path,
143
+ additional_skip_platforms: _TNestedReferences | None,
144
+ default_timeout: float | None,
145
+ ):
113
146
  """Run a CLI command and check its output against the test case.
114
147
 
115
148
  ..todo::
116
149
  Add support for environment variables.
117
150
 
118
151
  ..todo::
119
- Add support for proper mixed stdout/stderr stream as a single,
152
+ Add support for proper mixed <stdout>/<stderr> stream as a single,
120
153
  intertwined output.
121
154
  """
155
+ if self.only_platforms:
156
+ if current_os() not in self.only_platforms: # type: ignore[operator]
157
+ raise SkippedTest(f"Test case only runs on platform: {current_os()}")
158
+
159
+ if current_os() in Group._extract_platforms(
160
+ self.skip_platforms, additional_skip_platforms
161
+ ):
162
+ raise SkippedTest(f"Skipping test case on platform: {current_os()}")
163
+
122
164
  if self.timeout is None and default_timeout is not None:
123
165
  logging.info(f"Set default test case timeout to {default_timeout} seconds")
124
166
  self.timeout = default_timeout
@@ -156,24 +198,33 @@ class TestCase:
156
198
  # encoding="utf-8",
157
199
  text=True,
158
200
  )
159
- except TimeoutExpired as ex:
201
+ except TimeoutExpired:
160
202
  raise TimeoutError(
161
- f"CLI timed out after {self.timeout} seconds: {clean_args}"
162
- ) from ex
203
+ f"CLI timed out after {self.timeout} seconds: {' '.join(clean_args)}"
204
+ )
163
205
 
164
- print_cli_run(clean_args, result)
206
+ for line in render_cli_run(clean_args, result).splitlines():
207
+ logging.info(line)
165
208
 
166
209
  for field_id, field_data in asdict(self).items():
167
- if field_id == "cli_parameters" or (not field_data and field_data != 0):
210
+ if field_id == "exit_code":
211
+ if field_data is not None:
212
+ logging.info(f"Test exit code, expecting: {field_data}")
213
+ if result.returncode != field_data:
214
+ raise AssertionError(
215
+ f"CLI exited with code {result.returncode}, "
216
+ f"expected {field_data}"
217
+ )
218
+ # The specific exit code matches, let's proceed to the next test.
168
219
  continue
169
220
 
170
- if field_id == "exit_code":
171
- if result.returncode != field_data:
172
- raise AssertionError(
173
- f"CLI exited with code {result.returncode}, "
174
- f"expected {field_data}"
175
- )
221
+ # Ignore non-output fields, and empty test cases.
222
+ elif not (
223
+ field_id.startswith(("output_", "stdout_", "stderr_")) and field_data
224
+ ):
225
+ continue
176
226
 
227
+ # Prepare output and name for comparison.
177
228
  output = ""
178
229
  name = ""
179
230
  if field_id.startswith("output_"):
@@ -188,50 +239,50 @@ class TestCase:
188
239
  name = "<stderr>"
189
240
 
190
241
  if self.strip_ansi:
242
+ logging.info(f"Strip ANSI sequences from {name}")
191
243
  output = strip_ansi(output)
192
244
 
193
245
  if field_id.endswith("_contains"):
194
246
  for sub_string in field_data:
247
+ logging.info(f"Check if {name} contains {sub_string!r}")
195
248
  if sub_string not in output:
196
- raise AssertionError(
197
- f"CLI's {name} does not contain {sub_string!r}"
198
- )
249
+ raise AssertionError(f"{name} does not contain {sub_string!r}")
199
250
 
200
251
  elif field_id.endswith("_regex_matches"):
201
252
  for regex in field_data:
202
- if not re.search(regex, output):
203
- raise AssertionError(
204
- f"CLI's {name} does not match regex {regex!r}"
205
- )
253
+ logging.info(f"Check if {name} matches {sub_string!r}")
254
+ if not regex.search(output):
255
+ raise AssertionError(f"{name} does not match regex {regex}")
206
256
 
207
257
  elif field_id.endswith("_regex_fullmatch"):
208
258
  regex = field_data
209
- if not re.fullmatch(regex, output):
210
- raise AssertionError(
211
- f"CLI's {name} does not fully match regex {regex!r}"
212
- )
259
+ if not regex.fullmatch(output):
260
+ raise AssertionError(f"{name} does not fully match regex {regex}")
213
261
 
214
262
 
215
- DEFAULT_TEST_PLAN = (
263
+ DEFAULT_TEST_PLAN: list[CLITestCase] = [
216
264
  # Output the version of the CLI.
217
- TestCase(cli_parameters="--version"),
265
+ CLITestCase(cli_parameters="--version"),
218
266
  # Test combination of version and verbosity.
219
- TestCase(cli_parameters=("--verbosity", "DEBUG", "--version")),
267
+ CLITestCase(cli_parameters=("--verbosity", "DEBUG", "--version")),
220
268
  # Test help output.
221
- TestCase(cli_parameters="--help"),
222
- )
269
+ CLITestCase(cli_parameters="--help"),
270
+ ]
271
+
223
272
 
273
+ def parse_test_plan(plan_string: str | None) -> Generator[CLITestCase, None, None]:
274
+ if not plan_string:
275
+ raise ValueError("Empty test plan")
224
276
 
225
- def parse_test_plan(plan_path: Path) -> Generator[TestCase, None, None]:
226
- plan = yaml.full_load(plan_path.read_text(encoding="UTF-8"))
277
+ plan = yaml.full_load(plan_string)
227
278
 
228
279
  # Validates test plan structure.
229
280
  if not plan:
230
- raise ValueError(f"Empty test plan file {plan_path}")
281
+ raise ValueError("Empty test plan")
231
282
  if not isinstance(plan, list):
232
283
  raise ValueError(f"Test plan is not a list: {plan}")
233
284
 
234
- directives = frozenset(TestCase.__dataclass_fields__.keys())
285
+ directives = frozenset(CLITestCase.__dataclass_fields__.keys())
235
286
 
236
287
  for index, test_case in enumerate(plan):
237
288
  # Validates test case structure.
@@ -243,4 +294,4 @@ def parse_test_plan(plan_path: Path) -> Generator[TestCase, None, None]:
243
294
  f"{set(test_case) - directives}"
244
295
  )
245
296
 
246
- yield TestCase(**test_case)
297
+ yield CLITestCase(**test_case)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: gha-utils
3
- Version: 4.14.1
3
+ Version: 4.15.0
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
@@ -19,7 +19,6 @@ Classifier: Operating System :: MacOS :: MacOS X
19
19
  Classifier: Operating System :: Microsoft :: Windows
20
20
  Classifier: Operating System :: POSIX :: Linux
21
21
  Classifier: Programming Language :: Python :: 3
22
- Classifier: Programming Language :: Python :: 3.10
23
22
  Classifier: Programming Language :: Python :: 3.11
24
23
  Classifier: Programming Language :: Python :: 3.12
25
24
  Classifier: Programming Language :: Python :: 3.13
@@ -43,17 +42,16 @@ Classifier: Topic :: Text Processing :: Markup :: HTML
43
42
  Classifier: Topic :: Text Processing :: Markup :: Markdown
44
43
  Classifier: Topic :: Utilities
45
44
  Classifier: Typing :: Typed
46
- Requires-Python: >=3.10
45
+ Requires-Python: >=3.11
47
46
  Description-Content-Type: text/markdown
48
- Requires-Dist: backports.strenum~=1.3.1; python_version < "3.11"
49
47
  Requires-Dist: boltons>=24.0.0
50
- Requires-Dist: bump-my-version>=0.21.0
51
- Requires-Dist: click-extra~=4.14.1
48
+ Requires-Dist: bump-my-version>=0.32.2
49
+ Requires-Dist: click-extra~=4.15.0
50
+ Requires-Dist: extra-platforms~=3.1.0
52
51
  Requires-Dist: packaging~=24.1
53
52
  Requires-Dist: PyDriller~=2.6
54
53
  Requires-Dist: pyproject-metadata~=0.9.0
55
54
  Requires-Dist: pyyaml~=6.0.0
56
- Requires-Dist: tomli~=2.0.1; python_version < "3.11"
57
55
  Requires-Dist: wcmatch>=8.5
58
56
  Provides-Extra: test
59
57
  Requires-Dist: coverage[toml]~=7.6.0; extra == "test"
@@ -137,7 +135,7 @@ $ uvx gha-utils --version
137
135
  gha-utils, version 4.9.0
138
136
  ```
139
137
 
140
- That's the best way to get started with `gha-utils`, and experiment with its features.
138
+ That's the best way to get started with `gha-utils` and experiment with it.
141
139
 
142
140
  ### Executables
143
141
 
@@ -0,0 +1,14 @@
1
+ gha_utils/__init__.py,sha256=H4VVQ1RRW-KQELnav8RAL_mjRUNRgbZq3kGBdL-8-b8,866
2
+ gha_utils/__main__.py,sha256=Dck9BjpLXmIRS83k0mghAMcYVYiMiFLltQdfRuMSP_Q,1703
3
+ gha_utils/changelog.py,sha256=JR7iQrWjLoIOpVNe6iXQSyEii82_hM_zrYpR7QO_Uxo,5777
4
+ gha_utils/cli.py,sha256=58uww5uCnKN6JGDZJ0Gm_ZG-VCuzHopKTXIxAcp7w9M,12936
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=AbiEdWjcJfUaVwlhV31J-HXJldZgFB0OKS2fHJ60FXU,48282
8
+ gha_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ gha_utils/test_plan.py,sha256=iuSfsJ9XoqsIUbqR0HrkooUvK17TFBtlx8HyCAtMVWM,12841
10
+ gha_utils-4.15.0.dist-info/METADATA,sha256=K8Fjiy-evM6R223OvGk5E9jh9X8rBdarb_g-WJadJXo,20372
11
+ gha_utils-4.15.0.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
12
+ gha_utils-4.15.0.dist-info/entry_points.txt,sha256=8bJOwQYf9ZqsLhBR6gUCzvwLNI9f8tiiBrJ3AR0EK4o,54
13
+ gha_utils-4.15.0.dist-info/top_level.txt,sha256=C94Blb61YkkyPBwCdM3J_JPDjWH0lnKa5nGZeZ5M6yE,10
14
+ gha_utils-4.15.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.0)
2
+ Generator: setuptools (75.8.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,14 +0,0 @@
1
- gha_utils/__init__.py,sha256=tyy7yfWx6CvbUpDZspBu8GcoheikuKeyNdTJc9kXb3k,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=k0y799fNn8W10A_LT4QuO-ZgSWUloA7owMAePJa70sI,10730
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=IeBm5VRQnaSUMohpV0SnBvPSLG-Ke6O4i8WJsHdyqOM,10609
10
- gha_utils-4.14.1.dist-info/METADATA,sha256=l3L-pznLOZFr_MYlQpQfuhA8HHRi4ltqS57oa0jUtXI,20514
11
- gha_utils-4.14.1.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
12
- gha_utils-4.14.1.dist-info/entry_points.txt,sha256=8bJOwQYf9ZqsLhBR6gUCzvwLNI9f8tiiBrJ3AR0EK4o,54
13
- gha_utils-4.14.1.dist-info/top_level.txt,sha256=C94Blb61YkkyPBwCdM3J_JPDjWH0lnKa5nGZeZ5M6yE,10
14
- gha_utils-4.14.1.dist-info/RECORD,,