Code

Date arithmetic fixes. Date +/- Interval passes all tests again, after
[roundup.git] / roundup / date.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 # $Id: date.py,v 1.55 2003-11-03 10:23:05 anthonybaxter Exp $
20 __doc__ = """
21 Date, time and time interval handling.
22 """
24 import time, re, calendar, types
25 from i18n import _
27 def _add_granularity(src, order, value = 1):
28     '''Increment first non-None value in src dictionary ordered by 'order'
29     parameter
30     '''
31     for gran in order:
32         if src[gran]:
33             src[gran] = int(src[gran]) + value
34             break
36 class Date:
37     '''
38     As strings, date-and-time stamps are specified with the date in
39     international standard format (yyyy-mm-dd) joined to the time
40     (hh:mm:ss) by a period ("."). Dates in this form can be easily compared
41     and are fairly readable when printed. An example of a valid stamp is
42     "2000-06-24.13:03:59". We'll call this the "full date format". When
43     Timestamp objects are printed as strings, they appear in the full date
44     format with the time always given in GMT. The full date format is
45     always exactly 19 characters long. 
47     For user input, some partial forms are also permitted: the whole time
48     or just the seconds may be omitted; and the whole date may be omitted
49     or just the year may be omitted. If the time is given, the time is
50     interpreted in the user's local time zone. The Date constructor takes
51     care of these conversions. In the following examples, suppose that yyyy
52     is the current year, mm is the current month, and dd is the current day
53     of the month; and suppose that the user is on Eastern Standard Time.
55       "2000-04-17" means <Date 2000-04-17.00:00:00>
56       "01-25" means <Date yyyy-01-25.00:00:00>
57       "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
58       "08-13.22:13" means <Date yyyy-08-14.03:13:00>
59       "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
60       "14:25" means <Date yyyy-mm-dd.19:25:00>
61       "8:47:11" means <Date yyyy-mm-dd.13:47:11>
62       "2003" means <Date 2003-01-01.00:00:00>
63       "2003-06" means <Date 2003-06-01.00:00:00>
64       "." means "right now"
66     The Date class should understand simple date expressions of the form
67     stamp + interval and stamp - interval. When adding or subtracting
68     intervals involving months or years, the components are handled
69     separately. For example, when evaluating "2000-06-25 + 1m 10d", we
70     first add one month to get 2000-07-25, then add 10 days to get
71     2000-08-04 (rather than trying to decide whether 1m 10d means 38 or 40
72     or 41 days).
74     Example usage:
75         >>> Date(".")
76         <Date 2000-06-26.00:34:02>
77         >>> _.local(-5)
78         "2000-06-25.19:34:02"
79         >>> Date(". + 2d")
80         <Date 2000-06-28.00:34:02>
81         >>> Date("1997-04-17", -5)
82         <Date 1997-04-17.00:00:00>
83         >>> Date("01-25", -5)
84         <Date 2000-01-25.00:00:00>
85         >>> Date("08-13.22:13", -5)
86         <Date 2000-08-14.03:13:00>
87         >>> Date("14:25", -5)
88         <Date 2000-06-25.19:25:00>
90     The date format 'yyyymmddHHMMSS' (year, month, day, hour,
91     minute, second) is the serialisation format returned by the serialise()
92     method, and is accepted as an argument on instatiation.
93     '''
94     
95     def __init__(self, spec='.', offset=0, add_granularity=0):
96         """Construct a date given a specification and a time zone offset.
98           'spec' is a full date or a partial form, with an optional
99                  added or subtracted interval. Or a date 9-tuple.
100         'offset' is the local time zone offset from GMT in hours.
101         """
102         if type(spec) == type(''):
103             self.set(spec, offset=offset, add_granularity=add_granularity)
104         else:
105             y,m,d,H,M,S,x,x,x = spec
106             ts = calendar.timegm((y,m,d,H+offset,M,S,0,0,0))
107             self.year, self.month, self.day, self.hour, self.minute, \
108                 self.second, x, x, x = time.gmtime(ts)
110     usagespec='[yyyy]-[mm]-[dd].[H]H:MM[:SS][offset]'
111     def set(self, spec, offset=0, date_re=re.compile(r'''
112             ((?P<y>\d\d\d\d)([/-](?P<m>\d\d?)([/-](?P<d>\d\d?))?)? # yyyy[-mm[-dd]]
113             |(?P<a>\d\d?)[/-](?P<b>\d\d?))?              # or mm-dd
114             (?P<n>\.)?                                     # .
115             (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d))?)?  # hh:mm:ss
116             (?P<o>.+)?                                     # offset
117             ''', re.VERBOSE), serialised_re=re.compile(r'''
118             (\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)
119             ''', re.VERBOSE), add_granularity=0):
120         ''' set the date to the value in spec
121         '''
123         m = serialised_re.match(spec)
124         if m is not None:
125             # we're serialised - easy!
126             self.year, self.month, self.day, self.hour, self.minute, \
127                 self.second = map(int, m.groups()[:6])
128             return
130         # not serialised data, try usual format
131         m = date_re.match(spec)
132         if m is None:
133             raise ValueError, _('Not a date spec: %s' % self.usagespec)
135         info = m.groupdict()
137         if add_granularity:
138             _add_granularity(info, 'SMHdmyab')
140         # get the current date as our default
141         y,m,d,H,M,S,x,x,x = time.gmtime(time.time())
143         if info['y'] is not None or info['a'] is not None:
144             if info['y'] is not None:
145                 y = int(info['y'])
146                 m,d = (1,1)
147                 if info['m'] is not None:
148                     m = int(info['m'])
149                     if info['d'] is not None:
150                         d = int(info['d'])
151             if info['a'] is not None:
152                 m = int(info['a'])
153                 d = int(info['b'])
154             H = -offset
155             M = S = 0
157         # override hour, minute, second parts
158         if info['H'] is not None and info['M'] is not None:
159             H = int(info['H']) - offset
160             M = int(info['M'])
161             S = 0
162             if info['S'] is not None: S = int(info['S'])
164         if add_granularity:
165             S = S - 1
166         
167         # now handle the adjustment of hour
168         ts = calendar.timegm((y,m,d,H,M,S,0,0,0))
169         self.year, self.month, self.day, self.hour, self.minute, \
170             self.second, x, x, x = time.gmtime(ts)
172         if info.get('o', None):
173             try:
174                 self.applyInterval(Interval(info['o'], allowdate=0))
175             except ValueError:
176                 raise ValueError, _('Not a date spec: %s' % self.usagespec)
178     def addInterval(self, interval):
179         ''' Add the interval to this date, returning the date tuple
180         '''
181         # do the basic calc
182         sign = interval.sign
183         year = self.year + sign * interval.year
184         month = self.month + sign * interval.month
185         day = self.day + sign * interval.day
186         hour = self.hour + sign * interval.hour
187         minute = self.minute + sign * interval.minute
188         second = self.second + sign * interval.second
190         # now cope with under- and over-flow
191         # first do the time
192         while (second < 0 or second > 59 or minute < 0 or minute > 59 or
193                 hour < 0 or hour > 59):
194             if second < 0: minute -= 1; second += 60
195             elif second > 59: minute += 1; second -= 60
196             if minute < 0: hour -= 1; minute += 60
197             elif minute > 59: hour += 1; minute -= 60
198             if hour < 0: day -= 1; hour += 24
199             elif hour > 59: day += 1; hour -= 24
201         # fix up the month so we're within range
202         while month < 1 or month > 12:
203             if month < 1: year -= 1; month += 12
204             if month > 12: year += 1; month -= 12
206         # now do the days, now that we know what month we're in
207         def get_mdays(year,month):
208             if month == 2 and calendar.isleap(year): return 29
209             else: return calendar.mdays[month]
210             
211         while month < 1 or month > 12 or day < 0 or day > get_mdays(year,month):
212             # now to day under/over
213             if day < 0: 
214                 # When going backwards, decrement month, then increment days
215                 month -= 1
216                 day += get_mdays(year,month)
217             elif day > get_mdays(year,month): 
218                 # When going forwards, decrement days, then increment month
219                 day -= get_mdays(year,month)
220                 month += 1
222             # possibly fix up the month so we're within range
223             while month < 1 or month > 12:
224                 if month < 1: year -= 1; month += 12 ; day += 31
225                 if month > 12: year += 1; month -= 12
227         return (year, month, day, hour, minute, second, 0, 0, 0)
229     def applyInterval(self, interval):
230         ''' Apply the interval to this date
231         '''
232         self.year, self.month, self.day, self.hour, self.minute, \
233             self.second, x, x, x = self.addInterval(interval)
235     def __add__(self, interval):
236         """Add an interval to this date to produce another date.
237         """
238         return Date(self.addInterval(interval))
240     # deviates from spec to allow subtraction of dates as well
241     def __sub__(self, other):
242         """ Subtract:
243              1. an interval from this date to produce another date.
244              2. a date from this date to produce an interval.
245         """
246         if isinstance(other, Interval):
247             other = Interval(other.get_tuple())
248             other.sign *= -1
249             return self.__add__(other)
251         assert isinstance(other, Date), 'May only subtract Dates or Intervals'
253         # TODO this code will fall over laughing if the dates cross
254         # leap years, phases of the moon, ....
255         a = calendar.timegm((self.year, self.month, self.day, self.hour,
256             self.minute, self.second, 0, 0, 0))
257         b = calendar.timegm((other.year, other.month, other.day,
258             other.hour, other.minute, other.second, 0, 0, 0))
259         diff = a - b
260         if diff < 0:
261             sign = 1
262             diff = -diff
263         else:
264             sign = -1
265         S = diff%60
266         M = (diff/60)%60
267         H = (diff/(60*60))%60
268         if H>1: S = 0
269         d = (diff/(24*60*60))%30
270         if d>1: H = S = M = 0
271         m = (diff/(30*24*60*60))%12
272         if m>1: H = S = M = 0
273         y = (diff/(365*24*60*60))
274         if y>1: d = H = S = M = 0
275         return Interval((y, m, d, H, M, S), sign=sign)
277     def __cmp__(self, other):
278         """Compare this date to another date."""
279         if other is None:
280             return 1
281         for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
282             if not hasattr(other, attr):
283                 return 1
284             r = cmp(getattr(self, attr), getattr(other, attr))
285             if r: return r
286         return 0
288     def __str__(self):
289         """Return this date as a string in the yyyy-mm-dd.hh:mm:ss format."""
290         return '%4d-%02d-%02d.%02d:%02d:%02d'%(self.year, self.month, self.day,
291             self.hour, self.minute, self.second)
293     def pretty(self, format='%d %B %Y'):
294         ''' print up the date date using a pretty format...
296             Note that if the day is zero, and the day appears first in the
297             format, then the day number will be removed from output.
298         '''
299         str = time.strftime(format, (self.year, self.month, self.day,
300             self.hour, self.minute, self.second, 0, 0, 0))
301         # handle zero day by removing it
302         if format.startswith('%d') and str[0] == '0':
303             return ' ' + str[1:]
304         return str
306     def __repr__(self):
307         return '<Date %s>'%self.__str__()
309     def local(self, offset):
310         """ Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone.
311         """
312         return Date((self.year, self.month, self.day, self.hour + offset,
313             self.minute, self.second, 0, 0, 0))
315     def get_tuple(self):
316         return (self.year, self.month, self.day, self.hour, self.minute,
317             self.second, 0, 0, 0)
319     def serialise(self):
320         return '%4d%02d%02d%02d%02d%02d'%(self.year, self.month,
321             self.day, self.hour, self.minute, self.second)
323 class Interval:
324     '''
325     Date intervals are specified using the suffixes "y", "m", and "d". The
326     suffix "w" (for "week") means 7 days. Time intervals are specified in
327     hh:mm:ss format (the seconds may be omitted, but the hours and minutes
328     may not).
330       "3y" means three years
331       "2y 1m" means two years and one month
332       "1m 25d" means one month and 25 days
333       "2w 3d" means two weeks and three days
334       "1d 2:50" means one day, two hours, and 50 minutes
335       "14:00" means 14 hours
336       "0:04:33" means four minutes and 33 seconds
338     Example usage:
339         >>> Interval("  3w  1  d  2:00")
340         <Interval + 22d 2:00>
341         >>> Date(". + 2d") + Interval("- 3w")
342         <Date 2000-06-07.00:34:02>
343         >>> Interval('1:59:59') + Interval('00:00:01')
344         <Interval + 2:00>
345         >>> Interval('2:00') + Interval('- 00:00:01')
346         <Interval + 1:59:59>
347         >>> Interval('1y')/2
348         <Interval + 6m>
349         >>> Interval('1:00')/2
350         <Interval + 0:30>
351         >>> Interval('2003-03-18')
352         <Interval + [number of days between now and 2003-03-18]>
353         >>> Interval('-4d 2003-03-18')
354         <Interval + [number of days between now and 2003-03-14]>
356     Interval arithmetic is handled in a couple of special ways, trying
357     to cater for the most common cases. Fundamentally, Intervals which
358     have both date and time parts will result in strange results in
359     arithmetic - because of the impossibility of handling day->month->year
360     over- and under-flows. Intervals may also be divided by some number.
362     Intervals are added to Dates in order of:
363        seconds, minutes, hours, years, months, days
365     Calculations involving months (eg '+2m') have no effect on days - only
366     days (or over/underflow from hours/mins/secs) will do that, and
367     days-per-month and leap years are accounted for. Leap seconds are not.
369     The interval format 'syyyymmddHHMMSS' (sign, year, month, day, hour,
370     minute, second) is the serialisation format returned by the serialise()
371     method, and is accepted as an argument on instatiation.
373     TODO: more examples, showing the order of addition operation
374     '''
375     def __init__(self, spec, sign=1, allowdate=1, add_granularity=0):
376         """Construct an interval given a specification."""
377         if type(spec) == type(''):
378             self.set(spec, allowdate=allowdate, add_granularity=add_granularity)
379         else:
380             if len(spec) == 7:
381                 self.sign, self.year, self.month, self.day, self.hour, \
382                     self.minute, self.second = spec
383             else:
384                 # old, buggy spec form
385                 self.sign = sign
386                 self.year, self.month, self.day, self.hour, self.minute, \
387                     self.second = spec
389     def set(self, spec, allowdate=1, interval_re=re.compile('''
390             \s*(?P<s>[-+])?         # + or -
391             \s*((?P<y>\d+\s*)y)?    # year
392             \s*((?P<m>\d+\s*)m)?    # month
393             \s*((?P<w>\d+\s*)w)?    # week
394             \s*((?P<d>\d+\s*)d)?    # day
395             \s*(((?P<H>\d+):(?P<M>\d+))?(:(?P<S>\d+))?)?   # time
396             \s*(?P<D>
397                  (\d\d\d\d[/-])?(\d\d?)?[/-](\d\d?)?       # [yyyy-]mm-dd
398                  \.?                                       # .
399                  (\d?\d:\d\d)?(:\d\d)?                     # hh:mm:ss
400                )?''', re.VERBOSE), serialised_re=re.compile('''
401             (?P<s>[+-])?1?(?P<y>([ ]{3}\d|\d{4}))(?P<m>\d{2})(?P<d>\d{2})
402             (?P<H>\d{2})(?P<M>\d{2})(?P<S>\d{2})''', re.VERBOSE),
403             add_granularity=0):
404         ''' set the date to the value in spec
405         '''
406         self.year = self.month = self.week = self.day = self.hour = \
407             self.minute = self.second = 0
408         self.sign = 1
409         m = serialised_re.match(spec)
410         if not m:
411             m = interval_re.match(spec)
412             if not m:
413                 raise ValueError, _('Not an interval spec: [+-] [#y] [#m] [#w] '
414                     '[#d] [[[H]H:MM]:SS] [date spec]')
415         else:
416             allowdate = 0
418         # pull out all the info specified
419         info = m.groupdict()
420         if add_granularity:
421             _add_granularity(info, 'SMHdwmy', (info['s']=='-' and -1 or 1))
423         valid = 0
424         for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day',
425                 'H':'hour', 'M':'minute', 'S':'second'}.items():
426             if info.get(group, None) is not None:
427                 valid = 1
428                 setattr(self, attr, int(info[group]))
430         # make sure it's valid
431         if not valid and not info['D']:
432             raise ValueError, _('Not an interval spec: [+-] [#y] [#m] [#w] '
433                 '[#d] [[[H]H:MM]:SS]')
435         if self.week:
436             self.day = self.day + self.week*7
438         if info['s'] is not None:
439             self.sign = {'+':1, '-':-1}[info['s']]
441         # use a date spec if one is given
442         if allowdate and info['D'] is not None:
443             now = Date('.')
444             date = Date(info['D'])
445             # if no time part was specified, nuke it in the "now" date
446             if not date.hour or date.minute or date.second:
447                 now.hour = now.minute = now.second = 0
448             if date != now:
449                 y = now - (date + self)
450                 self.__init__(y.get_tuple())
452     def __cmp__(self, other):
453         """Compare this interval to another interval."""
454         if other is None:
455             # we are always larger than None
456             return 1
457         for attr in 'sign year month day hour minute second'.split():
458             r = cmp(getattr(self, attr), getattr(other, attr))
459             if r:
460                 return r
461         return 0
463     def __str__(self):
464         """Return this interval as a string."""
465         l = []
466         if self.year: l.append('%sy'%self.year)
467         if self.month: l.append('%sm'%self.month)
468         if self.day: l.append('%sd'%self.day)
469         if self.second:
470             l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second))
471         elif self.hour or self.minute:
472             l.append('%d:%02d'%(self.hour, self.minute))
473         if l:
474             l.insert(0, {1:'+', -1:'-'}[self.sign])
475         return ' '.join(l)
477     def __add__(self, other):
478         if isinstance(other, Date):
479             # the other is a Date - produce a Date
480             return Date(other.addInterval(self))
481         elif isinstance(other, Interval):
482             # add the other Interval to this one
483             a = self.get_tuple()
484             as = a[0]
485             b = other.get_tuple()
486             bs = b[0]
487             i = [as*x + bs*y for x,y in zip(a[1:],b[1:])]
488             i.insert(0, 1)
489             i = fixTimeOverflow(i)
490             return Interval(i)
491         # nope, no idea what to do with this other...
492         raise TypeError, "Can't add %r"%other
494     def __sub__(self, other):
495         if isinstance(other, Date):
496             # the other is a Date - produce a Date
497             interval = Interval(self.get_tuple())
498             interval.sign *= -1
499             return Date(other.addInterval(interval))
500         elif isinstance(other, Interval):
501             # add the other Interval to this one
502             a = self.get_tuple()
503             as = a[0]
504             b = other.get_tuple()
505             bs = b[0]
506             i = [as*x - bs*y for x,y in zip(a[1:],b[1:])]
507             i.insert(0, 1)
508             i = fixTimeOverflow(i)
509             return Interval(i)
510         # nope, no idea what to do with this other...
511         raise TypeError, "Can't add %r"%other
513     def __div__(self, other):
514         ''' Divide this interval by an int value.
516             Can't divide years and months sensibly in the _same_
517             calculation as days/time, so raise an error in that situation.
518         '''
519         try:
520             other = float(other)
521         except TypeError:
522             raise ValueError, "Can only divide Intervals by numbers"
524         y, m, d, H, M, S = (self.year, self.month, self.day,
525             self.hour, self.minute, self.second)
526         if y or m:
527             if d or H or M or S:
528                 raise ValueError, "Can't divide Interval with date and time"
529             months = self.year*12 + self.month
530             months *= self.sign
532             months = int(months/other)
534             sign = months<0 and -1 or 1
535             m = months%12
536             y = months / 12
537             return Interval((sign, y, m, 0, 0, 0, 0))
539         else:
540             # handle a day/time division
541             seconds = S + M*60 + H*60*60 + d*60*60*24
542             seconds *= self.sign
544             seconds = int(seconds/other)
546             sign = seconds<0 and -1 or 1
547             seconds *= sign
548             S = seconds%60
549             seconds /= 60
550             M = seconds%60
551             seconds /= 60
552             H = seconds%24
553             d = seconds / 24
554             return Interval((sign, 0, 0, d, H, M, S))
556     def __repr__(self):
557         return '<Interval %s>'%self.__str__()
559     def pretty(self):
560         ''' print up the date date using one of these nice formats..
561         '''
562         if self.year:
563             if self.year == 1:
564                 return _('1 year')
565             else:
566                 return _('%(number)s years')%{'number': self.year}
567         elif self.month or self.day > 13:
568             days = (self.month * 30) + self.day
569             if days > 28:
570                 if int(days/30) > 1:
571                     s = _('%(number)s months')%{'number': int(days/30)}
572                 else:
573                     s = _('1 month')
574             else:
575                 s = _('%(number)s weeks')%{'number': int(days/7)}
576         elif self.day > 7:
577             s = _('1 week')
578         elif self.day > 1:
579             s = _('%(number)s days')%{'number': self.day}
580         elif self.day == 1 or self.hour > 12:
581             if self.sign > 0:
582                 return _('tomorrow')
583             else:
584                 return _('yesterday')
585         elif self.hour > 1:
586             s = _('%(number)s hours')%{'number': self.hour}
587         elif self.hour == 1:
588             if self.minute < 15:
589                 s = _('an hour')
590             elif self.minute/15 == 2:
591                 s = _('1 1/2 hours')
592             else:
593                 s = _('1 %(number)s/4 hours')%{'number': self.minute/15}
594         elif self.minute < 1:
595             if self.sign > 0:
596                 return _('in a moment')
597             else:
598                 return _('just now')
599         elif self.minute == 1:
600             s = _('1 minute')
601         elif self.minute < 15:
602             s = _('%(number)s minutes')%{'number': self.minute}
603         elif int(self.minute/15) == 2:
604             s = _('1/2 an hour')
605         else:
606             s = _('%(number)s/4 hour')%{'number': int(self.minute/15)}
607         if self.sign < 0: 
608             s = s + _(' ago')
609         else:
610             s = _('in ') + s
611         return s
613     def get_tuple(self):
614         return (self.sign, self.year, self.month, self.day, self.hour,
615             self.minute, self.second)
617     def serialise(self):
618         sign = self.sign > 0 and '+' or '-'
619         return '%s%04d%02d%02d%02d%02d%02d'%(sign, self.year, self.month,
620             self.day, self.hour, self.minute, self.second)
622 def fixTimeOverflow(time):
623     ''' Handle the overflow in the time portion (H, M, S) of "time":
624             (sign, y,m,d,H,M,S)
626         Overflow and underflow will at most affect the _days_ portion of
627         the date. We do not overflow days to months as we don't know _how_
628         to, generally.
629     '''
630     # XXX we could conceivably use this function for handling regular dates
631     # XXX too - we just need to interrogate the month/year for the day
632     # XXX overflow...
634     sign, y, m, d, H, M, S = time
635     seconds = sign * (S + M*60 + H*60*60 + d*60*60*24)
636     if seconds:
637         sign = seconds<0 and -1 or 1
638         seconds *= sign
639         S = seconds%60
640         seconds /= 60
641         M = seconds%60
642         seconds /= 60
643         H = seconds%24
644         d = seconds / 24
645     else:
646         months = y*12 + m
647         sign = months<0 and -1 or 1
648         months *= sign
649         m = months%12
650         y = months/12
652     return (sign, y, m, d, H, M, S)
654 class Range:
655     """
656     Represents range between two values
657     Ranges can be created using one of theese two alternative syntaxes:
658         
659         1. Native english syntax: 
660             [[From] <value>][ To <value>]
661            Keywords "From" and "To" are case insensitive. Keyword "From" is optional.
663         2. "Geek" syntax:
664             [<value>][; <value>]
666     Either first or second <value> can be omitted in both syntaxes.
668     Examples (consider local time is Sat Mar  8 22:07:48 EET 2003):
669         >>> Range("from 2-12 to 4-2")
670         <Range from 2003-02-12.00:00:00 to 2003-04-02.00:00:00>
671         
672         >>> Range("18:00 TO +2m")
673         <Range from 2003-03-08.18:00:00 to 2003-05-08.20:07:48>
674         
675         >>> Range("12:00")
676         <Range from 2003-03-08.12:00:00 to None>
677         
678         >>> Range("tO +3d")
679         <Range from None to 2003-03-11.20:07:48>
680         
681         >>> Range("2002-11-10; 2002-12-12")
682         <Range from 2002-11-10.00:00:00 to 2002-12-12.00:00:00>
683         
684         >>> Range("; 20:00 +1d")
685         <Range from None to 2003-03-09.20:00:00>
687     """
688     def __init__(self, spec, Type, allow_granularity=1, **params):
689         """Initializes Range of type <Type> from given <spec> string.
690         
691         Sets two properties - from_value and to_value. None assigned to any of
692         this properties means "infinitum" (-infinitum to from_value and
693         +infinitum to to_value)
695         The Type parameter here should be class itself (e.g. Date), not a
696         class instance.
697         
698         """
699         self.range_type = Type
700         re_range = r'(?:^|from(.+?))(?:to(.+?)$|$)'
701         re_geek_range = r'(?:^|(.+?));(?:(.+?)$|$)'
702         # Check which syntax to use
703         if  spec.find(';') == -1:
704             # Native english
705             mch_range = re.search(re_range, spec.strip(), re.IGNORECASE)
706         else:
707             # Geek
708             mch_range = re.search(re_geek_range, spec.strip())
709         if mch_range:
710             self.from_value, self.to_value = mch_range.groups()
711             if self.from_value:
712                 self.from_value = Type(self.from_value.strip(), **params)
713             if self.to_value:
714                 self.to_value = Type(self.to_value.strip(), **params)
715         else:
716             if allow_granularity:
717                 self.from_value = Type(spec, **params)
718                 self.to_value = Type(spec, add_granularity=1, **params)
719             else:
720                 raise ValueError, "Invalid range"
722     def __str__(self):
723         return "from %s to %s" % (self.from_value, self.to_value)
725     def __repr__(self):
726         return "<Range %s>" % self.__str__()
727  
728 def test_range():
729     rspecs = ("from 2-12 to 4-2", "from 18:00 TO +2m", "12:00;", "tO +3d",
730         "2002-11-10; 2002-12-12", "; 20:00 +1d", '2002-10-12')
731     rispecs = ('from -1w 2d 4:32 to 4d', '-2w 1d')
732     for rspec in rspecs:
733         print '>>> Range("%s")' % rspec
734         print `Range(rspec, Date)`
735         print
736     for rspec in rispecs:
737         print '>>> Range("%s")' % rspec
738         print `Range(rspec, Interval)`
739         print
741 def test():
742     intervals = ("  3w  1  d  2:00", " + 2d", "3w")
743     for interval in intervals:
744         print '>>> Interval("%s")'%interval
745         print `Interval(interval)`
747     dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25",
748         "08-13.22:13", "14:25", '2002-12')
749     for date in dates:
750         print '>>> Date("%s")'%date
751         print `Date(date)`
753     sums = ((". + 2d", "3w"), (".", "  3w  1  d  2:00"))
754     for date, interval in sums:
755         print '>>> Date("%s") + Interval("%s")'%(date, interval)
756         print `Date(date) + Interval(interval)`
758 if __name__ == '__main__':
759     test()
761 # vim: set filetype=python ts=4 sw=4 et si