gha-utils 4.17.8__py3-none-any.whl → 4.18.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.17.8"
20
+ __version__ = "4.18.0"
gha_utils/cli.py CHANGED
@@ -116,8 +116,8 @@ def gha_utils():
116
116
  )
117
117
  @option(
118
118
  "--format",
119
- type=Choice(tuple(item.value for item in Dialects), case_sensitive=False),
120
- default="github",
119
+ type=Choice(Dialects, case_sensitive=False),
120
+ default=Dialects.github,
121
121
  help="Rendering format of the metadata.",
122
122
  )
123
123
  @option(
gha_utils/matrix.py CHANGED
@@ -27,7 +27,7 @@ from boltons.iterutils import unique
27
27
  RESERVED_MATRIX_KEYWORDS = ["include", "exclude"]
28
28
 
29
29
 
30
- class Matrix(FrozenDict):
30
+ class Matrix:
31
31
  """A matrix as defined by GitHub's actions workflows.
32
32
 
33
33
  See GitHub official documentation on `how-to implement variations of jobs in a
@@ -47,35 +47,43 @@ class Matrix(FrozenDict):
47
47
  matrix.
48
48
  """
49
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()
50
+ def __init__(self, *args, **kwargs):
51
+ self.variations: dict = {}
52
+
53
+ # Tuples are used to keep track of the insertion order and force immutability.
54
+ self.include: tuple[dict[str, str], ...] = tuple()
55
+ self.exclude: tuple[dict[str, str], ...] = tuple()
56
+
57
+ self._job_counter = None
53
58
 
54
59
  def matrix(
55
60
  self, ignore_includes: bool = False, ignore_excludes: bool = False
56
- ) -> dict[str, str]:
61
+ ) -> FrozenDict[str, str]:
57
62
  """Returns a copy of the matrix.
58
63
 
59
64
  The special ``include`` and ``excludes`` directives will be added by default.
60
65
  You can selectively ignore them by passing the corresponding boolean parameters.
61
66
  """
62
- dict_copy = dict(self)
67
+ dict_copy = self.variations.copy()
63
68
  if not ignore_includes and self.include:
64
69
  dict_copy["include"] = self.include
65
70
  if not ignore_excludes and self.exclude:
66
71
  dict_copy["exclude"] = self.exclude
67
- return dict_copy
72
+ return FrozenDict(dict_copy)
68
73
 
69
74
  def __repr__(self) -> str:
70
- return (
71
- f"<{self.__class__.__name__}: {super(FrozenDict, self).__repr__()}; "
72
- f"include={self.include}; exclude={self.exclude}>"
73
- )
75
+ return f"<{self.__class__.__name__}: {self.matrix()}>"
74
76
 
75
77
  def __str__(self) -> str:
76
78
  """Render matrix as a JSON string."""
77
79
  return json.dumps(self.matrix())
78
80
 
81
+ def __getitem__(self, key: str) -> tuple[str, ...]:
82
+ """Returns the values of a variation by its ID."""
83
+ if key in self.variations:
84
+ return self.variations[key]
85
+ raise KeyError(f"Variation {key} not found in matrix")
86
+
79
87
  @staticmethod
80
88
  def _check_ids(*var_ids: str) -> None:
81
89
  for var_id in var_ids:
@@ -89,8 +97,8 @@ class Matrix(FrozenDict):
89
97
  if any(type(v) is not str for v in values):
90
98
  raise ValueError(f"Only strings are accepted in {values}")
91
99
  # 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)))
100
+ var_values = list(self.variations.get(variation_id, [])) + list(values)
101
+ self.variations[variation_id] = tuple(unique(var_values))
94
102
 
