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.
@@ -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
+ [![Test wwvbgen](https://github.com/jepler/wwvbpy/actions/workflows/test.yml/badge.svg)](https://github.com/jepler/wwvbpy/actions/workflows/test.yml)
33
+ [![codecov](https://codecov.io/gh/jepler/wwvbpy/branch/main/graph/badge.svg?token=Exx0c3Gp65)](https://codecov.io/gh/jepler/wwvbpy)
34
+ [![Update DUT1 data](https://github.com/jepler/wwvbpy/actions/workflows/cron.yml/badge.svg)](https://github.com/jepler/wwvbpy/actions/workflows/cron.yml)
35
+ [![PyPI](https://img.shields.io/pypi/v/wwvb)](https://pypi.org/project/wwvb)
36
+ [![CodeQL](https://github.com/jepler/wwvbpy/actions/workflows/codeql.yml/badge.svg)](https://github.com/jepler/wwvbpy/actions/workflows/codeql.yml)
37
+ [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/jepler/wwvbpy/main.svg)](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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (70.3.0)
2
+ Generator: setuptools (75.2.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -0,0 +1,8 @@
1
+ [console_scripts]
2
+ dut1table = wwvb.dut1table:main
3
+ updateiers = wwvb.updateiers:main
4
+ wwvbdecode = wwvb.decode:main
5
+ wwvbgen = wwvb.gen:main
6
+
7
+ [gui_scripts]
8
+ wwvbtk = wwvb.wwvbtk:main
@@ -0,0 +1,2 @@
1
+ uwwvb
2
+ wwvb
@@ -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)