Skip to content

Commit 3af40d1

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

19 files changed

Lines changed: 368 additions & 243 deletions

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.tracking_save()
6061
logger.save_log_to_file(command)
6162

6263
return result

src/briefcase/commands/base.py

Lines changed: 121 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
import textwrap
1313
from abc import ABC, abstractmethod
1414
from argparse import RawDescriptionHelpFormatter
15+
from collections.abc import Iterable
1516
from pathlib import Path
1617
from typing import Any
1718

19+
import tomli_w
1820
from cookiecutter import exceptions as cookiecutter_exceptions
1921
from cookiecutter.repository import is_repo_url
2022
from platformdirs import PlatformDirs
@@ -153,11 +155,12 @@ def __init__(
153155
self,
154156
logger: Log,
155157
console: Console,
156-
tools: ToolCache = None,
157-
apps: dict = None,
158-
base_path: Path = None,
159-
data_path: Path = None,
158+
tools: ToolCache | None = None,
159+
apps: dict[str, AppConfig] | None = None,
160+
base_path: Path | None = None,
161+
data_path: Path | None = None,
160162
is_clone: bool = False,
163+
tracking: dict[AppConfig, dict[str, ...]] = None,
161164
):
162165
"""Base for all Commands.
163166
@@ -171,10 +174,7 @@ def __init__(
171174
Command; for instance, RunCommand can invoke UpdateCommand and/or
172175
BuildCommand.
173176
"""
174-
if base_path is None:
175-
self.base_path = Path.cwd()
176-
else:
177-
self.base_path = base_path
177+
self.base_path = Path.cwd() if base_path is None else base_path
178178
self.data_path = self.validate_data_path(data_path)
179179
self.apps = {} if apps is None else apps
180180
self.is_clone = is_clone
@@ -194,6 +194,9 @@ def __init__(
194194

195195
self.global_config = None
196196
self._briefcase_toml: dict[AppConfig, dict[str, ...]] = {}
197+
self._tracking: dict[AppConfig, dict[str, ...]] = (
198+
{} if tracking is None else tracking
199+
)
197200

198201
@property
199202
def logger(self):
@@ -319,6 +322,7 @@ def _command_factory(self, command_name: str):
319322
console=self.input,
320323
tools=self.tools,
321324
is_clone=True,
325+
tracking=self._tracking,
322326
)
323327
command.clone_options(self)
324328
return command
@@ -389,6 +393,9 @@ def binary_path(self, app) -> Path:
389393
:param app: The app config
390394
"""
391395

396+
def briefcase_toml_path(self, app: AppConfig) -> Path:
397+
return self.bundle_path(app) / "briefcase.toml"
398+
392399
def briefcase_toml(self, app: AppConfig) -> dict[str, ...]:
393400
"""Load the ``briefcase.toml`` file provided by the app template.
394401
@@ -399,11 +406,11 @@ def briefcase_toml(self, app: AppConfig) -> dict[str, ...]:
399406
return self._briefcase_toml[app]
400407
except KeyError:
401408
try:
402-
with (self.bundle_path(app) / "briefcase.toml").open("rb") as f:
403-
self._briefcase_toml[app] = tomllib.load(f)
409+
toml = self.briefcase_toml_path(app).read_text(encoding="utf-8")
404410
except OSError as e:
405411
raise MissingAppMetadata(self.bundle_path(app)) from e
406412
else:
413+
self._briefcase_toml[app] = tomllib.loads(toml)
407414
return self._briefcase_toml[app]
408415

409416
def path_index(self, app: AppConfig, path_name: str) -> str | dict | list:
@@ -487,11 +494,11 @@ def app_module_path(self, app: AppConfig) -> Path:
487494
"""Find the path for the application module for an app.
488495
489496
:param app: The config object for the app
490-
:returns: The Path to the dist-info folder.
497+
:returns: The Path to the app module
491498
"""
492499
app_home = [
493500
path.split("/")
494-
for path in app.sources
501+
for path in app.sources()
495502
if path.rsplit("/", 1)[-1] == app.module_name
496503
]
497504

