Code

added support for searching on ranges of dates
[roundup.git] / roundup / date.py
index 484889bbeac2bda60d85075482811a18abeeedcb..3e6559ff0bad626efeee0264823ee649d33b00ef 100644 (file)
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: date.py,v 1.27 2002-09-10 01:27:13 richard Exp $
+# $Id: date.py,v 1.46 2003-03-08 20:41:45 kedder Exp $
 
 __doc__ = """
 Date, time and time interval handling.
 """
 
-import time, re, calendar
+import time, re, calendar, types
 from i18n import _
 
 class Date:
@@ -153,7 +153,7 @@ class Date:
         """
         return Date(self.addInterval(interval))
 
-    # XXX deviates from spec to allow subtraction of dates as well
+    # deviates from spec to allow subtraction of dates as well
     def __sub__(self, other):
         """ Subtract:
              1. an interval from this date to produce another date.
@@ -206,43 +206,56 @@ class Date:
         return '%4d-%02d-%02d.%02d:%02d:%02d'%(self.year, self.month, self.day,
             self.hour, self.minute, self.second)
 
-    def pretty(self):
+    def pretty(self, format='%d %B %Y'):
         ''' print up the date date using a pretty format...
+
+            Note that if the day is zero, and the day appears first in the
+            format, then the day number will be removed from output.
         '''
-        str = time.strftime('%d %B %Y', (self.year, self.month,
-            self.day, self.hour, self.minute, self.second, 0, 0, 0))
-        if str[0] == '0': return ' ' + str[1:]
+        str = time.strftime(format, (self.year, self.month, self.day,
+            self.hour, self.minute, self.second, 0, 0, 0))
+        # handle zero day by removing it
+        if format.startswith('%d') and str[0] == '0':
+            return ' ' + str[1:]
         return str
 
     def set(self, spec, offset=0, date_re=re.compile(r'''
             (((?P<y>\d\d\d\d)-)?((?P<m>\d\d?)-(?P<d>\d\d?))?)? # yyyy-mm-dd
-            (?P<n>\.)?                                         # .
-            (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d))?)?      # hh:mm:ss
-            (?P<o>.+)?                                         # offset
-            ''', re.VERBOSE), serialised_re=re.compile('''
-            (?P<y>\d{4})(?P<m>\d{2})(?P<d>\d{2}) # yyyymmdd
-            (?P<H>\d{2})(?P<M>\d{2})(?P<S>\d{2}) # HHMMSS
+            (?P<n>\.)?                                     # .
+            (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d))?)?  # hh:mm:ss
+            (?P<o>.+)?                                     # offset
+            ''', re.VERBOSE), serialised_re=re.compile(r'''
+            (\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)
             ''', re.VERBOSE)):
         ''' set the date to the value in spec
         '''
         m = serialised_re.match(spec)
-        if not m:
-            m = date_re.match(spec)
-            if not m:
-                raise ValueError, _('Not a date spec: [[yyyy-]mm-dd].'
-                    '[[h]h:mm[:ss]][offset]')
+        if m is not None:
+            # we're serialised - easy!
+            self.year, self.month, self.day, self.hour, self.minute, \
+                self.second = map(int, m.groups()[:6])
+            return
+
+        # not serialised data, try usual format
+        m = date_re.match(spec)
+        if m is None:
+            raise ValueError, _('Not a date spec: [[yyyy-]mm-dd].'
+                '[[h]h:mm[:ss]][offset]')
 
         info = m.groupdict()
 
-        # get the current date/time using the offset
+        # get the current date as our default
         y,m,d,H,M,S,x,x,x = time.gmtime(time.time())
 
         # override year, month, day parts
         if info['m'] is not None and info['d'] is not None:
             m = int(info['m'])
             d = int(info['d'])
