Repository: python-babel/babel (python)

Issue:
The `parse_decimal` function accepts irregular input
Examples:

```python
>>> parse_decimal('1,,000', locale='en_US')
1000
>>> parse_decimal('25.00', locale='de')
2500
```

Inputting `25.00` instead of `25,00` is an easy mistake to make, and it can be confusing that the software reads it as `2500` instead of `25`.

Maybe `parse_decimal` should be more strict and reject numbers formatted in a weird way?

This would require checking that the separators are positioned correctly before [replacing them](https://github.com/python-babel/babel/blob/v2.6.0/babel/numbers.py#L604-L605).

Pull request description:
Add a strict mode to `parse_decimal()`
This branch adds a `strict` flag to the `parse_decimal` function. When set to `True` an exception is raised if the input number is formatted in a weird way. This is determined by comparing the string to the result of `format_decimal()`.

Closes #589.

Patch:
diff --git a/babel/numbers.py b/babel/numbers.py
index 518d494..d3d2238 100644
--- a/babel/numbers.py
+++ b/babel/numbers.py
@@ -634,6 +634,11 @@ def format_scientific(
 class NumberFormatError(ValueError):
     """Exception raised when a string cannot be parsed into a number."""
 
+    def __init__(self, message, suggestions=None):
+        super(NumberFormatError, self).__init__(message)
+        #: a list of properly formatted numbers derived from the invalid input
+        self.suggestions = suggestions
+
 
 def parse_number(string, locale=LC_NUMERIC):
     """Parse localized number string into an integer.
@@ -661,7 +666,7 @@ def parse_number(string, locale=LC_NUMERIC):
         raise NumberFormatError('%r is not a valid number' % string)
 
 
-def parse_decimal(string, locale=LC_NUMERIC):
+def parse_decimal(string, locale=LC_NUMERIC, strict=False):
     """Parse localized decimal string into a decimal.
 
     >>> parse_decimal('1,099.98', locale='en_US')
@@ -676,17 +681,47 @@ def parse_decimal(string, locale=LC_NUMERIC):
         ...
     NumberFormatError: '2,109,998' is not a valid decimal number
 
+    If `strict` is set to `True` and the given string contains a number
+    formatted in an irregular way, an exception is raised:
+
+    >>> parse_decimal('30.00', locale='de', strict=True)
+    Traceback (most recent call last):
+        ...
+    NumberFormatError: '30.00' is not a properly formatted decimal number. Did you mean '3.000'? Or maybe '30,00'?
+
     :param string: the string to parse
     :param locale: the `Locale` object or locale identifier
+    :param strict: controls whether numbers formatted in a weird way are
+                   accepted or rejected
     :raise NumberFormatError: if the string can not be converted to a
                               decimal number
     """
     locale = Locale.parse(locale)
+    group_symbol = get_group_symbol(locale)
+    decimal_symbol = get_decimal_symbol(locale)
     try:
-        return decimal.Decimal(string.replace(get_group_symbol(locale), '')
-                               .replace(get_decimal_symbol(locale), '.'))
+        parsed = decimal.Decimal(string.replace(group_symbol, '')
+                                       .replace(decimal_symbol, '.'))
     except decimal.InvalidOperation:
         raise NumberFormatError('%r is not a valid decimal number' % string)
+    if strict and group_symbol in string:
+        proper = format_decimal(parsed, locale=locale, decimal_quantization=False)
+        if string != proper and string.rstrip('0') != (proper + decimal_symbol):
+            try:
+                parsed_alt = decimal.Decimal(string.replace(decimal_symbol, '')
+                                                   .replace(group_symbol, '.'))
+            except decimal.InvalidOperation:
+                raise NumberFormatError((
+                    "%r is not a properly formatted decimal number. Did you mean %r?" %
+                    (string, proper)
+                ), suggestions=[proper])
+            else:
+                proper_alt = format_decimal(parsed_alt, locale=locale, decimal_quantization=False)
+                raise NumberFormatError((
+                    "%r is not a properly formatted decimal number. Did you mean %r? Or maybe %r?" %
+                    (string, proper, proper_alt)
+                ), suggestions=[proper, proper_alt])
+    return parsed
 
 
 PREFIX_END = r'[^0-9@#.,]'
diff --git a/docs/api/numbers.rst b/docs/api/numbers.rst
index 758ceba..f9b0833 100644
--- a/docs/api/numbers.rst
+++ b/docs/api/numbers.rst
@@ -30,6 +30,7 @@ Exceptions
 ----------
 
 .. autoexception:: NumberFormatError
+    :members:
 
 Data Access
 -----------


Test patch:
diff --git a/tests/test_numbers.py b/tests/test_numbers.py
index 32f4280..9c3896c 100644
--- a/tests/test_numbers.py
+++ b/tests/test_numbers.py
@@ -165,6 +165,32 @@ class NumberParsingTestCase(unittest.TestCase):
         self.assertRaises(numbers.NumberFormatError,
                           lambda: numbers.parse_decimal('2,109,998', locale='de'))
 
+    def test_parse_decimal_strict_mode(self):
+        # Numbers with a misplaced grouping symbol should be rejected
+        with self.assertRaises(numbers.NumberFormatError) as info:
+            numbers.parse_decimal('11.11', locale='de', strict=True)
+        assert info.exception.suggestions == ['1.111', '11,11']
+        # Numbers with two misplaced grouping symbols should be rejected
+        with self.assertRaises(numbers.NumberFormatError) as info:
+            numbers.parse_decimal('80.00.00', locale='de', strict=True)
+        assert info.exception.suggestions == ['800.000']
+        # Partially grouped numbers should be rejected
+        with self.assertRaises(numbers.NumberFormatError) as info:
+            numbers.parse_decimal('2000,000', locale='en_US', strict=True)
+        assert info.exception.suggestions == ['2,000,000', '2,000']
+        # Numbers with duplicate grouping symbols should be rejected
+        with self.assertRaises(numbers.NumberFormatError) as info:
+            numbers.parse_decimal('0,,000', locale='en_US', strict=True)
+        assert info.exception.suggestions == ['0']
+        # Properly formatted numbers should be accepted
+        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 without any grouping symbol should be accepted
+        assert str(numbers.parse_decimal('2000.1', locale='en_US', strict=True)) == '2000.1'
+        # High precision numbers should be accepted
+        assert str(numbers.parse_decimal('5,000001', locale='fr', strict=True)) == '5.000001'
+
 
 def test_list_currencies():
     assert isinstance(list_currencies(), set)