This commit is contained in:
2025-09-07 22:09:54 +02:00
parent e1b817252c
commit 2fc0d000b6
7796 changed files with 2159515 additions and 933 deletions

View File

@ -0,0 +1,10 @@
from . import resource_loader
from .resource_loader import I18nFileLoadError, register_loader, load_config
from .translator import t
from .translations import add as add_translation
from . import config
from .config import set, get
resource_loader.init_loaders()
load_path = config.get('load_path')

View File

@ -0,0 +1,35 @@
try:
__import__("yaml")
yaml_available = True
except ImportError:
yaml_available = False
try:
__import__("json")
json_available = True
except ImportError:
json_available = False
settings = {
'filename_format': '{namespace}.{locale}.{format}',
'file_format': 'yml' if yaml_available else 'json' if json_available else 'py',
'available_locales': ['en'],
'load_path': [],
'locale': 'en',
'fallback': 'en',
'placeholder_delimiter': '%',
'error_on_missing_translation': False,
'error_on_missing_placeholder': False,
'error_on_missing_plural': False,
'encoding': 'utf-8',
'namespace_delimiter': '.',
'plural_few': 5,
'skip_locale_root_data': False,
'enable_memoization': False
}
def set(key, value):
settings[key] = value
def get(key):
return settings[key]

View File

@ -0,0 +1,14 @@
import json
from .loader import Loader, I18nFileLoadError
class JsonLoader(Loader):
"""class to load yaml files"""
def __init__(self):
super(JsonLoader, self).__init__()
def parse_file(self, file_content):
try:
return json.loads(file_content)
except ValueError as e:
raise I18nFileLoadError("invalid JSON: {0}".format(e.strerror))

View File

@ -0,0 +1,49 @@
from .. import config
import io
class I18nFileLoadError(Exception):
def __init__(self, value):
self.value = value
def __str__(self):
return str(self.value)
class Loader(object):
"""Base class to load resources"""
def __init__(self):
super(Loader, self).__init__()
self.memoization_dict = {}
def _load_file_data(self, filename):
try:
with io.open(filename, 'r', encoding=config.get('encoding')) as f:
return f.read()
except IOError as e:
raise I18nFileLoadError("error loading file {0}: {1}".format(filename, e.strerror))
def load_file(self, filename):
enable_memoization = config.get('enable_memoization')
if enable_memoization:
if filename not in self.memoization_dict:
self.memoization_dict[filename] = self._load_file_data(filename)
return self.memoization_dict[filename]
else:
return self._load_file_data(filename)
def parse_file(self, file_content):
raise NotImplementedError("the method parse_file has not been implemented for class {0}".format(self.__class__.name__))
def check_data(self, data, root_data):
return True if root_data is None else root_data in data
def get_data(self, data, root_data):
return data if root_data is None else data[root_data]
def load_resource(self, filename, root_data):
file_content = self.load_file(filename)
data = self.parse_file(file_content)
if not self.check_data(data, root_data):
raise I18nFileLoadError("error getting data from {0}: {1} not defined".format(filename, root_data))
return self.get_data(data, root_data)

View File

@ -0,0 +1,29 @@
import os.path
import sys
from .loader import Loader, I18nFileLoadError
class PythonLoader(Loader):
"""class to load python files"""
def __init__(self):
super(PythonLoader, self).__init__()
def load_file(self, filename):
path, name = os.path.split(filename)
module_name, ext = os.path.splitext(name)
if path not in sys.path:
sys.path.append(path)
try:
return __import__(module_name)
except ImportError:
raise I18nFileLoadError("error loading file {0}".format(filename))
def parse_file(self, file_content):
return file_content
def check_data(self, data, root_data):
return hasattr(data, root_data)
def get_data(self, data, root_data):
return getattr(data, root_data)

View File

@ -0,0 +1,17 @@
import yaml
from .loader import Loader, I18nFileLoadError
class YamlLoader(Loader):
"""class to load yaml files"""
def __init__(self):
super(YamlLoader, self).__init__()
def parse_file(self, file_content):
try:
if hasattr(yaml, "FullLoader"):
return yaml.load(file_content, Loader=yaml.FullLoader)
else:
return yaml.load(file_content)
except yaml.scanner.ScannerError as e:
raise I18nFileLoadError("invalid YAML: {0}".format(str(e)))

View File

