@@ -294,21 +294,82 @@ class Language:
294294def tag (self )-> str :
295295return self .iso639_tag .replace ("_" ,"-" ).lower ()
296296
297- @property
298- def is_translation (self )-> bool :
299- return self .tag != "en"
300-
301- @property
302- def locale_repo_url (self )-> str :
303- return f"https://github.com/python/python-docs-{ self .tag } .git"
304-
305297@property
306298def switcher_label (self )-> str :
307299if self .translated_name :
308300return f"{ self .name } |{ self .translated_name } "
309301return self .name
310302
311303
304+ @dataclasses .dataclass (frozen = True ,kw_only = True ,slots = True )
305+ class BuildMetadata :
306+ _version :Version
307+ _language :Language
308+
309+ @property
310+ def sphinxopts (self )-> Sequence [str ]:
311+ return self ._language .sphinxopts
312+
313+ @property
314+ def iso639_tag (self )-> str :
315+ return self ._language .iso639_tag
316+
317+ @property
318+ def html_only (self )-> bool :
319+ return self ._language .html_only
320+
321+ @property
322+ def url (self ):
323+ """The URL of this version in production."""
324+ if self .is_translation :
325+ return f"https://docs.python.org/{ self .version } /{ self .language } /"
326+ return f"https://docs.python.org/{ self .version } /"
327+
328+ @property
329+ def branch_or_tag (self )-> str :
330+ return self ._version .branch_or_tag
331+
332+ @property
333+ def status (self )-> str :
334+ return self ._version .status
335+
336+ @property
337+ def is_eol (self )-> bool :
338+ return self ._version .status == "EOL"
339+
340+ @property
341+ def dependencies (self )-> list [str ]:
342+ return self ._version .requirements
343+
344+ @property
345+ def version (self ):
346+ return self ._version .name
347+
348+ @property
349+ def version_tuple (self ):
350+ return self ._version .as_tuple ()
351+
352+ @property
353+ def language (self ):
354+ return self ._language .tag
355+
356+ @property
357+ def is_translation (self ):
358+ return self .language != "en"
359+
360+ @property
361+ def slug (self )-> str :
362+ return f"{ self .language } /{ self .version } "
363+
364+ @property
365+ def venv_name (self )-> str :
366+ return f"venv-{ self .version } "
367+
368+ @property
369+ def locale_repo_url (self )-> str :
370+ return f"https://github.com/python/python-docs-{ self .language } .git"
371+
372+
312373def run (
313374cmd :Sequence [str | Path ],cwd :Path | None = None
314375)-> subprocess .CompletedProcess :
@@ -534,8 +595,7 @@ def version_info() -> None:
534595class DocBuilder :
535596"""Builder for a CPython version and a language."""
536597
537- version :Version
538- language :Language
598+ build_meta :BuildMetadata
539599cpython_repo :Repository
540600docs_by_version_content :bytes
541601switchers_content :bytes
@@ -553,7 +613,7 @@ def html_only(self) -> bool:
553613return (
554614self .select_output in {"only-html" ,"only-html-en" }
555615or self .quick
556- or self .language .html_only
616+ or self .build_meta .html_only
557617 )
558618
559619@property
@@ -567,11 +627,11 @@ def run(self, http: urllib3.PoolManager, force_build: bool) -> bool | None:
567627start_timestamp = dt .datetime .now (tz = dt .UTC ).replace (microsecond = 0 )
568628logging .info ("Running." )
569629try :
570- if self .language .html_only and not self .includes_html :
630+ if self .build_meta .html_only and not self .includes_html :
571631logging .info ("Skipping non-HTML build (language is HTML-only)." )
572632return None # skipped
573- self .cpython_repo .switch (self .version .branch_or_tag )
574- if self .language .is_translation :
633+ self .cpython_repo .switch (self .build_meta .branch_or_tag )
634+ if self .build_meta .is_translation :
575635self .clone_translation ()
576636if trigger_reason := self .should_rebuild (force_build ):
577637self .build_venv ()
@@ -593,7 +653,7 @@ def run(self, http: urllib3.PoolManager, force_build: bool) -> bool | None:
593653
594654@property
595655def locale_dir (self )-> Path :
596- return self .build_root / self .version . name / "locale"
656+ return self .build_root / self .build_meta . version / "locale"
597657
598658@property
599659def checkout (self )-> Path :
@@ -608,8 +668,8 @@ def clone_translation(self) -> None:
608668def translation_repo (self )-> Repository :
609669"""See PEP 545 for translations repository naming convention."""
610670
611- locale_clone_dir = self .locale_dir / self .language .iso639_tag / "LC_MESSAGES"
612- return Repository (self .language .locale_repo_url ,locale_clone_dir )
671+ locale_clone_dir = self .locale_dir / self .build_meta .iso639_tag / "LC_MESSAGES"
672+ return Repository (self .build_meta .locale_repo_url ,locale_clone_dir )
613673
614674@property
615675def translation_branch (self )-> str :
@@ -623,25 +683,25 @@ def translation_branch(self) -> str:
623683 """
624684remote_branches = self .translation_repo .run ("branch" ,"-r" ).stdout
625685branches = re .findall (r"/([0-9]+\.[0-9]+)$" ,remote_branches ,re .M )
626- return locate_nearest_version (branches ,self .version . name )
686+ return locate_nearest_version (branches ,self .build_meta . version )
627687
628688def build (self )-> None :
629689"""Build this version/language doc."""
630690logging .info ("Build start." )
631691start_time = perf_counter ()
632- sphinxopts = list (self .language .sphinxopts )
633- if self .language .is_translation :
692+ sphinxopts = list (self .build_meta .sphinxopts )
693+ if self .build_meta .is_translation :
634694sphinxopts .extend ((
635695f"-D locale_dirs={ self .locale_dir } " ,
636- f"-D language={ self .language .iso639_tag } " ,
696+ f"-D language={ self .build_meta .iso639_tag } " ,
637697"-D gettext_compact=0" ,
638698"-D translation_progress_classes=1" ,
639699 ))
640700
641- if self .version . status == "EOL" :
701+ if self .build_meta . is_eol :
642702sphinxopts .append ("-D html_context.outdated=1" )
643703
644- if self .version .status in ("in development" ,"pre-release" ):
704+ if self .build_meta .status in ("in development" ,"pre-release" ):
645705maketarget = "autobuild-dev"
646706else :
647707maketarget = "autobuild-stable"
@@ -653,17 +713,15 @@ def build(self) -> None:
653713blurb = self .venv / "bin" / "blurb"
654714
655715if self .includes_html :
656- site_url = self .version .url
657- if self .language .is_translation :
658- site_url += f"{ self .language .tag } /"
716+ site_url = self .build_meta .url
659717# Define a tag to enable opengraph socialcards previews
660718# (used in Doc/conf.py and requires matplotlib)
661719sphinxopts += (
662720"-t create-social-cards" ,
663721f"-D ogp_site_url={ site_url } " ,
664722 )
665723
666- if self .version . as_tuple () < (3 ,8 ):
724+ if self .build_meta . version_tuple < (3 ,8 ):
667725# Disable CPython switchers, we handle them now:
668726text = (self .checkout / "Doc" / "Makefile" ).read_text (encoding = "utf-8" )
669727text = text .replace (" -A switchers=1" ,"" )
@@ -696,12 +754,12 @@ def build_venv(self) -> None:
696754 So we can reuse them from builds to builds, while they contain
697755 different Sphinx versions.
698756 """
699- requirements = list (self .version . requirements )
757+ requirements = list (self .build_meta . dependencies )
700758if self .includes_html :
701759# opengraph previews
702760requirements .append ("matplotlib>=3" )
703761
704- venv_path = self .build_root / f"venv- { self .version . name } "
762+ venv_path = self .build_root / self .build_meta . venv_name
705763venv .create (venv_path ,symlinks = os .name != "nt" ,with_pip = True )
706764run (
707765 (
@@ -726,7 +784,7 @@ def setup_indexsidebar(self) -> None:
726784dbv_path = tmpl_dst / "_docs_by_version.html"
727785
728786shutil .copy (tmpl_src / "indexsidebar.html" ,tmpl_dst / "indexsidebar.html" )
729- if self .version . status != "EOL" :
787+ if not self .build_meta . is_eol :
730788dbv_path .write_bytes (self .docs_by_version_content )
731789else :
732790shutil .copy (tmpl_src / "_docs_by_version.html" ,dbv_path )
@@ -736,14 +794,14 @@ def copy_build_to_webroot(self, http: urllib3.PoolManager) -> None:
736794logging .info ("Publishing start." )
737795start_time = perf_counter ()
738796self .www_root .mkdir (parents = True ,exist_ok = True )
739- if not self .language .is_translation :
740- target = self .www_root / self .version . name
797+ if not self .build_meta .is_translation :
798+ target = self .www_root / self .build_meta . version
741799else :
742- language_dir = self .www_root / self .language . tag
800+ language_dir = self .www_root / self .build_meta . language
743801language_dir .mkdir (parents = True ,exist_ok = True )
744802chgrp (language_dir ,group = self .group ,recursive = True )
745803language_dir .chmod (0o775 )
746- target = language_dir / self .version . name
804+ target = language_dir / self .build_meta . version
747805
748806target .mkdir (parents = True ,exist_ok = True )
749807try :
@@ -792,8 +850,7 @@ def copy_build_to_webroot(self, http: urllib3.PoolManager) -> None:
792850
793851logging .info ("%s files changed" ,changed )
794852if changed and not self .skip_cache_invalidation :
795- surrogate_key = f"{ self .language .tag } /{ self .version .name } "
796- purge_surrogate_key (http ,surrogate_key )
853+ purge_surrogate_key (http ,self .build_meta .slug )
797854logging .info (
798855"Publishing done (%s)." ,format_seconds (perf_counter ()- start_time )
799856 )
@@ -804,7 +861,7 @@ def should_rebuild(self, force: bool) -> str | Literal[False]:
804861logging .info ("Should rebuild: no previous state found." )
805862return "no previous state"
806863cpython_sha = self .cpython_repo .run ("rev-parse" ,"HEAD" ).stdout .strip ()
807- if self .language .is_translation :
864+ if self .build_meta .is_translation :
808865translation_sha = self .translation_repo .run (
809866"rev-parse" ,"HEAD"
810867 ).stdout .strip ()
@@ -839,7 +896,7 @@ def load_state(self) -> dict:
839896state_file = self .build_root / "state.toml"
840897try :
841898return tomlkit .loads (state_file .read_text (encoding = "UTF-8" ))[
842- f"/{ self .language . tag } / { self . version . name } /"
899+ f"/{ self .build_meta . slug } /"
843900 ]
844901except (KeyError ,FileNotFoundError ):
845902return {}
@@ -860,14 +917,14 @@ def save_state(
860917except FileNotFoundError :
861918states = tomlkit .document ()
862919
863- key = f"/{ self .language . tag } / { self . version . name } /"
920+ key = f"/{ self .build_meta . slug } /"
864921state = {
865922"last_build_start" :build_start ,
866923"last_build_duration" :round (build_duration ,0 ),
867924"triggered_by" :trigger ,
868925"cpython_sha" :self .cpython_repo .run ("rev-parse" ,"HEAD" ).stdout .strip (),
869926 }
870- if self .language .is_translation :
927+ if self .build_meta .is_translation :
871928state ["translation_sha" ]= self .translation_repo .run (
872929"rev-parse" ,"HEAD"
873930 ).stdout .strip ()
@@ -1123,7 +1180,7 @@ def build_docs(args: argparse.Namespace) -> int:
11231180# pairs from the end of the list, effectively reversing it.
11241181# This runs languages in config.toml order and versions newest first.
11251182todo = [
1126- ( version ,language )
1183+ BuildMetadata ( _version = version ,_language = language )
11271184for version in versions .filter (args .branches )
11281185for language in reversed (languages .filter (args .languages ))
11291186 ]
@@ -1142,28 +1199,27 @@ def build_docs(args: argparse.Namespace) -> int:
11421199args .build_root / _checkout_name (args .select_output ),
11431200 )
11441201while todo :
1145- version , language = todo .pop ()
1202+ build_props = todo .pop ()
11461203logging .root .handlers [0 ].setFormatter (
11471204logging .Formatter (
1148- f"%(asctime)s %(levelname)s{ language . tag } / { version . name } : %(message)s"
1205+ f"%(asctime)s %(levelname)s{ build_props . slug } : %(message)s"
11491206 )
11501207 )
11511208if sentry_sdk :
11521209scope = sentry_sdk .get_isolation_scope ()
1153- scope .set_tag ("version" ,version . name )
1154- scope .set_tag ("language" ,language . tag )
1210+ scope .set_tag ("version" ,build_props . version )
1211+ scope .set_tag ("language" ,build_props . language )
11551212cpython_repo .update ()
11561213builder = DocBuilder (
1157- version ,
1158- language ,
1214+ build_props ,
11591215cpython_repo ,
11601216docs_by_version_content ,
11611217switchers_content ,
11621218** vars (args ),
11631219 )
11641220built_successfully = builder .run (http ,force_build = force_build )
11651221if built_successfully :
1166- build_succeeded .add (( version . name , language . tag ) )
1222+ build_succeeded .add (build_props . slug )
11671223elif built_successfully is not None :
11681224any_build_failed = True
11691225
@@ -1286,7 +1342,7 @@ def make_symlinks(
12861342group :str ,
12871343versions :Versions ,
12881344languages :Languages ,
1289- successful_builds :Set [tuple [ str , str ] ],
1345+ successful_builds :Set [str ],
12901346skip_cache_invalidation :bool ,
12911347http :urllib3 .PoolManager ,
12921348)-> None :
@@ -1306,7 +1362,7 @@ def make_symlinks(
13061362 ("dev" ,versions .current_dev .name ),
13071363 ):
13081364for language in languages :
1309- if ( symlink_target , language .tag ) in successful_builds :
1365+ if f" { language .tag } / { symlink_target } " in successful_builds :
13101366symlink (
13111367www_root ,
13121368language .tag ,