Repository: joke2k/faker (python)

Issue:
Subsecond precision support for fake time
* Faker version: 19.10.0
* OS: Arch Linux

Randomized datetimes produced by a faker instance only ever have second precision. This is rarely a problem but when combined with systems that send datetimes as unix timestamps (because it is easy to deal with in json), it can become a problem that `when.timestamp() == int(when.timestamp())`. This is caused by pythons somewhat loose equality check on numbers where floats and integers can compared and would actually be equal each other, when the fractional part of a float is zero.

I have skimmed the code generating datetime objects and it seems that in most (if not all) cases the randomness comes from a simpler random generation in the form of a second interval generated by `random.randint`, which as the name implies only ever returns integers and not floats as my needs would require.

If this has any interest then I could probably give it a go replacing the innermost calls to `random.randint` in the datetime provider, with something like `random.uniform`. But since am not very familiar with this code, there might be more suitable solution that I am just unaware of.

### Steps to reproduce

```python
from datetime import datetime, timezone, timedelta
from faker import Faker

faker = Faker()
point_in_time = datetime.now(timezone.utc)

randomized_datetime = faker.date_time_between(
	point_in_time,
	point_in_time + timedelta(days=5),
)

# technically this could be able to pass,
# but I have never been so lucky:
assert randomized_datetime.microsecond != 0

# or alternatively from the description above:
unix_ts = randomized_datetime.timestamp()
assert unix_ts != int(unix_ts)
```

### Expected behavior

When producing randomized datetime objects the microsecond value should be non-zero, for sub-second precision.

### Actual behavior

Randomized datetime objects only ever have second precision.

Pull request description:
Support platform specific second precision
### What does this change

This inspects the platform and defines a platform specific property for generating random seconds.
For windows this uses `random.randint` as used before and for everything else `random.uniform`, which returns floats instead of ints.

Then later on when trying to generate a random number of seconds this proper is then used to get the function that can return values with the correct behaviour.

I tried setting the function directly on `self` in an `__init__`, but that worked poorly when seeding the faker after creation, as seeding recreates the internal random instance, so it requires some lazy evaluation to be able to point to the correct random instance in all cases.

Internally the points in time is still just ints. Changing those to floats is not strictly important, and would just add noise to diff - making it harder to review.

### What was wrong

Using `random.randint` everywhere when generating seconds can only ever give seconds precision.

### How this fixes it

Fixes #1945 by allowing sub-second on platforms expected to support it.


Patch:
diff --git a/faker/providers/date_time/__init__.py b/faker/providers/date_time/__init__.py
index 17b9debd..b3a2197f 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",
@@ -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())


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)