1# (c) 2005 Clark C. Evans and contributors
2# This module is part of the Python Paste Project and is released under
3# the MIT License: http://www.opensource.org/licenses/mit-license.php
4# Some of this code was funded by: http://prometheusresearch.com
5"""
6Date, Time, and Timespan Parsing Utilities
7
8This module contains parsing support to create "human friendly"
9``datetime`` object parsing.  The explicit goal of these routines is
10to provide a multi-format date/time support not unlike that found in
11Microsoft Excel.  In most approaches, the input is very "strict" to
12prevent errors -- however, this approach is much more liberal since we
13are assuming the user-interface is parroting back the normalized value
14and thus the user has immediate feedback if the data is not typed in
15correctly.
16
17  ``parse_date`` and ``normalize_date``
18
19     These functions take a value like '9 jan 2007' and returns either an
20     ``date`` object, or an ISO 8601 formatted date value such
21     as '2007-01-09'.  There is an option to provide an Oracle database
22     style output as well, ``09 JAN 2007``, but this is not the default.
23
24     This module always treats '/' delimiters as using US date order
25     (since the author's clients are US based), hence '1/9/2007' is
26     January 9th.  Since this module treats the '-' as following
27     European order this supports both modes of data-entry; together
28     with immediate parroting back the result to the screen, the author
29     has found this approach to work well in pratice.
30
31  ``parse_time`` and ``normalize_time``
32
33     These functions take a value like '1 pm' and returns either an
34     ``time`` object, or an ISO 8601 formatted 24h clock time
35     such as '13:00'.  There is an option to provide for US style time
36     values, '1:00 PM', however this is not the default.
37
38  ``parse_datetime`` and ``normalize_datetime``
39
40     These functions take a value like '9 jan 2007 at 1 pm' and returns
41     either an ``datetime`` object, or an ISO 8601 formatted
42     return (without the T) such as '2007-01-09 13:00'. There is an
43     option to provide for Oracle / US style, '09 JAN 2007 @ 1:00 PM',
44     however this is not the default.
45
46  ``parse_delta`` and ``normalize_delta``
47
48     These functions take a value like '1h 15m' and returns either an
49     ``timedelta`` object, or an 2-decimal fixed-point
50     numerical value in hours, such as '1.25'.  The rationale is to
51     support meeting or time-billing lengths, not to be an accurate
52     representation in mili-seconds.  As such not all valid
53     ``timedelta`` values will have a normalized representation.
54
55"""
56from datetime import timedelta, time, date
57from time import localtime
58
59__all__ = ['parse_timedelta', 'normalize_timedelta',
60           'parse_time', 'normalize_time',
61           'parse_date', 'normalize_date']
62
63def _number(val):
64    try:
65        return int(val)
66    except:
67        return None
68
69#
70# timedelta
71#
72def parse_timedelta(val):
73    """
74    returns a ``timedelta`` object, or None
75    """
76    if not val:
77        return None
78    val = val.lower()
79    if "." in val:
80        val = float(val)
81        return timedelta(hours=int(val), minutes=60*(val % 1.0))
82    fHour = ("h" in val or ":" in val)
83    fMin  = ("m" in val or ":" in val)
84    for noise in "minu:teshour()":
85        val = val.replace(noise, ' ')
86    val = val.strip()
87    val = val.split()
88    hr = 0.0
89    mi = 0
90    val.reverse()
91    if fHour:
92        hr = int(val.pop())
93    if fMin:
94        mi = int(val.pop())
95    if len(val) > 0 and not hr:
96        hr = int(val.pop())
97    return timedelta(hours=hr, minutes=mi)
98
99def normalize_timedelta(val):
100    """
101    produces a normalized string value of the timedelta
102
103    This module returns a normalized time span value consisting of the
104    number of hours in fractional form. For example '1h 15min' is
105    formatted as 01.25.
106    """
107    if type(val) == str:
108        val = parse_timedelta(val)
109    if not val:
110        return ''
111    hr = val.seconds/3600
112    mn = (val.seconds % 3600)/60
113    return "%d.%02d" % (hr, mn * 100/60)
114
115#
116# time
117#
118def parse_time(val):
119    if not val:
120        return None
121    hr = mi = 0
122    val = val.lower()
123    amflag = (-1 != val.find('a'))  # set if AM is found
124    pmflag = (-1 != val.find('p'))  # set if PM is found
125    for noise in ":amp.":
126        val = val.replace(noise, ' ')
127    val = val.split()
128    if len(val) > 1:
129        hr = int(val[0])
130        mi = int(val[1])
131    else:
132        val = val[0]
133        if len(val) < 1:
134            pass
135        elif 'now' == val:
136            tm = localtime()
137            hr = tm[3]
138            mi = tm[4]
139        elif 'noon' == val:
140            hr = 12
141        elif len(val) < 3:
142            hr = int(val)
143            if not amflag and not pmflag and hr < 7:
144                hr += 12
145        elif len(val) < 5:
146            hr = int(val[:-2])
147            mi = int(val[-2:])
148        else:
149            hr = int(val[:1])
150    if amflag and hr >= 12:
151        hr = hr - 12
152    if pmflag and hr < 12:
153        hr = hr + 12
154    return time(hr, mi)
155
156def normalize_time(value, ampm):
157    if not value:
158        return ''
159    if type(value) == str:
160        value = parse_time(value)
161    if not ampm:
162        return "%02d:%02d" % (value.hour, value.minute)
163    hr = value.hour
164    am = "AM"
165    if hr < 1 or hr > 23:
166        hr = 12
167    elif hr >= 12:
168        am = "PM"
169        if hr > 12:
170            hr = hr - 12
171    return "%02d:%02d %s" % (hr, value.minute, am)
172
173#
174# Date Processing
175#
176
177_one_day = timedelta(days=1)
178
179_str2num = {'jan':1, 'feb':2, 'mar':3, 'apr':4,  'may':5, 'jun':6,
180            'jul':7, 'aug':8, 'sep':9, 'oct':10, 'nov':11, 'dec':12 }
181
182def _month(val):
183    for (key, mon) in _str2num.items():
184        if key in val:
185            return mon
186    raise TypeError("unknown month '%s'" % val)
187
188_days_in_month = {1: 31, 2: 28, 3: 31, 4: 30, 5: 31, 6: 30,
189                  7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31,
190                  }
191_num2str = {1: 'Jan', 2: 'Feb', 3: 'Mar', 4: 'Apr', 5: 'May', 6: 'Jun',
192            7: 'Jul', 8: 'Aug', 9: 'Sep', 10: 'Oct', 11: 'Nov', 12: 'Dec',
193            }
194_wkdy = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
195
196def parse_date(val):
197    if not(val):
198        return None
199    val = val.lower()
200    now = None
201
202    # optimized check for YYYY-MM-DD
203    strict = val.split("-")
204    if len(strict) == 3:
205        (y, m, d) = strict
206        if "+" in d:
207            d = d.split("+")[0]
208        if " " in d:
209            d = d.split(" ")[0]
210        try:
211            now = date(int(y), int(m), int(d))
212            val = "xxx" + val[10:]
213        except ValueError:
214            pass
215
216    # allow for 'now', 'mon', 'tue', etc.
217    if not now:
218        chk = val[:3]
219        if chk in ('now','tod'):
220            now = date.today()
221        elif chk in _wkdy:
222            now = date.today()
223            idx = list(_wkdy).index(chk) + 1
224            while now.isoweekday() != idx:
225                now += _one_day
226
227    # allow dates to be modified via + or - /w number of days, so
228    # that now+3 is three days from now
229    if now:
230        tail = val[3:].strip()
231        tail = tail.replace("+"," +").replace("-"," -")
232        for item in tail.split():
233            try:
234                days = int(item)
235            except ValueError:
236                pass
237            else:
238                now += timedelta(days=days)
239        return now
240
241    # ok, standard parsing
242    yr = mo = dy = None
243    for noise in ('/', '-', ',', '*'):
244        val = val.replace(noise, ' ')
245    for noise in _wkdy:
246        val = val.replace(noise, ' ')
247    out = []
248    last = False
249    ldig = False
250    for ch in val:
251        if ch.isdigit():
252            if last and not ldig:
253               out.append(' ')
254            last = ldig = True
255        else:
256            if ldig:
257                out.append(' ')
258                ldig = False
259            last = True
260        out.append(ch)
261    val = "".join(out).split()
262    if 3 == len(val):
263        a = _number(val[0])
264        b = _number(val[1])
265        c = _number(val[2])
266        if len(val[0]) == 4:
267            yr = a
268            if b:  # 1999 6 23
269                mo = b
270                dy = c
271            else:  # 1999 Jun 23
272                mo = _month(val[1])
273                dy = c
274        elif a is not None and a > 0:
275            yr = c
276            if len(val[2]) < 4:
277                raise TypeError("four digit year required")
278            if b: # 6 23 1999
279                dy = b
280                mo = a
281            else: # 23 Jun 1999
282                dy = a
283                mo = _month(val[1])
284        else: # Jun 23, 2000
285            dy = b
286            yr = c
287            if len(val[2]) < 4:
288                raise TypeError("four digit year required")
289            mo = _month(val[0])
290    elif 2 == len(val):
291        a = _number(val[0])
292        b = _number(val[1])
293        if a is not None and a > 999:
294            yr = a
295            dy = 1
296            if b is not None and b > 0: # 1999 6
297                mo = b
298            else: # 1999 Jun
299                mo = _month(val[1])
300        elif a is not None and a > 0:
301            if b is not None and b > 999: # 6 1999
302                mo = a
303                yr = b
304                dy = 1
305            elif b is not None and b > 0: # 6 23
306                mo = a
307                dy = b
308            else: # 23 Jun
309                dy = a
310                mo = _month(val[1])
311        else:
312            if b > 999: # Jun 2001
313                yr = b
314                dy = 1
315            else:  # Jun 23
316                dy = b
317            mo = _month(val[0])
318    elif 1 == len(val):
319        val = val[0]
320        if not val.isdigit():
321            mo = _month(val)
322            if mo is not None:
323                dy = 1
324        else:
325            v = _number(val)
326            val = str(v)
327            if 8 == len(val): # 20010623
328                yr = _number(val[:4])
329                mo = _number(val[4:6])
330                dy = _number(val[6:])
331            elif len(val) in (3,4):
332                if v is not None and v > 1300: # 2004
333                    yr = v
334                    mo = 1
335                    dy = 1
336                else:        # 1202
337                    mo = _number(val[:-2])
338                    dy = _number(val[-2:])
339            elif v < 32:
340                dy = v
341            else:
342                raise TypeError("four digit year required")
343    tm = localtime()
344    if mo is None:
345        mo = tm[1]
346    if dy is None:
347        dy = tm[2]
348    if yr is None:
349        yr = tm[0]
350    return date(yr, mo, dy)
351
352def normalize_date(val, iso8601=True):
353    if not val:
354        return ''
355    if type(val) == str:
356        val = parse_date(val)
357    if iso8601:
358        return "%4d-%02d-%02d" % (val.year, val.month, val.day)
359    return "%02d %s %4d" % (val.day, _num2str[val.month], val.year)
360