Skip to content
New issue

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

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

Already on GitHub? Sign in 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 = 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.

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 @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/briefcase/platforms/linux/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)

Expand Down
76 changes: 76 additions & 0 deletions tests/commands/base/test_verify_requires_python.py
Original file line number Diff line number Diff line change
@@ -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)
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
16 changes: 8 additions & 8 deletions tests/platforms/linux/system/test_mixin__verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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):
Expand Down Expand Up @@ -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()
Expand All @@ -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()
6 changes: 3 additions & 3 deletions tests/platforms/linux/system/test_mixin__verify_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down Expand Up @@ -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[
Expand Down Expand Up @@ -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[
Expand Down
Loading