Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Monotonic overhaul #406

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,12 @@ For example:
traveller.move_to(234)
assert time.time() == 234

By default, ``move_to()`` does not affect ``time.monotonic``, but passing
``affect_monotonic=True`` allows to let monotonic timer to get moved.
However, be aware than by doing so, ``time.monotonic`` may step back in time
either whem moving to an earlier date or when exiting the travel context,
breaking everything depending on its monotonic behaviour.

``shift(delta)``
^^^^^^^^^^^^^^^^

Expand All @@ -297,6 +303,12 @@ For example:
traveller.shift(-dt.timedelta(seconds=10))
assert time.time() == 90

By default, ``shift()`` does not affect ``time.monotonic``, but passing
``affect_monotonic=True`` allows to let monotonic timer get affected by
``shift``.
However, be aware than by doing so, monotonic may step back in time when
exiting travel, breaking everything depending on its monotonic behaviour.

pytest plugin
-------------

Expand Down
58 changes: 56 additions & 2 deletions src/_time_machine.c
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ _time_machine_now(PyTypeObject *type, PyObject *const *args, Py_ssize_t nargs, P
PyObject *result = NULL;

PyObject *time_machine_module = PyImport_ImportModule("time_machine");
if (!time_machine_module)
return NULL;
PyObject *time_machine_now = PyObject_GetAttrString(time_machine_module, "now");

result = _PyObject_Vectorcall(time_machine_now, args, nargs, kwnames);
Expand Down Expand Up @@ -70,6 +72,8 @@ static PyObject*
_time_machine_utcnow(PyObject *cls, PyObject *args)
{
PyObject *time_machine_module = PyImport_ImportModule("time_machine");
if (!time_machine_module)
return NULL;
PyObject *time_machine_utcnow = PyObject_GetAttrString(time_machine_module, "utcnow");

PyObject* result = PyObject_CallObject(time_machine_utcnow, args);
Expand Down Expand Up @@ -106,6 +110,8 @@ static PyObject*
_time_machine_clock_gettime(PyObject *self, PyObject *args)
{
PyObject *time_machine_module = PyImport_ImportModule("time_machine");
if (!time_machine_module)
return NULL;
PyObject *time_machine_clock_gettime = PyObject_GetAttrString(time_machine_module, "clock_gettime");

PyObject* result = PyObject_CallObject(time_machine_clock_gettime, args);
Expand Down Expand Up @@ -140,6 +146,8 @@ static PyObject*
_time_machine_clock_gettime_ns(PyObject *self, PyObject *args)
{
PyObject *time_machine_module = PyImport_ImportModule("time_machine");
if (!time_machine_module)
return NULL;
PyObject *time_machine_clock_gettime_ns = PyObject_GetAttrString(time_machine_module, "clock_gettime_ns");

PyObject* result = PyObject_CallObject(time_machine_clock_gettime_ns, args);
Expand Down Expand Up @@ -174,6 +182,8 @@ static PyObject*
_time_machine_gmtime(PyObject *self, PyObject *args)
{
PyObject *time_machine_module = PyImport_ImportModule("time_machine");
if (!time_machine_module)
return NULL;
PyObject *time_machine_gmtime = PyObject_GetAttrString(time_machine_module, "gmtime");

PyObject* result = PyObject_CallObject(time_machine_gmtime, args);
Expand Down Expand Up @@ -208,6 +218,8 @@ static PyObject*
_time_machine_localtime(PyObject *self, PyObject *args)
{
PyObject *time_machine_module = PyImport_ImportModule("time_machine");
if (!time_machine_module)
return NULL;
PyObject *time_machine_localtime = PyObject_GetAttrString(time_machine_module, "localtime");

PyObject* result = PyObject_CallObject(time_machine_localtime, args);
Expand Down Expand Up @@ -238,6 +250,24 @@ Call time.localtime() after patching.");

/* time.monotonic() */

static PyObject*
_time_machine_monotonic(PyObject *self, PyObject *args)
{
PyObject *time_machine_module = PyImport_ImportModule("time_machine");
if (!time_machine_module) {
return NULL;
}
PyObject *time_machine_monotonic = PyObject_GetAttrString(
time_machine_module, "monotonic");

PyObject* result = PyObject_CallObject(time_machine_monotonic, args);

Py_DECREF(time_machine_monotonic);
Py_DECREF(time_machine_module);

return result;
}

static PyObject*
_time_machine_original_monotonic(PyObject* module, PyObject* args)
{
Expand All @@ -258,6 +288,24 @@ Call time.monotonic() after patching.");

/* time.monotonic_ns() */

static PyObject*
_time_machine_monotonic_ns(PyObject *self, PyObject *args)
{
PyObject *time_machine_module = PyImport_ImportModule("time_machine");
if (!time_machine_module) {
return NULL;
}
PyObject *time_machine_monotonic_ns = PyObject_GetAttrString(
time_machine_module, "monotonic_ns");

PyObject* result = PyObject_CallObject(time_machine_monotonic_ns, args);

Py_DECREF(time_machine_monotonic_ns);
Py_DECREF(time_machine_module);

return result;
}

static PyObject*
_time_machine_original_monotonic_ns(PyObject* module, PyObject* args)
{
Expand All @@ -282,6 +330,8 @@ static PyObject*
_time_machine_strftime(PyObject *self, PyObject *args)
{
PyObject *time_machine_module = PyImport_ImportModule("time_machine");
if (!time_machine_module)
return NULL;
PyObject *time_machine_strftime = PyObject_GetAttrString(time_machine_module, "strftime");

PyObject* result = PyObject_CallObject(time_machine_strftime, args);
Expand Down Expand Up @@ -316,6 +366,8 @@ static PyObject*
_time_machine_time(PyObject *self, PyObject *args)
{
PyObject *time_machine_module = PyImport_ImportModule("time_machine");
if (!time_machine_module)
return NULL;
PyObject *time_machine_time = PyObject_GetAttrString(time_machine_module, "time");

PyObject* result = PyObject_CallObject(time_machine_time, args);
Expand Down Expand Up @@ -350,6 +402,8 @@ static PyObject*
_time_machine_time_ns(PyObject *self, PyObject *args)
{
PyObject *time_machine_module = PyImport_ImportModule("time_machine");
if (!time_machine_module)
return NULL;
PyObject *time_machine_time_ns = PyObject_GetAttrString(time_machine_module, "time_ns");

PyObject* result = PyObject_CallObject(time_machine_time_ns, args);
Expand Down Expand Up @@ -439,12 +493,12 @@ _time_machine_patch_if_needed(PyObject *module, PyObject *unused)

PyCFunctionObject *time_monotonic = (PyCFunctionObject *) PyObject_GetAttrString(time_module, "monotonic");
state->original_monotonic = time_monotonic->m_ml->ml_meth;
time_monotonic->m_ml->ml_meth = _time_machine_time;
time_monotonic->m_ml->ml_meth = _time_machine_monotonic;
Py_DECREF(time_monotonic);

PyCFunctionObject *time_monotonic_ns = (PyCFunctionObject *) PyObject_GetAttrString(time_module, "monotonic_ns");
state->original_monotonic_ns = time_monotonic_ns->m_ml->ml_meth;
time_monotonic_ns->m_ml->ml_meth = _time_machine_time_ns;
time_monotonic_ns->m_ml->ml_meth = _time_machine_monotonic_ns;
Py_DECREF(time_monotonic_ns);

PyCFunctionObject *time_strftime = (PyCFunctionObject *) PyObject_GetAttrString(time_module, "strftime");
Expand Down
60 changes: 52 additions & 8 deletions src/time_machine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,42 +152,67 @@ def __init__(
self._destination_tzname = destination_tzname
self._tick = tick
self._requested = False
self._monotonic_start: int = _time_machine.original_monotonic_ns()

def time(self) -> float:
return self.time_ns() / NANOSECONDS_PER_SECOND

def _base(self) -> int:
return SYSTEM_EPOCH_TIMESTAMP_NS + self._destination_timestamp_ns

def time_ns(self) -> int:
if not self._tick:
return self._destination_timestamp_ns

base = SYSTEM_EPOCH_TIMESTAMP_NS + self._destination_timestamp_ns
now_ns: int = _time_machine.original_time_ns()

if not self._requested:
self._requested = True
self._real_start_timestamp_ns = now_ns
return base
return self._base()

return self._base() + (now_ns - self._real_start_timestamp_ns)

return base + (now_ns - self._real_start_timestamp_ns)
def monotonic(self) -> float:
return self.monotonic_ns() / NANOSECONDS_PER_SECOND

def shift(self, delta: dt.timedelta | int | float) -> None:
def monotonic_ns(self) -> int:
ticks = self.time_ns() - self._base()
# XXX: not striclty monotonic if called twice in the leap second;
# would need to duplicate time_ns with a monotonic call to fix.
ticks = max(ticks, 0)
# prevent having discontinuity between outside and inside monotonic.
return self._monotonic_start + ticks

def shift(
self, delta: dt.timedelta | int | float, affect_monotonic: bool = False
) -> None:
if isinstance(delta, dt.timedelta):
total_seconds = delta.total_seconds()
elif isinstance(delta, (int, float)):
total_seconds = delta
else:
raise TypeError(f"Unsupported type for delta argument: {delta!r}")

self._destination_timestamp_ns += int(total_seconds * NANOSECONDS_PER_SECOND)
shift = int(total_seconds * NANOSECONDS_PER_SECOND)
self._destination_timestamp_ns += shift
if affect_monotonic:
self._monotonic_start += shift

def move_to(
self,
destination: DestinationType,
tick: bool | None = None,
affect_monotonic: bool = False,
) -> None:
prev_dest_time = self._destination_timestamp_ns
self._stop()
timestamp, self._destination_tzname = extract_timestamp_tzname(destination)
self._destination_timestamp_ns = int(timestamp * NANOSECONDS_PER_SECOND)
if affect_monotonic:
# XXX: might be negative but when affect_monotonic is used, all bets
# are off.
self._monotonic_start += self._destination_timestamp_ns - prev_dest_time
self._requested = False
self._start()
if tick is not None:
Expand Down Expand Up @@ -414,6 +439,20 @@ def time_ns() -> int:
return coordinates_stack[-1].time_ns()


def monotonic() -> float:
if not coordinates_stack:
result: float = _time_machine.original_monotonic()
return result
return coordinates_stack[-1].monotonic()


def monotonic_ns() -> int:
if not coordinates_stack:
result: int = _time_machine.original_monotonic_ns()
return result
return coordinates_stack[-1].monotonic_ns()


# pytest plugin

if HAVE_PYTEST: # pragma: no branch
Expand All @@ -430,6 +469,7 @@ def move_to(
self,
destination: DestinationType,
tick: bool | None = None,
affect_monotonic: bool = False,
) -> None:
if self.traveller is None:
if tick is None:
Expand All @@ -438,15 +478,19 @@ def move_to(
self.coordinates = self.traveller.start()
else:
assert self.coordinates is not None
self.coordinates.move_to(destination, tick=tick)
self.coordinates.move_to(
destination, tick=tick, affect_monotonic=affect_monotonic
)

def shift(self, delta: dt.timedelta | int | float) -> None:
def shift(
self, delta: dt.timedelta | int | float, affect_monotonic: bool = False
) -> None:
if self.traveller is None:
raise RuntimeError(
"Initialize time_machine with move_to() before using shift()."
)
assert self.coordinates is not None
self.coordinates.shift(delta=delta)
self.coordinates.shift(delta=delta, affect_monotonic=affect_monotonic)

def stop(self) -> None:
if self.traveller is not None:
Expand Down
67 changes: 60 additions & 7 deletions tests/test_time_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,22 +249,75 @@ def test_time_localtime_arg():
assert local_time.tm_mday == 1


def test_time_montonic():
def test_time_monotonic():
last_time = time.monotonic()

def get_check_monotonic() -> float:
nonlocal last_time
new_time = time.monotonic()
assert new_time >= last_time
last_time = new_time
return new_time

with time_machine.travel(EPOCH, tick=False) as t:
assert time.monotonic() == EPOCH
get_check_monotonic()
t.shift(1)
assert time.monotonic() == EPOCH + 1
get_check_monotonic()

# check with tick
with time_machine.travel(EPOCH, tick=True) as t:
get_check_monotonic()
get_check_monotonic()

with time_machine.travel(EPOCH, tick=False) as t:
start_time = get_check_monotonic()
t.shift(1, affect_monotonic=True)
assert get_check_monotonic() - start_time == 1.0

t.move_to(EPOCH_PLUS_ONE_YEAR, affect_monotonic=True)
assert (
get_check_monotonic() - start_time
== (EPOCH_PLUS_ONE_YEAR_DATETIME - EPOCH_DATETIME).total_seconds()
)

# XXX: get_check_monotonic_ns() would fail here as we get back in time after
# the time shifts


def test_time_monotonic_ns():
last_time = time.monotonic_ns()

def get_check_monotonic_ns() -> int:
nonlocal last_time
new_time = time.monotonic_ns()
assert new_time >= last_time
last_time = new_time
return new_time

with time_machine.travel(EPOCH, tick=False) as t:
assert time.monotonic_ns() == int(EPOCH * NANOSECONDS_PER_SECOND)
get_check_monotonic_ns()
t.shift(1)
assert (
time.monotonic_ns()
== int(EPOCH * NANOSECONDS_PER_SECOND) + NANOSECONDS_PER_SECOND
get_check_monotonic_ns()

# check with ticks
with time_machine.travel(EPOCH, tick=True) as t:
get_check_monotonic_ns()
get_check_monotonic_ns()

with time_machine.travel(EPOCH, tick=False) as t:
start_time = get_check_monotonic_ns()
t.shift(1, affect_monotonic=True)
assert get_check_monotonic_ns() - start_time == NANOSECONDS_PER_SECOND

t.move_to(EPOCH_PLUS_ONE_YEAR, affect_monotonic=True)
assert get_check_monotonic_ns() - start_time == (
(EPOCH_PLUS_ONE_YEAR_DATETIME - EPOCH_DATETIME).total_seconds()
* NANOSECONDS_PER_SECOND
)

# XXX: get_check_monotonic_ns() would fail here as we get back in time after
# the time shifts


def test_time_strftime_format():
with time_machine.travel(EPOCH):
Expand Down