diff --git a/babel/localedata.py b/babel/localedata.py index 4648e6626..8c641dd04 100644 --- a/babel/localedata.py +++ b/babel/localedata.py @@ -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 @@ -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 diff --git a/tests/test_localedata.py b/tests/test_localedata.py index 42810b992..f8c286d2c 100644 --- a/tests/test_localedata.py +++ b/tests/test_localedata.py @@ -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')