Repository: beeware/briefcase
Pull request #2029 (2024-10-14 09:02:12)

Honor PEP 621 `requires-python` setting.
<!--- Describe your changes in detail -->
<!--- What problem does this change solve? -->
<!--- If this PR relates to an issue, include Refs #XXX or Fixes #XXX -->
Fixes https://github.com/beeware/briefcase/issues/2016

While I believe this changeset addresses the issue as described, I have some lingering questions about the approach I took. Namely, the Linux system command mixins already include some Python interpreter version validation to ensure the running interpreter is compatible with Briefcase, and I haven't found a good way to integrate that into this change. I wondered for a bit whether a basic definition of `LinuxSystemMostlyPassiveMixin::verify_python` should be hoisted to the `BaseCommand`, and then `super()` called from those Linux system commands instead of creating an entirely new method on the `BaseCommand`. I chose against trying to do that because it seemed the concerns were getting mixed a bit, and I wanted to see what the maintainers thought before making any bigger changes.

If I were going to fully integrate this new bit of interpreter version validation with the existing bits, I think I'd like to rename `verify_python` to `verify_interpreter_briefcase_compatibility` (or something along those lines). Then name the `BaseCommand` method just `verify_python` and call to `verify_interpreter_briefcase_compatibility` from an override of that base command method in the mixin.

Speaking generally, it would probably be a good idea to rename `verify_python` in that mixin anyway, to at least nominally clarify the difference between its responsibility and that of the base command's own Python interpreter verification. But, like I said, I wasn't too confident in any of the approaches I could come up with, and wanted to avoid creating a bigger changeset than absolutely necessary. That being said, I'm happy to keep working on this to create a more cohesive solution, just let me know :slightly_smiling_face:.

Also: thank you for the excellent contributor documentation. It was easy to get started contributing to BeeWare! And a pleasant codebase to navigate around too.

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [x] All new features have been tested
- [x] All new features have been documented
- [x] I have read the **CONTRIBUTING.md** file
- [x] I will abide by the code of conduct



Patch:
diff --git a/changes/2016.feature.rst b/changes/2016.feature.rst
new file mode 100644
index 00000000..a465694c
--- /dev/null
+++ b/changes/2016.feature.rst
@@ -0,0 +1,1 @@
+Briefcase will now validate that the running Python interpreter meets requirements specified by the PEP 621 ``requires-python`` setting. If ``requires-python`` is not set, there is no change in behavior. Briefcase will also validate that ``requires-python`` is a valid version specifier as laid out by PEP 621's requirements.
diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst
index 0974bf4d..43b9fe1a 100644
--- a/docs/reference/configuration.rst
+++ b/docs/reference/configuration.rst
@@ -611,3 +611,5 @@ available:
   cumulative setting.
 * ``text`` in a ``[project.license]`` section will be mapped to ``license``.
 * ``homepage`` in a ``[project.urls]`` section will be mapped to ``url``.
+* ``requires-python`` will be used to validate the running Python interpreter's
+  version against the requirement.
diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py
index d96b75d1..1e192666 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -15,6 +15,7 @@ from typing import Any
 
 from cookiecutter import exceptions as cookiecutter_exceptions
 from cookiecutter.repository import is_repo_url
+from packaging.specifiers import InvalidSpecifier, Specifier
 from packaging.version import Version
 from platformdirs import PlatformDirs
 
@@ -35,6 +36,7 @@ from briefcase.exceptions import (
     NetworkFailure,
     TemplateUnsupportedVersion,
     UnsupportedHostError,
+    UnsupportedPythonVersion,
 )
 from briefcase.integrations.base import ToolCache
 from briefcase.integrations.file import File
@@ -627,6 +629,7 @@ a custom location for Briefcase's tools.
         """
         self.verify_app_template(app)
         self.verify_app_tools(app)
+        self.verify_required_python(app)
 
     def verify_app_tools(self, app: AppConfig):
         """Verify that tools needed to run the command for this app exist."""
@@ -662,6 +665,27 @@ any compatibility problems, and then add the compatibility declaration.
 """
             )
 
