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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions babel/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,17 @@
Decimal = _dec
InvalidOperation = _invop
ROUND_HALF_EVEN = _RHE


# From six 1.9.0.
# six is Copyright (c) 2010-2015 Benjamin Peterson.
# six is licensed under the MIT license.
def with_metaclass(meta, *bases):
"""Create a base class with a metaclass."""
# This requires a bit of explanation: the basic idea is to make a dummy
# metaclass for one level of class instantiation that replaces itself with
# the actual metaclass.
class metaclass(meta):
def __new__(cls, name, this_bases, d):
return meta(name, bases, d)
return type.__new__(metaclass, 'temporary_class', (), {})
36 changes: 36 additions & 0 deletions babel/_memoized.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# TODO: This can't live in .util until the circular import of
# core -> util -> localtime -> win32 -> core is resolved.


class Memoized(type):
"""
Metaclass for memoization based on __init__ args/kwargs.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably document that it assumes the constructor&initializer are pure since the cache-fetch is not locked, you could have two identical memoized instances being constructed.

"""

def __new__(mcs, name, bases, dict):
if "_cache" not in dict:
dict["_cache"] = {}
if "_cache_lock" not in dict:
dict["_cache_lock"] = None
return type.__new__(mcs, name, bases, dict)

def __memoized_init__(cls, *args, **kwargs):
lock = cls._cache_lock
if hasattr(cls, "_get_memo_key"):
key = cls._get_memo_key(args, kwargs)
else:
key = (args or None, frozenset(kwargs.items()) or None)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe memoization could provide both a metaclass and a base class (using the metaclass), the latter providing a default _get_memo_key method? That would obviate the need for this conditional? (though not the __new__ override)


try:
return cls._cache[key]
except KeyError:
try:
if lock:
lock.acquire()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't that be outside the try? If lock acquisition fails for some reason, it's going to try and release it anyway, which might blow up with a different error (hiding the initial error) or do odd things.

inst = cls._cache[key] = type.__call__(cls, *args, **kwargs)
return inst
finally:
if lock:
lock.release()

__call__ = __memoized_init__ # This aliasing makes tracebacks more understandable.
53 changes: 49 additions & 4 deletions babel/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@
:copyright: (c) 2013 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""

import os
import threading

from babel import localedata
from babel._compat import pickle, string_types
from babel._compat import pickle, string_types, with_metaclass
from babel.plural import PluralRule
from babel._memoized import Memoized

__all__ = ['UnknownLocaleError', 'Locale', 'default_locale', 'negotiate_locale',
'parse_locale']
Expand Down Expand Up @@ -89,7 +90,7 @@ def __init__(self, identifier):
self.identifier = identifier


class Locale(object):
class Locale(with_metaclass(Memoized)):
"""Representation of a specific locale.

>>> locale = Locale('en', 'US')
Expand Down Expand Up @@ -121,6 +122,33 @@ class Locale(object):
For more information see :rfc:`3066`.
"""

#: The dictionary used by the locale cache metaclass.
_cache = {}

#: The lock used for the cache metaclass.
_cache_lock = threading.RLock()

@staticmethod
def _get_memo_key(args, kwargs):
# Getter for a cache key for the Memoized metaclass.
# Since we know the argument names (language, territory, script, variant)
# for Locales, there's no need to use the inspect module or other heavy-duty
# machinery here.
#
# However, since this method is called fairly often, it's "unrolled"
# here and has a separate slow-path for the kwargs + args case.
nargs = len(args)
args = args + (None,) * (4 - nargs)
if kwargs:
get = kwargs.get
return (
get('language', args[0]),
get('territory', args[1]),
get('script', args[2]),
get('variant', args[3]),
)
return args

def __init__(self, language, territory=None, script=None, variant=None):
"""Initialize the locale object from the given identifier components.

Expand Down Expand Up @@ -151,6 +179,9 @@ def __init__(self, language, territory=None, script=None, variant=None):
if not localedata.exists(identifier):
raise UnknownLocaleError(identifier)

self.__immutable = True


@classmethod
def default(cls, category=None, aliases=LOCALE_ALIASES):
"""Return the system default locale for the specified category.
Expand Down Expand Up @@ -250,6 +281,10 @@ def parse(cls, identifier, sep='_', resolve_likely_subtags=True):
raise TypeError('Unxpected value for identifier: %r' % (identifier,))

parts = parse_locale(identifier, sep=sep)

if parts in cls._cache: # We've loaded this one before.
return cls._cache[parts]

input_id = get_locale_identifier(parts)

def _try_load(parts):
Expand Down Expand Up @@ -342,10 +377,20 @@ def __str__(self):
return get_locale_identifier((self.language, self.territory,
self.script, self.variant))

def __setattr__(self, key, value):
if key == "_Locale__data" or not getattr(self, "_Locale__immutable", False):
return super(Locale, self).__setattr__(key, value)
raise ValueError("%r is immutable." % self)

def __delattr__(self, item):
if getattr(self, "_Locale__immutable", False):
raise ValueError("%r is immutable." % self)
super(Locale, self).__delattr__(item)

