Skip to content

Commit b28e481

Browse files
committed
minitz: First version.
1 parent 29e1b7b commit b28e481

File tree

4 files changed

+320
-0
lines changed

4 files changed

+320
-0
lines changed

micropython/minitz/manifest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
metadata(description="MiniTZ timezone support.", version="0.1.0")
2+
3+
module("minitz.py", opt=3)

micropython/minitz/minitz/__init__.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from _minitz import Database
2+
import datetime as _datetime
3+
4+
5+
# Wraps a _minitz.Zone, and implements tzinfo
6+
class tzwrap(_datetime.tzinfo):
7+
def __init__(self, mtz_zone):
8+
self._mtz_zone = mtz_zone
9+
10+
def __str__(self):
11+
return self.tzname(None)
12+
13+
# Returns (offset: int, designator: str, is_dst: int)
14+
def _lookup_local(self, dt):
15+
if dt.tzinfo is not self:
16+
raise ValueError()
17+
t = dt.replace(tzinfo=_datetime.timezone.utc, fold=0).timestamp()
18+
return self._mtz_zone.lookup_local(t, dt.fold)
19+
20+
def utcoffset(self, dt):
21+
return _datetime.timedelta(seconds=self._lookup_local(dt)[0])
22+
23+
def is_dst(self, dt):
24+
# Nonstandard. Returns bool.
25+
return bool(self._lookup_local(dt)[2])
26+
27+
def dst(self, dt):
28+
is_dst = self._lookup_local(dt)[2]
29+
# TODO in the case of is_dst=1, this is returning
30+
# a made-up value that may be wrong.
31+
return _datetime.timedelta(hours=is_dst)
32+
33+
def tzname(self, dt):
34+
return self._lookup_local(dt)[1]
35+
36+
def fromutc(self, dt):
37+
if dt.fold != 0:
38+
raise ValueError()
39+
t = dt.replace(tzinfo=_datetime.timezone.utc).timestamp()
40+
offset = self._mtz_zone.lookup_utc(t)[0]
41+
_datetime.timedelta(seconds=offset)
42+
43+
return dt + self._offset

micropython/minitz/minitz/fetch.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import requests
2+
import email.utils
3+
4+
# Configurable settings
5+
server_url = 'http://tzdata.net/api/1/'
6+
dataset = '1-15'
7+
8+
9+
def fetch(url, last_modified, timeout=None):
10+
headers = {}
11+
if last_modified:
12+
headers['if-modified-since'] = email.utils.formatdate(last_modified, False, True)
13+
14+
resp = requests.request('GET', url, headers=headers, timeout=timeout, parse_headers=True)
15+
16+
if resp.status_code == 304:
17+
# Not modified
18+
resp.close()
19+
return None
20+
if resp.status_code != 200:
21+
resp.close()
22+
raise Exception()
23+
24+
content = resp.content
25+
26+
last_modified = resp.get_date_as_int('last-modified') or 0
27+
28+
return (last_modified, content)
29+
30+
31+
def fetch_zone(zone_name, last_modified, timeout=None):
32+
url = server_url + dataset + '/zones-minitzif/' + zone_name
33+
return fetch(url, last_modified, timeout)
34+
35+
36+
def fetch_all(last_modified, timeout=None):
37+
url = server_url + dataset + '/minitzdb'
38+
return fetch(url, last_modified, timeout)
39+
40+
41+
def fetch_names(last_modified, timeout=None):
42+
url = server_url + 'zone-names-mini'
43+
return fetch(url, last_modified, timeout)

