Repository: joke2k/faker
Pull request #1947 (2023-11-02 20:46:01)

Fix pydecimal crash on float min_value or max_value
### What does this change

This fixes crashes of `Faker().pydecimal` when used with `float` `min_value` and/or `max_value` on python >= 3.10.

```py
>>> From faker import Faker

>>> Faker().pydecimal(min_value=0.2, max_value=1.5)

ValueError: non-integer arg 1 for randrange()
```

### What was wrong

An internal change of `random.randrange` introduced in Python 3.10 prohibits float values (see #1824) 

### How this fixes it

Casting values to `int` before passing them to `random_int` ensures that `random.randrange` gets integers.



Patch:
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index fb4a34a8..6d453044 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 19.13.0
+current_version = 20.0.1
 files = VERSION faker/__init__.py docs/conf.py
 commit = True
 tag = True
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d0a6cac3..ad035160 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 ## Changelog
 
+### [v20.0.1 - 2023-11-14](https://github.com/joke2k/faker/compare/v20.0.0...v20.0.1)
+
+* Fix type hint for `unix_time`.
+
+### [v20.0.0 - 2023-11-10](https://github.com/joke2k/faker/compare/v19.13.0...v20.0.0)
+
+* Support platform-specific second precision. Thanks @cknv.
+
 ### [v19.13.0 - 2023-11-01](https://github.com/joke2k/faker/compare/v19.12.1...v19.13.0)
 
 * Add more entries in `cs_CZ`'s geo. Thanks @george0st.
diff --git a/VERSION b/VERSION
index 24373f0e..fcc01369 100644
--- a/VERSION
+++ b/VERSION
@@ -1,1 +1,1 @@
-19.13.0
+20.0.1
diff --git a/docs/conf.py b/docs/conf.py
index 66ae03ee..729822bf 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -51,9 +51,9 @@ copyright = "2014, Daniele Faraglia"
 # built documents.
 #
 # The short X.Y version.
-version = "19.13.0"
+version = "20.0.1"
 # The full version, including alpha/beta/rc tags.
-release = "19.13.0"
+release = "20.0.1"
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
diff --git a/faker/__init__.py b/faker/__init__.py
index 3a2e4522..e3eea64d 100644
--- a/faker/__init__.py
+++ b/faker/__init__.py
@@ -2,6 +2,6 @@ from faker.factory import Factory
 from faker.generator import Generator
 from faker.proxy import Faker
 
-VERSION = "19.13.0"
+VERSION = "20.0.1"
 
 __all__ = ("Factory", "Generator", "Faker")
diff --git a/faker/providers/color/color.py b/faker/providers/color/color.py
index 379024f1..366a073a 100644
--- a/faker/providers/color/color.py
+++ b/faker/providers/color/color.py
@@ -291,32 +291,38 @@ class RandomColor:
 
         return 0
 
+    def _validate_color_input(self, color_input: HueType) -> Tuple[int, int]:
+        if (
+            not isinstance(color_input, (list, tuple))
+            or len(color_input) != 2
+            or any(not isinstance(c, (float, int)) for c in color_input)
+        ):
+            raise TypeError("Hue must be a valid string, numeric type, or a tuple/list of 2 numeric types.")
+
+        return color_input[0], color_input[1]
+
     def get_hue_range(self, color_input: Optional[HueType]) -> Tuple[int, int]:
         """Return the hue range for a given ``color_input``."""
+        if color_input is None:
+            return 0, 360
+
         if isinstance(color_input, (int, float)) and 0 <= color_input <= 360:
             color_input = int(color_input)
             return color_input, color_input
-        elif isinstance(color_input, str) and color_input in self.colormap:
+
+        if isinstance(color_input, str) and color_input in self.colormap:
             return self.colormap[color_input]["hue_range"][0]
-        elif color_input is None:
-            return 0, 360
 
-        if isinstance(color_input, list):
-            color_input = tuple(color_input)
-        if (
-            isinstance(color_input, tuple)
-            and len(color_input) == 2
-            and all(isinstance(c, (float, int)) for c in color_input)
-        ):
-            v1 = int(color_input[0])
-            v2 = int(color_input[1])
-
-            if v2 < v1:
-                v1, v2 = v2, v1
-            v1 = max(v1, 0)
-            v2 = min(v2, 360)
-            return v1, v2
-        raise TypeError("Hue must be a valid string, numeric type, or a tuple/list of 2 numeric types.")
+        color_input = self._validate_color_input(color_input)
+
+        v1 = int(color_input[0])
+        v2 = int(color_input[1])
+
+        if v2 < v1:
+            v1, v2 = v2, v1
+        v1 = max(v1, 0)
+        v2 = min(v2, 360)
+        return v1, v2
 
     def get_saturation_range(self, hue: int) -> Tuple[int, int]:
         """Return the saturation range for a given numerical ``hue`` value."""
diff --git a/faker/providers/date_time/__init__.py b/faker/providers/date_time/__init__.py
index 17b9debd..75022eae 100644
--- a/faker/providers/date_time/__init__.py
+++ b/faker/providers/date_time/__init__.py
@@ -1,3 +1,4 @@
+import platform
 import re
 
 from calendar import timegm
@@ -73,6 +74,21 @@ for name, sym in [
 
 
 class Provider(BaseProvider):
+    # NOTE: Windows only guarantee second precision, in order to emulate that
+    #       we need to inspect the platform to determine which function is most
+    #       appropriate to generate random seconds with.
+    if platform.system() == "Windows":
+
+        @property
+        def _rand_seconds(self):
+            return self.generator.random.randint
+
+    else:
+
+        @property
+        def _rand_seconds(self):
+            return self.generator.random.uniform
+
     centuries: ElementsType[str] = [
         "I",
         "II",
@@ -1805,7 +1821,7 @@ class Provider(BaseProvider):
         self,
         end_datetime: Optional[DateParseType] = None,
         start_datetime: Optional[DateParseType] = None,
-    ) -> int:
+    ) -> float:
         """
         Get a timestamp between January 1, 1970 and now, unless passed
         explicit start_datetime or end_datetime values.
@@ -1814,7 +1830,7 @@ class Provider(BaseProvider):
         """
         start_datetime = self._parse_start_datetime(start_datetime)
         end_datetime = self._parse_end_datetime(end_datetime)
-        return self.generator.random.randint(start_datetime, end_datetime)
+        return self._rand_seconds(start_datetime, end_datetime)
 
     def time_delta(self, end_datetime: Optional[DateParseType] = None) -> timedelta:
         """
@@ -1824,7 +1840,7 @@ class Provider(BaseProvider):
         end_datetime = self._parse_end_datetime(end_datetime)
         seconds = end_datetime - start_datetime
 
-        ts = self.generator.random.randint(*sorted([0, seconds]))
+        ts = self._rand_seconds(*sorted([0, seconds]))
         return timedelta(seconds=ts)
 
     def date_time(
@@ -1867,7 +1883,7 @@ class Provider(BaseProvider):
         start_time = -62135596800 if start_datetime is None else self._parse_start_datetime(start_datetime)
         end_datetime = self._parse_end_datetime(end_datetime)
 
-        ts = self.generator.random.randint(start_time, end_datetime)
+        ts = self._rand_seconds(start_time, end_datetime)
         # NOTE: using datetime.fromtimestamp(ts) directly will raise
         #       a "ValueError: timestamp out of range for platform time_t"
         #       on some platforms due to system C functions;
@@ -2033,7 +2049,7 @@ class Provider(BaseProvider):
         if end_date - start_date <= 1:
             ts = start_date + self.generator.random.random()
         else:
-            ts = self.generator.random.randint(start_date, end_date)
+            ts = self._rand_seconds(start_date, end_date)
         if tzinfo is None:
             return datetime(1970, 1, 1, tzinfo=tzinfo) + timedelta(seconds=ts)
         else:
@@ -2132,7 +2148,7 @@ class Provider(BaseProvider):
             datetime_to_timestamp(datetime.now(tzinfo)) if datetime_end is None else self._parse_date_time(datetime_end)
         )
 
-        timestamp = self.generator.random.randint(datetime_start_, datetime_end_)
+        timestamp = self._rand_seconds(datetime_start_, datetime_end_)
         try:
             if tzinfo is None:
                 pick = convert_timestamp_to_datetime(timestamp, tzlocal())
diff --git a/faker/providers/python/__init__.py b/faker/providers/python/__init__.py
index 3118fb89..32f918a6 100644
--- a/faker/providers/python/__init__.py
+++ b/faker/providers/python/__init__.py
@@ -258,21 +258,21 @@ class Provider(BaseProvider):
 
     def pydecimal(
         self,
-        left_digits=None,
-        right_digits=None,
-        positive=False,
-        min_value=None,
-        max_value=None,
-    ):
+        left_digits: Optional[int] = None,
+        right_digits: Optional[int] = None,
+        positive: bool = False,
+        min_value: Optional[float] = None,
+        max_value: Optional[float] = None,
+    ) -> Decimal:
         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:
+        if min_value is not None and max_value is not None 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:
+        if min_value is not None and max_value is not None 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")
@@ -299,7 +299,7 @@ class Provider(BaseProvider):
 
         if sign == "+":
             if max_value is not None:
-                left_number = str(self.random_int(max(min_value or 0, 0), max_value))
+                left_number = str(self.random_int(int(max(min_value or 0, 0)), int(max_value)))
             else:
                 min_left_digits = math.ceil(math.log10(max(min_value or 1, 1)))
                 if left_digits is None:
@@ -307,7 +307,7 @@ class Provider(BaseProvider):
                 left_number = "".join([str(self.random_digit()) for i in range(0, left_digits)]) or "0"
         else:
             if min_value is not None:
-                left_number = str(self.random_int(max(max_value or 0, 0), abs(min_value)))
+                left_number = str(self.random_int(int(max(max_value or 0, 0)), int(abs(min_value))))
             else:
                 min_left_digits = math.ceil(math.log10(abs(min(max_value or 1, 1))))
                 if left_digits is None:
diff --git a/faker/typing.py b/faker/typing.py
index cea3a908..2fb4426d 100644
--- a/faker/typing.py
+++ b/faker/typing.py
@@ -2,7 +2,7 @@ import dataclasses
 import sys
 
 from datetime import date, datetime, timedelta
-from typing import Sequence, TypeVar, Union
+from typing import Sequence, Union
 
 try:
     from typing import Literal  # type: ignore
@@ -17,7 +17,7 @@ else:
     from typing_extensions import OrderedDict as OrderedDictType  # NOQA
 
 DateParseType = Union[date, datetime, timedelta, str, int]
-HueType = TypeVar("HueType", str, float, Sequence[int])
+HueType = Union[str, float, int, Sequence[int]]
 SexLiteral = Literal["M", "F"]
 SeedType = Union[int, float, str, bytes, bytearray, None]
 


Test patch:
diff --git a/tests/providers/test_date_time.py b/tests/providers/test_date_time.py
index 492b3ec3..bb8cb69a 100644
--- a/tests/providers/test_date_time.py
+++ b/tests/providers/test_date_time.py
@@ -183,7 +183,6 @@ class TestDateTime(unittest.TestCase):
         assert not self.fake.iso8601().endswith("+00:00")
         assert self.fake.iso8601(utc).endswith("+00:00")
         assert self.fake.iso8601()[10] == "T"
-        assert len(self.fake.iso8601()) == 19
         assert len(self.fake.iso8601(timespec="hours")) == 13
         assert len(self.fake.iso8601(timespec="minutes")) == 16
         assert len(self.fake.iso8601(timespec="seconds")) == 19
@@ -194,6 +193,14 @@ class TestDateTime(unittest.TestCase):
         assert self.fake.iso8601(tzinfo=utc, sep=" ")[10] == " "
         assert self.fake.iso8601(tzinfo=utc, sep="_")[10] == "_"
 
+    @pytest.mark.skipif(not sys.platform.startswith("win"), reason="windows does not support sub second precision")
+    def test_iso8601_fractional_seconds_win(self):
+        assert len(self.fake.iso8601()) == 19
+
+    @pytest.mark.skipif(sys.platform.startswith("win"), reason="non windows does support sub second precision")
+    def test_iso8601_fractional_seconds_non_win(self):
+        assert len(self.fake.iso8601()) == 26
+
     def test_date_object(self):
         assert isinstance(self.fake.date_object(), date)
 
@@ -493,7 +500,7 @@ class TestDateTime(unittest.TestCase):
 
             constrained_unix_time = self.fake.unix_time(end_datetime=end_datetime, start_datetime=start_datetime)
 
-            self.assertIsInstance(constrained_unix_time, int)
+            self.assertIsInstance(constrained_unix_time, (int, float))
             self.assertBetween(
                 constrained_unix_time,
                 datetime_to_timestamp(start_datetime),
@@ -505,7 +512,7 @@ class TestDateTime(unittest.TestCase):
 
             recent_unix_time = self.fake.unix_time(start_datetime=one_day_ago)
 
-            self.assertIsInstance(recent_unix_time, int)
+            self.assertIsInstance(recent_unix_time, (int, float))
             self.assertBetween(
                 recent_unix_time,
                 datetime_to_timestamp(one_day_ago),
@@ -517,7 +524,7 @@ class TestDateTime(unittest.TestCase):
 
             distant_unix_time = self.fake.unix_time(end_datetime=one_day_after_epoch_start)
 
-            self.assertIsInstance(distant_unix_time, int)
+            self.assertIsInstance(distant_unix_time, (int, float))
             self.assertBetween(
                 distant_unix_time,
                 datetime_to_timestamp(epoch_start),
@@ -527,7 +534,7 @@ class TestDateTime(unittest.TestCase):
             # Ensure wide-open unix_times are generated correctly
             self.fake.unix_time()
 
-            self.assertIsInstance(constrained_unix_time, int)
+            self.assertIsInstance(constrained_unix_time, (int, float))
             self.assertBetween(constrained_unix_time, 0, datetime_to_timestamp(now))
 
             # Ensure it does not throw error with startdate='now' for machines with negative offset
@@ -538,6 +545,16 @@ class TestDateTime(unittest.TestCase):
             if platform.system() != "Windows":
                 del os.environ["TZ"]
 
+    @pytest.mark.skipif(not sys.platform.startswith("win"), reason="windows does not support sub second precision")
+    def test_unix_time_win(self):
+        unix_time = self.fake.unix_time()
+        assert isinstance(unix_time, int)
+
+    @pytest.mark.skipif(sys.platform.startswith("win"), reason="non windows does support sub second precision")
+    def test_unix_time_non_win(self):
+        unix_time = self.fake.unix_time()
+        assert isinstance(unix_time, float)
+
     def test_change_year(self):
         _2020_06_01 = datetime.strptime("2020-06-01", "%Y-%m-%d")
         _20_years_ago = change_year(_2020_06_01, -20)
diff --git a/tests/providers/test_python.py b/tests/providers/test_python.py
index 27a2a69e..6342bfb9 100644
--- a/tests/providers/test_python.py
+++ b/tests/providers/test_python.py
@@ -84,6 +84,36 @@ def test_pyfloat_right_or_left_digit_overflow():
             assert str(abs(result)) == "1.12345678901234"
 
 
+@pytest.mark.skipif(sys.version_info < (3, 10), reason="Only relevant for Python 3.10 and later.")
+@pytest.mark.parametrize(
+    ("min_value", "max_value"),
+    [
+        (1.5, None),
+        (-1.5, None),
+        (None, -1.5),
+        (None, 1.5),
+        (-1.5, 1.5),
+    ],
+)
+@pytest.mark.parametrize(("left_digits"), [None, 5])
+@pytest.mark.parametrize(("right_digits"), [None, 5])
+@pytest.mark.filterwarnings(
+    # Convert the warning to an error for this test
+    r"error:non-integer arguments to randrange\(\):DeprecationWarning"
+)
+def test_float_min_and_max_value_does_not_crash(
+    left_digits: Optional[int],
+    right_digits: Optional[int],
+    min_value: Optional[float],
+    max_value: Optional[float],
+):
+    """
+    Float arguments to randrange are deprecated from Python 3.10. This is a regression
+    test to check that `pydecimal` does not cause a crash on any code path.
+    """
+    Faker().pydecimal(left_digits, right_digits, min_value=min_value, max_value=max_value)
+
+
 class TestPyint(unittest.TestCase):
     def setUp(self):
         self.fake = Faker()