dev/hack/make/play


Faking date/time in Python

Sometimes there's a need (testing, debugging, re-running yesterday's report, …) to "pretend" that program is being run at a different point in time than what clock currently shows. Easiest/lamest approach is to just set the system clock to that time and be done with it (not really applicable to a production machine or a server). Another approach is to add another command line argument to an app and pass it around to all likes of datetime.now() or similar but that can quickly become very messy. Simplest would be to fake the current system time just for that app.

TL;DR: code is at https://bitbucket.org/igor_b/timefake/

Disclaimer: only after I finished writing the code I checked online if the "faketime" name is available and immediately found https://github.com/wolfcw/libfaketime which is exactly what I wanted but much better and not limited to Python.

Even though this work turned out to be quite useless I learned some new things about monkey patching in Python. So, here it is.

Monkey patching is a process of dynamically replacing object/class/module methods and it can easily lead to chaos. But, nevertheless, it can be useful in some situations.

The goal here is to somehow specify some point in time and then let the time run normally (i.e. we don't want to "freeze" time). There are two time related modules in Python: time and datetime and we'll need to take care of both of them.

Datetime

Checking the /usr/lib/python3.7/datetime.py reveals that only methods related to getting the current time are datetime.now(), datetime.utcnow() and date.today(). All of them use time.time() function so we only need to tell them to use our function. Problem with datetime module is that it is built in so we can't just replace the method. But we can subclass the whole class and set the class name to point to our new class:

# define class
class _mydatetime(datetime.datetime):
    @classmethod
    def now(cls, tz=None):
        t = time.time()
        return cls.fromtimestamp(t, tz)
# replace it
datetime.datetime = _mydatetime

The same would fo for other methods. Class datetime also uses time module locally aliased as _time so we need to patch the time module first and then replace datetime class.

Time

By looking at the documentation we can see that functions related to getting the current time are time(), localtime(), gmtime(), asctime() and ctime(). All of them need to be replaced (they all use time.time() internally but our change will not "propagate" to the so we'll need to redefine those functions). I found it easiest to just store the original version of the function as a function's attribute and use it later. For example, the time() function looks like this:

def _mytime(offset=0):
    if not hasattr(_mytime, 'time'):
        _mytime.time = time.time
        _mytime.offset = offset
    return _mytime.time()-_mytime.offset
time.time = _mytime

But there is one issue I encountered by accident. When testing I tried all those functions and also the logging module configured to display "%asctime" format. And it raised an exception telling me that asctime requires one argument but two were provided. 

Traceback (most recent call last):
  ...
  File "/usr/lib/python3.7/logging/__init__.py", line 557, in formatTime
    ct = self.converter(record.created)
TypeError: _mylocaltime() takes from 0 to 1 positional arguments but 2 were given

I checked the logging module's code and saw that in the Logging class it takes a reference to time.localtime() (or time.asctime()). Andd the two arguments it complains about are the Logging class (hmmmm… self?) and the time. The thing I learned is following (anyone, please correct me if I got something wrong):

If my function was defined as static this wouldn't happen. But I didn't found the way to define a function which should be static after assigning to class and when used as a regular function (staticmethod() doesn't work here).

So, to get around that I made all functions accept variable number of arguments (actually two would be enough but…) and if two arguments are received the second one is used (first one is self).

def _mylocaltime(*args):
    if not hasattr(_mylocaltime, 'base'):
        _mylocaltime.base = time.localtime
    if len(args)==1:
        t=args[0]
    elif len(args)==2:
        t=args[1]
    else:
        t = time.time()
    return _mylocaltime.base(t)

The full code is at https://bitbucket.org/igor_b/timefake/

Comments