Code

add "ago" to intervals in the past (sf bug 679232)
[roundup.git] / roundup / date.py
index c707e6830d96f408149720c8dbea582fb9159293..7073238f66424fa2a0db85c34117f806409640ce 100644 (file)
@@ -15,7 +15,7 @@
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: date.py,v 1.19 2002-02-21 23:11:45 richard Exp $
+# $Id: date.py,v 1.41 2003-02-07 01:01:25 richard Exp $
 
 __doc__ = """
 Date, time and time interval handling.
@@ -75,6 +75,10 @@ class Date:
         <Date 2000-08-14.03:13:00>
         >>> Date("14:25", -5)
         <Date 2000-06-25.19:25:00>
+
+    The date format 'yyyymmddHHMMSS' (year, month, day, hour,
+    minute, second) is the serialisation format returned by the serialise()
+    method, and is accepted as an argument on instatiation.
     '''
     def __init__(self, spec='.', offset=0):
         """Construct a date given a specification and a time zone offset.
@@ -91,29 +95,17 @@ class Date:
             self.year, self.month, self.day, self.hour, self.minute, \
                 self.second, x, x, x = time.gmtime(ts)
 
-    def applyInterval(self, interval):
-        ''' Apply the interval to this date
+    def addInterval(self, interval):
+        ''' Add the interval to this date, returning the date tuple
         '''
-        t = (self.year + interval.year,
-             self.month + interval.month,
-             self.day + interval.day,
-             self.hour + interval.hour,
-             self.minute + interval.minute,
-             self.second + interval.second, 0, 0, 0)
-        self.year, self.month, self.day, self.hour, self.minute, \
-            self.second, x, x, x = time.gmtime(calendar.timegm(t))
-
-    def __add__(self, other):
-        """Add an interval to this date to produce another date.
-        """
         # do the basic calc
-        sign = other.sign
-        year = self.year + sign * other.year
-        month = self.month + sign * other.month
-        day = self.day + sign * other.day
-        hour = self.hour + sign * other.hour
-        minute = self.minute + sign * other.minute
-        second = self.second + sign * other.second
+        sign = interval.sign
+        year = self.year + sign * interval.year
+        month = self.month + sign * interval.month
+        day = self.day + sign * interval.day
+        hour = self.hour + sign * interval.hour
+        minute = self.minute + sign * interval.minute
+        second = self.second + sign * interval.second
 
         # now cope with under- and over-flow
         # first do the time
@@ -123,8 +115,8 @@ class Date:
             elif second > 59: minute += 1; second -= 60
             if minute < 0: hour -= 1; minute += 60
             elif minute > 59: hour += 1; minute -= 60
-            if hour < 0: day -= 1; hour += 60
-            elif hour > 59: day += 1; hour -= 60
+            if hour < 0: day -= 1; hour += 24
+            elif hour > 59: day += 1; hour -= 24
 
         # fix up the month so we're within range
         while month < 1 or month > 12:
@@ -148,46 +140,63 @@ class Date:
             # re-figure the number of days for this month
             if month == 2 and calendar.isleap(year): month_days = 29
             else: month_days = mdays[month]
+        return (year, month, day, hour, minute, second, 0, 0, 0)
 
-        return Date((year, month, day, hour, minute, second, 0, 0, 0))
+    def applyInterval(self, interval):
+        ''' Apply the interval to this date
+        '''
+        self.year, self.month, self.day, self.hour, self.minute, \
+            self.second, x, x, x = self.addInterval(interval)
+
+    def __add__(self, interval):
+        """Add an interval to this date to produce another 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.
              2. a date from this date to produce an interval.
         """
-        if isinstance(other, Date):
-            # TODO this code will fall over laughing if the dates cross
-            # leap years, phases of the moon, ....
-            a = calendar.timegm((self.year, self.month, self.day, self.hour,
-                self.minute, self.second, 0, 0, 0))
-            b = calendar.timegm((other.year, other.month, other.day,
-                other.hour, other.minute, other.second, 0, 0, 0))
-            diff = a - b
-            if diff < 0:
-                sign = 1
-                diff = -diff
-            else:
-                sign = -1
-            S = diff%60
-            M = (diff/60)%60
-            H = (diff/(60*60))%60
-            if H>1: S = 0
-            d = (diff/(24*60*60))%30
-            if d>1: H = S = M = 0
-            m = (diff/(30*24*60*60))%12
-            if m>1: H = S = M = 0
-            y = (diff/(365*24*60*60))
-            if y>1: d = H = S = M = 0
-            return Interval((y, m, d, H, M, S), sign=sign)
-        return self.__add__(other)
+        if isinstance(other, Interval):
+            other = Interval(other.get_tuple())
+            other.sign *= -1
+            return self.__add__(other)
+
+        assert isinstance(other, Date), 'May only subtract Dates or Intervals'
+
+        # TODO this code will fall over laughing if the dates cross
+        # leap years, phases of the moon, ....
+        a = calendar.timegm((self.year, self.month, self.day, self.hour,
+            self.minute, self.second, 0, 0, 0))
+        b = calendar.timegm((other.year, other.month, other.day,
+            other.hour, other.minute, other.second, 0, 0, 0))
+        diff = a - b
+        if diff < 0:
+            sign = 1
+            diff = -diff
+        else:
+            sign = -1
+        S = diff%60
+        M = (diff/60)%60
+        H = (diff/(60*60))%60
+        if H>1: S = 0
+        d = (diff/(24*60*60))%30
+        if d>1: H = S = M = 0
+        m = (diff/(30*24*60*60))%12
+        if m>1: H = S = M = 0
+        y = (diff/(365*24*60*60))
+        if y>1: d = H = S = M = 0
+        return Interval((y, m, d, H, M, S), sign=sign)
 
     def __cmp__(self, other):
         """Compare this date to another date."""
         if other is None:
             return 1
         for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
+            if not hasattr(other, attr):
+                return 1
             r = cmp(getattr(self, attr), getattr(other, attr))
             if r: return r
         return 0
@@ -197,36 +206,54 @@ 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)):
+            (((?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(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 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 not m:
-            raise ValueError, _('Not a date spec: [[yyyy-]mm-dd].[[h]h:mm[:ss]]'
-                '[offset]')
+        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'])
+            if info['y'] is not None:
+                y = int(info['y'])
+            # time defaults to 00:00:00 now
             H = M = S = 0
 
         # override hour, minute, second parts
@@ -241,23 +268,26 @@ class Date:
         self.year, self.month, self.day, self.hour, self.minute, \
             self.second, x, x, x = time.gmtime(ts)
 
-        if info['o']:
+        if info.get('o', None):
             self.applyInterval(Interval(info['o']))
 
     def __repr__(self):
         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,
             self.second, 0, 0, 0)
 
+    def serialise(self):
+        return '%4d%02d%02d%02d%02d%02d'%(self.year, self.month,
+            self.day, self.hour, self.minute, self.second)
+
 class Interval:
     '''
     Date intervals are specified using the suffixes "y", "m", and "d". The
