gha-utils 4.11.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 ADDED
@@ -0,0 +1,20 @@
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
+ """Expose package-wide elements."""
17
+
18
+ from __future__ import annotations
19
+
20
+ __version__ = "4.11.0"
gha_utils/__main__.py ADDED
@@ -0,0 +1,49 @@
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
+ """Allow the module to be run as a CLI. I.e.:
17
+
18
+ .. code-block:: shell-session
19
+
20
+ $ python -m gha_utils
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+
26
+ def main():
27
+ """Execute the CLI but force its name to not let Click defaults to:
28
+
29
+ .. code-block:: shell-session
30
+ $ python -m gha_utils --version
31
+ python -m gha_utils, version 4.0.0
32
+
33
+ Indirection via this ``main()`` method was `required to reconcile
34
+ <https://github.com/python-poetry/poetry/issues/5981>`_:
35
+
36
+ - plain inline package call: ``python -m gha_utils``,
37
+ - Poetry's script entry point: ``gha-utils = 'gha_utils.__main__:main``,
38
+ - Nuitka's main module invocation requirement:
39
+ ``python -m nuitka (...) gha_utils/__main__.py``
40
+
41
+ That way we can deduce all three cases from the entry point.
42
+ """
43
+ from gha_utils.cli import gha_utils
44
+
45
+ gha_utils(prog_name=gha_utils.name)
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
gha_utils/changelog.py ADDED
@@ -0,0 +1,150 @@
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 logging
20
+ import re
21
+ import sys
22
+ from functools import cached_property
23
+ from pathlib import Path
24
+ from textwrap import indent
25
+
26
+ if sys.version_info >= (3, 11):
27
+ import tomllib
28
+ else:
29
+ import tomli as tomllib # type: ignore[import-not-found]
30
+
31
+
32
+ class Changelog:
33
+ """Helpers to manipulate changelog files written in Markdown."""
34
+
35
+ def __init__(self, initial_changelog: str | None = None) -> None:
36
+ if not initial_changelog:
37
+ self.content = "# Changelog\n"
38
+ else:
39
+ self.content = initial_changelog
40
+ logging.debug(f"Initial content set to:\n{self.content}")
41
+
42
+ @cached_property
43
+ def current_version(self) -> str | None:
44
+ # Extract current version as defined by bump-my-version.
45
+ config_file = Path("./pyproject.toml").resolve()
46
+ logging.info(f"Open {config_file}")
47
+ config = tomllib.loads(config_file.read_text(encoding="UTF-8"))
48
+ current_version = config["tool"]["bumpversion"]["current_version"]
49
+ logging.info(f"Current version: {current_version}")
50
+ return current_version if current_version else None
51
+
52
+ def update(self) -> str:
53
+ r"""Adds a new empty entry at the top of the changelog.
54
+
55
+ Will return the same content as the current changelog if it has already been updated.
56
+
57
+ This is designed to be used just after a new release has been tagged. And before a
58
+ post-release version increment is applied with a call to:
59
+
60
+ ```shell-session
61
+ $ bump-my-version bump --verbose patch
62
+ Starting BumpVersion 0.5.1.dev6
63
+ Reading config file pyproject.toml:
64
+ Specified version (2.17.5) does not match last tagged version (2.17.4)
65
+ Parsing version '2.17.5' using '(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)'
66
+ Parsed the following values: major=2, minor=17, patch=5
67
+ Attempting to increment part 'patch'
68
+ Values are now: major=2, minor=17, patch=6
69
+ New version will be '2.17.6'
70
+ Dry run active, won't touch any files.
71
+ Asserting files ./changelog.md contain the version string...
72
+ Found '[2.17.5 (unreleased)](' in ./changelog.md at line 2:
73
+ ## [2.17.5 (unreleased)](https://github.com/kdeldycke/workflows/compare/v2.17.4...main)
74
+ Would change file ./changelog.md:
75
+ *** before ./changelog.md
76
+ --- after ./changelog.md
77
+ ***************
78
+ *** 1,6 ****
79
+ # Changelog
80
+
81
+ ! ## [2.17.5 (unreleased)](https://github.com/kdeldycke/workflows/compare/v2.17.4...main)
82
+
83
+ > [!IMPORTANT]
84
+ > This version is not released yet and is under active development.
85
+ --- 1,6 ----
86
+ # Changelog
87
+
88
+ ! ## [2.17.6 (unreleased)](https://github.com/kdeldycke/workflows/compare/v2.17.4...main)
89
+
90
+ > [!IMPORTANT]
91
+ > This version is not released yet and is under active development.
92
+ Would write to config file pyproject.toml:
93
+ *** before pyproject.toml
94
+ --- after pyproject.toml
95
+ ***************
96
+ *** 1,5 ****
97
+ [tool.bumpversion]
98
+ ! current_version = "2.17.5"
99
+ allow_dirty = true
100
+
101
+ [[tool.bumpversion.files]]
102
+ --- 1,5 ----
103
+ [tool.bumpversion]
104
+ ! current_version = "2.17.6"
105
+ allow_dirty = true
106
+
107
+ [[tool.bumpversion.files]]
108
+ Would not commit
109
+ Would not tag since we are not committing
110
+ ```
111
+ """
112
+ # Extract parts of the changelog or set default values.
113
+ SECTION_START = "##"
114
+ sections = self.content.split(SECTION_START, 2)
115
+ changelog_header = sections[0] if len(sections) > 0 else "# Changelog\n\n"
116
+ current_entry = f"{SECTION_START}{sections[1]}" if len(sections) > 1 else ""
117
+ past_entries = f"{SECTION_START}{sections[2]}" if len(sections) > 2 else ""
118
+
119
+ # Derive the release template from the last entry.
120
+ DATE_REGEX = r"\d{4}\-\d{2}\-\d{2}"
121
+ VERSION_REGEX = r"\d+\.\d+\.\d+"
122
+
123
+ # Replace the release date with the unreleased tag.
124
+ new_entry = re.sub(DATE_REGEX, "unreleased", current_entry, count=1)
125
+
126
+ # Update GitHub's comparison URL to target the main branch.
127
+ new_entry = re.sub(
128
+ rf"v{VERSION_REGEX}\.\.\.v{VERSION_REGEX}",
129
+ f"v{self.current_version}...main",
130
+ new_entry,
131
+ count=1,
132
+ )
133
+
134
+ # Replace the whole paragraph of changes by a notice message. The paragraph is
135
+ # identified as starting by a blank line, at which point everything gets
136
+ # replaced.
137
+ new_entry = re.sub(
138
+ r"\n\n.*",
139
+ "\n\n"
140
+ "> [!IMPORTANT]\n"
141
+ "> This version is not released yet and is under active development.\n\n",
142
+ new_entry,
143
+ flags=re.MULTILINE | re.DOTALL,
144
+ )
145
+ logging.info("New generated section:\n" + indent(new_entry, " " * 2))
146
+
147
+ history = current_entry + past_entries
148
+ if new_entry not in history:
149
+ history = new_entry + history
150
+ return (changelog_header + history).rstrip()
gha_utils/cli.py ADDED
@@ -0,0 +1,270 @@
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 logging
20
+ import os
21
+ import sys
22
+ from datetime import datetime
23
+ from pathlib import Path
24
+ from typing import IO
25
+
26
+ from click_extra import (
27
+ Choice,
28
+ Context,
29
+ argument,
30
+ echo,
31
+ extra_group,
32
+ file_path,
33
+ option,
34
+ pass_context,
35
+ path,
36
+ )
37
+
38
+ from . import __version__
39
+ from .changelog import Changelog
40
+ from .mailmap import Mailmap
41
+ from .metadata import Dialects, Metadata
42
+
43
+
44
+ def is_stdout(filepath: Path) -> bool:
45
+ """Check if a file path is set to stdout.
46
+
47
+ Prevents the creation of a ``-`` file in the current directory.
48
+ """
49
+ return str(filepath) == "-"
50
+
51
+
52
+ def prep_path(filepath: Path) -> IO | None:
53
+ """Prepare the output file parameter for Click's echo function."""
54
+ if is_stdout(filepath):
55
+ return None
56
+ return filepath.open("w", encoding="UTF-8")
57
+
58
+
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 = (
64
+ f"# Generated by {ctx.command_path} v{__version__}"
65
+ " - https://github.com/kdeldycke/workflows\n"
66
+ f"# Timestamp: {datetime.now().isoformat()}\n"
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
94
+
95
+
96
+ @extra_group
97
+ def gha_utils():
98
+ pass
99
+
100
+
101
+ @gha_utils.command(short_help="Output project metadata")
102
+ @option(
103
+ "--format",
104
+ type=Choice(tuple(item.value for item in Dialects), case_sensitive=False),
105
+ default="github",
106
+ help="Rendering format of the metadata.",
107
+ )
108
+ @option(
109
+ "--overwrite",
110
+ "--force",
111
+ "--replace",
112
+ is_flag=True,
113
+ default=False,
114
+ help="Allow output target file to be silently wiped out if it already exists.",
115
+ )
116
+ @argument(
117
+ "output_path",
118
+ type=file_path(writable=True, resolve_path=True, allow_dash=True),
119
+ default="-",
120
+ )
121
+ @pass_context
122
+ def metadata(ctx, format, overwrite, output_path):
123
+ """Dump project metadata to a file.
124
+
125
+ By default the metadata produced are displayed directly to the console output.
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`.
129
+
130
+ For GitHub you want to output to the standard environment file pointed to by the
131
+ `$GITHUB_OUTPUT` variable. I.e.:
132
+
133
+ $ gha-utils metadata --format github "$GITHUB_OUTPUT"
134
+ """
135
+ if is_stdout(output_path):
136
+ if overwrite:
137
+ logging.warning("Ignore the --overwrite/--force/--replace option.")
138
+ logging.info(f"Print metadata to {sys.stdout.name}")
139
+ else:
140
+ logging.info(f"Dump all metadata to {output_path}")
141
+
142
+ if output_path.exists():
143
+ msg = "Target file exist and will be overwritten."
144
+ if overwrite:
145
+ logging.warning(msg)
146
+ else:
147
+ logging.critical(msg)
148
+ ctx.exit(2)
149
+
150
+ metadata = Metadata()
151
+
152
+ # Output a warning in GitHub runners if metadata are not saved to $GITHUB_OUTPUT.
153
+ if metadata.in_ci_env:
154
+ env_file = os.getenv("GITHUB_OUTPUT")
155
+ if env_file and Path(env_file) != output_path:
156
+ logging.warning(
157
+ "Output path is not the same as $GITHUB_OUTPUT environment variable,"
158
+ " which is generally what we're looking to do in GitHub CI runners for"
159
+ " other jobs to consume the produced metadata."
160
+ )
161
+
162
+ dialect = Dialects(format)
163
+ content = metadata.dump(dialect=dialect)
164
+ echo(content, file=prep_path(output_path))
165
+
166
+
167
+ @gha_utils.command(short_help="Maintain a Markdown-formatted changelog")
168
+ @option(
169
+ "--source",
170
+ type=path(exists=True, readable=True, resolve_path=True),
171
+ default="changelog.md",
172
+ help="Changelog source file in Markdown format.",
173
+ )
174
+ @argument(
175
+ "changelog_path",
176
+ type=file_path(writable=True, resolve_path=True, allow_dash=True),
177
+ default="-",
178
+ )
179
+ @pass_context
180
+ def changelog(ctx, source, changelog_path):
181
+ initial_content = None
182
+ if source:
183
+ logging.info(f"Read initial changelog from {source}")
184
+ initial_content = source.read_text(encoding="UTF-8")
185
+
186
+ changelog = Changelog(initial_content)
187
+ content = changelog.update()
188
+ if content == initial_content:
189
+ logging.warning("Changelog already up to date. Do nothing.")
190
+ ctx.exit()
191
+
192
+ if is_stdout(changelog_path):
193
+ logging.info(f"Print updated results to {sys.stdout.name}")
194
+ else:
195
+ logging.info(f"Save updated results to {changelog_path}")
196
+ echo(content, file=prep_path(changelog_path))
197
+
198
+
199
+ @gha_utils.command(short_help="Update Git's .mailmap file with missing contributors")
200
+ @option(
201
+ "--source",
202
+ type=file_path(readable=True, resolve_path=True),
203
+ default=".mailmap",
204
+ help="Mailmap source file to use as reference for contributors identities that "
205
+ "are already grouped.",
206
+ )
207
+ @option(
208
+ "--create-if-missing/--skip-if-missing",
209
+ is_flag=True,
210
+ default=True,
211
+ help="Create the destination mailmap file if it is not found. Or skip the update "
212
+ "process entirely if if does not already exists in the first place. This option "
213
+ f"is ignore if the destination is to print the result to {sys.stdout.name}.",
214
+ )
215
+ @argument(
216
+ "destination_mailmap",
217
+ type=file_path(writable=True, resolve_path=True, allow_dash=True),
218
+ default="-",
219
+ )
220
+ @pass_context
221
+ def mailmap_sync(ctx, source, create_if_missing, destination_mailmap):
222
+ """Update a ``.mailmap`` file with all missing contributors found in Git commit
223
+ history.
224
+
225
+ By default the ``.mailmap`` at the root of the repository is read and its content
226
+ is reused as reference, so identities already aliased in there are preserved and
227
+ used as initial mapping. Only missing contributors not found in this initial mapping
228
+ are added.
229
+
230
+ The resulting updated mapping is printed to the console output. So a bare call to
231
+ `gha-utils mailmap-sync` is the same as a call to
232
+ `gha-utils mailmap-sync --source .mailmap -`.
233
+
234
+ To have the updated mapping written to a file, specify the output file like so:
235
+ `gha-utils mailmap-sync .mailmap`.
236
+
237
+ The updated results are sorted. But no attempts are made at regrouping new
238
+ contributors. SO you have to edit entries by hand to regroup them
239
+ """
240
+ mailmap = Mailmap()
241
+
242
+ if source.exists():
243
+ logging.info(f"Read initial mapping from {source}")
244
+ content = remove_header(source.read_text(encoding="UTF-8"))
245
+ mailmap.parse(content)
246
+ else:
247
+ logging.debug(f"Mailmap source file {source} does not exists.")
248
+
249
+ mailmap.update_from_git()
250
+ new_content = mailmap.render()
251
+
252
+ if is_stdout(destination_mailmap):
253
+ logging.info(f"Print updated results to {sys.stdout.name}.")
254
+ logging.debug(
255
+ "Ignore the "
256
+ + ("--create-if-missing" if create_if_missing else "--skip-if-missing")
257
+ + " option."
258
+ )
259
+ else:
260
+ logging.info(f"Save updated results to {destination_mailmap}")
261
+ if not create_if_missing and not destination_mailmap.exists():
262
+ logging.warning(
263
+ f"{destination_mailmap} does not exists, stop the sync process."
264
+ )
265
+ ctx.exit()
266
+ if content == new_content:
267
+ logging.warning("Nothing to update, stop the sync process.")
268
+ ctx.exit()
269
+
270
+ echo(generate_header(ctx) + new_content, file=prep_path(destination_mailmap))
gha_utils/mailmap.py ADDED
@@ -0,0 +1,166 @@
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 logging
20
+ import sys
21
+ from dataclasses import dataclass, field
22
+ from functools import cached_property
23
+ from subprocess import run
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)
52
+
53
+
54
+ class Mailmap:
55
+ """Helpers to manipulate ``.mailmap`` files.
56
+
57
+ ``.mailmap`` `file format is documented on Git website
58
+ <https://git-scm.com/docs/gitmailmap>`_.
59
+ """
60
+
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
119
+
120
+ @cached_property
121
+ def git_contributors(self) -> set[str]:
122
+ """Returns the set of all constributors found in the Git commit history.
123
+
124
+ No normalization happens: all variations of authors and committers strings
125
+ attached to all commits are considered.
126
+
127
+ For format output syntax, see:
128
+ https://git-scm.com/docs/pretty-formats#Documentation/pretty-formats.txt-emaNem
129
+ """
130
+ contributors = set()
131
+
132
+ git_cli = ("git", "log", "--pretty=format:%aN <%aE>%n%cN <%cE>")
133
+ logging.debug(f"Run: {' '.join(git_cli)}")
134
+ process = run(git_cli, capture_output=True, encoding="UTF-8")
135
+
136
+ # Parse git CLI output.
137
+ if process.returncode:
138
+ sys.exit(process.stderr)
139
+ for line in map(str.strip, process.stdout.splitlines()):
140
+ if line:
141
+ contributors.add(line)
142
+
143
+ logging.debug(
144
+ "Authors and committers found in Git history:\n"
145
+ + "\n".join(sorted(contributors, key=str.casefold))
146
+ )
147
+ return contributors
148
+
149
+ def update_from_git(self) -> None:
150
+ """Add to internal records all missing contributors found in commit history.
151
+
152
+ This method will refrain from adding contributors already registered as aliases.
153
+ """
154
+ for contributor in self.git_contributors:
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()))
166
+ )