├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── examples ├── test1 │ ├── babel.cfg │ ├── i18n.py │ └── main.py ├── with_context │ ├── babel.cfg │ ├── i18n.py │ ├── lang │ │ ├── en │ │ │ └── LC_MESSAGES │ │ │ │ └── messages.po │ │ └── fa │ │ │ └── LC_MESSAGES │ │ │ └── messages.po │ └── main.py ├── with_jinja │ ├── babel.cfg │ ├── full.py │ ├── lang │ │ └── fa │ │ │ └── LC_MESSAGES │ │ │ └── messages.po │ └── templates │ │ └── item.html └── wtforms │ ├── __init__.py │ ├── app.py │ ├── babel.cfg │ ├── forms.py │ ├── i18n.py │ ├── lang │ └── fa │ │ └── LC_MESSAGES │ │ └── messages.po │ ├── main.py │ ├── routes.py │ └── templates │ └── index.html ├── fastapi_babel ├── __init__.py ├── cli.py ├── core.py ├── exceptions.py ├── helpers.py ├── local_context.py ├── middleware.py └── properties.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py └── tests └── test_concurrent.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | .vscode 112 | .idea 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-toml 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | - repo: https://github.com/pycqa/isort 11 | rev: 5.12.0 12 | hooks: 13 | - id: isort 14 | name: isort (python) 15 | - id: isort 16 | name: isort (cython) 17 | types: [cython] 18 | - id: isort 19 | name: isort (pyi) 20 | types: [pyi] 21 | - repo: https://github.com/psf/black 22 | rev: 23.3.0 23 | hooks: 24 | - id: black 25 | - repo: https://github.com/pycqa/flake8 26 | rev: 6.0.0 27 | hooks: 28 | - id: flake8 29 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | This document instructs with setting up a dev environment and experimenting with `fastapi-babel` and contributing to the project. 3 | 4 | Create a personal fork of the project on Github. (Click on the `Fork` button on Project's main page). 5 | 6 | Clone the fork on your local machine: 7 | - `git clone git@github.com:YourUsername/fastapi-babel` 8 | 9 | Your remote repo on Github is called `origin`. 10 | 11 | Change directory to the cloned repo. 12 | - `cd fastapi-babel` 13 | 14 | Add the original repository as a remote called upstream. 15 | - `git remote set-url upstream git@github.com:Anbarryprojects/fastapi-babel.git` 16 | 17 | If you created your fork a while ago be sure to pull upstream changes into your local repository. 18 | - `git pull upstream` 19 | 20 | Set up your dev environment: 21 | - Create a `virtualenv` first: 22 | - `python -m virtualenv .venv` 23 | - Activate the `virtualenv`: 24 | - `source ./venv/bin/activate` 25 | - Install the dev requirements: 26 | - `pip install -r requirements-dev.txt` 27 | 28 | Create a new branch to work on! Branch from `develop` if it exists, else from `main`. 29 | 30 | Implement/fix your feature, comment your code. 31 | 32 | Follow the code style of the project, including indentation. 33 | 34 | Write or adapt tests as needed. run the tests with `tox`. 35 | 36 | Add or change the documentation as needed. 37 | 38 | Squash your commits into a single commit with git's interactive rebase. 39 | 40 | Push your branch to your fork on Github, the remote origin. 41 | 42 | Click the `Compare & pull request` button that is showed up in Github. 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Anbarry 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VENV_NAME := .venv 2 | PYTHON := $(VENV_NAME)/bin/python 3 | VERSION := $(shell $(PYTHON) -c "import fastapi_babel;print(fastapi_babel.__version__)") 4 | 5 | RM := rm -rf 6 | 7 | mkvenv: 8 | virtualenv $(VENV_NAME) 9 | $(PYTHON) -m pip install -r requirements.txt 10 | 11 | clean: 12 | find . -name '*.pyc' -exec $(RM) {} + 13 | find . -name '*.pyo' -exec $(RM) {} + 14 | find . -name '*~' -exec $(RM) {} + 15 | find . -name '__pycache__' -exec $(RM) {} + 16 | $(RM) build/ dist/ docs/build/ .tox/ .cache/ .pytest_cache/ *.egg-info 17 | 18 | tag: 19 | @echo "Add tag: '$(VERSION)'" 20 | git tag v$(VERSION) 21 | 22 | build: 23 | $(PYTHON) setup.py sdist bdist_wheel 24 | 25 | upload: 26 | twine upload dist/* 27 | 28 | release: 29 | make clean 30 | make test 31 | make build 32 | make tag 33 | @echo "Released $(VERSION)" 34 | 35 | 36 | full-release: 37 | make release 38 | make upload 39 | 40 | install: 41 | $(PYTHON) setup.py install 42 | 43 | install-dev: 44 | $(PYTHON) setup.py develop 45 | 46 | test: 47 | tox 48 | 49 | summary: 50 | cloc pytitle/ tests/ docs/ setup.py 51 | 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | 6 | 7 | 8 | # FastAPI BABEL 9 | ### Get [pybabbel](https://github.com/python-babel/babel) tools directly within your FastAPI project without hassle. 10 | 11 | FastAPI Babel is integrated within FastAPI framework and gives you support of i18n, l10n, date and time locales, and all other pybabel functionalities. 12 | 13 | ## Features: 14 | - **I18n** (Internationalization) 15 | - **Wtform Translation** (Lazy Text) 16 | - **l10n** (Localization) 17 | - **Date and time** locale 18 | - **Decimal, Number** locale 19 | - **Money and currency** locale converter 20 | - locale selector from **HTTP header** 21 | 22 | ## Support 23 | **Python:** 3.6 and later (tested on Python 3.6, 3.12) 24 | **FastAPI**: 0.45.0 + 25 | **PyBabel**: All 26 | 27 | ## Installation 28 | pip install fastapi-babel 29 | 30 | # How to use 31 | 32 | 1. install FastAPI and FastAPI Babel: 33 | 34 | `pip install fastapi` 35 | 36 | and 37 | 38 | `pip install fastapi_babel` 39 | 40 | 2. make `babel.py` file: 41 | 42 | ```python 43 | from fastapi_babel import Babel, BabelConfigs 44 | 45 | configs = BabelConfigs( 46 | ROOT_DIR=__file__, 47 | BABEL_DEFAULT_LOCALE="en", 48 | BABEL_TRANSLATION_DIRECTORY="lang", 49 | ) 50 | babel = Babel(configs=configs) 51 | 52 | if __name__ == "__main__": 53 | babel.run_cli() 54 | ``` 55 | 56 | 3. make `babel.cfg` file 57 | 58 | *babel.cfg* 59 | 60 | [python: **.py] 61 | 62 | 63 | 4. Create main.py file: 64 | 65 | ```python 66 | from fastapi_babel import Babel, BabelConfigs, _ 67 | 68 | configs = BabelConfigs( 69 | ROOT_DIR=__file__, 70 | BABEL_DEFAULT_LOCALE="en", 71 | BABEL_TRANSLATION_DIRECTORY="lang", 72 | ) 73 | babel = Babel(configs=configs) 74 | 75 | def main(): 76 | babel.locale = "en" 77 | en_text = _("Hello World") 78 | print(en_text) 79 | 80 | babel.locale = "fa" 81 | fa_text = _("Hello World") 82 | print(fa_text) 83 | 84 | if __name__ == "__main__": 85 | main() 86 | ``` 87 | 88 | 5. Extract the message 89 | 90 | `pybabel extract -F babel.cfg -o messages.pot .` 91 | 92 | 6. Initialize pybabel 93 | 94 | `pybabel init -i messages.pot -d lang -l fa` 95 | 96 | 7. Goto *lang/**YOUR_LANGUAGE_CODE**/LC_MESSAGES/messages.po* and **add your translation** to your messages. 97 | 98 | 8. Go back to the root folder and Compile 99 | 100 | `pybabel compile -d lang` 101 | 102 | 9. Run `main.py` 103 | 104 | `python3 main.py` 105 | 106 | - ### FastAPI Babel Commands 107 | Install click at first: 108 | `pip install click` 109 | 110 | 1. Add this snippet to your FasAPI code: 111 | 112 | ```python 113 | ... 114 | babel.run_cli() 115 | ... 116 | ``` 117 | 2. Now just follow the documentation from [step 5](#step5). 118 | 119 | For more information just take a look at help flag of `main.py` 120 | `python main.py --help` 121 | 122 | 123 | #### Why FastAPI Babel CLI is recommanded ? 124 | FastAPI Babel CLI will eliminate the need of concering the directories and paths, so you can concentrate on the project and spend less time on going forward and backward. You only need to specify **domain name**, **babel.cfg** and **localization directory**. 125 | 126 | 127 | **NOTICE:** Do **not** use `FastAPI Babel` beside fastapi runner files (`main.py` or `run.py`), as uvicorn cli will not work. 128 | 129 | 130 | [========] 131 | 132 | ## Using FastAPI Babel in an API 133 | 134 | - create file `babel.py` and write the code below. 135 | 136 | ```python 137 | from fastapi_babel import Babel, BabelConfigs, BabelMiddleware 138 | 139 | configs = BabelConfigs( 140 | ROOT_DIR=__file__, 141 | BABEL_DEFAULT_LOCALE="en", 142 | BABEL_TRANSLATION_DIRECTORY="lang", 143 | ) 144 | app.add_middleware(BabelMiddleware, babel_configs=configs) 145 | 146 | if __name__ == "__main__": 147 | Babel(configs).run_cli() 148 | ``` 149 | 1. extract messages with following command 150 | 151 | `python3 babel.py extract -d/--dir {watch_dir}` 152 | 153 | 154 | **Notice: ** watch_dir is your project root directory, where the messages will be extracted. 155 | 156 | 2. Add your own language locale directory, for instance `fa`. 157 | 158 | `python3 babel.py init -l fa` 159 | 160 | 3. Go to ./lang/Fa/.po and add your translations. 161 | 162 | 4. compile all locale directories. 163 | `python3 babel.py compile` 164 | 165 | ```python 166 | from fastapi import FastAPI 167 | 168 | 169 | from fastapi_babel import _ 170 | from fastapi_babel import Babel, BabelConfigs 171 | from fastapi_babel import BabelMiddleware 172 | 173 | app = FastAPI() 174 | babel_configs = BabelConfigs( 175 | ROOT_DIR=__file__, 176 | BABEL_DEFAULT_LOCALE="en", 177 | BABEL_TRANSLATION_DIRECTORY="lang", 178 | ) 179 | app.add_middleware(BabelMiddleware, babel_configs=babel_configs) 180 | 181 | 182 | @app.get("/") 183 | async def index(): 184 | return {"text": _("Hello World")} 185 | 186 | 187 | if __name__ == "__main__": 188 | Babel(configs=babel_configs).run_cli() 189 | 190 | ``` 191 | 192 | 5. Now you can control your translation language from the request header and the locale code. The parameter is `Accept-Language`. 193 | 194 | ### How to use Jinja In FastAPI Babel 195 | 196 | 1. Add jinja extension to **babel.cfg** 197 | 198 | 199 | ```xml 200 | [python: **.py] 201 | [jinja2: **/templates/**.html] 202 | extensions=jinja2.ext.autoescape,jinja2.ext.with_ 203 | ``` 204 | 205 | 2. Here is how your `main.py` should look like. 206 | 207 | 208 | *main.py* 209 | 210 | ```python 211 | from fastapi import FastAPI, Request 212 | from fastapi.responses import HTMLResponse 213 | from fastapi.staticfiles import StaticFiles 214 | from fastapi.templating import Jinja2Templates 215 | 216 | 217 | from fastapi_babel import _ 218 | from fastapi_babel import Babel, BabelConfigs 219 | from fastapi_babel import BabelMiddleware 220 | 221 | app = FastAPI() 222 | babel_configs = BabelConfigs( 223 | ROOT_DIR=__file__, 224 | BABEL_DEFAULT_LOCALE="en", 225 | BABEL_TRANSLATION_DIRECTORY="lang", 226 | ) 227 | templates = Jinja2Templates(directory="templates") 228 | app.add_middleware( 229 | BabelMiddleware, babel_configs=babel_configs, jinja2_templates=templates 230 | ) 231 | app.mount("/static", StaticFiles(directory="static"), name="static") 232 | 233 | 234 | @app.get("/") 235 | async def index(): 236 | return {"text": _("Hello World")} 237 | 238 | 239 | @app.get("/items/{id}", response_class=HTMLResponse) 240 | async def read_item(request: Request, id: str): 241 | return templates.TemplateResponse("item.html", {"request": request, "id": id}) 242 | 243 | 244 | if __name__ == "__main__": 245 | Babel(configs=babel_configs).run_cli() 246 | 247 | ``` 248 | 3. Here is sample `index.html` file 249 | 250 | *index.html* 251 | 252 | ```html 253 | 254 | 255 | 256 | 257 | 258 | 259 | Document 260 | 261 | 262 |

