gha-utils 4.13.4__tar.gz → 4.14.1__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 (23) hide show
  1. {gha_utils-4.13.4 → gha_utils-4.14.1}/PKG-INFO +10 -4
  2. {gha_utils-4.13.4 → gha_utils-4.14.1}/gha_utils/__init__.py +1 -1
  3. {gha_utils-4.13.4 → gha_utils-4.14.1}/gha_utils/cli.py +42 -2
  4. {gha_utils-4.13.4 → gha_utils-4.14.1}/gha_utils/mailmap.py +1 -1
  5. gha_utils-4.14.1/gha_utils/matrix.py +279 -0
  6. {gha_utils-4.13.4 → gha_utils-4.14.1}/gha_utils/metadata.py +48 -120
  7. gha_utils-4.14.1/gha_utils/test_plan.py +246 -0
  8. {gha_utils-4.13.4 → gha_utils-4.14.1}/gha_utils.egg-info/PKG-INFO +10 -4
  9. {gha_utils-4.13.4 → gha_utils-4.14.1}/gha_utils.egg-info/SOURCES.txt +6 -1
  10. {gha_utils-4.13.4 → gha_utils-4.14.1}/gha_utils.egg-info/requires.txt +5 -1
  11. {gha_utils-4.13.4 → gha_utils-4.14.1}/pyproject.toml +9 -3
  12. {gha_utils-4.13.4 → gha_utils-4.14.1}/readme.md +5 -2
  13. gha_utils-4.14.1/tests/test_mailmap.py +73 -0
  14. gha_utils-4.14.1/tests/test_matrix.py +662 -0
  15. gha_utils-4.14.1/tests/test_metadata.py +143 -0
  16. {gha_utils-4.13.4 → gha_utils-4.14.1}/gha_utils/__main__.py +0 -0
  17. {gha_utils-4.13.4 → gha_utils-4.14.1}/gha_utils/changelog.py +0 -0
  18. {gha_utils-4.13.4 → gha_utils-4.14.1}/gha_utils/py.typed +0 -0
  19. {gha_utils-4.13.4 → gha_utils-4.14.1}/gha_utils.egg-info/dependency_links.txt +0 -0
  20. {gha_utils-4.13.4 → gha_utils-4.14.1}/gha_utils.egg-info/entry_points.txt +0 -0
  21. {gha_utils-4.13.4 → gha_utils-4.14.1}/gha_utils.egg-info/top_level.txt +0 -0
  22. {gha_utils-4.13.4 → gha_utils-4.14.1}/setup.cfg +0 -0
  23. {gha_utils-4.13.4 → gha_utils-4.14.1}/tests/test_changelog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: gha-utils
3
- Version: 4.13.4
3
+ Version: 4.14.1
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
@@ -48,10 +48,11 @@ Description-Content-Type: text/markdown
48
48
  Requires-Dist: backports.strenum~=1.3.1; python_version < "3.11"
49
49
  Requires-Dist: boltons>=24.0.0
50
50
  Requires-Dist: bump-my-version>=0.21.0
51
- Requires-Dist: click-extra~=4.13.2
51
+ Requires-Dist: click-extra~=4.14.1
52
52
  Requires-Dist: packaging~=24.1
53
53
  Requires-Dist: PyDriller~=2.6
54
54
  Requires-Dist: pyproject-metadata~=0.9.0
55
+ Requires-Dist: pyyaml~=6.0.0
55
56
  Requires-Dist: tomli~=2.0.1; python_version < "3.11"
56
57
  Requires-Dist: wcmatch>=8.5
57
58
  Provides-Extra: test
@@ -61,6 +62,8 @@ Requires-Dist: pytest-cases~=3.8.3; extra == "test"
61
62
  Requires-Dist: pytest-cov~=6.0.0; extra == "test"
62
63
  Requires-Dist: pytest-github-actions-annotate-failures~=0.3.0; extra == "test"
