From 4202070471406bab9dbf7d90fc14711acd33e5fb Mon Sep 17 00:00:00 2001 From: Russell Martin Date: Fri, 29 Mar 2024 19:20:20 -0400 Subject: [PATCH] Implement persistent build tracking for more intuitive behavior --- pyproject.toml | 1 + src/briefcase/__main__.py | 77 +-- src/briefcase/commands/base.py | 525 ++++++++++++++- src/briefcase/commands/build.py | 104 ++- src/briefcase/commands/convert.py | 9 +- src/briefcase/commands/create.py | 598 ++++++++++-------- src/briefcase/commands/dev.py | 62 +- src/briefcase/commands/new.py | 4 +- src/briefcase/commands/open.py | 3 +- src/briefcase/commands/package.py | 36 +- src/briefcase/commands/publish.py | 3 +- src/briefcase/commands/run.py | 22 +- src/briefcase/commands/update.py | 105 ++- src/briefcase/config.py | 46 +- src/briefcase/integrations/docker.py | 2 +- src/briefcase/platforms/android/gradle.py | 16 + src/briefcase/platforms/iOS/xcode.py | 5 + src/briefcase/platforms/linux/__init__.py | 6 +- src/briefcase/platforms/linux/appimage.py | 1 + src/briefcase/platforms/linux/flatpak.py | 10 +- src/briefcase/platforms/linux/system.py | 5 + src/briefcase/platforms/macOS/app.py | 5 + src/briefcase/platforms/macOS/xcode.py | 6 + src/briefcase/platforms/web/static.py | 4 + src/briefcase/platforms/windows/app.py | 5 + .../platforms/windows/visualstudio.py | 5 + tests/commands/base/test_app_module_path.py | 16 +- .../commands/create/test_install_app_code.py | 36 +- .../create/test_install_app_requirements.py | 70 +- .../dev/test_install_dev_requirements.py | 10 +- tests/config/test_AppConfig.py | 4 +- tests/platforms/iOS/xcode/test_create.py | 2 +- tests/platforms/iOS/xcode/test_update.py | 2 +- .../linux/test_LocalRequirementsMixin.py | 8 +- tests/platforms/macOS/app/test_create.py | 8 +- 35 files changed, 1273 insertions(+), 548 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a393d121f..1a089a220 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,7 @@ dependencies = [ "rich >= 12.6, < 14.0", "tomli >= 2.0, < 3.0; python_version <= '3.10'", "tomli_w >= 1.0, < 2.0", + "watchdog >= 3.0.0, < 5.0", ] [project.optional-dependencies] diff --git a/src/briefcase/__main__.py b/src/briefcase/__main__.py index 94544c278..fb0883092 100644 --- a/src/briefcase/__main__.py +++ b/src/briefcase/__main__.py @@ -15,48 +15,49 @@ def main(): result = 0 command = None + printer = Printer() console = Console(printer=printer) logger = Log(printer=printer) - try: - Command, extra_cmdline = parse_cmdline(sys.argv[1:], console=console) - command = Command(logger=logger, console=console) - options, overrides = command.parse_options(extra=extra_cmdline) - command.parse_config( - Path.cwd() / "pyproject.toml", - overrides=overrides, - ) - command(**options) - except HelpText as e: - logger.info() - logger.info(str(e)) - result = e.error_code - except BriefcaseWarning as w: - # The case of something that hasn't gone right, but in an - # acceptable way. - logger.warning(str(w)) - result = w.error_code - except BriefcaseTestSuiteFailure as e: - # Test suite status is logged when the test is executed. - # Set the return code, but don't log anything else. - result = e.error_code - except BriefcaseError as e: - logger.error() - logger.error(str(e)) - result = e.error_code - logger.capture_stacktrace() - except Exception: - logger.capture_stacktrace() - raise - except KeyboardInterrupt: - logger.warning() - logger.warning("Aborted by user.") - logger.warning() - result = -42 - if logger.save_log: + + with suppress(KeyboardInterrupt): + try: + Command, extra_cmdline = parse_cmdline(sys.argv[1:], console=console) + command = Command(logger=logger, console=console) + options, overrides = command.parse_options(extra=extra_cmdline) + command.parse_config(Path.cwd() / "pyproject.toml", overrides=overrides) + command(**options) + except HelpText as e: + logger.info() + logger.info(str(e)) + result = e.error_code + except BriefcaseWarning as w: + # The case of something that hasn't gone right, but in an + # acceptable way. + logger.warning(str(w)) + result = w.error_code + except BriefcaseTestSuiteFailure as e: + # Test suite status is logged when the test is executed. + # Set the return code, but don't log anything else. + result = e.error_code + except BriefcaseError as e: + logger.error() + logger.error(str(e)) + result = e.error_code + logger.capture_stacktrace() + except Exception: logger.capture_stacktrace() - finally: - with suppress(KeyboardInterrupt): + raise + except KeyboardInterrupt: + logger.warning() + logger.warning("Aborted by user.") + logger.warning() + result = -42 + if logger.save_log: + logger.capture_stacktrace() + finally: + if command is not None: + command.tracking_save() logger.save_log_to_file(command) return result diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py index 96c842484..0802d8272 100644 --- a/src/briefcase/commands/base.py +++ b/src/briefcase/commands/base.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import hashlib import importlib import importlib.metadata import inspect @@ -8,22 +9,26 @@ import platform import subprocess import sys +import time from abc import ABC, abstractmethod from argparse import RawDescriptionHelpFormatter +from collections.abc import Iterable +from functools import lru_cache from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any +import tomli_w from cookiecutter import exceptions as cookiecutter_exceptions from cookiecutter.repository import is_repo_url from packaging.version import Version from platformdirs import PlatformDirs +from watchdog.utils.dirsnapshot import DirectorySnapshot if sys.version_info >= (3, 11): # pragma: no-cover-if-lt-py311 import tomllib else: # pragma: no-cover-if-gte-py311 import tomli as tomllib -import briefcase from briefcase import __version__ from briefcase.config import AppConfig, GlobalConfig, parse_config from briefcase.console import MAX_TEXT_WIDTH, Console, Log @@ -41,6 +46,27 @@ from briefcase.integrations.subprocess import Subprocess from briefcase.platforms import get_output_formats, get_platforms +if TYPE_CHECKING: + from briefcase.commands import ( + BuildCommand, + CreateCommand, + PackageCommand, + PublishCommand, + RunCommand, + UpdateCommand, + ) + + +def timeit(func): # TODO:PR: remove + def wrapper(*a, **kw): + start_time = time.time() + try: + return func(*a, **kw) + finally: + Log().warning(f"{func.__name__}: {round(time.time() - start_time, 3)}s") + + return wrapper + def create_config(klass, config, msg): try: @@ -134,16 +160,33 @@ class BaseCommand(ABC): # compatibility with that version epoch. An epoch begins when a breaking change is # introduced for a platform such that older versions of a template are incompatible platform_target_version: str | None = None + # platform-specific project metadata fields tracked for changes + tracking_metadata_fields: list[str] = [] + # platform-agnostic project metadata fields tracked for changes + _tracking_base_metadata_fields: list[str] = [ + "author", + "author_email", + "bundle", + "description", + "document_types", + "formal_name", + "license", + "permission", + "project_name", + "url", + "version", + ] def __init__( self, logger: Log, console: Console, - tools: ToolCache = None, - apps: dict = None, - base_path: Path = None, - data_path: Path = None, + tools: ToolCache | None = None, + apps: dict[str, AppConfig] | None = None, + base_path: Path | None = None, + data_path: Path | None = None, is_clone: bool = False, + tracking: dict[AppConfig, dict[str, ...]] = None, ): """Base for all Commands. @@ -157,10 +200,7 @@ def __init__( Command; for instance, RunCommand can invoke UpdateCommand and/or BuildCommand. """ - if base_path is None: - self.base_path = Path.cwd() - else: - self.base_path = base_path + self.base_path = Path.cwd() if base_path is None else base_path self.data_path = self.validate_data_path(data_path) self.apps = {} if apps is None else apps self.is_clone = is_clone @@ -180,6 +220,9 @@ def __init__( self.global_config = None self._briefcase_toml: dict[AppConfig, dict[str, ...]] = {} + self._tracking: dict[AppConfig, dict[str, ...]] = ( + {} if tracking is None else tracking + ) @property def logger(self): @@ -305,41 +348,57 @@ def _command_factory(self, command_name: str): console=self.input, tools=self.tools, is_clone=True, + tracking=self._tracking, ) command.clone_options(self) return command @property - def create_command(self): + def create_command(self) -> CreateCommand: """Create Command factory for the same platform and format.""" return self._command_factory("create") @property - def update_command(self): + def update_command(self) -> UpdateCommand: """Update Command factory for the same platform and format.""" return self._command_factory("update") @property - def build_command(self): + def build_command(self) -> BuildCommand: """Build Command factory for the same platform and format.""" return self._command_factory("build") @property - def run_command(self): + def run_command(self) -> RunCommand: """Run Command factory for the same platform and format.""" return self._command_factory("run") @property - def package_command(self): + def package_command(self) -> PackageCommand: """Package Command factory for the same platform and format.""" return self._command_factory("package") @property - def publish_command(self): + def publish_command(self) -> PublishCommand: """Publish Command factory for the same platform and format.""" return self._command_factory("publish") - def template_cache_path(self, template) -> Path: + @property + @lru_cache + def briefcase_version(self) -> Version: + """Parsed Briefcase version.""" + return Version(__version__) + + @property + @lru_cache + def briefcase_project_cache_path(self) -> Path: + """The path for project-specific information cache.""" + path = self.base_path / ".briefcase" + # TODO:PR: should we go through the trouble to mark hidden on Windows? + path.mkdir(exist_ok=True) + return path + + def template_cache_path(self, template: str) -> Path: """The path where Briefcase keeps template checkouts. :param template: The URL for the template that will be cached locally. @@ -410,6 +469,10 @@ def unbuilt_executable_path(self, app) -> Path: "Stub" + self.binary_executable_path(app).suffix ) + def briefcase_toml_path(self, app: AppConfig) -> Path: + """Path to ``briefcase.toml`` for output format bundle.""" + return self.bundle_path(app) / "briefcase.toml" + def briefcase_toml(self, app: AppConfig) -> dict[str, ...]: """Load the ``briefcase.toml`` file provided by the app template. @@ -420,11 +483,11 @@ def briefcase_toml(self, app: AppConfig) -> dict[str, ...]: return self._briefcase_toml[app] except KeyError: try: - with (self.bundle_path(app) / "briefcase.toml").open("rb") as f: - self._briefcase_toml[app] = tomllib.load(f) + toml = self.briefcase_toml_path(app).read_text(encoding="utf-8") except OSError as e: raise MissingAppMetadata(self.bundle_path(app)) from e else: + self._briefcase_toml[app] = tomllib.loads(toml) return self._briefcase_toml[app] def path_index(self, app: AppConfig, path_name: str) -> str | dict | list: @@ -516,20 +579,20 @@ def app_module_path(self, app: AppConfig) -> Path: """Find the path for the application module for an app. :param app: The config object for the app - :returns: The Path to the dist-info folder. + :returns: The Path to the app module """ app_home = [ path.split("/") - for path in app.sources + for path in app.sources() if path.rsplit("/", 1)[-1] == app.module_name ] - if len(app_home) == 0: + if len(app_home) == 1: + path = Path(self.base_path, *app_home[0]) + elif len(app_home) == 0: raise BriefcaseCommandError( f"Unable to find code for application {app.app_name!r}" ) - elif len(app_home) == 1: - path = Path(str(self.base_path), *app_home[0]) else: raise BriefcaseCommandError( f"Multiple paths in sources found for application {app.app_name!r}" @@ -537,6 +600,10 @@ def app_module_path(self, app: AppConfig) -> Path: return path + def dist_info_path(self, app: AppConfig) -> Path: + """Path to dist-info for the app in the output format build.""" + return self.app_path(app) / f"{app.module_name}-{app.version}.dist-info" + @property def briefcase_required_python_version(self): """The major.minor of the minimum Python version required by Briefcase itself. @@ -783,12 +850,7 @@ def add_default_options(self, parser): help="Save a detailed log to file. By default, this log file is only created for critical errors", ) - def _add_update_options( - self, - parser, - context_label="", - update=True, - ): + def _add_update_options(self, parser, context_label="", update=True): """Internal utility method for adding common update options. :param parser: The parser to which options should be added. @@ -1069,9 +1131,8 @@ def generate_template( ) -> None: # If a branch wasn't supplied through the --template-branch argument, # use the branch derived from the Briefcase version - version = Version(briefcase.__version__) if branch is None: - template_branch = f"v{version.base_version}" + template_branch = f"v{self.briefcase_version.base_version}" else: template_branch = branch @@ -1082,7 +1143,7 @@ def generate_template( { "template_source": template, "template_branch": template_branch, - "briefcase_version": str(version), + "briefcase_version": str(self.briefcase_version.base_version), } ) @@ -1100,7 +1161,7 @@ def generate_template( except TemplateUnsupportedVersion: # Only use the main template if we're on a development branch of briefcase # and the user didn't explicitly specify which branch to use. - if version.dev is None or branch is not None: + if self.briefcase_version.dev is None or branch is not None: raise # Development branches can use the main template. @@ -1115,3 +1176,399 @@ def generate_template( output_path=output_path, extra_context=extra_context, ) + + # ------------------------------ + # Tracking + # ------------------------------ + def tracking_database_path(self, app: AppConfig) -> Path: + """Path to tracking database for the app. + + For most commands, the database lives in the bundle directory for the output + format. Certain commands, such as DevCommand, will store the database elsewhere + since a relevant build directory will not be available. + + Some Commands may raise AttributeError or NotImplementedError. + """ + return self.bundle_path(app) / "tracking.toml" + + def tracking(self, app: AppConfig) -> dict[str, ...]: + """Load the tracking database for the app.""" + try: + return self._tracking[app]["briefcase"]["app"][app.app_name] + except KeyError: + try: + toml = self.tracking_database_path(app).read_text(encoding="utf-8") + except (OSError, AttributeError): + toml = "" + + self._tracking[app] = tomllib.loads(toml) + # ensure [briefcase.app.] table exists + self._tracking[app].setdefault("briefcase", {}) + self._tracking[app]["briefcase"].setdefault("app", {}) + self._tracking[app]["briefcase"]["app"].setdefault(app.app_name, {}) + # return tracking data just for the current app + return self._tracking[app]["briefcase"]["app"][app.app_name] + + def tracking_save(self) -> None: + """Update the persistent tracking database for each app.""" + for app in self.apps.values(): + # skip saving tracking if the command doesn't support it or + # cannot currently define the database path + try: + app_tracking_db_path = self.tracking_database_path(app) + except (AttributeError, NotImplementedError): + continue + # assume significant command failure if the path doesn't + # exist and just skip saving/updating tracking + if not app_tracking_db_path.parent.exists(): + continue + + try: + toml = tomli_w.dumps(self._tracking[app]) + except KeyError: + # skip saving tracking for apps that never loaded it + pass + else: + try: + self.tracking_database_path(app).write_text(toml, encoding="utf-8") + except OSError as e: + self.logger.warning( + f"Failed to update build tracking for {app.app_name!r}: " + f"{type(e).__name__}: {e}" + ) + + def tracking_set(self, app: AppConfig, key: str, value: object) -> None: + """Set a key/value pair in the tracking database for an app.""" + self.tracking(app)[key] = value + + def tracking_get(self, app: AppConfig, key: str) -> Any: + """Retrieve a value for a key from the tracking database for an app.""" + return self.tracking(app)[key] + + @property + @lru_cache + def _tracking_briefcase_version(self): + """The version of Briefcase for tracking. + + This version captures the tagged versions of Briefcase as well as whether a + version of Briefcase is under development. + """ + return ( + f"{self.briefcase_version.base_version}" + f"{'.dev' if self.briefcase_version.dev is not None else ''}" + ) + + def tracking_add_briefcase_version(self, app: AppConfig) -> None: + """Track the version of Briefcase that created an app bundle.""" + self.tracking_set( + app, key="briefcase-version", value=self._tracking_briefcase_version + ) + + def tracking_is_briefcase_version_updated(self, app: AppConfig) -> bool: + """Has the version of Briefcase changed since the app was created?""" + try: + tracked_briefcase_version = self.tracking_get(app, key="briefcase-version") + except KeyError: + return True + else: + return tracked_briefcase_version != self._tracking_briefcase_version + + @property + @lru_cache + def _tracking_python_exe_mtime(self) -> float: + """The modified datetime for the Python interpreter executable. + + Since virtual environments will often symlink the Python exe to the Python that + created the virtual environment, following symlinks is disabled. This allows the + modified datetime to proxy the creation datetime of the virtual environment. + """ + return self.tools.os.stat(sys.executable, follow_symlinks=False).st_mtime + + def tracking_add_python_env(self, app: AppConfig) -> None: + """Track the Python environment used for the app.""" + self.tracking_set( + app, key="python-exe-mtime", value=self._tracking_python_exe_mtime + ) + self.tracking_set(app, key="python-version", value=self.python_version_tag) + + def tracking_is_python_env_updated(self, app: AppConfig) -> bool: + """Has the Python environment changed for the app?""" + try: + tracked_python_mtime = self.tracking_get(app, key="python-exe-mtime") + tracked_python_version = self.tracking_get(app, key="python-version") + except KeyError: + return True + else: + return ( + tracked_python_mtime != self._tracking_python_exe_mtime + or tracked_python_version != self.python_version_tag + ) + + def _tracking_metadata(self, app: AppConfig, field: str) -> object: + """Resolve app metadata field to a value. + + This approach coerces app fields that are explicitly set to None to "" since + None cannot be stored in TOML. It also always stores a value for a metadata + field so there is something to compare against later when evaluating for + changes. + """ + if (value := getattr(app, field, None)) is None: + value = "" + return value + + def tracking_is_metadata_changed(self, app: AppConfig) -> bool: + """Has the project's metadata changed for the app?""" + try: + for field in ( + self.tracking_metadata_fields + self._tracking_base_metadata_fields + ): + current_value = self._tracking_metadata(app, field) + if self.tracking_get(app, key=field) != current_value: + return True + except (KeyError, AttributeError): + return True + + def tracking_add_metadata(self, app: AppConfig): + """Track the project's metadata.""" + for field in ( + self.tracking_metadata_fields + self._tracking_base_metadata_fields + ): + self.tracking_set(app, key=field, value=self._tracking_metadata(app, field)) + + def _tracking_add_instant(self, app: AppConfig, key: str): + """Track a time instant for a specified key.""" + self.tracking_set(app, key=f"{key}-instant", value=time.time()) + + def tracking_add_created_instant(self, app: AppConfig) -> None: + """Track the instant when an app bundle was created.""" + self._tracking_add_instant(app, key="created") + + def tracking_is_created(self, app: AppConfig) -> bool: + """Has the app bundle been created?""" + try: + return self.tracking_get(app, key="created") is not None + except KeyError: + return False + + def tracking_add_built_instant(self, app: AppConfig) -> None: + """Track the instant when an app bundle was built.""" + self._tracking_add_instant(app, key="built") + + def tracking_is_built(self, app: AppConfig) -> bool: + """Has the app bundle been built?""" + try: + return self.tracking_get(app, key="built") is not None + except KeyError: + return False + + @timeit + def tracking_add_requirements( + self, + app: AppConfig, + requires: Iterable[str], + ) -> None: + """Track the requirements installed for the app.""" + requires_hash = self._tracking_fs_hash(filter(is_local_requirement, requires)) + self.tracking_set(app, key="requires-files-hash", value=requires_hash) + self.tracking_set(app, key="requires", value=list(requires)) + + @timeit + def tracking_is_requirements_updated( + self, + app: AppConfig, + requires: Iterable[str], + ) -> bool: + """Have the app's requirements changed since last run?""" + try: + tracked_requires = self.tracking_get(app, key="requires") + except KeyError: + return True + else: + is_requires_changed = tracked_requires != list(requires) + + try: + tracked_requires_hash = self.tracking_get(app, key="requires-files-hash") + except KeyError: + tracked_requires_hash = "" + + requires_hash = self._tracking_fs_hash(filter(is_local_requirement, requires)) + is_hash_changed = tracked_requires_hash != requires_hash + + return is_requires_changed or is_hash_changed + + def _tracking_fs_hash(self, filepaths: Iterable[str | os.PathLike]) -> str: + """Return a hash representing the current state of the filepaths.""" + if not (filepaths := list(filepaths)): + return "" + + h = hashlib.new("md5", usedforsecurity=False) + for filepath in map(os.fsdecode, filepaths): + snapshot = DirectorySnapshot(path=filepath, recursive=True) + # the paths must be added in the same order each time so the same + # hash is produced for the same set of files/dirs + for path in sorted(snapshot.paths): + h.update( + ( + f"{snapshot.inode(path)}" + f"{snapshot.mtime(path)}" + f"{snapshot.size(path)}" + ).encode() + ) + return h.hexdigest() + + @timeit + def tracking_add_sources( + self, + app: AppConfig, + sources: Iterable[str | os.PathLike], + ) -> None: + """Track the sources installed for the app.""" + self.tracking_set( + app, key="sources-files-hash", value=self._tracking_fs_hash(sources) + ) + + @timeit + def tracking_is_source_modified( + self, + app: AppConfig, + sources: Iterable[str | os.PathLike], + ) -> bool: + """Has the app's source been modified since last run?""" + try: + tracked_hash = self.tracking_get(app, key="sources-files-hash") + except KeyError: + return True + else: + return tracked_hash != self._tracking_fs_hash(sources) + + def _tracking_url_file_hash(self, url: str) -> str: + """Generates a hash for a URL if it resolves to a local file path. + + A hash is only calculated if `url` is a filepath. Otherwise, it is assumed the + URL is an HTTP resource and an empty string is returned to be tracked. + """ + if url and (file_path := Path(url)).exists(): + return self._tracking_fs_hash([file_path]) + else: + return "" + + @timeit + def tracking_add_support_package(self, app: AppConfig, support_url: str) -> None: + """Track the support package installed for the app.""" + self.tracking_set(app, key="support-package-url", value=support_url) + self.tracking_set( + app, + key="support-package-hash", + value=self._tracking_url_file_hash(support_url), + ) + + @timeit + def tracking_is_support_package_updated( + self, app: AppConfig, support_url: str + ) -> bool: + """Has the app's support package changed since last run?""" + try: + tracked_support_url = self.tracking_get(app, key="support-package-url") + except KeyError: + return True + + try: + tracked_support_package_hash = self.tracking_get( + app, key="support-package-hash" + ) + except KeyError: + return True + + return ( + tracked_support_url != support_url + or tracked_support_package_hash != self._tracking_url_file_hash(support_url) + ) + + @timeit + def tracking_add_stub_binary(self, app: AppConfig, stub_binary_url: str) -> None: + """Track the stub binary installed for the app.""" + self.tracking_set(app, key="stub-binary-url", value=stub_binary_url) + self.tracking_set( + app, + key="stub-binary-hash", + value=self._tracking_url_file_hash(stub_binary_url), + ) + + @timeit + def tracking_is_stub_binary_updated(self, app: AppConfig, stub_url: str) -> bool: + """Has the app's stub binary changed since last run?""" + try: + tracked_stub_url = self.tracking_get(app, key="stub-binary-url") + except KeyError: + return True + + try: + tracked_stub_hash = self.tracking_get(app, key="stub-binary-hash") + except KeyError: + return True + + return ( + tracked_stub_url != stub_url + or tracked_stub_hash != self._tracking_url_file_hash(stub_url) + ) + + def tracking_add_resources( + self, + app: AppConfig, + resources: Iterable[str | os.PathLike], + ) -> None: + """Track the resources installed for the app.""" + return self.tracking_set( + app, + key="resources-hash", + value=self._tracking_fs_hash(resources), + ) + + @timeit + def tracking_is_resources_updated( + self, + app: AppConfig, + resources: Iterable[str | os.PathLike], + ) -> bool: + """Has the app's resources changed since last run?""" + try: + tracked_resources = self.tracking_get(app, key="resources-hash") + except KeyError: + return True + else: + return tracked_resources != self._tracking_fs_hash(resources) + + +def _has_url(requirement: str) -> bool: + """Determine if the requirement is defined as a URL. + + Detects any of the URL schemes supported by pip + (https://pip.pypa.io/en/stable/topics/vcs-support/). + + :param requirement: The requirement to check + :returns: True if the requirement is a URL supported by pip. + """ + return any( + f"{scheme}:" in requirement + for scheme in ( + ["http", "https", "file", "ftp"] + + ["git+file", "git+https", "git+ssh", "git+http", "git+git", "git"] + + ["hg+file", "hg+http", "hg+https", "hg+ssh", "hg+static-http"] + + ["svn", "svn+svn", "svn+http", "svn+https", "svn+ssh"] + + ["bzr+http", "bzr+https", "bzr+ssh", "bzr+sftp", "bzr+ftp", "bzr+lp"] + ) + ) + + +def is_local_requirement(requirement: str) -> bool: + """Determine if the requirement is a local file path. + + :param requirement: The requirement to check + :returns: True if the requirement is a local file path + """ + # Windows allows both / and \ as a path separator in requirements. + separators = [os.sep] + if os.altsep: + separators.append(os.altsep) + + return any(sep in requirement for sep in separators) and (not _has_url(requirement)) diff --git a/src/briefcase/commands/build.py b/src/briefcase/commands/build.py index f684536f2..76f48d12f 100644 --- a/src/briefcase/commands/build.py +++ b/src/briefcase/commands/build.py @@ -14,16 +14,63 @@ def add_options(self, parser): self._add_update_options(parser, context_label=" before building") self._add_test_options(parser, context_label="Build") - def build_app(self, app: AppConfig, **options): + def build_app(self, app: AppConfig, test_mode: bool, **options): """Build an application. :param app: The application to build + :param test_mode: Is the app being build in test mode? """ # Default implementation; nothing to build. + def check_for_recreate(self, app: AppConfig) -> bool: + """Should the app be re-created because the environment changed?""" + change_desc = "" + + if self.tracking_is_metadata_changed(app): + change_desc = "Important project metadata" + + elif self.tracking_is_briefcase_version_updated(app): + change_desc = "The version of Briefcase" + + elif self.tracking_is_python_env_updated(app): + change_desc = "The version of Python" + + if change_desc != "": + self.logger.info("Environment changes detected", prefix=app.app_name) + self.logger.info( + self.input.textwrap( + f"{change_desc} has changed since the app's bundle was originally " + f"created.\n" + "\n" + "It is recommended to re-create your app after this change. This " + "will overwrite any manual updates to the files in the app build " + "directory." + ) + ) + self.input.prompt() + return self.input.boolean_input("Would you like to do this now") + else: + return False + + def update_tracking(self, app: AppConfig, test_mode: bool): + """Updates the tracking database for a successful build.""" + self.tracking_add_built_instant(app) + # if an app build uses a requirements file, then the app's requirements are + # updated during the build; therefore, a successful build means the requirements + # were successfully reinstalled and need to be updated in the tracking database + try: + self.app_requirements_path(app) + except KeyError: + pass + else: + self.tracking_add_requirements( + app, requires=app.requires(test_mode=test_mode) + ) + def _build_app( self, app: AppConfig, + build: bool, update: bool, update_requirements: bool, update_resources: bool, @@ -32,12 +79,13 @@ def _build_app( no_update: bool, test_mode: bool, **options, - ) -> dict | None: + ) -> dict: """Internal method to invoke a build on a single app. Ensures the app exists, and has been updated (if requested) before attempting to issue the actual build command. :param app: The application to build + :param build: Should the application be built irrespective? :param update: Should the application be updated before building? :param update_requirements: Should the application requirements be updated before building? @@ -48,20 +96,21 @@ def _build_app( :param no_update: Should automated updates be disabled? :param test_mode: Is the app being build in test mode? """ - if not self.bundle_path(app).exists(): - state = self.create_command(app, test_mode=test_mode, **options) - elif ( - update # An explicit update has been requested - 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 - ): + bundle_exists = self.bundle_path(app).exists() + force_recreate = bundle_exists and self.check_for_recreate(app) + + if not bundle_exists or force_recreate: + state = self.create_command( + app, + test_mode=test_mode, + force=force_recreate, + **options, + ) + build = True # always build after creating the app + elif not no_update: state = self.update_command( app, + update_app=update, update_requirements=update_requirements, update_resources=update_resources, update_support=update_support, @@ -70,22 +119,28 @@ def _build_app( **options, ) else: - state = None + state = {} + + if build or (state and state.pop("is_app_updated", False)): + self.verify_app(app) - self.verify_app(app) + state = self.build_app( + app, test_mode=test_mode, **full_options(state, options) + ) + self.update_tracking(app, test_mode=test_mode) - state = self.build_app(app, test_mode=test_mode, **full_options(state, options)) + qualifier = " (test mode)" if test_mode else "" + self.logger.info( + f"Built {self.binary_path(app).relative_to(self.base_path)}{qualifier}", + prefix=app.app_name, + ) - qualifier = " (test mode)" if test_mode else "" - self.logger.info( - f"Built {self.binary_path(app).relative_to(self.base_path)}{qualifier}", - prefix=app.app_name, - ) return state def __call__( self, app: AppConfig | None = None, + build: bool = True, update: bool = False, update_requirements: bool = False, update_resources: bool = False, @@ -119,13 +174,13 @@ def __call__( "Cannot specify both --update-stub and --no-update" ) - # Confirm host compatibility, that all required tools are available, - # and that the app configuration is finalized. + # Finish preparing the AppConfigs and run final checks required to for command self.finalize(app) if app: state = self._build_app( app, + build=build, update=update, update_requirements=update_requirements, update_resources=update_resources, @@ -140,6 +195,7 @@ def __call__( for app_name, app in sorted(self.apps.items()): state = self._build_app( app, + build=build, update=update, update_requirements=update_requirements, update_resources=update_resources, diff --git a/src/briefcase/commands/convert.py b/src/briefcase/commands/convert.py index 48c6c9652..092485e3a 100644 --- a/src/briefcase/commands/convert.py +++ b/src/briefcase/commands/convert.py @@ -690,6 +690,7 @@ def convert_app( cookiecutter. :param template: The cookiecutter template to use. :param template_branch: The git branch that the template should use. + :param project_overrides: Project configuration overrides from CLI """ self.input.prompt() self.input.prompt("Let's setup an existing project as a Briefcase app!") @@ -735,7 +736,7 @@ def convert_app( ) def validate_pyproject_file(self) -> None: - """Cannot setup new app if it already has briefcase settings in pyproject.""" + """Cannot set up new app if it already has briefcase settings in pyproject.""" if not (self.base_path / "pyproject.toml").exists(): raise BriefcaseCommandError( "Cannot automatically set up Briefcase for a project without a " @@ -757,13 +758,13 @@ def __call__( project_overrides: list[str] = None, **options, ): - # Confirm host compatibility, and that all required tools are available. - # There are no apps, so finalize() will be a no op on app configurations. + # Finish preparing the AppConfigs and run final checks required to for command + # (There are no apps, so finalize() will be a no op on app configurations) self.finalize() self.validate_pyproject_file() - # Setup the app for briefcase + # Set up the app for briefcase with TemporaryDirectory() as tmp_path: tmp_path = Path(tmp_path) self.convert_app( diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py index b2a17e262..0d9aecc76 100644 --- a/src/briefcase/commands/create.py +++ b/src/briefcase/commands/create.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import hashlib import os import platform @@ -10,6 +11,7 @@ from pathlib import Path import briefcase +from briefcase.commands.base import is_local_requirement from briefcase.config import AppConfig from briefcase.exceptions import ( BriefcaseCommandError, @@ -207,9 +209,6 @@ def generate_app_template(self, app: AppConfig): :param app: The config object for the app """ - # If the app config doesn't explicitly define a template, - # use a default template. - # Construct a template context from the app configuration. extra_context = { key: value @@ -221,7 +220,7 @@ def generate_app_template(self, app: AppConfig): extra_context.pop("template") extra_context.pop("template_branch") - # Augment with some extra fields. + # Augment with some extra fields extra_context.update( { # Ensure the output format is in the case we expect @@ -245,7 +244,7 @@ def generate_app_template(self, app: AppConfig): # Add in any extra template context to support permissions extra_context.update(self.permissions_context(app, self._x_permissions(app))) - # Add in any extra template context required by the output format. + # Add in any extra template context required by the output format extra_context.update(self.output_format_template_context(app)) # Create the platform directory (if it doesn't already exist) @@ -259,7 +258,7 @@ def generate_app_template(self, app: AppConfig): extra_context=extra_context, ) - def _unpack_support_package(self, support_file_path, support_path): + def _unpack_support_package(self, support_file_path: Path, support_path: Path): """Unpack a support package into a specific location. :param support_file_path: The path to the support file to be unpacked. @@ -305,23 +304,49 @@ def install_app_support_package(self, app: AppConfig): :param app: The config object for the app """ + support_install_path, support_url, custom = self._app_support_package(app) + + if support_install_path is None: + self.logger.info("No support package required.") + self.tracking_add_support_package(app, support_url="") + else: + self.logger.info( + f"Using{' custom' if custom else ''} support package {support_url}" + ) + support_file_path = self._resolve_support_package_url(support_url, custom) + self._unpack_support_package(support_file_path, support_install_path) + self.tracking_add_support_package(app, support_url=support_url) + + def _app_support_package( + self, + app: AppConfig, + warn_user: bool = True, + ) -> tuple[Path | None, str, bool]: + """Derive support package download and install locations. + + Raises MissingSupportPackage if app does not define a support package. + + :param app: The config object for the app + :param warn_user: Disable warnings for ignored app support configuration + :returns: A tuple of the: + [0] ``Path`` where the support package should be installed; will be ``None`` + if the app does not require a support package + [1] string for filesystem path or URL for support package archive + [2] ``True``/``False`` for whether the support package is custom and not + the Briefcase-provided package + """ + # If the app does not define a filesystem location to install the support + # package in to the app, one is not required try: support_path = self.support_path(app) except KeyError: - self.logger.info("No support package required.") - else: - support_file_path = self._download_support_package(app) - self._unpack_support_package(support_file_path, support_path) + return None, "", False - def _download_support_package(self, app: AppConfig): try: - # Work out if the app defines a custom override for - # the support package URL. - try: - support_package_url = app.support_package - custom_support_package = True - self.logger.info(f"Using custom support package {support_package_url}") - try: + support_url = app.support_package + custom_support = True + if warn_user: + with contextlib.suppress(AttributeError): # If the app has a custom support package *and* a support revision, # that's an error. app.support_revision @@ -329,66 +354,76 @@ def _download_support_package(self, app: AppConfig): "App specifies both a support package and a support revision; " "support 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: + support_revision = app.support_revision except AttributeError: - # If the app specifies a support revision, use it; - # otherwise, use the support revision named by the template + # No support revision specified; use the template-specified version try: - support_revision = app.support_revision - except AttributeError: - # No support revision specified; use the template-specified version - try: - support_revision = self.support_revision(app) - except KeyError: - # No template-specified support revision - raise MissingSupportPackage( - python_version_tag=self.python_version_tag, - platform=self.platform, - host_arch=self.tools.host_arch, - is_32bit=self.tools.is_32bit_python, - ) - - support_package_url = self.support_package_url(support_revision) - custom_support_package = False - self.logger.info(f"Using support package {support_package_url}") - - if support_package_url.startswith(("https://", "http://")): - if custom_support_package: - # 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 - / "support" - / hashlib.sha256( - support_package_url.encode("utf-8") - ).hexdigest() + support_revision = self.support_revision(app) + except KeyError: + # No template-specified support revision + raise MissingSupportPackage( + python_version_tag=self.python_version_tag, + platform=self.platform, + host_arch=self.tools.host_arch, + is_32bit=self.tools.is_32bit_python, ) - else: - download_path = self.data_path / "support" + support_url = self.support_package_url(support_revision) + custom_support = False + + return support_path, support_url, custom_support + + def _resolve_support_package_url(self, support_url: str, custom: bool) -> Path: + """Resolve a filesystem location for the support package. + + The support package for an app can be either a remote HTTP resource or a local + filesystem path. If the support package is a remote address, the archive is + downloaded and cached in the Briefcase data directory. + + :param support_url: URL or filepath to support package + :param custom: ``True`` if user is not using a Briefcase-provided support package + :returns: ``Path`` for archive of support package to install in to the app + """ + if support_url.startswith(("https://", "http://")): + if custom: + # 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 + / "support" + / hashlib.sha256(support_url.encode("utf-8")).hexdigest() + ) + else: + download_path = self.data_path / "support" + + try: # Download the support file, caching the result # in the user's briefcase support cache directory. return self.tools.file.download( - url=support_package_url, + url=support_url, download_path=download_path, role="support package", ) - else: - return Path(support_package_url) - except MissingNetworkResourceError as e: - # If there is a custom support package, report the missing resource as-is. - if custom_support_package: - raise - else: - raise MissingSupportPackage( - 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 + except MissingNetworkResourceError as e: + # If there is a custom support package, report the missing resource as-is. + if custom: + raise + else: + raise MissingSupportPackage( + 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 + + else: + return Path(support_url) def cleanup_stub_binary(self, app: AppConfig): """Clean up an existing application support package. @@ -404,12 +439,23 @@ def install_stub_binary(self, app: AppConfig): :param app: The config object for the app """ - unbuilt_executable_path = self.unbuilt_executable_path(app) - stub_binary_path = self._download_stub_binary(app) + # 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. + try: + self.stub_binary_revision(app) + except KeyError: + return + + self.logger.info("Installing stub binary...", prefix=app.app_name) + + stub_install_path, stub_url, custom_stub = self._stub_binary(app) + stub_binary_path = self._resolve_stub_binary_url(stub_url, custom_stub) with self.input.wait_bar("Installing stub binary..."): + self.logger.info(f"Using stub binary {stub_url}") # Ensure the folder for the stub binary exists - unbuilt_executable_path.parent.mkdir(exist_ok=True, parents=True) + stub_install_path.parent.mkdir(exist_ok=True, parents=True) # Install the stub binary into the unbuilt location. # Allow for both raw and compressed artefacts. @@ -417,29 +463,42 @@ def install_stub_binary(self, app: AppConfig): if self.tools.file.is_archive(stub_binary_path): self.tools.file.unpack_archive( stub_binary_path, - extract_dir=unbuilt_executable_path.parent, + extract_dir=stub_install_path.parent, ) elif stub_binary_path.is_file(): - self.tools.shutil.copyfile( - stub_binary_path, unbuilt_executable_path - ) + self.tools.shutil.copyfile(stub_binary_path, stub_install_path) else: raise InvalidStubBinary(stub_binary_path) except (shutil.ReadError, EOFError, OSError) as e: raise InvalidStubBinary(stub_binary_path) from e else: # Ensure the binary is executable - self.tools.os.chmod(unbuilt_executable_path, 0o755) + self.tools.os.chmod(stub_install_path, 0o755) + + self.tracking_add_stub_binary(app, stub_binary_url=stub_url) + + def _stub_binary( + self, + app: AppConfig, + warn_user: bool = True, + ) -> tuple[Path, str, bool]: + """Derive stub binary download and install locations. + + :param app: The config object for the app + :param warn_user: Disable warnings for ignored stub binary configuration + :returns: A tuple of the: + [0] ``Path`` where the stub binary should be installed + [1] string for filesystem path or URL for stub binary archive/file + [2] ``True``/``False`` for whether the stub binary is custom and not + the Briefcase-provided package + """ + stub_install_path = self.unbuilt_executable_path(app) - def _download_stub_binary(self, app: AppConfig) -> Path: 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: + stub_url: str = app.stub_binary + custom_stub = True + if warn_user: + with contextlib.suppress(AttributeError): # If the app has a custom stub binary *and* a support revision, # that's an error. app.stub_binary_revision @@ -447,37 +506,36 @@ def _download_stub_binary(self, app: AppConfig) -> Path: "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. This value *must* exist, as + # stub binary handling won't be triggered at all unless it is present. + try: + stub_binary_revision = app.stub_binary_revision except AttributeError: - # If the app specifies a support revision, use it; otherwise, use the - # support revision named by the template. This value *must* exist, as - # stub binary handling won't be triggered at all unless it is present. - try: - stub_binary_revision = app.stub_binary_revision - except AttributeError: - stub_binary_revision = self.stub_binary_revision(app) + stub_binary_revision = self.stub_binary_revision(app) - 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}") + stub_url = self.stub_binary_url(stub_binary_revision, app.console_app) + custom_stub = False - 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" + return stub_install_path, stub_url, custom_stub + + def _resolve_stub_binary_url(self, stub_binary_url: str, custom_stub_binary: bool): + 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" + try: # Download the stub binary, caching the result # in the user's briefcase stub cache directory. return self.tools.file.download( @@ -485,19 +543,19 @@ def _download_stub_binary(self, app: AppConfig) -> Path: 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 + 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 + else: + return Path(stub_binary_url) def _write_requirements_file( self, @@ -512,7 +570,6 @@ def _write_requirements_file( :param requirements_path: The full path to a requirements.txt file that will be written. """ - with self.input.wait_bar("Writing requirements file..."): with requirements_path.open("w", encoding="utf-8") as f: if requires: @@ -523,7 +580,7 @@ def _write_requirements_file( # If the requirement is a local path, convert it to # absolute, because Flatpak moves the requirements file # to a different place before using it. - if _is_local_requirement(requirement): + if is_local_requirement(requirement): # We use os.path.abspath() rather than Path.resolve() # because we *don't* want Path's symlink resolving behavior. requirement = os.path.abspath(self.base_path / requirement) @@ -608,7 +665,7 @@ def _install_app_requirements( :param requires: The list of requirements to install :param app_packages_path: The full path of the app_packages folder into which requirements should be installed. - :param progress_message: The waitbar progress message to display to the user. + :param progress_message: The Wait Bar progress message to display to the user. :param pip_kwargs: Any additional keyword arguments to pass to the subprocess when invoking pip. """ @@ -646,23 +703,34 @@ def install_app_requirements(self, app: AppConfig, test_mode: bool): :param app: The config object for the app :param test_mode: Should the test requirements be installed? """ - requires = app.requires.copy() if app.requires else [] - if test_mode and app.test_requires: - requires.extend(app.test_requires) + requires = app.requires(test_mode=test_mode) + requirements_path = app_packages_path = None try: requirements_path = self.app_requirements_path(app) - self._write_requirements_file(app, requires, requirements_path) except KeyError: try: app_packages_path = self.app_packages_path(app) - self._install_app_requirements(app, requires, app_packages_path) except KeyError as e: raise BriefcaseCommandError( "Application path index file does not define " "`app_requirements_path` or `app_packages_path`" ) from e + if requirements_path: + self._write_requirements_file(app, requires, requirements_path) + else: + try: + self._install_app_requirements(app, requires, app_packages_path) + except BaseException: + # Installing the app's requirements will delete any currently installed + # requirements; so, if anything goes wrong, clear the tracking info to + # ensure the requirements are installed on the next run. + self.tracking_add_requirements(app, requires=[]) + raise + else: + self.tracking_add_requirements(app, requires=requires) + def install_app_code(self, app: AppConfig, test_mode: bool): """Install the application code into the bundle. @@ -675,9 +743,7 @@ def install_app_code(self, app: AppConfig, test_mode: bool): self.tools.shutil.rmtree(app_path) self.tools.os.mkdir(app_path) - sources = app.sources.copy() if app.sources else [] - if test_mode and app.test_sources: - sources.extend(app.test_sources) + sources = app.sources(test_mode=test_mode) # Install app code. if sources: @@ -696,14 +762,83 @@ def install_app_code(self, app: AppConfig, test_mode: bool): else: self.logger.info(f"No sources defined for {app.app_name}.") - # Write the dist-info folder for the application. - write_dist_info( - app=app, - dist_info_path=self.app_path(app) - / f"{app.module_name}-{app.version}.dist-info", - ) + self.tracking_add_sources(app, sources=sources) + + # Write the dist-info folder for the application + write_dist_info(app=app, dist_info_path=self.dist_info_path(app)) + + def install_app_resources(self, app: AppConfig): + """Install the application resources (such as icons and splash screens) into the + bundle. + + :param app: The config object for the app + """ + resources = self._resolve_app_resources(app, do_install=True) + self.tracking_add_resources(app, resources=resources.values()) + + def _resolve_app_resources( + self, + app: AppConfig, + do_install: bool = True, + ) -> dict[str, Path]: + """Resolve app resource files to their targets.""" + resource_map = {} + + for variant_or_size, targets in self.icon_targets(app).items(): + try: + # Treat the targets as a dictionary of sizes; + # if there's no `items`, then it's an icon without variants. + for size, target in targets.items(): + resource_map.update( + self._resolve_image( + "application icon", + source=app.icon, + variant=variant_or_size, + size=size, + target=self.bundle_path(app) / target, + do_install=do_install, + ) + ) + except AttributeError: + # Either a single variant, or a single size. + resource_map.update( + self._resolve_image( + "application icon", + source=app.icon, + variant=None, + size=variant_or_size, + target=self.bundle_path(app) / targets, + do_install=do_install, + ) + ) + + # Briefcase v0.3.18 - splash screens deprecated. + if getattr(app, "splash", None) and do_install: + self.logger.warning() + self.logger.warning( + "Splash screens are now configured based on the icon. " + "The splash configuration will be ignored." + ) + + for extension, doctype in self.document_type_icon_targets(app).items(): + self.logger.info() + for size, target in doctype.items(): + resource_map.update( + self._resolve_image( + f"icon for .{extension} documents", + size=size, + source=app.document_types[extension]["icon"], + variant=None, + target=self.bundle_path(app) / target, + do_install=do_install, + ) + ) - def install_image(self, role, variant, size, source, target): + return resource_map + + def _resolve_image( + self, role, variant, size, source, target, do_install=True + ) -> dict[str, Path]: """Install an icon/image of the requested size at a target location, using the source images defined by the app config. @@ -717,7 +852,10 @@ def install_image(self, role, variant, size, source, target): modifier; these will be added based on the requested target and variant. :param target: The full path where the image should be installed. + :param do_install: Copy image in to the app bundle """ + image = {} + if source is not None: if size is None: if variant is None: @@ -730,10 +868,10 @@ def install_image(self, role, variant, size, source, target): except TypeError: source_filename = f"{source}-{variant}{target.suffix}" except KeyError: - self.logger.info( - f"Unknown variant {variant!r} for {role}; using default" - ) - return + if do_install: + self.logger.info( + f"Unknown variant {variant!r} for {role}; using default" + ) else: if variant is None: # An annoying edge case is the case of an unsized variant. @@ -756,71 +894,28 @@ def install_image(self, role, variant, size, source, target): except TypeError: source_filename = f"{source}-{variant}-{size}{target.suffix}" except KeyError: - self.logger.info( - f"Unknown variant {variant!r} for {size}px {role}; using default" - ) - return - - full_source = self.base_path / source_filename - if full_source.exists(): - with self.input.wait_bar( - f"Installing {source_filename} as {full_role}..." - ): - # Make sure the target directory exists - target.parent.mkdir(parents=True, exist_ok=True) - # Copy the source image to the target location - self.tools.shutil.copy(full_source, target) + if do_install: + self.logger.info( + f"Unknown variant {variant!r} for {size}px {role}; using default" + ) + + if (full_source := self.base_path / source_filename).exists(): + if do_install: + with self.input.wait_bar( + f"Installing {source_filename} as {full_role}..." + ): + # Make sure the target directory exists + target.parent.mkdir(parents=True, exist_ok=True) + # Copy the source image to the target location + self.tools.shutil.copy(full_source, target) + image[full_role] = full_source else: - self.logger.info( - f"Unable to find {source_filename} for {full_role}; using default" - ) - - def install_app_resources(self, app: AppConfig): - """Install the application resources (such as icons and splash screens) into the - bundle. - - :param app: The config object for the app - """ - for variant_or_size, targets in self.icon_targets(app).items(): - try: - # Treat the targets as a dictionary of sizes; - # if there's no `items`, then it's an icon without variants. - for size, target in targets.items(): - self.install_image( - "application icon", - source=app.icon, - variant=variant_or_size, - size=size, - target=self.bundle_path(app) / target, + if do_install: + self.logger.info( + f"Unable to find {source_filename} for {full_role}; using default" ) - except AttributeError: - # Either a single variant, or a single size. - self.install_image( - "application icon", - source=app.icon, - variant=None, - size=variant_or_size, - target=self.bundle_path(app) / targets, - ) - - # Briefcase v0.3.18 - splash screens deprecated. - if getattr(app, "splash", None): - self.logger.warning() - self.logger.warning( - "Splash screens are now configured based on the icon. " - "The splash configuration will be ignored." - ) - for extension, doctype in self.document_type_icon_targets(app).items(): - self.logger.info() - for size, target in doctype.items(): - self.install_image( - f"icon for .{extension} documents", - size=size, - source=app.document_types[extension]["icon"], - variant=None, - target=self.bundle_path(app) / target, - ) + return image def cleanup_app_content(self, app: AppConfig): """Remove any content not needed by the final app bundle. @@ -858,26 +953,42 @@ def cleanup_app_content(self, app: AppConfig): self.logger.verbose(f"Removing {relative_path}") path.unlink() - def create_app(self, app: AppConfig, test_mode: bool = False, **options): + def update_tracking(self, app: AppConfig): + """Updates the tracking database when an app is successfully created.""" + self.tracking_add_created_instant(app) + self.tracking_add_briefcase_version(app) + self.tracking_add_python_env(app) + self.tracking_add_metadata(app) + + def create_app( + self, + app: AppConfig, + test_mode: bool = False, + force: bool = False, + **options, + ): """Create an application bundle. :param app: The config object for the app :param test_mode: Should the app be updated in test mode? (default: False) + :param force: Should the app be created if it already exists? (default: False) """ if not app.supported: raise UnsupportedPlatform(self.platform) bundle_path = self.bundle_path(app) + if bundle_path.exists(): - self.logger.info() - confirm = self.input.boolean_input( - f"Application {app.app_name!r} already exists; overwrite", default=False - ) - if not confirm: - self.logger.error( - f"Aborting creation of app {app.app_name!r}; existing application will not be overwritten." - ) - return + if not force: + self.logger.info() + if not self.input.boolean_input( + f"Application {app.app_name!r} already exists; overwrite", + default=False, + ): + raise BriefcaseCommandError( + f"Aborting re-creation of app {app.app_name!r}", + skip_logfile=True, + ) self.logger.info("Removing old application bundle...", prefix=app.app_name) self.tools.shutil.rmtree(bundle_path) @@ -887,16 +998,7 @@ 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) + 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. @@ -914,6 +1016,8 @@ def create_app(self, app: AppConfig, test_mode: bool = False, **options): self.logger.info("Removing unneeded app content...", prefix=app.app_name) self.cleanup_app_content(app=app) + self.update_tracking(app=app) + self.logger.info( f"Created {bundle_path.relative_to(self.base_path)}", prefix=app.app_name, @@ -937,8 +1041,7 @@ def __call__( app: AppConfig | None = None, **options, ) -> dict | None: - # Confirm host compatibility, that all required tools are available, - # and that the app configuration is finalized. + # Finish preparing the AppConfigs and run final checks required to for command self.finalize(app) if app: @@ -949,38 +1052,3 @@ def __call__( state = self.create_app(app, **full_options(state, options)) return state - - -def _has_url(requirement): - """Determine if the requirement is defined as a URL. - - Detects any of the URL schemes supported by pip - (https://pip.pypa.io/en/stable/topics/vcs-support/). - - :param requirement: The requirement to check - :returns: True if the requirement is a URL supported by pip. - """ - return any( - f"{scheme}:" in requirement - for scheme in ( - ["http", "https", "file", "ftp"] - + ["git+file", "git+https", "git+ssh", "git+http", "git+git", "git"] - + ["hg+file", "hg+http", "hg+https", "hg+ssh", "hg+static-http"] - + ["svn", "svn+svn", "svn+http", "svn+https", "svn+ssh"] - + ["bzr+http", "bzr+https", "bzr+ssh", "bzr+sftp", "bzr+ftp", "bzr+lp"] - ) - ) - - -def _is_local_requirement(requirement): - """Determine if the requirement is a local file path. - - :param requirement: The requirement to check - :returns: True if the requirement is a local file path - """ - # Windows allows both / and \ as a path separator in requirements. - separators = [os.sep] - if os.altsep: - separators.append(os.altsep) - - return any(sep in requirement for sep in separators) and (not _has_url(requirement)) diff --git a/src/briefcase/commands/dev.py b/src/briefcase/commands/dev.py index 066eb8e14..7c579e7ec 100644 --- a/src/briefcase/commands/dev.py +++ b/src/briefcase/commands/dev.py @@ -74,16 +74,26 @@ def add_options(self, parser): help="Run the app in test mode", ) - def install_dev_requirements(self, app: AppConfig, **options): + def dist_info_path(self, app: AppConfig) -> Path: + """Path to dist-info for the app where the app source lives.""" + return self.app_module_path(app).parent / f"{app.module_name}.dist-info" + + def tracking_database_path(self, app: AppConfig) -> Path: + """Path to tracking database when running in dev mode.""" + return self.briefcase_project_cache_path / "tracking.toml" + + def update_tracking(self, app: AppConfig) -> None: + self.tracking_add_python_env(app) + + def install_dev_requirements(self, app: AppConfig, test_mode: bool, **options): """Install the requirements for the app dev. This will always include test requirements, if specified. :param app: The config object for the app + :param test_mode: Whether the test suite is being run, rather than the app? """ - requires = app.requires if app.requires else [] - if app.test_requires: - requires.extend(app.test_requires) + requires = app.requires(test_mode=test_mode) if requires: with self.input.wait_bar("Installing dev requirements..."): @@ -106,6 +116,8 @@ def install_dev_requirements(self, app: AppConfig, **options): ) except subprocess.CalledProcessError as e: raise RequirementsInstallError() from e + else: + self.tracking_add_requirements(app, requires=requires) else: self.logger.info("No application requirements.") @@ -173,6 +185,8 @@ def run_dev_app( clean_output=False, ) + self.update_tracking(app) + def get_environment(self, app, test_mode: bool): # Create a shell environment where PYTHONPATH points to the source # directories described by the app config. @@ -215,31 +229,35 @@ def __call__( raise BriefcaseCommandError( f"Project doesn't define an application named '{appname}'" ) from e - else: raise BriefcaseCommandError( "Project specifies more than one application; use --app to specify which one to start." ) - # Confirm host compatibility, that all required tools are available, - # and that the app configuration is finalized. + + # Finish preparing the AppConfigs and run final checks required to for command self.finalize(app) self.verify_app(app) - # Look for the existence of a dist-info file. - # If one exists, assume that the requirements have already been - # installed. If a dependency update has been manually requested, - # do it regardless. - dist_info_path = ( - self.app_module_path(app).parent / f"{app.module_name}.dist-info" - ) - if not run_app: - # If we are not running the app, it means we should update requirements. - update_requirements = True - if update_requirements or not dist_info_path.exists(): + # If we are not running the app, it means we should update requirements. + update_requirements |= not run_app + + if not update_requirements: + update_requirements = self.tracking_is_python_env_updated(app) + if update_requirements: # TODO:PR: delete + self.logger.warning("Python environment change detected") + + if not update_requirements: + update_requirements = self.tracking_is_requirements_updated( + app, requires=app.requires(test_mode=test_mode) + ) + if update_requirements: # TODO:PR: delete + self.logger.warning("Requirements change detected") + + if update_requirements: self.logger.info("Installing requirements...", prefix=app.app_name) - self.install_dev_requirements(app, **options) - write_dist_info(app, dist_info_path) + self.install_dev_requirements(app, test_mode, **options) + write_dist_info(app, self.dist_info_path(app)) if run_app: if test_mode: @@ -248,10 +266,10 @@ def __call__( ) else: self.logger.info("Starting in dev mode...", prefix=app.app_name) - env = self.get_environment(app, test_mode=test_mode) + return self.run_dev_app( app, - env, + env=self.get_environment(app, test_mode=test_mode), test_mode=test_mode, passthrough=[] if passthrough is None else passthrough, **options, diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py index 4e4997855..c8b28688a 100644 --- a/src/briefcase/commands/new.py +++ b/src/briefcase/commands/new.py @@ -762,8 +762,8 @@ def __call__( project_overrides: list[str] = None, **options, ): - # Confirm host compatibility, and that all required tools are available. - # There are no apps, so finalize() will be a no op on app configurations. + # Finish preparing the AppConfigs and run final checks required to for command + # (There are no apps, so finalize() will be a no op on app configurations) self.finalize() return self.new_app( diff --git a/src/briefcase/commands/open.py b/src/briefcase/commands/open.py index 5d7b34629..76c025c7a 100644 --- a/src/briefcase/commands/open.py +++ b/src/briefcase/commands/open.py @@ -49,8 +49,7 @@ def __call__( app: AppConfig | None = None, **options, ): - # Confirm host compatibility, that all required tools are available, - # and that the app configuration is finalized. + # Finish preparing the AppConfigs and run final checks required to for command self.finalize(app) if app: diff --git a/src/briefcase/commands/package.py b/src/briefcase/commands/package.py index 403481907..9011a676b 100644 --- a/src/briefcase/commands/package.py +++ b/src/briefcase/commands/package.py @@ -58,28 +58,16 @@ def _package_app( :param update: Should the application be updated (and rebuilt) first? :param packaging_format: The format of the packaging artefact to create. """ - - template_file = self.bundle_path(app) - binary_file = self.binary_path(app) - if not template_file.exists(): - state = self.create_command(app, **options) - state = self.build_command(app, **full_options(state, options)) - elif update: - # If we're updating for packaging, update everything. - # This ensures everything in the packaged artefact is up to date, - # and is in a production state - state = self.update_command( - app, - update_resources=True, - update_requirements=True, - update_support=True, - **options, - ) - state = self.build_command(app, **full_options(state, options)) - elif not binary_file.exists(): - state = self.build_command(app, **options) - else: - state = None + # Update and build the app if necessary + state = self.build_command( + app, + build=not self.tracking_is_built, + update=update, + update_resources=update, + update_requirements=update, + update_support=update, + **options, + ) # Annotate the packaging format onto the app app.packaging_format = packaging_format @@ -100,6 +88,7 @@ def _package_app( filename = self.distribution_path(app).relative_to(self.base_path) self.logger.info(f"Packaged {filename}", prefix=app.app_name) + return state def add_options(self, parser): @@ -139,8 +128,7 @@ def __call__( update: bool = False, **options, ) -> dict | None: - # Confirm host compatibility, that all required tools are available, - # and that the app configuration is finalized. + # Finish preparing the AppConfigs and run final checks required to for command self.finalize(app) if app: diff --git a/src/briefcase/commands/publish.py b/src/briefcase/commands/publish.py index c710fdee0..7f41ce665 100644 --- a/src/briefcase/commands/publish.py +++ b/src/briefcase/commands/publish.py @@ -60,8 +60,7 @@ def __call__( channel: str | None = None, **options, ): - # Confirm host compatibility, that all required tools are available, - # and that all app configurations are finalized. + # Finish preparing the AppConfigs and run final checks required to for command self.finalize() # Check the apps have been built first. diff --git a/src/briefcase/commands/run.py b/src/briefcase/commands/run.py index bca5afeb6..20fe181c2 100644 --- a/src/briefcase/commands/run.py +++ b/src/briefcase/commands/run.py @@ -285,29 +285,17 @@ def __call__( ) from e else: raise BriefcaseCommandError( - "Project specifies more than one application; use --app to specify which one to start." + "Project specifies more than one application; " + "use --app to specify which one to start." ) - # Confirm host compatibility, that all required tools are available, - # and that the app configuration is finalized. + # Finish preparing the AppConfigs and run final checks required to for command self.finalize(app) - template_file = self.bundle_path(app) - exec_file = self.binary_executable_path(app) - if ( - (not template_file.exists()) # App hasn't been created - or update # An explicit update has been requested - 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 - ) # Test mode, but updates have not been disabled - ): + if not no_update: state = self.build_command( app, + build=not self.tracking_is_built, update=update, update_requirements=update_requirements, update_resources=update_resources, diff --git a/src/briefcase/commands/update.py b/src/briefcase/commands/update.py index f00b5c755..791544418 100644 --- a/src/briefcase/commands/update.py +++ b/src/briefcase/commands/update.py @@ -1,6 +1,9 @@ from __future__ import annotations +from contextlib import suppress + from briefcase.config import AppConfig +from briefcase.exceptions import MissingSupportPackage from .base import full_options from .create import CreateCommand @@ -17,46 +20,99 @@ def add_options(self, parser): def update_app( self, app: AppConfig, + update_app: bool, update_requirements: bool, update_resources: bool, update_support: bool, update_stub: bool, test_mode: bool, **options, - ) -> dict | None: + ) -> dict: """Update an existing application bundle. :param app: The config object for the app + :param update_app: Should the app sources be updated? :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? """ - if not self.bundle_path(app).exists(): self.logger.error( "Application does not exist; call create first!", prefix=app.app_name ) - return + return {} - self.verify_app(app) + if not update_app: + update_app = self.tracking_is_source_modified( + app, sources=app.sources(test_mode=test_mode) + ) + if update_app: # TODO:PR: delete + self.logger.warning("App source change detected") - self.logger.info("Updating application code...", prefix=app.app_name) - self.install_app_code(app=app, test_mode=test_mode) + if not update_requirements: + update_requirements = self.tracking_is_requirements_updated( + app, requires=app.requires(test_mode=test_mode) + ) + if update_requirements: # TODO:PR: delete + self.logger.warning("Requirements change detected") + + if not update_resources: + update_resources = self.tracking_is_resources_updated( + app, + resources=self._resolve_app_resources(app, do_install=False).values(), + ) + if update_resources: # TODO:PR: delete + self.logger.warning("Resources change detected") + + if not update_support: + # an app's missing support package will be reported later + with suppress(MissingSupportPackage): + update_support = self.tracking_is_support_package_updated( + app, support_url=self._app_support_package(app, warn_user=False)[1] + ) + if update_support: # TODO:PR: delete + self.logger.warning("Support package change detected") + + if not update_stub: + # TODO:PR: figure out a better way to protect against no binary revision + try: + self.stub_binary_revision(app) + except KeyError: + pass + else: + update_stub = self.tracking_is_stub_binary_updated( + app, stub_url=self._stub_binary(app, warn_user=False)[1] + ) + if update_stub: # TODO:PR: delete + self.logger.warning("Binary stub change detected") + + if is_app_being_updated := ( + update_app + or update_requirements + or update_resources + or update_support + or update_stub + ): + self.verify_app(app) + + if update_app: + self.logger.info("Updating application code...", prefix=app.app_name) + self.install_app_code(app, test_mode=test_mode) if update_requirements: self.logger.info("Updating requirements...", prefix=app.app_name) - self.install_app_requirements(app=app, test_mode=test_mode) + self.install_app_requirements(app, test_mode=test_mode) if update_resources: self.logger.info("Updating application resources...", prefix=app.app_name) - self.install_app_resources(app=app) + self.install_app_resources(app) if update_support: self.logger.info("Updating application support...", prefix=app.app_name) - self.cleanup_app_support_package(app=app) - self.install_app_support_package(app=app) + self.cleanup_app_support_package(app) + self.install_app_support_package(app) if update_stub: try: @@ -68,31 +124,45 @@ def update_app( 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.cleanup_stub_binary(app) + self.install_stub_binary(app) self.logger.info("Removing unneeded app content...", prefix=app.app_name) - self.cleanup_app_content(app=app) + self.cleanup_app_content(app) + + if is_app_being_updated: + self.logger.info("Removing unneeded app content...", prefix=app.app_name) + self.cleanup_app_content(app) + + self.logger.info("Application updated.", prefix=app.app_name) - self.logger.info("Application updated.", prefix=app.app_name) + return { + "is_app_source_updated": update_app, + "is_requirements_updated": update_requirements, + "is_resources_updated": update_resources, + "is_support_package_updated": update_support, + "is_binary_stub_updated": update_stub, + "is_app_updated": is_app_being_updated, + } def __call__( self, app: AppConfig | None = None, + update_app: bool = True, update_requirements: bool = False, update_resources: bool = False, update_support: bool = False, update_stub: bool = False, test_mode: bool = False, **options, - ) -> dict | None: - # Confirm host compatibility, that all required tools are available, - # and that the app configuration is finalized. + ) -> dict: + # Finish preparing the AppConfigs and run final checks required to for command self.finalize(app) if app: state = self.update_app( app, + update_app=update_app, update_requirements=update_requirements, update_resources=update_resources, update_support=update_support, @@ -105,6 +175,7 @@ def __call__( for app_name, app in sorted(self.apps.items()): state = self.update_app( app, + update_app=update_app, update_requirements=update_requirements, update_resources=update_resources, update_support=update_support, diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 14cc2ac00..f86ab8fd4 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import copy import keyword import re @@ -212,12 +214,12 @@ def __init__( self.bundle = bundle # Description can only be a single line. Ignore everything else. self.description = description.split("\n")[0] - self.sources = sources + self._sources = sources self.formal_name = app_name if formal_name is None else formal_name self.url = url self.author = author self.author_email = author_email - self.requires = requires + self._requires = requires self.icon = icon self.document_types = {} if document_type is None else document_type self.permission = {} if permission is None else permission @@ -256,8 +258,8 @@ def __init__( ) # Sources list doesn't include any duplicates - source_modules = {source.rsplit("/", 1)[-1] for source in self.sources} - if len(self.sources) != len(source_modules): + source_modules = {source.rsplit("/", 1)[-1] for source in self._sources} + if len(self._sources) != len(source_modules): raise BriefcaseConfigError( f"The `sources` list for {self.app_name!r} contains duplicated " "package names." @@ -270,11 +272,11 @@ def __init__( f"package named {self.module_name!r}." ) - def __repr__(self): + def __repr__(self) -> str: return f"<{self.bundle_identifier} v{self.version} AppConfig>" @property - def module_name(self): + def module_name(self) -> str: """The module name for the app. This is derived from the name, but: @@ -283,7 +285,7 @@ def module_name(self): return self.app_name.replace("-", "_") @property - def bundle_name(self): + def bundle_name(self) -> str: """The bundle name for the app. This is derived from the app name, but: @@ -292,7 +294,7 @@ def bundle_name(self): return self.app_name.replace("_", "-") @property - def bundle_identifier(self): + def bundle_identifier(self) -> str: """The bundle identifier for the app. This is derived from the bundle and the bundle name, joined by a `.`. @@ -300,7 +302,7 @@ def bundle_identifier(self): return f"{self.bundle}.{self.bundle_name}" @property - def class_name(self): + def class_name(self) -> str: """The class name for the app. This is derived from the formal name for the app. @@ -308,19 +310,19 @@ def class_name(self): return make_class_name(self.formal_name) @property - def package_name(self): + def package_name(self) -> str: """The bundle name of the app, with `-` replaced with `_` to create something that can be used a namespace identifier on Python or Java, similar to `module_name`.""" return self.bundle.replace("-", "_") - def PYTHONPATH(self, test_mode): + def PYTHONPATH(self, test_mode: bool) -> list[str]: """The PYTHONPATH modifications needed to run this app. :param test_mode: Should test_mode sources be included? """ paths = [] - sources = self.sources + sources = self._sources if test_mode and self.test_sources: sources.extend(self.test_sources) @@ -330,7 +332,7 @@ def PYTHONPATH(self, test_mode): paths.append(path) return paths - def main_module(self, test_mode: bool): + def main_module(self, test_mode: bool) -> str: """The path to the main module for the app. In normal operation, this is ``app.module_name``; however, @@ -343,6 +345,20 @@ def main_module(self, test_mode: bool): else: return self.module_name + def requires(self, test_mode: bool = False) -> list[str]: + """App requirements incorporating whether test mode is active.""" + requires = self._requires.copy() if self._requires else [] + if test_mode and self.test_requires: + requires.extend(self.test_requires) + return requires + + def sources(self, test_mode: bool = False) -> list[str]: + """App sources incorporating whether test mode is active.""" + sources = self._sources.copy() if self._sources else [] + if test_mode and self.test_sources: + sources.extend(self.test_sources) + return sources + def merge_config(config, data): """Merge a new set of configuration requirements into a base configuration. @@ -467,9 +483,11 @@ def parse_config(config_file, platform, output_format, logger): # Merge the PEP621 configuration (if it exists) try: - merge_pep621_config(global_config, pyproject["project"]) + pep612_config = pyproject["project"] except KeyError: pass + else: + merge_pep621_config(global_config, pep612_config) # For consistent results, sort the platforms and formats all_platforms = sorted(get_platforms().keys()) diff --git a/src/briefcase/integrations/docker.py b/src/briefcase/integrations/docker.py index 50127c8ae..3964946f1 100644 --- a/src/briefcase/integrations/docker.py +++ b/src/briefcase/integrations/docker.py @@ -925,7 +925,7 @@ def prepare( f"HOST_GID={self.tools.os.getgid()}", Path( self.app_base_path, - *self.app.sources[0].split("/")[:-1], + *self.app.sources()[0].split("/")[:-1], ), ] + (extra_build_args if extra_build_args is not None else []), diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py index ef87d2899..a25562b54 100644 --- a/src/briefcase/platforms/android/gradle.py +++ b/src/briefcase/platforms/android/gradle.py @@ -68,6 +68,22 @@ class GradleMixin: output_format = "gradle" platform = "android" platform_target_version = "0.3.15" + tracking_metadata_fields: list[str] = [ + "primary_color", + "primary_color_dark", + "accent_color", + "splash_background_color", + "base_theme", + "version_code", + "build_gradle_dependencies", + "build_gradle_extra_content", + "android_manifest_attrs_extra_content", + "android_manifest_extra_content", + "android_manifest_application_attrs_extra_content", + "android_manifest_application_extra_content", + "android_manifest_activity_attrs_extra_content", + "android_manifest_activity_extra_content", + ] @property def packaging_formats(self): diff --git a/src/briefcase/platforms/iOS/xcode.py b/src/briefcase/platforms/iOS/xcode.py index e7cca1a9d..3ab786af1 100644 --- a/src/briefcase/platforms/iOS/xcode.py +++ b/src/briefcase/platforms/iOS/xcode.py @@ -30,6 +30,11 @@ class iOSXcodePassiveMixin(iOSMixin): output_format = "Xcode" + tracking_metadata_fields: list[str] = [ + "splash_background_color", + "info", + "build", + ] @property def packaging_formats(self): diff --git a/src/briefcase/platforms/linux/__init__.py b/src/briefcase/platforms/linux/__init__.py index 63ec625db..6166151b8 100644 --- a/src/briefcase/platforms/linux/__init__.py +++ b/src/briefcase/platforms/linux/__init__.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import List -from briefcase.commands.create import _is_local_requirement +from briefcase.commands.base import is_local_requirement from briefcase.commands.open import OpenCommand from briefcase.config import AppConfig from briefcase.exceptions import BriefcaseCommandError, ParseError @@ -156,7 +156,7 @@ def _install_app_requirements( # Iterate over every requirement, looking for local references for requirement in requires: - if _is_local_requirement(requirement): + if is_local_requirement(requirement): if Path(requirement).is_dir(): # Requirement is a filesystem reference # Build an sdist for the local requirement @@ -210,7 +210,7 @@ def _pip_requires(self, app: AppConfig, requires: List[str]): final = [ requirement for requirement in super()._pip_requires(app, requires) - if not _is_local_requirement(requirement) + if not is_local_requirement(requirement) ] # Add in any local packages. diff --git a/src/briefcase/platforms/linux/appimage.py b/src/briefcase/platforms/linux/appimage.py index 0a302ec6a..dcc21961b 100644 --- a/src/briefcase/platforms/linux/appimage.py +++ b/src/briefcase/platforms/linux/appimage.py @@ -36,6 +36,7 @@ class LinuxAppImagePassiveMixin(LinuxMixin): supported_host_os_reason = ( "Linux AppImages can only be built on Linux, or on macOS using Docker." ) + tracking_metadata_fields: list[str] = ["manylinux", "dockerfile_extra_content"] def appdir_path(self, app): return self.bundle_path(app) / f"{app.formal_name}.AppDir" diff --git a/src/briefcase/platforms/linux/flatpak.py b/src/briefcase/platforms/linux/flatpak.py index d996a23c7..d98723b17 100644 --- a/src/briefcase/platforms/linux/flatpak.py +++ b/src/briefcase/platforms/linux/flatpak.py @@ -19,6 +19,13 @@ class LinuxFlatpakMixin(LinuxMixin): output_format = "flatpak" supported_host_os = {"Linux"} supported_host_os_reason = "Flatpaks can only be built on Linux." + tracking_metadata_fields: list[str] = [ + "flatpak_runtime", + "flatpak_runtime_version", + "flatpak_sdk", + "finish_args", + "finish_args", + ] def binary_path(self, app): # Flatpak doesn't really produce an identifiable "binary" as part of its @@ -160,10 +167,11 @@ class LinuxFlatpakOpenCommand(LinuxFlatpakMixin, OpenCommand): class LinuxFlatpakBuildCommand(LinuxFlatpakMixin, BuildCommand): description = "Build a Linux Flatpak." - def build_app(self, app: AppConfig, **kwargs): + def build_app(self, app: AppConfig, test_mode: bool, **kwargs): """Build an application. :param app: The application to build + :param test_mode: Is the app being build in test mode? """ self.logger.info( "Ensuring Flatpak runtime for the app is available...", diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py index b28b25f8d..dbf130c9e 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -39,6 +39,11 @@ class LinuxSystemPassiveMixin(LinuxMixin): supported_host_os_reason = ( "Linux system projects can only be built on Linux, or on macOS using Docker." ) + tracking_metadata_fields: list[str] = [ + "revision", + "long_description", + "dockerfile_extra_content", + ] @property def use_docker(self): diff --git a/src/briefcase/platforms/macOS/app.py b/src/briefcase/platforms/macOS/app.py index 6fc37c77f..6de67c09b 100644 --- a/src/briefcase/platforms/macOS/app.py +++ b/src/briefcase/platforms/macOS/app.py @@ -25,6 +25,11 @@ class macOSAppMixin(macOSMixin): output_format = "app" + tracking_metadata_fields: list[str] = [ + "info", + "entitlements", + "build", + ] def project_path(self, app): return self.binary_path(app) / "Contents" diff --git a/src/briefcase/platforms/macOS/xcode.py b/src/briefcase/platforms/macOS/xcode.py index cd8a0e648..9f3cc93b8 100644 --- a/src/briefcase/platforms/macOS/xcode.py +++ b/src/briefcase/platforms/macOS/xcode.py @@ -29,6 +29,12 @@ class macOSXcodeMixin(macOSMixin): "macOS applications require the Xcode command line " "tools, which are only available on macOS." ) + tracking_metadata_fields: list[str] = [ + "info", + "entitlements", + "build", + "universal_build", + ] def verify_tools(self): Xcode.verify(self.tools, min_version=(13, 0, 0)) diff --git a/src/briefcase/platforms/web/static.py b/src/briefcase/platforms/web/static.py index a80aa39fa..db8b862b6 100644 --- a/src/briefcase/platforms/web/static.py +++ b/src/briefcase/platforms/web/static.py @@ -32,6 +32,10 @@ class StaticWebMixin: output_format = "static" platform = "web" + tracking_metadata_fields: list[str] = [ + "splash_background_color", + "build", + ] def project_path(self, app): return self.bundle_path(app) / "www" diff --git a/src/briefcase/platforms/windows/app.py b/src/briefcase/platforms/windows/app.py index ea2ec53fd..2eb4e28f7 100644 --- a/src/briefcase/platforms/windows/app.py +++ b/src/briefcase/platforms/windows/app.py @@ -18,6 +18,11 @@ class WindowsAppMixin(WindowsMixin): output_format = "app" packaging_root = Path("src") + tracking_metadata_fields: list[str] = [ + "guid", + "install_scope", + "use_full_install_path", + ] def project_path(self, app): return self.bundle_path(app) diff --git a/src/briefcase/platforms/windows/visualstudio.py b/src/briefcase/platforms/windows/visualstudio.py index 603353f53..833886500 100644 --- a/src/briefcase/platforms/windows/visualstudio.py +++ b/src/briefcase/platforms/windows/visualstudio.py @@ -16,6 +16,11 @@ class WindowsVisualStudioMixin(WindowsMixin): output_format = "VisualStudio" packaging_root = Path("x64/Release") + tracking_metadata_fields: list[str] = [ + "guid", + "install_scope", + "use_full_install_path", + ] def project_path(self, app): return self.bundle_path(app) / f"{app.formal_name}.sln" diff --git a/tests/commands/base/test_app_module_path.py b/tests/commands/base/test_app_module_path.py index bce0edf05..784faa4f3 100644 --- a/tests/commands/base/test_app_module_path.py +++ b/tests/commands/base/test_app_module_path.py @@ -6,7 +6,7 @@ def test_single_source(base_command, my_app): """If an app provides a single source location and it matches, it is selected as the dist-info location.""" - my_app.sources = ["src/my_app"] + my_app._sources = ["src/my_app"] assert base_command.app_module_path(my_app) == base_command.base_path / "src/my_app" @@ -14,7 +14,7 @@ def test_single_source(base_command, my_app): def test_no_prefix(base_command, my_app): """If an app provides a source location without a prefix and it matches, it is selected as the dist-info location.""" - my_app.sources = ["my_app"] + my_app._sources = ["my_app"] assert base_command.app_module_path(my_app) == base_command.base_path / "my_app" @@ -22,7 +22,7 @@ def test_no_prefix(base_command, my_app): def test_long_prefix(base_command, my_app): """If an app provides a source location with a long prefix and it matches, it is selected as the dist-info location.""" - my_app.sources = ["path/to/src/my_app"] + my_app._sources = ["path/to/src/my_app"] assert ( base_command.app_module_path(my_app) @@ -33,14 +33,14 @@ def test_long_prefix(base_command, my_app): def test_matching_source(base_command, my_app): """If an app provides a single matching source location, it is selected as the dist- info location.""" - my_app.sources = ["src/other", "src/my_app", "src/extra"] + my_app._sources = ["src/other", "src/my_app", "src/extra"] assert base_command.app_module_path(my_app) == base_command.base_path / "src/my_app" def test_multiple_match(base_command, my_app): """If an app provides multiple matching source location, an error is raised.""" - my_app.sources = ["src/my_app", "extra/my_app"] + my_app._sources = ["src/my_app", "extra/my_app"] with pytest.raises( BriefcaseCommandError, @@ -52,7 +52,7 @@ def test_multiple_match(base_command, my_app): def test_hyphen_source(base_command, my_app): """If an app provides a single source location with a hyphen, an error is raised.""" # The source directory must be a valid module, so hyphens aren't legal. - my_app.sources = ["src/my-app"] + my_app._sources = ["src/my-app"] with pytest.raises( BriefcaseCommandError, @@ -64,7 +64,7 @@ def test_hyphen_source(base_command, my_app): def test_no_match(base_command, my_app): """If an app provides a multiple locations, none of which match, an error is raised.""" - my_app.sources = ["src/pork", "src/spam"] + my_app._sources = ["src/pork", "src/spam"] with pytest.raises( BriefcaseCommandError, @@ -75,7 +75,7 @@ def test_no_match(base_command, my_app): def test_no_source(base_command, my_app): """If an app provides no source locations, an error is raised.""" - my_app.sources = [] + my_app._sources = [] with pytest.raises( BriefcaseCommandError, diff --git a/tests/commands/create/test_install_app_code.py b/tests/commands/create/test_install_app_code.py index ab0a754c5..a398001e2 100644 --- a/tests/commands/create/test_install_app_code.py +++ b/tests/commands/create/test_install_app_code.py @@ -50,7 +50,7 @@ def test_no_code( create_command.tools.shutil = mock.MagicMock(spec_set=shutil) create_command.tools.os = mock.MagicMock(spec_set=os) - myapp.sources = None + myapp._sources = None create_command.install_app_code(myapp, test_mode=False) @@ -76,7 +76,7 @@ def test_empty_code( create_command.tools.shutil = mock.MagicMock(spec_set=shutil) create_command.tools.os = mock.MagicMock(spec_set=os) - myapp.sources = [] + myapp._sources = [] create_command.install_app_code(myapp, test_mode=False) @@ -98,7 +98,7 @@ def test_source_missing( ): """If an app defines sources that are missing, an error is raised.""" # Set the app definition to point at sources that don't exist - myapp.sources = ["missing"] + myapp._sources = ["missing"] with pytest.raises(MissingAppSources): create_command.install_app_code(myapp, test_mode=False) @@ -138,7 +138,7 @@ def test_source_dir( ) # Set the app definition, and install sources - myapp.sources = ["src/first", "src/second"] + myapp._sources = ["src/first", "src/second"] create_command.install_app_code(myapp, test_mode=False) @@ -156,7 +156,7 @@ def test_source_dir( assert_dist_info(app_path) # Original app definitions haven't changed - assert myapp.sources == ["src/first", "src/second"] + assert myapp._sources == ["src/first", "src/second"] assert myapp.test_sources is None @@ -182,7 +182,7 @@ def test_source_file( ) # Set the app definition, and install sources - myapp.sources = ["src/demo.py", "other.py"] + myapp._sources = ["src/demo.py", "other.py"] create_command.install_app_code(myapp, test_mode=False) @@ -194,7 +194,7 @@ def test_source_file( assert_dist_info(app_path) # Original app definitions haven't changed - assert myapp.sources == ["src/demo.py", "other.py"] + assert myapp._sources == ["src/demo.py", "other.py"] assert myapp.test_sources is None @@ -231,7 +231,7 @@ def test_no_existing_app_folder( shutil.rmtree(app_path) # Set the app definition, and install sources - myapp.sources = ["src/first/demo.py", "src/second"] + myapp._sources = ["src/first/demo.py", "src/second"] create_command.install_app_code(myapp, test_mode=False) @@ -260,7 +260,7 @@ def test_no_existing_app_folder( assert_dist_info(app_path) # Original app definitions haven't changed - assert myapp.sources == ["src/first/demo.py", "src/second"] + assert myapp._sources == ["src/first/demo.py", "src/second"] assert myapp.test_sources is None @@ -334,7 +334,7 @@ def test_replace_sources( old_dist_info_dir.mkdir() # Set the app definition, and install sources - myapp.sources = ["src/first/demo.py", "src/second"] + myapp._sources = ["src/first/demo.py", "src/second"] create_command.install_app_code(myapp, test_mode=False) @@ -363,7 +363,7 @@ def test_replace_sources( assert_dist_info(app_path) # Original app definitions haven't changed - assert myapp.sources == ["src/first/demo.py", "src/second"] + assert myapp._sources == ["src/first/demo.py", "src/second"] assert myapp.test_sources is None @@ -385,7 +385,7 @@ def test_non_latin_metadata( create_command.tools.shutil = mock.MagicMock(spec_set=shutil) create_command.tools.os = mock.MagicMock(spec_set=os) - myapp.sources = [] + myapp._sources = [] create_command.install_app_code(myapp, test_mode=False) @@ -471,7 +471,7 @@ def test_test_sources( ) # Set the app definition, and install sources - myapp.sources = ["src/first", "src/second"] + myapp._sources = ["src/first", "src/second"] myapp.test_sources = ["tests", "othertests"] create_command.install_app_code(myapp, test_mode=False) @@ -491,7 +491,7 @@ def test_test_sources( assert_dist_info(app_path) # Original app definitions haven't changed - assert myapp.sources == ["src/first", "src/second"] + assert myapp._sources == ["src/first", "src/second"] assert myapp.test_sources == ["tests", "othertests"] @@ -543,7 +543,7 @@ def test_test_sources_test_mode( ) # Set the app definition, and install sources - myapp.sources = ["src/first", "src/second"] + myapp._sources = ["src/first", "src/second"] myapp.test_sources = ["tests", "othertests"] create_command.install_app_code(myapp, test_mode=True) @@ -566,7 +566,7 @@ def test_test_sources_test_mode( assert_dist_info(app_path) # Original app definitions haven't changed - assert myapp.sources == ["src/first", "src/second"] + assert myapp._sources == ["src/first", "src/second"] assert myapp.test_sources == ["tests", "othertests"] @@ -614,7 +614,7 @@ def test_only_test_sources_test_mode( ) # Set the app definition, and install sources - myapp.sources = None + myapp._sources = None myapp.test_sources = ["tests", "othertests"] create_command.install_app_code(myapp, test_mode=True) @@ -634,5 +634,5 @@ def test_only_test_sources_test_mode( assert_dist_info(app_path) # Original app definitions haven't changed - assert myapp.sources is None + assert myapp._sources is None assert myapp.test_sources == ["tests", "othertests"] diff --git a/tests/commands/create/test_install_app_requirements.py b/tests/commands/create/test_install_app_requirements.py index 51ecf579e..ff027e35b 100644 --- a/tests/commands/create/test_install_app_requirements.py +++ b/tests/commands/create/test_install_app_requirements.py @@ -8,7 +8,7 @@ import tomli_w import briefcase -from briefcase.commands.create import _is_local_requirement +from briefcase.commands.create import is_local_requirement from briefcase.console import LogLevel from briefcase.exceptions import BriefcaseCommandError, RequirementsInstallError from briefcase.integrations.subprocess import Subprocess @@ -73,7 +73,7 @@ def test_bad_path_index(create_command, myapp, bundle_path, app_requirements_pat tomli_w.dump(index, f) # Set up requirements for the app - myapp.requires = ["first", "second", "third"] + myapp._requires = ["first", "second", "third"] # Install requirements with pytest.raises( @@ -89,7 +89,7 @@ def test_bad_path_index(create_command, myapp, bundle_path, app_requirements_pat assert not app_requirements_path.exists() # Original app definitions haven't changed - assert myapp.requires == ["first", "second", "third"] + assert myapp._requires == ["first", "second", "third"] assert myapp.test_requires is None @@ -100,7 +100,7 @@ def test_app_packages_no_requires( app_packages_path_index, ): """If an app has no requirements, install_app_requirements is a no-op.""" - myapp.requires = None + myapp._requires = None create_command.install_app_requirements(myapp, test_mode=False) @@ -115,7 +115,7 @@ def test_app_packages_empty_requires( app_packages_path_index, ): """If an app has an empty requirements list, install_app_requirements is a no-op.""" - myapp.requires = [] + myapp._requires = [] create_command.install_app_requirements(myapp, test_mode=False) @@ -130,7 +130,7 @@ def test_app_packages_valid_requires( app_packages_path_index, ): """If an app has a valid list of requirements, pip is invoked.""" - myapp.requires = ["first", "second==1.2.3", "third>=3.2.1"] + myapp._requires = ["first", "second==1.2.3", "third>=3.2.1"] create_command.install_app_requirements(myapp, test_mode=False) @@ -158,7 +158,7 @@ def test_app_packages_valid_requires( ) # Original app definitions haven't changed - assert myapp.requires == ["first", "second==1.2.3", "third>=3.2.1"] + assert myapp._requires == ["first", "second==1.2.3", "third>=3.2.1"] assert myapp.test_requires is None @@ -170,7 +170,7 @@ def test_app_packages_valid_requires_no_support_package( ): """If the template doesn't specify a support package, the cross-platform site isn't specified.""" - myapp.requires = ["first", "second==1.2.3", "third>=3.2.1"] + myapp._requires = ["first", "second==1.2.3", "third>=3.2.1"] # Override the cache of paths to specify an app packages path, but no support package path create_command._briefcase_toml[myapp] = { @@ -203,7 +203,7 @@ def test_app_packages_valid_requires_no_support_package( ) # Original app definitions haven't changed - assert myapp.requires == ["first", "second==1.2.3", "third>=3.2.1"] + assert myapp._requires == ["first", "second==1.2.3", "third>=3.2.1"] assert myapp.test_requires is None @@ -214,7 +214,7 @@ def test_app_packages_invalid_requires( app_packages_path_index, ): """If an app has a valid list of requirements, pip is invoked.""" - myapp.requires = ["does-not-exist"] + myapp._requires = ["does-not-exist"] # Unfortunately, no way to tell the difference between "offline" and # "your requirements are invalid"; pip returns status code 1 for all @@ -250,7 +250,7 @@ def test_app_packages_invalid_requires( ) # Original app definitions haven't changed - assert myapp.requires == ["does-not-exist"] + assert myapp._requires == ["does-not-exist"] assert myapp.test_requires is None @@ -261,7 +261,7 @@ def test_app_packages_offline( app_packages_path_index, ): """If user is offline, pip fails.""" - myapp.requires = ["first", "second", "third"] + myapp._requires = ["first", "second", "third"] # Unfortunately, no way to tell the difference between "offline" and # "your requirements are invalid"; pip returns status code 1 for all @@ -299,7 +299,7 @@ def test_app_packages_offline( ) # Original app definitions haven't changed - assert myapp.requires == ["first", "second", "third"] + assert myapp._requires == ["first", "second", "third"] assert myapp.test_requires is None @@ -316,11 +316,11 @@ def test_app_packages_install_requirements( create_command.logger.verbosity = logging_level # Set up the app requirements - myapp.requires = ["first", "second", "third"] + myapp._requires = ["first", "second", "third"] # The side effect of calling pip is creating installation artefacts create_command.tools[myapp].app_context.run.side_effect = ( - create_installation_artefacts(app_packages_path, myapp.requires) + create_installation_artefacts(app_packages_path, myapp._requires) ) # Install the requirements @@ -357,7 +357,7 @@ def test_app_packages_install_requirements( assert (app_packages_path / "third/__main__.py").exists() # Original app definitions haven't changed - assert myapp.requires == ["first", "second", "third"] + assert myapp._requires == ["first", "second", "third"] assert myapp.test_requires is None @@ -372,11 +372,11 @@ def test_app_packages_replace_existing_requirements( create_installation_artefacts(app_packages_path, ["old", "ancient"])() # Set up the app requirements - myapp.requires = ["first", "second", "third"] + myapp._requires = ["first", "second", "third"] # The side effect of calling pip is creating installation artefacts create_command.tools[myapp].app_context.run.side_effect = ( - create_installation_artefacts(app_packages_path, myapp.requires) + create_installation_artefacts(app_packages_path, myapp._requires) ) # Install the requirements @@ -418,7 +418,7 @@ def test_app_packages_replace_existing_requirements( assert not (app_packages_path / "ancient").exists() # Original app definitions haven't changed - assert myapp.requires == ["first", "second", "third"] + assert myapp._requires == ["first", "second", "third"] assert myapp.test_requires is None @@ -429,7 +429,7 @@ def test_app_requirements_no_requires( app_requirements_path_index, ): """If an app has no requirements, a requirements file is still written.""" - myapp.requires = None + myapp._requires = None # Install requirements into the bundle create_command.install_app_requirements(myapp, test_mode=False) @@ -440,7 +440,7 @@ def test_app_requirements_no_requires( assert f.read() == "" # Original app definitions haven't changed - assert myapp.requires is None + assert myapp._requires is None assert myapp.test_requires is None @@ -452,7 +452,7 @@ def test_app_requirements_empty_requires( ): """If an app has an empty requirements list, a requirements file is still written.""" - myapp.requires = [] + myapp._requires = [] # Install requirements into the bundle create_command.install_app_requirements(myapp, test_mode=False) @@ -463,7 +463,7 @@ def test_app_requirements_empty_requires( assert f.read() == "" # Original app definitions haven't changed - assert myapp.requires == [] + assert myapp._requires == [] assert myapp.test_requires is None @@ -476,7 +476,7 @@ def test_app_requirements_requires( ): """If an app has an empty requirements list, a requirements file is still written.""" - myapp.requires = ["first", "second==1.2.3", "third>=3.2.1"] + myapp._requires = ["first", "second==1.2.3", "third>=3.2.1"] # Install requirements into the bundle create_command.install_app_requirements(myapp, test_mode=False) @@ -487,7 +487,7 @@ def test_app_requirements_requires( assert f.read() == f"{GENERATED_DATETIME}\nfirst\nsecond==1.2.3\nthird>=3.2.1\n" # Original app definitions haven't changed - assert myapp.requires == ["first", "second==1.2.3", "third>=3.2.1"] + assert myapp._requires == ["first", "second==1.2.3", "third>=3.2.1"] assert myapp.test_requires is None @@ -501,7 +501,7 @@ def test_app_requirements_requires( (">", "asdf+xcvb", False), ], ) -def test__is_local_requirement_altsep_respected( +def test_is_local_requirement_altsep_respected( altsep, requirement, expected, @@ -510,7 +510,7 @@ def test__is_local_requirement_altsep_respected( """``os.altsep`` is included as a separator when available.""" monkeypatch.setattr(os, "sep", "/") monkeypatch.setattr(os, "altsep", altsep) - assert _is_local_requirement(requirement) is expected + assert is_local_requirement(requirement) is expected def _test_app_requirements_paths( @@ -525,7 +525,7 @@ def _test_app_requirements_paths( requirement, converted = requirement else: converted = requirement - myapp.requires = ["first", requirement, "third"] + myapp._requires = ["first", requirement, "third"] create_command.install_app_requirements(myapp, test_mode=False) with app_requirements_path.open(encoding="utf-8") as f: @@ -542,7 +542,7 @@ def _test_app_requirements_paths( ) # Original app definitions haven't changed - assert myapp.requires == ["first", requirement, "third"] + assert myapp._requires == ["first", requirement, "third"] assert myapp.test_requires is None @@ -663,7 +663,7 @@ def test_app_packages_test_requires( ): """If an app has test requirements, they're not included unless we are in test mode.""" - myapp.requires = ["first", "second==1.2.3", "third>=3.2.1"] + myapp._requires = ["first", "second==1.2.3", "third>=3.2.1"] myapp.test_requires = ["pytest", "pytest-tldr"] create_command.install_app_requirements(myapp, test_mode=False) @@ -692,7 +692,7 @@ def test_app_packages_test_requires( ) # Original app definitions haven't changed - assert myapp.requires == ["first", "second==1.2.3", "third>=3.2.1"] + assert myapp._requires == ["first", "second==1.2.3", "third>=3.2.1"] assert myapp.test_requires == ["pytest", "pytest-tldr"] @@ -703,7 +703,7 @@ def test_app_packages_test_requires_test_mode( app_packages_path_index, ): """If an app has test requirements and we're in test mode, they are installed.""" - myapp.requires = ["first", "second==1.2.3", "third>=3.2.1"] + myapp._requires = ["first", "second==1.2.3", "third>=3.2.1"] myapp.test_requires = ["pytest", "pytest-tldr"] create_command.install_app_requirements(myapp, test_mode=True) @@ -734,7 +734,7 @@ def test_app_packages_test_requires_test_mode( ) # Original app definitions haven't changed - assert myapp.requires == ["first", "second==1.2.3", "third>=3.2.1"] + assert myapp._requires == ["first", "second==1.2.3", "third>=3.2.1"] assert myapp.test_requires == ["pytest", "pytest-tldr"] @@ -746,7 +746,7 @@ def test_app_packages_only_test_requires_test_mode( ): """If an app only has test requirements and we're in test mode, they are installed.""" - myapp.requires = None + myapp._requires = None myapp.test_requires = ["pytest", "pytest-tldr"] create_command.install_app_requirements(myapp, test_mode=True) @@ -774,5 +774,5 @@ def test_app_packages_only_test_requires_test_mode( ) # Original app definitions haven't changed - assert myapp.requires is None + assert myapp._requires is None assert myapp.test_requires == ["pytest", "pytest-tldr"] diff --git a/tests/commands/dev/test_install_dev_requirements.py b/tests/commands/dev/test_install_dev_requirements.py index a813a4cdc..c3f421bae 100644 --- a/tests/commands/dev/test_install_dev_requirements.py +++ b/tests/commands/dev/test_install_dev_requirements.py @@ -13,7 +13,7 @@ def test_install_requirements_no_error(dev_command, first_app, logging_level): # Configure logging level dev_command.logger.verbosity = logging_level - first_app.requires = ["package-one", "package_two", "packagethree"] + first_app._requires = ["package-one", "package_two", "packagethree"] dev_command.install_dev_requirements(app=first_app) @@ -37,7 +37,7 @@ def test_install_requirements_no_error(dev_command, first_app, logging_level): def test_install_requirements_error(dev_command, first_app): """Ensure RequirementsInstallError exception is raised for install errors.""" - first_app.requires = ["package-one", "package_two", "packagethree"] + first_app._requires = ["package-one", "package_two", "packagethree"] dev_command.tools.subprocess.run.side_effect = CalledProcessError( returncode=-1, cmd="pip" @@ -70,7 +70,7 @@ def test_install_requirements_error(dev_command, first_app): def test_no_requirements(dev_command, first_app): """Ensure dependency installation is not attempted when nothing to install.""" - first_app.requires = [] + first_app._requires = [] dev_command.install_dev_requirements(app=first_app) @@ -79,7 +79,7 @@ def test_no_requirements(dev_command, first_app): def test_install_requirements_test_mode(dev_command, first_app): """If an app has test requirements, they are also installed.""" - first_app.requires = ["package-one", "package_two", "packagethree"] + first_app._requires = ["package-one", "package_two", "packagethree"] first_app.test_requires = ["test-one", "test_two"] dev_command.install_dev_requirements(app=first_app) @@ -107,7 +107,7 @@ def test_install_requirements_test_mode(dev_command, first_app): def test_only_test_requirements(dev_command, first_app): """If an app only has test requirements, they're installed correctly.""" - first_app.requires = None + first_app._requires = None first_app.test_requires = ["test-one", "test_two"] dev_command.install_dev_requirements(app=first_app) diff --git a/tests/config/test_AppConfig.py b/tests/config/test_AppConfig.py index dfd5f0414..eb35a6fc0 100644 --- a/tests/config/test_AppConfig.py +++ b/tests/config/test_AppConfig.py @@ -20,7 +20,7 @@ def test_minimal_AppConfig(): assert config.version == "1.2.3" assert config.bundle == "org.beeware" assert config.description == "A simple app" - assert config.requires is None + assert config._requires is None # Derived properties have been set. assert config.bundle_name == "myapp" @@ -71,7 +71,7 @@ def test_extra_attrs(): assert config.description == "A simple app" assert config.long_description == "A longer description\nof the app" assert config.template == "/path/to/template" - assert config.requires == ["first", "second", "third"] + assert config._requires == ["first", "second", "third"] # Properties that are derived by default have been set explicitly assert config.formal_name == "My App!" diff --git a/tests/platforms/iOS/xcode/test_create.py b/tests/platforms/iOS/xcode/test_create.py index 6e9ba9d9a..8ec6abf91 100644 --- a/tests/platforms/iOS/xcode/test_create.py +++ b/tests/platforms/iOS/xcode/test_create.py @@ -37,7 +37,7 @@ def test_extra_pip_args(create_command, first_app_generated, tmp_path): # requirements for the current platform. create_command.tools.host_arch = "wonky" - first_app_generated.requires = ["something==1.2.3", "other>=2.3.4"] + first_app_generated._requires = ["something==1.2.3", "other>=2.3.4"] create_command.tools[first_app_generated].app_context = MagicMock( spec_set=Subprocess diff --git a/tests/platforms/iOS/xcode/test_update.py b/tests/platforms/iOS/xcode/test_update.py index 9f2300151..cb5627a27 100644 --- a/tests/platforms/iOS/xcode/test_update.py +++ b/tests/platforms/iOS/xcode/test_update.py @@ -24,7 +24,7 @@ def test_extra_pip_args(update_command, first_app_generated, tmp_path): # requirements for the current platform. update_command.tools.host_arch = "wonky" - first_app_generated.requires = ["something==1.2.3", "other>=2.3.4"] + first_app_generated._requires = ["something==1.2.3", "other>=2.3.4"] update_command.tools[first_app_generated].app_context = MagicMock( spec_set=Subprocess diff --git a/tests/platforms/linux/test_LocalRequirementsMixin.py b/tests/platforms/linux/test_LocalRequirementsMixin.py index db3d8002c..6c9317f60 100644 --- a/tests/platforms/linux/test_LocalRequirementsMixin.py +++ b/tests/platforms/linux/test_LocalRequirementsMixin.py @@ -267,7 +267,7 @@ def test_install_app_requirements_with_locals( """If the app has local requirements, they are compiled into sdists for installation.""" # Add local requirements - first_app_config.requires.extend([first_package, second_package, third_package]) + first_app_config._requires.extend([first_package, second_package, third_package]) # Mock the side effect of building an sdist def build_sdist(*args, **kwargs): @@ -373,7 +373,7 @@ def test_install_app_requirements_with_bad_local( ): """If the app has local requirement that can't be built, an error is raised.""" # Add a local requirement - first_app_config.requires.append(first_package) + first_app_config._requires.append(first_package) # Mock the building an sdist raising an error create_command.tools.subprocess.check_output.side_effect = ( @@ -425,7 +425,7 @@ def test_install_app_requirements_with_missing_local_build( """If the app references a requirement that needs to be built, but is missing, an error is raised.""" # Define a local requirement, but don't create the files it points at - first_app_config.requires.append(str(tmp_path / "local/first")) + first_app_config._requires.append(str(tmp_path / "local/first")) # Install requirements with pytest.raises( @@ -457,7 +457,7 @@ def test_install_app_requirements_with_bad_local_file( """If the app references a local requirement file that doesn't exist, an error is raised.""" # Add a local requirement that doesn't exist - first_app_config.requires.append(str(tmp_path / "local/missing-2.3.4.tar.gz")) + first_app_config._requires.append(str(tmp_path / "local/missing-2.3.4.tar.gz")) # Install requirements with pytest.raises( diff --git a/tests/platforms/macOS/app/test_create.py b/tests/platforms/macOS/app/test_create.py index ae8573aef..fbfec6ced 100644 --- a/tests/platforms/macOS/app/test_create.py +++ b/tests/platforms/macOS/app/test_create.py @@ -333,7 +333,7 @@ def test_install_app_packages( bundle_path = tmp_path / "base_path/build/first-app/macos/app" create_command.tools.host_arch = host_arch - first_app_templated.requires = ["first", "second==1.2.3", "third>=3.2.1"] + first_app_templated._requires = ["first", "second==1.2.3", "third>=3.2.1"] # Mock the result of finding the binary packages - 2 of the packages are binary; # the version on the loosely specified package doesn't match the lower bound. @@ -458,7 +458,7 @@ def test_install_app_packages_no_binary( create_installed_package(bundle_path / f"app_packages.{other_arch}", "legacy") create_command.tools.host_arch = host_arch - first_app_templated.requires = ["first", "second==1.2.3", "third>=3.2.1"] + first_app_templated._requires = ["first", "second==1.2.3", "third>=3.2.1"] # Mock the result of finding no binary packages. create_command.find_binary_packages = mock.Mock(return_value=[]) @@ -534,7 +534,7 @@ def test_install_app_packages_failure(create_command, first_app_templated, tmp_p create_installed_package(bundle_path / "app_packages.x86_64", "legacy") create_command.tools.host_arch = "arm64" - first_app_templated.requires = ["first", "second==1.2.3", "third>=3.2.1"] + first_app_templated._requires = ["first", "second==1.2.3", "third>=3.2.1"] # Mock the result of finding the binary packages - 2 of the packages are binary; # the version on the loosely specified package doesn't match the lower bound. @@ -657,7 +657,7 @@ def test_install_app_packages_non_universal( bundle_path = tmp_path / "base_path/build/first-app/macos/app" create_command.tools.host_arch = host_arch - first_app_templated.requires = ["first", "second==1.2.3", "third>=3.2.1"] + first_app_templated._requires = ["first", "second==1.2.3", "third>=3.2.1"] first_app_templated.universal_build = False # Mock the find_binary_packages command so we can confirm it wasn't invoked.