gha-utils 4.0.1__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 +20 -0
- gha_utils/__main__.py +49 -0
- gha_utils/changelog.py +151 -0
- gha_utils/cli.py +209 -0
- gha_utils/mailmap.py +99 -0
- gha_utils/metadata.py +1032 -0
- gha_utils/py.typed +0 -0
- gha_utils-4.0.1.dist-info/METADATA +625 -0
- gha_utils-4.0.1.dist-info/RECORD +12 -0
- gha_utils-4.0.1.dist-info/WHEEL +5 -0
- gha_utils-4.0.1.dist-info/entry_points.txt +2 -0
- gha_utils-4.0.1.dist-info/top_level.txt +1 -0
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.0.1"
|
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,151 @@
|
|
|
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 pathlib import Path
|
|
23
|
+
from textwrap import indent
|
|
24
|
+
|
|
25
|
+
if sys.version_info >= (3, 11):
|
|
26
|
+
import tomllib
|
|
27
|
+
else:
|
|
28
|
+
import tomli as tomllib # type: ignore[import]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Changelog:
|
|
32
|
+
"""Helpers to manipulate changelog files written in Markdown."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, initial_changelog: str | None = None) -> None:
|
|
35
|
+
if not initial_changelog:
|
|
36
|
+
self.content = "# Changelog\n"
|
|
37
|
+
else:
|
|
38
|
+
self.content = initial_changelog
|
|
39
|
+
logging.debug(f"Initial content set to:\n{self.content}")
|
|
40
|
+
|
|
41
|
+
def update(self) -> str:
|
|
42
|
+
r"""Adds a new empty entry at the top of the changelog.
|
|
43
|
+
|
|
44
|
+
This is designed to be used just after a new release has been tagged. And before a
|
|
45
|
+
post-release version increment is applied with a call to:
|
|
46
|
+
|
|
47
|
+
```shell-session
|
|
48
|
+
$ bump-my-version bump --verbose patch
|
|
49
|
+
Starting BumpVersion 0.5.1.dev6
|
|
50
|
+
Reading config file pyproject.toml:
|
|
51
|
+
Specified version (2.17.5) does not match last tagged version (2.17.4)
|
|
52
|
+
Parsing version '2.17.5' using '(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)'
|
|
53
|
+
Parsed the following values: major=2, minor=17, patch=5
|
|
54
|
+
Attempting to increment part 'patch'
|
|
55
|
+
Values are now: major=2, minor=17, patch=6
|
|
56
|
+
New version will be '2.17.6'
|
|
57
|
+
Dry run active, won't touch any files.
|
|
58
|
+
Asserting files ./changelog.md contain the version string...
|
|
59
|
+
Found '[2.17.5 (unreleased)](' in ./changelog.md at line 2:
|
|
60
|
+
## [2.17.5 (unreleased)](https://github.com/kdeldycke/workflows/compare/v2.17.4...main)
|
|
61
|
+
Would change file ./changelog.md:
|
|
62
|
+
*** before ./changelog.md
|
|
63
|
+
--- after ./changelog.md
|
|
64
|
+
***************
|
|
65
|
+
*** 1,6 ****
|
|
66
|
+
# Changelog
|
|
67
|
+
|
|
68
|
+
! ## [2.17.5 (unreleased)](https://github.com/kdeldycke/workflows/compare/v2.17.4...main)
|
|
69
|
+
|
|
70
|
+
> \[!IMPORTANT\]
|
|
71
|
+
> This version is not released yet and is under active development.
|
|
72
|
+
--- 1,6 ----
|
|
73
|
+
# Changelog
|
|
74
|
+
|
|
75
|
+
! ## [2.17.6 (unreleased)](https://github.com/kdeldycke/workflows/compare/v2.17.4...main)
|
|
76
|
+
|
|
77
|
+
> \[!IMPORTANT\]
|
|
78
|
+
> This version is not released yet and is under active development.
|
|
79
|
+
Would write to config file pyproject.toml:
|
|
80
|
+
*** before pyproject.toml
|
|
81
|
+
--- after pyproject.toml
|
|
82
|
+
***************
|
|
83
|
+
*** 1,5 ****
|
|
84
|
+
[tool.bumpversion]
|
|
85
|
+
! current_version = "2.17.5"
|
|
86
|
+
allow_dirty = true
|
|
87
|
+
|
|
88
|
+
[[tool.bumpversion.files]]
|
|
89
|
+
--- 1,5 ----
|
|
90
|
+
[tool.bumpversion]
|
|
91
|
+
! current_version = "2.17.6"
|
|
92
|
+
allow_dirty = true
|
|
93
|
+
|
|
94
|
+
[[tool.bumpversion.files]]
|
|
95
|
+
Would not commit
|
|
96
|
+
Would not tag since we are not committing
|
|
97
|
+
```
|
|
98
|
+
"""
|
|
99
|
+
# Extract current version as defined by bump-my-version.
|
|
100
|
+
config_file = Path("./pyproject.toml").resolve()
|
|
101
|
+
logging.info(f"Open {config_file}")
|
|
102
|
+
config = tomllib.loads(config_file.read_text(encoding="utf-8"))
|
|
103
|
+
current_version = config["tool"]["bumpversion"]["current_version"]
|
|
104
|
+
logging.info(f"Current version: {current_version}")
|
|
105
|
+
assert current_version
|
|
106
|
+
|
|
107
|
+
assert current_version in self.content
|
|
108
|
+
|
|
109
|
+
# Analyse the current changelog.
|
|
110
|
+
SECTION_START = "##"
|
|
111
|
+
changelog_header, last_entry, past_entries = self.content.split(
|
|
112
|
+
SECTION_START, 2
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Derive the release template from the last entry.
|
|
116
|
+
DATE_REGEX = r"\d{4}\-\d{2}\-\d{2}"
|
|
117
|
+
VERSION_REGEX = r"\d+\.\d+\.\d+"
|
|
118
|
+
|
|
119
|
+
# Replace the release date with the unreleased tag.
|
|
120
|
+
new_entry = re.sub(DATE_REGEX, "unreleased", last_entry, count=1)
|
|
121
|
+
|
|
122
|
+
# Update GitHub's comparison URL to target the main branch.
|
|
123
|
+
new_entry = re.sub(
|
|
124
|
+
rf"v{VERSION_REGEX}\.\.\.v{VERSION_REGEX}",
|
|
125
|
+
f"v{current_version}...main",
|
|
126
|
+
new_entry,
|
|
127
|
+
count=1,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Replace the whole paragraph of changes by a notice message. The paragraph is
|
|
131
|
+
# identified as starting by a blank line, at which point everything gets
|
|
132
|
+
# replaced.
|
|
133
|
+
new_entry = re.sub(
|
|
134
|
+
r"\n\n.*",
|
|
135
|
+
"\n\n"
|
|
136
|
+
"> \[!IMPORTANT\]\n"
|
|
137
|
+
"> This version is not released yet and is under active development.\n\n",
|
|
138
|
+
new_entry,
|
|
139
|
+
flags=re.MULTILINE | re.DOTALL,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Prefix entries with section marker.
|
|
143
|
+
new_entry = f"{SECTION_START}{new_entry}"
|
|
144
|
+
history = f"{SECTION_START}{last_entry}{SECTION_START}{past_entries}"
|
|
145
|
+
|
|
146
|
+
logging.info("New generated section:\n" + indent(new_entry, " " * 2))
|
|
147
|
+
|
|
148
|
+
assert new_entry not in history
|
|
149
|
+
|
|
150
|
+
# Recompose full changelog with new top entry.
|
|
151
|
+
return f"{changelog_header}{new_entry}{history}"
|
gha_utils/cli.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
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 contextlib import contextmanager
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from click_extra import (
|
|
27
|
+
Choice,
|
|
28
|
+
argument,
|
|
29
|
+
extra_group,
|
|
30
|
+
file_path,
|
|
31
|
+
option,
|
|
32
|
+
pass_context,
|
|
33
|
+
path,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from . import __version__
|
|
37
|
+
from .changelog import Changelog
|
|
38
|
+
from .mailmap import Mailmap
|
|
39
|
+
from .metadata import Dialects, Metadata
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_stdout(filepath):
|
|
43
|
+
return str(filepath) == "-"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@contextmanager
|
|
47
|
+
def file_writer(filepath):
|
|
48
|
+
"""A context-aware file writer which default to stdout if no path is
|
|
49
|
+
provided."""
|
|
50
|
+
if is_stdout(filepath):
|
|
51
|
+
yield sys.stdout
|
|
52
|
+
else:
|
|
53
|
+
writer = filepath.open("w")
|
|
54
|
+
yield writer
|
|
55
|
+
writer.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@extra_group
|
|
59
|
+
def gha_utils():
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@gha_utils.command(short_help="Output project metadata")
|
|
64
|
+
@option(
|
|
65
|
+
"--format",
|
|
66
|
+
type=Choice(tuple(item.value for item in Dialects), case_sensitive=False),
|
|
67
|
+
default="github",
|
|
68
|
+
help="Rendering format of the metadata.",
|
|
69
|
+
)
|
|
70
|
+
@option(
|
|
71
|
+
"--overwrite",
|
|
72
|
+
"--force",
|
|
73
|
+
"--replace",
|
|
74
|
+
is_flag=True,
|
|
75
|
+
default=False,
|
|
76
|
+
help="Allow output target file to be silently wiped out if it already exists.",
|
|
77
|
+
)
|
|
78
|
+
@argument(
|
|
79
|
+
"output_path",
|
|
80
|
+
type=file_path(writable=True, resolve_path=True, allow_dash=True),
|
|
81
|
+
default="-",
|
|
82
|
+
)
|
|
83
|
+
@pass_context
|
|
84
|
+
def metadata(ctx, format, overwrite, output_path):
|
|
85
|
+
"""Dump project metadata to a file.
|
|
86
|
+
|
|
87
|
+
By default the metadata produced are displayed directly to the console output.
|
|
88
|
+
So `gha-utils metadata` is the same as a call to `gha-utils metadata -`. To have the results written
|
|
89
|
+
in a file on disk, specify the output file like so: `gha-utils metadata dump.txt`.
|
|
90
|
+
|
|
91
|
+
For GitHub you want to output to the standard environment file pointed to by the `$GITHUB_OUTPUT` variable. I.e.:
|
|
92
|
+
|
|
93
|
+
$ gha-utils metadata --format github "$GITHUB_OUTPUT"
|
|
94
|
+
"""
|
|
95
|
+
if is_stdout(output_path):
|
|
96
|
+
if overwrite:
|
|
97
|
+
logging.warning("Ignore the --overwrite/--force/--replace option.")
|
|
98
|
+
logging.info(f"Print metadata to {sys.stdout.name}")
|
|
99
|
+
else:
|
|
100
|
+
logging.info(f"Dump all metadata to {output_path}")
|
|
101
|
+
|
|
102
|
+
if output_path.exists():
|
|
103
|
+
msg = "Target file exist and will be overwritten."
|
|
104
|
+
if overwrite:
|
|
105
|
+
logging.warning(msg)
|
|
106
|
+
else:
|
|
107
|
+
logging.critical(msg)
|
|
108
|
+
ctx.exit(2)
|
|
109
|
+
|
|
110
|
+
metadata = Metadata()
|
|
111
|
+
|
|
112
|
+
# Output a warning in GitHub runners if metadata are not saved to $GITHUB_OUTPUT.
|
|
113
|
+
if metadata.in_ci_env:
|
|
114
|
+
env_file = os.getenv("GITHUB_OUTPUT")
|
|
115
|
+
if env_file and Path(env_file) != output_path:
|
|
116
|
+
logging.warning(
|
|
117
|
+
"Output path is not the same as $GITHUB_OUTPUT environment variable, which is generally what we're looking to do in GitHub CI runners for other jobs to consume the produced metadata."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
dialect = Dialects(format)
|
|
121
|
+
content = metadata.dump(dialect=dialect)
|
|
122
|
+
|
|
123
|
+
with file_writer(output_path) as f:
|
|
124
|
+
f.write(
|
|
125
|
+
# Leave some metadata as comment.
|
|
126
|
+
f"# Generated by {ctx.command_path} v{__version__}"
|
|
127
|
+
" - https://github.com/kdeldycke/workflows.\n"
|
|
128
|
+
f"# Timestamp: {datetime.now().isoformat()}.\n"
|
|
129
|
+
f"{content}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@gha_utils.command(short_help="Maintain a Markdown-formatted changelog")
|
|
134
|
+
@option(
|
|
135
|
+
"--source",
|
|
136
|
+
type=path(exists=True, readable=True, resolve_path=True),
|
|
137
|
+
default="changelog.md",
|
|
138
|
+
help="Changelog source file in Markdown format.",
|
|
139
|
+
)
|
|
140
|
+
@argument(
|
|
141
|
+
"changelog_path",
|
|
142
|
+
type=file_path(writable=True, resolve_path=True, allow_dash=True),
|
|
143
|
+
default="-",
|
|
144
|
+
)
|
|
145
|
+
@pass_context
|
|
146
|
+
def changelog(ctx, source, changelog_path):
|
|
147
|
+
initial_content = None
|
|
148
|
+
if source:
|
|
149
|
+
logging.info(f"Read initial changelog from {source}")
|
|
150
|
+
initial_content = source.read_text()
|
|
151
|
+
|
|
152
|
+
changelog = Changelog(initial_content)
|
|
153
|
+
content = changelog.update()
|
|
154
|
+
|
|
155
|
+
if is_stdout(changelog_path):
|
|
156
|
+
logging.info(f"Print updated results to {sys.stdout.name}")
|
|
157
|
+
else:
|
|
158
|
+
logging.info(f"Save updated results to {changelog_path}")
|
|
159
|
+
|
|
160
|
+
with file_writer(changelog_path) as f:
|
|
161
|
+
f.write(content)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@gha_utils.command(short_help="Sync Git's .mailmap at project's root")
|
|
165
|
+
@option(
|
|
166
|
+
"--source",
|
|
167
|
+
type=path(exists=True, readable=True, resolve_path=True),
|
|
168
|
+
default=".mailmap",
|
|
169
|
+
help=".mailmap source file to be updated with missing contributors.",
|
|
170
|
+
)
|
|
171
|
+
@argument(
|
|
172
|
+
"updated_mailmap",
|
|
173
|
+
type=file_path(writable=True, resolve_path=True, allow_dash=True),
|
|
174
|
+
default="-",
|
|
175
|
+
)
|
|
176
|
+
@pass_context
|
|
177
|
+
def mailmap(ctx, source, updated_mailmap):
|
|
178
|
+
"""Update a ``.mailmap`` file with missing contributors found in Git commit history.
|
|
179
|
+
|
|
180
|
+
By default the existing .mailmap is read to be used as initial mapping. To which missing contributors are added.
|
|
181
|
+
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 -`.
|
|
182
|
+
|
|
183
|
+
To have the updated results written
|
|
184
|
+
to a file on disk, specify the output file like so: `gha-utils mailmap .mailmap`.
|
|
185
|
+
|
|
186
|
+
The updated results are quite dumb, so it is advised to identify potential duplicate identities,
|
|
187
|
+
then regroup them by hand.
|
|
188
|
+
"""
|
|
189
|
+
initial_content = None
|
|
190
|
+
if source:
|
|
191
|
+
logging.info(f"Read initial mapping from {source}")
|
|
192
|
+
initial_content = source.read_text()
|
|
193
|
+
|
|
194
|
+
mailmap = Mailmap(initial_content)
|
|
195
|
+
content = mailmap.updated_map()
|
|
196
|
+
|
|
197
|
+
if is_stdout(updated_mailmap):
|
|
198
|
+
logging.info(f"Print updated results to {sys.stdout.name}")
|
|
199
|
+
else:
|
|
200
|
+
logging.info(f"Save updated results to {updated_mailmap}")
|
|
201
|
+
|
|
202
|
+
with file_writer(updated_mailmap) as f:
|
|
203
|
+
f.write(
|
|
204
|
+
# Leave some metadata as comment.
|
|
205
|
+
f"# Generated by {ctx.command_path} v{__version__}"
|
|
206
|
+
" - https://github.com/kdeldycke/workflows.\n"
|
|
207
|
+
f"# Timestamp: {datetime.now().isoformat()}.\n\n"
|
|
208
|
+
f"{content}"
|
|
209
|
+
)
|
gha_utils/mailmap.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
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 functools import cached_property
|
|
22
|
+
from subprocess import run
|
|
23
|
+
from textwrap import dedent
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Mailmap:
|
|
27
|
+
"""Helpers to manipulate ``.mailmap`` file.
|
|
28
|
+
|
|
29
|
+
The ``.mailmap`` files expected to be found in the root of repository.
|
|
30
|
+
"""
|
|
31
|
+
|
|
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}")
|
|
46
|
+
|
|
47
|
+
@cached_property
|
|
48
|
+
def git_contributors(self) -> set[str]:
|
|
49
|
+
"""Returns the set of all constributors found in the Git commit history.
|
|
50
|
+
|
|
51
|
+
No normalization happens: all variations of authors and committers strings attached to all commits are considered.
|
|
52
|
+
|
|
53
|
+
For format output syntax, see:
|
|
54
|
+
https://git-scm.com/docs/pretty-formats#Documentation/pretty-formats.txt-emaNem
|
|
55
|
+
"""
|
|
56
|
+
contributors = set()
|
|
57
|
+
|
|
58
|
+
git_cli = ("git", "log", "--pretty=format:%aN <%aE>%n%cN <%cE>")
|
|
59
|
+
logging.debug(f"Run: {' '.join(git_cli)}")
|
|
60
|
+
process = run(git_cli, capture_output=True, encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
# Parse git CLI output.
|
|
63
|
+
if process.returncode:
|
|
64
|
+
sys.exit(process.stderr)
|
|
65
|
+
for line in process.stdout.splitlines():
|
|
66
|
+
if line.strip():
|
|
67
|
+
contributors.add(line)
|
|
68
|
+
|
|
69
|
+
logging.debug(
|
|
70
|
+
"Authors and committers found in Git history:\n"
|
|
71
|
+
f"{'\n'.join(sorted(contributors, key=str.casefold))}"
|
|
72
|
+
)
|
|
73
|
+
return contributors
|
|
74
|
+
|
|
75
|
+
def updated_map(self):
|
|
76
|
+
"""Add all missing contributors from commit history to mailmap.
|
|
77
|
+
|
|
78
|
+
This method will refrain from adding contributors already registered as aliases.
|
|
79
|
+
"""
|
|
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
|
+
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
|
+
f"{'\n'.join(header_comments)}\n\n"
|
|
98
|
+
f"{'\n'.join(sorted(mappings, key=str.casefold))}\n"
|
|
99
|
+
)
|