Code

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