gha-utils 4.24.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.
- gha_utils/__init__.py +20 -0
- gha_utils/__main__.py +49 -0
- gha_utils/changelog.py +146 -0
- gha_utils/cli.py +452 -0
- gha_utils/mailmap.py +184 -0
- gha_utils/matrix.py +291 -0
- gha_utils/metadata.py +1693 -0
- gha_utils/py.typed +0 -0
- gha_utils/test_plan.py +352 -0
- gha_utils-4.24.0.dist-info/METADATA +375 -0
- gha_utils-4.24.0.dist-info/RECORD +14 -0
- gha_utils-4.24.0.dist-info/WHEEL +5 -0
- gha_utils-4.24.0.dist-info/entry_points.txt +2 -0
- gha_utils-4.24.0.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.24.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,146 @@
|
|
|
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
|
+
from functools import cached_property
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from textwrap import indent
|
|
24
|
+
|
|
25
|
+
import tomllib
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Changelog:
|
|
29
|
+
"""Helpers to manipulate changelog files written in Markdown."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, initial_changelog: str | None = None) -> None:
|
|
32
|
+
if not initial_changelog:
|
|
33
|
+
self.content = "# Changelog\n"
|
|
34
|
+
else:
|
|
35
|
+
self.content = initial_changelog
|
|
36
|
+
logging.debug(f"Initial content set to:\n{self.content}")
|
|
37
|
+
|
|
38
|
+
@cached_property
|
|
39
|
+
def current_version(self) -> str | None:
|
|
40
|
+
# Extract current version as defined by bump-my-version.
|
|
41
|
+
config_file = Path("./pyproject.toml").resolve()
|
|
42
|
+
logging.info(f"Open {config_file}")
|
|
43
|
+
config = tomllib.loads(config_file.read_text(encoding="UTF-8"))
|
|
44
|
+
current_version = config["tool"]["bumpversion"]["current_version"]
|
|
45
|
+
logging.info(f"Current version: {current_version}")
|
|
46
|
+
return current_version if current_version else None
|
|
47
|
+
|
|
48
|
+
def update(self) -> str:
|
|
49
|
+
r"""Adds a new empty entry at the top of the changelog.
|
|
50
|
+
|
|
51
|
+
Will return the same content as the current changelog if it has already been updated.
|
|
52
|
+
|
|
53
|
+
This is designed to be used just after a new release has been tagged. And before a
|
|
54
|
+
post-release version increment is applied with a call to:
|
|
55
|
+
|
|
56
|
+
```shell-session
|
|
57
|
+
$ bump-my-version bump --verbose patch
|
|
58
|
+
Starting BumpVersion 0.5.1.dev6
|
|
59
|
+
Reading config file pyproject.toml:
|
|
60
|
+
Specified version (2.17.5) does not match last tagged version (2.17.4)
|
|
61
|
+
Parsing version '2.17.5' using '(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)'
|
|
62
|
+
Parsed the following values: major=2, minor=17, patch=5
|
|
63
|
+
Attempting to increment part 'patch'
|
|
64
|
+
Values are now: major=2, minor=17, patch=6
|
|
65
|
+
New version will be '2.17.6'
|
|
66
|
+
Dry run active, won't touch any files.
|
|
67
|
+
Asserting files ./changelog.md contain the version string...
|
|
68
|
+
Found '[2.17.5 (unreleased)](' in ./changelog.md at line 2:
|
|
69
|
+
## [2.17.5 (unreleased)](https://github.com/kdeldycke/workflows/compare/v2.17.4...main)
|
|
70
|
+
Would change file ./changelog.md:
|
|
71
|
+
*** before ./changelog.md
|
|
72
|
+
--- after ./changelog.md
|
|
73
|
+
***************
|
|
74
|
+
*** 1,6 ****
|
|
75
|
+
# Changelog
|
|
76
|
+
|
|
77
|
+
! ## [2.17.5 (unreleased)](https://github.com/kdeldycke/workflows/compare/v2.17.4...main)
|
|
78
|
+
|
|
79
|
+
> [!IMPORTANT]
|
|
80
|
+
> This version is not released yet and is under active development.
|
|
81
|
+
--- 1,6 ----
|
|
82
|
+
# Changelog
|
|
83
|
+
|
|
84
|
+
! ## [2.17.6 (unreleased)](https://github.com/kdeldycke/workflows/compare/v2.17.4...main)
|
|
85
|
+
|
|
86
|
+
> [!IMPORTANT]
|
|
87
|
+
> This version is not released yet and is under active development.
|
|
88
|
+
Would write to config file pyproject.toml:
|
|
89
|
+
*** before pyproject.toml
|
|
90
|
+
--- after pyproject.toml
|
|
91
|
+
***************
|
|
92
|
+
*** 1,5 ****
|
|
93
|
+
[tool.bumpversion]
|
|
94
|
+
! current_version = "2.17.5"
|
|
95
|
+
allow_dirty = true
|
|
96
|
+
|
|
97
|
+
[[tool.bumpversion.files]]
|
|
98
|
+
--- 1,5 ----
|
|
99
|
+
[tool.bumpversion]
|
|
100
|
+
! current_version = "2.17.6"
|
|
101
|
+
allow_dirty = true
|
|
102
|
+
|
|
103
|
+
[[tool.bumpversion.files]]
|
|
104
|
+
Would not commit
|
|
105
|
+
Would not tag since we are not committing
|
|
106
|
+
```
|
|
107
|
+
"""
|
|
108
|
+
# Extract parts of the changelog or set default values.
|
|
109
|
+
SECTION_START = "##"
|
|
110
|
+
sections = self.content.split(SECTION_START, 2)
|
|
111
|
+
changelog_header = sections[0] if len(sections) > 0 else "# Changelog\n\n"
|
|
112
|
+
current_entry = f"{SECTION_START}{sections[1]}" if len(sections) > 1 else ""
|
|
113
|
+
past_entries = f"{SECTION_START}{sections[2]}" if len(sections) > 2 else ""
|
|
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", current_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{self.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
|
+
logging.info("New generated section:\n" + indent(new_entry, " " * 2))
|
|
142
|
+
|
|
143
|
+
history = current_entry + past_entries
|
|
144
|
+
if new_entry not in history:
|
|
145
|
+
history = new_entry + history
|
|
146
|
+
return (changelog_header + history).rstrip()
|
gha_utils/cli.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
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 re
|
|
22
|
+
import sys
|
|
23
|
+
from collections import Counter
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from boltons.iterutils import unique
|
|
28
|
+
from click_extra import (
|
|
29
|
+
Choice,
|
|
30
|
+
Context,
|
|
31
|
+
EnumChoice,
|
|
32
|
+
FloatRange,
|
|
33
|
+
IntRange,
|
|
34
|
+
argument,
|
|
35
|
+
echo,
|
|
36
|
+
file_path,
|
|
37
|
+
group,
|
|
38
|
+
option,
|
|
39
|
+
pass_context,
|
|
40
|
+
)
|
|
41
|
+
from click_extra.envvar import merge_envvar_ids
|
|
42
|
+
from extra_platforms import ALL_IDS, is_github_ci
|
|
43
|
+
|
|
44
|
+
from . import __version__
|
|
45
|
+
from .changelog import Changelog
|
|
46
|
+
from .mailmap import Mailmap
|
|
47
|
+
from .metadata import NUITKA_BUILD_TARGETS, Dialect, Metadata
|
|
48
|
+
from .test_plan import DEFAULT_TEST_PLAN, SkippedTest, parse_test_plan
|
|
49
|
+
|
|
50
|
+
TYPE_CHECKING = False
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from typing import IO
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_stdout(filepath: Path) -> bool:
|
|
56
|
+
"""Check if a file path is set to stdout.
|
|
57
|
+
|
|
58
|
+
Prevents the creation of a ``-`` file in the current directory.
|
|
59
|
+
"""
|
|
60
|
+
return str(filepath) == "-"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def prep_path(filepath: Path) -> IO | None:
|
|
64
|
+
"""Prepare the output file parameter for Click's echo function."""
|
|
65
|
+
if is_stdout(filepath):
|
|
66
|
+
return None
|
|
67
|
+
return filepath.open("w", encoding="UTF-8")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def generate_header(ctx: Context) -> str:
|
|
71
|
+
"""Generate metadata to be left as comments to the top of a file generated by
|
|
72
|
+
this CLI.
|
|
73
|
+
"""
|
|
74
|
+
header = (
|
|
75
|
+
f"# Generated by {ctx.command_path} v{__version__}"
|
|
76
|
+
" - https://github.com/kdeldycke/workflows\n"
|
|
77
|
+
f"# Timestamp: {datetime.now().isoformat()}\n"
|
|
78
|
+
)
|
|
79
|
+
logging.debug(f"Generated header:\n{header}")
|
|
80
|
+
return header
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def remove_header(content: str) -> str:
|
|
84
|
+
"""Return the same content provided, but without the blank lines and header metadata generated by the function above."""
|
|
85
|
+
logging.debug(f"Removing header from:\n{content}")
|
|
86
|
+
lines = []
|
|
87
|
+
still_in_header = True
|
|
88
|
+
for line in content.splitlines():
|
|
89
|
+
if still_in_header:
|
|
90
|
+
# We are still in the header as long as we have blank lines or we have
|
|
91
|
+
# comment lines matching the format produced by the method above.
|
|
92
|
+
if not line.strip() or line.startswith((
|
|
93
|
+
"# Generated by ",
|
|
94
|
+
"# Timestamp: ",
|
|
95
|
+
)):
|
|
96
|
+
continue
|
|
97
|
+
else:
|
|
98
|
+
still_in_header = False
|
|
99
|
+
# We are past the header, so keep all the lines: we have nothing left to remove.
|
|
100
|
+
lines.append(line)
|
|
101
|
+
|
|
102
|
+
headerless_content = "\n".join(lines)
|
|
103
|
+
logging.debug(f"Result of header removal:\n{headerless_content}")
|
|
104
|
+
return headerless_content
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@group
|
|
108
|
+
def gha_utils():
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@gha_utils.command(short_help="Output project metadata")
|
|
113
|
+
@option(
|
|
114
|
+
"-u",
|
|
115
|
+
"--unstable-targets",
|
|
116
|
+
help="Build targets for which Nuitka is allowed to fail without comprimising the "
|
|
117
|
+
" release workflow. This option accepts a mangled string with multiple targets "
|
|
118
|
+
"separated by arbitrary separators. Recognized targets are: "
|
|
119
|
+
f"{', '.join(NUITKA_BUILD_TARGETS)}.",
|
|
120
|
+
)
|
|
121
|
+
@option(
|
|
122
|
+
"--format",
|
|
123
|
+
type=EnumChoice(Dialect),
|
|
124
|
+
default=Dialect.github,
|
|
125
|
+
help="Rendering format of the metadata.",
|
|
126
|
+
)
|
|
127
|
+
@option(
|
|
128
|
+
"--overwrite",
|
|
129
|
+
"--force",
|
|
130
|
+
"--replace",
|
|
131
|
+
is_flag=True,
|
|
132
|
+
default=False,
|
|
133
|
+
help="Allow output target file to be silently wiped out if it already exists.",
|
|
134
|
+
)
|
|
135
|
+
@argument(
|
|
136
|
+
"output_path",
|
|
137
|
+
type=file_path(writable=True, resolve_path=True, allow_dash=True),
|
|
138
|
+
default="-",
|
|
139
|
+
)
|
|
140
|
+
@pass_context
|
|
141
|
+
def metadata(ctx, unstable_targets, format, overwrite, output_path):
|
|
142
|
+
"""Dump project metadata to a file.
|
|
143
|
+
|
|
144
|
+
By default the metadata produced are displayed directly to the console output.
|
|
145
|
+
So `gha-utils metadata` is the same as a call to `gha-utils metadata -`. To have
|
|
146
|
+
the results written in a file on disk, specify the output file like so:
|
|
147
|
+
`gha-utils metadata dump.txt`.
|
|
148
|
+
|
|
149
|
+
For GitHub you want to output to the standard environment file pointed to by the
|
|
150
|
+
`$GITHUB_OUTPUT` variable. I.e.:
|
|
151
|
+
|
|
152
|
+
$ gha-utils metadata --format github "$GITHUB_OUTPUT"
|
|
153
|
+
"""
|
|
154
|
+
if is_stdout(output_path):
|
|
155
|
+
if overwrite:
|
|
156
|
+
logging.warning("Ignore the --overwrite/--force/--replace option.")
|
|
157
|
+
logging.info(f"Print metadata to {sys.stdout.name}")
|
|
158
|
+
else:
|
|
159
|
+
logging.info(f"Dump all metadata to {output_path}")
|
|
160
|
+
|
|
161
|
+
if output_path.exists():
|
|
162
|
+
msg = "Target file exist and will be overwritten."
|
|
163
|
+
if overwrite:
|
|
164
|
+
logging.warning(msg)
|
|
165
|
+
else:
|
|
166
|
+
logging.critical(msg)
|
|
167
|
+
ctx.exit(2)
|
|
168
|
+
|
|
169
|
+
# Extract targets from the raw string provided by the user.
|
|
170
|
+
valid_targets = set()
|
|
171
|
+
if unstable_targets:
|
|
172
|
+
for target in re.split(r"[^a-z0-9\-]", unstable_targets.lower()):
|
|
173
|
+
if target:
|
|
174
|
+
if target not in NUITKA_BUILD_TARGETS:
|
|
175
|
+
logging.fatal(
|
|
176
|
+
f"Unrecognized {target!r} target. "
|
|
177
|
+
f"Must be one of {', '.join(NUITKA_BUILD_TARGETS)}."
|
|
178
|
+
)
|
|
179
|
+
sys.exit(1)
|
|
180
|
+
valid_targets.add(target)
|
|
181
|
+
logging.debug(
|
|
182
|
+
f"Parsed {unstable_targets!r} string into {valid_targets} targets."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
metadata = Metadata(valid_targets)
|
|
186
|
+
|
|
187
|
+
# Output a warning in GitHub runners if metadata are not saved to $GITHUB_OUTPUT.
|
|
188
|
+
if is_github_ci():
|
|
189
|
+
env_file = os.getenv("GITHUB_OUTPUT")
|
|
190
|
+
if env_file and Path(env_file) != output_path:
|
|
191
|
+
logging.warning(
|
|
192
|
+
"Output path is not the same as $GITHUB_OUTPUT environment variable,"
|
|
193
|
+
" which is generally what we're looking to do in GitHub CI runners for"
|
|
194
|
+
" other jobs to consume the produced metadata."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
dialect = Dialect(format)
|
|
198
|
+
content = metadata.dump(dialect=dialect)
|
|
199
|
+
echo(content, file=prep_path(output_path))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@gha_utils.command(short_help="Maintain a Markdown-formatted changelog")
|
|
203
|
+
@option(
|
|
204
|
+
"--source",
|
|
205
|
+
type=file_path(exists=True, readable=True, resolve_path=True),
|
|
206
|
+
default="changelog.md",
|
|
207
|
+
help="Changelog source file in Markdown format.",
|
|
208
|
+
)
|
|
209
|
+
@argument(
|
|
210
|
+
"changelog_path",
|
|
211
|
+
type=file_path(writable=True, resolve_path=True, allow_dash=True),
|
|
212
|
+
default="-",
|
|
213
|
+
)
|
|
214
|
+
@pass_context
|
|
215
|
+
def changelog(ctx, source, changelog_path):
|
|
216
|
+
initial_content = None
|
|
217
|
+
if source:
|
|
218
|
+
logging.info(f"Read initial changelog from {source}")
|
|
219
|
+
initial_content = source.read_text(encoding="UTF-8")
|
|
220
|
+
|
|
221
|
+
changelog = Changelog(initial_content)
|
|
222
|
+
content = changelog.update()
|
|
223
|
+
if content == initial_content:
|
|
224
|
+
logging.warning("Changelog already up to date. Do nothing.")
|
|
225
|
+
ctx.exit()
|
|
226
|
+
|
|
227
|
+
if is_stdout(changelog_path):
|
|
228
|
+
logging.info(f"Print updated results to {sys.stdout.name}")
|
|
229
|
+
else:
|
|
230
|
+
logging.info(f"Save updated results to {changelog_path}")
|
|
231
|
+
echo(content, file=prep_path(changelog_path))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@gha_utils.command(short_help="Update Git's .mailmap file with missing contributors")
|
|
235
|
+
@option(
|
|
236
|
+
"--source",
|
|
237
|
+
type=file_path(readable=True, resolve_path=True),
|
|
238
|
+
default=".mailmap",
|
|
239
|
+
help="Mailmap source file to use as reference for contributors identities that "
|
|
240
|
+
"are already grouped.",
|
|
241
|
+
)
|
|
242
|
+
@option(
|
|
243
|
+
"--create-if-missing/--skip-if-missing",
|
|
244
|
+
is_flag=True,
|
|
245
|
+
default=True,
|
|
246
|
+
help="If not found, either create the missing destination mailmap file, or skip "
|
|
247
|
+
"the update process entirely. This option is ignored if the destination is to print "
|
|
248
|
+
f"the result to {sys.stdout.name}.",
|
|
249
|
+
)
|
|
250
|
+
@argument(
|
|
251
|
+
"destination_mailmap",
|
|
252
|
+
type=file_path(writable=True, resolve_path=True, allow_dash=True),
|
|
253
|
+
default="-",
|
|
254
|
+
)
|
|
255
|
+
@pass_context
|
|
256
|
+
def mailmap_sync(ctx, source, create_if_missing, destination_mailmap):
|
|
257
|
+
"""Update a ``.mailmap`` file with all missing contributors found in Git commit
|
|
258
|
+
history.
|
|
259
|
+
|
|
260
|
+
By default the ``.mailmap`` at the root of the repository is read and its content
|
|
261
|
+
is reused as reference, so identities already aliased in there are preserved and
|
|
262
|
+
used as initial mapping. Only missing contributors not found in this initial mapping
|
|
263
|
+
are added.
|
|
264
|
+
|
|
265
|
+
The resulting updated mapping is printed to the console output. So a bare call to
|
|
266
|
+
`gha-utils mailmap-sync` is the same as a call to
|
|
267
|
+
`gha-utils mailmap-sync --source .mailmap -`.
|
|
268
|
+
|
|
269
|
+
To have the updated mapping written to a file, specify the output file like so:
|
|
270
|
+
`gha-utils mailmap-sync .mailmap`.
|
|
271
|
+
|
|
272
|
+
The updated results are sorted. But no attempts are made at regrouping new
|
|
273
|
+
contributors. SO you have to edit entries by hand to regroup them
|
|
274
|
+
"""
|
|
275
|
+
mailmap = Mailmap()
|
|
276
|
+
|
|
277
|
+
if source.exists():
|
|
278
|
+
logging.info(f"Read initial mapping from {source}")
|
|
279
|
+
content = remove_header(source.read_text(encoding="UTF-8"))
|
|
280
|
+
mailmap.parse(content)
|
|
281
|
+
else:
|
|
282
|
+
logging.debug(f"Mailmap source file {source} does not exists.")
|
|
283
|
+
|
|
284
|
+
mailmap.update_from_git()
|
|
285
|
+
new_content = mailmap.render()
|
|
286
|
+
|
|
287
|
+
if is_stdout(destination_mailmap):
|
|
288
|
+
logging.info(f"Print updated results to {sys.stdout.name}.")
|
|
289
|
+
logging.debug(
|
|
290
|
+
"Ignore the "
|
|
291
|
+
+ ("--create-if-missing" if create_if_missing else "--skip-if-missing")
|
|
292
|
+
+ " option."
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
logging.info(f"Save updated results to {destination_mailmap}")
|
|
296
|
+
if not create_if_missing and not destination_mailmap.exists():
|
|
297
|
+
logging.warning(
|
|
298
|
+
f"{destination_mailmap} does not exists, stop the sync process."
|
|
299
|
+
)
|
|
300
|
+
ctx.exit()
|
|
301
|
+
if content == new_content:
|
|
302
|
+
logging.warning("Nothing to update, stop the sync process.")
|
|
303
|
+
ctx.exit()
|
|
304
|
+
|
|
305
|
+
echo(generate_header(ctx) + new_content, file=prep_path(destination_mailmap))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@gha_utils.command(short_help="Run a test plan from a file against a binary")
|
|
309
|
+
@option(
|
|
310
|
+
"--command",
|
|
311
|
+
"--binary",
|
|
312
|
+
required=True,
|
|
313
|
+
metavar="COMMAND",
|
|
314
|
+
help="Path to the binary file to test, or a command line to be executed.",
|
|
315
|
+
)
|
|
316
|
+
@option(
|
|
317
|
+
"-F",
|
|
318
|
+
"--plan-file",
|
|
319
|
+
type=file_path(exists=True, readable=True, resolve_path=True),
|
|
320
|
+
multiple=True,
|
|
321
|
+
metavar="FILE_PATH",
|
|
322
|
+
help="Path to a test plan file in YAML. This option can be repeated to run "
|
|
323
|
+
"multiple test plans in sequence. If not provided, a default test plan will be "
|
|
324
|
+
"executed.",
|
|
325
|
+
)
|
|
326
|
+
@option(
|
|
327
|
+
"-E",
|
|
328
|
+
"--plan-envvar",
|
|
329
|
+
multiple=True,
|
|
330
|
+
metavar="ENVVAR_NAME",
|
|
331
|
+
help="Name of an environment variable containing a test plan in YAML. This "
|
|
332
|
+
"option can be repeated to collect multiple test plans.",
|
|
333
|
+
)
|
|
334
|
+
@option(
|
|
335
|
+
"-t",
|
|
336
|
+
"--select-test",
|
|
337
|
+
type=IntRange(min=1),
|
|
338
|
+
multiple=True,
|
|
339
|
+
metavar="INTEGER",
|
|
340
|
+
help="Only run the tests matching the provided test case numbers. This option can "
|
|
341
|
+
"be repeated to run multiple test cases. If not provided, all test cases will be "
|
|
342
|
+
"run.",
|
|
343
|
+
)
|
|
344
|
+
@option(
|
|
345
|
+
"-s",
|
|
346
|
+
"--skip-platform",
|
|
347
|
+
type=Choice(sorted(ALL_IDS), case_sensitive=False),
|
|
348
|
+
multiple=True,
|
|
349
|
+
help="Skip tests for the specified platforms. This option can be repeated to "
|
|
350
|
+
"skip multiple platforms.",
|
|
351
|
+
)
|
|
352
|
+
@option(
|
|
353
|
+
"-x",
|
|
354
|
+
"--exit-on-error",
|
|
355
|
+
is_flag=True,
|
|
356
|
+
default=False,
|
|
357
|
+
help="Exit instantly on first failed test.",
|
|
358
|
+
)
|
|
359
|
+
@option(
|
|
360
|
+
"-T",
|
|
361
|
+
"--timeout",
|
|
362
|
+
# Timeout passed to subprocess.run() is a float that is silently clamped to
|
|
363
|
+
# 0.0 is negative values are provided, so we mimic this behavior here:
|
|
364
|
+
# https://github.com/python/cpython/blob/5740b95076b57feb6293cda4f5504f706a7d622d/Lib/subprocess.py#L1596-L1597
|
|
365
|
+
type=FloatRange(min=0, clamp=True),
|
|
366
|
+
metavar="SECONDS",
|
|
367
|
+
help="Set the default timeout for each CLI call, if not specified in the "
|
|
368
|
+
"test plan.",
|
|
369
|
+
)
|
|
370
|
+
@option(
|
|
371
|
+
"--show-trace-on-error/--hide-trace-on-error",
|
|
372
|
+
default=True,
|
|
373
|
+
help="Show execution trace of failed tests.",
|
|
374
|
+
)
|
|
375
|
+
@option(
|
|
376
|
+
"--stats/--no-stats",
|
|
377
|
+
is_flag=True,
|
|
378
|
+
default=True,
|
|
379
|
+
help="Print per-manager package statistics.",
|
|
380
|
+
)
|
|
381
|
+
def test_plan(
|
|
382
|
+
command: str,
|
|
383
|
+
plan_file: tuple[Path, ...] | None,
|
|
384
|
+
plan_envvar: tuple[str, ...] | None,
|
|
385
|
+
select_test: tuple[int, ...] | None,
|
|
386
|
+
skip_platform: tuple[str, ...] | None,
|
|
387
|
+
exit_on_error: bool,
|
|
388
|
+
timeout: float | None,
|
|
389
|
+
show_trace_on_error: bool,
|
|
390
|
+
stats: bool,
|
|
391
|
+
) -> None:
|
|
392
|
+
# Load test plan from workflow input, or use a default one.
|
|
393
|
+
test_list = []
|
|
394
|
+
if plan_file or plan_envvar:
|
|
395
|
+
for file in unique(plan_file):
|
|
396
|
+
logging.info(f"Get test plan from {file} file")
|
|
397
|
+
tests = list(parse_test_plan(file.read_text(encoding="UTF-8")))
|
|
398
|
+
logging.info(f"{len(tests)} test cases found.")
|
|
399
|
+
test_list.extend(tests)
|
|
400
|
+
for envvar_id in merge_envvar_ids(plan_envvar):
|
|
401
|
+
logging.info(f"Get test plan from {envvar_id!r} environment variable")
|
|
402
|
+
tests = list(parse_test_plan(os.getenv(envvar_id)))
|
|
403
|
+
logging.info(f"{len(tests)} test cases found.")
|
|
404
|
+
test_list.extend(tests)
|
|
405
|
+
|
|
406
|
+
else:
|
|
407
|
+
logging.warning(
|
|
408
|
+
"No test plan provided through --plan-file/-F or --plan-envvar/-E options:"
|
|
409
|
+
" use default test plan."
|
|
410
|
+
)
|
|
411
|
+
test_list = DEFAULT_TEST_PLAN
|
|
412
|
+
logging.debug(f"Test plan: {test_list}")
|
|
413
|
+
|
|
414
|
+
counter = Counter(total=len(test_list), skipped=0, failed=0)
|
|
415
|
+
|
|
416
|
+
for index, test_case in enumerate(test_list):
|
|
417
|
+
test_number = index + 1
|
|
418
|
+
test_name = f"#{test_number}"
|
|
419
|
+
logging.info(f"Run test {test_name}...")
|
|
420
|
+
|
|
421
|
+
if select_test and test_number not in select_test:
|
|
422
|
+
logging.warning(f"Test {test_name} skipped by user request.")
|
|
423
|
+
counter["skipped"] += 1
|
|
424
|
+
continue
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
logging.debug(f"Test case parameters: {test_case}")
|
|
428
|
+
test_case.run_cli_test(
|
|
429
|
+
command,
|
|
430
|
+
additional_skip_platforms=skip_platform,
|
|
431
|
+
default_timeout=timeout,
|
|
432
|
+
)
|
|
433
|
+
except SkippedTest as ex:
|
|
434
|
+
counter["skipped"] += 1
|
|
435
|
+
logging.warning(f"Test {test_name} skipped: {ex}")
|
|
436
|
+
except Exception as ex:
|
|
437
|
+
counter["failed"] += 1
|
|
438
|
+
logging.error(f"Test {test_name} failed: {ex}")
|
|
439
|
+
if show_trace_on_error and test_case.execution_trace:
|
|
440
|
+
echo(test_case.execution_trace)
|
|
441
|
+
if exit_on_error:
|
|
442
|
+
logging.debug("Don't continue testing, a failed test was found.")
|
|
443
|
+
sys.exit(1)
|
|
444
|
+
|
|
445
|
+
if stats:
|
|
446
|
+
echo(
|
|
447
|
+
"Test plan results - "
|
|
448
|
+
+ ", ".join((f"{k.title()}: {v}" for k, v in counter.items()))
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
if counter["failed"]:
|
|
452
|
+
sys.exit(1)
|