| """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 |