Code

Fix matching of incoming email addresses to the alternate_addresses
[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.94 2007-12-23 00:23:23 richard Exp $
20 """Date, time and time interval handling.
21 """
22 __docformat__ = 'restructuredtext'
24 import calendar
25 import datetime
26 import time
27 import re
29 try:
30     import pytz
31 except ImportError:
32     pytz = None
34 from roundup import i18n
36 # no, I don't know why we must anchor the date RE when we only ever use it
37 # in a match()
38 date_re = re.compile(r'''^
39     ((?P<y>\d\d\d\d)([/-](?P<m>\d\d?)([/-](?P<d>\d\d?))?)? # yyyy[-mm[-dd]]
40     |(?P<a>\d\d?)[/-](?P<b>\d\d?))?              # or mm-dd
41     (?P<n>\.)?                                   # .
42     (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d?(\.\d+)?))?)?  # hh:mm:ss
43     (?P<o>[\d\smywd\-+]+)?                       # offset
44 $''', re.VERBOSE)
45 serialised_date_re = re.compile(r'''
46     (\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d?(\.\d+)?)
47 ''', re.VERBOSE)
49 _timedelta0 = datetime.timedelta(0)
51 # load UTC tzinfo
52 if pytz:
53     UTC = pytz.utc
54 else:
55     # fallback implementation from Python Library Reference
57     class _UTC(datetime.tzinfo):
59         """Universal Coordinated Time zoneinfo"""
61         def utcoffset(self, dt):
62             return _timedelta0
64         def tzname(self, dt):
65             return "UTC"
67         def dst(self, dt):
68             return _timedelta0
70         def __repr__(self):
71             return "<UTC>"
73         # pytz adjustments interface
74         # Note: pytz verifies that dt is naive datetime for localize()
75         # and not naive datetime for normalize().
76         # In this implementation, we don't care.
78         def normalize(self, dt, is_dst=False):
79             return dt.replace(tzinfo=self)
81         def localize(self, dt, is_dst=False):
82             return dt.replace(tzinfo=self)
84     UTC = _UTC()
86 # integral hours offsets were available in Roundup versions prior to 1.1.3
87 # and still are supported as a fallback if pytz module is not installed
88 class SimpleTimezone(datetime.tzinfo):
90     """Simple zoneinfo with fixed numeric offset and no daylight savings"""
92     def __init__(self, offset=0, name=None):
93         super(SimpleTimezone, self).__init__()
94         self.offset = offset
95         if name:
96             self.name = name
97         else:
98             self.name = "Etc/GMT%+d" % self.offset
100     def utcoffset(self, dt):
101         return datetime.timedelta(hours=self.offset)
103     def tzname(self, dt):
104         return self.name
106     def dst(self, dt):
107         return _timedelta0
109     def __repr__(self):
110         return "<%s: %s>" % (self.__class__.__name__, self.name)
112     # pytz adjustments interface
114     def normalize(self, dt):
115         return dt.replace(tzinfo=self)
117     def localize(self, dt, is_dst=False):
118         return dt.replace(tzinfo=self)
120 # simple timezones with fixed offset
121 _tzoffsets = dict(GMT=0, UCT=0, EST=5, MST=7, HST=10)
123 def get_timezone(tz):
124     # if tz is None, return None (will result in naive datetimes)
125     # XXX should we return UTC for None?
126     if tz is None:
127         return None
128     # try integer offset first for backward compatibility
129     try:
130         utcoffset = int(tz)
131     except (TypeError, ValueError):
132         pass
133     else:
134         if utcoffset == 0:
135             return UTC
136         else:
137             return SimpleTimezone(utcoffset)
138     # tz is a timezone name
139     if pytz:
140         return pytz.timezone(tz)
141     elif tz == "UTC":
142         return UTC
143     elif tz in _tzoffsets:
144         return SimpleTimezone(_tzoffsets[tz], tz)
145     else:
146         raise KeyError, tz
148 def _utc_to_local(y,m,d,H,M,S,tz):
149     TZ = get_timezone(tz)
150     frac = S - int(S)
151     dt = datetime.datetime(y, m, d, H, M, int(S), tzinfo=UTC)
152     y,m,d,H,M,S = dt.astimezone(TZ).timetuple()[:6]
153     S = S + frac
154     return (y,m,d,H,M,S)
156 def _local_to_utc(y,m,d,H,M,S,tz):
157     TZ = get_timezone(tz)
158     dt = datetime.datetime(y,m,d,H,M,int(S))
159     y,m,d,H,M,S = TZ.localize(dt).utctimetuple()[:6]
160     return (y,m,d,H,M,S)
162 class Date:
163     '''
164     As strings, date-and-time stamps are specified with the date in
165     international standard format (yyyy-mm-dd) joined to the time
166     (hh:mm:ss) by a period ("."). Dates in this form can be easily compared
167     and are fairly readable when printed. An example of a valid stamp is
168     "2000-06-24.13:03:59". We'll call this the "full date format". When
169     Timestamp objects are printed as strings, they appear in the full date
170     format with the time always given in GMT. The full date format is
171     always exactly 19 characters long.
173     For user input, some partial forms are also permitted: the whole time
174     or just the seconds may be omitted; and the whole date may be omitted
175     or just the year may be omitted. If the time is given, the time is
176     interpreted in the user's local time zone. The Date constructor takes
177     care of these conversions. In the following examples, suppose that yyyy
178     is the current year, mm is the current month, and dd is the current day
179     of the month; and suppose that the user is on Eastern Standard Time.
180     Examples::
182       "2000-04-17" means <Date 2000-04-17.00:00:00>
183       "01-25" means <Date yyyy-01-25.00:00:00>
184       "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
185       "08-13.22:13" means <Date yyyy-08-14.03:13:00>
186       "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
187       "14:25" means <Date yyyy-mm-dd.19:25:00>
188       "8:47:11" means <Date yyyy-mm-dd.13:47:11>
189       "2003" means <Date 2003-01-01.00:00:00>
190       "2003-06" means <Date 2003-06-01.00:00:00>
191       "." means "right now"
193     The Date class should understand simple date expressions of the form
194     stamp + interval and stamp - interval. When adding or subtracting
195     intervals involving months or years, the components are handled
196     separately. For example, when evaluating "2000-06-25 + 1m 10d", we
197     first add one month to get 2000-07-25, then add 10 days to get
198     2000-08-04 (rather than trying to decide whether 1m 10d means 38 or 40
199     or 41 days).  Example usage::
201         >>> Date(".")
202         <Date 2000-06-26.00:34:02>
203         >>> _.local(-5)
204         "2000-06-25.19:34:02"
205         >>> Date(". + 2d")
206         <Date 2000-06-28.00:34:02>
207         >>> Date("1997-04-17", -5)
208         <Date 1997-04-17.00:00:00>
209         >>> Date("01-25", -5)
210         <Date 2000-01-25.00:00:00>
211         >>> Date("08-13.22:13", -5)
212         <Date 2000-08-14.03:13:00>
213         >>> Date("14:25", -5)
214         <Date 2000-06-25.19:25:00>
216     The date format 'yyyymmddHHMMSS' (year, month, day, hour,
217     minute, second) is the serialisation format returned by the serialise()
218     method, and is accepted as an argument on instatiation.
220     The date class handles basic arithmetic::
222         >>> d1=Date('.')
223         >>> d1
224         <Date 2004-04-06.22:04:20.766830>
225         >>> d2=Date('2003-07-01')
226         >>> d2
227         <Date 2003-07-01.00:00:0.000000>
228         >>> d1-d2
229         <Interval + 280d 22:04:20>
230         >>> i1=_
231         >>> d2+i1
232         <Date 2004-04-06.22:04:20.000000>
233         >>> d1-i1
234         <Date 2003-07-01.00:00:0.000000>
235     '''
237     def __init__(self, spec='.', offset=0, add_granularity=False,
238             translator=i18n):
239         """Construct a date given a specification and a time zone offset.
241         'spec'
242            is a full date or a partial form, with an optional added or
243            subtracted interval. Or a date 9-tuple.
244         'offset'
245            is the local time zone offset from GMT in hours.
246         'translator'
247            is i18n module or one of gettext translation classes.
248            It must have attributes 'gettext' and 'ngettext',
249            serving as translation functions.
250         """
251         self.setTranslator(translator)
252         # Python 2.3+ datetime object
253         # common case when reading from database: avoid double-conversion
254         if isinstance(spec, datetime.datetime):
255             if offset == 0:
256                 self.year, self.month, self.day, self.hour, self.minute, \
257                     self.second = spec.timetuple()[:6]
258             else:
259                 TZ = get_timezone(tz)
260                 self.year, self.month, self.day, self.hour, self.minute, \
261                     self.second = TZ.localize(spec).utctimetuple()[:6]
262             self.second += spec.microsecond/1000000.
263             return
265         if type(spec) == type(''):
266             self.set(spec, offset=offset, add_granularity=add_granularity)
267             return
268         elif hasattr(spec, 'tuple'):
269             spec = spec.tuple()
270         elif isinstance(spec, Date):
271             spec = spec.get_tuple()
272         try:
273             y,m,d,H,M,S,x,x,x = spec
274             frac = S - int(S)
275             self.year, self.month, self.day, self.hour, self.minute, \
276                 self.second = _local_to_utc(y, m, d, H, M, S, offset)
277             # we lost the fractional part
278             self.second = self.second + frac
279             if str(self.second) == '60.0': self.second = 59.9
280         except:
281             raise ValueError, 'Unknown spec %r' % (spec,)
283     def set(self, spec, offset=0, date_re=date_re,
284             serialised_re=serialised_date_re, add_granularity=False):
285         ''' set the date to the value in spec
286         '''
288         m = serialised_re.match(spec)
289         if m is not None:
290             # we're serialised - easy!
291             g = m.groups()
292             (self.year, self.month, self.day, self.hour, self.minute) = \
293                 map(int, g[:5])
294             self.second = float(g[5])
295             return
297         # not serialised data, try usual format
298         m = date_re.match(spec)
299         if m is None:
300             raise ValueError, self._('Not a date spec: '
301                 '"yyyy-mm-dd", "mm-dd", "HH:MM", "HH:MM:SS" or '
302                 '"yyyy-mm-dd.HH:MM:SS.SSS"')
304         info = m.groupdict()
306         # If add_granularity is true, construct the maximum time given
307         # the precision of the input.  For example, given the input
308         # "12:15", construct "12:15:59".  Or, for "2008", construct
309         # "2008-12-31.23:59:59".
310         if add_granularity:
311             for gran in 'SMHdmy':
312                 if info[gran] is not None:
313                     if gran == 'S':
314                         raise ValueError
315                     elif gran == 'M':
316                         add_granularity = Interval('00:01')
317                     elif gran == 'H':
318                         add_granularity = Interval('01:00')
319                     else:
320                         add_granularity = Interval('+1%s'%gran)
321                     break
322             else:
323                 raise ValueError(self._('Could not determine granularity'))
325         # get the current date as our default
326         dt = datetime.datetime.utcnow()
327         y,m,d,H,M,S,x,x,x = dt.timetuple()
328         S += dt.microsecond/1000000.
330         # whether we need to convert to UTC
331         adjust = False
333         if info['y'] is not None or info['a'] is not None:
334             if info['y'] is not None:
335                 y = int(info['y'])
336                 m,d = (1,1)
337                 if info['m'] is not None:
338                     m = int(info['m'])
339                     if info['d'] is not None:
340                         d = int(info['d'])
341             if info['a'] is not None:
342                 m = int(info['a'])
343                 d = int(info['b'])
344             H = 0
345             M = S = 0
346             adjust = True
348         # override hour, minute, second parts
349         if info['H'] is not None and info['M'] is not None:
350             H = int(info['H'])
351             M = int(info['M'])
352             S = 0
353             if info['S'] is not None:
354                 S = float(info['S'])
355             adjust = True
358         # now handle the adjustment of hour
359         frac = S - int(S)
360         dt = datetime.datetime(y,m,d,H,M,int(S), int(frac * 1000000.))
361         y, m, d, H, M, S, x, x, x = dt.timetuple()
362         if adjust:
363             y, m, d, H, M, S = _local_to_utc(y, m, d, H, M, S, offset)
364         self.year, self.month, self.day, self.hour, self.minute, \
365             self.second = y, m, d, H, M, S
366         # we lost the fractional part along the way
367         self.second += dt.microsecond/1000000.
369         if info.get('o', None):
370             try:
371                 self.applyInterval(Interval(info['o'], allowdate=0))
372             except ValueError:
373                 raise ValueError, self._('%r not a date / time spec '
374                     '"yyyy-mm-dd", "mm-dd", "HH:MM", "HH:MM:SS" or '
375                     '"yyyy-mm-dd.HH:MM:SS.SSS"')%(spec,)
377         # adjust by added granularity
378         if add_granularity:
379             self.applyInterval(add_granularity)
380             self.applyInterval(Interval('- 00:00:01'))
382     def addInterval(self, interval):
383         ''' Add the interval to this date, returning the date tuple
384         '''
385         # do the basic calc
386         sign = interval.sign
387         year = self.year + sign * interval.year
388         month = self.month + sign * interval.month
389         day = self.day + sign * interval.day
390         hour = self.hour + sign * interval.hour
391         minute = self.minute + sign * interval.minute
392         # Intervals work on whole seconds
393         second = int(self.second) + sign * interval.second
395         # now cope with under- and over-flow
396         # first do the time
397         while (second < 0 or second > 59 or minute < 0 or minute > 59 or
398                 hour < 0 or hour > 23):
399             if second < 0: minute -= 1; second += 60
400             elif second > 59: minute += 1; second -= 60
401             if minute < 0: hour -= 1; minute += 60
402             elif minute > 59: hour += 1; minute -= 60
403             if hour < 0: day -= 1; hour += 24
404             elif hour > 23: day += 1; hour -= 24
406         # fix up the month so we're within range
407         while month < 1 or month > 12:
408             if month < 1: year -= 1; month += 12
409             if month > 12: year += 1; month -= 12
411         # now do the days, now that we know what month we're in
412         def get_mdays(year, month):
413             if month == 2 and calendar.isleap(year): return 29
414             else: return calendar.mdays[month]
416         while month < 1 or month > 12 or day < 1 or day > get_mdays(year,month):
417             # now to day under/over
418             if day < 1:
419                 # When going backwards, decrement month, then increment days
420                 month -= 1
421                 day += get_mdays(year,month)
422             elif day > get_mdays(year,month):
423                 # When going forwards, decrement days, then increment month
424                 day -= get_mdays(year,month)
425                 month += 1
427             # possibly fix up the month so we're within range
428             while month < 1 or month > 12:
429                 if month < 1: year -= 1; month += 12 ; day += 31
430                 if month > 12: year += 1; month -= 12
432         return (year, month, day, hour, minute, second, 0, 0, 0)
434     def differenceDate(self, other):
435         "Return the difference between this date and another date"
436         return self - other
438     def applyInterval(self, interval):
439         ''' Apply the interval to this date
440         '''
441         self.year, self.month, self.day, self.hour, self.minute, \
442             self.second, x, x, x = self.addInterval(interval)
444     def __add__(self, interval):
445         """Add an interval to this date to produce another date.
446         """
447         return Date(self.addInterval(interval), translator=self.translator)
449     # deviates from spec to allow subtraction of dates as well
450     def __sub__(self, other):
451         """ Subtract:
452              1. an interval from this date to produce another date.
453              2. a date from this date to produce an interval.
454         """
455         if isinstance(other, Interval):
456             other = Interval(other.get_tuple())
457             other.sign *= -1
458             return self.__add__(other)
460         assert isinstance(other, Date), 'May only subtract Dates or Intervals'
462         return self.dateDelta(other)
464     def dateDelta(self, other):
465         """ Produce an Interval of the difference between this date
466             and another date. Only returns days:hours:minutes:seconds.
467         """
468         # Returning intervals larger than a day is almost
469         # impossible - months, years, weeks, are all so imprecise.
470         a = calendar.timegm((self.year, self.month, self.day, self.hour,
471             self.minute, self.second, 0, 0, 0))
472         b = calendar.timegm((other.year, other.month, other.day,
473             other.hour, other.minute, other.second, 0, 0, 0))
474         # intervals work in whole seconds
475         diff = int(a - b)
476         if diff > 0:
477             sign = 1
478         else:
479             sign = -1
480             diff = -diff
481         S = diff%60
482         M = (diff/60)%60
483         H = (diff/(60*60))%24
484         d = diff/(24*60*60)
485         return Interval((0, 0, d, H, M, S), sign=sign,
486             translator=self.translator)
488     def __cmp__(self, other, int_seconds=0):
489         """Compare this date to another date."""
490         if other is None:
491             return 1
492         for attr in ('year', 'month', 'day', 'hour', 'minute'):
493             if not hasattr(other, attr):
494                 return 1
495             r = cmp(getattr(self, attr), getattr(other, attr))
496             if r: return r
497         if not hasattr(other, 'second'):
498             return 1
499         if int_seconds:
500             return cmp(int(self.second), int(other.second))
501         return cmp(self.second, other.second)
503     def __str__(self):
504         """Return this date as a string in the yyyy-mm-dd.hh:mm:ss format."""
505         return self.formal()
507     def formal(self, sep='.', sec='%02d'):
508         f = '%%04d-%%02d-%%02d%s%%02d:%%02d:%s'%(sep, sec)
509         return f%(self.year, self.month, self.day, self.hour, self.minute,
510             self.second)
512     def pretty(self, format='%d %B %Y'):
513         ''' print up the date date using a pretty format...
515             Note that if the day is zero, and the day appears first in the
516             format, then the day number will be removed from output.
517         '''
518         dt = datetime.datetime(self.year, self.month, self.day, self.hour,
519             self.minute, int(self.second),
520             int ((self.second - int (self.second)) * 1000000.))
521         str = dt.strftime(format)
523         # handle zero day by removing it
524         if format.startswith('%d') and str[0] == '0':
525             return ' ' + str[1:]
526         return str
528     def __repr__(self):
529         return '<Date %s>'%self.formal(sec='%06.3f')
531     def local(self, offset):
532         """ Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone.
533             The offset is a pytz tz offset if pytz is installed.
534         """
535         y, m, d, H, M, S = _utc_to_local(self.year, self.month, self.day,
536                 self.hour, self.minute, self.second, offset)
537         return Date((y, m, d, H, M, S, 0, 0, 0), translator=self.translator)
539     def __deepcopy__(self, memo):
540         return Date((self.year, self.month, self.day, self.hour,
541             self.minute, self.second, 0, 0, 0), translator=self.translator)
543     def get_tuple(self):
544         return (self.year, self.month, self.day, self.hour, self.minute,
545             self.second, 0, 0, 0)
547     def serialise(self):
548         return '%04d%02d%02d%02d%02d%06.3f'%(self.year, self.month,
549             self.day, self.hour, self.minute, self.second)
551     def timestamp(self):
552         ''' return a UNIX timestamp for this date '''
553         frac = self.second - int(self.second)
554         ts = calendar.timegm((self.year, self.month, self.day, self.hour,
555             self.minute, self.second, 0, 0, 0))
556         # we lose the fractional part
557         return ts + frac
559     def setTranslator(self, translator):
560         """Replace the translation engine
562         'translator'
563            is i18n module or one of gettext translation classes.
564            It must have attributes 'gettext' and 'ngettext',
565            serving as translation functions.
566         """
567         self.translator = translator
568         self._ = translator.gettext
569         self.ngettext = translator.ngettext
571     def fromtimestamp(cls, ts):
572         """Create a date object from a timestamp.
574         The timestamp may be outside the gmtime year-range of
575         1902-2038.
576         """
577         usec = int((ts - int(ts)) * 1000000.)
578         delta = datetime.timedelta(seconds = int(ts), microseconds = usec)
579         return cls(datetime.datetime(1970, 1, 1) + delta)
580     fromtimestamp = classmethod(fromtimestamp)
582 class Interval:
583     '''
584     Date intervals are specified using the suffixes "y", "m", and "d". The
585     suffix "w" (for "week") means 7 days. Time intervals are specified in
586     hh:mm:ss format (the seconds may be omitted, but the hours and minutes
587     may not).
589       "3y" means three years
590       "2y 1m" means two years and one month
591       "1m 25d" means one month and 25 days
592       "2w 3d" means two weeks and three days
593       "1d 2:50" means one day, two hours, and 50 minutes
594       "14:00" means 14 hours
595       "0:04:33" means four minutes and 33 seconds
597     Example usage:
598         >>> Interval("  3w  1  d  2:00")
599         <Interval + 22d 2:00>
600         >>> Date(". + 2d") + Interval("- 3w")
601         <Date 2000-06-07.00:34:02>
602         >>> Interval('1:59:59') + Interval('00:00:01')
603         <Interval + 2:00>
604         >>> Interval('2:00') + Interval('- 00:00:01')
605         <Interval + 1:59:59>
606         >>> Interval('1y')/2
607         <Interval + 6m>
608         >>> Interval('1:00')/2
609         <Interval + 0:30>
610         >>> Interval('2003-03-18')
611         <Interval + [number of days between now and 2003-03-18]>
612         >>> Interval('-4d 2003-03-18')
613         <Interval + [number of days between now and 2003-03-14]>
615     Interval arithmetic is handled in a couple of special ways, trying
616     to cater for the most common cases. Fundamentally, Intervals which
617     have both date and time parts will result in strange results in
618     arithmetic - because of the impossibility of handling day->month->year
619     over- and under-flows. Intervals may also be divided by some number.
621     Intervals are added to Dates in order of:
622        seconds, minutes, hours, years, months, days
624     Calculations involving months (eg '+2m') have no effect on days - only
625     days (or over/underflow from hours/mins/secs) will do that, and
626     days-per-month and leap years are accounted for. Leap seconds are not.
628     The interval format 'syyyymmddHHMMSS' (sign, year, month, day, hour,
629     minute, second) is the serialisation format returned by the serialise()
630     method, and is accepted as an argument on instatiation.
632     TODO: more examples, showing the order of addition operation
633     '''
634     def __init__(self, spec, sign=1, allowdate=1, add_granularity=False,
635         translator=i18n
636     ):
637         """Construct an interval given a specification."""
638         self.setTranslator(translator)
639         if isinstance(spec, (int, float, long)):
640             self.from_seconds(spec)
641         elif isinstance(spec, basestring):
642             self.set(spec, allowdate=allowdate, add_granularity=add_granularity)
643         elif isinstance(spec, Interval):
644             (self.sign, self.year, self.month, self.day, self.hour,
645                 self.minute, self.second) = spec.get_tuple()
646         else:
647             if len(spec) == 7:
648                 self.sign, self.year, self.month, self.day, self.hour, \
649                     self.minute, self.second = spec
650                 self.second = int(self.second)
651             else:
652                 # old, buggy spec form
653                 self.sign = sign
654                 self.year, self.month, self.day, self.hour, self.minute, \
655                     self.second = spec
656                 self.second = int(self.second)
658     def __deepcopy__(self, memo):
659         return Interval((self.sign, self.year, self.month, self.day,
660             self.hour, self.minute, self.second), translator=self.translator)
662     def set(self, spec, allowdate=1, interval_re=re.compile('''
663             \s*(?P<s>[-+])?         # + or -
664             \s*((?P<y>\d+\s*)y)?    # year
665             \s*((?P<m>\d+\s*)m)?    # month
666             \s*((?P<w>\d+\s*)w)?    # week
667             \s*((?P<d>\d+\s*)d)?    # day
668             \s*(((?P<H>\d+):(?P<M>\d+))?(:(?P<S>\d+))?)?   # time
669             \s*(?P<D>
670                  (\d\d\d\d[/-])?(\d\d?)?[/-](\d\d?)?       # [yyyy-]mm-dd
671                  \.?                                       # .
672                  (\d?\d:\d\d)?(:\d\d)?                     # hh:mm:ss
673                )?''', re.VERBOSE), serialised_re=re.compile('''
674             (?P<s>[+-])?1?(?P<y>([ ]{3}\d|\d{4}))(?P<m>\d{2})(?P<d>\d{2})
675             (?P<H>\d{2})(?P<M>\d{2})(?P<S>\d{2})''', re.VERBOSE),
676             add_granularity=False):
677         ''' set the date to the value in spec
678         '''
679         self.year = self.month = self.week = self.day = self.hour = \
680             self.minute = self.second = 0
681         self.sign = 1
682         m = serialised_re.match(spec)
683         if not m:
684             m = interval_re.match(spec)
685             if not m:
686                 raise ValueError, self._('Not an interval spec:'
687                     ' [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]')
688         else:
689             allowdate = 0
691         # pull out all the info specified
692         info = m.groupdict()
693         if add_granularity:
694             for gran in 'SMHdwmy':
695                 if info[gran] is not None:
696                     info[gran] = int(info[gran]) + (info['s']=='-' and -1 or 1)
697                     break
699         valid = 0
700         for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day',
701                 'H':'hour', 'M':'minute', 'S':'second'}.items():
702             if info.get(group, None) is not None:
703                 valid = 1
704                 setattr(self, attr, int(info[group]))
706         # make sure it's valid
707         if not valid and not info['D']:
708             raise ValueError, self._('Not an interval spec:'
709                 ' [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]')
711         if self.week:
712             self.day = self.day + self.week*7
714         if info['s'] is not None:
715             self.sign = {'+':1, '-':-1}[info['s']]
717         # use a date spec if one is given
718         if allowdate and info['D'] is not None:
719             now = Date('.')
720             date = Date(info['D'])
721             # if no time part was specified, nuke it in the "now" date
722             if not date.hour or date.minute or date.second:
723                 now.hour = now.minute = now.second = 0
724             if date != now:
725                 y = now - (date + self)
726                 self.__init__(y.get_tuple())
728     def __cmp__(self, other):
729         """Compare this interval to another interval."""
731         if other is None:
732             # we are always larger than None
733             return 1
734         return cmp(self.as_seconds(), other.as_seconds())
736     def __str__(self):
737         """Return this interval as a string."""
738         l = []
739         if self.year: l.append('%sy'%self.year)
740         if self.month: l.append('%sm'%self.month)
741         if self.day: l.append('%sd'%self.day)
742         if self.second:
743             l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second))
744         elif self.hour or self.minute:
745             l.append('%d:%02d'%(self.hour, self.minute))
746         if l:
747             l.insert(0, {1:'+', -1:'-'}[self.sign])
748         else:
749             l.append('00:00')
750         return ' '.join(l)
752     def __add__(self, other):
753         if isinstance(other, Date):
754             # the other is a Date - produce a Date
755             return Date(other.addInterval(self), translator=self.translator)
756         elif isinstance(other, Interval):
757             # add the other Interval to this one
758             a = self.get_tuple()
759             asgn = a[0]
760             b = other.get_tuple()
761             bsgn = b[0]
762             i = [asgn*x + bsgn*y for x,y in zip(a[1:],b[1:])]
763             i.insert(0, 1)
764             i = fixTimeOverflow(i)
765             return Interval(i, translator=self.translator)
766         # nope, no idea what to do with this other...
767         raise TypeError, "Can't add %r"%other
769     def __sub__(self, other):
770         if isinstance(other, Date):
771             # the other is a Date - produce a Date
772             interval = Interval(self.get_tuple())
773             interval.sign *= -1
774             return Date(other.addInterval(interval),
775                 translator=self.translator)
776         elif isinstance(other, Interval):
777             # add the other Interval to this one
778             a = self.get_tuple()
779             asgn = a[0]
780             b = other.get_tuple()
781             bsgn = b[0]
782             i = [asgn*x - bsgn*y for x,y in zip(a[1:],b[1:])]
783             i.insert(0, 1)
784             i = fixTimeOverflow(i)
785             return Interval(i, translator=self.translator)
786         # nope, no idea what to do with this other...
787         raise TypeError, "Can't add %r"%other
789     def __div__(self, other):
790         """ Divide this interval by an int value.
792             Can't divide years and months sensibly in the _same_
793             calculation as days/time, so raise an error in that situation.
794         """
795         try:
796             other = float(other)
797         except TypeError:
798             raise ValueError, "Can only divide Intervals by numbers"
800         y, m, d, H, M, S = (self.year, self.month, self.day,
801             self.hour, self.minute, self.second)
802         if y or m:
803             if d or H or M or S:
804                 raise ValueError, "Can't divide Interval with date and time"
805             months = self.year*12 + self.month
806             months *= self.sign
808             months = int(months/other)
810             sign = months<0 and -1 or 1
811             m = months%12
812             y = months / 12
813             return Interval((sign, y, m, 0, 0, 0, 0),
814                 translator=self.translator)
816         else:
817             # handle a day/time division
818             seconds = S + M*60 + H*60*60 + d*60*60*24
819             seconds *= self.sign
821             seconds = int(seconds/other)
823             sign = seconds<0 and -1 or 1
824             seconds *= sign
825             S = seconds%60
826             seconds /= 60
827             M = seconds%60
828             seconds /= 60
829             H = seconds%24
830             d = seconds / 24
831             return Interval((sign, 0, 0, d, H, M, S),
832                 translator=self.translator)
834     def __repr__(self):
835         return '<Interval %s>'%self.__str__()
837     def pretty(self):
838         ''' print up the date date using one of these nice formats..
839         '''
840         _quarters = self.minute / 15
841         if self.year:
842             s = self.ngettext("%(number)s year", "%(number)s years",
843                 self.year) % {'number': self.year}
844         elif self.month or self.day > 28:
845             _months = max(1, int(((self.month * 30) + self.day) / 30))
846             s = self.ngettext("%(number)s month", "%(number)s months",
847                 _months) % {'number': _months}
848         elif self.day > 7:
849             _weeks = int(self.day / 7)
850             s = self.ngettext("%(number)s week", "%(number)s weeks",
851                 _weeks) % {'number': _weeks}
852         elif self.day > 1:
853             # Note: singular form is not used
854             s = self.ngettext('%(number)s day', '%(number)s days',
855                 self.day) % {'number': self.day}
856         elif self.day == 1 or self.hour > 12:
857             if self.sign > 0:
858                 return self._('tomorrow')
859             else:
860                 return self._('yesterday')
861         elif self.hour > 1:
862             # Note: singular form is not used
863             s = self.ngettext('%(number)s hour', '%(number)s hours',
864                 self.hour) % {'number': self.hour}
865         elif self.hour == 1:
866             if self.minute < 15:
867                 s = self._('an hour')
868             elif _quarters == 2:
869                 s = self._('1 1/2 hours')
870             else:
871                 s = self.ngettext('1 %(number)s/4 hours',
872                     '1 %(number)s/4 hours', _quarters)%{'number': _quarters}
873         elif self.minute < 1:
874             if self.sign > 0:
875                 return self._('in a moment')
876             else:
877                 return self._('just now')
878         elif self.minute == 1:
879             # Note: used in expressions "in 1 minute" or "1 minute ago"
880             s = self._('1 minute')
881         elif self.minute < 15:
882             # Note: used in expressions "in 2 minutes" or "2 minutes ago"
883             s = self.ngettext('%(number)s minute', '%(number)s minutes',
884                 self.minute) % {'number': self.minute}
885         elif _quarters == 2:
886             s = self._('1/2 an hour')
887         else:
888             s = self.ngettext('%(number)s/4 hour', '%(number)s/4 hours',
889                 _quarters) % {'number': _quarters}
890         # XXX this is internationally broken
891         if self.sign < 0:
892             s = self._('%s ago') % s
893         else:
894             s = self._('in %s') % s
895         return s
897     def get_tuple(self):
898         return (self.sign, self.year, self.month, self.day, self.hour,
899             self.minute, self.second)
901     def serialise(self):
902         sign = self.sign > 0 and '+' or '-'
903         return '%s%04d%02d%02d%02d%02d%02d'%(sign, self.year, self.month,
904             self.day, self.hour, self.minute, self.second)
906     def as_seconds(self):
907         '''Calculate the Interval as a number of seconds.
909         Months are counted as 30 days, years as 365 days. Returns a Long
910         int.
911         '''
912         n = self.year * 365L
913         n = n + self.month * 30
914         n = n + self.day
915         n = n * 24
916         n = n + self.hour
917         n = n * 60
918         n = n + self.minute
919         n = n * 60
920         n = n + self.second
921         return n * self.sign
923     def from_seconds(self, val):
924         '''Figure my second, minute, hour and day values using a seconds
925         value.
926         '''
927         val = int(val)
928         if val < 0:
929             self.sign = -1
930             val = -val
931         else:
932             self.sign = 1
933         self.second = val % 60
934         val = val / 60
935         self.minute = val % 60
936         val = val / 60
937         self.hour = val % 24
938         val = val / 24
939         self.day = val
940         self.month = self.year = 0
942     def setTranslator(self, translator):
943         """Replace the translation engine
945         'translator'
946            is i18n module or one of gettext translation classes.
947            It must have attributes 'gettext' and 'ngettext',
948            serving as translation functions.
949         """
950         self.translator = translator
951         self._ = translator.gettext
952         self.ngettext = translator.ngettext
955 def fixTimeOverflow(time):
956     """ Handle the overflow in the time portion (H, M, S) of "time":
957             (sign, y,m,d,H,M,S)
959         Overflow and underflow will at most affect the _days_ portion of
960         the date. We do not overflow days to months as we don't know _how_
961         to, generally.
962     """
963     # XXX we could conceivably use this function for handling regular dates
964     # XXX too - we just need to interrogate the month/year for the day
965     # XXX overflow...
967     sign, y, m, d, H, M, S = time
968     seconds = sign * (S + M*60 + H*60*60 + d*60*60*24)
969     if seconds:
970         sign = seconds<0 and -1 or 1
971         seconds *= sign
972         S = seconds%60
973         seconds /= 60
974         M = seconds%60
975         seconds /= 60
976         H = seconds%24
977         d = seconds / 24
978     else:
979         months = y*12 + m
980         sign = months<0 and -1 or 1
981         months *= sign
982         m = months%12
983         y = months/12
985     return (sign, y, m, d, H, M, S)
987 class Range:
988     """Represents range between two values
989     Ranges can be created using one of theese two alternative syntaxes:
991     1. Native english syntax::
993             [[From] <value>][ To <value>]
995        Keywords "From" and "To" are case insensitive. Keyword "From" is
996        optional.
998     2. "Geek" syntax::
1000           [<value>][; <value>]
1002     Either first or second <value> can be omitted in both syntaxes.
1004     Examples (consider local time is Sat Mar  8 22:07:48 EET 2003)::
1006         >>> Range("from 2-12 to 4-2")
1007         <Range from 2003-02-12.00:00:00 to 2003-04-02.00:00:00>
1009         >>> Range("18:00 TO +2m")
1010         <Range from 2003-03-08.18:00:00 to 2003-05-08.20:07:48>
1012         >>> Range("12:00")
1013         <Range from 2003-03-08.12:00:00 to None>
1015         >>> Range("tO +3d")
1016         <Range from None to 2003-03-11.20:07:48>
1018         >>> Range("2002-11-10; 2002-12-12")
1019         <Range from 2002-11-10.00:00:00 to 2002-12-12.00:00:00>
1021         >>> Range("; 20:00 +1d")
1022         <Range from None to 2003-03-09.20:00:00>
1024     """
1025     def __init__(self, spec, Type, allow_granularity=True, **params):
1026         """Initializes Range of type <Type> from given <spec> string.
1028         Sets two properties - from_value and to_value. None assigned to any of
1029         this properties means "infinitum" (-infinitum to from_value and
1030         +infinitum to to_value)
1032         The Type parameter here should be class itself (e.g. Date), not a
1033         class instance.
1034         """
1035         self.range_type = Type
1036         re_range = r'(?:^|from(.+?))(?:to(.+?)$|$)'
1037         re_geek_range = r'(?:^|(.+?));(?:(.+?)$|$)'
1038         # Check which syntax to use
1039         if ';' in spec:
1040             # Geek
1041             m = re.search(re_geek_range, spec.strip())
1042         else:
1043             # Native english
1044             m = re.search(re_range, spec.strip(), re.IGNORECASE)
1045         if m:
1046             self.from_value, self.to_value = m.groups()
1047             if self.from_value:
1048                 self.from_value = Type(self.from_value.strip(), **params)
1049             if self.to_value:
1050                 self.to_value = Type(self.to_value.strip(), **params)
1051         else:
1052             if allow_granularity:
1053                 self.from_value = Type(spec, **params)
1054                 self.to_value = Type(spec, add_granularity=True, **params)
1055             else:
1056                 raise ValueError, "Invalid range"
1058     def __str__(self):
1059         return "from %s to %s" % (self.from_value, self.to_value)
1061     def __repr__(self):
1062         return "<Range %s>" % self.__str__()
1064 def test_range():
1065     rspecs = ("from 2-12 to 4-2", "from 18:00 TO +2m", "12:00;", "tO +3d",
1066         "2002-11-10; 2002-12-12", "; 20:00 +1d", '2002-10-12')
1067     rispecs = ('from -1w 2d 4:32 to 4d', '-2w 1d')
1068     for rspec in rspecs:
1069         print '>>> Range("%s")' % rspec
1070         print `Range(rspec, Date)`
1071         print
1072     for rspec in rispecs:
1073         print '>>> Range("%s")' % rspec
1074         print `Range(rspec, Interval)`
1075         print
1077 def test():
1078     intervals = ("  3w  1  d  2:00", " + 2d", "3w")
1079     for interval in intervals:
1080         print '>>> Interval("%s")'%interval
1081         print `Interval(interval)`
1083     dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25",
1084         "08-13.22:13", "14:25", '2002-12')
1085     for date in dates:
1086         print '>>> Date("%s")'%date
1087         print `Date(date)`
1089     sums = ((". + 2d", "3w"), (".", "  3w  1  d  2:00"))
1090     for date, interval in sums:
1091         print '>>> Date("%s") + Interval("%s")'%(date, interval)
1092         print `Date(date) + Interval(interval)`
1094 if __name__ == '__main__':
1095     test()
1097 # vim: set filetype=python sts=4 sw=4 et si :