+    def verify_required_python(self, app: AppConfig):
+        """Verify that the running version of Python meets the project's specifications."""
+
+        requires_python = getattr(self.global_config, "requires_python", None)
+        if not requires_python:
+            return
+
+        try:
+            spec = Specifier(requires_python)
+        except InvalidSpecifier as e:
+            raise BriefcaseConfigError(
+                f"Invalid requires-python in pyproject.toml: {e}"
+            ) from e
+
+        running_version = platform.python_version()
+
+        if not spec.contains(running_version):
+            raise UnsupportedPythonVersion(
+                version_specifier=requires_python, running_version=running_version
+            )
+
     def parse_options(self, extra):
         """Parse the command line arguments for the Command.
 
diff --git a/src/briefcase/config.py b/src/briefcase/config.py
index 92f1b75c..632268ec 100644
--- a/src/briefcase/config.py
+++ b/src/briefcase/config.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import copy
 import keyword
 import re
@@ -100,7 +102,6 @@ def validate_url(candidate):
 
 
 def validate_document_type_config(document_type_id, document_type):
-
     try:
         if not (
             isinstance(document_type["extension"], str)
@@ -216,6 +217,7 @@ class GlobalConfig(BaseConfig):
         url=None,
         author=None,
         author_email=None,
+        requires_python=None,
         **kwargs,
     ):
         super().__init__(**kwargs)
@@ -226,6 +228,7 @@ class GlobalConfig(BaseConfig):
         self.author = author
         self.author_email = author_email
         self.license = license
+        self.requires_python = requires_python
 
         # Version number is PEP440 compliant:
         if not is_pep440_canonical_version(self.version):
@@ -442,6 +445,9 @@ def merge_config(config, data):
 def merge_pep621_config(global_config, pep621_config):
     """Merge a PEP621 configuration into a Briefcase configuration."""
 
+    if requires_python := pep621_config.get("requires-python"):
+        global_config["requires_python"] = requires_python
+
     def maybe_update(field, *project_fields):
         # If there's an existing key in the Briefcase config, it takes priority.
         if field in global_config:
diff --git a/src/briefcase/exceptions.py b/src/briefcase/exceptions.py
index 4f7723ca..c5356a15 100644
--- a/src/briefcase/exceptions.py
+++ b/src/briefcase/exceptions.py
@@ -230,6 +230,18 @@ to the PyPI server.{install_hint}
         )
 
 
+class UnsupportedPythonVersion(BriefcaseCommandError):
+    def __init__(self, version_specifier, running_version):
+        super().__init__(
+            f"""\
+Unable to run Briefcase command. The project configuration requires
+Python versions {version_specifier}, but the environment's Python
+version is {running_version}. Please run Briefcase using a Python
+version that satisfies the project's requirements.
+"""
+        )
+
+
 class MissingAppSources(BriefcaseCommandError):
     def __init__(self, src):
         self.src = src
diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py
index b28b25f8..dfa9bc87 100644
--- a/src/briefcase/platforms/linux/system.py
+++ b/src/briefcase/platforms/linux/system.py
@@ -386,7 +386,7 @@ class LinuxSystemMostlyPassiveMixin(LinuxSystemPassiveMixin):
         self.target_image = command.target_image
         self.extra_docker_build_args = command.extra_docker_build_args
 
-    def verify_python(self, app: AppConfig):
+    def verify_docker_python(self, app: AppConfig):
         """Verify that the version of Python being used to build the app in Docker is
         compatible with the version being used to run Briefcase.
 
@@ -591,7 +591,7 @@ to install the missing dependencies, and re-run Briefcase.
             # Check the system Python on the target system to see if it is
             # compatible with Briefcase.
             if verify_python:
-                self.verify_python(app)
+                self.verify_docker_python(app)
         else:
             NativeAppContext.verify(tools=self.tools, app=app)
 


Test patch:
diff --git a/tests/commands/base/test_verify_requires_python.py b/tests/commands/base/test_verify_requires_python.py
new file mode 100644
index 00000000..33a1ced5
--- /dev/null
+++ b/tests/commands/base/test_verify_requires_python.py
@@ -0,0 +1,76 @@
+import platform
+
+import pytest
+
+from briefcase.config import GlobalConfig
+from briefcase.exceptions import BriefcaseConfigError, UnsupportedPythonVersion
+
+
+def _get_global_config(requires_python):
+    return GlobalConfig(
+        project_name="pep621-requires-python-testing",
+        version="0.0.1",
+        bundle="com.example",
+        requires_python=requires_python,
+    )
+
+
+def test_no_requires_python(base_command, my_app):
+    """If requires-python isn't set, no verification is necessary."""
+
+    base_command.global_config = _get_global_config(requires_python=None)
+    base_command.verify_required_python(my_app)
+
+
+@pytest.mark.parametrize(
+    "requires_python",
+    (
+        "!= 3.2",
+        ">= 3.2",
+        "> 3.2",
+        ">= {current}",
+        "== {current}",
+        "~= {current}",
+        "<= {current}",
+        "< 3.100",
+    ),
+)
+def test_requires_python_met(base_command, my_app, requires_python):
+    """Validation passes if requires-python specifies a version compatible with the running interpreter."""
+
+    base_command.global_config = _get_global_config(
+        requires_python.format(current=platform.python_version())
+    )
+    base_command.verify_required_python(my_app)
+
+
+@pytest.mark.parametrize(
+    "requires_python",
+    [
+        # Require a version higher than anything that can exist
+        "> 3.100",
+        ">= 3.100",
+        # Require a version lower than anything that is supported
+        "< 3.2",
+        "<= 3.2",
+        # Equality with a version that definitely isn't supported
+        "== 2.0",
+        "~= 2.0",
+    ],
+)
+def test_requires_python_unmet(base_command, my_app, requires_python):
+    """Validation fails if requires-python specifies a version incompatible with the running interpreter."""
+
+    base_command.global_config = _get_global_config(requires_python)
+
+    with pytest.raises(UnsupportedPythonVersion):
+        base_command.verify_required_python(my_app)
+
+
+def test_requires_python_invalid_specifier(base_command, my_app):
+    """Validation fails if requires-python is not a valid specifier."""
+
+    base_command.global_config = _get_global_config(requires_python="0")
+
+    with pytest.raises(BriefcaseConfigError, match="Invalid requires-python"):
+        base_command.verify_required_python(my_app)
diff --git a/tests/config/test_merge_pep621_config.py b/tests/config/test_merge_pep621_config.py
index f1a2f781..88d26d75 100644
--- a/tests/config/test_merge_pep621_config.py
+++ b/tests/config/test_merge_pep621_config.py
@@ -21,6 +21,7 @@ def test_base_keys():
             "version": "1.2.3",
             "urls": {"Homepage": "https://example.com"},
             "license": {"text": "BSD License"},
