gha-utils 4.1.4__py3-none-any.whl → 4.2.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.1.4"
20
+ __version__ = "4.2.0"
gha_utils/changelog.py CHANGED
@@ -25,7 +25,7 @@ from textwrap import indent
25
25
  if sys.version_info >= (3, 11):
26
26
  import tomllib
27
27
  else:
28
- import tomli as tomllib # type: ignore[import]
28
+ import tomli as tomllib # type: ignore[import-not-found]
29
29
 
30
30
 
31
31
  class Changelog:
gha_utils/cli.py CHANGED
@@ -56,13 +56,41 @@ def file_writer(filepath):
56
56
  writer.close()
57
57
 
58
58
 
59
- def get_header(ctx: Context):
60
- """Generates metadata to be leaved as comments to the top of a file generated by this CLI."""
61
- return (
59
+ def generate_header(ctx: Context) -> str:
60
+ """Generate metadata to be left as comments to the top of a file generated by
61
+ this CLI.
62
+ """
63
+ header = (
62
64
  f"# Generated by {ctx.command_path} v{__version__}"
63
65
  " - https://github.com/kdeldycke/workflows\n"
64
- f"# Timestamp: {datetime.now().isoformat()}.\n\n"
66
+ f"# Timestamp: {datetime.now().isoformat()}\n"
65
67
  )
68
+ logging.debug(f"Generated header:\n{header}")
69
+ return header
70
+
71
+
72
+ def remove_header(content: str) -> str:
73
+ """Return the same content provided, but without the blank lines and header metadata generated by the function above."""
74
+ logging.debug(f"Removing header from:\n{content}")
75
+ lines = []
76
+ still_in_header = True
77
+ for line in content.splitlines():
78
+ if still_in_header:
79
+ # We are still in the header as long as we have blank lines or we have
80
+ # comment lines matching the format produced by the method above.
81
+ if not line.strip() or line.startswith((
82
+ "# Generated by ",
83
+ "# Timestamp: ",
84
+ )):
85
+ continue
86
+ else:
87
+ still_in_header = False
88
+ # We are past the header, so keep all the lines: we have nothing left to remove.
89
+ lines.append(line)
90
+
91
+ headerless_content = "\n".join(lines)
92
+ logging.debug(f"Result of header removal:\n{headerless_content}")
93
+ return headerless_content
66
94
 
67
95
 
68
96
  @extra_group
@@ -95,10 +123,12 @@ def metadata(ctx, format, overwrite, output_path):
95
123
  """Dump project metadata to a file.
96
124
 
97
125
  By default the metadata produced are displayed directly to the console output.
98
- So `gha-utils metadata` is the same as a call to `gha-utils metadata -`. To have the results written
99
- in a file on disk, specify the output file like so: `gha-utils metadata dump.txt`.
126
+ So `gha-utils metadata` is the same as a call to `gha-utils metadata -`. To have
127
+ the results written in a file on disk, specify the output file like so:
128
+ `gha-utils metadata dump.txt`.
100
129
 
101
- For GitHub you want to output to the standard environment file pointed to by the `$GITHUB_OUTPUT` variable. I.e.:
130
+ For GitHub you want to output to the standard environment file pointed to by the
131
+ `$GITHUB_OUTPUT` variable. I.e.:
102
132
 
103
133
  $ gha-utils metadata --format github "$GITHUB_OUTPUT"
104
134
  """
@@ -170,43 +200,76 @@ def changelog(ctx, source, changelog_path):
170
200
  f.write(content)
171
201
 
172
202
 
173
- @gha_utils.command(short_help="Sync Git's .mailmap at project's root")
203
+ @gha_utils.command(short_help="Update Git's .mailmap file with missing contributors")
174
204
  @option(
175
205
  "--source",
176
- type=path(exists=True, readable=True, resolve_path=True),
206
+ type=file_path(readable=True, resolve_path=True),
177
207
  default=".mailmap",
178
- help=".mailmap source file to be updated with missing contributors.",
208
+ help="Mailmap source file to use as reference for contributors identities that "
209
+ "are already grouped.",
210
+ )
211
+ @option(
212
+ "--create-if-missing/--skip-if-missing",
213
+ is_flag=True,
214
+ default=True,
215
+ help="Create the destination mailmap file if it is not found. Or skip the update "
216
+ "process entirely if if does not already exists in the first place. This option "
217
+ f"is ignore if the destination is to print the result to {sys.stdout.name}.",
179
218
  )
180
219
  @argument(
181
- "updated_mailmap",
220
+ "destination_mailmap",
182
221
  type=file_path(writable=True, resolve_path=True, allow_dash=True),
183
222
  default="-",
184
223
  )
185
224
  @pass_context
186
- def mailmap(ctx, source, updated_mailmap):
187
- """Update a ``.mailmap`` file with missing contributors found in Git commit history.
225
+ def mailmap_sync(ctx, source, create_if_missing, destination_mailmap):
226
+ """Update a ``.mailmap`` file with all missing contributors found in Git commit
227
+ history.
188
228
 
189
- By default the existing .mailmap is read to be used as initial mapping. To which missing contributors are added.
190
- Then the results are printed to the console output. So `gha-utils mailmap` is the same as a call to `gha-utils mailmap --source .mailmap -`.
229
+ By default the ``.mailmap`` at the root of the repository is read and its content
230
+ is reused as reference, so identities already aliased in there are preserved and
231
+ used as initial mapping. Only missing contributors not found in this initial mapping
232
+ are added.
191
233
 
192
- To have the updated results written
193
- to a file on disk, specify the output file like so: `gha-utils mailmap .mailmap`.
234
+ The resulting updated mapping is printed to the console output. So a bare call to
235
+ `gha-utils mailmap-sync` is the same as a call to
236
+ `gha-utils mailmap-sync --source .mailmap -`.
194
237
 
195
- The updated results are quite dumb, so it is advised to identify potential duplicate identities,
196
- then regroup them by hand.
197
- """
198
- initial_content = None
199
- if source:
200
- logging.info(f"Read initial mapping from {source}")
201
- initial_content = source.read_text(encoding="utf-8")
238
+ To have the updated mapping written to a file, specify the output file like so:
239
+ `gha-utils mailmap-sync .mailmap`.
202
240
 
203
- mailmap = Mailmap(initial_content)
204
- content = mailmap.updated_map()
241
+ The updated results are sorted. But no attempts are made at regrouping new
242
+ contributors. SO you have to edit entries by hand to regroup them
243
+ """
244
+ mailmap = Mailmap()
205
245
 
206
- if is_stdout(updated_mailmap):
207
- logging.info(f"Print updated results to {sys.stdout.name}")
246
+ if source.exists():
247
+ logging.info(f"Read initial mapping from {source}")
248
+ content = remove_header(source.read_text(encoding="utf-8"))
249
+ mailmap.parse(content)
250
+ else:
251
+ logging.debug(f"Mailmap source file {source} does not exists.")
252
+
253
+ mailmap.update_from_git()
254
+ new_content = mailmap.render()
255
+
256
+ if is_stdout(destination_mailmap):
257
+ logging.info(f"Print updated results to {sys.stdout.name}.")
258
+ logging.debug(
259
+ "Ignore the "
260
+ + ("--create-if-missing" if create_if_missing else "--skip-if-missing")
261
+ + " option."
262
+ )
208
263
  else:
209
- logging.info(f"Save updated results to {updated_mailmap}")
264
+ logging.info(f"Save updated results to {destination_mailmap}")
265
+ if not create_if_missing and not destination_mailmap.exists():
266
+ logging.warning(
267
+ f"{destination_mailmap} does not exists, stop the sync process."
268
+ )
269
+ ctx.exit()
270
+ if content == new_content:
271
+ logging.warning("Nothing to update, stop the sync process.")
272
+ ctx.exit()
210
273
 
211
- with file_writer(updated_mailmap) as f:
212
- f.write(f"{get_header(ctx)}{content}")
274
+ with file_writer(destination_mailmap) as f:
275
+ f.write(generate_header(ctx) + new_content)
gha_utils/mailmap.py CHANGED
@@ -18,37 +18,111 @@ from __future__ import annotations
18
18
 
19
19
  import logging
20
20
  import sys
21
+ from dataclasses import dataclass, field
21
22
  from functools import cached_property
22
23
  from subprocess import run
23
- from textwrap import dedent
24
+
25
+ from boltons.iterutils import unique # type: ignore[import-untyped]
26
+
27
+
28
+ @dataclass(order=True, frozen=True)
29
+ class Record:
30
+ """A mailmap identity mapping entry."""
31
+
32
+ # Mapping is define as the first field so we have natural sorting,
33
+ # whatever the value of the pre_comment is.
34
+ canonical: str = ""
35
+ aliases: set[str] = field(default_factory=set)
36
+ pre_comment: str = ""
37
+
38
+ def __str__(self) -> str:
39
+ """Render the record with pre-comments first, followed by the identity mapping.
40
+
41
+ Sort all entries in the mapping without case-sensitivity, but keep the first in
42
+ its place as the canonical identity.
43
+ """
44
+ lines = []
45
+ if self.pre_comment:
46
+ lines.append(self.pre_comment)
47
+ if self.canonical:
48
+ lines.append(
49
+ " ".join((self.canonical, *sorted(self.aliases, key=str.casefold)))
50
+ )
51
+ return "\n".join(lines)
24
52
 
25
53
 
26
54
  class Mailmap:
27
- """Helpers to manipulate ``.mailmap`` file.
55
+ """Helpers to manipulate ``.mailmap`` files.
28
56
 
29
- The ``.mailmap`` files expected to be found in the root of repository.
57
+ ``.mailmap`` `file format is documented on Git website
58
+ <https://git-scm.com/docs/gitmailmap>`_.
30
59
  """
31
60
 
32
- def __init__(self, initial_mailmap: str | None = None) -> None:
33
- if not initial_mailmap:
34
- # Initialize empty .mailmap with pointers to reference documentation.
35
- self.content = dedent(
36
- """\
37
- # Format is:
38
- # Preferred Name <preferred e-mail> Other Name <other e-mail>
39
- #
40
- # Reference: https://git-scm.com/docs/git-blame#_mapping_authors
41
- """,
42
- )
43
- else:
44
- self.content = initial_mailmap
45
- logging.debug(f"Initial content set to:\n{self.content}")
61
+ records: list[Record] = list()
62
+
63
+ @staticmethod
64
+ def split_identities(mapping: str) -> tuple[str, set[str]]:
65
+ """Split a mapping of identities and normalize them."""
66
+ identities = []
67
+ for identity in map(str.strip, mapping.split(">")):
68
+ # Skip blank strings produced by uneven spaces.
69
+ if not identity:
70
+ continue
71
+ assert identity.count("<") == 1, f"Unexpected email format in {identity!r}"
72
+ name, email = identity.split("<", maxsplit=1)
73
+ identities.append(f"{name.strip()} <{email}>")
74
+
75
+ assert len(identities), f"No identities found in {mapping!r}"
76
+
77
+ identities = list(unique(identities))
78
+ return identities[0], set(identities[1:])
79
+
80
+ def parse(self, content: str) -> None:
81
+ """Parse mailmap content and add it to the current list of records.
82
+
83
+ Each non-empty, non-comment line is considered a mapping entry.
84
+
85
+ The preceding lines of a mapping entry are kept attached to it as pre-comments,
86
+ so the layout will be preserved on rendering, during which records are sorted.
87
+ """
88
+ logging.debug(f"Parsing:\n{content}")
89
+ pre_lines = []
90
+ for line in map(str.strip, content.splitlines()):
91
+ # Comment lines are added as-is.
92
+ if line.startswith("#"):
93
+ pre_lines.append(line)
94
+ # Blank lines are added as-is.
95
+ elif not line:
96
+ pre_lines.append(line)
97
+ # Mapping entry, which mark the end of a block, so add it to the list
98
+ # mailmap records.
99
+ else:
100
+ canonical, aliases = self.split_identities(line)
101
+ record = Record(
102
+ pre_comment="\n".join(pre_lines),
103
+ canonical=canonical,
104
+ aliases=aliases,
105
+ )
106
+ logging.debug(record)
107
+ pre_lines = []
108
+ self.records.append(record)
109
+
110
+ def find(self, identity: str) -> bool:
111
+ """Returns ``True`` if the provided identity matched any record."""
112
+ identity_token = identity.lower()
113
+ for record in self.records:
114
+ # Identity matching is case insensitive:
115
+ # https://git-scm.com/docs/gitmailmap#_syntax
116
+ if identity_token in map(str.lower, (record.canonical, *record.aliases)):
117
+ return True
118
+ return False
46
119
 
47
120
  @cached_property
48
121
  def git_contributors(self) -> set[str]:
49
122
  """Returns the set of all constributors found in the Git commit history.
50
123
 
51
- No normalization happens: all variations of authors and committers strings attached to all commits are considered.
124
+ No normalization happens: all variations of authors and committers strings
125
+ attached to all commits are considered.
52
126
 
53
127
  For format output syntax, see:
54
128
  https://git-scm.com/docs/pretty-formats#Documentation/pretty-formats.txt-emaNem
@@ -62,8 +136,8 @@ class Mailmap:
62
136
  # Parse git CLI output.
63
137
  if process.returncode:
64
138
  sys.exit(process.stderr)
65
- for line in process.stdout.splitlines():
66
- if line.strip():
139
+ for line in map(str.strip, process.stdout.splitlines()):
140
+ if line:
67
141
  contributors.add(line)
68
142
 
69
143
  logging.debug(
@@ -72,29 +146,21 @@ class Mailmap:
72
146
  )
73
147
  return contributors
74
148
 
75
- def updated_map(self):
76
- """Add all missing contributors from commit history to mailmap.
149
+ def update_from_git(self) -> None:
150
+ """Add to internal records all missing contributors found in commit history.
77
151
 
78
152
  This method will refrain from adding contributors already registered as aliases.
79
153
  """
80
- # Extract comments in .mailmap header and keep mapping lines.
81
- header_comments = []
82
- mappings = set()
83
- for line in self.content.splitlines():
84
- if line.startswith("#"):
85
- header_comments.append(line)
86
- elif line.strip():
87
- mappings.add(line)
88
-
89
- # Add all missing contributors to the mail mapping.
90
154
  for contributor in self.git_contributors:
91
- if contributor not in self.content:
92
- logging.debug(f"{contributor!r} not found in original content, add it.")
93
- mappings.add(contributor)
94
-
95
- # Render content in .mailmap format.
96
- return (
97
- "\n".join(header_comments)
98
- + "\n\n"
99
- + "\n".join(sorted(mappings, key=str.casefold))
155
+ if not self.find(contributor):
156
+ record = Record(canonical=contributor)
157
+ logging.info(f"Add new identity {record}")
158
+ self.records.append(record)
159
+ else:
160
+ logging.debug(f"Ignore existing identity {contributor}")
161
+
162
+ def render(self) -> str:
163
+ """Render internal records in Mailmap format."""
164
+ return "\n".join(
165
+ map(str, sorted(self.records, key=lambda r: r.canonical.casefold()))
100
166
  )
gha_utils/metadata.py CHANGED
@@ -120,7 +120,7 @@ from bumpversion.config.files import find_config_file # type: ignore[import-unt
120
120
  from bumpversion.show import resolve_name # type: ignore[import-untyped]
121
121
  from packaging.specifiers import SpecifierSet
122
122
  from packaging.version import Version
123
- from pydriller import Commit, Git, Repository # type: ignore[import]
123
+ from pydriller import Commit, Git, Repository # type: ignore[import-untyped]
124
124
  from pyproject_metadata import ConfigurationError, StandardMetadata
125
125
  from wcmatch.glob import (
126
126
  BRACE,
@@ -283,7 +283,7 @@ class Metadata:
283
283
  context = json.loads(os.environ["GITHUB_CONTEXT"])
284
284
  logging.debug("--- GitHub context ---")
285
285
  logging.debug(json.dumps(context, indent=4))
286
- return context
286
+ return context # type:ignore[no-any-return]
287
287
 
288
288
  def git_stash_count(self, git_repo: Git) -> int:
289
289
  """Returns the number of stashes."""
@@ -441,8 +441,8 @@ class Metadata:
441
441
  return None
442
442
 
443
443
  if bool(os.environ.get("GITHUB_BASE_REF")):
444
- return WorkflowEvent.pull_request
445
- return WorkflowEvent.push
444
+ return WorkflowEvent.pull_request # type: ignore[no-any-return]
445
+ return WorkflowEvent.push # type: ignore[no-any-return]
446
446
 
447
447
  @cached_property
448
448
  def commit_range(self) -> tuple[str, str] | None:
@@ -475,7 +475,7 @@ class Metadata:
475
475
  if not self.github_context or not self.event_type:
476
476
  return None
477
477
  # Pull request event.
478
- if self.event_type in (
478
+ if self.event_type in ( # type: ignore[unreachable]
479
479
  WorkflowEvent.pull_request,
480
480
  WorkflowEvent.pull_request_target,
481
481
  ):
@@ -601,7 +601,7 @@ class Metadata:
601
601
 
602
602
  @property
603
603
  def is_python_project(self):
604
- """Returns ``true`` if repository is a Python project.
604
+ """Returns ``True`` if repository is a Python project.
605
605
 
606
606
  Presence of a ``pyproject.toml`` file is not enough, as 3rd party tools can use
607
607
  that file to store their own configuration.
@@ -792,7 +792,7 @@ class Metadata:
792
792
  if self.new_commits_matrix:
793
793
  details = self.new_commits_matrix.get("include")
794
794
  if details:
795
- version = details[0].get("current_version") # type: ignore[union-attr]
795
+ version = details[0].get("current_version")
796
796
  return version
797
797
 
798
798
  @cached_property
@@ -805,18 +805,18 @@ class Metadata:
805
805
  # This script is only designed for at most 1 release in the list of new
806
806
  # commits.
807
807
  assert len(details) == 1
808
- version = details[0].get("current_version") # type: ignore[union-attr]
808
+ version = details[0].get("current_version")
809
809
  return version
810
810
 
811
811
  @cached_property
812
812
  def is_sphinx(self) -> bool:
813
- """Returns true if the Sphinx config file is present."""
813
+ """Returns ``True`` if the Sphinx config file is present."""
814
814
  # The Sphinx config file is present, that's enough for us.
815
815
  return self.sphinx_conf_path.exists() and self.sphinx_conf_path.is_file()
816
816
 
817
817
  @cached_property
818
818
  def active_autodoc(self) -> bool:
819
- """Returns true if there are active Sphinx extensions."""
819
+ """Returns ``True`` if there are active Sphinx extensions."""
820
820
  if self.is_sphinx:
821
821
  # Look for list of active Sphinx extensions.
822
822
  for node in ast.parse(self.sphinx_conf_path.read_bytes()).body:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: gha-utils
3
- Version: 4.1.4
3
+ Version: 4.2.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
@@ -46,6 +46,7 @@ Classifier: Topic :: Utilities
46
46
  Classifier: Typing :: Typed
47
47
  Requires-Python: >=3.8.6
48
48
  Description-Content-Type: text/markdown
49
+ Requires-Dist: boltons ~=24.0.0
49
50
  Requires-Dist: bump-my-version ~=0.24.0
50
51
  Requires-Dist: click-extra ~=4.8.3
51
52
  Requires-Dist: packaging ~=24.1
@@ -0,0 +1,12 @@
1
+ gha_utils/__init__.py,sha256=Tz40D2ZlD8ZokFTQ3MT6KpA0nZd0FTwFuwhnPcTKEY8,865
2
+ gha_utils/__main__.py,sha256=Dck9BjpLXmIRS83k0mghAMcYVYiMiFLltQdfRuMSP_Q,1703
3
+ gha_utils/changelog.py,sha256=EK0uz8IJidYrWoxoSWutoQ5_g3bdsGrKgOitVCiAXw4,5775
4
+ gha_utils/cli.py,sha256=4wUG29fB0-Z-n105UBgIg5NLuqtODtoMLwKJP7RQFXo,9150
5
+ gha_utils/mailmap.py,sha256=sum4XIme2Dis7XtHyO9U7ogWelZwqb-yvJ5I92PPnqg,6301
6
+ gha_utils/metadata.py,sha256=cOVWY9Y9sCbR1QTUIjwej9y5KAMiw1pqiJAqAkH_3JI,47198
7
+ gha_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ gha_utils-4.2.0.dist-info/METADATA,sha256=81DyHok5klvNfKCfJVNoKNgdmbvXoLcKwIgAc5NC-ic,17220
9
+ gha_utils-4.2.0.dist-info/WHEEL,sha256=Z4pYXqR_rTB7OWNDYFOm1qRk0RX6GFP2o8LgvP453Hk,91
10
+ gha_utils-4.2.0.dist-info/entry_points.txt,sha256=8bJOwQYf9ZqsLhBR6gUCzvwLNI9f8tiiBrJ3AR0EK4o,54
11
+ gha_utils-4.2.0.dist-info/top_level.txt,sha256=C94Blb61YkkyPBwCdM3J_JPDjWH0lnKa5nGZeZ5M6yE,10
12
+ gha_utils-4.2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (70.2.0)
2
+ Generator: setuptools (70.3.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,12 +0,0 @@
1
- gha_utils/__init__.py,sha256=gDhYW8Bij0ynm3uEyK9IAiMLzNdlja2CmT4W15FjbRE,865
2
- gha_utils/__main__.py,sha256=Dck9BjpLXmIRS83k0mghAMcYVYiMiFLltQdfRuMSP_Q,1703
3
- gha_utils/changelog.py,sha256=Ny9lCe8w_6uyh8x5P_EuzgQX88j2FJYemVoi9pw_8DI,5765
4
- gha_utils/cli.py,sha256=PSQFbBF21qFDg-wdwHNAto0xouo6Ou7vQf1WHbD-96E,6784
5
- gha_utils/mailmap.py,sha256=7pkkE0G2Mg6nDQ5wAaujvE0tTBRBVVCqj1vkEps12bk,3725
6
- gha_utils/metadata.py,sha256=PkoEJvn9QnB0FdrIkYey___zPjogEPrsb67I1XRR08k,47117
7
- gha_utils/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- gha_utils-4.1.4.dist-info/METADATA,sha256=VAmzUvB9M3qYUL3uI6I4PZ_J5bp7Y6-iA9V25-9y5_0,17188
9
- gha_utils-4.1.4.dist-info/WHEEL,sha256=y4mX-SOX4fYIkonsAGA5N0Oy-8_gI4FXw5HNI1xqvWg,91
10
- gha_utils-4.1.4.dist-info/entry_points.txt,sha256=8bJOwQYf9ZqsLhBR6gUCzvwLNI9f8tiiBrJ3AR0EK4o,54
11
- gha_utils-4.1.4.dist-info/top_level.txt,sha256=C94Blb61YkkyPBwCdM3J_JPDjWH0lnKa5nGZeZ5M6yE,10
12
- gha_utils-4.1.4.dist-info/RECORD,,