ariebovenberg / whenever

⏰ Modern datetime library for Python
https://whenever.rtfd.io
MIT License
898 stars 15 forks source link

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

Closed ariebovenberg closed 3 months ago

ariebovenberg commented 4 months 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 4 months ago

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

ariebovenberg commented 4 months 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 4 months ago

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

StefanBRas commented 4 months 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 4 months 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 4 months 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 4 months 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 4 months 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 4 months 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 4 months 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 4 months 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 4 months 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 4 months 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 4 months 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 4 months 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.

spacemanspiff2007 commented 4 months ago

Using time-machine can have unintended side effects because it mocks all native datetime/time calls. This can result in e.g. the asyncio event loop not running properly anymore in case of tick=False. I think it's very surprising that wanting to mock whenever will also change the behavior of e.g. time.monotonic() The only reason time-machine exists in the first place is that there is no native way of mocking the standard library. If your goal is to make time-machine additionally work with whenever I'm fine with that but I really think providing a native way makes a lot of sense and is something that is reasonably and also expected by the users.

ariebovenberg commented 4 months ago

@spacemanspiff2007 good point! I'll keep the native patch_current_time and provide time-machine support for those that want it 👍

ariebovenberg commented 4 months ago

Release 0.6.4 is out with the change. I'll leave this issue open for a bit for immediate feedback.