diff options
author | Bryan Newbold <bnewbold@archive.org> | 2022-08-12 12:20:25 -0700 |
---|---|---|
committer | Bryan Newbold <bnewbold@archive.org> | 2022-08-12 12:22:20 -0700 |
commit | 9cb5c59114b174a7eb8ec1065ff3321c0b1b86a4 (patch) | |
tree | b533928514866bbd97fd8dc3f35eed9ee1ce8741 /fatcat_scholar/web_hacks.py | |
parent | cc8a89d9d4a529af3eb87d50ed2ff36051e674f3 (diff) | |
download | fatcat-scholar-9cb5c59114b174a7eb8ec1065ff3321c0b1b86a4.tar.gz fatcat-scholar-9cb5c59114b174a7eb8ec1065ff3321c0b1b86a4.zip |
web: refactor i18n template loading
This is an attempt to fix a bug with random HTML template escapes in
production. I believe these are being caused by cross-request
contamination of template state due to using globals to hold on to
per-language jinja2 templates.
I originally thought this might be a bug in the jinja2 i18n extension
itself, and there may still be concurrency concerns there, but it seems
like the proximal cause is the use of globals.
This change probably has a negative performance impact, because the
jinja2 environment is re-created on every request (though babel files
are not reloaded on every request).
Diffstat (limited to 'fatcat_scholar/web_hacks.py')
-rw-r--r-- | fatcat_scholar/web_hacks.py | 84 |
1 files changed, 84 insertions, 0 deletions
diff --git a/fatcat_scholar/web_hacks.py b/fatcat_scholar/web_hacks.py index 2be90f0..aa33cbb 100644 --- a/fatcat_scholar/web_hacks.py +++ b/fatcat_scholar/web_hacks.py @@ -1,9 +1,13 @@ import typing +import babel.numbers +import babel.support import jinja2 from starlette.background import BackgroundTask from starlette.templating import _TemplateResponse +from fatcat_scholar.config import I18N_LANG_OPTIONS, settings + class Jinja2Templates: """ @@ -53,6 +57,86 @@ class Jinja2Templates: ) +def load_i18n_files() -> typing.Any: + """ + This is a hack to work around lack of per-request translation + (babel/gettext) locale switching in FastAPI and Starlette. Flask (and + presumably others) get around this using global context (eg, in + Flask-Babel). + + See related issues: + + - https://github.com/encode/starlette/issues/279 + - https://github.com/aio-libs/aiohttp-jinja2/issues/187 + """ + + d = dict() + for lang_opt in I18N_LANG_OPTIONS: + translations = babel.support.Translations.load( + dirname="fatcat_scholar/translations", + locales=[lang_opt], + ) + d[lang_opt] = translations + return d + + +I18N_TRANSLATION_FILES = load_i18n_files() + + +def locale_gettext(translations: typing.Any) -> typing.Any: + def gt(s): # noqa: ANN001,ANN201 + return translations.ugettext(s) + + return gt + + +def locale_ngettext(translations: typing.Any) -> typing.Any: + def ngt(s, p, n): # noqa: ANN001,ANN201 + return translations.ungettext(s, p, n) + + return ngt + + +def i18n_templates(locale: str) -> Jinja2Templates: + """ + This is a hack to work around lack of per-request translation + (babel/gettext) locale switching in FastAPI and Starlette. Flask (and + presumably others) get around this using global context (eg, in + Flask-Babel). + + The intent is to call this function and create a new Jinja2 Environment for + a specific language separately within a request (aka, not shared between + requests), when needed. This is inefficient but should resolve issues with + cross-request poisoning, both in threading (threadpool) or async + concurrency. + + See related issues: + + - https://github.com/encode/starlette/issues/279 + - https://github.com/aio-libs/aiohttp-jinja2/issues/187 + """ + + translations = I18N_TRANSLATION_FILES[locale] + templates = Jinja2Templates( + directory="fatcat_scholar/templates", + extensions=["jinja2.ext.i18n", "jinja2.ext.do"], + ) + templates.env.install_gettext_translations(translations, newstyle=True) # type: ignore + templates.env.install_gettext_callables( # type: ignore + locale_gettext(translations), + locale_ngettext(translations), + newstyle=True, + ) + # remove a lot of whitespace in HTML output with these configs + templates.env.trim_blocks = True + templates.env.lstrip_blocks = True + # pass-through application settings to be available in templates + templates.env.globals["settings"] = settings + templates.env.globals["babel_numbers"] = babel.numbers + templates.env.globals["make_access_redirect_url"] = make_access_redirect_url + return templates + + def parse_accept_lang(header: str, options: typing.List[str]) -> typing.Optional[str]: """ Crude HTTP Accept-Language content negotiation. |