@ -0,0 +1,117 @@
import os.path
from . import config
from .loaders.loader import I18nFileLoadError
from . import translations
loaders = {}
PLURALS = ["zero", "one", "few", "many", "other"]
def register_loader(loader_class, supported_extensions):
if not hasattr(loader_class, "load_resource"):
raise ValueError("loader class should have a 'load_resource' method")
for extension in supported_extensions:
loaders[extension] = loader_class()
def load_resource(filename, root_data):
extension = os.path.splitext(filename)[1][1:]
if extension not in loaders:
raise I18nFileLoadError("no loader available for extension {0}".format(extension))
return getattr(loaders[extension], "load_resource")(filename, root_data)
def init_loaders():
init_python_loader()
if config.yaml_available:
init_yaml_loader()
if config.json_available:
init_json_loader()
def init_python_loader():
from .loaders.python_loader import PythonLoader
register_loader(PythonLoader, ["py"])
def init_yaml_loader():
from .loaders.yaml_loader import YamlLoader
register_loader(YamlLoader, ["yml", "yaml"])
def init_json_loader():
from .loaders.json_loader import JsonLoader
register_loader(JsonLoader, ["json"])
def load_config(filename):
settings_data = load_resource(filename, "settings")
for key, value in settings_data.items():
config.settings[key] = value
def get_namespace_from_filepath(filename):
namespace = os.path.dirname(filename).strip(os.sep).replace(os.sep, config.get('namespace_delimiter'))
if '{namespace}' in config.get('filename_format'):
try:
splitted_filename = os.path.basename(filename).split('.')
if namespace:
namespace += config.get('namespace_delimiter')
namespace += splitted_filename[config.get('filename_format').index('{namespace}')]
except ValueError:
raise I18nFileLoadError("incorrect file format.")
return namespace
def load_translation_file(filename, base_directory, locale=config.get('locale')):
skip_locale_root_data = config.get('skip_locale_root_data')
root_data = None if skip_locale_root_data else locale
translations_dic = load_resource(os.path.join(base_directory, filename), root_data)
namespace = get_namespace_from_filepath(filename)
load_translation_dic(translations_dic, namespace, locale)
def load_translation_dic(dic, namespace, locale):
if namespace:
namespace += config.get('namespace_delimiter')
for key, value in dic.items():
if type(value) == dict and len(set(PLURALS).intersection(value)) < 2:
load_translation_dic(value, namespace + key, locale)
else:
translations.add(namespace + key, value, locale)
def load_directory(directory, locale=config.get('locale')):
for f in os.listdir(directory):
path = os.path.join(directory, f)
if os.path.isfile(path) and path.endswith(config.get('file_format')):
if '{locale}' in config.get('filename_format') and not locale in f:
continue
load_translation_file(f, directory, locale)
def search_translation(key, locale=config.get('locale')):
splitted_key = key.split(config.get('namespace_delimiter'))
if not splitted_key:
return
namespace = splitted_key[:-1]
if not namespace and '{namespace}' not in config.get('filename_format'):
for directory in config.get('load_path'):
load_directory(directory, locale)
else:
for directory in config.get('load_path'):
recursive_search_dir(namespace, '', directory, locale)
def recursive_search_dir(splitted_namespace, directory, root_dir, locale=config.get('locale')):
if not splitted_namespace:
return
seeked_file = config.get('filename_format').format(namespace=splitted_namespace[0], format=config.get('file_format'), locale=locale)
dir_content = os.listdir(os.path.join(root_dir, directory))
if seeked_file in dir_content:
load_translation_file(os.path.join(directory, seeked_file), root_dir, locale)
elif splitted_namespace[0] in dir_content:
recursive_search_dir(splitted_namespace[1:], os.path.join(directory, splitted_namespace[0]), root_dir, locale)

View File

