Skip to content

Commit 11a68b7

Browse files
authored
fix: ensure package version matches release tag (#65)
1 parent aacae6f commit 11a68b7

File tree

4 files changed

+320
-43
lines changed

4 files changed

+320
-43
lines changed

.ci/check_version_match.py

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
# Copyright (C) 2025 ANSYS, Inc. and/or its affiliates.
2+
# SPDX-License-Identifier: MIT
3+
#
4+
#
5+
# Permission is hereby granted, free of charge, to any person obtaining a copy
6+
# of this software and associated documentation files (the "Software"), to deal
7+
# in the Software without restriction, including without limitation the rights
8+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
# copies of the Software, and to permit persons to whom the Software is
10+
# furnished to do so, subject to the following conditions:
11+
#
12+
# The above copyright notice and this permission notice shall be included in all
13+
# copies or substantial portions of the Software.
14+
#
15+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
# SOFTWARE.
22+
"""Simple CLI script to compare version numbers."""
23+
24+
from __future__ import annotations
25+
26+
from argparse import ArgumentParser
27+
from dataclasses import dataclass
28+
from importlib import metadata
29+
import itertools
30+
import logging
31+
import re
32+
import sys
33+
from typing import Literal
34+
35+
from packaging.version import Version as PyPIVersion
36+
import semver
37+
38+
logger = logging.getLogger(__name__)
39+
40+
41+
def convert2semver(ver: PyPIVersion) -> semver.Version:
42+
"""Convert a PyPI version into a semver version.
43+
44+
https://python-semver.readthedocs.io/en/latest/advanced/convert-pypi-to-semver.html
45+
"""
46+
if ver.epoch:
47+
raise ValueError("Can't convert an epoch to semver") # noqa: TRY003, EM101
48+
if ver.post:
49+
raise ValueError("Can't convert a post part to semver") # noqa: TRY003, EM101
50+
51+
pre = None if not ver.pre else "".join([str(i) for i in ver.pre])
52+
if pre and ver.dev:
53+
raise ValueError("Can't handle both a pre and a dev portion") # noqa: TRY003, EM101
54+
if pre is None and ver.dev is None:
55+
pre = None
56+
else:
57+
pre = pre if pre is not None else f"dev{ver.dev}"
58+
59+
return semver.Version(*ver.release, prerelease=pre, build=ver.local)
60+
61+
62+
# keys are how PyPI defines it, values how semver defines
63+
PRELEASE_CONVERSIONS = {"a": "alpha", "b": "beta"}
64+
SEMVER_PRERELEASE_PATTERN = re.compile(r"^(?P<pretype>[a-zA-Z]*)(\.(?P<prenum>\d+))?$")
65+
PYPI_PRERELEASE_PATTERN = re.compile(r"^(?P<pretype>[a-zA-Z]*)(?P<prenum>\d+)?$")
66+
67+
68+
@dataclass
69+
class SemVer:
70+
"""A SemVer version."""
71+
72+
version: str
73+
74+
75+
@dataclass
76+
class PyPI:
77+
"""A PyPI version."""
78+
79+
version: str
80+
81+
82+
@dataclass
83+
class PyPIMetadata:
84+
"""Get the version from a PyPI metadata."""
85+
86+
package: str
87+
88+
89+
@dataclass
90+
class Version:
91+
"""A version."""
92+
93+
version: semver.Version
94+
source: SemVer | PyPI | PyPIMetadata
95+
96+
def prerelease_info(self) -> tuple[str, int] | None:
97+
"""Get the prelease info.
98+
99+
Returns:
100+
(str, int) of prerelease type, prerelease number
101+
102+
OR
103+
104+
None if not a prerelease
105+
106+
"""
107+
if not self.version.prerelease:
108+
return None
109+
110+
if isinstance(self.source, SemVer):
111+
prerelease_matches = SEMVER_PRERELEASE_PATTERN.fullmatch(
112+
self.version.prerelease,
113+
)
114+
else:
115+
prerelease_matches = PYPI_PRERELEASE_PATTERN.fullmatch(
116+
self.version.prerelease,
117+
)
118+
119+
if prerelease_matches is None:
120+
raise PrereleasePatternError(self)
121+
pretype = prerelease_matches.group("pretype")
122+
prenum = int(prerelease_matches.group("prenum") or 0)
123+
124+
if not isinstance(self.source, SemVer):
125+
pretype = PRELEASE_CONVERSIONS.get(pretype, pretype)
126+
127+
return (pretype, prenum)
128+
129+
def __str__(self) -> str:
130+
"""Get string representation of version."""
131+
return f"{self.version} (from {self.source})"
132+
133+
134+
class PrereleasePatternError(ValueError):
135+
"""Error if prerelease pattern does not match."""
136+
137+
def __init__(self, ver: Version) -> None: # noqa: D107
138+
super().__init__(
139+
f"The prerelease portion of '{ver}' does not match the expected pattern.",
140+
)
141+
142+
143+
def versions_match(
144+
a: Version,
145+
b: Version,
146+
compare: Literal["major", "minor", "patch", "prerelease", "all"] = "all",
147+
) -> bool:
148+
"""Check if 2 versions match."""
149+
if compare == "prerelease":
150+
a.version = a.version.replace(build=None)
151+
b.version = b.version.replace(build=None)
152+
elif compare == "patch":
153+
a.version = a.version.replace(prerelease=None, build=None)
154+
b.version = b.version.replace(prerelease=None, build=None)
155+
elif compare == "minor":
156+
a.version = a.version.replace(patch=0, prerelease=None, build=None)
157+
b.version = b.version.replace(patch=0, prerelease=None, build=None)
158+
elif compare == "major":
159+
a.version = a.version.replace(minor=0, patch=0, prerelease=None, build=None)
160+
b.version = b.version.replace(minor=0, patch=0, prerelease=None, build=None)
161+
162+
# check that major/minor/patch versions are equal
163+
if a.version.finalize_version() != b.version.finalize_version():
164+
return False
165+
166+
a_pretype, a_prenum = a.prerelease_info() or (None, None)
167+
b_pretype, b_prenum = b.prerelease_info() or (None, None)
168+
169+
if a_pretype != b_pretype:
170+
logger.error("Prerelease types do not match: %s != %s", a_pretype, b_pretype)
171+
return False
172+
if a_prenum != b_prenum:
173+
logger.error("Prerelease numbers do not match: %d != %d", a_prenum, b_prenum)
174+
return False
175+
return True
176+
177+
178+
def main() -> None:
179+
"""Cli to compare version numbers."""
180+
logging.basicConfig(level=logging.DEBUG)
181+
182+
parser = ArgumentParser(
183+
description="Compare version numbers from an arbitrary number of provided versions.",
184+
)
185+
parser.add_argument(
186+
"-c",
187+
"--compare",
188+
choices=["major", "minor", "patch", "prerelease", "all"],
189+
default="all",
190+
help="The portions of the provided versions to compare",
191+
)
192+
parser.add_argument(
193+
"-s",
194+
"--semver",
195+
action="append",
196+
type=SemVer,
197+
default=[],
198+
help="A true semantic version",
199+
)
200+
parser.add_argument(
201+
"-p",
202+
"--pypi",
203+
action="append",
204+
type=PyPI,
205+
default=[],
206+
help="A version compatible with PyPI metadata",
207+
)
208+
parser.add_argument(
209+
"-m",
210+
"--pypi-metadata",
211+
action="append",
212+
type=PyPIMetadata,
213+
default=[],
214+
help="Get the version of an installed python package from the metadata by its name",
215+
)
216+
args = parser.parse_args()
217+
218+
all_versions = (
219+
[
220+
Version(
221+
semver.Version.parse(
222+
s.version,
223+
optional_minor_and_patch=args.compare in {"major", "minor"},
224+
),
225+
s,
226+
)
227+
for s in args.semver
228+
]
229+
+ [Version(convert2semver(PyPIVersion(v.version)), v) for v in args.pypi]
230+
+ [Version(convert2semver(PyPIVersion(metadata.version(p.package))), p) for p in args.pypi_metadata]
231+
)
232+
233+
for a, b in itertools.combinations(all_versions, r=2):
234+
if versions_match(a, b, compare=args.compare):
235+
logger.debug(
236+
"Versions are equivalent%s: %s == %s",
237+
"" if args.compare == "all" else f" up to {args.compare} portion",
238+
a,
239+
b,
240+
)
241+
else:
242+
logger.error(
243+
"Versions are not equivalent%s: %s != %s",
244+
"" if args.compare == "all" else f" up to {args.compare} portion",
245+
a,
246+
b,
247+
)
248+
sys.exit(1)
249+
250+
if args.compare == "all":
251+
logger.info("Provided versions are all equivalent")
252+
else:
253+
logger.info(
254+
"Provided versions are all equivalent up to %s portion",
255+
args.compare,
256+
)
257+
258+
259+
if __name__ == "__main__":
260+
main()

.github/workflows/ci_cd.yml

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,8 +385,28 @@ jobs:
385385
path: signtool/installer/dist/*
386386
if-no-files-found: error
387387

388+
check-tag:
389+
runs-on: ubuntu-latest
390+
if: github.event_name == 'push' && contains(github.ref, 'refs/tags')
391+
steps:
392+
- uses: actions/checkout@v4
393+
with:
394+
fetch-depth: 0
395+
- uses: actions/setup-python@v5
396+
with:
397+
python-version: ${{ env.MAIN_PYTHON_VERSION }}
398+
cache: pip
399+
- id: get-tag-version
400+
run: echo "TAG_VERSION=$(echo ${{ github.ref_name }} | cut -dv -f2 -)" | tee "$GITHUB_OUTPUT"
401+
- name: check package versions match tag
402+
run: |
403+
pip install '.[ci]'
404+
python .ci/check_version_match.py --semver ${{ steps.get-tag-version.outputs.TAG_VERSION }} --pypi-metadata ${{ env.PACKAGE_NAME }}
405+
- name: Check if tag is on main branch
406+
run: git branch -r --contains ${{ github.ref_name }} | grep origin/main
407+
388408
release:
389-
needs: [sign-windows-binaries]
409+
needs: [check-tag, sign-windows-binaries]
390410
runs-on: ubuntu-latest
391411
steps:
392412
- uses: actions/checkout@v4
@@ -456,4 +476,4 @@ jobs:
456476
- name: Run release script
457477
run: |
458478
cd aali/scripts/releasehelper
459-
go run main.go "release" ${{ github.ref_name }} ${{ secrets.PYANSYS_CI_BOT_TOKEN }}
479+
go run main.go "release" ${{ github.ref_name }} ${{ secrets.PYANSYS_CI_BOT_TOKEN }}

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ repos:
1717
additional_dependencies: [black==23.12.1]
1818

1919
- repo: https://github.com/pre-commit/pre-commit-hooks
20-
rev: v4.6.0
20+
rev: v5.0.0
2121
hooks:
2222
- id: check-merge-conflict
2323
- id: debug-statements

0 commit comments

Comments
 (0)