95
103
  def _add_and_dedup_dicts(
96
104
  self, *new_dicts: dict[str, str]
@@ -123,9 +131,9 @@ class Matrix(FrozenDict):
123
131
  passing the corresponding ``with_matrix``, ``with_includes`` and
124
132
  ``with_excludes`` boolean filter parameters.
125
133
  """
126
- variations = {}
134
+ all_variations = {}
127
135
  if with_matrix:
128
- variations = {k: list(v) for k, v in self.items()}
136
+ all_variations = {k: list(v) for k, v in self.variations.items()}
129
137
 
130
138
  for expand, directives in (
131
139
  (with_includes, self.include),
@@ -134,9 +142,9 @@ class Matrix(FrozenDict):
134
142
  if expand:
135
143
  for value in directives:
136
144
  for k, v in value.items():
137
- variations.setdefault(k, []).append(v)
145
+ all_variations.setdefault(k, []).append(v)
138
146
 
139
- return {k: tuple(unique(v)) for k, v in variations.items()}
147
+ return {k: tuple(unique(v)) for k, v in all_variations.items()}
140
148
 
141
149
  def product(
142
150
  self, with_includes: bool = False, with_excludes: bool = False
@@ -148,17 +156,17 @@ class Matrix(FrozenDict):
148
156
 
149
157
  Respects the order of variations and their values.
150
158
  """
151
- variations = self.all_variations(
159
+ all_variations = self.all_variations(
152
160
  with_includes=with_includes, with_excludes=with_excludes
153
161
  )
154
- if not variations:
162
+ if not all_variations:
155
163
  return
156
164
  yield from map(
157
165
  dict,
158
166
  itertools.product(
159
167
  *(
160
168
  tuple((variant_id, v) for v in variations)
161
- for variant_id, variations in variations.items()
169
+ for variant_id, variations in all_variations.items()
162
170
  )
163
171
  ),
164
172
  )
@@ -187,11 +195,11 @@ class Matrix(FrozenDict):
187
195
  self.all_variations(
188
196
  with_matrix=False, with_includes=True, with_excludes=True
189
197
  )
190
- ).difference(self)
198
+ ).difference(self.variations)
191
199
  if unreferenced_keys:
192
200
  raise ValueError(
193
201
  f"Matrix exclude keys {list(unreferenced_keys)} does not match any "
194
- f"{list(self)} key within the matrix"
202
+ f"{self.variations.keys()} key within the matrix"
195
203
  )
196
204
 
197
205
  # Reset the number of combinations.
@@ -202,7 +210,7 @@ class Matrix(FrozenDict):
202
210
 
203
211
  # The matrix is empty, none of the include directive will match, so condider all
204
212
  # directives as un-applicable.
205
- if not self:
213
+ if not self.variations:
206
214
  leftover_includes = list(self.include)
207
215
 
208
216
  # Search for include directives that matches the original matrix variations
gha_utils/metadata.py CHANGED
@@ -164,12 +164,13 @@ from operator import itemgetter
164
164
  from pathlib import Path
165
165
  from random import randint
166
166
  from re import escape
167
- from typing import Any, Final, Iterator, cast
167
+ from typing import Any, Final, cast
168
168
 
169
169
  from bumpversion.config import get_configuration # type: ignore[import-untyped]
170
170
  from bumpversion.config.files import find_config_file # type: ignore[import-untyped]
171
171
  from bumpversion.show import resolve_name # type: ignore[import-untyped]
172
172
  from extra_platforms import is_github_ci
173
+ from gitignore_parser import parse_gitignore
173
174
  from packaging.specifiers import SpecifierSet
174
175
  from packaging.version import Version
175
176
  from pydriller import Commit, Git, Repository # type: ignore[import-untyped]
@@ -196,6 +197,7 @@ SHORT_SHA_LENGTH = 7
196
197
  depends on the size of the repository.
197
198
  """
198
199
 
200
+ GITIGNORE_PATH = Path(".gitignore")
199
201
 
200
202
  NUITKA_BUILD_TARGETS = {
201
203
  "linux-arm64": {
@@ -315,7 +317,7 @@ WorkflowEvent = StrEnum(
315
317
  """
316
318
 
317
319
 
