From 951fa3f1c5a7f22000020c3d89a86a6802c05e05 Mon Sep 17 00:00:00 2001 From: Aleksandr Kotlyar Date: Sun, 5 Jul 2026 06:33:48 +0300 Subject: [PATCH 01/15] Add issue #73 runtime i18n support --- MANIFEST.in | 1 + setup.py | 3 +- voluptuous/__init__.py | 1 + voluptuous/_i18n.py | 95 +++++++++++++++++++++++++ voluptuous/humanize.py | 3 +- voluptuous/schema_builder.py | 75 +++++++++++--------- voluptuous/tests/tests.py | 134 +++++++++++++++++++++++++++++++++++ voluptuous/util.py | 5 +- voluptuous/validators.py | 129 ++++++++++++++++++--------------- 9 files changed, 352 insertions(+), 94 deletions(-) create mode 100644 voluptuous/_i18n.py diff --git a/MANIFEST.in b/MANIFEST.in index cfd6717..5249f74 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,3 +4,4 @@ include voluptuous/tests/*.py include voluptuous/tests/*.md include pyproject.toml include tox.ini +recursive-include voluptuous/locale *.mo diff --git a/setup.py b/setup.py index a889e41..48114dd 100644 --- a/setup.py +++ b/setup.py @@ -22,8 +22,9 @@ license='BSD-3-Clause', platforms=['any'], packages=['voluptuous'], + include_package_data=True, package_data={ - 'voluptuous': ['py.typed'], + 'voluptuous': ['py.typed', 'locale/*/LC_MESSAGES/*.mo'], }, author='Alec Thomas', author_email='alec@swapoff.org', diff --git a/voluptuous/__init__.py b/voluptuous/__init__.py index 966ab6f..eac3ed8 100644 --- a/voluptuous/__init__.py +++ b/voluptuous/__init__.py @@ -86,6 +86,7 @@ from voluptuous.schema_builder import * from voluptuous.util import * from voluptuous.validators import * +from voluptuous._i18n import configure_i18n, gettext_scope, set_gettext from voluptuous.error import * # isort: skip diff --git a/voluptuous/_i18n.py b/voluptuous/_i18n.py new file mode 100644 index 0000000..13e527e --- /dev/null +++ b/voluptuous/_i18n.py @@ -0,0 +1,95 @@ +# fmt: off +from __future__ import annotations + +from collections.abc import Callable, Iterable +from contextlib import contextmanager +from contextvars import ContextVar +from pathlib import Path + +import gettext as _gettext + +# Public interface for translating user-visible strings. +TranslateFunc = Callable[[str], str] + + +def _resolve_localedir(localedir: str | None = None) -> str | None: + if localedir is not None: + return localedir + + package_locale_dir = Path(__file__).resolve().parent / "locale" + if package_locale_dir.is_dir(): + return str(package_locale_dir) + return None + + +def _normalize_languages(languages: str | Iterable[str] | None) -> list[str] | None: + if languages is None: + return None + if isinstance(languages, str): + return [languages] + return list(languages) + +_default_translator: TranslateFunc = _gettext.gettext +_translator: ContextVar[TranslateFunc | None] = 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 current context translator and the module default. + """ + + global _default_translator + _default_translator = translation_func + _translator.set(translation_func) + return translation_func + + +def configure_i18n( + *, + domain: str = "voluptuous", + localedir: str | None = None, + languages: str | Iterable[str] | None = None, + fallback: bool = True, +) -> TranslateFunc: + """Configure module-level translation backend for voluptuous messages.""" + + translation = _gettext.translation( + domain, + localedir=_resolve_localedir(localedir), + languages=_normalize_languages(languages), + fallback=fallback, + ) + _default_translator = translation.gettext + _translator.set(_default_translator) + return _default_translator + + +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) + + +# Backward-compatible alias used by library internals. +_ = gettext + + +@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) + + +# Initialize the default translator using package locale layout and default locale chain. +configure_i18n() diff --git a/voluptuous/humanize.py b/voluptuous/humanize.py index eabfd02..3d63b15 100644 --- a/voluptuous/humanize.py +++ b/voluptuous/humanize.py @@ -4,6 +4,7 @@ from voluptuous import Invalid, MultipleInvalid from voluptuous.error import Error from voluptuous.schema_builder import Schema +from voluptuous._i18n import _ # fmt: on @@ -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 _('%s. Got %s') % (validation_error, offending_item_summary) def validate_with_humanized_errors( diff --git a/voluptuous/schema_builder.py b/voluptuous/schema_builder.py index 3673e86..344f9cc 100644 --- a/voluptuous/schema_builder.py +++ b/voluptuous/schema_builder.py @@ -13,6 +13,7 @@ from voluptuous import error as er from voluptuous.error import Error +from voluptuous._i18n import _ # fmt: on @@ -37,8 +38,8 @@ def __repr__(self): UNDEFINED = Undefined() -def Self() -> None: - raise er.SchemaError('"Self" should never be called') +def Self(*_args, **_kwargs) -> None: + raise er.SchemaError(_('"Self" should never be called')) DefaultFactory = typing.Union[Undefined, typing.Callable[[], typing.Any]] @@ -65,9 +66,9 @@ def raises( raise AssertionError(f"Did not raise exception {exc.__name__}") -def Extra(_) -> None: +def Extra(*_args, **_kwargs) -> None: """Allow keys in the data that are not present in the schema.""" - raise er.SchemaError('"Extra" should never be called') + raise er.SchemaError(_('"Extra" should never be called')) # As extra() is never called there's no way to catch references to the @@ -233,11 +234,11 @@ def _compile(self, schema): type_ = schema if type_ in (*primitive_types, object, type(None)) or callable(schema): return _compile_scalar(schema) - raise er.SchemaError('unsupported schema data type %r' % type(schema).__name__) + raise er.SchemaError(_('unsupported schema data type %r') % type(schema).__name__) def _compile_mapping(self, schema, invalid_msg=None): """Create validator for given mapping.""" - invalid_msg = invalid_msg or 'mapping value' + invalid_msg = invalid_msg or _('mapping value') # Keys that may be required all_required_keys = set( @@ -319,7 +320,8 @@ def validate_mapping(path, iterable, out): msg = ( complex_key.msg if hasattr(complex_key, 'msg') and complex_key.msg - else f'at least one of {complex_key.candidate_keys} is required' + else _('at least one of %s is required') + % (complex_key.candidate_keys,) ) errors.append(er.RequiredFieldInvalid(msg, path + [complex_key])) else: @@ -395,14 +397,14 @@ def validate_mapping(path, iterable, out): elif error: errors.append(error) else: - errors.append(er.Invalid('extra keys not allowed', key_path)) + errors.append(er.Invalid(_('extra keys not allowed'), key_path)) # for any required keys left that weren't found and don't have defaults: for key in required_keys: msg = ( key.msg if hasattr(key, 'msg') and key.msg - else 'required key not provided' + else _('required key not provided') ) errors.append(er.RequiredFieldInvalid(msg, path + [key])) if errors: @@ -430,11 +432,11 @@ def _compile_object(self, schema): ... validate(Structure(one='three')) """ - base_validate = self._compile_mapping(schema, invalid_msg='object value') + base_validate = self._compile_mapping(schema, invalid_msg=_('object value')) def validate_object(path, data): if schema.cls is not UNDEFINED and not isinstance(data, schema.cls): - raise er.ObjectInvalid('expected a {0!r}'.format(schema.cls), path) + raise er.ObjectInvalid(_('expected a {0!r}').format(schema.cls), path) iterable = _iterate_object(data) iterable = filter(lambda item: item[1] is not None, iterable) out = base_validate(path, iterable, {}) @@ -518,7 +520,7 @@ def _compile_dict(self, schema): "expected str for dictionary value @ data['adict']['strfield']"] """ - base_validate = self._compile_mapping(schema, invalid_msg='dictionary value') + base_validate = self._compile_mapping(schema, invalid_msg=_('dictionary value')) groups_of_exclusion = {} groups_of_inclusion = {} @@ -532,7 +534,7 @@ def _compile_dict(self, schema): def validate_dict(path, data): if not isinstance(data, dict): - raise er.DictInvalid('expected a dictionary', path) + raise er.DictInvalid(_('expected a dictionary'), path) errors = [] for label, group in groups_of_exclusion.items(): @@ -543,7 +545,9 @@ def validate_dict(path, data): msg = ( exclusive.msg if hasattr(exclusive, 'msg') and exclusive.msg - else "two or more values in the same group of exclusion '%s'" + else _( + "two or more values in the same group of exclusion '%s'" + ) % label ) next_path = path + [VirtualPathComponent(label)] @@ -558,7 +562,7 @@ def validate_dict(path, data): included = [node.schema in data for node in group] if any(included) and not all(included): msg = ( - "some but not all values in the same group of inclusion '%s'" + _("some but not all values in the same group of inclusion '%s'") % label ) for g in group: @@ -595,14 +599,16 @@ def _compile_sequence(self, schema, seq_type): def validate_sequence(path, data): if not isinstance(data, seq_type): - raise er.SequenceTypeInvalid('expected a %s' % seq_type_name, path) + raise er.SequenceTypeInvalid( + _('expected a %s') % seq_type_name, path + ) # Empty seq schema, reject any data. if not schema: if data: - raise er.MultipleInvalid( - [er.ValueInvalid('not a valid value', path if path else data)] - ) + raise er.MultipleInvalid([ + er.ValueInvalid(_('not a valid value'), path if path else data) + ]) return data out = [] @@ -682,7 +688,7 @@ def _compile_set(self, schema): def validate_set(path, data): if not isinstance(data, type_): - raise er.Invalid('expected a %s' % type_name, path) + raise er.Invalid(_('expected a %s') % type_name, path) _compiled = [self._compile(s) for s in schema] errors = [] @@ -697,7 +703,7 @@ def validate_set(path, data): except er.Invalid: pass else: - invalid = er.Invalid('invalid value in %s' % type_name, path) + invalid = er.Invalid(_('invalid value in %s') % type_name, path) errors.append(invalid) if errors: @@ -733,10 +739,12 @@ def extend( if isinstance(schema, Schema): if schema.extra != result_extra: raise er.SchemaError( - 'Schema.extend() cannot preserve extra from the extension ' - 'Schema when it differs from the resulting Schema extra. ' - 'Pass child_schema.schema explicitly if you only want raw ' - 'dict merge semantics.' + _( + 'Schema.extend() cannot preserve extra from the extension ' + 'Schema when it differs from the resulting Schema extra. ' + 'Pass child_schema.schema explicitly if you only want raw ' + 'dict merge semantics.' + ) ) schema = self._normalize_schema_extension( schema.schema, schema.required, result_required @@ -744,7 +752,7 @@ def extend( assert isinstance(self.schema, dict) and isinstance( schema, dict - ), 'Both schemas must be dictionary-based' + ), _('Both schemas must be dictionary-based') result = self.schema.copy() @@ -831,7 +839,7 @@ def validate_instance(path, data): if isinstance(data, schema): return data else: - msg = 'expected %s' % schema.__name__ + msg = _('expected %s') % schema.__name__ raise er.TypeInvalid(msg, path) return validate_instance @@ -842,7 +850,7 @@ def validate_callable(path, data): try: return schema(data) except ValueError: - raise er.ValueInvalid('not a valid value', path) + raise er.ValueInvalid(_('not a valid value'), path) except er.Invalid as e: e.prepend(path) raise @@ -851,7 +859,7 @@ def validate_callable(path, data): def validate_value(path, data): if data != schema: - raise er.ScalarInvalid('not a valid value', path) + raise er.ScalarInvalid(_('not a valid value'), path) return data return validate_value @@ -971,7 +979,7 @@ def __init__( ) -> None: if cls and not issubclass(cls, er.Invalid): raise er.SchemaError( - "Msg can only use subclases of Invalid as custom class" + _('Msg can only use subclases of Invalid as custom class') ) self._schema = schema self.schema = Schema(schema) @@ -1318,7 +1326,7 @@ def message( """ if cls and not issubclass(cls, er.Invalid): raise er.SchemaError( - "message can only use subclases of Invalid as custom class" + _('message can only use subclases of Invalid as custom class') ) def decorator(f): @@ -1329,9 +1337,8 @@ def wrapper(*args, **kwargs): try: return f(*args, **kwargs) except ValueError: - raise (clsoverride or cls or er.ValueInvalid)( - msg or default or 'invalid value' - ) + message = msg or _(default or "invalid value") + raise (clsoverride or cls or er.ValueInvalid)(message) return wrapper diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index 0608548..a6f9154 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -6,6 +6,8 @@ import pytest +import voluptuous._i18n as _i18n + from voluptuous import ( ALLOW_EXTRA, PREVENT_EXTRA, @@ -28,6 +30,7 @@ FqdnUrl, In, Inclusive, + IsTrue, InInvalid, Invalid, IsDir, @@ -79,6 +82,137 @@ def test_new_required_test(): assert schema.required +def test_i18n_set_gettext(): + _i18n.set_gettext(lambda message: f"localized:{message}") + + try: + assert _i18n.gettext("value") == "localized:value" + assert _i18n._("value") == "localized:value" + finally: + _i18n.configure_i18n() + + +def test_configure_i18n_fallback_keeps_identity(tmp_path): + translated = _i18n.configure_i18n( + localedir=str(tmp_path), + languages=("xx",), + ) + assert translated("message") == "message" + assert _i18n.gettext("value") == "value" + + _i18n.configure_i18n() + + +def test_configure_i18n_passes_parameters(monkeypatch, tmp_path): + call = {} + + class FakeTranslations: + def gettext(self, message: str) -> str: + return f"localized({message})" + + def fake_translation( + domain: str, + localedir: str | None = None, + languages: list[str] | None = None, + fallback: bool = True, + ) -> object: + call.update( + domain=domain, + localedir=localedir, + languages=languages, + fallback=fallback, + ) + return FakeTranslations() + + monkeypatch.setattr(_i18n._gettext, "translation", fake_translation) + + translated = _i18n.configure_i18n( + domain="voluptuous-test", + localedir=str(tmp_path), + languages=("de",), + fallback=False, + ) + + assert translated("hello") == "localized(hello)" + assert _i18n.gettext("value") == "localized(value)" + assert call["domain"] == "voluptuous-test" + assert call["localedir"] == str(tmp_path) + assert call["languages"] == ["de"] + assert call["fallback"] is False + + _i18n.configure_i18n() + + +def test_gettext_scope_temporary_override(): + _i18n.configure_i18n() + + with _i18n.gettext_scope(lambda message: f"scoped:{message}"): + assert _i18n.gettext("value") == "scoped:value" + + assert _i18n.gettext("value") == "value" + + +def test_gettext_scope_supports_nesting(): + _i18n.configure_i18n() + + with _i18n.gettext_scope(lambda message: f"outer:{message}"): + with _i18n.gettext_scope(lambda message: f"inner:{message}"): + assert _i18n.gettext("value") == "inner:value" + assert _i18n.gettext("value") == "outer:value" + + +def test_message_decorator_uses_runtime_localizer(): + _i18n.set_gettext(lambda message: f"localized:{message}") + + try: + with pytest.raises( + MultipleInvalid, + match="localized:value was not true", + ): + Schema(IsTrue())(False) + finally: + _i18n.configure_i18n() + + +def test_message_decorator_respects_explicit_message_override(): + _i18n.set_gettext(lambda message: f"localized:{message}") + + try: + with pytest.raises( + MultipleInvalid, + match="explicit message", + ): + Schema(IsTrue("explicit message"))(False) + finally: + _i18n.configure_i18n() + + +def test_schema_multiple_errors_use_runtime_localizer(): + _i18n.set_gettext(lambda message: f"localized:{message}") + + try: + schema = Schema( + { + "a": IsTrue(), + "b": Url(), + "c": Email(), + } + ) + + with pytest.raises(MultipleInvalid) as ctx: + schema({"a": False, "b": "not-an-url", "c": "bad"}) + + error_texts = [str(error) for error in ctx.value.errors] + assert len(error_texts) == 3 + assert any("localized:value was not true" in error for error in error_texts) + assert any("localized:expected a URL" in error for error in error_texts) + assert any( + "localized:expected an email address" in error for error in error_texts + ) + finally: + _i18n.configure_i18n() + + def test_exact_sequence(): schema = Schema(ExactSequence([int, int])) with raises(Invalid): diff --git a/voluptuous/util.py b/voluptuous/util.py index 0bf9302..47ab137 100644 --- a/voluptuous/util.py +++ b/voluptuous/util.py @@ -6,6 +6,7 @@ from voluptuous.error import Invalid, LiteralInvalid, TypeInvalid # noqa: F401 from voluptuous.schema_builder import DefaultFactory # noqa: F401 from voluptuous.schema_builder import Schema, default_factory, raises # noqa: F401 +from voluptuous._i18n import _ # fmt: on @@ -125,7 +126,7 @@ def __call__(self, v): try: set_v = set(v) except Exception as e: - raise TypeInvalid(self.msg or 'cannot be presented as set: {0}'.format(e)) + raise TypeInvalid(self.msg or _('cannot be presented as set: {0}').format(e)) return set_v def __repr__(self): @@ -138,7 +139,7 @@ def __init__(self, lit) -> None: def __call__(self, value, msg: typing.Optional[str] = None): if self.lit != value: - raise LiteralInvalid(msg or '%s not match for %s' % (value, self.lit)) + raise LiteralInvalid(msg or _('%s not match for %s') % (value, self.lit)) else: return self.lit diff --git a/voluptuous/validators.py b/voluptuous/validators.py index a69bb8a..3adbfc8 100644 --- a/voluptuous/validators.py +++ b/voluptuous/validators.py @@ -39,6 +39,7 @@ # F401: flake8 complains about 'raises' not being used, but it is used in doctests from voluptuous.schema_builder import Schema, Schemable, message, raises # noqa: F401 +from voluptuous._i18n import _ if typing.TYPE_CHECKING: from _typeshed import SupportsAllComparisons @@ -144,16 +145,16 @@ def __call__(self, v): try: return self.type(v) except (ValueError, TypeError, InvalidOperation): - msg = self.msg or ('expected %s' % self.type_name) + msg = self.msg or (_('expected %s') % self.type_name) if not self.msg and Enum and issubclass(self.type, Enum): - msg += " or one of %s" % str([e.value for e in self.type])[1:-1] + msg += _(' or one of %s') % str([e.value for e in self.type])[1:-1] raise CoerceInvalid(msg) def __repr__(self): return 'Coerce(%s, msg=%r)' % (self.type_name, self.msg) -@message('value was not true', cls=TrueInvalid) +@message("value was not true", cls=TrueInvalid) @truth def IsTrue(v): """Assert that a value is true, in the Python sense. @@ -180,7 +181,7 @@ def IsTrue(v): return v -@message('value was not false', cls=FalseInvalid) +@message("value was not false", cls=FalseInvalid) def IsFalse(v): """Assert that a value is false, in the Python sense. @@ -202,7 +203,7 @@ def IsFalse(v): return v -@message('expected boolean', cls=BooleanInvalid) +@message("expected boolean", cls=BooleanInvalid) def Boolean(v): """Convert human-readable boolean values to a bool. @@ -326,7 +327,7 @@ def _exec(self, funcs, v, path=None): else: if error: raise error if self.msg is None else AnyInvalid(self.msg, path=path) - raise AnyInvalid(self.msg or 'no valid value found', path=path) + raise AnyInvalid(self.msg or _('no valid value found'), path=path) # Convenience alias @@ -368,7 +369,7 @@ def _exec(self, funcs, v, path=None): else: if error: raise error if self.msg is None else AnyInvalid(self.msg, path=path) - raise AnyInvalid(self.msg or 'no valid value found', path=path) + raise AnyInvalid(self.msg or _('no valid value found'), path=path) # Convenience alias @@ -435,11 +436,11 @@ def __call__(self, v): try: match = self.pattern.match(v) except TypeError: - raise MatchInvalid("expected string or buffer") + raise MatchInvalid(_("expected string or buffer")) if not match: raise MatchInvalid( self.msg - or 'does not match regular expression {}'.format(self.pattern.pattern) + or _('does not match regular expression {}').format(self.pattern.pattern) ) return v @@ -482,11 +483,11 @@ def __repr__(self): def _url_validation(v: str) -> urlparse.ParseResult: parsed = urlparse.urlparse(v) if not parsed.scheme or not parsed.netloc: - raise UrlInvalid("must have a URL scheme and host") + raise UrlInvalid(_("must have a URL scheme and host")) return parsed -@message('expected an email address', cls=EmailInvalid) +@message("expected an email address", cls=EmailInvalid) def Email(v): """Verify that the value is an email address or not. @@ -502,17 +503,17 @@ def Email(v): """ try: if not v or "@" not in v: - raise EmailInvalid("Invalid email address") + raise EmailInvalid(_('invalid email address')) user_part, domain_part = v.rsplit('@', 1) if not (USER_REGEX.match(user_part) and DOMAIN_REGEX.match(domain_part)): - raise EmailInvalid("Invalid email address") + raise EmailInvalid(_('invalid email address')) return v except: # noqa: E722 raise ValueError -@message('expected a fully qualified domain name URL', cls=UrlInvalid) +@message("expected a fully qualified domain name URL", cls=UrlInvalid) def FqdnUrl(v): """Verify that the value is a fully qualified domain name URL. @@ -525,13 +526,13 @@ def FqdnUrl(v): try: parsed_url = _url_validation(v) if "." not in parsed_url.netloc: - raise UrlInvalid("must have a domain name in URL") + raise UrlInvalid(_('must have a domain name in URL')) return v except: # noqa: E722 raise ValueError -@message('expected a URL', cls=UrlInvalid) +@message("expected a URL", cls=UrlInvalid) def Url(v): """Verify that the value is a URL. @@ -548,7 +549,7 @@ def Url(v): raise ValueError -@message('Not a file', cls=FileInvalid) +@message("Not a file", cls=FileInvalid) @truth def IsFile(v): """Verify the file exists. @@ -565,12 +566,12 @@ def IsFile(v): v = str(v) return os.path.isfile(v) else: - raise FileInvalid('Not a file') + raise FileInvalid(_('Not a file')) except TypeError: - raise FileInvalid('Not a file') + raise FileInvalid(_('Not a file')) -@message('Not a directory', cls=DirInvalid) +@message("Not a directory", cls=DirInvalid) @truth def IsDir(v): """Verify the directory exists. @@ -585,12 +586,12 @@ def IsDir(v): v = str(v) return os.path.isdir(v) else: - raise DirInvalid("Not a directory") + raise DirInvalid(_('Not a directory')) except TypeError: - raise DirInvalid("Not a directory") + raise DirInvalid(_('Not a directory')) -@message('path does not exist', cls=PathInvalid) +@message("path does not exist", cls=PathInvalid) @truth def PathExists(v): """Verify the path exists, regardless of its type. @@ -607,9 +608,9 @@ def PathExists(v): v = str(v) return os.path.exists(v) else: - raise PathInvalid("Not a Path") + raise PathInvalid(_('Not a Path')) except TypeError: - raise PathInvalid("Not a Path") + raise PathInvalid(_('Not a Path')) def Maybe(validator: Schemable, msg: typing.Optional[str] = None): @@ -668,22 +669,22 @@ def __call__(self, v): if self.min_included: if self.min is not None and not v >= self.min: raise RangeInvalid( - self.msg or 'value must be at least %s' % self.min + self.msg or _('value must be at least %s') % self.min ) else: if self.min is not None and not v > self.min: raise RangeInvalid( - self.msg or 'value must be higher than %s' % self.min + self.msg or _('value must be higher than %s') % self.min ) if self.max_included: if self.max is not None and not v <= self.max: raise RangeInvalid( - self.msg or 'value must be at most %s' % self.max + self.msg or _('value must be at most %s') % self.max ) else: if self.max is not None and not v < self.max: raise RangeInvalid( - self.msg or 'value must be lower than %s' % self.max + self.msg or _('value must be lower than %s') % self.max ) return v @@ -691,7 +692,8 @@ def __call__(self, v): # Objects that lack a partial ordering, e.g. None or strings will raise TypeError except TypeError: raise RangeInvalid( - self.msg or 'invalid value or type (must have a partial ordering)' + self.msg + or _('invalid value or type (must have a partial ordering)') ) def __repr__(self): @@ -739,7 +741,8 @@ def __call__(self, v): # Objects that lack a partial ordering, e.g. None or strings will raise TypeError except TypeError: raise RangeInvalid( - self.msg or 'invalid value or type (must have a partial ordering)' + self.msg + or _('invalid value or type (must have a partial ordering)') ) def __repr__(self): @@ -763,17 +766,17 @@ def __call__(self, v): try: if self.min is not None and len(v) < self.min: raise LengthInvalid( - self.msg or 'length of value must be at least %s' % self.min + self.msg or _('length of value must be at least %s') % self.min ) if self.max is not None and len(v) > self.max: raise LengthInvalid( - self.msg or 'length of value must be at most %s' % self.max + self.msg or _('length of value must be at most %s') % self.max ) return v # Objects that have no length e.g. None or strings will raise TypeError except TypeError: - raise RangeInvalid(self.msg or 'invalid value or type') + raise RangeInvalid(self.msg or _('invalid value or type')) def __repr__(self): return 'Length(min=%s, max=%s)' % (self.min, self.max) @@ -795,7 +798,7 @@ def __call__(self, v): datetime.datetime.strptime(v, self.format) except (TypeError, ValueError): raise DatetimeInvalid( - self.msg or 'value does not match expected format %s' % self.format + self.msg or _('value does not match expected format %s') % self.format ) return v @@ -813,7 +816,7 @@ def __call__(self, v): datetime.datetime.strptime(v, self.format) except (TypeError, ValueError): raise DateInvalid( - self.msg or 'value does not match expected format %s' % self.format + self.msg or _('value does not match expected format %s') % self.format ) return v @@ -840,12 +843,16 @@ def __call__(self, v): if check: try: raise InInvalid( - self.msg or f'value must be one of {sorted(self.container)}' + self.msg + or _('value must be one of %s') % str(sorted(self.container)) ) except TypeError: raise InInvalid( self.msg - or f'value must be one of {sorted(self.container, key=str)}' + or _( + 'value must be one of %s' + ) + % str(sorted(self.container, key=str)) ) return v @@ -870,12 +877,15 @@ def __call__(self, v): if check: try: raise NotInInvalid( - self.msg or f'value must not be one of {sorted(self.container)}' + self.msg + or _('value must not be one of %s') + % str(sorted(self.container)) ) except TypeError: raise NotInInvalid( self.msg - or f'value must not be one of {sorted(self.container, key=str)}' + or _('value must not be one of %s') + % str(sorted(self.container, key=str)) ) return v @@ -903,7 +913,7 @@ def __call__(self, v): except TypeError: check = True if check: - raise ContainsInvalid(self.msg or 'value is not allowed') + raise ContainsInvalid(self.msg or _('value is not allowed')) return v def __repr__(self): @@ -982,11 +992,13 @@ def __call__(self, v): try: set_v = set(v) except TypeError as e: - raise TypeInvalid(self.msg or 'contains unhashable elements: {0}'.format(e)) + raise TypeInvalid( + self.msg or _('contains unhashable elements: {0}').format(e) + ) if len(set_v) != len(v): seen = set() dupes = list(set(x for x in v if x in seen or seen.add(x))) - raise Invalid(self.msg or 'contains duplicate items: {0}'.format(dupes)) + raise Invalid(self.msg or _('contains duplicate items: {0}').format(dupes)) return v def __repr__(self): @@ -1017,7 +1029,9 @@ def __call__(self, v): if v != self.target: raise Invalid( self.msg - or 'Values are not equal: value:{} != target:{}'.format(v, self.target) + or _( + 'Values are not equal: value:{value} != target:{target}' + ).format(value=v, target=self.target) ) return v @@ -1052,13 +1066,13 @@ def __init__( def __call__(self, v): if not isinstance(v, (list, tuple)): - raise Invalid(self.msg or 'Value {} is not sequence!'.format(v)) + raise Invalid(self.msg or _('Value {} is not sequence!').format(v)) if len(v) != len(self._schemas): raise Invalid( self.msg - or 'List lengths differ, value:{} != target:{}'.format( - len(v), len(self._schemas) + or _('List lengths differ, value:{value} != target:{target}').format( + value=len(v), target=len(self._schemas) ) ) @@ -1084,18 +1098,20 @@ def __call__(self, v): el = missing[0] raise Invalid( self.msg - or 'Element #{} ({}) is not valid against any validator'.format( - el[0], el[1] + or _( + 'Element #{index} ({value}) is not valid against any validator' ) + .format(index=el[0], value=el[1]) ) elif missing: raise MultipleInvalid( [ Invalid( self.msg - or 'Element #{} ({}) is not valid against any validator'.format( - el[0], el[1] + or _( + 'Element #{index} ({value}) is not valid against any validator' ) + .format(index=el[0], value=el[1]) ) for el in missing ] @@ -1148,17 +1164,18 @@ def __call__(self, v): ): raise Invalid( self.msg - or "Precision must be equal to %s, and Scale must be equal to %s" + or _('Precision must be equal to %s, and Scale must be equal to %s') % (self.precision, self.scale) ) else: if self.precision is not None and precision != self.precision: raise Invalid( - self.msg or "Precision must be equal to %s" % self.precision + self.msg + or _('Precision must be equal to %s') % self.precision ) if self.scale is not None and scale != self.scale: - raise Invalid(self.msg or "Scale must be equal to %s" % self.scale) + raise Invalid(self.msg or _('Scale must be equal to %s') % self.scale) if self.yield_decimal: return decimal_num @@ -1180,14 +1197,14 @@ def _get_precision_scale(self, number) -> typing.Tuple[int, int, Decimal]: try: decimal_num = Decimal(number) except (InvalidOperation, TypeError, ValueError): - raise Invalid(self.msg or 'Value must be a number enclosed with string') + raise Invalid(self.msg or _('Value must be a number enclosed with string')) exp = decimal_num.as_tuple().exponent if isinstance(exp, int): return (len(decimal_num.as_tuple().digits), -exp, decimal_num) else: # Infinity/NaN have no precision; report as Invalid, not a raw TypeError. - raise Invalid(self.msg or 'Value must be a number enclosed with string') + raise Invalid(self.msg or _('Value must be a number enclosed with string')) class SomeOf(_WithSubValidators): From 6d1ac65b7816ba21dad39b87a94e78d36da4bf01 Mon Sep 17 00:00:00 2001 From: Aleksandr Kotlyar Date: Sun, 5 Jul 2026 07:06:12 +0300 Subject: [PATCH 02/15] Fix i18n translator defaults and add locale packaging tests --- README.md | 23 ++- voluptuous/_i18n.py | 14 +- .../locale/de/LC_MESSAGES/voluptuous.mo | Bin 0 -> 278 bytes .../locale/de/LC_MESSAGES/voluptuous.po | 11 ++ voluptuous/tests/tests.py | 133 ++++++++++++++++++ 5 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 voluptuous/locale/de/LC_MESSAGES/voluptuous.mo create mode 100644 voluptuous/locale/de/LC_MESSAGES/voluptuous.po diff --git a/README.md b/README.md index e359f11..e856bd8 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,28 @@ 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 validates all user-facing error messages through `voluptuous._i18n`. + +Use `configure_i18n()` to load a gettext domain (for packaged locale files) and set +the module-wide default translator. Use `set_gettext()` for custom translator tests +or temporary integrations. `configure_i18n()` updates the default translator and +preserves active context-local overrides. + +Use `gettext_scope()` to switch translator behavior for a specific execution context: + +```pycon +>>> from voluptuous._i18n import set_gettext, gettext_scope, configure_i18n +>>> set_gettext(lambda message: f"localized: {message}") +>>> gettext_scope(lambda message: f"scoped: {message}") +``` + +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 @@ -843,4 +865,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. - diff --git a/voluptuous/_i18n.py b/voluptuous/_i18n.py index 13e527e..fc67265 100644 --- a/voluptuous/_i18n.py +++ b/voluptuous/_i18n.py @@ -54,7 +54,11 @@ def configure_i18n( languages: str | Iterable[str] | None = None, fallback: bool = True, ) -> TranslateFunc: - """Configure module-level translation backend for voluptuous messages.""" + """Configure module-level translation backend for voluptuous messages. + + This updates the module default translator and only updates current-context + state when no explicit context override is active. + """ translation = _gettext.translation( domain, @@ -62,9 +66,11 @@ def configure_i18n( languages=_normalize_languages(languages), fallback=fallback, ) - _default_translator = translation.gettext - _translator.set(_default_translator) - return _default_translator + translation_func = translation.gettext + _default_translator = translation_func + if _translator.get() is None: + _translator.set(translation_func) + return translation_func def gettext(message: str) -> str: diff --git a/voluptuous/locale/de/LC_MESSAGES/voluptuous.mo b/voluptuous/locale/de/LC_MESSAGES/voluptuous.mo new file mode 100644 index 0000000000000000000000000000000000000000..5526269a1b1f2df7b5f9dd3f0c63aa17650e7f3b GIT binary patch literal 278 zcmYMuu}T9$5C-5eYI9{$2^RYT7b94Nh?OV=3|Pc~U6$Jkx4P_}v$Ki8cd+yIQu`SG zXQ4myEz1lHa~Pg{5poRYa0RFE0DabQ1{Q|!0nhLWeg7LqFgPMY(E$2ge-VBwUf7ve z-kX@zbdt8-v7@aGh0V_9-?mC&dX)6Sjn;EluUN8Aab{L2luk=3xmwH@cmIRS@ua#< zQ8{Y8+;==P%I|v71?%IrX<93p_2y}LUr#Gy*(7l+!8a{uv*F Date: Sun, 5 Jul 2026 08:30:54 +0300 Subject: [PATCH 03/15] Preserve i18n context overrides --- MANIFEST.in | 2 +- README.md | 36 ++++-- setup.py | 2 +- voluptuous/_i18n.py | 22 ++-- voluptuous/schema_builder.py | 8 +- .../locale/de/LC_MESSAGES/voluptuous.mo | Bin .../locale/de/LC_MESSAGES/voluptuous.po | 0 voluptuous/tests/tests.py | 106 +++++++++++++++--- 8 files changed, 140 insertions(+), 36 deletions(-) rename voluptuous/{ => tests/fixtures}/locale/de/LC_MESSAGES/voluptuous.mo (100%) rename voluptuous/{ => tests/fixtures}/locale/de/LC_MESSAGES/voluptuous.po (100%) diff --git a/MANIFEST.in b/MANIFEST.in index 5249f74..e42c242 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,4 +4,4 @@ include voluptuous/tests/*.py include voluptuous/tests/*.md include pyproject.toml include tox.ini -recursive-include voluptuous/locale *.mo +recursive-include voluptuous/tests/fixtures *.mo *.po diff --git a/README.md b/README.md index e856bd8..93e2f07 100644 --- a/README.md +++ b/README.md @@ -45,19 +45,39 @@ The documentation is provided [here](http://alecthomas.github.io/voluptuous/). Voluptuous validates all user-facing error messages through `voluptuous._i18n`. -Use `configure_i18n()` to load a gettext domain (for packaged locale files) and set -the module-wide default translator. Use `set_gettext()` for custom translator tests -or temporary integrations. `configure_i18n()` updates the default translator and -preserves active context-local overrides. +Use `configure_i18n()` to load gettext translations and set the module-wide +default translator: -Use `gettext_scope()` to switch translator behavior for a specific execution context: +```pycon +>>> from voluptuous._i18n import configure_i18n +>>> configure_i18n( +... domain="voluptuous", +... localedir="/path/to/locale", +... languages=("de",), +... fallback=True, +... ) +``` + +`domain` is the gettext domain to load, such as `voluptuous`. +`localedir` points at a locale directory containing paths like +`de/LC_MESSAGES/voluptuous.mo`. `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._i18n import set_gettext, gettext_scope, configure_i18n ->>> set_gettext(lambda message: f"localized: {message}") ->>> gettext_scope(lambda message: f"scoped: {message}") +>>> from voluptuous._i18n import gettext, gettext_scope +>>> with gettext_scope(lambda message: f"scoped: {message}"): +... gettext("required key not provided") +'scoped: required key not provided' ``` +Use `set_gettext()` when you want to replace both the current context translator +and the global default with a custom callable. + When no translation is configured, messages stay in English. `Invalid.__str__` always renders the path fragment as diff --git a/setup.py b/setup.py index 48114dd..4cc99d2 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ packages=['voluptuous'], include_package_data=True, package_data={ - 'voluptuous': ['py.typed', 'locale/*/LC_MESSAGES/*.mo'], + 'voluptuous': ['py.typed'], }, author='Alec Thomas', author_email='alec@swapoff.org', diff --git a/voluptuous/_i18n.py b/voluptuous/_i18n.py index fc67265..e0d8968 100644 --- a/voluptuous/_i18n.py +++ b/voluptuous/_i18n.py @@ -7,12 +7,13 @@ from pathlib import Path import gettext as _gettext +import typing # Public interface for translating user-visible strings. TranslateFunc = Callable[[str], str] -def _resolve_localedir(localedir: str | None = None) -> str | None: +def _resolve_localedir(localedir: typing.Optional[str] = None) -> typing.Optional[str]: if localedir is not None: return localedir @@ -22,7 +23,9 @@ def _resolve_localedir(localedir: str | None = None) -> str | None: return None -def _normalize_languages(languages: str | Iterable[str] | None) -> list[str] | None: +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): @@ -30,7 +33,7 @@ def _normalize_languages(languages: str | Iterable[str] | None) -> list[str] | N return list(languages) _default_translator: TranslateFunc = _gettext.gettext -_translator: ContextVar[TranslateFunc | None] = ContextVar( +_translator: ContextVar[typing.Optional[TranslateFunc]] = ContextVar( "voluptuous_gettext", default=None ) @@ -50,16 +53,17 @@ def set_gettext(translation_func: TranslateFunc) -> TranslateFunc: def configure_i18n( *, domain: str = "voluptuous", - localedir: str | None = None, - languages: str | Iterable[str] | None = None, + 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 and only updates current-context - state when no explicit context override is active. + This updates the module default translator. Explicit context overrides set + by set_gettext() or gettext_scope() remain active for the current context. """ + global _default_translator translation = _gettext.translation( domain, localedir=_resolve_localedir(localedir), @@ -68,8 +72,6 @@ def configure_i18n( ) translation_func = translation.gettext _default_translator = translation_func - if _translator.get() is None: - _translator.set(translation_func) return translation_func @@ -97,5 +99,5 @@ def gettext_scope(translation_func: TranslateFunc): _translator.reset(token) -# Initialize the default translator using package locale layout and default locale chain. +# Initialize the default translator using the default locale chain. configure_i18n() diff --git a/voluptuous/schema_builder.py b/voluptuous/schema_builder.py index 344f9cc..7643bb9 100644 --- a/voluptuous/schema_builder.py +++ b/voluptuous/schema_builder.py @@ -371,7 +371,9 @@ def validate_mapping(path, iterable, out): continue for err in exception_errors: if len(err.path) <= len(key_path): - err.error_type = invalid_msg + err.error_type = ( + _(invalid_msg) if invalid_msg is not None else None + ) errors.append(err) # If there is a validation error for a required # key, this means that the key was provided. @@ -432,7 +434,7 @@ def _compile_object(self, schema): ... validate(Structure(one='three')) """ - base_validate = self._compile_mapping(schema, invalid_msg=_('object value')) + base_validate = self._compile_mapping(schema, invalid_msg='object value') def validate_object(path, data): if schema.cls is not UNDEFINED and not isinstance(data, schema.cls): @@ -520,7 +522,7 @@ def _compile_dict(self, schema): "expected str for dictionary value @ data['adict']['strfield']"] """ - base_validate = self._compile_mapping(schema, invalid_msg=_('dictionary value')) + base_validate = self._compile_mapping(schema, invalid_msg='dictionary value') groups_of_exclusion = {} groups_of_inclusion = {} diff --git a/voluptuous/locale/de/LC_MESSAGES/voluptuous.mo b/voluptuous/tests/fixtures/locale/de/LC_MESSAGES/voluptuous.mo similarity index 100% rename from voluptuous/locale/de/LC_MESSAGES/voluptuous.mo rename to voluptuous/tests/fixtures/locale/de/LC_MESSAGES/voluptuous.mo diff --git a/voluptuous/locale/de/LC_MESSAGES/voluptuous.po b/voluptuous/tests/fixtures/locale/de/LC_MESSAGES/voluptuous.po similarity index 100% rename from voluptuous/locale/de/LC_MESSAGES/voluptuous.po rename to voluptuous/tests/fixtures/locale/de/LC_MESSAGES/voluptuous.po diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index 7df0d31..872749e 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -7,6 +7,7 @@ import sys import tarfile import threading +import typing import zipfile from enum import Enum from pathlib import Path @@ -78,14 +79,17 @@ # fmt: on -I18N_LOCALE_DIR = Path(__file__).resolve().parent.parent / "locale" +I18N_LOCALE_DIR = Path(__file__).resolve().parent / "fixtures" / "locale" I18N_REQUIRED_DE = "erforderliches feld fehlt" @pytest.fixture(autouse=True) def _restore_i18n_translator(): + _i18n._translator.set(None) + _i18n.configure_i18n() yield - _i18n.set_gettext(_i18n._gettext.gettext) + _i18n._translator.set(None) + _i18n.configure_i18n() def test_new_required_test(): @@ -128,8 +132,8 @@ def gettext(self, message: str) -> str: def fake_translation( domain: str, - localedir: str | None = None, - languages: list[str] | None = None, + localedir: typing.Optional[str] = None, + languages: typing.Optional[list[str]] = None, fallback: bool = True, ) -> object: call.update( @@ -241,6 +245,16 @@ def test_configure_i18n_with_real_mo_file(): _i18n.configure_i18n() +def test_de_mo_file_contains_real_translations(): + mo_path = I18N_LOCALE_DIR / "de" / "LC_MESSAGES" / "voluptuous.mo" + + with mo_path.open("rb") as mo_file: + translation = _i18n._gettext.GNUTranslations(mo_file) + + assert translation.gettext("required key not provided") == I18N_REQUIRED_DE + assert translation.gettext("value was not true") == "wert war nicht wahr" + + def test_fresh_context_and_thread_use_default_translator_after_set_gettext(): _i18n.set_gettext(lambda message: f"localized:{message}") assert _i18n.gettext("value") == "localized:value" @@ -277,15 +291,50 @@ def test_fresh_context_after_configure_i18n_uses_updated_default(): def _collect_fresh_thread_value(): fresh_thread_result["value"] = _i18n.gettext("required key not provided") - fresh_thread = threading.Thread( - target=_collect_fresh_thread_value, - context=contextvars.Context(), - ) + fresh_context = contextvars.Context() + + def _collect_in_fresh_context(): + fresh_context.run(_collect_fresh_thread_value) + + fresh_thread = threading.Thread(target=_collect_in_fresh_context) fresh_thread.start() fresh_thread.join() assert fresh_thread_result["value"] == translated("required key not provided") +def test_configure_i18n_preserves_context_override_and_updates_default(): + with _i18n.gettext_scope(lambda message: f"scoped:{message}"): + translated = _i18n.configure_i18n( + localedir=str(I18N_LOCALE_DIR), + languages=("de",), + domain="voluptuous", + ) + + assert _i18n.gettext("required key not provided") == ( + "scoped:required key not provided" + ) + assert contextvars.Context().run( + _i18n.gettext, "required key not provided" + ) == translated("required key not provided") + + fresh_thread_result: dict[str, str] = {} + fresh_thread_context = contextvars.Context() + + def _collect_fresh_thread_value(): + fresh_thread_result["value"] = fresh_thread_context.run( + _i18n.gettext, "required key not provided" + ) + + fresh_thread = threading.Thread(target=_collect_fresh_thread_value) + fresh_thread.start() + fresh_thread.join() + assert fresh_thread_result["value"] == translated("required key not provided") + + assert _i18n.gettext("required key not provided") == translated( + "required key not provided" + ) + + def test_schema_created_before_locale_switch_still_translates_messages(): schema = Schema({Required("name"): str}) _i18n.configure_i18n( @@ -300,6 +349,32 @@ def test_schema_created_before_locale_switch_still_translates_messages(): assert I18N_REQUIRED_DE in str(ctx.value) +def test_mapping_error_type_uses_runtime_localizer(): + schema = Schema({"name": int}) + _i18n.set_gettext(lambda message: f"localized:{message}") + + with pytest.raises(MultipleInvalid) as ctx: + schema({"name": "not-an-int"}) + + assert ( + str(ctx.value) + == "localized:expected int for localized:dictionary value @ data['name']" + ) + + +def test_object_error_type_uses_runtime_localizer(): + schema = Schema(Object({"value": int}, cls=MyValueClass)) + _i18n.set_gettext(lambda message: f"localized:{message}") + + with pytest.raises(MultipleInvalid) as ctx: + schema(MyValueClass(value="not-an-int")) + + assert ( + str(ctx.value) + == "localized:expected int for localized:object value @ data['value']" + ) + + def test_invalid_str_path_fragment_is_not_translated(): _i18n.set_gettext(lambda message: f"localized:{message}") @@ -310,11 +385,12 @@ def test_invalid_str_path_fragment_is_not_translated(): assert " @ data['name']" in str(ctx.value) -def test_locale_files_included_in_sdist_and_wheel(tmp_path): +def test_locale_fixture_does_not_ship_as_package_data(tmp_path): dist_dir = tmp_path / "dist" dist_dir.mkdir() - project_dir = Path(__file__).resolve().parent.parent - expected_path = "voluptuous/locale/de/LC_MESSAGES/voluptuous.mo" + project_dir = Path(__file__).resolve().parents[2] + production_path = "voluptuous/locale/de/LC_MESSAGES/voluptuous.mo" + fixture_path = "voluptuous/tests/fixtures/locale/de/LC_MESSAGES/voluptuous.mo" sdist = subprocess.run( [sys.executable, "setup.py", "sdist", "--dist-dir", str(dist_dir)], @@ -340,10 +416,14 @@ def test_locale_files_included_in_sdist_and_wheel(tmp_path): assert wheel_files, f"no wheel found in {dist_dir}" with tarfile.open(sdist_files[0], "r:gz") as archive: - assert expected_path in archive.getnames() + archive_names = archive.getnames() + assert production_path not in archive_names + assert any(name.endswith(fixture_path) for name in archive_names) with zipfile.ZipFile(wheel_files[0]) as archive: - assert expected_path in archive.namelist() + archive_names = archive.namelist() + assert production_path not in archive_names + assert fixture_path not in archive_names def test_exact_sequence(): From 9286c9a406f612983eef8b337609e6229789ca66 Mon Sep 17 00:00:00 2001 From: Aleksandr Kotlyar Date: Sun, 5 Jul 2026 08:55:11 +0300 Subject: [PATCH 04/15] - --- voluptuous/_i18n.py | 5 -- voluptuous/humanize.py | 4 +- voluptuous/schema_builder.py | 78 ++++++++++++++--------- voluptuous/tests/tests.py | 1 - voluptuous/util.py | 10 ++- voluptuous/validators.py | 119 +++++++++++++++++++---------------- 6 files changed, 122 insertions(+), 95 deletions(-) diff --git a/voluptuous/_i18n.py b/voluptuous/_i18n.py index e0d8968..3ea3085 100644 --- a/voluptuous/_i18n.py +++ b/voluptuous/_i18n.py @@ -83,11 +83,6 @@ def gettext(message: str) -> str: translator = _default_translator return translator(message) - -# Backward-compatible alias used by library internals. -_ = gettext - - @contextmanager def gettext_scope(translation_func: TranslateFunc): """Temporarily activate a translator for the current execution context.""" diff --git a/voluptuous/humanize.py b/voluptuous/humanize.py index 3d63b15..ebcbc2a 100644 --- a/voluptuous/humanize.py +++ b/voluptuous/humanize.py @@ -4,7 +4,7 @@ from voluptuous import Invalid, MultipleInvalid from voluptuous.error import Error from voluptuous.schema_builder import Schema -from voluptuous._i18n import _ +from voluptuous._i18n import gettext # fmt: on @@ -46,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( diff --git a/voluptuous/schema_builder.py b/voluptuous/schema_builder.py index 7643bb9..9b9eb53 100644 --- a/voluptuous/schema_builder.py +++ b/voluptuous/schema_builder.py @@ -13,7 +13,7 @@ from voluptuous import error as er from voluptuous.error import Error -from voluptuous._i18n import _ +from voluptuous._i18n import gettext # fmt: on @@ -38,8 +38,8 @@ def __repr__(self): UNDEFINED = Undefined() -def Self(*_args, **_kwargs) -> None: - raise er.SchemaError(_('"Self" should never be called')) +def Self(_) -> None: + raise er.SchemaError(gettext('"Self" should never be called')) DefaultFactory = typing.Union[Undefined, typing.Callable[[], typing.Any]] @@ -66,9 +66,9 @@ def raises( raise AssertionError(f"Did not raise exception {exc.__name__}") -def Extra(*_args, **_kwargs) -> None: +def Extra(_) -> None: """Allow keys in the data that are not present in the schema.""" - raise er.SchemaError(_('"Extra" should never be called')) + raise er.SchemaError(gettext('"Extra" should never be called')) # As extra() is never called there's no way to catch references to the @@ -234,11 +234,13 @@ def _compile(self, schema): type_ = schema if type_ in (*primitive_types, object, type(None)) or callable(schema): return _compile_scalar(schema) - raise er.SchemaError(_('unsupported schema data type %r') % type(schema).__name__) + raise er.SchemaError( + gettext('unsupported schema data type %r') % type(schema).__name__ + ) def _compile_mapping(self, schema, invalid_msg=None): """Create validator for given mapping.""" - invalid_msg = invalid_msg or _('mapping value') + invalid_msg = invalid_msg or gettext('mapping value') # Keys that may be required all_required_keys = set( @@ -320,7 +322,7 @@ def validate_mapping(path, iterable, out): msg = ( complex_key.msg if hasattr(complex_key, 'msg') and complex_key.msg - else _('at least one of %s is required') + else gettext('at least one of %s is required') % (complex_key.candidate_keys,) ) errors.append(er.RequiredFieldInvalid(msg, path + [complex_key])) @@ -372,7 +374,9 @@ def validate_mapping(path, iterable, out): for err in exception_errors: if len(err.path) <= len(key_path): err.error_type = ( - _(invalid_msg) if invalid_msg is not None else None + gettext(invalid_msg) + if invalid_msg is not None + else None ) errors.append(err) # If there is a validation error for a required @@ -399,14 +403,16 @@ def validate_mapping(path, iterable, out): elif error: errors.append(error) else: - errors.append(er.Invalid(_('extra keys not allowed'), key_path)) + errors.append( + er.Invalid(gettext('extra keys not allowed'), key_path) + ) # for any required keys left that weren't found and don't have defaults: for key in required_keys: msg = ( key.msg if hasattr(key, 'msg') and key.msg - else _('required key not provided') + else gettext('required key not provided') ) errors.append(er.RequiredFieldInvalid(msg, path + [key])) if errors: @@ -438,7 +444,9 @@ def _compile_object(self, schema): def validate_object(path, data): if schema.cls is not UNDEFINED and not isinstance(data, schema.cls): - raise er.ObjectInvalid(_('expected a {0!r}').format(schema.cls), path) + raise er.ObjectInvalid( + gettext('expected a {0!r}').format(schema.cls), path + ) iterable = _iterate_object(data) iterable = filter(lambda item: item[1] is not None, iterable) out = base_validate(path, iterable, {}) @@ -536,7 +544,7 @@ def _compile_dict(self, schema): def validate_dict(path, data): if not isinstance(data, dict): - raise er.DictInvalid(_('expected a dictionary'), path) + raise er.DictInvalid(gettext('expected a dictionary'), path) errors = [] for label, group in groups_of_exclusion.items(): @@ -547,7 +555,7 @@ def validate_dict(path, data): msg = ( exclusive.msg if hasattr(exclusive, 'msg') and exclusive.msg - else _( + else gettext( "two or more values in the same group of exclusion '%s'" ) % label @@ -564,7 +572,9 @@ def validate_dict(path, data): included = [node.schema in data for node in group] if any(included) and not all(included): msg = ( - _("some but not all values in the same group of inclusion '%s'") + gettext( + "some but not all values in the same group of inclusion '%s'" + ) % label ) for g in group: @@ -602,15 +612,19 @@ def _compile_sequence(self, schema, seq_type): def validate_sequence(path, data): if not isinstance(data, seq_type): raise er.SequenceTypeInvalid( - _('expected a %s') % seq_type_name, path + gettext('expected a %s') % seq_type_name, path ) # Empty seq schema, reject any data. if not schema: if data: - raise er.MultipleInvalid([ - er.ValueInvalid(_('not a valid value'), path if path else data) - ]) + raise er.MultipleInvalid( + [ + er.ValueInvalid( + gettext('not a valid value'), path if path else data + ) + ] + ) return data out = [] @@ -690,7 +704,7 @@ def _compile_set(self, schema): def validate_set(path, data): if not isinstance(data, type_): - raise er.Invalid(_('expected a %s') % type_name, path) + raise er.Invalid(gettext('expected a %s') % type_name, path) _compiled = [self._compile(s) for s in schema] errors = [] @@ -705,7 +719,9 @@ def validate_set(path, data): except er.Invalid: pass else: - invalid = er.Invalid(_('invalid value in %s') % type_name, path) + invalid = er.Invalid( + gettext('invalid value in %s') % type_name, path + ) errors.append(invalid) if errors: @@ -741,7 +757,7 @@ def extend( if isinstance(schema, Schema): if schema.extra != result_extra: raise er.SchemaError( - _( + gettext( 'Schema.extend() cannot preserve extra from the extension ' 'Schema when it differs from the resulting Schema extra. ' 'Pass child_schema.schema explicitly if you only want raw ' @@ -752,9 +768,9 @@ def extend( schema.schema, schema.required, result_required ) - assert isinstance(self.schema, dict) and isinstance( - schema, dict - ), _('Both schemas must be dictionary-based') + assert isinstance(self.schema, dict) and isinstance(schema, dict), gettext( + 'Both schemas must be dictionary-based' + ) result = self.schema.copy() @@ -841,7 +857,7 @@ def validate_instance(path, data): if isinstance(data, schema): return data else: - msg = _('expected %s') % schema.__name__ + msg = gettext('expected %s') % schema.__name__ raise er.TypeInvalid(msg, path) return validate_instance @@ -852,7 +868,7 @@ def validate_callable(path, data): try: return schema(data) except ValueError: - raise er.ValueInvalid(_('not a valid value'), path) + raise er.ValueInvalid(gettext('not a valid value'), path) except er.Invalid as e: e.prepend(path) raise @@ -861,7 +877,7 @@ def validate_callable(path, data): def validate_value(path, data): if data != schema: - raise er.ScalarInvalid(_('not a valid value'), path) + raise er.ScalarInvalid(gettext('not a valid value'), path) return data return validate_value @@ -981,7 +997,7 @@ def __init__( ) -> None: if cls and not issubclass(cls, er.Invalid): raise er.SchemaError( - _('Msg can only use subclases of Invalid as custom class') + gettext('Msg can only use subclases of Invalid as custom class') ) self._schema = schema self.schema = Schema(schema) @@ -1328,7 +1344,7 @@ def message( """ if cls and not issubclass(cls, er.Invalid): raise er.SchemaError( - _('message can only use subclases of Invalid as custom class') + gettext('message can only use subclases of Invalid as custom class') ) def decorator(f): @@ -1339,7 +1355,7 @@ def wrapper(*args, **kwargs): try: return f(*args, **kwargs) except ValueError: - message = msg or _(default or "invalid value") + message = msg or gettext(default or "invalid value") raise (clsoverride or cls or er.ValueInvalid)(message) return wrapper diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index 872749e..c809de9 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -107,7 +107,6 @@ def test_i18n_set_gettext(): try: assert _i18n.gettext("value") == "localized:value" - assert _i18n._("value") == "localized:value" finally: _i18n.configure_i18n() diff --git a/voluptuous/util.py b/voluptuous/util.py index 47ab137..3b6d6f7 100644 --- a/voluptuous/util.py +++ b/voluptuous/util.py @@ -6,7 +6,7 @@ from voluptuous.error import Invalid, LiteralInvalid, TypeInvalid # noqa: F401 from voluptuous.schema_builder import DefaultFactory # noqa: F401 from voluptuous.schema_builder import Schema, default_factory, raises # noqa: F401 -from voluptuous._i18n import _ +from voluptuous._i18n import gettext # fmt: on @@ -126,7 +126,9 @@ def __call__(self, v): try: set_v = set(v) except Exception as e: - raise TypeInvalid(self.msg or _('cannot be presented as set: {0}').format(e)) + raise TypeInvalid( + self.msg or gettext('cannot be presented as set: {0}').format(e) + ) return set_v def __repr__(self): @@ -139,7 +141,9 @@ def __init__(self, lit) -> None: def __call__(self, value, msg: typing.Optional[str] = None): if self.lit != value: - raise LiteralInvalid(msg or _('%s not match for %s') % (value, self.lit)) + raise LiteralInvalid( + msg or gettext('%s not match for %s') % (value, self.lit) + ) else: return self.lit diff --git a/voluptuous/validators.py b/voluptuous/validators.py index 3adbfc8..92111cb 100644 --- a/voluptuous/validators.py +++ b/voluptuous/validators.py @@ -39,7 +39,7 @@ # F401: flake8 complains about 'raises' not being used, but it is used in doctests from voluptuous.schema_builder import Schema, Schemable, message, raises # noqa: F401 -from voluptuous._i18n import _ +from voluptuous._i18n import gettext if typing.TYPE_CHECKING: from _typeshed import SupportsAllComparisons @@ -145,9 +145,11 @@ def __call__(self, v): try: return self.type(v) except (ValueError, TypeError, InvalidOperation): - msg = self.msg or (_('expected %s') % self.type_name) + msg = self.msg or (gettext('expected %s') % self.type_name) if not self.msg and Enum and issubclass(self.type, Enum): - msg += _(' or one of %s') % str([e.value for e in self.type])[1:-1] + msg += ( + gettext(' or one of %s') % str([e.value for e in self.type])[1:-1] + ) raise CoerceInvalid(msg) def __repr__(self): @@ -327,7 +329,7 @@ def _exec(self, funcs, v, path=None): else: if error: raise error if self.msg is None else AnyInvalid(self.msg, path=path) - raise AnyInvalid(self.msg or _('no valid value found'), path=path) + raise AnyInvalid(self.msg or gettext('no valid value found'), path=path) # Convenience alias @@ -369,7 +371,7 @@ def _exec(self, funcs, v, path=None): else: if error: raise error if self.msg is None else AnyInvalid(self.msg, path=path) - raise AnyInvalid(self.msg or _('no valid value found'), path=path) + raise AnyInvalid(self.msg or gettext('no valid value found'), path=path) # Convenience alias @@ -436,11 +438,13 @@ def __call__(self, v): try: match = self.pattern.match(v) except TypeError: - raise MatchInvalid(_("expected string or buffer")) + raise MatchInvalid(gettext("expected string or buffer")) if not match: raise MatchInvalid( self.msg - or _('does not match regular expression {}').format(self.pattern.pattern) + or gettext('does not match regular expression {}').format( + self.pattern.pattern + ) ) return v @@ -483,7 +487,7 @@ def __repr__(self): def _url_validation(v: str) -> urlparse.ParseResult: parsed = urlparse.urlparse(v) if not parsed.scheme or not parsed.netloc: - raise UrlInvalid(_("must have a URL scheme and host")) + raise UrlInvalid(gettext("must have a URL scheme and host")) return parsed @@ -503,11 +507,11 @@ def Email(v): """ try: if not v or "@" not in v: - raise EmailInvalid(_('invalid email address')) + raise EmailInvalid(gettext('invalid email address')) user_part, domain_part = v.rsplit('@', 1) if not (USER_REGEX.match(user_part) and DOMAIN_REGEX.match(domain_part)): - raise EmailInvalid(_('invalid email address')) + raise EmailInvalid(gettext('invalid email address')) return v except: # noqa: E722 raise ValueError @@ -526,7 +530,7 @@ def FqdnUrl(v): try: parsed_url = _url_validation(v) if "." not in parsed_url.netloc: - raise UrlInvalid(_('must have a domain name in URL')) + raise UrlInvalid(gettext('must have a domain name in URL')) return v except: # noqa: E722 raise ValueError @@ -566,9 +570,9 @@ def IsFile(v): v = str(v) return os.path.isfile(v) else: - raise FileInvalid(_('Not a file')) + raise FileInvalid(gettext('Not a file')) except TypeError: - raise FileInvalid(_('Not a file')) + raise FileInvalid(gettext('Not a file')) @message("Not a directory", cls=DirInvalid) @@ -586,9 +590,9 @@ def IsDir(v): v = str(v) return os.path.isdir(v) else: - raise DirInvalid(_('Not a directory')) + raise DirInvalid(gettext('Not a directory')) except TypeError: - raise DirInvalid(_('Not a directory')) + raise DirInvalid(gettext('Not a directory')) @message("path does not exist", cls=PathInvalid) @@ -608,9 +612,9 @@ def PathExists(v): v = str(v) return os.path.exists(v) else: - raise PathInvalid(_('Not a Path')) + raise PathInvalid(gettext('Not a Path')) except TypeError: - raise PathInvalid(_('Not a Path')) + raise PathInvalid(gettext('Not a Path')) def Maybe(validator: Schemable, msg: typing.Optional[str] = None): @@ -669,22 +673,22 @@ def __call__(self, v): if self.min_included: if self.min is not None and not v >= self.min: raise RangeInvalid( - self.msg or _('value must be at least %s') % self.min + self.msg or gettext('value must be at least %s') % self.min ) else: if self.min is not None and not v > self.min: raise RangeInvalid( - self.msg or _('value must be higher than %s') % self.min + self.msg or gettext('value must be higher than %s') % self.min ) if self.max_included: if self.max is not None and not v <= self.max: raise RangeInvalid( - self.msg or _('value must be at most %s') % self.max + self.msg or gettext('value must be at most %s') % self.max ) else: if self.max is not None and not v < self.max: raise RangeInvalid( - self.msg or _('value must be lower than %s') % self.max + self.msg or gettext('value must be lower than %s') % self.max ) return v @@ -693,7 +697,7 @@ def __call__(self, v): except TypeError: raise RangeInvalid( self.msg - or _('invalid value or type (must have a partial ordering)') + or gettext('invalid value or type (must have a partial ordering)') ) def __repr__(self): @@ -742,7 +746,7 @@ def __call__(self, v): except TypeError: raise RangeInvalid( self.msg - or _('invalid value or type (must have a partial ordering)') + or gettext('invalid value or type (must have a partial ordering)') ) def __repr__(self): @@ -766,17 +770,18 @@ def __call__(self, v): try: if self.min is not None and len(v) < self.min: raise LengthInvalid( - self.msg or _('length of value must be at least %s') % self.min + self.msg + or gettext('length of value must be at least %s') % self.min ) if self.max is not None and len(v) > self.max: raise LengthInvalid( - self.msg or _('length of value must be at most %s') % self.max + self.msg or gettext('length of value must be at most %s') % self.max ) return v # Objects that have no length e.g. None or strings will raise TypeError except TypeError: - raise RangeInvalid(self.msg or _('invalid value or type')) + raise RangeInvalid(self.msg or gettext('invalid value or type')) def __repr__(self): return 'Length(min=%s, max=%s)' % (self.min, self.max) @@ -798,7 +803,8 @@ def __call__(self, v): datetime.datetime.strptime(v, self.format) except (TypeError, ValueError): raise DatetimeInvalid( - self.msg or _('value does not match expected format %s') % self.format + self.msg + or gettext('value does not match expected format %s') % self.format ) return v @@ -816,7 +822,8 @@ def __call__(self, v): datetime.datetime.strptime(v, self.format) except (TypeError, ValueError): raise DateInvalid( - self.msg or _('value does not match expected format %s') % self.format + self.msg + or gettext('value does not match expected format %s') % self.format ) return v @@ -844,14 +851,12 @@ def __call__(self, v): try: raise InInvalid( self.msg - or _('value must be one of %s') % str(sorted(self.container)) + or gettext('value must be one of %s') % str(sorted(self.container)) ) except TypeError: raise InInvalid( self.msg - or _( - 'value must be one of %s' - ) + or gettext('value must be one of %s') % str(sorted(self.container, key=str)) ) return v @@ -878,13 +883,13 @@ def __call__(self, v): try: raise NotInInvalid( self.msg - or _('value must not be one of %s') + or gettext('value must not be one of %s') % str(sorted(self.container)) ) except TypeError: raise NotInInvalid( self.msg - or _('value must not be one of %s') + or gettext('value must not be one of %s') % str(sorted(self.container, key=str)) ) return v @@ -913,7 +918,7 @@ def __call__(self, v): except TypeError: check = True if check: - raise ContainsInvalid(self.msg or _('value is not allowed')) + raise ContainsInvalid(self.msg or gettext('value is not allowed')) return v def __repr__(self): @@ -993,12 +998,14 @@ def __call__(self, v): set_v = set(v) except TypeError as e: raise TypeInvalid( - self.msg or _('contains unhashable elements: {0}').format(e) + self.msg or gettext('contains unhashable elements: {0}').format(e) ) if len(set_v) != len(v): seen = set() dupes = list(set(x for x in v if x in seen or seen.add(x))) - raise Invalid(self.msg or _('contains duplicate items: {0}').format(dupes)) + raise Invalid( + self.msg or gettext('contains duplicate items: {0}').format(dupes) + ) return v def __repr__(self): @@ -1029,7 +1036,7 @@ def __call__(self, v): if v != self.target: raise Invalid( self.msg - or _( + or gettext( 'Values are not equal: value:{value} != target:{target}' ).format(value=v, target=self.target) ) @@ -1066,14 +1073,14 @@ def __init__( def __call__(self, v): if not isinstance(v, (list, tuple)): - raise Invalid(self.msg or _('Value {} is not sequence!').format(v)) + raise Invalid(self.msg or gettext('Value {} is not sequence!').format(v)) if len(v) != len(self._schemas): raise Invalid( self.msg - or _('List lengths differ, value:{value} != target:{target}').format( - value=len(v), target=len(self._schemas) - ) + or gettext( + 'List lengths differ, value:{value} != target:{target}' + ).format(value=len(v), target=len(self._schemas)) ) consumed = set() @@ -1098,20 +1105,18 @@ def __call__(self, v): el = missing[0] raise Invalid( self.msg - or _( + or gettext( 'Element #{index} ({value}) is not valid against any validator' - ) - .format(index=el[0], value=el[1]) + ).format(index=el[0], value=el[1]) ) elif missing: raise MultipleInvalid( [ Invalid( self.msg - or _( + or gettext( 'Element #{index} ({value}) is not valid against any validator' - ) - .format(index=el[0], value=el[1]) + ).format(index=el[0], value=el[1]) ) for el in missing ] @@ -1164,18 +1169,22 @@ def __call__(self, v): ): raise Invalid( self.msg - or _('Precision must be equal to %s, and Scale must be equal to %s') + or gettext( + 'Precision must be equal to %s, and Scale must be equal to %s' + ) % (self.precision, self.scale) ) else: if self.precision is not None and precision != self.precision: raise Invalid( self.msg - or _('Precision must be equal to %s') % self.precision + or gettext('Precision must be equal to %s') % self.precision ) if self.scale is not None and scale != self.scale: - raise Invalid(self.msg or _('Scale must be equal to %s') % self.scale) + raise Invalid( + self.msg or gettext('Scale must be equal to %s') % self.scale + ) if self.yield_decimal: return decimal_num @@ -1197,14 +1206,18 @@ def _get_precision_scale(self, number) -> typing.Tuple[int, int, Decimal]: try: decimal_num = Decimal(number) except (InvalidOperation, TypeError, ValueError): - raise Invalid(self.msg or _('Value must be a number enclosed with string')) + raise Invalid( + self.msg or gettext('Value must be a number enclosed with string') + ) exp = decimal_num.as_tuple().exponent if isinstance(exp, int): return (len(decimal_num.as_tuple().digits), -exp, decimal_num) else: # Infinity/NaN have no precision; report as Invalid, not a raw TypeError. - raise Invalid(self.msg or _('Value must be a number enclosed with string')) + raise Invalid( + self.msg or gettext('Value must be a number enclosed with string') + ) class SomeOf(_WithSubValidators): From 821862c8f6735576e3b121a742b8e4c7f446d0e7 Mon Sep 17 00:00:00 2001 From: Aleksandr Kotlyar Date: Sun, 5 Jul 2026 09:02:17 +0300 Subject: [PATCH 05/15] Keep i18n catalogs API-only --- README.md | 20 ++--- setup.py | 1 - voluptuous/_i18n.py | 27 ++----- voluptuous/tests/tests.py | 152 ++++++++++++++++++++++++++------------ 4 files changed, 123 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 93e2f07..c5303aa 100644 --- a/README.md +++ b/README.md @@ -45,24 +45,26 @@ The documentation is provided [here](http://alecthomas.github.io/voluptuous/). Voluptuous validates all user-facing error messages through `voluptuous._i18n`. -Use `configure_i18n()` to load gettext translations and set the module-wide -default translator: +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._i18n import configure_i18n +>>> from voluptuous import configure_i18n >>> configure_i18n( -... domain="voluptuous", -... localedir="/path/to/locale", +... domain="myapp", +... localedir="/path/to/myapp/locale", ... languages=("de",), ... fallback=True, ... ) ``` -`domain` is the gettext domain to load, such as `voluptuous`. +`domain` is the gettext domain to load, such as `myapp` or `voluptuous`. `localedir` points at a locale directory containing paths like -`de/LC_MESSAGES/voluptuous.mo`. `languages` selects one or more locale names. -`fallback=True` keeps untranslated messages in English instead of raising when -catalog files are missing. +`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 diff --git a/setup.py b/setup.py index 4cc99d2..a889e41 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,6 @@ license='BSD-3-Clause', platforms=['any'], packages=['voluptuous'], - include_package_data=True, package_data={ 'voluptuous': ['py.typed'], }, diff --git a/voluptuous/_i18n.py b/voluptuous/_i18n.py index 3ea3085..bf33f03 100644 --- a/voluptuous/_i18n.py +++ b/voluptuous/_i18n.py @@ -1,28 +1,16 @@ # fmt: off from __future__ import annotations +import gettext as _gettext +import typing from collections.abc import Callable, Iterable from contextlib import contextmanager from contextvars import ContextVar -from pathlib import Path - -import gettext as _gettext -import typing # Public interface for translating user-visible strings. TranslateFunc = Callable[[str], str] -def _resolve_localedir(localedir: typing.Optional[str] = None) -> typing.Optional[str]: - if localedir is not None: - return localedir - - package_locale_dir = Path(__file__).resolve().parent / "locale" - if package_locale_dir.is_dir(): - return str(package_locale_dir) - return None - - def _normalize_languages( languages: typing.Optional[typing.Union[str, Iterable[str]]], ) -> typing.Optional[list[str]]: @@ -66,7 +54,7 @@ def configure_i18n( global _default_translator translation = _gettext.translation( domain, - localedir=_resolve_localedir(localedir), + localedir=localedir, languages=_normalize_languages(languages), fallback=fallback, ) @@ -83,6 +71,11 @@ def gettext(message: str) -> str: translator = _default_translator return translator(message) + +# Backward-compatible alias for existing callers. +_ = gettext + + @contextmanager def gettext_scope(translation_func: TranslateFunc): """Temporarily activate a translator for the current execution context.""" @@ -92,7 +85,3 @@ def gettext_scope(translation_func: TranslateFunc): yield finally: _translator.reset(token) - - -# Initialize the default translator using the default locale chain. -configure_i18n() diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index c809de9..9639d54 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -3,6 +3,7 @@ import contextvars import copy import os +import shutil import subprocess import sys import tarfile @@ -15,7 +16,6 @@ import pytest import voluptuous._i18n as _i18n - from voluptuous import ( ALLOW_EXTRA, PREVENT_EXTRA, @@ -38,11 +38,11 @@ FqdnUrl, In, Inclusive, - IsTrue, InInvalid, Invalid, IsDir, IsFile, + IsTrue, Length, Literal, LiteralInvalid, @@ -83,13 +83,16 @@ I18N_REQUIRED_DE = "erforderliches feld fehlt" +def _reset_i18n_translator(): + _i18n._translator.set(None) + _i18n._default_translator = _i18n._gettext.gettext + + @pytest.fixture(autouse=True) def _restore_i18n_translator(): - _i18n._translator.set(None) - _i18n.configure_i18n() + _reset_i18n_translator() yield - _i18n._translator.set(None) - _i18n.configure_i18n() + _reset_i18n_translator() def test_new_required_test(): @@ -107,8 +110,9 @@ def test_i18n_set_gettext(): try: assert _i18n.gettext("value") == "localized:value" + assert _i18n._("value") == "localized:value" finally: - _i18n.configure_i18n() + _reset_i18n_translator() def test_configure_i18n_fallback_keeps_identity(tmp_path): @@ -119,7 +123,45 @@ def test_configure_i18n_fallback_keeps_identity(tmp_path): assert translated("message") == "message" assert _i18n.gettext("value") == "value" - _i18n.configure_i18n() + _reset_i18n_translator() + + +def test_configure_i18n_without_localedir_does_not_probe_package_locale(monkeypatch): + call = {} + + class FakeTranslations: + def gettext(self, message: str) -> str: + return f"localized({message})" + + def fake_translation( + domain: str, + localedir: typing.Optional[str] = None, + languages: typing.Optional[list[str]] = None, + fallback: bool = True, + ) -> object: + call.update( + domain=domain, + localedir=localedir, + languages=languages, + fallback=fallback, + ) + return FakeTranslations() + + if hasattr(_i18n, "_resolve_localedir"): + monkeypatch.setattr( + _i18n, + "_resolve_localedir", + lambda localedir=None: "/unexpected/package/locale", + ) + monkeypatch.setattr(_i18n._gettext, "translation", fake_translation) + + translated = _i18n.configure_i18n(languages="de") + + assert translated("hello") == "localized(hello)" + assert call["domain"] == "voluptuous" + assert call["localedir"] is None + assert call["languages"] == ["de"] + assert call["fallback"] is True def test_configure_i18n_passes_parameters(monkeypatch, tmp_path): @@ -159,11 +201,11 @@ def fake_translation( assert call["languages"] == ["de"] assert call["fallback"] is False - _i18n.configure_i18n() + _reset_i18n_translator() def test_gettext_scope_temporary_override(): - _i18n.configure_i18n() + _reset_i18n_translator() with _i18n.gettext_scope(lambda message: f"scoped:{message}"): assert _i18n.gettext("value") == "scoped:value" @@ -172,7 +214,7 @@ def test_gettext_scope_temporary_override(): def test_gettext_scope_supports_nesting(): - _i18n.configure_i18n() + _reset_i18n_translator() with _i18n.gettext_scope(lambda message: f"outer:{message}"): with _i18n.gettext_scope(lambda message: f"inner:{message}"): @@ -190,7 +232,7 @@ def test_message_decorator_uses_runtime_localizer(): ): Schema(IsTrue())(False) finally: - _i18n.configure_i18n() + _reset_i18n_translator() def test_message_decorator_respects_explicit_message_override(): @@ -203,7 +245,7 @@ def test_message_decorator_respects_explicit_message_override(): ): Schema(IsTrue("explicit message"))(False) finally: - _i18n.configure_i18n() + _reset_i18n_translator() def test_schema_multiple_errors_use_runtime_localizer(): @@ -229,7 +271,7 @@ def test_schema_multiple_errors_use_runtime_localizer(): "localized:expected an email address" in error for error in error_texts ) finally: - _i18n.configure_i18n() + _reset_i18n_translator() def test_configure_i18n_with_real_mo_file(): @@ -241,7 +283,7 @@ def test_configure_i18n_with_real_mo_file(): assert translated("required key not provided") == I18N_REQUIRED_DE assert translated("value was not true") == "wert war nicht wahr" - _i18n.configure_i18n() + _reset_i18n_translator() def test_de_mo_file_contains_real_translations(): @@ -280,7 +322,10 @@ def test_fresh_context_after_configure_i18n_uses_updated_default(): domain="voluptuous", ) - assert _i18n.gettext("required key not provided") == "localized:required key not provided" + assert ( + _i18n.gettext("required key not provided") + == "localized:required key not provided" + ) assert contextvars.Context().run( _i18n.gettext, "required key not provided" ) == translated("required key not provided") @@ -388,41 +433,52 @@ def test_locale_fixture_does_not_ship_as_package_data(tmp_path): dist_dir = tmp_path / "dist" dist_dir.mkdir() project_dir = Path(__file__).resolve().parents[2] + generated_metadata = ( + project_dir / "build", + project_dir / "voluptuous.egg-info", + ) production_path = "voluptuous/locale/de/LC_MESSAGES/voluptuous.mo" fixture_path = "voluptuous/tests/fixtures/locale/de/LC_MESSAGES/voluptuous.mo" - sdist = subprocess.run( - [sys.executable, "setup.py", "sdist", "--dist-dir", str(dist_dir)], - cwd=str(project_dir), - capture_output=True, - text=True, - ) - if sdist.returncode != 0: - pytest.skip(f"sdist command unavailable: {sdist.stderr}") - - wheel = subprocess.run( - [sys.executable, "setup.py", "bdist_wheel", "--dist-dir", str(dist_dir)], - cwd=str(project_dir), - capture_output=True, - text=True, - ) - if wheel.returncode != 0: - pytest.skip(f"bdist_wheel command unavailable: {wheel.stderr}") - - sdist_files = sorted(dist_dir.glob("voluptuous-*.tar.gz")) - wheel_files = sorted(dist_dir.glob("voluptuous-*.whl")) - assert sdist_files, f"no sdist found in {dist_dir}" - assert wheel_files, f"no wheel found in {dist_dir}" - - with tarfile.open(sdist_files[0], "r:gz") as archive: - archive_names = archive.getnames() - assert production_path not in archive_names - assert any(name.endswith(fixture_path) for name in archive_names) - - with zipfile.ZipFile(wheel_files[0]) as archive: - archive_names = archive.namelist() - assert production_path not in archive_names - assert fixture_path not in archive_names + for path in generated_metadata: + shutil.rmtree(path, ignore_errors=True) + + try: + sdist = subprocess.run( + [sys.executable, "setup.py", "sdist", "--dist-dir", str(dist_dir)], + cwd=str(project_dir), + capture_output=True, + text=True, + ) + if sdist.returncode != 0: + pytest.skip(f"sdist command unavailable: {sdist.stderr}") + + wheel = subprocess.run( + [sys.executable, "setup.py", "bdist_wheel", "--dist-dir", str(dist_dir)], + cwd=str(project_dir), + capture_output=True, + text=True, + ) + if wheel.returncode != 0: + pytest.skip(f"bdist_wheel command unavailable: {wheel.stderr}") + + sdist_files = sorted(dist_dir.glob("voluptuous-*.tar.gz")) + wheel_files = sorted(dist_dir.glob("voluptuous-*.whl")) + assert sdist_files, f"no sdist found in {dist_dir}" + assert wheel_files, f"no wheel found in {dist_dir}" + + with tarfile.open(sdist_files[0], "r:gz") as archive: + archive_names = archive.getnames() + assert production_path not in archive_names + assert any(name.endswith(fixture_path) for name in archive_names) + + with zipfile.ZipFile(wheel_files[0]) as archive: + archive_names = archive.namelist() + assert production_path not in archive_names + assert fixture_path not in archive_names + finally: + for path in generated_metadata: + shutil.rmtree(path, ignore_errors=True) def test_exact_sequence(): From e281ca828cbc9017953f41ea84f583c73c41e192 Mon Sep 17 00:00:00 2001 From: Aleksandr Kotlyar Date: Sun, 5 Jul 2026 09:13:49 +0300 Subject: [PATCH 06/15] - --- voluptuous/schema_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/voluptuous/schema_builder.py b/voluptuous/schema_builder.py index 9b9eb53..137a8b7 100644 --- a/voluptuous/schema_builder.py +++ b/voluptuous/schema_builder.py @@ -38,7 +38,7 @@ def __repr__(self): UNDEFINED = Undefined() -def Self(_) -> None: +def Self() -> None: raise er.SchemaError(gettext('"Self" should never be called')) From 2a17997f01e9b3302a925ba06bbb53c873d824a7 Mon Sep 17 00:00:00 2001 From: Aleksandr Kotlyar Date: Sun, 5 Jul 2026 09:15:50 +0300 Subject: [PATCH 07/15] Strengthen i18n packaging checks --- README.md | 13 +++++++++---- tox.ini | 1 + voluptuous/tests/tests.py | 12 ++++++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c5303aa..a2a80bf 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The documentation is provided [here](http://alecthomas.github.io/voluptuous/). ## Internationalization -Voluptuous validates all user-facing error messages through `voluptuous._i18n`. +Voluptuous validates all user-facing error messages through its i18n hooks. Voluptuous does not ship production translation catalogs. Use `configure_i18n()` to load your own gettext translations and set the @@ -71,10 +71,15 @@ overrides are preserved, so a request, task, or test can temporarily use a different translator without changing other contexts: ```pycon ->>> from voluptuous._i18n import gettext, gettext_scope +>>> from voluptuous import MultipleInvalid, Required, Schema, gettext_scope +>>> schema = Schema({Required("name"): str}) >>> with gettext_scope(lambda message: f"scoped: {message}"): -... gettext("required key not provided") -'scoped: required key not provided' +... 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 both the current context translator diff --git a/tox.ini b/tox.ini index acb505d..dc2bb17 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,7 @@ deps = pytest pytest-cov coverage>=3.0 + wheel commands = pytest \ --cov=voluptuous \ diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index 9639d54..156591a 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -451,7 +451,11 @@ def test_locale_fixture_does_not_ship_as_package_data(tmp_path): text=True, ) if sdist.returncode != 0: - pytest.skip(f"sdist command unavailable: {sdist.stderr}") + pytest.fail( + f"sdist command failed with exit code {sdist.returncode}\n" + f"stdout:\n{sdist.stdout}\n" + f"stderr:\n{sdist.stderr}" + ) wheel = subprocess.run( [sys.executable, "setup.py", "bdist_wheel", "--dist-dir", str(dist_dir)], @@ -460,7 +464,11 @@ def test_locale_fixture_does_not_ship_as_package_data(tmp_path): text=True, ) if wheel.returncode != 0: - pytest.skip(f"bdist_wheel command unavailable: {wheel.stderr}") + pytest.fail( + f"bdist_wheel command failed with exit code {wheel.returncode}\n" + f"stdout:\n{wheel.stdout}\n" + f"stderr:\n{wheel.stderr}" + ) sdist_files = sorted(dist_dir.glob("voluptuous-*.tar.gz")) wheel_files = sorted(dist_dir.glob("voluptuous-*.whl")) From 941c71e0e0710059c4b59e86855a8b19f3757117 Mon Sep 17 00:00:00 2001 From: Aleksandr Kotlyar Date: Sun, 5 Jul 2026 09:17:48 +0300 Subject: [PATCH 08/15] Make set_gettext update only default translator --- README.md | 4 ++-- voluptuous/_i18n.py | 5 ++--- voluptuous/tests/tests.py | 8 ++++---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a2a80bf..26edb80 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,8 @@ different translator without changing other contexts: "scoped: required key not provided @ data['name']" ``` -Use `set_gettext()` when you want to replace both the current context translator -and the global default with a custom callable. +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. diff --git a/voluptuous/_i18n.py b/voluptuous/_i18n.py index bf33f03..233b8d2 100644 --- a/voluptuous/_i18n.py +++ b/voluptuous/_i18n.py @@ -29,12 +29,11 @@ def _normalize_languages( def set_gettext(translation_func: TranslateFunc) -> TranslateFunc: """Inject a custom gettext-compatible callable used by voluptuous messages. - This updates the current context translator and the module default. + This updates the module default translator. """ global _default_translator _default_translator = translation_func - _translator.set(translation_func) return translation_func @@ -48,7 +47,7 @@ def configure_i18n( """Configure module-level translation backend for voluptuous messages. This updates the module default translator. Explicit context overrides set - by set_gettext() or gettext_scope() remain active for the current context. + by gettext_scope() remain active for the current context. """ global _default_translator diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index 156591a..bf89a61 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -111,6 +111,7 @@ def test_i18n_set_gettext(): try: assert _i18n.gettext("value") == "localized:value" assert _i18n._("value") == "localized:value" + assert _i18n._translator.get() is None finally: _reset_i18n_translator() @@ -313,7 +314,7 @@ def _collect_thread_value(): assert thread_result["value"] == "localized:value" -def test_fresh_context_after_configure_i18n_uses_updated_default(): +def test_configure_i18n_replaces_set_gettext_default(): _i18n.set_gettext(lambda message: f"localized:{message}") translated = _i18n.configure_i18n( @@ -322,9 +323,8 @@ def test_fresh_context_after_configure_i18n_uses_updated_default(): domain="voluptuous", ) - assert ( - _i18n.gettext("required key not provided") - == "localized:required key not provided" + assert _i18n.gettext("required key not provided") == translated( + "required key not provided" ) assert contextvars.Context().run( _i18n.gettext, "required key not provided" From d2db2735097f35c2949930cd33e4bf25373b7f20 Mon Sep 17 00:00:00 2001 From: Aleksandr Kotlyar Date: Sun, 5 Jul 2026 09:18:03 +0300 Subject: [PATCH 09/15] - --- voluptuous/schema_builder.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/voluptuous/schema_builder.py b/voluptuous/schema_builder.py index 137a8b7..91c0fb6 100644 --- a/voluptuous/schema_builder.py +++ b/voluptuous/schema_builder.py @@ -1,4 +1,3 @@ -# fmt: off from __future__ import annotations import collections @@ -15,8 +14,6 @@ from voluptuous.error import Error from voluptuous._i18n import gettext -# fmt: on - # options for extra keys PREVENT_EXTRA = 0 # any extra key not in schema will raise an error ALLOW_EXTRA = 1 # extra keys not in schema will be included in output From 88fb282a91de61cf6c9e0c35e3ad7ebefc5f90f9 Mon Sep 17 00:00:00 2001 From: Aleksandr Kotlyar Date: Sun, 5 Jul 2026 09:20:25 +0300 Subject: [PATCH 10/15] Fix default mapping error translation timing --- voluptuous/schema_builder.py | 2 +- voluptuous/tests/tests.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/voluptuous/schema_builder.py b/voluptuous/schema_builder.py index 91c0fb6..e82d437 100644 --- a/voluptuous/schema_builder.py +++ b/voluptuous/schema_builder.py @@ -237,7 +237,7 @@ def _compile(self, schema): def _compile_mapping(self, schema, invalid_msg=None): """Create validator for given mapping.""" - invalid_msg = invalid_msg or gettext('mapping value') + invalid_msg = invalid_msg or 'mapping value' # Keys that may be required all_required_keys = set( diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index bf89a61..eff26e9 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -406,6 +406,20 @@ def test_mapping_error_type_uses_runtime_localizer(): ) +def test_default_mapping_error_type_uses_runtime_localizer(): + _i18n.set_gettext(lambda message: f"compile:{message}") + validate_mapping = Schema({})._compile_mapping({"name": int}) + _i18n.set_gettext(lambda message: f"runtime:{message}") + + with pytest.raises(MultipleInvalid) as ctx: + validate_mapping([], {"name": "not-an-int"}.items(), {}) + + assert ( + str(ctx.value) + == "runtime:expected int for runtime:mapping value @ data['name']" + ) + + def test_object_error_type_uses_runtime_localizer(): schema = Schema(Object({"value": int}, cls=MyValueClass)) _i18n.set_gettext(lambda message: f"localized:{message}") From cf6fa8245aeec5b12bf9d22a13b179eeb9be6c15 Mon Sep 17 00:00:00 2001 From: Aleksandr Kotlyar Date: Sun, 5 Jul 2026 09:23:18 +0300 Subject: [PATCH 11/15] Localize SomeOf bounds assertion --- voluptuous/tests/tests.py | 13 +++++++++++++ voluptuous/validators.py | 4 +++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index eff26e9..7e5f442 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -249,6 +249,19 @@ def test_message_decorator_respects_explicit_message_override(): _reset_i18n_translator() +def test_someof_bounds_assertion_uses_runtime_localizer(): + _i18n.set_gettext(lambda message: f"localized:{message}") + + with pytest.raises(AssertionError) as ctx: + SomeOf(validators=[]) + + expected = ( + 'localized:when using "SomeOf" you should specify at least one of ' + 'min_valid and max_valid' + ) + assert str(ctx.value) == expected + + def test_schema_multiple_errors_use_runtime_localizer(): _i18n.set_gettext(lambda message: f"localized:{message}") diff --git a/voluptuous/validators.py b/voluptuous/validators.py index 92111cb..d8f8136 100644 --- a/voluptuous/validators.py +++ b/voluptuous/validators.py @@ -1252,7 +1252,9 @@ def __init__( **kwargs, ) -> None: assert min_valid is not None or max_valid is not None, ( - 'when using "%s" you should specify at least one of min_valid and max_valid' + gettext( + 'when using "%s" you should specify at least one of min_valid and max_valid' + ) % (type(self).__name__,) ) self.min_valid = min_valid or 0 From f5e027318280dd73e2a95fa7135112f70df5081f Mon Sep 17 00:00:00 2001 From: Aleksandr Kotlyar Date: Sun, 5 Jul 2026 13:34:00 +0300 Subject: [PATCH 12/15] fix linters --- tox.ini | 1 + voluptuous/__init__.py | 3 ++- voluptuous/_i18n.py | 1 + voluptuous/humanize.py | 2 +- voluptuous/schema_builder.py | 2 +- voluptuous/util.py | 2 +- voluptuous/validators.py | 11 ++++------- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tox.ini b/tox.ini index dc2bb17..61a3a3b 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,7 @@ deps = pytest pytest-cov coverage>=3.0 + setuptools wheel commands = pytest \ diff --git a/voluptuous/__init__.py b/voluptuous/__init__.py index eac3ed8..e89e650 100644 --- a/voluptuous/__init__.py +++ b/voluptuous/__init__.py @@ -74,6 +74,8 @@ True """ +from voluptuous._i18n import configure_i18n, gettext_scope, set_gettext + # Dataclasses support from voluptuous.dataclasses_support import ( DataclassSchema, @@ -86,7 +88,6 @@ from voluptuous.schema_builder import * from voluptuous.util import * from voluptuous.validators import * -from voluptuous._i18n import configure_i18n, gettext_scope, set_gettext from voluptuous.error import * # isort: skip diff --git a/voluptuous/_i18n.py b/voluptuous/_i18n.py index 233b8d2..af1266b 100644 --- a/voluptuous/_i18n.py +++ b/voluptuous/_i18n.py @@ -20,6 +20,7 @@ def _normalize_languages( return [languages] return list(languages) + _default_translator: TranslateFunc = _gettext.gettext _translator: ContextVar[typing.Optional[TranslateFunc]] = ContextVar( "voluptuous_gettext", default=None diff --git a/voluptuous/humanize.py b/voluptuous/humanize.py index ebcbc2a..e1419e7 100644 --- a/voluptuous/humanize.py +++ b/voluptuous/humanize.py @@ -2,9 +2,9 @@ import typing from voluptuous import Invalid, MultipleInvalid +from voluptuous._i18n import gettext from voluptuous.error import Error from voluptuous.schema_builder import Schema -from voluptuous._i18n import gettext # fmt: on diff --git a/voluptuous/schema_builder.py b/voluptuous/schema_builder.py index e82d437..2df347c 100644 --- a/voluptuous/schema_builder.py +++ b/voluptuous/schema_builder.py @@ -11,8 +11,8 @@ from functools import cache, wraps from voluptuous import error as er -from voluptuous.error import Error from voluptuous._i18n import gettext +from voluptuous.error import Error # options for extra keys PREVENT_EXTRA = 0 # any extra key not in schema will raise an error diff --git a/voluptuous/util.py b/voluptuous/util.py index 3b6d6f7..0eb2a1c 100644 --- a/voluptuous/util.py +++ b/voluptuous/util.py @@ -3,10 +3,10 @@ import typing from voluptuous import validators # noqa: F401 +from voluptuous._i18n import gettext from voluptuous.error import Invalid, LiteralInvalid, TypeInvalid # noqa: F401 from voluptuous.schema_builder import DefaultFactory # noqa: F401 from voluptuous.schema_builder import Schema, default_factory, raises # noqa: F401 -from voluptuous._i18n import gettext # fmt: on diff --git a/voluptuous/validators.py b/voluptuous/validators.py index d8f8136..46f625a 100644 --- a/voluptuous/validators.py +++ b/voluptuous/validators.py @@ -9,6 +9,7 @@ from decimal import Decimal, InvalidOperation from functools import wraps +from voluptuous._i18n import gettext from voluptuous.error import ( AllInvalid, AnyInvalid, @@ -39,7 +40,6 @@ # F401: flake8 complains about 'raises' not being used, but it is used in doctests from voluptuous.schema_builder import Schema, Schemable, message, raises # noqa: F401 -from voluptuous._i18n import gettext if typing.TYPE_CHECKING: from _typeshed import SupportsAllComparisons @@ -1251,12 +1251,9 @@ def __init__( max_valid: typing.Optional[int] = None, **kwargs, ) -> None: - assert min_valid is not None or max_valid is not None, ( - gettext( - 'when using "%s" you should specify at least one of min_valid and max_valid' - ) - % (type(self).__name__,) - ) + assert min_valid is not None or max_valid is not None, gettext( + 'when using "%s" you should specify at least one of min_valid and max_valid' + ) % (type(self).__name__,) self.min_valid = min_valid or 0 self.max_valid = max_valid or len(validators) super(SomeOf, self).__init__(*validators, **kwargs) From 0490c3590a796af20c1ce83d052437549952c319 Mon Sep 17 00:00:00 2001 From: Aleksandr Kotlyar Date: Sun, 5 Jul 2026 13:59:29 +0300 Subject: [PATCH 13/15] polish --- README.md | 2 +- voluptuous/_i18n.py | 5 ----- voluptuous/tests/tests.py | 5 ++--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 26edb80..86c46b1 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The documentation is provided [here](http://alecthomas.github.io/voluptuous/). ## Internationalization -Voluptuous validates all user-facing error messages through its i18n hooks. +Voluptuous routes built-in validation message templates through its i18n hooks. Voluptuous does not ship production translation catalogs. Use `configure_i18n()` to load your own gettext translations and set the diff --git a/voluptuous/_i18n.py b/voluptuous/_i18n.py index af1266b..3362705 100644 --- a/voluptuous/_i18n.py +++ b/voluptuous/_i18n.py @@ -1,4 +1,3 @@ -# fmt: off from __future__ import annotations import gettext as _gettext @@ -72,10 +71,6 @@ def gettext(message: str) -> str: return translator(message) -# Backward-compatible alias for existing callers. -_ = gettext - - @contextmanager def gettext_scope(translation_func: TranslateFunc): """Temporarily activate a translator for the current execution context.""" diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index 7e5f442..9538767 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -110,7 +110,6 @@ def test_i18n_set_gettext(): try: assert _i18n.gettext("value") == "localized:value" - assert _i18n._("value") == "localized:value" assert _i18n._translator.get() is None finally: _reset_i18n_translator() @@ -504,12 +503,12 @@ def test_locale_fixture_does_not_ship_as_package_data(tmp_path): with tarfile.open(sdist_files[0], "r:gz") as archive: archive_names = archive.getnames() - assert production_path not in archive_names + assert not any(name.endswith(production_path) for name in archive_names) assert any(name.endswith(fixture_path) for name in archive_names) with zipfile.ZipFile(wheel_files[0]) as archive: archive_names = archive.namelist() - assert production_path not in archive_names + assert not any(name.endswith(production_path) for name in archive_names) assert fixture_path not in archive_names finally: for path in generated_metadata: From 8e39042d54b406364971244be89fa7108eb0486f Mon Sep 17 00:00:00 2001 From: Aleksandr Kotlyar Date: Sun, 5 Jul 2026 15:02:27 +0300 Subject: [PATCH 14/15] Localize invalid error type format --- README.md | 7 ++++--- voluptuous/error.py | 7 ++++++- voluptuous/tests/tests.py | 36 ++++++++++++++++++++++++++++++++---- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 86c46b1..5e6417b 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ The documentation is provided [here](http://alecthomas.github.io/voluptuous/). ## Internationalization -Voluptuous routes built-in validation message templates through its i18n hooks. +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 @@ -87,8 +88,8 @@ 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. +`Invalid.__str__` always renders the path fragment as `" @ data[...]"` (not +translated) so tooling that parses error paths stays stable. ## Contribution to Documentation diff --git a/voluptuous/error.py b/voluptuous/error.py index fdedb5e..6e1345f 100644 --- a/voluptuous/error.py +++ b/voluptuous/error.py @@ -1,6 +1,8 @@ # fmt: off import typing +from voluptuous._i18n import gettext + # fmt: on @@ -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: diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index 9538767..19a659c 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -405,7 +405,7 @@ def test_schema_created_before_locale_switch_still_translates_messages(): assert I18N_REQUIRED_DE in str(ctx.value) -def test_mapping_error_type_uses_runtime_localizer(): +def test_mapping_error_type_and_format_use_runtime_localizer(): schema = Schema({"name": int}) _i18n.set_gettext(lambda message: f"localized:{message}") @@ -414,7 +414,10 @@ def test_mapping_error_type_uses_runtime_localizer(): assert ( str(ctx.value) - == "localized:expected int for localized:dictionary value @ data['name']" + == ( + "localized:localized:expected int for localized:dictionary value" + " @ data['name']" + ) ) @@ -428,7 +431,7 @@ def test_default_mapping_error_type_uses_runtime_localizer(): assert ( str(ctx.value) - == "runtime:expected int for runtime:mapping value @ data['name']" + == "runtime:runtime:expected int for runtime:mapping value @ data['name']" ) @@ -441,10 +444,35 @@ def test_object_error_type_uses_runtime_localizer(): assert ( str(ctx.value) - == "localized:expected int for localized:object value @ data['value']" + == "localized:localized:expected int for localized:object value @ data['value']" ) +def test_invalid_str_error_type_format_defaults_to_existing_english(): + schema = Schema({"age": int}) + + with pytest.raises(MultipleInvalid) as ctx: + schema({"age": "old"}) + + assert str(ctx.value) == "expected int for dictionary value @ data['age']" + + +def test_invalid_str_error_type_format_is_translatable_and_reorderable(): + schema = Schema({"age": int}) + translations = { + "expected %s": "%s expected", + "dictionary value": "dict value", + "%(message)s for %(error_type)s": "%(error_type)s: %(message)s", + } + + with _i18n.gettext_scope(lambda message: translations.get(message, message)): + with pytest.raises(MultipleInvalid) as ctx: + schema({"age": "old"}) + rendered = str(ctx.value) + + assert rendered == "dict value: int expected @ data['age']" + + def test_invalid_str_path_fragment_is_not_translated(): _i18n.set_gettext(lambda message: f"localized:{message}") From 0d33e696b7d81af23e601f9ba9b7ff63b221da05 Mon Sep 17 00:00:00 2001 From: Aleksandr Kotlyar Date: Sun, 5 Jul 2026 15:06:51 +0300 Subject: [PATCH 15/15] Format i18n tests with Black --- voluptuous/tests/tests.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index 19a659c..2f9dab4 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -412,12 +412,9 @@ def test_mapping_error_type_and_format_use_runtime_localizer(): with pytest.raises(MultipleInvalid) as ctx: schema({"name": "not-an-int"}) - assert ( - str(ctx.value) - == ( - "localized:localized:expected int for localized:dictionary value" - " @ data['name']" - ) + assert str(ctx.value) == ( + "localized:localized:expected int for localized:dictionary value" + " @ data['name']" )