@ -0,0 +1,240 @@
# -*- encoding: utf-8 -*-
from __future__ import unicode_literals
import unittest
import os
import os.path
import tempfile
import i18n
from i18n import resource_loader
from i18n.resource_loader import I18nFileLoadError
from i18n.translator import t
from i18n import config
from i18n.config import json_available, yaml_available
from i18n import translations
try:
reload # Python 2.7
except NameError:
try:
from importlib import reload # Python 3.4+
except ImportError:
from imp import reload # Python 3.0 - 3.3
RESOURCE_FOLDER = os.path.join(os.path.dirname(__file__), "resources")
class TestFileLoader(unittest.TestCase):
def setUp(self):
resource_loader.loaders = {}
translations.container = {}
reload(config)
config.set("load_path", [os.path.join(RESOURCE_FOLDER, "translations")])
config.set("filename_format", "{namespace}.{locale}.{format}")
config.set("encoding", "utf-8")
def test_load_unavailable_extension(self):
with self.assertRaisesRegexp(I18nFileLoadError, "no loader .*"):
resource_loader.load_resource("foo.bar", "baz")
def test_register_wrong_loader(self):
class WrongLoader(object):
pass
with self.assertRaises(ValueError):
resource_loader.register_loader(WrongLoader, [])
def test_register_python_loader(self):
resource_loader.init_python_loader()
with self.assertRaisesRegexp(I18nFileLoadError, "error loading file .*"):
resource_loader.load_resource("foo.py", "bar")
@unittest.skipUnless(yaml_available, "yaml library not available")
def test_register_yaml_loader(self):
resource_loader.init_yaml_loader()
with self.assertRaisesRegexp(I18nFileLoadError, "error loading file .*"):
resource_loader.load_resource("foo.yml", "bar")
@unittest.skipUnless(json_available, "json library not available")
def test_load_wrong_json_file(self):
resource_loader.init_json_loader()
with self.assertRaisesRegexp(I18nFileLoadError, "error getting data .*"):
resource_loader.load_resource(os.path.join(RESOURCE_FOLDER, "settings", "dummy_config.json"), "foo")
@unittest.skipUnless(yaml_available, "yaml library not available")
def test_load_yaml_file(self):
resource_loader.init_yaml_loader()
data = resource_loader.load_resource(os.path.join(RESOURCE_FOLDER, "settings", "dummy_config.yml"), "settings")
self.assertIn("foo", data)
self.assertEqual("bar", data["foo"])
@unittest.skipUnless(json_available, "json library not available")
def test_load_json_file(self):
resource_loader.init_json_loader()
data = resource_loader.load_resource(os.path.join(RESOURCE_FOLDER, "settings", "dummy_config.json"), "settings")
self.assertIn("foo", data)
self.assertEqual("bar", data["foo"])
def test_load_python_file(self):
resource_loader.init_python_loader()
data = resource_loader.load_resource(os.path.join(RESOURCE_FOLDER, "settings", "dummy_config.py"), "settings")
self.assertIn("foo", data)
self.assertEqual("bar", data["foo"])
@unittest.skipUnless(yaml_available, "yaml library not available")
def test_memoization_with_file(self):
'''This test creates a temporary file with the help of the
tempfile library and writes a simple key: value dictionary in it.
It will then use that file to load the translations and, after having
enabled memoization, try to access it, causing the file to be (hopefully)
memoized. It will then _remove_ the temporary file and try to access again,
asserting that an error is not raised, thus making sure the data is
actually loaded from memory and not from disk access.'''
memoization_file_name = 'memoize.en.yml'
# create the file and write the data in it
try:
d = tempfile.TemporaryDirectory()
tmp_dir_name = d.name
except AttributeError:
# we are running python2, use mkdtemp
tmp_dir_name = tempfile.mkdtemp()
fd = open('{}/{}'.format(tmp_dir_name, memoization_file_name), 'w')
fd.write('en:\n key: value')
fd.close()
# create the loader and pass the file to it
resource_loader.init_yaml_loader()
resource_loader.load_translation_file(memoization_file_name, tmp_dir_name)
# try loading the value to make sure it's working
self.assertEqual(t('memoize.key'), 'value')
# now delete the file and directory
# we are running python2, delete manually
import shutil
shutil.rmtree(tmp_dir_name)
# test the translation again to make sure it's loaded from memory
self.assertEqual(t('memoize.key'), 'value')
@unittest.skipUnless(json_available, "json library not available")
def test_load_file_with_strange_encoding(self):
resource_loader.init_json_loader()
config.set("encoding", "euc-jp")
data = resource_loader.load_resource(os.path.join(RESOURCE_FOLDER, "settings", "eucjp_config.json"), "settings")
self.assertIn("ほげ", data)
self.assertEqual("ホゲ", data["ほげ"])
def test_get_namespace_from_filepath_with_filename(self):
tests = {
"foo": "foo.ja.yml",
"foo.bar": os.path.join("foo", "bar.ja.yml"),
"foo.bar.baz": os.path.join("foo", "bar", "baz.en.yml"),
}
for expected, test_val in tests.items():
namespace = resource_loader.get_namespace_from_filepath(test_val)
self.assertEqual(expected, namespace)
def test_get_namespace_from_filepath_without_filename(self):
tests = {
"": "ja.yml",
"foo": os.path.join("foo", "ja.yml"),
"foo.bar": os.path.join("foo", "bar", "en.yml"),
}
config.set("filename_format", "{locale}.{format}")
for expected, test_val in tests.items():
namespace = resource_loader.get_namespace_from_filepath(test_val)
self.assertEqual(expected, namespace)
@unittest.skipUnless(yaml_available, "yaml library not available")
def test_load_translation_file(self):
resource_loader.init_yaml_loader()
resource_loader.load_translation_file("foo.en.yml", os.path.join(RESOURCE_FOLDER, "translations"))
self.assertTrue(translations.has("foo.normal_key"))
self.assertTrue(translations.has("foo.parent.nested_key"))
@unittest.skipUnless(json_available, "json library not available")
def test_load_plural(self):
resource_loader.init_yaml_loader()
resource_loader.load_translation_file("foo.en.yml", os.path.join(RESOURCE_FOLDER, "translations"))
self.assertTrue(translations.has("foo.mail_number"))
translated_plural = translations.get("foo.mail_number")
self.assertIsInstance(translated_plural, dict)
self.assertEqual(translated_plural["zero"], "You do not have any mail.")
self.assertEqual(translated_plural["one"], "You have a new mail.")
self.assertEqual(translated_plural["many"], "You have %{count} new mails.")
@unittest.skipUnless(yaml_available, "yaml library not available")
def test_search_translation_yaml(self):
resource_loader.init_yaml_loader()
config.set("file_format", "yml")
resource_loader.search_translation("foo.normal_key")
self.assertTrue(translations.has("foo.normal_key"))
@unittest.skipUnless(json_available, "json library not available")
def test_search_translation_json(self):
resource_loader.init_json_loader()
config.set("file_format", "json")
resource_loader.search_translation("bar.baz.qux")
self.assertTrue(translations.has("bar.baz.qux"))
@unittest.skipUnless(json_available, "json library not available")
def test_search_translation_without_ns(self):
resource_loader.init_json_loader()
config.set("file_format", "json")
config.set("filename_format", "{locale}.{format}")
resource_loader.search_translation("foo")
self.assertTrue(translations.has("foo"))
@unittest.skipUnless(json_available, "json library not available")
def test_search_translation_without_ns_nested_dict__two_levels_neting__default_locale(self):
resource_loader.init_json_loader()
config.set("file_format", "json")
config.set("load_path", [os.path.join(RESOURCE_FOLDER, "translations", "nested_dict_json")])
config.set("filename_format", "{locale}.{format}")
config.set('skip_locale_root_data', True)
config.set("locale", ["en", "pl"])
resource_loader.search_translation("COMMON.VERSION")
self.assertTrue(translations.has("COMMON.VERSION"))
self.assertEqual(translations.get("COMMON.VERSION"), "version")
@unittest.skipUnless(json_available, "json library not available")
def test_search_translation_without_ns_nested_dict__two_levels_neting__other_locale(self):
resource_loader.init_json_loader()
config.set("file_format", "json")
config.set("load_path", [os.path.join(RESOURCE_FOLDER, "translations", "nested_dict_json")])
config.set("filename_format", "{locale}.{format}")
config.set('skip_locale_root_data', True)
config.set("locale", ["en", "pl"])
resource_loader.search_translation("COMMON.VERSION", locale="pl")
self.assertTrue(translations.has("COMMON.VERSION", locale="pl"))
self.assertEqual(translations.get("COMMON.VERSION", locale="pl"), "wersja")
@unittest.skipUnless(json_available, "json library not available")
def test_search_translation_without_ns_nested_dict__default_locale(self):
resource_loader.init_json_loader()
config.set("file_format", "json")
config.set("load_path", [os.path.join(RESOURCE_FOLDER, "translations", "nested_dict_json")])
config.set("filename_format", "{locale}.{format}")
config.set('skip_locale_root_data', True)
config.set("locale", "en")
resource_loader.search_translation("TOP_MENU.TOP_BAR.LOGS")
self.assertTrue(translations.has("TOP_MENU.TOP_BAR.LOGS"))
self.assertEqual(translations.get("TOP_MENU.TOP_BAR.LOGS"), "Logs")
@unittest.skipUnless(json_available, "json library not available")
def test_search_translation_without_ns_nested_dict__other_locale(self):
resource_loader.init_json_loader()
config.set("file_format", "json")
config.set("load_path", [os.path.join(RESOURCE_FOLDER, "translations", "nested_dict_json")])
config.set("filename_format", "{locale}.{format}")
config.set('skip_locale_root_data', True)
config.set("locale", "en")
resource_loader.search_translation("TOP_MENU.TOP_BAR.LOGS", locale="pl")
self.assertTrue(translations.has("TOP_MENU.TOP_BAR.LOGS", locale="pl"))
self.assertEqual(translations.get("TOP_MENU.TOP_BAR.LOGS", locale="pl"), "Logi")
suite = unittest.TestLoader().loadTestsFromTestCase(TestFileLoader)
unittest.TextTestRunner(verbosity=2).run(suite)