318
- Dialects = StrEnum("Dialects", ("github", "plain"))
320
+ Dialects = StrEnum("Dialects", ("github", "json"))
319
321
  """Dialects in which metadata can be formatted to."""
320
322
 
321
323
 
@@ -347,6 +349,19 @@ MYPY_VERSION_MIN: Final = (3, 8)
347
349
  """
348
350
 
349
351
 
352
+ class JSONMetadata(json.JSONEncoder):
353
+ """Custom JSON encoder for metadata serialization."""
354
+
355
+ def default(self, o: Any) -> str:
356
+ if isinstance(o, Matrix):
357
+ return o.matrix()
358
+
359
+ if isinstance(o, Path):
360
+ return str(o)
361
+
362
+ return super().default(o)
363
+
364
+
350
365
  class Metadata:
351
366
  """Metadata class."""
352
367
 
@@ -685,8 +700,11 @@ class Metadata:
685
700
  else None
686
701
  )
687
702
 
688
- @staticmethod
689
- def glob_files(*patterns: str) -> Iterator[Path]:
703
+ @cached_property
704
+ def gitignore_exists(self) -> bool:
705
+ return GITIGNORE_PATH.is_file()
706
+
707
+ def glob_files(self, *patterns: str) -> list[Path]:
690
708
  """Return all file path matching the ``patterns``.
691
709
 
692
710
  Patterns are glob patterns supporting ``**`` for recursive search, and ``!``
@@ -695,46 +713,77 @@ class Metadata:
695
713
  All directories are traversed, whether they are hidden (i.e. starting with a
696
714
  dot ``.``) or not, including symlinks.
697
715
 
698
- Returns both hidden and non-hidden files, but no directories.
716
+ Skips:
717
+
718
+ - files which does not exists
719
+ - directories
720
+ - broken symlinks
721
+ - files matching patterns specified by ``.gitignore`` file
722
+
723
+ Returns both hidden and non-hidden files.
699
724
 
700
725
  All files are normalized to their absolute path, so that duplicates produced by
701
726
  symlinks are ignored.
702
727
 
703
- Files that doesn't exist and broken symlinks are skipped.
728
+ File path are returned as relative to the current working directory if
729
+ possible, or as absolute path otherwise.
730
+
731
+ The resulting list of file paths is sorted.
704
732
  """
733
+ current_dir = Path.cwd()
705
734
  seen = set()
735
+
736
+ # If the .gitignore file exists, we parse it to filter out ignored files.
737
+ gitignore = None
738
+ if self.gitignore_exists:
739
+ logging.debug(f"Load {GITIGNORE_PATH} to filter out ignored files.")
740
+ gitignore = parse_gitignore(GITIGNORE_PATH)
741
+
706
742
  for file_path in iglob(
707
743
  patterns,
708
744
  flags=NODIR | GLOBSTAR | DOTGLOB | GLOBTILDE | BRACE | FOLLOW | NEGATE,
709
745
  ):
710
746
  # Normalize the path to avoid duplicates.
711
747
  try:
712
- normalized_path = Path(file_path).resolve(strict=True)
713
- # Skip files that do not exist or broken symlinks.
748
+ absolute_path = Path(file_path).resolve(strict=True)
749
+ # Skip files that do not exists and broken symlinks.
714
750
  except OSError:
715
- logging.warning(
716
- f"Skipping non-existing file / broken symlink: {file_path}"
717
- )
751
+ logging.warning(f"Skip non-existing file / broken symlink: {file_path}")
718
752
  continue
753
+
754
+ # Simplify the path by trying to make it relative to the current location.
755
+ normalized_path = absolute_path
756
+ try:
757
+ normalized_path = absolute_path.relative_to(current_dir)
758
+ except ValueError:
759
+ # If the file is not relative to the current directory, keep its
760
+ # absolute path.
761
+ logging.debug(
762
+ f"{absolute_path} is not relative to {current_dir}. "
763
+ "Keeping the path absolute."
764
+ )
765
+
719
766
  if normalized_path in seen:
720
- logging.debug(f"Skipping duplicate file: {normalized_path}")
767
+ logging.debug(f"Skip duplicate file: {normalized_path}")
721
768
  continue
722
- seen.add(normalized_path)
723
- yield normalized_path
724
769
 
725
- @cached_property
726
- def gitignore_exists(self) -> bool:
727
- return Path(".gitignore").is_file()
770
+ # Skip files that are ignored by .gitignore.
771
+ if gitignore and gitignore(file_path):
772
+ logging.debug(f"Skip file matching {GITIGNORE_PATH}: {file_path}")
773
+ continue
774
+
775
+ seen.add(normalized_path)
776
+ return sorted(seen)
728
777
 
729
778
  @cached_property
730
- def python_files(self) -> Iterator[Path]:
779
+ def python_files(self) -> list[Path]:
731
780
  """Returns a list of python files."""
732
- yield from self.glob_files("**/*.py", "!.venv/**")
781
+ return self.glob_files("**/*.py", "!.venv/**")
733
782
 
734
783
  @cached_property
735
- def doc_files(self) -> Iterator[Path]:
784
+ def doc_files(self) -> list[Path]:
736
785
  """Returns a list of doc files."""
737
- yield from self.glob_files("**/*.{md,markdown,rst,tex}", "!.venv/**")
786
+ return self.glob_files("**/*.{md,markdown,rst,tex}", "!.venv/**")
738
787
 
739
788
  @property
740
789
  def is_python_project(self):
@@ -844,7 +893,7 @@ class Metadata:
844
893
  return None
845
894
 
846
895
  @cached_property
847
- def blacken_docs_params(self) -> tuple[str, ...] | None:
896
+ def blacken_docs_params(self) -> str | None:
848
897
  """Generates ``blacken-docs`` parameters.
