goodtime 0.4.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.
goodtime-0.4.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Edward Toroshchin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: goodtime
3
+ Version: 0.4.0
4
+ Summary: A time library that prioritises clean and safe APIs.
5
+ Author-email: Edward Toroshchin <dev@hades.name>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Project-URL: Home, https://github.com/hades/goodtime
11
+
12
+ Welcome to **goodtime**, a Python date/time library that prioritises safety and correctness.
13
+
14
+ ```python
15
+ >>> from goodtime import *
16
+ >>> Instant.now()
17
+ Instant(unix_timestamp_ns=1770029789026987530)
18
+ >>> Instant.now() + hours(1)
19
+ Instant(unix_timestamp_ns=1770033404038049963)
20
+ >>> Instant.now().to_unix_seconds()
21
+ 1770029824
22
+ >>> Instant.from_unix_seconds(1770029824)
23
+ Instant(unix_timestamp_ns=1770029824000000000)
24
+ ```
25
+
26
+ Handling of timestamps, durations and human-readable dates and times is
27
+ notoriously error-prone due to issues with time zones, ambiguous arithmetics,
28
+ ambiguous units, etc. This is especially true in distributed systems, where
29
+ interacting systems may be written by multiple teams that did not have shared
30
+ assumptions about date/time semantics.
31
+
32
+ The goal of **goodtime** is to minimise the potential for errors by:
33
+
34
+ * using types that have well-defined semantics,
35
+ * preventing ambiguous conversions (e.g. timestamp to integer),
36
+ * providing explicit interfaces to interoperate with non-goodtime code.
37
+
38
+ See package documentation for more information on provided types, operations,
39
+ and best practices.
40
+
41
+ ## Getting Started
42
+
43
+ Install **goodtime** from PyPI using your favourite Python package manager:
44
+
45
+ ```console
46
+ $ python -m pip install goodtime
47
+ $ poetry add goodtime
48
+ $ uv add goodtime
49
+ ```
50
+
51
+ ## Contributing
52
+
53
+ We are open to feedback and contributions under the terms of the MIT license.
54
+ Feel free to open a Github issue or a pull request.
@@ -0,0 +1,43 @@
1
+ Welcome to **goodtime**, a Python date/time library that prioritises safety and correctness.
2
+
3
+ ```python
4
+ >>> from goodtime import *
5
+ >>> Instant.now()
6
+ Instant(unix_timestamp_ns=1770029789026987530)
7
+ >>> Instant.now() + hours(1)
8
+ Instant(unix_timestamp_ns=1770033404038049963)
9
+ >>> Instant.now().to_unix_seconds()
10
+ 1770029824
11
+ >>> Instant.from_unix_seconds(1770029824)
12
+ Instant(unix_timestamp_ns=1770029824000000000)
13
+ ```
14
+
15
+ Handling of timestamps, durations and human-readable dates and times is
16
+ notoriously error-prone due to issues with time zones, ambiguous arithmetics,
17
+ ambiguous units, etc. This is especially true in distributed systems, where
18
+ interacting systems may be written by multiple teams that did not have shared
19
+ assumptions about date/time semantics.
20
+
21
+ The goal of **goodtime** is to minimise the potential for errors by:
22
+
23
+ * using types that have well-defined semantics,
24
+ * preventing ambiguous conversions (e.g. timestamp to integer),
25
+ * providing explicit interfaces to interoperate with non-goodtime code.
26
+
27
+ See package documentation for more information on provided types, operations,
28
+ and best practices.
29
+
30
+ ## Getting Started
31
+
32
+ Install **goodtime** from PyPI using your favourite Python package manager:
33
+
34
+ ```console
35
+ $ python -m pip install goodtime
36
+ $ poetry add goodtime
37
+ $ uv add goodtime
38
+ ```
39
+
40
+ ## Contributing
41
+
42
+ We are open to feedback and contributions under the terms of the MIT license.
43
+ Feel free to open a Github issue or a pull request.
@@ -0,0 +1,81 @@
1
+ """A time library that prioritises clean and safe APIs.
2
+
3
+ Please read this documentation carefully to understand what **goodtime** types
4
+ and functions do, and how to prevent most common date/time errors.
5
+
6
+ The core types are Instant (representing a fixed absolute point in time on
7
+ Earth), Duration (representing a length of time) and CivilTime (representing
8
+ local time as used by humans).
9
+
10
+ ## Design
11
+
12
+ The first thing to understand about how **goodtime** handles time is that the
13
+ "absolute time" and "civil time" are explicitly treated as completely different
14
+ (and, generally-speaking, incompatible) concepts.
15
+
16
+ **Absolute time** (represented with the Instant type) is the underlying basis of
17
+ measurement of time, as currently agreed upon practically everywhere on Earth.
18
+ Any instantaneous event (keypress, rocket lift-off, log message) will have a
19
+ unique absolute time as defined by the International Telecommunication Union. It
20
+ is therefore very convenient to avoid ambiguity, and is commonly used in
21
+ computing, communications, aviation, etc.
22
+
23
+ **Civil time**, or **local time** is a measurement of time as regulated by
24
+ civilian authorities, and is a tuple of six fields: year, month, day, hour,
25
+ minute, second. The same tuple can have different meaning depending on the
26
+ jurisdiction where it is interpreted. As such, it is inconvenient for
27
+ communication and computing, but there is no way to avoid it, as most humans
28
+ understand their civil time much better than global absolute time, and mostly
29
+ use civil time to organise their life.
30
+
31
+ ## Truncation
32
+
33
+ Operations that truncate the precision of values (e.g. Instant.to_unix_seconds
34
+ or Duration.to_seconds) will round towards infinite past or negative infinite
35
+ duration.
36
+ """
37
+
38
+ __version__ = "0.4.0"
39
+
40
+ from ._civil_time import CivilSecond, CivilTime
41
+ from ._duration import (
42
+ Duration,
43
+ NegativeDuration,
44
+ PositiveDuration,
45
+ SignedDuration,
46
+ hours,
47
+ microseconds,
48
+ milliseconds,
49
+ minutes,
50
+ nanoseconds,
51
+ seconds,
52
+ )
53
+ from ._instant import Instant
54
+ from ._timezone import (
55
+ CivilTimeInstant,
56
+ RepeatedCivilTimeInstant,
57
+ SkippedCivilTimeInstant,
58
+ Timezone,
59
+ UniqueCivilTimeInstant,
60
+ )
61
+
62
+ __all__ = [
63
+ "CivilSecond",
64
+ "CivilTime",
65
+ "CivilTimeInstant",
66
+ "Duration",
67
+ "Instant",
68
+ "NegativeDuration",
69
+ "PositiveDuration",
70
+ "RepeatedCivilTimeInstant",
71
+ "SignedDuration",
72
+ "SkippedCivilTimeInstant",
73
+ "Timezone",
74
+ "UniqueCivilTimeInstant",
75
+ "hours",
76
+ "microseconds",
77
+ "milliseconds",
78
+ "minutes",
79
+ "nanoseconds",
80
+ "seconds",
81
+ ]
@@ -0,0 +1,84 @@
1
+ """Representations for civil (local) date and time."""
2
+
3
+ import dataclasses
4
+ import datetime
5
+ from typing import final
6
+
7
+ from ._duration import Duration
8
+
9
+
10
+ @final
11
+ @dataclasses.dataclass(init=False, repr=False, order=True, frozen=True, slots=True)
12
+ class CivilSecond:
13
+ dt: datetime.datetime
14
+
15
+ def __init__(self, *, dt: datetime.datetime):
16
+ if not isinstance(dt, datetime.datetime):
17
+ msg = f"dt must be datetime.datetime, was {type(dt)}"
18
+ raise TypeError(msg)
19
+ if dt.tzinfo is not datetime.timezone.utc:
20
+ msg = f"dt must have tzinfo=datetime.timezone.utc, was {dt.tzinfo!r}"
21
+ raise ValueError(msg)
22
+ object.__setattr__(self, "dt", dt)
23
+
24
+ @classmethod
25
+ def from_utc_datetime(cls, dt: datetime.datetime) -> "CivilSecond":
26
+ return CivilSecond(dt=dt)
27
+
28
+ @classmethod
29
+ def from_fields(cls, year: int, month: int, day: int, hour: int, minute: int, second: int) -> "CivilSecond": # noqa: PLR0913
30
+ return CivilSecond(
31
+ dt=datetime.datetime(
32
+ year=year,
33
+ month=month,
34
+ day=day,
35
+ hour=hour,
36
+ minute=minute,
37
+ second=second,
38
+ tzinfo=datetime.timezone.utc,
39
+ )
40
+ )
41
+
42
+ @property
43
+ def year(self) -> int:
44
+ return self.dt.year
45
+
46
+ @property
47
+ def month(self) -> int:
48
+ return self.dt.month
49
+
50
+ @property
51
+ def day(self) -> int:
52
+ return self.dt.day
53
+
54
+ @property
55
+ def hour(self) -> int:
56
+ return self.dt.hour
57
+
58
+ @property
59
+ def minute(self) -> int:
60
+ return self.dt.minute
61
+
62
+ @property
63
+ def second(self) -> int:
64
+ return self.dt.second
65
+
66
+
67
+ @final
68
+ @dataclasses.dataclass(init=False, repr=True, order=True, frozen=True, slots=True)
69
+ class CivilTime:
70
+ second: CivilSecond
71
+ subsecond: Duration
72
+
73
+ def __init__(self, *, second: CivilSecond, subsecond: Duration):
74
+ if not isinstance(subsecond, Duration):
75
+ msg = f"subsecond_ns must be Duration, was {type(subsecond)}"
76
+ raise TypeError(msg)
77
+ if not isinstance(second, CivilSecond):
78
+ msg = f"second must be CivilSecond, was {type(second)}"
79
+ raise TypeError(msg)
80
+ if not (0 <= subsecond.to_nanos() < 1_000_000_000): # noqa: PLR2004
81
+ msg = f"subsecond must be in [0, 1_000_000_000)ns, was {subsecond.to_nanos()}ns"
82
+ raise ValueError(msg)
83
+ object.__setattr__(self, "second", second)
84
+ object.__setattr__(self, "subsecond", subsecond)
@@ -0,0 +1,385 @@
1
+ """Durations represent finite lengths of time."""
2
+
3
+ import dataclasses
4
+ import datetime
5
+ from typing import final
6
+
7
+
8
+ @final
9
+ @dataclasses.dataclass(init=False, repr=True, order=True, frozen=True, slots=True)
10
+ class Duration:
11
+ """Duration represents a fixed length of time with nanosecond precision.
12
+
13
+ The Duration values can be constructed using factory functions (from_nanos,
14
+ from_micros, from_millis and from_seconds) and can be used for unit
15
+ arithmetics, e.g. adding or subtracting from each other and to/from the
16
+ Instant values.
17
+
18
+ Unlike SignedDuration, the length of time is always non-negative. It is therefore
19
+ useful for measuring, for example, the amount of time a request or a computation took
20
+ place, or to set a timeout.
21
+
22
+ Python operators (comparison, hashing, addition, subtraction) are supported
23
+ naturally. Note that subtracting another Duration results in a SignedDuration.
24
+
25
+ It is recommended to use this (or SignedDuration) type in all APIs (fields,
26
+ function signatures) instead of raw integers to avoid ambiguity. Use explicit
27
+ conversion functions at the boundary of your code to interoperate with
28
+ non-goodtime libraries and APIs.
29
+ """
30
+
31
+ ns: int
32
+
33
+ def __init__(self, *, ns: int):
34
+ """Create an instance of Duration from non-negative amount of nanoseconds.
35
+
36
+ To improve readability it is recommended to use the explicit factory functions (from_nanos
37
+ from_micros, from_millis and from_seconds) instead.
38
+ """
39
+ if not isinstance(ns, int):
40
+ msg = f"ns must be int, was {type(ns)}"
41
+ raise TypeError(msg)
42
+ if ns < 0:
43
+ msg = f"ns must be non-negative, was {ns}"
44
+ raise ValueError(msg)
45
+ object.__setattr__(self, "ns", ns)
46
+
47
+ @classmethod
48
+ def from_nanos(cls, value: int) -> "Duration":
49
+ """Create an instance of Duration from non-negative amount of nanoseconds."""
50
+ return cls(ns=value)
51
+
52
+ @classmethod
53
+ def from_micros(cls, value: int) -> "Duration":
54
+ """Create an instance of Duration from non-negative amount of microseconds."""
55
+ return cls(ns=value * 1000)
56
+
57
+ @classmethod
58
+ def from_millis(cls, value: int) -> "Duration":
59
+ """Create an instance of Duration from non-negative amount of milliseconds."""
60
+ return cls(ns=value * 1_000_000)
61
+
62
+ @classmethod
63
+ def from_seconds(cls, value: int) -> "Duration":
64
+ """Create an instance of Duration from non-negative amount of seconds."""
65
+ return cls(ns=value * 1_000_000_000)
66
+
67
+ def to_nanos(self) -> int:
68
+ """Return the duration as integer amount of nanoseconds."""
69
+ return self.ns
70
+
71
+ def to_micros(self) -> int:
72
+ """Return the duration as integer amount of microseconds, rounding towards zero."""
73
+ return self.ns // 1000
74
+
75
+ def to_millis(self) -> int:
76
+ """Return the duration as integer amount of milliseconds, rounding towards zero."""
77
+ return self.ns // 1_000_000
78
+
79
+ def to_seconds(self) -> int:
80
+ """Return the duration as integer amount of seconds, rounding towards zero."""
81
+ return self.ns // 1_000_000_000
82
+
83
+ def to_timedelta(self) -> datetime.timedelta:
84
+ """Return the duration as Python timedelta value, rounding towards zero."""
85
+ return datetime.timedelta(microseconds=self.to_micros())
86
+
87
+ def __add__(self, other: "Duration") -> "Duration":
88
+ if not isinstance(other, Duration):
89
+ return NotImplemented
90
+ return Duration(ns=self.ns + other.ns)
91
+
92
+ def __sub__(self, other: "Duration") -> "SignedDuration":
93
+ if not isinstance(other, Duration):
94
+ return NotImplemented
95
+ return SignedDuration.from_nanos(self.ns - other.ns)
96
+
97
+
98
+ @dataclasses.dataclass(init=False, repr=False, order=False, frozen=True, slots=True)
99
+ class SignedDuration:
100
+ """SignedDuration represents a fixed length of time with nanosecond precision.
101
+
102
+ The SignedDuration values can be constructed using factory functions (from_nanos,
103
+ from_micros, from_millis and from_seconds) or convenience helper functions
104
+ (hours, minutes, seconds, etc.) and can be used for unit arithmetics, e.g.
105
+ adding or subtracting from each other and to/from the Instant values.
106
+
107
+ Do not use SignedDuration constructor directly. SignedDuration is meant as a
108
+ union type, and may be updated in the future to prevent incorrect usage.
109
+
110
+ Unlike Duration, the length of time can be negative. This makes it useful for
111
+ unit arithmetics, while simultaneously preventing the user from accidentally
112
+ using a negative duration where it does not make sense, such as timeouts.
113
+
114
+ To extract Duration from SignedDuration, check whether your value is an
115
+ instance of PositiveDuration or NegativeDuration. A `match` operator provides
116
+ a concise syntax for this:
117
+
118
+ >>> def print_duration(d: Duration) -> None:
119
+ ... match d:
120
+ ... case PositiveDuration(p):
121
+ ... print(f"{p.to_seconds()}s")
122
+ ... case NegativeDuration(n):
123
+ ... print(f"-{n.to_seconds()}s")
124
+ ...
125
+ >>> print_duration(hours(2))
126
+ 7200s
127
+ >>> print_duration(hours(-2))
128
+ -7200s
129
+
130
+ Python operators (comparison, hashing, addition, subtraction) are supported
131
+ naturally. Note that adding a SignedDuration to a Duration results in a
132
+ SignedDuration.
133
+
134
+ It is recommended to use this (or Duration) type in all APIs (fields, function
135
+ signatures) instead of raw integers to avoid ambiguity. Use explicit
136
+ conversion functions at the boundary of your code to interoperate with
137
+ non-goodtime libraries and APIs.
138
+ """
139
+
140
+ absolute_value: Duration
141
+
142
+ def __init__(self, absolute_value: Duration):
143
+ """Use only in subclasses of SignedDuration."""
144
+ if not isinstance(absolute_value, Duration):
145
+ msg = f"absolute_value must be Duration, was {type(absolute_value)}"
146
+ raise TypeError(msg)
147
+ object.__setattr__(self, "absolute_value", absolute_value)
148
+
149
+ @classmethod
150
+ def from_timedelta(cls, value: datetime.timedelta) -> "SignedDuration":
151
+ """Create an instance of SignedDuration from a Python timedelta value."""
152
+ if not isinstance(value, datetime.timedelta):
153
+ msg = f"value must be datetime.timedelta, was {type(value)}"
154
+ raise TypeError(msg)
155
+ if value.days >= 0:
156
+ return PositiveDuration(Duration.from_micros(value // datetime.timedelta(microseconds=1)))
157
+ return NegativeDuration(Duration.from_micros(value // datetime.timedelta(microseconds=-1)))
158
+
159
+ @classmethod
160
+ def from_nanos(cls, value: int) -> "SignedDuration":
161
+ """Create an instance of SignedDuration from integer amount of nanoseconds."""
162
+ if value > 0:
163
+ return PositiveDuration(Duration(ns=value))
164
+ return NegativeDuration(Duration(ns=-value))
165
+
166
+ @classmethod
167
+ def from_micros(cls, value: int) -> "SignedDuration":
168
+ """Create an instance of SignedDuration from integer amount of microseconds."""
169
+ if value > 0:
170
+ return PositiveDuration(Duration(ns=value * 1000))
171
+ return NegativeDuration(Duration(ns=-value * 1000))
172
+
173
+ @classmethod
174
+ def from_millis(cls, value: int) -> "SignedDuration":
175
+ """Create an instance of SignedDuration from integer amount of milliseconds."""
176
+ if value > 0:
177
+ return PositiveDuration(Duration(ns=value * 1_000_000))
178
+ return NegativeDuration(Duration(ns=-value * 1_000_000))
179
+
180
+ @classmethod
181
+ def from_seconds(cls, value: int) -> "SignedDuration":
182
+ """Create an instance of SignedDuration from integer amount of seconds."""
183
+ if value > 0:
184
+ return PositiveDuration(Duration(ns=value * 1_000_000_000))
185
+ return NegativeDuration(Duration(ns=-value * 1_000_000_000))
186
+
187
+ def to_timedelta(self) -> datetime.timedelta:
188
+ """Return the duration as Python timedelta value, rounding towards negative infinity."""
189
+ raise NotImplementedError
190
+
191
+ def __ge__(self, other: "SignedDuration|Duration") -> bool:
192
+ return NotImplemented
193
+
194
+ def __gt__(self, other: "SignedDuration|Duration") -> bool:
195
+ return NotImplemented
196
+
197
+ def __le__(self, other: "SignedDuration|Duration") -> bool:
198
+ return NotImplemented
199
+
200
+ def __lt__(self, other: "SignedDuration|Duration") -> bool:
201
+ return NotImplemented
202
+
203
+
204
+ @final
205
+ class PositiveDuration(SignedDuration):
206
+ def __repr__(self) -> str:
207
+ return f"PositiveDuration({self.absolute_value!r})"
208
+
209
+ @classmethod
210
+ def from_nanos(cls, _value: int) -> SignedDuration:
211
+ msg = "Calling PositiveDuration.from_nanos is not supported. Call SignedDuration.from_nanos instead."
212
+ raise TypeError(msg)
213
+
214
+ @classmethod
215
+ def from_micros(cls, _value: int) -> SignedDuration:
216
+ msg = "Calling PositiveDuration.from_micros is not supported. Call SignedDuration.from_micros instead."
217
+ raise TypeError(msg)
218
+
219
+ @classmethod
220
+ def from_millis(cls, _value: int) -> SignedDuration:
221
+ msg = "Calling PositiveDuration.from_millis is not supported. Call SignedDuration.from_millis instead."
222
+ raise TypeError(msg)
223
+
224
+ @classmethod
225
+ def from_seconds(cls, _value: int) -> SignedDuration:
226
+ msg = "Calling PositiveDuration.from_seconds is not supported. Call SignedDuration.from_seconds instead."
227
+ raise TypeError(msg)
228
+
229
+ def to_timedelta(self) -> datetime.timedelta:
230
+ return datetime.timedelta(microseconds=self.absolute_value.to_micros())
231
+
232
+ def __add__(self, other: SignedDuration | Duration) -> SignedDuration:
233
+ match other:
234
+ case Duration(ns=other_ns) | PositiveDuration(Duration(ns=other_ns)):
235
+ return SignedDuration.from_nanos(self.absolute_value.ns + other_ns)
236
+ case NegativeDuration(Duration(ns=other_negative_ns)):
237
+ return SignedDuration.from_nanos(self.absolute_value.ns - other_negative_ns)
238
+ return NotImplemented
239
+
240
+ def __radd__(self, other: Duration) -> SignedDuration:
241
+ return self.__add__(other)
242
+
243
+ def __sub__(self, other: SignedDuration | Duration) -> SignedDuration:
244
+ match other:
245
+ case Duration(ns=other_ns) | PositiveDuration(Duration(ns=other_ns)):
246
+ return SignedDuration.from_nanos(self.absolute_value.ns - other_ns)
247
+ case NegativeDuration(Duration(ns=other_negative_ns)):
248
+ return SignedDuration.from_nanos(self.absolute_value.ns + other_negative_ns)
249
+ return NotImplemented
250
+
251
+ def __rsub__(self, other: Duration) -> SignedDuration:
252
+ if not isinstance(other, Duration):
253
+ return NotImplemented
254
+ return SignedDuration.from_nanos(other.ns - self.absolute_value.ns)
255
+
256
+ def __le__(self, other: SignedDuration | Duration) -> bool:
257
+ match other:
258
+ case Duration(ns=other_ns) | PositiveDuration(Duration(ns=other_ns)):
259
+ return self.absolute_value.ns <= other_ns
260
+ case NegativeDuration(Duration(ns=other_negative_ns)):
261
+ return self.absolute_value.ns <= -other_negative_ns
262
+ return NotImplemented
263
+
264
+ def __gt__(self, other: SignedDuration | Duration) -> bool:
265
+ result = self.__le__(other)
266
+ return NotImplemented if result is NotImplemented else not result
267
+
268
+ def __lt__(self, other: SignedDuration | Duration) -> bool:
269
+ match other:
270
+ case Duration(ns=other_ns) | PositiveDuration(Duration(ns=other_ns)):
271
+ return self.absolute_value.ns < other_ns
272
+ case NegativeDuration(Duration(ns=other_negative_ns)):
273
+ return self.absolute_value.ns < -other_negative_ns
274
+ return NotImplemented
275
+
276
+ def __ge__(self, other: SignedDuration | Duration) -> bool:
277
+ result = self.__lt__(other)
278
+ return NotImplemented if result is NotImplemented else not result
279
+
280
+
281
+ @final
282
+ class NegativeDuration(SignedDuration):
283
+ def __repr__(self) -> str:
284
+ return f"NegativeDuration({self.absolute_value!r})"
285
+
286
+ @classmethod
287
+ def from_nanos(cls, _value: int) -> SignedDuration:
288
+ msg = "Calling NegativeDuration.from_nanos is not supported. Call SignedDuration.from_nanos instead."
289
+ raise TypeError(msg)
290
+
291
+ @classmethod
292
+ def from_micros(cls, _value: int) -> SignedDuration:
293
+ msg = "Calling NegativeDuration.from_micros is not supported. Call SignedDuration.from_micros instead."
294
+ raise TypeError(msg)
295
+
296
+ @classmethod
297
+ def from_millis(cls, _value: int) -> SignedDuration:
298
+ msg = "Calling NegativeDuration.from_millis is not supported. Call SignedDuration.from_millis instead."
299
+ raise TypeError(msg)
300
+
301
+ @classmethod
302
+ def from_seconds(cls, _value: int) -> SignedDuration:
303
+ msg = "Calling NegativeDuration.from_seconds is not supported. Call SignedDuration.from_seconds instead."
304
+ raise TypeError(msg)
305
+
306
+ def to_timedelta(self) -> datetime.timedelta:
307
+ return datetime.timedelta(microseconds=-self.absolute_value.to_micros())
308
+
309
+ def __add__(self, other: SignedDuration | Duration) -> SignedDuration:
310
+ match other:
311
+ case Duration(ns=other_ns) | PositiveDuration(Duration(ns=other_ns)):
312
+ return SignedDuration.from_nanos(-self.absolute_value.ns + other_ns)
313
+ case NegativeDuration(Duration(ns=other_negative_ns)):
314
+ return SignedDuration.from_nanos(-self.absolute_value.ns - other_negative_ns)
315
+ return NotImplemented
316
+
317
+ def __radd__(self, other: Duration) -> SignedDuration:
318
+ return self.__add__(other)
319
+
320
+ def __sub__(self, other: SignedDuration | Duration) -> SignedDuration:
321
+ match other:
322
+ case Duration(ns=other_ns) | PositiveDuration(Duration(ns=other_ns)):
323
+ return SignedDuration.from_nanos(-self.absolute_value.ns - other_ns)
324
+ case NegativeDuration(Duration(ns=other_negative_ns)):
325
+ return SignedDuration.from_nanos(-self.absolute_value.ns + other_negative_ns)
326
+ return NotImplemented
327
+
328
+ def __rsub__(self, other: Duration) -> SignedDuration:
329
+ if not isinstance(other, Duration):
330
+ return NotImplemented
331
+ return SignedDuration.from_nanos(other.ns + self.absolute_value.ns)
332
+
333
+ def __le__(self, other: SignedDuration | Duration) -> bool:
334
+ match other:
335
+ case Duration(ns=other_ns) | PositiveDuration(Duration(ns=other_ns)):
336
+ return -self.absolute_value.ns <= other_ns
337
+ case NegativeDuration(Duration(ns=other_negative_ns)):
338
+ return -self.absolute_value.ns <= -other_negative_ns
339
+ return NotImplemented
340
+
341
+ def __gt__(self, other: SignedDuration | Duration) -> bool:
342
+ result = self.__le__(other)
343
+ return NotImplemented if result is NotImplemented else not result
344
+
345
+ def __lt__(self, other: SignedDuration | Duration) -> bool:
346
+ match other:
347
+ case Duration(ns=other_ns) | PositiveDuration(Duration(ns=other_ns)):
348
+ return -self.absolute_value.ns < other_ns
349
+ case NegativeDuration(Duration(ns=other_negative_ns)):
350
+ return -self.absolute_value.ns < -other_negative_ns
351
+ return NotImplemented
352
+
353
+ def __ge__(self, other: SignedDuration | Duration) -> bool:
354
+ result = self.__lt__(other)
355
+ return NotImplemented if result is NotImplemented else not result
356
+
357
+
358
+ def hours(n: int) -> SignedDuration:
359
+ """Create an instance of SignedDuration from integer amount of hours."""
360
+ return SignedDuration.from_seconds(n * 3600)
361
+
362
+
363
+ def minutes(n: int) -> SignedDuration:
364
+ """Create an instance of SignedDuration from integer amount of minutes."""
365
+ return SignedDuration.from_seconds(n * 60)
366
+
367
+
368
+ def seconds(n: int) -> SignedDuration:
369
+ """Create an instance of SignedDuration from integer amount of seconds."""
370
+ return SignedDuration.from_seconds(n)
371
+
372
+
373
+ def milliseconds(n: int) -> SignedDuration:
374
+ """Create an instance of SignedDuration from integer amount of milliseconds."""
375
+ return SignedDuration.from_millis(n)
376
+
377
+
378
+ def microseconds(n: int) -> SignedDuration:
379
+ """Create an instance of SignedDuration from integer amount of microseconds."""
380
+ return SignedDuration.from_micros(n)
381
+
382
+
383
+ def nanoseconds(n: int) -> SignedDuration:
384
+ """Create an instance of SignedDuration from integer amount of nanoseconds."""
385
+ return SignedDuration.from_nanos(n)
@@ -0,0 +1,110 @@
1
+ """Instants represent fixed points in time."""
2
+
3
+ import dataclasses
4
+ import time
5
+ from typing import final, overload
6
+
7
+ from ._duration import Duration, NegativeDuration, PositiveDuration, SignedDuration
8
+
9
+
10
+ @final
11
+ @dataclasses.dataclass(init=False, repr=True, order=True, frozen=True, slots=True)
12
+ class Instant:
13
+ """Instant represents a specific absolute instant in time on Earth.
14
+
15
+ Values can be created using the now() function or the factory functions
16
+ (from_unix_nanos, from_unix_micros, from_unix_millis, from_unix_seconds).
17
+
18
+ Instant has absolutely no understanding of leap seconds. If your system does
19
+ not implement leap second smearing, you may encounter "jumps" or "skips" in
20
+ the value of Instant that can lead to errors. Additionally, calculating the
21
+ length of time between two Instants does not represent the real physical
22
+ amount of time passed whenever leap seconds have been inserted or removed.
23
+
24
+ It is recommended to use the values of Instant type everywhere in your code,
25
+ including fields and function signatures. Use explicit conversion functions at
26
+ the boundary of your code to interoperate with non-goodtime libraries and
27
+ APIs.
28
+ """
29
+
30
+ unix_timestamp_ns: int
31
+
32
+ def __init__(self, *, unix_timestamp_ns: int):
33
+ """Construct a value of Instant from UNIX timestamp in nanoseconds.
34
+
35
+ For readability it is recommended to use the explicit factory functions
36
+ (from_unix_nanos, from_unix_micros, from_unix_millis, from_unix_seconds)
37
+ instead.
38
+ """
39
+ if not isinstance(unix_timestamp_ns, int):
40
+ msg = f"unix_timestamp_ns must be int, was {type(unix_timestamp_ns)}"
41
+ raise TypeError(msg)
42
+ object.__setattr__(self, "unix_timestamp_ns", unix_timestamp_ns)
43
+
44
+ @classmethod
45
+ def from_unix_nanos(cls, value: int) -> "Instant":
46
+ """Create an Instant value from UNIX timestamp in nanoseconds."""
47
+ return cls(unix_timestamp_ns=value)
48
+
49
+ @classmethod
50
+ def from_unix_micros(cls, value: int) -> "Instant":
51
+ """Create an Instant value from UNIX timestamp in microseconds."""
52
+ return cls(unix_timestamp_ns=value * 1000)
53
+
54
+ @classmethod
55
+ def from_unix_millis(cls, value: int) -> "Instant":
56
+ """Create an Instant value from UNIX timestamp in milliseconds."""
57
+ return cls(unix_timestamp_ns=value * 1_000_000)
58
+
59
+ @classmethod
60
+ def from_unix_seconds(cls, value: int) -> "Instant":
61
+ """Create an Instant value from UNIX timestamp in seconds."""
62
+ return cls(unix_timestamp_ns=value * 1_000_000_000)
63
+
64
+ @classmethod
65
+ def now(cls) -> "Instant":
66
+ """Create an Instant representing the current time."""
67
+ return cls(unix_timestamp_ns=time.time_ns())
68
+
69
+ def to_unix_nanos(self) -> int:
70
+ """Return the value of this Instant as UNIX timestamp in nanoseconds."""
71
+ return self.unix_timestamp_ns
72
+
73
+ def to_unix_micros(self) -> int:
74
+ """Return the value of this Instant as UNIX timestamp in microseconds, rounding towards infinite past."""
75
+ return self.unix_timestamp_ns // 1000
76
+
77
+ def to_unix_millis(self) -> int:
78
+ """Return the value of this Instant as UNIX timestamp in milliseconds, rounding towards infinite past."""
79
+ return self.unix_timestamp_ns // 1_000_000
80
+
81
+ def to_unix_seconds(self) -> int:
82
+ """Return the value of this Instant as UNIX timestamp in seconds, rounding towards infinite past."""
83
+ return self.unix_timestamp_ns // 1_000_000_000
84
+
85
+ def __add__(self, other: Duration | SignedDuration) -> "Instant":
86
+ match other:
87
+ case Duration(ns=delta) | PositiveDuration(Duration(ns=delta)):
88
+ return Instant.from_unix_nanos(self.unix_timestamp_ns + delta)
89
+ case NegativeDuration(Duration(ns=negative_delta)):
90
+ return Instant.from_unix_nanos(self.unix_timestamp_ns - negative_delta)
91
+ return NotImplemented
92
+
93
+ def __radd__(self, other: Duration | SignedDuration) -> "Instant":
94
+ return self.__add__(other)
95
+
96
+ @overload
97
+ def __sub__(self, other: "Instant") -> SignedDuration: ...
98
+
99
+ @overload
100
+ def __sub__(self, other: Duration | SignedDuration) -> "Instant": ...
101
+
102
+ def __sub__(self, other: "Instant|Duration|SignedDuration") -> "Instant|SignedDuration":
103
+ match other:
104
+ case Duration(ns=delta) | PositiveDuration(Duration(ns=delta)):
105
+ return Instant.from_unix_nanos(self.unix_timestamp_ns - delta)
106
+ case NegativeDuration(Duration(ns=negative_delta)):
107
+ return Instant.from_unix_nanos(self.unix_timestamp_ns + negative_delta)
108
+ case Instant(unix_timestamp_ns=other_timestamp_ns):
109
+ return SignedDuration.from_nanos(self.unix_timestamp_ns - other_timestamp_ns)
110
+ return NotImplemented
@@ -0,0 +1,164 @@
1
+ """Representations for time zones."""
2
+
3
+ import dataclasses
4
+ import datetime
5
+ import zoneinfo
6
+
7
+ from ._civil_time import CivilSecond, CivilTime
8
+ from ._duration import Duration
9
+ from ._instant import Instant
10
+
11
+
12
+ @dataclasses.dataclass(frozen=True, repr=True, init=True, slots=True, eq=True)
13
+ class UniqueCivilTimeInstant:
14
+ """Civil time that has been uniquely mapped to an absolute instant.
15
+
16
+ This will be the result of Timezone.civil_time_to_instant most of the time
17
+ (i.e. except DST transitions).
18
+ """
19
+
20
+ instant: Instant
21
+
22
+
23
+ @dataclasses.dataclass(frozen=True, repr=True, init=True, slots=True, eq=True)
24
+ class RepeatedCivilTimeInstant:
25
+ """Civil time that happens twice due to DST transitions.
26
+
27
+ When Daylight Savings Time ends, the civil wall clock will display time that
28
+ it has already displayed before. It is therefore impossible to uniquely
29
+ identify the exact absolute instant of when the provided civil time was
30
+ observed.
31
+
32
+ Two possible options are pre_transition (civil time was observed before the
33
+ DST ended) and post_transition (after the DST ended). The instant of the
34
+ transition itself is returned in the transition field.
35
+ """
36
+
37
+ pre_transition: Instant
38
+ post_transition: Instant
39
+ transition: Instant
40
+
41
+
42
+ @dataclasses.dataclass(frozen=True, repr=True, init=True, slots=True, eq=True)
43
+ class SkippedCivilTimeInstant:
44
+ """Civil time that was (or will be) skipped.
45
+
46
+ When Daylight Savings Time begins, the civil wall clock will skip certain
47
+ times. The provided civil time never happened (and never will happen).
48
+
49
+ For convenience, the instant of the transition itself is returned, as well as
50
+ instants when two hypothetical incorrect wall clocks would have shown the
51
+ provided civil time: pre_transition (wall clock that was not updated for the
52
+ DST), and post_transition (wall clock that was updated for the DST too early).
53
+ """
54
+
55
+ pre_transition: Instant
56
+ post_transition: Instant
57
+ transition: Instant
58
+
59
+
60
+ CivilTimeInstant = UniqueCivilTimeInstant | RepeatedCivilTimeInstant | SkippedCivilTimeInstant
61
+
62
+
63
+ @dataclasses.dataclass(frozen=True, repr=True, order=False, slots=True, eq=False)
64
+ class Timezone:
65
+ """A Timezone represents a geographical region with identical civil time.
66
+
67
+ This is not the same as a UTC offset, since multiple regions have different
68
+ UTC offsets throughout the year (such as offsets for standard time and
69
+ daylight saving time).
70
+
71
+ To construct an instance of Timezone, use the from_tzdata_identifier factory
72
+ function.
73
+ """
74
+
75
+ tzinfo: datetime.tzinfo
76
+
77
+ @classmethod
78
+ def from_tzdata_identifier(cls, identifier: str) -> "Timezone":
79
+ """Construct a Timezone instance from a string identifier.
80
+
81
+ This identifier is also known as tzdata identifier, zoneinfo identifier or
82
+ IANA time zone. Examples are "America/Los_Angeles" and "Europe/Berlin".
83
+ """
84
+ return Timezone(zoneinfo.ZoneInfo(identifier))
85
+
86
+ def instant_to_civil_time(self, instant: Instant) -> CivilTime:
87
+ """Return the civil time for this timezone at a certain instant."""
88
+ seconds, nanoseconds = divmod(instant.to_unix_nanos(), 1_000_000_000)
89
+ dt = datetime.datetime.fromtimestamp(seconds, tz=datetime.timezone.utc).astimezone(self.tzinfo)
90
+ dt = datetime.datetime(
91
+ year=dt.year,
92
+ month=dt.month,
93
+ day=dt.day,
94
+ hour=dt.hour,
95
+ minute=dt.minute,
96
+ second=dt.second,
97
+ tzinfo=datetime.timezone.utc,
98
+ )
99
+ return CivilTime(second=CivilSecond.from_utc_datetime(dt), subsecond=Duration.from_nanos(nanoseconds))
100
+
101
+ def civil_time_to_instant(self, civil_time: CivilTime) -> CivilTimeInstant:
102
+ """Return the absolute instant when a given civil time was observed in this timezone.
103
+
104
+ When a civil time is repeated or skipped due to DST transitions, returns times calculated
105
+ before and after the transition, as well as the transition time itself.
106
+ """
107
+ utc_ts = int(
108
+ datetime.datetime(
109
+ year=civil_time.second.year,
110
+ month=civil_time.second.month,
111
+ day=civil_time.second.day,
112
+ hour=civil_time.second.hour,
113
+ minute=civil_time.second.minute,
114
+ second=civil_time.second.second,
115
+ tzinfo=datetime.timezone.utc,
116
+ ).timestamp()
117
+ )
118
+ offsets: list[tuple[int, int]] = []
119
+ for ts in range(utc_ts - 38 * 3600, utc_ts + 38 * 3600):
120
+ offset_td = datetime.datetime.fromtimestamp(ts, tz=self.tzinfo).utcoffset()
121
+ if not offset_td:
122
+ continue
123
+ offset = int(offset_td.total_seconds())
124
+ if not offsets or offsets[-1][0] != offset:
125
+ offsets.append((offset, ts))
126
+ if not offsets:
127
+ msg = (
128
+ "Unable to look up Instant based on given time zone and civil_time: tzinfo {self.tzinfo!r} did not "
129
+ "return UTC offsets as expected."
130
+ )
131
+ raise ValueError(msg)
132
+ if len(offsets) == 1:
133
+ return UniqueCivilTimeInstant(Instant.from_unix_seconds(utc_ts - offsets[0][0]) + civil_time.subsecond)
134
+ if len(offsets) > 2: # noqa: PLR2004
135
+ msg = (
136
+ "Unable to look up Instant based on given time zone and civil_time: tzinfo {self.tzinfo!r} returned "
137
+ "too many different UTC offsets around the expected time."
138
+ )
139
+ raise ValueError(msg)
140
+ transition_ts = offsets[1][1]
141
+ pre_ts = utc_ts - offsets[0][0]
142
+ post_ts = utc_ts - offsets[1][0]
143
+ is_pre_ts_valid = pre_ts < transition_ts
144
+ is_post_ts_valid = post_ts >= transition_ts
145
+ match (is_pre_ts_valid, is_post_ts_valid):
146
+ case (True, True):
147
+ return RepeatedCivilTimeInstant(
148
+ Instant.from_unix_seconds(pre_ts) + civil_time.subsecond,
149
+ Instant.from_unix_seconds(post_ts) + civil_time.subsecond,
150
+ Instant.from_unix_seconds(transition_ts),
151
+ )
152
+ case (True, False):
153
+ return UniqueCivilTimeInstant(
154
+ Instant.from_unix_seconds(pre_ts) + civil_time.subsecond,
155
+ )
156
+ case (False, True):
157
+ return UniqueCivilTimeInstant(
158
+ Instant.from_unix_seconds(post_ts) + civil_time.subsecond,
159
+ )
160
+ return SkippedCivilTimeInstant(
161
+ Instant.from_unix_seconds(pre_ts) + civil_time.subsecond,
162
+ Instant.from_unix_seconds(post_ts) + civil_time.subsecond,
163
+ Instant.from_unix_seconds(transition_ts),
164
+ )
@@ -0,0 +1,53 @@
1
+ [project]
2
+ authors = [{name = "Edward Toroshchin", email = "dev@hades.name"}]
3
+ dependencies = []
4
+ dynamic = ["version", "description"]
5
+ license = "MIT"
6
+ license-files = ["LICENSE"]
7
+ name = "goodtime"
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+
11
+ [build-system]
12
+ requires = ["flit_core >=3.11,<4"]
13
+ build-backend = "flit_core.buildapi"
14
+
15
+ [project.urls]
16
+ Home = "https://github.com/hades/goodtime"
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "mypy>=1.19.1",
21
+ "pytest>=9.0.2",
22
+ "pytest-cov>=7.0.0",
23
+ "ruff>=0.14.14",
24
+ ]
25
+
26
+ [tool.mypy]
27
+ strict = true
28
+
29
+ [tool.ruff]
30
+ line-length = 120
31
+ indent-width = 2
32
+ target-version = "py310"
33
+
34
+ [tool.ruff.lint]
35
+ select = ["ALL"]
36
+ ignore = [
37
+ "COM812", # missing-trailing-comma
38
+ "COM819", # prohibited-trailing-comma
39
+ "D203", # incorrect-blank-line-before-class
40
+ "D213", # multi-line-summary-second-line
41
+ "TRY003", # raise-vanilla-args
42
+ ]
43
+
44
+ [tool.ruff.lint.per-file-ignores]
45
+ "tests/**" = [
46
+ "ANN",
47
+ "D",
48
+ "PLR",
49
+ "S",
50
+ ]
51
+
52
+ [tool.ruff.lint.flake8-annotations]
53
+ mypy-init-return = true