From fa9cc85521ff0cbb9a480885c026beb5bd0002a9 Mon Sep 17 00:00:00 2001 From: Remy Noel Date: Mon, 20 Nov 2023 17:26:07 +0100 Subject: [PATCH 1/5] Handle calls while module is unloaded. --- src/_time_machine.c | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/_time_machine.c b/src/_time_machine.c index d55ac54b..1be79f62 100644 --- a/src/_time_machine.c +++ b/src/_time_machine.c @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); @@ -282,6 +294,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); @@ -316,6 +330,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); @@ -350,6 +366,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); From fa9d2eeef8c7c42b1fca697fd979eeaefa2caaa4 Mon Sep 17 00:00:00 2001 From: Remy Noel Date: Tue, 21 Nov 2023 15:10:59 +0100 Subject: [PATCH 2/5] Properly restore monotonic(_ns) after traveling --- src/_time_machine.c | 40 ++++++++++++++++++++++++++++++++++-- src/time_machine/__init__.py | 14 +++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/_time_machine.c b/src/_time_machine.c index 1be79f62..a8ff4c55 100644 --- a/src/_time_machine.c +++ b/src/_time_machine.c @@ -250,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) { @@ -270,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) { @@ -457,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"); diff --git a/src/time_machine/__init__.py b/src/time_machine/__init__.py index 4fa2142e..8d05e221 100644 --- a/src/time_machine/__init__.py +++ b/src/time_machine/__init__.py @@ -414,6 +414,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].time() + + +def monotonic_ns() -> int: + if not coordinates_stack: + result: int = _time_machine.original_monotonic_ns() + return result + return coordinates_stack[-1].time_ns() + + # pytest plugin if HAVE_PYTEST: # pragma: no branch From 2e2e77b8b6579a712004dc7f00ee0f54a64340e6 Mon Sep 17 00:00:00 2001 From: Remy Noel Date: Tue, 21 Nov 2023 18:53:24 +0100 Subject: [PATCH 3/5] monotonic: Have an (almost) proper monotonicity. Still not monotonic if we hit the leap second twice after the start, but at least asyncio is not broken by it. --- src/time_machine/__init__.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/time_machine/__init__.py b/src/time_machine/__init__.py index 8d05e221..78a689c1 100644 --- a/src/time_machine/__init__.py +++ b/src/time_machine/__init__.py @@ -152,23 +152,37 @@ 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 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) -> None: if isinstance(delta, dt.timedelta): @@ -418,14 +432,14 @@ def monotonic() -> float: if not coordinates_stack: result: float = _time_machine.original_monotonic() return result - return coordinates_stack[-1].time() + 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].time_ns() + return coordinates_stack[-1].monotonic_ns() # pytest plugin From d8e361427036bc1dc7ac6b98d92c18f9883db86e Mon Sep 17 00:00:00 2001 From: Remy Noel Date: Wed, 22 Nov 2023 10:59:34 +0100 Subject: [PATCH 4/5] Allow to break monotonicity explicitely and fix tests. --- README.rst | 12 +++++++ src/time_machine/__init__.py | 23 ++++++++++--- tests/test_time_machine.py | 67 +++++++++++++++++++++++++++++++----- 3 files changed, 89 insertions(+), 13 deletions(-) diff --git a/README.rst b/README.rst index 016e810f..aa6b7e92 100644 --- a/README.rst +++ b/README.rst @@ -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)`` ^^^^^^^^^^^^^^^^ @@ -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 ------------- diff --git a/src/time_machine/__init__.py b/src/time_machine/__init__.py index 78a689c1..885cbc3e 100644 --- a/src/time_machine/__init__.py +++ b/src/time_machine/__init__.py @@ -184,7 +184,8 @@ def monotonic_ns(self) -> int: # prevent having discontinuity between outside and inside monotonic. return self._monotonic_start + ticks - def shift(self, delta: dt.timedelta | int | float) -> None: + 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)): @@ -192,16 +193,25 @@ def shift(self, delta: dt.timedelta | int | float) -> None: 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: @@ -458,6 +468,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: @@ -466,15 +477,17 @@ 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: diff --git a/tests/test_time_machine.py b/tests/test_time_machine.py index 163ec2b5..e59af250 100644 --- a/tests/test_time_machine.py +++ b/tests/test_time_machine.py @@ -249,21 +249,72 @@ 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. + + 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(): From d00e9d8d332797abd3dd0f1c8136e3502204ce74 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 22 Nov 2023 10:40:44 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/time_machine/__init__.py | 15 +++++++++------ tests/test_time_machine.py | 14 ++++++++------ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/time_machine/__init__.py b/src/time_machine/__init__.py index 885cbc3e..4c28e103 100644 --- a/src/time_machine/__init__.py +++ b/src/time_machine/__init__.py @@ -184,8 +184,9 @@ def monotonic_ns(self) -> int: # 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: + 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)): @@ -477,11 +478,13 @@ def move_to( self.coordinates = self.traveller.start() else: assert self.coordinates is not None - self.coordinates.move_to(destination, tick=tick, - affect_monotonic=affect_monotonic) + self.coordinates.move_to( + destination, tick=tick, affect_monotonic=affect_monotonic + ) - def shift(self, delta: dt.timedelta | int | float, - affect_monotonic: bool =False) -> 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()." diff --git a/tests/test_time_machine.py b/tests/test_time_machine.py index e59af250..1333fc0c 100644 --- a/tests/test_time_machine.py +++ b/tests/test_time_machine.py @@ -272,17 +272,18 @@ def get_check_monotonic() -> float: 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. + 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() + 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() @@ -310,8 +311,9 @@ def get_check_monotonic_ns() -> int: 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) + (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