-            if info['y'] is not None: y = int(info['y'])
-            H = M = S = 0
+            if info['y'] is not None:
+                y = int(info['y'])
+            # time defaults to 00:00:00 GMT - offset (local midnight)
+            H = -offset
+            M = S = 0
 
         # override hour, minute, second parts
         if info['H'] is not None and info['M'] is not None:
@@ -263,11 +276,10 @@ class Date:
         return '<Date %s>'%self.__str__()
 
     def local(self, offset):
-        """Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone."""
-        t = (self.year, self.month, self.day, self.hour + offset, self.minute,
-             self.second, 0, 0, 0)
-        self.year, self.month, self.day, self.hour, self.minute, \
-            self.second, x, x, x = time.gmtime(calendar.timegm(t))
+        """ Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone.
+        """
+        return Date((self.year, self.month, self.day, self.hour + offset,
+            self.minute, self.second, 0, 0, 0))
 
     def get_tuple(self):
         return (self.year, self.month, self.day, self.hour, self.minute,
@@ -294,14 +306,28 @@ class Interval:
 
     Example usage:
         >>> Interval("  3w  1  d  2:00")
-        <Interval 22d 2:00>
+        <Interval 22d 2:00>
         >>> Date(". + 2d") + Interval("- 3w")
         <Date 2000-06-07.00:34:02>
-
-    Intervals are added/subtracted in order of:
+        >>> Interval('1:59:59') + Interval('00:00:01')
+        <Interval + 2:00>
+        >>> Interval('2:00') + Interval('- 00:00:01')
+        <Interval + 1:59:59>
+        >>> Interval('1y')/2
+        <Interval + 6m>
+        >>> Interval('1:00')/2
+        <Interval + 0:30>
+
+    Interval arithmetic is handled in a couple of special ways, trying
+    to cater for the most common cases. Fundamentally, Intervals which
+    have both date and time parts will result in strange results in
+    arithmetic - because of the impossibility of handling day->month->year
+    over- and under-flows. Intervals may also be divided by some number.
+
+    Intervals are added to Dates in order of:
        seconds, minutes, hours, years, months, days
 
-    Calculations involving monts (eg '+2m') have no effect on days - only
+    Calculations involving months (eg '+2m') have no effect on days - only
     days (or over/underflow from hours/mins/secs) will do that, and
     days-per-month and leap years are accounted for. Leap seconds are not.
 
@@ -329,17 +355,17 @@ class Interval:
         """Compare this interval to another interval."""
         if other is None:
             return 1
-        for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
+        for attr in 'sign year month day hour minute second'.split():
             if not hasattr(other, attr):
                 return 1
             r = cmp(getattr(self, attr), getattr(other, attr))
-            if r: return r
+            if r:
+                return r
         return 0
 
     def __str__(self):
         """Return this interval as a string."""
-        sign = {1:'+', -1:'-'}[self.sign]
-        l = [sign]
+        l = []
         if self.year: l.append('%sy'%self.year)
         if self.month: l.append('%sm'%self.month)
         if self.day: l.append('%sd'%self.day)
@@ -347,8 +373,89 @@ class Interval:
             l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second))
         elif self.hour or self.minute:
             l.append('%d:%02d'%(self.hour, self.minute))
+        if l:
+            l.insert(0, {1:'+', -1:'-'}[self.sign])
         return ' '.join(l)
 
