diff --git a/changes/2957.feature.md b/changes/2957.feature.md new file mode 100644 index 0000000000..0226717006 --- /dev/null +++ b/changes/2957.feature.md @@ -0,0 +1 @@ +You can now set the `BRIEFCASE_ALLOW_EMULATION` environment variable to allow Briefcase to run when using an x86_64 Python executable on ARM64 hardware. diff --git a/docs/en/reference/environment.md b/docs/en/reference/environment.md index 9b36294bef..0c8522c5d8 100644 --- a/docs/en/reference/environment.md +++ b/docs/en/reference/environment.md @@ -19,3 +19,15 @@ There are three restrictions on this path specification: 3. It *must not* be on a network drive. The second two restrictions both exist because some of the tools that Briefcase uses (in particular, the Android SDK) do not work in these locations. + +### `BRIEFCASE_ALLOW_EMULATION` + +/// warning | Do not use in production + +It is *highly* inadvisable to generate production apps while this environment variable is set. + +/// + +Both macOS and Windows provide mechanisms for running x86_64 binaries on ARM64 hardware. These tools can make it very difficult to generate a packaged application, as it can be difficult to ensure a packaged application contains code matching the right architecture when the platform provides misleading answers about the current platform. As a result, Briefcase will raise an error if it detects that the version of Python that is being used doesn't match the underlying hardware architecture. + +However, it is possible to disable this check by setting the `BRIEFCASE_ALLOW_EMULATION` environment variable. This will cause Briefcase to output a warning if it appears that Python is running in emulation mode, but it will allow Briefcase commands to run. diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py index b62a7ce1ea..08a1af3a26 100644 --- a/src/briefcase/commands/create.py +++ b/src/briefcase/commands/create.py @@ -122,26 +122,22 @@ def support_package_url(self, support_revision: str) -> str: f"{self.support_package_filename(support_revision)}" ) - def stub_binary_filename(self, support_revision: str, is_console_app: bool) -> str: + def stub_binary_filename( + self, + support_revision: str, + app: FinalizedAppConfig, + ) -> str: """The filename for the stub binary.""" - stub_type = "Console" if is_console_app else "GUI" - win_suffix = ( - f"-{self.tools.host_arch.lower()}" - if self.tools.host_os == "Windows" - else "" - ) - return ( - f"{stub_type}-Stub-{self.python_version_tag}{win_suffix}" - f"-b{support_revision}.zip" - ) + stub_type = "Console" if app.console_app else "GUI" + return f"{stub_type}-Stub-{self.python_version_tag}-b{support_revision}.zip" - def stub_binary_url(self, support_revision: str, is_console_app: bool) -> str: + def stub_binary_url(self, support_revision: str, app: FinalizedAppConfig) -> str: """The URL of the stub binary to use for apps of this type.""" return ( "https://briefcase-support.s3.amazonaws.com/python/" f"{self.python_version_tag}/" f"{self.platform}/" - f"{self.stub_binary_filename(support_revision, is_console_app)}" + f"{self.stub_binary_filename(support_revision, app)}" ) def icon_targets(self, app: FinalizedAppConfig): @@ -492,9 +488,7 @@ def _download_stub_binary(self, app: FinalizedAppConfig) -> Path: except AttributeError: stub_binary_revision = self.stub_binary_revision(app) - stub_binary_url = self.stub_binary_url( - stub_binary_revision, app.console_app - ) + stub_binary_url = self.stub_binary_url(stub_binary_revision, app=app) custom_stub_binary = False self.console.info(f"Using stub binary {stub_binary_url}") diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index 0088970bb7..cf0a3e7793 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -106,12 +106,23 @@ def verify_tools(self): self.tools.platform.machine() == "x86_64" and "ARM64" in self.tools.platform.version() ): - raise BriefcaseCommandError( - "The Python interpreter that is being used to run Briefcase has been " - "compiled for x86_64, and is running in emulation mode on Apple " - "Silicon hardware. You must use a Python interpreter that has been " - "compiled for Apple Silicon, or is a Universal binary." - ) + if bool(self.tools.os.getenv("BRIEFCASE_ALLOW_EMULATION", "")): + self.console.warning_banner( + "Running in CPU emulation mode", + ( + "The Python interpreter that is being used to run Briefcase " + "has been compiled for x86_64, and is running in emulation " + "mode on ARM64 hardware. This configuration should not be used " + "for production apps." + ), + ) + else: + raise BriefcaseCommandError( + "The Python interpreter that is being used to run Briefcase has " + "been compiled for x86_64, and is running in emulation mode on " + "Apple Silicon hardware. You must use a Python interpreter that " + "has been compiled for Apple Silicon, or is a Universal binary." + ) super().verify_tools() diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py index 78d4ae1179..8127b84f64 100644 --- a/src/briefcase/platforms/windows/__init__.py +++ b/src/briefcase/platforms/windows/__init__.py @@ -98,12 +98,23 @@ def verify_host(self): self.tools.host_arch == "ARM64" and "AMD64" in self.tools.platform.python_compiler() ): - raise UnsupportedHostError( - "The Python interpreter that is being used to run Briefcase has been " - "compiled for x86_64, and is running in emulation mode on ARM64 " - "hardware. You must use a Python interpreter that has been " - "compiled for ARM64." - ) + if bool(self.tools.os.getenv("BRIEFCASE_ALLOW_EMULATION", "")): + self.console.warning_banner( + "Running in CPU emulation mode", + ( + "The Python interpreter that is being used to run Briefcase " + "has been compiled for x86_64, and is running in emulation " + "mode on ARM64 hardware. This configuration should not be used " + "for production apps." + ), + ) + else: + raise UnsupportedHostError( + "The Python interpreter that is being used to run Briefcase has " + "been compiled for x86_64, and is running in emulation mode on " + "ARM64 hardware. You must use a Python interpreter that has been " + "compiled for ARM64." + ) if self.tools.host_arch not in ("AMD64", "ARM64"): if all(app.external_package_path for app in self.apps.values()): @@ -148,6 +159,18 @@ def target_windows_build(self, app: FinalizedAppConfig) -> int | None: class WindowsCreateCommand(CreateCommand): + def stub_binary_filename( + self, + support_revision: str, + app: FinalizedAppConfig, + ) -> str: + """The filename for the stub binary.""" + stub_type = "Console" if app.console_app else "GUI" + return ( + f"{stub_type}-Stub-{self.python_version_tag}-{self.tools.host_arch.lower()}" + f"-b{support_revision}.zip" + ) + def support_package_filename(self, support_revision): arch = self.tools.host_arch.lower() return f"python-{self.python_version_tag}.{support_revision}-embed-{arch}.zip" diff --git a/src/briefcase/platforms/windows/app.py b/src/briefcase/platforms/windows/app.py index 0559bdae95..a9e0c48dc3 100644 --- a/src/briefcase/platforms/windows/app.py +++ b/src/briefcase/platforms/windows/app.py @@ -9,7 +9,7 @@ PublishCommand, UpdateCommand, ) -from briefcase.config import BaseConfig +from briefcase.config import FinalizedAppConfig from briefcase.exceptions import BriefcaseCommandError from briefcase.integrations.rcedit import RCEdit from briefcase.integrations.windows_sdk import WindowsSDK @@ -59,7 +59,7 @@ def verify_tools(self): with suppress(BriefcaseCommandError): WindowsSDK.verify(tools=self.tools) - def build_app(self, app: BaseConfig, **kwargs): + def build_app(self, app: FinalizedAppConfig, **kwargs): """Build the application. :param app: The config object for the app diff --git a/tests/platforms/macOS/test_macOSMixin_verify_tools.py b/tests/platforms/macOS/test_macOSMixin_verify_tools.py index fe6c8e3573..2fdb20fae9 100644 --- a/tests/platforms/macOS/test_macOSMixin_verify_tools.py +++ b/tests/platforms/macOS/test_macOSMixin_verify_tools.py @@ -25,3 +25,23 @@ def test_verify_macos_cpu_arch(dummy_command): ), ): dummy_command.verify_tools() + + +def test_verify_macos_cpu_arch_warning(monkeypatch, dummy_command, capsys): + """Rosetta emulation is allowed if you opt in, with a warning.""" + # Set the environment variable to allow emulation + monkeypatch.setenv("BRIEFCASE_ALLOW_EMULATION", "1") + + # Create a Mock object for the platform module + dummy_command.tools.platform = MagicMock(spec_set=platform) + + # Simulate that Mock platform is running on Apple Silicon with an x86_64 Python interpreter + dummy_command.tools.platform.machine = MagicMock(return_value="x86_64") + dummy_command.tools.platform.version = MagicMock(return_value="ARM64") + + dummy_command.verify_tools() + + # A warning was raised, but the app can continue. + stdout, stderr = capsys.readouterr() + assert "Running in CPU emulation mode" in stdout + assert stderr == "" diff --git a/tests/platforms/macOS/xcode/conftest.py b/tests/platforms/macOS/xcode/conftest.py index 8f2fd070e6..c58130e8f2 100644 --- a/tests/platforms/macOS/xcode/conftest.py +++ b/tests/platforms/macOS/xcode/conftest.py @@ -7,7 +7,7 @@ def first_app_generated(first_app_config, tmp_path): # Create the briefcase.toml file create_file( - tmp_path / "base_path/macos/xcode/First App/briefcase.toml", + tmp_path / "base_path/build/first-app/macos/xcode/briefcase.toml", """ [paths] app_packages_path="app_packages" @@ -15,9 +15,10 @@ def first_app_generated(first_app_config, tmp_path): info_plist_path="Info.plist" """, ) + (tmp_path / "base_path/build/first-app/macos/xcode/support").mkdir(parents=True) create_plist_file( - tmp_path / "base_path/macos/xcode/First App/Info.plist", + tmp_path / "base_path/build/first-app/macos/xcode/First App/Info.plist", { "MainModule": "first_app", }, diff --git a/tests/platforms/windows/app/create/test_create.py b/tests/platforms/windows/app/create/test_create.py index f9998758fc..ed4f97de76 100644 --- a/tests/platforms/windows/app/create/test_create.py +++ b/tests/platforms/windows/app/create/test_create.py @@ -110,6 +110,29 @@ def test_verify_windows_cpu_arch(create_command): create_command.verify_host() +def test_verify_windows_cpu_arch_warning(monkeypatch, create_command, capsys): + """User can opt into emuluation mode, but they will get a warning.""" + # Set the environment variable to allow emulation + monkeypatch.setenv("BRIEFCASE_ALLOW_EMULATION", "1") + + # Create a Mock object for the platform module + create_command.tools.platform = MagicMock(spec_set=platform) + + # Simulate that Mock platform is running on Windows ARM64 with an x86_64 Python interpreter + create_command.tools.host_os = "Windows" + create_command.tools.host_arch = "ARM64" + create_command.tools.platform.python_compiler = MagicMock( + return_value="MSV v.1950 64 bit (AMD64)" + ) + + create_command.verify_host() + + # A warning was raised, but the app can continue. + stdout, stderr = capsys.readouterr() + assert "Running in CPU emulation mode" in stdout + assert stderr == "" + + def test_context(create_command, first_app_config): context = create_command.output_format_template_context(first_app_config) assert sorted(context.keys()) == [ @@ -196,6 +219,36 @@ def test_installer_images(create_command, first_app_config, tmp_path): } +@pytest.mark.parametrize( + ("console_app", "arch", "revision", "expected_binary"), + [ + (False, "AMD64", "42", "GUI-Stub-3.X-amd64-b42.zip"), + (True, "AMD64", "42", "Console-Stub-3.X-amd64-b42.zip"), + (False, "arm64", "37", "GUI-Stub-3.X-arm64-b37.zip"), + (True, "arm64", "37", "Console-Stub-3.X-arm64-b37.zip"), + ], +) +def test_stub_binary_filename( + create_command, + first_app_config, + console_app, + arch, + revision, + expected_binary, +): + """A valid support package URL is created for a support revision.""" + first_app_config.console_app = console_app + create_command.tools.host_arch = arch + + create_command.tools.sys = MagicMock(spec=sys) + create_command.tools.sys.version_info = ("3", "X", "Y") + + assert ( + create_command.stub_binary_filename(revision, first_app_config) + == expected_binary + ) + + @pytest.mark.parametrize( ("revision", "micro"), [ @@ -209,9 +262,7 @@ def test_installer_images(create_command, first_app_config, tmp_path): ("0rc1", "0"), ], ) -def test_support_package_url( - create_command, revision, micro, first_app_config, tmp_path -): +def test_support_package_url(create_command, revision, micro): """A valid support package URL is created for a support revision.""" expected_link = ( f"https://www.python.org/ftp/python"