@property
def _data(self):
if self.__data is None:
self.__data = localedata.LocaleDataDict(localedata.load(str(self)))
self.__data = localedata.load(str(self))
return self.__data

def get_display_name(self, locale=None):
Expand Down
59 changes: 16 additions & 43 deletions babel/localedata.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@
:copyright: (c) 2013 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""

import os
import threading
from collections import MutableMapping
from collections import Mapping

from babel._compat import pickle

Expand Down Expand Up @@ -100,6 +99,7 @@ def load(name, merge_inherited=True):
_cache[name] = data
finally:
fileobj.close()
resolve_aliases(data, data)
return data
finally:
_cache_lock.release()
Expand Down Expand Up @@ -138,6 +138,20 @@ def merge(dict1, dict2):
dict1[key] = val1


def resolve_aliases(dic, base):
"""Convert all aliases to values"""
for k, v in dic.items():
if isinstance(v, Alias):
dic[k] = v.resolve(base)
elif isinstance(v, tuple):
alias, others = v
data = alias.resolve(base).copy()
merge(data, others)
dic[k] = data
elif isinstance(v, Mapping):
resolve_aliases(v, base)


class Alias(object):
"""Representation of an alias in the locale data.

Expand Down Expand Up @@ -169,44 +183,3 @@ def resolve(self, data):
alias, others = data
data = alias.resolve(base)
return data


class LocaleDataDict(MutableMapping):
"""Dictionary wrapper that automatically resolves aliases to the actual
values.
"""

def __init__(self, data, base=None):
self._data = data
if base is None:
base = data
self.base = base

def __len__(self):
return len(self._data)

def __iter__(self):
return iter(self._data)

def __getitem__(self, key):
orig = val = self._data[key]
if isinstance(val, Alias): # resolve an alias
val = val.resolve(self.base)
if isinstance(val, tuple): # Merge a partial dict with an alias
alias, others = val
val = alias.resolve(self.base).copy()
merge(val, others)
if type(val) is dict: # Return a nested alias-resolving dict
val = LocaleDataDict(val, base=self.base)
if val is not orig:
self._data[key] = val
return val

def __setitem__(self, key, value):
self._data[key] = value

def __delitem__(self, key):
del self._data[key]

def copy(self):
return LocaleDataDict(self._data.copy(), base=self.base)
50 changes: 50 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,56 @@ def test_hash():
assert hash(locale_a) != hash(locale_c)


def test_locale_immutability():
loc = Locale('en', 'US')
with pytest.raises(ValueError):
loc.language = 'xq'
assert loc.language == 'en'


def test_locale_caching():
# Explicitly clear the cache dict now, if we've already loaded a locale in the past.
Locale._cache.clear()
assert not Locale._cache

# (1) Just args
loc = Locale('en', 'US')
assert len(Locale._cache) == 1 # Cached something!
assert Locale._cache[('en', 'US', None, None)] is loc # Gotta be the same instance!
# (2) How about Locale.parse?
loc2 = Locale.parse('en_US')
assert len(Locale._cache) == 1 # No change here!
assert loc is loc2 # Still the same instance!
# (3) And kwargs, wildly misordered?!
loc3 = Locale(territory='US', variant=None, language='en')
assert len(Locale._cache) == 1 # Still no change!
assert loc is loc3 # Still the same instance!

# Let's add some more locales!
Locale('fi', 'FI')
Locale('nb', 'NO')
Locale('sv', 'SE')
Locale('zh', 'CN', script='Hans')
Locale('zh', 'TW', script='Hant')
assert len(Locale._cache) == 6 # Cache GET!


def test_locale_cache_shared_by_parse():
# Test that Locale.parse() shares the cache and doesn't do (much)
# extra work loading locales.

# Put a dummy object into the cache...
en_US_cache_key = ('en', 'US', None, None)
dummy = object()
Locale._cache[en_US_cache_key] = dummy

try:
assert Locale.parse("en^US", sep="^") is dummy # That's a weird separator, man!
finally:
# Now purge our silliness (even in case this test failed)
Locale._cache.clear()


class TestLocaleClass:
def test_attributes(self):
locale = Locale('en', 'US')
Expand Down
21 changes: 0 additions & 21 deletions tests/test_localedata.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,27 +41,6 @@ def test_merge_nested_dict_no_overlap(self):
'y': {'a': 11, 'b': 12}
}, d1)

def test_merge_with_alias_and_resolve(self):
alias = localedata.Alias('x')
d1 = {
'x': {'a': 1, 'b': 2, 'c': 3},
'y': alias
}
d2 = {
'x': {'a': 1, 'b': 12, 'd': 14},
'y': {'b': 22, 'e': 25}
}
localedata.merge(d1, d2)
self.assertEqual({
'x': {'a': 1, 'b': 12, 'c': 3, 'd': 14},
'y': (alias, {'b': 22, 'e': 25})
}, d1)
d = localedata.LocaleDataDict(d1)
self.assertEqual({
'x': {'a': 1, 'b': 12, 'c': 3, 'd': 14},
'y': {'a': 1, 'b': 22, 'c': 3, 'd': 14, 'e': 25}
}, dict(d.items()))


def test_load():
assert localedata.load('en_US')['languages']['sv'] == 'Swedish'
Expand Down