3737import  sys 
3838from  bisect  import  bisect_left  as  bisect 
3939from  collections  import  OrderedDict 
40+ from  datetime  import  datetime  as  dt , timezone 
4041from  pathlib  import  Path 
4142from  string  import  Template 
4243from  textwrap  import  indent 
44+ from  time  import  perf_counter , sleep 
4345from  typing  import  Iterable 
4446from  urllib .parse  import  urljoin 
4547
@@ -246,8 +248,6 @@ def run(cmd, cwd=None) -> subprocess.CompletedProcess:
246248            cmdstring ,
247249            indent ("\n " .join (result .stdout .split ("\n " )[- 20 :]), "    " ),
248250        )
249-     else :
250-         logging .debug ("Run: %r OK" , cmdstring )
251251    result .check_returncode ()
252252    return  result 
253253
@@ -292,7 +292,13 @@ def get_ref(self, pattern):
292292            return  self .run ("show-ref" , "-s" , "tags/"  +  pattern ).stdout .strip ()
293293
294294    def  fetch (self ):
295-         self .run ("fetch" )
295+         """Try (and retry) to run git fetch.""" 
296+         try :
297+             return  self .run ("fetch" )
298+         except  subprocess .CalledProcessError  as  err :
299+             logging .error ("'git fetch' failed (%s), retrying..." , err .stderr )
300+             sleep (5 )
301+         return  self .run ("fetch" )
296302
297303    def  switch (self , branch_or_tag ):
298304        """Reset and cleans the repository to the given branch or tag.""" 
@@ -354,20 +360,6 @@ def locate_nearest_version(available_versions, target_version):
354360    return  tuple_to_version (found )
355361
356362
357- def  translation_branch (repo : Repository , needed_version : str ):
358-     """Some cpython versions may be untranslated, being either too old or 
359-     too new. 
360- 
361-     This function looks for remote branches on the given repo, and 
362-     returns the name of the nearest existing branch. 
363- 
364-     It could be enhanced to also search for tags. 
365-     """ 
366-     remote_branches  =  repo .run ("branch" , "-r" ).stdout 
367-     branches  =  re .findall (r"/([0-9]+\.[0-9]+)$" , remote_branches , re .M )
368-     return  locate_nearest_version (branches , needed_version )
369- 
370- 
371363@contextmanager  
372364def  edit (file : Path ):
373365    """Context manager to edit a file "in place", use it as: 
@@ -612,11 +604,15 @@ def parse_args():
612604def  setup_logging (log_directory : Path ):
613605    """Setup logging to stderr if ran by a human, or to a file if ran from a cron.""" 
614606    if  sys .stderr .isatty ():
615-         logging .basicConfig (format = "%(levelname)s:%(message)s" , stream = sys .stderr )
607+         logging .basicConfig (
608+             format = "%(asctime)s %(levelname)s: %(message)s" , stream = sys .stderr 
609+         )
616610    else :
617611        log_directory .mkdir (parents = True , exist_ok = True )
618612        handler  =  logging .handlers .WatchedFileHandler (log_directory  /  "docsbuild.log" )
619-         handler .setFormatter (logging .Formatter ("%(levelname)s:%(asctime)s:%(message)s" ))
613+         handler .setFormatter (
614+             logging .Formatter ("%(asctime)s %(levelname)s: %(message)s" )
615+         )
620616        logging .getLogger ().addHandler (handler )
621617    logging .getLogger ().setLevel (logging .DEBUG )
622618
@@ -652,19 +648,19 @@ def full_build(self):
652648
653649    def  run (self ) ->  bool :
654650        """Build and publish a Python doc, for a language, and a version.""" 
651+         start_time  =  perf_counter ()
652+         logging .info ("Running." )
655653        try :
656654            self .cpython_repo .switch (self .version .branch_or_tag )
657655            if  self .language .tag  !=  "en" :
658656                self .clone_translation ()
659-             self .build_venv ()
660-             self .build ()
661-             self .copy_build_to_webroot ()
657+             if  self .should_rebuild ():
658+                 self .build_venv ()
659+                 self .build ()
660+                 self .copy_build_to_webroot ()
661+                 self .save_state (build_duration = perf_counter () -  start_time )
662662        except  Exception  as  err :
663-             logging .exception (
664-                 "Exception while building %s version %s" ,
665-                 self .language .tag ,
666-                 self .version .name ,
667-             )
663+             logging .exception ("Badly handled exception, human, please help." )
668664            if  sentry_sdk :
669665                sentry_sdk .capture_exception (err )
670666            return  False 
@@ -676,10 +672,13 @@ def checkout(self) -> Path:
676672        return  self .build_root  /  "cpython" 
677673
678674    def  clone_translation (self ):
679-         """Clone the translation repository from github. 
675+         self .translation_repo .update ()
676+         self .translation_repo .switch (self .translation_branch )
677+ 
678+     @property  
679+     def  translation_repo (self ):
680+         """See PEP 545 for translations repository naming convention.""" 
680681
681-         See PEP 545 for repository naming convention. 
682-         """ 
683682        locale_repo  =  f"https://github.com/python/python-docs-{ self .language .tag }  
684683        locale_clone_dir  =  (
685684            self .build_root 
@@ -688,17 +687,25 @@ def clone_translation(self):
688687            /  self .language .iso639_tag 
689688            /  "LC_MESSAGES" 
690689        )
691-         repo  =  Repository (locale_repo , locale_clone_dir )
692-         repo .update ()
693-         repo .switch (translation_branch (repo , self .version .name ))
690+         return  Repository (locale_repo , locale_clone_dir )
691+ 
692+     @property  
693+     def  translation_branch (self ):
694+         """Some cpython versions may be untranslated, being either too old or 
695+         too new. 
696+ 
697+         This function looks for remote branches on the given repo, and 
698+         returns the name of the nearest existing branch. 
699+ 
700+         It could be enhanced to also search for tags. 
701+         """ 
702+         remote_branches  =  self .translation_repo .run ("branch" , "-r" ).stdout 
703+         branches  =  re .findall (r"/([0-9]+\.[0-9]+)$" , remote_branches , re .M )
704+         return  locate_nearest_version (branches , self .version .name )
694705
695706    def  build (self ):
696707        """Build this version/language doc.""" 
697-         logging .info (
698-             "Build start for version: %s, language: %s" ,
699-             self .version .name ,
700-             self .language .tag ,
701-         )
708+         logging .info ("Build start." )
702709        sphinxopts  =  list (self .language .sphinxopts )
703710        sphinxopts .extend (["-q" ])
704711        if  self .language .tag  !=  "en" :
@@ -774,11 +781,7 @@ def build(self):
774781        setup_switchers (
775782            self .versions , self .languages , self .checkout  /  "Doc"  /  "build"  /  "html" 
776783        )
777-         logging .info (
778-             "Build done for version: %s, language: %s" ,
779-             self .version .name ,
780-             self .language .tag ,
781-         )
784+         logging .info ("Build done." )
782785
783786    def  build_venv (self ):
784787        """Build a venv for the specific Python version. 
@@ -799,11 +802,7 @@ def build_venv(self):
799802
800803    def  copy_build_to_webroot (self ):
801804        """Copy a given build to the appropriate webroot with appropriate rights.""" 
802-         logging .info (
803-             "Publishing start for version: %s, language: %s" ,
804-             self .version .name ,
805-             self .language .tag ,
806-         )
805+         logging .info ("Publishing start." )
807806        self .www_root .mkdir (parents = True , exist_ok = True )
808807        if  self .language .tag  ==  "en" :
809808            target  =  self .www_root  /  self .version .name 
@@ -873,7 +872,7 @@ def copy_build_to_webroot(self):
873872                ]
874873            )
875874        if  self .full_build :
876-             logging .debug ("Copying dist files" )
875+             logging .debug ("Copying dist files. " )
877876            run (
878877                [
879878                    "chown" ,
@@ -916,11 +915,69 @@ def copy_build_to_webroot(self):
916915            purge (* prefixes )
917916            for  prefix  in  prefixes :
918917                purge (* [prefix  +  p  for  p  in  changed ])
919-         logging .info (
920-             "Publishing done for version: %s, language: %s" ,
921-             self .version .name ,
922-             self .language .tag ,
923-         )
918+         logging .info ("Publishing done" )
919+ 
920+     def  should_rebuild (self ):
921+         state  =  self .load_state ()
922+         if  not  state :
923+             logging .info ("Should rebuild: no previous state found." )
924+             return  True 
925+         cpython_sha  =  self .cpython_repo .run ("rev-parse" , "HEAD" ).stdout .strip ()
926+         if  self .language .tag  !=  "en" :
927+             translation_sha  =  self .translation_repo .run (
928+                 "rev-parse" , "HEAD" 
929+             ).stdout .strip ()
930+             if  translation_sha  !=  state ["translation_sha" ]:
931+                 logging .info (
932+                     "Should rebuild: new translations (from %s to %s)" ,
933+                     state ["translation_sha" ],
934+                     translation_sha ,
935+                 )
936+                 return  True 
937+         if  cpython_sha  !=  state ["cpython_sha" ]:
938+             diff  =  self .cpython_repo .run (
939+                 "diff" , "--name-only" , state ["cpython_sha" ], cpython_sha 
940+             ).stdout 
941+             if  "Doc/"  in  diff :
942+                 logging .info (
943+                     "Should rebuild: Doc/ has changed (from %s to %s)" ,
944+                     state ["cpython_sha" ],
945+                     cpython_sha ,
946+                 )
947+                 return  True 
948+         logging .info ("Nothing changed, no rebuild needed." )
949+         return  False 
950+ 
951+     def  load_state (self ) ->  dict :
952+         state_file  =  self .build_root  /  "state.toml" 
953+         try :
954+             return  tomlkit .loads (state_file .read_text (encoding = "UTF-8" ))[
955+                 f"/{ self .language .tag } { self .version .name }  
956+             ]
957+         except  KeyError :
958+             return  {}
959+ 
960+     def  save_state (self , build_duration : float ):
961+         """Save current cpython sha1 and current translation sha1. 
962+ 
963+         Using this we can deduce if a rebuild is needed or not. 
964+         """ 
965+         state_file  =  self .build_root  /  "state.toml" 
966+         try :
967+             states  =  tomlkit .parse (state_file .read_text (encoding = "UTF-8" ))
968+         except  FileNotFoundError :
969+             states  =  tomlkit .document ()
970+ 
971+         state  =  {}
972+         state ["cpython_sha" ] =  self .cpython_repo .run ("rev-parse" , "HEAD" ).stdout .strip ()
973+         if  self .language .tag  !=  "en" :
974+             state ["translation_sha" ] =  self .translation_repo .run (
975+                 "rev-parse" , "HEAD" 
976+             ).stdout .strip ()
977+         state ["last_build" ] =  dt .now (timezone .utc )
978+         state ["last_build_duration" ] =  build_duration 
979+         states [f"/{ self .language .tag } { self .version .name }  ] =  state 
980+         state_file .write_text (tomlkit .dumps (states ), encoding = "UTF-8" )
924981
925982
926983def  symlink (www_root : Path , language : Language , directory : str , name : str , group : str ):
@@ -1063,6 +1120,11 @@ def build_docs(args) -> bool:
10631120    cpython_repo .update ()
10641121    while  todo :
10651122        version , language  =  todo .pop ()
1123+         logging .root .handlers [0 ].setFormatter (
1124+             logging .Formatter (
1125+                 f"%(asctime)s %(levelname)s { language .tag } { version .name }  
1126+             )
1127+         )
10661128        if  sentry_sdk :
10671129            with  sentry_sdk .configure_scope () as  scope :
10681130                scope .set_tag ("version" , version .name )
@@ -1071,6 +1133,10 @@ def build_docs(args) -> bool:
10711133            version , versions , language , languages , cpython_repo , ** vars (args )
10721134        )
10731135        all_built_successfully  &=  builder .run ()
1136+     logging .root .handlers [0 ].setFormatter (
1137+         logging .Formatter ("%(asctime)s %(levelname)s: %(message)s" )
1138+     )
1139+ 
10741140    build_sitemap (versions , languages , args .www_root , args .group )
10751141    build_404 (args .www_root , args .group )
10761142    build_robots_txt (
0 commit comments