Skip to content

Commit d7ebb95

Browse files
committed
[POC] Implement persistent build tracking for more intuitive behavior
1 parent a147aaf commit d7ebb95

File tree

4 files changed

+137
-55
lines changed

4 files changed

+137
-55
lines changed

src/briefcase/__main__.py

Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -15,48 +15,49 @@
1515
def main():
1616
result = 0
1717
command = None
18+
1819
printer = Printer()
1920
console = Console(printer=printer)
2021
logger = Log(printer=printer)
21-
try:
22-
Command, extra_cmdline = parse_cmdline(sys.argv[1:])
23-
command = Command(logger=logger, console=console)
24-
options, overrides = command.parse_options(extra=extra_cmdline)
25-
command.parse_config(
26-
Path.cwd() / "pyproject.toml",
27-
overrides=overrides,
28-
)
29-
command(**options)
30-
except HelpText as e:
31-
logger.info()
32-
logger.info(str(e))
33-
result = e.error_code
34-
except BriefcaseWarning as w:
35-
# The case of something that hasn't gone right, but in an
36-
# acceptable way.
37-
logger.warning(str(w))
38-
result = w.error_code
39-
except BriefcaseTestSuiteFailure as e:
40-
# Test suite status is logged when the test is executed.
41-
# Set the return code, but don't log anything else.
42-
result = e.error_code
43-
except BriefcaseError as e:
44-
logger.error()
45-
logger.error(str(e))
46-
result = e.error_code
47-
logger.capture_stacktrace()
48-
except Exception:
49-
logger.capture_stacktrace()
50-
raise
51-
except KeyboardInterrupt:
52-
logger.warning()
53-
logger.warning("Aborted by user.")
54-
logger.warning()
55-
result = -42
56-
if logger.save_log:
22+
23+
with suppress(KeyboardInterrupt):
24+
try:
25+
Command, extra_cmdline = parse_cmdline(sys.argv[1:])
26+
command = Command(logger=logger, console=console)
27+
options, overrides = command.parse_options(extra=extra_cmdline)
28+
command.parse_config(Path.cwd() / "pyproject.toml", overrides=overrides)
29+
command(**options)
30+
except HelpText as e:
31+
logger.info()
32+
logger.info(str(e))
33+
result = e.error_code
34+
except BriefcaseWarning as w:
35+
# The case of something that hasn't gone right, but in an
36+
# acceptable way.
37+
logger.warning(str(w))
38+
result = w.error_code
39+
except BriefcaseTestSuiteFailure as e:
40+
# Test suite status is logged when the test is executed.
41+
# Set the return code, but don't log anything else.
42+
result = e.error_code
43+
except BriefcaseError as e:
44+
logger.error()
45+
logger.error(str(e))
46+
result = e.error_code
47+
logger.capture_stacktrace()
48+
except Exception:
5749
logger.capture_stacktrace()
58-
finally:
59-
with suppress(KeyboardInterrupt):
50+
raise
51+
except KeyboardInterrupt:
52+
logger.warning()
53+
logger.warning("Aborted by user.")
54+
logger.warning()
55+
result = -42
56+
if logger.save_log:
57+
logger.capture_stacktrace()
58+
finally:
59+
if command is not None:
60+
command.build_tracking_save()
6061
logger.save_log_to_file(command)
6162

6263
return result

src/briefcase/commands/base.py

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import textwrap
1313
from abc import ABC, abstractmethod
1414
from argparse import RawDescriptionHelpFormatter
15+
from json import dumps, loads
1516
from pathlib import Path
1617
from typing import Any
1718

