clock-pattern 0.10.0__tar.gz → 0.11.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.
Files changed (58) hide show
  1. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/PKG-INFO +26 -10
  2. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/README.md +24 -9
  3. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/__init__.py +1 -1
  4. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/clocks/testing/fixed_clock.py +58 -4
  5. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/deadlines/models/deadline.py +1 -2
  6. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/deadlines/system_deadline.py +3 -4
  7. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/deadlines/testing/mock_deadline.py +0 -1
  8. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/pollers/models/poller.py +3 -0
  9. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/pollers/models/poller_async.py +4 -0
  10. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/pollers/system_poller.py +6 -0
  11. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/pollers/system_poller_async.py +48 -1
  12. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/retriers/models/retrier.py +3 -0
  13. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/retriers/models/retrier_async.py +3 -0
  14. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/retriers/system_retrier.py +11 -2
  15. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/retriers/system_retrier_async.py +11 -2
  16. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/retriers/testing/mock_retrier.py +18 -0
  17. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/retriers/testing/mock_retrier_async.py +18 -0
  18. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/sleepers/models/sleeper.py +3 -3
  19. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/sleepers/models/sleeper_async.py +5 -3
  20. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/sleepers/system_sleeper.py +4 -4
  21. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/sleepers/system_sleeper_async.py +14 -7
  22. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/sleepers/testing/mock_sleeper_async.py +9 -2
  23. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/pyproject.toml +24 -14
  24. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/.gitignore +0 -0
  25. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/LICENSE.md +0 -0
  26. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/clocks/__init__.py +0 -0
  27. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/clocks/models/__init__.py +0 -0
  28. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/clocks/models/clock.py +0 -0
  29. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/clocks/system_clock.py +0 -0
  30. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/clocks/testing/__init__.py +0 -0
  31. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/clocks/testing/mock_clock.py +0 -0
  32. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/clocks/utc_clock.py +0 -0
  33. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/deadlines/__init__.py +0 -0
  34. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/deadlines/errors/__init__.py +0 -0
  35. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/deadlines/errors/timeout_expired_error.py +0 -0
  36. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/deadlines/models/__init__.py +0 -0
  37. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/deadlines/testing/__init__.py +0 -0
  38. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/monotonic_clocks/__init__.py +0 -0
  39. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/monotonic_clocks/models/__init__.py +0 -0
  40. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/monotonic_clocks/models/monotonic_clock.py +0 -0
  41. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/monotonic_clocks/system_monotonic_clock.py +0 -0
  42. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/monotonic_clocks/testing/__init__.py +0 -0
  43. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/monotonic_clocks/testing/mock_monotonic_clock.py +0 -0
  44. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/pollers/__init__.py +0 -0
  45. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/pollers/models/__init__.py +0 -0
  46. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/pollers/testing/__init__.py +0 -0
  47. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/pollers/testing/mock_poller.py +0 -0
  48. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/pollers/testing/mock_poller_async.py +0 -0
  49. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/py.typed +0 -0
  50. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/retriers/__init__.py +0 -0
  51. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/retriers/models/__init__.py +0 -0
  52. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/retriers/testing/__init__.py +0 -0
  53. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/sleepers/__init__.py +0 -0
  54. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/sleepers/models/__init__.py +0 -0
  55. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/sleepers/testing/__init__.py +0 -0
  56. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/sleepers/testing/mock_sleeper.py +0 -0
  57. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/stopwatches/__init__.py +0 -0
  58. {clock_pattern-0.10.0 → clock_pattern-0.11.0}/clock_pattern/stopwatches/stopwatch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: clock-pattern
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: The Clock Pattern is a Python package that turns time into an injectable dependency.
5
5
  Project-URL: Homepage, https://github.com/adriamontoto/clock-pattern
6
6
  Project-URL: Repository, https://github.com/adriamontoto/clock-pattern
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: 3.13
20
20
  Classifier: Programming Language :: Python :: 3.14
21
21
  Classifier: Typing :: Typed
22
22
  Requires-Python: >=3.11
23
+ Requires-Dist: typing-extensions>=4.4.0; python_version < '3.12'
23
24
  Requires-Dist: value-object-pattern>=1.36.0
24
25
  Description-Content-Type: text/markdown
25
26
 
@@ -43,9 +44,6 @@ Description-Content-Type: text/markdown
43
44
  <a href="https://pepy.tech/projects/clock-pattern" target="_blank">
44
45
  <img src="https://static.pepy.tech/badge/clock-pattern/month" alt="Package Downloads">
45
46
  </a>
46
- <a href="https://deepwiki.com/adriamontoto/clock-pattern" target="_blank">
47
- <img src="https://img.shields.io/badge/DeepWiki-adriamontoto%2Fclock--pattern-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==" alt="Project Documentation">
48
- </a>
49
47
  </p>
50
48
 
51
49
  The **Clock Pattern** is a Python 🐍 package that turns time into an injectable dependency 🧩. Instead of scattering
@@ -103,10 +101,9 @@ The root README is the entry point. Deeper guides live in this repository and ar
103
101
  - [`docs/README.md`](docs/README.md): Documentation hub.
104
102
  - [`docs/usage/README.md`](docs/usage/README.md): Core usage patterns and service composition.
105
103
  - [`docs/timezones/README.md`](docs/timezones/README.md): Timezone behavior, UTC defaults, and date-boundary guidance.
104
+ - [`docs/examples/README.md`](docs/examples/README.md): Token expiration, cache TTL, polling, and retry recipes.
106
105
  - [`docs/testing/README.md`](docs/testing/README.md): `FixedClock`, `MockClock`, and deterministic test patterns.
107
106
 
