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 +21 -0
- goodtime-0.4.0/PKG-INFO +54 -0
- goodtime-0.4.0/README.md +43 -0
- goodtime-0.4.0/goodtime/__init__.py +81 -0
- goodtime-0.4.0/goodtime/_civil_time.py +84 -0
- goodtime-0.4.0/goodtime/_duration.py +385 -0
- goodtime-0.4.0/goodtime/_instant.py +110 -0
- goodtime-0.4.0/goodtime/_timezone.py +164 -0
- goodtime-0.4.0/pyproject.toml +53 -0
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.
|
goodtime-0.4.0/PKG-INFO
ADDED
|
@@ -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.
|
goodtime-0.4.0/README.md
ADDED
|
@@ -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
|