View File

@ -0,0 +1,20 @@
import unittest
from i18n.tests.translation_tests import TestTranslationFormat
from i18n.tests.loader_tests import TestFileLoader
def suite():
suite = unittest.TestSuite()
suite.addTest(unittest.makeSuite(TestFileLoader))
suite.addTest(unittest.makeSuite(TestTranslationFormat))
return suite
if __name__ == '__main__':
runner = unittest.TextTestRunner()
test_suite = suite()
runner.run(test_suite)

View File

@ -0,0 +1,133 @@
# -*- encoding: utf-8 -*-
from __future__ import unicode_literals
import unittest
import os
import os.path
from i18n import resource_loader
from i18n.translator import t
from i18n import translations
from i18n import config
try:
reload # Python 2.7
except NameError:
try:
from importlib import reload # Python 3.4+
except ImportError:
from imp import reload # Python 3.0 - 3.3
RESOURCE_FOLDER = os.path.dirname(__file__) + os.sep + 'resources' + os.sep
class TestTranslationFormat(unittest.TestCase):
@classmethod
def setUpClass(cls):
resource_loader.init_loaders()
reload(config)
config.set('load_path', [os.path.join(RESOURCE_FOLDER, 'translations')])
translations.add('foo.hi', 'Hello %{name} !')
translations.add('foo.hello', 'Salut %{name} !', locale='fr')
translations.add('foo.basic_plural', {
'one': '1 elem',
'many': '%{count} elems'
})
translations.add('foo.plural', {
'zero': 'no mail',
'one': '1 mail',
'few': 'only %{count} mails',
'many': '%{count} mails'
})
translations.add('foo.bad_plural', {
'bar': 'foo elems'
})
def setUp(self):
config.set('error_on_missing_translation', False)
config.set('error_on_missing_placeholder', False)
config.set('fallback', 'en')
config.set('locale', 'en')
def test_basic_translation(self):
self.assertEqual(t('foo.normal_key'), 'normal_value')
def test_missing_translation(self):
self.assertEqual(t('foo.inexistent'), 'foo.inexistent')
def test_missing_translation_error(self):
config.set('error_on_missing_translation', True)
with self.assertRaises(KeyError):
t('foo.inexistent')
def test_locale_change(self):
config.set('locale', 'fr')
self.assertEqual(t('foo.hello', name='Bob'), 'Salut Bob !')
def test_fallback(self):
config.set('fallback', 'fr')
self.assertEqual(t('foo.hello', name='Bob'), 'Salut Bob !')
def test_fallback_from_resource(self):
config.set('fallback', 'ja')
self.assertEqual(t('foo.fallback_key'), 'フォールバック')
def test_basic_placeholder(self):
self.assertEqual(t('foo.hi', name='Bob'), 'Hello Bob !')
def test_missing_placehoder(self):
self.assertEqual(t('foo.hi'), 'Hello %{name} !')
def test_missing_placeholder_error(self):
config.set('error_on_missing_placeholder', True)
with self.assertRaises(KeyError):
t('foo.hi')
def test_basic_pluralization(self):
self.assertEqual(t('foo.basic_plural', count=0), '0 elems')
self.assertEqual(t('foo.basic_plural', count=1), '1 elem')
self.assertEqual(t('foo.basic_plural', count=2), '2 elems')
def test_full_pluralization(self):
self.assertEqual(t('foo.plural', count=0), 'no mail')
self.assertEqual(t('foo.plural', count=1), '1 mail')
self.assertEqual(t('foo.plural', count=4), 'only 4 mails')
self.assertEqual(t('foo.plural', count=12), '12 mails')
def test_bad_pluralization(self):
config.set('error_on_missing_plural', False)
self.assertEqual(t('foo.normal_key', count=5), 'normal_value')
config.set('error_on_missing_plural', True)
with self.assertRaises(KeyError):
t('foo.bad_plural', count=0)
def test_default(self):
self.assertEqual(t('inexistent_key', default='foo'), 'foo')
def test_skip_locale_root_data(self):
config.set('filename_format', '{locale}.{format}')
config.set('file_format', 'json')
config.set('locale', 'gb')
config.set('skip_locale_root_data', True)
resource_loader.init_loaders()
self.assertEqual(t('foo'), 'Lorry')
config.set('skip_locale_root_data', False)
def test_skip_locale_root_data_nested_json_dict__default_locale(self):
config.set("file_format", "json")
config.set("load_path", [os.path.join(RESOURCE_FOLDER, "translations", "nested_dict_json")])
config.set("filename_format", "{locale}.{format}")
config.set('skip_locale_root_data', True)
config.set("locale", "en")
resource_loader.init_json_loader()
self.assertEqual(t('COMMON.START'), 'Start')
def test_skip_locale_root_data_nested_json_dict__other_locale(self):
config.set("file_format", "json")
config.set("load_path", [os.path.join(RESOURCE_FOLDER, "translations", "nested_dict_json")])
config.set("filename_format", "{locale}.{format}")
config.set('skip_locale_root_data', True)
config.set("locale", "en")
resource_loader.init_json_loader()
self.assertEqual(t('COMMON.EXECUTE', locale="pl"), 'Wykonaj')