108
- This [project's DeepWiki documentation](https://deepwiki.com/adriamontoto/clock-pattern) is also available for generated
109
- repository navigation.
110
107
 
111
108
  <p align="right">
112
109
  <a href="#readme-top">🔼 Back to top</a>
@@ -214,7 +211,7 @@ Use the top-level package for contracts and production helpers, and each feature
214
211
 
215
212
  | API | Import path | Purpose |
216
213
  | --- | --- | --- |
217
- | [`FixedClock`](https://github.com/adriamontoto/clock-pattern/blob/master/clock_pattern/clocks/testing/fixed_clock.py) | `from clock_pattern.clocks.testing import FixedClock` | Test clock that always returns the same datetime and derived date. |
214
+ | [`FixedClock`](https://github.com/adriamontoto/clock-pattern/blob/master/clock_pattern/clocks/testing/fixed_clock.py) | `from clock_pattern.clocks.testing import FixedClock` | Test clock that stays fixed until explicitly set or advanced. |
218
215
  | [`MockClock`](https://github.com/adriamontoto/clock-pattern/blob/master/clock_pattern/clocks/testing/mock_clock.py) | `from clock_pattern.clocks.testing import MockClock` | Test clock with prepared return values and call assertions. |
219
216
  | `MockMonotonicClock` | `from clock_pattern.monotonic_clocks.testing import MockMonotonicClock` | Controllable elapsed-time source with call assertions. |
220
217
  | `MockDeadline` | `from clock_pattern.deadlines.testing import MockDeadline` | Controllable deadline with expiry call assertions. |
@@ -223,26 +220,43 @@ Use the top-level package for contracts and production helpers, and each feature
223
220
  | `MockRetrier` / `MockRetrierAsync` | `from clock_pattern.retriers.testing import MockRetrier, MockRetrierAsync` | Retry test doubles with prepared results. |
224
221
 
225
222
  ```python
226
- from clock_pattern import Stopwatch, SystemDeadline, SystemMonotonicClock, SystemPoller, SystemRetrier, SystemSleeper
223
+ from clock_pattern import Stopwatch, SystemMonotonicClock, SystemSleeper
227
224
 
228
225
  monotonic_clock = SystemMonotonicClock()
229
226
  sleeper = SystemSleeper(monotonic_clock=monotonic_clock)
230
- poller = SystemPoller(sleeper=sleeper, monotonic_clock=monotonic_clock)
231
227
 
232
228
  with Stopwatch(monotonic_clock=monotonic_clock) as stopwatch:
233
229
  pass
234
230
 
235
- with sleeper.minimum_duration(seconds=2):
231
+ with sleeper.minimum_duration(seconds=0.001):
236
232
  pass
233
+ ```
234
+
235
+ The deadline context requires a Unix main thread:
237
236
 
237
+ ```python unix
238
+ from clock_pattern import SystemDeadline, SystemMonotonicClock
239
+
240
+ monotonic_clock = SystemMonotonicClock()
238
241
  with SystemDeadline(seconds=5, monotonic_clock=monotonic_clock):
239
242
  pass
243
+ ```
244
+
245
+ Continue with polling and retries on all platforms:
246
+
247
+ ```python
248
+ from clock_pattern import SystemMonotonicClock, SystemPoller, SystemRetrier, SystemSleeper
249
+
250
+ monotonic_clock = SystemMonotonicClock()
251
+ sleeper = SystemSleeper(monotonic_clock=monotonic_clock)
252
+ poller = SystemPoller(sleeper=sleeper, monotonic_clock=monotonic_clock)
240
253
 
241
254
  poller.poll_until(condition=lambda: True, timeout_seconds=5, interval_seconds=0.1)
242
255
  SystemRetrier(sleeper=sleeper).retry(
243
256
  operation=lambda: 'done',
244
257
  attempts=3,
245
258
  delay_seconds=0.2,
259
+ max_delay_seconds=2,
246
260
  backoff=2,
247
261
  jitter=True,
248
262
  )
@@ -321,6 +335,8 @@ clock.assert_today_method_was_called_once()
321
335
  clock.assert_now_method_was_not_called()
322
336
  ```
323
337
 
338
+ `FixedClock.advance(delta=timedelta(...))` moves time forward explicitly; `set(instant=...)` replaces it, including backward jumps. Both keep `now()` and `today()` consistent.
339
+
324
340
  More testing recipes are available in [`docs/testing/README.md`](docs/testing/README.md).
325
341
 
326
342
  <p align="right">
@@ -18,9 +18,6 @@
18
18
  <a href="https://pepy.tech/projects/clock-pattern" target="_blank">
19
19
  <img src="https://static.pepy.tech/badge/clock-pattern/month" alt="Package Downloads">
20
20
  </a>
21
- <a href="https://deepwiki.com/adriamontoto/clock-pattern" target="_blank">
22
- <img src="https://img.shields.io/badge/DeepWiki-adriamontoto%2Fclock--pattern-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==" alt="Project Documentation">
23
- </a>
24
21
  </p>
25
22
 
26
23
  The **Clock Pattern** is a Python 🐍 package that turns time into an injectable dependency 🧩. Instead of scattering
@@ -78,10 +75,9 @@ The root README is the entry point. Deeper guides live in this repository and ar
78
75
  - [`docs/README.md`](docs/README.md): Documentation hub.
79
76
  - [`docs/usage/README.md`](docs/usage/README.md): Core usage patterns and service composition.
80
77
  - [`docs/timezones/README.md`](docs/timezones/README.md): Timezone behavior, UTC defaults, and date-boundary guidance.
78
+ - [`docs/examples/README.md`](docs/examples/README.md): Token expiration, cache TTL, polling, and retry recipes.
81
79
  - [`docs/testing/README.md`](docs/testing/README.md): `FixedClock`, `MockClock`, and deterministic test patterns.
82
80
 
83
- This [project's DeepWiki documentation](https://deepwiki.com/adriamontoto/clock-pattern) is also available for generated
84
- repository navigation.
85
81
 
86
82
  <p align="right">
87
83
  <a href="#readme-top">🔼 Back to top</a>
@@ -189,7 +185,7 @@ Use the top-level package for contracts and production helpers, and each feature
189
185
 
190
186
  | API | Import path | Purpose |
191
187
  | --- | --- | --- |
192
- | [`FixedClock`](https://github.com/adriamontoto/clock-pattern/blob/master/clock_pattern/clocks/testing/fixed_clock.py) | `from clock_pattern.clocks.testing import FixedClock` | Test clock that always returns the same datetime and derived date. |
188
+ | [`FixedClock`](https://github.com/adriamontoto/clock-pattern/blob/master/clock_pattern/clocks/testing/fixed_clock.py) | `from clock_pattern.clocks.testing import FixedClock` | Test clock that stays fixed until explicitly set or advanced. |
193
189
  | [`MockClock`](https://github.com/adriamontoto/clock-pattern/blob/master/clock_pattern/clocks/testing/mock_clock.py) | `from clock_pattern.clocks.testing import MockClock` | Test clock with prepared return values and call assertions. |
194
190
  | `MockMonotonicClock` | `from clock_pattern.monotonic_clocks.testing import MockMonotonicClock` | Controllable elapsed-time source with call assertions. |
195
191
  | `MockDeadline` | `from clock_pattern.deadlines.testing import MockDeadline` | Controllable deadline with expiry call assertions. |
@@ -198,26 +194,43 @@ Use the top-level package for contracts and production helpers, and each feature
198
194
  | `MockRetrier` / `MockRetrierAsync` | `from clock_pattern.retriers.testing import MockRetrier, MockRetrierAsync` | Retry test doubles with prepared results. |
199
195
 
200
196
  ```python
201
- from clock_pattern import Stopwatch, SystemDeadline, SystemMonotonicClock, SystemPoller, SystemRetrier, SystemSleeper
197
+ from clock_pattern import Stopwatch, SystemMonotonicClock, SystemSleeper
202
198
 
203
199
  monotonic_clock = SystemMonotonicClock()
204
200
  sleeper = SystemSleeper(monotonic_clock=monotonic_clock)
205
- poller = SystemPoller(sleeper=sleeper, monotonic_clock=monotonic_clock)
206
201
 
207
202
  with Stopwatch(monotonic_clock=monotonic_clock) as stopwatch:
208
203
  pass
209
204
 
210
- with sleeper.minimum_duration(seconds=2):
205
+ with sleeper.minimum_duration(seconds=0.001):
211
206
  pass
207
+ ```
208
+
209
+ The deadline context requires a Unix main thread:
212
210
 
211
+ ```python unix
212
+ from clock_pattern import SystemDeadline, SystemMonotonicClock
213
+
214
+ monotonic_clock = SystemMonotonicClock()
213
215
  with SystemDeadline(seconds=5, monotonic_clock=monotonic_clock):
214
216
  pass
217
+ ```
218
+
219
+ Continue with polling and retries on all platforms:
220
+
221
+ ```python
222
+ from clock_pattern import SystemMonotonicClock, SystemPoller, SystemRetrier, SystemSleeper
223
+
224
+ monotonic_clock = SystemMonotonicClock()
225
+ sleeper = SystemSleeper(monotonic_clock=monotonic_clock)
226
+ poller = SystemPoller(sleeper=sleeper, monotonic_clock=monotonic_clock)
215
227
 
216
228
  poller.poll_until(condition=lambda: True, timeout_seconds=5, interval_seconds=0.1)
217
229
  SystemRetrier(sleeper=sleeper).retry(
218
230
  operation=lambda: 'done',
219
231
  attempts=3,
220
232
  delay_seconds=0.2,
233
+ max_delay_seconds=2,
221
234
  backoff=2,
222
235
  jitter=True,
223
236
  )
@@ -296,6 +309,8 @@ clock.assert_today_method_was_called_once()
296
309
  clock.assert_now_method_was_not_called()
297
310
  ```
298
311
 
312
+ `FixedClock.advance(delta=timedelta(...))` moves time forward explicitly; `set(instant=...)` replaces it, including backward jumps. Both keep `now()` and `today()` consistent.
313
+
299
314
  More testing recipes are available in [`docs/testing/README.md`](docs/testing/README.md).
300
315
 
301
316
  <p align="right">
@@ -1,4 +1,4 @@
1
- __version__ = '0.10.0'
1
+ __version__ = '0.11.0'
2
2
 
3
3
  from .clocks import Clock, SystemClock, UtcClock
4
4
  from .deadlines import Deadline, SystemDeadline, TimeoutExpiredError
@@ -1,5 +1,5 @@
1
1
  """
2
- Deterministic clock that always returns the same instant.
2
+ Deterministic clock that stays fixed until explicitly adjusted.
3
3
  """
4
4
 
5
5
  from sys import version_info
@@ -9,7 +9,7 @@ if version_info >= (3, 12):
9
9
  else:
10
10
  from typing_extensions import override # pragma: no cover
11
11
 
12
- from datetime import UTC, date, datetime
12
+ from datetime import UTC, date, datetime, timedelta
13
13
 
14
14
  from value_object_pattern.usables.dates import DatetimeValueObject
15
15
 
@@ -21,7 +21,8 @@ class FixedClock(Clock):
21
21
  Return a fixed datetime and the date derived from it.
22
22
 
23
23
  `FixedClock` is useful when a test only needs stable time. Naive datetimes are normalized to UTC during
24
- initialization. Use `MockClock` when a test also needs to assert whether `now()` or `today()` was called.
24
+ initialization and `set()`. Use `advance()` to simulate elapsed time, or `set()` to choose another instant.
25
+ Use `MockClock` when a test also needs to assert whether `now()` or `today()` was called.
25
26
 
26
27
  Example:
27
28
  ```python
@@ -45,7 +46,7 @@ class FixedClock(Clock):
45
46
  If `instant` is naive, UTC is added as its timezone. A timezone-aware `instant` is preserved as provided.
46
47
 
47
48
  Args:
48
- instant: Datetime returned by `now()` and used by `today()`.
49
+ instant (datetime): Datetime returned by `now()` and used by `today()`.
49
50
 
50
51
  Raises:
51
52
  TypeError: If `instant` is not a datetime.
@@ -62,6 +63,30 @@ class FixedClock(Clock):
62
63
  # >>> 1999-01-01 00:00:00+00:00
63
64
  ```
64
65
  """
66
+ self.set(instant=instant)
67
+
68
+ def set(self, *, instant: datetime) -> None:
69
+ """
70
+ Set the instant returned by both clock methods, allowing forward or backward jumps.
71
+
72
+ Args:
73
+ instant (datetime): Datetime returned by `now()` and used by `today()`.
74
+
75
+ Raises:
76
+ TypeError: If `instant` is not a datetime.
77
+
78
+ Example:
79
+ ```python
80
+ from datetime import UTC, datetime
81
+
82
+ from clock_pattern.clocks.testing import FixedClock
83
+
84
+ clock = FixedClock(instant=datetime(2025, 2, 1, tzinfo=UTC))
85
+ clock.set(instant=datetime(2025, 1, 1, tzinfo=UTC))
86
+ print(clock.now())
87
+ # >>> 2025-01-01 00:00:00+00:00
88
+ ```
89
+ """
65
90
  DatetimeValueObject(value=instant, title='FixedClock', parameter='instant')
66
91
 
67
92
  if instant.tzinfo is None:
@@ -69,6 +94,35 @@ class FixedClock(Clock):
69
94
 
70
95
  self._instant = instant
71
96
 
97
+ def advance(self, *, delta: timedelta) -> None:
98
+ """
99
+ Advance by a non-negative elapsed duration, preserving the clock's timezone.
100
+
101
+ Args:
102
+ delta (timedelta): Non-negative elapsed duration to add to the instant.
103
+
104
+ Raises:
105
+ ValueError: If `delta` is negative.
106
+ OverflowError: If the resulting instant is outside datetime's range.
107
+
108
+ Example:
109
+ ```python
110
+ from datetime import UTC, datetime, timedelta
111
+
112
+ from clock_pattern.clocks.testing import FixedClock
113
+
114
+ clock = FixedClock(instant=datetime(2025, 12, 31, 23, 59, tzinfo=UTC))
115
+ clock.advance(delta=timedelta(minutes=2))
116
+ print(clock.now())
117
+ # >>> 2026-01-01 00:01:00+00:00
118
+ ```
119
+ """
120
+ if delta < timedelta(0):
121
+ raise ValueError(f'FixedClock delta <<<{delta}>>> must be greater than or equal to zero.')
122
+
123
+ if delta:
124
+ self._instant = (self._instant.astimezone(UTC) + delta).astimezone(self._instant.tzinfo)
125
+
72
126
  @override
73
127
  def now(self) -> datetime:
74
128
  """
@@ -11,7 +11,7 @@ class Deadline(AbstractContextManager['Deadline']):
11
11
  Define the interface for injectable deadlines.
12
12
 
13
13
  Example:
14
- ```python
14
+ ```python unix
15
15
  from clock_pattern import SystemDeadline, SystemMonotonicClock
16
16
 
17
17
  with SystemDeadline(seconds=1, monotonic_clock=SystemMonotonicClock()) as deadline:
@@ -101,6 +101,5 @@ class Deadline(AbstractContextManager['Deadline']):
101
101
  monotonic_clock = MockMonotonicClock()
102
102
  deadline = SystemDeadline(seconds=1, monotonic_clock=monotonic_clock)
103
103
  monotonic_clock.advance(seconds=1)
104
- deadline.raise_if_expired()
105
104
  ```
106
105
  """
@@ -30,7 +30,7 @@ class SystemDeadline(Deadline):
30
30
  Expose monotonic deadline state and enforce Unix main-thread context timeouts.
31
31
 
32
32
  Example:
33
- ```python
33
+ ```python unix
34
34
  from clock_pattern import SystemDeadline, SystemMonotonicClock
35
35
 
36
36
  with SystemDeadline(seconds=1, monotonic_clock=SystemMonotonicClock()) as deadline:
@@ -160,7 +160,6 @@ class SystemDeadline(Deadline):
160
160
  monotonic_clock = MockMonotonicClock()
161
161
  deadline = SystemDeadline(seconds=1, monotonic_clock=monotonic_clock)
162
162
  monotonic_clock.advance(seconds=1)
163
- deadline.raise_if_expired()
164
163
  ```
165
164
  """
166
165
  elapsed_seconds = self.elapsed_seconds
@@ -183,7 +182,7 @@ class SystemDeadline(Deadline):
183
182
  Self: This deadline instance.
184
183
 
185
184
  Example:
186
- ```python
185
+ ```python unix
187
186
  from clock_pattern import SystemDeadline, SystemMonotonicClock
188
187
 
189
188
  with SystemDeadline(seconds=1, monotonic_clock=SystemMonotonicClock()) as deadline:
@@ -252,7 +251,7 @@ class SystemDeadline(Deadline):
252
251
  bool | None: `None`, so exceptions from the managed block are not suppressed.
253
252
 
254
253
  Example:
255
- ```python
254
+ ```python unix
256
255
  from clock_pattern import SystemDeadline, SystemMonotonicClock
257
256
 
258
257
  with SystemDeadline(seconds=1, monotonic_clock=SystemMonotonicClock()) as deadline:
@@ -138,7 +138,6 @@ class MockDeadline(Deadline):
138
138
 
139
139
  deadline = MockDeadline(seconds=1)
140
140
  deadline.advance(seconds=1)
141
- deadline.raise_if_expired()
142
141
  ```
143
142
  """
144
143
  self._raise_if_expired_mock()
@@ -41,6 +41,9 @@ class Poller(ABC):
41
41
  """
42
42
  Poll a condition until it succeeds or the timeout expires.
43
43
 
44
+ Timeout checks are cooperative: conditions are not interrupted. Success at the boundary is accepted;
45
+ success after a positive timeout is rejected. A zero timeout evaluates the condition once.
46
+
44
47
  Args:
45
48
  condition (Callable[[], bool]): Condition checked until it returns `True`.
46
49
  timeout_seconds (float): Maximum duration to wait.
@@ -41,6 +41,10 @@ class PollerAsync(ABC):
41
41
  """
42
42
  Poll a condition until it succeeds or the timeout expires.
43
43
 
44
+ The timeout covers condition evaluation and sleeping. Overdue awaits are cancelled; synchronous blocking code
45
+ cannot be interrupted. Conditions must propagate cancellation. Injected-clock checks reject success after a
46
+ positive timeout. A zero timeout permits one evaluation only if it completes without suspending.
47
+
44
48
  Args:
45
49
  condition (Callable[[], bool | Awaitable[bool]]): Sync or async condition checked until it succeeds.
46
50
  timeout_seconds (float): Maximum duration to wait.
@@ -69,6 +69,9 @@ class SystemPoller(Poller):
69
69
  """
70
70
  Poll `condition` until it returns `True`.
71
71
 
72
+ Conditions run cooperatively and are not interrupted. A successful result at the timeout boundary is
73
+ accepted, but a result after a positive timeout raises. A zero timeout evaluates the condition once.
74
+
72
75
  Args:
73
76
  condition (Callable[[], bool]): Condition checked until it returns `True`.
74
77
  timeout_seconds (float): Maximum duration to wait.
@@ -101,6 +104,9 @@ class SystemPoller(Poller):
101
104
  deadline = SystemDeadline(seconds=timeout_seconds, monotonic_clock=self._monotonic_clock)
102
105
  while True:
103
106
  condition_result = BooleanValueObject(value=condition(), title='SystemPoller', parameter='condition').value
107
+ if timeout_seconds > 0 and deadline.elapsed_seconds > timeout_seconds:
108
+ deadline.raise_if_expired()
109
+
104
110
  if condition_result:
105
111
  return
106
112
 
@@ -9,12 +9,13 @@ if version_info >= (3, 12):
9
9
  else:
10
10
  from typing_extensions import override # pragma: no cover
11
11
 
12
+ from asyncio import timeout
12
13
  from collections.abc import Awaitable, Callable
13
14
  from inspect import isawaitable
14
15
 
15
16
  from value_object_pattern.usables import BooleanValueObject, PositiveNumberValueObject, PositiveOrZeroNumberValueObject
16
17
 
17
- from clock_pattern.deadlines import SystemDeadline
18
+ from clock_pattern.deadlines import SystemDeadline, TimeoutExpiredError
18
19
  from clock_pattern.monotonic_clocks.models import MonotonicClock
19
20
  from clock_pattern.pollers.models import PollerAsync
20
21
  from clock_pattern.sleepers.models import SleeperAsync
@@ -70,6 +71,11 @@ class SystemPollerAsync(PollerAsync):
70
71
  """
71
72
  Poll `condition` until it returns or awaits to `True`.
72
73
 
74
+ The timeout covers condition evaluation and sleeping, cancelling overdue awaits through asyncio. Blocking
75
+ synchronous code and coroutines that suppress cancellation cannot be forcibly interrupted. Injected-clock checks
76
+ also reject results after a positive timeout. A zero timeout allows one immediate evaluation, but cancels it if
77
+ it suspends. External cancellation and condition exceptions propagate.
78
+
73
79
  Args:
74
80
  condition (Callable[[], bool | Awaitable[bool]]): Sync or async condition checked until it is true.
75
81
  timeout_seconds (float): Maximum duration to wait.
@@ -100,6 +106,44 @@ class SystemPollerAsync(PollerAsync):
100
106
  PositiveNumberValueObject(value=interval_seconds, title='SystemPollerAsync', parameter='interval_seconds')
101
107
 
102
108
  deadline = SystemDeadline(seconds=timeout_seconds, monotonic_clock=self._monotonic_clock)
109
+
110
+ timeout_context = timeout(timeout_seconds)
111
+ try:
112
+ async with timeout_context:
113
+ await self._poll_until(
114
+ condition=condition,
115
+ deadline=deadline,
116
+ timeout_seconds=timeout_seconds,
117
+ interval_seconds=interval_seconds,
118
+ )
119
+
120
+ except TimeoutError as error:
121
+ if timeout_context.expired():
122
+ raise TimeoutExpiredError(elapsed_seconds=deadline.elapsed_seconds) from error
123
+
124
+ raise
125
+
126
+ async def _poll_until(
127
+ self,
128
+ *,
129
+ condition: Callable[[], bool | Awaitable[bool]],
130
+ deadline: SystemDeadline,
131
+ timeout_seconds: float,
132
+ interval_seconds: float,
133
+ ) -> None:
134
+ """
135
+ Poll using the injected clock while the enclosing asyncio timeout bounds awaits.
136
+
137
+ Args:
138
+ condition (Callable[[], bool | Awaitable[bool]]): Sync or async condition checked until it is true.
139
+ deadline (SystemDeadline): Deadline used to check for timeout expiration.
140
+ timeout_seconds (float): Maximum duration to wait.
141
+ interval_seconds (float): Duration between condition checks.
142
+
143
+ Raises:
144
+ TypeError: If `condition` does not return a boolean.
145
+ TimeoutExpiredError: If the timeout expires before `condition` returns `True`.
146
+ """
103
147
  while True:
104
148
  condition_result = condition()
105
149
 
@@ -107,6 +151,9 @@ class SystemPollerAsync(PollerAsync):
107
151
  condition_result = await condition_result
108
152
 
109
153
  condition_result = BooleanValueObject(value=condition_result, title='SystemPollerAsync', parameter='condition').value # noqa: E501 # fmt: skip
154
+ if timeout_seconds > 0 and deadline.elapsed_seconds > timeout_seconds:
155
+ deadline.raise_if_expired()
156
+
110
157
  if condition_result:
111
158
  return
112
159
 
@@ -42,6 +42,7 @@ class Retrier(ABC):
42
42
  operation: Callable[[], T],
43
43
  attempts: int,
44
44
  delay_seconds: float = 0.0,
45
+ max_delay_seconds: float | None = None,
45
46
  backoff: float = 1.0,
46
47
  jitter: bool = False,
47
48
  retry_on: type[Exception] | tuple[type[Exception], ...] = Exception,
@@ -54,6 +55,8 @@ class Retrier(ABC):
54
55
  attempts (int): Maximum number of attempts, including the first call.
55
56
  delay_seconds (float, optional): Finite, non-negative initial delay between failed attempts. Defaults
56
57
  to 0.0 seconds.
58
+ max_delay_seconds (float | None, optional): Finite, non-negative delay cap applied before jitter. Defaults
59
+ to `None` (uncapped). Zero disables sleeping. Backoff grows from the capped delay.
57
60
  backoff (float, optional): Finite, positive multiplier applied to the delay after each failed attempt.
58
61
  Defaults to 1.0 (no backoff).
59
62
  jitter (bool, optional): Whether to randomize each delay. Defaults to `False`.
@@ -45,6 +45,7 @@ class RetrierAsync(ABC):
45
45
  operation: Callable[[], Awaitable[T]],
46
46
  attempts: int,
47
47
  delay_seconds: float = 0.0,
48
+ max_delay_seconds: float | None = None,
48
49
  backoff: float = 1.0,
49
50
  jitter: bool = False,
50
51
  retry_on: type[Exception] | tuple[type[Exception], ...] = Exception,
@@ -57,6 +58,8 @@ class RetrierAsync(ABC):
57
58
  attempts (int): Maximum number of attempts, including the first call.
58
59
  delay_seconds (float, optional): Finite, non-negative initial delay between failed attempts. Defaults
59
60
  to 0.0 seconds.
61
+ max_delay_seconds (float | None, optional): Finite, non-negative delay cap applied before jitter. Defaults
62
+ to `None` (uncapped). Zero disables sleeping. Backoff grows from the capped delay.
60
63
  backoff (float, optional): Finite, positive multiplier applied to the delay after each failed attempt.
61
64
  Defaults to 1.0 (no backoff).
62
65
  jitter (bool, optional): Whether to randomize each delay. Defaults to `False`.
@@ -73,6 +73,7 @@ class SystemRetrier(Retrier):
73
73
  operation: Callable[[], T],
74
74
  attempts: int,
75
75
  delay_seconds: float = 0.0,
76
+ max_delay_seconds: float | None = None,
76
77
  backoff: float = 1.0,
77
78
  jitter: bool = False,
78
79
  retry_on: type[Exception] | tuple[type[Exception], ...] = Exception,
@@ -85,6 +86,8 @@ class SystemRetrier(Retrier):
85
86
  attempts (int): Maximum number of attempts, including the first call.
86
87
  delay_seconds (float, optional): Finite, non-negative initial delay between failed attempts. Defaults
87
88
  to 0.0 seconds.
89
+ max_delay_seconds (float | None, optional): Finite, non-negative delay cap applied before jitter. Defaults
90
+ to `None` (uncapped). Zero disables sleeping. Backoff grows from the capped delay.
88
91
  backoff (float, optional): Finite, positive multiplier applied to the delay after each failed attempt.
89
92
  Defaults to 1.0 (no backoff).
90
93
  jitter (bool, optional): Whether to randomize each delay. Defaults to `False`.
@@ -96,6 +99,8 @@ class SystemRetrier(Retrier):
96
99
  ValueError: If the `attempts` is not a positive integer.
97
100
  TypeError: If the `delay_seconds` is not an integer or float.
98
101
  ValueError: If the `delay_seconds` is negative.
102
+ ValueError: If `max_delay_seconds` is negative or non-finite.
103
+ TypeError: If `max_delay_seconds` is neither a number nor `None`.
99
104
  TypeError: If the `backoff` is not an integer or float.
100
105
  ValueError: If the `backoff` is not positive.
101
106
  TypeError: If the `jitter` is not a boolean.
@@ -118,10 +123,14 @@ class SystemRetrier(Retrier):
118
123
  """
119
124
  PositiveIntegerValueObject(value=attempts, title='SystemRetrier', parameter='attempts')
120
125
  PositiveOrZeroNumberValueObject(value=delay_seconds, title='SystemRetrier', parameter='delay_seconds')
126
+ if max_delay_seconds is not None:
127
+ PositiveOrZeroNumberValueObject(value=max_delay_seconds, title='SystemRetrier', parameter='max_delay_seconds') # noqa: E501 # fmt: skip
128
+
121
129
  PositiveNumberValueObject(value=backoff, title='SystemRetrier', parameter='backoff')
122
130
  BooleanValueObject(value=jitter, title='SystemRetrier', parameter='jitter')
123
131
 
124
- current_delay_seconds = delay_seconds
132
+ delay_cap = float('inf') if max_delay_seconds is None else max_delay_seconds
133
+ current_delay_seconds = min(delay_seconds, delay_cap)
125
134
  for attempt_number in range(1, attempts + 1):
126
135
  try:
127
136
  return operation()
@@ -134,6 +143,6 @@ class SystemRetrier(Retrier):
134
143
  if sleep_seconds > 0:
135
144
  self._sleeper.sleep(seconds=sleep_seconds)
136
145
 
137
- current_delay_seconds *= backoff
146
+ current_delay_seconds = min(current_delay_seconds * backoff, delay_cap)
138
147
 
139
148
  raise RuntimeError('SystemRetrier attempts loop ended unexpectedly.') # pragma: no cover
@@ -83,6 +83,7 @@ class SystemRetrierAsync(RetrierAsync):
83
83
  operation: Callable[[], Awaitable[T]],
84
84
  attempts: int,
85
85
  delay_seconds: float = 0.0,
86
+ max_delay_seconds: float | None = None,
86
87
  backoff: float = 1.0,
87
88
  jitter: bool = False,
88
89
  retry_on: type[Exception] | tuple[type[Exception], ...] = Exception,
@@ -95,6 +96,8 @@ class SystemRetrierAsync(RetrierAsync):
95
96
  attempts (int): Maximum number of attempts, including the first call.
96
97
  delay_seconds (float, optional): Finite, non-negative initial delay between failed attempts. Defaults
97
98
  to 0.0 seconds.
99
+ max_delay_seconds (float | None, optional): Finite, non-negative delay cap applied before jitter. Defaults
100
+ to `None` (uncapped). Zero disables sleeping. Backoff grows from the capped delay.
98
101
  backoff (float, optional): Finite, positive multiplier applied to the delay after each failed attempt.
99
102
  Defaults to 1.0 (no backoff).
100
103
  jitter (bool, optional): Whether to randomize each delay. Defaults to `False`.
@@ -106,6 +109,8 @@ class SystemRetrierAsync(RetrierAsync):
106
109
  ValueError: If the `attempts` is not a positive integer.
107
110
  TypeError: If the `delay_seconds` is not an integer or float.
108
111
  ValueError: If the `delay_seconds` is negative.
112
+ ValueError: If `max_delay_seconds` is negative or non-finite.
113
+ TypeError: If `max_delay_seconds` is neither a number nor `None`.
109
114
  TypeError: If the `backoff` is not an integer or float.
110
115
  ValueError: If the `backoff` is not positive.
111
116
  TypeError: If the `jitter` is not a boolean.
@@ -133,10 +138,14 @@ class SystemRetrierAsync(RetrierAsync):
133
138
  """
134
139
  PositiveIntegerValueObject(value=attempts, title='AsyncSystemRetrier', parameter='attempts')
135
140
  PositiveOrZeroNumberValueObject(value=delay_seconds, title='AsyncSystemRetrier', parameter='delay_seconds')
141
+ if max_delay_seconds is not None:
142
+ PositiveOrZeroNumberValueObject(value=max_delay_seconds, title='AsyncSystemRetrier', parameter='max_delay_seconds') # noqa: E501 # fmt: skip
143
+
136
144
  PositiveNumberValueObject(value=backoff, title='AsyncSystemRetrier', parameter='backoff')
137
145
  BooleanValueObject(value=jitter, title='AsyncSystemRetrier', parameter='jitter')
138
146
 
139
- current_delay_seconds = delay_seconds
147
+ delay_cap = float('inf') if max_delay_seconds is None else max_delay_seconds
148
+ current_delay_seconds = min(delay_seconds, delay_cap)
140
149
  for attempt_number in range(1, attempts + 1):
141
150
  try:
142
151
  return await operation()
@@ -149,6 +158,6 @@ class SystemRetrierAsync(RetrierAsync):
149
158
  if sleep_seconds > 0:
150
159
  await self._sleeper.sleep(seconds=sleep_seconds)
151
160
 
152
- current_delay_seconds *= backoff
161
+ current_delay_seconds = min(current_delay_seconds * backoff, delay_cap)
153
162
 
154
163
  raise RuntimeError('SystemRetrierAsync attempts loop ended unexpectedly.') # pragma: no cover
@@ -76,6 +76,7 @@ class MockRetrier(Retrier):
76
76
  operation: Callable[[], T],
77
77
  attempts: int,
78
78
  delay_seconds: float = 0.0,
79
+ max_delay_seconds: float | None = None,
79
80
  backoff: float = 1.0,
80
81
  jitter: bool = False,
81
82
  retry_on: type[Exception] | tuple[type[Exception], ...] = Exception,
@@ -88,6 +89,8 @@ class MockRetrier(Retrier):
88
89
  attempts (int): Maximum number of attempts, including the first call.
89
90
  delay_seconds (float, optional): Finite, non-negative initial delay between failed attempts. Defaults
90
91
  to 0.0 seconds.
92
+ max_delay_seconds (float | None, optional): Finite, non-negative delay cap applied before jitter. Defaults
93
+ to `None` (uncapped). Zero disables sleeping. Backoff grows from the capped delay.
91
94
  backoff (float, optional): Finite, positive multiplier applied to the delay after each failed attempt.
92
95
  Defaults to 1.0 (no backoff).
93
96
  jitter (bool, optional): Whether to randomize each delay. Defaults to `False`.
@@ -99,6 +102,8 @@ class MockRetrier(Retrier):
99
102
  ValueError: If the `attempts` is not a positive integer.
100
103
  TypeError: If the `delay_seconds` is not an integer or float.
101
104
  ValueError: If the `delay_seconds` is negative.
105
+ ValueError: If `max_delay_seconds` is negative or non-finite.
106
+ TypeError: If `max_delay_seconds` is neither a number nor `None`.
102
107
  TypeError: If the `backoff` is not an integer or float.
103
108
  ValueError: If the `backoff` is not positive.
104
109
  TypeError: If the `jitter` is not a boolean.
@@ -122,6 +127,9 @@ class MockRetrier(Retrier):
122
127
  """
123
128
  PositiveIntegerValueObject(value=attempts, title='MockRetrier', parameter='attempts')
124
129
  PositiveOrZeroNumberValueObject(value=delay_seconds, title='MockRetrier', parameter='delay_seconds')
130
+ if max_delay_seconds is not None:
131
+ PositiveOrZeroNumberValueObject(value=max_delay_seconds, title='MockRetrier', parameter='max_delay_seconds')
132
+
125
133
  PositiveNumberValueObject(value=backoff, title='MockRetrier', parameter='backoff')
126
134
  BooleanValueObject(value=jitter, title='MockRetrier', parameter='jitter')
127
135
 
@@ -129,6 +137,7 @@ class MockRetrier(Retrier):
129
137
  operation=operation,
130
138
  attempts=attempts,
131
139
  delay_seconds=delay_seconds,
140
+ max_delay_seconds=max_delay_seconds,
132
141
  backoff=backoff,
133
142
  jitter=jitter,
134
143
  retry_on=retry_on,
@@ -195,6 +204,7 @@ class MockRetrier(Retrier):
195
204
  operation: Callable[[], Any],
196
205
  attempts: int,
197
206
  delay_seconds: float = 0.0,
207
+ max_delay_seconds: float | None = None,
198
208
  backoff: float = 1.0,
199
209
  jitter: bool = False,
200
210
  retry_on: type[Exception] | tuple[type[Exception], ...] = Exception,
@@ -206,6 +216,8 @@ class MockRetrier(Retrier):
206
216
  operation (Callable[[], Any]): Expected operation callable.
207
217
  attempts (int): Expected maximum number of attempts.
208
218
  delay_seconds (float, optional): Expected finite, non-negative initial delay. Defaults to 0.0 seconds.
219
+ max_delay_seconds (float | None, optional): Finite, non-negative delay cap applied before jitter. Defaults
220
+ to `None` (uncapped). Zero disables sleeping. Backoff grows from the capped delay.
209
221
  backoff (float, optional): Expected finite, positive delay multiplier. Defaults to 1.0.
210
222
  jitter (bool, optional): Expected jitter flag. Defaults to False.
211
223
  retry_on (type[Exception] | tuple[type[Exception], ...], optional): Expected retryable exception types.
@@ -216,6 +228,8 @@ class MockRetrier(Retrier):
216
228
  ValueError: If the `attempts` is not a positive integer.
217
229
  TypeError: If the `delay_seconds` is not an integer or float.
218
230
  ValueError: If the `delay_seconds` is negative.
231
+ ValueError: If `max_delay_seconds` is negative or non-finite.
232
+ TypeError: If `max_delay_seconds` is neither a number nor `None`.
219
233
  TypeError: If the `backoff` is not an integer or float.
220
234
  ValueError: If the `backoff` is not positive.
221
235
  TypeError: If the `jitter` is not a boolean.
@@ -233,6 +247,9 @@ class MockRetrier(Retrier):
233
247
  """
234
248
  PositiveIntegerValueObject(value=attempts, title='MockRetrier', parameter='attempts')
235
249
  PositiveOrZeroNumberValueObject(value=delay_seconds, title='MockRetrier', parameter='delay_seconds')
250
+ if max_delay_seconds is not None:
251
+ PositiveOrZeroNumberValueObject(value=max_delay_seconds, title='MockRetrier', parameter='max_delay_seconds')
252
+
236
253
  PositiveNumberValueObject(value=backoff, title='MockRetrier', parameter='backoff')
237
254
  BooleanValueObject(value=jitter, title='MockRetrier', parameter='jitter')
238
255
 
@@ -240,6 +257,7 @@ class MockRetrier(Retrier):
240
257
  operation=operation,
241
258
  attempts=attempts,
242
259
  delay_seconds=delay_seconds,
260
+ max_delay_seconds=max_delay_seconds,
243
261
  backoff=backoff,
244
262
  jitter=jitter,
245
263
  retry_on=retry_on,
@@ -76,6 +76,7 @@ class MockRetrierAsync(RetrierAsync):
76
76
  operation: Callable[[], Awaitable[T]],
77
77
  attempts: int,
78
78
  delay_seconds: float = 0.0,
79
+ max_delay_seconds: float | None = None,
79
80
  backoff: float = 1.0,
80
81
  jitter: bool = False,
81
82
  retry_on: type[Exception] | tuple[type[Exception], ...] = Exception,
@@ -88,6 +89,8 @@ class MockRetrierAsync(RetrierAsync):
88
89
  attempts (int): Maximum number of attempts requested by the code under test.
89
90
  delay_seconds (float, optional): Finite, non-negative initial delay requested by the code under test.
90
91
  Defaults to 0.0 seconds.
92
+ max_delay_seconds (float | None, optional): Finite, non-negative delay cap applied before jitter. Defaults
93
+ to `None` (uncapped). Zero disables sleeping. Backoff grows from the capped delay.
91
94
  backoff (float, optional): Finite, positive delay multiplier requested by the code under test.
92
95
  Defaults to 1.0.
93
96
  jitter (bool, optional): Whether jitter was requested. Defaults to False.
@@ -99,6 +102,8 @@ class MockRetrierAsync(RetrierAsync):
99
102
  ValueError: If the `attempts` is not a positive integer.
100
103
  TypeError: If the `delay_seconds` is not an integer or float.
101
104
  ValueError: If the `delay_seconds` is negative.
105
+ ValueError: If `max_delay_seconds` is negative or non-finite.
106
+ TypeError: If `max_delay_seconds` is neither a number nor `None`.
102
107
  TypeError: If the `backoff` is not an integer or float.
103
108
  ValueError: If the `backoff` is not positive.
104
109
  TypeError: If the `jitter` is not a boolean.
@@ -126,6 +131,9 @@ class MockRetrierAsync(RetrierAsync):
126
131
  """
127
132
  PositiveIntegerValueObject(value=attempts, title='MockRetrierAsync', parameter='attempts')
128
133
  PositiveOrZeroNumberValueObject(value=delay_seconds, title='MockRetrierAsync', parameter='delay_seconds')
134
+ if max_delay_seconds is not None:
135
+ PositiveOrZeroNumberValueObject(value=max_delay_seconds, title='MockRetrierAsync', parameter='max_delay_seconds') # noqa: E501 # fmt: skip
136
+
129
137
  PositiveNumberValueObject(value=backoff, title='MockRetrierAsync', parameter='backoff')
130
138
  BooleanValueObject(value=jitter, title='MockRetrierAsync', parameter='jitter')
131
139
 
@@ -133,6 +141,7 @@ class MockRetrierAsync(RetrierAsync):
133
141
  operation=operation,
134
142
  attempts=attempts,
135
143
  delay_seconds=delay_seconds,
144
+ max_delay_seconds=max_delay_seconds,
136
145
  backoff=backoff,
137
146
  jitter=jitter,
138
147
  retry_on=retry_on,
@@ -204,6 +213,7 @@ class MockRetrierAsync(RetrierAsync):
204
213
  operation: Callable[[], Awaitable[Any]],
205
214
  attempts: int,
206
215
  delay_seconds: float = 0.0,
216
+ max_delay_seconds: float | None = None,
207
217
  backoff: float = 1.0,
208
218
  jitter: bool = False,
209
219
  retry_on: type[Exception] | tuple[type[Exception], ...] = Exception,
@@ -215,6 +225,8 @@ class MockRetrierAsync(RetrierAsync):
215
225
  operation (Callable[[], Awaitable[Any]]): Expected async operation callable.
216
226
  attempts (int): Expected maximum number of attempts.
217
227
  delay_seconds (float, optional): Expected finite, non-negative initial delay. Defaults to 0.0 seconds.
228
+ max_delay_seconds (float | None, optional): Finite, non-negative delay cap applied before jitter. Defaults
229
+ to `None` (uncapped). Zero disables sleeping. Backoff grows from the capped delay.
218
230
  backoff (float, optional): Expected finite, positive delay multiplier. Defaults to 1.0.
219
231
  jitter (bool, optional): Expected jitter flag. Defaults to False.
220
232
  retry_on (type[Exception] | tuple[type[Exception], ...], optional): Expected retryable exception types.
@@ -225,6 +237,8 @@ class MockRetrierAsync(RetrierAsync):
225
237
  ValueError: If the `attempts` is not a positive integer.
226
238
  TypeError: If the `delay_seconds` is not an integer or float.
227
239
  ValueError: If the `delay_seconds` is negative.
240
+ ValueError: If `max_delay_seconds` is negative or non-finite.
241
+ TypeError: If `max_delay_seconds` is neither a number nor `None`.
228
242
  TypeError: If the `backoff` is not an integer or float.
229
243
  ValueError: If the `backoff` is not positive.
230
244
  TypeError: If the `jitter` is not a boolean.
@@ -246,6 +260,9 @@ class MockRetrierAsync(RetrierAsync):
246
260
  """
247
261
  PositiveIntegerValueObject(value=attempts, title='MockRetrierAsync', parameter='attempts')
248
262
  PositiveOrZeroNumberValueObject(value=delay_seconds, title='MockRetrierAsync', parameter='delay_seconds')
263
+ if max_delay_seconds is not None:
264
+ PositiveOrZeroNumberValueObject(value=max_delay_seconds, title='MockRetrierAsync', parameter='max_delay_seconds') # noqa: E501 # fmt: skip
265
+
249
266
  PositiveNumberValueObject(value=backoff, title='MockRetrierAsync', parameter='backoff')
250
267
  BooleanValueObject(value=jitter, title='MockRetrierAsync', parameter='jitter')
251
268
 
@@ -253,6 +270,7 @@ class MockRetrierAsync(RetrierAsync):
253
270
  operation=operation,
254
271
  attempts=attempts,
255
272
  delay_seconds=delay_seconds,
273
+ max_delay_seconds=max_delay_seconds,
256
274
  backoff=backoff,
257
275
  jitter=jitter,
258
276
  retry_on=retry_on,
@@ -16,7 +16,7 @@ class Sleeper(ABC):
16
16
  from clock_pattern.monotonic_clocks import SystemMonotonicClock
17
17
 
18
18
  sleeper = SystemSleeper(monotonic_clock=SystemMonotonicClock())
19
- sleeper.sleep(seconds=1)
19
+ sleeper.sleep(seconds=0.001)
20
20
  ```
21
21
  """
22
22
 
@@ -34,7 +34,7 @@ class Sleeper(ABC):
34
34
  from clock_pattern.monotonic_clocks import SystemMonotonicClock
35
35
 
36
36
  sleeper = SystemSleeper(monotonic_clock=SystemMonotonicClock())
37
- sleeper.sleep(seconds=1)
37
+ sleeper.sleep(seconds=0.001)
38
38
  ```
39
39
  """
40
40
 
@@ -56,7 +56,7 @@ class Sleeper(ABC):
56
56
 
57
57
  sleeper = SystemSleeper(monotonic_clock=SystemMonotonicClock())
58
58
 
59
- with sleeper.minimum_duration(seconds=1):
59
+ with sleeper.minimum_duration(seconds=0.001):
60
60
  pass
61
61
  ```
62
62
  """
@@ -16,7 +16,7 @@ class SleeperAsync(ABC):
16
16
  from clock_pattern.monotonic_clocks import SystemMonotonicClock
17
17
 
18
18
  sleeper = SystemSleeperAsync(monotonic_clock=SystemMonotonicClock())
19
- await sleeper.sleep(seconds=1)
19
+ await sleeper.sleep(seconds=0.001)
20
20
  ```
21
21
  """
22
22
 
@@ -34,7 +34,7 @@ class SleeperAsync(ABC):
34
34
  from clock_pattern.monotonic_clocks import SystemMonotonicClock
35
35
 
36
36
  sleeper = SystemSleeperAsync(monotonic_clock=SystemMonotonicClock())
37
- await sleeper.sleep(seconds=0.1)
37
+ await sleeper.sleep(seconds=0.001)
38
38
  ```
39
39
  """
40
40
 
@@ -43,6 +43,8 @@ class SleeperAsync(ABC):
43
43
  """
44
44
  Create an async context manager that ensures the enclosed work takes at least `seconds`.
45
45
 
46
+ Cancellation propagates without padding the duration. Other body errors still pad the duration.
47
+
46
48
  Args:
47
49
  seconds (float): The minimum duration the enclosed work should take.
48
50
 
@@ -56,7 +58,7 @@ class SleeperAsync(ABC):
56
58
 
57
59
  sleeper = SystemSleeperAsync(monotonic_clock=SystemMonotonicClock())
58
60
 
59
- async with sleeper.minimum_duration(seconds=1):
61
+ async with sleeper.minimum_duration(seconds=0.001):
60
62
  pass
61
63
  ```
62
64
  """
@@ -29,7 +29,7 @@ class SystemSleeper(Sleeper):
29
29
  from clock_pattern.monotonic_clocks import SystemMonotonicClock
30
30
 
31
31
  sleeper = SystemSleeper(monotonic_clock=SystemMonotonicClock())
32
- sleeper.sleep(seconds=1)
32
+ sleeper.sleep(seconds=0.001)
33
33
  ```
34
34
  """
35
35
 
@@ -48,7 +48,7 @@ class SystemSleeper(Sleeper):
48
48
  from clock_pattern.monotonic_clocks import SystemMonotonicClock
49
49
 
50
50
  sleeper = SystemSleeper(monotonic_clock=SystemMonotonicClock())
51
- sleeper.sleep(seconds=1)
51
+ sleeper.sleep(seconds=0.001)
52
52
  ```
53
53
  """
54
54
  self._monotonic_clock = monotonic_clock
@@ -71,7 +71,7 @@ class SystemSleeper(Sleeper):
71
71
  from clock_pattern.monotonic_clocks import SystemMonotonicClock
72
72
 
73
73
  sleeper = SystemSleeper(monotonic_clock=SystemMonotonicClock())
74
- sleeper.sleep(seconds=1)
74
+ sleeper.sleep(seconds=0.001)
75
75
  ```
76
76
  """
77
77
  PositiveOrZeroNumberValueObject(value=seconds, title='SystemSleeper', parameter='seconds')
@@ -102,7 +102,7 @@ class SystemSleeper(Sleeper):
102
102
 
103
103
  sleeper = SystemSleeper(monotonic_clock=SystemMonotonicClock())
104
104
 
105
- with sleeper.minimum_duration(seconds=1):
105
+ with sleeper.minimum_duration(seconds=0.001):
106
106
  pass
107
107
  ```
108
108
  """
@@ -9,7 +9,7 @@ if version_info >= (3, 12):
9
9
  else:
10
10
  from typing_extensions import override # pragma: no cover
11
11
 
12
- from asyncio import sleep
12
+ from asyncio import CancelledError, sleep
13
13
  from collections.abc import AsyncIterator
14
14
  from contextlib import asynccontextmanager
15
15
 
@@ -29,7 +29,7 @@ class SystemSleeperAsync(SleeperAsync):
29
29
  from clock_pattern.monotonic_clocks import SystemMonotonicClock
30
30
 
31
31
  sleeper = SystemSleeperAsync(monotonic_clock=SystemMonotonicClock())
32
- await sleeper.sleep(seconds=1)
32
+ await sleeper.sleep(seconds=0.001)
33
33
  ```
34
34
  """
35
35
 
@@ -48,7 +48,7 @@ class SystemSleeperAsync(SleeperAsync):
48
48
  from clock_pattern.monotonic_clocks import SystemMonotonicClock
49
49
 
50
50
  sleeper = SystemSleeperAsync(monotonic_clock=SystemMonotonicClock())
51
- await sleeper.sleep(seconds=1)
51
+ await sleeper.sleep(seconds=0.001)
52
52
  ```
53
53
  """
54
54
  self._monotonic_clock = monotonic_clock
@@ -71,7 +71,7 @@ class SystemSleeperAsync(SleeperAsync):
71
71
  from clock_pattern.monotonic_clocks import SystemMonotonicClock
72
72
 
73
73
  sleeper = SystemSleeperAsync(monotonic_clock=SystemMonotonicClock())
74
- await sleeper.sleep(seconds=1)
74
+ await sleeper.sleep(seconds=0.001)
75
75
  ```
76
76
  """
77
77
  PositiveOrZeroNumberValueObject(value=seconds, title='SystemSleeperAsync', parameter='seconds')
@@ -84,6 +84,9 @@ class SystemSleeperAsync(SleeperAsync):
84
84
  """
85
85
  Create an async context manager that ensures the enclosed work takes at least `seconds`.
86
86
 
87
+ Cancellation propagates immediately without padding the duration. Other body errors still wait out
88
+ the remaining duration before propagating.
89
+
87
90
  Args:
88
91
  seconds (float): The minimum elapsed duration for the enclosed asynchronous work.
89
92
 
@@ -102,19 +105,23 @@ class SystemSleeperAsync(SleeperAsync):
102
105
 
103
106
  sleeper = SystemSleeperAsync(monotonic_clock=SystemMonotonicClock())
104
107
 
105
- async with sleeper.minimum_duration(seconds=1):
108
+ async with sleeper.minimum_duration(seconds=0.001):
106
109
  pass
107
110
  ```
108
111
  """
109
112
  PositiveOrZeroNumberValueObject(value=seconds, title='SystemSleeperAsync', parameter='seconds')
110
113
 
111
114
  started_time = self._monotonic_clock.current_seconds()
115
+ cancelled = False
112
116
  try:
113
117
  yield
114
118
 
119
+ except CancelledError:
120
+ cancelled = True
121
+ raise
122
+
115
123
  finally:
116
- elapsed_seconds = self._monotonic_clock.current_seconds() - started_time
117
- remaining_seconds = seconds - elapsed_seconds
124
+ remaining_seconds = 0.0 if cancelled else seconds - (self._monotonic_clock.current_seconds() - started_time)
118
125
 
119
126
  if remaining_seconds > 0:
120
127
  await self.sleep(seconds=remaining_seconds)
@@ -9,6 +9,7 @@ if version_info >= (3, 12):
9
9
  else:
10
10
  from typing_extensions import override # pragma: no cover
11
11
 
12
+ from asyncio import CancelledError
12
13
  from collections.abc import AsyncIterator
13
14
  from contextlib import asynccontextmanager
14
15
  from unittest.mock import AsyncMock
@@ -138,6 +139,8 @@ class MockSleeperAsync(SleeperAsync):
138
139
  """
139
140
  Create an async context manager that ensures the enclosed work takes at least `seconds`.
140
141
 
142
+ Cancellation propagates without recording sleep or advancing time. Other body errors still pad the duration.
143
+
141
144
  Args:
142
145
  seconds (float): The minimum elapsed duration for the enclosed asynchronous work.
143
146
 
@@ -162,12 +165,16 @@ class MockSleeperAsync(SleeperAsync):
162
165
  PositiveOrZeroNumberValueObject(value=seconds, title='MockSleeperAsync', parameter='seconds')
163
166
 
164
167
  started_time = self._monotonic_clock.current_seconds()
168
+ cancelled = False
165
169
  try:
166
170
  yield
167
171
 
172
+ except CancelledError:
173
+ cancelled = True
174
+ raise
175
+
168
176
  finally:
169
- elapsed_seconds = self._monotonic_clock.current_seconds() - started_time
170
- remaining_seconds = seconds - elapsed_seconds
177
+ remaining_seconds = 0.0 if cancelled else seconds - (self._monotonic_clock.current_seconds() - started_time)
171
178
 
172
179
  if remaining_seconds > 0:
173
180
  await self.sleep(seconds=remaining_seconds)
@@ -38,7 +38,10 @@ keywords = [
38
38
  'domain-driven-design',
39
39
  ]
40
40
  requires-python = '>=3.11'
41
- dependencies = ['value-object-pattern>=1.36.0']
41
+ dependencies = [
42
+ 'value-object-pattern>=1.36.0',
43
+ 'typing-extensions>=4.4.0; python_version < "3.12"',
44
+ ]
42
45
  dynamic = ['version']
43
46
 
44
47
  [project.urls]
@@ -52,11 +55,13 @@ coverage = ['coverage[toml]>=7.0.0', 'smokeshow>=0.5.0']
52
55
  develop = ['pre-commit>=3.0.0']
53
56
  format = ['ruff>=0.11.10']
54
57
  lint = ['ruff>=0.11.10', 'ty>=0.0.77', { include-group = 'test' }]
55
- release = ['build>=1.2.2', 'python-semantic-release>=10.0.2']
58
+ # TODO: Remove the pin when python-semantic-release supports click>=8.3.3 (PYSEC-2026-2132).
59
+ release = ['build>=1.2.2', 'python-semantic-release==10.6.1']
56
60
  test = [
57
61
  'pytest>=8.0.0',
58
62
  'pytest-asyncio>=1.3.0',
59
63
  'pytest-randomly>=3.0.0',
64
+ 'sybil[pytest]>=10.1.0',
60
65
  'object-mother-pattern>=2.1.0',
61
66
  { include-group = 'coverage' },
62
67
  ]
@@ -68,18 +73,13 @@ all = [
68
73
  { include-group = 'release' },
69
74
  { include-group = 'test' },
70
75
  ]
71
-
72
-
73
- [tool.uv]
74
- constraint-dependencies = ['click>=8.3.3'] # PYSEC-2026-2132
75
-
76
-
77
76
  [tool.pytest.ini_options]
78
77
  pythonpath = '.'
79
78
  addopts = [
80
- '--strict-config', # fail if an unregistered option is used
81
- '--strict-markers', # fail if an unregistered marker is used
82
- '--color=yes', # color the output
79
+ '--strict-config', # fail if an unregistered option is used
80
+ '--strict-markers', # fail if an unregistered marker is used
81
+ '--color=yes', # color the output
82
+ '-p tests.test_docstrings',
83
83
  ]
84
84
  markers = ['unit_testing: Check a unique functionality']
85
85
  xfail_strict = true
@@ -113,7 +113,7 @@ source = ['clock_pattern']
113
113
  [tool.ruff]
114
114
  indent-width = 4
115
115
  line-length = 120
116
- target-version = 'py314'
116
+ target-version = 'py311'
117
117
  output-format = 'grouped'
118
118
 
119
119
  [tool.ruff.format]
@@ -199,8 +199,18 @@ version_variables = ['clock_pattern/__init__.py:__version__']
199
199
 
200
200
  [tool.semantic_release.commit_parser_options]
201
201
  minor_tags = ['feat']
202
- patch_tags = ['fix', 'perf', 'build']
203
- allowed_tags = ['feat', 'fix', 'perf', 'build']
202
+ patch_tags = ['fix', 'perf']
203
+ allowed_tags = [
204
+ 'feat',
205
+ 'fix',
206
+ 'perf',
207
+ 'build',
208
+ 'refactor',
209
+ 'test',
210
+ 'docs',
211
+ 'ci',
212
+ 'chore',
213
+ ]
204
214
  default_bump_level = 0
205
215
  parse_squash_commits = false
206
216
  ignore_merge_commits = true