From ef162e8d070f51f422e57e9cfd62beb1db47e932 Mon Sep 17 00:00:00 2001 From: Bryan Newbold Date: Tue, 12 May 2020 15:50:49 -0700 Subject: very hack-y i18n support in jinja2 templates --- extra/i18n/babel.cfg | 4 + extra/i18n/web_interface.pot | 27 ++++++ fatcat_scholar/hacks.py | 51 +++++++++++ fatcat_scholar/templates/home.html | 4 + .../translations/de/LC_MESSAGES/messages.mo | Bin 0 -> 635 bytes .../translations/de/LC_MESSAGES/messages.po | 28 ++++++ .../translations/zh/LC_MESSAGES/messages.mo | Bin 0 -> 621 bytes .../translations/zh/LC_MESSAGES/messages.po | 28 ++++++ fatcat_scholar/web.py | 94 +++++++++++++++++---- 9 files changed, 220 insertions(+), 16 deletions(-) create mode 100644 extra/i18n/babel.cfg create mode 100644 extra/i18n/web_interface.pot create mode 100644 fatcat_scholar/hacks.py create mode 100644 fatcat_scholar/translations/de/LC_MESSAGES/messages.mo create mode 100644 fatcat_scholar/translations/de/LC_MESSAGES/messages.po create mode 100644 fatcat_scholar/translations/zh/LC_MESSAGES/messages.mo create mode 100644 fatcat_scholar/translations/zh/LC_MESSAGES/messages.po diff --git a/extra/i18n/babel.cfg b/extra/i18n/babel.cfg new file mode 100644 index 0000000..ff50c82 --- /dev/null +++ b/extra/i18n/babel.cfg @@ -0,0 +1,4 @@ +[python 1="**.py" language=":"][/python] +[jinja2: **/templates/**.html] +encoding = utf-8 +extensions = jinja2.ext.autoescape,jinja2.ext.with_ diff --git a/extra/i18n/web_interface.pot b/extra/i18n/web_interface.pot new file mode 100644 index 0000000..b9b0b7f --- /dev/null +++ b/extra/i18n/web_interface.pot @@ -0,0 +1,27 @@ +# Translations template for PROJECT. +# Copyright (C) 2020 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2020. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2020-05-12 14:16-0700\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.6.0\n" + +#: fatcat_scholar/templates/home.html:8 +msgid "This is a longer paragraph, all of which should be translated." +msgstr "" + +#: fatcat_scholar/templates/home.html:10 +msgid "this is a quick" +msgstr "" + diff --git a/fatcat_scholar/hacks.py b/fatcat_scholar/hacks.py new file mode 100644 index 0000000..fc1dacd --- /dev/null +++ b/fatcat_scholar/hacks.py @@ -0,0 +1,51 @@ + +import typing +import jinja2 + +from starlette.background import BackgroundTask +from starlette.templating import _TemplateResponse + +class Jinja2Templates: + """ + This is a patched version of starlette.templating.Jinja2Templates that + supports extensions (list of strings) passed to jinja2.Environment + """ + + def __init__(self, directory: str, extensions: typing.List[str] = []) -> None: + assert jinja2 is not None, "jinja2 must be installed to use Jinja2Templates" + self.env = self.get_env(directory, extensions) + + def get_env(self, directory: str, extensions: typing.List[str] = []) -> "jinja2.Environment": + @jinja2.contextfunction + def url_for(context: dict, name: str, **path_params: typing.Any) -> str: + request = context["request"] + return request.url_for(name, **path_params) + + loader = jinja2.FileSystemLoader(directory) + env = jinja2.Environment(loader=loader, extensions=extensions, autoescape=True) + env.globals["url_for"] = url_for + return env + + def get_template(self, name: str) -> "jinja2.Template": + return self.env.get_template(name) + + def TemplateResponse( + self, + name: str, + context: dict, + status_code: int = 200, + headers: dict = None, + media_type: str = None, + background: BackgroundTask = None, + ) -> _TemplateResponse: + if "request" not in context: + raise ValueError('context must include a "request" key') + template = self.get_template(name) + return _TemplateResponse( + template, + context, + status_code=status_code, + headers=headers, + media_type=media_type, + background=background, + ) diff --git a/fatcat_scholar/templates/home.html b/fatcat_scholar/templates/home.html index 82eb69d..e6e09b5 100644 --- a/fatcat_scholar/templates/home.html +++ b/fatcat_scholar/templates/home.html @@ -4,5 +4,9 @@

The Start

+ +

{% trans %}This is a longer paragraph, all of which should be translated.{% endtrans %} + +

and {{ _("this is a quick") }} thing to translate. diff --git a/fatcat_scholar/translations/de/LC_MESSAGES/messages.mo b/fatcat_scholar/translations/de/LC_MESSAGES/messages.mo new file mode 100644 index 0000000..8abbad1 Binary files /dev/null and b/fatcat_scholar/translations/de/LC_MESSAGES/messages.mo differ diff --git a/fatcat_scholar/translations/de/LC_MESSAGES/messages.po b/fatcat_scholar/translations/de/LC_MESSAGES/messages.po new file mode 100644 index 0000000..6356808 --- /dev/null +++ b/fatcat_scholar/translations/de/LC_MESSAGES/messages.po @@ -0,0 +1,28 @@ +# German translations for PROJECT. +# Copyright (C) 2020 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2020. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2020-05-12 14:16-0700\n" +"PO-Revision-Date: 2020-05-12 13:45-0700\n" +"Last-Translator: FULL NAME \n" +"Language: de\n" +"Language-Team: de \n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.6.0\n" + +#: fatcat_scholar/templates/home.html:8 +msgid "This is a longer paragraph, all of which should be translated." +msgstr "Dies ist ein längerer Absatz, der alle übersetzt werden sollte" + +#: fatcat_scholar/templates/home.html:10 +msgid "this is a quick" +msgstr "das ist schnell" + diff --git a/fatcat_scholar/translations/zh/LC_MESSAGES/messages.mo b/fatcat_scholar/translations/zh/LC_MESSAGES/messages.mo new file mode 100644 index 0000000..16166b3 Binary files /dev/null and b/fatcat_scholar/translations/zh/LC_MESSAGES/messages.mo differ diff --git a/fatcat_scholar/translations/zh/LC_MESSAGES/messages.po b/fatcat_scholar/translations/zh/LC_MESSAGES/messages.po new file mode 100644 index 0000000..2ffaacf --- /dev/null +++ b/fatcat_scholar/translations/zh/LC_MESSAGES/messages.po @@ -0,0 +1,28 @@ +# Chinese translations for PROJECT. +# Copyright (C) 2020 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2020. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2020-05-12 14:16-0700\n" +"PO-Revision-Date: 2020-05-12 13:45-0700\n" +"Last-Translator: FULL NAME \n" +"Language: zh\n" +"Language-Team: zh \n" +"Plural-Forms: nplurals=1; plural=0\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.6.0\n" + +#: fatcat_scholar/templates/home.html:8 +msgid "This is a longer paragraph, all of which should be translated." +msgstr "这是一个较长的段落,所有段落均应翻译" + +#: fatcat_scholar/templates/home.html:10 +msgid "this is a quick" +msgstr "这是一个快速" + diff --git a/fatcat_scholar/web.py b/fatcat_scholar/web.py index dfebe01..8725ce0 100644 --- a/fatcat_scholar/web.py +++ b/fatcat_scholar/web.py @@ -1,17 +1,24 @@ - """ -This file is the FastAPI web application. +This contains the FastAPI web application and RESTful API. + +So far there are few endpoints, so we just put them all here! """ from enum import Enum -from fastapi import FastAPI, APIRouter, Request, Depends +import babel.support +from fastapi import FastAPI, APIRouter, Request, Depends, Header from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates +from fatcat_scholar.hacks import Jinja2Templates from fastapi.responses import HTMLResponse +from pydantic import BaseModel I18N_LANG_DEFAULT = "en" -I18N_LANG_OPTIONS = ["en", "de", "zh"] +I18N_LANG_TRANSLATIONS = ["de", "zh"] +I18N_LANG_OPTIONS = I18N_LANG_TRANSLATIONS + [I18N_LANG_DEFAULT,] + +class SearchParams(BaseModel): + q: str = "" class LangPrefix: """ @@ -46,34 +53,89 @@ class ContentNegotiation: api = APIRouter() @api.get("/", operation_id="get_home") -async def home(request: Request): +async def home(): return {"endpoints": {"/": "this", "/search": "fulltext search"}} @api.get("/search", operation_id="get_search") -async def search(request: Request): +async def search(query: SearchParams = Depends(SearchParams)): return {"message": "search results would go here, I guess"} web = APIRouter() -templates = Jinja2Templates(directory="fatcat_scholar/templates") + +def locale_gettext(translations): + def gt(s): + return translations.ugettext(s) + return gt + +def locale_ngettext(translations): + def ngt(s, n): + return translations.ungettext(s) + return ngt + +def load_i18n_templates(): + """ + 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], + ) + templates = Jinja2Templates( + directory="fatcat_scholar/templates", + extensions=["jinja2.ext.i18n"], + ) + templates.env.install_gettext_translations(translations, newstyle=True) + templates.env.install_gettext_callables( + locale_gettext(translations), + locale_ngettext(translations), + newstyle=True, + ) + d[lang_opt] = templates + return d + +i18n_templates = load_i18n_templates() @web.get("/", include_in_schema=False) async def web_home(request: Request, lang: LangPrefix = Depends(LangPrefix), content: ContentNegotiation = Depends(ContentNegotiation)): if content.mimetype == "application/json": - return await api_home(request) - return templates.TemplateResponse("home.html", {"request": request}) + return await home() + return i18n_templates[lang.code].TemplateResponse("home.html", {"request": request, "locale": lang.code, "lang_prefix": lang.prefix}) @web.get("/search", include_in_schema=False) -async def web_search(request: Request): +async def web_search(request: Request, query: SearchParams = Depends(SearchParams), lang: LangPrefix = Depends(LangPrefix), content: ContentNegotiation = Depends(ContentNegotiation)): if content.mimetype == "application/json": - return await api_search(request) - return templates.TemplateResponse("search.html", {"request": request}) - -app = FastAPI() - + return await search(query) + return i18n_templates[lang.code].TemplateResponse("search.html", {"request": request}) + +app = FastAPI( + title="Fatcat Scholar", + description="Fulltext search interface for scholarly web content in the Fatcat catalog. An Internet Archive project.", + version="0.1.0-dev", + openapi_url="/api/openapi.json", + redoc_url="/api/redoc", + docs_url="/api/docs", +) app.include_router(web) for lang_option in I18N_LANG_OPTIONS: app.include_router(web, prefix=f"/{lang_option}") + +# Becasue we are mounting 'api' after 'web', the web routes will take +# precedence. Requests get passed through the API handlers based on content +# negotiation. This is counter-intuitive here in the code, but does seem to +# work, and results in the OpenAPI docs looking correct. app.include_router(api) + app.mount("/static", StaticFiles(directory="fatcat_scholar/static"), name="static") -- cgit v1.2.3