Declarative localization for Python. Plurals, grammar, and formatting in .ftl files - not your code.
"1 coffee" or "5 coffees" - simple in English. Polish has 4 plural forms. Arabic has 6. FTLLexEngine handles them declaratively so your code stays clean.
But it goes further: bidirectional parsing. Your customer types "1 234,56" in France or "1,234.56" in the US - FTLLexEngine parses both to Decimal('1234.56'). Errors come back as data, not exceptions.
Built on the Fluent specification that powers Firefox. 200+ locales via Unicode CLDR. Thread-safe.
- Bidirectional - Format data for display and parse user input back to Python types
- Thread-safe - No global state. Serve 1000 concurrent requests without locale conflicts
- Financial-grade -
Decimalprecision throughout. No float rounding surprises - Introspectable - Query what variables a message needs before you call it
- Declarative grammar - Plurals, gender, cases in
.ftlfiles. Code stays clean
from ftllexengine import FluentBundle
bundle = FluentBundle("en_US")
bundle.add_resource("""
order = { $count ->
[one] 1 coffee
*[other] { $count } coffees
}
""")
result, _ = bundle.format_pattern("order", {"count": 5})
# "5 coffees"Parse user input back to Python types:
from ftllexengine.parsing import parse_decimal
# French customer enters a price
amount, errors = parse_decimal("1 234,56", "fr_FR")
# amount = Decimal('1234.56') - not a float, not an exception
if errors:
print(errors[0]) # Structured error with input, locale, parse type- Installation
- Your Cafe Speaks Every Language
- Customers Type Prices. You Get Decimals.
- Concurrent Requests? No Problem.
- Know What Your Messages Need
- When to Use FTLLexEngine
- Documentation
- Contributing
- License
pip install ftllexengine[babel]Or with uv:
uv add ftllexengine[babel]Requirements: Python >= 3.13 | Babel >= 2.17
Parser-only installation (no Babel dependency)
pip install ftllexengineWorks without Babel:
- FTL syntax parsing (
parse_ftl(),serialize_ftl()) - AST manipulation and transformation
- Validation and introspection
Requires Babel:
FluentBundle(locale-aware formatting)FluentLocalization(multi-locale fallback)- Bidirectional parsing (numbers, dates, currency)
You run a coffee shop. "1 coffee" or "5 coffees" - simple in English. But your app goes global.
The problem: Polish has four plural forms. Arabic has six. Your if-statements turn into spaghetti.
The solution: Move grammar rules to .ftl files. Your code just passes data.
cafe.ftl
order-message = { $count ->
[0] no coffees ordered
[one] 1 coffee ordered
*[other] { $count } coffees ordered
}
total = Total: { CURRENCY($amount, currency: "USD") }from pathlib import Path
from decimal import Decimal
from ftllexengine import FluentBundle
bundle = FluentBundle("en_US")
bundle.add_resource(Path("cafe.ftl").read_text())
result, _ = bundle.format_pattern("order-message", {"count": 5})
# "5 coffees ordered"
result, _ = bundle.format_pattern("total", {"amount": Decimal("18.75")})
# "Total: $18.75"Now add Polish - four plural forms, zero code changes:
cafe_pl.ftl
order-message = { $count ->
[0] brak kaw
[one] 1 kawa
[few] { $count } kawy
[many] { $count } kaw
*[other] { $count } kawy
}bundle = FluentBundle("pl_PL")
bundle.add_resource(Path("cafe_pl.ftl").read_text())
result, _ = bundle.format_pattern("order-message", {"count": 5})
# "5 kaw"
result, _ = bundle.format_pattern("order-message", {"count": 2})
# "2 kawy"Japanese? Same pattern, different script:
cafe_ja.ftl
order-message = { $count ->
[0] コーヒーの注文なし
[one] コーヒー1杯
*[other] コーヒー{ $count }杯
}bundle = FluentBundle("ja_JP")
bundle.add_resource(Path("cafe_ja.ftl").read_text())
result, _ = bundle.format_pattern("order-message", {"count": 3})
# "コーヒー3杯"German, Spanish, Arabic - same pattern. Translators edit .ftl files. Developers ship features.
A customer in Germany types their tip: "5,00". A customer in the US types "5.00". Both mean five dollars.
Most libraries only format outbound - they turn your data into display strings. FTLLexEngine works both directions.
from decimal import Decimal
from ftllexengine.parsing import parse_currency, parse_decimal, parse_date
# American customer types a tip
tip_result, errors = parse_currency("$5.00", "en_US", default_currency="USD")
if not errors:
tip, currency = tip_result # (Decimal('5.00'), 'USD')
# German customer types a price
price, errors = parse_decimal("1.234,56", "de_DE")
# Decimal('1234.56')
# French format: space for thousands, comma for decimal
price, errors = parse_decimal("1 234,56", "fr_FR")
# Decimal('1234.56')
# Dates work too
date_val, errors = parse_date("Jan 15, 2026", "en_US")
# datetime.date(2026, 1, 15)When parsing fails, you get errors - not exceptions:
price, errors = parse_decimal("five fifty", "en_US")
# price = None
# errors = (FluentParseError(...),)
if errors:
err = errors[0]
print(err) # "Failed to parse decimal 'five fifty' for locale 'en_US': ..."
# Structured data for programmatic handling
err.input_value # "five fifty"
err.locale_code # "en_US"
err.parse_type # "decimal"Your cafe calculates bills. Float math fails you: 0.1 + 0.2 = 0.30000000000000004.
FTLLexEngine uses Decimal throughout:
from decimal import Decimal
from ftllexengine.parsing import parse_currency
tip_result, errors = parse_currency("$5.00", "en_US", default_currency="USD")
if not errors:
tip, currency = tip_result # (Decimal('5.00'), 'USD')
subtotal = Decimal("13.50") # 3 espressos at $4.50
tax = subtotal * Decimal("0.08") # 8% tax
total = subtotal + tax + tip
# Decimal('19.58') - exact, every timeYour cafe gets busy. Flask, FastAPI, Django - concurrent requests, each customer in a different locale.
The problem: Python's locale module uses global state. Thread A sets German, Thread B reads it, chaos ensues.
The solution: FTLLexEngine bundles are isolated. No global state. No locks you manage. No race conditions.
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal
from ftllexengine import FluentBundle
# Create locale-specific bundles (typically done once at startup)
us_bundle = FluentBundle("en_US")
de_bundle = FluentBundle("de_DE")
jp_bundle = FluentBundle("ja_JP")
ftl_source = "receipt = Total: { CURRENCY($amount, currency: \"USD\") }"
us_bundle.add_resource(ftl_source)
de_bundle.add_resource(ftl_source)
jp_bundle.add_resource(ftl_source)
def format_receipt(bundle, amount):
result, _ = bundle.format_pattern("receipt", {"amount": amount})
return result
with ThreadPoolExecutor(max_workers=100) as executor:
futures = [
executor.submit(format_receipt, us_bundle, Decimal("1234.50")), # "Total: $1,234.50"
executor.submit(format_receipt, de_bundle, Decimal("1234.50")), # "Total: 1.234,50 $"
executor.submit(format_receipt, jp_bundle, Decimal("1234.50")), # "Total: $1,234.50"
]
receipts = [f.result() for f in futures]FluentBundle is thread-safe by design:
- Multiple threads can format messages simultaneously (read lock)
- Adding resources or functions acquires exclusive access (write lock)
- You don't manage any of this - it just works
Your AI agent generates order confirmations. Before it calls format_pattern(), it needs to know: what variables does this message require?
from ftllexengine import FluentBundle
bundle = FluentBundle("en_US")
bundle.add_resource("""
order-confirmation = { $customer_name }, your order of { $quantity }
{ $quantity ->
[one] coffee
*[other] coffees
} is ready. Total: { CURRENCY($total, currency: "USD") }
""")
info = bundle.introspect_message("order-confirmation")
info.get_variable_names()
# frozenset({'customer_name', 'quantity', 'total'})
info.get_function_names()
# frozenset({'CURRENCY'})
info.has_selectors
# True (uses plural selection)
info.requires_variable("customer_name")
# TrueUse cases:
- AI agents verify they have all required data before formatting
- Form builders auto-generate input fields from message templates
- Linters catch missing variables at build time, not runtime
| Scenario | Why FTLLexEngine |
|---|---|
| Parsing user input | Errors as data, not exceptions. Show helpful feedback. |
| Financial calculations | Decimal precision. No float rounding bugs. |
| Web servers | Thread-safe. No global locale state. |
| Complex plurals | Polish has 4 forms. Arabic has 6. Handle them declaratively. |
| Multi-locale apps | 200+ locales. CLDR-compliant. |
| AI integrations | Introspect messages before formatting. |
| Content/code separation | Translators edit .ftl files. Developers ship code. |
| Scenario | Why Skip It |
|---|---|
| Single locale, no user input | f"{value:,.2f}" is enough |
| No grammar logic | No plurals, no conditionals |
| Zero dependencies required | You need pure stdlib |
| Resource | Description |
|---|---|
| Quick Reference | Copy-paste patterns for common tasks |
| API Reference | Complete class and function documentation |
| Parsing Guide | Bidirectional parsing deep-dive |
| Terminology | Fluent and FTLLexEngine concepts |
| Examples | Working code you can run |
Contributions welcome. See CONTRIBUTING.md for setup and guidelines.
MIT License - See LICENSE.
Implements the Fluent Specification (Apache 2.0).
Legal: PATENTS.md | NOTICE
