Skip to content

Commit

Permalink
add patch_current_time
Browse files Browse the repository at this point in the history
  • Loading branch information
ariebovenberg committed Jul 14, 2024
1 parent bc99d48 commit 730faaf
Show file tree
Hide file tree
Showing 16 changed files with 266 additions and 50 deletions.
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ extend-ignore =
# let black handle line length
E501
per-file-ignores =
__init__.py: F401,F403
__init__.py: F401,F403,F405

6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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)
------------------

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "whenever"
version = "0.6.3"
version = "0.6.4"
authors = []
description = "Rust extension module for whenever"
edition = "2021"
Expand Down
2 changes: 2 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,5 @@ Miscellaneous

.. autoexception:: whenever.InvalidOffset
:show-inheritance:

.. autoclass:: whenever.patch_current_time
7 changes: 7 additions & 0 deletions docs/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,13 @@ DateDelta(P3M16D)

See the :ref:`API reference <date-and-time-api>` 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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
84 changes: 84 additions & 0 deletions pysrc/whenever/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -26,6 +29,9 @@
_KnowsInstant,
_KnowsInstantAndLocal,
_KnowsLocal,
_patch_time_frozen,
_patch_time_keep_ticking,
_unpatch_time,
_unpkl_date,
_unpkl_ddelta,
_unpkl_dtdelta,
Expand All @@ -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()
11 changes: 10 additions & 1 deletion pysrc/whenever/__init__.pyi
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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]: ...
37 changes: 32 additions & 5 deletions pysrc/whenever/_pywhenever.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
# - It saves some overhead
from __future__ import annotations

__version__ = "0.6.3"
__version__ = "0.6.4"

import enum
import re
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
32 changes: 8 additions & 24 deletions src/instant.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;

Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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()
}
Expand All @@ -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"),
Expand Down
Loading

0 comments on commit 730faaf

Please sign in to comment.