63
64
  Requires-Dist: pytest-randomly~=3.16.0; extra == "test"
65
+ Provides-Extra: typing
66
+ Requires-Dist: types-PyYAML~=6.0.12.9; extra == "typing"
64
67
 
65
68
  # `gha-utils` CLI + reusable workflows
66
69
 
@@ -100,7 +103,6 @@ Thanks to `uv`, you can install and run `gha-utils` in one command, without poll
100
103
 
101
104
  ```shell-session
102
105
  $ uvx gha-utils
103
- Installed 45 packages in 45ms
104
106
  Usage: gha-utils [OPTIONS] COMMAND [ARGS]...
105
107
 
106
108
  Options:
@@ -115,8 +117,11 @@ Options:
115
117
  utils/*.{toml,yaml,yml,json,ini,xml}]
116
118
  --show-params Show all CLI parameters, their provenance, defaults
117
119
  and value, then exit.
118
- -v, --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG.
120
+ --verbosity LEVEL Either CRITICAL, ERROR, WARNING, INFO, DEBUG.
119
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]
120
125
  --version Show the version and exit.
121
126
  -h, --help Show this message and exit.
122
127
 
@@ -124,6 +129,7 @@ Commands:
124
129
  changelog Maintain a Markdown-formatted changelog
125
130
  mailmap-sync Update Git's .mailmap file with missing contributors
126
131
  metadata Output project metadata
132
+ test-plan Run a test plan from a file against a binary
127
133
  ```
128
134
 
