diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py index bdb1b7781..1a0f20dc4 100644 --- a/src/briefcase/commands/base.py +++ b/src/briefcase/commands/base.py @@ -137,6 +137,8 @@ class BaseCommand(ABC): cmd_line = "briefcase {command} {platform} {output_format}" supported_host_os = {"Darwin", "Linux", "Windows"} supported_host_os_reason = f"This command is not supported on {platform.system()}." + exe_extension = "" + # defined by platform-specific subclasses command: str description: str @@ -400,6 +402,22 @@ def binary_executable_path(self, app) -> Path: """ return self.binary_path(app) + def unbuilt_executable_path(self, app) -> Path: + """The path to the unbuilt form of the binary object for the app. + + The pre-built stub binary may need to undergo some manipulation before it can be + used; to mark that this manipulation is required, the "unbuilt" binary has a + "raw" name that doesn't involve any app details. The build step moves the binary + to the final name. + + :param app: The app config + """ + return self.binary_executable_path(app).parent / ( + ("Console" if app.console_app else "GUI") + + "-Stub" + + self.binary_executable_path(app).suffix + ) + def briefcase_toml(self, app: AppConfig) -> dict[str, ...]: """Load the ``briefcase.toml`` file provided by the app template. @@ -444,6 +462,14 @@ def template_target_version(self, app: AppConfig) -> str | None: except KeyError: return None + def stub_binary_revision(self, app: AppConfig) -> str: + """Obtain the stub binary revision that the template requires. + + :param app: The config object for the app + :return: The stub binary revision required by the template. + """ + return self.path_index(app, "stub_binary_revision") + def support_path(self, app: AppConfig) -> Path: """Obtain the path into which the support package should be unpacked. @@ -799,6 +825,12 @@ def _add_update_options( help=f"Update support package for the app{context_label}", ) + parser.add_argument( + "--update-stub", + action="store_true", + help=f"Update stub binary for the app{context_label}", + ) + parser.add_argument( "--update-resources", action="store_true", diff --git a/src/briefcase/commands/build.py b/src/briefcase/commands/build.py index 9c3c7ddc4..f684536f2 100644 --- a/src/briefcase/commands/build.py +++ b/src/briefcase/commands/build.py @@ -28,6 +28,7 @@ def _build_app( update_requirements: bool, update_resources: bool, update_support: bool, + update_stub: bool, no_update: bool, test_mode: bool, **options, @@ -43,6 +44,7 @@ def _build_app( :param update_resources: Should the application resources be updated before building? :param update_support: Should the application support be updated? + :param update_stub: Should the stub binary be updated? :param no_update: Should automated updates be disabled? :param test_mode: Is the app being build in test mode? """ @@ -53,6 +55,7 @@ def _build_app( or update_requirements # An explicit update of requirements has been requested or update_resources # An explicit update of resources has been requested or update_support # An explicit update of app support has been requested + or update_stub # An explicit update of the stub binary has been requested or ( test_mode and not no_update ) # Test mode, but updates have not been disabled @@ -62,6 +65,7 @@ def _build_app( update_requirements=update_requirements, update_resources=update_resources, update_support=update_support, + update_stub=update_stub, test_mode=test_mode, **options, ) @@ -86,6 +90,7 @@ def __call__( update_requirements: bool = False, update_resources: bool = False, update_support: bool = False, + update_stub: bool = False, no_update: bool = False, test_mode: bool = False, **options, @@ -109,6 +114,10 @@ def __call__( raise BriefcaseCommandError( "Cannot specify both --update-support and --no-update" ) + if update_stub: + raise BriefcaseCommandError( + "Cannot specify both --update-stub and --no-update" + ) # Confirm host compatibility, that all required tools are available, # and that the app configuration is finalized. @@ -121,6 +130,7 @@ def __call__( update_requirements=update_requirements, update_resources=update_resources, update_support=update_support, + update_stub=update_stub, no_update=no_update, test_mode=test_mode, **options, @@ -134,6 +144,7 @@ def __call__( update_requirements=update_requirements, update_resources=update_resources, update_support=update_support, + update_stub=update_stub, no_update=no_update, test_mode=test_mode, **full_options(state, options), diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py index 09fb00193..2f00cfc45 100644 --- a/src/briefcase/commands/create.py +++ b/src/briefcase/commands/create.py @@ -16,6 +16,7 @@ InvalidSupportPackage, MissingAppSources, MissingNetworkResourceError, + MissingStubBinary, MissingSupportPackage, RequirementsInstallError, UnsupportedPlatform, @@ -100,6 +101,18 @@ def support_package_url(self, support_revision): + self.support_package_filename(support_revision) ) + def stub_binary_filename(self, support_revision, is_console_app): + """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): + """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) + ) + def icon_targets(self, app: AppConfig): """Obtain the dictionary of icon targets that the template requires. @@ -382,6 +395,120 @@ def _download_support_package(self, app: AppConfig): is_32bit=self.tools.is_32bit_python, ) from e + def cleanup_stub_binary(self, app: AppConfig): + """Clean up an existing application support package. + + :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() + + def install_stub_binary(self, app: AppConfig): + """Install the application stub binary into the "unbuilt" location. + + :param app: The config object for the app + """ + unbuilt_executable_path = self.unbuilt_executable_path(app) + stub_binary_path = self._download_stub_binary(app) + + 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, + ) + else: + self.tools.shutil.copyfile(stub_binary_path, unbuilt_executable_path) + # Ensure the binary is executable + self.tools.os.chmod(unbuilt_executable_path, 0o755) + + def _download_stub_binary(self, app: AppConfig): + try: + # Work out if the app defines a custom override for + # the support package URL. + try: + stub_binary_url = app.stub_binary + custom_stub_binary = True + self.logger.info(f"Using custom stub binary {stub_binary_url}") + try: + # If the app has a custom stub binary *and* a support revision, + # that's an error. + app.stub_binary_revision + self.logger.warning( + "App specifies both a stub binary and a stub binary revision; " + "stub binary revision will be ignored." + ) + except AttributeError: + pass + except AttributeError: + # If the app specifies a support revision, use it; + # otherwise, use the support revision named by the template + try: + stub_binary_revision = app.stub_binary_revision + except AttributeError: + # No support revision specified; use the template-specified version + try: + stub_binary_revision = self.stub_binary_revision(app) + except KeyError: + # No template-specified stub binary + raise MissingStubBinary( + python_version_tag=self.python_version_tag, + platform=self.platform, + host_arch=self.tools.host_arch, + is_32bit=self.tools.is_32bit_python, + ) + + stub_binary_url = self.stub_binary_url( + stub_binary_revision, app.console_app + ) + custom_stub_binary = False + self.logger.info(f"Using stub binary {stub_binary_url}") + + if stub_binary_url.startswith(("https://", "http://")): + if custom_stub_binary: + # If the support package is custom, cache it using a hash of + # the download URL. This is needed to differentiate to support + # packages with the same filename, served at different URLs. + # (or a custom package that collides with an official package name) + download_path = ( + self.data_path + / "stub" + / hashlib.sha256(stub_binary_url.encode("utf-8")).hexdigest() + ) + else: + download_path = self.data_path / "stub" + + # Download the stub binary, caching the result + # in the user's briefcase stub cache directory. + return self.tools.download.file( + url=stub_binary_url, + download_path=download_path, + role="stub binary", + ) + else: + return Path(stub_binary_url) + except MissingNetworkResourceError as e: + # If there is a custom support package, report the missing resource as-is. + if custom_stub_binary: + raise + else: + raise MissingStubBinary( + python_version_tag=self.python_version_tag, + platform=self.platform, + host_arch=self.tools.host_arch, + is_32bit=self.tools.is_32bit_python, + ) from e + def _write_requirements_file( self, app: AppConfig, @@ -770,6 +897,17 @@ def create_app(self, app: AppConfig, test_mode: bool = False, **options): self.logger.info("Installing support package...", prefix=app.app_name) self.install_app_support_package(app=app) + try: + # If the platform uses a stub binary, the template will define a binary + # revision. If this template configuration item doesn't exist, no stub + # binary is required. + self.stub_binary_revision(app) + except KeyError: + pass + else: + self.logger.info("Installing stub binary...", prefix=app.app_name) + self.install_stub_binary(app=app) + # Verify the app after the app template and support package # are in place since the app tools may be dependent on them. self.verify_app(app) diff --git a/src/briefcase/commands/run.py b/src/briefcase/commands/run.py index 5746bec71..bca5afeb6 100644 --- a/src/briefcase/commands/run.py +++ b/src/briefcase/commands/run.py @@ -265,6 +265,7 @@ def __call__( update_requirements: bool = False, update_resources: bool = False, update_support: bool = False, + update_stub: bool = False, no_update: bool = False, test_mode: bool = False, passthrough: list[str] | None = None, @@ -299,6 +300,7 @@ def __call__( or update_requirements # An explicit update of requirements has been requested or update_resources # An explicit update of resources has been requested or update_support # An explicit update of support files has been requested + or update_stub # An explicit update of the stub binary has been requested or (not exec_file.exists()) # Executable binary doesn't exist yet or ( test_mode and not no_update @@ -310,6 +312,7 @@ def __call__( update_requirements=update_requirements, update_resources=update_resources, update_support=update_support, + update_stub=update_stub, no_update=no_update, test_mode=test_mode, **options, diff --git a/src/briefcase/commands/update.py b/src/briefcase/commands/update.py index 4b3809fcc..f00b5c755 100644 --- a/src/briefcase/commands/update.py +++ b/src/briefcase/commands/update.py @@ -20,6 +20,7 @@ def update_app( update_requirements: bool, update_resources: bool, update_support: bool, + update_stub: bool, test_mode: bool, **options, ) -> dict | None: @@ -29,6 +30,7 @@ def update_app( :param update_requirements: Should requirements be updated? :param update_resources: Should extra resources be updated? :param update_support: Should app support be updated? + :param update_stub: Should stub binary be updated? :param test_mode: Should the app be updated in test mode? """ @@ -56,6 +58,19 @@ def update_app( self.cleanup_app_support_package(app=app) self.install_app_support_package(app=app) + if update_stub: + try: + # If the platform uses a stub binary, the template will define a binary + # revision. If this template configuration item doesn't exist, there's + # no stub binary + self.stub_binary_revision(app) + except KeyError: + pass + else: + self.logger.info("Updating stub binary...", prefix=app.app_name) + self.cleanup_stub_binary(app=app) + self.install_stub_binary(app=app) + self.logger.info("Removing unneeded app content...", prefix=app.app_name) self.cleanup_app_content(app=app) @@ -67,6 +82,7 @@ def __call__( update_requirements: bool = False, update_resources: bool = False, update_support: bool = False, + update_stub: bool = False, test_mode: bool = False, **options, ) -> dict | None: @@ -80,6 +96,7 @@ def __call__( update_requirements=update_requirements, update_resources=update_resources, update_support=update_support, + update_stub=update_stub, test_mode=test_mode, **options, ) @@ -91,6 +108,7 @@ def __call__( update_requirements=update_requirements, update_resources=update_resources, update_support=update_support, + update_stub=update_stub, test_mode=test_mode, **full_options(state, options), ) diff --git a/src/briefcase/exceptions.py b/src/briefcase/exceptions.py index c6ba1420b..a89c16520 100644 --- a/src/briefcase/exceptions.py +++ b/src/briefcase/exceptions.py @@ -183,6 +183,25 @@ def __init__(self, python_version_tag, platform, host_arch, is_32bit): ) +class MissingStubBinary(BriefcaseCommandError): + def __init__(self, python_version_tag, platform, host_arch, is_32bit): + self.python_version_tag = python_version_tag + self.platform = platform + self.host_arch = host_arch + self.is_32bit = is_32bit + platform_name = f"{'32 bit ' if is_32bit else ''}{platform}" + super().__init__( + f"""\ +Unable to download {platform_name} stub binary for Python {self.python_version_tag} on {self.host_arch}. + +This is likely because either Python {self.python_version_tag} and/or {self.host_arch} is not yet +supported on {platform_name}. You will need to: + * Use an older version of Python; or + * Compile your own stub binary. +""" + ) + + class RequirementsInstallError(BriefcaseCommandError): def __init__(self, install_hint=""): super().__init__( diff --git a/src/briefcase/platforms/macOS/app.py b/src/briefcase/platforms/macOS/app.py index de1547cc0..6fc37c77f 100644 --- a/src/briefcase/platforms/macOS/app.py +++ b/src/briefcase/platforms/macOS/app.py @@ -97,11 +97,11 @@ def build_app(self, app: AppConfig, **kwargs): """ self.logger.info("Building App...", prefix=app.app_name) - # Move the stub binary in to the final executable location - stub_path = self.binary_path(app) / "Contents/MacOS/Stub" - if stub_path.exists(): + # Move the unbuilt binary in to the final executable location + unbuilt_path = self.unbuilt_executable_path(app) + if unbuilt_path.exists(): with self.input.wait_bar("Renaming stub binary..."): - stub_path.rename(self.binary_executable_path(app)) + unbuilt_path.rename(self.binary_executable_path(app)) if not getattr(app, "universal_build", True): with self.input.wait_bar("Ensuring stub binary is thin..."): diff --git a/src/briefcase/platforms/windows/app.py b/src/briefcase/platforms/windows/app.py index 6e5a40c01..ea2ec53fd 100644 --- a/src/briefcase/platforms/windows/app.py +++ b/src/briefcase/platforms/windows/app.py @@ -55,10 +55,10 @@ def build_app(self, app: BaseConfig, **kwargs): self.logger.info("Building App...", prefix=app.app_name) # Move the stub binary in to the final executable location - stub_path = self.binary_path(app).parent / "Stub.exe" - if stub_path.exists(): + unbuilt_binary_path = self.unbuilt_executable_path(app) + if unbuilt_binary_path.exists(): with self.input.wait_bar("Renaming stub binary..."): - stub_path.rename(self.binary_path(app)) + unbuilt_binary_path.rename(self.binary_executable_path(app)) if hasattr(self.tools, "windows_sdk"): # If an app has been packaged and code signed previously, then the digital