"""Builder superclass for all builders.""" from __future__ import annotations import os import re from datetime import datetime, timezone from os import path from typing import TYPE_CHECKING, Callable, NamedTuple import babel.dates from babel.messages.mofile import write_mo from babel.messages.pofile import read_po from sphinx.errors import SphinxError from sphinx.locale import __ from sphinx.util import logging from sphinx.util.osutil import SEP, canon_path, relpath if TYPE_CHECKING: from collections.abc import Generator from sphinx.environment import BuildEnvironment logger = logging.getLogger(__name__) class LocaleFileInfoBase(NamedTuple): base_dir: str domain: str charset: str class CatalogInfo(LocaleFileInfoBase): @property def po_file(self) -> str: return self.domain + '.po' @property def mo_file(self) -> str: return self.domain + '.mo' @property def po_path(self) -> str: return path.join(self.base_dir, self.po_file) @property def mo_path(self) -> str: return path.join(self.base_dir, self.mo_file) def is_outdated(self) -> bool: return ( not path.exists(self.mo_path) or path.getmtime(self.mo_path) < path.getmtime(self.po_path)) def write_mo(self, locale: str, use_fuzzy: bool = False) -> None: with open(self.po_path, encoding=self.charset) as file_po: try: po = read_po(file_po, locale) except Exception as exc: logger.warning(__('reading error: %s, %s'), self.po_path, exc) return with open(self.mo_path, 'wb') as file_mo: try: write_mo(file_mo, po, use_fuzzy) except Exception as exc: logger.warning(__('writing error: %s, %s'), self.mo_path, exc) class CatalogRepository: """A repository for message catalogs.""" def __init__(self, basedir: str | os.PathLike[str], locale_dirs: list[str], language: str, encoding: str) -> None: self.basedir = basedir self._locale_dirs = locale_dirs self.language = language self.encoding = encoding @property def locale_dirs(self) -> Generator[str, None, None]: if not self.language: return for locale_dir in self._locale_dirs: locale_dir = path.join(self.basedir, locale_dir) locale_path = path.join(locale_dir, self.language, 'LC_MESSAGES') if path.exists(locale_path): yield locale_dir else: logger.verbose(__('locale_dir %s does not exist'), locale_path) @property def pofiles(self) -> Generator[tuple[str, str], None, None]: for locale_dir in self.locale_dirs: basedir = path.join(locale_dir, self.language, 'LC_MESSAGES') for root, dirnames, filenames in os.walk(basedir): # skip dot-directories for dirname in dirnames: if dirname.startswith('.'): dirnames.remove(dirname) for filename in filenames: if filename.endswith('.po'): fullpath = path.join(root, filename) yield basedir, relpath(fullpath, basedir) @property def catalogs(self) -> Generator[CatalogInfo, None, None]: for basedir, filename in self.pofiles: domain = canon_path(path.splitext(filename)[0]) yield CatalogInfo(basedir, domain, self.encoding) def docname_to_domain(docname: str, compaction: bool | str) -> str: """Convert docname to domain for catalogs.""" if isinstance(compaction, str): return compaction if compaction: return docname.split(SEP, 1)[0] else: return docname # date_format mappings: ustrftime() to babel.dates.format_datetime() date_format_mappings = { '%a': 'EEE', # Weekday as locale’s abbreviated name. '%A': 'EEEE', # Weekday as locale’s full name. '%b': 'MMM', # Month as locale’s abbreviated name. '%B': 'MMMM', # Month as locale’s full name. '%c': 'medium', # Locale’s appropriate date and time representation. '%-d': 'd', # Day of the month as a decimal number. '%d': 'dd', # Day of the month as a zero-padded decimal number. '%-H': 'H', # Hour (24-hour clock) as a decimal number [0,23]. '%H': 'HH', # Hour (24-hour clock) as a zero-padded decimal number [00,23]. '%-I': 'h', # Hour (12-hour clock) as a decimal number [1,12]. '%I': 'hh', # Hour (12-hour clock) as a zero-padded decimal number [01,12]. '%-j': 'D', # Day of the year as a decimal number. '%j': 'DDD', # Day of the year as a zero-padded decimal number. '%-m': 'M', # Month as a decimal number. '%m': 'MM', # Month as a zero-padded decimal number. '%-M': 'm', # Minute as a decimal number [0,59]. '%M': 'mm', # Minute as a zero-padded decimal number [00,59]. '%p': 'a', # Locale’s equivalent of either AM or PM. '%-S': 's', # Second as a decimal number. '%S': 'ss', # Second as a zero-padded decimal number. '%U': 'WW', # Week number of the year (Sunday as the first day of the week) # as a zero padded decimal number. All days in a new year preceding # the first Sunday are considered to be in week 0. '%w': 'e', # Weekday as a decimal number, where 0 is Sunday and 6 is Saturday. '%-W': 'W', # Week number of the year (Monday as the first day of the week) # as a decimal number. All days in a new year preceding the first # Monday are considered to be in week 0. '%W': 'WW', # Week number of the year (Monday as the first day of the week) # as a zero-padded decimal number. '%x': 'medium', # Locale’s appropriate date representation. '%X': 'medium', # Locale’s appropriate time representation. '%y': 'YY', # Year without century as a zero-padded decimal number. '%Y': 'yyyy', # Year with century as a decimal number. '%Z': 'zzz', # Time zone name (no characters if no time zone exists). '%z': 'ZZZ', # UTC offset in the form ±HHMM[SS[.ffffff]] # (empty string if the object is naive). '%%': '%', } date_format_re = re.compile('(%s)' % '|'.join(date_format_mappings)) def babel_format_date(date: datetime, format: str, locale: str, formatter: Callable = babel.dates.format_date) -> str: # Check if we have the tzinfo attribute. If not we cannot do any time # related formats. if not hasattr(date, 'tzinfo'): formatter = babel.dates.format_date try: return formatter(date, format, locale=locale) except (ValueError, babel.core.UnknownLocaleError): # fallback to English return formatter(date, format, locale='en') except AttributeError: logger.warning(__('Invalid date format. Quote the string by single quote ' 'if you want to output it directly: %s'), format) return format def format_date( format: str, *, date: datetime | None = None, language: str, ) -> str: if date is None: # If time is not specified, try to use $SOURCE_DATE_EPOCH variable # See https://wiki.debian.org/ReproducibleBuilds/TimestampsProposal source_date_epoch = os.getenv('SOURCE_DATE_EPOCH') if source_date_epoch is not None: date = datetime.fromtimestamp(float(source_date_epoch), tz=timezone.utc) else: date = datetime.now(tz=timezone.utc).astimezone() result = [] tokens = date_format_re.split(format) for token in tokens: if token in date_format_mappings: babel_format = date_format_mappings.get(token, '') # Check if we have to use a different babel formatter then # format_datetime, because we only want to format a date # or a time. if token == '%x': function = babel.dates.format_date elif token == '%X': function = babel.dates.format_time else: function = babel.dates.format_datetime result.append(babel_format_date(date, babel_format, locale=language, formatter=function)) else: result.append(token) return "".join(result) def get_image_filename_for_language( filename: str | os.PathLike[str], env: BuildEnvironment, ) -> str: root, ext = path.splitext(filename) dirname = path.dirname(root) docpath = path.dirname(env.docname) try: return env.config.figure_language_filename.format( root=root, ext=ext, path=dirname and dirname + SEP, basename=path.basename(root), docpath=docpath and docpath + SEP, language=env.config.language, ) except KeyError as exc: msg = f'Invalid figure_language_filename: {exc!r}' raise SphinxError(msg) from exc def search_image_for_language(filename: str, env: BuildEnvironment) -> str: translated = get_image_filename_for_language(filename, env) _, abspath = env.relfn2path(translated) if path.exists(abspath): return translated else: return filename