849
898
 
850
899
  `Blacken-docs reuses Black's --target-version pyXY parameters
@@ -867,7 +916,7 @@ class Metadata:
867
916
  <https://github.com/psf/black/issues/751#issuecomment-473066811>`_.
868
917
  """
869
918
  if self.py_target_versions:
870
- return tuple(
919
+ return " ".join(
871
920
  f"--target-version py{version.major}{version.minor}"
872
921
  for version in self.py_target_versions
873
922
  )
@@ -1185,7 +1234,7 @@ class Metadata:
1185
1234
  for variations in matrix.solve():
1186
1235
  # We will re-attach back this binary name to the with an include directive,
1187
1236
  # so we need a copy the main variants it corresponds to.
1188
- bin_name_include = {k: variations[k] for k in matrix}
1237
+ bin_name_include = {k: variations[k] for k in matrix.variations}
1189
1238
  bin_name_include["bin_name"] = (
1190
1239
  "{cli_id}-{target}-{short_sha}.{extension}"
1191
1240
  ).format(**variations)
@@ -1316,8 +1365,8 @@ class Metadata:
1316
1365
  delimiter = f"ghadelimiter_{randint(10**8, (10**9) - 1)}"
1317
1366
  content += f"{env_name}<<{delimiter}\n{env_value}\n{delimiter}\n"
1318
1367
  else:
1319
- assert dialect == Dialects.plain
1320
- content = repr(metadata)
1368
+ assert dialect == Dialects.json
1369
+ content = json.dumps(metadata, cls=JSONMetadata, indent=2)
1321
1370
 
1322
1371
  logging.debug(f"Formatted metadata:\n{content}")
1323
1372
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gha-utils
3
- Version: 4.17.8
3
+ Version: 4.18.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
@@ -48,13 +48,14 @@ Requires-Dist: boltons>=24.0.0
48
48
  Requires-Dist: bump-my-version<1.1.1,>=0.32.2
49
49
  Requires-Dist: click-extra~=5.0.2
50
50
  Requires-Dist: extra-platforms~=3.2.0
51
+ Requires-Dist: gitignore-parser~=0.1.12
51
52
  Requires-Dist: packaging~=25.0
52
53
  Requires-Dist: PyDriller~=2.6
53
54
  Requires-Dist: pyproject-metadata~=0.9.0
54
55
  Requires-Dist: pyyaml~=6.0.0
55
56
  Requires-Dist: wcmatch>=8.5
56
57
  Provides-Extra: test
57
- Requires-Dist: coverage[toml]~=7.9.1; extra == "test"
58
+ Requires-Dist: coverage[toml]~=7.10.0; extra == "test"
58
59
  Requires-Dist: pytest~=8.4.0; extra == "test"
59
60
  Requires-Dist: pytest-cases~=3.9.1; extra == "test"
60
61
  Requires-Dist: pytest-cov~=6.2.1; extra == "test"
@@ -0,0 +1,14 @@
1
+ gha_utils/__init__.py,sha256=KNIydaZSQDWkUZrmo5UJzpgBt-OmcHis97wXDtM0gu8,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=jmrSI05qfrkxcB3fQuiB6xf4eumXqg-MBiCITdeW104,15273
5
+ gha_utils/mailmap.py,sha256=g3LQiPNjHsAgCbEYOJcQwdlXqxzmFh697vv2sxHZq-s,7014
6
+ gha_utils/matrix.py,sha256=4WM2ORH3n-Ohi3q6XnmAA2PtOdpLvhwaePy6kmlClmQ,12302
7
+ gha_utils/metadata.py,sha256=-b6R7UjleS1Y6HA7aA0Z7q4LfMky-lXuzuzBZjGpkTw,53970
8
+ gha_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ gha_utils/test_plan.py,sha256=AE8Mf1vSQG5EZTytoTts-gzMwUg2Zy21gUwkMlzXT94,13394
10
+ gha_utils-4.18.0.dist-info/METADATA,sha256=ySyh9n8_a3brTZFPiMn6tldA3calXtlEXT3gb44h8yg,21301
11
+ gha_utils-4.18.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
+ gha_utils-4.18.0.dist-info/entry_points.txt,sha256=8bJOwQYf9ZqsLhBR6gUCzvwLNI9f8tiiBrJ3AR0EK4o,54
13
+ gha_utils-4.18.0.dist-info/top_level.txt,sha256=C94Blb61YkkyPBwCdM3J_JPDjWH0lnKa5nGZeZ5M6yE,10
14
+ gha_utils-4.18.0.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- gha_utils/__init__.py,sha256=xQ8XhrrKPUX1ugF2PQDTpF9WludE08Fhm2O-56UBNU0,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=1xx7gG0fXwqHQJSQWzBLEKO1p9rAnFnkcuPnmqPS5M4,15296
5
- gha_utils/mailmap.py,sha256=g3LQiPNjHsAgCbEYOJcQwdlXqxzmFh697vv2sxHZq-s,7014
6
- gha_utils/matrix.py,sha256=_afJD0K-xZLNxwykVnUhD0Gj9cdO0Z43g3VHa-q_tkI,11941
7
- gha_utils/metadata.py,sha256=o7vyW8d97PGtoGZ1pyvJQbeKl86mRRfbxJnEo5dxUos,52322
8
- gha_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
- gha_utils/test_plan.py,sha256=AE8Mf1vSQG5EZTytoTts-gzMwUg2Zy21gUwkMlzXT94,13394
10
- gha_utils-4.17.8.dist-info/METADATA,sha256=mnhwwWZHaAvT-79rR8qMfVvXUmseMSgtLHQJrUSE3i8,21260
11
- gha_utils-4.17.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- gha_utils-4.17.8.dist-info/entry_points.txt,sha256=8bJOwQYf9ZqsLhBR6gUCzvwLNI9f8tiiBrJ3AR0EK4o,54
13
- gha_utils-4.17.8.dist-info/top_level.txt,sha256=C94Blb61YkkyPBwCdM3J_JPDjWH0lnKa5nGZeZ5M6yE,10
14
- gha_utils-4.17.8.dist-info/RECORD,,