Metadata-Version: 2.4
Name: flask-babel-locale-chain
Version: 1.0.0
Summary: Configurable locale fallback chains for Flask-Babel
Author-email: "i18nagent.ai" <hello@i18nagent.ai>
License-Expression: MIT
Project-URL: Homepage, https://github.com/i18n-agent/flask-babel-locale-chain
Project-URL: Repository, https://github.com/i18n-agent/flask-babel-locale-chain
Project-URL: Documentation, https://github.com/i18n-agent/flask-babel-locale-chain#readme
Project-URL: Changelog, https://github.com/i18n-agent/flask-babel-locale-chain/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/i18n-agent/flask-babel-locale-chain/issues
Keywords: flask,babel,i18n,locale,fallback,translation
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Web Environment
Classifier: Framework :: Flask
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Internationalization
Classifier: Topic :: Software Development :: Localization
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Flask>=2.0
Requires-Dist: Flask-Babel>=3.0
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Dynamic: license-file

# flask-babel-locale-chain

Smart locale fallback chains for Flask-Babel -- because pt-BR users deserve pt-PT, not English.

## The Problem

Flask-Babel's translation system falls back directly to the app's `BABEL_DEFAULT_LOCALE` when a regional locale variant is missing. There is no intermediate fallback.

**Example:** A user's browser sends `Accept-Language: pt-BR`. Your Flask app has `pt-PT` translations but no `pt-BR` locale. Flask-Babel skips `pt-PT` entirely and shows English (or whatever your `BABEL_DEFAULT_LOCALE` is).

The same thing happens with `es-MX` -> `es`, `fr-CA` -> `fr`, `de-AT` -> `de`, and every other regional variant.

Your users see English when a perfectly good translation exists in a sibling locale.

## The Solution

One extension. Zero changes to your existing translation code.

`flask-babel-locale-chain` wraps Flask-Babel with fallback chain support using Python's native `gettext.GNUTranslations.add_fallback` mechanism. Missing keys in the primary locale catalogue are resolved from fallback locales before reaching the app's default language. Your existing translation calls just work:

- `_("key")` and `gettext("key")` in Python code
- `{{ _("key") }}` in Jinja2 templates
- `ngettext()` pluralization
- All Flask-Babel translation functions

## Installation

```bash
pip install flask-babel-locale-chain
```

## Quick Start

### 1. Install the package

```bash
pip install flask-babel-locale-chain
```

### 2. Initialize the extension

```python
from flask import Flask
from flask_locale_chain import LocaleChainBabel

app = Flask(__name__)
lcb = LocaleChainBabel(app)
```

That's it. All 75 default fallback chains are active. A `pt-BR` user will now see `pt-PT` translations when `pt-BR` is not available.

### 3. (Optional) Add custom chains via Flask config

```python
# config.py or in your app factory

app.config["LOCALE_FALLBACK_CHAINS"] = {
    "pt-BR": ["pt-PT", "pt"],
    "es-MX": ["es-419", "es"],
    "fr-CA": ["fr"],
}
```

Your custom chains are merged with the defaults. Keys you specify replace the corresponding default chain.

### 4. Use `_()` / `gettext()` as normal

```python
from flask_babel import gettext as _

@app.route("/")
def index():
    return _("greeting")  # Falls back through the chain automatically
```

## App Factory Pattern

`flask-babel-locale-chain` supports Flask's application factory pattern with `init_app()`:

```python
from flask import Flask
from flask_locale_chain import LocaleChainBabel

lcb = LocaleChainBabel()

def create_app():
    app = Flask(__name__)
    app.config["BABEL_DEFAULT_LOCALE"] = "en"
    app.config["LOCALE_FALLBACK_CHAINS"] = {
        "pt-BR": ["pt-PT", "pt"],
    }
    lcb.init_app(app)
    return app
```

You can also pass an existing `Babel` instance if you need to configure Babel separately:

```python
from flask_babel import Babel
from flask_locale_chain import LocaleChainBabel

babel = Babel()
lcb = LocaleChainBabel()

def create_app():
    app = Flask(__name__)
    babel.init_app(app)
    lcb.init_app(app, babel=babel)
    return app
```

## Configuration Modes

### Default (zero config)

Just create the extension. Uses all 75 built-in fallback chains covering Chinese, Portuguese, Spanish, French, German, Italian, Dutch, English, Arabic, Norwegian, and Malay regional variants.

```python
lcb = LocaleChainBabel(app)
```

### Flask config

```python
# Custom chains merged with defaults
app.config["LOCALE_FALLBACK_CHAINS"] = {
    "pt-BR": ["pt-PT", "pt"],
    "ja-JP": ["ja"],
}
```

### Programmatic API

