Repository: beeware/briefcase

Issue:
Honor the PEP 621  `requires-python` setting
### What is the problem or limitation you are having?

Although Briefcase is able to target a wide range of Python versions, the app being published may not be equally flexible. As an example, the Toga testbed *requires* the use of Python 3.10 as a result of the specific dependencies that have been pinned. 

### Describe the solution you'd like

Briefcase should honor the PEP 612 [`requires-python`](https://packaging.python.org/en/latest/specifications/pyproject-toml/#requires-python) configuration item. If a project defines `requires-python` setting, and the currently active Python doesn't meet the requirement defined by that setting, Briefcase should raise an error.

### Describe alternatives you've considered

Do nothing, and rely on project documentation to define acceptable deployment platforms.

### Additional context

The `BaseCommand.verify_app()` method would be a reasonable place for this check.

Hints from issue comments:
@sarayourfriend: Hi @freakboy3742, I'd like to work on this issue. I hope to have a PR up for it later today, but if you see this before I manage to get that up, can you confirm that the best place for me to add the check would be in `merge_pep621_config`, in other words, when the PEP621 properties are parsed in the first place? If I've followed the code correctly, [the config is parsed every time a Briefcase command runs](https://github.com/beeware/briefcase/blob/c11a391c574c08bda8cc4bd5a700dddfeda27d88/src/briefcase/__main__.py#L25-L28), so that would prevent running any command with a Python version that does not satisfy `requires-python` (when defined).

The thing I'm somewhat unsure of is whether adding the check there mixes concerns. Perhaps `merge_pep621_config` should just place the `requires-python` setting into the global config and the check/exception should happen somewhere else (not sure where though)?@freakboy3742: @sarayourfriend No - `merge_pep621_config` isn't the right place, essentially for the reason you've identified.

There's two parts to the process of reading a configuration. The first is reading the configuration file; this involves a series of merges so that all the various parts of a configuration are pulled together into a single "flat" configuration dictionary for each app, no matter how complex your app's configuration is. This is where the `merge_pep621_config` comes in - it pulls known PEP 621 configuration items into the app's configuration. This *is* doing the parsing, which will sometimes involve some checks that the values are reasonable (e.g, that you've actually provided a string for a value that needs to be a a string); but it doesn't do anything to verify that the app configuration itself makes sense in the context of the app.

Then, when you run a command, there's [the `verify_app(app)` method](https://github.com/beeware/briefcase/blob/main/src/briefcase/commands/base.py#L619). This is defined on the base command; but other commands extend this implementation to do other checks, once more context about what has been requested is available. *That* is where any validations should be performed.

There *might* be a need to modify the `merge_pep621_config` implementation to ensure that the `requires-python` option is parsed and merged into the app configuration, but it shouldn't be doing anything other than ensuring the key is in the configuration. The actual validation should be done in the `verify_app()` method.@sarayourfriend: Brilliant, I hoped something like `verify_app` existed, glad I asked! :slightly_smiling_face:

I'll have a PR up shortly, and will ask any further questions or clarification there.

Solution patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c4bdc682a..d79ea6bf5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -82,7 +82,7 @@ jobs:
             experimental: true
     steps:
     - name: Checkout
-      uses: actions/checkout@v4.2.0
+      uses: actions/checkout@v4.2.1
       with:
         fetch-depth: 0
 
@@ -113,7 +113,7 @@ jobs:
 
     - name: Store Coverage Data
       if: always() && contains('success,failure', steps.test.outcome)
-      uses: actions/upload-artifact@v4.4.0
+      uses: actions/upload-artifact@v4.4.3
       with:
         name: coverage-data-${{ matrix.platform }}-${{ matrix.python-version }}
         path: ".coverage.*"
@@ -133,7 +133,7 @@ jobs:
     if: always() && contains('success,failure', needs.unit-tests.result)
     steps:
     - name: Checkout
-      uses: actions/checkout@v4.2.0
+      uses: actions/checkout@v4.2.1
       with:
         fetch-depth: 0
 
@@ -169,7 +169,7 @@ jobs:
 
     - name: Upload Project Coverage HTML Report
       if: always() && steps.project-coverage.outcome == 'failure'
-      uses: actions/upload-artifact@v4.4.0
+      uses: actions/upload-artifact@v4.4.3
       with:
         name: html-coverage-report-project
         path: htmlcov
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 484cfffc0..777ca09c8 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -9,7 +9,7 @@ repos:
       - id: end-of-file-fixer
       - id: trailing-whitespace
   - repo: https://github.com/asottile/pyupgrade
-    rev: v3.17.0
+    rev: v3.18.0
     hooks:
       - id: pyupgrade
         args: [--py39-plus]
@@ -24,7 +24,7 @@ repos:
   #     - id: docformatter
   #       args: [--in-place, --black]
   - repo: https://github.com/psf/black-pre-commit-mirror
-    rev: 24.8.0
+    rev: 24.10.0
     hooks:
       - id: black
         language_version: python3
diff --git a/changes/2016.feature.rst b/changes/2016.feature.rst
new file mode 100644
index 000000000..a465694ca
--- /dev/null
+++ b/changes/2016.feature.rst
@@ -0,0 +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/changes/2021.misc.rst b/changes/2021.misc.rst
new file mode 100644
index 000000000..fa13cc2e2
--- /dev/null
+++ b/changes/2021.misc.rst
@@ -0,0 +1 @@
+Updated pre-commit from 4.0.0 to 4.0.1.
diff --git a/changes/2022.misc.rst b/changes/2022.misc.rst
new file mode 100644
index 000000000..9628a82bd
--- /dev/null
+++ b/changes/2022.misc.rst
@@ -0,0 +1 @@
+Updated actions/checkout from 4.2.0 to 4.2.1.
diff --git a/changes/2023.misc.rst b/changes/2023.misc.rst
new file mode 100644
index 000000000..93320696e
--- /dev/null
+++ b/changes/2023.misc.rst
@@ -0,0 +1 @@
+Updated actions/upload-artifact from 4.4.0 to 4.4.3.
diff --git a/changes/2024.misc.rst b/changes/2024.misc.rst
new file mode 100644
index 000000000..9eb46067a
--- /dev/null
+++ b/changes/2024.misc.rst
@@ -0,0 +1 @@
+The ``pre-commit`` hook for ``pyupgrade`` was updated to its latest version.
diff --git a/changes/2025.misc.rst b/changes/2025.misc.rst
new file mode 100644
index 000000000..bd8bb5d7c
--- /dev/null
+++ b/changes/2025.misc.rst
@@ -0,0 +1 @@
+The ``pre-commit`` hook for ``black-pre-commit-mirror`` was updated to its latest version.
diff --git a/changes/2031.misc.rst b/changes/2031.misc.rst
new file mode 100644
index 000000000..cbd28d906
--- /dev/null
+++ b/changes/2031.misc.rst
@@ -0,0 +1 @@
+Updated sphinx from 8.0.2 to 8.1.3.
diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst
index 0974bf4d3..43b9fe1aa 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/pyproject.toml b/pyproject.toml
index 827b755f0..05f4f6950 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -97,7 +97,7 @@ dependencies = [
 dev = [
     "coverage[toml] == 7.6.1",
     "coverage-conditional-plugin == 0.9.0",
-    "pre-commit == 4.0.0",
+    "pre-commit == 4.0.1",
     "pytest == 8.3.3",
     "pytest-xdist == 3.6.1",
     "setuptools_scm == 8.1.0",
@@ -108,7 +108,7 @@ dev = [
 docs = [
     "furo == 2024.8.6",
     "pyenchant == 3.2.2",
-    "sphinx == 8.0.2",
+    "sphinx == 8.1.3",
     "sphinx_tabs == 3.4.5",
     "sphinx-autobuild == 2024.10.3",
     "sphinx-copybutton == 0.5.2",
diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py
index d96b75d1d..1e192666c 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -15,6 +15,7 @@
 
 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 @@
     NetworkFailure,
     TemplateUnsupportedVersion,
     UnsupportedHostError,
+    UnsupportedPythonVersion,
 )
 from briefcase.integrations.base import ToolCache
 from briefcase.integrations.file import File
@@ -627,6 +629,7 @@ def verify_app(self, app: AppConfig):
         """
         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 @@ def verify_app_template(self, app: AppConfig):
 """
             )
 
+    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 92f1b75c0..632268ec8 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 @@ def __init__(
         url=None,
         author=None,
         author_email=None,
+        requires_python=None,
         **kwargs,
     ):
         super().__init__(**kwargs)
@@ -226,6 +228,7 @@ def __init__(
         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 4f7723ca7..c5356a15b 100644
--- a/src/briefcase/exceptions.py
+++ b/src/briefcase/exceptions.py
@@ -230,6 +230,18 @@ def __init__(self, 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 b28b25f8d..dfa9bc873 100644
--- a/src/briefcase/platforms/linux/system.py
+++ b/src/briefcase/platforms/linux/system.py
@@ -386,7 +386,7 @@ def clone_options(self, command):
         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 @@ def verify_app_tools(self, app: AppConfig):
             # 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 000000000..33a1ced52
--- /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 f1a2f7813..88d26d756 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 e20c15549..2a64b39c6 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 01b672713..604e85f92 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[