├── .bumpversion.cfg ├── .git-commits.yaml ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── asgi_babel ├── __init__.py └── py.typed ├── example ├── __init__.py └── locales │ └── fr │ └── LC_MESSAGES │ ├── messages.mo │ └── messages.po ├── pyproject.toml └── tests ├── __init__.py └── test_asgi_babel.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | commit = True 3 | current_version = 0.10.0 4 | files = pyproject.toml 5 | tag = True 6 | tag_name = {new_version} 7 | message = build(version): {current_version} -> {new_version} 8 | -------------------------------------------------------------------------------- /.git-commits.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | convention: 4 | commitTypes: 5 | - feat 6 | - fix 7 | - perf 8 | - refactor 9 | - style 10 | - test 11 | - build 12 | - ops 13 | - docs 14 | - merge 15 | commitScopes: [] 16 | releaseTagGlobPattern: v[0-9]*.[0-9]*.[0-9]* 17 | 18 | changelog: 19 | commitTypes: 20 | - feat 21 | - fix 22 | - perf 23 | - merge 24 | includeInvalidCommits: true 25 | commitScopes: [] 26 | commitIgnoreRegexPattern: "^WIP " 27 | headlines: 28 | feat: Features 29 | fix: Bug Fixes 30 | perf: Performance Improvements 31 | merge: Merges 32 | breakingChange: BREAKING CHANGES 33 | commitUrl: https://github.com/klen/asgi-babel/commit/%commit% 34 | commitRangeUrl: https://github.com/klen/asgi-babel/compare/%from%...%to%?diff=split 35 | issueRegexPattern: "#[0-9]+" 36 | issueUrl: https://github.com/klen/asgi-babel/issues/%issue% 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | # Maintain dependencies for GitHub Actions 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | labels: 8 | - dependencies 9 | - autosquash 10 | schedule: 11 | interval: "monthly" 12 | 13 | # Maintain dependencies for Python 14 | - package-ecosystem: "pip" 15 | directory: "/requirements" 16 | labels: 17 | - dependencies 18 | - autosquash 19 | schedule: 20 | interval: "weekly" 21 | open-pull-requests-limit: 10 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | workflow_run: 5 | workflows: [tests] 6 | branches: [master] 7 | types: [completed] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | if: github.event.workflow_run.conclusion == 'success' 14 | 15 | steps: 16 | 17 | - uses: actions/checkout@main 18 | with: 19 | fetch-depth: 5 20 | 21 | - uses: actions/setup-python@main 22 | with: 23 | python-version: '3.12' 24 | 25 | - name: Build package 26 | run: | 27 | pip install build 28 | python -m build 29 | 30 | - uses: actions/upload-artifact@main 31 | with: 32 | name: dist 33 | path: dist 34 | 35 | publish: 36 | runs-on: ubuntu-latest 37 | needs: [build] 38 | steps: 39 | 40 | - name: Download a distribution artifact 41 | uses: actions/download-artifact@main 42 | with: 43 | name: dist 44 | path: dist 45 | 46 | - name: Publish distribution 📦 to PyPI 47 | uses: pypa/gh-action-pypi-publish@release/v1 48 | with: 49 | user: __token__ 50 | password: ${{ secrets.pypy }} 51 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: tests 5 | 6 | on: 7 | pull_request: 8 | branches: [master, develop] 9 | 10 | push: 11 | branches: [master, develop] 12 | 13 | jobs: 14 | 15 | tests: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ['3.9', '3.10', '3.11', '3.12'] 21 | 22 | steps: 23 | - name: Checkout changes 24 | uses: actions/checkout@main 25 | 26 | - name: Use Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@main 28 | with: 29 | cache: pip 30 | python-version: ${{ matrix.python-version }} 31 | 32 | - name: Install dependencies 33 | run: python -m pip install .[tests] 34 | 35 | - name: Check code 36 | run: ruff check asgi_babel 37 | 38 | - name: Test with pytest 39 | run: pytest --mypy tests 40 | 41 | notify: 42 | runs-on: ubuntu-latest 43 | needs: tests 44 | steps: 45 | 46 | - name: Notify Success 47 | uses: archive/github-actions-slack@master 48 | with: 49 | slack-channel: C2CRL4C4V 50 | slack-text: Tests are passed *[${{ github.ref }}]* https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} 51 | slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_TOKEN }} 52 | slack-optional-as_user: false 53 | slack-optional-icon_emoji: ":white_check_mark:" 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /dist 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | 4 | fail_fast: true 5 | default_install_hook_types: [commit-msg, pre-commit, pre-push] 6 | 7 | repos: 8 | 9 | - repo: https://github.com/qoomon/git-conventional-commits 10 | rev: 'v2.6.7' 11 | hooks: 12 | - id: conventional-commits 13 | args: ["-c", ".git-commits.yaml"] 14 | stages: ["commit-msg"] 15 | 16 | - repo: https://github.com/pre-commit/pre-commit-hooks 17 | rev: v4.6.0 18 | hooks: 19 | - id: check-case-conflict 20 | stages: ["pre-commit"] 21 | - id: check-merge-conflict 22 | stages: ["pre-commit"] 23 | - id: check-added-large-files 24 | stages: ["pre-commit"] 25 | args: ['--maxkb=5000'] 26 | - id: check-ast 27 | stages: ["pre-commit"] 28 | - id: check-executables-have-shebangs 29 | stages: ["pre-commit"] 30 | - id: check-symlinks 31 | stages: ["pre-commit"] 32 | - id: check-toml 33 | stages: ["pre-commit"] 34 | - id: check-yaml 35 | stages: ["pre-commit"] 36 | - id: debug-statements 37 | stages: ["pre-commit"] 38 | - id: end-of-file-fixer 39 | stages: ["pre-commit"] 40 | - id: trailing-whitespace 41 | stages: ["pre-commit"] 42 | 43 | - repo: https://github.com/psf/black 44 | rev: 24.4.2 45 | hooks: 46 | - id: black 47 | stages: ["pre-commit"] 48 | 49 | - repo: local 50 | hooks: 51 | - id: ruff 52 | name: ruff 53 | entry: ruff check asgi_babel 54 | language: system 55 | pass_filenames: false 56 | files: \.py$ 57 | stages: ["pre-commit"] 58 | 59 | - id: mypy 60 | name: mypy 61 | entry: mypy 62 | language: system 63 | pass_filenames: false 64 | files: \.py$ 65 | stages: ["pre-push"] 66 | 67 | - id: pytest 68 | name: pytest 69 | entry: pytest 70 | language: system 71 | pass_filenames: false 72 | files: \.py$ 73 | stages: ["pre-push"] 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kirill Klenov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include requirements *.txt 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VIRTUAL_ENV ?= env 2 | 3 | all: $(VIRTUAL_ENV) 4 | 5 | $(VIRTUAL_ENV): pyproject.toml .pre-commit-config.yaml 6 | @[ -d $(VIRTUAL_ENV) ] || python -m venv $(VIRTUAL_ENV) 7 | @$(VIRTUAL_ENV)/bin/pip install -e .[tests,dev,example] 8 | @$(VIRTUAL_ENV)/bin/pre-commit install 9 | @touch $(VIRTUAL_ENV) 10 | 11 | VERSION ?= minor 12 | 13 | .PHONY: version 14 | version: $(VIRTUAL_ENV) 15 | $(VIRTUAL_ENV)/bin/bump2version $(VERSION) 16 | git checkout master 17 | git pull 18 | git merge develop 19 | git checkout develop 20 | git push origin develop master 21 | git push --tags 22 | 23 | .PHONY: minor 24 | minor: 25 | make version VERSION=minor 26 | 27 | .PHONY: patch 28 | patch: 29 | make version VERSION=patch 30 | 31 | .PHONY: major 32 | major: 33 | make version VERSION=major 34 | 35 | 36 | .PHONY: clean 37 | # target: clean - Display callable targets 38 | clean: 39 | rm -rf build/ dist/ docs/_build *.egg-info 40 | find $(CURDIR) -name "*.py[co]" -delete 41 | find $(CURDIR) -name "*.orig" -delete 42 | find $(CURDIR)/$(MODULE) -name "__pycache__" | xargs rm -rf 43 | 44 | .PHONY: register 45 | # target: register - Register module on PyPi 46 | register: 47 | @python setup.py register 48 | 49 | 50 | test t: $(VIRTUAL_ENV) 51 | $(VIRTUAL_ENV)/bin/pytest tests 52 | 53 | 54 | mypy: $(VIRTUAL_ENV) 55 | $(VIRTUAL_ENV)/bin/mypy asgi_babel 56 | 57 | 58 | .PHONY: example 59 | example: $(VIRTUAL_ENV) 60 | $(VIRTUAL_ENV)/bin/uvicorn --reload --port 5000 example:app 61 | 62 | messages: $(VIRTUAL_ENV) 63 | # $(VIRTUAL_ENV)/bin/pybabel extract example -o locale-fr.pot 64 | # $(VIRTUAL_ENV)/bin/pybabel init -l fr -d example/locales -i locale-fr.pot 65 | $(VIRTUAL_ENV)/bin/pybabel compile -d example/locales 66 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ASGI-Babel 2 | ########### 3 | 4 | .. _description: 5 | 6 | **asgi-babel** -- Adds internationalization (i18n) support to ASGI applications (Asyncio_ / Trio_ / Curio_) 7 | 8 | .. _badges: 9 | 10 | .. image:: https://github.com/klen/asgi-babel/workflows/tests/badge.svg 11 | :target: https://github.com/klen/asgi-babel/actions 12 | :alt: Tests Status 13 | 14 | .. image:: https://img.shields.io/pypi/v/asgi-babel 15 | :target: https://pypi.org/project/asgi-babel/ 16 | :alt: PYPI Version 17 | 18 | .. image:: https://img.shields.io/pypi/pyversions/asgi-babel 19 | :target: https://pypi.org/project/asgi-babel/ 20 | :alt: Python Versions 21 | 22 | .. _contents: 23 | 24 | .. contents:: 25 | 26 | .. _requirements: 27 | 28 | Requirements 29 | ============= 30 | 31 | - python >= 3.9 32 | 33 | .. _installation: 34 | 35 | Installation 36 | ============= 37 | 38 | **asgi-babel** should be installed using pip: :: 39 | 40 | pip install asgi-babel 41 | 42 | 43 | Usage 44 | ===== 45 | 46 | Common ASGI applications: 47 | 48 | .. code:: python 49 | 50 | from asgi_babel import BabelMiddleware, current_locale, gettext 51 | 52 | async def my_app(scope, receive, send): 53 | """Get a current locale.""" 54 | locale = current_locale.get().language.encode() 55 | hello_world = gettext('Hello World!').encode() 56 | 57 | await send({"type": "http.response.start", "status": 200}) 58 | await send({"type": "http.response.body", "body": b"Current locale is %s\n" % locale}) 59 | await send({"type": "http.response.body", "body": hello_world}) 60 | 61 | app = BabelMiddleware(my_app, locales_dirs=['tests/locales']) 62 | 63 | # http GET / 64 | # 65 | # Current_locale is en 66 | # Hello World! 67 | 68 | # http GET / "accept-language: ft-CH, fr;q-0.9" 69 | # 70 | # Current_locale is fr 71 | # Bonjour le monde! 72 | 73 | As `ASGI-Tools`_ Internal middleware 74 | 75 | .. code:: python 76 | 77 | from asgi_tools import App 78 | from asgi_babel import BabelMiddleware, gettext 79 | 80 | app = App() 81 | app.middleware(BabelMiddleware.setup(locales_dirs=['tests/locales'])) 82 | 83 | @app.route('/') 84 | async def index(request): 85 | return gettext('Hello World!') 86 | 87 | @app.route('/locale') 88 | async def locale(request): 89 | return current_locale.get().language 90 | 91 | 92 | Usage with Curio async library 93 | ------------------------------ 94 | 95 | The `asgi-babel` uses context variable to set current locale. To enable the 96 | context variables with curio you have to run Curio_ with ``contextvars`` 97 | support: 98 | 99 | .. code-block:: python 100 | 101 | from curio.task import ContextTask 102 | 103 | curio.run(main, taskcls=ContextTask) 104 | 105 | 106 | Options 107 | ======== 108 | 109 | The middleware's options with default values: 110 | 111 | .. code:: python 112 | 113 | from asgi_babel import BabelMiddleware 114 | 115 | app = BabelMiddleware( 116 | 117 | # Your ASGI application 118 | app, 119 | 120 | # Default locale 121 | default_locale='en', 122 | 123 | # A path to find translations 124 | locales_dirs=['locales'] 125 | 126 | # A function with type: typing.Callable[[asgi_tools.Request], t.Awaitable[t.Optional[str]]] 127 | # which takes a request and default locale and return current locale 128 | locale_selector=asgi_babel.select_locale_by_request, 129 | 130 | ) 131 | 132 | 133 | How to extract & compile locales: 134 | ================================= 135 | 136 | http://babel.pocoo.org/en/latest/messages.html 137 | 138 | http://babel.pocoo.org/en/latest/cmdline.html 139 | 140 | .. _bugtracker: 141 | 142 | Bug tracker 143 | =========== 144 | 145 | If you have any suggestions, bug reports or 146 | annoyances please report them to the issue tracker 147 | at https://github.com/klen/asgi-babel/issues 148 | 149 | .. _contributing: 150 | 151 | Contributing 152 | ============ 153 | 154 | Development of the project happens at: https://github.com/klen/asgi-babel 155 | 156 | .. _license: 157 | 158 | License 159 | ======== 160 | 161 | Licensed under a `MIT license`_. 162 | 163 | 164 | .. _links: 165 | 166 | .. _ASGI-Tools: https://github.com/klen/asgi-tools 167 | .. _Asyncio: https://docs.python.org/3/library/asyncio.html 168 | .. _Curio: https://curio.readthedocs.io/en/latest/ 169 | .. _MIT license: http://opensource.org/licenses/MIT 170 | .. _Trio: https://trio.readthedocs.io/en/stable/ 171 | .. _klen: https://github.com/klen 172 | -------------------------------------------------------------------------------- /asgi_babel/__init__.py: -------------------------------------------------------------------------------- 1 | """Support cookie-encrypted sessions for ASGI applications.""" 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | from contextvars import ContextVar 7 | from dataclasses import dataclass, field 8 | from typing import TYPE_CHECKING, Awaitable, Callable, Optional, Union 9 | 10 | from asgi_tools import Request 11 | from asgi_tools.middleware import BaseMiddeware 12 | from babel import Locale, support 13 | 14 | if TYPE_CHECKING: 15 | from asgi_tools.types import TASGIApp, TASGIReceive, TASGIScope, TASGISend 16 | 17 | 18 | __all__ = ( 19 | "current_locale", 20 | "BabelMiddleware", 21 | "get_translations", 22 | "gettext", 23 | "ngettext", 24 | "pgettext", 25 | "npgettext", 26 | ) 27 | 28 | 29 | current_locale = ContextVar[Optional[Locale]]("locale", default=None) 30 | BABEL = None 31 | 32 | 33 | class BabelMiddlewareError(RuntimeError): 34 | """Base class for BabelMiddleware errors.""" 35 | 36 | 37 | async def select_locale_by_request(request: Request) -> Optional[str]: 38 | """Select a locale by the given request.""" 39 | locale_header = request.headers.get("accept-language") 40 | if locale_header: 41 | ulocales = list(parse_accept_header(locale_header)) 42 | if ulocales: 43 | return ulocales[0][1] 44 | 45 | return None 46 | 47 | 48 | @dataclass(eq=False, order=False) 49 | class BabelMiddleware(BaseMiddeware): 50 | """Support i18n.""" 51 | 52 | app: TASGIApp 53 | default_locale: str = "en" 54 | domain: str = "messages" 55 | locales_dirs: list[str] = field(default_factory=lambda: ["locales"]) 56 | locale_selector: Callable[[Request], Awaitable[Optional[str]]] = field( 57 | repr=False, 58 | default=select_locale_by_request, 59 | ) 60 | 61 | translations: dict[tuple[str, str], support.Translations] = field( 62 | init=False, 63 | repr=False, 64 | default_factory=dict, 65 | ) 66 | 67 | def __post_init__(self): 68 | global BABEL # noqa:PLW0603 69 | BABEL = self 70 | 71 | async def __process__( 72 | self, 73 | scope: TASGIScope, 74 | receive: TASGIReceive, 75 | send: TASGISend, 76 | ): 77 | """Load/save the sessions.""" 78 | if isinstance(scope, Request): 79 | request = scope 80 | else: 81 | request = scope.get("request") or Request(scope, receive, send) 82 | 83 | lang = await self.locale_selector(request) or self.default_locale 84 | locale = Locale.parse(lang, sep="-") 85 | current_locale.set(locale) 86 | 87 | return await self.app(scope, receive, send) 88 | 89 | 90 | def get_translations( 91 | domain: Optional[str] = None, 92 | locale: Optional[Locale] = None, 93 | ) -> Union[support.Translations, support.NullTranslations]: 94 | """Load and cache translations.""" 95 | if BABEL is None: 96 | raise BabelMiddlewareError 97 | 98 | locale = locale or current_locale.get() 99 | if locale is None: 100 | return support.NullTranslations() 101 | 102 | domain = domain or BABEL.domain 103 | if (domain, locale.language) not in BABEL.translations: 104 | translations = None 105 | for path in reversed(BABEL.locales_dirs): 106 | trans = support.Translations.load(path, locales=str(locale), domain=domain) 107 | if translations: 108 | translations._catalog.update(trans._catalog) 109 | else: 110 | translations = trans 111 | 112 | BABEL.translations[(domain, locale.language)] = translations 113 | 114 | return BABEL.translations[(domain, locale.language)] 115 | 116 | 117 | def gettext(string: str, domain: Optional[str] = None, **variables): 118 | """Translate a string with the current locale.""" 119 | t = get_translations(domain) 120 | return t.ugettext(string) % variables 121 | 122 | 123 | def ngettext( 124 | singular: str, 125 | plural: str, 126 | num: int, 127 | domain: Optional[str] = None, 128 | **variables, 129 | ): 130 | """Translate a string wity the current locale. 131 | 132 | The `num` parameter is used to dispatch between singular and various plural forms of the 133 | message. 134 | 135 | """ 136 | variables.setdefault("num", num) 137 | t = get_translations(domain) 138 | return t.ungettext(singular, plural, num) % variables 139 | 140 | 141 | def pgettext(context: str, string: str, domain: Optional[str] = None, **variables): 142 | """Like :meth:`gettext` but with a context.""" 143 | t = get_translations(domain) 144 | return t.upgettext(context, string) % variables 145 | 146 | 147 | def npgettext( 148 | context: str, 149 | singular: str, 150 | plural: str, 151 | num: int, 152 | domain: Optional[str] = None, 153 | **variables, 154 | ): 155 | """Like :meth:`ngettext` but with a context.""" 156 | variables.setdefault("num", num) 157 | t = get_translations(domain) 158 | return t.unpgettext(context, singular, plural, num) % variables 159 | 160 | 161 | def parse_accept_header(header: str) -> list[tuple[float, str]]: 162 | """Parse accept headers.""" 163 | result = [] 164 | for match in accept_re.finditer(header): 165 | quality = 1.0 166 | try: 167 | if match.group(2): 168 | quality = max(min(float(match.group(2)), 1), 0) 169 | if match.group(1) == "*": 170 | continue 171 | except ValueError: 172 | continue 173 | result.append((quality, match.group(1))) 174 | 175 | return sorted(result, reverse=True) 176 | 177 | 178 | locale_delim_re = re.compile(r"[_-]") 179 | accept_re = re.compile( 180 | r"""( # media-range capturing-parenthesis 181 | [^\s;,]+ # type/subtype 182 | (?:[ \t]*;[ \t]* # ";" 183 | (?: # parameter non-capturing-parenthesis 184 | [^\s;,q][^\s;,]* # token that doesn't start with "q" 185 | | # or 186 | q[^\s;,=][^\s;,]* # token that is more than just "q" 187 | ) 188 | )* # zero or more parameters 189 | ) # end of media-range 190 | (?:[ \t]*;[ \t]*q= # weight is a "q" parameter 191 | (\d*(?:\.\d+)?) # qvalue capturing-parentheses 192 | [^,]* # "extension" accept params: who cares? 193 | )? # accept params are optional 194 | """, 195 | re.VERBOSE, 196 | ) 197 | -------------------------------------------------------------------------------- /asgi_babel/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klen/asgi-babel/62a55e3265f6dc84af918ba4d655403669b4a716/asgi_babel/py.typed -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- 1 | """A simple i18n example (en/fr). 2 | 3 | To run the example install any ASGI server (uvicorn/hypercorn/etc): 4 | 5 | uvicorn example:app 6 | 7 | """ 8 | from asgi_tools import ResponseHTML 9 | from asgi_babel import BabelMiddleware, current_locale, gettext 10 | 11 | 12 | async def app(scope, receive, send): 13 | locale = current_locale.get() 14 | 15 | response = ResponseHTML( 16 | "" # noqa 17 | "