+    def __add__(self, other):
+        if isinstance(other, Date):
+            # the other is a Date - produce a Date
+            return Date(other.addInterval(self))
+        elif isinstance(other, Interval):
+            # add the other Interval to this one
+            a = self.get_tuple()
+            as = a[0]
+            b = other.get_tuple()
+            bs = b[0]
+            i = [as*x + bs*y for x,y in zip(a[1:],b[1:])]
+            i.insert(0, 1)
+            i = fixTimeOverflow(i)
+            return Interval(i)
+        # nope, no idea what to do with this other...
+        raise TypeError, "Can't add %r"%other
+
+    def __sub__(self, other):
+        if isinstance(other, Date):
+            # the other is a Date - produce a Date
+            interval = Interval(self.get_tuple())
+            interval.sign *= -1
+            return Date(other.addInterval(interval))
+        elif isinstance(other, Interval):
+            # add the other Interval to this one
+            a = self.get_tuple()
+            as = a[0]
+            b = other.get_tuple()
+            bs = b[0]
+            i = [as*x - bs*y for x,y in zip(a[1:],b[1:])]
+            i.insert(0, 1)
+            i = fixTimeOverflow(i)
+            return Interval(i)
+        # nope, no idea what to do with this other...
+        raise TypeError, "Can't add %r"%other
+
+    def __div__(self, other):
+        ''' Divide this interval by an int value.
+
+            Can't divide years and months sensibly in the _same_
+            calculation as days/time, so raise an error in that situation.
+        '''
+        try:
+            other = float(other)
+        except TypeError:
+            raise ValueError, "Can only divide Intervals by numbers"
+
+        y, m, d, H, M, S = (self.year, self.month, self.day,
+            self.hour, self.minute, self.second)
+        if y or m:
+            if d or H or M or S:
+                raise ValueError, "Can't divide Interval with date and time"
+            months = self.year*12 + self.month
+            months *= self.sign
+
+            months = int(months/other)
+
+            sign = months<0 and -1 or 1
+            m = months%12
+            y = months / 12
+            return Interval((sign, y, m, 0, 0, 0, 0))
+
+        else:
+            # handle a day/time division
+            seconds = S + M*60 + H*60*60 + d*60*60*24
+            seconds *= self.sign
+
+            seconds = int(seconds/other)
+
+            sign = seconds<0 and -1 or 1
+            seconds *= sign
+            S = seconds%60
+            seconds /= 60
+            M = seconds%60
+            seconds /= 60
+            H = seconds%24
+            d = seconds / 24
+            return Interval((sign, 0, 0, d, H, M, S))
+
     def set(self, spec, interval_re=re.compile('''
             \s*(?P<s>[-+])?         # + or -
             \s*((?P<y>\d+\s*)y)?    # year
@@ -357,7 +464,7 @@ class Interval:
             \s*((?P<d>\d+\s*)d)?    # day
             \s*(((?P<H>\d+):(?P<M>\d+))?(:(?P<S>\d+))?)?   # time
             \s*''', re.VERBOSE), serialised_re=re.compile('''
-            (?P<s>[+-])(?P<y>\d{4})(?P<m>\d{2})(?P<d>\d{2})
+            (?P<s>[+-])?1?(?P<y>([ ]{3}\d|\d{4}))(?P<m>\d{2})(?P<d>\d{2})
             (?P<H>\d{2})(?P<M>\d{2})(?P<S>\d{2})''', re.VERBOSE)):
         ''' set the date to the value in spec
         '''
@@ -434,6 +541,10 @@ class Interval:
             s = _('1/2 an hour')
         else:
             s = _('%(number)s/4 hour')%{'number': int(self.minute/15)}
+        if self.sign < 0: 
+            s = s + _(' ago')
+        else:
+            s = _('in') + s
         return s
 
     def get_tuple(self):
@@ -441,9 +552,115 @@ class Interval:
             self.minute, self.second)
 
     def serialise(self):
