From d270c2454c750e94441f4abbed69f8c6bb05bfd2 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 5 Oct 2023 12:38:13 +0800 Subject: [PATCH] Add tests of multi-pass requirements install and support package handling. --- src/briefcase/platforms/macOS/__init__.py | 3 + tests/platforms/macOS/app/conftest.py | 29 +- tests/platforms/macOS/app/test_create.py | 396 ++++++++++++++++++ tests/platforms/macOS/app/test_package.py | 29 +- .../macOS/app/test_package__notarize.py | 29 +- tests/platforms/macOS/app/test_signing.py | 2 +- 6 files changed, 454 insertions(+), 34 deletions(-) create mode 100644 tests/platforms/macOS/app/test_create.py diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index 2520b2f5e..551d0ddd5 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -84,6 +84,8 @@ def _install_app_requirements( [ sys.executable, "-u", + "-X", + "utf8", "-m", "pip", "install", @@ -100,6 +102,7 @@ def _install_app_requirements( for package, version in binary_packages ], check=True, + encoding="UTF-8", env={ "PYTHONPATH": str( self.support_path(app) diff --git a/tests/platforms/macOS/app/conftest.py b/tests/platforms/macOS/app/conftest.py index cd1a28f98..e14e5d299 100644 --- a/tests/platforms/macOS/app/conftest.py +++ b/tests/platforms/macOS/app/conftest.py @@ -7,7 +7,7 @@ @pytest.fixture -def first_app_with_binaries(first_app_config, tmp_path): +def first_app_templated(first_app_config, tmp_path): app_path = ( tmp_path / "base_path" @@ -59,8 +59,30 @@ def first_app_with_binaries(first_app_config, tmp_path): }, ) + # Create some folders that need to exist. + (app_path / "Contents" / "Resources" / "app_packages").mkdir(parents=True) + (app_path / "Contents" / "Frameworks").mkdir(parents=True) + + # Select dmg packaging by default + first_app_config.packaging_format = "dmg" + + return first_app_config + + +@pytest.fixture +def first_app_with_binaries(first_app_templated, first_app_config, tmp_path): + app_path = ( + tmp_path + / "base_path" + / "build" + / "first-app" + / "macos" + / "app" + / "First App.app" + ) + # Create some libraries that need to be signed. - lib_path = app_path / "Contents" / "Resources" + lib_path = app_path / "Contents" / "Resources" / "app_packages" frameworks_path = app_path / "Contents" / "Frameworks" for lib in [ @@ -103,7 +125,4 @@ def first_app_with_binaries(first_app_config, tmp_path): with (lib_path / "unknown.binary").open("wb") as f: f.write(b"\xCA\xFE\xBA\xBEother") - # Select dmg packaging by default - first_app_config.packaging_format = "dmg" - return first_app_config diff --git a/tests/platforms/macOS/app/test_create.py b/tests/platforms/macOS/app/test_create.py new file mode 100644 index 000000000..a7bf62ae6 --- /dev/null +++ b/tests/platforms/macOS/app/test_create.py @@ -0,0 +1,396 @@ +import subprocess +import sys +from unittest import mock + +import pytest + +from briefcase.console import Console, Log +from briefcase.exceptions import BriefcaseCommandError +from briefcase.integrations.subprocess import Subprocess +from briefcase.platforms.macOS.app import macOSAppCreateCommand + +from ....utils import create_file, create_installed_package, mock_tgz_download + + +@pytest.fixture +def create_command(tmp_path, first_app_templated): + command = macOSAppCreateCommand( + logger=Log(), + console=Console(), + base_path=tmp_path / "base_path", + data_path=tmp_path / "briefcase", + ) + + # mock subprocess app context for this app + command.tools[first_app_templated].app_context = mock.MagicMock(spec_set=Subprocess) + + return command + + +@pytest.mark.parametrize( + "host_arch, other_arch", + [ + ("arm64", "x86_64"), + ("arm64", "x86_64"), + ], +) +def test_install_app_packages( + create_command, + first_app_templated, + tmp_path, + host_arch, + other_arch, +): + """A 2-pass install of app packages is performed.""" + bundle_path = tmp_path / "base_path" / "build" / "first-app" / "macos" / "app" + + create_command.tools.host_arch = host_arch + first_app_templated.requires = ["first", "second==1.2.3", "third>=3.2.1"] + + # Mock the result of finding the binary packages - 2 of the packages are binary; + # the version on the loosely specified package doesn't match the lower bound. + create_command.find_binary_packages = mock.Mock( + return_value=[ + ("second", "1.2.3"), + ("third", "3.4.5"), + ] + ) + # Mock the merge command so we can confirm it was invoked. + create_command.merge_app_packages = mock.Mock() + + create_command.install_app_requirements(first_app_templated, test_mode=False) + + # We looked for binary packages in the host app_packages + create_command.find_binary_packages.assert_called_once_with( + bundle_path / f"app_packages.{host_arch}", + universal_suffix="_universal2", + ) + + # A request was made to install requirements + assert create_command.tools[first_app_templated].app_context.run.mock_calls == [ + # First call is to install the initial packages on the host arch + mock.call( + [ + sys.executable, + "-u", + "-X", + "utf8", + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--no-python-version-warning", + "--upgrade", + "--no-user", + f"--target={bundle_path / ('app_packages.' + host_arch)}", + "first", + "second==1.2.3", + "third>=3.2.1", + ], + check=True, + encoding="UTF-8", + ), + # Second call installs the binary packages for the other architecture. + mock.call( + [ + sys.executable, + "-u", + "-X", + "utf8", + "-m", + "pip", + "install", + "--no-deps", + "--disable-pip-version-check", + "--no-python-version-warning", + "--upgrade", + "--no-user", + f"--target={bundle_path / ('app_packages.' + other_arch)}", + "second==1.2.3", + "third==3.4.5", + ], + check=True, + encoding="UTF-8", + env={ + "PYTHONPATH": str( + bundle_path / "support" / "platform-site" / f"macosx.{other_arch}" + ) + }, + ), + ] + + # The app packages folder has been created. The existence of the target and host + # versions is validated as a result of the underlying install/merge methods. + assert (bundle_path / f"app_packages.{other_arch}").is_dir() + + # An attempt was made to merge packages. + create_command.merge_app_packages.assert_called_once_with( + target_app_packages=bundle_path + / "First App.app" + / "Contents" + / "Resources" + / "app_packages", + sources=[ + bundle_path / f"app_packages.{host_arch}", + bundle_path / f"app_packages.{other_arch}", + ], + ) + + +@pytest.mark.parametrize( + "host_arch, other_arch", + [ + ("arm64", "x86_64"), + ("arm64", "x86_64"), + ], +) +def test_install_app_packages_no_binary( + create_command, + first_app_templated, + tmp_path, + host_arch, + other_arch, +): + """If there's no binaries in the first pass, the second pass isn't performed.""" + bundle_path = tmp_path / "base_path" / "build" / "first-app" / "macos" / "app" + + # Create pre-existing other-arch content + create_installed_package(bundle_path / f"app_packages.{other_arch}", "legacy") + + create_command.tools.host_arch = host_arch + first_app_templated.requires = ["first", "second==1.2.3", "third>=3.2.1"] + + # Mock the result of finding no binary packages. + create_command.find_binary_packages = mock.Mock(return_value=[]) + + # Mock the merge command so we can confirm it was invoked. + create_command.merge_app_packages = mock.Mock() + + create_command.install_app_requirements(first_app_templated, test_mode=False) + + # We looked for binary packages in the host app_packages + create_command.find_binary_packages.assert_called_once_with( + bundle_path / f"app_packages.{host_arch}", + universal_suffix="_universal2", + ) + + # A request was made to install requirements + assert create_command.tools[first_app_templated].app_context.run.mock_calls == [ + # Only call is to install the initial packages on the host arch + mock.call( + [ + sys.executable, + "-u", + "-X", + "utf8", + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--no-python-version-warning", + "--upgrade", + "--no-user", + f"--target={bundle_path / ('app_packages.' + host_arch)}", + "first", + "second==1.2.3", + "third>=3.2.1", + ], + check=True, + encoding="UTF-8", + ), + ] + + # The app packages folder for the other architecture has been created, even though + # it isn't needed. The existence of the target and host versions is validated as a + # result of the underlying install/merge methods. + assert (bundle_path / f"app_packages.{other_arch}").is_dir() + + # We still need to merge the app packages; this is effectively just a copy. + create_command.merge_app_packages.assert_called_once_with( + target_app_packages=bundle_path + / "First App.app" + / "Contents" + / "Resources" + / "app_packages", + sources=[ + bundle_path / f"app_packages.{host_arch}", + bundle_path / f"app_packages.{other_arch}", + ], + ) + + +def test_install_app_packages_failure(create_command, first_app_templated, tmp_path): + """If the install of other-arch binaries fails, an exception is raised.""" + bundle_path = tmp_path / "base_path" / "build" / "first-app" / "macos" / "app" + + # Create pre-existing other-arch content + create_installed_package(bundle_path / "app_packages.x86_64", "legacy") + + create_command.tools.host_arch = "arm64" + first_app_templated.requires = ["first", "second==1.2.3", "third>=3.2.1"] + + # Mock the result of finding the binary packages - 2 of the packages are binary; + # the version on the loosely specified package doesn't match the lower bound. + create_command.find_binary_packages = mock.Mock( + return_value=[ + ("second", "1.2.3"), + ("third", "3.4.5"), + ] + ) + + # Mock the merge command so we can confirm it was invoked. + create_command.merge_app_packages = mock.Mock() + + # Mock a failure on the second install + create_command.tools[first_app_templated].app_context.run.side_effect = [ + None, + subprocess.CalledProcessError(returncode=1, cmd="pip"), + ] + + # Install the requirements; this will raise an error + with pytest.raises( + BriefcaseCommandError, + match=( + r"Unable to install requirements\. This may be because one of your\n" + r"requirements is invalid, or because pip was unable to connect\n" + r"to the PyPI server.\n" + ), + ): + create_command.install_app_requirements(first_app_templated, test_mode=False) + + # We looked for binary packages in the host app_packages + create_command.find_binary_packages.assert_called_once_with( + bundle_path / "app_packages.arm64", + universal_suffix="_universal2", + ) + + # A request was made to install requirements + assert create_command.tools[first_app_templated].app_context.run.mock_calls == [ + # First call is to install the initial packages on the host arch + mock.call( + [ + sys.executable, + "-u", + "-X", + "utf8", + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--no-python-version-warning", + "--upgrade", + "--no-user", + f"--target={bundle_path / 'app_packages.arm64'}", + "first", + "second==1.2.3", + "third>=3.2.1", + ], + check=True, + encoding="UTF-8", + ), + # Second call installs the binary packages for the other architecture; + # this is the call that failed. + mock.call( + [ + sys.executable, + "-u", + "-X", + "utf8", + "-m", + "pip", + "install", + "--no-deps", + "--disable-pip-version-check", + "--no-python-version-warning", + "--upgrade", + "--no-user", + f"--target={bundle_path / 'app_packages.x86_64'}", + "second==1.2.3", + "third==3.4.5", + ], + check=True, + encoding="UTF-8", + env={ + "PYTHONPATH": str( + bundle_path / "support" / "platform-site" / "macosx.x86_64" + ) + }, + ), + ] + + # The app packages folder for the other architecture has been created, even though + # it isn't needed. The existence of the target and host versions is validated as a + # result of the underlying install/merge methods. + assert (bundle_path / "app_packages.x86_64").is_dir() + + # We didn't attempt to merge, because we didn't complete installing. + create_command.merge_app_packages.assert_not_called() + + +@pytest.mark.parametrize("pre_existing", [True, False]) +def test_install_support_package( + create_command, + first_app_templated, + tmp_path, + pre_existing, +): + """The standard library is copied out of the support package into the app bundle.""" + # Hard code the support revision + first_app_templated.support_revision = "37" + + bundle_path = tmp_path / "base_path" / "build" / "first-app" / "macos" / "app" + runtime_support_path = ( + bundle_path / "First App.app" / "Contents" / "Resources" / "support" + ) + + if pre_existing: + create_file( + runtime_support_path / "python-stdlib" / "old-stdlib", + "Legacy stdlib file", + ) + + # Mock download.file to return a support package + create_command.tools.download.file = mock.MagicMock( + side_effect=mock_tgz_download( + f"Python-3.{sys.version_info.minor}-macOS-support.b37.tar.gz", + [ + ("python-stdlib/stdlib.txt", "this is the standard library"), + ( + "platform-site/macosx.arm64/sitecustomize.py", + "this is the arm64 platform site", + ), + ( + "platform-site/macosx.x86_64/sitecustomize.py", + "this is the x86_64 platform site", + ), + ("Python.xcframework/info.plist", "this is the xcframework"), + ], + ) + ) + + # Install the support package + create_command.install_app_support_package(first_app_templated) + + # Confirm that the support files have been unpacked into the bundle location + assert (bundle_path / "support" / "python-stdlib" / "stdlib.txt").exists() + assert ( + bundle_path / "support" / "platform-site" / "macosx.arm64" / "sitecustomize.py" + ).exists() + assert ( + bundle_path / "support" / "platform-site" / "macosx.x86_64" / "sitecustomize.py" + ).exists() + assert (bundle_path / "support" / "Python.xcframework" / "info.plist").exists() + + # The standard library has been copied to the app... + assert (runtime_support_path / "python-stdlib" / "stdlib.txt").exists() + # ... but the other support files have not. + assert not ( + runtime_support_path / "platform-site" / "macosx.arm64" / "sitecustomize.py" + ).exists() + assert not ( + runtime_support_path / "platform-site" / "macosx.x86_64" / "sitecustomize.py" + ).exists() + assert not (runtime_support_path / "Python.xcframework" / "info.plist").exists() + + # The legacy content has been purged + assert not (runtime_support_path / "python-stdlib" / "old-stdlib").exists() diff --git a/tests/platforms/macOS/app/test_package.py b/tests/platforms/macOS/app/test_package.py index 6630affeb..93dda82fa 100644 --- a/tests/platforms/macOS/app/test_package.py +++ b/tests/platforms/macOS/app/test_package.py @@ -485,20 +485,21 @@ def test_package_bare_app(package_command, first_app_with_binaries, tmp_path): "First App.app/Contents/Frameworks/Extras.framework/Resources/extras.dylib", "First App.app/Contents/Info.plist", "First App.app/Contents/Resources/", - "First App.app/Contents/Resources/Extras.app/", - "First App.app/Contents/Resources/Extras.app/Contents/", - "First App.app/Contents/Resources/Extras.app/Contents/MacOS/", - "First App.app/Contents/Resources/Extras.app/Contents/MacOS/Extras", - "First App.app/Contents/Resources/first.other", - "First App.app/Contents/Resources/first_dylib.dylib", - "First App.app/Contents/Resources/first_so.so", - "First App.app/Contents/Resources/other_binary", - "First App.app/Contents/Resources/second.other", - "First App.app/Contents/Resources/special.binary", - "First App.app/Contents/Resources/subfolder/", - "First App.app/Contents/Resources/subfolder/second_dylib.dylib", - "First App.app/Contents/Resources/subfolder/second_so.so", - "First App.app/Contents/Resources/unknown.binary", + "First App.app/Contents/Resources/app_packages/", + "First App.app/Contents/Resources/app_packages/Extras.app/", + "First App.app/Contents/Resources/app_packages/Extras.app/Contents/", + "First App.app/Contents/Resources/app_packages/Extras.app/Contents/MacOS/", + "First App.app/Contents/Resources/app_packages/Extras.app/Contents/MacOS/Extras", + "First App.app/Contents/Resources/app_packages/first.other", + "First App.app/Contents/Resources/app_packages/first_dylib.dylib", + "First App.app/Contents/Resources/app_packages/first_so.so", + "First App.app/Contents/Resources/app_packages/other_binary", + "First App.app/Contents/Resources/app_packages/second.other", + "First App.app/Contents/Resources/app_packages/special.binary", + "First App.app/Contents/Resources/app_packages/subfolder/", + "First App.app/Contents/Resources/app_packages/subfolder/second_dylib.dylib", + "First App.app/Contents/Resources/app_packages/subfolder/second_so.so", + "First App.app/Contents/Resources/app_packages/unknown.binary", ] diff --git a/tests/platforms/macOS/app/test_package__notarize.py b/tests/platforms/macOS/app/test_package__notarize.py index 9384b548c..ca4b6c70e 100644 --- a/tests/platforms/macOS/app/test_package__notarize.py +++ b/tests/platforms/macOS/app/test_package__notarize.py @@ -69,20 +69,21 @@ def test_notarize_app(package_command, first_app_with_binaries, tmp_path): "First App.app/Contents/Frameworks/Extras.framework/Resources/extras.dylib", "First App.app/Contents/Info.plist", "First App.app/Contents/Resources/", - "First App.app/Contents/Resources/Extras.app/", - "First App.app/Contents/Resources/Extras.app/Contents/", - "First App.app/Contents/Resources/Extras.app/Contents/MacOS/", - "First App.app/Contents/Resources/Extras.app/Contents/MacOS/Extras", - "First App.app/Contents/Resources/first.other", - "First App.app/Contents/Resources/first_dylib.dylib", - "First App.app/Contents/Resources/first_so.so", - "First App.app/Contents/Resources/other_binary", - "First App.app/Contents/Resources/second.other", - "First App.app/Contents/Resources/special.binary", - "First App.app/Contents/Resources/subfolder/", - "First App.app/Contents/Resources/subfolder/second_dylib.dylib", - "First App.app/Contents/Resources/subfolder/second_so.so", - "First App.app/Contents/Resources/unknown.binary", + "First App.app/Contents/Resources/app_packages/", + "First App.app/Contents/Resources/app_packages/Extras.app/", + "First App.app/Contents/Resources/app_packages/Extras.app/Contents/", + "First App.app/Contents/Resources/app_packages/Extras.app/Contents/MacOS/", + "First App.app/Contents/Resources/app_packages/Extras.app/Contents/MacOS/Extras", + "First App.app/Contents/Resources/app_packages/first.other", + "First App.app/Contents/Resources/app_packages/first_dylib.dylib", + "First App.app/Contents/Resources/app_packages/first_so.so", + "First App.app/Contents/Resources/app_packages/other_binary", + "First App.app/Contents/Resources/app_packages/second.other", + "First App.app/Contents/Resources/app_packages/special.binary", + "First App.app/Contents/Resources/app_packages/subfolder/", + "First App.app/Contents/Resources/app_packages/subfolder/second_dylib.dylib", + "First App.app/Contents/Resources/app_packages/subfolder/second_so.so", + "First App.app/Contents/Resources/app_packages/unknown.binary", ] # The calls to notarize were made diff --git a/tests/platforms/macOS/app/test_signing.py b/tests/platforms/macOS/app/test_signing.py index ede1fcbb0..c557fe695 100644 --- a/tests/platforms/macOS/app/test_signing.py +++ b/tests/platforms/macOS/app/test_signing.py @@ -517,7 +517,7 @@ def test_sign_app(dummy_command, first_app_with_binaries, tmp_path, debug, capsy / "app" / "First App.app" ) - lib_path = app_path / "Contents" / "Resources" + lib_path = app_path / "Contents" / "Resources" / "app_packages" frameworks_path = app_path / "Contents" / "Frameworks" dummy_command.tools.subprocess.run.assert_has_calls( [