from collections.abc import Callable
from typing import cast
from warnings import warn
import flask
import werkzeug
from markupsafe import Markup
from toolforge_i18n._formatters import I18nFormatter
from toolforge_i18n._language_info import lang_bcp47_to_mw, lang_dir, lang_fallbacks, lang_mw_to_bcp47
from toolforge_i18n._translations import load_translations
[docs]
class UnknownMessageWarning(UserWarning):
"""Warning issued by :py:func:`~message` when a message is not defined.
This warning usually indicates one of two problems:
1. a typo in the message key
(whether in the :py:func:`~message` call or in ``i18n/en.json``), or
2. a message that was not added to ``i18n/en.json`` yet.
"""
def __init__(self, message_code: str, language_codes: list[str]) -> None:
"""Create the warning. This should only be called within toolforge_i18n.
message_code is the code (or key) of the message that was not found in the translations;
language_codes are the translations in which the message was searched for.
"""
self.message_code = message_code
self.language_codes: list[str] = []
for language_code in language_codes:
if language_code not in self.language_codes:
self.language_codes.append(language_code)
message = f'Message {message_code!r} not found in {self.language_codes}'
super().__init__(message)
def init_html_language_codes() -> None:
"""Initialize the stack of HTML language codes.
The stack is used to track whether a message needs to be wrapped
in a new HTML element to set the lang= and dir= attributes,
or whether it’s in the same language as the surrounding markup.
It’s maintained by :py:func:`~message`, :py:func:`~push_html_lang` and :py:func:`~pop_html_lang`.
"""
flask.g.html_language_codes = []
[docs]
def push_html_lang(language_code: str) -> Markup:
"""Push an HTML language code to the stack.
Many tools will not need to call this,
as it’s called by the :py:func:`~message` function automatically.
However, if you also add localized text from other sources than messages,
you should call this function with the MediaWiki language code you are using;
for example, in a Jinja2 template:
.. code-block:: html+jinja
<span {{ push_html_lang(label.language) }}>
{{ label.value }}
</span{{ pop_html_lang(label.language) }}>
"""
html_language_code = lang_mw_to_bcp47(language_code)
flask.g.html_language_codes.append(html_language_code)
return Markup(r'lang="{}" dir="{}"').format(html_language_code, lang_dir(html_language_code))
[docs]
def add_lang_if_needed(message: Markup, language_code: str) -> Markup:
"""Wrap the given message in a language-tagged element if necessary.
Given a (formatted) message in a certain language (MediaWiki language code),
wrap it in a ``<span>`` with ``lang=`` and ``dir=`` attributes
if the current language on top of the stack is different.
Note that :py:func:`~message` calls this function automatically,
so you generally don’t need to use this function yourself.
"""
if flask.g.html_language_codes and flask.g.html_language_codes[-1] == language_code:
return message
return Markup('<span {}>{}</span{}>').format(push_html_lang(language_code), message, pop_html_lang(language_code))
[docs]
def pop_html_lang(language_code: str) -> Markup:
"""Pop an HTML language code from the stack.
See :py:func:`~push_html_lang` for details.
"""
html_language_code = lang_mw_to_bcp47(language_code)
assert flask.g.html_language_codes.pop() == html_language_code
return Markup(r'')
def assert_html_language_codes_empty(response: werkzeug.Response) -> werkzeug.Response:
"""Assert that the stack of HTML language codes is depleted.
This is called at the end of a request;
if the assertion fails, some :py:func:`~push_html_lang` call is missing
a corresponding :py:func:`~pop_html_lang` call.
"""
assert flask.g.html_language_codes == []
return response
[docs]
def interface_language_code_from_request(translations: dict[str, dict[str, str]]) -> str:
"""Default implementation to determine the language code of a request.
This function supports the ``?uselang=`` URL parameter
and otherwise determines the language based on the request’s ``Accept-Language`` header.
You may want to override this method to implement a persistent language preference;
to keep the features mentioned above,
your implementation should generally look like this::
from toolforge_i18n import interface_language_code_from_request
def interface_language_code(translations):
# ?uselang= takes precedence if present
if 'uselang' in flask.request.args:
return interface_language_code_from_request(translations)
# try persistent language preference (e.g. from flask.session) next
# ...
# finally, fall back to Accept-Language:
return interface_language_code_from_request(translations)
# ...later, pass the implementation into ToolforgeI18n:
i18n = ToolforgeI18n(app, interface_language_code)
"""
if 'uselang' in flask.request.args:
return flask.request.args['uselang']
available_bcp47_languages = [lang_mw_to_bcp47(code) for code in translations]
best_bcp47_language = flask.request.accept_languages.best_match(available_bcp47_languages, 'en')
return lang_bcp47_to_mw(best_bcp47_language)
def _message_with_language(message_code: str) -> tuple[Markup, str, bool]:
interface_language_code = cast(str, flask.g.interface_language_code)
language_codes = [interface_language_code, *lang_fallbacks(interface_language_code), 'en']
translations = flask.current_app.extensions['toolforge_i18n'].translations
for language_code in language_codes:
try:
text = translations[language_code][message_code]
except LookupError:
continue
else:
return Markup(text), language_code, True
warn(UnknownMessageWarning(message_code, language_codes), stacklevel=4)
return Markup('⧼{message_code}⧽').format(message_code=message_code), 'qqx', False
def _message_qqx(message_code: str, **kwargs: object) -> Markup:
message = Markup('(')
message += message_code
if kwargs:
message += ': '
first = True
for key, value in kwargs.items():
if first:
first = False
else:
message += ', '
message += key
message += '='
message += repr(value)
message += ')'
return message
def _message(message_code: str, **kwargs: object) -> Markup:
if flask.g.qqx:
return _message_qqx(message_code, **kwargs)
message, language, do_format = _message_with_language(message_code)
if do_format and kwargs:
config = flask.current_app.extensions['toolforge_i18n'].config
formatter = I18nFormatter(
locale_identifier=config.language_code_to_babel(language),
get_gender=config.get_gender,
)
# I18nFormatter returns Markup given Markup
message = cast(Markup, formatter.format(message, **kwargs))
return add_lang_if_needed(message, language)
[docs]
def message(message_code: str, **kwargs: object) -> Markup:
"""Format an interface message in the user interface language.
The kwargs may contain (named) arguments,
using the argument names defined in :py:attr:`~TranslationsConfig.variables`.
This method is available as a template global, and is usually used there
(but may also be imported and called from Python code).
"""
# this function, which is exported for calling from Python code,
# just adds one stack level before calling _message(),
# while _message() is directly registered as a template global;
# this means that the warning from _message_with_language(), with stacklevel=4,
# will report the tool’s message() call correctly in two scenarios:
# 1. tool python -> message() -> _message() -> _message_with_language()
# 2. tool template -> Jinja2 -> _message() -> _message_with_language()
# (of course, this is somewhat brittle and might break with Jinja2 changes,
# but it works well enough as of 2024-10-03 under Flask 3.0.3 + Jinja2 3.1.4)
return _message(message_code, **kwargs)