Repository: plone/cookieplone

Issue:
Implement filter to convert Python versions to Semver

Discussion:

Solution patch:
diff --git a/cookieplone/filters/__init__.py b/cookieplone/filters/__init__.py
index 4d61bd8..9735565 100644
--- a/cookieplone/filters/__init__.py
+++ b/cookieplone/filters/__init__.py
@@ -127,3 +127,9 @@ def locales_language_code(code: str) -> str:
 def image_prefix(registry: str) -> str:
     """Return the a prefix to be used with all Docker images."""
     return containers.image_prefix(registry)
+
+
+@simple_filter
+def as_semver(v: str) -> str:
+    """Convert a PEP 440 version to SemVer."""
+    return versions.convert_pep440_semver(v)
diff --git a/cookieplone/utils/versions.py b/cookieplone/utils/versions.py
index dbc5a0e..84e76b3 100644
--- a/cookieplone/utils/versions.py
+++ b/cookieplone/utils/versions.py
@@ -1,14 +1,50 @@
 # SPDX-FileCopyrightText: 2024-present Plone Foundation <board@plone.org>
 #
 # SPDX-License-Identifier: MIT
+import re
+
 import requests
+import semver
 from packaging.version import Version
 
 from cookieplone import settings
 
+VERSION_PATTERNS = (
+    (r"^(a)(\d{1,2})", r"alpha.\2"),
+    (r"^(b)(\d{1,2})", r"beta.\2"),
+    (r"^(rc)(\d{1,2})", r"rc.\2"),
+)
+
+
+def convert_pep440_semver(version: str) -> str:
+    """Converts a PEP 440 version into a SemVer version
+
+    :param ver: the PyPI version
+    :return: a SemVer version
+    """
+    pypi_version = Version(version)
+    pre = None if not pypi_version.pre else "".join([str(i) for i in pypi_version.pre])
+    if pre:
+        for raw_pattern, replace in VERSION_PATTERNS:
+            pattern = re.compile(raw_pattern)
+            if re.search(pattern, pre):
+                pre = re.sub(pattern, replace, pre)
+
+    parts = list(pypi_version.release)
+    if len(parts) == 2:
+        parts.append(0)
+    major, minor, patch = parts
+    build = ""
+    if pypi_version.dev is not None:
+        build = f"dev-{pypi_version.dev}"
+    elif pypi_version.post is not None:
+        build = f"post-{pypi_version.post}"
+    version = str(semver.Version(major, minor, patch, prerelease=pre, build=build))
+    return version
+
 
 def get_npm_package_versions(package: str) -> list[str]:
-    """Get versions for a NPM package."""
+    """Get versions for a npm package."""
     url: str = f"https://registry.npmjs.org/{package}"
     resp = requests.get(  # noQA: S113
         url, headers={"Accept": "application/vnd.npm.install-v1+json"}
diff --git a/news/73.feature b/news/73.feature
new file mode 100644
index 0000000..01ae328
--- /dev/null
+++ b/news/73.feature
@@ -0,0 +1 @@
+Add filter `as_semver` to convert Python versions (PEP 440) to SemVer. @ericof


Test patch:
diff --git a/tests/test_filters.py b/tests/test_filters.py
index 72b2f16..db2fad7 100644
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -57,6 +57,9 @@ def func(filter_: str) -> Path:
         ["python_version_earliest", "{{'6.1' | python_version_earliest}}", "3.10"],
         ["python_version_latest", "{{'6.0' | python_version_latest}}", "3.12"],
         ["python_version_latest", "{{'6.1' | python_version_latest}}", "3.13"],
+        ["as_semver", "{{'6.1' | as_semver}}", "6.1.0"],
+        ["as_semver", "{{'1.0.0a0' | as_semver}}", "1.0.0-alpha.0"],
+        ["as_semver", "{{'1.0.0a0.dev0' | as_semver}}", "1.0.0-alpha.0+dev-0"],
     ],
 )
 def test_filters(generate_context_file, filter_: str, raw: str, expected: str):
diff --git a/tests/utils/test_versions.py b/tests/utils/test_versions.py
index 8ad264d..7fbf84f 100644
--- a/tests/utils/test_versions.py
+++ b/tests/utils/test_versions.py
@@ -255,3 +255,23 @@ def test_python_version_for_plone(plone_version: str, expected: str):
     result = func(plone_version)
     assert isinstance(result, str)
     assert result == expected
+
+
+@pytest.mark.parametrize(
+    "pep440,expected",
+    [
+        ["1.0.0a0", "1.0.0-alpha.0"],
+        ["1.0.0b1", "1.0.0-beta.1"],
+        ["1.0.0rc1", "1.0.0-rc.1"],
+        ["1.0.0rc1.dev0", "1.0.0-rc.1+dev-0"],
+        ["1.0.0", "1.0.0"],
+        ["6.1.1.dev0", "6.1.1+dev-0"],
+        ["6.1.1.post0", "6.1.1+post-0"],
+        ["202503.1", "202503.1.0"],
+        ["202512.1", "202512.1.0"],
+    ],
+)
+def test_convert_pep440_semver(pep440: str, expected: str):
+    func = versions.convert_pep440_semver
+    result = func(pep440)
+    assert result == expected