Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ include voluptuous/tests/*.py
include voluptuous/tests/*.md
include pyproject.toml
include tox.ini
recursive-include voluptuous/tests/fixtures *.mo *.po
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,56 @@ To file a bug, create a [new issue](https://github.com/alecthomas/voluptuous/iss

The documentation is provided [here](http://alecthomas.github.io/voluptuous/).

## Internationalization

Voluptuous routes built-in validation message templates and the
`Invalid.__str__` message/error-type format through its i18n hooks.

Voluptuous does not ship production translation catalogs. Use
`configure_i18n()` to load your own gettext translations and set the
module-wide default translator:

```pycon
>>> from voluptuous import configure_i18n
>>> configure_i18n(
... domain="myapp",
... localedir="/path/to/myapp/locale",
... languages=("de",),
... fallback=True,
... )
```

`domain` is the gettext domain to load, such as `myapp` or `voluptuous`.
`localedir` points at a locale directory containing paths like
`de/LC_MESSAGES/myapp.mo` for `domain="myapp"`, or
`de/LC_MESSAGES/voluptuous.mo` for `domain="voluptuous"`. `languages` selects
one or more locale names. `fallback=True` keeps untranslated messages in
English instead of raising when catalog files are missing.

`configure_i18n()` updates the global default translator. Active context-local
overrides are preserved, so a request, task, or test can temporarily use a
different translator without changing other contexts:

```pycon
>>> from voluptuous import MultipleInvalid, Required, Schema, gettext_scope
>>> schema = Schema({Required("name"): str})
>>> with gettext_scope(lambda message: f"scoped: {message}"):
... try:
... schema({})
... except MultipleInvalid as error:
... result = str(error)
>>> result
"scoped: required key not provided @ data['name']"
```

Use `set_gettext()` when you want to replace the global default with a custom
callable. Use `gettext_scope()` for temporary context-local overrides.

When no translation is configured, messages stay in English.

`Invalid.__str__` always renders the path fragment as `" @ data[...]"` (not
translated) so tooling that parses error paths stays stable.

## Contribution to Documentation

Documentation is built using `Sphinx`. You can install it by
Expand Down Expand Up @@ -843,4 +893,3 @@ using voluptuous validators in `assert`s.

I greatly prefer the light-weight style promoted by these libraries to
the complexity of libraries like FormEncode.

2 changes: 2 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ deps =
pytest
pytest-cov
coverage>=3.0
setuptools
wheel
commands =
pytest \
--cov=voluptuous \
Expand Down
2 changes: 2 additions & 0 deletions voluptuous/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@
True
"""

from voluptuous._i18n import configure_i18n, gettext_scope, set_gettext

# Dataclasses support
from voluptuous.dataclasses_support import (
DataclassSchema,
Expand Down
82 changes: 82 additions & 0 deletions voluptuous/_i18n.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from __future__ import annotations

import gettext as _gettext
import typing
from collections.abc import Callable, Iterable
from contextlib import contextmanager
from contextvars import ContextVar

# Public interface for translating user-visible strings.
TranslateFunc = Callable[[str], str]


def _normalize_languages(
languages: typing.Optional[typing.Union[str, Iterable[str]]],
) -> typing.Optional[list[str]]:
if languages is None:
return None
if isinstance(languages, str):
return [languages]
return list(languages)


_default_translator: TranslateFunc = _gettext.gettext
_translator: ContextVar[typing.Optional[TranslateFunc]] = ContextVar(
"voluptuous_gettext", default=None
)


def set_gettext(translation_func: TranslateFunc) -> TranslateFunc:
"""Inject a custom gettext-compatible callable used by voluptuous messages.

This updates the module default translator.
"""

global _default_translator
_default_translator = translation_func
return translation_func


def configure_i18n(
*,
domain: str = "voluptuous",
localedir: typing.Optional[str] = None,
languages: typing.Optional[typing.Union[str, Iterable[str]]] = None,
fallback: bool = True,
) -> TranslateFunc:
"""Configure module-level translation backend for voluptuous messages.

This updates the module default translator. Explicit context overrides set
by gettext_scope() remain active for the current context.
"""

global _default_translator
translation = _gettext.translation(
domain,
localedir=localedir,
languages=_normalize_languages(languages),
fallback=fallback,
)
translation_func = translation.gettext
_default_translator = translation_func
return translation_func


def gettext(message: str) -> str:
"""Translate a user-facing message through active backend."""

translator = _translator.get()
if translator is None:
translator = _default_translator
return translator(message)


@contextmanager
def gettext_scope(translation_func: TranslateFunc):
"""Temporarily activate a translator for the current execution context."""

token = _translator.set(translation_func)
try:
yield
finally:
_translator.reset(token)
7 changes: 6 additions & 1 deletion voluptuous/error.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# fmt: off
import typing

from voluptuous._i18n import gettext

# fmt: on


Expand Down Expand Up @@ -50,7 +52,10 @@ def __str__(self) -> str:
path = ' @ data[%s]' % ']['.join(map(repr, self.path)) if self.path else ''
output = Exception.__str__(self)
if self.error_type:
output += ' for ' + self.error_type
output = gettext('%(message)s for %(error_type)s') % {
'message': output,
'error_type': self.error_type,
}
return output + path

def __repr__(self) -> str:
Expand Down
3 changes: 2 additions & 1 deletion voluptuous/humanize.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import typing

from voluptuous import Invalid, MultipleInvalid
from voluptuous._i18n import gettext
from voluptuous.error import Error
from voluptuous.schema_builder import Schema

Expand Down Expand Up @@ -45,7 +46,7 @@ def humanize_error(
offending_item_summary = (
offending_item_summary[: max_sub_error_length - 3] + '...'
)
return '%s. Got %s' % (validation_error, offending_item_summary)
return gettext('%s. Got %s') % (validation_error, offending_item_summary)


def validate_with_humanized_errors(
Expand Down
Loading
Loading