From d7ebb955031f5fd12a564458ac8b2c045e8a940b Mon Sep 17 00:00:00 2001 From: Russell Martin Date: Fri, 29 Mar 2024 19:20:20 -0400 Subject: [PATCH] [POC] Implement persistent build tracking for more intuitive behavior --- src/briefcase/__main__.py | 77 ++++++++++++----------- src/briefcase/commands/base.py | 104 ++++++++++++++++++++++++++----- src/briefcase/commands/build.py | 6 ++ src/briefcase/commands/create.py | 5 +- 4 files changed, 137 insertions(+), 55 deletions(-) diff --git a/src/briefcase/__main__.py b/src/briefcase/__main__.py index 31033945c..b3452efd9 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:]) - 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:]) + 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.build_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 aa088ec67..7d6e044f5 100644 --- a/src/briefcase/commands/base.py +++ b/src/briefcase/commands/base.py @@ -12,6 +12,7 @@ import textwrap from abc import ABC, abstractmethod from argparse import RawDescriptionHelpFormatter +from json import dumps, loads from pathlib import Path from typing import Any @@ -153,11 +154,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, + build_tracking: dict[AppConfig, dict[str, ...]] = None, ): """Base for all Commands. @@ -171,10 +173,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 +193,9 @@ def __init__( self.global_config = None self._briefcase_toml: dict[AppConfig, dict[str, ...]] = {} + self._build_tracking: dict[AppConfig, dict[str, ...]] = ( + {} if build_tracking is None else build_tracking + ) @property def logger(self): @@ -319,6 +321,7 @@ def _command_factory(self, command_name: str): console=self.input, tools=self.tools, is_clone=True, + build_tracking=self._build_tracking, ) command.clone_options(self) return command @@ -389,6 +392,9 @@ def binary_path(self, app) -> Path: :param app: The app config """ + def briefcase_toml_path(self, app: AppConfig) -> Path: + 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 +405,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: @@ -508,6 +514,77 @@ def app_module_path(self, app: AppConfig) -> Path: return path + def build_tracking_path(self, app: AppConfig) -> Path: + return self.bundle_path(app) / ".build_tracking.json" + + def build_tracking(self, app: AppConfig) -> dict[str, ...]: + """Load the build tracking information for the app. + + :param app: The config object for the app + :return: ConfigParser for build tracking + """ + try: + return self._build_tracking[app] + except KeyError: + try: + config = self.build_tracking_path(app).read_text(encoding="utf-8") + except OSError: + config = "{}" + + self._build_tracking[app] = loads(config) + return self._build_tracking[app] + + def build_tracking_save(self) -> None: + """Update the persistent build tracking information.""" + for app in self.apps.values(): + try: + content = dumps(self._build_tracking[app], indent=4) + except KeyError: + pass + else: + try: + with self.build_tracking_path(app).open("w", encoding="utf-8") as f: + f.write(content) + except OSError as e: + self.logger.warning( + f"Failed to update build tracking for {app.app_name!r}: " + f"{type(e).__name__}: {e}" + ) + + def build_tracking_set(self, app: AppConfig, key: str, value: object) -> None: + """Update a build tracking key/value pair.""" + self.build_tracking(app)[key] = value + + def build_tracking_add_requirements(self, app: AppConfig) -> None: + """Update the building tracking for the app's requirements.""" + self.build_tracking_set(app, key="requires", value=app.requires) + + def build_tracking_is_requirements_updated(self, app: AppConfig) -> bool: + """Have the app's requirements changed since last run?""" + return self.build_tracking(app).get("requires") != app.requires + + def build_tracking_source_modified_time(self, app: AppConfig) -> float: + """The epoch datetime of the most recently modified file in the app's + sources.""" + return max( + max((Path(dir_path) / f).stat().st_mtime for f in files) + for src in app.sources + for dir_path, _, files in self.tools.os.walk(Path.cwd() / src) + ) + + def build_tracking_add_source_modified_time(self, app: AppConfig) -> None: + """Update build tracking for the app's source code's last modified datetime.""" + self.build_tracking_set( + app, + key="src_last_modified", + value=self.build_tracking_source_modified_time(app), + ) + + def build_tracking_is_source_modified(self, app: AppConfig) -> bool: + """Has the app's source been modified since last run?""" + curr_modified_time = self.build_tracking_source_modified_time(app) + return self.build_tracking(app).get("src_last_modified") < curr_modified_time + @property def briefcase_required_python_version(self): """The major.minor of the minimum Python version required by Briefcase itself. @@ -755,12 +832,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. diff --git a/src/briefcase/commands/build.py b/src/briefcase/commands/build.py index 9c3c7ddc4..945345451 100644 --- a/src/briefcase/commands/build.py +++ b/src/briefcase/commands/build.py @@ -46,6 +46,12 @@ def _build_app( :param no_update: Should automated updates be disabled? :param test_mode: Is the app being build in test mode? """ + if not update_requirements: + update_requirements = self.build_tracking_is_requirements_updated(app) + + if not update: + update = self.build_tracking_is_source_modified(app) + if not self.bundle_path(app).exists(): state = self.create_command(app, test_mode=test_mode, **options) elif ( diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py index da05e9866..49e3fc879 100644 --- a/src/briefcase/commands/create.py +++ b/src/briefcase/commands/create.py @@ -548,7 +548,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. """ @@ -602,6 +602,7 @@ def install_app_requirements(self, app: AppConfig, test_mode: bool): "Application path index file does not define " "`app_requirements_path` or `app_packages_path`" ) from e + self.build_tracking_add_requirements(app) def install_app_code(self, app: AppConfig, test_mode: bool): """Install the application code into the bundle. @@ -636,6 +637,8 @@ def install_app_code(self, app: AppConfig, test_mode: bool): else: self.logger.info(f"No sources defined for {app.app_name}.") + self.build_tracking_add_source_modified_time(app) + # Write the dist-info folder for the application. write_dist_info( app=app,