Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Honor PEP 621 requires-python setting. #2029

Merged
1 change: 1 addition & 0 deletions changes/2016.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/reference/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 24 additions & 0 deletions src/briefcase/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -35,6 +36,7 @@
NetworkFailure,
TemplateUnsupportedVersion,
UnsupportedHostError,
UnsupportedPythonVersion,
)
from briefcase.integrations.base import ToolCache
from briefcase.integrations.file import File
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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 = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.minor}"

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.

Expand Down
8 changes: 7 additions & 1 deletion src/briefcase/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import copy
import keyword
import re
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -216,6 +217,7 @@ def __init__(
url=None,
author=None,
author_email=None,
requires_python=None,
**kwargs,
):
super().__init__(**kwargs)
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions src/briefcase/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,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
Expand Down
51 changes: 51 additions & 0 deletions tests/commands/base/test_verify_requires_python.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import sys

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)


def test_requires_python_met(base_command, my_app):
"""Validation passes if requires-python specifies a version lower than the running interpreter."""

major, minor, micro, *_ = sys.version_info
spec = f">{major}.{minor - 1}.{micro}"
base_command.global_config = _get_global_config(requires_python=spec)
base_command.verify_required_python(my_app)


def test_requires_python_unmet(base_command, my_app):
"""Validation fails if requires-python specifies a version greater than the running interpreter."""

major, minor, micro, *_ = sys.version_info
spec = f">{major}.{minor + 1}.{micro}"
base_command.global_config = _get_global_config(requires_python=spec)

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)
2 changes: 2 additions & 0 deletions tests/config/test_merge_pep621_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
)

Expand All @@ -30,6 +31,7 @@ def test_base_keys():
"version": "1.2.3",
"license": {"text": "BSD License"},
"url": "https://example.com",
"requires_python": ">=3.9",
}


Expand Down
Loading