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