From 4821a82eb3b41c707489a371c49a83c44de8cd43 Mon Sep 17 00:00:00 2001 From: boulea7 Date: Mon, 12 Oct 2026 00:30:14 +0800 Subject: [PATCH] numbers: Preserve apostrophes when formatting Decode doubled apostrophes inside quoted pattern affixes and unquote the selected prefix and suffix separately from generated number symbols. This preserves ASCII apostrophe grouping in locales such as fr_CH. Keep currency substitution before unquoting, and cover quoted affixes, grouping, and adjacent currency placeholders with regression tests. --- babel/numbers.py | 34 +++++++++++++++++--------- tests/test_numbers.py | 56 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/babel/numbers.py b/babel/numbers.py index b782521e5..a8351732f 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -1544,23 +1544,35 @@ def apply( numbering_system=numbering_system, ) + prefix = self.prefix[is_negative] + suffix = self.suffix[is_negative] + if currency is not None and ('¤' in prefix or '¤' in suffix): + for token, replacement in ( + ('¤¤¤', get_currency_name(currency, value, locale)), + ('¤¤', currency.upper()), + ('¤', get_currency_symbol(currency, locale)), + ): + prefix = prefix.replace(token, replacement) + suffix = suffix.replace(token, replacement) + + # Unquote the affixes separately from the generated number symbols. + prefix, suffix = ( + re.sub( + r"''|'((?:[^']|'')*)'", + lambda m: m.group(1).replace("''", "'") if m.group(1) is not None else "'", + affix, + ) + for affix in (prefix, suffix) + ) + retval = ''.join( ( - self.prefix[is_negative], + prefix, number if self.number_pattern != '' else '', - self.suffix[is_negative], + suffix, ), ) - if '¤' in retval and currency is not None: - retval = retval.replace('¤¤¤', get_currency_name(currency, value, locale)) - retval = retval.replace('¤¤', currency.upper()) - retval = retval.replace('¤', get_currency_symbol(currency, locale)) - - # remove single quotes around text, except for doubled single quotes - # which are replaced with a single quote - retval = re.sub(r"'([^']*)'", lambda m: m.group(1) or "'", retval) - return retval # diff --git a/tests/test_numbers.py b/tests/test_numbers.py index d20a18315..b1e70ec12 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -660,6 +660,62 @@ def test_single_quotes_in_pattern(): assert numbers.format_decimal(12, "'#'0 o''clock", locale='en') == "#12 o'clock" +@pytest.mark.parametrize(('number', 'pattern', 'expected'), [ + (1, "0'kg''s'", "1kg's"), + (-1, "0'kg''s'", "-1kg's"), + (0, "0'kg''s'", "0kg's"), + (1, "'pre''0fix'0", "pre'0fix1"), + (1, "''''0''''", "''1''"), + (1, "0'a''''b'", "1a''b"), + (1, "'''a'0", "'a1"), + (1, "'pos''fix'0'suf''fix';'neg''fix'0'neg''suf'", "pos'fix1suf'fix"), + (-1, "'pos''fix'0'suf''fix';'neg''fix'0'neg''suf'", "neg'fix1neg'suf"), +]) +def test_doubled_single_quotes_in_pattern(number, pattern, expected): + assert numbers.format_decimal(number, pattern, locale='en_US') == expected + + +@pytest.mark.parametrize(('number', 'pattern', 'expected'), [ + (12345, "#,##0'kg''s'", "12'345kg's"), + (-12345, "#,##0'kg''s'", "-12'345kg's"), + (1234567, '#,##0', "1'234'567"), + (1234567, "'pre''fix'#,##0", "pre'fix1'234'567"), + (1234567, "'pre''fix'#,##0'suf''fix'", "pre'fix1'234'567suf'fix"), +]) +def test_single_quotes_in_pattern_with_apostrophe_grouping(number, pattern, expected): + assert numbers.format_decimal(number, pattern, locale='fr_CH') == expected + + +@pytest.mark.parametrize(('number', 'pattern', 'locale', 'expected'), [ + (1, "'pre''fix'¤0'suf''fix'", 'en_US', "pre'fix$1suf'fix"), + (1, "'pre''fix'¤¤0'suf''fix'", 'en_US', "pre'fixUSD1suf'fix"), + (1, "'pre''fix'¤¤¤0'suf''fix'", 'en_US', "pre'fixUS dollar1suf'fix"), + (12345, "'pre''fix'¤#,##0'suf''fix'", 'fr_CH', "pre'fix$US12'345suf'fix"), +]) +def test_doubled_single_quotes_in_currency_pattern(number, pattern, locale, expected): + assert numbers.format_currency( + number, + 'USD', + format=pattern, + currency_digits=False, + locale=locale, + ) == expected + + +@pytest.mark.parametrize(('pattern', 'expected'), [ + ("¤'¤'0", '$$1'), + ("'¤¤'¤0", 'USD$1'), +]) +def test_currency_substitution_before_unquoting(pattern, expected): + assert numbers.format_currency( + 1, + 'USD', + format=pattern, + currency_digits=False, + locale='en_US', + ) == expected + + def test_format_currency_with_none_locale_with_default(monkeypatch): """Test that the default locale is used when locale is None.""" monkeypatch.setattr(numbers, "LC_MONETARY", "fi_FI")