diff --git a/pyproject.toml b/pyproject.toml index f0f74e92a..bb6b5b1a4 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 6e9772e54..702922cce 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,14 +9,20 @@ 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 @@ -39,6 +46,16 @@ 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 create_config(klass, config, msg): try: @@ -151,11 +168,12 @@ 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. @@ -169,10 +187,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 @@ -192,6 +207,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): @@ -317,40 +335,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. @@ -387,6 +421,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. @@ -397,11 +435,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: @@ -485,20 +523,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}" @@ -506,6 +544,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. @@ -752,12 +794,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. @@ -979,3 +1016,201 @@ def generate_template(self, template, branch, output_path, extra_context): except cookiecutter_exceptions.RepositoryCloneFailed as e: # Branch does not exist. raise TemplateUnsupportedVersion(branch) from e + + # ------------------------------ + # 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. + """ + 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: + 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 cache.""" + self.tracking(app)[key] = value + + 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.briefcase_version.base_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(app)["briefcase_version"] + except KeyError: + return True + else: + return tracked_briefcase_version != self.briefcase_version.base_version + + def tracking_add_python_version(self, app: AppConfig) -> None: + """Track the version of Python that created an app bundle.""" + self.tracking_set(app, key="python_version", value=self.python_version_tag) + + def tracking_is_python_version_updated(self, app: AppConfig) -> bool: + """Has the version of Python changed since the app was created?""" + try: + tracked_python_version = self.tracking(app)["python_version"] + except KeyError: + return True + else: + return tracked_python_version != self.python_version_tag + + 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?""" + return self.tracking(app).get("created", None) is not None + + 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(list(filter(is_local_requirement, requires))), + ) + + 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(app)["requires"] + except KeyError: + is_requires_changed = True + else: + is_requires_changed = tracked_requires != requires + + try: + tracked_requires_hash = self.tracking(app)["requires_files_hash"] + except KeyError: + tracked_requires_hash = "" + + is_hash_changed = tracked_requires_hash != self._tracking_fs_hash( + list(filter(is_local_requirement, requires)) + ) + + return is_requires_changed or is_hash_changed + + def _tracking_fs_hash(self, directories: Iterable[str | os.PathLike]) -> str: + """Return a hash representing the current state of the directories.""" + if not (directories := list(directories)): + return "" + + h = hashlib.new("md5", usedforsecurity=False) + for directory in map(os.fsdecode, directories): + snapshot = DirectorySnapshot(path=directory, 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(str(snapshot.stat_info(snapshot_path)).encode()) + return h.hexdigest() + + 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) + ) + + 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(app)["sources_files_hash"] + except KeyError: + return True + else: + return tracked_hash != self._tracking_fs_hash(sources) + + +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..8a45e5567 100644 --- a/src/briefcase/commands/build.py +++ b/src/briefcase/commands/build.py @@ -21,9 +21,46 @@ def build_app(self, app: AppConfig, **options): """ # Default implementation; nothing to build. + def check_for_recreate(self, app: AppConfig) -> bool: + """Should the app be re-created because the environment changed?""" + do_create = False + + if self.tracking_is_briefcase_version_updated(app): + self.logger.info("Environment changed", prefix=app.app_name) + self.logger.info( + self.input.textwrap( + "The version of Briefcase has changed since the app's bundle was " + "originally created.\n" + "\n" + "It is recommended to re-create your app after changing the Briefcase " + "version. This will overwrite any manual updates to the files in the " + "app build directory." + ) + ) + self.input.prompt() + do_create = self.input.boolean_input("Would you like to do this now") + + if not do_create and self.tracking_is_python_version_updated(app): + self.logger.info("Environment changes detected", prefix=app.app_name) + self.logger.info( + self.input.textwrap( + "The version of Python has changed since the app's bundle was " + "originally created.\n" + "\n" + "It is recommended to re-create your app after changing the Python " + "version. This will overwrite any manual updates to the files in the " + "app build directory." + ) + ) + self.input.prompt() + do_create = self.input.boolean_input("Would you like to do this now") + + return do_create + def _build_app( self, app: AppConfig, + build: bool, update: bool, update_requirements: bool, update_resources: bool, @@ -31,12 +68,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 +84,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 +107,27 @@ 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) + ) - 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, @@ -117,6 +163,7 @@ def __call__( if app: state = self._build_app( app, + build=build, update=update, update_requirements=update_requirements, update_resources=update_resources, @@ -130,6 +177,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 da05e9866..a77824037 100644 --- a/src/briefcase/commands/create.py +++ b/src/briefcase/commands/create.py @@ -9,9 +9,8 @@ from datetime import date from pathlib import Path -from packaging.version import Version - import briefcase +from briefcase.commands.base import is_local_requirement from briefcase.config import AppConfig from briefcase.exceptions import ( BriefcaseCommandError, @@ -222,9 +221,8 @@ def generate_app_template(self, app: AppConfig): # If the app config doesn't explicitly define a template branch, # use the branch derived from the Briefcase version - version = Version(briefcase.__version__) if app.template_branch is None: - template_branch = f"v{version.base_version}" + template_branch = f"v{self.briefcase_version.base_version}" else: template_branch = app.template_branch @@ -286,7 +284,7 @@ def generate_app_template(self, app: AppConfig): # If we're on a development branch, and the template branch was *not* # provided explicitly, we can use a fallback development template. # Otherwise, re-raise the exception about the unsupported template version. - if version.dev is not None and app.template_branch is None: + if self.briefcase_version.dev is not None and app.template_branch is None: # Development branches can use the main template. self.logger.info( f"Template branch {template_branch} not found; falling back to development template" @@ -463,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) @@ -548,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. """ @@ -586,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 Exception: + # Installing the app's requirements will delete any currently installed + # requirements; so, clear the tracking info to ensure the requirements + # are installed on the next run + self.tracking_add_requirements(app, requires=[]) + raise + + self.tracking_add_requirements(app, requires=requires) + def install_app_code(self, app: AppConfig, test_mode: bool): """Install the application code into the bundle. @@ -615,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: @@ -636,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 @@ -811,26 +816,35 @@ 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 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) @@ -861,6 +875,10 @@ def create_app(self, app: AppConfig, test_mode: bool = False, **options): prefix=app.app_name, ) + self.tracking_add_created_instant(app) + self.tracking_add_briefcase_version(app) + self.tracking_add_python_version(app) + def verify_tools(self): """Verify that the tools needed to run this command exist. @@ -891,38 +909,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..bbab2a72b 100644 --- a/src/briefcase/commands/dev.py +++ b/src/briefcase/commands/dev.py @@ -74,16 +74,23 @@ 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 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 +113,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.") @@ -195,31 +204,30 @@ 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. 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 + + # TODO:PR: need to detect if new venv was created without reqs installed... + if not update_requirements: + update_requirements = self.tracking_is_requirements_updated( + app, requires=app.requires(test_mode=test_mode) + ) + + 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 +236,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/package.py b/src/briefcase/commands/package.py index 403481907..b5e4d8e01 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): diff --git a/src/briefcase/commands/run.py b/src/briefcase/commands/run.py index 4977b3419..5b10a976d 100644 --- a/src/briefcase/commands/run.py +++ b/src/briefcase/commands/run.py @@ -283,21 +283,10 @@ def __call__( # and that the app configuration is finalized. 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..27852c124 100644 --- a/src/briefcase/commands/update.py +++ b/src/briefcase/commands/update.py @@ -17,31 +17,52 @@ 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_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("New requirements detected") - 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 changes detected") - self.logger.info("Updating application code...", prefix=app.app_name) - self.install_app_code(app=app, test_mode=test_mode) + 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,20 +77,24 @@ 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) + 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: + ) -> dict: # Confirm host compatibility, that all required tools are available, # and that the app configuration is finalized. self.finalize(app) @@ -77,6 +102,7 @@ def __call__( 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 +114,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/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.