xtr-clock 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xtr_clock/__init__.py +61 -0
- xtr_clock/clock.py +198 -0
- xtr_clock/clock_aware_mixin.py +58 -0
- xtr_clock/clock_interface.py +80 -0
- xtr_clock/date_point.py +250 -0
- xtr_clock/exception/__init__.py +17 -0
- xtr_clock/exception/clock_error.py +14 -0
- xtr_clock/exception/invalid_modifier_error.py +32 -0
- xtr_clock/exception/invalid_timezone_error.py +28 -0
- xtr_clock/mock_clock.py +133 -0
- xtr_clock/modifier.py +311 -0
- xtr_clock/monotonic_clock.py +97 -0
- xtr_clock/now.py +45 -0
- xtr_clock/py.typed +0 -0
- xtr_clock/pytest_plugin.py +48 -0
- xtr_clock/system_clock.py +75 -0
- xtr_clock/testing.py +61 -0
- xtr_clock/timezone.py +104 -0
- xtr_clock-1.0.0.dist-info/METADATA +445 -0
- xtr_clock-1.0.0.dist-info/RECORD +22 -0
- xtr_clock-1.0.0.dist-info/WHEEL +4 -0
- xtr_clock-1.0.0.dist-info/licenses/LICENSE +21 -0
xtr_clock/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""A clock an application can be handed, instead of the one it is standing on.
|
|
2
|
+
|
|
3
|
+
Reading the time is an input like any other, and code that reaches for the
|
|
4
|
+
operating system to get it cannot be told what time it is. That is what makes
|
|
5
|
+
a test about expiry, backoff or a billing period slow, flaky, or quietly
|
|
6
|
+
wrong every March.
|
|
7
|
+
|
|
8
|
+
Depend on :class:`~xtr_clock.clock_interface.ClockInterface` and the choice
|
|
9
|
+
becomes a constructor argument:
|
|
10
|
+
:class:`~xtr_clock.system_clock.SystemClock` in production,
|
|
11
|
+
:class:`~xtr_clock.monotonic_clock.MonotonicClock` where a duration must be
|
|
12
|
+
trusted, :class:`~xtr_clock.mock_clock.MockClock` in a test. What comes back
|
|
13
|
+
is always a :class:`~xtr_clock.date_point.DatePoint`: a
|
|
14
|
+
:class:`~datetime.datetime` that is always timezone-aware and stays itself
|
|
15
|
+
through every operation.
|
|
16
|
+
|
|
17
|
+
Code that cannot be handed anything asks :meth:`~xtr_clock.clock.Clock.get`,
|
|
18
|
+
and a test answers with :func:`~xtr_clock.testing.mock_time`.
|
|
19
|
+
|
|
20
|
+
Nothing here depends on anything outside the standard library.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
24
|
+
|
|
25
|
+
from .clock import Clock
|
|
26
|
+
from .clock_aware_mixin import ClockAwareMixin
|
|
27
|
+
from .clock_interface import ClockInterface, SupportsNow
|
|
28
|
+
from .date_point import DatePoint
|
|
29
|
+
from .exception import ClockError, InvalidModifierError, InvalidTimezoneError
|
|
30
|
+
from .mock_clock import MockClock
|
|
31
|
+
from .modifier import apply_modifier
|
|
32
|
+
from .monotonic_clock import MonotonicClock
|
|
33
|
+
from .now import now
|
|
34
|
+
from .system_clock import SystemClock
|
|
35
|
+
from .timezone import local_timezone, resolve_timezone
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
__version__ = version("xtr-clock")
|
|
39
|
+
except PackageNotFoundError: # pragma: no cover
|
|
40
|
+
# Running from a source tree or a vendored copy, with no installed
|
|
41
|
+
# metadata to read. Having no version is better than refusing to import.
|
|
42
|
+
__version__ = "0+unknown"
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"Clock",
|
|
46
|
+
"ClockAwareMixin",
|
|
47
|
+
"ClockError",
|
|
48
|
+
"ClockInterface",
|
|
49
|
+
"DatePoint",
|
|
50
|
+
"InvalidModifierError",
|
|
51
|
+
"InvalidTimezoneError",
|
|
52
|
+
"MockClock",
|
|
53
|
+
"MonotonicClock",
|
|
54
|
+
"SupportsNow",
|
|
55
|
+
"SystemClock",
|
|
56
|
+
"__version__",
|
|
57
|
+
"apply_modifier",
|
|
58
|
+
"local_timezone",
|
|
59
|
+
"now",
|
|
60
|
+
"resolve_timezone",
|
|
61
|
+
]
|
xtr_clock/clock.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""The clock in force, and the adapter that makes a foreign one fit.
|
|
2
|
+
|
|
3
|
+
Injecting a clock is the honest way to make a class testable, and most of
|
|
4
|
+
this library exists to support it. But some code cannot be handed anything —
|
|
5
|
+
a module-level helper, a validator called by a framework, a function three
|
|
6
|
+
libraries deep. That code asks :meth:`Clock.get`, and a test answers by
|
|
7
|
+
installing another clock with :meth:`Clock.using`.
|
|
8
|
+
|
|
9
|
+
The clock in force is held in a :class:`~contextvars.ContextVar`, so a
|
|
10
|
+
scope that installs one does not leak into a concurrent task that did not,
|
|
11
|
+
and two tests running side by side cannot see each other's. Installing also
|
|
12
|
+
updates a process-wide fallback, so a thread started later — which begins
|
|
13
|
+
with a fresh context — still sees the clock the application chose.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from contextvars import ContextVar
|
|
20
|
+
from typing import TYPE_CHECKING, ClassVar, Self, final
|
|
21
|
+
|
|
22
|
+
from .clock_interface import ClockInterface, SupportsNow
|
|
23
|
+
from .date_point import DatePoint
|
|
24
|
+
from .system_clock import SystemClock
|
|
25
|
+
from .timezone import resolve_timezone
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Generator
|
|
29
|
+
from datetime import tzinfo
|
|
30
|
+
|
|
31
|
+
__all__ = ["Clock"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@final
|
|
35
|
+
class Clock:
|
|
36
|
+
"""The clock in force, and a clock that reads it.
|
|
37
|
+
|
|
38
|
+
As a class it is the registry: :meth:`get` answers with whatever clock
|
|
39
|
+
is in force, :meth:`set` installs one for good, :meth:`using` installs
|
|
40
|
+
one for a scope.
|
|
41
|
+
|
|
42
|
+
As an instance it is a clock in its own right, and a useful one twice
|
|
43
|
+
over. Built around an object that can only say :meth:`~SupportsNow.now`,
|
|
44
|
+
it fills in the rest of the contract. Built around nothing, it forwards
|
|
45
|
+
to whatever is in force at the moment each question is asked — so a
|
|
46
|
+
class handed one at startup reads a clock a test installs later.
|
|
47
|
+
|
|
48
|
+
Prefer taking a :class:`~xtr_clock.clock_interface.ClockInterface` as a
|
|
49
|
+
constructor argument. Reach for this only where nothing can be handed
|
|
50
|
+
in.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_current: ClassVar[ContextVar[ClockInterface | None]] = ContextVar(
|
|
54
|
+
"xtr_clock_current",
|
|
55
|
+
default=None,
|
|
56
|
+
)
|
|
57
|
+
_fallback: ClassVar[ClockInterface | None] = None
|
|
58
|
+
|
|
59
|
+
__slots__ = ("_clock", "_timezone")
|
|
60
|
+
|
|
61
|
+
_clock: SupportsNow | None
|
|
62
|
+
_timezone: tzinfo | None
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
clock: SupportsNow | None = None,
|
|
67
|
+
timezone: str | tzinfo | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Wrap ``clock``, or the clock in force when none is given.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
clock: The clock to read. ``None`` reads whichever clock is in
|
|
73
|
+
force each time a question is asked, rather than the one in
|
|
74
|
+
force now.
|
|
75
|
+
timezone: A zone to pin every reading to. ``None`` reports
|
|
76
|
+
instants in whatever zone the wrapped clock used.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
InvalidTimezoneError: When ``timezone`` names no known zone.
|
|
80
|
+
"""
|
|
81
|
+
self._clock = clock
|
|
82
|
+
self._timezone = resolve_timezone(timezone) if timezone is not None else None
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def get(cls) -> ClockInterface:
|
|
86
|
+
"""Return the clock in force.
|
|
87
|
+
|
|
88
|
+
A scope opened by :meth:`using` wins; otherwise whatever :meth:`set`
|
|
89
|
+
installed; otherwise a :class:`~xtr_clock.system_clock.SystemClock`,
|
|
90
|
+
built once and kept.
|
|
91
|
+
"""
|
|
92
|
+
if (scoped := Clock._current.get()) is not None:
|
|
93
|
+
return scoped
|
|
94
|
+
|
|
95
|
+
if Clock._fallback is None:
|
|
96
|
+
Clock._fallback = SystemClock()
|
|
97
|
+
|
|
98
|
+
return Clock._fallback
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def set(cls, clock: SupportsNow) -> None:
|
|
102
|
+
"""Install ``clock`` as the clock in force, until something replaces it.
|
|
103
|
+
|
|
104
|
+
An object that only answers :meth:`~SupportsNow.now` is wrapped so
|
|
105
|
+
it satisfies the whole contract.
|
|
106
|
+
|
|
107
|
+
Prefer :meth:`using` in a test: this one has no end, and a test that
|
|
108
|
+
forgets to undo it hands the next one a clock it never asked for.
|
|
109
|
+
"""
|
|
110
|
+
Clock._fallback = _adapt(clock)
|
|
111
|
+
_ = Clock._current.set(Clock._fallback)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
@contextmanager
|
|
115
|
+
def using(cls, clock: SupportsNow) -> Generator[ClockInterface, None, None]:
|
|
116
|
+
"""Install ``clock`` for the duration of a ``with`` block.
|
|
117
|
+
|
|
118
|
+
The previous clock comes back on the way out, including when the
|
|
119
|
+
block raises.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
clock: The clock to install. An object that only answers
|
|
123
|
+
:meth:`~SupportsNow.now` is wrapped.
|
|
124
|
+
|
|
125
|
+
Yields:
|
|
126
|
+
The clock as installed, which is ``clock`` itself unless it had
|
|
127
|
+
to be wrapped.
|
|
128
|
+
"""
|
|
129
|
+
resolved = _adapt(clock)
|
|
130
|
+
previous = Clock._fallback
|
|
131
|
+
token = Clock._current.set(resolved)
|
|
132
|
+
Clock._fallback = resolved
|
|
133
|
+
try:
|
|
134
|
+
yield resolved
|
|
135
|
+
finally:
|
|
136
|
+
Clock._current.reset(token)
|
|
137
|
+
Clock._fallback = previous
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def timezone(self) -> tzinfo | None:
|
|
141
|
+
"""The zone readings are pinned to, or ``None`` to follow the clock read."""
|
|
142
|
+
return self._timezone
|
|
143
|
+
|
|
144
|
+
def now(self) -> DatePoint:
|
|
145
|
+
"""Return the current instant, as the clock being read tells it."""
|
|
146
|
+
moment = DatePoint.from_datetime(self._target().now())
|
|
147
|
+
|
|
148
|
+
return moment.with_timezone(self._timezone) if self._timezone is not None else moment
|
|
149
|
+
|
|
150
|
+
def sleep(self, seconds: float) -> None:
|
|
151
|
+
"""Block for ``seconds``, the way the clock being read does.
|
|
152
|
+
|
|
153
|
+
A clock that only answers :meth:`~SupportsNow.now` cannot say how to
|
|
154
|
+
wait, so real time is what passes.
|
|
155
|
+
"""
|
|
156
|
+
target = self._target()
|
|
157
|
+
if isinstance(target, ClockInterface):
|
|
158
|
+
target.sleep(seconds)
|
|
159
|
+
else:
|
|
160
|
+
SystemClock().sleep(seconds)
|
|
161
|
+
|
|
162
|
+
async def sleep_async(self, seconds: float) -> None:
|
|
163
|
+
"""Wait ``seconds`` the way the clock being read does, without blocking."""
|
|
164
|
+
target = self._target()
|
|
165
|
+
if isinstance(target, ClockInterface):
|
|
166
|
+
await target.sleep_async(seconds)
|
|
167
|
+
else:
|
|
168
|
+
await SystemClock().sleep_async(seconds)
|
|
169
|
+
|
|
170
|
+
def with_timezone(self, timezone: str | tzinfo) -> Self:
|
|
171
|
+
"""Return the same clock pinning every reading to ``timezone``.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
InvalidTimezoneError: When ``timezone`` names no known zone.
|
|
175
|
+
"""
|
|
176
|
+
return type(self)(self._clock, timezone)
|
|
177
|
+
|
|
178
|
+
def _target(self) -> SupportsNow:
|
|
179
|
+
"""Return the clock to read: the wrapped one, or the one in force."""
|
|
180
|
+
if self._clock is not None:
|
|
181
|
+
return self._clock
|
|
182
|
+
|
|
183
|
+
current = Clock.get()
|
|
184
|
+
|
|
185
|
+
# An empty Clock installed as the clock in force would otherwise ask
|
|
186
|
+
# itself what time it is, and never come back.
|
|
187
|
+
return SystemClock() if current is self else current
|
|
188
|
+
|
|
189
|
+
def __repr__(self) -> str:
|
|
190
|
+
"""Return a reading that names what is wrapped and any pinned zone."""
|
|
191
|
+
pinned = f", {self._timezone!s}" if self._timezone is not None else ""
|
|
192
|
+
|
|
193
|
+
return f"{type(self).__name__}({self._clock!r}{pinned})"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _adapt(clock: SupportsNow) -> ClockInterface:
|
|
197
|
+
"""Return ``clock`` as a full contract, wrapping it only if it is not one."""
|
|
198
|
+
return clock if isinstance(clock, ClockInterface) else Clock(clock)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""A mixin for a class that needs to know what time it is."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
6
|
+
|
|
7
|
+
from .clock import Clock
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .clock_interface import ClockInterface
|
|
11
|
+
from .date_point import DatePoint
|
|
12
|
+
|
|
13
|
+
__all__ = ["ClockAwareMixin"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClockAwareMixin:
|
|
17
|
+
"""Gives a class a clock it can be handed, and a default until it is.
|
|
18
|
+
|
|
19
|
+
Taking a :class:`~xtr_clock.clock_interface.ClockInterface` as a
|
|
20
|
+
constructor argument is still the clearest thing to do. This is for the
|
|
21
|
+
case where you cannot: a class whose constructor is already spoken for
|
|
22
|
+
by a framework, or a long-lived one you are making testable without
|
|
23
|
+
rewriting every place that builds it.
|
|
24
|
+
|
|
25
|
+
It defines no ``__init__``, so it composes with any class — including a
|
|
26
|
+
dataclass — and reads the clock in force until :meth:`set_clock` says
|
|
27
|
+
otherwise.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
class TokenIssuer(ClockAwareMixin):
|
|
31
|
+
def issue(self) -> Token:
|
|
32
|
+
return Token(expires_at=self.now().modify("+1 hour"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
issuer = TokenIssuer()
|
|
36
|
+
issuer.set_clock(MockClock("2024-04-09 12:00:00"))
|
|
37
|
+
```
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
__slots__: ClassVar[tuple[str, ...]] = ("_clock",)
|
|
41
|
+
|
|
42
|
+
_clock: ClockInterface
|
|
43
|
+
|
|
44
|
+
def set_clock(self, clock: ClockInterface) -> None:
|
|
45
|
+
"""Read time from ``clock`` from now on."""
|
|
46
|
+
self._clock = clock
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def clock(self) -> ClockInterface:
|
|
50
|
+
"""The clock this object reads: the one it was given, or the one in force."""
|
|
51
|
+
try:
|
|
52
|
+
return self._clock
|
|
53
|
+
except AttributeError:
|
|
54
|
+
return Clock.get()
|
|
55
|
+
|
|
56
|
+
def now(self) -> DatePoint:
|
|
57
|
+
"""Return the current instant, according to this object's clock."""
|
|
58
|
+
return self.clock.now()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""What a clock answers to.
|
|
2
|
+
|
|
3
|
+
Two contracts, because two different things depend on a clock.
|
|
4
|
+
|
|
5
|
+
:class:`SupportsNow` is the smaller one — a single ``now()`` — and exists so
|
|
6
|
+
a clock from somewhere else can be adopted without implementing anything.
|
|
7
|
+
:class:`ClockInterface` is what this library's own clocks satisfy and what
|
|
8
|
+
application code should ask for, because reading the time is rarely the only
|
|
9
|
+
thing a time-sensitive class needs to do with it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING, Protocol, Self, runtime_checkable
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from datetime import datetime, tzinfo
|
|
18
|
+
|
|
19
|
+
from .date_point import DatePoint
|
|
20
|
+
|
|
21
|
+
__all__ = ["ClockInterface", "SupportsNow"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class SupportsNow(Protocol):
|
|
26
|
+
"""Anything that can say what time it is.
|
|
27
|
+
|
|
28
|
+
Deliberately structural and deliberately minimal: a clock written
|
|
29
|
+
against some other library, or a two-line stub in a test, satisfies it
|
|
30
|
+
without importing anything from here. Hand one to
|
|
31
|
+
:class:`~xtr_clock.clock.Clock` to get the full contract back.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def now(self) -> datetime:
|
|
35
|
+
"""Return the current instant."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@runtime_checkable
|
|
40
|
+
class ClockInterface(SupportsNow, Protocol):
|
|
41
|
+
"""The contract time-sensitive code should depend on.
|
|
42
|
+
|
|
43
|
+
Depend on this rather than on a concrete clock and the choice between
|
|
44
|
+
reading the operating system, counting monotonically, and standing still
|
|
45
|
+
for a test becomes a constructor argument.
|
|
46
|
+
|
|
47
|
+
Both a blocking and an awaitable wait live here rather than on separate
|
|
48
|
+
contracts, because a clock that can only do one of them is a clock half
|
|
49
|
+
the application cannot use — and the two cannot share a name, since one
|
|
50
|
+
returns and the other is awaited.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def now(self) -> DatePoint:
|
|
54
|
+
"""Return the current instant, always timezone-aware."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def sleep(self, seconds: float) -> None:
|
|
58
|
+
"""Block for ``seconds``, which a frozen clock does instantly.
|
|
59
|
+
|
|
60
|
+
A value of zero or less returns immediately.
|
|
61
|
+
"""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
async def sleep_async(self, seconds: float) -> None:
|
|
65
|
+
"""Wait ``seconds`` without blocking the event loop.
|
|
66
|
+
|
|
67
|
+
A value of zero or less returns immediately.
|
|
68
|
+
"""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
def with_timezone(self, timezone: str | tzinfo) -> Self:
|
|
72
|
+
"""Return the same clock reading its instants in another zone.
|
|
73
|
+
|
|
74
|
+
This one is left alone, so a class that pins a zone for itself does
|
|
75
|
+
not change the time anybody else reads.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
InvalidTimezoneError: When ``timezone`` names no known zone.
|
|
79
|
+
"""
|
|
80
|
+
...
|
xtr_clock/date_point.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""An instant that stays aware, and stays a ``DatePoint``.
|
|
2
|
+
|
|
3
|
+
``DatePoint`` is a :class:`~datetime.datetime`, so everything already
|
|
4
|
+
written against one keeps working: comparison, subtraction, formatting,
|
|
5
|
+
``strftime``, ``isoformat``, sorting, a database driver. It adds two
|
|
6
|
+
guarantees the standard library leaves to the caller.
|
|
7
|
+
|
|
8
|
+
**It is always timezone-aware.** A naive reading is taken as local time, the
|
|
9
|
+
same way the standard library reads one when it converts. There is therefore
|
|
10
|
+
no such thing as a ``DatePoint`` whose offset is unknown, which is what makes
|
|
11
|
+
comparing two of them always meaningful.
|
|
12
|
+
|
|
13
|
+
**It stays a ``DatePoint``.** ``replace``, ``astimezone``, adding a
|
|
14
|
+
:class:`~datetime.timedelta`, ``fromisoformat``, ``strptime`` — each answers
|
|
15
|
+
with a ``DatePoint``, so the type survives a chain of operations and the
|
|
16
|
+
guarantee above survives with it.
|
|
17
|
+
|
|
18
|
+
``now()`` reads the clock in force rather than the operating system, so a
|
|
19
|
+
frozen clock in a test reaches code that never heard of this library's
|
|
20
|
+
interfaces.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from typing import TYPE_CHECKING, ClassVar, Self, TypeVar
|
|
27
|
+
|
|
28
|
+
from .modifier import apply_modifier
|
|
29
|
+
from .timezone import local_timezone, resolve_timezone
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from datetime import tzinfo
|
|
34
|
+
from typing import SupportsIndex
|
|
35
|
+
|
|
36
|
+
__all__ = ["DatePoint"]
|
|
37
|
+
|
|
38
|
+
_DatePointT = TypeVar("_DatePointT", bound="DatePoint")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DatePoint(datetime):
|
|
42
|
+
"""A timezone-aware instant that survives every operation as itself."""
|
|
43
|
+
|
|
44
|
+
__slots__: ClassVar[tuple[str, ...]] = ()
|
|
45
|
+
|
|
46
|
+
def __new__(
|
|
47
|
+
cls,
|
|
48
|
+
year: int,
|
|
49
|
+
month: int,
|
|
50
|
+
day: int,
|
|
51
|
+
hour: int = 0,
|
|
52
|
+
minute: int = 0,
|
|
53
|
+
second: int = 0,
|
|
54
|
+
microsecond: int = 0,
|
|
55
|
+
tzinfo: tzinfo | None = None,
|
|
56
|
+
*,
|
|
57
|
+
fold: int = 0,
|
|
58
|
+
) -> Self:
|
|
59
|
+
"""Build an instant, attaching the local zone when none is given.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
year: The year.
|
|
63
|
+
month: The month, 1 to 12.
|
|
64
|
+
day: The day of the month.
|
|
65
|
+
hour: The hour, 0 to 23.
|
|
66
|
+
minute: The minute.
|
|
67
|
+
second: The second.
|
|
68
|
+
microsecond: The microsecond.
|
|
69
|
+
tzinfo: The zone these fields are read in. ``None`` means local,
|
|
70
|
+
which is the reading the standard library gives a naive
|
|
71
|
+
datetime whenever it has to pick one.
|
|
72
|
+
fold: Which of two identical wall clocks is meant, when a
|
|
73
|
+
daylight saving change produced the same one twice.
|
|
74
|
+
"""
|
|
75
|
+
return super().__new__(
|
|
76
|
+
cls,
|
|
77
|
+
year,
|
|
78
|
+
month,
|
|
79
|
+
day,
|
|
80
|
+
hour,
|
|
81
|
+
minute,
|
|
82
|
+
second,
|
|
83
|
+
microsecond,
|
|
84
|
+
tzinfo if tzinfo is not None else local_timezone(),
|
|
85
|
+
fold=fold,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def now(cls, tz: tzinfo | None = None) -> Self:
|
|
90
|
+
"""Return the current instant, read from the clock in force.
|
|
91
|
+
|
|
92
|
+
This is the one method that departs from
|
|
93
|
+
:meth:`datetime.datetime.now`: it asks
|
|
94
|
+
:meth:`~xtr_clock.clock.Clock.get` rather than the operating system,
|
|
95
|
+
so freezing the clock in a test reaches code that calls
|
|
96
|
+
``DatePoint.now()`` without having been handed a clock.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
tz: The zone to read the instant in. ``None`` keeps whichever
|
|
100
|
+
zone the clock answers in.
|
|
101
|
+
"""
|
|
102
|
+
from .clock import Clock # noqa: PLC0415 — deferred: Clock answers in DatePoint
|
|
103
|
+
|
|
104
|
+
moment = Clock.get().now()
|
|
105
|
+
|
|
106
|
+
return cls.from_datetime(moment if tz is None else moment.astimezone(tz))
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def parse(
|
|
110
|
+
cls,
|
|
111
|
+
spec: str,
|
|
112
|
+
timezone: str | tzinfo | None = None,
|
|
113
|
+
*,
|
|
114
|
+
reference: datetime | None = None,
|
|
115
|
+
) -> Self:
|
|
116
|
+
"""Return the instant ``spec`` describes.
|
|
117
|
+
|
|
118
|
+
``spec`` is read by the grammar documented on
|
|
119
|
+
:mod:`xtr_clock.modifier`: an offset like ``'+1 day'``, a keyword
|
|
120
|
+
like ``'tomorrow'``, an ISO-8601 datetime, a timezone name, or any
|
|
121
|
+
of them with a zone trailing.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
spec: The modifier to read.
|
|
125
|
+
timezone: The zone to read the reference in before applying
|
|
126
|
+
``spec``. A zone named inside ``spec`` wins over this one.
|
|
127
|
+
reference: The instant ``spec`` is relative to. Defaults to
|
|
128
|
+
whatever the clock in force says now is.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The instant described.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
InvalidModifierError: When the grammar cannot read ``spec``.
|
|
135
|
+
InvalidTimezoneError: When ``timezone`` names no known zone.
|
|
136
|
+
"""
|
|
137
|
+
if reference is None:
|
|
138
|
+
from .clock import Clock # noqa: PLC0415 — deferred: Clock answers in DatePoint
|
|
139
|
+
|
|
140
|
+
reference = Clock.get().now()
|
|
141
|
+
|
|
142
|
+
base = cls.from_datetime(reference)
|
|
143
|
+
if timezone is not None:
|
|
144
|
+
base = base.with_timezone(timezone)
|
|
145
|
+
|
|
146
|
+
return cls.from_datetime(apply_modifier(base, spec))
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def from_datetime(cls, value: datetime) -> Self:
|
|
150
|
+
"""Return ``value`` as a ``DatePoint``.
|
|
151
|
+
|
|
152
|
+
A ``DatePoint`` is returned unchanged, so adopting one that already
|
|
153
|
+
is costs nothing. A naive datetime is read as local time.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
value: The datetime to adopt.
|
|
157
|
+
"""
|
|
158
|
+
if isinstance(value, cls):
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
return cls(
|
|
162
|
+
value.year,
|
|
163
|
+
value.month,
|
|
164
|
+
value.day,
|
|
165
|
+
value.hour,
|
|
166
|
+
value.minute,
|
|
167
|
+
value.second,
|
|
168
|
+
value.microsecond,
|
|
169
|
+
value.tzinfo,
|
|
170
|
+
fold=value.fold,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def modify(self, modifier: str) -> Self:
|
|
174
|
+
"""Return the instant ``modifier`` describes, relative to this one.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
modifier: A modifier the grammar on :mod:`xtr_clock.modifier`
|
|
178
|
+
can read.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
A new instant; this one is unchanged.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
InvalidModifierError: When the grammar cannot read ``modifier``.
|
|
185
|
+
"""
|
|
186
|
+
return type(self).from_datetime(apply_modifier(self, modifier))
|
|
187
|
+
|
|
188
|
+
def with_timezone(self, timezone: str | tzinfo) -> Self:
|
|
189
|
+
"""Return this same instant, read in another zone.
|
|
190
|
+
|
|
191
|
+
The moment does not move — only the wall clock showing it does.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
timezone: A name, an offset, or a resolved zone.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
InvalidTimezoneError: When ``timezone`` names no known zone.
|
|
198
|
+
"""
|
|
199
|
+
return self.astimezone(resolve_timezone(timezone))
|
|
200
|
+
|
|
201
|
+
def __reduce_ex__(
|
|
202
|
+
self, protocol: SupportsIndex
|
|
203
|
+
) -> tuple[Callable[..., Self], tuple[object, ...]]:
|
|
204
|
+
"""Pickle and copy through :meth:`__reduce__`, which pins the fields.
|
|
205
|
+
|
|
206
|
+
The inherited implementation answers pickle directly and never
|
|
207
|
+
consults ``__reduce__``, so overriding only that one would leave both
|
|
208
|
+
pickling and copying on the packed path this class does not read.
|
|
209
|
+
"""
|
|
210
|
+
return self.__reduce__()
|
|
211
|
+
|
|
212
|
+
def __reduce__(self) -> tuple[Callable[..., Self], tuple[object, ...]]:
|
|
213
|
+
"""Pickle by field rather than by packed state.
|
|
214
|
+
|
|
215
|
+
The inherited form hands the constructor a packed byte string this
|
|
216
|
+
class does not read. Rebuilding from the fields it does read keeps
|
|
217
|
+
unpickling on the same path as every other way of making an instant,
|
|
218
|
+
so a restored one carries the same guarantees.
|
|
219
|
+
"""
|
|
220
|
+
return (
|
|
221
|
+
_rebuild,
|
|
222
|
+
(
|
|
223
|
+
type(self),
|
|
224
|
+
self.year,
|
|
225
|
+
self.month,
|
|
226
|
+
self.day,
|
|
227
|
+
self.hour,
|
|
228
|
+
self.minute,
|
|
229
|
+
self.second,
|
|
230
|
+
self.microsecond,
|
|
231
|
+
self.tzinfo,
|
|
232
|
+
self.fold,
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _rebuild(
|
|
238
|
+
cls: type[_DatePointT],
|
|
239
|
+
year: int,
|
|
240
|
+
month: int,
|
|
241
|
+
day: int,
|
|
242
|
+
hour: int,
|
|
243
|
+
minute: int,
|
|
244
|
+
second: int,
|
|
245
|
+
microsecond: int,
|
|
246
|
+
tzinfo: tzinfo | None,
|
|
247
|
+
fold: int,
|
|
248
|
+
) -> _DatePointT:
|
|
249
|
+
"""Rebuild a pickled instant. Named at module level so pickle can find it."""
|
|
250
|
+
return cls(year, month, day, hour, minute, second, microsecond, tzinfo, fold=fold)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Every error this library raises.
|
|
2
|
+
|
|
3
|
+
All of them derive from :class:`ClockError`, so one ``except`` catches
|
|
4
|
+
anything a clock can go wrong with, and a narrower one handles a single
|
|
5
|
+
cause. Each carries the data a caller needs as typed attributes rather than
|
|
6
|
+
forcing a message to be parsed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .clock_error import ClockError
|
|
10
|
+
from .invalid_modifier_error import InvalidModifierError
|
|
11
|
+
from .invalid_timezone_error import InvalidTimezoneError
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ClockError",
|
|
15
|
+
"InvalidModifierError",
|
|
16
|
+
"InvalidTimezoneError",
|
|
17
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The root every error in this library derives from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["ClockError"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ClockError(Exception):
|
|
9
|
+
"""Base class for every error raised by this library.
|
|
10
|
+
|
|
11
|
+
Catch this to handle anything reading a clock can go wrong with; catch a
|
|
12
|
+
subclass to handle one cause. Every subclass carries the data a caller
|
|
13
|
+
needs as typed attributes and composes its own message from them.
|
|
14
|
+
"""
|