wwvb 4.1.0a0__py3-none-any.whl → 5.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.
- uwwvb.py +193 -0
- wwvb/__init__.py +937 -0
- {leapseconddata → wwvb}/__version__.py +2 -2
- wwvb/decode.py +93 -0
- wwvb/dut1table.py +32 -0
- wwvb/gen.py +127 -0
- wwvb/iersdata.json +1 -0
- wwvb/iersdata.json.license +2 -0
- wwvb/iersdata.py +37 -0
- wwvb/tz.py +12 -0
- wwvb/updateiers.py +161 -0
- wwvb/wwvbtk.py +146 -0
- wwvb-5.0.0.dist-info/METADATA +200 -0
- wwvb-5.0.0.dist-info/RECORD +18 -0
- {wwvb-4.1.0a0.dist-info → wwvb-5.0.0.dist-info}/WHEEL +1 -1
- wwvb-5.0.0.dist-info/entry_points.txt +8 -0
- wwvb-5.0.0.dist-info/top_level.txt +2 -0
- leapseconddata/__init__.py +0 -342
- leapseconddata/__main__.py +0 -169
- wwvb-4.1.0a0.dist-info/METADATA +0 -60
- wwvb-4.1.0a0.dist-info/RECORD +0 -9
- wwvb-4.1.0a0.dist-info/entry_points.txt +0 -2
- wwvb-4.1.0a0.dist-info/top_level.txt +0 -1
- {leapseconddata → wwvb}/py.typed +0 -0
@@ -0,0 +1,200 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: wwvb
|
3
|
+
Version: 5.0.0
|
4
|
+
Summary: Generate WWVB timecodes for any desired time
|
5
|
+
Author-email: Jeff Epler <jepler@gmail.com>
|
6
|
+
Project-URL: Source, https://github.com/jepler/wwvbpy
|
7
|
+
Project-URL: Documentation, https://github.com/jepler/wwvbpy
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
12
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
13
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
14
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
15
|
+
Classifier: Operating System :: OS Independent
|
16
|
+
Requires-Python: >=3.9
|
17
|
+
Description-Content-Type: text/markdown
|
18
|
+
Requires-Dist: adafruit-circuitpython-datetime
|
19
|
+
Requires-Dist: beautifulsoup4
|
20
|
+
Requires-Dist: click
|
21
|
+
Requires-Dist: leapseconddata
|
22
|
+
Requires-Dist: platformdirs
|
23
|
+
Requires-Dist: python-dateutil
|
24
|
+
Requires-Dist: requests
|
25
|
+
Requires-Dist: tzdata
|
26
|
+
|
27
|
+
<!--
|
28
|
+
SPDX-FileCopyrightText: 2021-2024 Jeff Epler
|
29
|
+
|
30
|
+
SPDX-License-Identifier: GPL-3.0-only
|
31
|
+
-->
|
32
|
+
[](https://github.com/jepler/wwvbpy/actions/workflows/test.yml)
|
33
|
+
[](https://codecov.io/gh/jepler/wwvbpy)
|
34
|
+
[](https://github.com/jepler/wwvbpy/actions/workflows/cron.yml)
|
35
|
+
[](https://pypi.org/project/wwvb)
|
36
|
+
[](https://github.com/jepler/wwvbpy/actions/workflows/codeql.yml)
|
37
|
+
[](https://results.pre-commit.ci/latest/github/jepler/wwvbpy/main)
|
38
|
+
|
39
|
+
# Purpose
|
40
|
+
|
41
|
+
wwvbpy generates WWVB timecodes for any desired time. These timecodes
|
42
|
+
may be useful in testing WWVB decoder software.
|
43
|
+
|
44
|
+
Where possible, wwvbpy uses existing facilities for calendar and time
|
45
|
+
manipulation (datetime and dateutil).
|
46
|
+
|
47
|
+
It uses DUT1/leap second data derived from IERS Bulletin "A" and from NIST's
|
48
|
+
"Leap second and UT1-UTC information" page. With regular updates to
|
49
|
+
the "iersdata", wwvbpy should be able to correctly encode the time anywhere
|
50
|
+
within the 100-year WWVB epoch. (yes, WWVB uses a 2-digit year! In order to
|
51
|
+
work with historical data, the epoch is arbitrarily assumed to run from 1970 to
|
52
|
+
2069.)
|
53
|
+
|
54
|
+
Programs include:
|
55
|
+
* `wwvbgen`, the main commandline generator program
|
56
|
+
* `wwvbdecode`, the main commandline decoder program
|
57
|
+
* `wwvbtk`, visualize the simulated WWVB signal in real-time using Tkinter
|
58
|
+
* `dut1table`, print the full history of dut1 values, including estimated future values
|
59
|
+
* `updateiers`, download the latest dut1 data including prospective data from IERS and NIST
|
60
|
+
|
61
|
+
The package includes:
|
62
|
+
* `wwvb`, for generating WWVB timecodes
|
63
|
+
* `wwvb.decode`, a generator-based state machine for decoding WWVB timecodes (amplitude modulation only)
|
64
|
+
* `uwwvb`, a version of the decoder intended for use on constrained environments such as [CircuitPython](https://circuitpython.org).
|
65
|
+
|
66
|
+
# Development status
|
67
|
+
|
68
|
+
The author (@jepler) occasionally develops and maintains this project, but
|
69
|
+
issues are not likely to be acted on. They would be interested in adding
|
70
|
+
co-maintainer(s).
|
71
|
+
|
72
|
+
|
73
|
+
# WWVB Timecodes
|
74
|
+
|
75
|
+
The National Institute of Standards and Technology operates the WWVB time
|
76
|
+
signal service near Fort Collins, Colorado. The signal can be received in most
|
77
|
+
of the continental US. Each minute, the signal transmits the current time,
|
78
|
+
including information about leap years, daylight saving time, and leap seconds.
|
79
|
+
The signal is composed of an amplitude channel and a phase modulation channel.
|
80
|
+
|
81
|
+
The amplitude channel can be visualized as a sequence of (usually) 60 symbols,
|
82
|
+
which by default wwvbgen displays as 0, 1, or 2. The 0s and 1s encode
|
83
|
+
information like the current day of the year, while the 2s appear in fixed
|
84
|
+
locations to allow a receiver to determine the start of a minute.
|
85
|
+
|
86
|
+
The phase channel (which is displayed with `--channel=phase` or
|
87
|
+
`--channel=both`) consists of the same number of symbols per minute. This
|
88
|
+
channel is substantially more complicated than the phase channel. It encodes
|
89
|
+
the current time as minute-of-the-century, provides extended DST information,
|
90
|
+
and includes error-correction information not available in the amplitude
|
91
|
+
channel.
|
92
|
+
|
93
|
+
# Usage
|
94
|
+
|
95
|
+
~~~~
|
96
|
+
Usage: python -m wwvb.gen [OPTIONS] [TIMESPEC]...
|
97
|
+
|
98
|
+
Generate WWVB timecodes
|
99
|
+
|
100
|
+
TIMESPEC: one of "year yday hour minute" or "year month day hour minute", or
|
101
|
+
else the current minute
|
102
|
+
|
103
|
+
Options:
|
104
|
+
-i, --iers / -I, --no-iers Whether to use IESR data for DUT1 and LS.
|
105
|
+
(Default: --iers)
|
106
|
+
-s, --leap-second Force a positive leap second at the end of
|
107
|
+
the GMT month (Implies --no-iers)
|
108
|
+
-n, --negative-leap-second Force a negative leap second at the end of
|
109
|
+
the GMT month (Implies --no-iers)
|
110
|
+
-S, --no-leap-second Force no leap second at the end of the month
|
111
|
+
(Implies --no-iers)
|
112
|
+
-d, --dut1 INTEGER Force the DUT1 value (Implies --no-iers)
|
113
|
+
-m, --minutes INTEGER Number of minutes to show (default: 10)
|
114
|
+
--style [bar|cradek|default|duration|json|sextant]
|
115
|
+
Style of output
|
116
|
+
-t, --all-timecodes / -T, --no-all-timecodes
|
117
|
+
Show the 'WWVB timecode' line before each
|
118
|
+
minute
|
119
|
+
--channel [amplitude|phase|both]
|
120
|
+
Modulation to show (default: amplitude)
|
121
|
+
--help Show this message and exit.
|
122
|
+
~~~~
|
123
|
+
|
124
|
+
For example, to display the leap second that occurred at the end of 1998,
|
125
|
+
~~~~
|
126
|
+
$ python wwvbgen.py -m 7 1998 365 23 56
|
127
|
+
WWVB timecode: year=98 days=365 hour=23 min=56 dst=0 ut1=-300 ly=0 ls=1
|
128
|
+
'98+365 23:56 210100110200100001120011001102010100010200110100121000001002
|
129
|
+
'98+365 23:57 210100111200100001120011001102010100010200110100121000001002
|
130
|
+
'98+365 23:58 210101000200100001120011001102010100010200110100121000001002
|
131
|
+
'98+365 23:59 2101010012001000011200110011020101000102001101001210000010022
|
132
|
+
'99+001 00:00 200000000200000000020000000002000100101201110100121001000002
|
133
|
+
'99+001 00:01 200000001200000000020000000002000100101201110100121001000002
|
134
|
+
'99+001 00:02 200000010200000000020000000002000100101201110100121001000002
|
135
|
+
~~~~
|
136
|
+
(the leap second is the extra digit at the end of the 23:59 line; that minute
|
137
|
+
consists of 61 seconds, instead of the normal 60)
|
138
|
+
|
139
|
+
|
140
|
+
# How wwvbpy handles DUT1 data
|
141
|
+
|
142
|
+
wwvbpy stores a compact representation of DUT1 values in `wwvb/iersdata_dist.py` or `wwvb_iersdata.py`.
|
143
|
+
In this representation, one value is used for one day (0000UTC through 2359UTC).
|
144
|
+
The letters `a` through `u` represent offsets of -1.0s through +1.0s
|
145
|
+
in 0.1s increments; `k` represents 0s. (In practice, only a smaller range
|
146
|
+
of values, typically -0.7s to +0.8s, is seen)
|
147
|
+
|
148
|
+
For 2001 through 2019, NIST has published the actual DUT1 values broadcast,
|
149
|
+
and the date of each change, though it in the format of an HTML
|
150
|
+
table and not designed for machine readability:
|
151
|
+
|
152
|
+
https://www.nist.gov/pml/time-and-frequency-division/atomic-standards/leap-second-and-ut1-utc-information
|
153
|
+
|
154
|
+
NIST does not update the value daily and does not seem to follow any
|
155
|
+
specific rounding rule. Rather, in WWVB "the resolution of the DUT1
|
156
|
+
correction is 0.1 s, and represents an average value for an extended
|
157
|
+
range of dates. Therefore, it will not agree exactly with the weekly
|
158
|
+
UT1-UTC(NIST) values shown in the earlier table, which have 1 ms
|
159
|
+
resolution and are updated weekly." Like wwvbpy's compact
|
160
|
+
representation of DUT1 values, the real WWVB does not appear to ever
|
161
|
+
broadcast DUT1=-0.0.
|
162
|
+
|
163
|
+
For a larger range of dates spanning 1973 through approximately one year from
|
164
|
+
now, IERS publishes historical and prospective UT1-UTC values to multiple
|
165
|
+
decimal places, in a machine readable fixed length format.
|
166
|
+
|
167
|
+
wwvbpy merges the WWVB and IERS datasets, favoring the WWVB dataset for dates when it is available. There are some caveats to this, which are mostly commented in the `wwvb/updateiers.py` script.
|
168
|
+
|
169
|
+
`wwvb/iersdata_dist.py` is updated monthly from github actions or with `iersdata --dist` from within the wwvbpy source tree. However, at this time, releases are not regularly made from the updated information.
|
170
|
+
|
171
|
+
A site or user version of the file, `wwvb_iersdata.py` can be created or updated with `iersdata --site` or `iersdata --user`. If the distributed iersdata is out of date, the generator will prompt you to run the update command.
|
172
|
+
|
173
|
+
Leap seconds are inferred from the DUT1 data as follows: If X and Y are the
|
174
|
+
1-digit-rounded DUT1 values for consecutive dates, and `X*Y<0`, then there is a
|
175
|
+
leap second at the end of day X. The direction of the leap second can be
|
176
|
+
inferred from the sign of X, a 59-second minute if X is positive and a
|
177
|
+
61-second minute if it is negative. As long
|
178
|
+
as DUT1 changes slowly enough during other times that there is at least one day
|
179
|
+
of DUT1=+0.0, no incorrect (negative) leapsecond will be inferred. (something
|
180
|
+
that should remain true for the next few centuries, until the length of the day
|
181
|
+
is 100ms different from 86400 seconds)
|
182
|
+
|
183
|
+
|
184
|
+
# The phase modulation channel
|
185
|
+
|
186
|
+
This should be considered more experimental than the AM channel, as the
|
187
|
+
tests only cover a single reference minute. Further tests could be informed
|
188
|
+
by the [other implementation I know of](http://www.leapsecond.com/tools/wwvb_pm.c), except that implementation appears incomplete.
|
189
|
+
|
190
|
+
|
191
|
+
# Testing wwvbpy
|
192
|
+
|
193
|
+
Run the testsuite, check coverage & type annotations with `gmake`.
|
194
|
+
|
195
|
+
There are several test suites:
|
196
|
+
* `testwwvb.py`: Check output against expected values. Uses hard coded leap seconds. Tests amplitude and phase data, though the phase testcases are dubious as they were also generated by wwvbpy.
|
197
|
+
* `testuwwvb.py`: Test the reduced-functionality version against the main version
|
198
|
+
* `testls.py`: Check the IERS data through 2020-1-1 for expected leap seconds
|
199
|
+
* `testpm.py`: Check the phase modulation data against a test case from NIST documentation
|
200
|
+
* `testcli.py`: Check the commandline programs work as expected (limited tests to get 100% coverage)
|
@@ -0,0 +1,18 @@
|
|
1
|
+
uwwvb.py,sha256=dyYlxZ6tIlcbKsWExNHKA8OtRfbziaCy5Xcj_97bGtA,5737
|
2
|
+
wwvb/__init__.py,sha256=rDpr-XLoMld473qP-ufDunQxiDbLQDeS6eN7NOBtW84,30790
|
3
|
+
wwvb/__version__.py,sha256=U7HnWMtKn0QTFHRJAzsVjr4cELMq3Toi6P5afKP6ah0,411
|
4
|
+
wwvb/decode.py,sha256=llTLKBW49nl6COheM90NsyMnTNeVApl2oeCHtl6Tf3w,2759
|
5
|
+
wwvb/dut1table.py,sha256=HVX1338RlQzAQ-bsMPEdmCqoyIxSWoJSoRu1YGyaJO4,875
|
6
|
+
wwvb/gen.py,sha256=_fpUypu_2nZfG5Vjnya0B8C26nk1WOhnLMTCXwskAHs,3720
|
7
|
+
wwvb/iersdata.json,sha256=cutY_J5jElAyLBsxfcDnP0eHY5fkQQ8Y1nDO77uEU4I,770
|
8
|
+
wwvb/iersdata.json.license,sha256=1k5fhRCuOn0yXbwHtB21G0Nntnf0qMxstflMHuK3-Js,71
|
9
|
+
wwvb/iersdata.py,sha256=_PSnGhNS5QU4AAk_aFbmR4Jru0pK_y-5JtNL6pAwtT8,1210
|
10
|
+
wwvb/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
11
|
+
wwvb/tz.py,sha256=nlxKnzFPmqLLtC-cEDhWaJ3v3GCSPfqzVtUMf8EEdZ0,248
|
12
|
+
wwvb/updateiers.py,sha256=2kIzgLD_109EpapdscZj7dGt8z-KzE7Yp4LhRi5NW7Q,5671
|
13
|
+
wwvb/wwvbtk.py,sha256=QBJntkgLJPPjsxYC0szDZZXajijUbuwEmU_mjGEJ4GI,4599
|
14
|
+
wwvb-5.0.0.dist-info/METADATA,sha256=nOmRifOcCGG1uEgKgr607Fxw-9vB8osk8IL1VBSQwug,10382
|
15
|
+
wwvb-5.0.0.dist-info/WHEEL,sha256=OVMc5UfuAQiSplgO0_WdW7vXVGAt9Hdd6qtN4HotdyA,91
|
16
|
+
wwvb-5.0.0.dist-info/entry_points.txt,sha256=KSevvHWLEKxOxUQ-L-OQidD4Sj2BPEfhZ2TQhOgyys4,179
|
17
|
+
wwvb-5.0.0.dist-info/top_level.txt,sha256=0IYdkhEAMgurpv_F-76rlyn4GdxepGFzG99tivVdQVU,11
|
18
|
+
wwvb-5.0.0.dist-info/RECORD,,
|
leapseconddata/__init__.py
DELETED
@@ -1,342 +0,0 @@
|
|
1
|
-
#!/usr/bin/env python3
|
2
|
-
|
3
|
-
# SPDX-FileCopyrightText: 2021 Jeff Epler
|
4
|
-
#
|
5
|
-
# SPDX-License-Identifier: GPL-3.0-only
|
6
|
-
|
7
|
-
"""Use the list of known and scheduled leap seconds
|
8
|
-
|
9
|
-
For example, to retrieve the UTC-TAI offset on January 1, 2011:
|
10
|
-
|
11
|
-
.. code-block:: python
|
12
|
-
:emphasize-lines: 2,3,5
|
13
|
-
|
14
|
-
>>> import datetime
|
15
|
-
>>> import leapseconddata
|
16
|
-
>>> ls = leapseconddata.LeapSecondData.from_standard_source()
|
17
|
-
>>> when = datetime.datetime(2011, 1, 1, tzinfo=datetime.timezone.utc)
|
18
|
-
>>> ls.tai_offset(when).total_seconds()
|
19
|
-
34.0
|
20
|
-
|
21
|
-
"""
|
22
|
-
|
23
|
-
from __future__ import annotations
|
24
|
-
|
25
|
-
import datetime
|
26
|
-
import hashlib
|
27
|
-
import io
|
28
|
-
import logging
|
29
|
-
import pathlib
|
30
|
-
import re
|
31
|
-
import urllib.request
|
32
|
-
from dataclasses import dataclass, field
|
33
|
-
from typing import BinaryIO
|
34
|
-
|
35
|
-
tai = datetime.timezone(datetime.timedelta(0), "TAI")
|
36
|
-
|
37
|
-
NTP_EPOCH = datetime.datetime(1900, 1, 1, tzinfo=datetime.timezone.utc)
|
38
|
-
|
39
|
-
|
40
|
-
@dataclass(frozen=True)
|
41
|
-
class LeapSecondInfo:
|
42
|
-
"""Information about a particular leap second"""
|
43
|
-
|
44
|
-
start: datetime.datetime
|
45
|
-
"""The UTC timestamp just after the insertion of the leap second.
|
46
|
-
|
47
|
-
The leap second is actually the 61th second of the previous minute (xx:xx:60)"""
|
48
|
-
|
49
|
-
tai_offset: datetime.timedelta
|
50
|
-
"""The new TAI-UTC offset. Positive numbers indicate that TAI is ahead of UTC"""
|
51
|
-
|
52
|
-
|
53
|
-
class ValidityError(ValueError):
|
54
|
-
"""The leap second information is not valid for the given timestamp"""
|
55
|
-
|
56
|
-
|
57
|
-
class InvalidHashError(ValueError):
|
58
|
-
"""The file hash could not be verified"""
|
59
|
-
|
60
|
-
|
61
|
-
def _from_ntp_epoch(value: int) -> datetime.datetime:
|
62
|
-
return NTP_EPOCH + datetime.timedelta(seconds=value)
|
63
|
-
|
64
|
-
|
65
|
-
def datetime_is_tai(when: datetime.datetime) -> bool:
|
66
|
-
"""Return true if the datetime is in the TAI timescale"""
|
67
|
-
return when.tzname() == "TAI"
|
68
|
-
|
69
|
-
|
70
|
-
@dataclass(frozen=True)
|
71
|
-
class LeapSecondData:
|
72
|
-
"""Represent the list of known and scheduled leapseconds
|
73
|
-
|
74
|
-
:param List[LeapSecondInfo] leap_seconds: A list of leap seconds
|
75
|
-
:param Optional[datetime.datetime] valid_until: The expiration of the data
|
76
|
-
:param Optional[datetime.datetime] updated: The last update time of the data
|
77
|
-
"""
|
78
|
-
|
79
|
-
leap_seconds: list[LeapSecondInfo]
|
80
|
-
"""All known and scheduled leap seconds"""
|
81
|
-
|
82
|
-
valid_until: datetime.datetime | None = field(default=None)
|
83
|
-
"""The list is valid until this UTC time"""
|
84
|
-
|
85
|
-
last_updated: datetime.datetime | None = field(default=None)
|
86
|
-
"""The last time the list was updated to add a new upcoming leap second"""
|
87
|
-
|
88
|
-
def _check_validity(self, when: datetime.datetime | None) -> str | None:
|
89
|
-
if when is None:
|
90
|
-
when = datetime.datetime.now(datetime.timezone.utc)
|
91
|
-
if not self.valid_until:
|
92
|
-
return "Data validity unknown"
|
93
|
-
if when > self.valid_until:
|
94
|
-
return f"Data only valid until {self.valid_until:%Y-%m-%d}"
|
95
|
-
return None
|
96
|
-
|
97
|
-
def valid(self, when: datetime.datetime | None = None) -> bool:
|
98
|
-
"""Return True if the data is valid at given datetime
|
99
|
-
|
100
|
-
If `when` is none, the validity for the current moment is checked.
|
101
|
-
|
102
|
-
:param when: Moment to check for validity
|
103
|
-
"""
|
104
|
-
return self._check_validity(when) is None
|
105
|
-
|
106
|
-
@staticmethod
|
107
|
-
def _utc_datetime(when: datetime.datetime) -> datetime.datetime:
|
108
|
-
if when.tzinfo is not None and when.tzinfo is not datetime.timezone.utc:
|
109
|
-
when = when.astimezone(datetime.timezone.utc)
|
110
|
-
return when
|
111
|
-
|
112
|
-
def tai_offset(self, when: datetime.datetime, *, check_validity: bool = True) -> datetime.timedelta:
|
113
|
-
"""For a given datetime, return the TAI-UTC offset
|
114
|
-
|
115
|
-
:param when: Moment in time to find offset for
|
116
|
-
:param check_validity: Check whether the database is valid for the given moment
|
117
|
-
|
118
|
-
For times before the first leap second, a zero offset is returned.
|
119
|
-
For times after the end of the file's validity, an exception is raised
|
120
|
-
unless `check_validity=False` is passed. In this case, it will return
|
121
|
-
the offset of the last list entry.
|
122
|
-
"""
|
123
|
-
is_tai = datetime_is_tai(when)
|
124
|
-
if not is_tai:
|
125
|
-
when = self._utc_datetime(when)
|
126
|
-
if check_validity:
|
127
|
-
message = self._check_validity(when)
|
128
|
-
if message is not None:
|
129
|
-
raise ValidityError(message)
|
130
|
-
|
131
|
-
if not self.leap_seconds:
|
132
|
-
return datetime.timedelta(0)
|
133
|
-
|
134
|
-
old_tai = datetime.timedelta()
|
135
|
-
for leap_second in self.leap_seconds:
|
136
|
-
start = leap_second.start
|
137
|
-
if is_tai:
|
138
|
-
start += leap_second.tai_offset - datetime.timedelta(seconds=1)
|
139
|
-
if when < start:
|
140
|
-
return old_tai
|
141
|
-
old_tai = leap_second.tai_offset
|
142
|
-
return self.leap_seconds[-1].tai_offset
|
143
|
-
|
144
|
-
def to_tai(self, when: datetime.datetime, *, check_validity: bool = True) -> datetime.datetime:
|
145
|
-
"""Convert the given datetime object to TAI.
|
146
|
-
|
147
|
-
:param when: Moment in time to convert. If naive, it is assumed to be in UTC.
|
148
|
-
:param check_validity: Check whether the database is valid for the given moment
|
149
|
-
|
150
|
-
Naive timestamps are assumed to be UTC. A TAI timestamp is returned unchanged.
|
151
|
-
"""
|
152
|
-
if datetime_is_tai(when):
|
153
|
-
return when
|
154
|
-
when = self._utc_datetime(when)
|
155
|
-
return (when + self.tai_offset(when, check_validity=check_validity)).replace(tzinfo=tai)
|
156
|
-
|
157
|
-
def tai_to_utc(self, when: datetime.datetime, *, check_validity: bool = True) -> datetime.datetime:
|
158
|
-
"""Convert the given datetime object to UTC
|
159
|
-
|
160
|
-
For a leap second, the ``fold`` property of the returned time is True.
|
161
|
-
|
162
|
-
:param when: Moment in time to convert. If not naive, its ``tzinfo`` must be `tai`.
|
163
|
-
:param check_validity: Check whether the database is valid for the given moment
|
164
|
-
"""
|
165
|
-
if when.tzinfo is not None and when.tzinfo is not tai:
|
166
|
-
raise ValueError("Input timestamp is not TAI or naive")
|
167
|
-
if when.tzinfo is None:
|
168
|
-
when = when.replace(tzinfo=tai)
|
169
|
-
result = (when - self.tai_offset(when, check_validity=check_validity)).replace(tzinfo=datetime.timezone.utc)
|
170
|
-
if self.is_leap_second(when, check_validity=check_validity):
|
171
|
-
result = result.replace(fold=True)
|
172
|
-
return result
|
173
|
-
|
174
|
-
def is_leap_second(self, when: datetime.datetime, *, check_validity: bool = True) -> bool:
|
175
|
-
"""Return True if the given timestamp is the leap second.
|
176
|
-
|
177
|
-
:param when: Moment in time to check. If naive, it is assumed to be in UTC.
|
178
|
-
:param check_validity: Check whether the database is valid for the given moment
|
179
|
-
|
180
|
-
For a TAI timestamp, it returns True for the leap second (the one that
|
181
|
-
would be shown as :60 in UTC). For a UTC timestamp, it returns True
|
182
|
-
for the :59 second if ``fold``, since the :60 second cannot be
|
183
|
-
represented.
|
184
|
-
"""
|
185
|
-
if when.tzinfo is not tai:
|
186
|
-
when = self.to_tai(when, check_validity=check_validity) + datetime.timedelta(seconds=when.fold)
|
187
|
-
tai_offset1 = self.tai_offset(when, check_validity=check_validity)
|
188
|
-
tai_offset2 = self.tai_offset(when - datetime.timedelta(seconds=1), check_validity=check_validity)
|
189
|
-
return tai_offset1 != tai_offset2
|
190
|
-
|
191
|
-
@classmethod
|
192
|
-
def from_standard_source(
|
193
|
-
cls,
|
194
|
-
when: datetime.datetime | None = None,
|
195
|
-
*,
|
196
|
-
check_hash: bool = True,
|
197
|
-
) -> LeapSecondData:
|
198
|
-
"""Get the list of leap seconds from a standard source.
|
199
|
-
|
200
|
-
:param when: Check that the data is valid for this moment
|
201
|
-
:param check_hash: Whether to check the embedded hash
|
202
|
-
|
203
|
-
Using a list of standard sources, including network sources, find a
|
204
|
-
leap-second.list data valid for the given timestamp, or the current
|
205
|
-
time (if unspecified)
|
206
|
-
"""
|
207
|
-
for location in [ # pragma no branch
|
208
|
-
"file:///usr/share/zoneinfo/leap-seconds.list", # Debian Linux
|
209
|
-
"file:///var/db/ntpd.leap-seconds.list", # FreeBSD
|
210
|
-
"https://raw.githubusercontent.com/eggert/tz/main/leap-seconds.list",
|
211
|
-
"https://www.meinberg.de/download/ntp/leap-seconds.list",
|
212
|
-
]:
|
213
|
-
logging.debug("Trying leap second data from %s", location)
|
214
|
-
try:
|
215
|
-
candidate = cls.from_url(location, check_hash=check_hash)
|
216
|
-
except InvalidHashError: # pragma no cover
|
217
|
-
logging.warning("Invalid hash while reading %s", location)
|
218
|
-
continue
|
219
|
-
if candidate is None: # pragma no cover
|
220
|
-
continue
|
221
|
-
if candidate.valid(when): # pragma no branch
|
222
|
-
logging.info("Using leap second data from %s", location)
|
223
|
-
return candidate
|
224
|
-
logging.warning("Validity expired for %s", location) # pragma no cover
|
225
|
-
|
226
|
-
raise ValidityError("No valid leap-second.list file could be found") # pragma no cover
|
227
|
-
|
228
|
-
@classmethod
|
229
|
-
def from_file(
|
230
|
-
cls,
|
231
|
-
filename: str = "/usr/share/zoneinfo/leap-seconds.list",
|
232
|
-
*,
|
233
|
-
check_hash: bool = True,
|
234
|
-
) -> LeapSecondData:
|
235
|
-
"""Retrieve the leap second list from a local file.
|
236
|
-
|
237
|
-
:param filename: Local filename to read leap second data from. The
|
238
|
-
default is the standard location for the file on Debian systems.
|
239
|
-
:param check_hash: Whether to check the embedded hash
|
240
|
-
"""
|
241
|
-
with pathlib.Path(filename).open("rb") as open_file: # pragma no cover
|
242
|
-
return cls.from_open_file(open_file, check_hash=check_hash)
|
243
|
-
|
244
|
-
@classmethod
|
245
|
-
def from_url(
|
246
|
-
cls,
|
247
|
-
url: str = "https://raw.githubusercontent.com/eggert/tz/main/leap-seconds.list",
|
248
|
-
*,
|
249
|
-
check_hash: bool = True,
|
250
|
-
) -> LeapSecondData | None:
|
251
|
-
"""Retrieve the leap second list from a local file
|
252
|
-
|
253
|
-
:param filename: URL to read leap second data from. The
|
254
|
-
default is maintained by the tzdata authors
|
255
|
-
:param check_hash: Whether to check the embedded hash
|
256
|
-
"""
|
257
|
-
try:
|
258
|
-
with urllib.request.urlopen(url) as open_file:
|
259
|
-
return cls.from_open_file(open_file, check_hash=check_hash)
|
260
|
-
except urllib.error.URLError: # pragma no cover
|
261
|
-
return None
|
262
|
-
|
263
|
-
@classmethod
|
264
|
-
def from_data(
|
265
|
-
cls,
|
266
|
-
data: bytes | str,
|
267
|
-
*,
|
268
|
-
check_hash: bool = True,
|
269
|
-
) -> LeapSecondData:
|
270
|
-
"""Retrieve the leap second list from local data
|
271
|
-
|
272
|
-
:param data: Data to parse as a leap second list
|
273
|
-
:param check_hash: Whether to check the embedded hash
|
274
|
-
"""
|
275
|
-
if isinstance(data, str):
|
276
|
-
data = data.encode("ascii", "replace")
|
277
|
-
return cls.from_open_file(io.BytesIO(data), check_hash=check_hash)
|
278
|
-
|
279
|
-
@staticmethod
|
280
|
-
def _parse_content_hash(row: bytes) -> str:
|
281
|
-
"""Transform the SHA1 content into a string that matches `hexdigest()`
|
282
|
-
|
283
|
-
The SHA1 hash of the leap second content is stored in an unusual way.
|
284
|
-
"""
|
285
|
-
parts = row.split()
|
286
|
-
hash_parts = [int(s, 16) for s in parts[1:]]
|
287
|
-
return "".join(f"{i:08x}" for i in hash_parts)
|
288
|
-
|
289
|
-
@classmethod
|
290
|
-
def from_open_file(cls, open_file: BinaryIO, *, check_hash: bool = True) -> LeapSecondData:
|
291
|
-
"""Retrieve the leap second list from an open file-like object
|
292
|
-
|
293
|
-
:param open_file: Binary IO object containing the leap second list
|
294
|
-
:param check_hash: Whether to check the embedded hash
|
295
|
-
"""
|
296
|
-
leap_seconds: list[LeapSecondInfo] = []
|
297
|
-
valid_until = None
|
298
|
-
last_updated = None
|
299
|
-
content_to_hash = []
|
300
|
-
content_hash = None
|
301
|
-
|
302
|
-
hasher = hashlib.sha1()
|
303
|
-
|
304
|
-
for row in open_file:
|
305
|
-
row = row.strip() # noqa: PLW2901
|
306
|
-
if row.startswith(b"#h"):
|
307
|
-
content_hash = cls._parse_content_hash(row)
|
308
|
-
continue
|
309
|
-
|
310
|
-
if row.startswith(b"#@"):
|
311
|
-
parts = row.split()
|
312
|
-
hasher.update(parts[1])
|
313
|
-
valid_until = _from_ntp_epoch(int(parts[1]))
|
314
|
-
continue
|
315
|
-
|
316
|
-
if row.startswith(b"#$"):
|
317
|
-
parts = row.split()
|
318
|
-
hasher.update(parts[1])
|
319
|
-
last_updated = _from_ntp_epoch(int(parts[1]))
|
320
|
-
continue
|
321
|
-
|
322
|
-
row = row.split(b"#")[0].strip() # noqa: PLW2901
|
323
|
-
content_to_hash.extend(re.findall(rb"\d+", row))
|
324
|
-
|
325
|
-
parts = row.split()
|
326
|
-
if len(parts) != 2: # noqa: PLR2004
|
327
|
-
continue
|
328
|
-
hasher.update(parts[0])
|
329
|
-
hasher.update(parts[1])
|
330
|
-
|
331
|
-
when = _from_ntp_epoch(int(parts[0]))
|
332
|
-
tai_offset = datetime.timedelta(seconds=int(parts[1]))
|
333
|
-
leap_seconds.append(LeapSecondInfo(when, tai_offset))
|
334
|
-
|
335
|
-
if check_hash:
|
336
|
-
if content_hash is None:
|
337
|
-
raise InvalidHashError("No #h line found")
|
338
|
-
digest = hasher.hexdigest()
|
339
|
-
if digest != content_hash:
|
340
|
-
raise InvalidHashError(f"Hash didn't match. Expected {content_hash[:8]}..., got {digest[:8]}...")
|
341
|
-
|
342
|
-
return LeapSecondData(leap_seconds, valid_until, last_updated)
|