wwvb 4.0.0a0__py3-none-any.whl → 4.1.0a0__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.
- leapseconddata/__init__.py +342 -0
- leapseconddata/__main__.py +169 -0
- {wwvb → leapseconddata}/__version__.py +2 -2
- wwvb-4.1.0a0.dist-info/METADATA +60 -0
- wwvb-4.1.0a0.dist-info/RECORD +9 -0
- wwvb-4.1.0a0.dist-info/entry_points.txt +2 -0
- wwvb-4.1.0a0.dist-info/top_level.txt +1 -0
- uwwvb.py +0 -193
- wwvb/__init__.py +0 -935
- wwvb/decode.py +0 -90
- wwvb/dut1table.py +0 -32
- wwvb/gen.py +0 -128
- wwvb/iersdata.py +0 -28
- wwvb/iersdata_dist.py +0 -38
- wwvb/testcli.py +0 -291
- wwvb/testdaylight.py +0 -60
- wwvb/testls.py +0 -63
- wwvb/testpm.py +0 -33
- wwvb/testuwwvb.py +0 -221
- wwvb/testwwvb.py +0 -403
- wwvb/tz.py +0 -13
- wwvb/updateiers.py +0 -199
- wwvb/wwvbtk.py +0 -144
- wwvb-4.0.0a0.dist-info/METADATA +0 -199
- wwvb-4.0.0a0.dist-info/RECORD +0 -23
- wwvb-4.0.0a0.dist-info/entry_points.txt +0 -8
- wwvb-4.0.0a0.dist-info/top_level.txt +0 -2
- {wwvb → leapseconddata}/py.typed +0 -0
- {wwvb-4.0.0a0.dist-info → wwvb-4.1.0a0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,342 @@
|
|
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)
|
@@ -0,0 +1,169 @@
|
|
1
|
+
# SPDX-FileCopyrightText: 2022 Jeff Epler
|
2
|
+
#
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
4
|
+
|
5
|
+
"""Commandline interface to leap second data"""
|
6
|
+
|
7
|
+
from __future__ import annotations
|
8
|
+
|
9
|
+
import datetime
|
10
|
+
import logging
|
11
|
+
import typing
|
12
|
+
from dataclasses import dataclass
|
13
|
+
|
14
|
+
import click
|
15
|
+
|
16
|
+
from . import LeapSecondData, tai
|
17
|
+
|
18
|
+
utc = datetime.timezone.utc
|
19
|
+
|
20
|
+
|
21
|
+
def utcnow() -> datetime.datetime:
|
22
|
+
"""Return the current time in UTC, with tzinfo=utc"""
|
23
|
+
return datetime.datetime.now(utc)
|
24
|
+
|
25
|
+
|
26
|
+
class UTCDateTime(click.DateTime):
|
27
|
+
"""Click option class for date time in UTC"""
|
28
|
+
|
29
|
+
def convert(
|
30
|
+
self,
|
31
|
+
value: typing.Any,
|
32
|
+
param: click.Parameter | None,
|
33
|
+
ctx: click.Context | None,
|
34
|
+
) -> typing.Any:
|
35
|
+
"""Convert the value then attach the utc timezone"""
|
36
|
+
converted = super().convert(value, param, ctx)
|
37
|
+
if converted is not None:
|
38
|
+
return converted.replace(tzinfo=utc)
|
39
|
+
return converted # pragma no cover
|
40
|
+
|
41
|
+
|
42
|
+
@dataclass
|
43
|
+
class State:
|
44
|
+
"""State shared across sub-commands"""
|
45
|
+
|
46
|
+
leap_second_data: LeapSecondData
|
47
|
+
|
48
|
+
|
49
|
+
@click.group()
|
50
|
+
@click.option(
|
51
|
+
"--url",
|
52
|
+
type=str,
|
53
|
+
default=None,
|
54
|
+
help="URL for leap second data (unspecified to use default source)",
|
55
|
+
)
|
56
|
+
@click.option("--debug/--no-debug", type=bool)
|
57
|
+
@click.pass_context
|
58
|
+
def cli(ctx: click.Context, *, url: str, debug: bool) -> None:
|
59
|
+
"""Access leap second database information"""
|
60
|
+
if debug: # pragma no cover
|
61
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
62
|
+
if ctx.find_object(LeapSecondData) is None: # pragma no branch
|
63
|
+
if url is None:
|
64
|
+
ctx.obj = LeapSecondData.from_standard_source()
|
65
|
+
else: # pragma no cover
|
66
|
+
ctx.obj = LeapSecondData.from_url(url)
|
67
|
+
|
68
|
+
|
69
|
+
@cli.command()
|
70
|
+
@click.pass_context
|
71
|
+
def info(ctx: click.Context) -> None:
|
72
|
+
"""Show information about leap second database"""
|
73
|
+
leap_second_data = ctx.obj
|
74
|
+
print(f"Last updated: {leap_second_data.last_updated:%Y-%m-%d}")
|
75
|
+
print(f"Valid until: {leap_second_data.valid_until:%Y-%m-%d}")
|
76
|
+
# The first leap_seconds entry
|
77
|
+
print(f"{len(leap_second_data.leap_seconds)-1} leap seconds")
|
78
|
+
|
79
|
+
|
80
|
+
@cli.command()
|
81
|
+
@click.pass_context
|
82
|
+
@click.option("--tai/--utc", "is_tai", default=False)
|
83
|
+
@click.argument("timestamp", type=UTCDateTime(), default=utcnow(), metavar="TIMESTAMP")
|
84
|
+
def offset(ctx: click.Context, *, is_tai: bool, timestamp: datetime.datetime) -> None:
|
85
|
+
"""Get the UTC offset for a given moment, in seconds"""
|
86
|
+
leap_second_data = ctx.obj
|
87
|
+
if is_tai:
|
88
|
+
timestamp = timestamp.replace(tzinfo=tai)
|
89
|
+
print(f"{leap_second_data.tai_offset(timestamp).total_seconds():.0f}")
|
90
|
+
|
91
|
+
|
92
|
+
@cli.command()
|
93
|
+
@click.pass_context
|
94
|
+
@click.option("--to-tai/--to-utc", default=True)
|
95
|
+
@click.argument("timestamp", type=UTCDateTime(), default=None, required=False, metavar="TIMESTAMP")
|
96
|
+
def convert(ctx: click.Context, *, to_tai: bool, timestamp: datetime.datetime | None = None) -> None:
|
97
|
+
"""Convert timestamps between TAI and UTC"""
|
98
|
+
leap_second_data = ctx.obj
|
99
|
+
if to_tai:
|
100
|
+
if timestamp is None:
|
101
|
+
timestamp = utcnow()
|
102
|
+
when_tai = leap_second_data.to_tai(timestamp)
|
103
|
+
print(f"{when_tai:%Y-%m-%d %H:%M:%S} TAI")
|
104
|
+
else:
|
105
|
+
if timestamp is None: # pragma no cover
|
106
|
+
raise click.UsageError("--to-utc requires explicit timestamp", ctx)
|
107
|
+
when_utc = leap_second_data.tai_to_utc(timestamp.replace(tzinfo=tai))
|
108
|
+
if when_utc.fold:
|
109
|
+
print(f"{when_utc:%Y-%m-%d %H:%M:60} UTC")
|
110
|
+
else:
|
111
|
+
print(f"{when_utc:%Y-%m-%d %H:%M:%S} UTC")
|
112
|
+
|
113
|
+
|
114
|
+
@cli.command()
|
115
|
+
@click.pass_context
|
116
|
+
@click.argument("timestamp", type=UTCDateTime(), default=utcnow(), metavar="TIMESTAMP")
|
117
|
+
def next_leapsecond(ctx: click.Context, *, timestamp: datetime.datetime) -> None:
|
118
|
+
"""Get the next leap second after a given UTC timestamp"""
|
119
|
+
leap_second_data = ctx.obj
|
120
|
+
ls = min(
|
121
|
+
(ls for ls in leap_second_data.leap_seconds if ls.start > timestamp),
|
122
|
+
default=None,
|
123
|
+
key=lambda x: x.start,
|
124
|
+
)
|
125
|
+
if ls is None:
|
126
|
+
print("None")
|
127
|
+
else:
|
128
|
+
print(f"{ls.start:%Y-%m-%d %H:%M:%S} UTC")
|
129
|
+
|
130
|
+
|
131
|
+
@cli.command()
|
132
|
+
@click.pass_context
|
133
|
+
@click.argument("timestamp", type=UTCDateTime(), default=utcnow(), metavar="TIMESTAMP")
|
134
|
+
def previous_leapsecond(ctx: click.Context, *, timestamp: datetime.datetime) -> None:
|
135
|
+
"""Get the last leap second before a given UTC timestamp"""
|
136
|
+
leap_second_data = ctx.obj
|
137
|
+
ls = max(
|
138
|
+
(ls for ls in leap_second_data.leap_seconds if ls.start < timestamp),
|
139
|
+
default=None,
|
140
|
+
key=lambda x: x.start,
|
141
|
+
)
|
142
|
+
if ls is None:
|
143
|
+
print("None")
|
144
|
+
else:
|
145
|
+
print(f"{ls.start:%Y-%m-%d %H:%M:%S} UTC")
|
146
|
+
|
147
|
+
|
148
|
+
@cli.command()
|
149
|
+
@click.argument(
|
150
|
+
"start",
|
151
|
+
type=UTCDateTime(),
|
152
|
+
default=datetime.datetime(1972, 1, 1, tzinfo=utc),
|
153
|
+
metavar="START-TIMESTAMP",
|
154
|
+
)
|
155
|
+
@click.argument("end", type=UTCDateTime(), default=utcnow(), metavar="[END-TIMESTAMP]")
|
156
|
+
@click.pass_context
|
157
|
+
def table(ctx: click.Context, *, start: datetime.datetime, end: datetime.datetime) -> None:
|
158
|
+
"""Print information about leap seconds"""
|
159
|
+
leap_second_data = ctx.obj
|
160
|
+
for leap_second in leap_second_data.leap_seconds: # pragma no branch
|
161
|
+
if leap_second.start < start:
|
162
|
+
continue
|
163
|
+
if leap_second.start > end:
|
164
|
+
break
|
165
|
+
print(f"{leap_second.start:%Y-%m-%d}: {leap_second.tai_offset.total_seconds():.0f}")
|
166
|
+
|
167
|
+
|
168
|
+
if __name__ == "__main__": # pragma no cover
|
169
|
+
cli()
|
@@ -0,0 +1,60 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: wwvb
|
3
|
+
Version: 4.1.0a0
|
4
|
+
Summary: Use the list of known and scheduled leap seconds
|
5
|
+
Author-email: Jeff Epler <jepler@gmail.com>
|
6
|
+
Project-URL: Source, https://github.com/jepler/leapseconddata
|
7
|
+
Project-URL: Documentation, https://leapseconddata.readthedocs.io/en/latest/
|
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: click
|
19
|
+
|
20
|
+
<!--
|
21
|
+
SPDX-FileCopyrightText: 2021 Jeff Epler
|
22
|
+
|
23
|
+
SPDX-License-Identifier: GPL-3.0-only
|
24
|
+
-->
|
25
|
+
[](https://github.com/jepler/leapseconddata/actions/workflows/test.yml)
|
26
|
+
[](https://pypi.org/project/leapseconddata)
|
27
|
+
[](https://leapseconddata.readthedocs.io/en/latest/?badge=latest)
|
28
|
+
|
29
|
+
# Python Leap Second List
|
30
|
+
|
31
|
+
Leap seconds are corrections applied irregularly so that the UTC day stays
|
32
|
+
fixed to the earth's rotation.
|
33
|
+
|
34
|
+
This module provides a class for parsing and validating the standard
|
35
|
+
`leap-seconds.list` file. Once parsed, it is possible to retrieve the
|
36
|
+
full list of leap seconds, or find the TAI-UTC offset for any UTC time.
|
37
|
+
|
38
|
+
# `leapsecond` program
|
39
|
+
|
40
|
+
Access leap second data from the command line.
|
41
|
+
|
42
|
+
```
|
43
|
+
Usage: leapsecond [OPTIONS] COMMAND [ARGS]...
|
44
|
+
|
45
|
+
Access leap second database information
|
46
|
+
|
47
|
+
Options:
|
48
|
+
--url TEXT URL for leap second data (unspecified to use default
|
49
|
+
source)
|
50
|
+
--debug / --no-debug
|
51
|
+
--help Show this message and exit.
|
52
|
+
|
53
|
+
Commands:
|
54
|
+
convert Convert timestamps between TAI and UTC
|
55
|
+
info Show information about leap second database
|
56
|
+
next-leapsecond Get the next leap second after a given UTC timestamp
|
57
|
+
offset Get the UTC offset for a given moment, in seconds
|
58
|
+
previous-leapsecond Get the last leap second before a given UTC timestamp
|
59
|
+
table Print information about leap seconds
|
60
|
+
```
|
@@ -0,0 +1,9 @@
|
|
1
|
+
leapseconddata/__init__.py,sha256=UyHCvvcJjbIugNEEQDf45HmhzFiIe77fNVGRMm7eNdg,13017
|
2
|
+
leapseconddata/__main__.py,sha256=c26T1gFoVwh1goK0_mRCQ3-lAsz8eCFYf8aFi0nESf8,5405
|
3
|
+
leapseconddata/__version__.py,sha256=XeG8WsGuH3HJ5DBhMc289w1eiYW9-G0-4h58Y3MV650,413
|
4
|
+
leapseconddata/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
5
|
+
wwvb-4.1.0a0.dist-info/METADATA,sha256=KmLslzML53yHVtNANGMbfuL4t4Xu5OvevkIC85tnWEw,2496
|
6
|
+
wwvb-4.1.0a0.dist-info/WHEEL,sha256=Z4pYXqR_rTB7OWNDYFOm1qRk0RX6GFP2o8LgvP453Hk,91
|
7
|
+
wwvb-4.1.0a0.dist-info/entry_points.txt,sha256=Ca5FvuhALDcLhQrO8H7l91Bg0hjkxkzLEwLU-9qY9dk,59
|
8
|
+
wwvb-4.1.0a0.dist-info/top_level.txt,sha256=82tQRfAH4hrno1b9AR-UhFUHrbBxENUJ2aUtEH-ctTs,15
|
9
|
+
wwvb-4.1.0a0.dist-info/RECORD,,
|
@@ -0,0 +1 @@
|
|
1
|
+
leapseconddata
|