Repository: joke2k/faker

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.


Discussion:
If I remember correctly, we intentionally keep the second-precision to avoid issues with Windows, and we should have regression tests for it.

Feel free to try, and if all tests still pass we can get it merged.
Alright, then that would mean different behavior on windows compared to the rest of the platforms, which makes it a bit more complex. It should be reasonably easy to write the code itself, but I need to figure out how to make conditional tests so the different behaviour actually gets tested.

Resolving patch:
diff --git a/faker/providers/date_time/__init__.py b/faker/providers/date_time/__init__.py
--- 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 @@ class ParseError(ValueError):
 
 
 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 @@ def unix_time(
         """
         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 @@ def time_delta(self, end_datetime: Optional[DateParseType] = None) -> timedelta:
         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 @@ def date_time_ad(
         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 @@ def date_time_between(
         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 @@ def date_time_between_dates(
             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
--- a/tests/providers/test_date_time.py
+++ b/tests/providers/test_date_time.py
@@ -183,7 +183,6 @@ def test_datetimes_with_and_without_tzinfo(self):
         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 @@ def test_datetimes_with_and_without_tzinfo(self):
         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 @@ def test_unix_time(self):
 
             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 @@ def test_unix_time(self):
 
             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 @@ def test_unix_time(self):
 
             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 @@ def test_unix_time(self):
             # 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 @@ def test_unix_time(self):
             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)