Code

svn repository setup
[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         if type(spec) == type(''):
253             self.set(spec, offset=offset, add_granularity=add_granularity)
254             return
255         elif isinstance(spec, datetime.datetime):
256             # Python 2.3+ datetime object
257             y,m,d,H,M,S,x,x,x = spec.timetuple()
258             S += spec.microsecond/1000000.
259             spec = (y,m,d,H,M,S,x,x,x)
260         elif hasattr(spec, 'tuple'):
261             spec = spec.tuple()
262         elif isinstance(spec, Date):
263             spec = spec.get_tuple()
264         try:
265             y,m,d,H,M,S,x,x,x = spec
266             frac = S - int(S)
267             self.year, self.month, self.day, self.hour, self.minute, \
268                 self.second = _local_to_utc(y, m, d, H, M, S, offset)
269             # we lost the fractional part
270             self.second = self.second + frac
271             if str(self.second) == '60.0': self.second = 59.9
272         except:
273             raise ValueError, 'Unknown spec %r' % (spec,)
275     def set(self, spec, offset=0, date_re=date_re,
276             serialised_re=serialised_date_re, add_granularity=False):
277         ''' set the date to the value in spec
278         '''
280         m = serialised_re.match(spec)
281         if m is not None:
282             # we're serialised - easy!
283             g = m.groups()
284             (self.year, self.month, self.day, self.hour, self.minute) = \
285                 map(int, g[:5])
286             self.second = float(g[5])
287             return
289         # not serialised data, try usual format
290         m = date_re.match(spec)
291         if m is None:
292             raise ValueError, self._('Not a date spec: '
293                 '"yyyy-mm-dd", "mm-dd", "HH:MM", "HH:MM:SS" or '
294                 '"yyyy-mm-dd.HH:MM:SS.SSS"')
296         info = m.groupdict()
298         # determine whether we need to add anything at the end
299         if add_granularity:
300             for gran in 'SMHdmy':
301                 if info[gran] is not None:
302                     if gran == 'S':
303                         raise ValueError
304                     elif gran == 'M':
305                         add_granularity = Interval('00:01')
306                     elif gran == 'H':
307                         add_granularity = Interval('01:00')
308                     else:
309                         add_granularity = Interval('+1%s'%gran)
310                     break
312         # get the current date as our default
313         dt = datetime.datetime.utcnow()
314         y,m,d,H,M,S,x,x,x = dt.timetuple()
315         S += dt.microsecond/1000000.
317         # whether we need to convert to UTC
318         adjust = False
320         if info['y'] is not None or info['a'] is not None:
321             if info['y'] is not None:
322                 y = int(info['y'])
323                 m,d = (1,1)
324                 if info['m'] is not None:
325                     m = int(info['m'])
326                     if info['d'] is not None:
327                         d = int(info['d'])
328             if info['a'] is not None:
329                 m = int(info['a'])
330                 d = int(info['b'])
331             H = 0
332             M = S = 0
333             adjust = True
335         # override hour, minute, second parts
336         if info['H'] is not None and info['M'] is not None:
337             H = int(info['H'])
338             M = int(info['M'])
339             S = 0
340             if info['S'] is not None:
341                 S = float(info['S'])
342             adjust = True
345         # now handle the adjustment of hour
346         frac = S - int(S)
347         dt = datetime.datetime(y,m,d,H,M,int(S), int(frac * 1000000.))
348         y, m, d, H, M, S, x, x, x = dt.timetuple()
349         if adjust:
350             y, m, d, H, M, S = _local_to_utc(y, m, d, H, M, S, offset)
351         self.year, self.month, self.day, self.hour, self.minute, \
352             self.second = y, m, d, H, M, S
353         # we lost the fractional part along the way
354         self.second += dt.microsecond/1000000.
356         if info.get('o', None):
357             try:
358                 self.applyInterval(Interval(info['o'], allowdate=0))
359             except ValueError:
360                 raise ValueError, self._('%r not a date / time spec '
361                     '"yyyy-mm-dd", "mm-dd", "HH:MM", "HH:MM:SS" or '
362                     '"yyyy-mm-dd.HH:MM:SS.SSS"')%(spec,)
364         # adjust by added granularity
365         if add_granularity:
366             self.applyInterval(add_granularity)
367             self.applyInterval(Interval('- 00:00:01'))
369     def addInterval(self, interval):
370         ''' Add the interval to this date, returning the date tuple
371         '''
372         # do the basic calc
373         sign = interval.sign
374         year = self.year + sign * interval.year
375         month = self.month + sign * interval.month
376         day = self.day + sign * interval.day
377         hour = self.hour + sign * interval.hour
378         minute = self.minute + sign * interval.minute
379         # Intervals work on whole seconds
380         second = int(self.second) + sign * interval.second
382         # now cope with under- and over-flow
383         # first do the time
384         while (second < 0 or second > 59 or minute < 0 or minute > 59 or
385                 hour < 0 or hour > 23):
386             if second < 0: minute -= 1; second += 60
387             elif second > 59: minute += 1; second -= 60
388             if minute < 0: hour -= 1; minute += 60
389             elif minute > 59: hour += 1; minute -= 60
390             if hour < 0: day -= 1; hour += 24
391             elif hour > 23: day += 1; hour -= 24
393         # fix up the month so we're within range
394         while month < 1 or month > 12:
395             if month < 1: year -= 1; month += 12
396             if month > 12: year += 1; month -= 12
398         # now do the days, now that we know what month we're in
399         def get_mdays(year, month):
400             if month == 2 and calendar.isleap(year): return 29
401             else: return calendar.mdays[month]
403         while month < 1 or month > 12 or day < 1 or day > get_mdays(year,month):
404             # now to day under/over
405             if day < 1:
406                 # When going backwards, decrement month, then increment days
407                 month -= 1
408                 day += get_mdays(year,month)
409             elif day > get_mdays(year,month):
410                 # When going forwards, decrement days, then increment month
411                 day -= get_mdays(year,month)
412                 month += 1
414             # possibly fix up the month so we're within range
415             while month < 1 or month > 12:
416                 if month < 1: year -= 1; month += 12 ; day += 31
417                 if month > 12: year += 1; month -= 12
419         return (year, month, day, hour, minute, second, 0, 0, 0)
421     def differenceDate(self, other):
422         "Return the difference between this date and another date"
423         return self - other
425     def applyInterval(self, interval):
426         ''' Apply the interval to this date
427         '''
428         self.year, self.month, self.day, self.hour, self.minute, \
429             self.second, x, x, x = self.addInterval(interval)
431     def __add__(self, interval):
432         """Add an interval to this date to produce another date.
433         """
434         return Date(self.addInterval(interval), translator=self.translator)
436     # deviates from spec to allow subtraction of dates as well
437     def __sub__(self, other):
438         """ Subtract:
439              1. an interval from this date to produce another date.
440              2. a date from this date to produce an interval.
441         """
442         if isinstance(other, Interval):
443             other = Interval(other.get_tuple())
444             other.sign *= -1
445             return self.__add__(other)
447         assert isinstance(other, Date), 'May only subtract Dates or Intervals'
449         return self.dateDelta(other)
451     def dateDelta(self, other):
452         """ Produce an Interval of the difference between this date
453             and another date. Only returns days:hours:minutes:seconds.
454         """
455         # Returning intervals larger than a day is almost
456         # impossible - months, years, weeks, are all so imprecise.
457         a = calendar.timegm((self.year, self.month, self.day, self.hour,
458             self.minute, self.second, 0, 0, 0))
459         b = calendar.timegm((other.year, other.month, other.day,
460             other.hour, other.minute, other.second, 0, 0, 0))
461         # intervals work in whole seconds
462         diff = int(a - b)
463         if diff > 0:
464             sign = 1
465         else:
466             sign = -1
467             diff = -diff
468         S = diff%60
469         M = (diff/60)%60
470         H = (diff/(60*60))%24
471         d = diff/(24*60*60)
472         return Interval((0, 0, d, H, M, S), sign=sign,
473             translator=self.translator)
475     def __cmp__(self, other, int_seconds=0):
476         """Compare this date to another date."""
477         if other is None:
478             return 1
479         for attr in ('year', 'month', 'day', 'hour', 'minute'):
480             if not hasattr(other, attr):
481                 return 1
482             r = cmp(getattr(self, attr), getattr(other, attr))
483             if r: return r
484         if not hasattr(other, 'second'):
485             return 1
486         if int_seconds:
487             return cmp(int(self.second), int(other.second))
488         return cmp(self.second, other.second)
490     def __str__(self):
491         """Return this date as a string in the yyyy-mm-dd.hh:mm:ss format."""
492         return self.formal()
494     def formal(self, sep='.', sec='%02d'):
495         f = '%%04d-%%02d-%%02d%s%%02d:%%02d:%s'%(sep, sec)
496         return f%(self.year, self.month, self.day, self.hour, self.minute,
497             self.second)
499     def pretty(self, format='%d %B %Y'):
500         ''' print up the date date using a pretty format...
502             Note that if the day is zero, and the day appears first in the
503             format, then the day number will be removed from output.
504         '''
505         dt = datetime.datetime(self.year, self.month, self.day, self.hour,
506             self.minute, int(self.second),
507             int ((self.second - int (self.second)) * 1000000.))
508         str = dt.strftime(format)
510         # handle zero day by removing it
511         if format.startswith('%d') and str[0] == '0':
512             return ' ' + str[1:]
513         return str
515     def __repr__(self):
516         return '<Date %s>'%self.formal(sec='%06.3f')
518     def local(self, offset):
519         """ Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone.
520         """
521         y, m, d, H, M, S = _utc_to_local(self.year, self.month, self.day,
522                 self.hour, self.minute, self.second, offset)
523         return Date((y, m, d, H, M, S, 0, 0, 0), translator=self.translator)
525     def __deepcopy__(self, memo):
526         return Date((self.year, self.month, self.day, self.hour,
527             self.minute, self.second, 0, 0, 0), translator=self.translator)
529     def get_tuple(self):
530         return (self.year, self.month, self.day, self.hour, self.minute,
531             self.second, 0, 0, 0)
533     def serialise(self):
534         return '%04d%02d%02d%02d%02d%06.3f'%(self.year, self.month,
535             self.day, self.hour, self.minute, self.second)
537     def timestamp(self):
538         ''' return a UNIX timestamp for this date '''
539         frac = self.second - int(self.second)
540         ts = calendar.timegm((self.year, self.month, self.day, self.hour,
541             self.minute, self.second, 0, 0, 0))
542         # we lose the fractional part
543         return ts + frac
545     def setTranslator(self, translator):
546         """Replace the translation engine
548         'translator'
549            is i18n module or one of gettext translation classes.
550            It must have attributes 'gettext' and 'ngettext',
551            serving as translation functions.
552         """
553         self.translator = translator
554         self._ = translator.gettext
555         self.ngettext = translator.ngettext
557     def fromtimestamp(cls, ts):
558         """Create a date object from a timestamp.
560         The timestamp may be outside the gmtime year-range of
561         1902-2038.
562         """
563         usec = int((ts - int(ts)) * 1000000.)
564         delta = datetime.timedelta(seconds = int(ts), microseconds = usec)
565         return cls(datetime.datetime(1970, 1, 1) + delta)
566     fromtimestamp = classmethod(fromtimestamp)
568 class Interval:
569     '''
570     Date intervals are specified using the suffixes "y", "m", and "d". The
571     suffix "w" (for "week") means 7 days. Time intervals are specified in
572     hh:mm:ss format (the seconds may be omitted, but the hours and minutes
573     may not).
575       "3y" means three years
576       "2y 1m" means two years and one month
577       "1m 25d" means one month and 25 days
578       "2w 3d" means two weeks and three days
579       "1d 2:50" means one day, two hours, and 50 minutes
580       "14:00" means 14 hours
581       "0:04:33" means four minutes and 33 seconds
583     Example usage:
584         >>> Interval("  3w  1  d  2:00")
585         <Interval + 22d 2:00>
586         >>> Date(". + 2d") + Interval("- 3w")
587         <Date 2000-06-07.00:34:02>
588         >>> Interval('1:59:59') + Interval('00:00:01')
589         <Interval + 2:00>
590         >>> Interval('2:00') + Interval('- 00:00:01')
591         <Interval + 1:59:59>
592         >>> Interval('1y')/2
593         <Interval + 6m>
594         >>> Interval('1:00')/2
595         <Interval + 0:30>
596         >>> Interval('2003-03-18')
597         <Interval + [number of days between now and 2003-03-18]>
598         >>> Interval('-4d 2003-03-18')
599         <Interval + [number of days between now and 2003-03-14]>
601     Interval arithmetic is handled in a couple of special ways, trying
602     to cater for the most common cases. Fundamentally, Intervals which
603     have both date and time parts will result in strange results in
604     arithmetic - because of the impossibility of handling day->month->year
605     over- and under-flows. Intervals may also be divided by some number.
607     Intervals are added to Dates in order of:
608        seconds, minutes, hours, years, months, days
610     Calculations involving months (eg '+2m') have no effect on days - only
611     days (or over/underflow from hours/mins/secs) will do that, and
612     days-per-month and leap years are accounted for. Leap seconds are not.
614     The interval format 'syyyymmddHHMMSS' (sign, year, month, day, hour,
615     minute, second) is the serialisation format returned by the serialise()
616     method, and is accepted as an argument on instatiation.
618     TODO: more examples, showing the order of addition operation
619     '''
620     def __init__(self, spec, sign=1, allowdate=1, add_granularity=False,
621         translator=i18n
622     ):
623         """Construct an interval given a specification."""
624         self.setTranslator(translator)
625         if isinstance(spec, (int, float, long)):
626             self.from_seconds(spec)
627         elif isinstance(spec, basestring):
628             self.set(spec, allowdate=allowdate, add_granularity=add_granularity)
629         elif isinstance(spec, Interval):
630             (self.sign, self.year, self.month, self.day, self.hour,
631                 self.minute, self.second) = spec.get_tuple()
632         else:
633             if len(spec) == 7:
634                 self.sign, self.year, self.month, self.day, self.hour, \
635                     self.minute, self.second = spec
636                 self.second = int(self.second)
637             else:
638                 # old, buggy spec form
639                 self.sign = sign
640                 self.year, self.month, self.day, self.hour, self.minute, \
641                     self.second = spec
642                 self.second = int(self.second)
644     def __deepcopy__(self, memo):
645         return Interval((self.sign, self.year, self.month, self.day,
646             self.hour, self.minute, self.second), translator=self.translator)
648     def set(self, spec, allowdate=1, interval_re=re.compile('''
649             \s*(?P<s>[-+])?         # + or -
650             \s*((?P<y>\d+\s*)y)?    # year
651             \s*((?P<m>\d+\s*)m)?    # month
652             \s*((?P<w>\d+\s*)w)?    # week
653             \s*((?P<d>\d+\s*)d)?    # day
654             \s*(((?P<H>\d+):(?P<M>\d+))?(:(?P<S>\d+))?)?   # time
655             \s*(?P<D>
656                  (\d\d\d\d[/-])?(\d\d?)?[/-](\d\d?)?       # [yyyy-]mm-dd
657                  \.?                                       # .
658                  (\d?\d:\d\d)?(:\d\d)?                     # hh:mm:ss
659                )?''', re.VERBOSE), serialised_re=re.compile('''
660             (?P<s>[+-])?1?(?P<y>([ ]{3}\d|\d{4}))(?P<m>\d{2})(?P<d>\d{2})
661             (?P<H>\d{2})(?P<M>\d{2})(?P<S>\d{2})''', re.VERBOSE),
662             add_granularity=False):
663         ''' set the date to the value in spec
664         '''
665         self.year = self.month = self.week = self.day = self.hour = \
666             self.minute = self.second = 0
667         self.sign = 1
668         m = serialised_re.match(spec)
669         if not m:
670             m = interval_re.match(spec)
671             if not m:
672                 raise ValueError, self._('Not an interval spec:'
673                     ' [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]')
674         else:
675             allowdate = 0
677         # pull out all the info specified
678         info = m.groupdict()
679         if add_granularity:
680             for gran in 'SMHdwmy':
681                 if info[gran] is not None:
682                     info[gran] = int(info[gran]) + (info['s']=='-' and -1 or 1)
683                     break
685         valid = 0
686         for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day',
687                 'H':'hour', 'M':'minute', 'S':'second'}.items():
688             if info.get(group, None) is not None:
689                 valid = 1
690                 setattr(self, attr, int(info[group]))
692         # make sure it's valid
693         if not valid and not info['D']:
694             raise ValueError, self._('Not an interval spec:'
695                 ' [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]')
697         if self.week:
698             self.day = self.day + self.week*7
700         if info['s'] is not None:
701             self.sign = {'+':1, '-':-1}[info['s']]
703         # use a date spec if one is given
704         if allowdate and info['D'] is not None:
705             now = Date('.')
706             date = Date(info['D'])
707             # if no time part was specified, nuke it in the "now" date
708             if not date.hour or date.minute or date.second:
709                 now.hour = now.minute = now.second = 0
710             if date != now:
711                 y = now - (date + self)
712                 self.__init__(y.get_tuple())
714     def __cmp__(self, other):
715         """Compare this interval to another interval."""
716         if other is None:
717             # we are always larger than None
718             return 1
719         for attr in 'sign year month day hour minute second'.split():
720             r = cmp(getattr(self, attr), getattr(other, attr))
721             if r:
722                 return r
723         return 0
725     def __str__(self):
726         """Return this interval as a string."""
727         l = []
728         if self.year: l.append('%sy'%self.year)
729         if self.month: l.append('%sm'%self.month)
730         if self.day: l.append('%sd'%self.day)
731         if self.second:
732             l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second))
733         elif self.hour or self.minute:
734             l.append('%d:%02d'%(self.hour, self.minute))
735         if l:
736             l.insert(0, {1:'+', -1:'-'}[self.sign])
737         else:
738             l.append('00:00')
739         return ' '.join(l)
741     def __add__(self, other):
742         if isinstance(other, Date):
743             # the other is a Date - produce a Date
744             return Date(other.addInterval(self), translator=self.translator)
745         elif isinstance(other, Interval):
746             # add the other Interval to this one
747             a = self.get_tuple()
748             asgn = a[0]
749             b = other.get_tuple()
750             bsgn = b[0]
751             i = [asgn*x + bsgn*y for x,y in zip(a[1:],b[1:])]
752             i.insert(0, 1)
753             i = fixTimeOverflow(i)
754             return Interval(i, translator=self.translator)
755         # nope, no idea what to do with this other...
756         raise TypeError, "Can't add %r"%other
758     def __sub__(self, other):
759         if isinstance(other, Date):
760             # the other is a Date - produce a Date
761             interval = Interval(self.get_tuple())
762             interval.sign *= -1
763             return Date(other.addInterval(interval),
764                 translator=self.translator)
765         elif isinstance(other, Interval):
766             # add the other Interval to this one
767             a = self.get_tuple()
768             asgn = a[0]
769             b = other.get_tuple()
770             bsgn = b[0]
771             i = [asgn*x - bsgn*y for x,y in zip(a[1:],b[1:])]
772             i.insert(0, 1)
773             i = fixTimeOverflow(i)
774             return Interval(i, translator=self.translator)
775         # nope, no idea what to do with this other...
776         raise TypeError, "Can't add %r"%other
778     def __div__(self, other):
779         """ Divide this interval by an int value.
781             Can't divide years and months sensibly in the _same_
782             calculation as days/time, so raise an error in that situation.
783         """
784         try:
785             other = float(other)
786         except TypeError:
787             raise ValueError, "Can only divide Intervals by numbers"
789         y, m, d, H, M, S = (self.year, self.month, self.day,
790             self.hour, self.minute, self.second)
791         if y or m:
792             if d or H or M or S:
793                 raise ValueError, "Can't divide Interval with date and time"
794             months = self.year*12 + self.month
795             months *= self.sign
797             months = int(months/other)
799             sign = months<0 and -1 or 1
800             m = months%12
801             y = months / 12
802             return Interval((sign, y, m, 0, 0, 0, 0),
803                 translator=self.translator)
805         else:
806             # handle a day/time division
807             seconds = S + M*60 + H*60*60 + d*60*60*24
808             seconds *= self.sign
810             seconds = int(seconds/other)
812             sign = seconds<0 and -1 or 1
813             seconds *= sign
814             S = seconds%60
815             seconds /= 60
816             M = seconds%60
817             seconds /= 60
818             H = seconds%24
819             d = seconds / 24
820             return Interval((sign, 0, 0, d, H, M, S),
821                 translator=self.translator)
823     def __repr__(self):
824         return '<Interval %s>'%self.__str__()
826     def pretty(self):
827         ''' print up the date date using one of these nice formats..
828         '''
829         _quarters = self.minute / 15
830         if self.year:
831             s = self.ngettext("%(number)s year", "%(number)s years",
832                 self.year) % {'number': self.year}
833         elif self.month or self.day > 28:
834             _months = max(1, int(((self.month * 30) + self.day) / 30))
835             s = self.ngettext("%(number)s month", "%(number)s months",
836                 _months) % {'number': _months}
837         elif self.day > 7:
838             _weeks = int(self.day / 7)
839             s = self.ngettext("%(number)s week", "%(number)s weeks",
840                 _weeks) % {'number': _weeks}
841         elif self.day > 1:
842             # Note: singular form is not used
843             s = self.ngettext('%(number)s day', '%(number)s days',
844                 self.day) % {'number': self.day}
845         elif self.day == 1 or self.hour > 12:
846             if self.sign > 0:
847                 return self._('tomorrow')
848             else:
849                 return self._('yesterday')
850         elif self.hour > 1:
851             # Note: singular form is not used
852             s = self.ngettext('%(number)s hour', '%(number)s hours',
853                 self.hour) % {'number': self.hour}
854         elif self.hour == 1:
855             if self.minute < 15:
856                 s = self._('an hour')
857             elif _quarters == 2:
858                 s = self._('1 1/2 hours')
859             else:
860                 s = self.ngettext('1 %(number)s/4 hours',
861                     '1 %(number)s/4 hours', _quarters)%{'number': _quarters}
862         elif self.minute < 1:
863             if self.sign > 0:
864                 return self._('in a moment')
865             else:
866                 return self._('just now')
867         elif self.minute == 1:
868             # Note: used in expressions "in 1 minute" or "1 minute ago"
869             s = self._('1 minute')
870         elif self.minute < 15:
871             # Note: used in expressions "in 2 minutes" or "2 minutes ago"
872             s = self.ngettext('%(number)s minute', '%(number)s minutes',
873                 self.minute) % {'number': self.minute}
874         elif _quarters == 2:
875             s = self._('1/2 an hour')
876         else:
877             s = self.ngettext('%(number)s/4 hour', '%(number)s/4 hours',
878                 _quarters) % {'number': _quarters}
879         # XXX this is internationally broken
880         if self.sign < 0:
881             s = self._('%s ago') % s
882         else:
883             s = self._('in %s') % s
884         return s
886     def get_tuple(self):
887         return (self.sign, self.year, self.month, self.day, self.hour,
888             self.minute, self.second)
890     def serialise(self):
891         sign = self.sign > 0 and '+' or '-'
892         return '%s%04d%02d%02d%02d%02d%02d'%(sign, self.year, self.month,
893             self.day, self.hour, self.minute, self.second)
895     def as_seconds(self):
896         '''Calculate the Interval as a number of seconds.
898         Months are counted as 30 days, years as 365 days. Returns a Long
899         int.
900         '''
901         n = self.year * 365L
902         n = n + self.month * 30
903         n = n + self.day
904         n = n * 24
905         n = n + self.hour
906         n = n * 60
907         n = n + self.minute
908         n = n * 60
909         n = n + self.second
910         return n * self.sign
912     def from_seconds(self, val):
913         '''Figure my second, minute, hour and day values using a seconds
914         value.
915         '''
916         val = int(val)
917         if val < 0:
918             self.sign = -1
919             val = -val
920         else:
921             self.sign = 1
922         self.second = val % 60
923         val = val / 60
924         self.minute = val % 60
925         val = val / 60
926         self.hour = val % 24
927         val = val / 24
928         self.day = val
929         self.month = self.year = 0
931     def setTranslator(self, translator):
932         """Replace the translation engine
934         'translator'
935            is i18n module or one of gettext translation classes.
936            It must have attributes 'gettext' and 'ngettext',
937            serving as translation functions.
938         """
939         self.translator = translator
940         self._ = translator.gettext
941         self.ngettext = translator.ngettext
944 def fixTimeOverflow(time):
945     """ Handle the overflow in the time portion (H, M, S) of "time":
946             (sign, y,m,d,H,M,S)
948         Overflow and underflow will at most affect the _days_ portion of
949         the date. We do not overflow days to months as we don't know _how_
950         to, generally.
951     """
952     # XXX we could conceivably use this function for handling regular dates
953     # XXX too - we just need to interrogate the month/year for the day
954     # XXX overflow...
956     sign, y, m, d, H, M, S = time
957     seconds = sign * (S + M*60 + H*60*60 + d*60*60*24)
958     if seconds:
959         sign = seconds<0 and -1 or 1
960         seconds *= sign
961         S = seconds%60
962         seconds /= 60
963         M = seconds%60
964         seconds /= 60
965         H = seconds%24
966         d = seconds / 24
967     else:
968         months = y*12 + m
969         sign = months<0 and -1 or 1
970         months *= sign
971         m = months%12
972         y = months/12
974     return (sign, y, m, d, H, M, S)
976 class Range:
977     """Represents range between two values
978     Ranges can be created using one of theese two alternative syntaxes:
980     1. Native english syntax::
982             [[From] <value>][ To <value>]
984        Keywords "From" and "To" are case insensitive. Keyword "From" is
985        optional.
987     2. "Geek" syntax::
989           [<value>][; <value>]
991     Either first or second <value> can be omitted in both syntaxes.
993     Examples (consider local time is Sat Mar  8 22:07:48 EET 2003)::
995         >>> Range("from 2-12 to 4-2")
996         <Range from 2003-02-12.00:00:00 to 2003-04-02.00:00:00>
998         >>> Range("18:00 TO +2m")
999         <Range from 2003-03-08.18:00:00 to 2003-05-08.20:07:48>
1001         >>> Range("12:00")
1002         <Range from 2003-03-08.12:00:00 to None>
1004         >>> Range("tO +3d")
1005         <Range from None to 2003-03-11.20:07:48>
1007         >>> Range("2002-11-10; 2002-12-12")
1008         <Range from 2002-11-10.00:00:00 to 2002-12-12.00:00:00>
1010         >>> Range("; 20:00 +1d")
1011         <Range from None to 2003-03-09.20:00:00>
1013     """
1014     def __init__(self, spec, Type, allow_granularity=True, **params):
1015         """Initializes Range of type <Type> from given <spec> string.
1017         Sets two properties - from_value and to_value. None assigned to any of
1018         this properties means "infinitum" (-infinitum to from_value and
1019         +infinitum to to_value)
1021         The Type parameter here should be class itself (e.g. Date), not a
1022         class instance.
1023         """
1024         self.range_type = Type
1025         re_range = r'(?:^|from(.+?))(?:to(.+?)$|$)'
1026         re_geek_range = r'(?:^|(.+?));(?:(.+?)$|$)'
1027         # Check which syntax to use
1028         if ';' in spec:
1029             # Geek
1030             m = re.search(re_geek_range, spec.strip())
1031         else:
1032             # Native english
1033             m = re.search(re_range, spec.strip(), re.IGNORECASE)
1034         if m:
1035             self.from_value, self.to_value = m.groups()
1036             if self.from_value:
1037                 self.from_value = Type(self.from_value.strip(), **params)
1038             if self.to_value:
1039                 self.to_value = Type(self.to_value.strip(), **params)
1040         else:
1041             if allow_granularity:
1042                 self.from_value = Type(spec, **params)
1043                 self.to_value = Type(spec, add_granularity=True, **params)
1044             else:
1045                 raise ValueError, "Invalid range"
1047     def __str__(self):
1048         return "from %s to %s" % (self.from_value, self.to_value)
1050     def __repr__(self):
1051         return "<Range %s>" % self.__str__()
1053 def test_range():
1054     rspecs = ("from 2-12 to 4-2", "from 18:00 TO +2m", "12:00;", "tO +3d",
1055         "2002-11-10; 2002-12-12", "; 20:00 +1d", '2002-10-12')
1056     rispecs = ('from -1w 2d 4:32 to 4d', '-2w 1d')
1057     for rspec in rspecs:
1058         print '>>> Range("%s")' % rspec
1059         print `Range(rspec, Date)`
1060         print
1061     for rspec in rispecs:
1062         print '>>> Range("%s")' % rspec
1063         print `Range(rspec, Interval)`
1064         print
1066 def test():
1067     intervals = ("  3w  1  d  2:00", " + 2d", "3w")
1068     for interval in intervals:
1069         print '>>> Interval("%s")'%interval
1070         print `Interval(interval)`
1072     dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25",
1073         "08-13.22:13", "14:25", '2002-12')
1074     for date in dates:
1075         print '>>> Date("%s")'%date
1076         print `Date(date)`
1078     sums = ((". + 2d", "3w"), (".", "  3w  1  d  2:00"))
1079     for date, interval in sums:
1080         print '>>> Date("%s") + Interval("%s")'%(date, interval)
1081         print `Date(date) + Interval(interval)`
1083 if __name__ == '__main__':
1084     test()
1086 # vim: set filetype=python sts=4 sw=4 et si :