Skip to content

resoltico/FTLLexEngine

Repository files navigation

FTLLexEngine Art


PyPI Python Versions codecov License: MIT


FTLLexEngine

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.


Why FTLLexEngine?

  • 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 - Decimal precision throughout. No float rounding surprises
  • Introspectable - Query what variables a message needs before you call it
  • Declarative grammar - Plurals, gender, cases in .ftl files. Code stays clean

Quickstart

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

Table of Contents


Installation

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 ftllexengine

Works 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)

Your Cafe Speaks Every Language

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.


Customers Type Prices. You Get Decimals.

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"

Financial Calculations Stay Exact

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 time

Concurrent Requests? No Problem.

Your 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

Know What Your Messages Need

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")
# True

Use 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

When to Use FTLLexEngine

Use It When:

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.

Use Something Simpler When:

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

Documentation

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

Contributing

Contributions welcome. See CONTRIBUTING.md for setup and guidelines.


License

MIT License - See LICENSE.

Implements the Fluent Specification (Apache 2.0).

Legal: PATENTS.md | NOTICE

About

Fluent (FTL) implementation with locale-aware parsing for numbers, dates, and currency

Topics

Resources

License

Contributing

Stars

Watchers

Forks