Skip to content
Closed
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
20 changes: 20 additions & 0 deletions babel/localedata.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@ def __init__(
base: Mapping[str | int | None, Any] | None = None,
):
self._data = data
# Tracks whether self._data is already a dict this instance
# owns exclusively (safe to mutate directly), as opposed to a
# dict that might still be shared with another locale (e.g.
# via load()'s shallow copy of inherited-but-unoverridden
# data). See __getitem__ and GH #1234.
self._data_is_own_copy = False
if base is None:
base = data
self.base = base
Expand All @@ -272,6 +278,20 @@ def __getitem__(self, key: str | int | None) -> Any:
if isinstance(val, dict): # Return a nested alias-resolving dict
val = LocaleDataDict(val, base=self.base)
if val is not orig:
# self._data may still be the very same dict object shared
# (via load()'s shallow `.copy()` of inherited-but-not-
# locally-overridden data) with another, unrelated locale
# that also inherits it unchanged from a common parent.
# Writing the resolved value directly into self._data here
# would therefore also "leak" it into that other locale's
# cached data the next time *it* looks up the same key.
# Copy self._data before mutating it, the same way merge()
# already copies a nested dict before recursing into it, so
# this locale gets its own independent dict to cache into.
# See GH #1234.
if not self._data_is_own_copy:
self._data = self._data.copy()
self._data_is_own_copy = True
self._data[key] = val
return val

Expand Down
61 changes: 61 additions & 0 deletions tests/test_localedata.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,67 @@ def test_merge_with_alias_and_resolve():
assert dict(d.items()) == {'x': {'a': 1, 'b': 12, 'c': 3, 'd': 14}, 'y': {'a': 1, 'b': 22, 'c': 3, 'd': 14, 'e': 25}}


def test_localedatadict_resolving_alias_does_not_leak_into_shared_underlying_dict():
"""
Regression test for
https://github.com/python-babel/babel/issues/1234

load() gives each locale its own top-level dict via a shallow
.copy() of its parent's cached data, and merge() only copies a
nested dict when the locale's own data file actually has an entry
at that key. So a nested structure a locale's data file doesn't
touch at all -- e.g. a "stand-alone" month-name alias, when the
locale relies entirely on the inherited default -- remains the
exact same object shared with whatever it last inherited it from,
even while sibling keys the locale *does* override (e.g. its own
"format" month names) are correctly independent per locale.

Resolving an alias must not mutate that shared "stand-alone"
structure in place, or the resolved value leaks into every other
locale sharing the same object -- exactly what happened with
Hebrew's resolved month name leaking into Norwegian's and French's
month names in the issue.
"""
# Shared, uncopied 'stand-alone' structure: neither "locale" below
# overrides it, so (as load()'s shallow copy would produce) they
# reference the exact same dict object for it.
shared_standalone = {
'wide': localedata.Alias(('months', 'format', 'wide')),
}

locale_a_data = {
'months': {
'format': {'wide': {10: 'LocaleAOctober'}},
'stand-alone': shared_standalone,
},
}
locale_b_data = {
'months': {
'format': {'wide': {10: 'LocaleBOctober'}},
'stand-alone': shared_standalone,
},
}
# Each locale's own "format" data is independent, but they share
# the exact same "stand-alone" object -- the precise shape that
# produces the bug.
assert locale_a_data['months']['format'] is not locale_b_data['months']['format']
assert locale_a_data['months']['stand-alone'] is locale_b_data['months']['stand-alone']

locale_a = localedata.LocaleDataDict(locale_a_data)
locale_b = localedata.LocaleDataDict(locale_b_data)

resolved_a = locale_a['months']['stand-alone']['wide']
assert resolved_a[10] == 'LocaleAOctober'

# Resolving the alias for locale_a must not affect locale_b's
# resolution of the same (shared) 'stand-alone' object.
resolved_b = locale_b['months']['stand-alone']['wide']
assert resolved_b[10] == 'LocaleBOctober'

# Re-checking locale_a again afterwards must still be correct too.
assert locale_a['months']['stand-alone']['wide'][10] == 'LocaleAOctober'


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