@@ -500,14 +507,114 @@ def app_module_path(self, app: AppConfig) -> Path:
500507
f"Unable to find code for application {app.app_name!r}"
501508
)
502509
elif len(app_home) == 1:
503-
path = Path(str(self.base_path), *app_home[0])
510+
path = Path(self.base_path, *app_home[0])
504511
else:
505512
raise BriefcaseCommandError(
506513
f"Multiple paths in sources found for application {app.app_name!r}"
507514
)
508515

509516
return path
510517

518+
def dist_info_path(self, app: AppConfig) -> Path:
519+
"""Path to dist-info for the app in the output format build."""
520+
return self.app_path(app) / f"{app.module_name}-{app.version}.dist-info"
521+
522+
def tracking_path(self, app: AppConfig) -> Path:
523+
return self.bundle_path(app) / f".tracking.{app.module_name}.toml"
524+
525+
def tracking(self, app: AppConfig) -> dict[str, ...]:
526+
"""Load the build tracking information for the app.
527+
528+
:param app: The config object for the app
529+
:return: build tracking cache
530+
"""
531+
try:
532+
return self._tracking[app]
533+
except KeyError:
534+
try:
535+
toml = self.tracking_path(app).read_text(encoding="utf-8")
536+
except (OSError, AttributeError):
537+
toml = ""
538+
539+
self._tracking[app] = tomllib.loads(toml)
540+
return self._tracking[app]
541+
542+
def tracking_save(self) -> None:
543+
"""Update the persistent build tracking information."""
544+
for app in self.apps.values():
545+
try:
546+
content = tomli_w.dumps(self._tracking[app])
547+
except KeyError:
548+
pass
549+
else:
550+
try:
551+
with self.tracking_path(app).open("w", encoding="utf-8") as f:
552+
f.write(content)
553+
except OSError as e:
554+
self.logger.warning(
555+
f"Failed to update build tracking for {app.app_name!r}: "
556+
f"{type(e).__name__}: {e}"
557+
)
558+
559+
def tracking_set(self, app: AppConfig, key: str, value: object) -> None:
560+
"""Update a build tracking key/value pair."""
561+
self.tracking(app)[key] = value
562+
563+
def tracking_add_requirements(
564+
self,
565+
app: AppConfig,
566+
requires: Iterable[str],
567+
) -> None:
568+
"""Update the building tracking for the app's requirements."""
569+
self.tracking_set(app, key="requires", value=requires)
570+
571+
def tracking_is_requirements_updated(
572+
self,
573+
app: AppConfig,
574+
requires: Iterable[str],
575+
) -> bool:
576+
"""Has the app's requirements changed since last run?"""
577+
try:
578+
return self.tracking(app)["requires"] != requires
579+
except KeyError:
580+
return True
581+
582+
def tracking_source_modified_time(
583+
self,
584+
sources: Iterable[str | os.PathLike],
585+
) -> float:
586+
"""The epoch datetime of the most recently modified file in the app's
587+
sources."""
588+
return max(
589+
max((Path(dir_path) / f).stat().st_mtime for f in files)
590+
for src in sources
591+
for dir_path, _, files in self.tools.os.walk(Path.cwd() / src)
592+
)
593+
594+
def tracking_add_source_modified_time(
595+
self,
596+
app: AppConfig,
597+
sources: Iterable[str | os.PathLike],
598+
) -> None:
599+
"""Update build tracking for the app's source code's last modified datetime."""
600+
self.tracking_set(
601+
app,
602+
key="src_last_modified",
603+
value=self.tracking_source_modified_time(sources),
604+
)
605+
606+
def tracking_is_source_modified(
607+
self,
608+
app: AppConfig,
609+
sources: Iterable[str | os.PathLike],
610+
) -> bool:
611+
"""Has the app's source been modified since last run?"""
612+
try:
613+
tracked_time = self.tracking(app)["src_last_modified"]
614+
return tracked_time < self.tracking_source_modified_time(sources)
615+
except KeyError:
616+
return True
617+
511618
@property
512619
def briefcase_required_python_version(self):
513620
"""The major.minor of the minimum Python version required by Briefcase itself.
@@ -755,12 +862,7 @@ def add_default_options(self, parser):
755862
help="Save a detailed log to file. By default, this log file is only created for critical errors",
756863
)
757864

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

0 commit comments

Comments
 (0)