diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8c73c1e3..1e7cf007 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,8 @@ Version 19.1.0 - Added support for E.164 phone number formatting to the ``Person`` provider's ``phone_number()`` method. - Added ``secondary_address()`` method to the ``Address`` provider. - Add SHA-3 family hash algorithms (``SHA3_224``, ``SHA3_256``, ``SHA3_384``, ``SHA3_512``, ``SHAKE128``, ``SHAKE256``) to the ``Algorithm`` enum. +- Add ``future_date()``, ``future_datetime()``, ``past_date()`` and ``past_datetime()`` methods for the ``Datetime`` provider. See (`#775 `_). + Version 19.0.0 -------------- diff --git a/mimesis/providers/date.py b/mimesis/providers/date.py index 43d419a3..203729e3 100644 --- a/mimesis/providers/date.py +++ b/mimesis/providers/date.py @@ -289,6 +289,78 @@ def timestamp( else: return int(stamp.timestamp()) + def future_date(self, days: int = 30) -> Date: + """Generates a random date in the future. + + :param days: Maximum number of days in the future. + :return: A date object between tomorrow and `days` from now. + """ + return self.future_datetime(days).date() + + def future_datetime( + self, + days: int = 30, + seconds: int | None = None, + timezone: str | None = None, + ) -> DateTime: + """Generates a random datetime in the future. + + :param days: Maximum number of days in the future (ignored if seconds is set). + :param seconds: Maximum number of seconds in the future (overrides days). + :param timezone: Set custom timezone (pytz required). + :return: A datetime object between now and the specified time in the future. + """ + now = datetime.now() + start_dt = now + timedelta(seconds=1) + window = timedelta(seconds=seconds) if seconds else timedelta(days=days) + end_dt = now + window + delta_seconds = self.random.randint(0, int((end_dt - start_dt).total_seconds())) + result = start_dt + timedelta(seconds=delta_seconds) + + if timezone: + if not pytz: + raise ImportError("Timezones are supported only with pytz") + tz = pytz.timezone(timezone) + result = tz.localize(result) + + return result + + def past_date(self, days: int = 30) -> Date: + """Generates a random date in the past. + + :param days: Maximum number of days in the past. + :return: A date object between `days` ago and yesterday. + """ + return self.past_datetime(days).date() + + def past_datetime( + self, + days: int = 30, + seconds: int | None = None, + timezone: str | None = None, + ) -> DateTime: + """Generates a random datetime in the past. + + :param days: Maximum number of days in the past (ignored if seconds is set). + :param seconds: Maximum number of seconds in the past (overrides days). + :param timezone: Set custom timezone (pytz required). + :return: A datetime object between the specified time ago and now. + """ + now = datetime.now() + window = timedelta(seconds=seconds) if seconds else timedelta(days=days) + start_dt = now - window + end_dt = now - timedelta(seconds=1) + delta_seconds = self.random.randint(0, int((end_dt - start_dt).total_seconds())) + result = start_dt + timedelta(seconds=delta_seconds) + + if timezone: + if not pytz: + raise ImportError("Timezones are supported only with pytz") + tz = pytz.timezone(timezone) + result = tz.localize(result) + + return result + def duration( self, min_duration: int = 1, diff --git a/tests/test_providers/test_localized/test_date.py b/tests/test_providers/test_localized/test_date.py index aa13659c..6cbecf53 100644 --- a/tests/test_providers/test_localized/test_date.py +++ b/tests/test_providers/test_localized/test_date.py @@ -258,6 +258,80 @@ def test_duration_error(self, _datetime): duration_unit=DurationUnit.WEEKS, ) + @pytest.mark.parametrize("days", [7, 30, 90]) + def test_future_date(self, _datetime, days): + today = datetime.date.today() + result = _datetime.future_date(days=days) + assert isinstance(result, datetime.date) + assert result > today + assert result <= today + datetime.timedelta(days=days) + + @pytest.mark.parametrize( + "days, timezone", + [ + (30, None), + (30, "Europe/Paris"), + ], + ) + def test_future_datetime(self, _datetime, days, timezone): + now = datetime.datetime.now() + result = _datetime.future_datetime(days=days, timezone=timezone) + assert isinstance(result, datetime.datetime) + assert result.replace(tzinfo=None) > now + if timezone: + assert result.tzinfo is not None + else: + assert result.tzinfo is None + + @pytest.mark.parametrize("days", [7, 30, 90]) + def test_past_date(self, _datetime, days): + today = datetime.date.today() + result = _datetime.past_date(days=days) + assert isinstance(result, datetime.date) + assert result < today + assert result >= today - datetime.timedelta(days=days) + + @pytest.mark.parametrize( + "days, timezone", + [ + (30, None), + (30, "Europe/Paris"), + ], + ) + def test_past_datetime(self, _datetime, days, timezone): + now = datetime.datetime.now() + result = _datetime.past_datetime(days=days, timezone=timezone) + assert isinstance(result, datetime.datetime) + assert result.replace(tzinfo=None) < now + if timezone: + assert result.tzinfo is not None + else: + assert result.tzinfo is None + + def test_future_datetime_no_pytz(self, _datetime, mocker): + mocker.patch("mimesis.providers.date.pytz", None) + with pytest.raises(ImportError, match="Timezones are supported only with pytz"): + _datetime.future_datetime(timezone="Europe/Paris") + + def test_past_datetime_no_pytz(self, _datetime, mocker): + mocker.patch("mimesis.providers.date.pytz", None) + with pytest.raises(ImportError, match="Timezones are supported only with pytz"): + _datetime.past_datetime(timezone="Europe/Paris") + + def test_future_datetime_seconds(self, _datetime): + now = datetime.datetime.now() + result = _datetime.future_datetime(seconds=60) + assert isinstance(result, datetime.datetime) + assert result > now + assert (result - now).total_seconds() <= 60 + + def test_past_datetime_seconds(self, _datetime): + now = datetime.datetime.now() + result = _datetime.past_datetime(seconds=120) + assert isinstance(result, datetime.datetime) + assert result < now + assert (now - result).total_seconds() <= 120 + class TestSeededDatetime: @pytest.fixture @@ -338,3 +412,21 @@ def test_duratioh(self, d1, d2): assert d1.duration(10, 20, DurationUnit.WEEKS) == d2.duration( 10, 20, DurationUnit.WEEKS ) + + def test_future_date(self, d1, d2): + assert d1.future_date() == d2.future_date() + assert d1.future_date(days=7) == d2.future_date(days=7) + + def test_future_datetime(self, d1, d2): + r1 = d1.future_datetime().replace(microsecond=0) + r2 = d2.future_datetime().replace(microsecond=0) + assert r1 == r2 + + def test_past_date(self, d1, d2): + assert d1.past_date() == d2.past_date() + assert d1.past_date(days=7) == d2.past_date(days=7) + + def test_past_datetime(self, d1, d2): + r1 = d1.past_datetime().replace(microsecond=0) + r2 = d2.past_datetime().replace(microsecond=0) + assert r1 == r2