@@ -153,11 +154,12 @@ def __init__(
153154
self,
154155
logger: Log,
155156
console: Console,
156-
tools: ToolCache = None,
157-
apps: dict = None,
158-
base_path: Path = None,
159-
data_path: Path = None,
157+
tools: ToolCache | None = None,
158+
apps: dict[str, AppConfig] | None = None,
159+
base_path: Path | None = None,
160+
data_path: Path | None = None,
160161
is_clone: bool = False,
162+
build_tracking: dict[AppConfig, dict[str, ...]] = None,
161163
):
162164
"""Base for all Commands.
163165
@@ -171,10 +173,7 @@ def __init__(
171173
Command; for instance, RunCommand can invoke UpdateCommand and/or
172174
BuildCommand.
173175
"""
174-
if base_path is None:
175-
self.base_path = Path.cwd()
176-
else:
177-
self.base_path = base_path
176+
self.base_path = Path.cwd() if base_path is None else base_path
178177
self.data_path = self.validate_data_path(data_path)
179178
self.apps = {} if apps is None else apps
180179
self.is_clone = is_clone
@@ -194,6 +193,9 @@ def __init__(
194193

195194
self.global_config = None
196195
self._briefcase_toml: dict[AppConfig, dict[str, ...]] = {}
196+
self._build_tracking: dict[AppConfig, dict[str, ...]] = (
197+
{} if build_tracking is None else build_tracking
198+
)
197199

198200
@property
199201
def logger(self):
@@ -319,6 +321,7 @@ def _command_factory(self, command_name: str):
319321
console=self.input,
320322
tools=self.tools,
321323
is_clone=True,
324+
build_tracking=self._build_tracking,
322325
)
323326
command.clone_options(self)
324327
return command
@@ -389,6 +392,9 @@ def binary_path(self, app) -> Path:
389392
:param app: The app config
390393
"""
391394

395+
def briefcase_toml_path(self, app: AppConfig) -> Path:
396+
return self.bundle_path(app) / "briefcase.toml"
397+
392398
def briefcase_toml(self, app: AppConfig) -> dict[str, ...]:
393399
"""Load the ``briefcase.toml`` file provided by the app template.
394400
@@ -399,11 +405,11 @@ def briefcase_toml(self, app: AppConfig) -> dict[str, ...]:
399405
return self._briefcase_toml[app]
400406
except KeyError:
401407
try:
402-
with (self.bundle_path(app) / "briefcase.toml").open("rb") as f:
403-
self._briefcase_toml[app] = tomllib.load(f)
408+
toml = self.briefcase_toml_path(app).read_text(encoding="utf-8")
404409
except OSError as e:
405410
raise MissingAppMetadata(self.bundle_path(app)) from e
406411
else:
412+
self._briefcase_toml[app] = tomllib.loads(toml)
407413
return self._briefcase_toml[app]
408414

409415
def path_index(self, app: AppConfig, path_name: str) -> str | dict | list:
@@ -508,6 +514,77 @@ def app_module_path(self, app: AppConfig) -> Path:
508514

509515
return path
510516

