Python: time-machine 3.3.0 lets your tests time-travel even faster

Updated image of my time machine.

time-machine is my library for mocking the current date and time in Python tests. I’ve just released version 3.3.0 with a decent number of changes, so here’s a lovely summary for you.

Despite doing one simple-to-describe task, time-machine has ended up being quite a big library, thanks to the various complexities of reading time and API changes and optimizations in Python. I’m grateful for all the help I’ve received through open source contributions, it really makes it more manageable.

Python 3.15 support

Python 3.15 is currently in beta, due in October 2026, but time-machine 3.3.0 already supports it (wheels pending the release candidate). I try to get time-machine ready for new Python versions as soon as possible because it is becoming more widely used, and projects like Fedora want to test all the packages they support against the new Python version early.

This release required a bit more work than usual. Historically, time-machine never needed to mock datetime.date.today(), because CPython implemented it by calling cls.fromtimestamp(time.time()), which time-machine already patched. Python 3.15 added a fast path that reads the system clock directly (CPython PR #130980), so time travel stopped affecting it. time-machine now mocks date.today() directly to restore the expected behaviour.

Thanks to Miro Hrončok and Karolina Surma for the report in Issue #610, Lumír ‘Frenzy’ Balhar for the fix in PR #618, and Maurycy Pawłowski-Wieroński for review.

Faster patched functions

When I first wrote time-machine, I knew I needed to write a C extension, but I wanted to avoid writing too much of it because I found it pwetty scawy. This meant that during time travel, the patched-in C functions would actually call into Python to compute the datetime value to return. This behaviour made things easy to implement, but it meant extra overhead on basic calls like time.time().

Now, with the power of LLM assistance and a rigorously built-up test suite, I can bravely read and write more C code, so I was able to convert those functions from Python to C, in PR #643. The results are much more verbose, but faster.

As an example, here’s how the intermediary function calculating a result for datetime.datetime.now() looked in Python:

def now(tz: dt.tzinfo | None = None) -> dt.datetime:
    return dt.datetime.fromtimestamp(time(), tz)

…and in C:

/* datetime.datetime.now() */

static PyObject *
_time_machine_now(
    PyTypeObject *type, PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames)

{
    PyObject *tz = Py_None;
    Py_ssize_t nkwargs = (kwnames != NULL) ? PyTuple_GET_SIZE(kwnames) : 0;
    for (Py_ssize_t i = 0; i < nkwargs; i++) {
        PyObject *name = PyTuple_GET_ITEM(kwnames, i);
        if (PyUnicode_CompareWithASCIIString(name, "tz") != 0) {
            PyErr_Format(
                PyExc_TypeError, "now() got an unexpected keyword argument '%U'", name);
            return NULL;
        }
        tz = args[nargs + i];
    }
    if (nargs + nkwargs > 1) {
        PyErr_Format(
            PyExc_TypeError, "now() takes at most 1 argument (%zd given)", nargs + nkwargs);
        return NULL;
    }
    if (nargs == 1) {
        tz = args[0];
    }

    // cls.fromtimestamp(traveller_time, tz)
    PyObject *timestamp = _time_machine_traveller_time();
    if (timestamp == NULL) {
        return NULL;
    }
    PyObject *stack[3] = {(PyObject *)type, timestamp, tz};
    PyObject *result = PyObject_VectorcallMethod(
        str_fromtimestamp, stack, 3 | PY_VECTORCALL_ARGUMENTS_OFFSET, NULL);
    Py_DECREF(timestamp);
    return result;
}

Yeah… it takes a lot of extra work in C to even verify the arguments before even calling cls.fromtimestamp().

At least the result is worth it, with a ~30% speedup, as demonstrated with this benchmark using IPython’s %timeit magic:

In [1]: %%timeit
   ...: import time, time_machine
   ...: with time_machine.travel(0):
   ...:     for _ in range(1_000_000):
   ...:         time.time()

Result before:

418 ms ± 4.49 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Result after:

299 ms ± 9.38 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

This change also unblocked a couple of bug fixes:

  1. The DeprecationWarning from datetime.utcnow() on Python 3.12+ is no longer hidden while time travelling. time-machine now emits a copy of the warning itself from its patched utcnow() function.
  2. Calling now() or utcnow() on a datetime subclass now returns an instance of that subclass, rather than a plain datetime.datetime instance. This was hard to fix with the Python-level intermediary function, because it did not receive the calling type as a parameter, but the C-level intermediary function does.

Subinterpreter support

Python 3.14 shipped concurrent.interpreters, which allows multiple Python interpreters to run in one process, allowing a new model of parallelization. Back in 2021, for version 2.2.0, I moved time-machine’s C module to multi-phase initialization, which was “future-proofing” for the day CPython supported subinterpreters. However, it was not sufficient, and time-machine would error in an isolated subinterpreter when another interpreter was time-travelling.

The fixes in this version fix those errors, enabling each interpreter to run its own time travel independently of the others. Hopefully this change unblocks experimentation with using subinterpreters to run tests in parallel.

There are probably still some edge cases to fix, but at least the basic functionality is there and tested.

Migration CLI

I built time-machine as an optimized successor to freezegun (it’s way faster). To help you migrate test suites from freezegun to time-machine (a current client task!), time-machine ships with a Migration CLI that rewrites your code as best it can.

The CLI gained some new capabilities in this release.

First, it now handles calls that pass tick, which it previously left alone:

-@freeze_time("2023-01-01", tick=True)
+@time_machine.travel("2023-01-01", tick=True)
 def test_ticking():
     ...

(Where tick isn’t passed, the tool adds tick=False, matching freezegun’s frozen-by-default behaviour.)

Thanks to George-Cristian Birzan for the report in Issue #609 and tanren for the fix in PR #636.

Second, it also rewrites decorators on async functions, another case it used to skip:

-@freeze_time("2023-01-01")
+@time_machine.travel("2023-01-01", tick=False)
 async def test_async():
     ...

Thanks to George-Cristian Birzan for the report in Issue #608 and Sanjay Santhanam for the fix in PR #640.

Third, from-imports that pull in freeze_time alongside other names now get split, keeping those other names importing from freezegun:

-from freezegun import freeze_time, FakeDate
+import time_machine
+from freezegun import FakeDate

Fourth, a bug fix: relative imports to modules that happen to be called freezegun name are now left alone. Previously, a line like this would get rewritten to import time_machine, even though it imports from your a local module:

from .freezegun import freeze_time

This might happen if you have a wrapper module, which probably needs its own consideration for migration, so the CLI should leave it alone.

Wheels

Two changes to what ships on PyPI:

Wheels are also now built with frame pointers enabled, per the PEP 831 change in Python 3.15. Frame pointers are a debugging feature for low-level languages like C, which allow debuggers to reconstruct the call stack of a program. Python has decided to enable frame pointers by default and encourage the ecosystem to do so as well, to make debugging easier for us all, so time-machine is joining the party.

Fin

Please upgrade today and let me know how it goes!

Are your tests still running?

—Adam


😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: