Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revert "Disable localisation when SOURCE_DATE_EPOCH is set (#10949)" #11306

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 0 additions & 2 deletions CHANGES
Expand Up @@ -92,8 +92,6 @@ Bugs fixed
* #11192: Restore correct parallel search index building.
Patch by Jeremy Maitin-Shepard
* Use the new Transifex ``tx`` client
* #9778: Disable localisation when the ``SOURCE_DATE_EPOCH`` environment
variable is set, to assist with 'reproducible builds'. Patch by James Addison

Testing
--------
Expand Down
2 changes: 1 addition & 1 deletion sphinx/builders/html/__init__.py
Expand Up @@ -502,7 +502,7 @@ def prepare_writing(self, docnames: set[str]) -> None:
# typically doesn't include the time of day
lufmt = self.config.html_last_updated_fmt
if lufmt is not None:
self.last_updated = format_date(lufmt or str(_('%b %d, %Y')),
self.last_updated = format_date(lufmt or _('%b %d, %Y'),
language=self.config.language)
else:
self.last_updated = None
Expand Down
2 changes: 1 addition & 1 deletion sphinx/builders/latex/__init__.py
Expand Up @@ -179,7 +179,7 @@ def init_context(self) -> None:
if self.config.today:
self.context['date'] = self.config.today
else:
self.context['date'] = format_date(self.config.today_fmt or str(_('%b %d, %Y')),
self.context['date'] = format_date(self.config.today_fmt or _('%b %d, %Y'),
language=self.config.language)

if self.config.latex_logo:
Expand Down
4 changes: 2 additions & 2 deletions sphinx/domains/std.py
Expand Up @@ -242,9 +242,9 @@ def add_target_and_index(self, firstname: str, sig: str, signode: desc_signature

# create an index entry
if currprogram:
descr = str(_('%s command line option') % currprogram)
descr = _('%s command line option') % currprogram
else:
descr = str(_('command line option'))
descr = _('command line option')
for option in signode.get('allnames', []):
entry = '; '.join([descr, option])
self.indexnode['entries'].append(('pair', entry, signode['ids'][0], '', None))
Expand Down
91 changes: 48 additions & 43 deletions sphinx/locale/__init__.py
Expand Up @@ -4,50 +4,56 @@

import locale
from gettext import NullTranslations, translation
from os import getenv, path
from os import path
from typing import Any, Callable


class _TranslationProxy:
"""
Class for proxy strings from gettext translations. This is a helper for the
lazy_* functions from this module.

The proxy implementation attempts to be as complete as possible, so that
the lazy objects should mostly work as expected, for example for sorting.
"""
__slots__ = '_catalogue', '_namespace', '_message'
__slots__ = ('_func', '_args')

def __new__(cls, func: Callable[..., str], *args: str) -> _TranslationProxy:
if not args:
# not called with "function" and "arguments", but a plain string
return str(func) # type: ignore[return-value]
return object.__new__(cls)

def __init__(self, catalogue: str, namespace: str, message: str) -> None:
self._catalogue = catalogue
self._namespace = namespace
self._message = message
def __getnewargs__(self) -> tuple[str]:
return (self._func,) + self._args # type: ignore

def __init__(self, func: Callable[..., str], *args: str) -> None:
self._func = func
self._args = args

def __str__(self) -> str:
try:
return translators[self._namespace, self._catalogue].gettext(self._message)
except KeyError:
# NullTranslations().gettext(self._message) == self._message
return self._message
return str(self._func(*self._args))

def __dir__(self) -> list[str]:
return dir(str)

def __getattr__(self, name: str) -> Any:
return getattr(self.__str__(), name)

def __getstate__(self) -> tuple[str, str, str]:
return self._catalogue, self._namespace, self._message
def __getstate__(self) -> tuple[Callable[..., str], tuple[str, ...]]:
return self._func, self._args

def __setstate__(self, tup: tuple[str, str, str]) -> None:
self._catalogue, self._namespace, self._message = tup
def __setstate__(self, tup: tuple[Callable[..., str], tuple[str]]) -> None:
self._func, self._args = tup

def __copy__(self) -> _TranslationProxy:
return _TranslationProxy(self._catalogue, self._namespace, self._message)
return _TranslationProxy(self._func, *self._args)

def __repr__(self) -> str:
try:
return f'i{self.__str__()!r}'
return 'i' + repr(str(self.__str__()))
except Exception:
return (self.__class__.__name__
+ f'({self._catalogue}, {self._namespace}, {self._message})')
return f'<{self.__class__.__name__} broken>'

def __add__(self, other: str) -> str:
return self.__str__() + other
Expand Down Expand Up @@ -104,30 +110,18 @@ def init(
# ignore previously failed attempts to find message catalogs
if translator.__class__ is NullTranslations:
translator = None
# the None entry is the system's default locale path
has_translation = True

if getenv('SOURCE_DATE_EPOCH') is not None:
# Disable localization during reproducible source builds
# See https://reproducible-builds.org/docs/source-date-epoch/
#
# Note: Providing an empty/none value to gettext.translation causes
# it to consult various language-related environment variables to find
# locale(s). We don't want that during a reproducible build; we want
# to run through the same code path, but to return NullTranslations.
#
# To achieve that, specify the ISO-639-3 'undetermined' language code,
# which should not match any translation catalogs.
languages: list[str] | None = ['und']
if language and '_' in language:
# for language having country code (like "de_AT")
languages: list[str] | None = [language, language.split('_')[0]]
elif language:
if '_' in language:
# for language having country code (like "de_AT")
languages = [language, language.split('_')[0]]
else:
languages = [language]
languages = [language]
else:
languages = None

# loading
# the None entry is the system's default locale path
for dir_ in locale_dirs:
try:
trans = translation(catalog, localedir=dir_, languages=languages)
Expand All @@ -138,13 +132,11 @@ def init(
except Exception:
# Language couldn't be found in the specified path
pass
if translator is not None:
has_translation = True
else:
# guarantee translators[(namespace, catalog)] exists
if translator is None:
translator = NullTranslations()
has_translation = False
# guarantee translators[(namespace, catalog)] exists
translators[namespace, catalog] = translator
translators[(namespace, catalog)] = translator
return translator, has_translation


Expand Down Expand Up @@ -177,6 +169,14 @@ def is_translator_registered(catalog: str = 'sphinx', namespace: str = 'general'
return (namespace, catalog) in translators


def _lazy_translate(catalog: str, namespace: str, message: str) -> str:
"""Used instead of _ when creating TranslationProxy, because _ is
not bound yet at that time.
"""
translator = get_translator(catalog, namespace)
return translator.gettext(message)


def get_translation(catalog: str, namespace: str = 'general') -> Callable[[str], str]:
"""Get a translation function based on the *catalog* and *namespace*.

Expand All @@ -203,7 +203,12 @@ def setup(app):
.. versionadded:: 1.8
"""
def gettext(message: str) -> str:
return _TranslationProxy(catalog, namespace, message) # type: ignore[return-value]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seemed like a nice cleanup -- and unlocks other simplifications -- so if it's possible to re-apply later in a separate change, then I'd like to do that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which bit were you talking about here? Open to keeping it if my partial revert didn't.

A

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In particular: switching everything over to lazy-loading (so that the gettext method doesn't require any conditional logic) - and as a side-effect, allowing content to be localised into more than one language during a unit test.

Referring back to some previous notes:

Attempting to write a test case to build the same application in two different languages was not initially possible -- the first-loaded translation catalog (as found in the sphinx.locale.translators global variable) -- would remain in-use for subsequent application builds under different locales

This can wait until after 6.2.x, unless you're particularly keen to dig into it. I'd plan to take another look at it fresh in a few days' time.

if not is_translator_registered(catalog, namespace):
# not initialized yet
return _TranslationProxy(_lazy_translate, catalog, namespace, message) # type: ignore[return-value] # noqa: E501
else:
translator = get_translator(catalog, namespace)
return translator.gettext(message)

return gettext

Expand Down
2 changes: 1 addition & 1 deletion sphinx/transforms/__init__.py
Expand Up @@ -106,7 +106,7 @@ def apply(self, **kwargs: Any) -> None:
text = self.config[refname]
if refname == 'today' and not text:
# special handling: can also specify a strftime format
text = format_date(self.config.today_fmt or str(_('%b %d, %Y')),
text = format_date(self.config.today_fmt or _('%b %d, %Y'),
language=self.config.language)
ref.replace_self(nodes.Text(text))

Expand Down
2 changes: 1 addition & 1 deletion sphinx/writers/manpage.py
Expand Up @@ -93,7 +93,7 @@ def __init__(self, document: nodes.document, builder: Builder) -> None:
if self.config.today:
self._docinfo['date'] = self.config.today
else:
self._docinfo['date'] = format_date(self.config.today_fmt or str(_('%b %d, %Y')),
self._docinfo['date'] = format_date(self.config.today_fmt or _('%b %d, %Y'),
language=self.config.language)
self._docinfo['copyright'] = self.config.copyright
self._docinfo['version'] = self.config.version
Expand Down
2 changes: 1 addition & 1 deletion sphinx/writers/texinfo.py
Expand Up @@ -220,7 +220,7 @@ def init_settings(self) -> None:
'project': self.escape(self.config.project),
'copyright': self.escape(self.config.copyright),
'date': self.escape(self.config.today or
format_date(self.config.today_fmt or str(_('%b %d, %Y')),
format_date(self.config.today_fmt or _('%b %d, %Y'),
language=self.config.language)),
})
# title
Expand Down
4 changes: 2 additions & 2 deletions sphinx/writers/text.py
Expand Up @@ -791,8 +791,8 @@ def visit_acks(self, node: Element) -> None:

def visit_image(self, node: Element) -> None:
if 'alt' in node.attributes:
self.add_text(str(_('[image: %s]') % node['alt']))
self.add_text(str(_('[image]')))
self.add_text(_('[image: %s]') % node['alt'])
self.add_text(_('[image]'))
raise nodes.SkipNode

def visit_transition(self, node: Element) -> None:
Expand Down
Binary file not shown.
2 changes: 0 additions & 2 deletions tests/roots/test-locale/locale1/et/LC_MESSAGES/myext.po

This file was deleted.

41 changes: 0 additions & 41 deletions tests/test_locale.py
Expand Up @@ -55,44 +55,3 @@ def test_add_message_catalog(app, rootdir):
assert _('Hello world') == 'HELLO WORLD'
assert _('Hello sphinx') == 'Hello sphinx'
assert _('Hello reST') == 'Hello reST'


def _empty_language_translation(rootdir):
locale_dirs, catalog = [rootdir / 'test-locale' / 'locale1'], 'myext'
locale.translators.clear()
locale.init(locale_dirs, language=None, catalog=catalog)
return locale.get_translation(catalog)


def test_init_environment_language(rootdir, monkeypatch):
with monkeypatch.context() as m:
m.setenv("LANGUAGE", "en_US:en")
_ = _empty_language_translation(rootdir)
assert _('Hello world') == 'HELLO WORLD'

with monkeypatch.context() as m:
m.setenv("LANGUAGE", "et_EE:et")
_ = _empty_language_translation(rootdir)
assert _('Hello world') == 'Tere maailm'
AA-Turner marked this conversation as resolved.
Show resolved Hide resolved


def test_init_reproducible_build_language(rootdir, monkeypatch):
with monkeypatch.context() as m:
m.setenv("SOURCE_DATE_EPOCH", "0")
m.setenv("LANGUAGE", "en_US:en")
_ = _empty_language_translation(rootdir)
sde_en_translation = str(_('Hello world')) # str cast to evaluate lazy method

with monkeypatch.context() as m:
m.setenv("SOURCE_DATE_EPOCH", "0")
m.setenv("LANGUAGE", "et_EE:et")
_ = _empty_language_translation(rootdir)
sde_et_translation = str(_('Hello world')) # str cast to evaluate lazy method

with monkeypatch.context() as m:
m.setenv("LANGUAGE", "et_EE:et")
_ = _empty_language_translation(rootdir)
loc_et_translation = str(_('Hello world')) # str cast to evaluate lazy method

assert sde_en_translation == sde_et_translation
assert sde_et_translation != loc_et_translation
56 changes: 0 additions & 56 deletions tests/test_util_inventory.py
Expand Up @@ -4,7 +4,6 @@
import zlib
from io import BytesIO

from sphinx.testing.util import SphinxTestApp
from sphinx.util.inventory import InventoryFile

inventory_v1 = b'''\
Expand Down Expand Up @@ -84,58 +83,3 @@ def test_read_inventory_v2_not_having_version():
invdata = InventoryFile.load(f, '/util', posixpath.join)
assert invdata['py:module']['module1'] == \
('foo', '', '/util/foo.html#module-module1', 'Long Module desc')


def _write_appconfig(dir, language, prefix=None):
prefix = prefix or language
(dir / prefix).makedirs()
(dir / prefix / 'conf.py').write_text(f'language = "{language}"', encoding='utf8')
(dir / prefix / 'index.rst').write_text('index.rst', encoding='utf8')
assert sorted((dir / prefix).listdir()) == ['conf.py', 'index.rst']
assert (dir / prefix / 'index.rst').exists()
return (dir / prefix)


def _build_inventory(srcdir):
app = SphinxTestApp(srcdir=srcdir)
app.build()
app.cleanup()
return (app.outdir / 'objects.inv')


def test_inventory_localization(tempdir):
# Build an app using Estonian (EE) locale
srcdir_et = _write_appconfig(tempdir, "et")
inventory_et = _build_inventory(srcdir_et)

# Build the same app using English (US) locale
srcdir_en = _write_appconfig(tempdir, "en")
inventory_en = _build_inventory(srcdir_en)

# Ensure that the inventory contents differ
assert inventory_et.read_bytes() != inventory_en.read_bytes()


def test_inventory_reproducible(tempdir, monkeypatch):
with monkeypatch.context() as m:
# Configure reproducible builds
# See: https://reproducible-builds.org/docs/source-date-epoch/
m.setenv("SOURCE_DATE_EPOCH", "0")

# Build an app using Estonian (EE) locale
srcdir_et = _write_appconfig(tempdir, "et")
reproducible_inventory_et = _build_inventory(srcdir_et)

# Build the same app using English (US) locale
srcdir_en = _write_appconfig(tempdir, "en")
reproducible_inventory_en = _build_inventory(srcdir_en)

# Also build the app using Estonian (EE) locale without build reproducibility enabled
srcdir_et = _write_appconfig(tempdir, "et", prefix="localized")
localized_inventory_et = _build_inventory(srcdir_et)

# Ensure that the reproducible inventory contents are identical
assert reproducible_inventory_et.read_bytes() == reproducible_inventory_en.read_bytes()

# Ensure that inventory contents are different between a localized and non-localized build
assert reproducible_inventory_et.read_bytes() != localized_inventory_et.read_bytes()