Skip to content

Commit 90d3eeb

Browse files
committed
Add time travel
1 parent 2b44bbd commit 90d3eeb

File tree

8 files changed

+300
-48
lines changed

8 files changed

+300
-48
lines changed

pendulum/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from pendulum.helpers import week_starts_at
4040
from pendulum.parser import parse
4141
from pendulum.period import Period
42+
from pendulum.testing.traveller import Traveller
4243
from pendulum.time import Time
4344
from pendulum.tz import UTC
4445
from pendulum.tz import local_timezone
@@ -297,6 +298,15 @@ def period(start: DateTime, end: DateTime, absolute: bool = False) -> Period:
297298
return Period(start, end, absolute=absolute)
298299

299300

301+
# Testing
302+
303+
_traveller = Traveller(DateTime)
304+
305+
freeze = _traveller.freeze
306+
travel = _traveller.travel
307+
travel_to = _traveller.travel_to
308+
travel_back = _traveller.travel_back
309+
300310
__all__ = [
301311
"__version__",
302312
"DAYS_PER_WEEK",
@@ -324,6 +334,7 @@ def period(start: DateTime, end: DateTime, absolute: bool = False) -> Period:
324334
"datetime",
325335
"duration",
326336
"format_diff",
337+
"freeze",
327338
"from_format",
328339
"from_timestamp",
329340
"get_locale",
@@ -352,6 +363,9 @@ def period(start: DateTime, end: DateTime, absolute: bool = False) -> Period:
352363
"timezones",
353364
"today",
354365
"tomorrow",
366+
"travel",
367+
"travel_back",
368+
"travel_to",
355369
"FixedTimezone",
356370
"Timezone",
357371
"yesterday",

pendulum/testing/__init__.py

Whitespace-only changes.

pendulum/testing/traveller.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
from typing import cast
5+
6+
from pendulum.datetime import DateTime
7+
from pendulum.utils._compat import PYPY
8+
9+
if TYPE_CHECKING:
10+
from types import TracebackType
11+
12+
13+
class BaseTraveller:
14+
def __init__(self, datetime_class: type[DateTime] = DateTime) -> None:
15+
self._datetime_class: type[DateTime] = datetime_class
16+
17+
def freeze(self: BaseTraveller) -> BaseTraveller:
18+
raise NotImplementedError()
19+
20+
def travel_back(self: BaseTraveller) -> BaseTraveller:
21+
raise NotImplementedError()
22+
23+
def travel(
24+
self,
25+
years: int = 0,
26+
months: int = 0,
27+
weeks: int = 0,
28+
days: int = 0,
29+
hours: int = 0,
30+
minutes: int = 0,
31+
seconds: int = 0,
32+
microseconds: int = 0,
33+
) -> BaseTraveller:
34+
raise NotImplementedError()
35+
36+
def travel_to(self, dt: DateTime) -> BaseTraveller:
37+
raise NotImplementedError()
38+
39+
40+
if not PYPY:
41+
import time_machine
42+
43+
class Traveller(BaseTraveller):
44+
def __init__(self, datetime_class: type[DateTime] = DateTime) -> None:
45+
super().__init__(datetime_class)
46+
47+
self._started: bool = False
48+
self._traveller: time_machine.travel | None = None
49+
self._coordinates: time_machine.Coordinates | None = None
50+
51+
def freeze(self) -> Traveller:
52+
if self._started:
53+
cast(time_machine.Coordinates, self._coordinates).move_to(
54+
self._datetime_class.now(), tick=False
55+
)
56+
else:
57+
self._start(freeze=True)
58+
59+
return self
60+
61+
def travel_back(self) -> Traveller:
62+
if not self._started:
63+
return self
64+
65+
cast(time_machine.travel, self._traveller).stop()
66+
self._coordinates = None
67+
self._traveller = None
68+
self._started = False
69+
70+
return self
71+
72+
def travel(
73+
self,
74+
years: int = 0,
75+
months: int = 0,
76+
weeks: int = 0,
77+
days: int = 0,
78+
hours: int = 0,
79+
minutes: int = 0,
80+
seconds: int = 0,
81+
microseconds: int = 0,
82+
*,
83+
freeze: bool = False,
84+
) -> Traveller:
85+
self._start(freeze=freeze)
86+
87+
cast(time_machine.Coordinates, self._coordinates).move_to(
88+
self._datetime_class.now().add(
89+
years=years,
90+
months=months,
91+
weeks=weeks,
92+
days=days,
93+
hours=hours,
94+
minutes=minutes,
95+
seconds=seconds,
96+
microseconds=microseconds,
97+
)
98+
)
99+
100+
return self
101+
102+
def travel_to(self, dt: DateTime, *, freeze: bool = False) -> Traveller:
103+
self._start(freeze=freeze)
104+
105+
cast(time_machine.Coordinates, self._coordinates).move_to(dt)
106+
107+
return self
108+
109+
def _start(self, freeze: bool = False) -> None:
110+
if self._started:
111+
return
112+
113+
if not self._traveller:
114+
self._traveller = time_machine.travel(
115+
self._datetime_class.now(), tick=not freeze
116+
)
117+
118+
self._coordinates = self._traveller.start()
119+
120+
self._started = True
121+
122+
def __enter__(self) -> Traveller:
123+
self._start()
124+
125+
return self
126+
127+
def __exit__(
128+
self,
129+
exc_type: type[BaseException] | None,
130+
exc_val: BaseException | None,
131+
exc_tb: TracebackType,
132+
) -> None:
133+
self.travel_back()
134+
135+
else:
136+
137+
class Traveller(BaseTraveller): # type: ignore[no-redef]
138+
139+
...

poetry.lock

Lines changed: 18 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ include = [
2828
python = "^3.7"
2929
python-dateutil = "^2.6"
3030
"backports.zoneinfo" = { version = "^0.2.1", python = ">=3.7,<3.9" }
31+
time-machine = { version = "^2.6.0", markers = "implementation_name != 'pypy'" }
3132
tzdata = ">=2020.1"
3233
importlib-resources = { version = "^5.9.0", python = ">=3.7,<3.9" }
3334

tests/datetime/test_construct.py

Lines changed: 43 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,21 @@
66

77
import pytest
88
import pytz
9-
import time_machine
109

1110
from dateutil import tz
1211

1312
import pendulum
1413

1514
from pendulum import DateTime
1615
from pendulum.tz import timezone
16+
from pendulum.utils._compat import PYPY
1717
from tests.conftest import assert_datetime
1818

19+
if not PYPY:
20+
import time_machine
21+
else:
22+
time_machine = None
23+
1924

2025
@pytest.fixture(autouse=True)
2126
def _setup():
@@ -105,44 +110,43 @@ def test_now():
105110
assert now.hour != in_paris.hour
106111

107112

108-
@time_machine.travel("2016-03-27 00:30:00Z", tick=False)
109-
def test_now_dst_off():
110-
utc = pendulum.now("UTC")
111-
in_paris = pendulum.now("Europe/Paris")
112-
in_paris_from_utc = utc.in_tz("Europe/Paris")
113-
assert in_paris.hour == 1
114-
assert not in_paris.is_dst()
115-
assert in_paris.isoformat() == in_paris_from_utc.isoformat()
116-
117-
118-
@time_machine.travel("2016-03-27 01:30:00Z", tick=False)
119-
def test_now_dst_transitioning_on():
120-
utc = pendulum.now("UTC")
121-
in_paris = pendulum.now("Europe/Paris")
122-
in_paris_from_utc = utc.in_tz("Europe/Paris")
123-
assert in_paris.hour == 3
124-
assert in_paris.is_dst()
125-
assert in_paris.isoformat() == in_paris_from_utc.isoformat()
126-
127-
128-
@time_machine.travel("2016-10-30 00:30:00Z", tick=False)
129-
def test_now_dst_on():
130-
utc = pendulum.now("UTC")
131-
in_paris = pendulum.now("Europe/Paris")
132-
in_paris_from_utc = utc.in_tz("Europe/Paris")
133-
assert in_paris.hour == 2
134-
assert in_paris.is_dst()
135-
assert in_paris.isoformat() == in_paris_from_utc.isoformat()
136-
137-
138-
@time_machine.travel("2016-10-30 01:30:00Z", tick=False)
139-
def test_now_dst_transitioning_off():
140-
utc = pendulum.now("UTC")
141-
in_paris = pendulum.now("Europe/Paris")
142-
in_paris_from_utc = utc.in_tz("Europe/Paris")
143-
assert in_paris.hour == 2
144-
assert not in_paris.is_dst()
145-
assert in_paris.isoformat() == in_paris_from_utc.isoformat()
113+
if time_machine:
114+
115+
@time_machine.travel("2016-03-27 00:30:00Z", tick=False)
116+
def test_now_dst_off():
117+
utc = pendulum.now("UTC")
118+
in_paris = pendulum.now("Europe/Paris")
119+
in_paris_from_utc = utc.in_tz("Europe/Paris")
120+
assert in_paris.hour == 1
121+
assert not in_paris.is_dst()
122+
assert in_paris.isoformat() == in_paris_from_utc.isoformat()
123+
124+
@time_machine.travel("2016-03-27 01:30:00Z", tick=False)
125+
def test_now_dst_transitioning_on():
126+
utc = pendulum.now("UTC")
127+
in_paris = pendulum.now("Europe/Paris")
128+
in_paris_from_utc = utc.in_tz("Europe/Paris")
129+
assert in_paris.hour == 3
130+
assert in_paris.is_dst()
131+
assert in_paris.isoformat() == in_paris_from_utc.isoformat()
132+
133+
@time_machine.travel("2016-10-30 00:30:00Z", tick=False)
134+
def test_now_dst_on():
135+
utc = pendulum.now("UTC")
136+
in_paris = pendulum.now("Europe/Paris")
137+
in_paris_from_utc = utc.in_tz("Europe/Paris")
138+
assert in_paris.hour == 2
139+
assert in_paris.is_dst()
140+
assert in_paris.isoformat() == in_paris_from_utc.isoformat()
141+
142+
@time_machine.travel("2016-10-30 01:30:00Z", tick=False)
143+
def test_now_dst_transitioning_off():
144+
utc = pendulum.now("UTC")
145+
in_paris = pendulum.now("Europe/Paris")
146+
in_paris_from_utc = utc.in_tz("Europe/Paris")
147+
assert in_paris.hour == 2
148+
assert not in_paris.is_dst()
149+
assert in_paris.isoformat() == in_paris_from_utc.isoformat()
146150

147151

148152
def test_now_with_fixed_offset():

tests/testing/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)