diff --git a/changes/2855.feature.md b/changes/2855.feature.md new file mode 100644 index 000000000..a9ed7bcfe --- /dev/null +++ b/changes/2855.feature.md @@ -0,0 +1 @@ +The minimum supported Windows build number can now be specified using the `min_os_version` option. diff --git a/docs/en/reference/platforms/windows/index.md b/docs/en/reference/platforms/windows/index.md index d00df8c9b..d0acdc9f9 100644 --- a/docs/en/reference/platforms/windows/index.md +++ b/docs/en/reference/platforms/windows/index.md @@ -129,6 +129,11 @@ The digest algorithm to request the Timestamp Authority server uses for the time The following options can be added to the `tool.briefcase.app..windows` section of your `pyproject.toml` file. +#### `min_os_version` + +The minimum [Windows build number](https://en.wikipedia.org/wiki/List_of_Microsoft_Windows_versions) that the app will support. +This is used by MSI installers to block installation on unsupported versions. + #### `dotnet_version` { #dotnet-version } The minimum .NET runtime version required by the application, as a version string (e.g., `"10.0.0"`). This is used by MSI installers to verify that the required runtime is installed before installing the app. If this value is not set, no .NET runtime check is performed. diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py index c8bd782f0..8ba004579 100644 --- a/src/briefcase/platforms/windows/__init__.py +++ b/src/briefcase/platforms/windows/__init__.py @@ -134,6 +134,18 @@ def verify_host(self): Install a 64bit version of Python and run Briefcase again. """) + def target_windows_build(self, app: FinalizedAppConfig) -> int | None: + """The minimum supported Windows build number for the app from + ``briefcase.toml``. + + :param app: The config object for the app + :return: version or None if one isn't specified + """ + try: + return self.briefcase_toml(app)["briefcase"]["target_windows_build"] + except KeyError: + return None + class WindowsCreateCommand(CreateCommand): def support_package_filename(self, support_revision): @@ -243,6 +255,26 @@ def _cleanup_app_support_package(self, support_path): """, ) + def _install_app_requirements( + self, + app: FinalizedAppConfig, + requires: list[str], + app_packages_path: Path, + **kwargs, + ): + if template_min_version := self.target_windows_build(app): + min_version = int(getattr(app, "min_os_version", template_min_version)) + if min_version < int(template_min_version): + raise BriefcaseCommandError( + "Your Windows app specifies a minimum build number of " + f"{min_version}, but the app template only supports " + f"{template_min_version}" + ) + + return super()._install_app_requirements( + app, requires, app_packages_path, **kwargs + ) + def install_license(self, app: FinalizedAppConfig): """Install the license for the project as a single RTF document. diff --git a/tests/platforms/windows/app/conftest.py b/tests/platforms/windows/app/conftest.py index 7855a7959..57e131b14 100644 --- a/tests/platforms/windows/app/conftest.py +++ b/tests/platforms/windows/app/conftest.py @@ -19,6 +19,8 @@ def first_app_templated(first_app_config, tmp_path): dedent( """\ [paths] + app_path = "src/app" + app_packages_path = "src/app_packages" extras_path = "custom_extras" """ ), diff --git a/tests/platforms/windows/app/create/test_create.py b/tests/platforms/windows/app/create/test_create.py index 623cca0cd..f9998758f 100644 --- a/tests/platforms/windows/app/create/test_create.py +++ b/tests/platforms/windows/app/create/test_create.py @@ -5,7 +5,8 @@ import pytest from packaging.version import Version -from briefcase.exceptions import UnsupportedHostError +from briefcase.exceptions import BriefcaseCommandError, UnsupportedHostError +from briefcase.integrations.subprocess import Subprocess from briefcase.platforms.windows.app import WindowsAppCreateCommand @@ -259,3 +260,60 @@ def test_external(create_command, external_first_app, tmp_path): context = create_command.output_format_template_context(external_first_app) assert context["package_path"] == str(tmp_path / "base_path/external/src") assert context["binary_path"] == "internal/app.exe" + + +@pytest.mark.parametrize( + ("template_version", "app_version", "compatible"), + [ + (10240, 7601, False), + (10240, 10240, True), + (10240, 17763, True), + (None, 10240, True), + (10240, None, True), + (None, None, True), + # Values provided as strings are converted to int + ("10240", "7601", False), + (10240, "7601", False), + ("10240", 7601, False), + ], +) +def test_min_os_version( + create_command, + first_app_templated, + template_version, + app_version, + compatible, +): + """If the app defines a min OS version that is incompatible with the app template, + an error is raised.""" + first_app_templated.requires = ["first", "second==1.2.3", "third>=3.2.1"] + create_command.target_windows_build = MagicMock(return_value=template_version) + if app_version: + first_app_templated.min_os_version = app_version + create_command.tools[first_app_templated].app_context = MagicMock( + spec_set=Subprocess + ) + if not compatible: + with pytest.raises( + BriefcaseCommandError, + match=( + f"Your Windows app specifies a minimum build number of {app_version}, " + f"but the app template only supports {template_version}" + ), + ): + create_command.install_app_requirements(first_app_templated) + create_command.tools[first_app_templated].app_context.run.assert_not_called() + else: + create_command.install_app_requirements(first_app_templated) + create_command.tools[first_app_templated].app_context.run.assert_called() + + +def test_target_windows_build(create_command, first_app_templated): + "Test that the target Windows build is returned" + + create_command._briefcase_toml[first_app_templated] = {"briefcase": {}} + assert create_command.target_windows_build(first_app_templated) is None + create_command._briefcase_toml[first_app_templated] = { + "briefcase": {"target_windows_build": 10240} + } + assert create_command.target_windows_build(first_app_templated) == 10240 diff --git a/tests/platforms/windows/visualstudio/conftest.py b/tests/platforms/windows/visualstudio/conftest.py new file mode 100644 index 000000000..f37e451c8 --- /dev/null +++ b/tests/platforms/windows/visualstudio/conftest.py @@ -0,0 +1,24 @@ +from textwrap import dedent + +import pytest + +from ....utils import create_file + + +@pytest.fixture +def first_app_templated(first_app_config, tmp_path): + bundle_path = tmp_path / "base_path/build/first-app/windows/visualstudio" + + create_file( + bundle_path / "briefcase.toml", + dedent( + """\ + [paths] + app_path = "src/app" + app_packages_path = "src/app_packages" + extras_path = "custom_extras" + """ + ), + ) + + return first_app_config diff --git a/tests/platforms/windows/visualstudio/test_create.py b/tests/platforms/windows/visualstudio/test_create.py index e373982b2..b9b68e094 100644 --- a/tests/platforms/windows/visualstudio/test_create.py +++ b/tests/platforms/windows/visualstudio/test_create.py @@ -1,5 +1,9 @@ +from unittest.mock import MagicMock + import pytest +from briefcase.exceptions import BriefcaseCommandError +from briefcase.integrations.subprocess import Subprocess from briefcase.platforms.windows.visualstudio import WindowsVisualStudioCreateCommand # Most tests and fixtures are the same for both "app" and "visualstudio". This file only @@ -22,3 +26,56 @@ def test_package_path(create_command, first_app_config, tmp_path): assert context["package_path"] == str( tmp_path / "base_path/build/first-app/windows/visualstudio/x64/Release" ) + + +@pytest.mark.parametrize( + ("template_version", "app_version", "compatible"), + [ + (10240, 7601, False), + (10240, 10240, True), + (10240, 17763, True), + (None, 10240, True), + (10240, None, True), + (None, None, True), + ], +) +def test_min_os_version( + create_command, + first_app_templated, + template_version, + app_version, + compatible, +): + """If the app defines a min OS version that is incompatible with the app template, + an error is raised.""" + first_app_templated.requires = ["first", "second==1.2.3", "third>=3.2.1"] + create_command.target_windows_build = MagicMock(return_value=template_version) + if app_version: + first_app_templated.min_os_version = app_version + create_command.tools[first_app_templated].app_context = MagicMock( + spec_set=Subprocess + ) + if not compatible: + with pytest.raises( + BriefcaseCommandError, + match=( + f"Your Windows app specifies a minimum build number of {app_version}, " + f"but the app template only supports {template_version}" + ), + ): + create_command.install_app_requirements(first_app_templated) + create_command.tools[first_app_templated].app_context.run.assert_not_called() + else: + create_command.install_app_requirements(first_app_templated) + create_command.tools[first_app_templated].app_context.run.assert_called() + + +def test_target_windows_build(create_command, first_app_templated): + "Test that the target Windows build is returned" + + create_command._briefcase_toml[first_app_templated] = {"briefcase": {}} + assert create_command.target_windows_build(first_app_templated) is None + create_command._briefcase_toml[first_app_templated] = { + "briefcase": {"target_windows_build": 10240} + } + assert create_command.target_windows_build(first_app_templated) == 10240