diff --git a/.flake8 b/.flake8
deleted file mode 100644
index 79a16af..0000000
--- a/.flake8
+++ /dev/null
@@ -1,2 +0,0 @@
-[flake8]
-max-line-length = 120
\ No newline at end of file
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..92aa565
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,51 @@
+name: fluentogram-tests
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ pre-commit-check:
+ if: github.event.pull_request.draft == false
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Set $PY environment variable
+ run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV
+ - uses: actions/cache@v4
+ with:
+ path: ~/.cache/pre-commit
+ key: pre-commit|${{ hashFiles('.pre-commit-config.yaml') }}
+ - uses: pre-commit/action@v3.0.1
+
+ test:
+ strategy:
+ matrix:
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install uv
+ uv pip install --system .[dev]
+
+ - name: Run tests
+ run: pytest tests
diff --git a/.gitignore b/.gitignore
index a2787e6..05f307e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,3 +74,4 @@ dmypy.json
.pytype/
cython_debug/
.idea
+.vscode
\ No newline at end of file
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
deleted file mode 100644
index 0c5e461..0000000
--- a/.gitlab-ci.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-# This file is a template, and might need editing before it works on your project.
-# To contribute improvements to CI/CD templates, please follow the Development guide at:
-# https://docs.gitlab.com/ee/development/cicd/templates.html
-# This specific template is located at:
-# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Getting-Started.gitlab-ci.yml
-
-# This is a sample GitLab CI/CD configuration file that should run without any modifications.
-# It demonstrates a basic 3 stage CI/CD pipeline. Instead of real tests or scripts,
-# it uses echo commands to simulate the pipeline execution.
-#
-# A pipeline is composed of independent jobs that run scripts, grouped into stages.
-# Stages run in sequential order, but jobs within stages run in parallel.
-#
-# For more information, see: https://docs.gitlab.com/ee/ci/yaml/index.html#stages
-
-stages: # List of stages for jobs, and their order of execution
- - build
- - test
- - deploy
-
-
-unit-test-job: # This job runs in the test stage.
- stage: test # It only starts when the job in the build stage completes successfully.
- script:
- - echo "Running unit tests"
- - poetry env use python3.10
- - poetry install
- - poetry run python3 -m unittest $CI_PROJECT_NAME.tests.test_usage
-
-deploy-job: # This job runs in the deploy stage.
- stage: deploy # It only runs when *both* jobs in the test stage complete successfully.
- script:
- - python3 -m build
- - twine upload --repository test.pypi.org --username __token__ --password $PYPI_TEST_TOKEN dist/*
- - sleep 60
- - pip3 install --index-url https://test.pypi.org/simple/ fluentogram
- - echo "Application successfully deployed."
- - twine upload --username __token__ --password $PYPI_PROD_TOKEN dist/*
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..a2e1ec8
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,28 @@
+default_stages: [pre-commit, pre-merge-commit]
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v5.0.0
+ hooks:
+ - id: no-commit-to-branch
+ args: [ '--branch', 'master' ]
+ - id: end-of-file-fixer
+ files: ^fluentogram/
+ - id: trailing-whitespace
+ files: ^fluentogram/
+
+ - repo: local
+ hooks:
+ - id: lint
+ name: Linter
+ entry: "scripts/lint-pre-commit.sh"
+ language: python
+ types: [python]
+ require_serial: true
+ verbose: true
+
+ - repo: https://github.com/Yelp/detect-secrets
+ rev: v1.5.0
+ hooks:
+ - id: detect-secrets
+ args: ['--baseline', '.secrets.baseline']
+ stages: [pre-commit, pre-merge-commit, manual]
\ No newline at end of file
diff --git a/.secrets.baseline b/.secrets.baseline
new file mode 100644
index 0000000..572c8da
--- /dev/null
+++ b/.secrets.baseline
@@ -0,0 +1,127 @@
+{
+ "version": "1.5.0",
+ "plugins_used": [
+ {
+ "name": "ArtifactoryDetector"
+ },
+ {
+ "name": "AWSKeyDetector"
+ },
+ {
+ "name": "AzureStorageKeyDetector"
+ },
+ {
+ "name": "Base64HighEntropyString",
+ "limit": 4.5
+ },
+ {
+ "name": "BasicAuthDetector"
+ },
+ {
+ "name": "CloudantDetector"
+ },
+ {
+ "name": "DiscordBotTokenDetector"
+ },
+ {
+ "name": "GitHubTokenDetector"
+ },
+ {
+ "name": "GitLabTokenDetector"
+ },
+ {
+ "name": "HexHighEntropyString",
+ "limit": 3.0
+ },
+ {
+ "name": "IbmCloudIamDetector"
+ },
+ {
+ "name": "IbmCosHmacDetector"
+ },
+ {
+ "name": "IPPublicDetector"
+ },
+ {
+ "name": "JwtTokenDetector"
+ },
+ {
+ "name": "KeywordDetector",
+ "keyword_exclude": ""
+ },
+ {
+ "name": "MailchimpDetector"
+ },
+ {
+ "name": "NpmDetector"
+ },
+ {
+ "name": "OpenAIDetector"
+ },
+ {
+ "name": "PrivateKeyDetector"
+ },
+ {
+ "name": "PypiTokenDetector"
+ },
+ {
+ "name": "SendGridDetector"
+ },
+ {
+ "name": "SlackDetector"
+ },
+ {
+ "name": "SoftlayerDetector"
+ },
+ {
+ "name": "SquareOAuthDetector"
+ },
+ {
+ "name": "StripeDetector"
+ },
+ {
+ "name": "TelegramBotTokenDetector"
+ },
+ {
+ "name": "TwilioKeyDetector"
+ }
+ ],
+ "filters_used": [
+ {
+ "path": "detect_secrets.filters.allowlist.is_line_allowlisted"
+ },
+ {
+ "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
+ "min_level": 2
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_indirect_reference"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_likely_id_string"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_lock_file"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_potential_uuid"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_sequential_string"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_swagger_file"
+ },
+ {
+ "path": "detect_secrets.filters.heuristic.is_templated_secret"
+ }
+ ],
+ "results": {},
+ "generated_at": "2025-06-28T20:21:22Z"
+}
diff --git a/README.md b/README.md
index 3aea1e6..0a022e9 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,242 @@
# fluentogram
-A proper way to use an i18n mechanism with Aiogram3. Using Project Fluent by Mozilla
-https://projectfluent.org/fluent/guide/
+fluentogram is easy way to use i18n (Fluent) mechanism in any python app.
-Short example:
+## Features
-```py
-# Somewhere in middleware.Grab language_code from telegram user object, or database, etc.
-translator_runner: TranslatorRunner = t_hub.get_translator_by_locale("en")
+- Customizable storage: You can implement your own storage to save translates.
+- Fallback support: Automatic fallback to root locale when translations are missing.
+- Precompiled fluent messages using fluent_compiler makes formatting messages faster.
+- Dot access to messages: `translator.hello(name='Alex')`
+- Stub generator
-# In message handler:
-async def message_handler(message: Message, ..., i18n: TranslatorRunner):
- ...
- await message.answer(i18n.welcome())
- await message.answer(i18n.greet.by.name(user="Alex")) #aka message.from_user.username
- await message.answer(i18n.shop.success.payment(
- amount=MoneyTransformer(currency="$", amount=Decimal("500")),
- dt=DateTimeTransformer(datetime.now()))
+## Installation
-# Going to be like:
- """
- Welcome to the fluent aiogram addon!
- Hello, Alex!
- Your money, $500.00, has been sent successfully at Dec 4, 2022.
- """
+```bash
+pip install fluentogram
```
-Check [*Examples*](example) folder.
+## Quick Start
+
+### Basic Usage
+
+```python
+from fluent_compiler.bundle import FluentBundle
+from fluentogram import FluentTranslator, TranslatorHub
+
+# Create translators for different locales
+translators = [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string(
+ "en-US",
+ "welcome = Welcome, { $username }!\n"
+ "items-count = You have { $count } items",
+ ),
+ ),
+]
+
+# Configure locale mapping with fallbacks
+locales_map = {
+ "en": "en",
+}
+
+# Create the translator hub
+hub = TranslatorHub(locales_map, translators)
+
+# Get a translator for a specific locale
+translator = hub.get_translator_by_locale("en")
+
+# Use translations
+print(translator.get("welcome", username="Alice")) # "Welcome, Alice!"
+```
+
+### Attribute-based Access
+
+Fluentogram supports a convenient attribute-based syntax for accessing translations:
+
+```python
+print(translator.welcome(username="Alice")) # "Welcome, Alice!"
+print(translator.items.count(count=5)) # "You have 5 items"
+```
+
+### Stub generator with CLI
+
+#### Install with CLI dependencies
+
+```sh
+pip install fluentogram[cli]
+```
+
+#### Run generator
+
+```sh
+fluentogram -f tests/assets/test.ftl -o test.pyi
+```
+
+## Storages
+
+### File storage
+
+```python
+from fluentogram import TranslatorHub
+from fluentogram.storage.file import FileStorage
+
+# Create FileStorage with custom path
+storage = FileStorage("my_translations/{locale}/")
+
+locales_map = {
+ "en": "en",
+}
+
+hub = TranslatorHub(locales_map, storage=storage)
+
+translator = hub.get_translator_by_locale("en")
+
+print(translator.get("hello")) # Hello, world!
+```
+
+## fluentogram supports real-time translation updates using NATS KV storage:
+
+Install:
+
+```sh
+pip install fluentogram[nats]
+```
+
+```python
+import asyncio
+
+from fluent_compiler.bundle import FluentBundle
+from nats.js.api import KeyValueConfig, StorageType
+
+from fluentogram import FluentTranslator, TranslatorHub
+from fluentogram.nats.storage import NatsKvStorage
+
+
+async def main():
+ # Configure NATS KV storage
+ kv_config = KeyValueConfig(
+ bucket="fluentogram",
+ storage=StorageType.FILE,
+ )
+
+ # Create NATS storage
+ storage = await NatsKvStorage.from_servers(
+ servers=["nats://localhost:4222"],
+ kv_config=kv_config,
+ )
+
+ # Create translators
+ translators = [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string(
+ "en-US",
+ "greeting = Hello, { $name }!",
+ ),
+ ),
+ ]
+
+ # Create hub with NATS storage
+ hub = TranslatorHub(
+ {"en": "en"},
+ translators,
+ storage=storage,
+ )
+
+ translator = hub.get_translator_by_locale("en")
+ print(translator.get("greeting", name="World")) # "Hello, World!"
+
+ # Update translation dynamically
+ await storage.update_translation("en", "greeting", "Hi there, { $name }!")
+
+ # Wait for the update to propagate
+ await asyncio.sleep(1)
+
+ # Get updated translation
+ print(translator.get("greeting", name="World")) # "Hi there, World!"
+
+ await storage.close()
+
+
+asyncio.run(main())
+```
+
+## Error Handling
+
+Fluentogram provides comprehensive error handling:
+
+```python
+from fluentogram.exceptions import KeyNotFoundError, FormatError, RootTranslatorNotFoundError
+
+try:
+ translator = hub.get_translator_by_locale("fr")
+ result = translator.get("nonexistent-key")
+except KeyNotFoundError as e:
+ print(f"Translation key not found: {e.key}")
+except RootTranslatorNotFoundError as e:
+ print(f"Root locale translator missing: {e.root_locale}")
+except FormatError as e:
+ print(f"Formatting error for key {e.key}: {e.original_error}")
+```
+
+# Inside of fluentogram
+
+### TranslatorHub
+
+`TranslatorHub` is unit of distribution TranslatorRunner's.
+
+Init:
+
+```python
+def __init__(
+ self,
+ locales_map: dict[str, str | Iterable[str]],
+ translators: list[FluentTranslator],
+ root_locale: str = "en",
+) -> None:
+```
+
+### Locales map
+
+That's like a configuration map for "Rollback" feature. If you haven't configured translation for current locale - first in collection, "Rollback" will look to others locales' data and try to find a translation
+
+For example:
+
+```python
+locales_map = {
+ "ua": ("ua", "de", "en"),
+ "de": ("de", "en"),
+ "en": ("en",)
+}
+```
+
+Let's look at this example
+
+If translator does not find a translation for `ua` locale in `ua` data, next stop is `de` data. If it's failed too - it will look to `en` translations.
+
+### Translators
+
+You have 3 variants to use translators:
+
+- In memory
+
+```python
+translators = [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string(
+ "en-US",
+ "welcome = Welcome, { $username }!\n"
+ "items-count = You have { $count } items",
+ ),
+ ),
+]
+hub = TranslatorHub(locales_map, translators)
+```
+
+- From files using file storage
+- From NATS KV using storage
+
+[↑ Back to Storages](#storages)
diff --git a/example/BaseUsage.md b/example/BaseUsage.md
deleted file mode 100644
index 280d17c..0000000
--- a/example/BaseUsage.md
+++ /dev/null
@@ -1,45 +0,0 @@
-```py
-example_ftl_file_content = """
-welcome = Welcome to the fluent aiogram addon!
-greet-by-name = Hello, { $user }!
-shop-success-payment = Your money, { $amount }, has been sent successfully.
-"""
-
-# main.py of bot:
-example_ftl_file_content = """
-welcome = Welcome to the fluent aiogram addon!
-greet-by-name = Hello, { $user }!
-shop-success-payment = Your money, { $amount }, has been sent successfully at { $dt }.
-"""
-
-t_hub = TranslatorHub(
- {"ua": ("ua", "ru", "en"),
- "ru": ("ru", "en"),
- "en": ("en",)},
- translators=[
- FluentTranslator(locale="en",
- translator=FluentBundle.from_string("en-US", example_ftl_file_content,
- use_isolating=False))],
- FluentTranslator(locale="ru",
- translator=...)]
- root_locale="en",
-)
-
-# Somewhere in middleware.Grab language_code from telegram user object, or database, etc.
-translator_runner: TranslatorRunner = t_hub.get_translator_by_locale("en")
-
-# In message handler:
-async def message_handler(message: Message, ..., i18n: TranslatorRunner):
- ...
- await message.answer(i18n.welcome())
- await message.answer(i18n.greet.by.name(user="Alex")) #aka message.from_user.username
- await message.answer(i18n.shop.success.payment(
- amount=MoneyTransformer(currency="$", amount=Decimal("500")),
- dt=DateTimeTransformer(datetime.now()))
-
-# Going to be like:
- """
- Welcome to the fluent aiogram addon!
- Hello, Alex!
- Your money, $500.00, has been sent successfully at Dec 4, 2022.
- """
\ No newline at end of file
diff --git a/example/KeyValueTranslatorHubUsage.md b/example/KeyValueTranslatorHubUsage.md
deleted file mode 100644
index c46d319..0000000
--- a/example/KeyValueTranslatorHubUsage.md
+++ /dev/null
@@ -1,76 +0,0 @@
-```py
-import asyncio
-
-import nats
-from fluent_compiler.bundle import FluentBundle
-from nats.js.api import KeyValueConfig, StorageType
-
-from fluentogram import FluentTranslator
-from fluentogram import NatsStorage
-from fluentogram.src.impl.transator_hubs import KvTranslatorHub
-
-async def main():
- # initialising the NATS connection and JetStream
- nc = await nats.connect(servers = ["nats://localhost:4222"])
- js = nc.jetstream()
-
- # key/value store creation
- kv_config = KeyValueConfig(
- bucket='my_bucket',
- storage=StorageType.FILE,
- )
- kv = await js.create_key_value(config=kv_config)
- storage = NatsStorage(kv=kv, js=js)
-
- translator_hub = KvTranslatorHub(
- {
- 'ru': ('ru', 'en'),
- 'en': ('en',),
-
- },
- translators=[
- FluentTranslator(locale='en',
- translator=FluentBundle.from_string(
- locale='en-US',
- text='hello = Hello {$name}!')
- ),
- FluentTranslator(locale='ru',
- translator=FluentBundle.from_string(
- locale='ru',
- text='hello = Привет {$name}!')
- )
- ]
- )
-
- # initialising storage in the translator hub
- await translator_hub.from_storage(kv_storage=storage)
-
- i18n_ru = translator_hub.get_translator_by_locale(locale='ru')
- i18n_en = translator_hub.get_translator_by_locale(locale='en')
-
- # retrieving texts from passed translators
- print(i18n_ru.hello(name='Александр')) # Привет Александр!
- print(i18n_en.hello(name='Alex')) # Hello Alex!
-
- # creating and updating keys/values
- await translator_hub.put(locale='ru', key='put_key', value='вставленное или обновленное значение №{$number}') # key insertion or replacement
- await translator_hub.create(locale='en', key='create_key', value='create value №{$number}') # only if this key does not exist
- await translator_hub.put(locale='en', mapping_values={
- "mapping_key1": "mapping value №1",
- "mapping_key2": "mapping value №2",
- })
-
- # it may take a few seconds for a listener running in the background to catch and process incoming updates
- await asyncio.sleep(5)
-
- print(i18n_ru.put_key(number=1)) # вставленное или обновленное значение №1
- print(i18n_en.create_key(number=1)) # create value №1
- print(i18n_en.mapping_key1()) # mapping value №1
- print(i18n_en.mapping_key2()) # mapping value №2
-
- # deletion keys/values
- await translator_hub.delete('en', "mapping_key1", "create_key")
-
-if __name__ == '__main__':
- asyncio.run(main())
-```
\ No newline at end of file
diff --git a/example/MRE.py b/example/MRE.py
deleted file mode 100644
index 40e3f61..0000000
--- a/example/MRE.py
+++ /dev/null
@@ -1,56 +0,0 @@
-import asyncio
-import os
-from typing import TYPE_CHECKING, Callable, Dict, Any, Awaitable
-
-from aiogram import Bot, Dispatcher, Router, BaseMiddleware
-from aiogram.types import Message
-from fluent_compiler.bundle import FluentBundle
-
-from fluentogram import FluentTranslator, TranslatorHub, TranslatorRunner
-
-if TYPE_CHECKING:
- from stub import TranslatorRunner # you haven't this file until you use TypingGenerator
-
-
-class TranslatorRunnerMiddleware(BaseMiddleware):
- async def __call__(
- self,
- handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
- event: Message,
- data: Dict[str, Any]
- ) -> Any:
- hub: TranslatorHub = data.get('_translator_hub')
- # There you can ask your database for locale
- data['i18n'] = hub.get_translator_by_locale(event.from_user.language_code)
- return await handler(event, data)
-
-
-main_router = Router()
-main_router.message.middleware(TranslatorRunnerMiddleware())
-
-
-@main_router.message()
-async def handler(message: Message, i18n: TranslatorRunner):
- await message.answer(i18n.start.hello(username=message.from_user.username))
-
-
-async def main():
- translator_hub = TranslatorHub(
- {
- "ru": ("ru", "en"),
- "en": ("en",)
- },
- [
- FluentTranslator("en", translator=FluentBundle.from_string("en-US", "start-hello = Hello, { $username }")),
- FluentTranslator("ru", translator=FluentBundle.from_string("ru", "start-hello = Привет, { $username }"))
- ],
- )
- bot = Bot(token=os.getenv("TOKEN"))
- dp = Dispatcher()
- dp.include_router(main_router)
- print("bot is ready")
- await dp.start_polling(bot, _translator_hub=translator_hub)
-
-
-if __name__ == '__main__':
- asyncio.run(main())
diff --git a/example/TranslatorHub.md b/example/TranslatorHub.md
deleted file mode 100644
index b051f20..0000000
--- a/example/TranslatorHub.md
+++ /dev/null
@@ -1,56 +0,0 @@
-# TranslatorHub
-
-TranslatorHub is unit of distribution TranslatorRunner's.
-
-Init:
-
-```python
-def __init__(
- self,
- locales_map: Dict[str, Union[str, Iterable[str]]],
- translators: List[TAbstractTranslator],
- root_locale: str = "en",
-) -> None:
-```
-
-*Locales map* - that's like a configuration map for "Rollback" feature. If you haven't configured translation for
-current locale - first in collection,
-"Rollback" will look to others locales' data and try to find a translation
-
-For example:
-
-```python
-locales_map = {
- "ua": ("ua", "de", "en"),
- "de": ("de", "en"),
- "en": ("en",)
-}
-```
-
-Let's look at this example
-
-If translator does not find a translation for "ua" locale in "ua" data, next stop is "de" data. If it's failed too - it
-will look to "en" translations.
-
-*Translators* - List of translator instances. Every translator has only one locale. Refer to "Translator" doc page.
-First parameter - telegram locale. Pay attention to format of them. Hub's
-method `def get_translator_by_locale(self, locale: str)` will use this parameter as key to find translator.
-
-Example:
-
-```python
-FluentTranslator("en", translator=FluentBundle.from_files("en-US", filenames=[".../main.ftl"])),
-```
-
-"Wait, what about no-files configuration?" may you ask. This is OK too, because you should just choose another option
-from FluentBundle:
-
-```python
-FluentTranslator("de", translator=FluentBundle.from_string("your*ftl*content"))
-```
-
-Get your strings from anywhere - Databases, Files, no matter the source.
-
-*Root locale* - if fluentogram will meet unknown locale - this locale will be used for getting translation.
-
-Pretty simple
\ No newline at end of file
diff --git a/example/TranslatorRunner.md b/example/TranslatorRunner.md
deleted file mode 100644
index 61daeae..0000000
--- a/example/TranslatorRunner.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# TranslatorRunner
-
-TranslatorRunner is a key component of Fluentogram
-
-This single-per-message unit executes translation request
-
-Like that:
-
-```python
-@router.message()
-async def handler(message: Message, i18n: TranslatorRunner):
- await message.answer(
- i18n.say.hello(username=message.from_user.username)
- )
-```
-
-FTL content:
-
-```text
-say-hello = "Hello { $username}!"
-```
-
-So, as can you see, i18n is instance of TranslatorRunner, created in middleware before the message handler.
-
-Note: *Any variables for TranslatorRunner should be passed like key-word arguments. This is means using "=" symbol
-between attribute name and content*
-
-Remember to be careful with count of subkeys (in example - "say" and "hello"). Very big count can slow things down. If
-it needed - you can use `i18n.get("say-hello", username=...)`
-instead of classic sugar-typed dot access method.
diff --git a/example/TypingGenerator.md b/example/TypingGenerator.md
deleted file mode 100644
index 1e28519..0000000
--- a/example/TypingGenerator.md
+++ /dev/null
@@ -1,29 +0,0 @@
-After installation, use:
-
-`i18n -ftl example.ftl -stub stub.pyi`
-
-By default, `stub.py` will contain `TranslatorRunner` class with type hints for translation keys.
-
-Usage in files:
-
-```py
-from typing import TYPE_CHECKING
-
-from aiogram import Router
-from aiogram.types import Message
-from fluentogram import TranslatorRunner
-
-if TYPE_CHECKING:
- from stub import TranslatorRunner
-
-router = Router()
-
-
-@router.message()
-async def handler(message: Message, i18n: TranslatorRunner):
- await message.answer(
- i18n.hello(username=message.from_user.username)
- )
-```
-
-stub.pyi - result file after stub generator
\ No newline at end of file
diff --git a/fluentogram/__init__.py b/fluentogram/__init__.py
index af213e2..d0baae9 100644
--- a/fluentogram/__init__.py
+++ b/fluentogram/__init__.py
@@ -1,24 +1,26 @@
-# coding=utf-8
-from . import misc
-from .src.impl import (
- AttribTracer,
- FluentTranslator,
- TranslatorRunner,
- TranslatorHub,
- KvTranslatorHub,
- MoneyTransformer,
- DateTimeTransformer,
- NatsStorage,
-)
+try:
+ import nats
+except ImportError:
+ nats = None
+
+if nats is None:
+ from .nats.mock import KvTranslatorHubMock as KvTranslatorHub
+ from .nats.mock import NatsStorageMock as NatsStorage
+else:
+ from .nats.hub import KvTranslatorHub
+ from .nats.storage import NatsStorage
+
+from .runner import TranslatorRunner
+from .transformers import DateTimeTransformer, MoneyTransformer
+from .translator import FluentTranslator
+from .translator_hub import TranslatorHub
__all__ = [
- "AttribTracer",
"DateTimeTransformer",
"FluentTranslator",
+ "KvTranslatorHub",
"MoneyTransformer",
+ "NatsStorage",
"TranslatorHub",
"TranslatorRunner",
- "KvTranslatorHub",
- "misc",
- "NatsStorage",
]
diff --git a/fluentogram/__main__.py b/fluentogram/__main__.py
new file mode 100644
index 0000000..65c7fc1
--- /dev/null
+++ b/fluentogram/__main__.py
@@ -0,0 +1,4 @@
+from fluentogram.cli.main import cli
+
+if __name__ == "__main__":
+ cli()
diff --git a/fluentogram/cli/__init__.py b/fluentogram/cli/__init__.py
index a851800..e69de29 100644
--- a/fluentogram/cli/__init__.py
+++ b/fluentogram/cli/__init__.py
@@ -1,5 +0,0 @@
-# coding=utf-8
-"""A couple of CLI-access functions"""
-from .cli import cli
-
-__all__ = ["cli"]
diff --git a/fluentogram/cli/cli.py b/fluentogram/cli/cli.py
deleted file mode 100644
index f09f2ac..0000000
--- a/fluentogram/cli/cli.py
+++ /dev/null
@@ -1,80 +0,0 @@
-import argparse
-import time
-from pathlib import Path
-
-from watchdog.events import FileModifiedEvent
-from watchdog.events import FileSystemEventHandler
-from watchdog.observers import Observer
-
-from fluentogram.typing_generator import ParsedRawFTL, Stubs, Tree
-
-
-class FtlFileEventHandler(FileSystemEventHandler):
- def __init__(self, track_path: str, stub_path: str):
- self.track_path = track_path
- self.stub_path = stub_path
-
- def on_modified(self, event: FileModifiedEvent):
- print('event type: %s, path: %s' % (event.event_type, event.src_path))
- if not event.is_directory:
- messages = parse_ftl_dir(self.track_path)
- tree = Tree(messages)
- stubs = Stubs(tree)
- stubs.to_file(self.stub_path)
-
-
-def parse_ftl(ftl_path: str | Path) -> dict:
- with open(ftl_path, "r", encoding="utf-8") as input_f:
- raw = ParsedRawFTL(input_f.read())
- messages = raw.get_messages()
- return messages
-
-
-def parse_ftl_dir(dir_path: str) -> dict:
- messages = {}
- for file in Path(dir_path).glob("*.ftl"):
- messages.update(parse_ftl(file))
- return messages
-
-
-def watch_ftl_dir(track_path: str, stub_path: str) -> None:
- observer = Observer()
- observer.schedule(FtlFileEventHandler(track_path, stub_path), track_path, recursive=True)
- observer.start()
- try:
- while True:
- time.sleep(1)
- finally:
- observer.stop()
- observer.join()
-
-
-def cli() -> None:
- parser = argparse.ArgumentParser()
- parser.add_argument("-ftl", dest="ftl_path", required=False)
- parser.add_argument("-track-ftl", dest="track_path", required=False)
- parser.add_argument("-dir-ftl", dest="dir_path", required=False)
- parser.add_argument("-stub", dest="stub_path", required=False)
-
- args = parser.parse_args()
-
- if not args.ftl_path and not args.track_path and not args.dir_path:
- print("Use 'i18n --help' to see help message")
- return
-
- if args.track_path:
- print("Watching for changes in %s" % args.track_path)
- watch_ftl_dir(args.track_path, args.stub_path)
- return
-
- elif args.dir_path:
- messages = parse_ftl_dir(args.dir_path)
- else:
- messages = parse_ftl(args.ftl_path)
-
- tree = Tree(messages)
- stubs = Stubs(tree)
- if args.stub_path:
- stubs.to_file(args.stub_path)
- else:
- print(stubs.echo())
diff --git a/fluentogram/cli/main.py b/fluentogram/cli/main.py
new file mode 100644
index 0000000..35ab59e
--- /dev/null
+++ b/fluentogram/cli/main.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+import argparse
+import time
+from pathlib import Path
+
+from watchdog.events import (
+ FileModifiedEvent,
+ FileSystemEventHandler,
+)
+from watchdog.observers import Observer
+
+from fluentogram.stub_generator.generator import generate
+
+
+class FluentogramFileHandler(FileSystemEventHandler):
+ """Handler for file system events that regenerates stubs when files change."""
+
+ def __init__(self, output_file: str, file_path: str | None, dir_path: str | None) -> None:
+ self.output_file = output_file
+ self.file_path = file_path
+ self.dir_path = dir_path
+
+ def on_modified(self, event: FileModifiedEvent) -> None:
+ print(f"event type: {event.event_type}, path: {event.src_path}")
+ if not event.is_directory:
+ generate(self.output_file, self.file_path, self.dir_path)
+ print(f"Regenerated stubs for {event.src_path}")
+
+
+def watch_files(output_file: str, file_path: str | None, dir_path: str | None) -> None:
+ """Start watching files/directories for changes."""
+ event_handler = FluentogramFileHandler(output_file, file_path, dir_path)
+ observer = Observer()
+
+ if file_path:
+ # Watch specific file
+ file_dir = str(Path(file_path).parent)
+ observer.schedule(event_handler, file_dir, recursive=False)
+ print(f"Watching file: {file_path}")
+ elif dir_path:
+ # Watch directory recursively
+ observer.schedule(event_handler, dir_path, recursive=True)
+ print(f"Watching directory: {dir_path}")
+
+ observer.start()
+ print("File watcher started. Press Ctrl+C to stop.")
+
+ try:
+ while True:
+ time.sleep(1)
+ except KeyboardInterrupt:
+ observer.stop()
+ print("\nFile watcher stopped.")
+
+ observer.join()
+
+
+def cli() -> None:
+ parser = argparse.ArgumentParser(prog="fluentogram")
+ parser.add_argument("-o", "--output-file", dest="output_file", required=False, help="Path to the output file")
+ parser.add_argument("-f", "--file-path", dest="file_path", required=False, help="Path to the file to watch")
+ parser.add_argument(
+ "-w",
+ "--watch",
+ dest="watch",
+ required=False,
+ help="Watch for file changes and regenerate stubs automatically",
+ )
+ parser.add_argument("-d", "--dir-path", dest="dir_path", required=False, help="Path to the directory to watch")
+ # back compatibility
+
+ parser.add_argument("-ftl", dest="ftl_path", required=False, help="Path to the file to watch")
+ parser.add_argument("-track-ftl", dest="watch", required=False, help="Path to the file to watch")
+ parser.add_argument("-dir-ftl", dest="dir_path", required=False, help="Path to the directory to watch")
+ parser.add_argument("-stub", dest="output_file", required=False, help="Path to the output file")
+
+ args = parser.parse_args()
+
+ if not args.output_file:
+ args.output_file = "fluentogram.pyi"
+
+ if args.watch:
+ # Start watching for changes
+ watch_files(args.output_file, args.file_path, args.dir_path)
+ else:
+ # Generate stubs once
+ generate(args.output_file, args.file_path, args.dir_path)
diff --git a/fluentogram/exceptions.py b/fluentogram/exceptions.py
new file mode 100644
index 0000000..cf9a3d5
--- /dev/null
+++ b/fluentogram/exceptions.py
@@ -0,0 +1,31 @@
+class FluentogramError(Exception):
+ pass
+
+
+class RootTranslatorNotFoundError(FluentogramError):
+ def __init__(self, root_locale: str) -> None:
+ self.root_locale = root_locale
+ super().__init__(
+ f"TranslatorHub does not have a root locale translator. "
+ f"Root locale is {self.root_locale!r}, provide translator with this locale.",
+ )
+
+
+class KeyNotFoundError(FluentogramError):
+ def __init__(self, key: str) -> None:
+ self.key = key
+ super().__init__(f"Key {self.key!r} not found in translators")
+
+
+class FormatError(FluentogramError):
+ def __init__(self, original_error: Exception, key: str) -> None:
+ self.original_error = original_error
+ self.key = key
+ super().__init__(f"Error formatting key: {self.key!r}: {self.original_error!r}")
+
+
+class LocalesNotFoundError(FluentogramError):
+ def __init__(self, locales: list[str], path: str) -> None:
+ self.locales = locales
+ self.path = path
+ super().__init__(f"No locales found in {self.path}")
diff --git a/fluentogram/exceptions/__init__.py b/fluentogram/exceptions/__init__.py
deleted file mode 100644
index 32923ea..0000000
--- a/fluentogram/exceptions/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-# coding=utf-8
-from .root_locale_translator import NotImplementedRootLocaleTranslator
-
-
-__all__ = ["NotImplementedRootLocaleTranslator", ]
diff --git a/fluentogram/exceptions/root_locale_translator.py b/fluentogram/exceptions/root_locale_translator.py
deleted file mode 100644
index 350e045..0000000
--- a/fluentogram/exceptions/root_locale_translator.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# coding=utf-8
-"""Custom exception, raises when main, core translator does not exist"""
-
-
-class NotImplementedRootLocaleTranslator(Exception):
- """
- This exception is raised when TranslatorHub has no translator for root locale and being impossible to work.
- """
-
- def __init__(self, root_locale) -> None:
- super().__init__(
- f"""\n
- You do not have a root locale translator.
- Root locale is "{root_locale}"
- Please, fix it!
- Just provide the data!
- """
- )
diff --git a/fluentogram/misc/__init__.py b/fluentogram/misc/__init__.py
deleted file mode 100644
index bdb5da0..0000000
--- a/fluentogram/misc/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-# coding=utf-8
-from .timezones import timezones
-
-__all__ = ["timezones"]
diff --git a/fluentogram/misc/timezones.py b/fluentogram/misc/timezones.py
deleted file mode 100644
index 12cf932..0000000
--- a/fluentogram/misc/timezones.py
+++ /dev/null
@@ -1,445 +0,0 @@
-# coding=utf-8
-"""A timezones typing"""
-from typing import Literal
-
-timezones = Literal[
- "Africa/Abidjan",
- "Africa/Accra",
- "Africa/Addis_Ababa",
- "Africa/Algiers",
- "Africa/Asmara",
- "Africa/Bamako",
- "Africa/Bangui",
- "Africa/Banjul",
- "Africa/Bissau",
- "Africa/Blantyre",
- "Africa/Brazzaville",
- "Africa/Bujumbura",
- "Africa/Cairo",
- "Africa/Casablanca",
- "Africa/Ceuta",
- "Africa/Conakry",
- "Africa/Dakar",
- "Africa/Dar_es_Salaam",
- "Africa/Djibouti",
- "Africa/Douala",
- "Africa/El_Aaiun",
- "Africa/Freetown",
- "Africa/Gaborone",
- "Africa/Harare",
- "Africa/Johannesburg",
- "Africa/Juba",
- "Africa/Kampala",
- "Africa/Khartoum",
- "Africa/Kigali",
- "Africa/Kinshasa",
- "Africa/Lagos",
- "Africa/Libreville",
- "Africa/Lome",
- "Africa/Luanda",
- "Africa/Lubumbashi",
- "Africa/Lusaka",
- "Africa/Malabo",
- "Africa/Maputo",
- "Africa/Maseru",
- "Africa/Mbabane",
- "Africa/Mogadishu",
- "Africa/Monrovia",
- "Africa/Nairobi",
- "Africa/Ndjamena",
- "Africa/Niamey",
- "Africa/Nouakchott",
- "Africa/Ouagadougou",
- "Africa/Porto-Novo",
- "Africa/Sao_Tome",
- "Africa/Tripoli",
- "Africa/Tunis",
- "Africa/Windhoek",
- "America/Adak",
- "America/Anchorage",
- "America/Anguilla",
- "America/Antigua",
- "America/Araguaina",
- "America/Argentina/Buenos_Aires",
- "America/Argentina/Catamarca",
- "America/Argentina/Cordoba",
- "America/Argentina/Jujuy",
- "America/Argentina/La_Rioja",
- "America/Argentina/Mendoza",
- "America/Argentina/Rio_Gallegos",
- "America/Argentina/Salta",
- "America/Argentina/San_Juan",
- "America/Argentina/San_Luis",
- "America/Argentina/Tucuman",
- "America/Argentina/Ushuaia",
- "America/Aruba",
- "America/Asuncion",
- "America/Atikokan",
- "America/Bahia",
- "America/Bahia_Banderas",
- "America/Barbados",
- "America/Belem",
- "America/Belize",
- "America/Blanc-Sablon",
- "America/Boa_Vista",
- "America/Bogota",
- "America/Boise",
- "America/Cambridge_Bay",
- "America/Campo_Grande",
- "America/Cancun",
- "America/Caracas",
- "America/Cayenne",
- "America/Cayman",
- "America/Chicago",
- "America/Chihuahua",
- "America/Costa_Rica",
- "America/Creston",
- "America/Cuiaba",
- "America/Curacao",
- "America/Danmarkshavn",
- "America/Dawson",
- "America/Dawson_Creek",
- "America/Denver",
- "America/Detroit",
- "America/Dominica",
- "America/Edmonton",
- "America/Eirunepe",
- "America/El_Salvador",
- "America/Fort_Nelson",
- "America/Fortaleza",
- "America/Glace_Bay",
- "America/Goose_Bay",
- "America/Grand_Turk",
- "America/Grenada",
- "America/Guadeloupe",
- "America/Guatemala",
- "America/Guayaquil",
- "America/Guyana",
- "America/Halifax",
- "America/Havana",
- "America/Hermosillo",
- "America/Indiana/Indianapolis",
- "America/Indiana/Knox",
- "America/Indiana/Marengo",
- "America/Indiana/Petersburg",
- "America/Indiana/Tell_City",
- "America/Indiana/Vevay",
- "America/Indiana/Vincennes",
- "America/Indiana/Winamac",
- "America/Inuvik",
- "America/Iqaluit",
- "America/Jamaica",
- "America/Juneau",
- "America/Kentucky/Louisville",
- "America/Kentucky/Monticello",
- "America/Kralendijk",
- "America/La_Paz",
- "America/Lima",
- "America/Los_Angeles",
- "America/Lower_Princes",
- "America/Maceio",
- "America/Managua",
- "America/Manaus",
- "America/Marigot",
- "America/Martinique",
- "America/Matamoros",
- "America/Mazatlan",
- "America/Menominee",
- "America/Merida",
- "America/Metlakatla",
- "America/Mexico_City",
- "America/Miquelon",
- "America/Moncton",
- "America/Monterrey",
- "America/Montevideo",
- "America/Montserrat",
- "America/Nassau",
- "America/New_York",
- "America/Nipigon",
- "America/Nome",
- "America/Noronha",
- "America/North_Dakota/Beulah",
- "America/North_Dakota/Center",
- "America/North_Dakota/New_Salem",
- "America/Nuuk",
- "America/Ojinaga",
- "America/Panama",
- "America/Pangnirtung",
- "America/Paramaribo",
- "America/Phoenix",
- "America/Port-au-Prince",
- "America/Port_of_Spain",
- "America/Porto_Velho",
- "America/Puerto_Rico",
- "America/Punta_Arenas",
- "America/Rainy_River",
- "America/Rankin_Inlet",
- "America/Recife",
- "America/Regina",
- "America/Resolute",
- "America/Rio_Branco",
- "America/Santarem",
- "America/Santiago",
- "America/Santo_Domingo",
- "America/Sao_Paulo",
- "America/Scoresbysund",
- "America/Sitka",
- "America/St_Barthelemy",
- "America/St_Johns",
- "America/St_Kitts",
- "America/St_Lucia",
- "America/St_Thomas",
- "America/St_Vincent",
- "America/Swift_Current",
- "America/Tegucigalpa",
- "America/Thule",
- "America/Thunder_Bay",
- "America/Tijuana",
- "America/Toronto",
- "America/Tortola",
- "America/Vancouver",
- "America/Whitehorse",
- "America/Winnipeg",
- "America/Yakutat",
- "America/Yellowknife",
- "Antarctica/Casey",
- "Antarctica/Davis",
- "Antarctica/DumontDUrville",
- "Antarctica/Macquarie",
- "Antarctica/Mawson",
- "Antarctica/McMurdo",
- "Antarctica/Palmer",
- "Antarctica/Rothera",
- "Antarctica/Syowa",
- "Antarctica/Troll",
- "Antarctica/Vostok",
- "Arctic/Longyearbyen",
- "Asia/Aden",
- "Asia/Almaty",
- "Asia/Amman",
- "Asia/Anadyr",
- "Asia/Aqtau",
- "Asia/Aqtobe",
- "Asia/Ashgabat",
- "Asia/Atyrau",
- "Asia/Baghdad",
- "Asia/Bahrain",
- "Asia/Baku",
- "Asia/Bangkok",
- "Asia/Barnaul",
- "Asia/Beirut",
- "Asia/Bishkek",
- "Asia/Brunei",
- "Asia/Chita",
- "Asia/Choibalsan",
- "Asia/Colombo",
- "Asia/Damascus",
- "Asia/Dhaka",
- "Asia/Dili",
- "Asia/Dubai",
- "Asia/Dushanbe",
- "Asia/Famagusta",
- "Asia/Gaza",
- "Asia/Hebron",
- "Asia/Ho_Chi_Minh",
- "Asia/Hong_Kong",
- "Asia/Hovd",
- "Asia/Irkutsk",
- "Asia/Jakarta",
- "Asia/Jayapura",
- "Asia/Jerusalem",
- "Asia/Kabul",
- "Asia/Kamchatka",
- "Asia/Karachi",
- "Asia/Kathmandu",
- "Asia/Khandyga",
- "Asia/Kolkata",
- "Asia/Krasnoyarsk",
- "Asia/Kuala_Lumpur",
- "Asia/Kuching",
- "Asia/Kuwait",
- "Asia/Macau",
- "Asia/Magadan",
- "Asia/Makassar",
- "Asia/Manila",
- "Asia/Muscat",
- "Asia/Nicosia",
- "Asia/Novokuznetsk",
- "Asia/Novosibirsk",
- "Asia/Omsk",
- "Asia/Oral",
- "Asia/Phnom_Penh",
- "Asia/Pontianak",
- "Asia/Pyongyang",
- "Asia/Qatar",
- "Asia/Qostanay",
- "Asia/Qyzylorda",
- "Asia/Riyadh",
- "Asia/Sakhalin",
- "Asia/Samarkand",
- "Asia/Seoul",
- "Asia/Shanghai",
- "Asia/Singapore",
- "Asia/Srednekolymsk",
- "Asia/Taipei",
- "Asia/Tashkent",
- "Asia/Tbilisi",
- "Asia/Tehran",
- "Asia/Thimphu",
- "Asia/Tokyo",
- "Asia/Tomsk",
- "Asia/Ulaanbaatar",
- "Asia/Urumqi",
- "Asia/Ust-Nera",
- "Asia/Vientiane",
- "Asia/Vladivostok",
- "Asia/Yakutsk",
- "Asia/Yangon",
- "Asia/Yekaterinburg",
- "Asia/Yerevan",
- "Atlantic/Azores",
- "Atlantic/Bermuda",
- "Atlantic/Canary",
- "Atlantic/Cape_Verde",
- "Atlantic/Faroe",
- "Atlantic/Madeira",
- "Atlantic/Reykjavik",
- "Atlantic/South_Georgia",
- "Atlantic/St_Helena",
- "Atlantic/Stanley",
- "Australia/Adelaide",
- "Australia/Brisbane",
- "Australia/Broken_Hill",
- "Australia/Darwin",
- "Australia/Eucla",
- "Australia/Hobart",
- "Australia/Lindeman",
- "Australia/Lord_Howe",
- "Australia/Melbourne",
- "Australia/Perth",
- "Australia/Sydney",
- "Canada/Atlantic",
- "Canada/Central",
- "Canada/Eastern",
- "Canada/Mountain",
- "Canada/Newfoundland",
- "Canada/Pacific",
- "Europe/Amsterdam",
- "Europe/Andorra",
- "Europe/Astrakhan",
- "Europe/Athens",
- "Europe/Belgrade",
- "Europe/Berlin",
- "Europe/Bratislava",
- "Europe/Brussels",
- "Europe/Bucharest",
- "Europe/Budapest",
- "Europe/Busingen",
- "Europe/Chisinau",
- "Europe/Copenhagen",
- "Europe/Dublin",
- "Europe/Gibraltar",
- "Europe/Guernsey",
- "Europe/Helsinki",
- "Europe/Isle_of_Man",
- "Europe/Istanbul",
- "Europe/Jersey",
- "Europe/Kaliningrad",
- "Europe/Kiev",
- "Europe/Kirov",
- "Europe/Lisbon",
- "Europe/Ljubljana",
- "Europe/London",
- "Europe/Luxembourg",
- "Europe/Madrid",
- "Europe/Malta",
- "Europe/Mariehamn",
- "Europe/Minsk",
- "Europe/Monaco",
- "Europe/Moscow",
- "Europe/Oslo",
- "Europe/Paris",
- "Europe/Podgorica",
- "Europe/Prague",
- "Europe/Riga",
- "Europe/Rome",
- "Europe/Samara",
- "Europe/San_Marino",
- "Europe/Sarajevo",
- "Europe/Saratov",
- "Europe/Simferopol",
- "Europe/Skopje",
- "Europe/Sofia",
- "Europe/Stockholm",
- "Europe/Tallinn",
- "Europe/Tirane",
- "Europe/Ulyanovsk",
- "Europe/Uzhgorod",
- "Europe/Vaduz",
- "Europe/Vatican",
- "Europe/Vienna",
- "Europe/Vilnius",
- "Europe/Volgograd",
- "Europe/Warsaw",
- "Europe/Zagreb",
- "Europe/Zaporozhye",
- "Europe/Zurich",
- "GMT",
- "Indian/Antananarivo",
- "Indian/Chagos",
- "Indian/Christmas",
- "Indian/Cocos",
- "Indian/Comoro",
- "Indian/Kerguelen",
- "Indian/Mahe",
- "Indian/Maldives",
- "Indian/Mauritius",
- "Indian/Mayotte",
- "Indian/Reunion",
- "Pacific/Apia",
- "Pacific/Auckland",
- "Pacific/Bougainville",
- "Pacific/Chatham",
- "Pacific/Chuuk",
- "Pacific/Easter",
- "Pacific/Efate",
- "Pacific/Fakaofo",
- "Pacific/Fiji",
- "Pacific/Funafuti",
- "Pacific/Galapagos",
- "Pacific/Gambier",
- "Pacific/Guadalcanal",
- "Pacific/Guam",
- "Pacific/Honolulu",
- "Pacific/Kanton",
- "Pacific/Kiritimati",
- "Pacific/Kosrae",
- "Pacific/Kwajalein",
- "Pacific/Majuro",
- "Pacific/Marquesas",
- "Pacific/Midway",
- "Pacific/Nauru",
- "Pacific/Niue",
- "Pacific/Norfolk",
- "Pacific/Noumea",
- "Pacific/Pago_Pago",
- "Pacific/Palau",
- "Pacific/Pitcairn",
- "Pacific/Pohnpei",
- "Pacific/Port_Moresby",
- "Pacific/Rarotonga",
- "Pacific/Saipan",
- "Pacific/Tahiti",
- "Pacific/Tarawa",
- "Pacific/Tongatapu",
- "Pacific/Wake",
- "Pacific/Wallis",
- "US/Alaska",
- "US/Arizona",
- "US/Central",
- "US/Eastern",
- "US/Hawaii",
- "US/Mountain",
- "US/Pacific",
- "UTC",
-]
diff --git a/fluentogram/nats/__init__.py b/fluentogram/nats/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/fluentogram/nats/hub.py b/fluentogram/nats/hub.py
new file mode 100644
index 0000000..1c63da0
--- /dev/null
+++ b/fluentogram/nats/hub.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+import warnings
+from typing import Any
+
+from fluentogram.nats.storage import NatsKvStorage
+from fluentogram.translator_hub import TranslatorHub
+
+
+class KvTranslatorHub(TranslatorHub):
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ warnings.warn(
+ "KvTranslatorHub is deprecated and will be removed in 2.0.0 version. "
+ "Use TranslatorHub with NatsStorage instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ super().__init__(*args, **kwargs)
+
+ async def from_storage(self, kv_storage: NatsKvStorage) -> None:
+ old_storage = self.storage
+ locales_map = old_storage.get_locales_map()
+ translators = old_storage.get_all_translators()
+
+ self.storage = kv_storage
+ self.storage.add_translators(translators)
+ self.storage.set_locales_map(locales_map)
+
+ async def put(
+ self,
+ locale: str,
+ key: str | None = None,
+ value: Any | None = None,
+ mapping_values: dict[str, Any] | None = None,
+ ) -> None:
+ if mapping_values is not None:
+ for k, v in mapping_values.items():
+ await self.put(locale=locale, key=k, value=v)
+
+ if key is not None and value is not None:
+ await self.storage.update_translation(
+ locale=locale,
+ key=key,
+ value=value,
+ )
+
+ async def create(
+ self,
+ locale: str,
+ key: str,
+ value: Any,
+ mapping_values: dict[str, Any] | None = None,
+ ) -> None:
+ await self.put(locale=locale, key=key, value=value, mapping_values=mapping_values)
+
+ async def delete(self, _: str, *__: str) -> None:
+ pass
diff --git a/fluentogram/nats/mock.py b/fluentogram/nats/mock.py
new file mode 100644
index 0000000..b45bcb2
--- /dev/null
+++ b/fluentogram/nats/mock.py
@@ -0,0 +1,11 @@
+from typing import Any
+
+
+class NatsStorageMock:
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ raise NotImplementedError("NatsStorage is not implemented")
+
+
+class KvTranslatorHubMock:
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ raise NotImplementedError("KvTranslatorHub is not implemented")
diff --git a/fluentogram/nats/storage.py b/fluentogram/nats/storage.py
new file mode 100644
index 0000000..396af8e
--- /dev/null
+++ b/fluentogram/nats/storage.py
@@ -0,0 +1,137 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from collections import defaultdict
+from contextlib import suppress
+from typing import Any, Callable
+
+from nats import connect
+from nats.aio.msg import Msg
+from nats.js import JetStreamContext
+from nats.js.api import KeyValueConfig
+from nats.js.kv import KV_DEL, KV_OP, KV_PURGE, KeyValue
+
+from fluentogram.storage.base import BaseStorage
+
+_JsonLoads = Callable[..., Any]
+_JsonDumps = Callable[..., bytes]
+
+logger = logging.getLogger(__name__)
+
+
+class NatsKvStorage(BaseStorage):
+ def __init__( # noqa: PLR0913
+ self,
+ kv: KeyValue,
+ js: JetStreamContext,
+ separator: str = ".",
+ serializer: _JsonDumps = lambda data: json.dumps(data).encode("utf-8"),
+ deserializer: _JsonLoads = json.loads,
+ consume_timeout: float = 1.0,
+ ) -> None:
+ self._js = js
+ self._nc = js._nc # noqa: SLF001
+ self._kv = kv
+ self._stream_name = kv._stream # noqa: SLF001
+ self.separator = separator
+ self.serializer = serializer
+ self.deserializer = deserializer
+ self.consume_timeout = consume_timeout
+ self._listen_for_changes_task: asyncio.Task | None = None
+ self._listen_for_changes_task = asyncio.create_task(self.listen_for_changes())
+ self._stop_event = asyncio.Event()
+ super().__init__()
+
+ @classmethod
+ async def from_servers(
+ cls,
+ servers: list[str],
+ kv_config: KeyValueConfig,
+ separator: str = ".",
+ serializer: _JsonDumps = lambda data: json.dumps(data).encode("utf-8"),
+ deserializer: _JsonLoads = json.loads,
+ ) -> NatsKvStorage:
+ nc = await connect(servers=servers)
+ js = nc.jetstream()
+ kv = await js.create_key_value(config=kv_config)
+ return cls(kv, js, separator, serializer, deserializer)
+
+ async def update_translation(
+ self,
+ locale: str,
+ key: str,
+ value: str,
+ ) -> None:
+ """Put translation to NATS KV store."""
+ await self._kv.put(f"{locale}{self.separator}{key}", self.serializer(value))
+
+ async def _create_consumer(self) -> JetStreamContext.PullSubscription:
+ stream = await self._js.stream_info(self._stream_name)
+ stream_name = stream.config.name
+ if stream_name is None:
+ raise ValueError("Stream name is None")
+ subject_name = stream_name.replace("_", self.separator, 1)
+ subject = f"${subject_name}.>"
+ return await self._js.pull_subscribe(subject=subject, stream=stream_name)
+
+ async def _process_messages(self, consumer: JetStreamContext.PullSubscription) -> None:
+ """Process messages from the consumer."""
+ try:
+ messages: list[Msg] = await consumer.fetch(50, timeout=self.consume_timeout)
+ if not messages:
+ return
+ logger.debug("Received %d messages: %s", len(messages), messages)
+ await self._update_compiled_messages(messages)
+ except TimeoutError:
+ pass
+ except Exception as e:
+ if not self._stop_event.is_set():
+ logger.exception("Error in listen_for_changes: %s", exc_info=e)
+
+ async def listen_for_changes(self) -> None:
+ """Listen for changes in NATS KV store and update local storage."""
+ consumer = await self._create_consumer()
+ while not self._stop_event.is_set():
+ await self._process_messages(consumer)
+
+ async def _update_compiled_messages(self, messages: list[Msg]) -> None:
+ """Update compiled messages based on NATS KV changes."""
+ changes = defaultdict(list)
+ for m in messages:
+ kind = m.headers.get(KV_OP) if m.headers is not None else None
+ *_, locale, key = m.subject.split(self.separator)
+ if kind in (KV_DEL, KV_PURGE):
+ # Remove translation from local storage
+ translator = self._storage.get(locale)
+ if translator:
+ logger.debug("Removing translation: %s", key)
+ else:
+ value = self.deserializer(m.data)
+ changes[locale].append((key, value))
+ await m.ack()
+ self._set_new_compiled_messages(changes)
+
+ def _set_new_compiled_messages(self, new_messages: dict[str, list[str]]) -> None:
+ """Set new compiled messages for translators."""
+ for locale, messages in new_messages.items():
+ translator = self._storage.get(locale)
+ if translator is None:
+ continue
+
+ for key, value in messages:
+ translator.update_translation(key, value)
+
+ async def close(self) -> None:
+ """Close the storage."""
+ self._stop_event.set()
+ if self._listen_for_changes_task is not None:
+ self._listen_for_changes_task.cancel()
+ with suppress(asyncio.CancelledError):
+ await self._listen_for_changes_task
+
+ await self._nc.close()
+
+
+NatsStorage = NatsKvStorage # backward compatibility
diff --git a/fluentogram/runner.py b/fluentogram/runner.py
new file mode 100644
index 0000000..d8eaca7
--- /dev/null
+++ b/fluentogram/runner.py
@@ -0,0 +1,35 @@
+"""A translator runner by itself"""
+
+from collections.abc import Iterable
+from typing import Any
+
+from fluentogram.exceptions import KeyNotFoundError
+from fluentogram.translator import FluentTranslator
+
+
+class TranslatorRunner:
+ def __init__(self, translators: Iterable[FluentTranslator], separator: str = "-") -> None:
+ self.translators = translators
+ self.separator = separator
+ self._request_line = ""
+
+ def get(self, key: str, **kwargs: Any) -> str:
+ """Fastest, direct way to use translator, without sugar-like typing supported attribute access way"""
+ return self._get_translation(key, **kwargs)
+
+ def _get_translation(self, key: str, **kwargs: Any) -> str:
+ for translator in self.translators:
+ text = translator.get(key, **kwargs)
+ if text is not None:
+ return text
+
+ raise KeyNotFoundError(key)
+
+ def __getattr__(self, item: str) -> "TranslatorRunner":
+ self._request_line += f"{item}{self.separator}"
+ return self
+
+ def __call__(self, **kwargs: Any) -> str:
+ text = self._get_translation(self._request_line.rstrip(self.separator), **kwargs)
+ self._request_line = ""
+ return text
diff --git a/fluentogram/src/__init__.py b/fluentogram/src/__init__.py
deleted file mode 100644
index df424ed..0000000
--- a/fluentogram/src/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-# coding=utf-8
-from . import abc
-from . import impl
-
-
-__all__ = ["abc", "impl", ]
diff --git a/fluentogram/src/abc/__init__.py b/fluentogram/src/abc/__init__.py
deleted file mode 100644
index 4db1d9b..0000000
--- a/fluentogram/src/abc/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-# coding=utf-8
-from .misc import AbstractAttribTracer
-from .transformer import AbstractDataTransformer
-from .translator import AbstractTranslator
-from .translator_hub import AbstractTranslatorsHub
-
-
-__all__ = [
- "AbstractAttribTracer",
- "AbstractDataTransformer",
- "AbstractTranslator",
- "AbstractTranslatorsHub",
-]
diff --git a/fluentogram/src/abc/misc.py b/fluentogram/src/abc/misc.py
deleted file mode 100644
index 1f48458..0000000
--- a/fluentogram/src/abc/misc.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# coding=utf-8
-"""
-Some miscellaneous
-"""
-
-from abc import ABC, abstractmethod
-
-
-class AbstractAttribTracer(ABC):
- """Implements a mechanism for tracing attributes access way.
-
- Like a pretty simple, external-typing supported version of the translator.get("some-key-for-translation")
-
- Equivalent to obj.some.key.for.translation(**some_kwargs)
- """
-
- @abstractmethod
- def __init__(self) -> None:
- self.request_line = ""
-
- @abstractmethod
- def _get_request_line(self) -> str:
- request_line = self.request_line
- self.request_line = ""
- return request_line
-
- @abstractmethod
- def __getattr__(self, item) -> 'AbstractAttribTracer':
- """
- This method exists to map the "obj.attrib1.attrib2" access to "attrib1-attrib2" key.
- """
- self.request_line += f"{item}{self.separator}"
- return self
diff --git a/fluentogram/src/abc/runner.py b/fluentogram/src/abc/runner.py
deleted file mode 100644
index 2d8f049..0000000
--- a/fluentogram/src/abc/runner.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# coding=utf-8
-"""
-An abstract translator runner
-"""
-from abc import ABC, abstractmethod
-
-from fluentogram.src.abc import AbstractAttribTracer
-
-
-class AbstractTranslatorRunner(AbstractAttribTracer, ABC):
- """This is one-shot per Telegram event translator with attrib tracer access way."""
-
- @abstractmethod
- def get(self, key: str, **kwargs) -> str:
- """Fastest, direct way to use translator, without sugar-like typing supported attribute access way"""
-
- @abstractmethod
- def _get_translation(self, key, **kwargs) -> str:
- ...
-
- @abstractmethod
- def __call__(self, **kwargs) -> str:
- ...
-
- @abstractmethod
- def __getattr__(self, item: str) -> 'AbstractTranslatorRunner':
- ...
diff --git a/fluentogram/src/abc/storage.py b/fluentogram/src/abc/storage.py
deleted file mode 100644
index 7400fc3..0000000
--- a/fluentogram/src/abc/storage.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# coding=utf-8
-"""
-An abstract base for the Storage object
-"""
-from abc import ABC, abstractmethod
-from typing import Any
-
-
-class AbstractStorage(ABC):
-
- @abstractmethod
- def __init__(self, kv, *args, **kwargs):
- raise NotImplementedError
-
- @abstractmethod
- def put(self, locale: str, key: str, value: Any, mapping_values: dict[str, Any]):
- """Creates or replaces an existing key/value"""
- raise NotImplementedError
-
- @abstractmethod
- def create(self, locale: str, key: str, value: Any, mapping_values: dict[str, Any]):
- """Create will add the key/value pair iff it does not exist."""
- raise NotImplementedError
-
- @abstractmethod
- def delete(self, locale: str, *keys):
- """Deletes all transmitted keys"""
- raise NotImplementedError
-
- @abstractmethod
- def listen(self, messages: dict[str, dict]) -> None:
- """Listen for new keys/values and updates them in TranslatorHub"""
- raise NotImplementedError
\ No newline at end of file
diff --git a/fluentogram/src/abc/translator.py b/fluentogram/src/abc/translator.py
deleted file mode 100644
index 225c59a..0000000
--- a/fluentogram/src/abc/translator.py
+++ /dev/null
@@ -1,25 +0,0 @@
-# coding=utf-8
-"""
-Translator as itself
-"""
-from abc import ABC, abstractmethod
-from typing import Any
-
-
-class AbstractTranslator(ABC):
- """A translator class, implements key-value interface for your translator mechanism."""
-
- @abstractmethod
- def __init__(self, locale: str, translator: Any, separator: str = "-") -> None:
- self.locale = locale
- self.separator = separator
- self.translator = translator
-
- @abstractmethod
- def get(self, key: str, **kwargs) -> str:
- """
- Convert a translation key to a translated text string.
- Use kwargs dict to pass external data to the translator.
- Expects to be fast and furious.
- """
- raise NotImplementedError
diff --git a/fluentogram/src/abc/translator_hub.py b/fluentogram/src/abc/translator_hub.py
deleted file mode 100644
index b3d4591..0000000
--- a/fluentogram/src/abc/translator_hub.py
+++ /dev/null
@@ -1,50 +0,0 @@
-# coding=utf-8
-"""
-An abstract base for the Translator Hub and Key/Value Translator Hub objects
-"""
-import sys
-from abc import ABC, abstractmethod
-if sys.version_info >= (3, 11):
- from typing import Self, Any
-else:
- from typing import Any
- from typing_extensions import Self
-
-from fluentogram.src.abc.runner import AbstractTranslatorRunner
-from fluentogram.src.abc.storage import AbstractStorage
-
-
-class AbstractTranslatorsHub(ABC):
- """This class should contain a couple of translator objects, usually one object per one locale."""
-
- @abstractmethod
- def __init__(self):
- raise NotImplementedError
-
- @abstractmethod
- def get_translator_by_locale(self, locale: str) -> AbstractTranslatorRunner:
- """
- Returns a Translator object by selected locale
- """
- raise NotImplementedError
-
-class AbstractKvTranslatorHub(AbstractTranslatorsHub, ABC):
- @abstractmethod
- def from_storage(self, kv_storage: AbstractStorage) -> Self:
- """
- Initializes the Translator Hub with the provided storage
- """
- raise NotImplementedError
-
- @abstractmethod
- def put(self, locale: str, key: str, value: Any, mapping_values: dict[str, Any]):
- raise NotImplementedError
-
- @abstractmethod
- def create(self, locale: str, key: str, value: Any, mapping_values: dict[str, Any]):
- raise NotImplementedError
-
- @abstractmethod
- def delete(self, locale: str, *keys):
- raise NotImplementedError
-
diff --git a/fluentogram/src/impl/__init__.py b/fluentogram/src/impl/__init__.py
deleted file mode 100644
index 32cf323..0000000
--- a/fluentogram/src/impl/__init__.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# coding=utf-8
-from .attrib_tracer import AttribTracer
-from .translator import FluentTranslator
-from .runner import TranslatorRunner
-from .transformers import MoneyTransformer, DateTimeTransformer
-from .transator_hubs.translator_hub import TranslatorHub
-from .transator_hubs.kv_translator_hub import KvTranslatorHub
-from .storages import NatsStorage
-
-__all__ = [
- "AttribTracer",
- "DateTimeTransformer",
- "FluentTranslator",
- "MoneyTransformer",
- "TranslatorRunner",
- "NatsStorage",
- "TranslatorHub",
- "KvTranslatorHub",
-]
diff --git a/fluentogram/src/impl/attrib_tracer.py b/fluentogram/src/impl/attrib_tracer.py
deleted file mode 100644
index 445f516..0000000
--- a/fluentogram/src/impl/attrib_tracer.py
+++ /dev/null
@@ -1,25 +0,0 @@
-# coding=utf-8
-"""
-An AttribTracer implementation
-"""
-
-from fluentogram.src.abc import AbstractAttribTracer
-
-
-class AttribTracer(AbstractAttribTracer):
- """Attribute tracer class for obj.attrib1.attrib2 access"""
-
- def __init__(self) -> None:
- self.request_line = ""
-
- def _get_request_line(self) -> str:
- request_line = self.request_line
- self.request_line = ""
- return request_line
-
- def __getattr__(self, item) -> 'AttribTracer':
- """
- This method exists to map the "obj.attrib1.attrib2" access to "attrib1-attrib2" key.
- """
- self.request_line += f"{item}{self.separator}"
- return self
diff --git a/fluentogram/src/impl/filter.py b/fluentogram/src/impl/filter.py
deleted file mode 100644
index 58aa83a..0000000
--- a/fluentogram/src/impl/filter.py
+++ /dev/null
@@ -1,62 +0,0 @@
-from typing import Optional, cast, Any
-
-from aiogram.filters import BaseFilter
-from aiogram.types import Message
-from fluentogram import TranslatorRunner
-
-
-DEFAULT_TRANSLATOR_KEY = "i18n"
-
-
-class FText(BaseFilter):
- translator_key = DEFAULT_TRANSLATOR_KEY
-
- def __init__(
- self, *,
- equals: Optional[str] = None,
- contains: Optional[str] = None,
- startswith: Optional[str] = None,
- endswith: Optional[str] = None,
- ignore_case: bool = False,
- translator_key: Optional[str] = None
- ) -> None:
- """
- Fluentogram text filter in the spirit of the deprecated aiogram Text filter.
-
- Usage example:
- @router.message(FText(equals="command-help"))
- async def helpCommand(message: Message, i18n: TranslatorRunner) -> TelegramMethod[Any]:
- return message.answer(i18n.help())
- """
- self.equals = equals
- self.contains = contains
- self.startswith = startswith
- self.endswith = endswith
- self.ignore_case = ignore_case
-
- if translator_key:
- self.translator_key = translator_key
-
- async def __call__(
- self, event: Message, **data: Any
- ) -> bool:
- text = event.text or event.caption
- if text is not None:
- i18n: Optional[TranslatorRunner] = data.get(self.translator_key)
- if i18n is None:
- raise RuntimeError(
- f"TranslatorRunner not found for key '{self.translator_key}'."
- )
- if self.ignore_case:
- text = text.casefold()
-
- if self.equals:
- return cast(bool, text == i18n.get(self.equals)) # lmao idk what he wants but mypy can't see bool here
- if self.contains:
- return i18n.get(self.contains) in text
- if self.startswith:
- return text.startswith(i18n.get(self.startswith))
- if self.endswith:
- return text.endswith(i18n.get(self.endswith))
-
- return False
diff --git a/fluentogram/src/impl/runner.py b/fluentogram/src/impl/runner.py
deleted file mode 100644
index 79e5a5e..0000000
--- a/fluentogram/src/impl/runner.py
+++ /dev/null
@@ -1,39 +0,0 @@
-# coding=utf-8
-"""
-A translator runner by itself
-"""
-from typing import Iterable
-
-from fluentogram.src.abc import AbstractTranslator
-from fluentogram.src.abc.runner import AbstractTranslatorRunner
-from fluentogram.src.impl import AttribTracer
-
-
-class TranslatorRunner(AbstractTranslatorRunner, AttribTracer):
- """This is one-shot per Telegram event translator with attrib tracer access way."""
-
- def __init__(self, translators: Iterable[AbstractTranslator], separator: str = "-") -> None:
- super().__init__()
- self.translators = translators
- self.separator = separator
- self.request_line = ""
-
- def get(self, key: str, **kwargs) -> str:
- """Fastest, direct way to use translator, without sugar-like typing supported attribute access way"""
- return self._get_translation(key, **kwargs)
-
- def _get_translation(self, key, **kwargs):
- for translator in self.translators:
- try:
- return translator.get(key, **kwargs)
- except KeyError:
- continue
-
- def __call__(self, **kwargs) -> str:
- text = self._get_translation(self.request_line[:-1], **kwargs)
- self.request_line = ""
- return text
-
- def __getattr__(self, item: str) -> 'TranslatorRunner':
- self.request_line += f"{item}{self.separator}"
- return self
diff --git a/fluentogram/src/impl/storages/__init__.py b/fluentogram/src/impl/storages/__init__.py
deleted file mode 100644
index d0372c4..0000000
--- a/fluentogram/src/impl/storages/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .nats_storage import NatsStorage
\ No newline at end of file
diff --git a/fluentogram/src/impl/storages/nats_storage.py b/fluentogram/src/impl/storages/nats_storage.py
deleted file mode 100644
index c8bacd1..0000000
--- a/fluentogram/src/impl/storages/nats_storage.py
+++ /dev/null
@@ -1,134 +0,0 @@
-# coding=utf-8
-"""
-NATS-based storage
-"""
-import asyncio
-import json
-import sys
-from collections import defaultdict
-if sys.version_info >= (3, 10):
- from typing import Any, NoReturn, TypeAlias, Optional
-else:
- from typing import Any, NoReturn, Optional
- from typing_extensions import TypeAlias
-
-from fluent_compiler.compiler import compile_messages
-from fluent_compiler.resource import FtlResource
-from nats.aio.msg import Msg
-from nats.js import JetStreamContext
-from nats.js.kv import KeyValue, KV_OP, KV_DEL, KV_PURGE
-
-from fluentogram.src.abc.storage import AbstractStorage
-
-KeyType: TypeAlias = Optional[str]
-ValueType: TypeAlias = Optional[Any]
-MappingValuesType: TypeAlias = Optional[dict[KeyType, ValueType]]
-
-
-class NatsStorage(AbstractStorage):
- def __init__(
- self,
- kv: KeyValue,
- js: JetStreamContext,
- separator: str = '.',
- serializer=lambda data: json.dumps(data).encode('utf-8'),
- deserializer=json.loads,
- consume_timeout: float = 1.0
- ):
- self._kv = kv
- self._js = js
- self.separator = separator
- self.messages = None
- self.serializer = serializer
- self.deserializer = deserializer
- self.consume_timeout = consume_timeout
-
- async def put(
- self,
- locale: str,
- key: KeyType,
- value: ValueType,
- mapping_values: MappingValuesType
- ):
- await self._interaction(
- func=self._kv.put,
- locale=locale,
- key=key,
- value=value,
- mapping_values=mapping_values
- )
-
- async def create(
- self,
- locale: str,
- key: KeyType,
- value: ValueType,
- mapping_values: MappingValuesType
- ):
- await self._interaction(
- func=self._kv.create,
- locale=locale,
- key=key,
- value=value,
- mapping_values=mapping_values
- )
-
- async def delete(self, locale: str, *keys):
- await asyncio.gather(
- *[
- self._kv.purge(f'{locale}{self.separator}{key}')
- for key in keys
- ]
- )
-
- async def _interaction(
- self,
- func,
- locale: str,
- key: KeyType,
- value: ValueType,
- mapping_values: MappingValuesType
- ):
- if key and value:
- await func(f'{locale}{self.separator}{key}', self.serializer(value))
- if mapping_values:
- await asyncio.gather(
- *[
- func(f'{locale}{self.separator}{m_key}', self.serializer(m_value))
- for m_key, m_value in mapping_values.items()
- ]
- )
-
- async def listen(self, messages: dict[str, dict]) -> NoReturn:
- self.messages = messages
- stream = await self._js.stream_info(self._kv._stream)
- stream_name = stream.config.name
- subject_name = stream_name.replace("_", self.separator, 1)
- subject = f'${subject_name}.>'
- consumer = await self._js.pull_subscribe(subject=subject, stream=stream_name)
- while True:
- try:
- messages: list[Msg] = await consumer.fetch(50, timeout=self.consume_timeout)
- except TimeoutError:
- pass
- else:
- await self._update_compiled_messages(messages)
-
- async def _update_compiled_messages(self, messages: list[Msg]):
- changes = defaultdict(list)
- for m in messages:
- kind = m.headers.get(KV_OP) if m.headers is not None else None
- *args, locale, key = m.subject.split(self.separator)
- if kind in (KV_DEL, KV_PURGE):
- self.messages[locale].pop(key, None)
- else:
- value = self.deserializer(m.data)
- changes[locale].append(f'{key} = {value}')
- await m.ack()
- self._set_new_compiled_messages(changes)
-
- def _set_new_compiled_messages(self, new_messages: dict[str, list[str]]) -> None:
- for locale, messages in new_messages.items():
- resources = [FtlResource.from_string(message) for message in messages]
- compiled_ftl = compile_messages(locale, resources)
- self.messages[locale].update(compiled_ftl.message_functions)
diff --git a/fluentogram/src/impl/stubs_translator_runner.py b/fluentogram/src/impl/stubs_translator_runner.py
deleted file mode 100644
index ca7d713..0000000
--- a/fluentogram/src/impl/stubs_translator_runner.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from fluentogram import AttribTracer
-
-
-class StubsTranslatorRunner(AttribTracer):
- def __init__(self):
- super().__init__()
- self.kwargs = {}
-
- def __call__(self, **kwargs):
- out = self._get_request_line()[:-1], kwargs
- self.request_line = ""
- return out
diff --git a/fluentogram/src/impl/transator_hubs/__init__.py b/fluentogram/src/impl/transator_hubs/__init__.py
index 97fe9ec..d5b60e9 100644
--- a/fluentogram/src/impl/transator_hubs/__init__.py
+++ b/fluentogram/src/impl/transator_hubs/__init__.py
@@ -1,2 +1,3 @@
-from .kv_translator_hub import KvTranslatorHub
-from .translator_hub import TranslatorHub
+from fluentogram import KvTranslatorHub
+
+__all__ = ["KvTranslatorHub"]
diff --git a/fluentogram/src/impl/transator_hubs/kv_translator_hub.py b/fluentogram/src/impl/transator_hubs/kv_translator_hub.py
deleted file mode 100644
index 112c4b8..0000000
--- a/fluentogram/src/impl/transator_hubs/kv_translator_hub.py
+++ /dev/null
@@ -1,103 +0,0 @@
-# coding=utf-8
-"""
-A Key/Value Translator Hub, using as factory for Translator objects
-"""
-import asyncio
-
-from typing import Dict, Iterable, Union, Optional, List, Any
-
-from fluent_compiler.bundle import FluentBundle
-
-from fluentogram.exceptions import NotImplementedRootLocaleTranslator
-from fluentogram.src.abc import AbstractTranslator
-from fluentogram.src.abc.storage import AbstractStorage
-from fluentogram.src.abc.translator_hub import AbstractKvTranslatorHub
-from fluentogram.src.impl import FluentTranslator
-from fluentogram.src.impl.transator_hubs.translator_hub import TranslatorHub
-
-class KvTranslatorHub(TranslatorHub, AbstractKvTranslatorHub):
- def __init__(
- self,
- locales_map: Dict[str, Union[str, Iterable[str]]],
- translators: Optional[List[AbstractTranslator]] = None,
- root_locale: str = "en",
- separator: str = "-"
- ) -> None:
- self.kv_storage = None
- self.messages = None
- if translators is not None:
- super().__init__(locales_map, translators, root_locale, separator)
- return
-
- self.locales_map = dict(
- zip(
- locales_map.keys(),
- map(
- lambda lang: tuple([lang]) if isinstance(lang, str) else lang,
- locales_map.values()
- )
- )
- )
- self.translators = translators
- self.root_locale = root_locale
- self.separator = separator
- self.storage = None
- self.translators_map = None
-
- async def from_storage(self, kv_storage: AbstractStorage):
- self.kv_storage = kv_storage
- if self.translators is None:
- self.translators = self._create_translators()
-
- self.storage: Dict[str, AbstractTranslator] = dict(
- zip([translator.locale for translator in self.translators], self.translators)
- )
- if not self.storage.get(self.root_locale):
- raise NotImplementedRootLocaleTranslator(self.root_locale)
- self.translators_map: Dict[str, Iterable[AbstractTranslator]] = self._locales_map_parser(self.locales_map)
-
- self.messages = self._get_translators_messages()
-
- asyncio.create_task(self._on_update())
-
- return self
-
- async def put(self,
- locale: str,
- key: Optional[str] = None,
- value: Optional[Any] = None,
- mapping_values: Optional[dict[str, Any]] = None):
- await self.kv_storage.put(locale=locale,
- key=key,
- value=value,
- mapping_values=mapping_values)
-
- async def create(self,
- locale: str,
- key: Optional[str] = None,
- value: Optional[Any] = None,
- mapping_values: Optional[dict[str, Any]] = None):
- await self.kv_storage.create(locale,
- key,
- value,
- mapping_values)
-
- async def delete(self, locale: str, *keys):
- await self.kv_storage.delete(locale, *keys)
-
- def _create_translators(self) -> list[FluentTranslator]:
- translators = []
- for locale in self.locales_map:
- bundle = FluentBundle(locale=locale, resources=[])
- translators.append(FluentTranslator(locale=locale,
- translator=bundle))
- return translators
-
- def _get_translators_messages(self) -> dict[str, dict]:
- messages = {}
- for translator in self.translators:
- messages[translator.locale] = translator.translator._compiled_messages
- return messages
-
- async def _on_update(self):
- await asyncio.shield(self.kv_storage.listen(self.messages))
\ No newline at end of file
diff --git a/fluentogram/src/impl/transator_hubs/translator_hub.py b/fluentogram/src/impl/transator_hubs/translator_hub.py
deleted file mode 100644
index 0021502..0000000
--- a/fluentogram/src/impl/transator_hubs/translator_hub.py
+++ /dev/null
@@ -1,67 +0,0 @@
-# coding=utf-8
-"""
-A Translator Hub, using as factory for Translator objects
-"""
-from typing import Dict, Iterable, List, Union
-
-from fluentogram.exceptions import NotImplementedRootLocaleTranslator
-from fluentogram.src.abc import AbstractTranslator, AbstractTranslatorsHub
-from fluentogram.src.impl import TranslatorRunner
-
-
-class TranslatorHub(AbstractTranslatorsHub):
- """
- This class implements a storage for all single-locale translators.
- """
-
- def __init__(
- self,
- locales_map: Dict[str, Union[str, Iterable[str]]],
- translators: List[AbstractTranslator],
- root_locale: str = "en",
- separator: str = "-",
- ) -> None:
- self.locales_map = dict(
- zip(
- locales_map.keys(),
- map(
- lambda lang: tuple([lang]) if isinstance(lang, str) else lang,
- locales_map.values()
- )
- )
- )
- self.translators = translators
- self.root_locale = root_locale
- self.separator = separator
- self.storage: Dict[str, AbstractTranslator] = dict(
- zip([translator.locale for translator in translators], translators)
- )
- if not self.storage.get(root_locale):
- raise NotImplementedRootLocaleTranslator(self.root_locale)
- self.translators_map: Dict[str, Iterable[AbstractTranslator]] = self._locales_map_parser(self.locales_map)
-
- def _locales_map_parser(
- self,
- locales_map: Dict[str, Union[str, Iterable[str]]]
- ) -> Dict[str, Iterable[AbstractTranslator]]:
- return {
- lang: tuple(
- [self.storage.get(locale)
- for locale in translator_locales if locale in self.storage.keys()]
- )
- for lang, translator_locales in
- locales_map.items()
- }
-
- def get_translator_by_locale(self, locale: str) -> TranslatorRunner:
- """
- Here is a little tricky moment.
- There should be like a one-shot scheme.
- For proper isolation, function returns TranslatorRunner new instance every time, not the same translator.
- This trick makes "obj.attribute1.attribute2" access to be able.
- You are able to do what you want, refer to the abstract class.
- """
- return TranslatorRunner(
- translators=self.translators_map.get(locale) or self.translators_map[self.root_locale],
- separator=self.separator
- )
diff --git a/fluentogram/src/impl/transformers/__init__.py b/fluentogram/src/impl/transformers/__init__.py
deleted file mode 100644
index 4ba013d..0000000
--- a/fluentogram/src/impl/transformers/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-# coding=utf-8
-from .datetime_transformer import DateTimeTransformer
-from .money_transformer import MoneyTransformer
-
-
-__all__ = ["DateTimeTransformer", "MoneyTransformer", ]
diff --git a/fluentogram/src/impl/transformers/datetime_transformer.py b/fluentogram/src/impl/transformers/datetime_transformer.py
deleted file mode 100644
index 1788edc..0000000
--- a/fluentogram/src/impl/transformers/datetime_transformer.py
+++ /dev/null
@@ -1,26 +0,0 @@
-# coding=utf-8
-"""
-A DateTimeTransformer itself
-"""
-from datetime import datetime
-from typing import Union
-
-from fluent_compiler.types import fluent_date, FluentDateType, FluentNone
-
-from fluentogram.src.abc import AbstractDataTransformer
-
-
-class DateTimeTransformer(AbstractDataTransformer):
- """This transformer converts a default python datetime object to FluentDate
- Typings refer to https://github.com/tc39/ecma402
- """
-
- def __new__(
- cls,
- date: datetime,
- **kwargs
- ) -> Union[FluentDateType, FluentNone]:
- return fluent_date(
- date,
- **kwargs
- )
diff --git a/fluentogram/src/impl/translator.py b/fluentogram/src/impl/translator.py
deleted file mode 100644
index 59dd4b0..0000000
--- a/fluentogram/src/impl/translator.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# coding=utf-8
-"""
-Fluent implementation of AbstractTranslator
-"""
-
-from fluent_compiler.bundle import FluentBundle
-
-from fluentogram.src.abc import AbstractTranslator
-
-
-class FluentTranslator(AbstractTranslator):
- """Single-locale Translator, implemented with fluent_compiler Bundles"""
-
- def __init__(self, locale: str, translator: FluentBundle, separator: str = "-"):
- self.locale = locale
- self.translator = translator
- self.separator = separator
-
- def get(self, key: str, **kwargs):
- """STR100: Calling format with insecure string.
- Route questions to --> https://github.com/django-ftl/fluent-compiler"""
- text, errors = self.translator.format(key, kwargs)
- if errors:
- raise errors.pop()
- return text
-
- def __repr__(self):
- return f""
diff --git a/fluentogram/storage/__init__.py b/fluentogram/storage/__init__.py
new file mode 100644
index 0000000..9924b54
--- /dev/null
+++ b/fluentogram/storage/__init__.py
@@ -0,0 +1,7 @@
+"""Storage implementations for fluentogram."""
+
+from fluentogram.storage.base import BaseStorage
+from fluentogram.storage.file import FileStorage
+from fluentogram.storage.memory import MemoryStorage
+
+__all__ = ["BaseStorage", "FileStorage", "MemoryStorage"]
diff --git a/fluentogram/storage/base.py b/fluentogram/storage/base.py
new file mode 100644
index 0000000..d5af733
--- /dev/null
+++ b/fluentogram/storage/base.py
@@ -0,0 +1,88 @@
+"""An abstract base for storage implementations"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from collections.abc import Iterable, Mapping
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from fluentogram.translator import FluentTranslator
+
+
+class BaseStorage(ABC):
+ """Abstract storage for translators by locale."""
+
+ def __init__(self) -> None:
+ """Initialize storage with empty containers."""
+ self._storage: dict[str, FluentTranslator] = {}
+ self._locales_map: dict[str, Iterable[str]] = {}
+ self._translators_map: dict[str, Iterable[FluentTranslator]] = {}
+
+ def get_locales_map(self) -> dict[str, Iterable[str]]:
+ """Get the locales mapping configuration."""
+ return self._locales_map
+
+ def add_translator(self, translator: FluentTranslator) -> None:
+ """Add a translator to storage."""
+ self._storage[translator.locale] = translator
+
+ def add_translators(self, translators: Iterable[FluentTranslator]) -> None:
+ """Add multiple translators to storage."""
+ for translator in translators:
+ self.add_translator(translator)
+
+ def get_translator(self, locale: str) -> FluentTranslator | None:
+ """Get translator by locale."""
+ return self._storage.get(locale)
+
+ def has_translator(self, locale: str) -> bool:
+ """Check if translator exists for given locale."""
+ return locale in self._storage
+
+ def get_all_translators(self) -> Iterable[FluentTranslator]:
+ """Get all translators from storage."""
+ return self._storage.values()
+
+ def get_translators_by_locales(self, locales: Iterable[str]) -> Iterable[FluentTranslator]:
+ """Get translators by list of locales."""
+ return tuple(self._storage[locale] for locale in locales if locale in self._storage)
+
+ def get_translators_list(self) -> list[FluentTranslator]:
+ """Get all translators as a list."""
+ return list(self._storage.values())
+
+ def set_locales_map(self, locales_map: Mapping[str, str | Iterable[str]]) -> None:
+ """Set the locales mapping configuration."""
+ # Normalize locales map (convert single strings to tuples)
+ self._locales_map = {key: (value,) if isinstance(value, str) else value for key, value in locales_map.items()}
+ # Rebuild translators map
+ self._build_translators_map()
+
+ def get_translators_map(self) -> dict[str, Iterable[FluentTranslator]]:
+ """Get the translators map based on locales configuration."""
+ return self._translators_map
+
+ def get_translators_for_language(self, language: str) -> Iterable[FluentTranslator]:
+ """Get translators for a specific language based on locales map."""
+ return self._translators_map.get(language, ())
+
+ def _build_translators_map(self) -> None:
+ """Build the translators map based on locales configuration."""
+ self._translators_map = {
+ lang: self.get_translators_by_locales(translator_locales)
+ for lang, translator_locales in self._locales_map.items()
+ }
+
+ async def update_translation(self, locale: str, key: str, value: str) -> bool:
+ """Update a translation key for a specific locale."""
+ translator = self._storage.get(locale)
+ if translator is None:
+ return False
+
+ translator.update_translation(key, value)
+ return True
+
+ @abstractmethod
+ async def close(self) -> None:
+ pass
diff --git a/fluentogram/storage/file.py b/fluentogram/storage/file.py
new file mode 100644
index 0000000..f3ea159
--- /dev/null
+++ b/fluentogram/storage/file.py
@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from fluent_compiler.bundle import FluentBundle
+
+from fluentogram.exceptions import LocalesNotFoundError
+from fluentogram.storage.base import BaseStorage
+from fluentogram.translator import FluentTranslator
+
+
+class FileStorage(BaseStorage):
+ def __init__(self, path: str | Path, use_isolating: bool = False) -> None: # noqa: FBT002
+ super().__init__()
+ self.path = Path(path)
+ self.use_isolating = use_isolating
+ self._load_translations()
+
+ def _extract_locales(self, path: Path) -> list[str]:
+ if "{locale}" in path.parts:
+ path = Path(*path.parts[: path.parts.index("{locale}")])
+
+ locales: list[str] = [file_path.name for file_path in path.iterdir() if file_path.is_dir()]
+
+ if not locales:
+ raise LocalesNotFoundError(locales=[], path=path.as_posix())
+
+ return locales
+
+ @staticmethod
+ def _find_locales(
+ path: Path,
+ locales: list[str],
+ ) -> dict[str, list[Path]]:
+ paths: dict[str, list[Path]] = {}
+
+ if "{locale}" not in path.as_posix():
+ path = path.joinpath("{locale}")
+
+ for locale in locales:
+ locale_path = Path(path.as_posix().format(locale=locale))
+ recursive_paths = locale_path.rglob("*.ftl") # Will recursively search for files
+ paths.setdefault(locale, []).extend(recursive_paths)
+
+ if not paths[locale]:
+ raise LocalesNotFoundError(locales=locales, path=locale_path.as_posix())
+
+ return paths
+
+ def _load_translations(self) -> None:
+ locales = self._extract_locales(self.path)
+ for locale, paths in self._find_locales(self.path, locales).items():
+ texts = [path.read_text(encoding="utf8") for path in paths]
+
+ self.add_translator(
+ FluentTranslator(
+ locale=locale,
+ translator=FluentBundle.from_string(
+ text="\n".join(texts),
+ locale=locale,
+ use_isolating=self.use_isolating,
+ ),
+ ),
+ )
+
+ async def close(self) -> None:
+ pass
diff --git a/fluentogram/storage/memory.py b/fluentogram/storage/memory.py
new file mode 100644
index 0000000..28fa59e
--- /dev/null
+++ b/fluentogram/storage/memory.py
@@ -0,0 +1,9 @@
+from fluentogram.storage.base import BaseStorage
+
+
+class MemoryStorage(BaseStorage):
+ def __init__(self) -> None:
+ super().__init__()
+
+ async def close(self) -> None:
+ pass
diff --git a/fluentogram/stub_generator/__init__.py b/fluentogram/stub_generator/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/fluentogram/stub_generator/generator.py b/fluentogram/stub_generator/generator.py
new file mode 100644
index 0000000..e28b076
--- /dev/null
+++ b/fluentogram/stub_generator/generator.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from fluentogram.stub_generator.parser import Message, get_messages
+from fluentogram.stub_generator.stubs import generate_stubs
+from fluentogram.stub_generator.tree import build_tree
+
+
+class Generator:
+ def __init__(
+ self,
+ output_file: str,
+ file_path: str | None = None,
+ directory: str | None = None,
+ ) -> None:
+ self.output_file = Path(output_file)
+ if self.output_file.suffix != ".pyi":
+ raise ValueError("Output file must have .pyi extension")
+
+ if file_path is None and directory is None:
+ raise ValueError("Either file_path or directory must be provided")
+
+ self.files = set() # set of Path objects to skip duplicates
+ if file_path:
+ self.files.add(Path(file_path))
+ if directory:
+ self.files.update(Path(directory).glob("*.ftl"))
+
+ self.messages: dict[str, Message] = {}
+
+ def generate(self) -> None:
+ for file in self.files:
+ messages = get_messages(file.read_text())
+ self.messages.update(messages)
+
+ tree = build_tree(self.messages)
+ content = generate_stubs(tree)
+ self.output_file.write_text(content)
+
+
+def generate(
+ output_file: str,
+ file_path: str | None = None,
+ directory: str | None = None,
+) -> None:
+ generator = Generator(output_file, file_path, directory)
+ generator.generate()
diff --git a/fluentogram/stub_generator/parser.py b/fluentogram/stub_generator/parser.py
new file mode 100644
index 0000000..8bbfa7a
--- /dev/null
+++ b/fluentogram/stub_generator/parser.py
@@ -0,0 +1,174 @@
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass, field
+
+from fluent.syntax import FluentParser, ast
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class Message:
+ name: str
+ result_text: str
+ raw_elements: list[ast.TextElement | ast.Placeable] # Store raw elements for later processing
+ placeholders: list[str] = field(default_factory=list)
+
+
+class Parser:
+ def __init__(self) -> None:
+ self.messages: dict[str, Message] = {}
+ self.terms: dict[str, str] = {}
+
+ def process_term(self, term: ast.Term) -> None:
+ for element in term.value.elements:
+ if isinstance(element, ast.TextElement):
+ self.terms[term.id.name] = element.value
+ elif isinstance(element, ast.TermReference):
+ self.terms[term.id.name] = self.terms[element.id.name]
+ else:
+ logger.warning("Unknown element type in term %s: %s", term.id.name, type(element))
+
+ def _parse_variable_reference(self, message_obj: Message, element: ast.VariableReference) -> None:
+ if element.id.name not in message_obj.placeholders:
+ message_obj.placeholders.append(element.id.name)
+ message_obj.result_text += f"{{ ${element.id.name} }}"
+
+ def _parse_term_reference(self, message_obj: Message, element: ast.TermReference) -> None:
+ message_obj.result_text += self.terms[element.id.name]
+
+ def _parse_message_reference(self, message_obj: Message, element: ast.MessageReference) -> None:
+ if element.id.name in self.messages:
+ # Resolve the referenced message recursively
+ referenced_message = self.messages[element.id.name]
+ if not referenced_message.result_text:
+ # If the referenced message hasn't been processed yet, process it now
+ self._process_message_elements(referenced_message)
+ message_obj.result_text += referenced_message.result_text
+ else:
+ logger.warning("Message reference %s not found", element.id.name)
+
+ def _parse_placeable(self, message_obj: Message, element: ast.Placeable) -> None:
+ expression = element.expression
+ if isinstance(expression, ast.VariableReference):
+ self._parse_variable_reference(message_obj, expression)
+ elif isinstance(expression, ast.TermReference):
+ self._parse_term_reference(message_obj, expression)
+ elif isinstance(expression, ast.SelectExpression):
+ self._parse_select_expression(message_obj, expression)
+ elif isinstance(expression, ast.FunctionReference):
+ self._parse_function_reference(message_obj, expression)
+ elif isinstance(expression, ast.MessageReference):
+ self._parse_message_reference(message_obj, expression)
+ elif isinstance(expression, ast.Placeable):
+ self._parse_placeable(message_obj, expression) # recurse
+ else:
+ logger.warning("Unknown expression type in placeable %s: %s", message_obj.name, type(expression))
+
+ def _parse_function_reference(self, message_obj: Message, element: ast.FunctionReference) -> None:
+ arguments = element.arguments
+ for pos_arg in arguments.positional:
+ if isinstance(pos_arg, ast.VariableReference):
+ self._parse_variable_reference(message_obj, pos_arg)
+ else:
+ logger.warning(
+ "Unknown positional argument type in function reference %s: %s",
+ message_obj.name,
+ type(pos_arg),
+ )
+
+ for named_arg in arguments.named:
+ if isinstance(named_arg, ast.VariableReference):
+ self._parse_variable_reference(message_obj, named_arg)
+ elif isinstance(named_arg, ast.NamedArgument):
+ logger.warning(
+ "Skipping named argument '%s' in function reference '%s'",
+ named_arg.name.name,
+ message_obj.name,
+ )
+ else:
+ logger.warning(
+ "Unknown named argument type in function reference %s: %s",
+ message_obj.name,
+ type(named_arg),
+ )
+
+ def _parse_select_expression(self, message_obj: Message, element: ast.SelectExpression) -> None:
+ """
+ Is selector can be many results, so we parse only default variant.
+ """
+ for variant in element.variants:
+ if variant.value is None or not variant.default:
+ continue
+ for variant_element in variant.value.elements:
+ if isinstance(variant_element, ast.TextElement):
+ message_obj.result_text += variant_element.value
+ elif isinstance(variant_element, ast.Placeable):
+ self._parse_placeable(message_obj, variant_element)
+ else:
+ logger.warning(
+ "Unknown element type in select expression %s: %s",
+ message_obj.name,
+ type(variant_element),
+ )
+
+ def process_message(self, message: ast.Message) -> None:
+ # First pass: just collect the message structure
+ message_obj = Message(
+ name=message.id.name,
+ result_text="",
+ raw_elements=[],
+ )
+
+ if not message.value:
+ logger.warning("Message %s has no value", message.id.name)
+ self.messages[message.id.name] = message_obj
+ return
+
+ # Store raw elements for later processing
+ message_obj.raw_elements = message.value.elements
+ self.messages[message.id.name] = message_obj
+
+ def _sort_placeholders(self, message_obj: Message) -> None:
+ message_obj.placeholders = sorted(message_obj.placeholders)
+
+ def _process_message_elements(self, message_obj: Message) -> None:
+ """Process the raw elements of a message to generate result_text and placeholders."""
+ # Skip if already processed
+ if message_obj.result_text and message_obj.placeholders:
+ return
+
+ for element in message_obj.raw_elements:
+ if isinstance(element, ast.TextElement):
+ message_obj.result_text += element.value
+ elif isinstance(element, ast.Placeable):
+ self._parse_placeable(message_obj, element)
+ else:
+ logger.warning("Unknown element type in message %s: %s", message_obj.name, type(element))
+
+ self._sort_placeholders(message_obj)
+
+ def parse(self, resource: ast.Resource) -> dict[str, Message]:
+ # First pass: collect all terms and message structures
+ for entry in resource.body:
+ if isinstance(entry, ast.Term):
+ self.process_term(entry)
+ elif isinstance(entry, ast.Message):
+ self.process_message(entry)
+
+ # Second pass: process all message elements (now all messages are available)
+ for message_obj in self.messages.values():
+ self._process_message_elements(message_obj)
+
+ return self._get_processed_messages()
+
+ def _get_processed_messages(self) -> dict[str, Message]:
+ return {
+ message.name: message for message in self.messages.values() if message.result_text or message.placeholders
+ }
+
+
+def get_messages(text: str) -> dict[str, Message]:
+ parser = Parser()
+ return parser.parse(FluentParser(with_spans=False).parse(text))
diff --git a/fluentogram/stub_generator/stubs.py b/fluentogram/stub_generator/stubs.py
new file mode 100644
index 0000000..aa2a177
--- /dev/null
+++ b/fluentogram/stub_generator/stubs.py
@@ -0,0 +1,123 @@
+from fluentogram.stub_generator.templates import (
+ Class,
+ ClassRef,
+ InternalMethod,
+ Method,
+ Runner,
+)
+from fluentogram.stub_generator.tree import TreeNode
+
+PYTHON_RESERVED_KEYWORDS = {
+ "class",
+ "def",
+ "if",
+ "else",
+ "elif",
+ "not",
+ "in",
+ "is",
+ "and",
+ "or",
+ "as",
+ "assert",
+ "break",
+ "continue",
+ "return",
+ "yield",
+ "from",
+ "import",
+ "pass",
+ "raise",
+ "try",
+ "except",
+ "finally",
+ "with",
+ "while",
+ "for",
+ "lambda",
+ "match",
+ "async",
+ "await",
+ "True",
+ "False",
+ "None",
+ "self",
+ "super",
+}
+
+
+def _is_reserved_keyword(name: str) -> bool:
+ return name in PYTHON_RESERVED_KEYWORDS
+
+
+def _is_valid_python_name(name: str) -> bool:
+ return not name.isdigit() and not _is_reserved_keyword(name)
+
+
+def _process_node(node: TreeNode, runner: Runner, parent_path: str = "") -> Class:
+ """Recursively processes tree node and creates corresponding class"""
+ # Create unique class name by combining parent path with node name
+ current_path = f"{parent_path}-{node.name}" if parent_path else node.name
+
+ knot = Class(current_path)
+
+ # If node has value (translation), add method __call__
+ if node.value is not None:
+ knot.add_method(
+ InternalMethod(result_text=node.value, args=node.placeholders),
+ )
+
+ # Process child nodes
+ for name, sub_node in node.children.items():
+ if sub_node.is_leaf:
+ # If child node is leaf with value, add method
+ if sub_node.value is not None:
+ if not _is_valid_python_name(name):
+ print(f"{name} is not a valid Python name")
+ continue
+ knot.add_method(
+ Method(
+ method_name=name,
+ result_text=sub_node.value,
+ args=sub_node.placeholders,
+ ),
+ )
+ else:
+ if not _is_valid_python_name(name):
+ print(f"{name} is not a valid Python name")
+ continue
+ # If child node is not leaf, create class reference
+ sub_class = _process_node(sub_node, runner, current_path)
+ runner.add_knot(sub_class)
+ knot.add_class_ref(ClassRef(name, sub_class.class_name))
+
+ return knot
+
+
+def generate_stubs(tree: TreeNode) -> str:
+ content = ""
+ runner = Runner(knots=[])
+
+ # Process root nodes
+ for node in tree.children.values():
+ if node.is_leaf:
+ # If root node is leaf, add it as method to Runner
+ if node.value is not None:
+ runner.add_method(
+ Method(
+ method_name=node.name,
+ result_text=node.value,
+ args=node.placeholders,
+ ),
+ )
+ else:
+ # If root node is not leaf, create class
+ knot = _process_node(node, runner)
+ if not _is_valid_python_name(node.name):
+ print(f"{node.name} is not a valid Python name")
+ continue
+ runner.add_class_ref(ClassRef(node.name, knot.class_name))
+ runner.add_knot(knot)
+
+ content += runner.render()
+ return content
diff --git a/fluentogram/stub_generator/templates.py b/fluentogram/stub_generator/templates.py
new file mode 100644
index 0000000..df4f0db
--- /dev/null
+++ b/fluentogram/stub_generator/templates.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+from jinja2 import Environment, Template
+
+
+def create_jinja_env() -> Environment:
+ """Create Jinja2 environment with custom filters"""
+ env = Environment(autoescape=True)
+
+ def camelcase(value: str) -> str:
+ """Convert dash/underscore separated string to CamelCase"""
+ return "".join(word.capitalize() for word in value.replace("_", "-").split("-"))
+
+ env.filters["camelcase"] = camelcase
+ return env
+
+
+jinja_env = create_jinja_env()
+
+
+class RenderAble:
+ render_pattern: Template
+
+ def __init__(self, **kwargs) -> None:
+ self.kwargs = kwargs
+
+ def render(self) -> str:
+ return self.render_pattern.render(**self.kwargs)
+
+
+class Import(RenderAble):
+ render_pattern = jinja_env.from_string("from typing import Literal")
+
+
+class Method(RenderAble):
+ render_pattern = jinja_env.from_string(
+ ' @staticmethod\n def {{ method_name }}({{ args }}) -> Literal["""{{ result_text }}"""]: ...',
+ )
+
+ def __init__(
+ self,
+ method_name: str,
+ result_text: str,
+ args: Iterable[str] | None = None,
+ ) -> None:
+ formatted_args = "*, " + ", ".join(f"{arg}: PossibleValue" for arg in args) if args else ""
+ super().__init__(result_text=result_text, args=formatted_args)
+ self.kwargs["method_name"] = method_name
+
+ def render(self) -> str:
+ return super().render() + "\n"
+
+
+class InternalMethod(Method):
+ def __init__(self, result_text: str, args: Iterable[str] | None = None) -> None:
+ super().__init__(method_name="__call__", result_text=result_text, args=args)
+
+
+class ClassRef(RenderAble):
+ render_pattern = jinja_env.from_string(
+ " {{ var_name }}: {{ var_full_name | camelcase }}",
+ )
+
+ def __init__(self, var_name: str, var_full_name: str | None = None) -> None:
+ super().__init__(
+ var_name=var_name,
+ var_full_name=var_full_name or var_name,
+ )
+
+ def render(self) -> str:
+ return super().render() + "\n"
+
+
+class Class(RenderAble):
+ render_pattern = jinja_env.from_string("\nclass {{ class_name | camelcase }}:")
+
+ def __init__(self, class_name: str) -> None:
+ super().__init__()
+ self.class_name = class_name
+ self.class_refs: list[ClassRef] = []
+ self.methods: list[Method] = []
+
+ def render(self) -> str:
+ text = self.render_pattern.render(class_name=self.class_name) + "\n"
+ for class_ref in self.class_refs:
+ text += class_ref.render()
+ if self.class_refs:
+ text += "\n"
+ for method in self.methods:
+ text += method.render()
+ return text.rstrip() + "\n"
+
+ def add_class_ref(self, class_ref: ClassRef) -> None:
+ self.class_refs.append(class_ref)
+
+ def add_method(self, method: Method) -> None:
+ self.methods.append(method)
+
+
+class Runner(Class):
+ render_pattern = jinja_env.from_string(
+ """from decimal import Decimal
+from typing import Literal
+
+from typing_extensions import TypeAlias
+
+PossibleValue: TypeAlias = str | int | float | Decimal | bool
+
+class {{ class_name }}:
+ def get(self, path: str, **kwargs: PossibleValue) -> str: ...""",
+ )
+
+ def __init__(self, knots: list[Class], name: str = "TranslatorRunner") -> None:
+ super().__init__(name)
+ self.knots = knots
+
+ def render(self) -> str:
+ text = super().render()
+ for knot in self.knots:
+ text += knot.render()
+ return text
+
+ def add_knot(self, knot: Class) -> None:
+ self.knots.append(knot)
diff --git a/fluentogram/stub_generator/tree.py b/fluentogram/stub_generator/tree.py
new file mode 100644
index 0000000..3237671
--- /dev/null
+++ b/fluentogram/stub_generator/tree.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from fluentogram.stub_generator.parser import Message
+
+
+@dataclass
+class TreeNode:
+ """Translation tree node"""
+
+ name: str
+ value: str | None = None
+ placeholders: list[str] = field(default_factory=list)
+ children: dict[str, TreeNode] = field(default_factory=dict)
+
+ @property
+ def is_leaf(self) -> bool:
+ """Is node a leaf (has no children)"""
+ return not self.children
+
+ @property
+ def has_value(self) -> bool:
+ """Does node have value (translation)"""
+ return self.value is not None
+
+
+def _build_node(key: str, message: Message, root: TreeNode, separator: str = "-") -> TreeNode:
+ parts = key.split(separator)
+
+ # Start with root
+ current_node = root
+
+ # Process all parts of the path, except the last one
+ for part in parts[:-1]:
+ # If child node does not exist, create it
+ if part not in current_node.children:
+ current_node.children[part] = TreeNode(name=part)
+ current_node = current_node.children[part]
+
+ # Last part is the node name with value
+ final_name = parts[-1]
+
+ # Create or update final node
+ if final_name not in current_node.children:
+ current_node.children[final_name] = TreeNode(
+ name=final_name,
+ value=message.result_text,
+ placeholders=message.placeholders,
+ )
+ else:
+ # If node already exists, update its value
+ existing_node = current_node.children[final_name]
+ existing_node.value = message.result_text
+ existing_node.placeholders = message.placeholders
+
+ return current_node
+
+
+def build_tree(messages: dict[str, Message], separator: str = "-") -> TreeNode:
+ root = TreeNode(name="root")
+ for key, message in messages.items():
+ _build_node(key, message, root, separator)
+ return root
diff --git a/fluentogram/tests/__init__.py b/fluentogram/tests/__init__.py
deleted file mode 100644
index f8a505e..0000000
--- a/fluentogram/tests/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-# coding=utf-8
-from . import test_usage
-
-__all__ = ["test_usage", ]
diff --git a/fluentogram/tests/test_stub_generation.py b/fluentogram/tests/test_stub_generation.py
deleted file mode 100644
index 36c117e..0000000
--- a/fluentogram/tests/test_stub_generation.py
+++ /dev/null
@@ -1,236 +0,0 @@
-# coding=utf-8
-import unittest
-
-from fluentogram.typing_generator import ParsedRawFTL, Tree, Stubs
-
-
-class StubGeneration(unittest.TestCase):
- DEFAULT_STUB_TEXT = """from typing import Literal
-
-
-class TranslatorRunner:
- def get(self, path: str, **kwargs) -> str: ...
- """
-
- def _gen_stub_text(self, raw_text):
- raw = ParsedRawFTL(raw_text)
- messages = raw.get_messages()
- tree = Tree(messages)
- stubs = Stubs(tree)
- return stubs.echo()
-
- def test_text_element(self):
- self.assertEquals(
- self._gen_stub_text("welcome = Welcome to the fluent aiogram addon!"),
- self.DEFAULT_STUB_TEXT
- + '''
- @staticmethod
- def welcome() -> Literal["""Welcome to the fluent aiogram addon!"""]: ...
-
-''',
- )
-
- def test_variable_reference(self):
- self.assertEquals(
- self._gen_stub_text(
- "greet-by-name = Hello, { $user }... You name is { $user }, isn't it?"
- ),
- self.DEFAULT_STUB_TEXT
- + '''
- greet: Greet
-
-
-class Greet:
- by: GreetBy
-
-
-class GreetBy:
- @staticmethod
- def name(*, user) -> Literal["""Hello, { $user }... You name is { $user }, isn't it?"""]: ...
-
-''',
- )
-
- def test_variable_reference_with_two_args(self):
- self.assertEquals(
- self._gen_stub_text(
- "shop-success-payment = Your money, { $amount }, has been sent successfully at { $dt }."
- ),
- self.DEFAULT_STUB_TEXT
- + '''
- shop: Shop
-
-
-class Shop:
- success: ShopSuccess
-
-
-class ShopSuccess:
- @staticmethod
- def payment(*, amount, dt) -> Literal["""Your money, { $amount }, has been sent successfully at { $dt }."""]: ...
-
-''',
- )
-
- def test_selector(self):
- self.assertEquals(
- self._gen_stub_text(
- """test-bool_indicator = { $is_true ->
- [one] ✅
- *[other] ❌
- } """
- ),
- self.DEFAULT_STUB_TEXT
- + '''
- test: Test
-
-
-class Test:
- @staticmethod
- def bool_indicator(*, is_true) -> Literal["""{ $is_true ->
-[one] ✅
-*[other] ❌
-}"""]: ...
-
-''',
- )
-
- def test_selector_num_key(self):
- self.assertEquals(
- self._gen_stub_text(
- """test-bool_indicator = { $is_true ->
- [0] ✅
- *[other] ❌
- } """
- ),
- self.DEFAULT_STUB_TEXT
- + '''
- test: Test
-
-
-class Test:
- @staticmethod
- def bool_indicator(*, is_true) -> Literal["""{ $is_true ->
-[0] ✅
-*[other] ❌
-}"""]: ...
-
-''',
- )
-
- def test_recursion(self):
- self.assertEquals(
- self._gen_stub_text(
- """recursion = { $is_true ->
-[one] one
-*[other] Recursion { $is_true ->
- [one] one
- *[other] Recursion { $is_true ->
- [one] one
- *[other] Recursion
- }
- }
-}"""
- ),
- self.DEFAULT_STUB_TEXT
- + '''
- @staticmethod
- def recursion(*, is_true) -> Literal["""{ $is_true ->
-[one] one
-*[other] Recursion
-*[other] { $is_true ->
-[one] one
-*[other] Recursion
-*[other] { $is_true ->
-[one] one
-*[other] Recursion
-}
-}
-}"""]: ...
-
-''',
- )
-
- def test_function_reference(self):
- self.assertEquals(
- self._gen_stub_text("test-number = { NUMBER($num, useGrouping: 0) }"),
- self.DEFAULT_STUB_TEXT
- + '''
- test: Test
-
-
-class Test:
- @staticmethod
- def number(*, num) -> Literal["""{ NUMBER({ $num }, useGrouping: 0) }"""]: ...
-
-''',
- )
-
- def test_message_reference_to_text(self):
- self.assertEquals(
- self._gen_stub_text(
- """simple = text
-ref = { simple }
- """
- ),
- self.DEFAULT_STUB_TEXT
- + '''
- @staticmethod
- def simple() -> Literal["""text"""]: ...
-
- @staticmethod
- def ref() -> Literal["""text"""]: ...
-
-''',
- )
-
- def test_message_reference_to_var(self):
- self.assertEquals(
- self._gen_stub_text(
- """var = { $name }
-ref = { var }
- """
- ),
- self.DEFAULT_STUB_TEXT
- + '''
- @staticmethod
- def var(*, name) -> Literal["""{ $name }"""]: ...
-
- @staticmethod
- def ref(*, name) -> Literal["""{ $name }"""]: ...
-
-''',
- )
-
- def test_message_reference_in_selector(self):
- self.assertEquals(
- self._gen_stub_text(
- """foo = { $var ->
-[test] { test }
-*[any] any text
-}
-test = { $is_true ->
-[one] true
-*[other] false
-}
-"""
- ),
- self.DEFAULT_STUB_TEXT
- + '''
- @staticmethod
- def test(*, is_true) -> Literal["""{ $is_true ->
-[one] true
-*[other] false
-}"""]: ...
-
- @staticmethod
- def foo(*, var, is_true) -> Literal["""{ $var ->
-[test] { $is_true ->
-[one] true
-*[other] false
-}
-*[any] any text
-}"""]: ...
-
-''',
- )
diff --git a/fluentogram/tests/test_usage.py b/fluentogram/tests/test_usage.py
deleted file mode 100644
index 7f911bb..0000000
--- a/fluentogram/tests/test_usage.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# coding=utf-8
-import unittest
-from datetime import datetime
-from decimal import Decimal
-
-from fluent_compiler.bundle import FluentBundle
-
-from fluentogram import (
- FluentTranslator,
- TranslatorHub,
- TranslatorRunner,
- MoneyTransformer,
- DateTimeTransformer,
-)
-
-
-class BasicUsage(unittest.TestCase):
- def test_full_usage(self):
- example_ftl_file_content = """
-welcome = Welcome to the fluent aiogram addon!
-greet-by-name = Hello, { $user }!
-shop-success-payment = Your money, { $amount }, has been sent successfully at { $dt }.
- """
- t_hub = TranslatorHub(
- {"ua": ("ua", "ru", "en"), "ru": ("ru", "en"), "en": ("en",)},
- translators=[
- FluentTranslator(
- locale="en",
- translator=FluentBundle.from_string(
- "en-US", example_ftl_file_content, use_isolating=False
- ),
- )
- ],
- root_locale="en",
- )
- translator_runner: TranslatorRunner = t_hub.get_translator_by_locale("en")
- print(
- translator_runner.welcome(),
- "\n",
- translator_runner.greet.by.name(user="Alex"),
- "\n",
- translator_runner.shop.success.payment(
- amount=MoneyTransformer(currency="$", amount=Decimal("500")),
- dt=DateTimeTransformer(datetime.now()),
- ),
- )
diff --git a/fluentogram/transformers/__init__.py b/fluentogram/transformers/__init__.py
new file mode 100644
index 0000000..d2f36f7
--- /dev/null
+++ b/fluentogram/transformers/__init__.py
@@ -0,0 +1,7 @@
+from .datetime import DateTimeTransformer
+from .money import MoneyTransformer
+
+__all__ = [
+ "DateTimeTransformer",
+ "MoneyTransformer",
+]
diff --git a/fluentogram/src/abc/transformer.py b/fluentogram/transformers/base.py
similarity index 61%
rename from fluentogram/src/abc/transformer.py
rename to fluentogram/transformers/base.py
index 40b2957..21cea37 100644
--- a/fluentogram/src/abc/transformer.py
+++ b/fluentogram/transformers/base.py
@@ -1,14 +1,11 @@
-# coding=utf-8
-"""
-An AbstractDataTransformer object, using to transform any data before being passed to translator directly.
-"""
+"""An AbstractDataTransformer object, using to transform any data before being passed to translator directly."""
+
from abc import ABC, abstractmethod
from typing import Any
class AbstractDataTransformer(ABC):
- """
- These transformers inspired by Functions of Project Fluent by Mozilla.
+ """These transformers inspired by Functions of Project Fluent by Mozilla.
Of course, it's a simple function, like a
def function(money: Union[int, float], **kwargs) -> str: ...
@@ -17,7 +14,8 @@ def function(money: Union[int, float], **kwargs) -> str: ...
"""
@abstractmethod
- def __new__(cls, data: Any, **kwargs) -> Any:
+ def __new__(cls, data: Any, **kwargs: Any) -> Any:
"""Using incoming data, create an object representation of these data for your translator via all needed
- parameters using kwargs"""
+ parameters using kwargs
+ """
raise NotImplementedError
diff --git a/fluentogram/transformers/datetime.py b/fluentogram/transformers/datetime.py
new file mode 100644
index 0000000..49882b2
--- /dev/null
+++ b/fluentogram/transformers/datetime.py
@@ -0,0 +1,25 @@
+"""A DateTimeTransformer itself"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from fluent_compiler.types import FluentDateType, FluentNone, fluent_date
+
+from fluentogram.transformers.base import AbstractDataTransformer
+
+
+class DateTimeTransformer(AbstractDataTransformer):
+ """This transformer converts a default python datetime object to FluentDate
+ Typings refer to https://github.com/tc39/ecma402
+ """
+
+ def __new__(
+ cls,
+ date: datetime,
+ **kwargs,
+ ) -> FluentDateType | FluentNone:
+ return fluent_date(
+ date,
+ **kwargs,
+ )
diff --git a/fluentogram/src/impl/transformers/money_transformer.py b/fluentogram/transformers/money.py
similarity index 51%
rename from fluentogram/src/impl/transformers/money_transformer.py
rename to fluentogram/transformers/money.py
index fea33cf..c3de617 100644
--- a/fluentogram/src/impl/transformers/money_transformer.py
+++ b/fluentogram/transformers/money.py
@@ -1,13 +1,13 @@
-# coding=utf-8
-"""
-A MoneyTransformer by itself
-"""
+"""A MoneyTransformer by itself"""
+
+from __future__ import annotations
+
from decimal import Decimal
-from typing import Literal, Union, Optional
+from typing import Literal
-from fluent_compiler.types import fluent_number, FluentNumber, FluentNone
+from fluent_compiler.types import FluentNone, FluentNumber, fluent_number
-from fluentogram.src.abc import AbstractDataTransformer
+from fluentogram.transformers.base import AbstractDataTransformer
class MoneyTransformer(AbstractDataTransformer):
@@ -15,20 +15,18 @@ class MoneyTransformer(AbstractDataTransformer):
Typings refer to https://github.com/tc39/ecma402
"""
- def __new__(
+ def __new__( # noqa: PLR0913
cls,
amount: Decimal,
currency: str,
- currency_display: Union[
- Literal["code"], Literal["symbol"], Literal["name"]
- ] = "code",
- use_grouping: bool = False,
- minimum_significant_digits: Optional[int] = None,
- maximum_significant_digits: Optional[int] = None,
- minimum_fraction_digits: Optional[int] = None,
- maximum_fraction_digits: Optional[int] = None,
- **kwargs
- ) -> Union[FluentNumber, FluentNone]:
+ currency_display: Literal["code", "symbol", "name"] = "code",
+ use_grouping: bool = False, # noqa: FBT002
+ minimum_significant_digits: int | None = None,
+ maximum_significant_digits: int | None = None,
+ minimum_fraction_digits: int | None = None,
+ maximum_fraction_digits: int | None = None,
+ **kwargs,
+ ) -> FluentNumber | FluentNone:
return fluent_number(
amount,
style="currency",
@@ -39,5 +37,5 @@ def __new__(
maximumSignificantDigits=maximum_significant_digits,
minimumFractionDigits=minimum_fraction_digits,
maximumFractionDigits=maximum_fraction_digits,
- **kwargs
+ **kwargs,
)
diff --git a/fluentogram/translator.py b/fluentogram/translator.py
new file mode 100644
index 0000000..8aba78c
--- /dev/null
+++ b/fluentogram/translator.py
@@ -0,0 +1,43 @@
+"""Fluent implementation of AbstractTranslator"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fluent_compiler.bundle import FluentBundle
+from fluent_compiler.compiler import compile_messages
+from fluent_compiler.resource import FtlResource
+
+from fluentogram.exceptions import FormatError
+
+
+class FluentTranslator:
+ """Single-locale Translator, implemented with fluent_compiler Bundles"""
+
+ def __init__(self, locale: str, translator: FluentBundle, separator: str = "-") -> None:
+ self.locale = locale
+ self.translator = translator
+ self.separator = separator
+
+ def get(self, key: str, **kwargs: Any) -> str | None:
+ """STR100: Calling format with insecure string.
+ Route questions to --> https://github.com/django-ftl/fluent-compiler
+ """
+ try:
+ text, errors = self.translator.format(key, kwargs)
+ if errors:
+ raise FormatError(errors.pop(), key)
+ except KeyError:
+ return None
+
+ return text
+
+ def update_translation(self, key: str, value: str) -> None:
+ """Update a translation key for a specific locale."""
+ self.translator._compiled_messages[key] = compile_messages( # noqa: SLF001
+ self.locale,
+ [FtlResource.from_string(f"{key} = {value}")],
+ ).message_functions[key]
+
+ def __repr__(self) -> str:
+ return f""
diff --git a/fluentogram/translator_hub.py b/fluentogram/translator_hub.py
new file mode 100644
index 0000000..e04133f
--- /dev/null
+++ b/fluentogram/translator_hub.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+from collections.abc import Iterable, Mapping
+
+from fluentogram.exceptions import RootTranslatorNotFoundError
+from fluentogram.runner import TranslatorRunner
+from fluentogram.storage import BaseStorage, MemoryStorage
+from fluentogram.translator import FluentTranslator
+
+
+class TranslatorHub:
+ """This class implements a storage for all single-locale translators."""
+
+ def __init__(
+ self,
+ locales_map: Mapping[str, str | Iterable[str]],
+ translators: list[FluentTranslator] | None = None,
+ root_locale: str = "en",
+ separator: str = "-",
+ storage: BaseStorage | None = None,
+ ) -> None:
+ self.root_locale = root_locale
+ self.separator = separator
+
+ self.storage = storage or MemoryStorage()
+
+ # Add translators to storage
+ if translators is not None:
+ self.storage.add_translators(translators)
+
+ # Set locales map in storage
+ self.storage.set_locales_map(locales_map)
+
+ if not self.storage.has_translator(root_locale):
+ raise RootTranslatorNotFoundError(self.root_locale)
+
+ async def update_translation(self, locale: str, key: str, value: str) -> bool:
+ """Update translation for a given locale and key."""
+ return await self.storage.update_translation(locale, key, value)
+
+ def get_translator_by_locale(self, locale: str) -> TranslatorRunner:
+ """Here is a little tricky moment.
+ There should be like a one-shot scheme.
+ For proper isolation, function returns TranslatorRunner new instance every time, not the same translator.
+ This trick makes "obj.attribute1.attribute2" access to be able.
+ You are able to do what you want, refer to the abstract class.
+ """
+ translators = self.storage.get_translators_for_language(locale)
+ if not translators:
+ # Fallback to root locale
+ translators = self.storage.get_translators_for_language(self.root_locale)
+
+ return TranslatorRunner(
+ translators=translators,
+ separator=self.separator,
+ )
+
+ @property
+ def translators(self) -> list[FluentTranslator]:
+ """Get all translators from storage."""
+ return self.storage.get_translators_list()
+
+ @property
+ def translators_map(self) -> dict[str, Iterable[FluentTranslator]]:
+ """Get translators map from storage."""
+ return self.storage.get_translators_map()
diff --git a/fluentogram/typing_generator/__init__.py b/fluentogram/typing_generator/__init__.py
deleted file mode 100644
index 0ebf246..0000000
--- a/fluentogram/typing_generator/__init__.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from .parsed_ftl import ParsedRawFTL
-from .renderable_items import Knot, InternalMethod, Method
-from .stubs import Stubs
-from .translation_dto import Translation
-from .tree import Tree, TreeNode
-
-__all__ = [
- "InternalMethod",
- "Knot",
- "Method",
- "ParsedRawFTL",
- "Stubs",
- "Translation",
- "Tree",
- "TreeNode",
-]
diff --git a/fluentogram/typing_generator/parsed_ftl.py b/fluentogram/typing_generator/parsed_ftl.py
deleted file mode 100644
index 613668b..0000000
--- a/fluentogram/typing_generator/parsed_ftl.py
+++ /dev/null
@@ -1,180 +0,0 @@
-from dataclasses import dataclass
-from typing import Dict
-
-from fluent.syntax import FluentParser
-from fluent.syntax.ast import (
- Message,
- Placeable,
- Literal,
- TextElement,
- VariableReference,
- SelectExpression,
- MessageReference,
- StringLiteral,
- NumberLiteral,
- TermReference,
- FunctionReference,
- InlineExpression,
- NamedArgument,
- Identifier,
-)
-from ordered_set import OrderedSet
-
-from fluentogram.typing_generator.translation_dto import Translation
-
-
-@dataclass
-class Node:
- value: str
- args: list[str]
-
-
-class ReferenceNotExists(Exception):
- pass
-
-
-class ParsedRawFTL:
- def __init__(self, ftl_data: str, parser=FluentParser()) -> None:
- self.parsed_ftl = parser.parse(ftl_data)
- self.nodes: dict[str, Node]
-
- def _parse_literal(self, obj: Literal) -> Node:
- return Node(value=obj.parse()["value"], args=[])
-
- def _parse_string_literal(self, obj: StringLiteral) -> Node:
- return self._parse_literal(obj)
-
- def _parse_number_literal(self, obj: NumberLiteral) -> Node:
- data = obj.parse()
- return Node(value=str(f"{data['value']:.{data['precision']}f}"), args=[])
-
- def _parse_message_reference(self, obj: MessageReference) -> Node:
- if obj.id.name not in self.nodes:
- raise ReferenceNotExists
- return self.nodes[obj.id.name]
-
- def _parse_text_element(self, obj: TextElement) -> Node:
- return Node(value=obj.value, args=[])
-
- def _parse_variable_reference(self, obj: VariableReference) -> Node:
- return Node(value=f"{{ ${obj.id.name} }}", args=[obj.id.name])
-
- def _parse_named_argument(self, obj: NamedArgument) -> Node:
- if isinstance(obj.value, NumberLiteral):
- value = self._parse_number_literal(obj.value)
- else: # StringLiteral
- value = self._parse_string_literal(obj.value)
- return Node(value=f"{obj.name.name}: {value.value}", args=[])
-
- def _parse_function_reference(self, obj: FunctionReference) -> Node:
- named_args = []
- for named_arg in obj.arguments.named:
- named_args.append(self._parse_named_argument(named_arg))
- positionals = []
- for positional in obj.arguments.positional:
- if isinstance(positional, Placeable):
- positionals.append(self._parse_placeable(positional))
- else: # InlineExpression
- positionals.append(self._parse_inline_expression(positional))
-
- named_args_string = ", ".join([named_arg.value for named_arg in named_args])
- positional_string = ", ".join([positional.value for positional in positionals])
-
- positional_args = []
- for positional in positionals:
- positional_args += positional.args
-
- return Node(
- value=f"{{ {obj.id.name}({positional_string}{',' if named_args_string else ''} {named_args_string}) }}",
- args=positional_args,
- )
-
- def _parse_inline_expression(self, obj: InlineExpression) -> Node:
- if isinstance(obj, NumberLiteral):
- return self._parse_number_literal(obj)
- elif isinstance(obj, StringLiteral):
- return self._parse_string_literal(obj)
- elif isinstance(obj, MessageReference):
- return self._parse_message_reference(obj)
- elif isinstance(obj, TermReference):
- ...
- # TODO: implementation
- elif isinstance(obj, VariableReference):
- return self._parse_variable_reference(obj)
- elif isinstance(obj, FunctionReference):
- return self._parse_function_reference(obj)
- return Node(value="", args=[])
-
- def _parse_select_expression(self, obj: SelectExpression) -> Node:
- selector = self._parse_inline_expression(obj.selector)
- value = f"{{ {', '.join([f'${s}' for s in selector.args])} ->"
- args = selector.args
-
- for variant in obj.variants:
- for element in variant.value.elements:
- if isinstance(element, TextElement):
- variant_node = self._parse_text_element(element)
- elif isinstance(element, Placeable):
- variant_node = self._parse_placeable(element)
- else:
- continue
- if isinstance(variant.key, Identifier):
- key_name = variant.key.name
- elif isinstance(variant.key, NumberLiteral):
- key_name = self._parse_number_literal(variant.key).value
- else:
- continue
- value += f"\n{'*' if variant.default else ''}[{key_name}] {variant_node.value}"
- args += variant_node.args
-
- value += "\n}"
- return Node(value=value, args=args)
-
- def _parse_placeable(self, obj: Placeable) -> Node:
- ex = obj.expression
- if isinstance(ex, VariableReference):
- return self._parse_variable_reference(ex)
- elif isinstance(ex, SelectExpression):
- return self._parse_select_expression(ex)
- elif isinstance(ex, InlineExpression):
- return self._parse_inline_expression(ex)
- elif isinstance(ex, Placeable):
- return self._parse_placeable(ex)
- return Node(value="", args=[])
-
- def _parse_message(self, obj: Message) -> Node:
- nodes: list[Node] = []
- for element in obj.value.elements:
- if isinstance(element, TextElement):
- nodes.append(self._parse_text_element(element))
- elif isinstance(element, Placeable):
- nodes.append(self._parse_placeable(element))
- else:
- continue
-
- node_value, node_args = "", []
- for sub_node in nodes:
- node_value += sub_node.value
- node_args += sub_node.args
- return Node(value=node_value, args=node_args)
-
- def _parse_body(self) -> dict[str, Node]:
- self.nodes: dict[str, Node] = {}
- for message in self.parsed_ftl.body:
- if not isinstance(message, Message):
- # TODO: other Entry
- continue
- if message.value is None:
- continue
- try:
- self.nodes[message.id.name] = self._parse_message(message)
- except ReferenceNotExists:
- self.parsed_ftl.body.append(message)
- continue
- return self.nodes
-
- def get_messages(self) -> Dict[str, Translation]:
- return {
- name: Translation(node.value, args=OrderedSet(node.args))
- for name, node in self._parse_body().items()
- }
diff --git a/fluentogram/typing_generator/renderable_items.py b/fluentogram/typing_generator/renderable_items.py
deleted file mode 100644
index 149136b..0000000
--- a/fluentogram/typing_generator/renderable_items.py
+++ /dev/null
@@ -1,92 +0,0 @@
-# coding=utf-8
-from typing import Optional, List, Iterable
-
-try:
- from jinja2 import Template
-except ModuleNotFoundError:
- raise ModuleNotFoundError("You should install Jinja2 package to use cli tools")
-
-
-class RenderAble:
- render_pattern: Template
-
- def __init__(self, **kwargs) -> None:
- self.kwargs = kwargs
-
- def render(self) -> str:
- return self.render_pattern.render(**self.kwargs) + "\n"
-
-
-class Import(RenderAble):
- render_pattern = Template("from typing import Literal", autoescape=True)
-
-
-class Method(RenderAble):
- render_pattern = Template(
- " @staticmethod\n"
- ' def {{ method_name }}({{ args }}) -> Literal["""{{ translation }}"""]: ...',
- autoescape=True,
- )
-
- def __init__(
- self, method_name: str, translation: str, args: Optional[Iterable[str]] = None
- ) -> None:
- if args:
- formatted_args = "*, " + ", ".join(args)
- else:
- formatted_args = ""
- super().__init__(translation=translation, args=formatted_args)
- self.kwargs["method_name"] = method_name
-
-
-class InternalMethod(Method):
- def __init__(self, translation: str, args: Optional[Iterable[str]] = None) -> None:
- super().__init__(method_name="__call__", translation=translation, args=args)
-
-
-class Var(RenderAble):
- render_pattern = Template(
- " {{ var_name }}: {{ var_full_name }}", autoescape=True
- )
-
- def __init__(self, var_name: str, var_full_name: Optional[str] = None) -> None:
- super().__init__(
- var_name=var_name,
- var_full_name=var_name if not var_full_name else var_full_name,
- )
-
-
-class Knot(RenderAble):
- render_pattern = Template("\nclass {{ class_name }}:\n", autoescape=True)
-
- def __init__(self, class_name: str) -> None:
- super().__init__()
- self.class_name = class_name
- self.variables: List[Var] = []
- self.methods: List[Method] = []
-
- def render(self) -> str:
- text = self.render_pattern.render(class_name=self.class_name) + "\n"
- for var in self.variables:
- text += var.render()
- if self.variables:
- text += "\n"
- for method in self.methods:
- text += method.render() + "\n"
- return text
-
- def add_var(self, var: Var) -> None:
- self.variables.append(var)
-
- def add_method(self, method: Method) -> None:
- self.methods.append(method)
-
-
-class Runner(Knot):
- render_pattern = Template(
- "\nclass {{ class_name }}:\n def get(self, path: str, **kwargs) -> str: ...\n ",
- autoescape=True,
- )
-
- def __init__(self, name: str = "TranslatorRunner") -> None:
- super().__init__(name)
diff --git a/fluentogram/typing_generator/stubs.py b/fluentogram/typing_generator/stubs.py
deleted file mode 100644
index c126b88..0000000
--- a/fluentogram/typing_generator/stubs.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from typing import Iterator
-
-from fluentogram.typing_generator.renderable_items import (
- Var,
- Knot,
- InternalMethod,
- Method,
- Runner,
-)
-from fluentogram.typing_generator.tree import Tree
-
-
-class Stubs:
- def __init__(self, tree: Tree, root: str = "TranslatorRunner") -> None:
- self.root = root
- self.nodes = tree.elements
- self.content: str = "from typing import Literal\n\n "
- for stub in self._gen_stubs():
- self.content += stub
-
- def _gen_stubs(self) -> Iterator[str]:
- for path, node in self.nodes.items():
- if node.is_leaf:
- continue
- if node.path:
- knot = Knot(node.path)
- else:
- knot = Runner(self.root)
- if node.children:
- if node.value:
- knot.add_method(
- InternalMethod(node.value, args=node.translation_vars)
- )
- for name, sub_node in node.children.items():
- if sub_node.is_leaf:
- if sub_node.value:
- knot.add_method(
- Method(
- name, sub_node.value, args=sub_node.translation_vars
- )
- )
- else:
- knot.add_var(Var(name, sub_node.path))
- yield knot.render()
-
- def to_file(self, file_name: str) -> None:
- with open(file_name, "w", encoding="utf-8") as f:
- f.write(self.content)
-
- def echo(self) -> str:
- return self.content
diff --git a/fluentogram/typing_generator/translation_dto.py b/fluentogram/typing_generator/translation_dto.py
deleted file mode 100644
index 5c78825..0000000
--- a/fluentogram/typing_generator/translation_dto.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from dataclasses import dataclass
-
-from ordered_set import OrderedSet
-
-
-@dataclass
-class Translation:
- text: str
- args: OrderedSet
diff --git a/fluentogram/typing_generator/tree.py b/fluentogram/typing_generator/tree.py
deleted file mode 100644
index 900ae29..0000000
--- a/fluentogram/typing_generator/tree.py
+++ /dev/null
@@ -1,59 +0,0 @@
-from dataclasses import dataclass
-from typing import Optional
-
-from ordered_set import OrderedSet
-
-from fluentogram.typing_generator.translation_dto import Translation
-
-
-@dataclass
-class TreeNode:
- path: str
- children: dict[str, "TreeNode"]
- name: str
- value: Optional[str] = None
- translation_vars: Optional[OrderedSet] = None
-
- @property
- def is_leaf(self) -> bool:
- if not self.children:
- return True
- return False
-
-
-class Tree:
- def __init__(
- self,
- ftl_syntax: dict[str, Translation],
- separator: str = "-",
- safe_separator: str = "",
- ) -> None:
- self.safe_separator = safe_separator
- self.ftl_syntax = ftl_syntax
- self.separator = separator
- self.elements: dict[tuple[str, ...], TreeNode] = {}
- for path, translation in ftl_syntax.items():
- *point_path, name = path.split("-")
- point_path.insert(0, "")
- self._build(tuple(point_path), name, translation)
-
- def path_to_str(self, path: tuple) -> str:
- clean_path = map(lambda s: s[0].capitalize() + s[1:], filter(lambda x: x, path))
- return self.safe_separator.join(clean_path)
-
- def _build(self, path: tuple[str, ...], name: str, value=None) -> None:
- own_class_def = TreeNode(
- path=self.path_to_str(path + (name,)),
- name=name,
- value=value.text if value else "",
- children={},
- translation_vars=value.args if value else OrderedSet(),
- )
-
- if path:
- if path not in self.elements:
- self._build(path[:-1], path[-1])
-
- self.elements[path].children[name] = own_class_def
-
- self.elements.setdefault(path + (name,), own_class_def)
diff --git a/pyproject.toml b/pyproject.toml
index 01c15af..7817ccf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,24 +1,96 @@
+[build-system]
+requires = ["setuptools>=42"]
+build-backend = "setuptools.build_meta"
+
[project]
name = "fluentogram"
-version = "1.1.13"
-description = "A proper way to use an i18n mechanism with Aiogram3."
+version = "1.2.0"
+description = "Fluentogram is easy way to use i18n (Fluent) mechanism in any python app."
authors = [{ name = "Aleks" }]
-requires-python = "~=3.9"
+requires-python = ">=3.9"
license = "MIT"
-dependencies = [
- "fluent-compiler~=1.1",
- "watchdog>=2.3.0,<3",
- "ordered-set>=4.1.0,<5",
- "nats-py>=2.9.0,<3",
- "typing-extensions>=4.12.2,<5",
+dependencies = ["fluent-compiler~=1.1"]
+
+[project.optional-dependencies]
+stubs = ["jinja2>=3.1.0,<4"]
+cli = ["fluentogram[stubs]", "watchdog>=3.0.0"]
+aiogram = ["aiogram~=3.20.0"]
+nats = ["nats-py~=2.10.0"]
+
+dev = [
+ "fluentogram[cli,aiogram,nats]",
+ "ruff~=0.11.2",
+ "pytest~=8.4.1",
+ "pre-commit~=4.2.0",
+ "detect-secrets~=1.5.0",
+ "pytest-asyncio~=1.0.0",
]
+
[project.urls]
Repository = "https://github.com/Arustinal/fluentogram"
[project.scripts]
-i18n = "fluentogram.cli:cli"
+fluentogram = "fluentogram.cli.main:cli"
-[build-system]
-requires = ["setuptools>=42"]
-build-backend = "setuptools.build_meta"
+[tool.ruff]
+exclude = [
+ ".bzr",
+ ".direnv",
+ ".eggs",
+ ".git",
+ ".git-rewrite",
+ ".hg",
+ ".ipynb_checkpoints",
+ ".mypy_cache",
+ ".nox",
+ ".pants.d",
+ ".pyenv",
+ ".pytest_cache",
+ ".pytype",
+ ".ruff_cache",
+ ".svn",
+ ".tox",
+ ".venv",
+ ".vscode",
+ "__pypackages__",
+ "_build",
+ "buck-out",
+ "build",
+ "dist",
+ "node_modules",
+ "site-packages",
+ "venv",
+ "tmp",
+]
+include = ["fluentogram/**/*.py"]
+line-length = 120
+indent-width = 4
+
+[tool.ruff.lint]
+select = ["ALL"]
+ignore = [
+ "D",
+ "RET504",
+ "PGH003",
+ "FBT001",
+ "EM101",
+ "TRY003",
+ "TC003",
+ "ANN401",
+ "ANN003",
+ "TC001",
+ "TC002",
+]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**/*.py" = ["S101"]
+"fluentogram/stub_generator/**/*.py" = ["T201"]
+"fluentogram/cli/**/*.py" = ["T201"]
+
+[tool.ruff.lint.mccabe]
+max-complexity = 8
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
diff --git a/scripts/lint-pre-commit.sh b/scripts/lint-pre-commit.sh
new file mode 100755
index 0000000..cbb6679
--- /dev/null
+++ b/scripts/lint-pre-commit.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+# from: https://jaredkhan.com/blog/mypy-pre-commit
+
+# A script for running mypy,
+# with all its dependencies installed.
+
+set -o errexit
+
+# Change directory to the project root directory.
+cd "$(dirname "$0")"/..
+
+# Install the dependencies into the mypy env.
+# Note that this can take seconds to run.
+# In my case, I need to use a custom index URL.
+# Avoid pip spending time quietly retrying since
+# likely cause of failure is lack of VPN connection.
+pip install ruff
+
+# Run on all files,
+# ignoring the paths passed to this script,
+# so as not to miss type errors.
+# My repo makes use of namespace packages.
+# Use the namespace-packages flag
+# and specify the package to run on explicitly.
+# Note that we do not use --ignore-missing-imports,
+# as this can give us false confidence in our results.
+./scripts/lint.sh
diff --git a/scripts/lint.sh b/scripts/lint.sh
new file mode 100755
index 0000000..4cec36d
--- /dev/null
+++ b/scripts/lint.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+set -e
+
+echo "Running ruff linter (isort, flake, pyupgrade, etc. replacement)..."
+ruff check --fix --exit-non-zero-on-fix
+
+echo "Running ruff formatter (black replacement)..."
+ruff format
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/assets/conflict.ftl b/tests/assets/conflict.ftl
new file mode 100644
index 0000000..fd8bdb1
--- /dev/null
+++ b/tests/assets/conflict.ftl
@@ -0,0 +1,3 @@
+button = Button
+
+button-text = Button text
diff --git a/tests/assets/conflict_in_prefix.ftl b/tests/assets/conflict_in_prefix.ftl
new file mode 100644
index 0000000..ba56868
--- /dev/null
+++ b/tests/assets/conflict_in_prefix.ftl
@@ -0,0 +1,3 @@
+first-unknown-error = first-unknown-error
+
+another-unknown-error = another-unknown-error
diff --git a/tests/assets/locales/en/main.ftl b/tests/assets/locales/en/main.ftl
new file mode 100644
index 0000000..5a04424
--- /dev/null
+++ b/tests/assets/locales/en/main.ftl
@@ -0,0 +1 @@
+hello = Hello, world!
\ No newline at end of file
diff --git a/tests/assets/simple.ftl b/tests/assets/simple.ftl
new file mode 100644
index 0000000..0c3df70
--- /dev/null
+++ b/tests/assets/simple.ftl
@@ -0,0 +1,2 @@
+hello = Hello, world!
+button = Button
\ No newline at end of file
diff --git a/tests/assets/test.ftl b/tests/assets/test.ftl
new file mode 100644
index 0000000..36f9c22
--- /dev/null
+++ b/tests/assets/test.ftl
@@ -0,0 +1,77 @@
+# Simple message
+hello = Hello, world!
+
+# Multiline value
+multiline =
+ This is a multiline message.
+ It continues on multiple lines.
+
+# Variables
+welcome = Hello, { $name }!
+
+# Attributes
+button =
+ .label = Send
+ .accesskey = O
+
+# Selectors
+email-status =
+ { $unreadCount ->
+ [0] You have no new emails.
+ [one] You have { $unreadCount } new email.
+ [few] You have { $unreadCount } new emails.
+ *[other] You have { $unreadCount } new emails.
+ }
+
+# Message reference
+greeting = { hello } This is a phrase with another message.
+
+# Selector by state
+task-state =
+ { $state ->
+ [new] New task
+ [in-progress] In progress
+ [done] Done
+ *[other] Unknown state
+ }
+
+# Function call (for formatting date/numbers)
+formatted-date = Today: { DATETIME($date, month: "long", year: "numeric", day: "numeric") }
+
+# Example with NUMBER and using parameters
+score = You scored { NUMBER($points, minimumFractionDigits: 1) } points
+
+# Example with nested messages
+outer-message = Attachment: { inner-message }
+inner-message = This is a nested message.
+
+# Escaping curly braces
+escaped = This is not a variable: {{ $notAVar }}
+
+# Using terms (terms, starting with -)
+-brand-name = Application X
+about = Information about { -brand-name }
+
+# Term attribute
+-icon =
+ .src = /images/icon.svg
+ .alt = Icon
+
+# Comments
+# This is a regular comment
+## This is a group comment
+### This is a documenting comment
+
+# Combination of everything
+complex-message =
+ Welcome, { $name }!
+ Today { DATETIME($date, weekday: "long") }.
+ You have { $unreadCount ->
+ [0] no new emails.
+ [one] one new email.
+ [few] { $unreadCount } new emails.
+ *[other] { $unreadCount } new emails.
+ }
+ Thank you for using { -brand-name }!
+
+must-be-shielded = "Must be shielded"
\ No newline at end of file
diff --git a/tests/test_basic_usage.py b/tests/test_basic_usage.py
new file mode 100644
index 0000000..b010ce5
--- /dev/null
+++ b/tests/test_basic_usage.py
@@ -0,0 +1,163 @@
+import pytest
+from fluent_compiler.bundle import FluentBundle
+
+from fluentogram import FluentTranslator, TranslatorHub
+from fluentogram.exceptions import FormatError, KeyNotFoundError, RootTranslatorNotFoundError
+
+
+def test_basic_usage() -> None:
+ translator_hub = TranslatorHub(
+ {
+ "en": "en",
+ },
+ [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string("en-US", "start-hello = Hello"),
+ ),
+ ],
+ )
+ translator = translator_hub.get_translator_by_locale("en")
+ assert translator.get("start-hello") == "Hello"
+
+
+def test_basic_usage_with_args() -> None:
+ translator_hub = TranslatorHub(
+ {
+ "en": "en",
+ },
+ [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string("en-US", "start-hello = Hello, { $username }", use_isolating=False),
+ ),
+ ],
+ )
+ translator = translator_hub.get_translator_by_locale("en")
+ assert translator.get("start-hello", username="Alex") == "Hello, Alex"
+
+
+def test_fallback_to_default_locale() -> None:
+ translator_hub = TranslatorHub(
+ {
+ "en": "en",
+ "ru": ("ru", "en"),
+ },
+ [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string("en-US", "start-hello = Hello, { $username }", use_isolating=False),
+ ),
+ ],
+ )
+ translator = translator_hub.get_translator_by_locale("ru")
+ assert translator.get("start-hello", username="Alex") == "Hello, Alex"
+
+
+def test_with_full_locales() -> None:
+ translator_hub = TranslatorHub(
+ {
+ "en": "en",
+ "ru": ("ru", "en"),
+ },
+ [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string(
+ "en-US",
+ "start-hello = Hello, { $username }",
+ use_isolating=False,
+ ),
+ ),
+ FluentTranslator(
+ "ru",
+ translator=FluentBundle.from_string(
+ "ru-RU",
+ "start-hello = Привет, { $username }",
+ use_isolating=False,
+ ),
+ ),
+ ],
+ )
+ translator = translator_hub.get_translator_by_locale("ru")
+ assert translator.get("start-hello", username="Alex") == "Привет, Alex"
+
+
+def test_when_translation_not_found() -> None:
+ translator_hub = TranslatorHub(
+ {
+ "en": "en",
+ },
+ [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string("en-US", "start-hello = Hello", use_isolating=False),
+ ),
+ ],
+ )
+ translator = translator_hub.get_translator_by_locale("en")
+ with pytest.raises(KeyNotFoundError):
+ translator.get("start-hello1", username="Alex")
+
+
+def test_when_root_translator_not_provided() -> None:
+ with pytest.raises(RootTranslatorNotFoundError):
+ TranslatorHub(
+ {
+ "en": "en",
+ },
+ [
+ FluentTranslator(
+ "ru",
+ translator=FluentBundle.from_string("ru-RU", "start-hello = Привет", use_isolating=False),
+ ),
+ ],
+ )
+
+
+def test_formatting_error() -> None:
+ translator_hub = TranslatorHub(
+ {
+ "en": "en",
+ },
+ [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string("en-US", "start-hello = Hello, { $username }", use_isolating=False),
+ ),
+ ],
+ )
+ translator = translator_hub.get_translator_by_locale("en")
+ with pytest.raises(FormatError):
+ translator.get("start-hello", username1="Alex")
+
+def test_get_text_by_attribute() -> None:
+ translator_hub = TranslatorHub(
+ {
+ "en": "en",
+ },
+ [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string("en-US", "start-hello = Hello", use_isolating=False),
+ ),
+ ],
+ )
+ translator = translator_hub.get_translator_by_locale("en")
+ assert translator.start.hello() == "Hello"
+
+def test_get_text_by_attribute_not_found() -> None:
+ translator_hub = TranslatorHub(
+ {
+ "en": "en",
+ },
+ [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string("en-US", "start-hello = Hello", use_isolating=False),
+ ),
+ ],
+ )
+ translator = translator_hub.get_translator_by_locale("en")
+ with pytest.raises(KeyNotFoundError):
+ translator.start.hello1()
diff --git a/tests/test_deprecated_kv_translator.py b/tests/test_deprecated_kv_translator.py
new file mode 100644
index 0000000..cdb3a7c
--- /dev/null
+++ b/tests/test_deprecated_kv_translator.py
@@ -0,0 +1,25 @@
+import pytest
+from fluent_compiler.bundle import FluentBundle
+
+from fluentogram import FluentTranslator
+from fluentogram.src.impl.transator_hubs import KvTranslatorHub
+
+
+def test_deprecated_msg_kv_translator() -> None:
+ with pytest.warns(DeprecationWarning):
+ KvTranslatorHub(
+ {
+ "ru": ("ru", "en"),
+ "en": ("en",),
+ },
+ translators=[
+ FluentTranslator(
+ locale="en",
+ translator=FluentBundle.from_string(locale="en-US", text="hello = Hello {$name}!"),
+ ),
+ FluentTranslator(
+ locale="ru",
+ translator=FluentBundle.from_string(locale="ru", text="hello = Привет {$name}!"),
+ ),
+ ],
+ )
diff --git a/tests/test_file_storage.py b/tests/test_file_storage.py
new file mode 100644
index 0000000..34afd83
--- /dev/null
+++ b/tests/test_file_storage.py
@@ -0,0 +1,42 @@
+import pytest
+from fluent_compiler.bundle import FluentBundle
+
+from fluentogram.exceptions import LocalesNotFoundError
+from fluentogram.storage import FileStorage
+from fluentogram.translator import FluentTranslator
+from fluentogram.translator_hub import TranslatorHub
+
+
+def test_file_storage() -> None:
+ storage = FileStorage("tests/assets/locales/{locale}/")
+ hub = TranslatorHub(
+ {
+ "en": "en",
+ },
+ storage=storage,
+ )
+ translator = hub.get_translator_by_locale("en")
+ assert translator.get("hello") == "Hello, world!"
+
+
+def test_file_storage_if_translators_passed() -> None:
+ storage = FileStorage("tests/assets/locales/{locale}/")
+ hub = TranslatorHub(
+ {
+ "en": "en",
+ },
+ translators=[
+ FluentTranslator(
+ locale="en",
+ translator=FluentBundle.from_string("en", "hello = Hello, world1!"),
+ ),
+ ],
+ storage=storage,
+ )
+ translator = hub.get_translator_by_locale("en")
+ assert translator.get("hello") == "Hello, world1!"
+
+
+def test_file_storage_if_no_locales_found() -> None:
+ with pytest.raises(LocalesNotFoundError):
+ FileStorage("tests")
diff --git a/tests/test_generator.py b/tests/test_generator.py
new file mode 100644
index 0000000..69d8b60
--- /dev/null
+++ b/tests/test_generator.py
@@ -0,0 +1,93 @@
+import tempfile
+from pathlib import Path
+
+from fluentogram.stub_generator.generator import generate
+
+
+def test_correctly_generated_stub_for_simple_file() -> None:
+ with tempfile.NamedTemporaryFile(suffix=".pyi", delete=False) as tmp_file:
+ output_path = tmp_file.name
+
+ generate(output_path, file_path="tests/assets/simple.ftl")
+
+ assert Path(output_path).exists()
+ content = Path(output_path).read_text()
+
+ assert "class TranslatorRunner:" in content
+ assert 'def hello() -> Literal["""Hello, world!"""]: ...' in content
+ assert 'def button() -> Literal["""Button"""]: ...' in content
+
+
+def test_generator_if_conflict() -> None:
+ with tempfile.NamedTemporaryFile(suffix=".pyi", delete=False) as tmp_file:
+ output_path = tmp_file.name
+
+ generate(output_path, file_path="tests/assets/conflict.ftl")
+
+ assert Path(output_path).exists()
+ content = Path(output_path).read_text()
+
+ assert "class TranslatorRunner:" in content
+ assert "class Button" in content
+ assert 'def __call__() -> Literal["""Button"""]: ...' in content
+ assert 'def text() -> Literal["""Button text"""]: ...' in content
+
+
+def test_correctly_generated_stub() -> None:
+ with tempfile.NamedTemporaryFile(suffix=".pyi", delete=False) as tmp_file:
+ output_path = tmp_file.name
+
+ generate(output_path, file_path="tests/assets/test.ftl")
+
+ assert Path(output_path).exists()
+ content = Path(output_path).read_text()
+
+ assert "class TranslatorRunner:" in content
+ assert "def get(self, path: str, **kwargs: PossibleValue) -> str: ..." in content
+ assert 'def hello() -> Literal["""Hello, world!"""]: ...' in content
+ assert 'def multiline() -> Literal["""This is a multiline message.' in content
+ assert 'def welcome(*, name: PossibleValue) -> Literal["""Hello, { $name }!"""]: ...' in content
+ assert "class Email:" in content
+ assert (
+ 'def status(*, unreadCount: PossibleValue) -> Literal["""You have { $unreadCount } new emails."""]: ...'
+ in content
+ )
+ assert 'def greeting() -> Literal["""Hello, world! This is a phrase with another message."""]: ...' in content
+ assert "class Task:" in content
+ assert 'def state() -> Literal["""Unknown state"""]: ...' in content
+ assert "class Formatted:" in content
+ assert 'def date(*, date: PossibleValue) -> Literal["""Today: { $date }"""]: ...' in content
+ assert 'def score(*, points: PossibleValue) -> Literal["""You scored { $points } points"""]: ...' in content
+ assert "class Outer:" in content
+ assert 'def message() -> Literal["""Attachment: This is a nested message."""]: ...' in content
+ assert "class Inner:" in content
+ assert 'def message() -> Literal["""This is a nested message.This is a nested message."""]: ...' in content
+ assert (
+ 'def escaped(*, notAVar: PossibleValue) -> Literal["""This is not a variable: { $notAVar }"""]: ...' in content
+ )
+ assert 'def about() -> Literal["""Information about Application X"""]: ...' in content
+ assert "class Complex:" in content
+ assert (
+ '''def message(*, date: PossibleValue, name: PossibleValue, unreadCount: PossibleValue) -> Literal["""Welcome, { $name }!
+Today { $date }.
+You have { $unreadCount } new emails.
+Thank you for using Application X!"""]: ...'''
+ in content
+ )
+ assert 'def shielded() -> Literal[""""Must be shielded""""]: ...' in content
+
+
+def test_generator_if_conflict_in_prefix() -> None:
+ with tempfile.NamedTemporaryFile(suffix=".pyi", delete=False) as tmp_file:
+ output_path = tmp_file.name
+
+ generate(output_path, file_path="tests/assets/conflict_in_prefix.ftl")
+
+ assert Path(output_path).exists()
+ content = Path(output_path).read_text()
+
+ assert "class TranslatorRunner:" in content
+ assert "class FirstUnknown" in content
+ assert "class AnotherUnknown" in content
+ assert 'def error() -> Literal["""first-unknown-error"""]: ...' in content
+ assert 'def error() -> Literal["""another-unknown-error"""]: ...' in content
diff --git a/tests/test_transformers.py b/tests/test_transformers.py
new file mode 100644
index 0000000..ba5c73b
--- /dev/null
+++ b/tests/test_transformers.py
@@ -0,0 +1,53 @@
+from datetime import datetime, timezone
+from decimal import Decimal
+
+from fluent_compiler.bundle import FluentBundle
+
+from fluentogram import DateTimeTransformer, FluentTranslator, MoneyTransformer, TranslatorHub
+
+
+def test_date_transformer() -> None:
+ translators = [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string(
+ "en-US",
+ "meeting-time = Meeting scheduled for { $date }",
+ use_isolating=False,
+ ),
+ ),
+ ]
+
+ hub = TranslatorHub({"en": "en"}, translators)
+ translator = hub.get_translator_by_locale("en")
+
+ # Use DateTimeTransformer for proper date formatting
+ meeting_date = datetime(2024, 1, 15, 14, 30, tzinfo=timezone.utc)
+ formatted_date = DateTimeTransformer(meeting_date, dateStyle="full", timeStyle="short")
+ assert (
+ translator.get(
+ "meeting-time",
+ date=formatted_date,
+ )
+ == "Meeting scheduled for Monday, January 15, 2024, 2:30 PM"
+ )
+
+
+def test_money_transformer() -> None:
+ translators = [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string(
+ "en-US",
+ "amount-message = You have { $amount }",
+ use_isolating=False,
+ ),
+ ),
+ ]
+
+ hub = TranslatorHub({"en": ("en")}, translators)
+ translator = hub.get_translator_by_locale("en")
+
+ amount = Decimal("123456.78")
+ formatted_amount = MoneyTransformer(amount, currency="USD", currency_display="symbol")
+ assert translator.get("amount-message", amount=formatted_amount) == "You have $123456.78"
diff --git a/tests/test_update.py b/tests/test_update.py
new file mode 100644
index 0000000..b47a5a3
--- /dev/null
+++ b/tests/test_update.py
@@ -0,0 +1,50 @@
+import pytest
+
+from fluent_compiler.bundle import FluentBundle
+
+from fluentogram import FluentTranslator, TranslatorHub
+
+
+@pytest.mark.asyncio
+async def test_update_translation() -> None:
+ translator_hub = TranslatorHub(
+ {
+ "en": "en",
+ },
+ [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string("en-US", "start-hello = Hello"),
+ ),
+ ],
+ )
+ translator = translator_hub.get_translator_by_locale("en")
+ assert translator.get("start-hello") == "Hello"
+
+ await translator_hub.storage.update_translation("en", "start-hello1", "Hello123")
+ assert translator.get("start-hello1") == "Hello123"
+
+
+@pytest.mark.asyncio
+async def test_update_translation_after_fallback() -> None:
+ translator_hub = TranslatorHub(
+ {
+ "en": "en",
+ "ru": ("ru", "en"),
+ },
+ [
+ FluentTranslator(
+ "en",
+ translator=FluentBundle.from_string("en-US", "start-hello1 = Hello1"),
+ ),
+ FluentTranslator(
+ "ru",
+ translator=FluentBundle.from_string("ru-RU", "start-hello = Привет"),
+ ),
+ ],
+ )
+ translator = translator_hub.get_translator_by_locale("ru")
+ assert translator.get("start-hello1") == "Hello1"
+
+ await translator_hub.storage.update_translation("ru", "start-hello1", "Привет1")
+ assert translator.get("start-hello1") == "Привет1"