diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 36ab60f..3f575c6 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -55,6 +55,10 @@ jobs: with: toolchain: "1.79" + - name: Add win32 target + if: ${{ matrix.os == 'windows-latest' }} + run: rustup target add i686-pc-windows-msvc + - uses: actions/setup-python@v5 if: ${{ !(matrix.os == 'windows-latest') }} with: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 42a4294..eae830e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,9 +1,10 @@ 🚀 Changelog ============ -Development ------------ +0.6.4 (2024-07-??) +------------------ +- Add helper ``patch_current_time`` for patching current time in whenever (only) (#147) - Support patching the current time with `time-machine `_ (#147) - Remove undocumented ``year``/``month``/``day``/``offset`` properties from ``Instant`` - Reduce size of binary distributions diff --git a/benchmarks/comparison/README.md b/benchmarks/comparison/README.md index f2323a1..152958d 100644 --- a/benchmarks/comparison/README.md +++ b/benchmarks/comparison/README.md @@ -9,7 +9,9 @@ python benchmarks/comparison/run_pendulum.py python benchmarks/comparison/run_arrow.py ``` -Tip: make sure to build `whenever` in release mode before running the benchmarks! +Make sure that: +- `whenever` is built in release mode +- `time_machine` isn't installed. **Whenever** detects it and uses a slower code path if it is installed. ## Generating the graphs diff --git a/docs/api.rst b/docs/api.rst index 96d69de..99d9e85 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -143,3 +143,5 @@ Miscellaneous .. autoexception:: whenever.InvalidOffset :show-inheritance: + +.. autoclass:: whenever.patch_current_time diff --git a/docs/overview.rst b/docs/overview.rst index 24508e2..2a01904 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -808,17 +808,74 @@ See the :ref:`API reference ` for more details. Testing ------- -**Whenever** supports patching the current time for testing purposes -using the `time-machine `_ package. -See its documentation for more details. +Patching the current time +~~~~~~~~~~~~~~~~~~~~~~~~~ -Here's an example: +Sometimes you need to 'fake' the output of ``.now()`` functions, typically for testing. +**Whenever** supports various ways to do this, depending on your needs: + +1. With :class:`whenever.patch_current_time`. This patcher + only affects **whenever**, not the standard library or other libraries. + See its documentation for more details. +2. With the `time-machine `_ package. + Using ``time-machine`` *does* affect the standard library and other libraries, + which can lead to unintended side effects. + Note that ``time-machine`` doesn't support PyPy. + +.. note:: + + It's also possible to use the + `freezegun `_ library, + but it will *only work on the Pure-Python version* of **whenever**. + +.. tip:: + + Instead of relying on patching, consider using dependency injection + instead. This is less error-prone and more explicit. + + You can do this by adding ``now`` argument to your function, + like this: + + .. code-block:: python + + def my_function(foo: int, now: Callable[[], Instant] = Instant.now): + current_time = now() + # more code here... + + # to test it: + my_function(foo=5, now=lambda: Instant.from_utc(2023, 1, 1)) + + +Patching the system timezone +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For changing the system timezone in tests, +use the :func:`~time.tzset` function from the standard library. +Since **whenever** uses the standard library to operate with the system timezone, +``tzset`` will behave as expected from the documentation. +Do note that this function is not available on Windows. +This is a limitation of ``tzset`` itself. + +Below is an example of a testing helper that can be used with ``pytest``: .. code-block:: python - @time_machine.travel("1980-03-02 02:00 UTC") - def test_patch_time(): - assert Instant.now() == Instant.from_utc(1980, 3, 2, hour=2) + import os + import pytest + import sys + import time + from contextlib import contextmanager + from unittest.mock import patch + + @contextmanager + def system_tz_ams(): + if sys.platform == "win32": + pytest.skip("tzset is not available on Windows") + with patch.dict(os.environ, {"TZ": "Europe/Amsterdam"}): + time.tzset() + yield + + time.tzset() # don't forget to set the old timezone back .. _systemtime: diff --git a/pyproject.toml b/pyproject.toml index c8a6180..f0e1c10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ authors = [ {name = "Arie Bovenberg", email = "a.c.bovenberg@gmail.com"}, ] readme = "README.md" -version = "0.6.4rc0" +version = "0.6.4" description = "Modern datetime library for Python, written in Rust" requires-python = ">=3.9" classifiers = [ diff --git a/pysrc/whenever/__init__.py b/pysrc/whenever/__init__.py index 17604d6..6e0060b 100644 --- a/pysrc/whenever/__init__.py +++ b/pysrc/whenever/__init__.py @@ -1,6 +1,9 @@ try: # pragma: no cover from ._whenever import * from ._whenever import ( + _patch_time_frozen, + _patch_time_keep_ticking, + _unpatch_time, _unpkl_date, _unpkl_ddelta, _unpkl_dtdelta, @@ -26,6 +29,9 @@ _KnowsInstant, _KnowsInstantAndLocal, _KnowsLocal, + _patch_time_frozen, + _patch_time_keep_ticking, + _unpatch_time, _unpkl_date, _unpkl_ddelta, _unpkl_dtdelta, @@ -39,3 +45,82 @@ ) _EXTENSION_LOADED = False + + +from contextlib import contextmanager as _contextmanager +from dataclasses import dataclass as _dataclass +from typing import Iterator as _Iterator + + +@_dataclass +class _TimePatch: + _pin: "Instant | ZonedDateTime | OffsetDateTime | SystemDateTime" + _keep_ticking: bool + + def shift(self, *args, **kwargs): + if self._keep_ticking: + self._pin = new = (self._pin + (Instant.now() - self._pin)).add( + *args, **kwargs + ) + _patch_time_keep_ticking( + new if isinstance(new, Instant) else new.instant() + ) + else: + self._pin = new = self._pin.add(*args, **kwargs) + _patch_time_frozen( + new if isinstance(new, Instant) else new.instant() + ) + + +@_contextmanager +def patch_current_time( + dt: "Instant | ZonedDateTime | OffsetDateTime | SystemDateTime", + /, + *, + keep_ticking: bool, +) -> _Iterator[_TimePatch]: + """Patch the current time to a fixed value (for testing purposes). + Behaves as a context manager or decorator, with similar semantics to + ``unittest.mock.patch``. + + Important + --------- + + * This function should be used only for testing purposes. It is not + thread-safe or part of the stable API. + * This function only affects whenever's ``now`` functions. It does not + affect the standard library's time functions or any other libraries. + * It doesn't affect the system timezone. + If you need to patch the system timezone, set the ``TZ`` environment + variable in combination with ``time.tzset``. Be aware that this only + works on Unix-like systems. + + Example + ------- + + >>> from whenever import Instant, patch_current_time + >>> i = Instant.from_utc(1980, 3, 2, hour=2) + >>> with patch_current_time(i, keep_ticking=False) as p: + ... assert Instant.now() == i + ... p.shift(hours=4) + ... assert i.now() == i.add(hours=4) + ... + >>> assert Instant.now() != i + ... + >>> @patch_current_time(i, keep_ticking=True) + ... def test_thing(p): + ... assert (Instant.now() - i) < seconds(1) + ... p.shift(hours=8) + ... sleep(0.000001) + ... assert hours(8) < (Instant.now() - i) < hours(8.1) + """ + instant = dt if isinstance(dt, Instant) else dt.instant() + if keep_ticking: + _patch_time_keep_ticking(instant) + else: + _patch_time_frozen(instant) + + try: + yield _TimePatch(dt, keep_ticking) + finally: + _unpatch_time() diff --git a/pysrc/whenever/_pywhenever.py b/pysrc/whenever/_pywhenever.py index f7cc325..85f35ff 100644 --- a/pysrc/whenever/_pywhenever.py +++ b/pysrc/whenever/_pywhenever.py @@ -32,7 +32,7 @@ # - It saves some overhead from __future__ import annotations -__version__ = "0.6.4rc0" +__version__ = "0.6.4" import enum import re @@ -4693,3 +4693,32 @@ def nanoseconds(i: int, /) -> TimeDelta: final(_KnowsLocal) final(_KnowsInstantAndLocal) final(_BasicConversions) + + +_time_patch = None + + +def _patch_time_frozen(inst: Instant) -> None: + global _time_patch + global time_ns + + def time_ns() -> int: + return inst.timestamp_nanos() + + +def _patch_time_keep_ticking(inst: Instant) -> None: + global _time_patch + global time_ns + + _patched_at = time_ns() + _time_ns = time_ns + + def time_ns() -> int: + return inst.timestamp_nanos() + _time_ns() - _patched_at + + +def _unpatch_time() -> None: + global _time_patch + global time_ns + + from time import time_ns diff --git a/requirements/test.txt b/requirements/test.txt index 435fbe8..63453c5 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -2,7 +2,7 @@ pytest>=7,<9 pytest-cov>=4,<6 pytest-benchmark[histogram]>=4,<6 hypothesis>=6,<7 -time_machine>=2,<3 +time_machine>=2,<3; implementation_name == 'cpython' # FUTURE: remove these constraints once rdps-py supports python 3.13 referencing>=0.23,<0.24.0; python_version == '3.13' diff --git a/src/instant.rs b/src/instant.rs index f980414..dd63aad 100644 --- a/src/instant.rs +++ b/src/instant.rs @@ -39,7 +39,7 @@ pub(crate) const SINGLETONS: &[(&CStr, Instant); 2] = &[ ), ]; -const UNIX_EPOCH_INSTANT: i64 = 62_135_683_200; // 1970-01-01 in seconds after 0000-12-31 +pub(crate) const UNIX_EPOCH_INSTANT: i64 = 62_135_683_200; // 1970-01-01 in seconds after 0000-12-31 pub(crate) const MIN_INSTANT: i64 = 24 * 60 * 60; pub(crate) const MAX_INSTANT: i64 = 315_537_983_999; @@ -82,6 +82,10 @@ impl Instant { self.secs as i128 * 1_000_000_000 + self.nanos as i128 } + pub(crate) const fn whole_secs(&self) -> i64 { + self.secs + } + pub(crate) const fn subsec_nanos(&self) -> u32 { self.nanos } diff --git a/src/lib.rs b/src/lib.rs index 0214299..cb25cab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,7 +22,7 @@ use date::unpickle as _unpkl_date; use date_delta::unpickle as _unpkl_ddelta; use date_delta::{days, months, weeks, years}; use datetime_delta::unpickle as _unpkl_dtdelta; -use instant::unpickle as _unpkl_utc; +use instant::{unpickle as _unpkl_utc, UNIX_EPOCH_INSTANT}; use local_datetime::unpickle as _unpkl_local; use offset_datetime::unpickle as _unpkl_offset; use system_datetime::unpickle as _unpkl_system; @@ -108,9 +108,52 @@ static mut METHODS: &[PyMethodDef] = &[ "Create a new `TimeDelta` representing the given number of nanoseconds.", METH_O ), + method!(_patch_time_frozen, "", METH_O), + method!(_patch_time_keep_ticking, "", METH_O), + method!(_unpatch_time, ""), PyMethodDef::zeroed(), ]; +unsafe fn _patch_time_frozen(module: *mut PyObject, arg: *mut PyObject) -> PyReturn { + _patch_time(module, arg, true) +} + +unsafe fn _patch_time_keep_ticking(module: *mut PyObject, arg: *mut PyObject) -> PyReturn { + _patch_time(module, arg, false) +} + +unsafe fn _patch_time(module: *mut PyObject, arg: *mut PyObject, freeze: bool) -> PyReturn { + let state: &mut State = PyModule_GetState(module).cast::().as_mut().unwrap(); + if Py_TYPE(arg) != state.instant_type { + Err(type_err!("Expected an Instant"))? + } + let inst = instant::Instant::extract(arg); + let pin = ( + inst.whole_secs() + .checked_sub(UNIX_EPOCH_INSTANT as _) + .ok_or_type_err("Cannot set time before 1970")?, + inst.subsec_nanos(), + ); + state.time_patch = if freeze { + TimePatch::Frozen(pin) + } else { + TimePatch::KeepTicking { + pin: std::time::Duration::new(pin.0 as _, pin.1), + at: SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .ok() + .ok_or_type_err("System time before 1970")?, + } + }; + Py_None().as_result() +} + +unsafe fn _unpatch_time(module: *mut PyObject, _: *mut PyObject) -> PyReturn { + let state: &mut State = PyModule_GetState(module).cast::().as_mut().unwrap(); + state.time_patch = TimePatch::Unset; + Py_None().as_result() +} + #[allow(non_upper_case_globals)] pub const Py_mod_gil: c_int = 4; #[allow(non_upper_case_globals)] @@ -397,9 +440,9 @@ unsafe extern "C" fn module_exec(module: *mut PyObject) -> c_int { // Making time patcheable results in a performance hit. // Only enable it if the time_machine module is available. - state.time_patchable = match PyImport_ImportModule(c"time_machine".as_ptr()).as_result() { - Ok(t) => { - Py_DecRef(t); + state.time_machine_exists = match PyImport_ImportModule(c"time_machine".as_ptr()).as_result() { + Ok(m) => { + Py_DecRef(m); true } Err(_) => { @@ -408,6 +451,8 @@ unsafe extern "C" fn module_exec(module: *mut PyObject) -> c_int { } }; + state.time_patch = TimePatch::Unset; + 0 } @@ -622,7 +667,17 @@ struct State { str_offset: *mut PyObject, str_ignore_dst: *mut PyObject, - time_patchable: bool, + time_patch: TimePatch, + time_machine_exists: bool, +} + +enum TimePatch { + Unset, + Frozen((i64, u32)), + KeepTicking { + pin: std::time::Duration, + at: std::time::Duration, + }, } impl State { @@ -642,10 +697,24 @@ impl State { } unsafe fn time_ns(&self) -> PyResult<(i64, u32)> { - if self.time_patchable { - self.time_ns_py() - } else { - self.time_ns_rust() + match self.time_patch { + TimePatch::Unset => { + if self.time_machine_exists { + self.time_ns_py() + } else { + self.time_ns_rust() + } + } + TimePatch::Frozen(e) => Ok(e), + TimePatch::KeepTicking { pin, at } => { + let dur = pin + + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .ok() + .ok_or_py_err(PyExc_OSError, "System time out of range")? + - at; + Ok((dur.as_secs() as i64, dur.subsec_nanos())) + } } } diff --git a/tests/test_main.py b/tests/test_main.py index 264d576..08a56d9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,9 +1,16 @@ import sys +from time import sleep import pytest -import time_machine -from whenever import ImplicitlyIgnoringDST, Instant, InvalidOffset +from whenever import ( + ImplicitlyIgnoringDST, + Instant, + InvalidOffset, + hours, + patch_current_time, + seconds, +) @pytest.mark.skipif( @@ -27,6 +34,36 @@ def test_exceptions(): assert issubclass(InvalidOffset, ValueError) -@time_machine.travel("1980-03-02 02:00 UTC") +@pytest.mark.skipif( + sys.implementation.name == "pypy", + reason="time-machine doesn't support PyPy", +) +def test_time_machine(): + import time_machine + + with time_machine.travel("1980-03-02 02:00 UTC"): + assert Instant.now() == Instant.from_utc(1980, 3, 2, hour=2) + + def test_patch_time(): - assert Instant.now() == Instant.from_utc(1980, 3, 2, hour=2) + + i = Instant.from_utc(1980, 3, 2, hour=2) + + with patch_current_time(i, keep_ticking=False) as p: + assert Instant.now() == i + p.shift(hours=3) + p.shift(hours=1) + assert i.now() == i.add(hours=4) + + assert Instant.now() != i + + with patch_current_time(i, keep_ticking=True) as p: + assert (Instant.now() - i) < seconds(1) + p.shift(hours=2) + sleep(0.000001) + assert hours(2) < (Instant.now() - i) < hours(2.1) + p.shift(hours=6) + sleep(0.000001) + assert hours(8) < (Instant.now() - i) < hours(8.1) + + assert Instant.now() - i > hours(40_000)