View File

@ -0,0 +1,15 @@
from . import config
container = {}
def add(key, value, locale=config.get('locale')):
container.setdefault(locale, {})[key] = value
def has(key, locale=config.get('locale')):
return key in container.get(locale, {})
def get(key, locale=config.get('locale')):
return container[locale][key]

View File

@ -0,0 +1,73 @@
from string import Template
from . import config
from . import resource_loader
from . import translations
class TranslationFormatter(Template):
delimiter = config.get('placeholder_delimiter')
def __init__(self, template):
super(TranslationFormatter, self).__init__(template)
def format(self, **kwargs):
if config.get('error_on_missing_placeholder'):
return self.substitute(**kwargs)
else:
return self.safe_substitute(**kwargs)
def t(key, **kwargs):
locale = kwargs.pop('locale', config.get('locale'))
if translations.has(key, locale):
return translate(key, locale=locale, **kwargs)
else:
resource_loader.search_translation(key, locale)
if translations.has(key, locale):
return translate(key, locale=locale, **kwargs)
elif locale != config.get('fallback'):
return t(key, locale=config.get('fallback'), **kwargs)
if 'default' in kwargs:
return kwargs['default']
if config.get('error_on_missing_translation'):
raise KeyError('key {0} not found'.format(key))
else:
return key
def translate(key, **kwargs):
locale = kwargs.pop('locale', config.get('locale'))
translation = translations.get(key, locale=locale)
if 'count' in kwargs:
translation = pluralize(key, translation, kwargs['count'])
return TranslationFormatter(translation).format(**kwargs)
def pluralize(key, translation, count):
return_value = key
try:
if type(translation) != dict:
return_value = translation
raise KeyError('use of count witouth dict for key {0}'.format(key))
if count == 0:
if 'zero' in translation:
return translation['zero']
elif count == 1:
if 'one' in translation:
return translation['one']
elif count <= config.get('plural_few'):
if 'few' in translation:
return translation['few']
# TODO: deprecate other
if 'other' in translation:
return translation['other']
if 'many' in translation:
return translation['many']
else:
raise KeyError('"many" not defined for key {0}'.format(key))
except KeyError as e:
if config.get('error_on_missing_plural'):
raise e
else:
return return_value