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.
Files changed (30) hide show
  1. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/LICENSE +1 -0
  2. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/PKG-INFO +22 -14
  3. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/README.md +19 -12
  4. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/pyproject.toml +8 -5
  5. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/pyproject.toml.orig +11 -5
  6. xtr_clock-4.0.2/src/xtr_clock/.agents/skills/xtr-clock/SKILL.md +160 -0
  7. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/bundle/clock_config.py +12 -0
  8. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/calendar_shift.py +6 -0
  9. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/clock_interface.py +3 -1
  10. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/date_point.py +8 -8
  11. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/mock_clock.py +6 -2
  12. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/modifier.py +7 -7
  13. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/monotonic_clock.py +10 -5
  14. xtr_clock-4.0.2/src/xtr_clock/recipe/__init__.py +15 -0
  15. xtr_clock-4.0.2/src/xtr_clock/recipe/manifest.toml +19 -0
  16. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/system_clock.py +8 -3
  17. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/__init__.py +0 -0
  18. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/bundle/__init__.py +0 -0
  19. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/bundle/clock_bundle.py +0 -0
  20. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/clock.py +0 -0
  21. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/clock_aware_mixin.py +0 -0
  22. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/exception/__init__.py +0 -0
  23. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/exception/clock_error.py +0 -0
  24. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/exception/invalid_modifier_error.py +0 -0
  25. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/exception/invalid_timezone_error.py +0 -0
  26. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/now.py +0 -0
  27. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/py.typed +0 -0
  28. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/pytest_plugin.py +0 -0
  29. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/testing.py +0 -0
  30. {xtr_clock-2.0.0 → xtr_clock-4.0.2}/src/xtr_clock/timezone.py +0 -0
@@ -1,6 +1,7 @@
1
1
  MIT License
2
2
 
3
3
  Copyright (c) 2026 xterr
4
+ Copyright (c) 2022-present Fabien Potencier
4
5
 
5
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
7
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xtr-clock
3
- Version: 2.0.0
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>=2.0,<3 ; extra == 'di'
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: 0" src="https://img.shields.io/badge/core%20deps-0-3FB950">
34
- <img alt="coverage 100%" src="https://img.shields.io/badge/coverage-100%25-3FB950">
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
- - 🪶 **No dependencies.** Not one. `zoneinfo`, `contextvars` and `datetime` are already there.
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)* | — | The whole library |
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 src tests
526
- uv run ruff format --check src tests
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. Statement and branch coverage are both 100%, measured with `coverage run`
539
- rather than `pytest --cov`, which starts too late to see a module's import.
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: 0" src="https://img.shields.io/badge/core%20deps-0-3FB950">
9
- <img alt="coverage 100%" src="https://img.shields.io/badge/coverage-100%25-3FB950">
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
- - 🪶 **No dependencies.** Not one. `zoneinfo`, `contextvars` and `datetime` are already there.
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)* | — | The whole library |
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 src tests
501
- uv run ruff format --check src tests
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. Statement and branch coverage are both 100%, measured with `coverage run`
514
- rather than `pytest --cov`, which starts too late to see a module's import.
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 = "2.0.0"
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>=2.0,<3",
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
- "anyio>=4.0",
53
+ "trio>=0.27",
51
54
  "ty>=0.0.83",
52
- "xtr-dependency-injection>=2.0,<3",
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 = "2.0.0"
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>=2.0,<3",
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
- "anyio>=4.0",
56
+ "trio>=0.27",
51
57
  "ty>=0.0.83",
52
- "xtr-dependency-injection>=2.0,<3",
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
- spec: str,
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 ``spec`` describes.
121
+ """Return the instant ``modifier`` describes.
122
122
 
123
- ``spec`` is read by the grammar documented on
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
- spec: The modifier to read.
129
+ modifier: The modifier to read.
130
130
  timezone: The zone to read the reference in before applying
131
- ``spec``. A zone named inside ``spec`` wins over this one.
132
- reference: The instant ``spec`` is relative to. Defaults to
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 ``spec``.
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, spec))
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 asyncio.sleep(0)
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
- spec = modifier.strip()
136
- if not spec:
135
+ text = modifier.strip()
136
+ if not text:
137
137
  raise InvalidModifierError(modifier, "it is empty")
138
138
 
139
- moment, rest = _split_timezone(reference, spec)
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, spec: str) -> tuple[datetime, str]:
160
- """Split a trailing timezone off ``spec``, moving ``reference`` into it.
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 = spec.split()
165
+ tokens = text.split()
166
166
  try:
167
167
  zone = resolve_timezone(tokens[-1])
168
168
  except InvalidTimezoneError:
169
- return reference, spec
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
- elapsed = self._anchor_ns + time.monotonic_ns()
66
- moment = _EPOCH + timedelta(microseconds=elapsed // _NANOSECONDS_PER_MICROSECOND)
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
- await asyncio.sleep(max(seconds, 0.0))
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
- await asyncio.sleep(max(seconds, 0.0))
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``.