Repository: plone/cookieplone

Issue:
Implement new filters to manipulate Python versions
- `python_versions`: List of Python versions supported by a given Plone version (To be used with trove classifiers, GitHub Actions)
- `python_version_oldest `: Lowest Python version supported by a given Plone (To be used in ruff/black)
- `python_version_latest`: Latest Python version for a given Plone (To be used in GitHub Actions, Makefile)

Hints from issue comments:
@ericof: c/c: @davisagli I would love to hear your opinions about this@stevepiercy: I would prefer `python_version_oldest` to `python_version_lowest`. Then it pairs better with `python_version_latest` because both use time-relative terms.@ericof: > I would prefer `python_version_oldest` to `python_version_lowest`. Then it pairs better with `python_version_latest` because both use time-relative terms.

Changed

Solution patch:
diff --git a/cookieplone/filters/__init__.py b/cookieplone/filters/__init__.py
index 5ba5093..4d61bd8 100644
--- a/cookieplone/filters/__init__.py
+++ b/cookieplone/filters/__init__.py
@@ -76,6 +76,27 @@ def latest_plone(use_prerelease_versions: str) -> str:
     return versions.latest_plone(allow_prerelease=allow_prerelease)
 
 
+@simple_filter
+def python_versions(plone_version: str) -> list[str]:
+    """Return a list of supported Python versions for a given Plone version."""
+    versions_info = versions.python_versions_for_plone(plone_version)
+    return versions_info.supported
+
+
+@simple_filter
+def python_version_earliest(plone_version: str) -> str:
+    """Return the earliest supported Python version for a given Plone version."""
+    versions_info = versions.python_versions_for_plone(plone_version)
+    return versions_info.earliest
+
+
+@simple_filter
+def python_version_latest(plone_version: str) -> str:
+    """Return the latest supported Python version for a given Plone version."""
+    versions_info = versions.python_versions_for_plone(plone_version)
+    return versions_info.latest
+
+
 @simple_filter
 def node_version_for_volto(volto_version: str) -> int:
     """Return the Node Version to be used."""
diff --git a/cookieplone/settings.py b/cookieplone/settings.py
index 5f8a097..03b425f 100644
--- a/cookieplone/settings.py
+++ b/cookieplone/settings.py
@@ -1,6 +1,18 @@
 # SPDX-FileCopyrightText: 2024-present Plone Foundation <board@plone.org>
 #
 # SPDX-License-Identifier: MIT
+from dataclasses import dataclass
+
+
+@dataclass
+class PythonVersionSupport:
+    """Python version support for Plone."""
+
+    supported: list[str]
+    earliest: str
+    latest: str
+
+
 PLONE_MIN_VERSION = "6"
 
 SUPPORTED_PYTHON_VERSIONS = [
@@ -10,6 +22,34 @@
     "3.13",
 ]
 
+# A matrix of Plone and Python version support.
+# For each version of Plone, its Python support is a tuple,
+# where the corresponding elements are
+# a list of supported Python versions,
+# its earliest supported Python version, and
+# its latest supported Python version.
+PLONE_PYTHON = {
+    "6.0": PythonVersionSupport(
+        [
+            "3.10",
+            "3.11",
+            "3.12",
+        ],
+        "3.10",
+        "3.12",
+    ),
+    "6.1": PythonVersionSupport(
+        [
+            "3.10",
+            "3.11",
+            "3.12",
+            "3.13",
+        ],
+        "3.10",
+        "3.13",
+    ),
+}
+
 DEFAULT_NODE = 22
 SUPPORTED_NODE_VERSIONS = [
     "20",
@@ -17,18 +57,18 @@
 ]
 
 
-VOLTO_MIN_VERSION = "18.0.0-alpha.1"
+VOLTO_MIN_VERSION = "18.0.0-alpha.43"
 VOLTO_NODE = {
     18: 22,
 }
 MIN_DOCKER_VERSION = "20.10"
 
-## DEFAULT
+# DEFAULT
 COOKIEPLONE_REPO = "https://github.com/plone/cookieplone"
 TEMPLATES_REPO = "https://github.com/plone/cookieplone-templates"
 REPO_DEFAULT = "gh:plone/cookieplone-templates"
 
-## Config
+# Config
 QUIET_MODE_VAR = "COOKIEPLONE_QUIET_MODE_SWITCH"
 REPO_LOCATION = "COOKIEPLONE_REPOSITORY"
 REPO_TAG = "COOKIEPLONE_REPOSITORY_TAG"
diff --git a/cookieplone/utils/versions.py b/cookieplone/utils/versions.py
index 83596ae..dbc5a0e 100644
--- a/cookieplone/utils/versions.py
+++ b/cookieplone/utils/versions.py
@@ -10,7 +10,9 @@
 def get_npm_package_versions(package: str) -> list[str]:
     """Get versions for a NPM package."""
     url: str = f"https://registry.npmjs.org/{package}"
-    resp = requests.get(url, headers={"Accept": "application/vnd.npm.install-v1+json"})  # noQA: S113
+    resp = requests.get(  # noQA: S113
+        url, headers={"Accept": "application/vnd.npm.install-v1+json"}
+    )
     data = resp.json()
     return list(data["dist-tags"].values())
 
