Repository: joke2k/faker

Issue:
pydecimal unnecessarily limited by float's max digits
* Faker version: master at time of writing https://github.com/joke2k/faker/commit/d9f4b00b9134e6dfbb09cc1caa81c912b79c3c7c
* OS: Linux

Python's `Decimal` can be arbitrarily many digits; with default precision 28. Faker's `pydecimal` uses `pyfloat`, and so gets limited to `sys.float_info.dig`, which is appropriate for `pyfloat` but not really relevant for `pydecimal`. (The Decimal context could even be less than that.)

### Steps to reproduce

1. `pydecimal(left_digits=16)`

### Expected behavior

Get a 16 digit Decimal

### Actual behavior

> ValueError: Asking for too many digits (16 + 0 == 16 > 15)

pydecimal unnecessarily limited by float's max digits
* Faker version: master at time of writing https://github.com/joke2k/faker/commit/d9f4b00b9134e6dfbb09cc1caa81c912b79c3c7c
* OS: Linux

Python's `Decimal` can be arbitrarily many digits; with default precision 28. Faker's `pydecimal` uses `pyfloat`, and so gets limited to `sys.float_info.dig`, which is appropriate for `pyfloat` but not really relevant for `pydecimal`. (The Decimal context could even be less than that.)

### Steps to reproduce

1. `pydecimal(left_digits=16)`

### Expected behavior

Get a 16 digit Decimal

### Actual behavior

> ValueError: Asking for too many digits (16 + 0 == 16 > 15)



Discussion:

Resolving patch:
diff --git a/faker/providers/python/__init__.py b/faker/providers/python/__init__.py
--- a/faker/providers/python/__init__.py
+++ b/faker/providers/python/__init__.py
@@ -151,10 +151,58 @@ def pyint(self, min_value=0, max_value=9999, step=1):
 
     def pydecimal(self, left_digits=None, right_digits=None, positive=False,
                   min_value=None, max_value=None):
+        if left_digits is not None and left_digits < 0:
+            raise ValueError(
+                'A decimal number cannot have less than 0 digits in its '
+                'integer part')
+        if right_digits is not None and right_digits < 0:
+            raise ValueError(
+                'A decimal number cannot have less than 0 digits in its '
+                'fractional part')
+        if (left_digits is not None and left_digits == 0) and (right_digits is not None and right_digits == 0):
+            raise ValueError(
+                'A decimal number cannot have 0 digits in total')
+        if None not in (min_value, max_value) and min_value > max_value:
+            raise ValueError('Min value cannot be greater than max value')
+        if None not in (min_value, max_value) and min_value == max_value:
+            raise ValueError('Min and max value cannot be the same')
+        if positive and min_value is not None and min_value <= 0:
+            raise ValueError(
+                'Cannot combine positive=True with negative or zero min_value')
+        if left_digits is not None and max_value and math.ceil(math.log10(abs(max_value))) > left_digits:
+            raise ValueError('Max value must fit within left digits')
+        if left_digits is not None and min_value and math.ceil(math.log10(abs(min_value))) > left_digits:
+            raise ValueError('Min value must fit within left digits')
+
+        # if either left or right digits are not specified we randomly choose a length
+        max_random_digits = 100
+        minimum_left_digits = len(str(min_value)) if min_value is not None else 1
+        if left_digits is None and right_digits is None:
+            right_digits = self.random_int(1, max_random_digits)
+            left_digits = self.random_int(minimum_left_digits, max_random_digits)
+        if left_digits is not None and right_digits is None:
+            right_digits = self.random_int(1, max_random_digits)
+        if left_digits is None and right_digits is not None:
+            left_digits = self.random_int(minimum_left_digits, max_random_digits)
 
