Skip to content

Commit

Permalink
reinstate patch_current_time
Browse files Browse the repository at this point in the history
  • Loading branch information
ariebovenberg committed Jul 26, 2024
1 parent 51ef1e7 commit d7cb57b
Show file tree
Hide file tree
Showing 12 changed files with 317 additions and 27 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/adamchainz/time-machine>`_ (#147)
- Remove undocumented ``year``/``month``/``day``/``offset`` properties from ``Instant``
- Reduce size of binary distributions
Expand Down
4 changes: 3 additions & 1 deletion benchmarks/comparison/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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
71 changes: 64 additions & 7 deletions docs/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -808,17 +808,74 @@ See the :ref:`API reference <date-and-time-api>` for more details.
Testing
-------

**Whenever** supports patching the current time for testing purposes
using the `time-machine <https://github.com/adamchainz/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 <https://github.com/adamchainz/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 <https://github.com/spulec/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:

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.4rc0"
version = "0.6.4"
description = "Modern datetime library for Python, written in Rust"
requires-python = ">=3.9"
classifiers = [
Expand Down
85 changes: 85 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,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()
31 changes: 30 additions & 1 deletion 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.4rc0"
__version__ = "0.6.4"

import enum
import re
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion requirements/test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 5 additions & 1 deletion src/instant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
}
Expand Down
Loading

0 comments on commit d7cb57b

Please sign in to comment.