1212import textwrap
1313from abc import ABC , abstractmethod
1414from argparse import RawDescriptionHelpFormatter
15+ from json import dumps , loads
1516from pathlib import Path
1617from 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.
0 commit comments