129
135
  ```shell-session
@@ -17,4 +17,4 @@
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- __version__ = "4.13.4"
20
+ __version__ = "4.14.1"
@@ -23,22 +23,24 @@ from datetime import datetime
23
23
  from pathlib import Path
24
24
  from typing import IO
25
25
 
26
+ import click
26
27
  from click_extra import (
27
28
  Choice,
28
29
  Context,
30
+ FloatRange,
29
31
  argument,
30
32
  echo,
31
33
  extra_group,
32
34
  file_path,
33
35
  option,
34
36
  pass_context,
35
- path,
36
37
  )
37
38
 
38
39
  from . import __version__
39
40
  from .changelog import Changelog
40
41
  from .mailmap import Mailmap
41
42
  from .metadata import Dialects, Metadata
43
+ from .test_plan import DEFAULT_TEST_PLAN, parse_test_plan
42
44
 
43
45
 
44
46
  def is_stdout(filepath: Path) -> bool:
@@ -167,7 +169,7 @@ def metadata(ctx, format, overwrite, output_path):
167
169
  @gha_utils.command(short_help="Maintain a Markdown-formatted changelog")
168
170
  @option(
169
171
  "--source",
170
- type=path(exists=True, readable=True, resolve_path=True),
172
+ type=file_path(exists=True, readable=True, resolve_path=True),
171
173
  default="changelog.md",
172
174
  help="Changelog source file in Markdown format.",
173
175
  )
@@ -268,3 +270,41 @@ def mailmap_sync(ctx, source, create_if_missing, destination_mailmap):
268
270
  ctx.exit()
269
271
 
270
272
  echo(generate_header(ctx) + new_content, file=prep_path(destination_mailmap))
273
+
274
+
275
+ @gha_utils.command(short_help="Run a test plan from a file against a binary")
276
+ @option(
277
+ "--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
+ required=True,
282
+ help="Path to the binary to test.",
283
+ )
284
+ @option(
285
+ "--plan",
286
+ type=file_path(exists=True, readable=True, resolve_path=True),
287
+ help="Test plan in YAML.",
288
+ )
289
+ @option(
290
+ "-t",
291
+ "--timeout",
292
+ # Timeout passed to subprocess.run() is a float that is silently clamped to
293
+ # 0.0 is negative values are provided, so we mimic this behavior here:
294
+ # https://github.com/python/cpython/blob/5740b95076b57feb6293cda4f5504f706a7d622d/Lib/subprocess.py#L1596-L1597
295
+ type=FloatRange(min=0, clamp=True),
296
+ help="Set the default timeout for each CLI call, if not specified in the "
297
+ "test plan.",
298
+ )
299
+ def test_plan(binary: Path, plan: Path | None, timeout: float | None) -> None:
300
+ # 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)
304
+ else:
305
+ logging.warning(f"No test plan provided. Default to: {DEFAULT_TEST_PLAN}")
306
+ test_plan = DEFAULT_TEST_PLAN # type: ignore[assignment]
307
+
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)
@@ -22,7 +22,7 @@ from dataclasses import dataclass, field
22
22
  from functools import cached_property
23
23
  from subprocess import run
24
24
 
25
- from boltons.iterutils import unique # type: ignore[import-untyped]
25
+ from boltons.iterutils import unique
26
26
 
27
27
 
28
28
  @dataclass(order=True, frozen=True)
@@ -0,0 +1,279 @@
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 itertools
20
+ import json
21
+ import logging
22
+ from typing import Iterable, Iterator
23
+
24
+ from boltons.dictutils import FrozenDict
25
+ from boltons.iterutils import unique
26
+
27
+ RESERVED_MATRIX_KEYWORDS = ["include", "exclude"]
28
+
29
+
30
+ class Matrix(FrozenDict):
31
+ """A matrix as defined by GitHub's actions workflows.
32
+
33
+ See GitHub official documentation on `how-to implement variations of jobs in a
34
+ workflow
35
+ <https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/running-variations-of-jobs-in-a-workflow>`_.
36
+
37
+ This Matrix behave like a ``dict`` and works everywhere a ``dict`` would. Only that
38
+ it is immutable and based on :class:`FrozenDict`. If you want to populate the matrix
39
+ you have to use the following methods:
40
+
41
+ - :meth:`add_variation`
42
+ - :meth:`add_includes`
43
+ - :meth:`add_excludes`
44
+
45
+ The implementation respects the order in which items were inserted. This provides a
46
+ natural and visual sorting that should ease the inspection and debugging of large
47
+ matrix.
48
+ """
49
+
50
+ # Tuples are used to keep track of the insertion order and force immutability.
51
+ include: tuple[dict[str, str], ...] = tuple()
52
+ exclude: tuple[dict[str, str], ...] = tuple()
53
+
54
+ def matrix(
55
+ self, ignore_includes: bool = False, ignore_excludes: bool = False
56
+ ) -> dict[str, str]:
57
+ """Returns a copy of the matrix.
58
+
59
+ The special ``include`` and ``excludes`` directives will be added by default.
60
+ You can selectively ignore them by passing the corresponding boolean parameters.
61
+ """
62
+ dict_copy = dict(self)
63
+ if not ignore_includes and self.include:
64
+ dict_copy["include"] = self.include
65
+ if not ignore_excludes and self.exclude:
66
+ dict_copy["exclude"] = self.exclude
67
+ return dict_copy
68
+
69
+ def __repr__(self) -> str:
70
+ return (
71
+ f"<{self.__class__.__name__}: {super(FrozenDict, self).__repr__()}; "
72
+ f"include={self.include}; exclude={self.exclude}>"
73
+ )
74
+
75
+ def __str__(self) -> str:
76
+ """Render matrix as a JSON string."""
77
+ return json.dumps(self.matrix())
78
+
79
+ @staticmethod
80
+ def _check_ids(*var_ids: str) -> None:
81
+ for var_id in var_ids:
82
+ if var_id in RESERVED_MATRIX_KEYWORDS:
83
+ raise ValueError(f"{var_id} cannot be used as a variation ID")
84
+
85
+ def add_variation(self, variation_id: str, values: Iterable[str]) -> None:
86
+ self._check_ids(variation_id)
87
+ if not values:
88
+ raise ValueError(f"No variation values provided: {values}")
89
+ if any(type(v) is not str for v in values):
90
+ raise ValueError(f"Only strings are accepted in {values}")
91
+ # Extend variation with values, and deduplicate them along the way.
92
+ var_values = list(self.get(variation_id, [])) + list(values)
93
+ super(FrozenDict, self).__setitem__(variation_id, tuple(unique(var_values)))
94
+
95
+ def _add_and_dedup_dicts(
96
+ self, *new_dicts: dict[str, str]
97
+ ) -> tuple[dict[str, str], ...]:
98
+ self._check_ids(*(k for d in new_dicts for k in d))
99
+ return tuple(
100
+ dict(items) for items in unique((tuple(d.items()) for d in new_dicts))
101
+ )
102
+
103
+ def add_includes(self, *new_includes: dict[str, str]) -> None:
104
+ """Add one or more ``include`` special directives to the matrix."""
105
+ self.include = self._add_and_dedup_dicts(*self.include, *new_includes)
106
+
107
+ def add_excludes(self, *new_excludes: dict[str, str]) -> None:
108
+ """Add one or more ``exclude`` special directives to the matrix."""
109
+ self.exclude = self._add_and_dedup_dicts(*self.exclude, *new_excludes)
110
+
111
+ def all_variations(
112
+ self,
113
+ with_matrix: bool = True,
114
+ with_includes: bool = False,
115
+ with_excludes: bool = False,
116
+ ) -> dict[str, tuple[str, ...]]:
117
+ """Collect all variations encountered in the matrix.
118
+
119
+ Extra variations mentioned in the special ``include`` and ``exclude``
120
+ directives will be ignored by default.
121
+
122
+ You can selectively expand or restrict the resulting inventory of variations by
123
+ passing the corresponding ``with_matrix``, ``with_includes`` and
124
+ ``with_excludes`` boolean filter parameters.
125
+ """
126
+ variations = {}
127
+ if with_matrix:
128
+ variations = {k: list(v) for k, v in self.items()}
129
+
130
+ for expand, directives in (
131
+ (with_includes, self.include),
132
+ (with_excludes, self.exclude),
133
+ ):
134
+ if expand:
135
+ for value in directives:
136
+ for k, v in value.items():
137
+ variations.setdefault(k, []).append(v)
138
+
139
+ return {k: tuple(unique(v)) for k, v in variations.items()}
140
+
141
+ def product(
142
+ self, with_includes: bool = False, with_excludes: bool = False
143
+ ) -> Iterator[dict[str, str]]:
144
+ """Only returns the combinations of the base matrix by default.
145
+
146
+ You can optionally add any variation referenced in the ``include`` and
147
+ ``exclude`` special directives.
148
+
149
+ Respects the order of variations and their values.
150
+ """
151
+ variations = self.all_variations(
152
+ with_includes=with_includes, with_excludes=with_excludes
153
+ )
154
+ if not variations:
155
+ return
156
+ yield from map(
157
+ dict,
158
+ itertools.product(
159
+ *(
160
+ tuple((variant_id, v) for v in variations)
161
+ for variant_id, variations in variations.items()
162
+ )
163
+ ),
164
+ )
165
+
166
+ def _count_job(self) -> None:
167
+ self._job_counter += 1
168
+ if self._job_counter > 256:
169
+ logging.critical("GitHub job matrix limit of 256 jobs reached")
170
+
171
+ def solve(self, strict: bool = False) -> Iterator[dict[str, str]]:
172
+ """Returns all combinations and apply ``include`` and ``exclude`` constraints.
173
+
174
+ .. caution::
175
+ As per GitHub specifications, all ``include`` combinations are processed
176
+ after ``exclude``. This allows you to use ``include`` to add back
177
+ combinations that were previously excluded.
178
+ """
179
+ # GitHub jobs fails with the following message if the exclude directive is
180
+ # referencing keys that are not present in the original base matrix:
181
+ # Invalid workflow file: .github/workflows/tests.yaml#L48
182
+ # The workflow is not valid.
183
+ # .github/workflows/tests.yaml (Line: 48, Col: 13): Matrix exclude key 'state'
184
+ # does not match any key within the matrix
185
+ if strict:
186
+ unreferenced_keys = set(
187
+ self.all_variations(
188
+ with_matrix=False, with_includes=True, with_excludes=True
189
+ )
190
+ ).difference(self)
191
+ if unreferenced_keys:
192
+ raise ValueError(
193
+ f"Matrix exclude keys {list(unreferenced_keys)} does not match any "
194
+ f"{list(self)} key within the matrix"
195
+ )
196
+
197
+ # Reset the number of combinations.
198
+ self._job_counter = 0
199
+
200
+ applicable_includes = []
201
+ leftover_includes: list[dict[str, str]] = []
202
+
203
+ # The matrix is empty, none of the include directive will match, so condider all
204
+ # directives as un-applicable.
205
+ if not self:
206
+ leftover_includes = list(self.include)
207
+
208
+ # Search for include directives that matches the original matrix variations
209
+ # without overwriting their values. Keep the left overs on the side.
210
+ else:
211
+ original_variations = self.all_variations()
212
+ for include in self.include:
213
+ # Keys shared between the include directive and the original matrix.
214
+ keys_overlap = set(include).intersection(original_variations)
215
+ # Collect include directives applicable to the original matrix.
216
+ if (
217
+ # If all overlapping keys in the directive exactly match any value
218
+ # of the original matrix, then we are certain the directive can be
219
+ # applied without overwriting the original variations.
220
+ all(include[k] in original_variations[k] for k in keys_overlap)
221
+ # Same if no keys are shared, in which case these extra variations
222
+ # will be added to all original ones.
223
+ or not keys_overlap
224
+ ):
225
+ applicable_includes.append(include)
226
+ # Other directives are considered non-applicable and will be returned
227
+ # as-is at the end of the process.
228
+ else:
229
+ leftover_includes.append(include)
230
+
231
+ # Iterates through all the variations of the original matrix, and act on the
232
+ # matching exclude and include directives.
233
+ for base_variations in self.product():
234
+ # Skip the variation if it is fully matching at least one exclude directive.
235
+ exclusion_candidate = False
236
+ if any(
237
+ all(
238
+ exclude[k] == base_variations[k]
239
+ for k in set(exclude).intersection(base_variations)
240
+ )
241
+ for exclude in self.exclude
242
+ ):
243
+ exclusion_candidate = True
244
+
245
+ # Expand and/or extend the original variation set with applicable include
246
+ # directives.
247
+ updated_variations = base_variations.copy()
248
+ for include in applicable_includes:
249
+ # Check if the include directive is completely disjoint to the
250
+ # variations of the original matrix. If that's the case, then we are
251
+ # supposed to augment the current variation with this include, at it has
252
+ # already been identified as applicable. But only do that if the updated
253
+ # variation has not been already updated with a previously evaluated,
254
+ # more targeted include directive.
255
+ if set(include).isdisjoint(base_variations):
256
+ if set(include).isdisjoint(updated_variations):
257
+ updated_variations.update(include)
258
+ continue
259
+
260
+ # Expand the base variation set with the fully matching include
261
+ # directive.
262
+ if all(
263
+ include[k] == base_variations[k]
264
+ for k in set(include).intersection(base_variations)
265
+ ):
266
+ # Re-instate the variation set as a valid candidate since we found
267
+ # an include directive that is explicitly referring to it,
268
+ # resurrecting it from the dead.
269
+ exclusion_candidate = False
270
+ updated_variations.update(include)
271
+
272
+ if not exclusion_candidate:
273
+ self._count_job()
274
+ yield updated_variations
275
+
276
+ # Return as-is all the includes that were not applied to the original matrix.
277
+ for variation in leftover_includes:
278
+ self._count_job()
279
+ yield variation