+            "requires-python": ">=3.9",
         },
     )
 
@@ -30,6 +31,7 @@ def test_base_keys():
         "version": "1.2.3",
         "license": {"text": "BSD License"},
         "url": "https://example.com",
+        "requires_python": ">=3.9",
     }
 
 
diff --git a/tests/platforms/linux/system/test_mixin__verify.py b/tests/platforms/linux/system/test_mixin__verify.py
index e20c1554..2a64b39c 100644
--- a/tests/platforms/linux/system/test_mixin__verify.py
+++ b/tests/platforms/linux/system/test_mixin__verify.py
@@ -80,7 +80,7 @@ def test_linux_docker(create_command, first_app_config, tmp_path, monkeypatch):
         "verify",
         mock_docker_app_context_verify,
     )
-    create_command.verify_python = MagicMock()
+    create_command.verify_docker_python = MagicMock()
 
     # Verify the tools
     create_command.verify_tools()
@@ -106,14 +106,14 @@ def test_linux_docker(create_command, first_app_config, tmp_path, monkeypatch):
     )
 
     # Python was also verified
-    create_command.verify_python.assert_called_once_with(first_app_config)
+    create_command.verify_docker_python.assert_called_once_with(first_app_config)
 
     # Reset the mock, then invoke verify_app_tools a second time.
-    create_command.verify_python.reset_mock()
+    create_command.verify_docker_python.reset_mock()
     create_command.verify_app_tools(app=first_app_config)
 
     # Python will *not* be verified a second time.
-    create_command.verify_python.assert_not_called()
+    create_command.verify_docker_python.assert_not_called()
 
 
 def test_non_linux_docker(create_command, first_app_config, tmp_path, monkeypatch):
@@ -159,7 +159,7 @@ def test_non_linux_docker(create_command, first_app_config, tmp_path, monkeypatc
         "verify",
         mock_docker_app_context_verify,
     )
-    create_command.verify_python = MagicMock()
+    create_command.verify_docker_python = MagicMock()
 
     # Verify the tools
     create_command.verify_tools()
@@ -185,11 +185,11 @@ def test_non_linux_docker(create_command, first_app_config, tmp_path, monkeypatc
     )
 
     # Python was also verified
-    create_command.verify_python.assert_called_once_with(first_app_config)
+    create_command.verify_docker_python.assert_called_once_with(first_app_config)
 
     # Reset the mock, then invoke verify_app_tools a second time.
-    create_command.verify_python.reset_mock()
+    create_command.verify_docker_python.reset_mock()
     create_command.verify_app_tools(app=first_app_config)
 
     # Python will *not* be verified a second time.
-    create_command.verify_python.assert_not_called()
+    create_command.verify_docker_python.assert_not_called()
diff --git a/tests/platforms/linux/system/test_mixin__verify_python.py b/tests/platforms/linux/system/test_mixin__verify_python.py
index 01b67271..604e85f9 100644
--- a/tests/platforms/linux/system/test_mixin__verify_python.py
+++ b/tests/platforms/linux/system/test_mixin__verify_python.py
@@ -23,7 +23,7 @@ def test_match(create_command, first_app_config, capsys):
     )
 
     # Verify python for the app
-    create_command.verify_python(first_app_config)
+    create_command.verify_docker_python(first_app_config)
 
     # The docker container was interrogated for a Python version
     create_command.tools[
@@ -62,7 +62,7 @@ def test_mismatch(create_command, first_app_config, capsys):
     )
 
     # Verify python for the app
-    create_command.verify_python(first_app_config)
+    create_command.verify_docker_python(first_app_config)
 
     # The docker container was interrogated for a Python version
     create_command.tools[
@@ -107,7 +107,7 @@ def test_target_too_old(create_command, first_app_config):
         match=r"The system python3 version provided by somevendor:surprising "
         r"is 3\.7\.16; Briefcase requires a minimum Python3 version of 3\.9\.",
     ):
-        create_command.verify_python(first_app_config)
+        create_command.verify_docker_python(first_app_config)
 
     # The docker container was interrogated for a Python version
     create_command.tools[