xtr-clock 1.0.0__py3-none-any.whl

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