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 } .git"
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 } : %(message)s"
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