{{_("Hello World")}}

263 | 264 | 265 | ``` 266 | 267 | 4. Now just follow the documentation from [step 5](#step5). 268 | 269 | 5. More features like lazy gettext, please check the [Wtform Example](https://github.com/Anbarryprojects/fastapi-babel/tree/main/examples/wtforms) 270 | 271 | ### How to use multithread mode for fastapi babel: 272 | 273 | ```python 274 | 275 | import threading 276 | from time import sleep 277 | from typing import Annotated 278 | 279 | from pydantic import BaseModel 280 | from i18n import _ 281 | from i18n import babel_configs 282 | from fastapi import Depends, FastAPI 283 | from fastapi_babel import BabelMiddleware, Babel 284 | from fastapi_babel.local_context import BabelContext 285 | from fastapi_babel import use_babel 286 | 287 | app = FastAPI() 288 | app.add_middleware(BabelMiddleware, babel_configs=babel_configs) 289 | 290 | 291 | class ResponseModel(BaseModel): 292 | idx: int 293 | text: str 294 | 295 | 296 | def translate_after(idx, babel: Babel): 297 | with BabelContext(babel_configs, babel=babel): 298 | print(_("Hello world"), babel.locale, idx) 299 | 300 | 301 | @app.get("/", response_model=ResponseModel) 302 | async def index(idx: int, babel: Annotated[Babel, Depends(use_babel)]): 303 | t = threading.Thread(target=translate_after, args=[idx, babel]) 304 | t.start() 305 | return ResponseModel(idx=idx, text=_("Hello world")) 306 | 307 | 308 | ``` 309 | 310 | ## Authors 311 | 312 | - [@Parsa Pourmhammad](https://github.com/Legopapurida) 313 | 314 | 315 | ## Contributing 316 | 317 | Contributions are always welcome! 318 | 319 | Please read `contributing.md` to get familiar how to get started. 320 | 321 | Please adhere to the project's `code of conduct`. 322 | 323 | 324 | ## Feedback And Support 325 | 326 | Please open an issue and follow the template, so the community can help you. 327 | -------------------------------------------------------------------------------- /examples/test1/babel.cfg: -------------------------------------------------------------------------------- 1 | [python: **.py] 2 | -------------------------------------------------------------------------------- /examples/test1/i18n.py: -------------------------------------------------------------------------------- 1 | from fastapi_babel import Babel 2 | from fastapi_babel import BabelConfigs 3 | from fastapi_babel import _ 4 | 5 | 6 | configs = BabelConfigs( 7 | ROOT_DIR=__file__, 8 | BABEL_DEFAULT_LOCALE="en", 9 | BABEL_TRANSLATION_DIRECTORY="lang", 10 | ) 11 | 12 | babel: Babel = Babel(configs=configs) 13 | 14 | if __name__ == "__main__": 15 | babel.run_cli() 16 | -------------------------------------------------------------------------------- /examples/test1/main.py: -------------------------------------------------------------------------------- 1 | from i18n import _ 2 | from i18n import babel 3 | 4 | if __name__ == "__main__": 5 | babel.locale = "en" 6 | en_text = _("File not found. There is nothing here") 7 | print(en_text) 8 | babel.locale = "fa" 9 | fa_text = _("File not found. There is nothing here") 10 | print(fa_text) 11 | babel.locale = "fr" 12 | fr_text = _("File not found. There is nothing here") 13 | print(fr_text) 14 | babel.locale = "es" 15 | es_text = _("File not found. There is nothing here") 16 | print(es_text) 17 | -------------------------------------------------------------------------------- /examples/with_context/babel.cfg: -------------------------------------------------------------------------------- 1 | [python: **.py] 2 | -------------------------------------------------------------------------------- /examples/with_context/i18n.py: -------------------------------------------------------------------------------- 1 | from fastapi_babel import Babel 2 | from fastapi_babel import BabelConfigs 3 | from fastapi_babel import _ 4 | 5 | 6 | babel_configs = BabelConfigs( 7 | ROOT_DIR=__file__, 8 | BABEL_DEFAULT_LOCALE="en", 9 | BABEL_TRANSLATION_DIRECTORY="lang", 10 | ) 11 | 12 | 13 | if __name__ == "__main__": 14 | babel: Babel = Babel(configs=babel_configs) 15 | babel.run_cli() 16 | -------------------------------------------------------------------------------- /examples/with_context/lang/en/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | # English translations for PROJECT. 2 | # Copyright (C) 2024 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2024. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 10 | "POT-Creation-Date: 2024-12-05 22:51+0330\n" 11 | "PO-Revision-Date: 2024-12-05 21:55+0330\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language: en\n" 14 | "Language-Team: en \n" 15 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.16.0\n" 20 | 21 | #: main.py:24 main.py:32 22 | msgid "Hello world" 23 | msgstr "" 24 | 25 | -------------------------------------------------------------------------------- /examples/with_context/lang/fa/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | # Persian translations for PROJECT. 2 | # Copyright (C) 2024 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2024. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 10 | "POT-Creation-Date: 2024-12-05 22:51+0330\n" 11 | "PO-Revision-Date: 2024-12-05 21:54+0330\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language: fa\n" 14 | "Language-Team: fa \n" 15 | "Plural-Forms: nplurals=1; plural=0;\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.16.0\n" 20 | 21 | #: main.py:24 main.py:32 22 | msgid "Hello world" 23 | msgstr "سلام دنیا" 24 | 25 | -------------------------------------------------------------------------------- /examples/with_context/main.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from time import sleep 3 | from typing import Annotated 4 | 5 | from pydantic import BaseModel 6 | from i18n import _ 7 | from i18n import babel_configs 8 | from fastapi import Depends, FastAPI 9 | from fastapi_babel import BabelMiddleware, Babel 10 | from fastapi_babel.local_context import BabelContext 11 | from fastapi_babel import use_babel 12 | 13 | app = FastAPI() 14 | app.add_middleware(BabelMiddleware, babel_configs=babel_configs) 15 | 16 | 17 | class ResponseModel(BaseModel): 18 | idx: int 19 | text: str 20 | 21 | 22 | def translate_after(idx, babel: Babel): 23 | with BabelContext(babel_configs, babel=babel): 24 | print(_("Hello world"), babel.locale, idx) 25 | 26 | 27 | @app.get("/", response_model=ResponseModel) 28 | async def index(idx: int, babel: Annotated[Babel, Depends(use_babel)]): 29 | t = threading.Thread(target=translate_after, args=[idx, babel]) 30 | t.start() 31 | return ResponseModel(idx=idx, text=_("Hello world")) 32 | -------------------------------------------------------------------------------- /examples/with_jinja/babel.cfg: -------------------------------------------------------------------------------- 1 | [python: **.py] 2 | [python: **.html] 3 | extensions=jinja2.ext.autoescape,jinja2.ext.with_ 4 | -------------------------------------------------------------------------------- /examples/with_jinja/full.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request 2 | from fastapi.responses import HTMLResponse 3 | from fastapi.staticfiles import StaticFiles 4 | from fastapi.templating import Jinja2Templates 5 | 6 | 7 | from fastapi_babel import _ 8 | from fastapi_babel import Babel, BabelConfigs 9 | from fastapi_babel import BabelMiddleware 10 | 11 | app = FastAPI() 12 | babel_configs = BabelConfigs( 13 | ROOT_DIR=__file__, 14 | BABEL_DEFAULT_LOCALE="en", 15 | BABEL_TRANSLATION_DIRECTORY="lang", 16 | ) 17 | templates = Jinja2Templates(directory="templates") 18 | app.add_middleware( 19 | BabelMiddleware, babel_configs=babel_configs, jinja2_templates=templates 20 | ) 21 | app.mount("/static", StaticFiles(directory="static"), name="static") 22 | 23 | 24 | @app.get("/") 25 | async def index(): 26 | return {"text": _("Hello World")} 27 | 28 | 29 | @app.get("/items/{id}", response_class=HTMLResponse) 30 | async def read_item(request: Request, id: str): 31 | return templates.TemplateResponse("item.html", {"request": request, "id": id}) 32 | 33 | 34 | if __name__ == "__main__": 35 | Babel(configs=babel_configs).run_cli() 36 | -------------------------------------------------------------------------------- /examples/with_jinja/lang/fa/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | # Persian translations for PROJECT. 2 | # Copyright (C) 2024 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2024. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 10 | "POT-Creation-Date: 2024-12-06 00:29+0330\n" 11 | "PO-Revision-Date: 2024-12-06 00:29+0330\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language: fa\n" 14 | "Language-Team: fa \n" 15 | "Plural-Forms: nplurals=1; plural=0;\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.16.0\n" 20 | 21 | #: full.py:28 templates/item.html:10 22 | msgid "Hello World" 23 | msgstr "سلام دنیا" 24 | 25 | -------------------------------------------------------------------------------- /examples/with_jinja/templates/item.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |

{{_("Hello World")}}

11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/wtforms/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Anbarryprojects/fastapi-babel/5b8091c53f835f3cbaf2db16d5c6230fb83c0837/examples/wtforms/__init__.py -------------------------------------------------------------------------------- /examples/wtforms/app.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | from fastapi import FastAPI 3 | from fastapi.templating import Jinja2Templates 4 | from i18n import babel 5 | from fastapi_babel.middleware import BabelMiddleware 6 | 7 | 8 | class Application: 9 | app: FastAPI 10 | templates: Jinja2Templates 11 | 12 | 13 | def create_app() -> FastAPI: 14 | root: Type[Application] = Application 15 | root.app = FastAPI() 16 | root.templates = Jinja2Templates(directory="templates") 17 | templates = Jinja2Templates(directory="templates") 18 | root.app.add_middleware( 19 | BabelMiddleware, babel_configs=babel.config, jinja2_templates=templates 20 | ) 21 | from routes import router 22 | 23 | root.app.include_router(router=router) 24 | return root.app 25 | -------------------------------------------------------------------------------- /examples/wtforms/babel.cfg: -------------------------------------------------------------------------------- 1 | [python: **.py] 2 | extensions=jinja2.ext.autoescape,jinja2.ext.with_ 3 | -------------------------------------------------------------------------------- /examples/wtforms/forms.py: -------------------------------------------------------------------------------- 1 | from i18n import _ 2 | from fastapi_babel import lazy_gettext as _ 3 | from wtforms import Form, StringField, validators as v 4 | from wtforms import SubmitField 5 | 6 | 7 | class RegistrationForm(Form): 8 | username = StringField( 9 | _("Name"), 10 | [v.InputRequired(_("Please provide your name"))], 11 | ) 12 | submit = SubmitField(_("Submit")) 13 | -------------------------------------------------------------------------------- /examples/wtforms/i18n.py: -------------------------------------------------------------------------------- 1 | from fastapi_babel import Babel 2 | from fastapi_babel import BabelCli 3 | from fastapi_babel import BabelConfigs 4 | from fastapi_babel import _ 5 | 6 | babel_config = BabelConfigs( 7 | ROOT_DIR=__file__, 8 | BABEL_DEFAULT_LOCALE="en", 9 | BABEL_TRANSLATION_DIRECTORY="lang", 10 | ) 11 | babel = Babel(configs=babel_config) 12 | 13 | if __name__ == "__main__": 14 | babel_cli = BabelCli(babel) 15 | babel_cli.run() 16 | -------------------------------------------------------------------------------- /examples/wtforms/lang/fa/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | # Persian translations for PROJECT. 2 | # Copyright (C) 2024 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2024. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 10 | "POT-Creation-Date: 2024-12-05 23:19+0330\n" 11 | "PO-Revision-Date: 2024-12-05 23:19+0330\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language: fa\n" 14 | "Language-Team: fa \n" 15 | "Plural-Forms: nplurals=1; plural=0;\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.16.0\n" 20 | 21 | #: forms.py:9 22 | msgid "Name" 23 | msgstr "نام" 24 | 25 | #: forms.py:10 26 | msgid "Please provide your name" 27 | msgstr "" 28 | 29 | #: forms.py:12 30 | msgid "Submit" 31 | msgstr "ارسال" 32 | 33 | -------------------------------------------------------------------------------- /examples/wtforms/main.py: -------------------------------------------------------------------------------- 1 | from app import create_app 2 | 3 | app = create_app() 4 | -------------------------------------------------------------------------------- /examples/wtforms/routes.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from fastapi import APIRouter, Depends 3 | from fastapi import Request 4 | from app import Application as root 5 | from fastapi_babel.core import Babel 6 | from fastapi_babel.helpers import use_babel 7 | from fastapi_babel.local_context import BabelContext 8 | from forms import RegistrationForm 9 | from i18n import babel_config 10 | 11 | router: APIRouter = APIRouter(prefix="") 12 | render = root.templates.TemplateResponse 13 | 14 | 15 | @router.get("/") 16 | async def read_item(request: Request, babel: Annotated[Babel, Depends(use_babel)]): 17 | babel.locale = "fa" 18 | # with BabelContext(babel_config, babel=babel): 19 | form = RegistrationForm() 20 | return render("index.html", {"request": request, "form": form}) 21 | 22 | 23 | @router.post("/") 24 | async def send_item(request: Request): 25 | form = RegistrationForm(await request.form()) 26 | if form.validate(): 27 | return render("index.html", {"request": request, "form": form}) 28 | return form.errors 29 | -------------------------------------------------------------------------------- /examples/wtforms/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Document 9 | 10 | 11 | 12 |
13 |
14 | {{ form.username.label }}: {{ form.username() }} 15 | {{ form.submit() }} 16 |
17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /fastapi_babel/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import Babel 2 | from .helpers import _, LazyText as lazy_gettext, use_babel 3 | from .cli import BabelCli 4 | from .local_context import BabelContext 5 | from .middleware import BabelMiddleware 6 | from .properties import RootConfigs as BabelConfigs 7 | 8 | __version__ = "1.0.0" 9 | __author__ = "papuridalego@gmail.com" 10 | __all__ = [ 11 | "Babel", 12 | "BabelCli", 13 | "BabelConfigs", 14 | "_", 15 | "lazy_gettext", 16 | "BabelContext", 17 | "BabelMiddleware", 18 | ] 19 | -------------------------------------------------------------------------------- /fastapi_babel/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from subprocess import run 4 | from typing import Optional 5 | from .core import Babel 6 | 7 | 8 | class BabelCli: 9 | __module_name__ = "pybabel" 10 | 11 | def __init__(self, babel: Babel) -> None: 12 | """Babel cli manager to facilitate using pybabel commands by specified congigs 13 | fron `BabelConfigs`. 14 | 15 | Args: 16 | babel (Babel): `Babel` instance 17 | """ 18 | self.babel = babel 19 | 20 | def extract(self, watch_dir: str) -> None: 21 | """extract all messages that annotated using gettext/_ 22 | in the specified directory. 23 | 24 | for first time will create messages.pot file into the root 25 | directory. 26 | 27 | Args: 28 | watch_dir (str): directory to extract messages. 29 | """ 30 | run( 31 | [ 32 | BabelCli.__module_name__, 33 | "extract", 34 | "-F", 35 | self.babel.config.BABEL_CONFIG_FILE, 36 | "-o", 37 | self.babel.config.BABEL_MESSAGE_POT_FILE, 38 | watch_dir, 39 | ] 40 | ) 41 | 42 | def init(self, lang: Optional[str] = None) -> None: 43 | """Initialized lacale directory for first time. 44 | if there is already exists the directory, notice that your 45 | all comiled and initialized messages will remove, in this 46 | condition has better to use `Babel.update` method. 47 | 48 | Args: 49 | lang (str): locale directory name and path 50 | """ 51 | run( 52 | [ 53 | BabelCli.__module_name__, 54 | "init", 55 | "-i", 56 | self.babel.config.BABEL_MESSAGE_POT_FILE, 57 | "-d", 58 | self.babel.config.BABEL_TRANSLATION_DIRECTORY, 59 | "-l", 60 | lang or self.babel.config.BABEL_DEFAULT_LOCALE, 61 | ] 62 | ) 63 | 64 | def update(self, watch_dir: Optional[str] = None) -> None: 65 | """update the extracted messages after init command/initialized directory 66 | , Default is `./lang`" 67 | 68 | Args: 69 | watch_dir (str): locale directory name and path 70 | """ 71 | run( 72 | [ 73 | BabelCli.__module_name__, 74 | "update", 75 | "-i", 76 | self.babel.config.BABEL_MESSAGE_POT_FILE, 77 | "-d", 78 | watch_dir or self.babel.config.BABEL_TRANSLATION_DIRECTORY, 79 | ] 80 | ) 81 | 82 | def compile(self): 83 | """ 84 | compile all messages from translation directory in .PO to .MO file and is 85 | a binnary text file. 86 | """ 87 | run( 88 | [ 89 | BabelCli.__module_name__, 90 | "compile", 91 | "-d", 92 | self.babel.config.BABEL_TRANSLATION_DIRECTORY, 93 | ] 94 | ) 95 | 96 | def run(self): 97 | try: 98 | from click import echo, group, option 99 | except ImportError: 100 | raise ImportError("click has not installed.") 101 | 102 | @group( 103 | "cmd", 104 | help=""" 105 | First Step to extracting messages:\n 106 | 107 | 1- extract -d/--dir {watch_dir}\n 108 | 2- init -l/--lang {lang}\n 109 | 3- add your custome translation to your lang `.po` file for example FA dir {./lang/fa}. \n 110 | 4- compile.\n 111 | 112 | Example: \n 113 | 1- extract -d .\n 114 | 2- init -l fa\n 115 | 3- go to ./lang/Fa/.po and add your translations.\n 116 | 4- compile\n 117 | 118 | If you have already extracted messages and you have an existing `.po` and `.mo` file 119 | follow this steps:\n 120 | 1- extract -d/--dir {watch_dir} \n 121 | 2- update -d/--dir {lang_dir} defaults is ./lang \n 122 | 3- add your custome to your lang `.po` file for example FA dir {./lang/fa}. \n 123 | 4- compile. 124 | 125 | Example: \n 126 | 1- extract -d .\n 127 | 2- update -d lang\n 128 | 3- go to ./lang/Fa/.po and add your translations.\n 129 | 4- compile\n 130 | """, # noqa 131 | ) 132 | def cmd(): 133 | pass 134 | 135 | @cmd.command( 136 | "extract", 137 | help="""extract all messages that annotated using gettext/_ 138 | in the specified directory. 139 | 140 | for first time will create messages.pot file into the root 141 | directory.""", 142 | ) 143 | @option("-d", "--dir", "dir", help="watch dir") 144 | def extract(dir: str): 145 | """Extract messages 146 | 147 | Args: 148 | dir (Optional[str], optional): directory to extract messages. Defaults to None. 149 | """ 150 | 151 | try: 152 | self.extract(dir) 153 | except Exception as err: 154 | echo(err) 155 | 156 | @cmd.command( 157 | "init", 158 | help="""Initialized lacale directory for first time. 159 | if there is already exists the directory, notice that your 160 | all comiled and initialized messages will remove, in this 161 | condition has better to use `update` command""", 162 | ) 163 | @option( 164 | "-l", 165 | "--lang", 166 | "lang", 167 | help="locale directory name and path, default is fa", 168 | default="fa", 169 | ) 170 | def init(lang: Optional[str] = None): 171 | try: 172 | self.init(lang) 173 | except Exception as err: 174 | echo(err) 175 | 176 | @cmd.command( 177 | "compile", 178 | help="""compile all messages from translation directory in .PO to .MO file and is 179 | a binnary text file.""", 180 | ) 181 | def compile(): 182 | try: 183 | self.compile() 184 | except Exception as err: 185 | echo(err) 186 | 187 | @cmd.command( 188 | "update", 189 | help="""update the extracted messages after init command/initialized directory 190 | , Default is `./lang`""", 191 | ) 192 | @option("-d", "--dir", "dir", help="locale directory name and path") 193 | def update(dir: Optional[str] = None): 194 | try: 195 | self.update(dir) 196 | except Exception as err: 197 | echo(err) 198 | 199 | self.__all__ = [update, compile, init, extract] 200 | cmd() 201 | -------------------------------------------------------------------------------- /fastapi_babel/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from gettext import gettext, translation 4 | from typing import Any, Callable, NoReturn, Optional 5 | 6 | from fastapi.templating import Jinja2Templates 7 | 8 | 9 | from .properties import RootConfigs 10 | from .exceptions import BabelProxyError 11 | from contextvars import ContextVar, Token 12 | 13 | 14 | class Babel: 15 | 16 | def __init__(self, configs: RootConfigs) -> None: 17 | """ 18 | `Babel` is manager for babel localization 19 | and i18n tools like gettext, translation, ... 20 | 21 | Args: 22 | configs (RootConfigs): Babel configs for using. 23 | """ 24 | self.config: RootConfigs = configs 25 | self.__locale: str = self.config.BABEL_DEFAULT_LOCALE 26 | self.__default_locale: str = self.config.BABEL_DEFAULT_LOCALE 27 | self.__domain: str = self.config.BABEL_DOMAIN.split(".")[0] 28 | 29 | @staticmethod 30 | def raise_context_error() -> NoReturn: 31 | raise BabelProxyError( 32 | "Babel instance is not available in the current request context." 33 | ) 34 | 35 | @property 36 | def domain(self) -> str: 37 | return self.__domain 38 | 39 | @property 40 | def default_locale(self) -> str: 41 | return self.__default_locale 42 | 43 | @property 44 | def locale(self) -> str: 45 | return self.__locale 46 | 47 | @locale.setter 48 | def locale(self, value: str) -> None: 49 | self.__locale = value 50 | 51 | @property 52 | def gettext(self) -> Callable[[str], str]: 53 | if self.default_locale != self.locale: 54 | gt = translation( 55 | self.domain, 56 | self.config.BABEL_TRANSLATION_DIRECTORY, 57 | [self.locale], 58 | ) 59 | gt.install() 60 | return gt.gettext 61 | return gettext 62 | 63 | def install_jinja(self, templates: Jinja2Templates) -> None: 64 | """ 65 | `Babel.install_jinja` install gettext to jinja2 environment 66 | to access `_` in whole 67 | the jinja templates and let it to pybabel for 68 | extracting included messages throughout the templates. 69 | 70 | Args: 71 | templates (Jinja2Templates): Starlette Jinja2Templates object. 72 | """ 73 | from .helpers import _ 74 | 75 | try: 76 | from jinja2 import Environment # type: ignore # noqa 77 | except ImportError: 78 | raise ImportError( 79 | """ 80 | Jinja2 has not installed. 81 | """ 82 | ) from ImportError 83 | 84 | self.env: Environment = getattr(templates, "env", Environment()) 85 | globals: dict[str, Any] = getattr(self.env, "globals", dict()) 86 | globals.update({"_": _}) 87 | 88 | def run_cli(self): 89 | """installs cli's for using pybabel commands easily by specified 90 | configs from `BabelConfigs`. 91 | """ 92 | 93 | from .cli import BabelCli # type: ignore # noqa 94 | 95 | babel_cli = BabelCli(self) 96 | babel_cli.run() 97 | -------------------------------------------------------------------------------- /fastapi_babel/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class BabelProxyError(Exception): 5 | """When babel proxy object points to a None object will raise this error""" 6 | 7 | def __init__(self, message: Optional[str] = None) -> None: 8 | self.message: str = "Proxy object points to an empty lookup instance" 9 | if message: 10 | self.message = message 11 | super().__init__("Proxy object points to an empty lookup instance") 12 | -------------------------------------------------------------------------------- /fastapi_babel/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any 3 | 4 | 5 | from fastapi import Request 6 | 7 | from .core import Babel 8 | from .local_context import context_var 9 | 10 | 11 | def _(message: str) -> str: 12 | gettext = context_var.get() 13 | if not gettext: 14 | Babel.raise_context_error() 15 | return gettext(message) 16 | 17 | 18 | class LaxyTextMeta(type): 19 | def __call__(cls, *args: Any, **kwds: Any) -> str: 20 | return super().__call__(*args, **kwds) 21 | 22 | 23 | class LazyText(metaclass=LaxyTextMeta): 24 | def __init__(self, message: str): 25 | self.message = message 26 | 27 | def __repr__(self) -> str: 28 | return _(self.message) 29 | 30 | 31 | def use_babel(request: Request): 32 | """translate the message and retrieve message from .PO and .MO depends on 33 | `Babel.locale` locale. 34 | 35 | Args: 36 | message (str): message content 37 | 38 | Returns: 39 | str: transalted message. 40 | """ 41 | # Get Babel instance from request or fallback to the CLI instance (when defined) 42 | babel = request.state.babel 43 | return babel 44 | -------------------------------------------------------------------------------- /fastapi_babel/local_context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from contextvars import ContextVar 4 | import logging 5 | from typing import Any, Callable, Type 6 | import typing 7 | 8 | 9 | from .properties import RootConfigs 10 | from .core import Babel 11 | from .properties import RootConfigs 12 | 13 | 14 | class BabelContext: 15 | def __init__( 16 | self, 17 | babel_config: RootConfigs, 18 | babel: typing.Optional[Babel] = None, 19 | logger: typing.Optional[logging.Logger] = None, 20 | do_log: bool = False, 21 | ) -> None: 22 | """Babel context to insert object into `ContextVar`. 23 | 24 | Args: 25 | babel_config (RootConfigs): Base config object 26 | logger (typing.Optional[Logger], optional): Logger object to log inside of context manager scope. 27 | Defaults to None. 28 | """ 29 | self.babel_config = babel_config 30 | self.logger = logger or logging.getLogger() 31 | self.__babel = babel or Babel(self.babel_config) 32 | self.do_log = do_log 33 | 34 | def _log(self, error: Exception, *msg: typing.Tuple[Any]): 35 | """default log method 36 | 37 | Args: 38 | error (Exception): raised error inside context manager scope. 39 | """ 40 | self.logger.error(error, *msg, exc_info=True) 41 | 42 | def __enter__(self): 43 | context_var.set(self.__babel.gettext) 44 | 45 | def __exit__(self, *args, **kwargs): ... 46 | 47 | 48 | context_var: ContextVar[Callable[[str], str]] = ContextVar("gettext") 49 | -------------------------------------------------------------------------------- /fastapi_babel/middleware.py: -------------------------------------------------------------------------------- 1 | import re 2 | from fastapi import Request, Response 3 | from fastapi.templating import Jinja2Templates 4 | from starlette.middleware.base import BaseHTTPMiddleware 5 | from starlette.middleware.base import RequestResponseEndpoint 6 | from starlette.middleware.base import DispatchFunction 7 | from starlette.types import ASGIApp 8 | from typing import Optional, Callable 9 | from .core import Babel 10 | from .local_context import context_var 11 | from .properties import RootConfigs 12 | from pathlib import Path 13 | 14 | 15 | LANGUAGES_PATTERN = re.compile(r"([a-z]{2})-?([A-Z]{2})?(;q=\d.\d{1,3})?") 16 | 17 | 18 | class BabelMiddleware(BaseHTTPMiddleware): 19 | def __init__( 20 | self, 21 | app: ASGIApp, 22 | babel_configs: RootConfigs, 23 | locale_selector: Optional[Callable[[Request], Optional[str]]] = None, 24 | jinja2_templates: Optional[Jinja2Templates] = None, 25 | dispatch: Optional[DispatchFunction] = None, 26 | ) -> None: 27 | super().__init__(app, dispatch) 28 | self.babel_configs = babel_configs 29 | self.jinja2_templates = jinja2_templates 30 | self.locale_selector = locale_selector or self._default_locale_selector 31 | 32 | def _default_locale_selector(self, request: Request): 33 | return request.headers.get("Accept-Language", None) 34 | 35 | def get_language(self, babel: Babel, lang_code: Optional[str] = None): 36 | """Applies an available language. 37 | 38 | To apply an available language it will be searched in the language folder for an available one 39 | and will also priotize the one with the highest quality value. The Fallback language will be the 40 | taken from the BABEL_DEFAULT_LOCALE var. 41 | 42 | Args: 43 | babel (Babel): Request scoped Babel instance 44 | lang_code (str): The Value of the Accept-Language Header. 45 | 46 | Returns: 47 | str: The language that should be used. 48 | """ 49 | if not lang_code: 50 | return babel.config.BABEL_DEFAULT_LOCALE 51 | 52 | matches = re.finditer(LANGUAGES_PATTERN, lang_code) 53 | languages = [ 54 | (f"{m.group(1)}{f'_{m.group(2)}' if m.group(2) else ''}", m.group(3) or "") 55 | for m in matches 56 | ] 57 | languages = sorted( 58 | languages, key=lambda x: x[1], reverse=True 59 | ) # sort the priority, no priority comes last 60 | translation_directory = Path(babel.config.BABEL_TRANSLATION_DIRECTORY) 61 | translation_files = [i.name for i in translation_directory.iterdir()] 62 | explicit_priority = None 63 | 64 | for lang, quality in languages: 65 | if lang in translation_files: 66 | if ( 67 | not quality 68 | ): # languages without quality value having the highest priority 1 69 | return lang 70 | 71 | elif ( 72 | not explicit_priority 73 | ): # set language with explicit priority <= priority 1 74 | explicit_priority = lang 75 | 76 | # Return language with explicit priority or default value 77 | return ( 78 | explicit_priority 79 | if explicit_priority 80 | else self.babel_configs.BABEL_DEFAULT_LOCALE 81 | ) 82 | 83 | async def dispatch( 84 | self, request: Request, call_next: RequestResponseEndpoint 85 | ) -> Response: 86 | """dispatch function 87 | 88 | Args: 89 | request (Request): ... 90 | call_next (RequestResponseEndpoint): ... 91 | 92 | Returns: 93 | Response: ... 94 | """ 95 | lang_code: Optional[str] = self.locale_selector(request) 96 | 97 | # Create a new Babel instance per request 98 | babel = Babel(configs=self.babel_configs) 99 | babel.locale = self.get_language(babel, lang_code) 100 | context_var.set(babel.gettext) 101 | if self.jinja2_templates: 102 | babel.install_jinja(self.jinja2_templates) 103 | 104 | request.state.babel = babel 105 | 106 | response: Response = await call_next(request) 107 | return response 108 | -------------------------------------------------------------------------------- /fastapi_babel/properties.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import typing 4 | from dataclasses import dataclass, field 5 | 6 | 7 | @dataclass 8 | class RootConfigs: 9 | 10 | ROOT_DIR: typing.Union[str, pathlib.Path] 11 | BABEL_DEFAULT_LOCALE: str 12 | BABEL_TRANSLATION_DIRECTORY: str 13 | BABEL_DOMAIN: str = "messages.pot" 14 | BABEL_CONFIG_FILE: str = "babel.cfg" 15 | BABEL_MESSAGE_POT_FILE: str = field(init=False) 16 | 17 | def __post_init__(self): 18 | setattr( 19 | self, 20 | "ROOT_DIR", 21 | pathlib.Path(self.ROOT_DIR).parent, 22 | ) 23 | setattr( 24 | self, 25 | "BABEL_TRANSLATION_DIRECTORY", 26 | os.path.join(self.ROOT_DIR, self.BABEL_TRANSLATION_DIRECTORY), 27 | ) 28 | setattr( 29 | self, 30 | "BABEL_CONFIG_FILE", 31 | os.path.join(self.ROOT_DIR, self.BABEL_CONFIG_FILE), 32 | ) 33 | setattr( 34 | self, 35 | "BABEL_MESSAGE_POT_FILE", 36 | os.path.join(self.ROOT_DIR, self.BABEL_DOMAIN), 37 | ) 38 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | pre-commit==2.19.0 4 | black==22.3.0 5 | flake8==4.0.1 6 | isort==5.10.1 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | babel 3 | uvicorn -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = fastapi-babel 3 | version = attr: fastapi_babel.__version__ 4 | url = https://github.com/Anbarryprojects/fastapi-babel 5 | project_urls = 6 | Documentation = https://github.com/Anbarryprojects/fastapi-babel 7 | Source Code = https://github.com/Anbarryprojects/fastapi-babel 8 | Issue Tracker = https://github.com/Anbarryprojects/fastapi-babel/issues/ 9 | license = MIT 10 | author = Parsa Pourmohammad 11 | author_email = parsapourmohammad1999@gmail.com 12 | maintainer = Anbarryprojects 13 | description = FastAPI-babel is an extenstion for I18n and I10n support in FastAPI, based on babel and pytz. 14 | long_description = file: README.md 15 | long_description_content_type = text/markdown 16 | classifiers = 17 | Development Status :: 3 - Alpha 18 | Environment :: Web Environment 19 | Framework :: FastAPI 20 | Intended Audience :: Developers 21 | Topic :: Internet :: WWW/HTTP :: Dynamic Content 22 | Topic :: Software Development :: Libraries :: Application Frameworks 23 | Programming Language :: Python :: 3 24 | License :: OSI Approved :: MIT License 25 | Operating System :: OS Independent 26 | 27 | [options] 28 | packages = find: 29 | # package_dir = = fastapi_babel 30 | include_package_data = True 31 | python_requires = >= 3.7 32 | # Dependencies are in setup.py for GitHub's dependency graph. 33 | 34 | [options.packages.find] 35 | where = fastapi_babel 36 | 37 | 38 | [flake8] 39 | max-line-length = 88 40 | per-file-ignores = 41 | # __init__ exports names 42 | fastapi_babel/__init__.py: F401 43 | 44 | [mypy] 45 | files = fastapi_babel 46 | python_version = 3.7 47 | show_error_codes = True 48 | allow_redefinition = True 49 | disallow_subclassing_any = True 50 | # disallow_untyped_calls = True 51 | # disallow_untyped_defs = True 52 | # disallow_incomplete_defs = True 53 | no_implicit_optional = True 54 | local_partial_types = True 55 | # no_implicit_reexport = True 56 | strict_equality = True 57 | warn_redundant_casts = True 58 | warn_unused_configs = True 59 | warn_unused_ignores = True 60 | # warn_return_any = True 61 | # warn_unreachable = True 62 | 63 | [mypy-click.*] 64 | ignore_missing_imports = True 65 | 66 | [mypy-jinja.*] 67 | ignore_missing_imports = True 68 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | # Metadata goes in setup.cfg. These are here for GitHub's dependency graph. 4 | setup( 5 | name="fastapi-babel", 6 | packages=find_packages(), 7 | include_package_data=True, 8 | zip_safe=False, 9 | install_requires=[ 10 | "fastapi", 11 | "uvicorn", 12 | "babel", 13 | ], 14 | ) 15 | -------------------------------------------------------------------------------- /tests/test_concurrent.py: -------------------------------------------------------------------------------- 1 | import random 2 | from threading import Thread 3 | from urllib import request 4 | 5 | from requests import get 6 | 7 | 8 | def test_api(idx: int): 9 | lang = random.choice(["fa", "en"]) 10 | resp = get( 11 | "http://127.0.0.1:8000", params={"idx": idx}, headers={"Accept-Language": lang} 12 | ) 13 | resp.raise_for_status() 14 | print( 15 | f"Lang: {lang} ID: {resp.json()['idx']} Text: {resp.json()['text']} FOR: {idx}" 16 | ) 17 | 18 | 19 | for thread in [Thread(target=test_api, args=[i]) for i in range(100)]: 20 | thread.start() 21 | --------------------------------------------------------------------------------