From 2cff5b2f8262d7572365dcee2f6381f28e5759ec Mon Sep 17 00:00:00 2001 From: Russell Martin Date: Sun, 9 Jun 2024 14:06:26 -0400 Subject: [PATCH 1/3] Tweaks to out-of-template stub binary implementation --- changes/1871.misc.rst | 1 + src/briefcase/commands/create.py | 87 ++++---- src/briefcase/exceptions.py | 8 +- src/briefcase/integrations/android_sdk.py | 3 +- src/briefcase/integrations/base.py | 14 ++ .../create/test_install_stub_binary.py | 192 ++++++++++++++---- tests/utils.py | 2 +- 7 files changed, 230 insertions(+), 77 deletions(-) create mode 100644 changes/1871.misc.rst diff --git a/changes/1871.misc.rst b/changes/1871.misc.rst new file mode 100644 index 000000000..00ce78066 --- /dev/null +++ b/changes/1871.misc.rst @@ -0,0 +1 @@ +Updated error handling and archive unpacking for the stub binary. diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py index 936759945..3447abf6d 100644 --- a/src/briefcase/commands/create.py +++ b/src/briefcase/commands/create.py @@ -13,6 +13,7 @@ from briefcase.config import AppConfig from briefcase.exceptions import ( BriefcaseCommandError, + InvalidStubBinary, InvalidSupportPackage, MissingAppSources, MissingNetworkResourceError, @@ -86,31 +87,35 @@ class CreateCommand(BaseCommand): hidden_app_properties = {"permission"} @property - def app_template_url(self): + def app_template_url(self) -> str: """The URL for a cookiecutter repository to use when creating apps.""" return f"https://github.com/beeware/briefcase-{self.platform}-{self.output_format}-template.git" - def support_package_filename(self, support_revision): + def support_package_filename(self, support_revision: str) -> str: """The query arguments to use in a support package query request.""" return f"Python-{self.python_version_tag}-{self.platform}-support.b{support_revision}.tar.gz" - def support_package_url(self, support_revision): + def support_package_url(self, support_revision: str) -> str: """The URL of the support package to use for apps of this type.""" return ( - f"https://briefcase-support.s3.amazonaws.com/python/{self.python_version_tag}/{self.platform}/" - + self.support_package_filename(support_revision) + "https://briefcase-support.s3.amazonaws.com/python/" + f"{self.python_version_tag}/" + f"{self.platform}/" + f"{self.support_package_filename(support_revision)}" ) - def stub_binary_filename(self, support_revision, is_console_app): + def stub_binary_filename(self, support_revision: str, is_console_app: bool) -> str: """The filename for the stub binary.""" stub_type = "Console" if is_console_app else "GUI" return f"{stub_type}-Stub-{self.python_version_tag}-b{support_revision}.zip" - def stub_binary_url(self, support_revision, is_console_app): + def stub_binary_url(self, support_revision: str, is_console_app: bool) -> str: """The URL of the stub binary to use for apps of this type.""" return ( - f"https://briefcase-support.s3.amazonaws.com/python/{self.python_version_tag}/{self.platform}/" - + self.stub_binary_filename(support_revision, is_console_app) + "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)}" ) def icon_targets(self, app: AppConfig): @@ -260,22 +265,13 @@ def _unpack_support_package(self, support_file_path, support_path): :param support_file_path: The path to the support file to be unpacked. :param support_path: The path where support files should be unpacked. """ - # Additional protections for unpacking tar files were introduced in Python 3.12. - # This enables the behavior that will be the default in Python 3.14. - # However, the protections can only be enabled for tar files...not zip files. - is_zip = support_file_path.name.endswith("zip") - if sys.version_info >= (3, 12) and not is_zip: # pragma: no-cover-if-lt-py312 - tarfile_kwargs = {"filter": "data"} - else: - tarfile_kwargs = {} - try: with self.input.wait_bar("Unpacking support package..."): support_path.mkdir(parents=True, exist_ok=True) self.tools.shutil.unpack_archive( support_file_path, extract_dir=support_path, - **tarfile_kwargs, + **self.tools.unpack_archive_kwargs(support_file_path), ) except (shutil.ReadError, EOFError) as e: raise InvalidSupportPackage(support_file_path) from e @@ -401,13 +397,8 @@ def cleanup_stub_binary(self, app: AppConfig): :param app: The config object for the app """ with self.input.wait_bar("Removing existing stub binary..."): - binary_executable_path = self.binary_executable_path(app) - if binary_executable_path.exists(): - binary_executable_path.unlink() - - unbuilt_executable_path = self.unbuilt_executable_path(app) - if unbuilt_executable_path.exists(): - unbuilt_executable_path.unlink() + self.binary_executable_path(app).unlink(missing_ok=True) + self.unbuilt_executable_path(app).unlink(missing_ok=True) def install_stub_binary(self, app: AppConfig): """Install the application stub binary into the "unbuilt" location. @@ -420,19 +411,41 @@ def install_stub_binary(self, app: AppConfig): with self.input.wait_bar("Installing stub binary..."): # Ensure the folder for the stub binary exists unbuilt_executable_path.parent.mkdir(exist_ok=True, parents=True) - # Install the stub binary into the unbuilt location. Allow for both raw - # and compressed artefacts. - if stub_binary_path.suffix in {".zip", ".tar.gz", ".tgz"}: - self.tools.shutil.unpack_archive( - stub_binary_path, - extract_dir=unbuilt_executable_path.parent, - ) + + # Determine if stub binary is a packed archive + supported_archive_extensions = { + ext for format in shutil.get_unpack_formats() for ext in format[1] + } + stub_path_exts = { + # captures extensions like .tar.gz, .tar.bz2, etc. + "".join(stub_binary_path.suffixes[-2:]), + # as well as .tar, .zip, etc. + stub_binary_path.suffix, + } + is_archive = not stub_path_exts.isdisjoint(supported_archive_extensions) + + # Install the stub binary into the unbuilt location. + # Allow for both raw and compressed artefacts. + try: + if is_archive: + self.tools.shutil.unpack_archive( + stub_binary_path, + extract_dir=unbuilt_executable_path.parent, + **self.tools.unpack_archive_kwargs(stub_binary_path), + ) + elif stub_binary_path.is_file(): + self.tools.shutil.copyfile( + stub_binary_path, unbuilt_executable_path + ) + else: + raise InvalidStubBinary(stub_binary_path) + except (shutil.ReadError, EOFError, OSError) as e: + raise InvalidStubBinary(stub_binary_path) from e else: - self.tools.shutil.copyfile(stub_binary_path, unbuilt_executable_path) - # Ensure the binary is executable - self.tools.os.chmod(unbuilt_executable_path, 0o755) + # Ensure the binary is executable + self.tools.os.chmod(unbuilt_executable_path, 0o755) - def _download_stub_binary(self, app: AppConfig): + def _download_stub_binary(self, app: AppConfig) -> Path: try: # Work out if the app defines a custom override for # the support package URL. diff --git a/src/briefcase/exceptions.py b/src/briefcase/exceptions.py index a89c16520..352142bd5 100644 --- a/src/briefcase/exceptions.py +++ b/src/briefcase/exceptions.py @@ -156,7 +156,13 @@ def __init__(self, platform): class InvalidSupportPackage(BriefcaseCommandError): def __init__(self, filename): self.filename = filename - super().__init__(f"Unable to unpack support package {filename!r}") + super().__init__(f"Unable to unpack support package '{filename}'.") + + +class InvalidStubBinary(BriefcaseCommandError): + def __init__(self, filename): + self.filename = filename + super().__init__(f"Unable to unpack or copy stub binary '{filename}'.") class MissingAppMetadata(BriefcaseCommandError): diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py index 9811cc442..68093d66c 100644 --- a/src/briefcase/integrations/android_sdk.py +++ b/src/briefcase/integrations/android_sdk.py @@ -6,7 +6,6 @@ import shlex import shutil import subprocess -import sys import time from datetime import datetime from pathlib import Path @@ -812,7 +811,7 @@ def verify_emulator_skin(self, skin: str): self.tools.shutil.unpack_archive( skin_tgz_path, extract_dir=skin_path, - **({"filter": "data"} if sys.version_info >= (3, 12) else {}), + **self.tools.unpack_archive_kwargs(skin_tgz_path), ) except (shutil.ReadError, EOFError) as e: raise BriefcaseCommandError( diff --git a/src/briefcase/integrations/base.py b/src/briefcase/integrations/base.py index a359b2301..cf593ca0d 100644 --- a/src/briefcase/integrations/base.py +++ b/src/briefcase/integrations/base.py @@ -234,6 +234,20 @@ def system_encoding(self) -> str: return encoding.upper() + def unpack_archive_kwargs(self, archive_path: str | os.PathLike) -> dict[str, str]: + """Additional options for unpacking archives based on its type. + + Additional protections for unpacking tar files were introduced in Python 3.12. + This enables the behavior that will be the default in Python 3.14. + However, the protections can only be enabled for tar files...not zip files. + """ + is_zip = Path(archive_path).suffix == ".zip" + if sys.version_info >= (3, 12) and not is_zip: # pragma: no-cover-if-lt-py312 + unpack_kwargs = {"filter": "data"} + else: + unpack_kwargs = {} + return unpack_kwargs + def __getitem__(self, app: AppConfig) -> ToolCache: return self.app_tools[app] diff --git a/tests/commands/create/test_install_stub_binary.py b/tests/commands/create/test_install_stub_binary.py index 336162912..95be35e80 100644 --- a/tests/commands/create/test_install_stub_binary.py +++ b/tests/commands/create/test_install_stub_binary.py @@ -1,17 +1,19 @@ import os import shutil +import sys from unittest import mock import pytest from requests import exceptions as requests_exceptions from briefcase.exceptions import ( + InvalidStubBinary, MissingNetworkResourceError, MissingStubBinary, NetworkFailure, ) -from ...utils import create_file, create_zip_file, mock_zip_download +from ...utils import create_file, create_tgz_file, create_zip_file, mock_zip_download @pytest.mark.parametrize("console_app", [True, False]) @@ -27,7 +29,7 @@ def test_install_stub_binary( myapp.console_app = console_app stub_name = "Console-Stub" if console_app else "GUI-Stub" - # Mock download.file to return a support package + # Mock download.file to return a stub binary create_command.tools.download.file = mock.MagicMock( side_effect=mock_zip_download( f"{stub_name}-3.X-b37.zip", @@ -40,7 +42,7 @@ def test_install_stub_binary( create_command.tools.shutil = mock.MagicMock(spec_set=shutil) create_command.tools.shutil.unpack_archive.side_effect = shutil.unpack_archive - # Install the support package + # Install the stub binary create_command.install_stub_binary(myapp) # Confirm the right URL was used @@ -56,10 +58,54 @@ def test_install_stub_binary( extract_dir=tmp_path / "base_path/build/my-app/tester/dummy", ) - # Confirm that the full path to the support file has been unpacked. + # Confirm that the full path to the stub file has been unpacked. assert (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists() +@pytest.mark.parametrize("console_app", [True, False]) +def test_install_stub_binary_unpack_failure( + create_command, + myapp, + console_app, + stub_binary_revision_path_index, + tmp_path, +): + """Errors during unpacking the archive raise InvalidStubBinary.""" + # Mock the app type + myapp.console_app = console_app + stub_name = "Console-Stub" if console_app else "GUI-Stub" + + # Mock download.file to return a stub binary + create_command.tools.download.file = mock.MagicMock( + side_effect=mock_zip_download( + f"{stub_name}-3.X-b37.zip", + [("Stub.bin", "stub binary")], + ) + ) + + # Mock shutil so we can confirm that unpack is called, + # but we still want the side effect of calling it + create_command.tools.shutil = mock.MagicMock(spec_set=shutil) + create_command.tools.shutil.unpack_archive.side_effect = shutil.ReadError + + # Install the stub binary + with pytest.raises(InvalidStubBinary, match="Unable to unpack or copy stub binary"): + create_command.install_stub_binary(myapp) + + # Confirm the right URL was used + create_command.tools.download.file.assert_called_with( + download_path=create_command.data_path / "stub", + url=f"https://briefcase-support.s3.amazonaws.com/python/3.X/Tester/{stub_name}-3.X-b37.zip", + role="stub binary", + ) + + # Confirm the right file was unpacked + create_command.tools.shutil.unpack_archive.assert_called_with( + tmp_path / f"data/stub/{stub_name}-3.X-b37.zip", + extract_dir=tmp_path / "base_path/build/my-app/tester/dummy", + ) + + @pytest.mark.parametrize("console_app", [True, False]) def test_install_pinned_stub_binary( create_command, @@ -76,7 +122,7 @@ def test_install_pinned_stub_binary( myapp.console_app = console_app stub_name = "Console-Stub" if console_app else "GUI-Stub" - # Mock download.file to return a support package + # Mock download.file to return a stub binary create_command.tools.download.file = mock.MagicMock( side_effect=mock_zip_download( f"{stub_name}-3.X-b42.zip", @@ -89,7 +135,7 @@ def test_install_pinned_stub_binary( create_command.tools.shutil = mock.MagicMock(spec_set=shutil) create_command.tools.shutil.unpack_archive.side_effect = shutil.unpack_archive - # Install the support package + # Install the stub binary create_command.install_stub_binary(myapp) # Confirm the right URL was used @@ -105,7 +151,7 @@ def test_install_pinned_stub_binary( extract_dir=tmp_path / "base_path/build/my-app/tester/dummy", ) - # Confirm that the full path to the support file has been unpacked. + # Confirm that the full path to the stub file has been unpacked. assert (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists() @@ -123,7 +169,7 @@ def test_install_stub_binary_missing( ) ) - # Install the support package; this will raise a custom exception + # Install the stub binary; this will raise a custom exception with pytest.raises( MissingStubBinary, match=r"Unable to download Tester stub binary for Python 3.X on gothic", @@ -141,7 +187,7 @@ def test_install_custom_stub_binary_url( # Provide an app-specific override of the stub binary as a URL myapp.stub_binary = "https://example.com/custom/My-Stub.zip" - # Mock download.file to return a support package + # Mock download.file to return a stub binary create_command.tools.download.file = mock.MagicMock( side_effect=mock_zip_download( "My-Stub.zip", @@ -154,7 +200,7 @@ def test_install_custom_stub_binary_url( create_command.tools.shutil = mock.MagicMock(spec_set=shutil) create_command.tools.shutil.unpack_archive.side_effect = shutil.unpack_archive - # Install the support package + # Install the stub binary create_command.install_stub_binary(myapp) # Confirm the right URL was used @@ -172,7 +218,7 @@ def test_install_custom_stub_binary_url( extract_dir=tmp_path / "base_path/build/my-app/tester/dummy", ) - # Confirm that the full path to the support file has been unpacked. + # Confirm that the full path to the stub file has been unpacked. assert (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists() @@ -182,11 +228,11 @@ def test_install_custom_stub_binary_file( tmp_path, stub_binary_revision_path_index, ): - """A custom support package can be specified as a local file.""" + """A custom stub binary can be specified as a local file.""" # Provide an app-specific override of the stub binary myapp.stub_binary = os.fsdecode(tmp_path / "custom/My-Stub") - # Write a temporary support binary + # Write a temporary stub binary create_file(tmp_path / "custom/My-Stub", "Custom stub") # Modify download.file to return the temp zipfile @@ -197,7 +243,7 @@ def test_install_custom_stub_binary_file( create_command.tools.shutil = mock.MagicMock(spec_set=shutil) create_command.tools.shutil.copyfile.side_effect = shutil.copyfile - # Install the support package + # Install the stub binary create_command.install_stub_binary(myapp) # There should have been no download attempt, @@ -207,23 +253,66 @@ def test_install_custom_stub_binary_file( # The file isn't an archive, so it hasn't been unpacked. create_command.tools.shutil.unpack_archive.assert_not_called() - # Confirm that the full path to the support file has been unpacked. + # Confirm that the full path to the stub file has been unpacked. + assert (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists() + + +def test_install_custom_stub_binary_zip( + create_command, + myapp, + tmp_path, + stub_binary_revision_path_index, +): + """A custom stub binary can be specified as a local archive.""" + # Provide an app-specific override of the stub binary + myapp.stub_binary = os.fsdecode(tmp_path / "custom/stub.zip") + + # Write a temporary stub zip file + stub_file = create_zip_file( + tmp_path / "custom/stub.zip", + [("Stub.bin", "Custom stub")], + ) + + # Modify download.file to return the temp zipfile + create_command.tools.download.file = mock.MagicMock() + + # Mock shutil so we can confirm that unpack is called, + # but we still want the side effect of calling it + create_command.tools.shutil = mock.MagicMock(spec_set=shutil) + create_command.tools.shutil.unpack_archive.side_effect = shutil.unpack_archive + + # Install the stub binary + create_command.install_stub_binary(myapp) + + # There should have been no download attempt, + # as the resource is local. + create_command.tools.download.file.assert_not_called() + + # Confirm the right file was unpacked + create_command.tools.shutil.unpack_archive.assert_called_with( + stub_file, + extract_dir=tmp_path / "base_path/build/my-app/tester/dummy", + ) + + # Confirm that the full path to the stub file has been unpacked. assert (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists() -def test_install_custom_stub_binary_archive( +@pytest.mark.parametrize("stub_filename", ("stub.tar", "stub.tar.gz")) +def test_install_custom_stub_binary_tar( create_command, myapp, + stub_filename, tmp_path, stub_binary_revision_path_index, ): - """A custom support package can be specified as a local archive.""" + """A custom stub binary can be specified as a local archive.""" # Provide an app-specific override of the stub binary - myapp.stub_binary = os.fsdecode(tmp_path / "custom/support.zip") + myapp.stub_binary = os.fsdecode(tmp_path / f"custom/{stub_filename}") - # Write a temporary support zip file - support_file = create_zip_file( - tmp_path / "custom/support.zip", + # Write a temporary stub zip file + stub_file = create_tgz_file( + tmp_path / f"custom/{stub_filename}", [("Stub.bin", "Custom stub")], ) @@ -235,7 +324,7 @@ def test_install_custom_stub_binary_archive( create_command.tools.shutil = mock.MagicMock(spec_set=shutil) create_command.tools.shutil.unpack_archive.side_effect = shutil.unpack_archive - # Install the support package + # Install the stub binary create_command.install_stub_binary(myapp) # There should have been no download attempt, @@ -244,11 +333,12 @@ def test_install_custom_stub_binary_archive( # Confirm the right file was unpacked create_command.tools.shutil.unpack_archive.assert_called_with( - support_file, + stub_file, extract_dir=tmp_path / "base_path/build/my-app/tester/dummy", + **({"filter": "data"} if sys.version_info >= (3, 12) else {}), ) - # Confirm that the full path to the support file has been unpacked. + # Confirm that the full path to the stub file has been unpacked. assert (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists() @@ -262,12 +352,12 @@ def test_install_custom_stub_binary_with_revision( """If a custom stub binary file also specifies a revision, the revision is ignored with a warning.""" # Provide an app-specific override of the stub binary, *and* the revision - myapp.stub_binary = os.fsdecode(tmp_path / "custom/support.zip") + myapp.stub_binary = os.fsdecode(tmp_path / "custom/stub.zip") myapp.stub_binary_revision = "42" - # Write a temporary support zip file - support_file = create_zip_file( - tmp_path / "custom/support.zip", + # Write a temporary stub zip file + stub_file = create_zip_file( + tmp_path / "custom/stub.zip", [("Stub.bin", "Custom stub")], ) @@ -279,7 +369,7 @@ def test_install_custom_stub_binary_with_revision( create_command.tools.shutil = mock.MagicMock(spec_set=shutil) create_command.tools.shutil.unpack_archive.side_effect = shutil.unpack_archive - # Install the support package + # Install the stub binary create_command.install_stub_binary(myapp) # There should have been no download attempt, @@ -288,14 +378,14 @@ def test_install_custom_stub_binary_with_revision( # Confirm the right file was unpacked create_command.tools.shutil.unpack_archive.assert_called_with( - support_file, + stub_file, extract_dir=tmp_path / "base_path/build/my-app/tester/dummy", ) - # Confirm that the full path to the support file has been unpacked. + # Confirm that the full path to the stub file has been unpacked. assert (tmp_path / "base_path/build/my-app/tester/dummy/Stub.bin").exists() - # A warning about the support revision was generated. + # A warning about the stub revision was generated. assert "stub binary revision will be ignored." in capsys.readouterr().out @@ -304,10 +394,10 @@ def test_install_custom_stub_binary_with_invalid_url( myapp, stub_binary_revision_path_index, ): - """Invalid URL for a custom support package raises MissingNetworkResourceError.""" + """Invalid URL for a custom stub binary raises MissingNetworkResourceError.""" - # Provide an custom stub binary URL - url = "https://example.com/custom/support.zip" + # Provide a custom stub binary URL + url = "https://example.com/custom/stub.zip" myapp.stub_binary = url # Modify download.file to raise an exception @@ -324,7 +414,7 @@ def test_install_custom_stub_binary_with_invalid_url( download_path=( create_command.data_path / "stub" - / "55441abbffa311f65622df45a943afc347a21ab40e8dcec79472c92ef467db24" + / "8d0f202db2a2c66b1568ead0ca63f66957bd2be7a12145f5b9fa2197a5e049f7" ), url=url, role="stub binary", @@ -344,3 +434,33 @@ def test_offline_install( # Installing while offline raises an error with pytest.raises(NetworkFailure): create_command.install_stub_binary(myapp) + + +def test_install_custom_stub_binary_with_invalid_filepath( + create_command, + myapp, + stub_binary_revision_path_index, + tmp_path, +): + """Invalid file path for custom stub library raises InvalidStubBinary.""" + # Provide an app-specific override of the stub binary + myapp.stub_binary = os.fsdecode(tmp_path / "custom/My-Stub") + + # Modify download.file to return the temp zipfile + create_command.tools.download.file = mock.MagicMock() + + # Mock shutil so we can confirm that unpack isn't called, + # but we still want the side effect of calling + create_command.tools.shutil = mock.MagicMock(spec_set=shutil) + create_command.tools.shutil.copyfile.side_effect = shutil.copyfile + + # Fail to install the stub binary + with pytest.raises(InvalidStubBinary, match="Unable to unpack or copy stub binary"): + create_command.install_stub_binary(myapp) + + # There should have been no download attempt, + # as the resource is local. + create_command.tools.download.file.assert_not_called() + + # The file isn't an archive, so it hasn't been unpacked. + create_command.tools.shutil.unpack_archive.assert_not_called() diff --git a/tests/utils.py b/tests/utils.py index 43896173b..f8c3b6da1 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -115,7 +115,7 @@ def create_tgz_file(tgzpath, content): Ensures that the directory for the file exists, and writes a file with specific content. - :param tgzpath: The path for the ZIP file to create + :param tgzpath: The path for the gzipped tarfile file to create :param content: A list of pairs; each pair is (path, data) describing an item to be added to the zip file. :returns: The path to the file that was created. From eb1d5abed18f5b06b2fc2f40ccd1d0f317cc96da Mon Sep 17 00:00:00 2001 From: Russell Martin Date: Sun, 9 Jun 2024 19:54:12 -0400 Subject: [PATCH 2/3] Introduce the File tool - Encapsulates complex file operations such as archive unpacking - Incorporates the (now removed) Download tool's functionality --- changes/1871.misc.rst | 2 +- src/briefcase/commands/base.py | 4 +- src/briefcase/commands/create.py | 24 +--- src/briefcase/integrations/__init__.py | 4 +- src/briefcase/integrations/android_sdk.py | 9 +- src/briefcase/integrations/base.py | 18 +-- .../integrations/{download.py => file.py} | 88 ++++++++++-- src/briefcase/integrations/java.py | 4 +- src/briefcase/integrations/linuxdeploy.py | 2 +- src/briefcase/integrations/rcedit.py | 2 +- src/briefcase/integrations/wix.py | 4 +- tests/commands/base/test_verify_tools.py | 4 +- .../test_cleanup_app_support_package.py | 4 +- .../test_install_app_support_package.py | 62 ++++----- .../create/test_install_stub_binary.py | 103 ++++++-------- .../android_sdk/AndroidSDK/test_upgrade.py | 2 +- .../android_sdk/AndroidSDK/test_verify.py | 66 +++++---- .../AndroidSDK/test_verify_emulator.py | 4 +- .../AndroidSDK/test_verify_emulator_skin.py | 45 ++++--- tests/integrations/android_sdk/conftest.py | 4 +- tests/integrations/conftest.py | 6 +- .../{download => file}/__init__.py | 0 .../integrations/file/test_File__archives.py | 126 ++++++++++++++++++ .../test_File__download.py} | 16 +-- .../test_File__verify.py} | 20 +-- tests/integrations/flatpak/conftest.py | 4 +- tests/integrations/java/conftest.py | 4 +- tests/integrations/java/test_JDK__upgrade.py | 43 +++--- tests/integrations/java/test_JDK__verify.py | 40 +++--- tests/integrations/linuxdeploy/conftest.py | 4 +- .../test_LinuxDeployBase__upgrade.py | 10 +- .../test_LinuxDeployBase__verify.py | 16 +-- .../test_LinuxDeployURLPlugin__verify.py | 8 +- .../test_LinuxDeploy__verify_plugins.py | 18 +-- tests/integrations/rcedit/conftest.py | 4 +- .../rcedit/test_RCEdit__upgrade.py | 10 +- .../rcedit/test_RCEdit__verify.py | 14 +- tests/integrations/windows_sdk/conftest.py | 4 +- tests/integrations/wix/conftest.py | 4 +- tests/integrations/wix/test_WiX__upgrade.py | 33 +++-- tests/integrations/wix/test_WiX__verify.py | 34 +++-- tests/integrations/xcode/conftest.py | 4 +- tests/platforms/android/gradle/test_open.py | 4 +- tests/platforms/linux/appimage/test_build.py | 10 +- tests/platforms/macOS/app/test_create.py | 2 +- tests/platforms/windows/app/test_build.py | 2 +- tests/utils.py | 6 +- 47 files changed, 526 insertions(+), 375 deletions(-) rename src/briefcase/integrations/{download.py => file.py} (68%) rename tests/integrations/{download => file}/__init__.py (100%) create mode 100644 tests/integrations/file/test_File__archives.py rename tests/integrations/{download/test_Download__file.py => file/test_File__download.py} (97%) rename tests/integrations/{download/test_Download__verify.py => file/test_File__verify.py} (54%) diff --git a/changes/1871.misc.rst b/changes/1871.misc.rst index 00ce78066..2efb64ba9 100644 --- a/changes/1871.misc.rst +++ b/changes/1871.misc.rst @@ -1 +1 @@ -Updated error handling and archive unpacking for the stub binary. +Consolidated file-oriented operations under a new File tool and updated error handling for stub binary. diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py index bae68f8d8..96c842484 100644 --- a/src/briefcase/commands/base.py +++ b/src/briefcase/commands/base.py @@ -37,7 +37,7 @@ UnsupportedHostError, ) from briefcase.integrations.base import ToolCache -from briefcase.integrations.download import Download +from briefcase.integrations.file import File from briefcase.integrations.subprocess import Subprocess from briefcase.platforms import get_output_formats, get_platforms @@ -173,7 +173,7 @@ def __init__( # Immediately add tools that must be always available Subprocess.verify(tools=self.tools) - Download.verify(tools=self.tools) + File.verify(tools=self.tools) if not is_clone: self.validate_locale() diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py index 3447abf6d..b2a17e262 100644 --- a/src/briefcase/commands/create.py +++ b/src/briefcase/commands/create.py @@ -268,10 +268,9 @@ def _unpack_support_package(self, support_file_path, support_path): try: with self.input.wait_bar("Unpacking support package..."): support_path.mkdir(parents=True, exist_ok=True) - self.tools.shutil.unpack_archive( + self.tools.file.unpack_archive( support_file_path, extract_dir=support_path, - **self.tools.unpack_archive_kwargs(support_file_path), ) except (shutil.ReadError, EOFError) as e: raise InvalidSupportPackage(support_file_path) from e @@ -372,7 +371,7 @@ def _download_support_package(self, app: AppConfig): # Download the support file, caching the result # in the user's briefcase support cache directory. - return self.tools.download.file( + return self.tools.file.download( url=support_package_url, download_path=download_path, role="support package", @@ -412,26 +411,13 @@ def install_stub_binary(self, app: AppConfig): # Ensure the folder for the stub binary exists unbuilt_executable_path.parent.mkdir(exist_ok=True, parents=True) - # Determine if stub binary is a packed archive - supported_archive_extensions = { - ext for format in shutil.get_unpack_formats() for ext in format[1] - } - stub_path_exts = { - # captures extensions like .tar.gz, .tar.bz2, etc. - "".join(stub_binary_path.suffixes[-2:]), - # as well as .tar, .zip, etc. - stub_binary_path.suffix, - } - is_archive = not stub_path_exts.isdisjoint(supported_archive_extensions) - # Install the stub binary into the unbuilt location. # Allow for both raw and compressed artefacts. try: - if is_archive: - self.tools.shutil.unpack_archive( + if self.tools.file.is_archive(stub_binary_path): + self.tools.file.unpack_archive( stub_binary_path, extract_dir=unbuilt_executable_path.parent, - **self.tools.unpack_archive_kwargs(stub_binary_path), ) elif stub_binary_path.is_file(): self.tools.shutil.copyfile( @@ -494,7 +480,7 @@ def _download_stub_binary(self, app: AppConfig) -> Path: # Download the stub binary, caching the result # in the user's briefcase stub cache directory. - return self.tools.download.file( + return self.tools.file.download( url=stub_binary_url, download_path=download_path, role="stub binary", diff --git a/src/briefcase/integrations/__init__.py b/src/briefcase/integrations/__init__.py index 2a789b054..18251e1bb 100644 --- a/src/briefcase/integrations/__init__.py +++ b/src/briefcase/integrations/__init__.py @@ -2,7 +2,7 @@ android_sdk, cookiecutter, docker, - download, + file, flatpak, git, java, @@ -19,7 +19,7 @@ "android_sdk", "cookiecutter", "docker", - "download", + "file", "flatpak", "git", "java", diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py index 68093d66c..ac35e3ca5 100644 --- a/src/briefcase/integrations/android_sdk.py +++ b/src/briefcase/integrations/android_sdk.py @@ -386,7 +386,7 @@ def uninstall(self): def install(self): """Download and install the Android SDK.""" - cmdline_tools_zip_path = self.tools.download.file( + cmdline_tools_zip_path = self.tools.file.download( url=self.cmdline_tools_url, download_path=self.tools.base_path, role="Android SDK Command-Line Tools", @@ -407,7 +407,7 @@ def install(self): ): self.cmdline_tools_path.parent.mkdir(parents=True, exist_ok=True) try: - self.tools.shutil.unpack_archive( + self.tools.file.unpack_archive( cmdline_tools_zip_path, extract_dir=self.cmdline_tools_path.parent ) except (shutil.ReadError, EOFError) as e: @@ -799,7 +799,7 @@ def verify_emulator_skin(self, skin: str): f"artwork/resources/device-art-resources/{skin}.tar.gz" ) - skin_tgz_path = self.tools.download.file( + skin_tgz_path = self.tools.file.download( url=skin_url, download_path=self.root_path, role=f"{skin} device skin", @@ -808,10 +808,9 @@ def verify_emulator_skin(self, skin: str): # Unpack skin archive with self.tools.input.wait_bar("Installing device skin..."): try: - self.tools.shutil.unpack_archive( + self.tools.file.unpack_archive( skin_tgz_path, extract_dir=skin_path, - **self.tools.unpack_archive_kwargs(skin_tgz_path), ) except (shutil.ReadError, EOFError) as e: raise BriefcaseCommandError( diff --git a/src/briefcase/integrations/base.py b/src/briefcase/integrations/base.py index cf593ca0d..1fea902a6 100644 --- a/src/briefcase/integrations/base.py +++ b/src/briefcase/integrations/base.py @@ -30,7 +30,7 @@ from briefcase.integrations.android_sdk import AndroidSDK from briefcase.integrations.docker import Docker, DockerAppContext - from briefcase.integrations.download import Download + from briefcase.integrations.file import File from briefcase.integrations.flatpak import Flatpak from briefcase.integrations.java import JDK from briefcase.integrations.linuxdeploy import LinuxDeploy @@ -148,7 +148,7 @@ class ToolCache(Mapping): android_sdk: AndroidSDK app_context: Subprocess | DockerAppContext docker: Docker - download: Download + file: File flatpak: Flatpak git: git_ java: JDK @@ -234,20 +234,6 @@ def system_encoding(self) -> str: return encoding.upper() - def unpack_archive_kwargs(self, archive_path: str | os.PathLike) -> dict[str, str]: - """Additional options for unpacking archives based on its type. - - Additional protections for unpacking tar files were introduced in Python 3.12. - This enables the behavior that will be the default in Python 3.14. - However, the protections can only be enabled for tar files...not zip files. - """ - is_zip = Path(archive_path).suffix == ".zip" - if sys.version_info >= (3, 12) and not is_zip: # pragma: no-cover-if-lt-py312 - unpack_kwargs = {"filter": "data"} - else: - unpack_kwargs = {} - return unpack_kwargs - def __getitem__(self, app: AppConfig) -> ToolCache: return self.app_tools[app] diff --git a/src/briefcase/integrations/download.py b/src/briefcase/integrations/file.py similarity index 68% rename from src/briefcase/integrations/download.py rename to src/briefcase/integrations/file.py index 5e4311f69..7d2adbde7 100644 --- a/src/briefcase/integrations/download.py +++ b/src/briefcase/integrations/file.py @@ -1,6 +1,8 @@ from __future__ import annotations import os +import shutil +import sys import tempfile from contextlib import suppress from email.message import Message @@ -18,21 +20,87 @@ from briefcase.integrations.base import Tool, ToolCache -class Download(Tool): - name = "download" - full_name = "Download" +class File(Tool): + name = "file" + full_name = "File" @classmethod - def verify_install(cls, tools: ToolCache, **kwargs) -> Download: - """Make downloader available in tool cache.""" + def verify_install(cls, tools: ToolCache, **kwargs) -> File: + """Make File available in tool cache.""" # short circuit since already verified and available - if hasattr(tools, "download"): - return tools.download + if hasattr(tools, "file"): + return tools.file - tools.download = Download(tools=tools) - return tools.download + tools.file = File(tools=tools) + return tools.file - def file(self, url: str, download_path: Path, role: str | None = None) -> Path: + def is_archive(self, filename: str | os.PathLike) -> bool: + """Can a file be unpacked via `shutil.unpack_archive()`? + + The evaluation is based purely on the name of the file. A more robust + implementation may actually interrogate the file itself. + + Notably, the types of archives that can be unpacked will vary based on the types + of compression the current Python supports. So, for example, if LZMA is not + available, then tar.xz archives cannot be unpacked. + + :param filename: path to file to evaluate as an archive + """ + filename = Path(filename) + file_extensions = { + # captures extensions like .tar.gz, .tar.bz2, etc. + "".join(filename.suffixes[-2:]), + # as well as .tar, .zip, etc. + filename.suffix, + } + return not file_extensions.isdisjoint(self.supported_archive_extensions) + + @property + def supported_archive_extensions(self) -> set[str]: + return { + extension + for archive_format in shutil.get_unpack_formats() + for extension in archive_format[1] + } + + def unpack_archive( + self, + filename: str | os.PathLike, + extract_dir: str | os.PathLike, + **kwargs, + ): + """Unpack an archive file in to a destination directory. + + :param filename: File path for the archive + :param extract_dir: Target file path for where to unpack archive + :param kwargs: additional arguments for shutil.unpack_archive + """ + self.tools.shutil.unpack_archive( + filename=filename, + extract_dir=extract_dir, + **{ + **self._unpack_archive_kwargs(filename), + **kwargs, + }, + ) + + def _unpack_archive_kwargs(self, archive_path: str | os.PathLike) -> dict[str, str]: + """Additional options for unpacking archives based on the archive's type. + + Additional protections for unpacking tar files were introduced in Python 3.12. + Since tarballs can contain anything valid in a UNIX file system, these + protections prevent unpacking potentially dangerous files. This behavior will be + the default in Python 3.14. However, the protections can only be enabled for tar + files...not zip files. + """ + is_zip = Path(archive_path).suffix == ".zip" + if sys.version_info >= (3, 12) and not is_zip: # pragma: no-cover-if-lt-py312 + unpack_kwargs = {"filter": "data"} + else: + unpack_kwargs = {} + return unpack_kwargs + + def download(self, url: str, download_path: Path, role: str | None = None) -> Path: """Download a given URL, caching it. If it has already been downloaded, return the value that has been cached. diff --git a/src/briefcase/integrations/java.py b/src/briefcase/integrations/java.py index 5a3874e6e..858a1670b 100644 --- a/src/briefcase/integrations/java.py +++ b/src/briefcase/integrations/java.py @@ -275,7 +275,7 @@ def managed_install(self) -> bool: def install(self): """Download and install a JDK.""" - jdk_zip_path = self.tools.download.file( + jdk_zip_path = self.tools.file.download( url=self.OpenJDK_download_url, download_path=self.tools.base_path, role=f"Java {self.JDK_MAJOR_VER} JDK", @@ -283,7 +283,7 @@ def install(self): with self.tools.input.wait_bar("Installing OpenJDK..."): try: - self.tools.shutil.unpack_archive( + self.tools.file.unpack_archive( os.fsdecode(jdk_zip_path), extract_dir=os.fsdecode(self.tools.base_path), ) diff --git a/src/briefcase/integrations/linuxdeploy.py b/src/briefcase/integrations/linuxdeploy.py index 1eb780ac7..42bf5e34b 100644 --- a/src/briefcase/integrations/linuxdeploy.py +++ b/src/briefcase/integrations/linuxdeploy.py @@ -83,7 +83,7 @@ def exists(self) -> bool: def install(self): """Download and install linuxdeploy or plugin.""" - self.tools.download.file( + self.tools.file.download( url=self.download_url, download_path=self.file_path, role=self.full_name, diff --git a/src/briefcase/integrations/rcedit.py b/src/briefcase/integrations/rcedit.py index 59b3f2d8c..5714147dd 100644 --- a/src/briefcase/integrations/rcedit.py +++ b/src/briefcase/integrations/rcedit.py @@ -54,7 +54,7 @@ def exists(self) -> bool: def install(self): """Download and install RCEdit.""" - self.tools.download.file( + self.tools.file.download( url=self.download_url, download_path=self.tools.base_path, role="RCEdit", diff --git a/src/briefcase/integrations/wix.py b/src/briefcase/integrations/wix.py index 3832632b8..87556ca44 100644 --- a/src/briefcase/integrations/wix.py +++ b/src/briefcase/integrations/wix.py @@ -134,7 +134,7 @@ def managed_install(self) -> bool: def install(self): """Download and install WiX.""" - wix_zip_path = self.tools.download.file( + wix_zip_path = self.tools.file.download( url=self.download_url, download_path=self.tools.base_path, role="WiX", @@ -142,7 +142,7 @@ def install(self): try: with self.tools.input.wait_bar("Installing WiX..."): - self.tools.shutil.unpack_archive( + self.tools.file.unpack_archive( os.fsdecode(wix_zip_path), extract_dir=os.fsdecode(self.wix_home), ) diff --git a/tests/commands/base/test_verify_tools.py b/tests/commands/base/test_verify_tools.py index e78a66212..05afd0d60 100644 --- a/tests/commands/base/test_verify_tools.py +++ b/tests/commands/base/test_verify_tools.py @@ -1,10 +1,10 @@ -from briefcase.integrations.download import Download +from briefcase.integrations.file import File from briefcase.integrations.subprocess import Subprocess def test_base_tools_exist(base_command): """Ensure default tools are always available.""" assert isinstance(base_command.tools.subprocess, Subprocess) - assert isinstance(base_command.tools.download, Download) + assert isinstance(base_command.tools.file, File) base_command.verify_tools() diff --git a/tests/commands/create/test_cleanup_app_support_package.py b/tests/commands/create/test_cleanup_app_support_package.py index c525a988c..68759770d 100644 --- a/tests/commands/create/test_cleanup_app_support_package.py +++ b/tests/commands/create/test_cleanup_app_support_package.py @@ -6,9 +6,9 @@ def test_no_support_path(create_command, myapp, no_support_path_index): """If support_path is not listed in briefcase.toml, no cleanup will be performed.""" - create_command.tools.download.file = mock.MagicMock() + create_command.tools.file.download = mock.MagicMock() create_command.cleanup_app_support_package(myapp) - create_command.tools.download.file.assert_not_called() + create_command.tools.file.download.assert_not_called() def test_support_path_does_not_exist( diff --git a/tests/commands/create/test_install_app_support_package.py b/tests/commands/create/test_install_app_support_package.py index b19d3caeb..d938af7b4 100644 --- a/tests/commands/create/test_install_app_support_package.py +++ b/tests/commands/create/test_install_app_support_package.py @@ -30,7 +30,7 @@ def test_install_app_support_package( ): """A support package can be downloaded and unpacked where it is needed.""" # Mock download.file to return a support package - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=mock_tgz_download( "Python-3.X-tester-support.b37.tar.gz", [("internal/file.txt", "hello world")], @@ -46,7 +46,7 @@ def test_install_app_support_package( create_command.install_app_support_package(myapp) # Confirm the right URL was used - create_command.tools.download.file.assert_called_with( + create_command.tools.file.download.assert_called_with( download_path=create_command.data_path / "support", url="https://briefcase-support.s3.amazonaws.com/python/3.X/Tester/Python-3.X-Tester-support.b37.tar.gz", role="support package", @@ -54,7 +54,7 @@ def test_install_app_support_package( # Confirm the right file was unpacked create_command.tools.shutil.unpack_archive.assert_called_with( - tmp_path / "data/support/Python-3.X-tester-support.b37.tar.gz", + filename=tmp_path / "data/support/Python-3.X-tester-support.b37.tar.gz", extract_dir=support_path, **({"filter": "data"} if sys.version_info >= (3, 12) else {}), ) @@ -76,7 +76,7 @@ def test_install_pinned_app_support_package( myapp.support_revision = "42" # Mock download.file to return a support package - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=mock_tgz_download( "Python-3.X-Tester-support.b42.tar.gz", [("internal/file.txt", "hello world")], @@ -92,7 +92,7 @@ def test_install_pinned_app_support_package( create_command.install_app_support_package(myapp) # Confirm the right URL was used - create_command.tools.download.file.assert_called_with( + create_command.tools.file.download.assert_called_with( download_path=create_command.data_path / "support", url="https://briefcase-support.s3.amazonaws.com/python/3.X/Tester/Python-3.X-Tester-support.b42.tar.gz", role="support package", @@ -100,7 +100,7 @@ def test_install_pinned_app_support_package( # Confirm the right file was unpacked create_command.tools.shutil.unpack_archive.assert_called_with( - tmp_path / "data/support/Python-3.X-Tester-support.b42.tar.gz", + filename=tmp_path / "data/support/Python-3.X-Tester-support.b42.tar.gz", extract_dir=support_path, **({"filter": "data"} if sys.version_info >= (3, 12) else {}), ) @@ -128,7 +128,7 @@ def test_install_custom_app_support_package_file( ) # Modify download.file to return the temp zipfile - create_command.tools.download.file = mock.MagicMock() + create_command.tools.file.download = mock.MagicMock() # Mock shutil so we can confirm that unpack is called, # but we still want the side effect of calling it @@ -140,11 +140,11 @@ def test_install_custom_app_support_package_file( # There should have been no download attempt, # as the resource is local. - create_command.tools.download.file.assert_not_called() + create_command.tools.file.download.assert_not_called() # Confirm the right file was unpacked create_command.tools.shutil.unpack_archive.assert_called_with( - support_file, + filename=support_file, extract_dir=support_path, ) @@ -174,7 +174,7 @@ def test_install_custom_app_support_package_file_with_revision( ) # Modify download.file to return the temp zipfile - create_command.tools.download.file = mock.MagicMock() + create_command.tools.file.download = mock.MagicMock() # Mock shutil so we can confirm that unpack is called, # but we still want the side effect of calling it @@ -186,11 +186,11 @@ def test_install_custom_app_support_package_file_with_revision( # There should have been no download attempt, # as the resource is local. - create_command.tools.download.file.assert_not_called() + create_command.tools.file.download.assert_not_called() # Confirm the right file was unpacked create_command.tools.shutil.unpack_archive.assert_called_with( - support_file, + filename=support_file, extract_dir=support_path, ) @@ -202,7 +202,7 @@ def test_install_custom_app_support_package_file_with_revision( assert "support revision will be ignored." in capsys.readouterr().out -def test_support_package_url_with_invalid_custom_support_packge_url( +def test_support_package_url_with_invalid_custom_support_package_url( create_command, myapp, app_requirements_path_index, @@ -214,7 +214,7 @@ def test_support_package_url_with_invalid_custom_support_packge_url( myapp.support_package = url # Modify download.file to raise an exception - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=MissingNetworkResourceError(url) ) @@ -223,7 +223,7 @@ def test_support_package_url_with_invalid_custom_support_packge_url( create_command.install_app_support_package(myapp) # However, there will have been a download attempt - create_command.tools.download.file.assert_called_with( + create_command.tools.file.download.assert_called_with( download_path=( create_command.data_path / "support" @@ -241,7 +241,7 @@ def test_support_package_url_with_unsupported_platform( ): """An unsupported platform raises MissingSupportPackage.""" # Modify download.file to raise an exception due to missing support package - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=MissingNetworkResourceError( url="https://briefcase-support.s3.amazonaws.com/python/3.X/Tester/Python-3.X-Tester-support.b37.tar.gz", ) @@ -252,7 +252,7 @@ def test_support_package_url_with_unsupported_platform( create_command.install_app_support_package(myapp) # However, there will have been a download attempt - create_command.tools.download.file.assert_called_with( + create_command.tools.file.download.assert_called_with( download_path=create_command.data_path / "support", url="https://briefcase-support.s3.amazonaws.com/python/3.X/Tester/Python-3.X-Tester-support.b37.tar.gz", role="support package", @@ -271,7 +271,7 @@ def test_install_custom_app_support_package_url( myapp.support_package = "https://example.com/custom/custom-support.zip" # Mock download.file to return a support package - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=mock_zip_download( "custom-support.zip", [("internal/file.txt", "hello world")], @@ -287,7 +287,7 @@ def test_install_custom_app_support_package_url( create_command.install_app_support_package(myapp) # Confirm the right URL and download path was used - create_command.tools.download.file.assert_called_with( + create_command.tools.file.download.assert_called_with( download_path=( create_command.data_path / "support" @@ -299,7 +299,7 @@ def test_install_custom_app_support_package_url( # Confirm the right file was unpacked into the hashed location create_command.tools.shutil.unpack_archive.assert_called_with( - tmp_path + filename=tmp_path / "data" / "support" / "1d3ac0e09eb22abc63c4e7b699b6ab5d58e277015eeae61070e3f9f11512e6b3" @@ -327,7 +327,7 @@ def test_install_custom_app_support_package_url_with_revision( myapp.support_revision = "42" # Mock download.file to return a support package - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=mock_zip_download( "custom-support.zip", [("internal/file.txt", "hello world")], @@ -343,7 +343,7 @@ def test_install_custom_app_support_package_url_with_revision( create_command.install_app_support_package(myapp) # Confirm the right URL and download path was used - create_command.tools.download.file.assert_called_with( + create_command.tools.file.download.assert_called_with( download_path=( create_command.data_path / "support" @@ -355,7 +355,7 @@ def test_install_custom_app_support_package_url_with_revision( # Confirm the right file was unpacked into the hashed location create_command.tools.shutil.unpack_archive.assert_called_with( - tmp_path + filename=tmp_path / "data" / "support" / "1d3ac0e09eb22abc63c4e7b699b6ab5d58e277015eeae61070e3f9f11512e6b3" @@ -383,7 +383,7 @@ def test_install_custom_app_support_package_url_with_args( myapp.support_package = "https://example.com/custom/custom-support.zip?cool=Yes" # Mock download.file to return a support package - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=mock_zip_download( "custom-support.zip", [("internal/file.txt", "hello world")], @@ -398,7 +398,7 @@ def test_install_custom_app_support_package_url_with_args( create_command.install_app_support_package(myapp) # Confirm the right URL was used - create_command.tools.download.file.assert_called_with( + create_command.tools.file.download.assert_called_with( download_path=create_command.data_path / "support" / "f8cf64ad2ba249a1efbb63db60ebdc64f043035fbdd81934c6ad1e84a030c429", @@ -408,7 +408,7 @@ def test_install_custom_app_support_package_url_with_args( # Confirm the right file was unpacked create_command.tools.shutil.unpack_archive.assert_called_with( - tmp_path + filename=tmp_path / "data" / "support" / "f8cf64ad2ba249a1efbb63db60ebdc64f043035fbdd81934c6ad1e84a030c429" @@ -446,7 +446,7 @@ def test_invalid_support_package( ): """If the support package isn't a valid zipfile, an error is raised.""" # Mock download.file to return a non-zip file - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=mock_file_download( "not-a.zip", "This isn't a zip file", @@ -477,19 +477,19 @@ def test_missing_support_package( def test_no_support_path(create_command, myapp, no_support_path_index): """If support_path is not listed in briefcase.toml, a support package will not be downloaded.""" - create_command.tools.download.file = mock.MagicMock() + create_command.tools.file.download = mock.MagicMock() create_command.install_app_support_package(myapp) - create_command.tools.download.file.assert_not_called() + create_command.tools.file.download.assert_not_called() def test_no_support_revision(create_command, myapp, no_support_revision_index): """If support_revision is not listed in briefcase.toml, a support package will not be downloaded.""" - create_command.tools.download.file = mock.MagicMock() + create_command.tools.file.download = mock.MagicMock() # An error is raised when attempting to install the support package with pytest.raises(MissingSupportPackage): create_command.install_app_support_package(myapp) # No download attempt is made. - create_command.tools.download.file.assert_not_called() + create_command.tools.file.download.assert_not_called() diff --git a/tests/commands/create/test_install_stub_binary.py b/tests/commands/create/test_install_stub_binary.py index 95be35e80..914d9ba07 100644 --- a/tests/commands/create/test_install_stub_binary.py +++ b/tests/commands/create/test_install_stub_binary.py @@ -30,23 +30,21 @@ def test_install_stub_binary( stub_name = "Console-Stub" if console_app else "GUI-Stub" # Mock download.file to return a stub binary - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=mock_zip_download( f"{stub_name}-3.X-b37.zip", [("Stub.bin", "stub binary")], ) ) - # Mock shutil so we can confirm that unpack is called, - # but we still want the side effect of calling it - create_command.tools.shutil = mock.MagicMock(spec_set=shutil) - create_command.tools.shutil.unpack_archive.side_effect = shutil.unpack_archive + # Wrap shutil so we can confirm that unpack is called + create_command.tools.shutil = mock.MagicMock(wraps=shutil) # Install the stub binary create_command.install_stub_binary(myapp) # Confirm the right URL was used - create_command.tools.download.file.assert_called_with( + create_command.tools.file.download.assert_called_with( download_path=create_command.data_path / "stub", url=f"https://briefcase-support.s3.amazonaws.com/python/3.X/Tester/{stub_name}-3.X-b37.zip", role="stub binary", @@ -54,7 +52,7 @@ def test_install_stub_binary( # Confirm the right file was unpacked create_command.tools.shutil.unpack_archive.assert_called_with( - tmp_path / f"data/stub/{stub_name}-3.X-b37.zip", + filename=tmp_path / f"data/stub/{stub_name}-3.X-b37.zip", extract_dir=tmp_path / "base_path/build/my-app/tester/dummy", ) @@ -76,16 +74,15 @@ def test_install_stub_binary_unpack_failure( stub_name = "Console-Stub" if console_app else "GUI-Stub" # Mock download.file to return a stub binary - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=mock_zip_download( f"{stub_name}-3.X-b37.zip", [("Stub.bin", "stub binary")], ) ) - # Mock shutil so we can confirm that unpack is called, - # but we still want the side effect of calling it - create_command.tools.shutil = mock.MagicMock(spec_set=shutil) + # Wrap shutil so we can confirm that unpack is called + create_command.tools.shutil = mock.MagicMock(wraps=shutil) create_command.tools.shutil.unpack_archive.side_effect = shutil.ReadError # Install the stub binary @@ -93,7 +90,7 @@ def test_install_stub_binary_unpack_failure( create_command.install_stub_binary(myapp) # Confirm the right URL was used - create_command.tools.download.file.assert_called_with( + create_command.tools.file.download.assert_called_with( download_path=create_command.data_path / "stub", url=f"https://briefcase-support.s3.amazonaws.com/python/3.X/Tester/{stub_name}-3.X-b37.zip", role="stub binary", @@ -101,7 +98,7 @@ def test_install_stub_binary_unpack_failure( # Confirm the right file was unpacked create_command.tools.shutil.unpack_archive.assert_called_with( - tmp_path / f"data/stub/{stub_name}-3.X-b37.zip", + filename=tmp_path / f"data/stub/{stub_name}-3.X-b37.zip", extract_dir=tmp_path / "base_path/build/my-app/tester/dummy", ) @@ -123,23 +120,21 @@ def test_install_pinned_stub_binary( stub_name = "Console-Stub" if console_app else "GUI-Stub" # Mock download.file to return a stub binary - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=mock_zip_download( f"{stub_name}-3.X-b42.zip", [("Stub.bin", "stub binary")], ) ) - # Mock shutil so we can confirm that unpack is called, - # but we still want the side effect of calling it - create_command.tools.shutil = mock.MagicMock(spec_set=shutil) - create_command.tools.shutil.unpack_archive.side_effect = shutil.unpack_archive + # Wrap shutil so we can confirm that unpack is called + create_command.tools.shutil = mock.MagicMock(wraps=shutil) # Install the stub binary create_command.install_stub_binary(myapp) # Confirm the right URL was used - create_command.tools.download.file.assert_called_with( + create_command.tools.file.download.assert_called_with( download_path=create_command.data_path / "stub", url=f"https://briefcase-support.s3.amazonaws.com/python/3.X/Tester/{stub_name}-3.X-b42.zip", role="stub binary", @@ -147,7 +142,7 @@ def test_install_pinned_stub_binary( # Confirm the right file was unpacked create_command.tools.shutil.unpack_archive.assert_called_with( - tmp_path / f"data/stub/{stub_name}-3.X-b42.zip", + filename=tmp_path / f"data/stub/{stub_name}-3.X-b42.zip", extract_dir=tmp_path / "base_path/build/my-app/tester/dummy", ) @@ -163,7 +158,7 @@ def test_install_stub_binary_missing( ): """If the system-nominated stub binary doesn't exist, a specific error is raised.""" # Modify download.file to raise an exception - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=MissingNetworkResourceError( "https://briefcase-support.s3.amazonaws.com/python/3.X/Tester/GUI-Stub-3.X-b37.zip" ) @@ -188,23 +183,21 @@ def test_install_custom_stub_binary_url( myapp.stub_binary = "https://example.com/custom/My-Stub.zip" # Mock download.file to return a stub binary - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=mock_zip_download( "My-Stub.zip", [("Stub.bin", "stub binary")], ) ) - # Mock shutil so we can confirm that unpack is called, - # but we still want the side effect of calling it - create_command.tools.shutil = mock.MagicMock(spec_set=shutil) - create_command.tools.shutil.unpack_archive.side_effect = shutil.unpack_archive + # Wrap shutil so we can confirm that unpack is called + create_command.tools.shutil = mock.MagicMock(wraps=shutil) # Install the stub binary create_command.install_stub_binary(myapp) # Confirm the right URL was used - create_command.tools.download.file.assert_called_with( + create_command.tools.file.download.assert_called_with( download_path=create_command.data_path / "stub/986428ef9d5a1852fc15d4367f19aa328ad530686056e9d83cdde03407c0bceb", url="https://example.com/custom/My-Stub.zip", @@ -213,7 +206,7 @@ def test_install_custom_stub_binary_url( # Confirm the right file was unpacked create_command.tools.shutil.unpack_archive.assert_called_with( - tmp_path + filename=tmp_path / "data/stub/986428ef9d5a1852fc15d4367f19aa328ad530686056e9d83cdde03407c0bceb/My-Stub.zip", extract_dir=tmp_path / "base_path/build/my-app/tester/dummy", ) @@ -236,19 +229,17 @@ def test_install_custom_stub_binary_file( create_file(tmp_path / "custom/My-Stub", "Custom stub") # Modify download.file to return the temp zipfile - create_command.tools.download.file = mock.MagicMock() + create_command.tools.file.download = mock.MagicMock() - # Mock shutil so we can confirm that unpack isn't called, - # but we still want the side effect of calling - create_command.tools.shutil = mock.MagicMock(spec_set=shutil) - create_command.tools.shutil.copyfile.side_effect = shutil.copyfile + # Wrap shutil so we can confirm that unpack is called + create_command.tools.shutil = mock.MagicMock(wraps=shutil) # Install the stub binary create_command.install_stub_binary(myapp) # There should have been no download attempt, # as the resource is local. - create_command.tools.download.file.assert_not_called() + create_command.tools.file.download.assert_not_called() # The file isn't an archive, so it hasn't been unpacked. create_command.tools.shutil.unpack_archive.assert_not_called() @@ -274,23 +265,21 @@ def test_install_custom_stub_binary_zip( ) # Modify download.file to return the temp zipfile - create_command.tools.download.file = mock.MagicMock() + create_command.tools.file.download = mock.MagicMock() - # Mock shutil so we can confirm that unpack is called, - # but we still want the side effect of calling it - create_command.tools.shutil = mock.MagicMock(spec_set=shutil) - create_command.tools.shutil.unpack_archive.side_effect = shutil.unpack_archive + # Wrap shutil so we can confirm that unpack is called + create_command.tools.shutil = mock.MagicMock(wraps=shutil) # Install the stub binary create_command.install_stub_binary(myapp) # There should have been no download attempt, # as the resource is local. - create_command.tools.download.file.assert_not_called() + create_command.tools.file.download.assert_not_called() # Confirm the right file was unpacked create_command.tools.shutil.unpack_archive.assert_called_with( - stub_file, + filename=stub_file, extract_dir=tmp_path / "base_path/build/my-app/tester/dummy", ) @@ -317,23 +306,21 @@ def test_install_custom_stub_binary_tar( ) # Modify download.file to return the temp zipfile - create_command.tools.download.file = mock.MagicMock() + create_command.tools.file.download = mock.MagicMock() - # Mock shutil so we can confirm that unpack is called, - # but we still want the side effect of calling it - create_command.tools.shutil = mock.MagicMock(spec_set=shutil) - create_command.tools.shutil.unpack_archive.side_effect = shutil.unpack_archive + # Wrap shutil so we can confirm that unpack is called + create_command.tools.shutil = mock.MagicMock(wraps=shutil) # Install the stub binary create_command.install_stub_binary(myapp) # There should have been no download attempt, # as the resource is local. - create_command.tools.download.file.assert_not_called() + create_command.tools.file.download.assert_not_called() # Confirm the right file was unpacked create_command.tools.shutil.unpack_archive.assert_called_with( - stub_file, + filename=stub_file, extract_dir=tmp_path / "base_path/build/my-app/tester/dummy", **({"filter": "data"} if sys.version_info >= (3, 12) else {}), ) @@ -362,23 +349,21 @@ def test_install_custom_stub_binary_with_revision( ) # Modify download.file to return the temp zipfile - create_command.tools.download.file = mock.MagicMock() + create_command.tools.file.download = mock.MagicMock() - # Mock shutil so we can confirm that unpack is called, - # but we still want the side effect of calling it - create_command.tools.shutil = mock.MagicMock(spec_set=shutil) - create_command.tools.shutil.unpack_archive.side_effect = shutil.unpack_archive + # Wrap shutil so we can confirm that unpack is called + create_command.tools.shutil = mock.MagicMock(wraps=shutil) # Install the stub binary create_command.install_stub_binary(myapp) # There should have been no download attempt, # as the resource is local. - create_command.tools.download.file.assert_not_called() + create_command.tools.file.download.assert_not_called() # Confirm the right file was unpacked create_command.tools.shutil.unpack_archive.assert_called_with( - stub_file, + filename=stub_file, extract_dir=tmp_path / "base_path/build/my-app/tester/dummy", ) @@ -401,7 +386,7 @@ def test_install_custom_stub_binary_with_invalid_url( myapp.stub_binary = url # Modify download.file to raise an exception - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=MissingNetworkResourceError(url) ) @@ -410,7 +395,7 @@ def test_install_custom_stub_binary_with_invalid_url( create_command.install_stub_binary(myapp) # However, there will have been a download attempt - create_command.tools.download.file.assert_called_with( + create_command.tools.file.download.assert_called_with( download_path=( create_command.data_path / "stub" @@ -447,7 +432,7 @@ def test_install_custom_stub_binary_with_invalid_filepath( myapp.stub_binary = os.fsdecode(tmp_path / "custom/My-Stub") # Modify download.file to return the temp zipfile - create_command.tools.download.file = mock.MagicMock() + create_command.tools.file.download = mock.MagicMock() # Mock shutil so we can confirm that unpack isn't called, # but we still want the side effect of calling @@ -460,7 +445,7 @@ def test_install_custom_stub_binary_with_invalid_filepath( # There should have been no download attempt, # as the resource is local. - create_command.tools.download.file.assert_not_called() + create_command.tools.file.download.assert_not_called() # The file isn't an archive, so it hasn't been unpacked. create_command.tools.shutil.unpack_archive.assert_not_called() diff --git a/tests/integrations/android_sdk/AndroidSDK/test_upgrade.py b/tests/integrations/android_sdk/AndroidSDK/test_upgrade.py index 678f8c6ad..dfd940759 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_upgrade.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_upgrade.py @@ -35,7 +35,7 @@ def test_uninstall_does_nothing(mock_tools, android_sdk): """Uninstalling the Android SDK does nothing since it is upgraded in place.""" android_sdk.uninstall() - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() mock_tools.shutil.unpack_archive.assert_not_called() mock_tools.subprocess.Popen.assert_not_called() mock_tools.subprocess.run.assert_not_called() diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify.py b/tests/integrations/android_sdk/AndroidSDK/test_verify.py index 8a1898167..7ca34326f 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_verify.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_verify.py @@ -1,6 +1,7 @@ import os import platform import shutil +import sys from unittest.mock import MagicMock import pytest @@ -45,7 +46,7 @@ def mock_tools(mock_tools) -> ToolCache: return mock_tools -def mock_unpack(filename, extract_dir): +def mock_unpack(filename, extract_dir, filter=None): # Create a file that would have been created by unpacking the archive # This includes the duplicated "cmdline-tools" folder name (extract_dir / "cmdline-tools/bin").mkdir(parents=True) @@ -149,7 +150,7 @@ def test_succeeds_immediately_in_happy_path(mock_tools, tmp_path): sdk = AndroidSDK.verify(mock_tools) # No calls to download, run or unpack anything. - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() mock_tools.subprocess.run.assert_not_called() mock_tools.shutil.unpack_archive.assert_not_called() @@ -179,7 +180,7 @@ def test_user_provided_sdk(mock_tools, env_var, tmp_path, capsys): sdk = AndroidSDK.verify(mock_tools) # No calls to download or unpack anything. - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() mock_tools.shutil.unpack_archive.assert_not_called() # The returned SDK has the expected root path. @@ -221,7 +222,7 @@ def test_consistent_user_provided_sdk(mock_tools, tmp_path, capsys): sdk = AndroidSDK.verify(mock_tools) # No calls to download or unpack anything. - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() mock_tools.shutil.unpack_archive.assert_not_called() # The returned SDK has the expected root path. @@ -259,7 +260,7 @@ def test_inconsistent_user_provided_sdk(mock_tools, tmp_path, capsys): sdk = AndroidSDK.verify(mock_tools) # No calls to download or unpack anything. - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() mock_tools.shutil.unpack_archive.assert_not_called() # The returned SDK has the expected root path. @@ -291,7 +292,7 @@ def test_invalid_user_provided_sdk(mock_tools, env_var, tmp_path, capsys): sdk = AndroidSDK.verify(mock_tools) # No calls to download, run or unpack anything. - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() mock_tools.subprocess.run.assert_not_called() mock_tools.shutil.unpack_archive.assert_not_called() @@ -337,7 +338,7 @@ def test_user_provided_sdk_wrong_cmdline_tools_ver( sdk = AndroidSDK.verify(mock_tools) # No calls to download and nothing unpacked - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() mock_tools.shutil.unpack_archive.assert_not_called() # Required Command-line Tools installed @@ -389,7 +390,7 @@ def test_user_provided_sdk_with_latest_cmdline_tools( sdk = AndroidSDK.verify(mock_tools) # No calls to download and nothing unpacked - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() mock_tools.shutil.unpack_archive.assert_not_called() # Required Command-line Tools installed @@ -435,7 +436,7 @@ def test_consistent_invalid_user_provided_sdk(mock_tools, tmp_path, capsys): sdk = AndroidSDK.verify(mock_tools) # No calls to download, run or unpack anything. - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() mock_tools.subprocess.run.assert_not_called() mock_tools.shutil.unpack_archive.assert_not_called() @@ -470,7 +471,7 @@ def test_inconsistent_invalid_user_provided_sdk(mock_tools, tmp_path, capsys): sdk = AndroidSDK.verify(mock_tools) # No calls to download, run or unpack anything. - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() mock_tools.subprocess.run.assert_not_called() mock_tools.shutil.unpack_archive.assert_not_called() @@ -490,7 +491,7 @@ def test_download_sdk(mock_tools, tmp_path, capsys): # The download will produce a cached file. cache_file = MagicMock() - mock_tools.download.file.return_value = cache_file + mock_tools.file.download = MagicMock(return_value=cache_file) # Calling unpack will create files mock_tools.shutil.unpack_archive.side_effect = mock_unpack @@ -506,14 +507,16 @@ def test_download_sdk(mock_tools, tmp_path, capsys): "https://dl.google.com/android/repository/" f"commandlinetools-{mock_tools._test_download_tag}-{SDK_MGR_DL_VER}_latest.zip" ) - mock_tools.download.file.assert_called_once_with( + mock_tools.file.download.assert_called_once_with( url=url, download_path=mock_tools.base_path, role="Android SDK Command-Line Tools", ) mock_tools.shutil.unpack_archive.assert_called_once_with( - cache_file, extract_dir=cmdline_tools_base_path + filename=cache_file, + extract_dir=cmdline_tools_base_path, + **({"filter": "data"} if sys.version_info >= (3, 12) else {}), ) # The cached file will be deleted @@ -550,7 +553,7 @@ def test_upgrade_existing_sdk(mock_tools, tmp_path, capsys): # The download will produce a cached file. cache_file = MagicMock() - mock_tools.download.file.return_value = cache_file + mock_tools.file.download = MagicMock(return_value=cache_file) # Calling unpack will create files mock_tools.shutil.unpack_archive.side_effect = mock_unpack @@ -566,14 +569,16 @@ def test_upgrade_existing_sdk(mock_tools, tmp_path, capsys): "https://dl.google.com/android/repository/" f"commandlinetools-{mock_tools._test_download_tag}-{SDK_MGR_DL_VER}_latest.zip" ) - mock_tools.download.file.assert_called_once_with( + mock_tools.file.download.assert_called_once_with( url=url, download_path=mock_tools.base_path, role="Android SDK Command-Line Tools", ) mock_tools.shutil.unpack_archive.assert_called_once_with( - cache_file, extract_dir=cmdline_tools_base_path + filename=cache_file, + extract_dir=cmdline_tools_base_path, + **({"filter": "data"} if sys.version_info >= (3, 12) else {}), ) # The cached file will be deleted @@ -621,7 +626,7 @@ def test_download_sdk_legacy_install(mock_tools, tmp_path): # The download will produce a cached file. cache_file = MagicMock() - mock_tools.download.file.return_value = cache_file + mock_tools.file.download = MagicMock(return_value=cache_file) # Calling unpack will create files mock_tools.shutil.unpack_archive.side_effect = mock_unpack @@ -637,14 +642,16 @@ def test_download_sdk_legacy_install(mock_tools, tmp_path): "https://dl.google.com/android/repository/" f"commandlinetools-{mock_tools._test_download_tag}-{SDK_MGR_DL_VER}_latest.zip" ) - mock_tools.download.file.assert_called_once_with( + mock_tools.file.download.assert_called_once_with( url=url, download_path=mock_tools.base_path, role="Android SDK Command-Line Tools", ) mock_tools.shutil.unpack_archive.assert_called_once_with( - cache_file, extract_dir=cmdline_tools_base_path + filename=cache_file, + extract_dir=cmdline_tools_base_path, + **({"filter": "data"} if sys.version_info >= (3, 12) else {}), ) # The cached file will be deleted @@ -677,7 +684,7 @@ def test_no_install(mock_tools, tmp_path): with pytest.raises(MissingToolError): AndroidSDK.verify(mock_tools, install=False) - assert mock_tools.download.file.call_count == 0 + assert mock_tools.file.download.call_count == 0 def test_download_sdk_if_sdkmanager_not_executable(mock_tools, tmp_path): @@ -693,7 +700,7 @@ def test_download_sdk_if_sdkmanager_not_executable(mock_tools, tmp_path): # The download will produce a cached file cache_file = MagicMock() - mock_tools.download.file.return_value = cache_file + mock_tools.file.download = MagicMock(return_value=cache_file) # Calling unpack will create files mock_tools.shutil.unpack_archive.side_effect = mock_unpack @@ -709,15 +716,16 @@ def test_download_sdk_if_sdkmanager_not_executable(mock_tools, tmp_path): "https://dl.google.com/android/repository/" f"commandlinetools-{mock_tools._test_download_tag}-{SDK_MGR_DL_VER}_latest.zip" ) - mock_tools.download.file.assert_called_once_with( + mock_tools.file.download.assert_called_once_with( url=url, download_path=mock_tools.base_path, role="Android SDK Command-Line Tools", ) mock_tools.shutil.unpack_archive.assert_called_once_with( - cache_file, + filename=cache_file, extract_dir=cmdline_tools_base_path, + **({"filter": "data"} if sys.version_info >= (3, 12) else {}), ) # The cached file will be deleted @@ -732,7 +740,7 @@ def test_download_sdk_if_sdkmanager_not_executable(mock_tools, tmp_path): def test_raises_networkfailure_on_connectionerror(mock_tools): """If an error occurs downloading the ZIP file, and error is raised.""" - mock_tools.download.file.side_effect = NetworkFailure("mock") + mock_tools.file.download = MagicMock(side_effect=NetworkFailure("mock")) with pytest.raises(NetworkFailure, match="Unable to mock"): AndroidSDK.verify(mock_tools) @@ -742,7 +750,7 @@ def test_raises_networkfailure_on_connectionerror(mock_tools): "https://dl.google.com/android/repository/" f"commandlinetools-{mock_tools._test_download_tag}-{SDK_MGR_DL_VER}_latest.zip" ) - mock_tools.download.file.assert_called_once_with( + mock_tools.file.download.assert_called_once_with( url=url, download_path=mock_tools.base_path, role="Android SDK Command-Line Tools", @@ -756,7 +764,7 @@ def test_detects_bad_zipfile(mock_tools, tmp_path): android_sdk_root_path = tmp_path / "tools/android_sdk" cache_file = MagicMock() - mock_tools.download.file.return_value = cache_file + mock_tools.file.download = MagicMock(return_value=cache_file) # But the unpack will fail. mock_tools.shutil.unpack_archive.side_effect = shutil.ReadError @@ -769,11 +777,13 @@ def test_detects_bad_zipfile(mock_tools, tmp_path): "https://dl.google.com/android/repository/" f"commandlinetools-{mock_tools._test_download_tag}-{SDK_MGR_DL_VER}_latest.zip" ) - mock_tools.download.file.assert_called_once_with( + mock_tools.file.download.assert_called_once_with( url=url, download_path=mock_tools.base_path, role="Android SDK Command-Line Tools", ) mock_tools.shutil.unpack_archive.assert_called_once_with( - cache_file, extract_dir=android_sdk_root_path / "cmdline-tools" + filename=cache_file, + extract_dir=android_sdk_root_path / "cmdline-tools", + **({"filter": "data"} if sys.version_info >= (3, 12) else {}), ) diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py index c65cfa550..8d248d3c9 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator.py @@ -33,7 +33,7 @@ def test_succeeds_immediately_if_emulator_installed(mock_tools, android_sdk): # No extra calls made mock_tools.subprocess.run.assert_not_called() - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() def test_creates_platforms_folder(mock_tools, android_sdk): @@ -49,7 +49,7 @@ def test_creates_platforms_folder(mock_tools, android_sdk): # No extra calls made mock_tools.subprocess.run.assert_not_called() - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() def test_installs_android_emulator(mock_tools, android_sdk): diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator_skin.py b/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator_skin.py index 0e5ad868f..047fb47cd 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator_skin.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_verify_emulator_skin.py @@ -6,6 +6,13 @@ from briefcase.exceptions import BriefcaseCommandError, NetworkFailure +from ....utils import create_tgz_file + + +@pytest.fixture +def skin_tgz_path(tmp_path) -> Path: + return create_tgz_file(tmp_path / "skin.tgz", content=[("skin", "skin contents")]) + def test_existing_skin(mock_tools, android_sdk): """If the skin already exists, don't attempt to download it again.""" @@ -15,21 +22,19 @@ def test_existing_skin(mock_tools, android_sdk): # Verify the system image that we already have android_sdk.verify_emulator_skin("pixel_X") - # download.file was *not* called. - mock_tools.download.file.assert_not_called() + # file.download was *not* called. + mock_tools.file.download.assert_not_called() -def test_new_skin(mock_tools, android_sdk): +def test_new_skin(mock_tools, android_sdk, skin_tgz_path): """If the skin doesn't exist, an attempt is made to download it.""" - skin_tgz_path = MagicMock(spec_set=Path) - skin_tgz_path.__fspath__.return_value = "/path/to/skin.tgz" - mock_tools.download.file.return_value = skin_tgz_path + mock_tools.file.download = MagicMock(return_value=skin_tgz_path) # Verify the skin, triggering a download android_sdk.verify_emulator_skin("pixel_X") # Skin was downloaded - mock_tools.download.file.assert_called_once_with( + mock_tools.file.download.assert_called_once_with( url="https://android.googlesource.com/platform/tools/adt/idea/" "+archive/refs/heads/mirror-goog-studio-main/" "artwork/resources/device-art-resources/pixel_X.tar.gz", @@ -39,30 +44,28 @@ def test_new_skin(mock_tools, android_sdk): # Skin is unpacked. mock_tools.shutil.unpack_archive.assert_called_once_with( - skin_tgz_path, + filename=skin_tgz_path, extract_dir=android_sdk.root_path / "skins/pixel_X", **({"filter": "data"} if sys.version_info >= (3, 12) else {}), ) # Original file was deleted. - skin_tgz_path.unlink.assert_called_once_with() + assert not skin_tgz_path.exists() -def test_skin_download_failure(mock_tools, android_sdk, tmp_path): +def test_skin_download_failure(mock_tools, android_sdk, skin_tgz_path): """If the skin download fails, an error is raised.""" - skin_tgz_path = MagicMock(spec_set=Path) - skin_tgz_path.__fspath__.return_value = "/path/to/skin.tgz" - mock_tools.download.file.return_value = skin_tgz_path + mock_tools.file.download = MagicMock(return_value=skin_tgz_path) # Mock a failure downloading the skin - mock_tools.download.file.side_effect = NetworkFailure("mock") + mock_tools.file.download.side_effect = NetworkFailure("mock") # Verify the skin, triggering a download with pytest.raises(NetworkFailure, match="Unable to mock"): android_sdk.verify_emulator_skin("pixel_X") # An attempt was made to download the skin - mock_tools.download.file.assert_called_once_with( + mock_tools.file.download.assert_called_once_with( url="https://android.googlesource.com/platform/tools/adt/idea/" "+archive/refs/heads/mirror-goog-studio-main/" "artwork/resources/device-art-resources/pixel_X.tar.gz", @@ -74,12 +77,10 @@ def test_skin_download_failure(mock_tools, android_sdk, tmp_path): assert mock_tools.shutil.unpack_archive.call_count == 0 -def test_unpack_failure(mock_tools, android_sdk, tmp_path): +def test_unpack_failure(mock_tools, android_sdk, skin_tgz_path): """If the download is corrupted and unpacking fails, an error is raised.""" # Mock the result of the download of a skin - skin_tgz_path = MagicMock(spec_set=Path) - skin_tgz_path.__fspath__.return_value = "/path/to/skin.tgz" - mock_tools.download.file.return_value = skin_tgz_path + mock_tools.file.download = MagicMock(return_value=skin_tgz_path) # Mock a failure unpacking the skin mock_tools.shutil.unpack_archive.side_effect = EOFError @@ -92,7 +93,7 @@ def test_unpack_failure(mock_tools, android_sdk, tmp_path): android_sdk.verify_emulator_skin("pixel_X") # Skin was downloaded - mock_tools.download.file.assert_called_once_with( + mock_tools.file.download.assert_called_once_with( url="https://android.googlesource.com/platform/tools/adt/idea/" "+archive/refs/heads/mirror-goog-studio-main/" "artwork/resources/device-art-resources/pixel_X.tar.gz", @@ -102,10 +103,10 @@ def test_unpack_failure(mock_tools, android_sdk, tmp_path): # An attempt to unpack the skin was made. mock_tools.shutil.unpack_archive.assert_called_once_with( - skin_tgz_path, + filename=skin_tgz_path, extract_dir=android_sdk.root_path / "skins/pixel_X", **({"filter": "data"} if sys.version_info >= (3, 12) else {}), ) # Original file wasn't deleted. - assert skin_tgz_path.unlink.call_count == 0 + assert skin_tgz_path.exists() diff --git a/tests/integrations/android_sdk/conftest.py b/tests/integrations/android_sdk/conftest.py index 9dbb847d2..7a3a683de 100644 --- a/tests/integrations/android_sdk/conftest.py +++ b/tests/integrations/android_sdk/conftest.py @@ -5,7 +5,7 @@ from briefcase.integrations.android_sdk import ADB, AndroidSDK from briefcase.integrations.base import ToolCache -from briefcase.integrations.download import Download +from briefcase.integrations.file import File from briefcase.integrations.java import JDK from briefcase.integrations.subprocess import Subprocess @@ -18,7 +18,7 @@ def mock_tools(mock_tools, tmp_path) -> ToolCache: # Mock default tools mock_tools.subprocess = MagicMock(spec_set=Subprocess) - mock_tools.download = MagicMock(spec_set=Download) + mock_tools.file.download = MagicMock(spec_set=File.download) # Set up a JDK mock_tools.java = MagicMock(spec=JDK) diff --git a/tests/integrations/conftest.py b/tests/integrations/conftest.py index 85d6cae47..bcdd6908e 100644 --- a/tests/integrations/conftest.py +++ b/tests/integrations/conftest.py @@ -9,7 +9,7 @@ from briefcase.config import AppConfig from briefcase.console import Log from briefcase.integrations.base import ToolCache -from briefcase.integrations.download import Download +from briefcase.integrations.file import File from briefcase.integrations.subprocess import Subprocess from tests.utils import DummyConsole @@ -36,8 +36,8 @@ def mock_tools(tmp_path) -> ToolCache: mock_tools.base_path.mkdir(parents=True) mock_tools.home_path.mkdir(parents=True) - # Make Download and Subprocess always available - Download.verify(tools=mock_tools) + # Make File and Subprocess always available + File.verify(tools=mock_tools) Subprocess.verify(tools=mock_tools) return mock_tools diff --git a/tests/integrations/download/__init__.py b/tests/integrations/file/__init__.py similarity index 100% rename from tests/integrations/download/__init__.py rename to tests/integrations/file/__init__.py diff --git a/tests/integrations/file/test_File__archives.py b/tests/integrations/file/test_File__archives.py new file mode 100644 index 000000000..e12496e0a --- /dev/null +++ b/tests/integrations/file/test_File__archives.py @@ -0,0 +1,126 @@ +import shutil +import sys +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture(scope="module") +def custom_packing_format(): + shutil.register_unpack_format( + "custom_packing", + [".archive", ".archive.ext"], + lambda x, y: True, + ) + yield + shutil.unregister_unpack_format("custom_packing") + + +@pytest.mark.parametrize( + "filename, outcome", + [ + ("filename.tar", True), + ("filename.zip", True), + ("filename.archive", True), + ("filename.part.archive", True), + ("filename.archive.ext", True), + ("filename.part.archive.ext", True), + ("filename.part.archive.ext", True), + ("filename", False), + ("filename.doc", False), + ("filename.archive.doc", False), + ("filename.archive.ext.doc", False), + ], +) +@pytest.mark.usefixtures("custom_packing_format") +def test_is_archive(mock_tools, filename, outcome, monkeypatch): + """Archive filenames are properly detected.""" + assert mock_tools.file.is_archive(filename) is outcome + + +def test_unpack_archive(mock_tools): + """Archive unpacking is deferred correctly for an arbitrary archive.""" + mock_tools.shutil = MagicMock(spec=shutil) + + mock_tools.file.unpack_archive( + "test_archive", + extract_dir="destination", + ) + + mock_tools.shutil.unpack_archive.assert_called_once_with( + filename="test_archive", + extract_dir="destination", + **({"filter": "data"} if sys.version_info >= (3, 12) else {}), + ) + + +def test_unpack_archive_kwargs(mock_tools): + """Archive unpacking is deferred correctly with kwargs.""" + mock_tools.shutil = MagicMock(spec=shutil) + + mock_tools.file.unpack_archive( + "test_archive", + extract_dir="destination", + extra_arg="arg this", + ) + + mock_tools.shutil.unpack_archive.assert_called_once_with( + filename="test_archive", + extract_dir="destination", + **( + {"filter": "data", "extra_arg": "arg this"} + if sys.version_info >= (3, 12) + else {"extra_arg": "arg this"} + ), + ) + + +def test_unpack_archive_override_filter(mock_tools): + """Archive unpacking is deferred correctly while overriding `filter`.""" + mock_tools.shutil = MagicMock(spec=shutil) + + mock_tools.file.unpack_archive( + "test_archive", + extract_dir="destination", + filter="onlycatpics", + extra_arg="arg this", + ) + + mock_tools.shutil.unpack_archive.assert_called_once_with( + filename="test_archive", + extract_dir="destination", + filter="onlycatpics", + extra_arg="arg this", + ) + + +def test_unpack_zip_archive(mock_tools): + """Archive unpacking is deferred correctly for ZIP archives.""" + mock_tools.shutil = MagicMock(spec=shutil) + + mock_tools.file.unpack_archive( + "test_archive.zip", + extract_dir="destination", + ) + + mock_tools.shutil.unpack_archive.assert_called_once_with( + filename="test_archive.zip", + extract_dir="destination", + ) + + +def test_unpack_zip_archive_kwargs(mock_tools): + """Archive unpacking is deferred correctly for ZIP archives with kwargs.""" + mock_tools.shutil = MagicMock(spec=shutil) + + mock_tools.file.unpack_archive( + "test_archive.zip", + extract_dir="destination", + extra_arg="arg this", + ) + + mock_tools.shutil.unpack_archive.assert_called_once_with( + filename="test_archive.zip", + extract_dir="destination", + extra_arg="arg this", + ) diff --git a/tests/integrations/download/test_Download__file.py b/tests/integrations/file/test_File__download.py similarity index 97% rename from tests/integrations/download/test_Download__file.py rename to tests/integrations/file/test_File__download.py index e6ba4e854..c25cc6e06 100644 --- a/tests/integrations/download/test_Download__file.py +++ b/tests/integrations/file/test_File__download.py @@ -97,7 +97,7 @@ def test_new_download_oneshot(mock_tools, file_perms, url, content_disposition): mock_tools.requests.get.return_value = response # Download the file - filename = mock_tools.download.file( + filename = mock_tools.file.download( url="https://example.com/support?useful=Yes", download_path=mock_tools.base_path / "downloads", ) @@ -147,7 +147,7 @@ def test_new_download_chunked(mock_tools, file_perms): mock_tools.requests.get.return_value = response # Download the file - filename = mock_tools.download.file( + filename = mock_tools.file.download( url="https://example.com/support?useful=Yes", download_path=mock_tools.base_path, ) @@ -196,7 +196,7 @@ def test_already_downloaded(mock_tools): mock_tools.requests.get.return_value = response # Download the file - filename = mock_tools.download.file( + filename = mock_tools.file.download( url="https://example.com/support?useful=Yes", download_path=mock_tools.base_path, ) @@ -230,7 +230,7 @@ def test_missing_resource(mock_tools): # Download the file with pytest.raises(MissingNetworkResourceError): - mock_tools.download.file( + mock_tools.file.download( url="https://example.com/something.zip?useful=Yes", download_path=mock_tools.base_path, ) @@ -259,7 +259,7 @@ def test_bad_resource(mock_tools): # Download the file with pytest.raises(BadNetworkResourceError): - mock_tools.download.file( + mock_tools.file.download( url="https://example.com/something.zip?useful=Yes", download_path=mock_tools.base_path, ) @@ -289,7 +289,7 @@ def test_get_connection_error(mock_tools): NetworkFailure, match=r"Unable to download https\:\/\/example.com\/something\.zip\?useful=Yes", ): - mock_tools.download.file( + mock_tools.file.download( url="https://example.com/something.zip?useful=Yes", download_path=mock_tools.base_path, ) @@ -320,7 +320,7 @@ def test_iter_content_connection_error(mock_tools): # Download the file with pytest.raises(NetworkFailure, match="Unable to download something.zip"): - mock_tools.download.file( + mock_tools.file.download( url="https://example.com/something.zip?useful=Yes", download_path=mock_tools.base_path, ) @@ -360,7 +360,7 @@ def test_content_connection_error(mock_tools): # Download the file with pytest.raises(NetworkFailure, match="Unable to download something.zip"): - mock_tools.download.file( + mock_tools.file.download( url="https://example.com/something.zip?useful=Yes", download_path=mock_tools.base_path, ) diff --git a/tests/integrations/download/test_Download__verify.py b/tests/integrations/file/test_File__verify.py similarity index 54% rename from tests/integrations/download/test_Download__verify.py rename to tests/integrations/file/test_File__verify.py index 28270fd57..bd60a3fcd 100644 --- a/tests/integrations/download/test_Download__verify.py +++ b/tests/integrations/file/test_File__verify.py @@ -1,17 +1,17 @@ import pytest from briefcase.exceptions import UnsupportedHostError -from briefcase.integrations.download import Download +from briefcase.integrations.file import File def test_short_circuit(mock_tools): """Tool is not created if already cached.""" - mock_tools.download = "tool" + mock_tools.file = "tool" - tool = Download.verify(mock_tools) + tool = File.verify(mock_tools) assert tool == "tool" - assert tool == mock_tools.download + assert tool == mock_tools.file def test_unsupported_os(mock_tools): @@ -19,17 +19,17 @@ def test_unsupported_os(mock_tools): mock_tools.host_os = "wonky" # Delete download since it has already been verified - delattr(mock_tools, "download") + delattr(mock_tools, "file") with pytest.raises( UnsupportedHostError, - match=f"{Download.name} is not supported on wonky", + match=f"{File.name} is not supported on wonky", ): - Download.verify(mock_tools) + File.verify(mock_tools) def test_verify(mock_tools): """Verifying Download always returns/set download tool.""" - download_tool = Download.verify(mock_tools) - assert isinstance(download_tool, Download) - assert download_tool is mock_tools.download + file_tool = File.verify(mock_tools) + assert isinstance(file_tool, File) + assert file_tool is mock_tools.file diff --git a/tests/integrations/flatpak/conftest.py b/tests/integrations/flatpak/conftest.py index 14ae8d788..89b14b68e 100644 --- a/tests/integrations/flatpak/conftest.py +++ b/tests/integrations/flatpak/conftest.py @@ -3,7 +3,7 @@ import pytest from briefcase.integrations.base import ToolCache -from briefcase.integrations.download import Download +from briefcase.integrations.file import File from briefcase.integrations.flatpak import Flatpak from briefcase.integrations.subprocess import Subprocess @@ -15,7 +15,7 @@ def mock_tools(mock_tools) -> ToolCache: # Mock default tools mock_tools.subprocess = MagicMock(spec_set=Subprocess) - mock_tools.download = MagicMock(spec_set=Download) + mock_tools.file.download = MagicMock(spec_set=File.download) return mock_tools diff --git a/tests/integrations/java/conftest.py b/tests/integrations/java/conftest.py index 10a2a27ee..48d56a380 100644 --- a/tests/integrations/java/conftest.py +++ b/tests/integrations/java/conftest.py @@ -3,7 +3,7 @@ import pytest from briefcase.integrations.base import ToolCache -from briefcase.integrations.download import Download +from briefcase.integrations.file import File from briefcase.integrations.subprocess import Subprocess JDK_RELEASE = "17.0.11" @@ -14,6 +14,6 @@ def mock_tools(mock_tools, tmp_path) -> ToolCache: # Mock default tools mock_tools.subprocess = MagicMock(spec_set=Subprocess) - mock_tools.download = MagicMock(spec_set=Download) + mock_tools.file.download = MagicMock(spec_set=File.download) return mock_tools diff --git a/tests/integrations/java/test_JDK__upgrade.py b/tests/integrations/java/test_JDK__upgrade.py index b1e5034d4..fb10ee2f6 100644 --- a/tests/integrations/java/test_JDK__upgrade.py +++ b/tests/integrations/java/test_JDK__upgrade.py @@ -13,6 +13,7 @@ from briefcase.integrations.base import ToolCache from briefcase.integrations.java import JDK +from ...utils import create_zip_file from .conftest import JDK_BUILD, JDK_RELEASE @@ -34,7 +35,7 @@ def test_non_managed_install(mock_tools, tmp_path, capsys): jdk.upgrade() # No download was attempted - assert mock_tools.download.file.call_count == 0 + assert mock_tools.file.download.call_count == 0 def test_non_existing_install(mock_tools, tmp_path): @@ -46,7 +47,7 @@ def test_non_existing_install(mock_tools, tmp_path): jdk.upgrade() # No download was attempted - assert mock_tools.download.file.call_count == 0 + assert mock_tools.file.download.call_count == 0 def test_existing_install(mock_tools, tmp_path): @@ -62,9 +63,8 @@ def rmtree(path): mock_tools.shutil.rmtree.side_effect = rmtree # Mock the cached download path. - archive = MagicMock() - archive.__fspath__.return_value = "/path/to/download.zip" - mock_tools.download.file.return_value = archive + jdk_zip_path = create_zip_file(tmp_path / "download.zip", content=[("jdk", "jdk")]) + mock_tools.file.download = MagicMock(return_value=jdk_zip_path) # Create a directory to make it look like Java was downloaded and unpacked. (tmp_path / "tools" / f"jdk-{JDK_RELEASE}+{JDK_BUILD}").mkdir(parents=True) @@ -79,7 +79,7 @@ def rmtree(path): mock_tools.shutil.rmtree.assert_called_with(java_home) # A download was initiated - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://github.com/adoptium/temurin17-binaries/releases/download/" f"jdk-{JDK_RELEASE}+{JDK_BUILD}/OpenJDK17U-jdk_x64_linux_hotspot_{JDK_RELEASE}_{JDK_BUILD}.tar.gz", download_path=tmp_path / "tools", @@ -88,10 +88,10 @@ def rmtree(path): # The archive was unpacked. mock_tools.shutil.unpack_archive.assert_called_with( - "/path/to/download.zip", extract_dir=os.fsdecode(tmp_path / "tools") + filename=os.fsdecode(jdk_zip_path), extract_dir=os.fsdecode(tmp_path / "tools") ) # The original archive was deleted - archive.unlink.assert_called_once_with() + assert not jdk_zip_path.exists() def test_macOS_existing_install(mock_tools, tmp_path): @@ -111,9 +111,8 @@ def rmtree(path): mock_tools.shutil.rmtree.side_effect = rmtree # Mock the cached download path. - archive = MagicMock() - archive.__fspath__.return_value = "/path/to/download.zip" - mock_tools.download.file.return_value = archive + jdk_zip_path = create_zip_file(tmp_path / "download.zip", content=[("jdk", "jdk")]) + mock_tools.file.download = MagicMock(return_value=jdk_zip_path) # Create a directory to make it look like Java was downloaded and unpacked. (tmp_path / "tools" / f"jdk-{JDK_RELEASE}+{JDK_BUILD}").mkdir(parents=True) @@ -128,7 +127,7 @@ def rmtree(path): mock_tools.shutil.rmtree.assert_called_with(tmp_path / "tools/java") # A download was initiated - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://github.com/adoptium/temurin17-binaries/releases/download/" f"jdk-{JDK_RELEASE}+{JDK_BUILD}/OpenJDK17U-jdk_x64_mac_hotspot_{JDK_RELEASE}_{JDK_BUILD}.tar.gz", download_path=tmp_path / "tools", @@ -137,11 +136,12 @@ def rmtree(path): # The archive was unpacked. mock_tools.shutil.unpack_archive.assert_called_with( - "/path/to/download.zip", + filename=os.fsdecode(jdk_zip_path), extract_dir=os.fsdecode(tmp_path / "tools"), ) + # The original archive was deleted - archive.unlink.assert_called_once_with() + assert not jdk_zip_path.exists() def test_download_fail(mock_tools, tmp_path): @@ -157,7 +157,7 @@ def rmtree(path): mock_tools.shutil.rmtree.side_effect = rmtree # Mock a failure on download - mock_tools.download.file.side_effect = NetworkFailure("mock") + mock_tools.file.download = MagicMock(side_effect=NetworkFailure("mock")) # Create an SDK wrapper jdk = JDK(mock_tools, java_home=java_home) @@ -170,7 +170,7 @@ def rmtree(path): mock_tools.shutil.rmtree.assert_called_with(java_home) # A download was initiated - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://github.com/adoptium/temurin17-binaries/releases/download/" f"jdk-{JDK_RELEASE}+{JDK_BUILD}/OpenJDK17U-jdk_x64_linux_hotspot_{JDK_RELEASE}_{JDK_BUILD}.tar.gz", download_path=tmp_path / "tools", @@ -194,9 +194,8 @@ def rmtree(path): mock_tools.shutil.rmtree.side_effect = rmtree # Mock the cached download path - archive = MagicMock() - archive.__fspath__.return_value = "/path/to/download.zip" - mock_tools.download.file.return_value = archive + jdk_zip_path = create_zip_file(tmp_path / "download.zip", content=[("jdk", "jdk")]) + mock_tools.file.download = MagicMock(return_value=jdk_zip_path) # Mock an unpack failure due to an invalid archive mock_tools.shutil.unpack_archive.side_effect = shutil.ReadError @@ -212,7 +211,7 @@ def rmtree(path): mock_tools.shutil.rmtree.assert_called_with(java_home) # A download was initiated - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://github.com/adoptium/temurin17-binaries/releases/download/" f"jdk-{JDK_RELEASE}+{JDK_BUILD}/OpenJDK17U-jdk_x64_linux_hotspot_{JDK_RELEASE}_{JDK_BUILD}.tar.gz", download_path=tmp_path / "tools", @@ -221,8 +220,8 @@ def rmtree(path): # The archive was unpacked. mock_tools.shutil.unpack_archive.assert_called_with( - "/path/to/download.zip", + filename=os.fsdecode(jdk_zip_path), extract_dir=os.fsdecode(tmp_path / "tools"), ) # The original archive was not deleted - assert archive.unlink.call_count == 0 + assert jdk_zip_path.exists() diff --git a/tests/integrations/java/test_JDK__verify.py b/tests/integrations/java/test_JDK__verify.py index de1a7594d..485b274bd 100644 --- a/tests/integrations/java/test_JDK__verify.py +++ b/tests/integrations/java/test_JDK__verify.py @@ -2,7 +2,7 @@ import shutil import subprocess from pathlib import Path -from unittest import mock +from unittest.mock import MagicMock, call import pytest @@ -15,10 +15,10 @@ ) from briefcase.integrations.java import JDK -from ...utils import assert_url_resolvable +from ...utils import assert_url_resolvable, create_zip_file from .conftest import JDK_BUILD, JDK_RELEASE -CALL_JAVA_HOME = mock.call(["/usr/libexec/java_home"]) +CALL_JAVA_HOME = call(["/usr/libexec/java_home"]) def test_short_circuit(mock_tools): @@ -73,7 +73,7 @@ def test_macos_tool_java_home(mock_tools, capsys): assert mock_tools.subprocess.check_output.mock_calls == [ CALL_JAVA_HOME, # Second is a call to verify a valid Java version - mock.call([Path("/path/to/java/bin/javac"), "-version"]), + call([Path("/path/to/java/bin/javac"), "-version"]), ] # No console output @@ -132,7 +132,7 @@ def test_macos_wrong_jdk_version(mock_tools, tmp_path, capsys): assert mock_tools.subprocess.check_output.mock_calls == [ CALL_JAVA_HOME, - mock.call([Path("/path/to/java/bin/javac"), "-version"]), + call([Path("/path/to/java/bin/javac"), "-version"]), ] # No console output @@ -164,7 +164,7 @@ def test_macos_invalid_jdk_path(mock_tools, tmp_path, capsys): assert mock_tools.subprocess.check_output.mock_calls == [ CALL_JAVA_HOME, - mock.call([Path("/path/to/java/bin/javac"), "-version"]), + call([Path("/path/to/java/bin/javac"), "-version"]), ] # No console output @@ -490,9 +490,8 @@ def test_successful_jdk_download( mock_tools.os.environ = {"JAVA_HOME": "/does/not/exist"} # Mock the cached download path - archive = mock.MagicMock() - archive.__fspath__.return_value = "/path/to/download.zip" - mock_tools.download.file.return_value = archive + jdk_zip_path = create_zip_file(tmp_path / "download.zip", content=[("jdk", "jdk")]) + mock_tools.file.download = MagicMock(return_value=jdk_zip_path) # Create a directory to make it look like Java was downloaded and unpacked. (tmp_path / "tools" / f"jdk-{JDK_RELEASE}+{JDK_BUILD}").mkdir(parents=True) @@ -508,17 +507,17 @@ def test_successful_jdk_download( assert "** WARNING: JAVA_HOME does not point to a Java 17 JDK" in output.out # Download was invoked - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url=jdk_url, download_path=tmp_path / "tools", role="Java 17 JDK", ) # The archive was unpacked mock_tools.shutil.unpack_archive.assert_called_with( - "/path/to/download.zip", extract_dir=os.fsdecode(tmp_path / "tools") + filename=os.fsdecode(jdk_zip_path), extract_dir=os.fsdecode(tmp_path / "tools") ) # The original archive was deleted - archive.unlink.assert_called_once_with() + assert not jdk_zip_path.exists() # The download URL for JDK exists assert_url_resolvable(mock_tools.java.OpenJDK_download_url) @@ -533,7 +532,7 @@ def test_not_installed(mock_tools, tmp_path): JDK.verify(mock_tools, install=False) # Download was not invoked - assert mock_tools.download.file.call_count == 0 + assert mock_tools.file.download.call_count == 0 def test_jdk_download_failure(mock_tools, tmp_path): @@ -543,14 +542,14 @@ def test_jdk_download_failure(mock_tools, tmp_path): mock_tools.host_arch = "x86_64" # Mock a failure on download - mock_tools.download.file.side_effect = NetworkFailure("mock") + mock_tools.file.download = MagicMock(side_effect=NetworkFailure("mock")) # Invoking verify_jdk causes a network failure. with pytest.raises(NetworkFailure, match="Unable to mock"): JDK.verify(mock_tools) # That download was attempted - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://github.com/adoptium/temurin17-binaries/releases/download/" f"jdk-{JDK_RELEASE}+{JDK_BUILD}/OpenJDK17U-jdk_x64_linux_hotspot_{JDK_RELEASE}_{JDK_BUILD}.tar.gz", download_path=tmp_path / "tools", @@ -567,9 +566,8 @@ def test_invalid_jdk_archive(mock_tools, tmp_path): mock_tools.host_arch = "x86_64" # Mock the cached download path - archive = mock.MagicMock() - archive.__fspath__.return_value = "/path/to/download.zip" - mock_tools.download.file.return_value = archive + jdk_zip_path = create_zip_file(tmp_path / "download.zip", content=[("jdk", "jdk")]) + mock_tools.file.download = MagicMock(return_value=jdk_zip_path) # Mock an unpack failure due to an invalid archive mock_tools.shutil.unpack_archive.side_effect = shutil.ReadError @@ -578,7 +576,7 @@ def test_invalid_jdk_archive(mock_tools, tmp_path): JDK.verify(mock_tools) # The download occurred - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://github.com/adoptium/temurin17-binaries/releases/download/" f"jdk-{JDK_RELEASE}+{JDK_BUILD}/OpenJDK17U-jdk_x64_linux_hotspot_{JDK_RELEASE}_{JDK_BUILD}.tar.gz", download_path=tmp_path / "tools", @@ -586,8 +584,8 @@ def test_invalid_jdk_archive(mock_tools, tmp_path): ) # An attempt was made to unpack the archive. mock_tools.shutil.unpack_archive.assert_called_with( - "/path/to/download.zip", + filename=os.fsdecode(jdk_zip_path), extract_dir=os.fsdecode(tmp_path / "tools"), ) # The original archive was not deleted - assert archive.unlink.call_count == 0 + assert jdk_zip_path.exists() diff --git a/tests/integrations/linuxdeploy/conftest.py b/tests/integrations/linuxdeploy/conftest.py index be9ee0109..0dca233d1 100644 --- a/tests/integrations/linuxdeploy/conftest.py +++ b/tests/integrations/linuxdeploy/conftest.py @@ -4,7 +4,7 @@ import pytest from briefcase.integrations.base import ToolCache -from briefcase.integrations.download import Download +from briefcase.integrations.file import File from briefcase.integrations.linuxdeploy import LinuxDeploy from briefcase.integrations.subprocess import Subprocess @@ -16,7 +16,7 @@ def mock_tools(tmp_path, mock_tools) -> ToolCache: # Mock default tools mock_tools.subprocess = MagicMock(spec_set=Subprocess) - mock_tools.download = MagicMock(spec_set=Download) + mock_tools.file = MagicMock(spec_set=File) # Restore shutil mock_tools.shutil = shutil diff --git a/tests/integrations/linuxdeploy/test_LinuxDeployBase__upgrade.py b/tests/integrations/linuxdeploy/test_LinuxDeployBase__upgrade.py index e7c771586..8a298d2ab 100644 --- a/tests/integrations/linuxdeploy/test_LinuxDeployBase__upgrade.py +++ b/tests/integrations/linuxdeploy/test_LinuxDeployBase__upgrade.py @@ -12,7 +12,7 @@ def test_upgrade_exists(linuxdeploy, mock_tools, tmp_path): appimage_path.touch() # Mock a successful download - mock_tools.download.file.side_effect = side_effect_create_mock_appimage( + mock_tools.file.download.side_effect = side_effect_create_mock_appimage( appimage_path ) @@ -23,7 +23,7 @@ def test_upgrade_exists(linuxdeploy, mock_tools, tmp_path): assert appimage_path.exists() # A download is invoked - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-i386.AppImage", download_path=tmp_path / "tools", role="linuxdeploy", @@ -39,7 +39,7 @@ def test_upgrade_does_not_exist(linuxdeploy, mock_tools): linuxdeploy.upgrade() # The tool wasn't already installed, so an error is raised. - assert mock_tools.download.file.call_count == 0 + assert mock_tools.file.download.call_count == 0 def test_upgrade_linuxdeploy_download_failure(linuxdeploy, mock_tools, tmp_path): @@ -50,7 +50,7 @@ def test_upgrade_linuxdeploy_download_failure(linuxdeploy, mock_tools, tmp_path) # Mock the existence of an install appimage_path.touch() - mock_tools.download.file.side_effect = NetworkFailure("mock") + mock_tools.file.download.side_effect = NetworkFailure("mock") # Updated the linuxdeploy wrapper; the upgrade will fail with pytest.raises(NetworkFailure, match="Unable to mock"): @@ -60,7 +60,7 @@ def test_upgrade_linuxdeploy_download_failure(linuxdeploy, mock_tools, tmp_path) assert not appimage_path.exists() # A download was invoked - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-i386.AppImage", download_path=tmp_path / "tools", role="linuxdeploy", diff --git a/tests/integrations/linuxdeploy/test_LinuxDeployBase__verify.py b/tests/integrations/linuxdeploy/test_LinuxDeployBase__verify.py index 03c379f52..3525e6e46 100644 --- a/tests/integrations/linuxdeploy/test_LinuxDeployBase__verify.py +++ b/tests/integrations/linuxdeploy/test_LinuxDeployBase__verify.py @@ -66,7 +66,7 @@ def test_verify_exists(mock_tools, tmp_path): linuxdeploy = LinuxDeployDummy.verify(mock_tools) # No download occurred - assert mock_tools.download.file.call_count == 0 + assert mock_tools.file.download.call_count == 0 assert mock_tools.os.chmod.call_count == 0 # The build command retains the path to the downloaded file. @@ -81,7 +81,7 @@ def test_verify_does_not_exist_dont_install(mock_tools): LinuxDeployDummy.verify(mock_tools, install=False) # No download occurred - assert mock_tools.download.file.call_count == 0 + assert mock_tools.file.download.call_count == 0 assert mock_tools.os.chmod.call_count == 0 @@ -90,7 +90,7 @@ def test_verify_does_not_exist(mock_tools, tmp_path): appimage_path = tmp_path / "tools/somewhere/linuxdeploy-dummy-wonky.AppImage" # Mock a successful download - mock_tools.download.file.side_effect = side_effect_create_mock_appimage( + mock_tools.file.download.side_effect = side_effect_create_mock_appimage( appimage_path ) @@ -105,7 +105,7 @@ def test_verify_does_not_exist(mock_tools, tmp_path): } # A download is invoked - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://example.com/path/to/linuxdeploy-dummy-wonky.AppImage", download_path=tmp_path / "tools/somewhere", role="Dummy plugin", @@ -123,13 +123,13 @@ def test_verify_does_not_exist_non_appimage(mock_tools, tmp_path): tool_path = tmp_path / "tools/somewhere/linuxdeploy-dummy.sh" # Mock a successful download - mock_tools.download.file.side_effect = side_effect_create_mock_tool(tool_path) + mock_tools.file.download.side_effect = side_effect_create_mock_tool(tool_path) # Create a linuxdeploy wrapper by verification linuxdeploy = LinuxDeployDummy.verify(mock_tools, file_name=tool_path.name) # A download is invoked - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://example.com/path/to/linuxdeploy-dummy.sh", download_path=tmp_path / "tools/somewhere", role="Dummy plugin", @@ -148,13 +148,13 @@ def test_verify_does_not_exist_non_appimage(mock_tools, tmp_path): def test_verify_linuxdeploy_download_failure(mock_tools, tmp_path): """If a tool/plugin doesn't exist, and a download failure occurs, an error is raised.""" - mock_tools.download.file.side_effect = NetworkFailure("mock") + mock_tools.file.download.side_effect = NetworkFailure("mock") with pytest.raises(NetworkFailure, match="Unable to mock"): LinuxDeployDummy.verify(mock_tools) # A download was invoked - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://example.com/path/to/linuxdeploy-dummy-wonky.AppImage", download_path=tmp_path / "tools/somewhere", role="Dummy plugin", diff --git a/tests/integrations/linuxdeploy/test_LinuxDeployURLPlugin__verify.py b/tests/integrations/linuxdeploy/test_LinuxDeployURLPlugin__verify.py index acdcb50e3..c9b1e0e08 100644 --- a/tests/integrations/linuxdeploy/test_LinuxDeployURLPlugin__verify.py +++ b/tests/integrations/linuxdeploy/test_LinuxDeployURLPlugin__verify.py @@ -25,7 +25,7 @@ def test_unsupported_os(mock_tools, host_os): def test_verify(mock_tools, tmp_path): """URL plugins will be downloaded.""" # Mock a successful download - mock_tools.download.file.side_effect = side_effect_create_mock_appimage( + mock_tools.file.download.side_effect = side_effect_create_mock_appimage( tmp_path / "tools" / "linuxdeploy_plugins" @@ -39,7 +39,7 @@ def test_verify(mock_tools, tmp_path): url="https://example.com/path/to/linuxdeploy-plugin-sometool-wonky.AppImage", ) - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://example.com/path/to/linuxdeploy-plugin-sometool-wonky.AppImage", download_path=tmp_path / "tools" @@ -54,7 +54,7 @@ def test_download_failure(mock_tools, tmp_path): """A failure downloading a custom URL plugin raises an error.""" # Mock a successful download - mock_tools.download.file.side_effect = NetworkFailure("mock") + mock_tools.file.download.side_effect = NetworkFailure("mock") url = "https://example.com/path/to/linuxdeploy-plugin-sometool-wonky.AppImage" @@ -62,7 +62,7 @@ def test_download_failure(mock_tools, tmp_path): LinuxDeployURLPlugin.verify(mock_tools, url=url) # A download was invoked - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url=url, download_path=mock_tools.base_path / "linuxdeploy_plugins" diff --git a/tests/integrations/linuxdeploy/test_LinuxDeploy__verify_plugins.py b/tests/integrations/linuxdeploy/test_LinuxDeploy__verify_plugins.py index 3819de7dc..fd9963675 100644 --- a/tests/integrations/linuxdeploy/test_LinuxDeploy__verify_plugins.py +++ b/tests/integrations/linuxdeploy/test_LinuxDeploy__verify_plugins.py @@ -23,7 +23,7 @@ def test_no_plugins(linuxdeploy, mock_tools, tmp_path): plugins = linuxdeploy.verify_plugins([], bundle_path=tmp_path / "bundle") - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() assert plugins == {} @@ -32,7 +32,7 @@ def test_gtk_plugin(linuxdeploy, mock_tools, tmp_path): """The GTK plugin can be verified.""" # Mock a successful download - mock_tools.download.file.side_effect = side_effect_create_mock_tool( + mock_tools.file.download.side_effect = side_effect_create_mock_tool( tmp_path / "tools/linuxdeploy_plugins/gtk/linuxdeploy-plugin-gtk.sh" ) @@ -41,7 +41,7 @@ def test_gtk_plugin(linuxdeploy, mock_tools, tmp_path): assert plugins.keys() == {"gtk"} assert isinstance(plugins["gtk"], LinuxDeployGtkPlugin) - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh", download_path=tmp_path / "tools/linuxdeploy_plugins/gtk", role="linuxdeploy GTK plugin", @@ -52,7 +52,7 @@ def test_qt_plugin(linuxdeploy, mock_tools, tmp_path): """The Qt plugin can be verified.""" # Mock a successful download - mock_tools.download.file.side_effect = side_effect_create_mock_appimage( + mock_tools.file.download.side_effect = side_effect_create_mock_appimage( tmp_path / "tools" / "linuxdeploy_plugins" @@ -65,7 +65,7 @@ def test_qt_plugin(linuxdeploy, mock_tools, tmp_path): assert plugins.keys() == {"qt"} assert isinstance(plugins["qt"], LinuxDeployQtPlugin) - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url=( "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/" "releases/download/continuous/linuxdeploy-plugin-qt-i386.AppImage" @@ -79,7 +79,7 @@ def test_custom_url_plugin(linuxdeploy, mock_tools, tmp_path): """A Custom URL plugin can be verified.""" # Mock a successful download - mock_tools.download.file.side_effect = side_effect_create_mock_appimage( + mock_tools.file.download.side_effect = side_effect_create_mock_appimage( tmp_path / "tools" / "linuxdeploy_plugins" @@ -96,7 +96,7 @@ def test_custom_url_plugin(linuxdeploy, mock_tools, tmp_path): assert plugins.keys() == {"sometool"} assert isinstance(plugins["sometool"], LinuxDeployURLPlugin) - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://example.com/path/to/linuxdeploy-plugin-sometool-i386.AppImage", download_path=tmp_path / "tools" @@ -127,7 +127,7 @@ def test_custom_local_file_plugin(linuxdeploy, mock_tools, tmp_path): assert isinstance(plugins["sometool"], LinuxDeployLocalFilePlugin) # No download happened - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() # But a copy happened assert (tmp_path / "bundle/linuxdeploy-plugin-sometool-i386.AppImage").exists() @@ -226,7 +226,7 @@ def mock_downloads(url, download_path, role): else: raise Exception("Unexpected download") - mock_tools.download.file.side_effect = mock_downloads + mock_tools.file.download.side_effect = mock_downloads # Local file tool is a local file. local_plugin_path = tmp_path / "path/to/linuxdeploy-plugin-sometool-i386.AppImage" diff --git a/tests/integrations/rcedit/conftest.py b/tests/integrations/rcedit/conftest.py index 44958eb76..ed7145e79 100644 --- a/tests/integrations/rcedit/conftest.py +++ b/tests/integrations/rcedit/conftest.py @@ -3,7 +3,7 @@ import pytest from briefcase.integrations.base import ToolCache -from briefcase.integrations.download import Download +from briefcase.integrations.file import File from briefcase.integrations.rcedit import RCEdit from briefcase.integrations.subprocess import Subprocess @@ -15,7 +15,7 @@ def mock_tools(tmp_path, mock_tools) -> ToolCache: # Mock default tools mock_tools.subprocess = MagicMock(spec_set=Subprocess) - mock_tools.download = MagicMock(spec_set=Download) + mock_tools.file = MagicMock(spec_set=File) return mock_tools diff --git a/tests/integrations/rcedit/test_RCEdit__upgrade.py b/tests/integrations/rcedit/test_RCEdit__upgrade.py index 93b898fd9..732d8064f 100644 --- a/tests/integrations/rcedit/test_RCEdit__upgrade.py +++ b/tests/integrations/rcedit/test_RCEdit__upgrade.py @@ -15,7 +15,7 @@ def side_effect_create_mock_appimage(*args, **kwargs): rcedit_path.touch() return "new-downloaded-file" - mock_tools.download.file.side_effect = side_effect_create_mock_appimage + mock_tools.file.download.side_effect = side_effect_create_mock_appimage # Do upgrade rcedit.upgrade() @@ -24,7 +24,7 @@ def side_effect_create_mock_appimage(*args, **kwargs): assert rcedit_path.exists() # A download is invoked - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://github.com/electron/rcedit/" "releases/download/v2.0.0/rcedit-x64.exe", download_path=tmp_path / "tools", @@ -39,7 +39,7 @@ def test_upgrade_does_not_exist(mock_tools, rcedit, tmp_path): rcedit.upgrade() # The tool wasn't already installed, so an error is raised. - assert mock_tools.download.file.call_count == 0 + assert mock_tools.file.download.call_count == 0 def test_upgrade_rcedit_download_failure(mock_tools, rcedit, tmp_path): @@ -48,7 +48,7 @@ def test_upgrade_rcedit_download_failure(mock_tools, rcedit, tmp_path): rcedit_path = tmp_path / "tools/rcedit-x64.exe" rcedit_path.touch() - mock_tools.download.file.side_effect = NetworkFailure("mock") + mock_tools.file.download.side_effect = NetworkFailure("mock") # The upgrade will fail with pytest.raises(NetworkFailure, match="Unable to mock"): @@ -58,7 +58,7 @@ def test_upgrade_rcedit_download_failure(mock_tools, rcedit, tmp_path): assert not rcedit_path.exists() # A download was invoked - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://github.com/electron/rcedit/" "releases/download/v2.0.0/rcedit-x64.exe", download_path=tmp_path / "tools", diff --git a/tests/integrations/rcedit/test_RCEdit__verify.py b/tests/integrations/rcedit/test_RCEdit__verify.py index c8094b897..b8e86d603 100644 --- a/tests/integrations/rcedit/test_RCEdit__verify.py +++ b/tests/integrations/rcedit/test_RCEdit__verify.py @@ -37,7 +37,7 @@ def test_verify_exists(mock_tools, tmp_path): rcedit = RCEdit.verify(mock_tools) # No download occurred - assert mock_tools.download.file.call_count == 0 + assert mock_tools.file.download.call_count == 0 assert mock_tools.os.chmod.call_count == 0 # The build command retains the path to the downloaded file. @@ -47,7 +47,7 @@ def test_verify_exists(mock_tools, tmp_path): def test_verify_does_not_exist_dont_install(mock_tools, tmp_path): """If RCEdit doesn't exist, and install=False, it is *not* downloaded.""" # Mock a successful download - mock_tools.download.file.return_value = "new-downloaded-file" + mock_tools.file.download.return_value = "new-downloaded-file" # True to create a rcedit wrapper by verification. # This will fail because it doesn't exist, but installation was disabled. @@ -55,7 +55,7 @@ def test_verify_does_not_exist_dont_install(mock_tools, tmp_path): RCEdit.verify(mock_tools, install=False) # No download occurred - assert mock_tools.download.file.call_count == 0 + assert mock_tools.file.download.call_count == 0 assert mock_tools.os.chmod.call_count == 0 @@ -68,13 +68,13 @@ def side_effect_create_mock_appimage(*args, **kwargs): rcedit_path.touch() return "new-downloaded-file" - mock_tools.download.file.side_effect = side_effect_create_mock_appimage + mock_tools.file.download.side_effect = side_effect_create_mock_appimage # Create a rcedit wrapper by verification rcedit = RCEdit.verify(mock_tools) # A download is invoked - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://github.com/electron/rcedit/" "releases/download/v2.0.0/rcedit-x64.exe", download_path=tmp_path / "tools", @@ -87,13 +87,13 @@ def side_effect_create_mock_appimage(*args, **kwargs): def test_verify_rcedit_download_failure(mock_tools, tmp_path): """If RCEdit doesn't exist, but a download failure occurs, an error is raised.""" - mock_tools.download.file.side_effect = NetworkFailure("mock") + mock_tools.file.download.side_effect = NetworkFailure("mock") with pytest.raises(NetworkFailure, match="Unable to mock"): RCEdit.verify(mock_tools) # A download was invoked - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url="https://github.com/electron/rcedit/" "releases/download/v2.0.0/rcedit-x64.exe", download_path=tmp_path / "tools", diff --git a/tests/integrations/windows_sdk/conftest.py b/tests/integrations/windows_sdk/conftest.py index 214207f52..bbec7814c 100644 --- a/tests/integrations/windows_sdk/conftest.py +++ b/tests/integrations/windows_sdk/conftest.py @@ -4,7 +4,7 @@ import pytest from briefcase.integrations.base import ToolCache -from briefcase.integrations.download import Download +from briefcase.integrations.file import File from briefcase.integrations.subprocess import Subprocess @@ -19,6 +19,6 @@ def mock_tools(tmp_path, mock_tools) -> ToolCache: # Mock default tools mock_tools.subprocess = MagicMock(spec_set=Subprocess) - mock_tools.download = MagicMock(spec_set=Download) + mock_tools.file.download = MagicMock(spec_set=File.download) return mock_tools diff --git a/tests/integrations/wix/conftest.py b/tests/integrations/wix/conftest.py index 727abc365..f1feb2b3b 100644 --- a/tests/integrations/wix/conftest.py +++ b/tests/integrations/wix/conftest.py @@ -4,7 +4,7 @@ import pytest from briefcase.integrations.base import ToolCache -from briefcase.integrations.download import Download +from briefcase.integrations.file import File from briefcase.integrations.subprocess import Subprocess WIX_DOWNLOAD_URL = "https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip" @@ -17,6 +17,6 @@ def mock_tools(tmp_path, mock_tools) -> ToolCache: # Mock default tools mock_tools.subprocess = MagicMock(spec_set=Subprocess) - mock_tools.download = MagicMock(spec_set=Download) + mock_tools.file.download = MagicMock(spec_set=File.download) return mock_tools diff --git a/tests/integrations/wix/test_WiX__upgrade.py b/tests/integrations/wix/test_WiX__upgrade.py index 70efb24ab..51d7b9299 100644 --- a/tests/integrations/wix/test_WiX__upgrade.py +++ b/tests/integrations/wix/test_WiX__upgrade.py @@ -11,6 +11,7 @@ ) from briefcase.integrations.wix import WiX +from ...utils import create_zip_file from .conftest import WIX_DOWNLOAD_URL @@ -25,7 +26,7 @@ def test_non_managed_install(mock_tools, tmp_path, capsys): wix.upgrade() # No download was attempted - assert mock_tools.download.file.call_count == 0 + assert mock_tools.file.download.call_count == 0 def test_non_existing_wix_install(mock_tools, tmp_path): @@ -37,7 +38,7 @@ def test_non_existing_wix_install(mock_tools, tmp_path): wix.upgrade() # No download was attempted - assert mock_tools.download.file.call_count == 0 + assert mock_tools.file.download.call_count == 0 def test_existing_wix_install(mock_tools, tmp_path): @@ -52,11 +53,9 @@ def test_existing_wix_install(mock_tools, tmp_path): # Mock the download wix_path = tmp_path / "tools/wix" - wix_zip_path = os.fsdecode(tmp_path / "tools/wix.zip") - wix_zip = MagicMock() - wix_zip.__fspath__.return_value = wix_zip_path + wix_zip_path = create_zip_file(tmp_path / "tools/wix.zip", content=[("wix", "wix")]) - mock_tools.download.file.return_value = wix_zip + mock_tools.file.download = MagicMock(return_value=wix_zip_path) # Create an SDK wrapper wix = WiX(mock_tools, wix_home=wix_path, bin_install=True) @@ -68,7 +67,7 @@ def test_existing_wix_install(mock_tools, tmp_path): mock_tools.shutil.rmtree.assert_called_with(wix_path) # A download was initiated - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url=WIX_DOWNLOAD_URL, download_path=tmp_path / "tools", role="WiX", @@ -76,11 +75,11 @@ def test_existing_wix_install(mock_tools, tmp_path): # The download was unpacked mock_tools.shutil.unpack_archive.assert_called_with( - os.fsdecode(wix_zip_path), extract_dir=os.fsdecode(wix_path) + filename=os.fsdecode(wix_zip_path), extract_dir=os.fsdecode(wix_path) ) # The zip file was removed - wix_zip.unlink.assert_called_with() + assert not wix_zip_path.exists() def test_download_fail(mock_tools, tmp_path): @@ -93,7 +92,7 @@ def test_download_fail(mock_tools, tmp_path): (wix_path / "candle.exe").touch() # Mock the download failure - mock_tools.download.file.side_effect = NetworkFailure("mock") + mock_tools.file.download = MagicMock(side_effect=NetworkFailure("mock")) # Create an SDK wrapper wix = WiX(mock_tools, wix_home=wix_path, bin_install=True) @@ -103,7 +102,7 @@ def test_download_fail(mock_tools, tmp_path): wix.upgrade() # A download was initiated - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url=WIX_DOWNLOAD_URL, download_path=tmp_path / "tools", role="WiX", @@ -123,11 +122,9 @@ def test_unpack_fail(mock_tools, tmp_path): (wix_path / "candle.exe").touch() # Mock the download - wix_zip_path = os.fsdecode(tmp_path / "tools/wix.zip") - wix_zip = MagicMock() - wix_zip.__fspath__.return_value = wix_zip_path + wix_zip_path = create_zip_file(tmp_path / "tools/wix.zip", content=[("wix", "wix")]) - mock_tools.download.file.return_value = wix_zip + mock_tools.file.download = MagicMock(return_value=wix_zip_path) # Mock an unpack failure mock_tools.shutil.unpack_archive.side_effect = EOFError @@ -141,7 +138,7 @@ def test_unpack_fail(mock_tools, tmp_path): wix.upgrade() # A download was initiated - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url=WIX_DOWNLOAD_URL, download_path=tmp_path / "tools", role="WiX", @@ -149,8 +146,8 @@ def test_unpack_fail(mock_tools, tmp_path): # The download was unpacked. mock_tools.shutil.unpack_archive.assert_called_with( - os.fsdecode(wix_zip_path), extract_dir=os.fsdecode(wix_path) + filename=os.fsdecode(wix_zip_path), extract_dir=os.fsdecode(wix_path) ) # The zip file was not removed - assert wix_zip.unlink.call_count == 0 + assert wix_zip_path.exists() diff --git a/tests/integrations/wix/test_WiX__verify.py b/tests/integrations/wix/test_WiX__verify.py index 7609fe2ef..231461ab2 100644 --- a/tests/integrations/wix/test_WiX__verify.py +++ b/tests/integrations/wix/test_WiX__verify.py @@ -11,7 +11,7 @@ ) from briefcase.integrations.wix import WiX -from ...utils import assert_url_resolvable +from ...utils import assert_url_resolvable, create_zip_file from .conftest import WIX_DOWNLOAD_URL @@ -90,7 +90,7 @@ def test_existing_wix_install(mock_tools, tmp_path): mock_tools.os.environ.get.assert_called_with("WIX") # No download was attempted - assert mock_tools.download.file.call_count == 0 + assert mock_tools.file.download.call_count == 0 # The returned paths are as expected assert wix.heat_exe == tmp_path / "tools/wix/heat.exe" @@ -106,11 +106,9 @@ def test_download_wix(mock_tools, tmp_path): # Mock the download wix_path = tmp_path / "tools/wix" - wix_zip_path = os.fsdecode(tmp_path / "tools/wix.zip") - wix_zip = MagicMock() - wix_zip.__fspath__.return_value = wix_zip_path + wix_zip_path = create_zip_file(tmp_path / "tools/wix.zip", content=[("wix", "wix")]) - mock_tools.download.file.return_value = wix_zip + mock_tools.file.download = MagicMock(return_value=wix_zip_path) # Verify the install. This will trigger a download wix = WiX.verify(mock_tools) @@ -119,7 +117,7 @@ def test_download_wix(mock_tools, tmp_path): mock_tools.os.environ.get.assert_called_with("WIX") # A download was initiated - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url=WIX_DOWNLOAD_URL, download_path=tmp_path / "tools", role="WiX", @@ -127,11 +125,11 @@ def test_download_wix(mock_tools, tmp_path): # The download was unpacked. mock_tools.shutil.unpack_archive.assert_called_with( - os.fsdecode(wix_zip_path), extract_dir=os.fsdecode(wix_path) + filename=os.fsdecode(wix_zip_path), extract_dir=os.fsdecode(wix_path) ) # The zip file was removed - wix_zip.unlink.assert_called_with() + assert not wix_zip_path.exists() # The returned paths are as expected assert wix.heat_exe == tmp_path / "tools/wix/heat.exe" @@ -156,7 +154,7 @@ def test_dont_install(mock_tools, tmp_path): mock_tools.os.environ.get.assert_called_with("WIX") # No download was initiated - mock_tools.download.file.assert_not_called() + mock_tools.file.download.assert_not_called() def test_download_fail(mock_tools, tmp_path): @@ -165,7 +163,7 @@ def test_download_fail(mock_tools, tmp_path): mock_tools.os.environ.get.return_value = None # Mock the download failure - mock_tools.download.file.side_effect = NetworkFailure("mock") + mock_tools.file.download = MagicMock(side_effect=NetworkFailure("mock")) # Verify the install. This will trigger a download with pytest.raises(NetworkFailure, match="Unable to mock"): @@ -175,7 +173,7 @@ def test_download_fail(mock_tools, tmp_path): mock_tools.os.environ.get.assert_called_with("WIX") # A download was initiated - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url=WIX_DOWNLOAD_URL, download_path=tmp_path / "tools", role="WiX", @@ -193,11 +191,9 @@ def test_unpack_fail(mock_tools, tmp_path): # Mock the download wix_path = tmp_path / "tools/wix" - wix_zip_path = os.fsdecode(tmp_path / "tools/wix.zip") - wix_zip = MagicMock() - wix_zip.__fspath__.return_value = wix_zip_path + wix_zip_path = create_zip_file(tmp_path / "tools/wix.zip", content=[("wix", "wix")]) - mock_tools.download.file.return_value = wix_zip + mock_tools.file.download = MagicMock(return_value=wix_zip_path) # Mock an unpack failure mock_tools.shutil.unpack_archive.side_effect = EOFError @@ -211,7 +207,7 @@ def test_unpack_fail(mock_tools, tmp_path): mock_tools.os.environ.get.assert_called_with("WIX") # A download was initiated - mock_tools.download.file.assert_called_with( + mock_tools.file.download.assert_called_with( url=WIX_DOWNLOAD_URL, download_path=tmp_path / "tools", role="WiX", @@ -219,8 +215,8 @@ def test_unpack_fail(mock_tools, tmp_path): # The download was unpacked. mock_tools.shutil.unpack_archive.assert_called_with( - os.fsdecode(wix_zip_path), extract_dir=os.fsdecode(wix_path) + filename=os.fsdecode(wix_zip_path), extract_dir=os.fsdecode(wix_path) ) # The zip file was not removed - assert wix_zip.unlink.call_count == 0 + assert wix_zip_path.exists() diff --git a/tests/integrations/xcode/conftest.py b/tests/integrations/xcode/conftest.py index 0301d7f52..24d007b56 100644 --- a/tests/integrations/xcode/conftest.py +++ b/tests/integrations/xcode/conftest.py @@ -3,7 +3,7 @@ import pytest from briefcase.integrations.base import ToolCache -from briefcase.integrations.download import Download +from briefcase.integrations.file import File from briefcase.integrations.subprocess import Subprocess @@ -13,6 +13,6 @@ def mock_tools(tmp_path, mock_tools) -> ToolCache: # Mock default tools mock_tools.subprocess = MagicMock(spec_set=Subprocess) - mock_tools.download = MagicMock(spec_set=Download) + mock_tools.file.download = MagicMock(spec_set=File.download) return mock_tools diff --git a/tests/platforms/android/gradle/test_open.py b/tests/platforms/android/gradle/test_open.py index d9ebec2ba..2e604243b 100644 --- a/tests/platforms/android/gradle/test_open.py +++ b/tests/platforms/android/gradle/test_open.py @@ -8,7 +8,7 @@ from briefcase.console import Console, Log from briefcase.exceptions import BriefcaseCommandError from briefcase.integrations.android_sdk import AndroidSDK -from briefcase.integrations.download import Download +from briefcase.integrations.file import File from briefcase.integrations.subprocess import Subprocess from briefcase.platforms.android.gradle import GradleOpenCommand @@ -39,7 +39,7 @@ def open_command(tmp_path, first_app_config): ) command.tools.os = MagicMock(spec_set=os) command.tools.subprocess = MagicMock(spec_set=Subprocess) - command.tools.download = MagicMock(spec_set=Download) + command.tools.file.download = MagicMock(spec_set=File.download) # Mock all apps as targeting version 0.3.15 command._briefcase_toml = defaultdict( diff --git a/tests/platforms/linux/appimage/test_build.py b/tests/platforms/linux/appimage/test_build.py index d00c776c7..26360a7d8 100644 --- a/tests/platforms/linux/appimage/test_build.py +++ b/tests/platforms/linux/appimage/test_build.py @@ -99,14 +99,14 @@ def test_verify_tools_wrong_platform(build_command): build_command.tools.host_os = "TestOS" build_command.build_app = mock.MagicMock() - build_command.tools.download.file = mock.MagicMock() + build_command.tools.file.download = mock.MagicMock() # Try to invoke the build with pytest.raises(UnsupportedHostError): build_command() # The download was not attempted - assert build_command.tools.download.file.call_count == 0 + assert build_command.tools.file.download.call_count == 0 # But it failed, so the file won't be made executable... assert build_command.tools.os.chmod.call_count == 0 @@ -121,7 +121,7 @@ def test_verify_tools_download_failure(build_command): delattr(build_command.tools, "linuxdeploy") build_command.build_app = mock.MagicMock() - build_command.tools.download.file = mock.MagicMock( + build_command.tools.file.download = mock.MagicMock( side_effect=NetworkFailure("mock") ) @@ -130,7 +130,7 @@ def test_verify_tools_download_failure(build_command): build_command() # The download was attempted - build_command.tools.download.file.assert_called_with( + build_command.tools.file.download.assert_called_with( url="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage", download_path=build_command.tools.base_path, role="linuxdeploy", @@ -542,7 +542,7 @@ def test_build_appimage_with_support_package_update( build_command.tools.shutil = mock.MagicMock(spec_set=shutil) # Mock downloads so we don't hit the network - build_command.tools.download = mock.MagicMock() + build_command.tools.file.download = mock.MagicMock() # Hard code a support revision so that the download support package is fixed, # and no linuxdeploy plugins. diff --git a/tests/platforms/macOS/app/test_create.py b/tests/platforms/macOS/app/test_create.py index fb29f237a..ae8573aef 100644 --- a/tests/platforms/macOS/app/test_create.py +++ b/tests/platforms/macOS/app/test_create.py @@ -730,7 +730,7 @@ def test_install_support_package( ) # Mock download.file to return a support package - create_command.tools.download.file = mock.MagicMock( + create_command.tools.file.download = mock.MagicMock( side_effect=mock_tgz_download( f"Python-3.{sys.version_info.minor}-macOS-support.b37.tar.gz", [ diff --git a/tests/platforms/windows/app/test_build.py b/tests/platforms/windows/app/test_build.py index 8d765f1d7..7874a87d4 100644 --- a/tests/platforms/windows/app/test_build.py +++ b/tests/platforms/windows/app/test_build.py @@ -30,7 +30,7 @@ def build_command(tmp_path): command.tools.host_arch = "AMD64" command.tools.subprocess = mock.MagicMock(spec_set=Subprocess) command.tools.shutil = mock.MagicMock(spec_set=shutil) - command.tools.download = mock.MagicMock() + command.tools.file.download = mock.MagicMock() command.tools.rcedit = RCEdit(command.tools) return command diff --git a/tests/utils.py b/tests/utils.py index f8c3b6da1..36b9f2dce 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -141,7 +141,7 @@ def mock_file_download(filename, content, mode="w", role=None): use `wb` and provide content as a bitstring if you need to write a binary file. :param role: The role played by the content being downloaded - :returns: a function that can act as a mock side effect for `download.file()` + :returns: a function that can act as a mock side effect for `file.download()` """ def _download_file(url, download_path, role): @@ -157,7 +157,7 @@ def mock_zip_download(filename, content, role=None): create as a side effect :param content: A string containing the content to write. :param role: The role played by the content being downloaded - :returns: a function that can act as a mock side effect for `download.file()` + :returns: a function that can act as a mock side effect for `file.download()` """ def _download_file(url, download_path, role): @@ -170,7 +170,7 @@ def mock_tgz_download(filename, content, role=None): """Create a side effect function that mocks the download of a .tar.gz file. :param content: A string containing the content to write. - :returns: a function that can act as a mock side effect for `download.file()` + :returns: a function that can act as a mock side effect for `file.download()` """ def _download_file(url, download_path, role): From 69231e0510db6b7822d210163c6349d46f89ba52 Mon Sep 17 00:00:00 2001 From: Russell Martin Date: Sun, 9 Jun 2024 21:23:56 -0400 Subject: [PATCH 3/3] Exception message formatting updates and refactor simplifying --- pyproject.toml | 1 + src/briefcase/exceptions.py | 10 +++++----- src/briefcase/integrations/file.py | 30 +++++++++++++----------------- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ef5d476e6..836195aa0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -212,6 +212,7 @@ no-cover-if-is-macos = "'darwin' == os_environ.get('COVERAGE_PLATFORM', sys_plat no-cover-if-not-macos = "'darwin' != os_environ.get('COVERAGE_PLATFORM', sys_platform) and os_environ.get('COVERAGE_EXCLUDE_PLATFORM') != 'disable'" no-cover-if-is-windows = "'win32' == os_environ.get('COVERAGE_PLATFORM', sys_platform) and os_environ.get('COVERAGE_EXCLUDE_PLATFORM') != 'disable'" no-cover-if-not-windows = "'win32' != os_environ.get('COVERAGE_PLATFORM', sys_platform) and os_environ.get('COVERAGE_EXCLUDE_PLATFORM') != 'disable'" +no-cover-if-gte-py312 = "sys_version_info > (3, 12) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" no-cover-if-is-py312 = "python_version == '3.12' and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" no-cover-if-lt-py312 = "sys_version_info < (3, 12) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" no-cover-if-lt-py311 = "sys_version_info < (3, 11) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" diff --git a/src/briefcase/exceptions.py b/src/briefcase/exceptions.py index 352142bd5..3197d5092 100644 --- a/src/briefcase/exceptions.py +++ b/src/briefcase/exceptions.py @@ -34,7 +34,7 @@ def __init__(self, requested, choices): def __str__(self): choices = ", ".join(sorted(self.choices, key=str.lower)) - return f"Invalid format '{self.requested}'; (choose from: {choices})" + return f"Invalid format {self.requested!r}; (choose from: {choices})" class UnsupportedCommandError(BriefcaseError): @@ -156,18 +156,18 @@ def __init__(self, platform): class InvalidSupportPackage(BriefcaseCommandError): def __init__(self, filename): self.filename = filename - super().__init__(f"Unable to unpack support package '{filename}'.") + super().__init__(f"Unable to unpack support package {str(filename)!r}.") class InvalidStubBinary(BriefcaseCommandError): def __init__(self, filename): self.filename = filename - super().__init__(f"Unable to unpack or copy stub binary '{filename}'.") + super().__init__(f"Unable to unpack or copy stub binary {str(filename)!r}.") class MissingAppMetadata(BriefcaseCommandError): def __init__(self, app_bundle_path): - super().__init__(f"Unable to find '{app_bundle_path / 'briefcase.toml'}'") + super().__init__(f"Unable to find {str(app_bundle_path / 'briefcase.toml')!r}") class MissingSupportPackage(BriefcaseCommandError): @@ -229,7 +229,7 @@ class InvalidDeviceError(BriefcaseCommandError): def __init__(self, id_type, device): self.id_type = id_type self.device = device - super().__init__(msg=f"Invalid device {id_type} '{device}'") + super().__init__(msg=f"Invalid device {id_type} {device!r}") class CorruptToolError(BriefcaseCommandError): diff --git a/src/briefcase/integrations/file.py b/src/briefcase/integrations/file.py index 7d2adbde7..3a6856da8 100644 --- a/src/briefcase/integrations/file.py +++ b/src/briefcase/integrations/file.py @@ -71,35 +71,31 @@ def unpack_archive( ): """Unpack an archive file in to a destination directory. + Additional protections for unpacking tar files were introduced in Python 3.12. + Since tarballs can contain anything valid in a UNIX file system, these + protections prevent unpacking potentially dangerous files. This behavior will be + the default in Python 3.14. However, the protections can only be enabled for tar + files...not zip files. + :param filename: File path for the archive :param extract_dir: Target file path for where to unpack archive :param kwargs: additional arguments for shutil.unpack_archive """ + is_zip = Path(filename).suffix == ".zip" + if sys.version_info >= (3, 12): # pragma: no-cover-if-lt-py312 + unpack_kwargs = {"filter": "data"} if not is_zip else {} + else: # pragma: no-cover-if-gte-py312 + unpack_kwargs = {} + self.tools.shutil.unpack_archive( filename=filename, extract_dir=extract_dir, **{ - **self._unpack_archive_kwargs(filename), + **unpack_kwargs, **kwargs, }, ) - def _unpack_archive_kwargs(self, archive_path: str | os.PathLike) -> dict[str, str]: - """Additional options for unpacking archives based on the archive's type. - - Additional protections for unpacking tar files were introduced in Python 3.12. - Since tarballs can contain anything valid in a UNIX file system, these - protections prevent unpacking potentially dangerous files. This behavior will be - the default in Python 3.14. However, the protections can only be enabled for tar - files...not zip files. - """ - is_zip = Path(archive_path).suffix == ".zip" - if sys.version_info >= (3, 12) and not is_zip: # pragma: no-cover-if-lt-py312 - unpack_kwargs = {"filter": "data"} - else: - unpack_kwargs = {} - return unpack_kwargs - def download(self, url: str, download_path: Path, role: str | None = None) -> Path: """Download a given URL, caching it. If it has already been downloaded, return the value that has been cached.