517+
def build_tracking_path(self, app: AppConfig) -> Path:
518+
return self.bundle_path(app) / ".build_tracking.json"
519+
520+
def build_tracking(self, app: AppConfig) -> dict[str, ...]:
521+
"""Load the build tracking information for the app.
522+
523+
:param app: The config object for the app
524+
:return: ConfigParser for build tracking
525+
"""
526+
try:
527+
return self._build_tracking[app]
528+
except KeyError:
529+
try:
530+
config = self.build_tracking_path(app).read_text(encoding="utf-8")
531+
except OSError:
532+
config = "{}"
533+
534+
self._build_tracking[app] = loads(config)
535+
return self._build_tracking[app]
536+
537+
def build_tracking_save(self) -> None:
538+
"""Update the persistent build tracking information."""
539+
for app in self.apps.values():
540+
try:
541+
content = dumps(self._build_tracking[app], indent=4)
542+
except KeyError:
543+
pass
544+
else:
545+
try:
546+
with self.build_tracking_path(app).open("w", encoding="utf-8") as f:
547+
f.write(content)
548+
except OSError as e:
549+
self.logger.warning(
550+
f"Failed to update build tracking for {app.app_name!r}: "
551+
f"{type(e).__name__}: {e}"
552+
)
553+
554+
def build_tracking_set(self, app: AppConfig, key: str, value: object) -> None:
555+
"""Update a build tracking key/value pair."""
556+
self.build_tracking(app)[key] = value
557+
558+
def build_tracking_add_requirements(self, app: AppConfig) -> None:
559+
"""Update the building tracking for the app's requirements."""
560+
self.build_tracking_set(app, key="requires", value=app.requires)
561+
562+
def build_tracking_is_requirements_updated(self, app: AppConfig) -> bool:
563+
"""Have the app's requirements changed since last run?"""
564+
return self.build_tracking(app).get("requires") != app.requires
565+
566+
def build_tracking_source_modified_time(self, app: AppConfig) -> float:
567+
"""The epoch datetime of the most recently modified file in the app's
568+
sources."""
569+
return max(
570+
max((Path(dir_path) / f).stat().st_mtime for f in files)
571+
for src in app.sources
572+
for dir_path, _, files in self.tools.os.walk(Path.cwd() / src)
573+
)
574+
575+
def build_tracking_add_source_modified_time(self, app: AppConfig) -> None:
576+
"""Update build tracking for the app's source code's last modified datetime."""
577+
self.build_tracking_set(
578+
app,
579+
key="src_last_modified",
580+
value=self.build_tracking_source_modified_time(app),
581+
)
582+
583+
def build_tracking_is_source_modified(self, app: AppConfig) -> bool:
584+
"""Has the app's source been modified since last run?"""
585+
curr_modified_time = self.build_tracking_source_modified_time(app)
586+
return self.build_tracking(app).get("src_last_modified") < curr_modified_time
587+
511588
@property
512589
def briefcase_required_python_version(self):
513590
"""The major.minor of the minimum Python version required by Briefcase itself.
@@ -755,12 +832,7 @@ def add_default_options(self, parser):
755832
help="Save a detailed log to file. By default, this log file is only created for critical errors",
756833
)
757834

758-
def _add_update_options(
759-
self,
760-
parser,
761-
context_label="",
762-
update=True,
763-
):
835+
def _add_update_options(self, parser, context_label="", update=True):
764836
"""Internal utility method for adding common update options.
765837
766838
:param parser: The parser to which options should be added.

src/briefcase/commands/build.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ def _build_app(
4646
:param no_update: Should automated updates be disabled?
4747
:param test_mode: Is the app being build in test mode?
4848
"""
49+
if not update_requirements:
50+
update_requirements = self.build_tracking_is_requirements_updated(app)
51+
52+
if not update:
53+
update = self.build_tracking_is_source_modified(app)
54+
4955
if not self.bundle_path(app).exists():
5056
state = self.create_command(app, test_mode=test_mode, **options)
5157
elif (

src/briefcase/commands/create.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,7 @@ def _install_app_requirements(
548548
:param requires: The list of requirements to install
549549
:param app_packages_path: The full path of the app_packages folder into which
550550
requirements should be installed.
551-
:param progress_message: The waitbar progress message to display to the user.
551+
:param progress_message: The Wait Bar progress message to display to the user.
552552
:param pip_kwargs: Any additional keyword arguments to pass to the subprocess
553553
when invoking pip.
554554
"""
@@ -602,6 +602,7 @@ def install_app_requirements(self, app: AppConfig, test_mode: bool):
602602
"Application path index file does not define "
603603
"`app_requirements_path` or `app_packages_path`"
604604
) from e
605+
self.build_tracking_add_requirements(app)
605606

606607
def install_app_code(self, app: AppConfig, test_mode: bool):
607608
"""Install the application code into the bundle.
@@ -636,6 +637,8 @@ def install_app_code(self, app: AppConfig, test_mode: bool):
636637
else:
637638
self.logger.info(f"No sources defined for {app.app_name}.")
638639

640+
self.build_tracking_add_source_modified_time(app)
641+
639642
# Write the dist-info folder for the application.
640643
write_dist_info(
641644
app=app,

0 commit comments

Comments
 (0)