@@ -286,6 +316,10 @@ class Interval:
     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.
 
+    The interval format 'syyyymmddHHMMSS' (sign, year, month, day, hour,
+    minute, second) is the serialisation format returned by the serialise()
+    method, and is accepted as an argument on instatiation.
+
     TODO: more examples, showing the order of addition operation
     '''
     def __init__(self, spec, sign=1):
@@ -293,19 +327,26 @@ class Interval:
         if type(spec) == type(''):
             self.set(spec)
         else:
-            self.sign = sign
-            self.year, self.month, self.day, self.hour, self.minute, \
-                self.second = spec
+            if len(spec) == 7:
+                self.sign, self.year, self.month, self.day, self.hour, \
+                    self.minute, self.second = spec
+            else:
+                # old, buggy spec form
+                self.sign = sign
+                self.year, self.month, self.day, self.hour, self.minute, \
+                    self.second = spec
 
     def __cmp__(self, other):
         """Compare this interval to another interval."""
         if other is None:
             return 1
         for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
+            if not hasattr(other, attr):
+                return 1
             r = cmp(getattr(self, attr), getattr(other, attr))
             if r: return r
         return 0
-        
+
     def __str__(self):
         """Return this interval as a string."""
         sign = {1:'+', -1:'-'}[self.sign]
@@ -319,35 +360,48 @@ class Interval:
             l.append('%d:%02d'%(self.hour, self.minute))
         return ' '.join(l)
 
-    def set(self, spec, interval_re = re.compile('''
-            \s*
-            (?P<s>[-+])?         # + or -
-            \s*
-            ((?P<y>\d+\s*)y)?    # year
-            \s*
-            ((?P<m>\d+\s*)m)?    # month
-            \s*
-            ((?P<w>\d+\s*)w)?    # week
-            \s*
-            ((?P<d>\d+\s*)d)?    # day
-            \s*
-            (((?P<H>\d+):(?P<M>\d+))?(:(?P<S>\d+))?)?   # time
-            \s*
-            ''', re.VERBOSE)):
+    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()
+            b = other.get_tuple()
+            if b[0] < 0:
+                i = Interval([x-y for x,y in zip(a[1:],b[1:])])
+            else:
+                i = Interval([x+y for x,y in zip(a[1:],b[1:])])
+            return i
+        # nope, no idea what to do with this other...
+        raise TypeError, "Can't add %r"%other
+
+    def set(self, spec, interval_re=re.compile('''
+            \s*(?P<s>[-+])?         # + or -
+            \s*((?P<y>\d+\s*)y)?    # year
+            \s*((?P<m>\d+\s*)m)?    # month
+            \s*((?P<w>\d+\s*)w)?    # week
+            \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>[+-])?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
         '''
         self.year = self.month = self.week = self.day = self.hour = \
             self.minute = self.second = 0
         self.sign = 1
-        m = interval_re.match(spec)
+        m = serialised_re.match(spec)
         if not m:
-            raise ValueError, _('Not an interval spec: [+-] [#y] [#m] [#w] '
-                '[#d] [[[H]H:MM]:SS]')
+            m = interval_re.match(spec)
+            if not m:
+                raise ValueError, _('Not an interval spec: [+-] [#y] [#m] [#w] '
+                    '[#d] [[[H]H:MM]:SS]')
 
         info = m.groupdict()
         for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day',
                 'H':'hour', 'M':'minute', 'S':'second'}.items():
-            if info[group] is not None:
+            if info.get(group, None) is not None:
                 setattr(self, attr, int(info[group]))
 
         if self.week:
@@ -362,8 +416,11 @@ class Interval:
     def pretty(self):
         ''' print up the date date using one of these nice formats..
         '''
-        if self.year or self.month > 2:
-            return None
+        if self.year:
+            if self.year == 1:
+                return _('1 year')
+            else:
+                return _('%(number)s years')%{'number': self.year}
         elif self.month or self.day > 13:
             days = (self.month * 30) + self.day
             if days > 28:
@@ -404,11 +461,18 @@ 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')
         return s
 
     def get_tuple(self):
-        return (self.year, self.month, self.day, self.hour, self.minute,
-            self.second)
+        return (self.sign, self.year, self.month, self.day, self.hour,
+            self.minute, self.second)
+
+    def serialise(self):
+        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 test():
@@ -431,67 +495,4 @@ def test():
 if __name__ == '__main__':
     test()
 
-#
-# $Log: not supported by cvs2svn $
-# Revision 1.18  2002/01/23 20:00:50  jhermann
-# %e is a UNIXism and not documented for Python
-#
-# Revision 1.17  2002/01/16 07:02:57  richard
-#  . lots of date/interval related changes:
-#    - more relaxed date format for input
-#
-# Revision 1.16  2002/01/08 11:56:24  richard
-# missed an import _
-#
-# Revision 1.15  2002/01/05 02:27:00  richard
-# I18N'ification
-#
-# Revision 1.14  2001/11/22 15:46:42  jhermann
-# Added module docstrings to all modules.
-#
-# Revision 1.13  2001/09/18 22:58:37  richard
-#
-# Added some more help to roundu-admin
-#
-# Revision 1.12  2001/08/17 03:08:11  richard
-# fixed prettification of intervals of 1 week
-#
-# Revision 1.11  2001/08/15 23:43:18  richard
-# Fixed some isFooTypes that I missed.
-# Refactored some code in the CGI code.
-#
-# Revision 1.10  2001/08/07 00:24:42  richard
-# stupid typo
-#
-# Revision 1.9  2001/08/07 00:15:51  richard
-# Added the copyright/license notice to (nearly) all files at request of
-# Bizar Software.
-#
-# Revision 1.8  2001/08/05 07:46:12  richard
-# Changed date.Date to use regular string formatting instead of strftime -
-# win32 seems to have problems with %T and no hour... or something...
-#
-# Revision 1.7  2001/08/02 00:27:04  richard
-# Extended the range of intervals that are pretty-printed before actual dates
-# are displayed.
-#
-# Revision 1.6  2001/07/31 09:54:18  richard
-# Fixed the 2.1-specific gmtime() (no arg) call in roundup.date. (Paul Wright)
-#
-# Revision 1.5  2001/07/29 07:01:39  richard
-# Added vim command to all source so that we don't get no steenkin' tabs :)
-#
-# Revision 1.4  2001/07/25 04:09:34  richard
-# Fixed offset handling (shoulda read the spec a little better)
-#
-# Revision 1.3  2001/07/23 07:56:05  richard
-# Storing only marshallable data in the db - no nasty pickled class references.
-#
-# Revision 1.2  2001/07/22 12:09:32  richard
-# Final commit of Grande Splite
-#
-# Revision 1.1  2001/07/22 11:58:35  richard
-# More Grande Splite
-#
-#
 # vim: set filetype=python ts=4 sw=4 et si