```python
from flask_locale_chain import configure, reset

# Zero-config -- all 75 default chains
configure()

# Override specific chains while keeping all defaults
configure(overrides={"pt-BR": ["pt"]})

# Full custom map, merged with defaults
configure(fallbacks={"ja-JP": ["ja"]})

# Full custom map, no defaults
configure(fallbacks={"pt-BR": ["pt-PT"]}, merge_defaults=False)

# Restore default behaviour
reset()
```

## API Reference

### `LocaleChainBabel(app=None, babel=None)`

Flask extension class.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `app` | `Flask \| None` | `None` | Flask application. If provided, `init_app()` is called immediately. |
| `babel` | `Babel \| None` | `None` | Existing Babel instance. A new one is created if not provided. |

#### `init_app(app, babel=None)`

Initialize the extension with a Flask application. Reads `LOCALE_FALLBACK_CHAINS` from `app.config` and merges with built-in defaults.

#### `get_chain(locale) -> list[str]`

Return the fallback chain for a given locale code. Returns an empty list if no chain is configured.

### `configure(overrides=None, fallbacks=None, merge_defaults=True, default_locale="en")`

Activate chain-aware translation lookup (programmatic API).

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `overrides` | `dict \| None` | `None` | Additional or replacement chains merged on top of defaults |
| `fallbacks` | `dict \| None` | `None` | A complete fallback map |
| `merge_defaults` | `bool` | `True` | Whether to include defaults when `fallbacks` is supplied |
| `default_locale` | `str` | `"en"` | The project's default locale |

### `reset()`

Remove all custom configuration and restore default behaviour. Safe to call multiple times.

### `DEFAULT_FALLBACKS`

A `dict[str, list[str]]` containing all 75 built-in fallback chains. Importable for inspection or as a base for custom maps.

### `merge_fallbacks(overrides=None, base=None, merge_defaults=True)`

Merge two fallback maps, returning a new dict. Entries in `overrides` replace same-key entries in `base`. Neither input is mutated.

## Flask Config Reference

| Setting | Type | Description |
|---------|------|-------------|
| `LOCALE_FALLBACK_CHAINS` | `dict[str, list[str]]` | Custom fallback chains, merged with built-in defaults |

**Priority order** (highest to lowest):

1. `configure()` call (programmatic API)
2. `LOCALE_FALLBACK_CHAINS` Flask config setting
3. Built-in defaults (zero-config)

## Default Fallback Map

### Chinese (Traditional)

| Locale | Fallback Chain |
|--------|---------------|
| zh-Hant-HK | zh-Hant-TW -> zh-Hant -> (default) |
| zh-Hant-MO | zh-Hant-HK -> zh-Hant-TW -> zh-Hant -> (default) |
| zh-Hant-TW | zh-Hant -> (default) |

### Chinese (Simplified)

| Locale | Fallback Chain |
|--------|---------------|
| zh-Hans-SG | zh-Hans -> (default) |
| zh-Hans-MY | zh-Hans -> (default) |

### Portuguese

| Locale | Fallback Chain |
|--------|---------------|
| pt-BR | pt-PT -> pt -> (default) |
| pt-PT | pt -> (default) |
| pt-AO | pt-PT -> pt -> (default) |
| pt-MZ | pt-PT -> pt -> (default) |

### Spanish

| Locale | Fallback Chain |
|--------|---------------|
| es-419 | es -> (default) |
| es-MX | es-419 -> es -> (default) |
| es-AR | es-419 -> es -> (default) |
| es-CO | es-419 -> es -> (default) |
| es-CL | es-419 -> es -> (default) |
| es-PE | es-419 -> es -> (default) |
| es-VE | es-419 -> es -> (default) |
| es-EC | es-419 -> es -> (default) |
| es-GT | es-419 -> es -> (default) |
| es-CU | es-419 -> es -> (default) |
| es-BO | es-419 -> es -> (default) |
| es-DO | es-419 -> es -> (default) |
| es-HN | es-419 -> es -> (default) |
| es-PY | es-419 -> es -> (default) |
| es-SV | es-419 -> es -> (default) |
| es-NI | es-419 -> es -> (default) |
| es-CR | es-419 -> es -> (default) |
| es-PA | es-419 -> es -> (default) |
| es-UY | es-419 -> es -> (default) |
| es-PR | es-419 -> es -> (default) |

### French

| Locale | Fallback Chain |
|--------|---------------|
| fr-CA | fr -> (default) |
| fr-BE | fr -> (default) |
| fr-CH | fr -> (default) |
| fr-LU | fr -> (default) |
| fr-MC | fr -> (default) |
| fr-SN | fr -> (default) |
| fr-CI | fr -> (default) |
| fr-ML | fr -> (default) |
| fr-CM | fr -> (default) |
| fr-MG | fr -> (default) |
| fr-CD | fr -> (default) |

