From 730faafbd1a78fc2d81f1851936c4c2a5eb4dc58 Mon Sep 17 00:00:00 2001 From: Arie Bovenberg Date: Sun, 14 Jul 2024 22:00:00 +0200 Subject: [PATCH] add patch_current_time --- .flake8 | 2 +- CHANGELOG.rst | 6 +++ Cargo.lock | 2 +- Cargo.toml | 2 +- docs/api.rst | 2 + docs/overview.rst | 7 +++ pyproject.toml | 2 +- pysrc/whenever/__init__.py | 84 +++++++++++++++++++++++++++++++++++ pysrc/whenever/__init__.pyi | 11 ++++- pysrc/whenever/_pywhenever.py | 37 ++++++++++++--- src/instant.rs | 32 ++++--------- src/lib.rs | 74 +++++++++++++++++++++++++++++- src/offset_datetime.rs | 7 ++- src/system_datetime.rs | 9 ++-- src/zoned_datetime.rs | 10 ++--- tests/test_main.py | 29 +++++++++++- 16 files changed, 266 insertions(+), 50 deletions(-) diff --git a/.flake8 b/.flake8 index 4a95e796..41871418 100644 --- a/.flake8 +++ b/.flake8 @@ -4,5 +4,5 @@ extend-ignore = # let black handle line length E501 per-file-ignores = - __init__.py: F401,F403 + __init__.py: F401,F403,F405 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 56619941..ccc47734 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,12 @@ 🚀 Changelog ============ +0.6.4 (2024-07-??) +------------------ + +- Add ``patch_current_time`` helper for patching the current time in tests +- Remove undocumented ``year``/``month``/``day`` properties from ``Instant`` + 0.6.3 (2024-07-13) ------------------ diff --git a/Cargo.lock b/Cargo.lock index fbafad8c..c7aee897 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,7 +165,7 @@ checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" [[package]] name = "whenever" -version = "0.6.3" +version = "0.6.4" dependencies = [ "pyo3", "pyo3-build-config", diff --git a/Cargo.toml b/Cargo.toml index 07ca0663..b8690da5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whenever" -version = "0.6.3" +version = "0.6.4" authors = [] description = "Rust extension module for whenever" edition = "2021" diff --git a/docs/api.rst b/docs/api.rst index 96d69de7..99d9e853 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 a55dd840..cb8ef8d8 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -806,6 +806,13 @@ DateDelta(P3M16D) See the :ref:`API reference ` for more details. +Testing +------- + +For manipulating the current time in tests, +there is a helper :func:`~whenever.patch_current_time`. +See its documentation for more details. + .. _systemtime: The system timezone diff --git a/pyproject.toml b/pyproject.toml index 4bfbbe2f..50c175f2 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.3" +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 17604d6d..69796323 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,81 @@ ) _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 only affects whenever's ``now`` functions. It does not + affect the standard library's time functions or any other libraries. + * This function should be used only for testing purposes. It is not + thread-safe or part of the stable API. + * 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/__init__.pyi b/pysrc/whenever/__init__.pyi index 170f11fd..f93787ec 100644 --- a/pysrc/whenever/__init__.pyi +++ b/pysrc/whenever/__init__.pyi @@ -1,12 +1,13 @@ import enum from abc import ABC +from contextlib import contextmanager from datetime import ( date as _date, datetime as _datetime, time as _time, timedelta as _timedelta, ) -from typing import ClassVar, Literal, TypeVar, final, overload +from typing import Any, ClassVar, Iterator, Literal, TypeVar, final, overload __all__ = [ "Date", @@ -856,3 +857,11 @@ def seconds(i: float) -> TimeDelta: ... def milliseconds(i: float) -> TimeDelta: ... def microseconds(i: float) -> TimeDelta: ... def nanoseconds(i: int) -> TimeDelta: ... + +class _TimePatch: + def shift(self, *args: Any, **kwargs: Any) -> None: ... + +@contextmanager +def patch_current_time( + i: _KnowsInstant, /, *, keep_ticking: bool +) -> Iterator[_TimePatch]: ... diff --git a/pysrc/whenever/_pywhenever.py b/pysrc/whenever/_pywhenever.py index 5cf4ff3a..85f35ff8 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.3" +__version__ = "0.6.4" import enum import re @@ -2558,9 +2558,7 @@ def from_utc( @classmethod def now(cls) -> Instant: secs, nanos = divmod(time_ns(), 1_000_000_000) - return cls._from_py_unchecked( - _datetime.fromtimestamp(secs, _UTC), nanos - ) + return cls._from_py_unchecked(_fromtimestamp(secs, _UTC), nanos) @classmethod def from_timestamp(cls, i: int, /) -> Instant: @@ -3726,7 +3724,7 @@ def __init__( def now(cls) -> SystemDateTime: secs, nanos = divmod(time_ns(), 1_000_000_000) return cls._from_py_unchecked( - _datetime.fromtimestamp(secs, _UTC).astimezone(None), nanos + _fromtimestamp(secs, _UTC).astimezone(None), nanos ) format_common_iso = OffsetDateTime.format_common_iso @@ -4695,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/src/instant.rs b/src/instant.rs index 3f803dfe..49609601 100644 --- a/src/instant.rs +++ b/src/instant.rs @@ -1,13 +1,12 @@ use core::ffi::{c_int, c_long, c_void, CStr}; use core::{mem, ptr::null_mut as NULL}; use pyo3_ffi::*; -use std::time::SystemTime; use crate::common::*; use crate::datetime_delta::handle_exact_unit; use crate::time_delta::{MAX_HOURS, MAX_MICROSECONDS, MAX_MILLISECONDS, MAX_MINUTES, MAX_SECS}; use crate::{ - date::{self, Date}, + date::Date, local_datetime::DateTime, offset_datetime::{self, OffsetDateTime}, time::Time, @@ -40,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; @@ -57,10 +56,6 @@ impl Instant { } } - pub(crate) const fn date(&self) -> date::Date { - date::Date::from_ord_unchecked((self.secs / 86400) as _) - } - pub(crate) const fn from_datetime( date: Date, Time { @@ -87,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 } @@ -527,8 +526,8 @@ unsafe fn from_py_datetime(cls: *mut PyObject, dt: *mut PyObject) -> PyReturn { } unsafe fn now(cls: *mut PyObject, _: *mut PyObject) -> PyReturn { - match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { - Ok(dur) => Instant { + match State::for_type(cls.cast()).epoch() { + Some(dur) => Instant { // FUTURE: decide on overflow check (only possible in ridiculous cases) secs: i64::try_from(dur.as_secs()).unwrap() + UNIX_EPOCH_INSTANT, nanos: dur.subsec_nanos(), @@ -882,18 +881,6 @@ static mut METHODS: &[PyMethodDef] = &[ PyMethodDef::zeroed(), ]; -unsafe fn get_year(slf: *mut PyObject) -> PyReturn { - Instant::extract(slf).date().year.to_py() -} - -unsafe fn get_month(slf: *mut PyObject) -> PyReturn { - Instant::extract(slf).date().month.to_py() -} - -unsafe fn get_day(slf: *mut PyObject) -> PyReturn { - Instant::extract(slf).date().day.to_py() -} - unsafe fn get_hour(slf: *mut PyObject) -> PyReturn { (Instant::extract(slf).secs % 86400 / 3600).to_py() } @@ -911,9 +898,6 @@ unsafe fn get_nanos(slf: *mut PyObject) -> PyReturn { } static mut GETSETTERS: &[PyGetSetDef] = &[ - getter!(get_year named "year", "The year component"), - getter!(get_month named "month", "The month component"), - getter!(get_day named "day", "The day component"), getter!(get_hour named "hour", "The hour component"), getter!(get_minute named "minute", "The minute component"), getter!(get_secs named "second", "The second component"), diff --git a/src/lib.rs b/src/lib.rs index 741d6468..24883a86 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ use core::ffi::{c_int, c_void, CStr}; use core::ptr::null_mut as NULL; use core::{mem, ptr}; use pyo3_ffi::*; +use std::time::SystemTime; use crate::common::*; @@ -21,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; @@ -107,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 = std::time::Duration::new( + (inst.whole_secs() as u64) + .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, + 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)] @@ -415,6 +459,8 @@ unsafe extern "C" fn module_exec(module: *mut PyObject) -> c_int { state.exc_implicitly_ignoring_dst = new_exc(module, c"whenever.ImplicitlyIgnoringDST", PyExc_TypeError); + state.time_patch = TimePatch::Unset; + 0 } @@ -558,6 +604,15 @@ unsafe extern "C" fn module_clear(module: *mut PyObject) -> c_int { 0 } +enum TimePatch { + Unset, + Frozen(std::time::Duration), + KeepTicking { + pin: std::time::Duration, + at: std::time::Duration, + }, +} + #[repr(C)] struct State { // types @@ -626,6 +681,8 @@ struct State { str_disambiguate: *mut PyObject, str_offset: *mut PyObject, str_ignore_dst: *mut PyObject, + + time_patch: TimePatch, } impl State { @@ -643,6 +700,21 @@ impl State { .as_ref() .unwrap() } + + unsafe fn epoch(&self) -> Option { + match self.time_patch { + TimePatch::Unset => SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .ok(), + TimePatch::Frozen(e) => Some(e), + TimePatch::KeepTicking { pin, at } => Some( + pin + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .ok()? + - at, + ), + } + } } #[allow(clippy::missing_safety_doc)] diff --git a/src/offset_datetime.rs b/src/offset_datetime.rs index 7d6bf989..449f891c 100644 --- a/src/offset_datetime.rs +++ b/src/offset_datetime.rs @@ -2,7 +2,6 @@ use core::ffi::{c_int, c_long, c_void, CStr}; use core::{mem, ptr::null_mut as NULL}; use pyo3_ffi::*; use std::fmt::{self, Display, Formatter}; -use std::time::SystemTime; use crate::common::*; use crate::datetime_delta::set_units_from_kwargs; @@ -647,9 +646,9 @@ unsafe fn now( kwargs: &mut KwargIter, ) -> PyReturn { let state = State::for_type(cls); - let nanos = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map_err(|_| py_err!(PyExc_OSError, "SystemTime before UNIX EPOCH"))? + let nanos = state + .epoch() + .ok_or_py_err(PyExc_OSError, "SystemTime before UNIX EPOCH")? .as_nanos(); let &[offset] = args else { diff --git a/src/system_datetime.rs b/src/system_datetime.rs index 00a431db..2f8e968e 100644 --- a/src/system_datetime.rs +++ b/src/system_datetime.rs @@ -1,7 +1,6 @@ use core::ffi::{c_int, c_long, c_void, CStr}; use core::{mem, ptr::null_mut as NULL}; use pyo3_ffi::*; -use std::time::SystemTime; use crate::common::*; use crate::{ @@ -528,9 +527,9 @@ unsafe fn replace( } unsafe fn now(cls: *mut PyObject, _: *mut PyObject) -> PyReturn { - let &State { py_api, .. } = State::for_type(cls.cast()); - let (timestamp, nanos) = match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { - Ok(dur) => (dur.as_secs(), dur.subsec_nanos()), + let state = State::for_type(cls.cast()); + let (timestamp, nanos) = match state.epoch() { + Some(dur) => (dur.as_secs(), dur.subsec_nanos()), _ => Err(py_err!(PyExc_OSError, "SystemTime before UNIX EPOCH"))?, }; // Technically conversion to i128 can overflow, but only if system @@ -540,7 +539,7 @@ unsafe fn now(cls: *mut PyObject, _: *mut PyObject) -> PyReturn { .ok() .and_then(Instant::from_timestamp) .ok_or_value_err("timestamp is out of range")? - .to_py_ignore_nanos(py_api)?; + .to_py_ignore_nanos(state.py_api)?; defer_decref!(utc_dt); let dt = methcall0(utc_dt, "astimezone")?; defer_decref!(dt); diff --git a/src/zoned_datetime.rs b/src/zoned_datetime.rs index 19369327..d485cb9a 100644 --- a/src/zoned_datetime.rs +++ b/src/zoned_datetime.rs @@ -2,7 +2,6 @@ use core::ffi::{c_int, c_long, c_void, CStr}; use core::{mem, ptr::null_mut as NULL}; use pyo3_ffi::*; use std::fmt::{self, Display, Formatter}; -use std::time::SystemTime; use crate::common::*; use crate::datetime_delta::set_units_from_kwargs; @@ -750,6 +749,7 @@ unsafe fn replace( } unsafe fn now(cls: *mut PyObject, tz: *mut PyObject) -> PyReturn { + let state = State::for_type(cls.cast()); let &State { py_api: &PyDateTime_CAPI { @@ -759,12 +759,12 @@ unsafe fn now(cls: *mut PyObject, tz: *mut PyObject) -> PyReturn { }, zoneinfo_type, .. - } = State::for_type(cls.cast()); + } = state; let zoneinfo = call1(zoneinfo_type, tz)? as *mut PyObject; defer_decref!(zoneinfo); - let (timestamp, subsec) = match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { - Ok(dur) => (dur.as_secs() as f64, dur.subsec_nanos()), - _ => Err(py_err!(PyExc_OSError, "SystemTime before UNIX EPOCH"))?, + let (timestamp, subsec) = match state.epoch() { + Some(dur) => (dur.as_secs() as f64, dur.subsec_nanos()), + None => Err(py_err!(PyExc_OSError, "SystemTime before UNIX EPOCH"))?, }; // OPTIMIZE: faster way without fromtimestamp? let dt = DateTime_FromTimestamp( diff --git a/tests/test_main.py b/tests/test_main.py index d34c6dc1..be6b8e38 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,8 +1,9 @@ import sys +from time import sleep import pytest -from whenever import ImplicitlyIgnoringDST, InvalidOffset +from whenever import ImplicitlyIgnoringDST, InvalidOffset, hours, seconds @pytest.mark.skipif( @@ -24,3 +25,29 @@ def test_multiple_interpreters(): def test_exceptions(): assert issubclass(ImplicitlyIgnoringDST, TypeError) assert issubclass(InvalidOffset, ValueError) + + +def test_patch_time(): + + 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=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)