Code

extended date syntax to make range searches even more useful
[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.53 2003-04-22 20:53:54 kedder 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         mdays = calendar.mdays
208         if month == 2 and calendar.isleap(year): month_days = 29
209         else: month_days = mdays[month]
210         while month < 1 or month > 12 or day < 0 or day > month_days:
211             # now to day under/over
212             if day < 0: month -= 1; day += month_days
213             elif day > month_days: month += 1; day -= month_days
215             # possibly fix up the month so we're within range
216             while month < 1 or month > 12:
217                 if month < 1: year -= 1; month += 12
218                 if month > 12: year += 1; month -= 12
220             # re-figure the number of days for this month
221             if month == 2 and calendar.isleap(year): month_days = 29
222             else: month_days = mdays[month]
223         return (year, month, day, hour, minute, second, 0, 0, 0)
225     def applyInterval(self, interval):
226         ''' Apply the interval to this date
227         '''
228         self.year, self.month, self.day, self.hour, self.minute, \
229             self.second, x, x, x = self.addInterval(interval)
231     def __add__(self, interval):
232         """Add an interval to this date to produce another date.
233         """
234         return Date(self.addInterval(interval))
236     # deviates from spec to allow subtraction of dates as well
237     def __sub__(self, other):
238         """ Subtract:
239              1. an interval from this date to produce another date.
240              2. a date from this date to produce an interval.
241         """
242         if isinstance(other, Interval):
243             other = Interval(other.get_tuple())
244             other.sign *= -1
245             return self.__add__(other)
247         assert isinstance(other, Date), 'May only subtract Dates or Intervals'
249         # TODO this code will fall over laughing if the dates cross
250         # leap years, phases of the moon, ....
251         a = calendar.timegm((self.year, self.month, self.day, self.hour,
252             self.minute, self.second, 0, 0, 0))
253         b = calendar.timegm((other.year, other.month, other.day,
254             other.hour, other.minute, other.second, 0, 0, 0))
255         diff = a - b
256         if diff < 0:
257             sign = 1
258             diff = -diff
259         else:
260             sign = -1
261         S = diff%60
262         M = (diff/60)%60
263         H = (diff/(60*60))%60
264         if H>1: S = 0
265         d = (diff/(24*60*60))%30
266         if d>1: H = S = M = 0
267         m = (diff/(30*24*60*60))%12
268         if m>1: H = S = M = 0
269         y = (diff/(365*24*60*60))
270         if y>1: d = H = S = M = 0
271         return Interval((y, m, d, H, M, S), sign=sign)
273     def __cmp__(self, other):
274         """Compare this date to another date."""
275         if other is None:
276             return 1
277         for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
278             if not hasattr(other, attr):
279                 return 1
280             r = cmp(getattr(self, attr), getattr(other, attr))
281             if r: return r
282         return 0
284     def __str__(self):
285         """Return this date as a string in the yyyy-mm-dd.hh:mm:ss format."""
286         return '%4d-%02d-%02d.%02d:%02d:%02d'%(self.year, self.month, self.day,
287             self.hour, self.minute, self.second)
289     def pretty(self, format='%d %B %Y'):
290         ''' print up the date date using a pretty format...
292             Note that if the day is zero, and the day appears first in the
293             format, then the day number will be removed from output.
294         '''
295         str = time.strftime(format, (self.year, self.month, self.day,
296             self.hour, self.minute, self.second, 0, 0, 0))
297         # handle zero day by removing it
298         if format.startswith('%d') and str[0] == '0':
299             return ' ' + str[1:]
300         return str
302     def __repr__(self):
303         return '<Date %s>'%self.__str__()
305     def local(self, offset):
306         """ Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone.
307         """
308         return Date((self.year, self.month, self.day, self.hour + offset,
309             self.minute, self.second, 0, 0, 0))
311     def get_tuple(self):
312         return (self.year, self.month, self.day, self.hour, self.minute,
313             self.second, 0, 0, 0)
315     def serialise(self):
316         return '%4d%02d%02d%02d%02d%02d'%(self.year, self.month,
317             self.day, self.hour, self.minute, self.second)
319 class Interval:
320     '''
321     Date intervals are specified using the suffixes "y", "m", and "d". The
322     suffix "w" (for "week") means 7 days. Time intervals are specified in
323     hh:mm:ss format (the seconds may be omitted, but the hours and minutes
324     may not).
326       "3y" means three years
327       "2y 1m" means two years and one month
328       "1m 25d" means one month and 25 days
329       "2w 3d" means two weeks and three days
330       "1d 2:50" means one day, two hours, and 50 minutes
331       "14:00" means 14 hours
332       "0:04:33" means four minutes and 33 seconds
334     Example usage:
335         >>> Interval("  3w  1  d  2:00")
336         <Interval + 22d 2:00>
337         >>> Date(". + 2d") + Interval("- 3w")
338         <Date 2000-06-07.00:34:02>
339         >>> Interval('1:59:59') + Interval('00:00:01')
340         <Interval + 2:00>
341         >>> Interval('2:00') + Interval('- 00:00:01')
342         <Interval + 1:59:59>
343         >>> Interval('1y')/2
344         <Interval + 6m>
345         >>> Interval('1:00')/2
346         <Interval + 0:30>
347         >>> Interval('2003-03-18')
348         <Interval + [number of days between now and 2003-03-18]>
349         >>> Interval('-4d 2003-03-18')
350         <Interval + [number of days between now and 2003-03-14]>
352     Interval arithmetic is handled in a couple of special ways, trying
353     to cater for the most common cases. Fundamentally, Intervals which
354     have both date and time parts will result in strange results in
355     arithmetic - because of the impossibility of handling day->month->year
356     over- and under-flows. Intervals may also be divided by some number.
358     Intervals are added to Dates in order of:
359        seconds, minutes, hours, years, months, days
361     Calculations involving months (eg '+2m') have no effect on days - only
362     days (or over/underflow from hours/mins/secs) will do that, and
363     days-per-month and leap years are accounted for. Leap seconds are not.
365     The interval format 'syyyymmddHHMMSS' (sign, year, month, day, hour,
366     minute, second) is the serialisation format returned by the serialise()
367     method, and is accepted as an argument on instatiation.
369     TODO: more examples, showing the order of addition operation
370     '''
371     def __init__(self, spec, sign=1, allowdate=1, add_granularity=0):
372         """Construct an interval given a specification."""
373         if type(spec) == type(''):
374             self.set(spec, allowdate=allowdate, add_granularity=add_granularity)
375         else:
376             if len(spec) == 7:
377                 self.sign, self.year, self.month, self.day, self.hour, \
378                     self.minute, self.second = spec
379             else:
380                 # old, buggy spec form
381                 self.sign = sign
382                 self.year, self.month, self.day, self.hour, self.minute, \
383                     self.second = spec
385     def set(self, spec, allowdate=1, interval_re=re.compile('''
386             \s*(?P<s>[-+])?         # + or -
387             \s*((?P<y>\d+\s*)y)?    # year
388             \s*((?P<m>\d+\s*)m)?    # month
389             \s*((?P<w>\d+\s*)w)?    # week
390             \s*((?P<d>\d+\s*)d)?    # day
391             \s*(((?P<H>\d+):(?P<M>\d+))?(:(?P<S>\d+))?)?   # time
392             \s*(?P<D>
393                  (\d\d\d\d[/-])?(\d\d?)?[/-](\d\d?)?       # [yyyy-]mm-dd
394                  \.?                                       # .
395                  (\d?\d:\d\d)?(:\d\d)?                     # hh:mm:ss
396                )?''', re.VERBOSE), serialised_re=re.compile('''
397             (?P<s>[+-])?1?(?P<y>([ ]{3}\d|\d{4}))(?P<m>\d{2})(?P<d>\d{2})
398             (?P<H>\d{2})(?P<M>\d{2})(?P<S>\d{2})''', re.VERBOSE),
399             add_granularity=0):
400         ''' set the date to the value in spec
401         '''
402         self.year = self.month = self.week = self.day = self.hour = \
403             self.minute = self.second = 0
404         self.sign = 1
405         m = serialised_re.match(spec)
406         if not m:
407             m = interval_re.match(spec)
408             if not m:
409                 raise ValueError, _('Not an interval spec: [+-] [#y] [#m] [#w] '
410                     '[#d] [[[H]H:MM]:SS] [date spec]')
411         else:
412             allowdate = 0
414         # pull out all the info specified
415         info = m.groupdict()
416         if add_granularity:
417             _add_granularity(info, 'SMHdwmy', (info['s']=='-' and -1 or 1))
419         valid = 0
420         for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day',
421                 'H':'hour', 'M':'minute', 'S':'second'}.items():
422             if info.get(group, None) is not None:
423                 valid = 1
424                 setattr(self, attr, int(info[group]))
426         # make sure it's valid
427         if not valid and not info['D']:
428             raise ValueError, _('Not an interval spec: [+-] [#y] [#m] [#w] '
429                 '[#d] [[[H]H:MM]:SS]')
431         if self.week:
432             self.day = self.day + self.week*7
434         if info['s'] is not None:
435             self.sign = {'+':1, '-':-1}[info['s']]
437         # use a date spec if one is given
438         if allowdate and info['D'] is not None:
439             now = Date('.')
440             date = Date(info['D'])
441             # if no time part was specified, nuke it in the "now" date
442             if not date.hour or date.minute or date.second:
443                 now.hour = now.minute = now.second = 0
444             if date != now:
445                 y = now - (date + self)
446                 self.__init__(y.get_tuple())
448     def __cmp__(self, other):
449         """Compare this interval to another interval."""
450         if other is None:
451             # we are always larger than None
452             return 1
453         for attr in 'sign year month day hour minute second'.split():
454             r = cmp(getattr(self, attr), getattr(other, attr))
455             if r:
456                 return r
457         return 0
459     def __str__(self):
460         """Return this interval as a string."""
461         l = []
462         if self.year: l.append('%sy'%self.year)
463         if self.month: l.append('%sm'%self.month)
464         if self.day: l.append('%sd'%self.day)
465         if self.second:
466             l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second))
467         elif self.hour or self.minute:
468             l.append('%d:%02d'%(self.hour, self.minute))
469         if l:
470             l.insert(0, {1:'+', -1:'-'}[self.sign])
471         return ' '.join(l)
473     def __add__(self, other):
474         if isinstance(other, Date):
475             # the other is a Date - produce a Date
476             return Date(other.addInterval(self))
477         elif isinstance(other, Interval):
478             # add the other Interval to this one
479             a = self.get_tuple()
480             as = a[0]
481             b = other.get_tuple()
482             bs = b[0]
483             i = [as*x + bs*y for x,y in zip(a[1:],b[1:])]
484             i.insert(0, 1)
485             i = fixTimeOverflow(i)
486             return Interval(i)
487         # nope, no idea what to do with this other...
488         raise TypeError, "Can't add %r"%other
490     def __sub__(self, other):
491         if isinstance(other, Date):
492             # the other is a Date - produce a Date
493             interval = Interval(self.get_tuple())
494             interval.sign *= -1
495             return Date(other.addInterval(interval))
496         elif isinstance(other, Interval):
497             # add the other Interval to this one
498             a = self.get_tuple()
499             as = a[0]
500             b = other.get_tuple()
501             bs = b[0]
502             i = [as*x - bs*y for x,y in zip(a[1:],b[1:])]
503             i.insert(0, 1)
504             i = fixTimeOverflow(i)
505             return Interval(i)
506         # nope, no idea what to do with this other...
507         raise TypeError, "Can't add %r"%other
509     def __div__(self, other):
510         ''' Divide this interval by an int value.
512             Can't divide years and months sensibly in the _same_
513             calculation as days/time, so raise an error in that situation.
514         '''
515         try:
516             other = float(other)
517         except TypeError:
518             raise ValueError, "Can only divide Intervals by numbers"
520         y, m, d, H, M, S = (self.year, self.month, self.day,
521             self.hour, self.minute, self.second)
522         if y or m:
523             if d or H or M or S:
524                 raise ValueError, "Can't divide Interval with date and time"
525             months = self.year*12 + self.month
526             months *= self.sign
528             months = int(months/other)
530             sign = months<0 and -1 or 1
531             m = months%12
532             y = months / 12
533             return Interval((sign, y, m, 0, 0, 0, 0))
535         else:
536             # handle a day/time division
537             seconds = S + M*60 + H*60*60 + d*60*60*24
538             seconds *= self.sign
540             seconds = int(seconds/other)
542             sign = seconds<0 and -1 or 1
543             seconds *= sign
544             S = seconds%60
545             seconds /= 60
546             M = seconds%60
547             seconds /= 60
548             H = seconds%24
549             d = seconds / 24
550             return Interval((sign, 0, 0, d, H, M, S))
552     def __repr__(self):
553         return '<Interval %s>'%self.__str__()
555     def pretty(self):
556         ''' print up the date date using one of these nice formats..
557         '''
558         if self.year:
559             if self.year == 1:
560                 return _('1 year')
561             else:
562                 return _('%(number)s years')%{'number': self.year}
563         elif self.month or self.day > 13:
564             days = (self.month * 30) + self.day
565             if days > 28:
566                 if int(days/30) > 1:
567                     s = _('%(number)s months')%{'number': int(days/30)}
568                 else:
569                     s = _('1 month')
570             else:
571                 s = _('%(number)s weeks')%{'number': int(days/7)}
572         elif self.day > 7:
573             s = _('1 week')
574         elif self.day > 1:
575             s = _('%(number)s days')%{'number': self.day}
576         elif self.day == 1 or self.hour > 12:
577             if self.sign > 0:
578                 return _('tomorrow')
579             else:
580                 return _('yesterday')
581         elif self.hour > 1:
582             s = _('%(number)s hours')%{'number': self.hour}
583         elif self.hour == 1:
584             if self.minute < 15:
585                 s = _('an hour')
586             elif self.minute/15 == 2:
587                 s = _('1 1/2 hours')
588             else:
589                 s = _('1 %(number)s/4 hours')%{'number': self.minute/15}
590         elif self.minute < 1:
591             if self.sign > 0:
592                 return _('in a moment')
593             else:
594                 return _('just now')
595         elif self.minute == 1:
596             s = _('1 minute')
597         elif self.minute < 15:
598             s = _('%(number)s minutes')%{'number': self.minute}
599         elif int(self.minute/15) == 2:
600             s = _('1/2 an hour')
601         else:
602             s = _('%(number)s/4 hour')%{'number': int(self.minute/15)}
603         if self.sign < 0: 
604             s = s + _(' ago')
605         else:
606             s = _('in') + s
607         return s
609     def get_tuple(self):
610         return (self.sign, self.year, self.month, self.day, self.hour,
611             self.minute, self.second)
613     def serialise(self):
614         sign = self.sign > 0 and '+' or '-'
615         return '%s%04d%02d%02d%02d%02d%02d'%(sign, self.year, self.month,
616             self.day, self.hour, self.minute, self.second)
618 def fixTimeOverflow(time):
619     ''' Handle the overflow in the time portion (H, M, S) of "time":
620             (sign, y,m,d,H,M,S)
622         Overflow and underflow will at most affect the _days_ portion of
623         the date. We do not overflow days to months as we don't know _how_
624         to, generally.
625     '''
626     # XXX we could conceivably use this function for handling regular dates
627     # XXX too - we just need to interrogate the month/year for the day
628     # XXX overflow...
630     sign, y, m, d, H, M, S = time
631     seconds = sign * (S + M*60 + H*60*60 + d*60*60*24)
632     if seconds:
633         sign = seconds<0 and -1 or 1
634         seconds *= sign
635         S = seconds%60
636         seconds /= 60
637         M = seconds%60
638         seconds /= 60
639         H = seconds%24
640         d = seconds / 24
641     else:
642         months = y*12 + m
643         sign = months<0 and -1 or 1
644         months *= sign
645         m = months%12
646         y = months/12
648     return (sign, y, m, d, H, M, S)
650 class Range:
651     """
652     Represents range between two values
653     Ranges can be created using one of theese two alternative syntaxes:
654         
655         1. Native english syntax: 
656             [[From] <value>][ To <value>]
657            Keywords "From" and "To" are case insensitive. Keyword "From" is optional.
659         2. "Geek" syntax:
660             [<value>][; <value>]
662     Either first or second <value> can be omitted in both syntaxes.
664     Examples (consider local time is Sat Mar  8 22:07:48 EET 2003):
665         >>> Range("from 2-12 to 4-2")
666         <Range from 2003-02-12.00:00:00 to 2003-04-02.00:00:00>
667         
668         >>> Range("18:00 TO +2m")
669         <Range from 2003-03-08.18:00:00 to 2003-05-08.20:07:48>
670         
671         >>> Range("12:00")
672         <Range from 2003-03-08.12:00:00 to None>
673         
674         >>> Range("tO +3d")
675         <Range from None to 2003-03-11.20:07:48>
676         
677         >>> Range("2002-11-10; 2002-12-12")
678         <Range from 2002-11-10.00:00:00 to 2002-12-12.00:00:00>
679         
680         >>> Range("; 20:00 +1d")
681         <Range from None to 2003-03-09.20:00:00>
683     """
684     def __init__(self, spec, Type, allow_granularity=1, **params):
685         """Initializes Range of type <Type> from given <spec> string.
686         
687         Sets two properties - from_value and to_value. None assigned to any of
688         this properties means "infinitum" (-infinitum to from_value and
689         +infinitum to to_value)
691         The Type parameter here should be class itself (e.g. Date), not a
692         class instance.
693         
694         """
695         self.range_type = Type
696         re_range = r'(?:^|from(.+?))(?:to(.+?)$|$)'
697         re_geek_range = r'(?:^|(.+?));(?:(.+?)$|$)'
698         # Check which syntax to use
699         if  spec.find(';') == -1:
700             # Native english
701             mch_range = re.search(re_range, spec.strip(), re.IGNORECASE)
702         else:
703             # Geek
704             mch_range = re.search(re_geek_range, spec.strip())
705         if mch_range:
706             self.from_value, self.to_value = mch_range.groups()
707             if self.from_value:
708                 self.from_value = Type(self.from_value.strip(), **params)
709             if self.to_value:
710                 self.to_value = Type(self.to_value.strip(), **params)
711         else:
712             if allow_granularity:
713                 self.from_value = Type(spec, **params)
714                 self.to_value = Type(spec, add_granularity=1, **params)
715             else:
716                 raise ValueError, "Invalid range"
718     def __str__(self):
719         return "from %s to %s" % (self.from_value, self.to_value)
721     def __repr__(self):
722         return "<Range %s>" % self.__str__()
723  
724 def test_range():
725     rspecs = ("from 2-12 to 4-2", "from 18:00 TO +2m", "12:00;", "tO +3d",
726         "2002-11-10; 2002-12-12", "; 20:00 +1d", '2002-10-12')
727     rispecs = ('from -1w 2d 4:32 to 4d', '-2w 1d')
728     for rspec in rspecs:
729         print '>>> Range("%s")' % rspec
730         print `Range(rspec, Date)`
731         print
732     for rspec in rispecs:
733         print '>>> Range("%s")' % rspec
734         print `Range(rspec, Interval)`
735         print
737 def test():
738     intervals = ("  3w  1  d  2:00", " + 2d", "3w")
739     for interval in intervals:
740         print '>>> Interval("%s")'%interval
741         print `Interval(interval)`
743     dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25",
744         "08-13.22:13", "14:25", '2002-12')
745     for date in dates:
746         print '>>> Date("%s")'%date
747         print `Date(date)`
749     sums = ((". + 2d", "3w"), (".", "  3w  1  d  2:00"))
750     for date, interval in sums:
751         print '>>> Date("%s") + Interval("%s")'%(date, interval)
752         print `Date(date) + Interval(interval)`
754 if __name__ == '__main__':
755     test()
757 # vim: set filetype=python ts=4 sw=4 et si