1212import textwrap
1313from abc import ABC , abstractmethod
1414from argparse import RawDescriptionHelpFormatter
15+ from collections .abc import Iterable
1516from pathlib import Path
1617from typing import Any
1718
19+ import tomli_w
1820from cookiecutter import exceptions as cookiecutter_exceptions
1921from cookiecutter .repository import is_repo_url
2022from 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