xtr-clock 1.0.0__tar.gz
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-1.0.0/LICENSE +21 -0
- xtr_clock-1.0.0/PKG-INFO +445 -0
- xtr_clock-1.0.0/README.md +423 -0
- xtr_clock-1.0.0/pyproject.toml +162 -0
- xtr_clock-1.0.0/pyproject.toml.orig +172 -0
- xtr_clock-1.0.0/src/xtr_clock/__init__.py +61 -0
- xtr_clock-1.0.0/src/xtr_clock/clock.py +198 -0
- xtr_clock-1.0.0/src/xtr_clock/clock_aware_mixin.py +58 -0
- xtr_clock-1.0.0/src/xtr_clock/clock_interface.py +80 -0
- xtr_clock-1.0.0/src/xtr_clock/date_point.py +250 -0
- xtr_clock-1.0.0/src/xtr_clock/exception/__init__.py +17 -0
- xtr_clock-1.0.0/src/xtr_clock/exception/clock_error.py +14 -0
- xtr_clock-1.0.0/src/xtr_clock/exception/invalid_modifier_error.py +32 -0
- xtr_clock-1.0.0/src/xtr_clock/exception/invalid_timezone_error.py +28 -0
- xtr_clock-1.0.0/src/xtr_clock/mock_clock.py +133 -0
- xtr_clock-1.0.0/src/xtr_clock/modifier.py +311 -0
- xtr_clock-1.0.0/src/xtr_clock/monotonic_clock.py +97 -0
- xtr_clock-1.0.0/src/xtr_clock/now.py +45 -0
- xtr_clock-1.0.0/src/xtr_clock/py.typed +0 -0
- xtr_clock-1.0.0/src/xtr_clock/pytest_plugin.py +48 -0
- xtr_clock-1.0.0/src/xtr_clock/system_clock.py +75 -0
- xtr_clock-1.0.0/src/xtr_clock/testing.py +61 -0
- xtr_clock-1.0.0/src/xtr_clock/timezone.py +104 -0
xtr_clock-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
xtr_clock-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-clock
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Decouples an application from the system clock: an injectable clock, a timezone-aware instant, and a frozen clock for tests.
|
|
5
|
+
Keywords: clock,time,datetime,timezone,testing,freeze-time,monotonic
|
|
6
|
+
Author: Razvan Ceana
|
|
7
|
+
Author-email: Razvan Ceana <razvan@ceana.ro>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: tzdata>=2024.1 ; extra == 'tzdata'
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Provides-Extra: tzdata
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
<div align="center">
|
|
24
|
+
|
|
25
|
+
# xtr-clock
|
|
26
|
+
|
|
27
|
+
**A clock an application can be handed, instead of the one it is standing on.**
|
|
28
|
+
|
|
29
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
30
|
+
<img alt="core dependencies: 0" src="https://img.shields.io/badge/core%20deps-0-3FB950">
|
|
31
|
+
<img alt="coverage 100%" src="https://img.shields.io/badge/coverage-100%25-3FB950">
|
|
32
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
33
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
34
|
+
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Why?
|
|
40
|
+
|
|
41
|
+
Reading the time is an input like any other. Code that reaches for the operating system to get
|
|
42
|
+
it is code you cannot tell what time it is â so a test about a token expiring in an hour either
|
|
43
|
+
waits an hour, or reaches for a library that rewrites the interpreter underneath it.
|
|
44
|
+
|
|
45
|
+
Take a **clock** as a constructor argument and the choice becomes configuration: the system
|
|
46
|
+
clock in production, a frozen one in a test. What comes back is a **`DatePoint`** â a
|
|
47
|
+
`datetime`, so nothing downstream has to change.
|
|
48
|
+
|
|
49
|
+
- ðŠķ **No dependencies.** Not one. `zoneinfo`, `contextvars` and `datetime` are already there.
|
|
50
|
+
- ð **A `DatePoint` is a `datetime`.** It drops into an ORM column, a serializer, a comparison,
|
|
51
|
+
an existing signature. Adopting this library changes no type you already have.
|
|
52
|
+
- ð§ **Always timezone-aware.** There is no such thing here as an instant whose offset is
|
|
53
|
+
unknown, which is what makes comparing two of them always mean something.
|
|
54
|
+
- âąïļ **Frozen time costs nothing.** `clock.sleep(86400)` returns immediately and the clock is a
|
|
55
|
+
day later.
|
|
56
|
+
- ð **Blocking or awaiting.** `sleep()` and `sleep_async()` on the same clock, so one contract
|
|
57
|
+
serves a worker and a web handler.
|
|
58
|
+
- ð§Đ **Protocol-based.** Every collaborator is a constructor argument, so a DI container can own
|
|
59
|
+
the graph â and a clock from another library satisfies `SupportsNow` as it is.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
expires_at = self._clock.now().modify("+1 hour")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Whether that is the real hour or an instant one in a test is a constructor argument.
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
uv add xtr-clock # everything
|
|
71
|
+
uv add "xtr-clock[tzdata]" # + a timezone database, where the system has none
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
| Extra | Brings | For |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| *(none)* | â | The whole library |
|
|
77
|
+
| `tzdata` | `tzdata` | Windows, and slim containers that ship no zone database |
|
|
78
|
+
|
|
79
|
+
Requires Python 3.11+.
|
|
80
|
+
|
|
81
|
+
## Quick start
|
|
82
|
+
|
|
83
|
+
A time-sensitive class asks for a clock and nothing else:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from dataclasses import dataclass
|
|
87
|
+
|
|
88
|
+
from xtr_clock import ClockInterface, DatePoint
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class TokenIssuer:
|
|
93
|
+
clock: ClockInterface
|
|
94
|
+
|
|
95
|
+
def issue(self) -> DatePoint:
|
|
96
|
+
return self.clock.now().modify("+1 hour")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
In production it gets the real one:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from xtr_clock import SystemClock
|
|
103
|
+
|
|
104
|
+
issuer = TokenIssuer(SystemClock())
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
In a test it gets one that stands still, and the test is about the hour rather than spent
|
|
108
|
+
waiting for it:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from xtr_clock import MockClock
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_a_token_expires_in_an_hour() -> None:
|
|
115
|
+
issuer = TokenIssuer(MockClock("2024-04-09 12:00:00"))
|
|
116
|
+
|
|
117
|
+
assert issuer.issue().isoformat() == "2024-04-09T13:00:00+00:00"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Nothing about `TokenIssuer` changed, and nothing patched the interpreter to get there.
|
|
121
|
+
|
|
122
|
+
## Clocks
|
|
123
|
+
|
|
124
|
+
Four, and the differences between them are the point.
|
|
125
|
+
|
|
126
|
+
| Clock | Reads | Use for |
|
|
127
|
+
| --- | --- | --- |
|
|
128
|
+
| `SystemClock` | The operating system's wall clock | Production. Recording *when* something happened |
|
|
129
|
+
| `MonotonicClock` | A counter that only moves forward | Measuring *how long* something took |
|
|
130
|
+
| `MockClock` | Whatever it was told, until told otherwise | Tests |
|
|
131
|
+
| `Clock` | Whatever is in force, or a clock from elsewhere | Adapting, and code nothing can be handed to |
|
|
132
|
+
|
|
133
|
+
Each answers the same four questions:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
clock.now() # a DatePoint, always aware
|
|
137
|
+
clock.sleep(2.5) # block; a MockClock returns at once, 2.5s later
|
|
138
|
+
await clock.sleep_async(2.5) # the same, without holding the event loop
|
|
139
|
+
clock.with_timezone("Europe/Paris") # a copy that reports in another zone
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`with_timezone` returns a copy, so pinning a zone for one class does not change the time
|
|
143
|
+
anybody else reads.
|
|
144
|
+
|
|
145
|
+
### Wall clock or monotonic
|
|
146
|
+
|
|
147
|
+
A wall clock jumps. A time daemon corrects a drift, an administrator fixes the date, a zone
|
|
148
|
+
changes its mind about daylight saving. Subtract two readings taken either side of one of those
|
|
149
|
+
and the duration is wrong â occasionally negative.
|
|
150
|
+
|
|
151
|
+
`MonotonicClock` anchors to the wall clock once, when it is built, and then reports that anchor
|
|
152
|
+
plus however far the machine's monotonic counter has moved. It is the right clock for a timeout
|
|
153
|
+
and the wrong one for a timestamp, because it drifts from the wall clock by exactly the
|
|
154
|
+
correction it refused to follow.
|
|
155
|
+
|
|
156
|
+
### A clock from somewhere else
|
|
157
|
+
|
|
158
|
+
Anything with a `now()` satisfies `SupportsNow`, which is the whole contract for adopting one:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from xtr_clock import Clock
|
|
162
|
+
|
|
163
|
+
clock = Clock(some_other_libraries_clock) # now a full ClockInterface
|
|
164
|
+
clock.now() # a DatePoint, whatever it answered with
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`Clock` fills in what the wrapped object cannot do. Asked to sleep, it hands the request on if
|
|
168
|
+
the wrapped clock knows how, and lets real time pass if it does not.
|
|
169
|
+
|
|
170
|
+
## Instants
|
|
171
|
+
|
|
172
|
+
`DatePoint` is a `datetime` subclass, which is the most important thing about it. Every method
|
|
173
|
+
you already use works, and every result is still a `DatePoint`:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
point = clock.now()
|
|
177
|
+
|
|
178
|
+
point.replace(hour=9) # DatePoint
|
|
179
|
+
point + timedelta(days=1) # DatePoint
|
|
180
|
+
point.astimezone(UTC) # DatePoint
|
|
181
|
+
point.isoformat() # a string, as always
|
|
182
|
+
point < other_datetime # compares fine
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
It adds two guarantees and three methods.
|
|
186
|
+
|
|
187
|
+
**It is always timezone-aware.** A naive reading is taken as local time â the reading the
|
|
188
|
+
standard library itself picks whenever it has to convert one. So `DatePoint(2024, 4, 9)` carries
|
|
189
|
+
a zone, and so does anything `fromisoformat`, `strptime` or `fromtimestamp` produces.
|
|
190
|
+
|
|
191
|
+
**`now()` reads the clock in force**, not the operating system. That one departure from
|
|
192
|
+
`datetime` is deliberate: it means freezing the clock in a test reaches a helper you never got
|
|
193
|
+
round to injecting a clock into.
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
DatePoint.parse("+1 day Europe/Paris") # the grammar below, against the current clock
|
|
197
|
+
point.modify("+1 hour") # the same, against this instant
|
|
198
|
+
point.with_timezone("Asia/Tokyo") # the same instant, another wall clock
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`DatePoint.from_datetime(value)` adopts any `datetime` you already have.
|
|
202
|
+
|
|
203
|
+
> A `DatePoint` is built the way a `datetime` is â `DatePoint(2024, 4, 9)`. The string grammar
|
|
204
|
+
> lives on `parse()` and `now()` rather than on the constructor, because `replace`, arithmetic
|
|
205
|
+
> and every inherited `from*` method route back through it, and a first argument that is
|
|
206
|
+
> sometimes a year and sometimes a sentence makes all of them untypeable.
|
|
207
|
+
|
|
208
|
+
## The modifier grammar
|
|
209
|
+
|
|
210
|
+
`'+1 day'` is convenient enough to be worth parsing, and small enough to be worth parsing
|
|
211
|
+
ourselves. Five shapes, and anything else is an error:
|
|
212
|
+
|
|
213
|
+
| Written | Means |
|
|
214
|
+
| --- | --- |
|
|
215
|
+
| `now` | The reference, untouched |
|
|
216
|
+
| `+1 day`, `-2 hours 30 minutes`, `2 days ago` | An offset. Units chain, and may be negative |
|
|
217
|
+
| `today`, `tomorrow`, `yesterday`, `midnight`, `noon` | A boundary of the day |
|
|
218
|
+
| `2024-04-09`, `2024-04-09 15:00`, `2024-04` | An absolute datetime, in ISO-8601 |
|
|
219
|
+
| `Europe/Paris`, `UTC`, `+02:00` | The same instant, read in another zone |
|
|
220
|
+
|
|
221
|
+
Units are `year`, `month`, `week`, `day`, `hour`, `minute`, `second`, `millisecond` and
|
|
222
|
+
`microsecond`, singular or plural, with the obvious short forms (`hr`, `mins`, `secs`). Single
|
|
223
|
+
letters are refused: `m` reads as both minute and month, and a modifier should never be a guess.
|
|
224
|
+
|
|
225
|
+
A zone may trail any of them, and is applied first:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
now("+1 day Europe/Paris") # move to Paris, then add a day there
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Which is not the same instant as adding a day and then moving â so the order is worth being
|
|
232
|
+
explicit about.
|
|
233
|
+
|
|
234
|
+
### Two rules about arithmetic
|
|
235
|
+
|
|
236
|
+
**Calendar units keep the wall clock. Durations keep the elapsed time.**
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
eve = MockClock("2025-03-30 01:00:00", "Europe/Amsterdam").now() # a spring forward is coming
|
|
240
|
+
|
|
241
|
+
eve.modify("+1 day") # 2025-03-31T01:00:00+02:00 â same time tomorrow
|
|
242
|
+
eve.modify("+24 hours") # 2025-03-31T02:00:00+02:00 â 24 real hours later
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Both are correct; they are answers to different questions. Keeping the wall clock can land on
|
|
246
|
+
one that never existed, on the morning an hour went missing, so every result is resolved back
|
|
247
|
+
through the instant it names â what comes out is a time that was really on the wall.
|
|
248
|
+
|
|
249
|
+
**Month arithmetic clamps.** January 31 plus a month is February 28, not March 3, so adding a
|
|
250
|
+
month never skips one. Clamping makes month arithmetic non-associative at month ends â two
|
|
251
|
+
`'+1 month'` steps from January 31 reach March 28, one `'+2 months'` step reaches March 31 â
|
|
252
|
+
which is a property of calendars rather than a defect here.
|
|
253
|
+
|
|
254
|
+
## Reaching code you cannot hand a clock
|
|
255
|
+
|
|
256
|
+
Injection is the honest answer and covers most code. It does not cover a module-level helper, a
|
|
257
|
+
validator a framework calls, or a function three libraries deep. That code calls `now()`:
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
from xtr_clock import now
|
|
261
|
+
|
|
262
|
+
now() # the current instant, from the clock in force
|
|
263
|
+
now("+1 hour")
|
|
264
|
+
now("tomorrow")
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
and a test answers by installing a different clock:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
from xtr_clock.testing import mock_time
|
|
271
|
+
|
|
272
|
+
with mock_time("2024-04-09 12:00:00"):
|
|
273
|
+
assert now().hour == 12
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The clock in force lives in a `ContextVar`, so a scope that installs one does not leak into a
|
|
277
|
+
concurrent task that did not, and two tests running side by side cannot see each other's.
|
|
278
|
+
Installing also updates a process-wide fallback, so a thread started later â which begins with a
|
|
279
|
+
fresh context â still sees the clock the application chose.
|
|
280
|
+
|
|
281
|
+
`Clock.set(clock)` installs one for good, which is what an application does at startup.
|
|
282
|
+
`Clock.using(clock)` installs one for a block and puts the old one back on the way out,
|
|
283
|
+
including when the block raises. Prefer `using` everywhere else: `set` has no end, and a test
|
|
284
|
+
that forgets to undo it hands the next one a clock it never asked for.
|
|
285
|
+
|
|
286
|
+
### A class that cannot take a constructor argument
|
|
287
|
+
|
|
288
|
+
`ClockAwareMixin` gives a class a clock it can be handed, and a default until it is. It defines
|
|
289
|
+
no `__init__`, so it composes with anything â including a dataclass:
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
from xtr_clock import ClockAwareMixin
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class AuditLog(ClockAwareMixin):
|
|
296
|
+
def record(self, event: str) -> None:
|
|
297
|
+
self._rows.append((self.now(), event))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
log = AuditLog()
|
|
301
|
+
log.set_clock(MockClock("2024-04-09 12:00:00"))
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Until `set_clock` is called it reads whatever is in force, so it works untouched in production
|
|
305
|
+
and freezes with everything else in a test.
|
|
306
|
+
|
|
307
|
+
## Testing your application
|
|
308
|
+
|
|
309
|
+
Three ways in, depending on how much of the application knows about clocks.
|
|
310
|
+
|
|
311
|
+
**A class that takes one** needs nothing from this section â hand it a `MockClock`.
|
|
312
|
+
|
|
313
|
+
**Code that calls `now()`** gets `mock_time`, which installs a frozen clock for a block:
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
from xtr_clock.testing import mock_time
|
|
317
|
+
|
|
318
|
+
with mock_time("2024-04-09 12:00:00") as clock:
|
|
319
|
+
assert issue_token().expires_at.hour == 12
|
|
320
|
+
clock.sleep(3600) # an hour passes; the test does not wait
|
|
321
|
+
assert token_has_expired()
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
`when` may be a string the grammar reads, a `datetime`, or nothing at all to freeze where you
|
|
325
|
+
already are. A relative one is read against the clock already in force, so `mock_time("+1 day")`
|
|
326
|
+
nested inside the block above lands on the 10th.
|
|
327
|
+
|
|
328
|
+
**A whole test** can have the fixture. Opt in once, in a `conftest.py`:
|
|
329
|
+
|
|
330
|
+
```python
|
|
331
|
+
pytest_plugins = ["xtr_clock.pytest_plugin"]
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
def test_a_token_expires_in_an_hour(clock: MockClock) -> None:
|
|
336
|
+
token = issue_token()
|
|
337
|
+
|
|
338
|
+
clock.sleep(3599)
|
|
339
|
+
assert token.is_valid()
|
|
340
|
+
clock.sleep(2)
|
|
341
|
+
assert not token.is_valid()
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
It is not registered automatically on purpose: a `clock` fixture appearing in every suite that
|
|
345
|
+
merely installs this library would collide with the one plenty of suites already have.
|
|
346
|
+
|
|
347
|
+
A `MockClock` freezes in **UTC** by default, where a real clock is built in the machine's own
|
|
348
|
+
zone â a frozen test should not change its answer because of where the laptop running it is.
|
|
349
|
+
|
|
350
|
+
> In async code prefer `mock_time` inside the coroutine over a fixture that finishes before the
|
|
351
|
+
> coroutine starts, so the block and the code it covers share one context.
|
|
352
|
+
> `MockClock.sleep_async` advances instantly and yields control once, so the tasks waiting on the
|
|
353
|
+
> clock get their turn â which is usually the behaviour the test is there to observe.
|
|
354
|
+
|
|
355
|
+
## Errors
|
|
356
|
+
|
|
357
|
+
Everything this library raises derives from `ClockError`, and carries what went wrong as typed
|
|
358
|
+
attributes rather than only a message. Both are also `ValueError`s, so code already guarding a
|
|
359
|
+
conversion with `except ValueError` keeps working.
|
|
360
|
+
|
|
361
|
+
| Error | Raised when |
|
|
362
|
+
| --- | --- |
|
|
363
|
+
| `InvalidModifierError` | The grammar cannot read a modifier. Carries `.modifier` and `.reason` |
|
|
364
|
+
| `InvalidTimezoneError` | A timezone is named that this system cannot resolve. Carries `.timezone` |
|
|
365
|
+
|
|
366
|
+
A typo is refused where it is written rather than resolving to something plausible:
|
|
367
|
+
|
|
368
|
+
```python
|
|
369
|
+
now("+1 dya") # InvalidModifierError, not silently "now"
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
## Relation to other datetime libraries
|
|
373
|
+
|
|
374
|
+
This library is about **who tells the time**, not about replacing `datetime`. It deliberately
|
|
375
|
+
introduces no datetime type of its own, and the arithmetic it does own is only what the modifier
|
|
376
|
+
grammar needs.
|
|
377
|
+
|
|
378
|
+
If you want a genuinely better datetime â nanosecond precision, naive and aware as separate
|
|
379
|
+
types, an explicit choice of what to do with an ambiguous hour â
|
|
380
|
+
[whenever](https://github.com/ariebovenberg/whenever) is the one to reach for. It is not so much
|
|
381
|
+
an alternative to this library as an orthogonal one, and because a `DatePoint` *is* an aware
|
|
382
|
+
`datetime`, the two need no adapter:
|
|
383
|
+
|
|
384
|
+
```python
|
|
385
|
+
from whenever import Instant
|
|
386
|
+
|
|
387
|
+
moment = Instant(clock.now()) # straight in
|
|
388
|
+
later = moment.to_tz("Europe/Paris").add(months=5) # its arithmetic, its disambiguation rules
|
|
389
|
+
back = DatePoint.from_datetime(later.to_stdlib()) # and straight back out
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
`Instant` takes any aware `datetime`. Its `ZonedDateTime` constructor is stricter â it wants a
|
|
393
|
+
`zoneinfo.ZoneInfo` exactly â so reach it through `to_tz` as above, or hand it a `DatePoint`
|
|
394
|
+
built with a named zone rather than the UTC default.
|
|
395
|
+
|
|
396
|
+
Prefer `whenever` outright if its types are the ones you want in your domain model; it has its
|
|
397
|
+
own way to patch the current time. Prefer this library when you want the clock to be an injected
|
|
398
|
+
dependency and `datetime` to stay the type on the boundary. The two compose.
|
|
399
|
+
|
|
400
|
+
## Layout
|
|
401
|
+
|
|
402
|
+
```
|
|
403
|
+
xtr_clock/
|
|
404
|
+
âââ clock_interface.py what a clock answers to â and SupportsNow, the smaller contract
|
|
405
|
+
âââ date_point.py an instant: a datetime that stays aware, and stays itself
|
|
406
|
+
âââ system_clock.py reads the operating system's wall clock
|
|
407
|
+
âââ monotonic_clock.py counts forward, whatever the wall clock does
|
|
408
|
+
âââ mock_clock.py stands still until a test moves it
|
|
409
|
+
âââ clock.py the clock in force, and the adapter that fits a foreign one
|
|
410
|
+
âââ clock_aware_mixin.py for a class that cannot take a constructor argument
|
|
411
|
+
âââ now.py now() â the one-call front door
|
|
412
|
+
âââ modifier.py the little grammar '+1 day' is written in
|
|
413
|
+
âââ timezone.py turning a zone's name into a zone, in one place
|
|
414
|
+
âââ testing.py freeze the clock for a block, restore it after
|
|
415
|
+
âââ pytest_plugin.py the same as a fixture, opt-in
|
|
416
|
+
âââ exception/ one error per module, all a ClockError
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
## Development
|
|
420
|
+
|
|
421
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
422
|
+
`packages/xtr-clock`; run the commands below from there. The `python-xtr-clock` repository is a
|
|
423
|
+
read-only copy, so send issues and pull requests to the monorepo.
|
|
424
|
+
|
|
425
|
+
```sh
|
|
426
|
+
uv sync --all-extras
|
|
427
|
+
uv run ruff check src tests
|
|
428
|
+
uv run ruff format --check src tests
|
|
429
|
+
uv run ty check
|
|
430
|
+
uv run basedpyright
|
|
431
|
+
uv run coverage run -m pytest && uv run coverage report
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Two type checkers on purpose â they disagree often enough to be worth both. Both run strict on
|
|
435
|
+
the tests too, with nothing suppressed.
|
|
436
|
+
|
|
437
|
+
The suite mirrors the source tree: `tests/unit/` holds a `test_<module>.py` for each module,
|
|
438
|
+
testing it alone; `tests/integration/` holds what needs a fresh interpreter â that the fixture
|
|
439
|
+
is invisible until a suite opts in, and that importing the library pulls in nothing but the
|
|
440
|
+
standard library. Statement and branch coverage are both 100%, measured with `coverage run`
|
|
441
|
+
rather than `pytest --cov`, which starts too late to see a module's import.
|
|
442
|
+
|
|
443
|
+
## License
|
|
444
|
+
|
|
445
|
+
[MIT](LICENSE) ÂĐ xterr
|