Metadata-Version: 2.4
Name: django-locale-chain
Version: 1.0.0
Summary: Configurable locale fallback chains for Django's i18n system
Author-email: "i18nagent.ai" <hello@i18nagent.ai>
License-Expression: MIT
Project-URL: Homepage, https://github.com/i18n-agent/django-locale-chain
Project-URL: Repository, https://github.com/i18n-agent/django-locale-chain
Project-URL: Documentation, https://github.com/i18n-agent/django-locale-chain#readme
Project-URL: Changelog, https://github.com/i18n-agent/django-locale-chain/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/i18n-agent/django-locale-chain/issues
Keywords: django,i18n,locale,fallback,translation
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
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.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=4.2
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Requires-Dist: pytest-django>=4.5; extra == "test"
Dynamic: license-file

# django-locale-chain

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

## The Problem

Django's translation system falls back directly to the project's `LANGUAGE_CODE` when a regional locale variant is missing. There is no intermediate fallback. This is a [long-standing limitation](https://code.djangoproject.com/ticket/28636) (Django Ticket #28636).

**Example:** A user's browser sends `Accept-Language: pt-BR`. Your Django project has `pt-PT` translations but no `pt-BR` locale. Django skips `pt-PT` entirely and shows English (or whatever your `LANGUAGE_CODE` 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 middleware. Zero changes to your existing translation code.

`django-locale-chain` installs gettext fallback chains using Python's native `gettext.GNUTranslations.add_fallback` mechanism. Missing keys in the primary locale catalogue are resolved from fallback locales before reaching the project's default language. Your existing translation calls just work:

- `{% trans "key" %}` in templates
- `gettext("key")` and `_("key")` in Python code
- `ngettext()` pluralization
- All `django.utils.translation` functions

## Installation

```bash
pip install django-locale-chain
```

## Quick Start

### 1. Add the middleware

Add `LocaleChainMiddleware` to your `MIDDLEWARE` setting **after** Django's `LocaleMiddleware`:

```python
# settings.py

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",
    "django_locale_chain.middleware.LocaleChainMiddleware",  # <-- add this
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]
```

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.

### 2. (Optional) Configure custom chains via settings

```python
# settings.py

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.

### 3. (Optional) Configure programmatically

```python
# In your AppConfig.ready() hook
from django_locale_chain import configure

class MyAppConfig(AppConfig):
    name = "myapp"

    def ready(self):
        configure()
```

## Configuration Modes

### Default (zero config)

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

### Django settings

```python
# settings.py

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

### Programmatic API

```python
from django_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

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

Activate locale fallback chains programmatically.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `overrides` | `dict \| None` | `None` | Additional or replacement chains merged on top of defaults. Ignored when `fallbacks` is provided. |
| `fallbacks` | `dict \| None` | `None` | A complete fallback map. When given, `overrides` is ignored. |
| `merge_defaults` | `bool` | `True` | Whether to include the 75 built-in defaults as a base layer |

Returns the resolved `dict[str, list[str]]` fallback map.

### `reset()`

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

### `LocaleChainMiddleware`

Django middleware class. Place **after** `django.middleware.locale.LocaleMiddleware` in `MIDDLEWARE`. Reads configuration from `configure()`, the `LOCALE_FALLBACK_CHAINS` setting, or the built-in defaults (in that priority order).

### `DEFAULT_FALLBACKS`

A `dict[str, list[str]]` containing all 75 built-in fallback chains. Importable from `django_locale_chain.fallback_map` 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.

## Django Settings 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` Django setting
3. Built-in defaults (zero-config)

## Default Fallback Map

### Chinese (Traditional)

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

### Chinese (Simplified)

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

### Portuguese

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

### Spanish

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

### French

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

### German

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

### Italian

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

### Dutch

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

### English

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

### Arabic

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

### Norwegian

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

### Malay

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

## Example

A working Django project is included in the `example/` directory:

```bash
cd example && pip install -r requirements.txt && python manage.py runserver 8111
```

Then test the fallback chain with:

```bash
curl -H "Accept-Language: pt-BR" http://localhost:8111/
```

See `example/README.md` for full details.

## How It Works

1. Django's `LocaleMiddleware` runs first and activates the user's preferred language via `translation.activate()`.
2. `LocaleChainMiddleware` reads the active language and looks up its fallback chain.
3. For each fallback locale in the chain, a `DjangoTranslation` catalogue is loaded and linked via `gettext.GNUTranslations.add_fallback()`.
4. The original terminal fallback (your `LANGUAGE_CODE`) is preserved at the end of the chain.
5. Subsequent `gettext()` / `_()` calls walk the chain automatically -- the fallback resolution is invisible to your application code.
6. Django caches translation catalogues per language per process, so the fallback wiring happens once per language and subsequent requests are virtually free.

## FAQ

**Is this production-ready?**
Yes. The library uses Python's native `gettext.GNUTranslations.add_fallback` mechanism and Django's public `DjangoTranslation` API. No monkey-patching, no private API access beyond the standard `_fallback` attribute that is part of Python's gettext protocol.

**Performance impact?**
Negligible. Fallback catalogues are loaded once per language per process. 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 Django's translation catalogues, which are compiled from `.po` files into `.mo` files. Any translation format that Django supports will work.

**Can I use a non-English default locale?**
Yes. The fallback chains are independent of your project's `LANGUAGE_CODE`. 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 middleware from your `MIDDLEWARE` setting.

**Does it work with Django REST Framework?**
Yes. DRF uses Django's translation system under the hood, so fallback chains work automatically.

**Minimum Django version?**
Django 4.2 (LTS). Also tested with Django 5.0 and 5.1.

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