Repository: python-babel/babel (python)

Issue:
parse_decimal raises error for decimals with thousand separator and trailing zeroes
# Bug report

`parse_decimal` raises a surprising NumberFormatError when parsing numbers with thousand separator and non-zero decimals but trailing zeros, when strict mode is set:
```
In [1]: parse_decimal('3,400.60',locale='en',strict=True)`
---------------------------------------------------------------------------`
NumberFormatError                         Traceback (most recent call last)
<ipython-input-6-ccc56249e0c2> in <module>
----> 1 parse_decimal('3,400.60', locale='en', strict=True)

~/iit_repos/hrreport/.tox/test/lib/python3.10/site-packages/babel/numbers.py in parse_decimal(string, locale, strict)
    765                     ), suggestions=[proper])
    766                 else:
--> 767                     raise NumberFormatError((
    768                         "%r is not a properly formatted decimal number. Did you mean %r? Or maybe %r?" %
    769                         (string, proper, proper_alt)

NumberFormatError: '3,400.60' is not a properly formatted decimal number. Did you mean '3,400.6'? Or maybe '3.4006'?`
```
Working variants:
```
In [2]: parse_decimal('3400.60', locale='en', strict=True)
Out[2]: Decimal('3400.60')

In [3]: parse_decimal('3,400.6', locale='en', strict=True)
Out[3]: Decimal('3400.6')

In [4]: parse_decimal('3,400.00', locale='en', strict=True)
Out[4]: Decimal('3400.00')
```
It is surprising and inconsistent that the presence or absence of the thousand separator should effect the result so.

# Your environment

- CPython versions tested on: 3.11.0, 3.10.6
- Babel versions tested on: 2.10.3, 2.11.0
- locales tested: 'en', 'de'
- Operating system and architecture: `5.15.0-52-generic #58-Ubuntu x86_64 GNU/Linux` (up-to-date Ubuntu 22.04 LTS)

# Additional Information
Thanks for the great library!

Pull request:
fix(numbers): correctly determine malformed decimals
Fixes #928

Problem:

The current code was primarily designed for numbers like `3,400.00`, where the r-stripped value (`3,400.`) matches the expected `proper` (`3,400`) + `.` (`3,400.`) format. It doesn't work for numbers like `3,400.60` since `3,400.6` (the value after r-stripping) is incorrectly perceived as not equal to the expected format, which is the `proper` (`3,400.6`) + `.` (`3,400.6.`), thereby leading to its erroneous detection as improperly formatted. 

Solution:

Added a more robust method for stripping trailing zeros, specifically targeting only those zeros that appear after the last non-zero digit following the decimal symbol.


Patch:
diff --git a/babel/numbers.py b/babel/numbers.py
index 01f1ca7..2240c65 100644
--- a/babel/numbers.py
+++ b/babel/numbers.py
@@ -1099,7 +1099,7 @@ def parse_decimal(
         raise NumberFormatError(f"{string!r} is not a valid decimal number") from exc
     if strict and group_symbol in string:
         proper = format_decimal(parsed, locale=locale, decimal_quantization=False, numbering_system=numbering_system)
-        if string != proper and string.rstrip('0') != (proper + decimal_symbol):
+        if string != proper and proper != _remove_trailing_zeros_after_decimal(string, decimal_symbol):
             try:
                 parsed_alt = decimal.Decimal(string.replace(decimal_symbol, '')
                                                    .replace(group_symbol, '.'))
@@ -1131,6 +1131,41 @@ def parse_decimal(
     return parsed
 
 
+def _remove_trailing_zeros_after_decimal(string: str, decimal_symbol: str) -> str:
+    """
+    Remove trailing zeros from the decimal part of a numeric string.
+
+    This function takes a string representing a numeric value and a decimal symbol.
+    It removes any trailing zeros that appear after the decimal symbol in the number.
+    If the decimal part becomes empty after removing trailing zeros, the decimal symbol
+    is also removed. If the string does not contain the decimal symbol, it is returned unchanged.
+
+    :param string: The numeric string from which to remove trailing zeros.
+    :type string: str
+    :param decimal_symbol: The symbol used to denote the decimal point.
+    :type decimal_symbol: str
+    :return: The numeric string with trailing zeros removed from its decimal part.
+    :rtype: str
+
+    Example:
+    >>> _remove_trailing_zeros_after_decimal("123.4500", ".")
+    '123.45'
+    >>> _remove_trailing_zeros_after_decimal("100.000", ".")
+    '100'
+    >>> _remove_trailing_zeros_after_decimal("100", ".")
+    '100'
+    """
+    integer_part, _, decimal_part = string.partition(decimal_symbol)
+
+    if decimal_part:
+        decimal_part = decimal_part.rstrip("0")
+        if decimal_part:
+            return integer_part + decimal_symbol + decimal_part
+        return integer_part
+
+    return string
+
+
 PREFIX_END = r'[^0-9@#.,]'
 NUMBER_TOKEN = r'[0-9@#.,E+]'
 


Test patch:
diff --git a/tests/test_numbers.py b/tests/test_numbers.py
index 5d9830b..d89592a 100644
--- a/tests/test_numbers.py
+++ b/tests/test_numbers.py
@@ -195,7 +195,6 @@ class NumberParsingTestCase(unittest.TestCase):
         with pytest.raises(numbers.UnsupportedNumberingSystemError):
             numbers.parse_decimal('2,109,998', locale='de', numbering_system="unknown")
 
-
     def test_parse_decimal_strict_mode(self):
         # Numbers with a misplaced grouping symbol should be rejected
         with pytest.raises(numbers.NumberFormatError) as info:
@@ -221,8 +220,19 @@ class NumberParsingTestCase(unittest.TestCase):
         assert str(numbers.parse_decimal('1.001', locale='de', strict=True)) == '1001'
         # Trailing zeroes should be accepted
         assert str(numbers.parse_decimal('3.00', locale='en_US', strict=True)) == '3.00'
+        # Numbers with a grouping symbol and no trailing zeroes should be accepted
+        assert str(numbers.parse_decimal('3,400.6', locale='en_US', strict=True)) == '3400.6'
+        # Numbers with a grouping symbol and trailing zeroes (not all zeroes after decimal) should be accepted
+        assert str(numbers.parse_decimal('3,400.60', locale='en_US', strict=True)) == '3400.60'
+        # Numbers with a grouping symbol and trailing zeroes (all zeroes after decimal) should be accepted
+        assert str(numbers.parse_decimal('3,400.00', locale='en_US', strict=True)) == '3400.00'
+        assert str(numbers.parse_decimal('3,400.0000', locale='en_US', strict=True)) == '3400.0000'
+        # Numbers with a grouping symbol and no decimal part should be accepted
+        assert str(numbers.parse_decimal('3,800', locale='en_US', strict=True)) == '3800'
         # Numbers without any grouping symbol should be accepted
         assert str(numbers.parse_decimal('2000.1', locale='en_US', strict=True)) == '2000.1'
+        # Numbers without any grouping symbol and no decimal should be accepted
+        assert str(numbers.parse_decimal('2580', locale='en_US', strict=True)) == '2580'
         # High precision numbers should be accepted
         assert str(numbers.parse_decimal('5,000001', locale='fr', strict=True)) == '5.000001'