diff --git a/pyproject.toml b/pyproject.toml index 9d493fc94..8976c2a74 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 160108308..42f55e9d6 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, Callable +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: @@ -148,16 +174,29 @@ 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 + # project metadata fields tracked for changes + tracking_metadata_fields: list[str] = [ + "project_name", + "bundle", + "version", + "url", + "license", + "author", + "author_email", + "formal_name", + "description", + ] 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. @@ -171,10 +210,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 @@ -194,6 +230,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): @@ -319,40 +358,56 @@ 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") + @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 build_path(self, app) -> Path: """The path in which all platform artefacts for the app will be built. @@ -389,6 +444,10 @@ def binary_path(self, app) -> Path: :param app: The app config """ + 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. @@ -399,11 +458,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: @@ -487,20 +546,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}" @@ -508,6 +567,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. @@ -754,12 +817,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. @@ -992,9 +1050,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 @@ -1005,7 +1062,7 @@ def generate_template( { "template_source": template, "template_branch": template_branch, - "briefcase_version": str(version), + "briefcase_version": str(self.briefcase_version.base_version), } ) @@ -1023,7 +1080,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. @@ -1038,3 +1095,315 @@ 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) + self._tracking[app].setdefault("briefcase", {}) + self._tracking[app]["briefcase"].setdefault("app", {}) + self._tracking[app]["briefcase"]["app"].setdefault(app.app_name, {}) + 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(): + try: + if not self.tracking_database_path(app).parent.exists(): + continue + except (AttributeError, NotImplementedError): + continue + try: + toml = tomli_w.dumps(self._tracking[app]) + except KeyError: + pass # skip saving tracking info for apps that never loaded it + 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 to track. + + 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_is_metadata_changed(self, app: AppConfig) -> bool: + """Has the project's metadata changed for the app.""" + try: + return any( + self.tracking_get(app, key=field) != getattr(app, field) + for field in self.tracking_metadata_fields + ) + 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_set(app, key=field, value=getattr(app, field)) + + def tracking_add_created_instant(self, app: AppConfig) -> None: + """Track the instant when an app bundle was created.""" + self.tracking_set(app, key="created", value=time.time()) + + 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 + + @timeit + def tracking_add_requirements( + self, + app: AppConfig, + requires: Iterable[str], + ) -> None: + """Track the requirements installed for the app.""" + self.tracking_set(app, key="requires", value=requires) + self.tracking_set( + app, + key="requires-files-hash", + value=self._tracking_fs_hash(filter(is_local_requirement, 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: + is_requires_changed = True + else: + is_requires_changed = tracked_requires != requires + + try: + tracked_requires_hash = self.tracking_get(app, key="requires-files-hash") + except KeyError: + tracked_requires_hash = "" + + is_hash_changed = tracked_requires_hash != self._tracking_fs_hash( + filter(is_local_requirement, requires) + ) + + return is_requires_changed or is_hash_changed + + def _tracking_fs_hash( + self, + filepaths: Iterable[str | os.PathLike], + filter_func: Callable[[str], bool] | None = None, + ) -> str: + """Return a hash representing the current state of the directories.""" + if not (filepaths := list(filepaths)): + return "" + + h = hashlib.new("md5", usedforsecurity=False) + for filepath in map(os.fsdecode, filepaths): + if filter_func and filter_func(filepath): + continue + 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 snapshot_path in sorted(snapshot.paths): + h.update(repr(snapshot.stat_info(snapshot_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) + + @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) + + @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 + else: + return tracked_support_url != support_url + + def tracking_add_resources( + self, + app: AppConfig, + resources: Iterable[str | os.PathLike], + ) -> None: + """Track the resources installed for the app.""" + resources = set( + filter( + None, + [app.icon, app.splash] + + [ext.get("icon") for ext in app.document_types], + ) + ) + # filepaths = list(map(Path, resources)) + + return self.tracking_set(app, key="resources", value=resources) + + 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") + except KeyError: + return True + else: + return tracked_resources != 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 9c3c7ddc4..d0af414b2 100644 --- a/src/briefcase/commands/build.py +++ b/src/briefcase/commands/build.py @@ -14,16 +14,62 @@ 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.""" + # 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, @@ -31,12 +77,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? @@ -46,19 +93,22 @@ 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 ( - 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, @@ -66,22 +116,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, @@ -110,13 +166,13 @@ def __call__( "Cannot specify both --update-support and --no-update" ) - # Confirm host compatibility, that all required tools are available, - # and that the app configuration is finalized. + # Finish preparing the AppConfig 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, @@ -130,6 +186,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/create.py b/src/briefcase/commands/create.py index 3f62a9c4c..f680da4ac 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, @@ -210,9 +212,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 @@ -224,7 +223,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 @@ -248,7 +247,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) @@ -262,7 +261,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. @@ -271,7 +270,7 @@ def _unpack_support_package(self, support_file_path, support_path): # Additional protections for unpacking tar files were introduced in Python 3.12. # This enables the behavior that will be the default in Python 3.14. # However, the protections can only be enabled for tar files...not zip files. - is_zip = support_file_path.name.endswith("zip") + is_zip = support_file_path.suffix == ".zip" if sys.version_info >= (3, 12) and not is_zip: # pragma: no-cover-if-lt-py312 tarfile_kwargs = {"filter": "data"} else: @@ -318,23 +317,51 @@ def install_app_support_package(self, app: AppConfig): :param app: The config object for the app """ - try: - support_path = self.support_path(app) - except KeyError: + support_path, support_url, custom = self._app_support_package(app) + + if support_path is None: self.logger.info("No support package required.") + self.tracking_add_support_package(app, support_url="") else: - support_file_path = self._download_support_package(app) + 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_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. - def _download_support_package(self, app: AppConfig): + :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 + # app doesn't require a support package if an install location + # is not defined in the template 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_path = self.support_path(app) + except KeyError: + return None, "", False + + 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 @@ -342,66 +369,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.download.file( - 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 _write_requirements_file( self, @@ -424,7 +461,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) @@ -509,7 +546,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. """ @@ -547,23 +584,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. @@ -576,9 +624,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: @@ -597,12 +643,10 @@ 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_image(self, role, variant, size, source, target): """Install an icon/image of the requested size at a target location, using the @@ -772,26 +816,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) @@ -817,6 +877,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, @@ -840,8 +902,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 AppConfig and run final checks required to for command self.finalize(app) if app: @@ -852,38 +913,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 f37e36ec5..fe5f7f25b 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.") @@ -157,6 +169,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. @@ -195,31 +209,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 AppConfig 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: @@ -228,10 +246,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/open.py b/src/briefcase/commands/open.py index 5d7b34629..a869bcdab 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 AppConfig 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..114d70971 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.binary_path(app).exists(), + 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 AppConfig and run final checks required to for command self.finalize(app) if app: diff --git a/src/briefcase/commands/run.py b/src/briefcase/commands/run.py index 4977b3419..9dd41cbf0 100644 --- a/src/briefcase/commands/run.py +++ b/src/briefcase/commands/run.py @@ -279,25 +279,13 @@ def __call__( "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 AppConfig and run final checks required to for command self.finalize(app) - template_file = self.bundle_path(app) - binary_file = self.binary_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 (not binary_file.exists()) # 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.binary_path(app).exists(), 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 4b3809fcc..1dea9d4e8 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,31 +20,64 @@ def add_options(self, parser): def update_app( self, app: AppConfig, + update_app: bool, update_requirements: bool, update_resources: bool, update_support: 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 test_mode: Should the app be updated in test mode? + :param test_mode: Should the app be updated for 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 {} + + 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.verify_app(app) + 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") - self.logger.info("Updating application code...", prefix=app.app_name) - self.install_app_code(app=app, test_mode=test_mode) + # TODO:PR: refactor determining which icons to install vs installing + # if not update_resources: + # update_resources = self.tracking_is_resources_updated(app, resources=) + + if not update_support: + 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") + + is_app_being_updated = ( + update_app or update_requirements or update_resources or update_support + ) + + if is_app_being_updated: + self.verify_app(app) + + if update_app: + self.logger.info("Updating application code...", prefix=app.app_name) + self.install_app_code(app=app, test_mode=test_mode) if update_requirements: self.logger.info("Updating requirements...", prefix=app.app_name) @@ -56,27 +92,32 @@ def update_app( self.cleanup_app_support_package(app=app) self.install_app_support_package(app=app) - self.logger.info("Removing unneeded app content...", prefix=app.app_name) - self.cleanup_app_content(app=app) + if is_app_being_updated: + self.logger.info("Removing unneeded app content...", prefix=app.app_name) + self.cleanup_app_content(app=app) + + self.logger.info("Application updated.", prefix=app.app_name) - self.logger.info("Application updated.", prefix=app.app_name) + # TODO:PR: expand to track each event type + return {"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, 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 AppConfig 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, @@ -88,6 +129,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 a86f8f2ac..6c4806b55 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 @@ -210,12 +212,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.splash = splash self.document_types = {} if document_type is None else document_type @@ -253,8 +255,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." @@ -267,11 +269,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: @@ -280,7 +282,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: @@ -289,7 +291,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 `.`. @@ -297,7 +299,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. @@ -305,19 +307,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) @@ -327,7 +329,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, @@ -340,6 +342,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. @@ -464,9 +480,11 @@ def parse_config(config_file, platform, output_format): # 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 e7660aaf1..cd853f7a5 100644 --- a/src/briefcase/integrations/docker.py +++ b/src/briefcase/integrations/docker.py @@ -924,7 +924,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/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/flatpak.py b/src/briefcase/platforms/linux/flatpak.py index 94ccc27b3..da4f21f01 100644 --- a/src/briefcase/platforms/linux/flatpak.py +++ b/src/briefcase/platforms/linux/flatpak.py @@ -160,10 +160,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/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 59dda9dc4..6e2b2f836 100644 --- a/tests/commands/create/test_install_app_requirements.py +++ b/tests/commands/create/test_install_app_requirements.py @@ -6,7 +6,7 @@ import pytest import tomli_w -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 @@ -56,7 +56,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( @@ -72,7 +72,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 @@ -83,7 +83,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) @@ -98,7 +98,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) @@ -113,7 +113,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) @@ -141,7 +141,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 @@ -153,7 +153,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] = { @@ -186,7 +186,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 @@ -197,7 +197,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 @@ -233,7 +233,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 @@ -244,7 +244,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 @@ -282,7 +282,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 @@ -299,11 +299,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 @@ -340,7 +340,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 @@ -355,11 +355,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 @@ -401,7 +401,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 @@ -412,7 +412,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) @@ -423,7 +423,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 @@ -435,7 +435,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) @@ -446,7 +446,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 @@ -458,7 +458,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) @@ -469,7 +469,7 @@ def test_app_requirements_requires( assert f.read() == "first\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 @@ -483,7 +483,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, @@ -492,7 +492,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( @@ -508,7 +508,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: @@ -524,7 +524,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 @@ -645,7 +645,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) @@ -674,7 +674,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"] @@ -685,7 +685,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) @@ -716,7 +716,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"] @@ -728,7 +728,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) @@ -756,5 +756,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 8d3cb6b5c..a016b5083 100644 --- a/tests/config/test_AppConfig.py +++ b/tests/config/test_AppConfig.py @@ -19,7 +19,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" @@ -70,7 +70,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 b7e286741..4b66ccfd2 100644 --- a/tests/platforms/macOS/app/test_create.py +++ b/tests/platforms/macOS/app/test_create.py @@ -303,7 +303,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. @@ -428,7 +428,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=[]) @@ -504,7 +504,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. @@ -627,7 +627,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.