micropython/minitz/minitz/persist.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import datetime
2+
import os
3+
import struct
4+
import time
5+
from . import Database, tzwrap
6+
from .fetch import fetch_zone, fetch_all
7+
8+
_local_zone_name = 'UTC'
9+
_whole_db = False
10+
11+
_db = None
12+
_last_modified = None
13+
_local_zone = None
14+
_local_tzinfo = datetime.timezone.utc
15+
16+
_last_check = None
17+
18+
path_for_meta_file = 'tzmeta'
19+
path_for_db_file = 'tzdata'
20+
21+
# Initialise by reading from persistent storage.
22+
def init(want_zone_name=None, want_whole_db=None):
23+
try:
24+
with open(path_for_meta_file, 'rb') as fp:
25+
last_modified, data_crc = struct.unpack('<QI', fp.read(12))
26+
local_zone_name = fp.read().decode()
27+
28+
if not local_zone_name:
29+
# Corrupt file:
30+
# Mode should be 1 or 2.
31+
# Zone name is mandatory.
32+
raise ValueError()
33+
if last_modified == 0:
34+
last_modified = None
35+
36+
with open(path_for_db_file, 'rb') as fp:
37+
data = fp.read()
38+
39+
db = Database(data)
40+
if db.kind != 1 and db.kind != 2:
41+
raise ValueError()
42+
if db.crc != data_crc:
43+
# The tzdata and tzmeta files do not match.
44+
raise ValueError()
45+
46+
whole_db = (db.kind == 2)
47+
if want_whole_db is not None and want_whole_db != whole_db:
48+
# Want to download one zone file only, have whole DB
49+
# OR want to download whole DB, only have one zone
50+
raise ValueError()
51+
52+
if want_zone_name is not None and want_zone_name != local_zone_name:
53+
if not whole_db:
54+
# Need to download correct zone file.
55+
raise ValueError()
56+
local_zone_name = want_zone_name
57+
58+
# For a TZIF file, the string passed to get_zone_by_name() is ignored.
59+
local_zone = db.get_zone_by_name(local_zone_name)
60+
local_tzinfo = tzwrap(local_zone)
61+
62+
# Success.
63+
success = True
64+
except:
65+
# Failed
66+
success = False
67+
68+
db = None
69+
last_modified = None
70+
local_zone_name = want_zone_name or 'UTC'
71+
whole_db = whole_db or False
72+
local_zone = None
73+
local_tzinfo = datetime.timezone.utc if local_zone_name == 'UTC' else None
74+
75+
# Save state.
76+
global _local_zone_name, _whole_db, _db, _last_modified, _local_zone, _local_tzinfo, _last_check
77+
_local_zone_name = local_zone_name
78+
_whole_db = whole_db
79+
_db = db
80+
_last_modified = last_modified
81+
_local_zone = local_zone
82+
_local_tzinfo = local_tzinfo
83+
_last_check = None
84+
if success:
85+
# Pretend last check was 23.5 hours ago.
86+
# That way the next check will be in 30 minutes.
87+
# This means that if there are many reboots in a row, we don't flood
88+
# the server with requests.
89+
# 23.5 * 3600 * 1000 = 84_600_000
90+
#
91+
# (It would be better if we could use real UTC time to track when the
92+
# last check was, and store the last update time in persistent memory.
93+
# But we don't necessarily know the real UTC time at init time, and may
94+
# not want a Flash write on every update check).
95+
_last_check = time.ticks_add(time.ticks_ms(), -84_600_000)
96+
97+
return success
98+
99+
100+
def _force_update_from_internet(zone_name=None, whole_db=None, timeout=None):
101+
last_modified = _last_modified
102+
if whole_db is None:
103+
whole_db = _whole_db
104+
elif whole_db != _whole_db:
105+
# Force fresh download as it's a different file
106+
last_modified = None
107+
if zone_name is None:
108+
zone_name = _local_zone_name
109+
elif zone_name != _local_zone_name and not whole_db:
110+
# Force fresh download as it's a different file
111+
last_modified = None
112+
if not zone_name:
113+
# Empty string is not a valid zone name.
114+
raise ValueError()
115+
116+
# We update _last_check even if the HTTP request fails.
117+
# This is to comply with the fair usage policy of tzdata.net.
118+
global _last_check
119+
_last_check = time.ticks_ms()
120+
121+
if whole_db:
122+
last_modified, data = fetch_zone(zone_name, last_modified, timeout)
123+
else:
124+
last_modified, data = fetch_all(last_modified, timeout)
125+
126+
if data is None:
127+
# Not changed
128+
return
129+
130+
db = Database(data)
131+
if db.kind != (2 if whole_db else 1):
132+
# Not the kind of file that was expected
133+
raise ValueError()
134+
135+
# For a TZIF file, the string passed to get_zone_by_name() is ignored.
136+
local_zone = db.get_zone_by_name(zone_name)
137+
local_tzinfo = tzwrap(local_zone)
138+
139+
# Download success!
140+
141+
# Save state.
142+
global _local_zone_name, _whole_db, _db, _last_modified, _local_zone, _local_tzinfo
143+
_local_zone_name = zone_name
144+
_whole_db = whole_db
145+
_db = db
146+
_last_modified = last_modified
147+
_local_zone = local_zone
148+
_local_tzinfo = local_tzinfo
149+
150+
# Save the data to persistent storage.
151+
152+
# Maybe this may make flash wear-levelling easier?
153+
# We give the filesystem as much free space as possible
154+
# before we start writing to it.
155+
os.unlink(path_for_db_file)
156+
157+
with open(path_for_meta_file, 'wb') as fp:
158+
fp.write(struct.pack('<QI', last_modified or 0, db.crc))
159+
fp.write(zone_name.encode())
160+
161+
with open(path_for_db_file, 'wb') as fp:
162+
fp.write(data)
163+
164+
165+
# Initialise by reading from persistent storage.
166+
# If that fails, will try to do first-time download of timezone
167+
# data from the Internet.
168+
def init_with_download_if_needed(zone_name=None, whole_db=None, timeout=None):
169+
if not init(zone_name, whole_db):
170+
if whole_db or zone_name != 'UTC':
171+
_force_update_from_internet(zone_name, whole_db, timeout)
172+
173+
174+
def set_zone(zone_name, can_download, timeout=None):
175+
if _local_zone_name == zone_name:
176+
# Nothing to do!
177+
pass
178+
elif _whole_db:
179+
local_zone = _db.get_zone_by_name(zone_name)
180+
local_tzinfo = tzwrap(local_zone)
181+
182+
global _local_zone_name, _local_zone, _local_tzinfo
183+
_local_zone_name = zone_name
184+
_local_zone = local_zone
185+
_local_tzinfo = local_tzinfo
186+
elif not can_download:
187+
raise ValueError("Changing zone without whole DB or Internet")
188+
else:
189+
_force_update_from_internet(zone_name, _whole_db, timeout)
190+
191+
192+
def update_from_internet_if_needed(timeout=None):
193+
# Call this regularly. Ideally at least once an hour, but it's fine
194+
# to call it much more frequently, even multiple times per second.
195+
# This function will do nothing if an update is not needed.
196+
#
197+
# We attempt an Internet update at most once per day.
198+
# This is to comply with the fair usage policy of tzdata.net.
199+
if (_last_check is not None and
200+
time.ticks_diff(time.ticks_ms(), _last_check) < 24 * 3600 * 1000):
201+
# Too soon.
202+
return
203+
_force_update_from_internet(timeout=timeout)
204+
205+
206+
def has_tzinfo():
207+
return _local_tzinfo is not None
208+
209+
def get_tzinfo():
210+
if _local_tzinfo is None:
211+
raise ValueError()
212+
return _local_tzinfo
213+
214+
def have_db():
215+
return _db is not None
216+
217+
def get_raw_zone():
218+
if _local_zone is None:
219+
raise ValueError()
220+
return _local_zone
221+
222+
def get_db():
223+
if _db is None:
224+
raise ValueError()
225+
return _db
226+
227+
def get_zone_name():
228+
return _local_zone_name
229+
230+
def get_last_modified():
231+
return datetime.datetime.fromtimestamp(_last_modified, datetime.timezone.utc)

0 commit comments

Comments
 (0)