1# Copyright (C) 2018 Philipp Hörist <philipp AT hoerist.com>
2#
3# This file is part of nbxmpp.
4#
5# This program is free software; you can redistribute it and/or
6# modify it under the terms of the GNU General Public License
7# as published by the Free Software Foundation; either version 3
8# of the License, or (at your option) any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; If not, see <http://www.gnu.org/licenses/>.
17
18import re
19import time
20import logging
21from datetime import datetime
22from datetime import timedelta
23from datetime import timezone
24from datetime import tzinfo
25
26log = logging.getLogger('nbxmpp.m.date_and_time')
27
28PATTERN_DATETIME = re.compile(
29    r'([0-9]{4}-[0-9]{2}-[0-9]{2})'
30    r'T'
31    r'([0-9]{2}:[0-9]{2}:[0-9]{2})'
32    r'(\.[0-9]{0,6})?'
33    r'(?:[0-9]+)?'
34    r'(?:(Z)|(?:([-+][0-9]{2}):([0-9]{2})))$'
35)
36
37PATTERN_DELAY = re.compile(
38    r'([0-9]{4}-[0-9]{2}-[0-9]{2})'
39    r'T'
40    r'([0-9]{2}:[0-9]{2}:[0-9]{2})'
41    r'(\.[0-9]{0,6})?'
42    r'(?:[0-9]+)?'
43    r'(?:(Z)|(?:([-+][0]{2}):([0]{2})))$'
44)
45
46
47ZERO = timedelta(0)
48HOUR = timedelta(hours=1)
49SECOND = timedelta(seconds=1)
50
51STDOFFSET = timedelta(seconds=-time.timezone)
52if time.daylight:
53    DSTOFFSET = timedelta(seconds=-time.altzone)
54else:
55    DSTOFFSET = STDOFFSET
56
57DSTDIFF = DSTOFFSET - STDOFFSET
58
59
60class LocalTimezone(tzinfo):
61    '''
62    A class capturing the platform's idea of local time.
63    May result in wrong values on historical times in
64    timezones where UTC offset and/or the DST rules had
65    changed in the past.
66    '''
67    def fromutc(self, dt):
68        assert dt.tzinfo is self
69        stamp = (dt - datetime(1970, 1, 1, tzinfo=self)) // SECOND
70        args = time.localtime(stamp)[:6]
71        dst_diff = DSTDIFF // SECOND
72        # Detect fold
73        fold = (args == time.localtime(stamp - dst_diff))
74        return datetime(*args, microsecond=dt.microsecond,
75                        tzinfo=self, fold=fold)
76
77    def utcoffset(self, dt):
78        if self._isdst(dt):
79            return DSTOFFSET
80        return STDOFFSET
81
82    def dst(self, dt):
83        if self._isdst(dt):
84            return DSTDIFF
85        return ZERO
86
87    def tzname(self, dt):
88        return 'local'
89
90    @staticmethod
91    def _isdst(dt):
92        tt = (dt.year, dt.month, dt.day,
93              dt.hour, dt.minute, dt.second,
94              dt.weekday(), 0, 0)
95        stamp = time.mktime(tt)
96        tt = time.localtime(stamp)
97        return tt.tm_isdst > 0
98
99
100def create_tzinfo(hours=0, minutes=0, tz_string=None):
101    if tz_string is None:
102        return timezone(timedelta(hours=hours, minutes=minutes))
103
104    if tz_string.lower() == 'z':
105        return timezone.utc
106
107    try:
108        hours, minutes = map(int, tz_string.split(':'))
109    except Exception:
110        log.warning('Wrong tz string: %s', tz_string)
111        return None
112
113    if hours not in range(-24, 24):
114        log.warning('Wrong tz string: %s', tz_string)
115        return None
116
117    if minutes not in range(0, 59):
118        log.warning('Wrong tz string: %s', tz_string)
119        return None
120
121    if hours in (24, -24) and minutes != 0:
122        log.warning('Wrong tz string: %s', tz_string)
123        return None
124    return timezone(timedelta(hours=hours, minutes=minutes))
125
126
127def parse_datetime(timestring, check_utc=False,
128                   convert='utc', epoch=False):
129    '''
130    Parse a XEP-0082 DateTime Profile String
131
132    :param timestring: a XEP-0082 DateTime profile formated string
133
134    :param check_utc:  if True, returns None if timestring is not
135                       a timestring expressing UTC
136
137    :param convert:    convert the given timestring to utc or local time
138
139    :param epoch:      if True, returns the time in epoch
140
141    Examples:
142    '2017-11-05T01:41:20Z'
143    '2017-11-05T01:41:20.123Z'
144    '2017-11-05T01:41:20.123+05:00'
145
146    return a datetime or epoch
147    '''
148    if timestring is None:
149        return None
150    if convert not in (None, 'utc', 'local'):
151        raise TypeError('"%s" is not a valid value for convert')
152    if check_utc:
153        match = PATTERN_DELAY.match(timestring)
154    else:
155        match = PATTERN_DATETIME.match(timestring)
156
157    if match:
158        timestring = ''.join(match.groups(''))
159        strformat = '%Y-%m-%d%H:%M:%S%z'
160        if match.group(3):
161            # Fractional second addendum to Time
162            strformat = '%Y-%m-%d%H:%M:%S.%f%z'
163        if match.group(4):
164            # UTC string denoted by addition of the character 'Z'
165            timestring = timestring[:-1] + '+0000'
166        try:
167            date_time = datetime.strptime(timestring, strformat)
168        except ValueError:
169            pass
170        else:
171            if check_utc:
172                if convert != 'utc':
173                    raise ValueError(
174                        'check_utc can only be used with convert="utc"')
175                date_time.replace(tzinfo=timezone.utc)
176                if epoch:
177                    return date_time.timestamp()
178                return date_time
179
180            if convert == 'utc':
181                date_time = date_time.astimezone(timezone.utc)
182                if epoch:
183                    return date_time.timestamp()
184                return date_time
185
186            if epoch:
187                # epoch is always UTC, use convert='utc' or check_utc=True
188                raise ValueError(
189                    'epoch not available while converting to local')
190
191            if convert == 'local':
192                date_time = date_time.astimezone(LocalTimezone())
193                return date_time
194
195            # convert=None
196            return date_time
197    return None
198