-        float_ = self.pyfloat(
-            left_digits, right_digits, positive, min_value, max_value)
-        return Decimal(str(float_))
+        sign = ''
+        left_number = ''.join([str(self.random_digit()) for i in range(0, left_digits)]) or '0'
+        if right_digits is not None:
+            right_number = ''.join([str(self.random_digit()) for i in range(0, right_digits)])
+        else:
+            right_number = ''
+        sign = '+' if positive else self.random_element(('+', '-'))
+
+        result = Decimal(f'{sign}{left_number}.{right_number}')
+
+        # Because the random result might have the same number of decimals as max_value the random number
+        # might be above max_value or below min_value
+        if max_value is not None and result > max_value:
+            result = max_value
+        if min_value is not None and result < min_value:
+            result = min_value
+
+        return result
 
     def pytuple(self, nb_elements=10, variable_nb_elements=True, value_types=None, *allowed_types):
         return tuple(


Test patch:
diff --git a/tests/providers/test_python.py b/tests/providers/test_python.py
--- a/tests/providers/test_python.py
+++ b/tests/providers/test_python.py
@@ -1,3 +1,4 @@
+import decimal
 import sys
 import unittest
 import warnings
@@ -215,6 +216,129 @@ def test_positive_doesnt_return_zero(self):
         self.assertGreater(result, 0)
 
 
+class TestPydecimal(unittest.TestCase):
+    def setUp(self):
+        self.fake = Faker()
+        Faker.seed(0)
+
+    def test_pydecimal(self):
+        result = self.fake.pydecimal()
+
+        self.assertIsInstance(result, decimal.Decimal)
+
+    def test_left_digits(self):
+        expected_left_digits = 10
+
+        result = self.fake.pydecimal(left_digits=expected_left_digits)
+
+        left_digits = len(str(abs(int(result))))
+        self.assertGreaterEqual(expected_left_digits, left_digits)
+
+    def test_right_digits(self):
+        expected_right_digits = 10
+
+        result = self.fake.pydecimal(right_digits=expected_right_digits)
+
+        right_digits = len(str(result).split('.')[1])
+        self.assertGreaterEqual(expected_right_digits, right_digits)
+
+    def test_positive(self):
+        result = self.fake.pydecimal(positive=True)
+
+        self.assertGreater(result, 0)
+        abs_result = -result if result < 0 else result  # abs() result returns scientific notation
+        self.assertEqual(result, abs_result)
+
+    def test_min_value(self):
+        min_values = (0, 10, -1000, 1000, 999999)
+
+        for min_value in min_values:
+            result = self.fake.pydecimal(min_value=min_value)
+            self.assertGreaterEqual(result, min_value)
+
+    def test_min_value_and_left_digits(self):
+        """
+        Combining the min_value and left_digits keyword arguments produces
+        numbers that obey both of those constraints.
+        """
+
+        result = self.fake.pydecimal(left_digits=1, min_value=0)
+        self.assertLess(result, 10)
+        self.assertGreaterEqual(result, 0)
+
+    def test_max_value(self):
+        max_values = (0, 10, -1000, 1000, 999999)
+
+        for max_value in max_values:
+            result = self.fake.pydecimal(max_value=max_value)
+            self.assertLessEqual(result, max_value)
+
+    def test_max_value_zero_and_left_digits(self):
+        """
+        Combining the max_value and left_digits keyword arguments produces
+        numbers that obey both of those constraints.
+        """
+
+        result = self.fake.pydecimal(left_digits=2, max_value=0)
+        self.assertLessEqual(result, 0)
+        self.assertGreater(result, -100)
+
+    def test_max_value_should_be_greater_than_min_value(self):
+        """
+        An exception should be raised if min_value is greater than max_value
+        """
+        expected_message = 'Min value cannot be greater than max value'
+        with self.assertRaises(ValueError) as raises:
+            self.fake.pydecimal(min_value=100, max_value=0)
+
+        message = str(raises.exception)
+        self.assertEqual(message, expected_message)
+
+    def test_max_value_and_positive(self):
+        """
+        Combining the max_value and positive keyword arguments produces
+        numbers that obey both of those constraints.
+        """
+
+        result = self.fake.pydecimal(positive=True, max_value=100)
+        self.assertLessEqual(result, 100)
+        self.assertGreater(result, 0)
+
+    def test_max_and_min_value_negative(self):
+        """
+        Combining the max_value and min_value keyword arguments with
+        negative values for each produces numbers that obey both of
+        those constraints.
+        """
+
+        result = self.fake.pydecimal(max_value=-100, min_value=-200)
+        self.assertLessEqual(result, -100)
+        self.assertGreaterEqual(result, -200)
+
+    def test_positive_and_min_value_incompatible(self):
+        """
+        An exception should be raised if positive=True is set, but
+        a negative min_value is provided.
+        """
+
+        expected_message = (
+            "Cannot combine positive=True with negative or zero min_value"
+        )
+        with self.assertRaises(ValueError) as raises:
+            self.fake.pydecimal(min_value=-100, positive=True)
+
+        message = str(raises.exception)
+        self.assertEqual(message, expected_message)
+
+    def test_positive_doesnt_return_zero(self):
+        """
+        Choose the right_digits and max_value so it's guaranteed to return zero,
+        then watch as it doesn't because positive=True
+        """
+        result = self.fake.pydecimal(positive=True, right_digits=0, max_value=1)
+        self.assertGreater(result, 0)
+
+
 class TestPystrFormat(unittest.TestCase):
 
     def setUp(self):
diff --git a/tests/test_factory.py b/tests/test_factory.py
--- a/tests/test_factory.py
+++ b/tests/test_factory.py
@@ -235,6 +235,14 @@ def test_negative_pyfloat(self):
         assert any(fake.pyfloat(left_digits=0, positive=False) < 0 for _ in range(100))
         assert any(fake.pydecimal(left_digits=0, positive=False) < 0 for _ in range(100))
 
+    def test_arbitrary_digits_pydecimal(self):
+        # tests for https://github.com/joke2k/faker/issues/1462
+        fake = Faker()
+        assert any(
+            len(str(fake.pydecimal(left_digits=sys.float_info.dig + i))) > sys.float_info.dig for i in range(100)
+        )
+        assert any(len(str(fake.pydecimal())) > sys.float_info.dig for _ in range(100))
+
     def test_pyfloat_empty_range_error(self):
         # tests for https://github.com/joke2k/faker/issues/1048
         fake = Faker()