├── .flake8 ├── .github └── workflows │ └── poetry-publish.yml ├── .gitignore ├── .gitlab-ci.yml ├── LICENSE ├── README.md ├── example ├── BaseUsage.md ├── KeyValueTranslatorHubUsage.md ├── MRE.py ├── TranslatorHub.md ├── TranslatorRunner.md └── TypingGenerator.md ├── fluentogram ├── __init__.py ├── cli │ ├── __init__.py │ └── cli.py ├── exceptions │ ├── __init__.py │ └── root_locale_translator.py ├── misc │ ├── __init__.py │ └── timezones.py ├── src │ ├── __init__.py │ ├── abc │ │ ├── __init__.py │ │ ├── misc.py │ │ ├── runner.py │ │ ├── storage.py │ │ ├── transformer.py │ │ ├── translator.py │ │ └── translator_hub.py │ └── impl │ │ ├── __init__.py │ │ ├── attrib_tracer.py │ │ ├── filter.py │ │ ├── runner.py │ │ ├── storages │ │ ├── __init__.py │ │ └── nats_storage.py │ │ ├── stubs_translator_runner.py │ │ ├── transator_hubs │ │ ├── __init__.py │ │ ├── kv_translator_hub.py │ │ └── translator_hub.py │ │ ├── transformers │ │ ├── __init__.py │ │ ├── datetime_transformer.py │ │ └── money_transformer.py │ │ └── translator.py ├── tests │ ├── __init__.py │ ├── test_stub_generation.py │ └── test_usage.py └── typing_generator │ ├── __init__.py │ ├── parsed_ftl.py │ ├── renderable_items.py │ ├── stubs.py │ ├── translation_dto.py │ └── tree.py └── pyproject.toml /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 -------------------------------------------------------------------------------- /.github/workflows/poetry-publish.yml: -------------------------------------------------------------------------------- 1 | # Publishes PyPI release on v{version} tags 2 | name: Release 3 | 4 | on: 5 | push: 6 | tags: 7 | - 'v*' 8 | 9 | jobs: 10 | test-release: 11 | name: Release Package to test PyPI 12 | runs-on: ubuntu-latest 13 | environment: main 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | 19 | - name: Setup Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: "3.9" 23 | 24 | - name: Install dependencies 25 | run: | 26 | pip install poetry 27 | poetry install --only main 28 | 29 | - name: Build and publish to test PyPI 30 | run: | 31 | poetry build 32 | 33 | poetry config repositories.test-pypi https://test.pypi.org/legacy/ 34 | poetry config pypi-token.test-pypi ${{ secrets.POETRY_PYPI_TOKEN }} 35 | poetry publish -r test-pypi 36 | 37 | release: 38 | name: Release Package to PyPI 39 | runs-on: ubuntu-latest 40 | environment: main 41 | needs: test-release 42 | 43 | steps: 44 | - name: Checkout code 45 | uses: actions/checkout@v2 46 | 47 | - name: Setup Python 48 | uses: actions/setup-python@v2 49 | with: 50 | python-version: "3.9" 51 | 52 | - name: Install dependencies 53 | run: | 54 | pip install poetry 55 | poetry install --only main 56 | 57 | - name: Build and publish to PyPI 58 | run: | 59 | poetry build 60 | 61 | poetry config pypi-token.pypi ${{ secrets.PYPI_PROD_TOKEN }} 62 | poetry publish 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *$py.class 4 | *.so 5 | .Python 6 | build/ 7 | develop-eggs/ 8 | dist/ 9 | downloads/ 10 | eggs/ 11 | .eggs/ 12 | lib/ 13 | lib64/ 14 | parts/ 15 | sdist/ 16 | var/ 17 | wheels/ 18 | share/python-wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | MANIFEST 23 | *.manifest 24 | *.spec 25 | pip-log.txt 26 | pip-delete-this-directory.txt 27 | htmlcov/ 28 | .tox/ 29 | .nox/ 30 | .coverage 31 | .coverage.* 32 | .cache 33 | nosetests.xml 34 | coverage.xml 35 | *.cover 36 | *.py,cover 37 | .hypothesis/ 38 | .pytest_cache/ 39 | cover/ 40 | *.mo 41 | *.pot 42 | *.log 43 | local_settings.py 44 | db.sqlite3 45 | db.sqlite3-journal 46 | instance/ 47 | .webassets-cache 48 | .scrapy 49 | docs/_build/ 50 | .pybuilder/ 51 | target/ 52 | .ipynb_checkpoints 53 | profile_default/ 54 | ipython_config.py 55 | __pypackages__/ 56 | celerybeat-schedule 57 | celerybeat.pid 58 | *.sage.py 59 | .env 60 | .venv 61 | env/ 62 | venv/ 63 | ENV/ 64 | env.bak/ 65 | venv.bak/ 66 | .spyderproject 67 | .spyproject 68 | .ropeproject 69 | /site 70 | .mypy_cache/ 71 | .dmypy.json 72 | dmypy.json 73 | .pyre/ 74 | .pytype/ 75 | cython_debug/ 76 | .idea 77 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | # This file is a template, and might need editing before it works on your project. 2 | # To contribute improvements to CI/CD templates, please follow the Development guide at: 3 | # https://docs.gitlab.com/ee/development/cicd/templates.html 4 | # This specific template is located at: 5 | # https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Getting-Started.gitlab-ci.yml 6 | 7 | # This is a sample GitLab CI/CD configuration file that should run without any modifications. 8 | # It demonstrates a basic 3 stage CI/CD pipeline. Instead of real tests or scripts, 9 | # it uses echo commands to simulate the pipeline execution. 10 | # 11 | # A pipeline is composed of independent jobs that run scripts, grouped into stages. 12 | # Stages run in sequential order, but jobs within stages run in parallel. 13 | # 14 | # For more information, see: https://docs.gitlab.com/ee/ci/yaml/index.html#stages 15 | 16 | stages: # List of stages for jobs, and their order of execution 17 | - build 18 | - test 19 | - deploy 20 | 21 | 22 | unit-test-job: # This job runs in the test stage. 23 | stage: test # It only starts when the job in the build stage completes successfully. 24 | script: 25 | - echo "Running unit tests" 26 | - poetry env use python3.10 27 | - poetry install 28 | - poetry run python3 -m unittest $CI_PROJECT_NAME.tests.test_usage 29 | 30 | deploy-job: # This job runs in the deploy stage. 31 | stage: deploy # It only runs when *both* jobs in the test stage complete successfully. 32 | script: 33 | - python3 -m build 34 | - twine upload --repository test.pypi.org --username __token__ --password $PYPI_TEST_TOKEN dist/* 35 | - sleep 60 36 | - pip3 install --index-url https://test.pypi.org/simple/ fluentogram 37 | - echo "Application successfully deployed." 38 | - twine upload --username __token__ --password $PYPI_PROD_TOKEN dist/* 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 MIT. All rights reserved. 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 NON INFRINGEMENT. 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fluentogram 2 | 3 | A proper way to use an i18n mechanism with Aiogram3. Using Project Fluent by Mozilla 4 | https://projectfluent.org/fluent/guide/ 5 | 6 | Short example: 7 | 8 | ```py 9 | # Somewhere in middleware.Grab language_code from telegram user object, or database, etc. 10 | translator_runner: TranslatorRunner = t_hub.get_translator_by_locale("en") 11 | 12 | # In message handler: 13 | async def message_handler(message: Message, ..., i18n: TranslatorRunner): 14 | ... 15 | await message.answer(i18n.welcome()) 16 | await message.answer(i18n.greet.by.name(user="Alex")) #aka message.from_user.username 17 | await message.answer(i18n.shop.success.payment( 18 | amount=MoneyTransformer(currency="$", amount=Decimal("500")), 19 | dt=DateTimeTransformer(datetime.now())) 20 | 21 | # Going to be like: 22 | """ 23 | Welcome to the fluent aiogram addon! 24 | Hello, Alex! 25 | Your money, $500.00, has been sent successfully at Dec 4, 2022. 26 | """ 27 | ``` 28 | 29 | Check [*Examples*](example) folder. 30 | -------------------------------------------------------------------------------- /example/BaseUsage.md: -------------------------------------------------------------------------------- 1 | ```py 2 | example_ftl_file_content = """ 3 | welcome = Welcome to the fluent aiogram addon! 4 | greet-by-name = Hello, { $user }! 5 | shop-success-payment = Your money, { $amount }, has been sent successfully. 6 | """ 7 | 8 | # main.py of bot: 9 | example_ftl_file_content = """ 10 | welcome = Welcome to the fluent aiogram addon! 11 | greet-by-name = Hello, { $user }! 12 | shop-success-payment = Your money, { $amount }, has been sent successfully at { $dt }. 13 | """ 14 | 15 | t_hub = TranslatorHub( 16 | {"ua": ("ua", "ru", "en"), 17 | "ru": ("ru", "en"), 18 | "en": ("en",)}, 19 | translators=[ 20 | FluentTranslator(locale="en", 21 | translator=FluentBundle.from_string("en-US", example_ftl_file_content, 22 | use_isolating=False))], 23 | FluentTranslator(locale="ru", 24 | translator=...)] 25 | root_locale="en", 26 | ) 27 | 28 | # Somewhere in middleware.Grab language_code from telegram user object, or database, etc. 29 | translator_runner: TranslatorRunner = t_hub.get_translator_by_locale("en") 30 | 31 | # In message handler: 32 | async def message_handler(message: Message, ..., i18n: TranslatorRunner): 33 | ... 34 | await message.answer(i18n.welcome()) 35 | await message.answer(i18n.greet.by.name(user="Alex")) #aka message.from_user.username 36 | await message.answer(i18n.shop.success.payment( 37 | amount=MoneyTransformer(currency="$", amount=Decimal("500")), 38 | dt=DateTimeTransformer(datetime.now())) 39 | 40 | # Going to be like: 41 | """ 42 | Welcome to the fluent aiogram addon! 43 | Hello, Alex! 44 | Your money, $500.00, has been sent successfully at Dec 4, 2022. 45 | """ -------------------------------------------------------------------------------- /example/KeyValueTranslatorHubUsage.md: -------------------------------------------------------------------------------- 1 | ```py 2 | import asyncio 3 | 4 | import nats 5 | from fluent_compiler.bundle import FluentBundle 6 | from nats.js.api import KeyValueConfig, StorageType 7 | 8 | from fluentogram import FluentTranslator 9 | from fluentogram import NatsStorage 10 | from fluentogram.src.impl.transator_hubs import KvTranslatorHub 11 | 12 | async def main(): 13 | # initialising the NATS connection and JetStream 14 | nc = await nats.connect(servers = ["nats://localhost:4222"]) 15 | js = nc.jetstream() 16 | 17 | # key/value store creation 18 | kv_config = KeyValueConfig( 19 | bucket='my_bucket', 20 | storage=StorageType.FILE, 21 | ) 22 | kv = await js.create_key_value(config=kv_config) 23 | storage = NatsStorage(kv=kv, js=js) 24 | 25 | translator_hub = KvTranslatorHub( 26 | { 27 | 'ru': ('ru', 'en'), 28 | 'en': ('en',), 29 | 30 | }, 31 | translators=[ 32 | FluentTranslator(locale='en', 33 | translator=FluentBundle.from_string( 34 | locale='en-US', 35 | text='hello = Hello {$name}!') 36 | ), 37 | FluentTranslator(locale='ru', 38 | translator=FluentBundle.from_string( 39 | locale='ru', 40 | text='hello = Привет {$name}!') 41 | ) 42 | ] 43 | ) 44 | 45 | # initialising storage in the translator hub 46 | await translator_hub.from_storage(kv_storage=storage) 47 | 48 | i18n_ru = translator_hub.get_translator_by_locale(locale='ru') 49 | i18n_en = translator_hub.get_translator_by_locale(locale='en') 50 | 51 | # retrieving texts from passed translators 52 | print(i18n_ru.hello(name='Александр')) # Привет Александр! 53 | print(i18n_en.hello(name='Alex')) # Hello Alex! 54 | 55 | # creating and updating keys/values 56 | await translator_hub.put(locale='ru', key='put_key', value='вставленное или обновленное значение №{$number}') # key insertion or replacement 57 | await translator_hub.create(locale='en', key='create_key', value='create value №{$number}') # only if this key does not exist 58 | await translator_hub.put(locale='en', mapping_values={ 59 | "mapping_key1": "mapping value №1", 60 | "mapping_key2": "mapping value №2", 61 | }) 62 | 63 | # it may take a few seconds for a listener running in the background to catch and process incoming updates 64 | await asyncio.sleep(5) 65 | 66 | print(i18n_ru.put_key(number=1)) # вставленное или обновленное значение №1 67 | print(i18n_en.create_key(number=1)) # create value №1 68 | print(i18n_en.mapping_key1()) # mapping value №1 69 | print(i18n_en.mapping_key2()) # mapping value №2 70 | 71 | # deletion keys/values 72 | await translator_hub.delete('en', "mapping_key1", "create_key") 73 | 74 | if __name__ == '__main__': 75 | asyncio.run(main()) 76 | ``` -------------------------------------------------------------------------------- /example/MRE.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from typing import TYPE_CHECKING, Callable, Dict, Any, Awaitable 4 | 5 | from aiogram import Bot, Dispatcher, Router, BaseMiddleware 6 | from aiogram.types import Message 7 | from fluent_compiler.bundle import FluentBundle 8 | 9 | from fluentogram import FluentTranslator, TranslatorHub, TranslatorRunner 10 | 11 | if TYPE_CHECKING: 12 | from stub import TranslatorRunner # you haven't this file until you use TypingGenerator 13 | 14 | 15 | class TranslatorRunnerMiddleware(BaseMiddleware): 16 | async def __call__( 17 | self, 18 | handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]], 19 | event: Message, 20 | data: Dict[str, Any] 21 | ) -> Any: 22 | hub: TranslatorHub = data.get('_translator_hub') 23 | # There you can ask your database for locale 24 | data['i18n'] = hub.get_translator_by_locale(event.from_user.language_code) 25 | return await handler(event, data) 26 | 27 | 28 | main_router = Router() 29 | main_router.message.middleware(TranslatorRunnerMiddleware()) 30 | 31 | 32 | @main_router.message() 33 | async def handler(message: Message, i18n: TranslatorRunner): 34 | await message.answer(i18n.start.hello(username=message.from_user.username)) 35 | 36 | 37 | async def main(): 38 | translator_hub = TranslatorHub( 39 | { 40 | "ru": ("ru", "en"), 41 | "en": ("en",) 42 | }, 43 | [ 44 | FluentTranslator("en", translator=FluentBundle.from_string("en-US", "start-hello = Hello, { $username }")), 45 | FluentTranslator("ru", translator=FluentBundle.from_string("ru", "start-hello = Привет, { $username }")) 46 | ], 47 | ) 48 | bot = Bot(token=os.getenv("TOKEN")) 49 | dp = Dispatcher() 50 | dp.include_router(main_router) 51 | print("bot is ready") 52 | await dp.start_polling(bot, _translator_hub=translator_hub) 53 | 54 | 55 | if __name__ == '__main__': 56 | asyncio.run(main()) 57 | -------------------------------------------------------------------------------- /example/TranslatorHub.md: -------------------------------------------------------------------------------- 1 | # TranslatorHub 2 | 3 | TranslatorHub is unit of distribution TranslatorRunner's. 4 | 5 | Init: 6 | 7 | ```python 8 | def __init__( 9 | self, 10 | locales_map: Dict[str, Union[str, Iterable[str]]], 11 | translators: List[TAbstractTranslator], 12 | root_locale: str = "en", 13 | ) -> None: 14 | ``` 15 | 16 | *Locales map* - that's like a configuration map for "Rollback" feature. If you haven't configured translation for 17 | current locale - first in collection, 18 | "Rollback" will look to others locales' data and try to find a translation 19 | 20 | For example: 21 | 22 | ```python 23 | locales_map = { 24 | "ua": ("ua", "de", "en"), 25 | "de": ("de", "en"), 26 | "en": ("en",) 27 | } 28 | ``` 29 | 30 | Let's look at this example 31 | 32 | If translator does not find a translation for "ua" locale in "ua" data, next stop is "de" data. If it's failed too - it 33 | will look to "en" translations. 34 | 35 | *Translators* - List of translator instances. Every translator has only one locale. Refer to "Translator" doc page. 36 | First parameter - telegram locale. Pay attention to format of them. Hub's 37 | method `def get_translator_by_locale(self, locale: str)` will use this parameter as key to find translator. 38 | 39 | Example: 40 | 41 | ```python 42 | FluentTranslator("en", translator=FluentBundle.from_files("en-US", filenames=[".../main.ftl"])), 43 | ``` 44 | 45 | "Wait, what about no-files configuration?" may you ask. This is OK too, because you should just choose another option 46 | from FluentBundle: 47 | 48 | ```python 49 | FluentTranslator("de", translator=FluentBundle.from_string("your*ftl*content")) 50 | ``` 51 | 52 | Get your strings from anywhere - Databases, Files, no matter the source. 53 | 54 | *Root locale* - if fluentogram will meet unknown locale - this locale will be used for getting translation. 55 | 56 | Pretty simple -------------------------------------------------------------------------------- /example/TranslatorRunner.md: -------------------------------------------------------------------------------- 1 | # TranslatorRunner 2 | 3 | TranslatorRunner is a key component of Fluentogram 4 | 5 | This single-per-message unit executes translation request 6 | 7 | Like that: 8 | 9 | ```python 10 | @router.message() 11 | async def handler(message: Message, i18n: TranslatorRunner): 12 | await message.answer( 13 | i18n.say.hello(username=message.from_user.username) 14 | ) 15 | ``` 16 | 17 | FTL content: 18 | 19 | ```text 20 | say-hello = "Hello { $username}!" 21 | ``` 22 | 23 | So, as can you see, i18n is instance of TranslatorRunner, created in middleware before the message handler. 24 | 25 | Note: *Any variables for TranslatorRunner should be passed like key-word arguments. This is means using "=" symbol 26 | between attribute name and content* 27 | 28 | Remember to be careful with count of subkeys (in example - "say" and "hello"). Very big count can slow things down. If 29 | it needed - you can use `i18n.get("say-hello", username=...)` 30 | instead of classic sugar-typed dot access method. 31 | -------------------------------------------------------------------------------- /example/TypingGenerator.md: -------------------------------------------------------------------------------- 1 | After installation, use: 2 | 3 | `i18n -ftl example.ftl -stub stub.pyi` 4 | 5 | By default, `stub.py` will contain `TranslatorRunner` class with type hints for translation keys. 6 | 7 | Usage in files: 8 | 9 | ```py 10 | from typing import TYPE_CHECKING 11 | 12 | from aiogram import Router 13 | from aiogram.types import Message 14 | from fluentogram import TranslatorRunner 15 | 16 | if TYPE_CHECKING: 17 | from stub import TranslatorRunner 18 | 19 | router = Router() 20 | 21 | 22 | @router.message() 23 | async def handler(message: Message, i18n: TranslatorRunner): 24 | await message.answer( 25 | i18n.hello(username=message.from_user.username) 26 | ) 27 | ``` 28 | 29 | stub.pyi - result file after stub generator -------------------------------------------------------------------------------- /fluentogram/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from . import misc 3 | from .src.impl import ( 4 | AttribTracer, 5 | FluentTranslator, 6 | TranslatorRunner, 7 | TranslatorHub, 8 | KvTranslatorHub, 9 | MoneyTransformer, 10 | DateTimeTransformer, 11 | NatsStorage, 12 | ) 13 | 14 | __all__ = [ 15 | "AttribTracer", 16 | "DateTimeTransformer", 17 | "FluentTranslator", 18 | "MoneyTransformer", 19 | "TranslatorHub", 20 | "TranslatorRunner", 21 | "KvTranslatorHub", 22 | "misc", 23 | "NatsStorage", 24 | ] 25 | -------------------------------------------------------------------------------- /fluentogram/cli/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """A couple of CLI-access functions""" 3 | from .cli import cli 4 | 5 | __all__ = ["cli"] 6 | -------------------------------------------------------------------------------- /fluentogram/cli/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import time 3 | from pathlib import Path 4 | 5 | from watchdog.events import FileModifiedEvent 6 | from watchdog.events import FileSystemEventHandler 7 | from watchdog.observers import Observer 8 | 9 | from fluentogram.typing_generator import ParsedRawFTL, Stubs, Tree 10 | 11 | 12 | class FtlFileEventHandler(FileSystemEventHandler): 13 | def __init__(self, track_path: str, stub_path: str): 14 | self.track_path = track_path 15 | self.stub_path = stub_path 16 | 17 | def on_modified(self, event: FileModifiedEvent): 18 | print('event type: %s, path: %s' % (event.event_type, event.src_path)) 19 | if not event.is_directory: 20 | messages = parse_ftl_dir(self.track_path) 21 | tree = Tree(messages) 22 | stubs = Stubs(tree) 23 | stubs.to_file(self.stub_path) 24 | 25 | 26 | def parse_ftl(ftl_path: str | Path) -> dict: 27 | with open(ftl_path, "r", encoding="utf-8") as input_f: 28 | raw = ParsedRawFTL(input_f.read()) 29 | messages = raw.get_messages() 30 | return messages 31 | 32 | 33 | def parse_ftl_dir(dir_path: str) -> dict: 34 | messages = {} 35 | for file in Path(dir_path).glob("*.ftl"): 36 | messages.update(parse_ftl(file)) 37 | return messages 38 | 39 | 40 | def watch_ftl_dir(track_path: str, stub_path: str) -> None: 41 | observer = Observer() 42 | observer.schedule(FtlFileEventHandler(track_path, stub_path), track_path, recursive=True) 43 | observer.start() 44 | try: 45 | while True: 46 | time.sleep(1) 47 | finally: 48 | observer.stop() 49 | observer.join() 50 | 51 | 52 | def cli() -> None: 53 | parser = argparse.ArgumentParser() 54 | parser.add_argument("-ftl", dest="ftl_path", required=False) 55 | parser.add_argument("-track-ftl", dest="track_path", required=False) 56 | parser.add_argument("-dir-ftl", dest="dir_path", required=False) 57 | parser.add_argument("-stub", dest="stub_path", required=False) 58 | 59 | args = parser.parse_args() 60 | 61 | if not args.ftl_path and not args.track_path and not args.dir_path: 62 | print("Use 'i18n --help' to see help message") 63 | return 64 | 65 | if args.track_path: 66 | print("Watching for changes in %s" % args.track_path) 67 | watch_ftl_dir(args.track_path, args.stub_path) 68 | return 69 | 70 | elif args.dir_path: 71 | messages = parse_ftl_dir(args.dir_path) 72 | else: 73 | messages = parse_ftl(args.ftl_path) 74 | 75 | tree = Tree(messages) 76 | stubs = Stubs(tree) 77 | if args.stub_path: 78 | stubs.to_file(args.stub_path) 79 | else: 80 | print(stubs.echo()) 81 | -------------------------------------------------------------------------------- /fluentogram/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from .root_locale_translator import NotImplementedRootLocaleTranslator 3 | 4 | 5 | __all__ = ["NotImplementedRootLocaleTranslator", ] 6 | -------------------------------------------------------------------------------- /fluentogram/exceptions/root_locale_translator.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """Custom exception, raises when main, core translator does not exist""" 3 | 4 | 5 | class NotImplementedRootLocaleTranslator(Exception): 6 | """ 7 | This exception is raised when TranslatorHub has no translator for root locale and being impossible to work. 8 | """ 9 | 10 | def __init__(self, root_locale) -> None: 11 | super().__init__( 12 | f"""\n 13 | You do not have a root locale translator. 14 | Root locale is "{root_locale}" 15 | Please, fix it! 16 | Just provide the data! 17 | """ 18 | ) 19 | -------------------------------------------------------------------------------- /fluentogram/misc/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from .timezones import timezones 3 | 4 | __all__ = ["timezones"] 5 | -------------------------------------------------------------------------------- /fluentogram/misc/timezones.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """A timezones typing""" 3 | from typing import Literal 4 | 5 | timezones = Literal[ 6 | "Africa/Abidjan", 7 | "Africa/Accra", 8 | "Africa/Addis_Ababa", 9 | "Africa/Algiers", 10 | "Africa/Asmara", 11 | "Africa/Bamako", 12 | "Africa/Bangui", 13 | "Africa/Banjul", 14 | "Africa/Bissau", 15 | "Africa/Blantyre", 16 | "Africa/Brazzaville", 17 | "Africa/Bujumbura", 18 | "Africa/Cairo", 19 | "Africa/Casablanca", 20 | "Africa/Ceuta", 21 | "Africa/Conakry", 22 | "Africa/Dakar", 23 | "Africa/Dar_es_Salaam", 24 | "Africa/Djibouti", 25 | "Africa/Douala", 26 | "Africa/El_Aaiun", 27 | "Africa/Freetown", 28 | "Africa/Gaborone", 29 | "Africa/Harare", 30 | "Africa/Johannesburg", 31 | "Africa/Juba", 32 | "Africa/Kampala", 33 | "Africa/Khartoum", 34 | "Africa/Kigali", 35 | "Africa/Kinshasa", 36 | "Africa/Lagos", 37 | "Africa/Libreville", 38 | "Africa/Lome", 39 | "Africa/Luanda", 40 | "Africa/Lubumbashi", 41 | "Africa/Lusaka", 42 | "Africa/Malabo", 43 | "Africa/Maputo", 44 | "Africa/Maseru", 45 | "Africa/Mbabane", 46 | "Africa/Mogadishu", 47 | "Africa/Monrovia", 48 | "Africa/Nairobi", 49 | "Africa/Ndjamena", 50 | "Africa/Niamey", 51 | "Africa/Nouakchott", 52 | "Africa/Ouagadougou", 53 | "Africa/Porto-Novo", 54 | "Africa/Sao_Tome", 55 | "Africa/Tripoli", 56 | "Africa/Tunis", 57 | "Africa/Windhoek", 58 | "America/Adak", 59 | "America/Anchorage", 60 | "America/Anguilla", 61 | "America/Antigua", 62 | "America/Araguaina", 63 | "America/Argentina/Buenos_Aires", 64 | "America/Argentina/Catamarca", 65 | "America/Argentina/Cordoba", 66 | "America/Argentina/Jujuy", 67 | "America/Argentina/La_Rioja", 68 | "America/Argentina/Mendoza", 69 | "America/Argentina/Rio_Gallegos", 70 | "America/Argentina/Salta", 71 | "America/Argentina/San_Juan", 72 | "America/Argentina/San_Luis", 73 | "America/Argentina/Tucuman", 74 | "America/Argentina/Ushuaia", 75 | "America/Aruba", 76 | "America/Asuncion", 77 | "America/Atikokan", 78 | "America/Bahia", 79 | "America/Bahia_Banderas", 80 | "America/Barbados", 81 | "America/Belem", 82 | "America/Belize", 83 | "America/Blanc-Sablon", 84 | "America/Boa_Vista", 85 | "America/Bogota", 86 | "America/Boise", 87 | "America/Cambridge_Bay", 88 | "America/Campo_Grande", 89 | "America/Cancun", 90 | "America/Caracas", 91 | "America/Cayenne", 92 | "America/Cayman", 93 | "America/Chicago", 94 | "America/Chihuahua", 95 | "America/Costa_Rica", 96 | "America/Creston", 97 | "America/Cuiaba", 98 | "America/Curacao", 99 | "America/Danmarkshavn", 100 | "America/Dawson", 101 | "America/Dawson_Creek", 102 | "America/Denver", 103 | "America/Detroit", 104 | "America/Dominica", 105 | "America/Edmonton", 106 | "America/Eirunepe", 107 | "America/El_Salvador", 108 | "America/Fort_Nelson", 109 | "America/Fortaleza", 110 | "America/Glace_Bay", 111 | "America/Goose_Bay", 112 | "America/Grand_Turk", 113 | "America/Grenada", 114 | "America/Guadeloupe", 115 | "America/Guatemala", 116 | "America/Guayaquil", 117 | "America/Guyana", 118 | "America/Halifax", 119 | "America/Havana", 120 | "America/Hermosillo", 121 | "America/Indiana/Indianapolis", 122 | "America/Indiana/Knox", 123 | "America/Indiana/Marengo", 124 | "America/Indiana/Petersburg", 125 | "America/Indiana/Tell_City", 126 | "America/Indiana/Vevay", 127 | "America/Indiana/Vincennes", 128 | "America/Indiana/Winamac", 129 | "America/Inuvik", 130 | "America/Iqaluit", 131 | "America/Jamaica", 132 | "America/Juneau", 133 | "America/Kentucky/Louisville", 134 | "America/Kentucky/Monticello", 135 | "America/Kralendijk", 136 | "America/La_Paz", 137 | "America/Lima", 138 | "America/Los_Angeles", 139 | "America/Lower_Princes", 140 | "America/Maceio", 141 | "America/Managua", 142 | "America/Manaus", 143 | "America/Marigot", 144 | "America/Martinique", 145 | "America/Matamoros", 146 | "America/Mazatlan", 147 | "America/Menominee", 148 | "America/Merida", 149 | "America/Metlakatla", 150 | "America/Mexico_City", 151 | "America/Miquelon", 152 | "America/Moncton", 153 | "America/Monterrey", 154 | "America/Montevideo", 155 | "America/Montserrat", 156 | "America/Nassau", 157 | "America/New_York", 158 | "America/Nipigon", 159 | "America/Nome", 160 | "America/Noronha", 161 | "America/North_Dakota/Beulah", 162 | "America/North_Dakota/Center", 163 | "America/North_Dakota/New_Salem", 164 | "America/Nuuk", 165 | "America/Ojinaga", 166 | "America/Panama", 167 | "America/Pangnirtung", 168 | "America/Paramaribo", 169 | "America/Phoenix", 170 | "America/Port-au-Prince", 171 | "America/Port_of_Spain", 172 | "America/Porto_Velho", 173 | "America/Puerto_Rico", 174 | "America/Punta_Arenas", 175 | "America/Rainy_River", 176 | "America/Rankin_Inlet", 177 | "America/Recife", 178 | "America/Regina", 179 | "America/Resolute", 180 | "America/Rio_Branco", 181 | "America/Santarem", 182 | "America/Santiago", 183 | "America/Santo_Domingo", 184 | "America/Sao_Paulo", 185 | "America/Scoresbysund", 186 | "America/Sitka", 187 | "America/St_Barthelemy", 188 | "America/St_Johns", 189 | "America/St_Kitts", 190 | "America/St_Lucia", 191 | "America/St_Thomas", 192 | "America/St_Vincent", 193 | "America/Swift_Current", 194 | "America/Tegucigalpa", 195 | "America/Thule", 196 | "America/Thunder_Bay", 197 | "America/Tijuana", 198 | "America/Toronto", 199 | "America/Tortola", 200 | "America/Vancouver", 201 | "America/Whitehorse", 202 | "America/Winnipeg", 203 | "America/Yakutat", 204 | "America/Yellowknife", 205 | "Antarctica/Casey", 206 | "Antarctica/Davis", 207 | "Antarctica/DumontDUrville", 208 | "Antarctica/Macquarie", 209 | "Antarctica/Mawson", 210 | "Antarctica/McMurdo", 211 | "Antarctica/Palmer", 212 | "Antarctica/Rothera", 213 | "Antarctica/Syowa", 214 | "Antarctica/Troll", 215 | "Antarctica/Vostok", 216 | "Arctic/Longyearbyen", 217 | "Asia/Aden", 218 | "Asia/Almaty", 219 | "Asia/Amman", 220 | "Asia/Anadyr", 221 | "Asia/Aqtau", 222 | "Asia/Aqtobe", 223 | "Asia/Ashgabat", 224 | "Asia/Atyrau", 225 | "Asia/Baghdad", 226 | "Asia/Bahrain", 227 | "Asia/Baku", 228 | "Asia/Bangkok", 229 | "Asia/Barnaul", 230 | "Asia/Beirut", 231 | "Asia/Bishkek", 232 | "Asia/Brunei", 233 | "Asia/Chita", 234 | "Asia/Choibalsan", 235 | "Asia/Colombo", 236 | "Asia/Damascus", 237 | "Asia/Dhaka", 238 | "Asia/Dili", 239 | "Asia/Dubai", 240 | "Asia/Dushanbe", 241 | "Asia/Famagusta", 242 | "Asia/Gaza", 243 | "Asia/Hebron", 244 | "Asia/Ho_Chi_Minh", 245 | "Asia/Hong_Kong", 246 | "Asia/Hovd", 247 | "Asia/Irkutsk", 248 | "Asia/Jakarta", 249 | "Asia/Jayapura", 250 | "Asia/Jerusalem", 251 | "Asia/Kabul", 252 | "Asia/Kamchatka", 253 | "Asia/Karachi", 254 | "Asia/Kathmandu", 255 | "Asia/Khandyga", 256 | "Asia/Kolkata", 257 | "Asia/Krasnoyarsk", 258 | "Asia/Kuala_Lumpur", 259 | "Asia/Kuching", 260 | "Asia/Kuwait", 261 | "Asia/Macau", 262 | "Asia/Magadan", 263 | "Asia/Makassar", 264 | "Asia/Manila", 265 | "Asia/Muscat", 266 | "Asia/Nicosia", 267 | "Asia/Novokuznetsk", 268 | "Asia/Novosibirsk", 269 | "Asia/Omsk", 270 | "Asia/Oral", 271 | "Asia/Phnom_Penh", 272 | "Asia/Pontianak", 273 | "Asia/Pyongyang", 274 | "Asia/Qatar", 275 | "Asia/Qostanay", 276 | "Asia/Qyzylorda", 277 | "Asia/Riyadh", 278 | "Asia/Sakhalin", 279 | "Asia/Samarkand", 280 | "Asia/Seoul", 281 | "Asia/Shanghai", 282 | "Asia/Singapore", 283 | "Asia/Srednekolymsk", 284 | "Asia/Taipei", 285 | "Asia/Tashkent", 286 | "Asia/Tbilisi", 287 | "Asia/Tehran", 288 | "Asia/Thimphu", 289 | "Asia/Tokyo", 290 | "Asia/Tomsk", 291 | "Asia/Ulaanbaatar", 292 | "Asia/Urumqi", 293 | "Asia/Ust-Nera", 294 | "Asia/Vientiane", 295 | "Asia/Vladivostok", 296 | "Asia/Yakutsk", 297 | "Asia/Yangon", 298 | "Asia/Yekaterinburg", 299 | "Asia/Yerevan", 300 | "Atlantic/Azores", 301 | "Atlantic/Bermuda", 302 | "Atlantic/Canary", 303 | "Atlantic/Cape_Verde", 304 | "Atlantic/Faroe", 305 | "Atlantic/Madeira", 306 | "Atlantic/Reykjavik", 307 | "Atlantic/South_Georgia", 308 | "Atlantic/St_Helena", 309 | "Atlantic/Stanley", 310 | "Australia/Adelaide", 311 | "Australia/Brisbane", 312 | "Australia/Broken_Hill", 313 | "Australia/Darwin", 314 | "Australia/Eucla", 315 | "Australia/Hobart", 316 | "Australia/Lindeman", 317 | "Australia/Lord_Howe", 318 | "Australia/Melbourne", 319 | "Australia/Perth", 320 | "Australia/Sydney", 321 | "Canada/Atlantic", 322 | "Canada/Central", 323 | "Canada/Eastern", 324 | "Canada/Mountain", 325 | "Canada/Newfoundland", 326 | "Canada/Pacific", 327 | "Europe/Amsterdam", 328 | "Europe/Andorra", 329 | "Europe/Astrakhan", 330 | "Europe/Athens", 331 | "Europe/Belgrade", 332 | "Europe/Berlin", 333 | "Europe/Bratislava", 334 | "Europe/Brussels", 335 | "Europe/Bucharest", 336 | "Europe/Budapest", 337 | "Europe/Busingen", 338 | "Europe/Chisinau", 339 | "Europe/Copenhagen", 340 | "Europe/Dublin", 341 | "Europe/Gibraltar", 342 | "Europe/Guernsey", 343 | "Europe/Helsinki", 344 | "Europe/Isle_of_Man", 345 | "Europe/Istanbul", 346 | "Europe/Jersey", 347 | "Europe/Kaliningrad", 348 | "Europe/Kiev", 349 | "Europe/Kirov", 350 | "Europe/Lisbon", 351 | "Europe/Ljubljana", 352 | "Europe/London", 353 | "Europe/Luxembourg", 354 | "Europe/Madrid", 355 | "Europe/Malta", 356 | "Europe/Mariehamn", 357 | "Europe/Minsk", 358 | "Europe/Monaco", 359 | "Europe/Moscow", 360 | "Europe/Oslo", 361 | "Europe/Paris", 362 | "Europe/Podgorica", 363 | "Europe/Prague", 364 | "Europe/Riga", 365 | "Europe/Rome", 366 | "Europe/Samara", 367 | "Europe/San_Marino", 368 | "Europe/Sarajevo", 369 | "Europe/Saratov", 370 | "Europe/Simferopol", 371 | "Europe/Skopje", 372 | "Europe/Sofia", 373 | "Europe/Stockholm", 374 | "Europe/Tallinn", 375 | "Europe/Tirane", 376 | "Europe/Ulyanovsk", 377 | "Europe/Uzhgorod", 378 | "Europe/Vaduz", 379 | "Europe/Vatican", 380 | "Europe/Vienna", 381 | "Europe/Vilnius", 382 | "Europe/Volgograd", 383 | "Europe/Warsaw", 384 | "Europe/Zagreb", 385 | "Europe/Zaporozhye", 386 | "Europe/Zurich", 387 | "GMT", 388 | "Indian/Antananarivo", 389 | "Indian/Chagos", 390 | "Indian/Christmas", 391 | "Indian/Cocos", 392 | "Indian/Comoro", 393 | "Indian/Kerguelen", 394 | "Indian/Mahe", 395 | "Indian/Maldives", 396 | "Indian/Mauritius", 397 | "Indian/Mayotte", 398 | "Indian/Reunion", 399 | "Pacific/Apia", 400 | "Pacific/Auckland", 401 | "Pacific/Bougainville", 402 | "Pacific/Chatham", 403 | "Pacific/Chuuk", 404 | "Pacific/Easter", 405 | "Pacific/Efate", 406 | "Pacific/Fakaofo", 407 | "Pacific/Fiji", 408 | "Pacific/Funafuti", 409 | "Pacific/Galapagos", 410 | "Pacific/Gambier", 411 | "Pacific/Guadalcanal", 412 | "Pacific/Guam", 413 | "Pacific/Honolulu", 414 | "Pacific/Kanton", 415 | "Pacific/Kiritimati", 416 | "Pacific/Kosrae", 417 | "Pacific/Kwajalein", 418 | "Pacific/Majuro", 419 | "Pacific/Marquesas", 420 | "Pacific/Midway", 421 | "Pacific/Nauru", 422 | "Pacific/Niue", 423 | "Pacific/Norfolk", 424 | "Pacific/Noumea", 425 | "Pacific/Pago_Pago", 426 | "Pacific/Palau", 427 | "Pacific/Pitcairn", 428 | "Pacific/Pohnpei", 429 | "Pacific/Port_Moresby", 430 | "Pacific/Rarotonga", 431 | "Pacific/Saipan", 432 | "Pacific/Tahiti", 433 | "Pacific/Tarawa", 434 | "Pacific/Tongatapu", 435 | "Pacific/Wake", 436 | "Pacific/Wallis", 437 | "US/Alaska", 438 | "US/Arizona", 439 | "US/Central", 440 | "US/Eastern", 441 | "US/Hawaii", 442 | "US/Mountain", 443 | "US/Pacific", 444 | "UTC", 445 | ] 446 | -------------------------------------------------------------------------------- /fluentogram/src/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from . import abc 3 | from . import impl 4 | 5 | 6 | __all__ = ["abc", "impl", ] 7 | -------------------------------------------------------------------------------- /fluentogram/src/abc/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from .misc import AbstractAttribTracer 3 | from .transformer import AbstractDataTransformer 4 | from .translator import AbstractTranslator 5 | from .translator_hub import AbstractTranslatorsHub 6 | 7 | 8 | __all__ = [ 9 | "AbstractAttribTracer", 10 | "AbstractDataTransformer", 11 | "AbstractTranslator", 12 | "AbstractTranslatorsHub", 13 | ] 14 | -------------------------------------------------------------------------------- /fluentogram/src/abc/misc.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Some miscellaneous 4 | """ 5 | 6 | from abc import ABC, abstractmethod 7 | 8 | 9 | class AbstractAttribTracer(ABC): 10 | """Implements a mechanism for tracing attributes access way. 11 | 12 | Like a pretty simple, external-typing supported version of the translator.get("some-key-for-translation") 13 | 14 | Equivalent to obj.some.key.for.translation(**some_kwargs) 15 | """ 16 | 17 | @abstractmethod 18 | def __init__(self) -> None: 19 | self.request_line = "" 20 | 21 | @abstractmethod 22 | def _get_request_line(self) -> str: 23 | request_line = self.request_line 24 | self.request_line = "" 25 | return request_line 26 | 27 | @abstractmethod 28 | def __getattr__(self, item) -> 'AbstractAttribTracer': 29 | """ 30 | This method exists to map the "obj.attrib1.attrib2" access to "attrib1-attrib2" key. 31 | """ 32 | self.request_line += f"{item}{self.separator}" 33 | return self 34 | -------------------------------------------------------------------------------- /fluentogram/src/abc/runner.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | An abstract translator runner 4 | """ 5 | from abc import ABC, abstractmethod 6 | 7 | from fluentogram.src.abc import AbstractAttribTracer 8 | 9 | 10 | class AbstractTranslatorRunner(AbstractAttribTracer, ABC): 11 | """This is one-shot per Telegram event translator with attrib tracer access way.""" 12 | 13 | @abstractmethod 14 | def get(self, key: str, **kwargs) -> str: 15 | """Fastest, direct way to use translator, without sugar-like typing supported attribute access way""" 16 | 17 | @abstractmethod 18 | def _get_translation(self, key, **kwargs) -> str: 19 | ... 20 | 21 | @abstractmethod 22 | def __call__(self, **kwargs) -> str: 23 | ... 24 | 25 | @abstractmethod 26 | def __getattr__(self, item: str) -> 'AbstractTranslatorRunner': 27 | ... 28 | -------------------------------------------------------------------------------- /fluentogram/src/abc/storage.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | An abstract base for the Storage object 4 | """ 5 | from abc import ABC, abstractmethod 6 | from typing import Any 7 | 8 | 9 | class AbstractStorage(ABC): 10 | 11 | @abstractmethod 12 | def __init__(self, kv, *args, **kwargs): 13 | raise NotImplementedError 14 | 15 | @abstractmethod 16 | def put(self, locale: str, key: str, value: Any, mapping_values: dict[str, Any]): 17 | """Creates or replaces an existing key/value""" 18 | raise NotImplementedError 19 | 20 | @abstractmethod 21 | def create(self, locale: str, key: str, value: Any, mapping_values: dict[str, Any]): 22 | """Create will add the key/value pair iff it does not exist.""" 23 | raise NotImplementedError 24 | 25 | @abstractmethod 26 | def delete(self, locale: str, *keys): 27 | """Deletes all transmitted keys""" 28 | raise NotImplementedError 29 | 30 | @abstractmethod 31 | def listen(self, messages: dict[str, dict]) -> None: 32 | """Listen for new keys/values and updates them in TranslatorHub""" 33 | raise NotImplementedError -------------------------------------------------------------------------------- /fluentogram/src/abc/transformer.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | An AbstractDataTransformer object, using to transform any data before being passed to translator directly. 4 | """ 5 | from abc import ABC, abstractmethod 6 | from typing import Any 7 | 8 | 9 | class AbstractDataTransformer(ABC): 10 | """ 11 | These transformers inspired by Functions of Project Fluent by Mozilla. 12 | Of course, it's a simple function, like a 13 | 14 | def function(money: Union[int, float], **kwargs) -> str: ... 15 | 16 | which result passes through translator, into engine itself, like a Fluent or anything else. 17 | """ 18 | 19 | @abstractmethod 20 | def __new__(cls, data: Any, **kwargs) -> Any: 21 | """Using incoming data, create an object representation of these data for your translator via all needed 22 | parameters using kwargs""" 23 | raise NotImplementedError 24 | -------------------------------------------------------------------------------- /fluentogram/src/abc/translator.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Translator as itself 4 | """ 5 | from abc import ABC, abstractmethod 6 | from typing import Any 7 | 8 | 9 | class AbstractTranslator(ABC): 10 | """A translator class, implements key-value interface for your translator mechanism.""" 11 | 12 | @abstractmethod 13 | def __init__(self, locale: str, translator: Any, separator: str = "-") -> None: 14 | self.locale = locale 15 | self.separator = separator 16 | self.translator = translator 17 | 18 | @abstractmethod 19 | def get(self, key: str, **kwargs) -> str: 20 | """ 21 | Convert a translation key to a translated text string. 22 | Use kwargs dict to pass external data to the translator. 23 | Expects to be fast and furious. 24 | """ 25 | raise NotImplementedError 26 | -------------------------------------------------------------------------------- /fluentogram/src/abc/translator_hub.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | An abstract base for the Translator Hub and Key/Value Translator Hub objects 4 | """ 5 | import sys 6 | from abc import ABC, abstractmethod 7 | if sys.version_info >= (3, 11): 8 | from typing import Self, Any 9 | else: 10 | from typing import Any 11 | from typing_extensions import Self 12 | 13 | from fluentogram.src.abc.runner import AbstractTranslatorRunner 14 | from fluentogram.src.abc.storage import AbstractStorage 15 | 16 | 17 | class AbstractTranslatorsHub(ABC): 18 | """This class should contain a couple of translator objects, usually one object per one locale.""" 19 | 20 | @abstractmethod 21 | def __init__(self): 22 | raise NotImplementedError 23 | 24 | @abstractmethod 25 | def get_translator_by_locale(self, locale: str) -> AbstractTranslatorRunner: 26 | """ 27 | Returns a Translator object by selected locale 28 | """ 29 | raise NotImplementedError 30 | 31 | class AbstractKvTranslatorHub(AbstractTranslatorsHub, ABC): 32 | @abstractmethod 33 | def from_storage(self, kv_storage: AbstractStorage) -> Self: 34 | """ 35 | Initializes the Translator Hub with the provided storage 36 | """ 37 | raise NotImplementedError 38 | 39 | @abstractmethod 40 | def put(self, locale: str, key: str, value: Any, mapping_values: dict[str, Any]): 41 | raise NotImplementedError 42 | 43 | @abstractmethod 44 | def create(self, locale: str, key: str, value: Any, mapping_values: dict[str, Any]): 45 | raise NotImplementedError 46 | 47 | @abstractmethod 48 | def delete(self, locale: str, *keys): 49 | raise NotImplementedError 50 | 51 | -------------------------------------------------------------------------------- /fluentogram/src/impl/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from .attrib_tracer import AttribTracer 3 | from .translator import FluentTranslator 4 | from .runner import TranslatorRunner 5 | from .transformers import MoneyTransformer, DateTimeTransformer 6 | from .transator_hubs.translator_hub import TranslatorHub 7 | from .transator_hubs.kv_translator_hub import KvTranslatorHub 8 | from .storages import NatsStorage 9 | 10 | __all__ = [ 11 | "AttribTracer", 12 | "DateTimeTransformer", 13 | "FluentTranslator", 14 | "MoneyTransformer", 15 | "TranslatorRunner", 16 | "NatsStorage", 17 | "TranslatorHub", 18 | "KvTranslatorHub", 19 | ] 20 | -------------------------------------------------------------------------------- /fluentogram/src/impl/attrib_tracer.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | An AttribTracer implementation 4 | """ 5 | 6 | from fluentogram.src.abc import AbstractAttribTracer 7 | 8 | 9 | class AttribTracer(AbstractAttribTracer): 10 | """Attribute tracer class for obj.attrib1.attrib2 access""" 11 | 12 | def __init__(self) -> None: 13 | self.request_line = "" 14 | 15 | def _get_request_line(self) -> str: 16 | request_line = self.request_line 17 | self.request_line = "" 18 | return request_line 19 | 20 | def __getattr__(self, item) -> 'AttribTracer': 21 | """ 22 | This method exists to map the "obj.attrib1.attrib2" access to "attrib1-attrib2" key. 23 | """ 24 | self.request_line += f"{item}{self.separator}" 25 | return self 26 | -------------------------------------------------------------------------------- /fluentogram/src/impl/filter.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, cast, Any 2 | 3 | from aiogram.filters import BaseFilter 4 | from aiogram.types import Message 5 | from fluentogram import TranslatorRunner 6 | 7 | 8 | DEFAULT_TRANSLATOR_KEY = "i18n" 9 | 10 | 11 | class FText(BaseFilter): 12 | translator_key = DEFAULT_TRANSLATOR_KEY 13 | 14 | def __init__( 15 | self, *, 16 | equals: Optional[str] = None, 17 | contains: Optional[str] = None, 18 | startswith: Optional[str] = None, 19 | endswith: Optional[str] = None, 20 | ignore_case: bool = False, 21 | translator_key: Optional[str] = None 22 | ) -> None: 23 | """ 24 | Fluentogram text filter in the spirit of the deprecated aiogram Text filter. 25 | 26 | Usage example: 27 | @router.message(FText(equals="command-help")) 28 | async def helpCommand(message: Message, i18n: TranslatorRunner) -> TelegramMethod[Any]: 29 | return message.answer(i18n.help()) 30 | """ 31 | self.equals = equals 32 | self.contains = contains 33 | self.startswith = startswith 34 | self.endswith = endswith 35 | self.ignore_case = ignore_case 36 | 37 | if translator_key: 38 | self.translator_key = translator_key 39 | 40 | async def __call__( 41 | self, event: Message, **data: Any 42 | ) -> bool: 43 | text = event.text or event.caption 44 | if text is not None: 45 | i18n: Optional[TranslatorRunner] = data.get(self.translator_key) 46 | if i18n is None: 47 | raise RuntimeError( 48 | f"TranslatorRunner not found for key '{self.translator_key}'." 49 | ) 50 | if self.ignore_case: 51 | text = text.casefold() 52 | 53 | if self.equals: 54 | return cast(bool, text == i18n.get(self.equals)) # lmao idk what he wants but mypy can't see bool here 55 | if self.contains: 56 | return i18n.get(self.contains) in text 57 | if self.startswith: 58 | return text.startswith(i18n.get(self.startswith)) 59 | if self.endswith: 60 | return text.endswith(i18n.get(self.endswith)) 61 | 62 | return False 63 | -------------------------------------------------------------------------------- /fluentogram/src/impl/runner.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | A translator runner by itself 4 | """ 5 | from typing import Iterable 6 | 7 | from fluentogram.src.abc import AbstractTranslator 8 | from fluentogram.src.abc.runner import AbstractTranslatorRunner 9 | from fluentogram.src.impl import AttribTracer 10 | 11 | 12 | class TranslatorRunner(AbstractTranslatorRunner, AttribTracer): 13 | """This is one-shot per Telegram event translator with attrib tracer access way.""" 14 | 15 | def __init__(self, translators: Iterable[AbstractTranslator], separator: str = "-") -> None: 16 | super().__init__() 17 | self.translators = translators 18 | self.separator = separator 19 | self.request_line = "" 20 | 21 | def get(self, key: str, **kwargs) -> str: 22 | """Fastest, direct way to use translator, without sugar-like typing supported attribute access way""" 23 | return self._get_translation(key, **kwargs) 24 | 25 | def _get_translation(self, key, **kwargs): 26 | for translator in self.translators: 27 | try: 28 | return translator.get(key, **kwargs) 29 | except KeyError: 30 | continue 31 | 32 | def __call__(self, **kwargs) -> str: 33 | text = self._get_translation(self.request_line[:-1], **kwargs) 34 | self.request_line = "" 35 | return text 36 | 37 | def __getattr__(self, item: str) -> 'TranslatorRunner': 38 | self.request_line += f"{item}{self.separator}" 39 | return self 40 | -------------------------------------------------------------------------------- /fluentogram/src/impl/storages/__init__.py: -------------------------------------------------------------------------------- 1 | from .nats_storage import NatsStorage -------------------------------------------------------------------------------- /fluentogram/src/impl/storages/nats_storage.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | NATS-based storage 4 | """ 5 | import asyncio 6 | import json 7 | import sys 8 | from collections import defaultdict 9 | if sys.version_info >= (3, 10): 10 | from typing import Any, NoReturn, TypeAlias, Optional 11 | else: 12 | from typing import Any, NoReturn, Optional 13 | from typing_extensions import TypeAlias 14 | 15 | from fluent_compiler.compiler import compile_messages 16 | from fluent_compiler.resource import FtlResource 17 | from nats.aio.msg import Msg 18 | from nats.js import JetStreamContext 19 | from nats.js.kv import KeyValue, KV_OP, KV_DEL, KV_PURGE 20 | 21 | from fluentogram.src.abc.storage import AbstractStorage 22 | 23 | KeyType: TypeAlias = Optional[str] 24 | ValueType: TypeAlias = Optional[Any] 25 | MappingValuesType: TypeAlias = Optional[dict[KeyType, ValueType]] 26 | 27 | 28 | class NatsStorage(AbstractStorage): 29 | def __init__( 30 | self, 31 | kv: KeyValue, 32 | js: JetStreamContext, 33 | separator: str = '.', 34 | serializer=lambda data: json.dumps(data).encode('utf-8'), 35 | deserializer=json.loads, 36 | consume_timeout: float = 1.0 37 | ): 38 | self._kv = kv 39 | self._js = js 40 | self.separator = separator 41 | self.messages = None 42 | self.serializer = serializer 43 | self.deserializer = deserializer 44 | self.consume_timeout = consume_timeout 45 | 46 | async def put( 47 | self, 48 | locale: str, 49 | key: KeyType, 50 | value: ValueType, 51 | mapping_values: MappingValuesType 52 | ): 53 | await self._interaction( 54 | func=self._kv.put, 55 | locale=locale, 56 | key=key, 57 | value=value, 58 | mapping_values=mapping_values 59 | ) 60 | 61 | async def create( 62 | self, 63 | locale: str, 64 | key: KeyType, 65 | value: ValueType, 66 | mapping_values: MappingValuesType 67 | ): 68 | await self._interaction( 69 | func=self._kv.create, 70 | locale=locale, 71 | key=key, 72 | value=value, 73 | mapping_values=mapping_values 74 | ) 75 | 76 | async def delete(self, locale: str, *keys): 77 | await asyncio.gather( 78 | *[ 79 | self._kv.purge(f'{locale}{self.separator}{key}') 80 | for key in keys 81 | ] 82 | ) 83 | 84 | async def _interaction( 85 | self, 86 | func, 87 | locale: str, 88 | key: KeyType, 89 | value: ValueType, 90 | mapping_values: MappingValuesType 91 | ): 92 | if key and value: 93 | await func(f'{locale}{self.separator}{key}', self.serializer(value)) 94 | if mapping_values: 95 | await asyncio.gather( 96 | *[ 97 | func(f'{locale}{self.separator}{m_key}', self.serializer(m_value)) 98 | for m_key, m_value in mapping_values.items() 99 | ] 100 | ) 101 | 102 | async def listen(self, messages: dict[str, dict]) -> NoReturn: 103 | self.messages = messages 104 | stream = await self._js.stream_info(self._kv._stream) 105 | stream_name = stream.config.name 106 | subject_name = stream_name.replace("_", self.separator, 1) 107 | subject = f'${subject_name}.>' 108 | consumer = await self._js.pull_subscribe(subject=subject, stream=stream_name) 109 | while True: 110 | try: 111 | messages: list[Msg] = await consumer.fetch(50, timeout=self.consume_timeout) 112 | except TimeoutError: 113 | pass 114 | else: 115 | await self._update_compiled_messages(messages) 116 | 117 | async def _update_compiled_messages(self, messages: list[Msg]): 118 | changes = defaultdict(list) 119 | for m in messages: 120 | kind = m.headers.get(KV_OP) if m.headers is not None else None 121 | *args, locale, key = m.subject.split(self.separator) 122 | if kind in (KV_DEL, KV_PURGE): 123 | self.messages[locale].pop(key, None) 124 | else: 125 | value = self.deserializer(m.data) 126 | changes[locale].append(f'{key} = {value}') 127 | await m.ack() 128 | self._set_new_compiled_messages(changes) 129 | 130 | def _set_new_compiled_messages(self, new_messages: dict[str, list[str]]) -> None: 131 | for locale, messages in new_messages.items(): 132 | resources = [FtlResource.from_string(message) for message in messages] 133 | compiled_ftl = compile_messages(locale, resources) 134 | self.messages[locale].update(compiled_ftl.message_functions) 135 | -------------------------------------------------------------------------------- /fluentogram/src/impl/stubs_translator_runner.py: -------------------------------------------------------------------------------- 1 | from fluentogram import AttribTracer 2 | 3 | 4 | class StubsTranslatorRunner(AttribTracer): 5 | def __init__(self): 6 | super().__init__() 7 | self.kwargs = {} 8 | 9 | def __call__(self, **kwargs): 10 | out = self._get_request_line()[:-1], kwargs 11 | self.request_line = "" 12 | return out 13 | -------------------------------------------------------------------------------- /fluentogram/src/impl/transator_hubs/__init__.py: -------------------------------------------------------------------------------- 1 | from .kv_translator_hub import KvTranslatorHub 2 | from .translator_hub import TranslatorHub 3 | -------------------------------------------------------------------------------- /fluentogram/src/impl/transator_hubs/kv_translator_hub.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | A Key/Value Translator Hub, using as factory for Translator objects 4 | """ 5 | import asyncio 6 | 7 | from typing import Dict, Iterable, Union, Optional, List, Any 8 | 9 | from fluent_compiler.bundle import FluentBundle 10 | 11 | from fluentogram.exceptions import NotImplementedRootLocaleTranslator 12 | from fluentogram.src.abc import AbstractTranslator 13 | from fluentogram.src.abc.storage import AbstractStorage 14 | from fluentogram.src.abc.translator_hub import AbstractKvTranslatorHub 15 | from fluentogram.src.impl import FluentTranslator 16 | from fluentogram.src.impl.transator_hubs.translator_hub import TranslatorHub 17 | 18 | class KvTranslatorHub(TranslatorHub, AbstractKvTranslatorHub): 19 | def __init__( 20 | self, 21 | locales_map: Dict[str, Union[str, Iterable[str]]], 22 | translators: Optional[List[AbstractTranslator]] = None, 23 | root_locale: str = "en", 24 | separator: str = "-" 25 | ) -> None: 26 | self.kv_storage = None 27 | self.messages = None 28 | if translators is not None: 29 | super().__init__(locales_map, translators, root_locale, separator) 30 | return 31 | 32 | self.locales_map = dict( 33 | zip( 34 | locales_map.keys(), 35 | map( 36 | lambda lang: tuple([lang]) if isinstance(lang, str) else lang, 37 | locales_map.values() 38 | ) 39 | ) 40 | ) 41 | self.translators = translators 42 | self.root_locale = root_locale 43 | self.separator = separator 44 | self.storage = None 45 | self.translators_map = None 46 | 47 | async def from_storage(self, kv_storage: AbstractStorage): 48 | self.kv_storage = kv_storage 49 | if self.translators is None: 50 | self.translators = self._create_translators() 51 | 52 | self.storage: Dict[str, AbstractTranslator] = dict( 53 | zip([translator.locale for translator in self.translators], self.translators) 54 | ) 55 | if not self.storage.get(self.root_locale): 56 | raise NotImplementedRootLocaleTranslator(self.root_locale) 57 | self.translators_map: Dict[str, Iterable[AbstractTranslator]] = self._locales_map_parser(self.locales_map) 58 | 59 | self.messages = self._get_translators_messages() 60 | 61 | asyncio.create_task(self._on_update()) 62 | 63 | return self 64 | 65 | async def put(self, 66 | locale: str, 67 | key: Optional[str] = None, 68 | value: Optional[Any] = None, 69 | mapping_values: Optional[dict[str, Any]] = None): 70 | await self.kv_storage.put(locale=locale, 71 | key=key, 72 | value=value, 73 | mapping_values=mapping_values) 74 | 75 | async def create(self, 76 | locale: str, 77 | key: Optional[str] = None, 78 | value: Optional[Any] = None, 79 | mapping_values: Optional[dict[str, Any]] = None): 80 | await self.kv_storage.create(locale, 81 | key, 82 | value, 83 | mapping_values) 84 | 85 | async def delete(self, locale: str, *keys): 86 | await self.kv_storage.delete(locale, *keys) 87 | 88 | def _create_translators(self) -> list[FluentTranslator]: 89 | translators = [] 90 | for locale in self.locales_map: 91 | bundle = FluentBundle(locale=locale, resources=[]) 92 | translators.append(FluentTranslator(locale=locale, 93 | translator=bundle)) 94 | return translators 95 | 96 | def _get_translators_messages(self) -> dict[str, dict]: 97 | messages = {} 98 | for translator in self.translators: 99 | messages[translator.locale] = translator.translator._compiled_messages 100 | return messages 101 | 102 | async def _on_update(self): 103 | await asyncio.shield(self.kv_storage.listen(self.messages)) -------------------------------------------------------------------------------- /fluentogram/src/impl/transator_hubs/translator_hub.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | A Translator Hub, using as factory for Translator objects 4 | """ 5 | from typing import Dict, Iterable, List, Union 6 | 7 | from fluentogram.exceptions import NotImplementedRootLocaleTranslator 8 | from fluentogram.src.abc import AbstractTranslator, AbstractTranslatorsHub 9 | from fluentogram.src.impl import TranslatorRunner 10 | 11 | 12 | class TranslatorHub(AbstractTranslatorsHub): 13 | """ 14 | This class implements a storage for all single-locale translators. 15 | """ 16 | 17 | def __init__( 18 | self, 19 | locales_map: Dict[str, Union[str, Iterable[str]]], 20 | translators: List[AbstractTranslator], 21 | root_locale: str = "en", 22 | separator: str = "-", 23 | ) -> None: 24 | self.locales_map = dict( 25 | zip( 26 | locales_map.keys(), 27 | map( 28 | lambda lang: tuple([lang]) if isinstance(lang, str) else lang, 29 | locales_map.values() 30 | ) 31 | ) 32 | ) 33 | self.translators = translators 34 | self.root_locale = root_locale 35 | self.separator = separator 36 | self.storage: Dict[str, AbstractTranslator] = dict( 37 | zip([translator.locale for translator in translators], translators) 38 | ) 39 | if not self.storage.get(root_locale): 40 | raise NotImplementedRootLocaleTranslator(self.root_locale) 41 | self.translators_map: Dict[str, Iterable[AbstractTranslator]] = self._locales_map_parser(self.locales_map) 42 | 43 | def _locales_map_parser( 44 | self, 45 | locales_map: Dict[str, Union[str, Iterable[str]]] 46 | ) -> Dict[str, Iterable[AbstractTranslator]]: 47 | return { 48 | lang: tuple( 49 | [self.storage.get(locale) 50 | for locale in translator_locales if locale in self.storage.keys()] 51 | ) 52 | for lang, translator_locales in 53 | locales_map.items() 54 | } 55 | 56 | def get_translator_by_locale(self, locale: str) -> TranslatorRunner: 57 | """ 58 | Here is a little tricky moment. 59 | There should be like a one-shot scheme. 60 | For proper isolation, function returns TranslatorRunner new instance every time, not the same translator. 61 | This trick makes "obj.attribute1.attribute2" access to be able. 62 | You are able to do what you want, refer to the abstract class. 63 | """ 64 | return TranslatorRunner( 65 | translators=self.translators_map.get(locale) or self.translators_map[self.root_locale], 66 | separator=self.separator 67 | ) 68 | -------------------------------------------------------------------------------- /fluentogram/src/impl/transformers/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from .datetime_transformer import DateTimeTransformer 3 | from .money_transformer import MoneyTransformer 4 | 5 | 6 | __all__ = ["DateTimeTransformer", "MoneyTransformer", ] 7 | -------------------------------------------------------------------------------- /fluentogram/src/impl/transformers/datetime_transformer.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | A DateTimeTransformer itself 4 | """ 5 | from datetime import datetime 6 | from typing import Union 7 | 8 | from fluent_compiler.types import fluent_date, FluentDateType, FluentNone 9 | 10 | from fluentogram.src.abc import AbstractDataTransformer 11 | 12 | 13 | class DateTimeTransformer(AbstractDataTransformer): 14 | """This transformer converts a default python datetime object to FluentDate 15 | Typings refer to https://github.com/tc39/ecma402 16 | """ 17 | 18 | def __new__( 19 | cls, 20 | date: datetime, 21 | **kwargs 22 | ) -> Union[FluentDateType, FluentNone]: 23 | return fluent_date( 24 | date, 25 | **kwargs 26 | ) 27 | -------------------------------------------------------------------------------- /fluentogram/src/impl/transformers/money_transformer.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | A MoneyTransformer by itself 4 | """ 5 | from decimal import Decimal 6 | from typing import Literal, Union, Optional 7 | 8 | from fluent_compiler.types import fluent_number, FluentNumber, FluentNone 9 | 10 | from fluentogram.src.abc import AbstractDataTransformer 11 | 12 | 13 | class MoneyTransformer(AbstractDataTransformer): 14 | """This transformer converts a decimal object to FluentNumber with proper metadata. 15 | Typings refer to https://github.com/tc39/ecma402 16 | """ 17 | 18 | def __new__( 19 | cls, 20 | amount: Decimal, 21 | currency: str, 22 | currency_display: Union[ 23 | Literal["code"], Literal["symbol"], Literal["name"] 24 | ] = "code", 25 | use_grouping: bool = False, 26 | minimum_significant_digits: Optional[int] = None, 27 | maximum_significant_digits: Optional[int] = None, 28 | minimum_fraction_digits: Optional[int] = None, 29 | maximum_fraction_digits: Optional[int] = None, 30 | **kwargs 31 | ) -> Union[FluentNumber, FluentNone]: 32 | return fluent_number( 33 | amount, 34 | style="currency", 35 | currencyDisplay=currency_display, 36 | currency=currency, 37 | useGrouping=use_grouping, 38 | minimumSignificantDigits=minimum_significant_digits, 39 | maximumSignificantDigits=maximum_significant_digits, 40 | minimumFractionDigits=minimum_fraction_digits, 41 | maximumFractionDigits=maximum_fraction_digits, 42 | **kwargs 43 | ) 44 | -------------------------------------------------------------------------------- /fluentogram/src/impl/translator.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | """ 3 | Fluent implementation of AbstractTranslator 4 | """ 5 | 6 | from fluent_compiler.bundle import FluentBundle 7 | 8 | from fluentogram.src.abc import AbstractTranslator 9 | 10 | 11 | class FluentTranslator(AbstractTranslator): 12 | """Single-locale Translator, implemented with fluent_compiler Bundles""" 13 | 14 | def __init__(self, locale: str, translator: FluentBundle, separator: str = "-"): 15 | self.locale = locale 16 | self.translator = translator 17 | self.separator = separator 18 | 19 | def get(self, key: str, **kwargs): 20 | """STR100: Calling format with insecure string. 21 | Route questions to --> https://github.com/django-ftl/fluent-compiler""" 22 | text, errors = self.translator.format(key, kwargs) 23 | if errors: 24 | raise errors.pop() 25 | return text 26 | 27 | def __repr__(self): 28 | return f"" 29 | -------------------------------------------------------------------------------- /fluentogram/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from . import test_usage 3 | 4 | __all__ = ["test_usage", ] 5 | -------------------------------------------------------------------------------- /fluentogram/tests/test_stub_generation.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | import unittest 3 | 4 | from fluentogram.typing_generator import ParsedRawFTL, Tree, Stubs 5 | 6 | 7 | class StubGeneration(unittest.TestCase): 8 | DEFAULT_STUB_TEXT = """from typing import Literal 9 | 10 | 11 | class TranslatorRunner: 12 | def get(self, path: str, **kwargs) -> str: ... 13 | """ 14 | 15 | def _gen_stub_text(self, raw_text): 16 | raw = ParsedRawFTL(raw_text) 17 | messages = raw.get_messages() 18 | tree = Tree(messages) 19 | stubs = Stubs(tree) 20 | return stubs.echo() 21 | 22 | def test_text_element(self): 23 | self.assertEquals( 24 | self._gen_stub_text("welcome = Welcome to the fluent aiogram addon!"), 25 | self.DEFAULT_STUB_TEXT 26 | + ''' 27 | @staticmethod 28 | def welcome() -> Literal["""Welcome to the fluent aiogram addon!"""]: ... 29 | 30 | ''', 31 | ) 32 | 33 | def test_variable_reference(self): 34 | self.assertEquals( 35 | self._gen_stub_text( 36 | "greet-by-name = Hello, { $user }... You name is { $user }, isn't it?" 37 | ), 38 | self.DEFAULT_STUB_TEXT 39 | + ''' 40 | greet: Greet 41 | 42 | 43 | class Greet: 44 | by: GreetBy 45 | 46 | 47 | class GreetBy: 48 | @staticmethod 49 | def name(*, user) -> Literal["""Hello, { $user }... You name is { $user }, isn't it?"""]: ... 50 | 51 | ''', 52 | ) 53 | 54 | def test_variable_reference_with_two_args(self): 55 | self.assertEquals( 56 | self._gen_stub_text( 57 | "shop-success-payment = Your money, { $amount }, has been sent successfully at { $dt }." 58 | ), 59 | self.DEFAULT_STUB_TEXT 60 | + ''' 61 | shop: Shop 62 | 63 | 64 | class Shop: 65 | success: ShopSuccess 66 | 67 | 68 | class ShopSuccess: 69 | @staticmethod 70 | def payment(*, amount, dt) -> Literal["""Your money, { $amount }, has been sent successfully at { $dt }."""]: ... 71 | 72 | ''', 73 | ) 74 | 75 | def test_selector(self): 76 | self.assertEquals( 77 | self._gen_stub_text( 78 | """test-bool_indicator = { $is_true -> 79 | [one] ✅ 80 | *[other] ❌ 81 | } """ 82 | ), 83 | self.DEFAULT_STUB_TEXT 84 | + ''' 85 | test: Test 86 | 87 | 88 | class Test: 89 | @staticmethod 90 | def bool_indicator(*, is_true) -> Literal["""{ $is_true -> 91 | [one] ✅ 92 | *[other] ❌ 93 | }"""]: ... 94 | 95 | ''', 96 | ) 97 | 98 | def test_selector_num_key(self): 99 | self.assertEquals( 100 | self._gen_stub_text( 101 | """test-bool_indicator = { $is_true -> 102 | [0] ✅ 103 | *[other] ❌ 104 | } """ 105 | ), 106 | self.DEFAULT_STUB_TEXT 107 | + ''' 108 | test: Test 109 | 110 | 111 | class Test: 112 | @staticmethod 113 | def bool_indicator(*, is_true) -> Literal["""{ $is_true -> 114 | [0] ✅ 115 | *[other] ❌ 116 | }"""]: ... 117 | 118 | ''', 119 | ) 120 | 121 | def test_recursion(self): 122 | self.assertEquals( 123 | self._gen_stub_text( 124 | """recursion = { $is_true -> 125 | [one] one 126 | *[other] Recursion { $is_true -> 127 | [one] one 128 | *[other] Recursion { $is_true -> 129 | [one] one 130 | *[other] Recursion 131 | } 132 | } 133 | }""" 134 | ), 135 | self.DEFAULT_STUB_TEXT 136 | + ''' 137 | @staticmethod 138 | def recursion(*, is_true) -> Literal["""{ $is_true -> 139 | [one] one 140 | *[other] Recursion 141 | *[other] { $is_true -> 142 | [one] one 143 | *[other] Recursion 144 | *[other] { $is_true -> 145 | [one] one 146 | *[other] Recursion 147 | } 148 | } 149 | }"""]: ... 150 | 151 | ''', 152 | ) 153 | 154 | def test_function_reference(self): 155 | self.assertEquals( 156 | self._gen_stub_text("test-number = { NUMBER($num, useGrouping: 0) }"), 157 | self.DEFAULT_STUB_TEXT 158 | + ''' 159 | test: Test 160 | 161 | 162 | class Test: 163 | @staticmethod 164 | def number(*, num) -> Literal["""{ NUMBER({ $num }, useGrouping: 0) }"""]: ... 165 | 166 | ''', 167 | ) 168 | 169 | def test_message_reference_to_text(self): 170 | self.assertEquals( 171 | self._gen_stub_text( 172 | """simple = text 173 | ref = { simple } 174 | """ 175 | ), 176 | self.DEFAULT_STUB_TEXT 177 | + ''' 178 | @staticmethod 179 | def simple() -> Literal["""text"""]: ... 180 | 181 | @staticmethod 182 | def ref() -> Literal["""text"""]: ... 183 | 184 | ''', 185 | ) 186 | 187 | def test_message_reference_to_var(self): 188 | self.assertEquals( 189 | self._gen_stub_text( 190 | """var = { $name } 191 | ref = { var } 192 | """ 193 | ), 194 | self.DEFAULT_STUB_TEXT 195 | + ''' 196 | @staticmethod 197 | def var(*, name) -> Literal["""{ $name }"""]: ... 198 | 199 | @staticmethod 200 | def ref(*, name) -> Literal["""{ $name }"""]: ... 201 | 202 | ''', 203 | ) 204 | 205 | def test_message_reference_in_selector(self): 206 | self.assertEquals( 207 | self._gen_stub_text( 208 | """foo = { $var -> 209 | [test] { test } 210 | *[any] any text 211 | } 212 | test = { $is_true -> 213 | [one] true 214 | *[other] false 215 | } 216 | """ 217 | ), 218 | self.DEFAULT_STUB_TEXT 219 | + ''' 220 | @staticmethod 221 | def test(*, is_true) -> Literal["""{ $is_true -> 222 | [one] true 223 | *[other] false 224 | }"""]: ... 225 | 226 | @staticmethod 227 | def foo(*, var, is_true) -> Literal["""{ $var -> 228 | [test] { $is_true -> 229 | [one] true 230 | *[other] false 231 | } 232 | *[any] any text 233 | }"""]: ... 234 | 235 | ''', 236 | ) 237 | -------------------------------------------------------------------------------- /fluentogram/tests/test_usage.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | import unittest 3 | from datetime import datetime 4 | from decimal import Decimal 5 | 6 | from fluent_compiler.bundle import FluentBundle 7 | 8 | from fluentogram import ( 9 | FluentTranslator, 10 | TranslatorHub, 11 | TranslatorRunner, 12 | MoneyTransformer, 13 | DateTimeTransformer, 14 | ) 15 | 16 | 17 | class BasicUsage(unittest.TestCase): 18 | def test_full_usage(self): 19 | example_ftl_file_content = """ 20 | welcome = Welcome to the fluent aiogram addon! 21 | greet-by-name = Hello, { $user }! 22 | shop-success-payment = Your money, { $amount }, has been sent successfully at { $dt }. 23 | """ 24 | t_hub = TranslatorHub( 25 | {"ua": ("ua", "ru", "en"), "ru": ("ru", "en"), "en": ("en",)}, 26 | translators=[ 27 | FluentTranslator( 28 | locale="en", 29 | translator=FluentBundle.from_string( 30 | "en-US", example_ftl_file_content, use_isolating=False 31 | ), 32 | ) 33 | ], 34 | root_locale="en", 35 | ) 36 | translator_runner: TranslatorRunner = t_hub.get_translator_by_locale("en") 37 | print( 38 | translator_runner.welcome(), 39 | "\n", 40 | translator_runner.greet.by.name(user="Alex"), 41 | "\n", 42 | translator_runner.shop.success.payment( 43 | amount=MoneyTransformer(currency="$", amount=Decimal("500")), 44 | dt=DateTimeTransformer(datetime.now()), 45 | ), 46 | ) 47 | -------------------------------------------------------------------------------- /fluentogram/typing_generator/__init__.py: -------------------------------------------------------------------------------- 1 | from .parsed_ftl import ParsedRawFTL 2 | from .renderable_items import Knot, InternalMethod, Method 3 | from .stubs import Stubs 4 | from .translation_dto import Translation 5 | from .tree import Tree, TreeNode 6 | 7 | __all__ = [ 8 | "InternalMethod", 9 | "Knot", 10 | "Method", 11 | "ParsedRawFTL", 12 | "Stubs", 13 | "Translation", 14 | "Tree", 15 | "TreeNode", 16 | ] 17 | -------------------------------------------------------------------------------- /fluentogram/typing_generator/parsed_ftl.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Dict 3 | 4 | from fluent.syntax import FluentParser 5 | from fluent.syntax.ast import ( 6 | Message, 7 | Placeable, 8 | Literal, 9 | TextElement, 10 | VariableReference, 11 | SelectExpression, 12 | MessageReference, 13 | StringLiteral, 14 | NumberLiteral, 15 | TermReference, 16 | FunctionReference, 17 | InlineExpression, 18 | NamedArgument, 19 | Identifier, 20 | ) 21 | from ordered_set import OrderedSet 22 | 23 | from fluentogram.typing_generator.translation_dto import Translation 24 | 25 | 26 | @dataclass 27 | class Node: 28 | value: str 29 | args: list[str] 30 | 31 | 32 | class ReferenceNotExists(Exception): 33 | pass 34 | 35 | 36 | class ParsedRawFTL: 37 | def __init__(self, ftl_data: str, parser=FluentParser()) -> None: 38 | self.parsed_ftl = parser.parse(ftl_data) 39 | self.nodes: dict[str, Node] 40 | 41 | def _parse_literal(self, obj: Literal) -> Node: 42 | return Node(value=obj.parse()["value"], args=[]) 43 | 44 | def _parse_string_literal(self, obj: StringLiteral) -> Node: 45 | return self._parse_literal(obj) 46 | 47 | def _parse_number_literal(self, obj: NumberLiteral) -> Node: 48 | data = obj.parse() 49 | return Node(value=str(f"{data['value']:.{data['precision']}f}"), args=[]) 50 | 51 | def _parse_message_reference(self, obj: MessageReference) -> Node: 52 | if obj.id.name not in self.nodes: 53 | raise ReferenceNotExists 54 | return self.nodes[obj.id.name] 55 | 56 | def _parse_text_element(self, obj: TextElement) -> Node: 57 | return Node(value=obj.value, args=[]) 58 | 59 | def _parse_variable_reference(self, obj: VariableReference) -> Node: 60 | return Node(value=f"{{ ${obj.id.name} }}", args=[obj.id.name]) 61 | 62 | def _parse_named_argument(self, obj: NamedArgument) -> Node: 63 | if isinstance(obj.value, NumberLiteral): 64 | value = self._parse_number_literal(obj.value) 65 | else: # StringLiteral 66 | value = self._parse_string_literal(obj.value) 67 | return Node(value=f"{obj.name.name}: {value.value}", args=[]) 68 | 69 | def _parse_function_reference(self, obj: FunctionReference) -> Node: 70 | named_args = [] 71 | for named_arg in obj.arguments.named: 72 | named_args.append(self._parse_named_argument(named_arg)) 73 | positionals = [] 74 | for positional in obj.arguments.positional: 75 | if isinstance(positional, Placeable): 76 | positionals.append(self._parse_placeable(positional)) 77 | else: # InlineExpression 78 | positionals.append(self._parse_inline_expression(positional)) 79 | 80 | named_args_string = ", ".join([named_arg.value for named_arg in named_args]) 81 | positional_string = ", ".join([positional.value for positional in positionals]) 82 | 83 | positional_args = [] 84 | for positional in positionals: 85 | positional_args += positional.args 86 | 87 | return Node( 88 | value=f"{{ {obj.id.name}({positional_string}{',' if named_args_string else ''} {named_args_string}) }}", 89 | args=positional_args, 90 | ) 91 | 92 | def _parse_inline_expression(self, obj: InlineExpression) -> Node: 93 | if isinstance(obj, NumberLiteral): 94 | return self._parse_number_literal(obj) 95 | elif isinstance(obj, StringLiteral): 96 | return self._parse_string_literal(obj) 97 | elif isinstance(obj, MessageReference): 98 | return self._parse_message_reference(obj) 99 | elif isinstance(obj, TermReference): 100 | ... 101 | # TODO: implementation 102 | elif isinstance(obj, VariableReference): 103 | return self._parse_variable_reference(obj) 104 | elif isinstance(obj, FunctionReference): 105 | return self._parse_function_reference(obj) 106 | return Node(value="", args=[]) 107 | 108 | def _parse_select_expression(self, obj: SelectExpression) -> Node: 109 | selector = self._parse_inline_expression(obj.selector) 110 | value = f"{{ {', '.join([f'${s}' for s in selector.args])} ->" 111 | args = selector.args 112 | 113 | for variant in obj.variants: 114 | for element in variant.value.elements: 115 | if isinstance(element, TextElement): 116 | variant_node = self._parse_text_element(element) 117 | elif isinstance(element, Placeable): 118 | variant_node = self._parse_placeable(element) 119 | else: 120 | continue 121 | if isinstance(variant.key, Identifier): 122 | key_name = variant.key.name 123 | elif isinstance(variant.key, NumberLiteral): 124 | key_name = self._parse_number_literal(variant.key).value 125 | else: 126 | continue 127 | value += f"\n{'*' if variant.default else ''}[{key_name}] {variant_node.value}" 128 | args += variant_node.args 129 | 130 | value += "\n}" 131 | return Node(value=value, args=args) 132 | 133 | def _parse_placeable(self, obj: Placeable) -> Node: 134 | ex = obj.expression 135 | if isinstance(ex, VariableReference): 136 | return self._parse_variable_reference(ex) 137 | elif isinstance(ex, SelectExpression): 138 | return self._parse_select_expression(ex) 139 | elif isinstance(ex, InlineExpression): 140 | return self._parse_inline_expression(ex) 141 | elif isinstance(ex, Placeable): 142 | return self._parse_placeable(ex) 143 | return Node(value="", args=[]) 144 | 145 | def _parse_message(self, obj: Message) -> Node: 146 | nodes: list[Node] = [] 147 | for element in obj.value.elements: 148 | if isinstance(element, TextElement): 149 | nodes.append(self._parse_text_element(element)) 150 | elif isinstance(element, Placeable): 151 | nodes.append(self._parse_placeable(element)) 152 | else: 153 | continue 154 | 155 | node_value, node_args = "", [] 156 | for sub_node in nodes: 157 | node_value += sub_node.value 158 | node_args += sub_node.args 159 | return Node(value=node_value, args=node_args) 160 | 161 | def _parse_body(self) -> dict[str, Node]: 162 | self.nodes: dict[str, Node] = {} 163 | for message in self.parsed_ftl.body: 164 | if not isinstance(message, Message): 165 | # TODO: other Entry 166 | continue 167 | if message.value is None: 168 | continue 169 | try: 170 | self.nodes[message.id.name] = self._parse_message(message) 171 | except ReferenceNotExists: 172 | self.parsed_ftl.body.append(message) 173 | continue 174 | return self.nodes 175 | 176 | def get_messages(self) -> Dict[str, Translation]: 177 | return { 178 | name: Translation(node.value, args=OrderedSet(node.args)) 179 | for name, node in self._parse_body().items() 180 | } 181 | -------------------------------------------------------------------------------- /fluentogram/typing_generator/renderable_items.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | from typing import Optional, List, Iterable 3 | 4 | try: 5 | from jinja2 import Template 6 | except ModuleNotFoundError: 7 | raise ModuleNotFoundError("You should install Jinja2 package to use cli tools") 8 | 9 | 10 | class RenderAble: 11 | render_pattern: Template 12 | 13 | def __init__(self, **kwargs) -> None: 14 | self.kwargs = kwargs 15 | 16 | def render(self) -> str: 17 | return self.render_pattern.render(**self.kwargs) + "\n" 18 | 19 | 20 | class Import(RenderAble): 21 | render_pattern = Template("from typing import Literal", autoescape=True) 22 | 23 | 24 | class Method(RenderAble): 25 | render_pattern = Template( 26 | " @staticmethod\n" 27 | ' def {{ method_name }}({{ args }}) -> Literal["""{{ translation }}"""]: ...', 28 | autoescape=True, 29 | ) 30 | 31 | def __init__( 32 | self, method_name: str, translation: str, args: Optional[Iterable[str]] = None 33 | ) -> None: 34 | if args: 35 | formatted_args = "*, " + ", ".join(args) 36 | else: 37 | formatted_args = "" 38 | super().__init__(translation=translation, args=formatted_args) 39 | self.kwargs["method_name"] = method_name 40 | 41 | 42 | class InternalMethod(Method): 43 | def __init__(self, translation: str, args: Optional[Iterable[str]] = None) -> None: 44 | super().__init__(method_name="__call__", translation=translation, args=args) 45 | 46 | 47 | class Var(RenderAble): 48 | render_pattern = Template( 49 | " {{ var_name }}: {{ var_full_name }}", autoescape=True 50 | ) 51 | 52 | def __init__(self, var_name: str, var_full_name: Optional[str] = None) -> None: 53 | super().__init__( 54 | var_name=var_name, 55 | var_full_name=var_name if not var_full_name else var_full_name, 56 | ) 57 | 58 | 59 | class Knot(RenderAble): 60 | render_pattern = Template("\nclass {{ class_name }}:\n", autoescape=True) 61 | 62 | def __init__(self, class_name: str) -> None: 63 | super().__init__() 64 | self.class_name = class_name 65 | self.variables: List[Var] = [] 66 | self.methods: List[Method] = [] 67 | 68 | def render(self) -> str: 69 | text = self.render_pattern.render(class_name=self.class_name) + "\n" 70 | for var in self.variables: 71 | text += var.render() 72 | if self.variables: 73 | text += "\n" 74 | for method in self.methods: 75 | text += method.render() + "\n" 76 | return text 77 | 78 | def add_var(self, var: Var) -> None: 79 | self.variables.append(var) 80 | 81 | def add_method(self, method: Method) -> None: 82 | self.methods.append(method) 83 | 84 | 85 | class Runner(Knot): 86 | render_pattern = Template( 87 | "\nclass {{ class_name }}:\n def get(self, path: str, **kwargs) -> str: ...\n ", 88 | autoescape=True, 89 | ) 90 | 91 | def __init__(self, name: str = "TranslatorRunner") -> None: 92 | super().__init__(name) 93 | -------------------------------------------------------------------------------- /fluentogram/typing_generator/stubs.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator 2 | 3 | from fluentogram.typing_generator.renderable_items import ( 4 | Var, 5 | Knot, 6 | InternalMethod, 7 | Method, 8 | Runner, 9 | ) 10 | from fluentogram.typing_generator.tree import Tree 11 | 12 | 13 | class Stubs: 14 | def __init__(self, tree: Tree, root: str = "TranslatorRunner") -> None: 15 | self.root = root 16 | self.nodes = tree.elements 17 | self.content: str = "from typing import Literal\n\n " 18 | for stub in self._gen_stubs(): 19 | self.content += stub 20 | 21 | def _gen_stubs(self) -> Iterator[str]: 22 | for path, node in self.nodes.items(): 23 | if node.is_leaf: 24 | continue 25 | if node.path: 26 | knot = Knot(node.path) 27 | else: 28 | knot = Runner(self.root) 29 | if node.children: 30 | if node.value: 31 | knot.add_method( 32 | InternalMethod(node.value, args=node.translation_vars) 33 | ) 34 | for name, sub_node in node.children.items(): 35 | if sub_node.is_leaf: 36 | if sub_node.value: 37 | knot.add_method( 38 | Method( 39 | name, sub_node.value, args=sub_node.translation_vars 40 | ) 41 | ) 42 | else: 43 | knot.add_var(Var(name, sub_node.path)) 44 | yield knot.render() 45 | 46 | def to_file(self, file_name: str) -> None: 47 | with open(file_name, "w", encoding="utf-8") as f: 48 | f.write(self.content) 49 | 50 | def echo(self) -> str: 51 | return self.content 52 | -------------------------------------------------------------------------------- /fluentogram/typing_generator/translation_dto.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from ordered_set import OrderedSet 4 | 5 | 6 | @dataclass 7 | class Translation: 8 | text: str 9 | args: OrderedSet 10 | -------------------------------------------------------------------------------- /fluentogram/typing_generator/tree.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | from ordered_set import OrderedSet 5 | 6 | from fluentogram.typing_generator.translation_dto import Translation 7 | 8 | 9 | @dataclass 10 | class TreeNode: 11 | path: str 12 | children: dict[str, "TreeNode"] 13 | name: str 14 | value: Optional[str] = None 15 | translation_vars: Optional[OrderedSet] = None 16 | 17 | @property 18 | def is_leaf(self) -> bool: 19 | if not self.children: 20 | return True 21 | return False 22 | 23 | 24 | class Tree: 25 | def __init__( 26 | self, 27 | ftl_syntax: dict[str, Translation], 28 | separator: str = "-", 29 | safe_separator: str = "", 30 | ) -> None: 31 | self.safe_separator = safe_separator 32 | self.ftl_syntax = ftl_syntax 33 | self.separator = separator 34 | self.elements: dict[tuple[str, ...], TreeNode] = {} 35 | for path, translation in ftl_syntax.items(): 36 | *point_path, name = path.split("-") 37 | point_path.insert(0, "") 38 | self._build(tuple(point_path), name, translation) 39 | 40 | def path_to_str(self, path: tuple) -> str: 41 | clean_path = map(lambda s: s[0].capitalize() + s[1:], filter(lambda x: x, path)) 42 | return self.safe_separator.join(clean_path) 43 | 44 | def _build(self, path: tuple[str, ...], name: str, value=None) -> None: 45 | own_class_def = TreeNode( 46 | path=self.path_to_str(path + (name,)), 47 | name=name, 48 | value=value.text if value else "", 49 | children={}, 50 | translation_vars=value.args if value else OrderedSet(), 51 | ) 52 | 53 | if path: 54 | if path not in self.elements: 55 | self._build(path[:-1], path[-1]) 56 | 57 | self.elements[path].children[name] = own_class_def 58 | 59 | self.elements.setdefault(path + (name,), own_class_def) 60 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fluentogram" 3 | version = "1.1.11" 4 | description = "A proper way to use an i18n mechanism with Aiogram3." 5 | authors = ["Aleks"] 6 | license = "MIT" 7 | repository = "https://github.com/Arustinal/fluentogram" 8 | 9 | [tool.poetry.scripts] 10 | i18n = 'fluentogram.cli:cli' 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.9" 14 | fluent-compiler = "^0.3" 15 | watchdog = "^2.3.0" 16 | ordered-set = "^4.1.0" 17 | nats-py = "^2.9.0" 18 | typing-extensions = "^4.12.2" 19 | 20 | [tool.poetry.dev-dependencies] 21 | 22 | [build-system] 23 | requires = ["setuptools>=42"] 24 | build-backend = "setuptools.build_meta" 25 | --------------------------------------------------------------------------------