### German

| Locale | Fallback Chain |
|--------|---------------|
| de-AT | de -> (default) |
| de-CH | de -> (default) |
| de-LU | de -> (default) |
| de-LI | de -> (default) |

### Italian

| Locale | Fallback Chain |
|--------|---------------|
| it-CH | it -> (default) |

### Dutch

| Locale | Fallback Chain |
|--------|---------------|
| nl-BE | nl -> (default) |

### English

| Locale | Fallback Chain |
|--------|---------------|
| en-GB | en -> (default) |
| en-AU | en-GB -> en -> (default) |
| en-NZ | en-AU -> en-GB -> en -> (default) |
| en-IN | en-GB -> en -> (default) |
| en-CA | en -> (default) |
| en-ZA | en-GB -> en -> (default) |
| en-IE | en-GB -> en -> (default) |
| en-SG | en-GB -> en -> (default) |

### Arabic

| Locale | Fallback Chain |
|--------|---------------|
| ar-SA | ar -> (default) |
| ar-EG | ar -> (default) |
| ar-AE | ar -> (default) |
| ar-MA | ar -> (default) |
| ar-DZ | ar -> (default) |
| ar-IQ | ar -> (default) |
| ar-KW | ar -> (default) |
| ar-QA | ar -> (default) |
| ar-BH | ar -> (default) |
| ar-OM | ar -> (default) |
| ar-JO | ar -> (default) |
| ar-LB | ar -> (default) |
| ar-TN | ar -> (default) |
| ar-LY | ar -> (default) |
| ar-SD | ar -> (default) |
| ar-YE | ar -> (default) |

### Norwegian

| Locale | Fallback Chain |
|--------|---------------|
| nb | no -> (default) |
| nn | nb -> no -> (default) |

### Malay

| Locale | Fallback Chain |
|--------|---------------|
| ms-MY | ms -> (default) |
| ms-SG | ms -> (default) |
| ms-BN | ms -> (default) |

## Example

A complete working example is included in the `example/` directory. It demonstrates locale fallback chains with Portuguese (pt-BR -> pt-PT -> pt) and Chinese (zh-Hant-HK -> zh-Hant-TW -> zh-Hant) variants.

```bash
cd example && pip install -r requirements.txt && python app.py
```

Then visit `http://localhost:5000/?lang=pt-BR` to see the fallback in action.

## How It Works

1. `LocaleChainBabel` wraps Flask-Babel's `Babel` extension and reads `LOCALE_FALLBACK_CHAINS` from your Flask config.
2. The resolved fallback chains are merged with the 75 built-in defaults.
3. When a translation key is missing in the active locale, the chain is walked in order -- each fallback locale's catalogue is checked before reaching the app's default locale.
4. The fallback resolution uses Python's native `gettext.GNUTranslations.add_fallback` mechanism -- no monkey-patching.
5. Your existing `_()` / `gettext()` / `ngettext()` calls work unchanged.

## FAQ

**Is this production-ready?**
Yes. The library uses Python's native `gettext.GNUTranslations.add_fallback` mechanism and Flask-Babel's public API. No monkey-patching, no private API access.

**Performance impact?**
Negligible. Fallback catalogues are loaded once per locale. After the initial wiring, `gettext()` calls walk the fallback chain with zero additional overhead from this library -- it is Python's built-in gettext resolution.

**Does it work with `.po` and `.mo` files?**
Yes. This library operates on Flask-Babel's translation catalogues, which are compiled from `.po` files into `.mo` files. Any translation format that Flask-Babel supports will work.

**Can I use a non-English default locale?**
Yes. The fallback chains are independent of your app's `BABEL_DEFAULT_LOCALE`. They only control which sibling locales are checked before the default language.

**Can I deactivate it?**
Yes. Call `reset()` to remove all custom configuration, or simply remove the `LocaleChainBabel` extension from your app.

**Does it work with Flask blueprints?**
Yes. Flask-Babel's translation system is app-wide, so fallback chains apply to all blueprints registered on the app.

**Does it work with the app factory pattern?**
Yes. Use `lcb = LocaleChainBabel()` and then `lcb.init_app(app)` in your factory function.

**Minimum Python version?**
Python 3.8. Works with Flask 2.0+ and Flask-Babel 3.0+.

## Contributing

- Open issues for bugs or feature requests.
- PRs welcome, especially for adding new locale fallback chains.
- Run tests with: `pytest`

## License

MIT License - see [LICENSE](LICENSE) file.

Built by [i18nagent.ai](https://i18nagent.ai)
