Repository: joke2k/faker

Issue:
Drop dateutil dependency
Since we have `zoneinfo` in Python 3.9 I don't think there is much dateutil is doing that we can't almost as easily do without it. It would be nice to be able to drop a dependency from the projects where I use faker. The two uses of `relativedelta` should also be quite painless to remove.

I'm happy to prepare a PR if this would be a welcome change.


Discussion:
I'm +1 on this, but we'll need to deprecate Python 3.8 first
Makes sense. I could also have a crack at that on the weekend maybe if you or someone else doesn't beat me to it.



Patch:
diff --git a/faker/providers/date_time/__init__.py b/faker/providers/date_time/__init__.py
index 69dfa5f9ac..97e9f7a06b 100644
--- a/faker/providers/date_time/__init__.py
+++ b/faker/providers/date_time/__init__.py
@@ -1,5 +1,6 @@
 import platform
 import re
+import zoneinfo
 
 from calendar import timegm
 from datetime import MAXYEAR
@@ -7,12 +8,10 @@
 from datetime import datetime
 from datetime import time as dttime
 from datetime import timedelta
+from datetime import timezone
 from datetime import tzinfo as TzInfo
 from typing import Any, Callable, Dict, Iterator, Optional, Tuple, Union
 
-from dateutil import relativedelta
-from dateutil.tz import gettz, tzlocal, tzutc
-
 from faker.typing import Country, DateParseType
 
 from .. import BaseProvider, ElementsType
@@ -20,16 +19,26 @@
 localized = True
 
 
+def _get_local_timezone():
+    datetime.now().astimezone().tzinfo
+
+
+def _get_next_month_start(dt: Union[dtdate, datetime]):
+    if dt.month == 12:
+        return dt.replace(year=dt.year + 1, month=1)
+    return dt.replace(month=dt.month + 1)
+
+
 def datetime_to_timestamp(dt: Union[dtdate, datetime]) -> int:
     if isinstance(dt, datetime) and getattr(dt, "tzinfo", None) is not None:
-        dt = dt.astimezone(tzutc())
+        dt = dt.astimezone(timezone.utc)
     return timegm(dt.timetuple())
 
 
 def timestamp_to_datetime(timestamp: Union[int, float], tzinfo: Optional[TzInfo]) -> datetime:
     if tzinfo is None:
-        pick = convert_timestamp_to_datetime(timestamp, tzlocal())
-        return pick.astimezone(tzutc()).replace(tzinfo=None)
+        pick = convert_timestamp_to_datetime(timestamp, _get_local_timezone())
+        return pick.astimezone(timezone.utc).replace(tzinfo=None)
     return convert_timestamp_to_datetime(timestamp, tzinfo)
 
 
@@ -2100,7 +2109,7 @@ def date_time_between(
         if tzinfo is None:
             return datetime(1970, 1, 1, tzinfo=tzinfo) + timedelta(seconds=ts)
         else:
-            return (datetime(1970, 1, 1, tzinfo=tzutc()) + timedelta(seconds=ts)).astimezone(tzinfo)
+            return (datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=ts)).astimezone(tzinfo)
 
     def date_between(self, start_date: DateParseType = "-30y", end_date: DateParseType = "today") -> dtdate:
         """
@@ -2202,9 +2211,9 @@ def date_time_between_dates(
         timestamp = self._rand_seconds(datetime_start_, datetime_end_)
         try:
             if tzinfo is None:
-                pick = convert_timestamp_to_datetime(timestamp, tzlocal())
+                pick = convert_timestamp_to_datetime(timestamp, _get_local_timezone())
                 try:
-                    pick = pick.astimezone(tzutc()).replace(tzinfo=None)
+                    pick = pick.astimezone(timezone.utc).replace(tzinfo=None)
                 except OSError:
                     pass
             else:
@@ -2336,8 +2345,8 @@ def date_time_this_month(
         """
         now = datetime.now(tzinfo)
         this_month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+        next_month_start = _get_next_month_start(this_month_start)
 
-        next_month_start = this_month_start + relativedelta.relativedelta(months=1)
         if before_now and after_now:
             return self.date_time_between_dates(this_month_start, next_month_start, tzinfo)
         elif not before_now and after_now:
@@ -2428,8 +2437,8 @@ def date_this_month(self, before_today: bool = True, after_today: bool = False)
         """
         today = dtdate.today()
         this_month_start = today.replace(day=1)
+        next_month_start = _get_next_month_start(this_month_start)
 
-        next_month_start = this_month_start + relativedelta.relativedelta(months=1)
         if before_today and after_today:
             return self.date_between_dates(this_month_start, next_month_start)
         elif not before_today and after_today:
@@ -2540,7 +2549,7 @@ def pytimezone(self, *args: Any, **kwargs: Any) -> Optional[TzInfo]:
 
         :sample:
         """
-        return gettz(self.timezone(*args, **kwargs))  # type: ignore
+        return zoneinfo.ZoneInfo(self.timezone(*args, **kwargs))  # type: ignore
 
     def date_of_birth(
         self,
diff --git a/setup.py b/setup.py
index 9425a09f5d..0525eee51c 100644
--- a/setup.py
+++ b/setup.py
@@ -68,7 +68,4 @@
     platforms=["any"],
     zip_safe=zip_safe,
     python_requires=">=3.9",
-    install_requires=[
-        "python-dateutil>=2.4",
-    ],
 )


Test patch:
diff --git a/tests/providers/test_date_time.py b/tests/providers/test_date_time.py
index 8c8b8f3ca1..f0862c5c4a 100644
--- a/tests/providers/test_date_time.py
+++ b/tests/providers/test_date_time.py
@@ -5,6 +5,7 @@
 import sys
 import time
 import unittest
+import zoneinfo
 
 from datetime import date, datetime
 from datetime import time as datetime_time
@@ -167,10 +168,8 @@ def test_timezone_conversion(self):
         assert today == today_back
 
     def test_pytimezone(self):
-        import dateutil
-
         pytz = self.fake.pytimezone()
-        assert isinstance(pytz, dateutil.tz.tz.tzfile)
+        assert isinstance(pytz, zoneinfo.ZoneInfo)
 
     def test_pytimezone_usable(self):
         pytz = self.fake.pytimezone()