1#!/usr/bin/env python 2# 3# Copyright (c) 2009, 2010, Henry Precheur <henry@precheur.org> 4# 5# Permission to use, copy, modify, and/or distribute this software for any 6# purpose with or without fee is hereby granted, provided that the above 7# copyright notice and this permission notice appear in all copies. 8# 9# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 11# FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14# OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15# PERFORMANCE OF THIS SOFTWARE. 16# 17'''Formats dates according to the :RFC:`3339`. 18 19Report bugs and feature requests on Sourcehut_ 20 21Source availabe on this Mercurial repository: https://hg.sr.ht/~henryprecheur/rfc3339 22 23.. _Sourcehut: https://todo.sr.ht/~henryprecheur/rfc3339 24''' 25 26__author__ = 'Henry Precheur <henry@precheur.org>' 27__license__ = 'ISCL' 28__version__ = '6.2' 29__all__ = ('rfc3339', ) 30 31from datetime import ( 32 datetime, 33 date, 34 timedelta, 35 tzinfo, 36) 37import time 38import unittest 39 40def _timezone(utc_offset): 41 ''' 42 Return a string representing the timezone offset. 43 44 >>> _timezone(0) 45 '+00:00' 46 >>> _timezone(3600) 47 '+01:00' 48 >>> _timezone(-28800) 49 '-08:00' 50 >>> _timezone(-8 * 60 * 60) 51 '-08:00' 52 >>> _timezone(-30 * 60) 53 '-00:30' 54 ''' 55 # Python's division uses floor(), not round() like in other languages: 56 # -1 / 2 == -1 and not -1 / 2 == 0 57 # That's why we use abs(utc_offset). 58 hours = abs(utc_offset) // 3600 59 minutes = abs(utc_offset) % 3600 // 60 60 sign = (utc_offset < 0 and '-') or '+' 61 return '%c%02d:%02d' % (sign, hours, minutes) 62 63def _timedelta_to_seconds(td): 64 ''' 65 >>> _timedelta_to_seconds(timedelta(hours=3)) 66 10800 67 >>> _timedelta_to_seconds(timedelta(hours=3, minutes=15)) 68 11700 69 >>> _timedelta_to_seconds(timedelta(hours=-8)) 70 -28800 71 ''' 72 return int((td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / 10**6) 73 74def _utc_offset(timestamp, use_system_timezone): 75 ''' 76 Return the UTC offset of `timestamp`. If `timestamp` does not have any `tzinfo`, use 77 the timezone informations stored locally on the system. 78 79 >>> if time.localtime().tm_isdst: 80 ... system_timezone = -time.altzone 81 ... else: 82 ... system_timezone = -time.timezone 83 >>> _utc_offset(datetime.now(), True) == system_timezone 84 True 85 >>> _utc_offset(datetime.now(), False) 86 0 87 ''' 88 if (isinstance(timestamp, datetime) and 89 timestamp.tzinfo is not None): 90 return _timedelta_to_seconds(timestamp.utcoffset()) 91 elif use_system_timezone: 92 if timestamp.year < 1970: 93 # We use 1972 because 1970 doesn't have a leap day (feb 29) 94 t = time.mktime(timestamp.replace(year=1972).timetuple()) 95 else: 96 t = time.mktime(timestamp.timetuple()) 97 if time.localtime(t).tm_isdst: # pragma: no cover 98 return -time.altzone 99 else: 100 return -time.timezone 101 else: 102 return 0 103 104def _string(d, timezone): 105 return ('%04d-%02d-%02dT%02d:%02d:%02d%s' % 106 (d.year, d.month, d.day, d.hour, d.minute, d.second, timezone)) 107 108def _string_milliseconds(d, timezone): 109 return ('%04d-%02d-%02dT%02d:%02d:%02d.%03d%s' % 110 (d.year, d.month, d.day, d.hour, d.minute, d.second, d.microsecond / 1000, timezone)) 111 112def _string_microseconds(d, timezone): 113 return ('%04d-%02d-%02dT%02d:%02d:%02d.%06d%s' % 114 (d.year, d.month, d.day, d.hour, d.minute, d.second, d.microsecond, timezone)) 115 116def _format(timestamp, string_format, utc, use_system_timezone): 117 # Try to convert timestamp to datetime 118 try: 119 if use_system_timezone: 120 timestamp = datetime.fromtimestamp(timestamp) 121 else: 122 timestamp = datetime.utcfromtimestamp(timestamp) 123 except TypeError: 124 pass 125 126 if not isinstance(timestamp, date): 127 raise TypeError('Expected timestamp or date object. Got %r.' % 128 type(timestamp)) 129 130 if not isinstance(timestamp, datetime): 131 timestamp = datetime(*timestamp.timetuple()[:3]) 132 utc_offset = _utc_offset(timestamp, use_system_timezone) 133 if utc: 134 # local time -> utc 135 return string_format(timestamp - timedelta(seconds=utc_offset), 'Z') 136 else: 137 return string_format(timestamp , _timezone(utc_offset)) 138 139def format_millisecond(timestamp, utc=False, use_system_timezone=True): 140 ''' 141 Same as `rfc3339.format` but with the millisecond fraction after the seconds. 142 ''' 143 return _format(timestamp, _string_milliseconds, utc, use_system_timezone) 144 145def format_microsecond(timestamp, utc=False, use_system_timezone=True): 146 ''' 147 Same as `rfc3339.format` but with the microsecond fraction after the seconds. 148 ''' 149 return _format(timestamp, _string_microseconds, utc, use_system_timezone) 150 151def format(timestamp, utc=False, use_system_timezone=True): 152 ''' 153 Return a string formatted according to the :RFC:`3339`. If called with 154 `utc=True`, it normalizes `timestamp` to the UTC date. If `timestamp` does 155 not have any timezone information, uses the local timezone:: 156 157 >>> d = datetime(2008, 4, 2, 20) 158 >>> rfc3339(d, utc=True, use_system_timezone=False) 159 '2008-04-02T20:00:00Z' 160 >>> rfc3339(d) # doctest: +ELLIPSIS 161 '2008-04-02T20:00:00...' 162 163 If called with `use_system_timezone=False` don't use the local timezone if 164 `timestamp` does not have timezone informations and consider the offset to UTC 165 to be zero:: 166 167 >>> rfc3339(d, use_system_timezone=False) 168 '2008-04-02T20:00:00+00:00' 169 170 `timestamp` must be a `datetime`, `date` or a timestamp as 171 returned by `time.time()`:: 172 173 >>> rfc3339(0, utc=True, use_system_timezone=False) 174 '1970-01-01T00:00:00Z' 175 >>> rfc3339(date(2008, 9, 6), utc=True, 176 ... use_system_timezone=False) 177 '2008-09-06T00:00:00Z' 178 >>> rfc3339(date(2008, 9, 6), 179 ... use_system_timezone=False) 180 '2008-09-06T00:00:00+00:00' 181 >>> rfc3339('foo bar') # doctest: +ELLIPSIS 182 Traceback (most recent call last): 183 ... 184 TypeError: Expected timestamp or date object. Got <... 'str'>. 185 186 For dates before January 1st 1970, the timezones will be the ones used in 187 1970. It might not be accurate, but on most sytem there is no timezone 188 information before 1970. 189 ''' 190 return _format(timestamp, _string, utc, use_system_timezone) 191 192# FIXME deprecated 193rfc3339 = format 194 195class LocalTimeTestCase(unittest.TestCase): 196 ''' 197 Test the use of the timezone saved locally. Since it is hard to test using 198 doctest. 199 ''' 200 201 def setUp(self): 202 local_utcoffset = _utc_offset(datetime.now(), 203 use_system_timezone=True) 204 self.local_utcoffset = timedelta(seconds=local_utcoffset) 205 self.local_timezone = _timezone(local_utcoffset) 206 207 def test_datetime(self): 208 d = datetime.now() 209 self.assertEqual(rfc3339(d), 210 d.strftime('%Y-%m-%dT%H:%M:%S') + self.local_timezone) 211 212 def test_datetime_timezone(self): 213 214 class FixedNoDst(tzinfo): 215 'A timezone info with fixed offset, not DST' 216 217 def utcoffset(self, dt): 218 return timedelta(hours=2, minutes=30) 219 220 def dst(self, dt): 221 return None 222 223 fixed_no_dst = FixedNoDst() 224 225 class Fixed(FixedNoDst): 226 'A timezone info with DST' 227 def utcoffset(self, dt): 228 return timedelta(hours=3, minutes=15) 229 230 def dst(self, dt): 231 return timedelta(hours=3, minutes=15) 232 233 fixed = Fixed() 234 235 d = datetime.now().replace(tzinfo=fixed_no_dst) 236 timezone = _timezone(_timedelta_to_seconds(fixed_no_dst.\ 237 utcoffset(None))) 238 self.assertEqual(rfc3339(d), 239 d.strftime('%Y-%m-%dT%H:%M:%S') + timezone) 240 241 d = datetime.now().replace(tzinfo=fixed) 242 timezone = _timezone(_timedelta_to_seconds(fixed.dst(None))) 243 self.assertEqual(rfc3339(d), 244 d.strftime('%Y-%m-%dT%H:%M:%S') + timezone) 245 246 def test_datetime_utc(self): 247 d = datetime.now() 248 d_utc = d - self.local_utcoffset 249 self.assertEqual(rfc3339(d, utc=True), 250 d_utc.strftime('%Y-%m-%dT%H:%M:%SZ')) 251 252 def test_date(self): 253 d = date.today() 254 self.assertEqual(rfc3339(d), 255 d.strftime('%Y-%m-%dT%H:%M:%S') + self.local_timezone) 256 257 def test_date_utc(self): 258 d = date.today() 259 # Convert `date` to `datetime`, since `date` ignores seconds and hours 260 # in timedeltas: 261 # >>> date(2008, 9, 7) + timedelta(hours=23) 262 # date(2008, 9, 7) 263 d_utc = datetime(*d.timetuple()[:3]) - self.local_utcoffset 264 self.assertEqual(rfc3339(d, utc=True), 265 d_utc.strftime('%Y-%m-%dT%H:%M:%SZ')) 266 267 def test_timestamp(self): 268 d = time.time() 269 self.assertEqual( 270 rfc3339(d), 271 datetime.fromtimestamp(d). 272 strftime('%Y-%m-%dT%H:%M:%S') + self.local_timezone) 273 274 def test_timestamp_utc(self): 275 d = time.time() 276 # utc -> local timezone 277 d_utc = datetime.utcfromtimestamp(d) + self.local_utcoffset 278 self.assertEqual(rfc3339(d), 279 (d_utc.strftime('%Y-%m-%dT%H:%M:%S') + 280 self.local_timezone)) 281 282 def test_before_1970(self): 283 d = date(1885, 1, 4) 284 self.assertTrue(rfc3339(d).startswith('1885-01-04T00:00:00')) 285 self.assertEqual(rfc3339(d, utc=True, use_system_timezone=False), 286 '1885-01-04T00:00:00Z') 287 288 def test_1920(self): 289 d = date(1920, 2, 29) 290 x = rfc3339(d, utc=False, use_system_timezone=True) 291 self.assertTrue(x.startswith('1920-02-29T00:00:00')) 292 293 # If these tests start failing it probably means there was a policy change 294 # for the Pacific time zone. 295 # See http://en.wikipedia.org/wiki/Pacific_Time_Zone. 296 if 'PST' in time.tzname: 297 def testPDTChange(self): 298 '''Test Daylight saving change''' 299 # PDT switch happens at 2AM on March 14, 2010 300 301 # 1:59AM PST 302 self.assertEqual(rfc3339(datetime(2010, 3, 14, 1, 59)), 303 '2010-03-14T01:59:00-08:00') 304 # 3AM PDT 305 self.assertEqual(rfc3339(datetime(2010, 3, 14, 3, 0)), 306 '2010-03-14T03:00:00-07:00') 307 308 def testPSTChange(self): 309 '''Test Standard time change''' 310 # PST switch happens at 2AM on November 6, 2010 311 312 # 0:59AM PDT 313 self.assertEqual(rfc3339(datetime(2010, 11, 7, 0, 59)), 314 '2010-11-07T00:59:00-07:00') 315 316 # 1:00AM PST 317 # There's no way to have 1:00AM PST without a proper tzinfo 318 self.assertEqual(rfc3339(datetime(2010, 11, 7, 1, 0)), 319 '2010-11-07T01:00:00-07:00') 320 321 def test_millisecond(self): 322 x = datetime(2018, 9, 20, 13, 11, 21, 123000) 323 self.assertEqual( 324 format_millisecond( 325 datetime(2018, 9, 20, 13, 11, 21, 123000), 326 utc=True, 327 use_system_timezone=False), 328 '2018-09-20T13:11:21.123Z') 329 330 def test_microsecond(self): 331 x = datetime(2018, 9, 20, 13, 11, 21, 12345) 332 self.assertEqual( 333 format_microsecond( 334 datetime(2018, 9, 20, 13, 11, 21, 12345), 335 utc=True, 336 use_system_timezone=False), 337 '2018-09-20T13:11:21.012345Z') 338 339if __name__ == '__main__': # pragma: no cover 340 import doctest 341 doctest.testmod() 342 unittest.main() 343