@@ -40,7 +42,7 @@ def is_valid_version(
     return status
 
 
-def latest_version(
+def version_latest(
     versions: list[str],
     min_version: str | None = None,
     max_version: str | None = None,
@@ -66,7 +68,7 @@ def latest_volto(
 ) -> str | None:
     """Return the latest volto version."""
     versions = get_npm_package_versions("@plone/volto")
-    return latest_version(
+    return version_latest(
         versions,
         min_version=min_version,
         max_version=max_version,
@@ -81,7 +83,7 @@ def latest_plone(
 ) -> str | None:
     """Return the latest Plone version."""
     versions = get_pypi_package_versions("Plone")
-    return latest_version(
+    return version_latest(
         versions,
         min_version=min_version,
         max_version=max_version,
@@ -90,6 +92,20 @@ def latest_plone(
 
 
 def node_version_for_volto(volto_version: str) -> int:
-    """Return the Node Version to be used with Volto."""
+    """Return the Node.js version to use with Volto."""
     major = Version(volto_version).major
     return settings.VOLTO_NODE.get(major, settings.DEFAULT_NODE)
+
+
+def python_versions_for_plone(plone_version: str) -> settings.PythonVersionSupport:
+    """Return the Python version information for a given Plone version."""
+    major = Version(plone_version).major
+    minor = Version(plone_version).minor
+    version = f"{major}.{minor}"
+    return settings.PLONE_PYTHON.get(version)
+
+
+def python_version_for_plone(plone_version: str) -> str:
+    """Return the latest supported Python version for a given Plone version."""
+    version_support = python_versions_for_plone(plone_version)
+    return version_support.latest
diff --git a/news/69.feature b/news/69.feature
new file mode 100644
index 0000000..8bcb2d1
--- /dev/null
+++ b/news/69.feature
@@ -0,0 +1 @@
+Implement new filters to manipulate Python versions. @ericof


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 2ebe704..719bf77 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -19,7 +19,8 @@ def tmp_repo(tmp_path):
 
 @pytest.fixture()
 def no_repo(tmp_path):
-    sub_path = "".join(random.choice(string.ascii_lowercase) for _ in range(20))  # noQA:S311
+    letters = string.ascii_lowercase
+    sub_path = "".join(random.choice(letters) for _ in range(20))  # noQA: S311
     path = tmp_path / sub_path
     path.mkdir(parents=True)
     return path
diff --git a/tests/test_filters.py b/tests/test_filters.py
index 69c87c6..72b2f16 100644
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -43,6 +43,20 @@ def func(filter_: str) -> Path:
         ["locales_language_code", "{{'es-mx' | locales_language_code}}", "es_MX"],
         ["image_prefix", "{{'github' | image_prefix}}", "ghcr.io/"],
         ["image_prefix", "{{'bitbucket' | image_prefix}}", ""],
+        [
+            "python_versions",
+            "{{'6.0' | python_versions}}",
+            "['3.10', '3.11', '3.12']",
+        ],
+        [
+            "python_versions",
+            "{{'6.1' | python_versions}}",
+            "['3.10', '3.11', '3.12', '3.13']",
+        ],
+        ["python_version_earliest", "{{'6.0' | python_version_earliest}}", "3.10"],
+        ["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"],
     ],
 )
 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 865e1f7..1aa1ff6 100644
--- a/tests/utils/test_versions.py
+++ b/tests/utils/test_versions.py
@@ -1,5 +1,6 @@
 import pytest
 
+from cookieplone.settings import PythonVersionSupport
 from cookieplone.utils import versions
 
 
@@ -168,14 +169,14 @@ def test_get_pypi_package_versions():
         ["17", "17.99", True, "17.15.5"],
     ],
 )
-def test_latest_version(
+def test_version_latest(
     volto_versions,
     min_version: str,
     max_version: str,
     allow_prerelease: bool,
     expected: str,
 ):
-    func = versions.latest_version
+    func = versions.version_latest
     assert func(volto_versions, min_version, max_version, allow_prerelease) == expected
 
 
@@ -220,3 +221,37 @@ def test_latest_plone(
 ):
     func = versions.latest_plone
     assert func(min_version, max_version, allow_prerelease) == expected
+
+
+@pytest.mark.parametrize(
+    "plone_version,supported,earliest,latest",
+    [
+        ["6.0", ["3.10", "3.11", "3.12"], "3.10", "3.12"],
+        ["6.0.14", ["3.10", "3.11", "3.12"], "3.10", "3.12"],
+        ["6.1.0", ["3.10", "3.11", "3.12", "3.13"], "3.10", "3.13"],
+    ],
+)
+def test_python_versions_for_plone(
+    plone_version: str, supported: list[str], earliest: str, latest: str
+):
+    func = versions.python_versions_for_plone
+    result = func(plone_version)
+    assert isinstance(result, PythonVersionSupport)
+    assert result.supported == supported
+    assert result.oldest == earliest
+    assert result.latest == latest
+
+
+@pytest.mark.parametrize(
+    "plone_version,expected",
+    [
+        ["6.0", "3.12"],
+        ["6.0.14", "3.12"],
+        ["6.1.0", "3.13"],
+    ],
+)
+def test_python_version_for_plone(plone_version: str, expected: str):
+    func = versions.python_version_for_plone
+    result = func(plone_version)
+    assert isinstance(result, str)
+    assert result == expected