ariebovenberg / whenever

⏰ Modern datetime library for Python, written in Rust
https://whenever.rtfd.io
MIT License
734 stars 12 forks source link

Ability to mock the current time (for testing) #147

Open ariebovenberg opened 2 weeks ago

ariebovenberg commented 2 weeks ago

For testing, it's often useful to set the current time in order to control the output of .now().

The API would probably look something like this:

from whenever import patch_current_time

with patch_current_time(Instant.from_utc(2000, 8, 4, hour=3), keep_ticking=True):
    Instant.now()  # returns the patched time
    ...  # etc.

Instant.now()  # back to unpatched
RonnyPfannschmidt commented 2 weeks ago

Having an objective one can explicitly tick is very helpful for getting exact timing results

ariebovenberg commented 2 weeks ago

@RonnyPfannschmidt you mean one that you can explicitly progress?

like this?

with patch_current_time(..., keep_ticking=False) as p:
    Instant.now()
    p.shift(hours=4)
    Instant.now()  # shows 4 hours later
RonnyPfannschmidt commented 2 weeks ago

Exactly, one common hack is to hijack sleep for progressing time

StefanBRas commented 2 weeks ago

My impression is that freezegun is the de facto standard for mocking time in python. Maybe it would make sense to just copy the API to make it easier to move between datetime and whenever?

ariebovenberg commented 2 weeks ago

@StefanBRas agree that we should hook into existing libraries like freezegun where possible. However, instead of copying the entire freezegun API (it's quite large!), we should see if it's possible to plug into it somehow.

spacemanspiff2007 commented 2 weeks ago

freezegun is explicitly a mock for the datetime module. I would be very surprised if it worked for whenever. Also it comes with its own drawbacks (see comparison on yet another datetime mock library). All these libraries were created because datetime does not provide a standardized way to mock the time.

I'd start with natively providing a way to mock the time (e.g. like pendulum does) and only if the native way is fundamentally lacking I'd think about using/supporting a 3rd party library.

StefanBRas commented 2 weeks ago

@StefanBRas agree that we should hook into existing libraries like freezegun where possible. However, instead of copying the entire freezegun API (it's quite large!), we should see if it's possible to plug into it somehow.

@ariebovenberg Maybe we're talking past each other here, but the API seems very small to me? It's a single callable used either as a decorator or as as context manager. I'll admit that it takes 8 arguments and I have no idea how much work it is to support the functionality of those.

I wasn't talking about actually using or extending freezegun (Doesn't seem like it has any support for extending except for upstreaming support for whenever) but just copy the API.

freezegun is explicitly a mock for the datetime module. I would be very surprised if it worked for whenever. Also it comes with its own drawbacks (see comparison on yet another datetime mock library).

@spacemanspiff2007 I was only talking about the API, not the implementation. The link actually states that the freezegun API is clear and good and that the time-machine API is inspired by it.

spacemanspiff2007 commented 2 weeks ago

I've written some code that uses SystemDateTime to e.g. run something every day at 2 o'clock. It would be very nice if it would also be possible to set the timezone for SystemDateTime. Currently I have to disable the tests during CI because I can't get them to pass ...

ariebovenberg commented 2 weeks ago

@spacemanspiff2007 setting the local timezone is something that's possible to do in the current version:

>>> import time
>>> import os
>>> os.environ['TZ'] = "Asia/Tokyo"
>>> time.tzset()
>>> from whenever import SystemDateTime
>>> SystemDateTime.now()
SystemDateTime(2024-07-11 19:51:54.762011+09:00)

Note however that tzset doesn't work on windows, unfortunately...

simon04 commented 1 week ago

Java's solution to this problem is the Clock interface:

Any now() function optionally accepts a clock, for instance LocalDateTime.now(clock)

ariebovenberg commented 1 week ago

@simon04 thanks for sharing. This 'dependency injection' is probably the most reasonable from a proper engineering standpoint and something I'd personally prefer—but there are downsides:

  1. The code under test would have to opt-into using the Clock, requiring discipline and making it impossible to patch any third-party code that simply doesn't do this. Making Clock required would be too strict.
  2. It adds more interface/classes to the API surface
  3. patching seems to be the norm in Python-land

For better or worse, patching is probably the most pragmatic way forward

spacemanspiff2007 commented 1 week ago

Note however that tzset doesn't work on windows

Additionally it's unclear which side effects tzset has and how performant this is. Is it suitable for each of the affected test cases to patch it to the desired time zone? If so then it should be documented under testing in the whenever docs.

ariebovenberg commented 1 week ago

@spacemanspiff2007 I've released a 0.6.4rc0 with patch_current_time so you can have a run with it—see if it works for your needs.

docs here: https://whenever.readthedocs.io/en/0.6.4rc0/api.html#whenever.patch_current_time

ariebovenberg commented 1 week ago

Additionally it's unclear which side effects tzset has and how performant this is. Is it suitable for each of the affected test cases to patch it to the desired time zone?

tzset is documented here. Whenever re-uses Python's datetime logic for the system timezone, so its effects should be exactly the same.

To illustrate, this is how I use tzset() in the whenever test suite:

@contextmanager
def system_tz_ams():
    if IS_WINDOWS:
        pytest.skip("tzset is not available on Windows")
    with patch.dict(os.environ, {"TZ": "Europe/Amsterdam"}):
        time.tzset()
        yield

    time.tzset()
ariebovenberg commented 10 hours ago

Reading up on time-machine, I've discovered it wouldn't be too hard to make whenever compatible with it. This means that patch_current_time can be removed in favor of just using time-machine.

The only awkwardness is that time-machine still works with datetime instances, but you can at least do:

@time_machine.travel("1980-03-02 02:00 UTC")
def test_patch_time():
    assert Instant.now() == Instant.from_utc(1980, 3, 2, hour=2)

Potential improvement is to implement a wrapper around time-machine, or even ask if it can be supported in the library itself.