diff --git a/MANIFEST.in b/MANIFEST.in index cfd6717..e42c242 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/tests/fixtures *.mo *.po diff --git a/README.md b/README.md index e359f11..5e6417b 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,56 @@ To file a bug, create a [new issue](https://github.com/alecthomas/voluptuous/iss The documentation is provided [here](http://alecthomas.github.io/voluptuous/). +## Internationalization + +Voluptuous routes built-in validation message templates and the +`Invalid.__str__` message/error-type format through its i18n hooks. + +Voluptuous does not ship production translation catalogs. Use +`configure_i18n()` to load your own gettext translations and set the +module-wide default translator: + +```pycon +>>> from voluptuous import configure_i18n +>>> configure_i18n( +... domain="myapp", +... localedir="/path/to/myapp/locale", +... languages=("de",), +... fallback=True, +... ) +``` + +`domain` is the gettext domain to load, such as `myapp` or `voluptuous`. +`localedir` points at a locale directory containing paths like +`de/LC_MESSAGES/myapp.mo` for `domain="myapp"`, or +`de/LC_MESSAGES/voluptuous.mo` for `domain="voluptuous"`. `languages` selects +one or more locale names. `fallback=True` keeps untranslated messages in +English instead of raising when catalog files are missing. + +`configure_i18n()` updates the global default translator. Active context-local +overrides are preserved, so a request, task, or test can temporarily use a +different translator without changing other contexts: + +```pycon +>>> from voluptuous import MultipleInvalid, Required, Schema, gettext_scope +>>> schema = Schema({Required("name"): str}) +>>> with gettext_scope(lambda message: f"scoped: {message}"): +... try: +... schema({}) +... except MultipleInvalid as error: +... result = str(error) +>>> result +"scoped: required key not provided @ data['name']" +``` + +Use `set_gettext()` when you want to replace the global default with a custom +callable. Use `gettext_scope()` for temporary context-local overrides. + +When no translation is configured, messages stay in English. + +`Invalid.__str__` always renders the path fragment as `" @ data[...]"` (not +translated) so tooling that parses error paths stays stable. + ## Contribution to Documentation Documentation is built using `Sphinx`. You can install it by @@ -843,4 +893,3 @@ using voluptuous validators in `assert`s. I greatly prefer the light-weight style promoted by these libraries to the complexity of libraries like FormEncode. - diff --git a/tox.ini b/tox.ini index acb505d..61a3a3b 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,8 @@ deps = pytest pytest-cov coverage>=3.0 + setuptools + wheel commands = pytest \ --cov=voluptuous \ diff --git a/voluptuous/__init__.py b/voluptuous/__init__.py index 966ab6f..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, diff --git a/voluptuous/_i18n.py b/voluptuous/_i18n.py new file mode 100644 index 0000000..3362705 --- /dev/null +++ b/voluptuous/_i18n.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import gettext as _gettext +import typing +from collections.abc import Callable, Iterable +from contextlib import contextmanager +from contextvars import ContextVar + +# Public interface for translating user-visible strings. +TranslateFunc = Callable[[str], str] + + +def _normalize_languages( + languages: typing.Optional[typing.Union[str, Iterable[str]]], +) -> typing.Optional[list[str]]: + if languages is None: + return None + if isinstance(languages, str): + return [languages] + return list(languages) + + +_default_translator: TranslateFunc = _gettext.gettext +_translator: ContextVar[typing.Optional[TranslateFunc]] = ContextVar( + "voluptuous_gettext", default=None +) + + +def set_gettext(translation_func: TranslateFunc) -> TranslateFunc: + """Inject a custom gettext-compatible callable used by voluptuous messages. + + This updates the module default translator. + """ + + global _default_translator + _default_translator = translation_func + return translation_func + + +def configure_i18n( + *, + domain: str = "voluptuous", + localedir: typing.Optional[str] = None, + languages: typing.Optional[typing.Union[str, Iterable[str]]] = None, + fallback: bool = True, +) -> TranslateFunc: + """Configure module-level translation backend for voluptuous messages. + + This updates the module default translator. Explicit context overrides set + by gettext_scope() remain active for the current context. + """ + + global _default_translator + translation = _gettext.translation( + domain, + localedir=localedir, + languages=_normalize_languages(languages), + fallback=fallback, + ) + translation_func = translation.gettext + _default_translator = translation_func + return translation_func + + +def gettext(message: str) -> str: + """Translate a user-facing message through active backend.""" + + translator = _translator.get() + if translator is None: + translator = _default_translator + return translator(message) + + +@contextmanager +def gettext_scope(translation_func: TranslateFunc): + """Temporarily activate a translator for the current execution context.""" + + token = _translator.set(translation_func) + try: + yield + finally: + _translator.reset(token) 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/humanize.py b/voluptuous/humanize.py index eabfd02..e1419e7 100644 --- a/voluptuous/humanize.py +++ b/voluptuous/humanize.py @@ -2,6 +2,7 @@ import typing from voluptuous import Invalid, MultipleInvalid +from voluptuous._i18n import gettext from voluptuous.error import Error from voluptuous.schema_builder import Schema @@ -45,7 +46,7 @@ def humanize_error( offending_item_summary = ( offending_item_summary[: max_sub_error_length - 3] + '...' ) - return '%s. Got %s' % (validation_error, offending_item_summary) + return gettext('%s. Got %s') % (validation_error, offending_item_summary) def validate_with_humanized_errors( diff --git a/voluptuous/schema_builder.py b/voluptuous/schema_builder.py index 3673e86..2df347c 100644 --- a/voluptuous/schema_builder.py +++ b/voluptuous/schema_builder.py @@ -1,4 +1,3 @@ -# fmt: off from __future__ import annotations import collections @@ -12,10 +11,9 @@ from functools import cache, wraps from voluptuous import error as er +from voluptuous._i18n import gettext from voluptuous.error import Error -# 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 @@ -38,7 +36,7 @@ def __repr__(self): def Self() -> None: - raise er.SchemaError('"Self" should never be called') + raise er.SchemaError(gettext('"Self" should never be called')) DefaultFactory = typing.Union[Undefined, typing.Callable[[], typing.Any]] @@ -67,7 +65,7 @@ def raises( 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 @@ -233,7 +231,9 @@ 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.""" @@ -319,7 +319,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 gettext('at least one of %s is required') + % (complex_key.candidate_keys,) ) errors.append(er.RequiredFieldInvalid(msg, path + [complex_key])) else: @@ -369,7 +370,11 @@ 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 = ( + gettext(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. @@ -395,14 +400,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: @@ -434,7 +441,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, {}) @@ -532,7 +541,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(): @@ -543,7 +552,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 gettext( + "two or more values in the same group of exclusion '%s'" + ) % label ) next_path = path + [VirtualPathComponent(label)] @@ -558,7 +569,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: @@ -595,13 +608,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) + raise er.SequenceTypeInvalid( + 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)] + [ + er.ValueInvalid( + gettext('not a valid value'), path if path else data + ) + ] ) return data @@ -682,7 +701,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 = [] @@ -697,7 +716,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: @@ -733,18 +754,20 @@ 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.' + 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 ' + 'dict merge semantics.' + ) ) schema = self._normalize_schema_extension( 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() @@ -831,7 +854,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 @@ -842,7 +865,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 @@ -851,7 +874,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 @@ -971,7 +994,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) @@ -1318,7 +1341,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): @@ -1329,9 +1352,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 gettext(default or "invalid value") + raise (clsoverride or cls or er.ValueInvalid)(message) return wrapper diff --git a/voluptuous/tests/fixtures/locale/de/LC_MESSAGES/voluptuous.mo b/voluptuous/tests/fixtures/locale/de/LC_MESSAGES/voluptuous.mo new file mode 100644 index 0000000..5526269 Binary files /dev/null and b/voluptuous/tests/fixtures/locale/de/LC_MESSAGES/voluptuous.mo differ diff --git a/voluptuous/tests/fixtures/locale/de/LC_MESSAGES/voluptuous.po b/voluptuous/tests/fixtures/locale/de/LC_MESSAGES/voluptuous.po new file mode 100644 index 0000000..e3de850 --- /dev/null +++ b/voluptuous/tests/fixtures/locale/de/LC_MESSAGES/voluptuous.po @@ -0,0 +1,11 @@ +msgid "" +msgstr "" +"Project-Id-Version: Voluptuous\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" + +msgid "required key not provided" +msgstr "erforderliches feld fehlt" + +msgid "value was not true" +msgstr "wert war nicht wahr" diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index 0608548..2f9dab4 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -1,11 +1,21 @@ # fmt: off import collections +import contextvars import copy import os +import shutil +import subprocess +import sys +import tarfile +import threading +import typing +import zipfile from enum import Enum +from pathlib import Path import pytest +import voluptuous._i18n as _i18n from voluptuous import ( ALLOW_EXTRA, PREVENT_EXTRA, @@ -32,6 +42,7 @@ Invalid, IsDir, IsFile, + IsTrue, Length, Literal, LiteralInvalid, @@ -68,6 +79,21 @@ # fmt: on +I18N_LOCALE_DIR = Path(__file__).resolve().parent / "fixtures" / "locale" +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(): + _reset_i18n_translator() + yield + _reset_i18n_translator() + def test_new_required_test(): schema = Schema( @@ -79,6 +105,441 @@ 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._translator.get() is None + finally: + _reset_i18n_translator() + + +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" + + _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): + 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() + + 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 + + _reset_i18n_translator() + + +def test_gettext_scope_temporary_override(): + _reset_i18n_translator() + + 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(): + _reset_i18n_translator() + + 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: + _reset_i18n_translator() + + +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: + _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}") + + 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: + _reset_i18n_translator() + + +def test_configure_i18n_with_real_mo_file(): + translated = _i18n.configure_i18n( + localedir=str(I18N_LOCALE_DIR), + languages=("de",), + domain="voluptuous", + ) + assert translated("required key not provided") == I18N_REQUIRED_DE + assert translated("value was not true") == "wert war nicht wahr" + + _reset_i18n_translator() + + +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" + + assert contextvars.Context().run(_i18n.gettext, "value") == "localized:value" + + thread_result: dict[str, str] = {} + + def _collect_thread_value(): + thread_result["value"] = _i18n.gettext("value") + + thread = threading.Thread(target=_collect_thread_value) + thread.start() + thread.join() + assert thread_result["value"] == "localized:value" + + +def test_configure_i18n_replaces_set_gettext_default(): + _i18n.set_gettext(lambda message: f"localized:{message}") + + translated = _i18n.configure_i18n( + localedir=str(I18N_LOCALE_DIR), + languages=("de",), + domain="voluptuous", + ) + + assert _i18n.gettext("required key not provided") == translated( + "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] = {} + + def _collect_fresh_thread_value(): + fresh_thread_result["value"] = _i18n.gettext("required key not provided") + + 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( + localedir=str(I18N_LOCALE_DIR), + languages=("de",), + domain="voluptuous", + ) + + with pytest.raises(MultipleInvalid) as ctx: + schema({}) + + assert I18N_REQUIRED_DE in str(ctx.value) + + +def test_mapping_error_type_and_format_use_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:localized:expected int for localized:dictionary value" + " @ data['name']" + ) + + +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: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}") + + with pytest.raises(MultipleInvalid) as ctx: + schema(MyValueClass(value="not-an-int")) + + assert ( + str(ctx.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}") + + with pytest.raises(MultipleInvalid) as ctx: + Schema({"name": int})({"name": "not-an-int"}) + + assert "localized:expected int" in str(ctx.value) + assert " @ data['name']" in str(ctx.value) + + +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" + + 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.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)], + cwd=str(project_dir), + capture_output=True, + text=True, + ) + if wheel.returncode != 0: + 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")) + 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 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 not any(name.endswith(production_path) for name 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(): schema = Schema(ExactSequence([int, int])) with raises(Invalid): diff --git a/voluptuous/util.py b/voluptuous/util.py index 0bf9302..0eb2a1c 100644 --- a/voluptuous/util.py +++ b/voluptuous/util.py @@ -3,6 +3,7 @@ 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 @@ -125,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): @@ -138,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 a69bb8a..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, @@ -144,16 +145,18 @@ 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): 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 +183,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 +205,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 +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 @@ -368,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 @@ -435,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 @@ -482,11 +487,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(gettext("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 +507,17 @@ 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 -@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 +530,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(gettext('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 +553,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 +570,12 @@ 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) +@message("Not a directory", cls=DirInvalid) @truth def IsDir(v): """Verify the directory exists. @@ -585,12 +590,12 @@ 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) +@message("path does not exist", cls=PathInvalid) @truth def PathExists(v): """Verify the path exists, regardless of its type. @@ -607,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): @@ -668,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 @@ -691,7 +696,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 gettext('invalid value or type (must have a partial ordering)') ) def __repr__(self): @@ -739,7 +745,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 gettext('invalid value or type (must have a partial ordering)') ) def __repr__(self): @@ -763,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) @@ -795,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 @@ -813,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 @@ -840,12 +850,14 @@ def __call__(self, v): if check: try: raise InInvalid( - self.msg or f'value must be one of {sorted(self.container)}' + self.msg + or gettext('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 gettext('value must be one of %s') + % str(sorted(self.container, key=str)) ) return v @@ -870,12 +882,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 gettext('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 gettext('value must not be one of %s') + % str(sorted(self.container, key=str)) ) return v @@ -903,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): @@ -982,11 +997,15 @@ 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 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): @@ -1017,7 +1036,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 gettext( + 'Values are not equal: value:{value} != target:{target}' + ).format(value=v, target=self.target) ) return v @@ -1052,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:{} != target:{}'.format( - len(v), len(self._schemas) - ) + or gettext( + 'List lengths differ, value:{value} != target:{target}' + ).format(value=len(v), target=len(self._schemas)) ) consumed = set() @@ -1084,18 +1105,18 @@ 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 gettext( + '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 gettext( + 'Element #{index} ({value}) is not valid against any validator' + ).format(index=el[0], value=el[1]) ) for el in missing ] @@ -1148,17 +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 + self.msg + 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 @@ -1180,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): @@ -1221,10 +1251,9 @@ def __init__( max_valid: typing.Optional[int] = None, **kwargs, ) -> None: - assert min_valid is not None or max_valid is not 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__,) - ) + ) % (type(self).__name__,) self.min_valid = min_valid or 0 self.max_valid = max_valid or len(validators) super(SomeOf, self).__init__(*validators, **kwargs)