-        return '%s%4d%02d%02d%02d%02d%02d'%(self.sign, self.year, self.month,
+        sign = self.sign > 0 and '+' or '-'
+        return '%s%04d%02d%02d%02d%02d%02d'%(sign, self.year, self.month,
             self.day, self.hour, self.minute, self.second)
 
+def fixTimeOverflow(time):
+    ''' Handle the overflow in the time portion (H, M, S) of "time":
+            (sign, y,m,d,H,M,S)
+
+        Overflow and underflow will at most affect the _days_ portion of
+        the date. We do not overflow days to months as we don't know _how_
+        to, generally.
+    '''
+    # XXX we could conceivably use this function for handling regular dates
+    # XXX too - we just need to interrogate the month/year for the day
+    # XXX overflow...
+
+    sign, y, m, d, H, M, S = time
+    seconds = sign * (S + M*60 + H*60*60 + d*60*60*24)
+    if seconds:
+        sign = seconds<0 and -1 or 1
+        seconds *= sign
+        S = seconds%60
+        seconds /= 60
+        M = seconds%60
+        seconds /= 60
+        H = seconds%24
+        d = seconds / 24
+    else:
+        months = y*12 + m
+        sign = months<0 and -1 or 1
+        months *= sign
+        m = months%12
+        y = months/12
+
+    return (sign, y, m, d, H, M, S)
+
+class Range:
+    """
+    Represents range between two values
+    Ranges can be created using one of theese two alternative syntaxes:
+        
+        1. Native english syntax: 
+            [[From] <value>][ To <value>]
+           Keywords "From" and "To" are case insensitive. Keyword "From" is optional.
+
+        2. "Geek" syntax:
+            [<value>][; <value>]
+
+    Either first or second <value> can be omitted in both syntaxes.
+
+    Examples (consider local time is Sat Mar  8 22:07:48 EET 2003):
+        >>> Range("from 2-12 to 4-2")
+        <Range from 2003-02-12.00:00:00 to 2003-04-02.00:00:00>
+        
+        >>> Range("18:00 TO +2m")
+        <Range from 2003-03-08.18:00:00 to 2003-05-08.20:07:48>
+        
+        >>> Range("12:00")
+        <Range from 2003-03-08.12:00:00 to None>
+        
+        >>> Range("tO +3d")
+        <Range from None to 2003-03-11.20:07:48>
+        
+        >>> Range("2002-11-10; 2002-12-12")
+        <Range from 2002-11-10.00:00:00 to 2002-12-12.00:00:00>
+        
+        >>> Range("; 20:00 +1d")
+        <Range from None to 2003-03-09.20:00:00>
+
+    """
+    def __init__(self, spec, type, **params):
+        """Initializes Range of type <type> from given <spec> string.
+        
+        Sets two properties - from_value and to_value. None assigned to any of
+        this properties means "infinitum" (-infinitum to from_value and
+        +infinitum to to_value)        
+        """
+        self.range_type = type
+        re_range = r'(?:^|(?:from)?(.+?))(?:to(.+?)$|$)'
+        re_geek_range = r'(?:^|(.+?))(?:;(.+?)$|$)'
+        # Check which syntax to use
+        if  spec.find(';') == -1:
+            # Native english
+            mch_range = re.search(re_range, spec.strip(), re.IGNORECASE)
+        else:
+            # Geek
+            mch_range = re.search(re_geek_range, spec.strip())
+        if mch_range:
+            self.from_value, self.to_value = mch_range.groups()
+            if self.from_value:
+                self.from_value = type(self.from_value.strip(), **params)
+            if self.to_value:
+                self.to_value = type(self.to_value.strip(), **params)
+        else:
+            raise ValueError, "Invalid range"
+
+    def __str__(self):
+        return "from %s to %s" % (self.from_value, self.to_value)
+
+    def __repr__(self):
+        return "<Range %s>" % self.__str__()
+def test_range():
+    rspecs = ("from 2-12 to 4-2", "18:00 TO +2m", "12:00", "tO +3d",
+        "2002-11-10; 2002-12-12", "; 20:00 +1d")
+    for rspec in rspecs:
+        print '>>> Range("%s")' % rspec
+        print `Range(rspec, Date)`
+        print
 
 def test():
     intervals = ("  3w  1  d  2:00", " + 2d", "3w")
@@ -463,6 +680,6 @@ def test():
         print `Date(date) + Interval(interval)`
 
 if __name__ == '__main__':
-    test()
+    test_range()
 
 # vim: set filetype=python ts=4 sw=4 et si