├── .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 |
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 |
--------------------------------------------------------------------------------