xtr-clock 2.0.0__tar.gz → 4.0.2__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-2.0.0 → xtr_clock-4.0.2}/LICENSE +1 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/PKG-INFO +22 -14
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/README.md +19 -12
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/pyproject.toml +8 -5
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/pyproject.toml.orig +11 -5
- xtr_clock-4.0.2/src/xtr_clock/.agents/skills/xtr-clock/SKILL.md +160 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/bundle/clock_config.py +12 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/calendar_shift.py +6 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/clock_interface.py +3 -1
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/date_point.py +8 -8
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/mock_clock.py +6 -2
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/modifier.py +7 -7
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/monotonic_clock.py +10 -5
- xtr_clock-4.0.2/src/xtr_clock/recipe/__init__.py +15 -0
- xtr_clock-4.0.2/src/xtr_clock/recipe/manifest.toml +19 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/system_clock.py +8 -3
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/__init__.py +0 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/bundle/__init__.py +0 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/bundle/clock_bundle.py +0 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/clock.py +0 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/clock_aware_mixin.py +0 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/exception/__init__.py +0 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/exception/clock_error.py +0 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/exception/invalid_modifier_error.py +0 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/exception/invalid_timezone_error.py +0 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/now.py +0 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/py.typed +0 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/pytest_plugin.py +0 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/testing.py +0 -0
- {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/timezone.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xtr-clock
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.2
|
|
4
4
|
Summary: Decouples an application from the system clock: an injectable clock, a timezone-aware instant, and a frozen clock for tests.
|
|
5
5
|
Keywords: clock,time,datetime,timezone,testing,freeze-time,monotonic
|
|
6
6
|
Author: Xterr
|
|
@@ -15,8 +15,9 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.14
|
|
16
16
|
Classifier: Topic :: Software Development :: Testing
|
|
17
17
|
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: anyio>=4.0
|
|
18
19
|
Requires-Dist: typing-extensions>=4.4 ; extra == 'di'
|
|
19
|
-
Requires-Dist: xtr-dependency-injection>=
|
|
20
|
+
Requires-Dist: xtr-dependency-injection>=4.0,<5 ; extra == 'di'
|
|
20
21
|
Requires-Dist: tzdata>=2024.1 ; extra == 'tzdata'
|
|
21
22
|
Requires-Python: >=3.11
|
|
22
23
|
Provides-Extra: di
|
|
@@ -30,8 +31,8 @@ Description-Content-Type: text/markdown
|
|
|
30
31
|
**A clock an application can be handed, instead of the one it is standing on.**
|
|
31
32
|
|
|
32
33
|
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
33
|
-
<img alt="core dependencies:
|
|
34
|
-
<img alt="
|
|
34
|
+
<img alt="core dependencies: anyio" src="https://img.shields.io/badge/core%20deps-anyio-3FB950">
|
|
35
|
+
<img alt="statements 100%" src="https://img.shields.io/badge/statements-100%25-3FB950">
|
|
35
36
|
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
36
37
|
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
37
38
|
|
|
@@ -49,7 +50,8 @@ Take a **clock** as a constructor argument and the choice becomes configuration:
|
|
|
49
50
|
clock in production, a frozen one in a test. What comes back is a **`DatePoint`** — a
|
|
50
51
|
`datetime`, so nothing downstream has to change.
|
|
51
52
|
|
|
52
|
-
- 🪶 **
|
|
53
|
+
- 🪶 **One dependency.** `anyio`, so an awaitable wait is not tied to one async backend.
|
|
54
|
+
`zoneinfo`, `contextvars` and `datetime` are already there.
|
|
53
55
|
- 🕐 **A `DatePoint` is a `datetime`.** It drops into an ORM column, a serializer, a comparison,
|
|
54
56
|
an existing signature. Adopting this library changes no type you already have.
|
|
55
57
|
- 🧭 **Always timezone-aware.** There is no such thing here as an instant whose offset is
|
|
@@ -57,7 +59,8 @@ clock in production, a frozen one in a test. What comes back is a **`DatePoint`*
|
|
|
57
59
|
- ⏱️ **Frozen time costs nothing.** `clock.sleep(86400)` returns immediately and the clock is a
|
|
58
60
|
day later.
|
|
59
61
|
- 🔀 **Blocking or awaiting.** `sleep()` and `sleep_async()` on the same clock, so one contract
|
|
60
|
-
serves a worker and a web handler
|
|
62
|
+
serves a worker and a web handler — and the awaitable one waits through anyio, so asyncio and
|
|
63
|
+
trio both get a clock they can use.
|
|
61
64
|
- 🧩 **Protocol-based.** Every collaborator is a constructor argument, so a DI container can own
|
|
62
65
|
the graph — and a clock from another library satisfies `SupportsNow` as it is.
|
|
63
66
|
|
|
@@ -76,7 +79,7 @@ uv add "xtr-clock[tzdata]" # + a timezone database, where the system has n
|
|
|
76
79
|
|
|
77
80
|
| Extra | Brings | For |
|
|
78
81
|
| --- | --- | --- |
|
|
79
|
-
| *(none)* |
|
|
82
|
+
| *(none)* | `anyio` | The whole library |
|
|
80
83
|
| `tzdata` | `tzdata` | Windows, and slim containers that ship no zone database |
|
|
81
84
|
|
|
82
85
|
Requires Python 3.11+.
|
|
@@ -376,6 +379,9 @@ Everything adding this package to an application on
|
|
|
376
379
|
[xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what removing it undoes.
|
|
377
380
|
|
|
378
381
|
- **Install** — `uv add "xtr-clock[di]"`; add `tzdata` on Windows or a slim container.
|
|
382
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* step below: it lists
|
|
383
|
+
`ClockBundle`, or leaves it out when another bundle requires it (the logging bundle does). There
|
|
384
|
+
is no config file, environment or ignore line to write.
|
|
379
385
|
- **Activate** — usually nothing to do: the logging bundle requires this one. Otherwise
|
|
380
386
|
`ClockBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported from
|
|
381
387
|
`xtr_clock.bundle`.
|
|
@@ -434,6 +440,9 @@ class TokenIssuer:
|
|
|
434
440
|
| --- | --- |
|
|
435
441
|
| `timezone` | The zone `Clock.now()` reports in. `None` follows the machine's own zone |
|
|
436
442
|
|
|
443
|
+
A zone this system cannot resolve is refused by `ClockConfig` itself, so a typo fails the build
|
|
444
|
+
with an `InvalidTimezoneError` naming it rather than the first reading taken at runtime.
|
|
445
|
+
|
|
437
446
|
A test freezes time by replacing the `Clock` service — the one both `ClockInterface` and
|
|
438
447
|
`Clock.get()` answer with once the kernel boots:
|
|
439
448
|
|
|
@@ -522,11 +531,8 @@ read-only copy, so send issues and pull requests to the monorepo.
|
|
|
522
531
|
|
|
523
532
|
```sh
|
|
524
533
|
uv sync --all-extras
|
|
525
|
-
uv run ruff check
|
|
526
|
-
uv run
|
|
527
|
-
uv run ty check
|
|
528
|
-
uv run basedpyright
|
|
529
|
-
uv run coverage run -m pytest && uv run coverage report
|
|
534
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
535
|
+
uv run coverage run -m pytest && uv run coverage report # statements 100%; the total adds branches
|
|
530
536
|
```
|
|
531
537
|
|
|
532
538
|
Two type checkers on purpose — they disagree often enough to be worth both. Both run strict on
|
|
@@ -535,8 +541,10 @@ the tests too, with nothing suppressed.
|
|
|
535
541
|
The suite mirrors the source tree: `tests/unit/` holds a `test_<module>.py` for each module,
|
|
536
542
|
testing it alone; `tests/integration/` holds what needs a fresh interpreter — that the fixture
|
|
537
543
|
is invisible until a suite opts in, and that importing the library pulls in nothing but the
|
|
538
|
-
standard library
|
|
539
|
-
|
|
544
|
+
standard library and `anyio`. The awaitable waits run on asyncio and, when the checkout has it,
|
|
545
|
+
on trio. Statement coverage is 100% — the figure the badge shows — measured with `coverage run`
|
|
546
|
+
rather than `pytest --cov`, which starts too late to see a module's import. The report's own
|
|
547
|
+
total reads 99%, since it counts branches too and one of them stays partial.
|
|
540
548
|
|
|
541
549
|
## License
|
|
542
550
|
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
**A clock an application can be handed, instead of the one it is standing on.**
|
|
6
6
|
|
|
7
7
|
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
8
|
-
<img alt="core dependencies:
|
|
9
|
-
<img alt="
|
|
8
|
+
<img alt="core dependencies: anyio" src="https://img.shields.io/badge/core%20deps-anyio-3FB950">
|
|
9
|
+
<img alt="statements 100%" src="https://img.shields.io/badge/statements-100%25-3FB950">
|
|
10
10
|
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
11
11
|
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
12
12
|
|
|
@@ -24,7 +24,8 @@ Take a **clock** as a constructor argument and the choice becomes configuration:
|
|
|
24
24
|
clock in production, a frozen one in a test. What comes back is a **`DatePoint`** — a
|
|
25
25
|
`datetime`, so nothing downstream has to change.
|
|
26
26
|
|
|
27
|
-
- 🪶 **
|
|
27
|
+
- 🪶 **One dependency.** `anyio`, so an awaitable wait is not tied to one async backend.
|
|
28
|
+
`zoneinfo`, `contextvars` and `datetime` are already there.
|
|
28
29
|
- 🕐 **A `DatePoint` is a `datetime`.** It drops into an ORM column, a serializer, a comparison,
|
|
29
30
|
an existing signature. Adopting this library changes no type you already have.
|
|
30
31
|
- 🧭 **Always timezone-aware.** There is no such thing here as an instant whose offset is
|
|
@@ -32,7 +33,8 @@ clock in production, a frozen one in a test. What comes back is a **`DatePoint`*
|
|
|
32
33
|
- ⏱️ **Frozen time costs nothing.** `clock.sleep(86400)` returns immediately and the clock is a
|
|
33
34
|
day later.
|
|
34
35
|
- 🔀 **Blocking or awaiting.** `sleep()` and `sleep_async()` on the same clock, so one contract
|
|
35
|
-
serves a worker and a web handler
|
|
36
|
+
serves a worker and a web handler — and the awaitable one waits through anyio, so asyncio and
|
|
37
|
+
trio both get a clock they can use.
|
|
36
38
|
- 🧩 **Protocol-based.** Every collaborator is a constructor argument, so a DI container can own
|
|
37
39
|
the graph — and a clock from another library satisfies `SupportsNow` as it is.
|
|
38
40
|
|
|
@@ -51,7 +53,7 @@ uv add "xtr-clock[tzdata]" # + a timezone database, where the system has n
|
|
|
51
53
|
|
|
52
54
|
| Extra | Brings | For |
|
|
53
55
|
| --- | --- | --- |
|
|
54
|
-
| *(none)* |
|
|
56
|
+
| *(none)* | `anyio` | The whole library |
|
|
55
57
|
| `tzdata` | `tzdata` | Windows, and slim containers that ship no zone database |
|
|
56
58
|
|
|
57
59
|
Requires Python 3.11+.
|
|
@@ -351,6 +353,9 @@ Everything adding this package to an application on
|
|
|
351
353
|
[xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what removing it undoes.
|
|
352
354
|
|
|
353
355
|
- **Install** — `uv add "xtr-clock[di]"`; add `tzdata` on Windows or a slim container.
|
|
356
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* step below: it lists
|
|
357
|
+
`ClockBundle`, or leaves it out when another bundle requires it (the logging bundle does). There
|
|
358
|
+
is no config file, environment or ignore line to write.
|
|
354
359
|
- **Activate** — usually nothing to do: the logging bundle requires this one. Otherwise
|
|
355
360
|
`ClockBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported from
|
|
356
361
|
`xtr_clock.bundle`.
|
|
@@ -409,6 +414,9 @@ class TokenIssuer:
|
|
|
409
414
|
| --- | --- |
|
|
410
415
|
| `timezone` | The zone `Clock.now()` reports in. `None` follows the machine's own zone |
|
|
411
416
|
|
|
417
|
+
A zone this system cannot resolve is refused by `ClockConfig` itself, so a typo fails the build
|
|
418
|
+
with an `InvalidTimezoneError` naming it rather than the first reading taken at runtime.
|
|
419
|
+
|
|
412
420
|
A test freezes time by replacing the `Clock` service — the one both `ClockInterface` and
|
|
413
421
|
`Clock.get()` answer with once the kernel boots:
|
|
414
422
|
|
|
@@ -497,11 +505,8 @@ read-only copy, so send issues and pull requests to the monorepo.
|
|
|
497
505
|
|
|
498
506
|
```sh
|
|
499
507
|
uv sync --all-extras
|
|
500
|
-
uv run ruff check
|
|
501
|
-
uv run
|
|
502
|
-
uv run ty check
|
|
503
|
-
uv run basedpyright
|
|
504
|
-
uv run coverage run -m pytest && uv run coverage report
|
|
508
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
509
|
+
uv run coverage run -m pytest && uv run coverage report # statements 100%; the total adds branches
|
|
505
510
|
```
|
|
506
511
|
|
|
507
512
|
Two type checkers on purpose — they disagree often enough to be worth both. Both run strict on
|
|
@@ -510,8 +515,10 @@ the tests too, with nothing suppressed.
|
|
|
510
515
|
The suite mirrors the source tree: `tests/unit/` holds a `test_<module>.py` for each module,
|
|
511
516
|
testing it alone; `tests/integration/` holds what needs a fresh interpreter — that the fixture
|
|
512
517
|
is invisible until a suite opts in, and that importing the library pulls in nothing but the
|
|
513
|
-
standard library
|
|
514
|
-
|
|
518
|
+
standard library and `anyio`. The awaitable waits run on asyncio and, when the checkout has it,
|
|
519
|
+
on trio. Statement coverage is 100% — the figure the badge shows — measured with `coverage run`
|
|
520
|
+
rather than `pytest --cov`, which starts too late to see a module's import. The report's own
|
|
521
|
+
total reads 99%, since it counts branches too and one of them stays partial.
|
|
515
522
|
|
|
516
523
|
## License
|
|
517
524
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xtr-clock"
|
|
3
|
-
version = "
|
|
3
|
+
version = "4.0.2"
|
|
4
4
|
description = "Decouples an application from the system clock: an injectable clock, a timezone-aware instant, and a frozen clock for tests."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -25,7 +25,7 @@ classifiers = [
|
|
|
25
25
|
"Topic :: Software Development :: Testing",
|
|
26
26
|
"Typing :: Typed",
|
|
27
27
|
]
|
|
28
|
-
dependencies = []
|
|
28
|
+
dependencies = ["anyio>=4.0"]
|
|
29
29
|
|
|
30
30
|
[[project.authors]]
|
|
31
31
|
name = "Xterr"
|
|
@@ -35,21 +35,24 @@ email = "me@xterr.dev"
|
|
|
35
35
|
tzdata = ["tzdata>=2024.1"]
|
|
36
36
|
di = [
|
|
37
37
|
"typing-extensions>=4.4",
|
|
38
|
-
"xtr-dependency-injection>=
|
|
38
|
+
"xtr-dependency-injection>=4.0,<5",
|
|
39
39
|
]
|
|
40
40
|
|
|
41
41
|
[project.entry-points."xtr_dependency_injection.bundles"]
|
|
42
42
|
clock = "xtr_clock.bundle:ClockBundle"
|
|
43
43
|
|
|
44
|
+
[project.entry-points.xtr_recipes]
|
|
45
|
+
clock = "xtr_clock.recipe"
|
|
46
|
+
|
|
44
47
|
[dependency-groups]
|
|
45
48
|
dev = [
|
|
46
49
|
"basedpyright>=1.21",
|
|
47
50
|
"ruff>=0.8",
|
|
48
51
|
"pytest>=8",
|
|
49
52
|
"pytest-cov>=5",
|
|
50
|
-
"
|
|
53
|
+
"trio>=0.27",
|
|
51
54
|
"ty>=0.0.83",
|
|
52
|
-
"xtr-dependency-injection>=
|
|
55
|
+
"xtr-dependency-injection>=4.0,<5",
|
|
53
56
|
]
|
|
54
57
|
|
|
55
58
|
[build-system]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xtr-clock"
|
|
3
|
-
version = "
|
|
3
|
+
version = "4.0.2"
|
|
4
4
|
description = "Decouples an application from the system clock: an injectable clock, a timezone-aware instant, and a frozen clock for tests."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -20,7 +20,9 @@ classifiers = [
|
|
|
20
20
|
"Topic :: Software Development :: Testing",
|
|
21
21
|
"Typing :: Typed",
|
|
22
22
|
]
|
|
23
|
-
dependencies = [
|
|
23
|
+
dependencies = [
|
|
24
|
+
"anyio>=4.0",
|
|
25
|
+
]
|
|
24
26
|
|
|
25
27
|
[project.optional-dependencies]
|
|
26
28
|
tzdata = [
|
|
@@ -28,7 +30,7 @@ tzdata = [
|
|
|
28
30
|
]
|
|
29
31
|
di = [
|
|
30
32
|
"typing-extensions>=4.4",
|
|
31
|
-
"xtr-dependency-injection>=
|
|
33
|
+
"xtr-dependency-injection>=4.0,<5",
|
|
32
34
|
]
|
|
33
35
|
|
|
34
36
|
# No `pytest11` entry point on purpose. The `clock` fixture is opted into with
|
|
@@ -41,15 +43,19 @@ di = [
|
|
|
41
43
|
[project.entry-points."xtr_dependency_injection.bundles"]
|
|
42
44
|
clock = "xtr_clock.bundle:ClockBundle"
|
|
43
45
|
|
|
46
|
+
# Advertises the recipe — what *Use in an application* takes — for recipes:sync.
|
|
47
|
+
[project.entry-points."xtr_recipes"]
|
|
48
|
+
clock = "xtr_clock.recipe"
|
|
49
|
+
|
|
44
50
|
[dependency-groups]
|
|
45
51
|
dev = [
|
|
46
52
|
"basedpyright>=1.21",
|
|
47
53
|
"ruff>=0.8",
|
|
48
54
|
"pytest>=8",
|
|
49
55
|
"pytest-cov>=5",
|
|
50
|
-
"
|
|
56
|
+
"trio>=0.27",
|
|
51
57
|
"ty>=0.0.83",
|
|
52
|
-
"xtr-dependency-injection>=
|
|
58
|
+
"xtr-dependency-injection>=4.0,<5",
|
|
53
59
|
]
|
|
54
60
|
|
|
55
61
|
[build-system]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: xtr-clock
|
|
3
|
+
description: How to read, inject and freeze time with xtr-clock. Use when code needs the current time, timestamps, expiry or durations, timezone handling, relative dates such as "+1 day", or a test that must freeze or advance time; also when adding ClockBundle to an application on xtr-dependency-injection.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# xtr-clock
|
|
7
|
+
|
|
8
|
+
Time is an input. Code takes a clock, the application decides which one, and a test hands it a
|
|
9
|
+
frozen one. Every reading is a `DatePoint` — a timezone-aware `datetime` subclass, so it goes
|
|
10
|
+
anywhere a `datetime` goes.
|
|
11
|
+
|
|
12
|
+
## Quick reference
|
|
13
|
+
|
|
14
|
+
- Take a `ClockInterface` as a constructor argument; call `clock.now()`. Never call
|
|
15
|
+
`datetime.now()`, `datetime.utcnow()` or `time.time()` in code that should be testable.
|
|
16
|
+
- Production: `SystemClock()`. Durations and timeouts: `MonotonicClock()`. Tests: `MockClock(...)`.
|
|
17
|
+
- Code that cannot be handed a clock (module helpers, validators): `from xtr_clock import now`.
|
|
18
|
+
- Relative times: `clock.now().modify("+1 hour")`, `now("tomorrow")`, `DatePoint.parse("+1 day UTC")`.
|
|
19
|
+
- Freeze for a block: `from xtr_clock.testing import mock_time`.
|
|
20
|
+
- In an application: activate `ClockBundle`, inject `ClockInterface`.
|
|
21
|
+
|
|
22
|
+
## Inject a clock
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
from xtr_clock import ClockInterface, DatePoint
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class TokenIssuer:
|
|
32
|
+
clock: ClockInterface
|
|
33
|
+
|
|
34
|
+
def issue(self) -> DatePoint:
|
|
35
|
+
return self.clock.now().modify("+1 hour")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Every clock answers the same four calls:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
clock.now() # DatePoint, always aware
|
|
42
|
+
clock.sleep(2.5) # MockClock returns at once and moves 2.5s forward
|
|
43
|
+
await clock.sleep_async(2.5) # same, without blocking; waits through anyio, so any backend works
|
|
44
|
+
clock.with_timezone("Europe/Paris") # a copy; the original is unchanged
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Pick the clock by question:
|
|
48
|
+
|
|
49
|
+
| Clock | Use for |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `SystemClock` | Recording *when* something happened |
|
|
52
|
+
| `MonotonicClock` | Measuring *how long* something took — never for stored timestamps |
|
|
53
|
+
| `MockClock` | Tests; freezes in UTC unless given a zone |
|
|
54
|
+
| `Clock` | The clock in force, or an adapter around any object with `now()` (`SupportsNow`) |
|
|
55
|
+
|
|
56
|
+
## Relative times
|
|
57
|
+
|
|
58
|
+
The modifier grammar accepts five shapes; anything else raises `InvalidModifierError`:
|
|
59
|
+
|
|
60
|
+
| Written | Means |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `now` | Unchanged |
|
|
63
|
+
| `+1 day`, `-2 hours 30 minutes`, `2 days ago` | An offset; units chain |
|
|
64
|
+
| `today`, `tomorrow`, `yesterday`, `midnight`, `noon` | A day boundary |
|
|
65
|
+
| `2024-04-09`, `2024-04-09 15:00` | An absolute ISO-8601 datetime |
|
|
66
|
+
| `Europe/Paris`, `UTC`, `+02:00` | The same instant in another zone |
|
|
67
|
+
|
|
68
|
+
- Single-letter units (`m`, `h`) are refused — write `min`, `hr`, `hours`.
|
|
69
|
+
- A trailing zone is applied **first**: `"+1 day Europe/Paris"` moves to Paris, then adds a day.
|
|
70
|
+
- Calendar units keep the wall clock (`+1 day` across a DST change is "same time tomorrow");
|
|
71
|
+
durations keep elapsed time (`+24 hours`).
|
|
72
|
+
- Months clamp: January 31 `+1 month` is February 28/29.
|
|
73
|
+
- Stepping through a calendar in code: `shift_calendar(moment, months=1)`.
|
|
74
|
+
|
|
75
|
+
## Code that cannot take a clock
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from xtr_clock import now
|
|
79
|
+
|
|
80
|
+
expires = now("+1 hour") # reads the clock in force, not the OS
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The clock in force lives in a `ContextVar`. Install one with:
|
|
84
|
+
|
|
85
|
+
- `Clock.set(clock)` — for good, at application startup only.
|
|
86
|
+
- `with Clock.using(clock):` — for a block; restored even on error. Prefer this.
|
|
87
|
+
- `restore = Clock.install(clock)` — until `restore()` is called, for lifetimes that are not a block.
|
|
88
|
+
|
|
89
|
+
A class that cannot take a constructor argument mixes in `ClockAwareMixin` and calls `self.now()`;
|
|
90
|
+
`set_clock(...)` replaces its clock.
|
|
91
|
+
|
|
92
|
+
## Testing
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from xtr_clock import MockClock
|
|
96
|
+
from xtr_clock.testing import mock_time
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_issue() -> None:
|
|
100
|
+
issuer = TokenIssuer(MockClock("2024-04-09 12:00:00"))
|
|
101
|
+
assert issuer.issue().isoformat() == "2024-04-09T13:00:00+00:00"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_helper_code() -> None:
|
|
105
|
+
with mock_time("2024-04-09 12:00:00") as clock:
|
|
106
|
+
clock.sleep(3600) # an hour passes instantly
|
|
107
|
+
assert now().hour == 13
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- A `clock` fixture is available after opting in from `conftest.py`:
|
|
111
|
+
`pytest_plugins = ["xtr_clock.pytest_plugin"]`. It is never registered automatically.
|
|
112
|
+
- In async tests, call `mock_time` inside the coroutine so the block and the code share a context.
|
|
113
|
+
|
|
114
|
+
## Use in an application
|
|
115
|
+
|
|
116
|
+
`uv run xtr-recipes recipes:sync` applies the recipe shipped with this package: it lists
|
|
117
|
+
`ClockBundle`, or leaves it out when another bundle requires it (the logging bundle does). That is
|
|
118
|
+
the steps below a recipe can do; the others it prints for you to make.
|
|
119
|
+
|
|
120
|
+
1. **Install** — `uv add "xtr-clock[di]"`; add the `tzdata` extra on Windows or slim containers.
|
|
121
|
+
2. **Activate** — usually nothing: the logging bundle requires it. Otherwise add
|
|
122
|
+
`ClockBundle: {"all": True}` to `BUNDLES` in `<app>/bundles.py`
|
|
123
|
+
(`from xtr_clock.bundle import ClockBundle`).
|
|
124
|
+
3. **Configure** — optional; with no configuration it reports in the machine's zone:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
# <app>/config/clock.py
|
|
128
|
+
from xtr_dependency_injection import configure
|
|
129
|
+
from xtr_clock.bundle import ClockConfig
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@configure
|
|
133
|
+
def clock() -> ClockConfig:
|
|
134
|
+
return ClockConfig(timezone="UTC")
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`ClockConfig` resolves the zone as it is built, so an unknown one raises
|
|
138
|
+
`InvalidTimezoneError` at build time rather than on the first reading.
|
|
139
|
+
|
|
140
|
+
4. **Use** — inject `ClockInterface`. The bundle also installs the same clock as the clock in
|
|
141
|
+
force, so `now()` and the injected clock agree.
|
|
142
|
+
5. **Test** — replace the `Clock` service:
|
|
143
|
+
`boot_for_test(kernel, overrides={Clock: Clock(MockClock("2026-01-01"), "UTC")})`.
|
|
144
|
+
6. **Check** — `debug:bundles` shows `clock` as `active`.
|
|
145
|
+
7. **Remove** — drop the `BUNDLES` entry and `<app>/config/clock.py`, then `uv remove xtr-clock`,
|
|
146
|
+
unless xtr-logging is installed (it depends on this package).
|
|
147
|
+
|
|
148
|
+
## Errors
|
|
149
|
+
|
|
150
|
+
Both derive from `ClockError` and from `ValueError`:
|
|
151
|
+
|
|
152
|
+
- `InvalidModifierError` — unreadable modifier (`.modifier`, `.reason`).
|
|
153
|
+
- `InvalidTimezoneError` — unknown zone (`.timezone`); install the `tzdata` extra if the system has no zone database.
|
|
154
|
+
|
|
155
|
+
## Do not
|
|
156
|
+
|
|
157
|
+
- Do not use naive datetimes; a `DatePoint` is always aware, and comparing aware with naive fails.
|
|
158
|
+
- Do not call `Clock.set()` in tests — it never ends; use `mock_time` or `Clock.using`.
|
|
159
|
+
- Do not use `MonotonicClock` for timestamps you store or show.
|
|
160
|
+
- Do not patch `datetime` or the interpreter to freeze time.
|
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
|
|
7
|
+
from xtr_clock.timezone import resolve_timezone
|
|
8
|
+
|
|
7
9
|
__all__ = ["ClockConfig"]
|
|
8
10
|
|
|
9
11
|
|
|
@@ -17,3 +19,13 @@ class ClockConfig:
|
|
|
17
19
|
"""
|
|
18
20
|
|
|
19
21
|
timezone: str | None = None
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
"""Resolve the zone now, so a typo fails the build and not the first reading.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
InvalidTimezoneError: When ``timezone`` names no zone this
|
|
28
|
+
system knows.
|
|
29
|
+
"""
|
|
30
|
+
if self.timezone is not None:
|
|
31
|
+
_ = resolve_timezone(self.timezone)
|
|
@@ -24,7 +24,13 @@ def shift_calendar(moment: _DateT, /, *, months: int = 0, days: int = 0) -> _Dat
|
|
|
24
24
|
24 hours later. A wall clock the zone skipped over (the hour lost as the
|
|
25
25
|
clocks go forward) is read as the instant it names, shown on the hour
|
|
26
26
|
that replaced it, so the result always really existed.
|
|
27
|
+
|
|
28
|
+
Asked for no shift at all — a scheduler stepping a calendar by zero —
|
|
29
|
+
it hands back what it was given, rather than resolving an instant it
|
|
30
|
+
never moved.
|
|
27
31
|
"""
|
|
32
|
+
if not months and not days:
|
|
33
|
+
return moment
|
|
28
34
|
if months:
|
|
29
35
|
position = moment.month - 1 + months
|
|
30
36
|
year = moment.year + position // _MONTHS_PER_YEAR
|
|
@@ -64,7 +64,9 @@ class ClockInterface(SupportsNow, Protocol):
|
|
|
64
64
|
async def sleep_async(self, seconds: float) -> None:
|
|
65
65
|
"""Wait ``seconds`` without blocking the event loop.
|
|
66
66
|
|
|
67
|
-
A value of zero or less returns immediately.
|
|
67
|
+
A value of zero or less returns immediately. Implementations wait
|
|
68
|
+
through anyio, so a clock is usable on whichever async backend the
|
|
69
|
+
application runs on.
|
|
68
70
|
"""
|
|
69
71
|
...
|
|
70
72
|
|
|
@@ -113,30 +113,30 @@ class DatePoint(datetime):
|
|
|
113
113
|
@classmethod
|
|
114
114
|
def parse(
|
|
115
115
|
cls,
|
|
116
|
-
|
|
116
|
+
modifier: str,
|
|
117
117
|
timezone: str | tzinfo | None = None,
|
|
118
118
|
*,
|
|
119
119
|
reference: datetime | None = None,
|
|
120
120
|
) -> Self:
|
|
121
|
-
"""Return the instant ``
|
|
121
|
+
"""Return the instant ``modifier`` describes.
|
|
122
122
|
|
|
123
|
-
``
|
|
123
|
+
``modifier`` is read by the grammar documented on
|
|
124
124
|
:mod:`xtr_clock.modifier`: an offset like ``'+1 day'``, a keyword
|
|
125
125
|
like ``'tomorrow'``, an ISO-8601 datetime, a timezone name, or any
|
|
126
126
|
of them with a zone trailing.
|
|
127
127
|
|
|
128
128
|
Args:
|
|
129
|
-
|
|
129
|
+
modifier: The modifier to read.
|
|
130
130
|
timezone: The zone to read the reference in before applying
|
|
131
|
-
``
|
|
132
|
-
reference: The instant ``
|
|
131
|
+
``modifier``. A zone named inside it wins over this one.
|
|
132
|
+
reference: The instant ``modifier`` is relative to. Defaults to
|
|
133
133
|
whatever the clock in force says now is.
|
|
134
134
|
|
|
135
135
|
Returns:
|
|
136
136
|
The instant described.
|
|
137
137
|
|
|
138
138
|
Raises:
|
|
139
|
-
InvalidModifierError: When the grammar cannot read ``
|
|
139
|
+
InvalidModifierError: When the grammar cannot read ``modifier``.
|
|
140
140
|
InvalidTimezoneError: When ``timezone`` names no known zone.
|
|
141
141
|
"""
|
|
142
142
|
if reference is None:
|
|
@@ -148,7 +148,7 @@ class DatePoint(datetime):
|
|
|
148
148
|
if timezone is not None:
|
|
149
149
|
base = base.with_timezone(timezone)
|
|
150
150
|
|
|
151
|
-
return cls.from_datetime(apply_modifier(base,
|
|
151
|
+
return cls.from_datetime(apply_modifier(base, modifier))
|
|
152
152
|
|
|
153
153
|
@classmethod
|
|
154
154
|
def from_datetime(cls, value: datetime) -> Self:
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import asyncio
|
|
6
5
|
from datetime import UTC, datetime, timedelta
|
|
7
6
|
from typing import TYPE_CHECKING, Self, final
|
|
8
7
|
|
|
8
|
+
from anyio.lowlevel import checkpoint
|
|
9
|
+
|
|
9
10
|
from .date_point import DatePoint
|
|
10
11
|
from .timezone import resolve_timezone
|
|
11
12
|
|
|
@@ -99,11 +100,14 @@ class MockClock:
|
|
|
99
100
|
Nothing is waited for, but control is handed back once so the tasks
|
|
100
101
|
that were waiting on this clock get their turn — which is the whole
|
|
101
102
|
behaviour a test of concurrent code is trying to observe.
|
|
103
|
+
|
|
104
|
+
The yield goes through anyio, so this works on whichever async
|
|
105
|
+
backend the test runs on rather than only on asyncio.
|
|
102
106
|
"""
|
|
103
107
|
if seconds > 0:
|
|
104
108
|
self._advance(seconds)
|
|
105
109
|
|
|
106
|
-
await
|
|
110
|
+
await checkpoint()
|
|
107
111
|
|
|
108
112
|
def modify(self, modifier: str) -> None:
|
|
109
113
|
"""Move to wherever ``modifier`` points, relative to right here.
|
|
@@ -132,11 +132,11 @@ def apply_modifier(reference: datetime, modifier: str) -> datetime:
|
|
|
132
132
|
Raises:
|
|
133
133
|
InvalidModifierError: When the grammar cannot read ``modifier``.
|
|
134
134
|
"""
|
|
135
|
-
|
|
136
|
-
if not
|
|
135
|
+
text = modifier.strip()
|
|
136
|
+
if not text:
|
|
137
137
|
raise InvalidModifierError(modifier, "it is empty")
|
|
138
138
|
|
|
139
|
-
moment, rest = _split_timezone(reference,
|
|
139
|
+
moment, rest = _split_timezone(reference, text)
|
|
140
140
|
if not rest or rest.lower() == "now":
|
|
141
141
|
return moment
|
|
142
142
|
|
|
@@ -156,17 +156,17 @@ def apply_modifier(reference: datetime, modifier: str) -> datetime:
|
|
|
156
156
|
)
|
|
157
157
|
|
|
158
158
|
|
|
159
|
-
def _split_timezone(reference: datetime,
|
|
160
|
-
"""Split a trailing timezone off ``
|
|
159
|
+
def _split_timezone(reference: datetime, text: str) -> tuple[datetime, str]:
|
|
160
|
+
"""Split a trailing timezone off ``text``, moving ``reference`` into it.
|
|
161
161
|
|
|
162
162
|
The zone is applied before anything else, so an offset in the rest of
|
|
163
163
|
the modifier is counted in the zone the caller asked for.
|
|
164
164
|
"""
|
|
165
|
-
tokens =
|
|
165
|
+
tokens = text.split()
|
|
166
166
|
try:
|
|
167
167
|
zone = resolve_timezone(tokens[-1])
|
|
168
168
|
except InvalidTimezoneError:
|
|
169
|
-
return reference,
|
|
169
|
+
return reference, text
|
|
170
170
|
|
|
171
171
|
return reference.astimezone(zone), " ".join(tokens[:-1])
|
|
172
172
|
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import asyncio
|
|
6
5
|
import time
|
|
7
6
|
from copy import copy
|
|
8
7
|
from datetime import UTC, datetime, timedelta
|
|
9
8
|
from typing import TYPE_CHECKING, Final, Self, final
|
|
10
9
|
|
|
10
|
+
import anyio
|
|
11
|
+
|
|
11
12
|
from .date_point import DatePoint
|
|
12
13
|
from .timezone import local_timezone, resolve_timezone
|
|
13
14
|
|
|
@@ -62,8 +63,8 @@ class MonotonicClock:
|
|
|
62
63
|
|
|
63
64
|
def now(self) -> DatePoint:
|
|
64
65
|
"""Return the current instant, truncated to the microsecond."""
|
|
65
|
-
|
|
66
|
-
moment = _EPOCH + timedelta(microseconds=
|
|
66
|
+
since_epoch_ns = self._anchor_ns + time.monotonic_ns()
|
|
67
|
+
moment = _EPOCH + timedelta(microseconds=since_epoch_ns // _NANOSECONDS_PER_MICROSECOND)
|
|
67
68
|
|
|
68
69
|
return DatePoint.from_datetime(moment.astimezone(self._timezone))
|
|
69
70
|
|
|
@@ -73,8 +74,12 @@ class MonotonicClock:
|
|
|
73
74
|
time.sleep(seconds)
|
|
74
75
|
|
|
75
76
|
async def sleep_async(self, seconds: float) -> None:
|
|
76
|
-
"""Wait ``seconds`` without blocking the event loop.
|
|
77
|
-
|
|
77
|
+
"""Wait ``seconds`` without blocking the event loop.
|
|
78
|
+
|
|
79
|
+
The wait goes through anyio, so the clock works on whichever async
|
|
80
|
+
backend the application runs on rather than only on asyncio.
|
|
81
|
+
"""
|
|
82
|
+
await anyio.sleep(max(seconds, 0.0))
|
|
78
83
|
|
|
79
84
|
def with_timezone(self, timezone: str | tzinfo) -> Self:
|
|
80
85
|
"""Return the same clock reporting in ``timezone``.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""This directory holds the package's recipe: its manifest.
|
|
2
|
+
|
|
3
|
+
The recipe is what *Use in an application* in the README lists, written as
|
|
4
|
+
data: the bundle to list — usually left out, because the logging bundle
|
|
5
|
+
requires it — and the step only a person can take. An application applies it
|
|
6
|
+
with ``xtr-recipes recipes:sync``, which finds this module through the
|
|
7
|
+
``xtr_recipes`` entry point.
|
|
8
|
+
|
|
9
|
+
Nothing is importable from here. The manifest is read as a file, so a recipe
|
|
10
|
+
is the same from a wheel as from a working tree.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# The recipe for xtr-clock: what *Use in an application* in the README takes,
|
|
2
|
+
# as data. Keep the two in step — the README is the source.
|
|
3
|
+
|
|
4
|
+
[bundles]
|
|
5
|
+
# "<module>:<class>" = the environments it is listed with, exactly as in BUNDLES.
|
|
6
|
+
# Usually nothing is written: a sync leaves out a bundle another active one
|
|
7
|
+
# requires, and the logging bundle requires this one.
|
|
8
|
+
"xtr_clock.bundle:ClockBundle" = { all = true }
|
|
9
|
+
|
|
10
|
+
# No [files]: configuring is optional. With none the clock reports in the
|
|
11
|
+
# machine's own zone; an application fixing one writes config/clock.py itself.
|
|
12
|
+
|
|
13
|
+
# No [env], and nothing to ignore.
|
|
14
|
+
|
|
15
|
+
[gitignore]
|
|
16
|
+
lines = []
|
|
17
|
+
|
|
18
|
+
[notes]
|
|
19
|
+
check = ["<script> debug:bundles — clock is listed or required, and active"]
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import asyncio
|
|
6
5
|
import time
|
|
7
6
|
from datetime import datetime
|
|
8
7
|
from typing import TYPE_CHECKING, Self, final
|
|
9
8
|
|
|
9
|
+
import anyio
|
|
10
|
+
|
|
10
11
|
from .date_point import DatePoint
|
|
11
12
|
from .timezone import local_timezone, resolve_timezone
|
|
12
13
|
|
|
@@ -59,8 +60,12 @@ class SystemClock:
|
|
|
59
60
|
time.sleep(seconds)
|
|
60
61
|
|
|
61
62
|
async def sleep_async(self, seconds: float) -> None:
|
|
62
|
-
"""Wait ``seconds`` without blocking the event loop.
|
|
63
|
-
|
|
63
|
+
"""Wait ``seconds`` without blocking the event loop.
|
|
64
|
+
|
|
65
|
+
The wait goes through anyio, so the clock works on whichever async
|
|
66
|
+
backend the application runs on rather than only on asyncio.
|
|
67
|
+
"""
|
|
68
|
+
await anyio.sleep(max(seconds, 0.0))
|
|
64
69
|
|
|
65
70
|
def with_timezone(self, timezone: str | tzinfo) -> Self:
|
|
66
71
|
"""Return the same clock reporting in ``timezone``.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|