diff options
Diffstat (limited to 'sphinx/util/i18n.py')
-rw-r--r-- | sphinx/util/i18n.py | 253 |
1 files changed, 253 insertions, 0 deletions
diff --git a/sphinx/util/i18n.py b/sphinx/util/i18n.py new file mode 100644 index 0000000..b820884 --- /dev/null +++ b/sphinx/util/i18n.py @@ -0,0 +1,253 @@ +"""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 |