Code

af8d1d533c155a4bf2c6ca055a640f342cc6c5ef
[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             The offset is a pytz tz offset if pytz is installed.
526         """
527         y, m, d, H, M, S = _utc_to_local(self.year, self.month, self.day,
528                 self.hour, self.minute, self.second, offset)
529         return Date((y, m, d, H, M, S, 0, 0, 0), translator=self.translator)
531     def __deepcopy__(self, memo):
532         return Date((self.year, self.month, self.day, self.hour,
533             self.minute, self.second, 0, 0, 0), translator=self.translator)
535     def get_tuple(self):
536         return (self.year, self.month, self.day, self.hour, self.minute,
537             self.second, 0, 0, 0)
539     def serialise(self):
540         return '%04d%02d%02d%02d%02d%06.3f'%(self.year, self.month,
541             self.day, self.hour, self.minute, self.second)
543     def timestamp(self):
544         ''' return a UNIX timestamp for this date '''
545         frac = self.second - int(self.second)
546         ts = calendar.timegm((self.year, self.month, self.day, self.hour,
547             self.minute, self.second, 0, 0, 0))
548         # we lose the fractional part
549         return ts + frac
551     def setTranslator(self, translator):
552         """Replace the translation engine
554         'translator'
555            is i18n module or one of gettext translation classes.
556            It must have attributes 'gettext' and 'ngettext',
557            serving as translation functions.
558         """
559         self.translator = translator
560         self._ = translator.gettext
561         self.ngettext = translator.ngettext
563     def fromtimestamp(cls, ts):
564         """Create a date object from a timestamp.
566         The timestamp may be outside the gmtime year-range of
567         1902-2038.
568         """
569         usec = int((ts - int(ts)) * 1000000.)
570         delta = datetime.timedelta(seconds = int(ts), microseconds = usec)
571         return cls(datetime.datetime(1970, 1, 1) + delta)
572     fromtimestamp = classmethod(fromtimestamp)
574 class Interval:
575     '''
576     Date intervals are specified using the suffixes "y", "m", and "d". The
577     suffix "w" (for "week") means 7 days. Time intervals are specified in
578     hh:mm:ss format (the seconds may be omitted, but the hours and minutes
579     may not).
581       "3y" means three years
582       "2y 1m" means two years and one month
583       "1m 25d" means one month and 25 days
584       "2w 3d" means two weeks and three days
585       "1d 2:50" means one day, two hours, and 50 minutes
586       "14:00" means 14 hours
587       "0:04:33" means four minutes and 33 seconds
589     Example usage:
590         >>> Interval("  3w  1  d  2:00")
591         <Interval + 22d 2:00>
592         >>> Date(". + 2d") + Interval("- 3w")
593         <Date 2000-06-07.00:34:02>
594         >>> Interval('1:59:59') + Interval('00:00:01')
595         <Interval + 2:00>
596         >>> Interval('2:00') + Interval('- 00:00:01')
597         <Interval + 1:59:59>
598         >>> Interval('1y')/2
599         <Interval + 6m>
600         >>> Interval('1:00')/2
601         <Interval + 0:30>
602         >>> Interval('2003-03-18')
603         <Interval + [number of days between now and 2003-03-18]>
604         >>> Interval('-4d 2003-03-18')
605         <Interval + [number of days between now and 2003-03-14]>
607     Interval arithmetic is handled in a couple of special ways, trying
608     to cater for the most common cases. Fundamentally, Intervals which
609     have both date and time parts will result in strange results in
610     arithmetic - because of the impossibility of handling day->month->year
611     over- and under-flows. Intervals may also be divided by some number.
613     Intervals are added to Dates in order of:
614        seconds, minutes, hours, years, months, days
616     Calculations involving months (eg '+2m') have no effect on days - only
617     days (or over/underflow from hours/mins/secs) will do that, and
618     days-per-month and leap years are accounted for. Leap seconds are not.
620     The interval format 'syyyymmddHHMMSS' (sign, year, month, day, hour,
621     minute, second) is the serialisation format returned by the serialise()
622     method, and is accepted as an argument on instatiation.
624     TODO: more examples, showing the order of addition operation
625     '''
626     def __init__(self, spec, sign=1, allowdate=1, add_granularity=False,
627         translator=i18n
628     ):
629         """Construct an interval given a specification."""
630         self.setTranslator(translator)
631         if isinstance(spec, (int, float, long)):
632             self.from_seconds(spec)
633         elif isinstance(spec, basestring):
634             self.set(spec, allowdate=allowdate, add_granularity=add_granularity)
635         elif isinstance(spec, Interval):
636             (self.sign, self.year, self.month, self.day, self.hour,
637                 self.minute, self.second) = spec.get_tuple()
638         else:
639             if len(spec) == 7:
640                 self.sign, self.year, self.month, self.day, self.hour, \
641                     self.minute, self.second = spec
642                 self.second = int(self.second)
643             else:
644                 # old, buggy spec form
645                 self.sign = sign
646                 self.year, self.month, self.day, self.hour, self.minute, \
647                     self.second = spec
648                 self.second = int(self.second)
650     def __deepcopy__(self, memo):
651         return Interval((self.sign, self.year, self.month, self.day,
652             self.hour, self.minute, self.second), translator=self.translator)
654     def set(self, spec, allowdate=1, interval_re=re.compile('''
655             \s*(?P<s>[-+])?         # + or -
656             \s*((?P<y>\d+\s*)y)?    # year
657             \s*((?P<m>\d+\s*)m)?    # month
658             \s*((?P<w>\d+\s*)w)?    # week
659             \s*((?P<d>\d+\s*)d)?    # day
660             \s*(((?P<H>\d+):(?P<M>\d+))?(:(?P<S>\d+))?)?   # time
661             \s*(?P<D>
662                  (\d\d\d\d[/-])?(\d\d?)?[/-](\d\d?)?       # [yyyy-]mm-dd
663                  \.?                                       # .
664                  (\d?\d:\d\d)?(:\d\d)?                     # hh:mm:ss
665                )?''', re.VERBOSE), serialised_re=re.compile('''
666             (?P<s>[+-])?1?(?P<y>([ ]{3}\d|\d{4}))(?P<m>\d{2})(?P<d>\d{2})
667             (?P<H>\d{2})(?P<M>\d{2})(?P<S>\d{2})''', re.VERBOSE),
668             add_granularity=False):
669         ''' set the date to the value in spec
670         '''
671         self.year = self.month = self.week = self.day = self.hour = \
672             self.minute = self.second = 0
673         self.sign = 1
674         m = serialised_re.match(spec)
675         if not m:
676             m = interval_re.match(spec)
677             if not m:
678                 raise ValueError, self._('Not an interval spec:'
679                     ' [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]')
680         else:
681             allowdate = 0
683         # pull out all the info specified
684         info = m.groupdict()
685         if add_granularity:
686             for gran in 'SMHdwmy':
687                 if info[gran] is not None:
688                     info[gran] = int(info[gran]) + (info['s']=='-' and -1 or 1)
689                     break
691         valid = 0
692         for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day',
693                 'H':'hour', 'M':'minute', 'S':'second'}.items():
694             if info.get(group, None) is not None:
695                 valid = 1
696                 setattr(self, attr, int(info[group]))
698         # make sure it's valid
699         if not valid and not info['D']:
700             raise ValueError, self._('Not an interval spec:'
701                 ' [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]')
703         if self.week:
704             self.day = self.day + self.week*7
706         if info['s'] is not None:
707             self.sign = {'+':1, '-':-1}[info['s']]
709         # use a date spec if one is given
710         if allowdate and info['D'] is not None:
711             now = Date('.')
712             date = Date(info['D'])
713             # if no time part was specified, nuke it in the "now" date
714             if not date.hour or date.minute or date.second:
715                 now.hour = now.minute = now.second = 0
716             if date != now:
717                 y = now - (date + self)
718                 self.__init__(y.get_tuple())
720     def __cmp__(self, other):
721         """Compare this interval to another interval."""
723         if other is None:
724             # we are always larger than None
725             return 1
726         return cmp(self.as_seconds(), other.as_seconds())
728     def __str__(self):
729         """Return this interval as a string."""
730         l = []
731         if self.year: l.append('%sy'%self.year)
732         if self.month: l.append('%sm'%self.month)
733         if self.day: l.append('%sd'%self.day)
734         if self.second:
735             l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second))
736         elif self.hour or self.minute:
737             l.append('%d:%02d'%(self.hour, self.minute))
738         if l:
739             l.insert(0, {1:'+', -1:'-'}[self.sign])
740         else:
741             l.append('00:00')
742         return ' '.join(l)
744     def __add__(self, other):
745         if isinstance(other, Date):
746             # the other is a Date - produce a Date
747             return Date(other.addInterval(self), translator=self.translator)
748         elif isinstance(other, Interval):
749             # add the other Interval to this one
750             a = self.get_tuple()
751             asgn = a[0]
752             b = other.get_tuple()
753             bsgn = b[0]
754             i = [asgn*x + bsgn*y for x,y in zip(a[1:],b[1:])]
755             i.insert(0, 1)
756             i = fixTimeOverflow(i)
757             return Interval(i, translator=self.translator)
758         # nope, no idea what to do with this other...
759         raise TypeError, "Can't add %r"%other
761     def __sub__(self, other):
762         if isinstance(other, Date):
763             # the other is a Date - produce a Date
764             interval = Interval(self.get_tuple())
765             interval.sign *= -1
766             return Date(other.addInterval(interval),
767                 translator=self.translator)
768         elif isinstance(other, Interval):
769             # add the other Interval to this one
770             a = self.get_tuple()
771             asgn = a[0]
772             b = other.get_tuple()
773             bsgn = b[0]
774             i = [asgn*x - bsgn*y for x,y in zip(a[1:],b[1:])]
775             i.insert(0, 1)
776             i = fixTimeOverflow(i)
777             return Interval(i, translator=self.translator)
778         # nope, no idea what to do with this other...
779         raise TypeError, "Can't add %r"%other
781     def __div__(self, other):
782         """ Divide this interval by an int value.
784             Can't divide years and months sensibly in the _same_
785             calculation as days/time, so raise an error in that situation.
786         """
787         try:
788             other = float(other)
789         except TypeError:
790             raise ValueError, "Can only divide Intervals by numbers"
792         y, m, d, H, M, S = (self.year, self.month, self.day,
793             self.hour, self.minute, self.second)
794         if y or m:
795             if d or H or M or S:
796                 raise ValueError, "Can't divide Interval with date and time"
797             months = self.year*12 + self.month
798             months *= self.sign
800             months = int(months/other)
802             sign = months<0 and -1 or 1
803             m = months%12
804             y = months / 12
805             return Interval((sign, y, m, 0, 0, 0, 0),
806                 translator=self.translator)
808         else:
809             # handle a day/time division
810             seconds = S + M*60 + H*60*60 + d*60*60*24
811             seconds *= self.sign
813             seconds = int(seconds/other)
815             sign = seconds<0 and -1 or 1
816             seconds *= sign
817             S = seconds%60
818             seconds /= 60
819             M = seconds%60
820             seconds /= 60
821             H = seconds%24
822             d = seconds / 24
823             return Interval((sign, 0, 0, d, H, M, S),
824                 translator=self.translator)
826     def __repr__(self):
827         return '<Interval %s>'%self.__str__()
829     def pretty(self):
830         ''' print up the date date using one of these nice formats..
831         '''
832         _quarters = self.minute / 15
833         if self.year:
834             s = self.ngettext("%(number)s year", "%(number)s years",
835                 self.year) % {'number': self.year}
836         elif self.month or self.day > 28:
837             _months = max(1, int(((self.month * 30) + self.day) / 30))
838             s = self.ngettext("%(number)s month", "%(number)s months",
839                 _months) % {'number': _months}
840         elif self.day > 7:
841             _weeks = int(self.day / 7)
842             s = self.ngettext("%(number)s week", "%(number)s weeks",
843                 _weeks) % {'number': _weeks}
844         elif self.day > 1:
845             # Note: singular form is not used
846             s = self.ngettext('%(number)s day', '%(number)s days',
847                 self.day) % {'number': self.day}
848         elif self.day == 1 or self.hour > 12:
849             if self.sign > 0:
850                 return self._('tomorrow')
851             else:
852                 return self._('yesterday')
853         elif self.hour > 1:
854             # Note: singular form is not used
855             s = self.ngettext('%(number)s hour', '%(number)s hours',
856                 self.hour) % {'number': self.hour}
857         elif self.hour == 1:
858             if self.minute < 15:
859                 s = self._('an hour')
860             elif _quarters == 2:
861                 s = self._('1 1/2 hours')
862             else:
863                 s = self.ngettext('1 %(number)s/4 hours',
864                     '1 %(number)s/4 hours', _quarters)%{'number': _quarters}
865         elif self.minute < 1:
866             if self.sign > 0:
867                 return self._('in a moment')
868             else:
869                 return self._('just now')
870         elif self.minute == 1:
871             # Note: used in expressions "in 1 minute" or "1 minute ago"
872             s = self._('1 minute')
873         elif self.minute < 15:
874             # Note: used in expressions "in 2 minutes" or "2 minutes ago"
875             s = self.ngettext('%(number)s minute', '%(number)s minutes',
876                 self.minute) % {'number': self.minute}
877         elif _quarters == 2:
878             s = self._('1/2 an hour')
879         else:
880             s = self.ngettext('%(number)s/4 hour', '%(number)s/4 hours',
881                 _quarters) % {'number': _quarters}
882         # XXX this is internationally broken
883         if self.sign < 0:
884             s = self._('%s ago') % s
885         else:
886             s = self._('in %s') % s
887         return s
889     def get_tuple(self):
890         return (self.sign, self.year, self.month, self.day, self.hour,
891             self.minute, self.second)
893     def serialise(self):
894         sign = self.sign > 0 and '+' or '-'
895         return '%s%04d%02d%02d%02d%02d%02d'%(sign, self.year, self.month,
896             self.day, self.hour, self.minute, self.second)
898     def as_seconds(self):
899         '''Calculate the Interval as a number of seconds.
901         Months are counted as 30 days, years as 365 days. Returns a Long
902         int.
903         '''
904         n = self.year * 365L
905         n = n + self.month * 30
906         n = n + self.day
907         n = n * 24
908         n = n + self.hour
909         n = n * 60
910         n = n + self.minute
911         n = n * 60
912         n = n + self.second
913         return n * self.sign
915     def from_seconds(self, val):
916         '''Figure my second, minute, hour and day values using a seconds
917         value.
918         '''
919         val = int(val)
920         if val < 0:
921             self.sign = -1
922             val = -val
923         else:
924             self.sign = 1
925         self.second = val % 60
926         val = val / 60
927         self.minute = val % 60
928         val = val / 60
929         self.hour = val % 24
930         val = val / 24
931         self.day = val
932         self.month = self.year = 0
934     def setTranslator(self, translator):
935         """Replace the translation engine
937         'translator'
938            is i18n module or one of gettext translation classes.
939            It must have attributes 'gettext' and 'ngettext',
940            serving as translation functions.
941         """
942         self.translator = translator
943         self._ = translator.gettext
944         self.ngettext = translator.ngettext
947 def fixTimeOverflow(time):
948     """ Handle the overflow in the time portion (H, M, S) of "time":
949             (sign, y,m,d,H,M,S)
951         Overflow and underflow will at most affect the _days_ portion of
952         the date. We do not overflow days to months as we don't know _how_
953         to, generally.
954     """
955     # XXX we could conceivably use this function for handling regular dates
956     # XXX too - we just need to interrogate the month/year for the day
957     # XXX overflow...
959     sign, y, m, d, H, M, S = time
960     seconds = sign * (S + M*60 + H*60*60 + d*60*60*24)
961     if seconds:
962         sign = seconds<0 and -1 or 1
963         seconds *= sign
964         S = seconds%60
965         seconds /= 60
966         M = seconds%60
967         seconds /= 60
968         H = seconds%24
969         d = seconds / 24
970     else:
971         months = y*12 + m
972         sign = months<0 and -1 or 1
973         months *= sign
974         m = months%12
975         y = months/12
977     return (sign, y, m, d, H, M, S)
979 class Range:
980     """Represents range between two values
981     Ranges can be created using one of theese two alternative syntaxes:
983     1. Native english syntax::
985             [[From] <value>][ To <value>]
987        Keywords "From" and "To" are case insensitive. Keyword "From" is
988        optional.
990     2. "Geek" syntax::
992           [<value>][; <value>]
994     Either first or second <value> can be omitted in both syntaxes.
996     Examples (consider local time is Sat Mar  8 22:07:48 EET 2003)::
998         >>> Range("from 2-12 to 4-2")
999         <Range from 2003-02-12.00:00:00 to 2003-04-02.00:00:00>
1001         >>> Range("18:00 TO +2m")
1002         <Range from 2003-03-08.18:00:00 to 2003-05-08.20:07:48>
1004         >>> Range("12:00")
1005         <Range from 2003-03-08.12:00:00 to None>
1007         >>> Range("tO +3d")
1008         <Range from None to 2003-03-11.20:07:48>
1010         >>> Range("2002-11-10; 2002-12-12")
1011         <Range from 2002-11-10.00:00:00 to 2002-12-12.00:00:00>
1013         >>> Range("; 20:00 +1d")
1014         <Range from None to 2003-03-09.20:00:00>
1016     """
1017     def __init__(self, spec, Type, allow_granularity=True, **params):
1018         """Initializes Range of type <Type> from given <spec> string.
1020         Sets two properties - from_value and to_value. None assigned to any of
1021         this properties means "infinitum" (-infinitum to from_value and
1022         +infinitum to to_value)
1024         The Type parameter here should be class itself (e.g. Date), not a
1025         class instance.
1026         """
1027         self.range_type = Type
1028         re_range = r'(?:^|from(.+?))(?:to(.+?)$|$)'
1029         re_geek_range = r'(?:^|(.+?));(?:(.+?)$|$)'
1030         # Check which syntax to use
1031         if ';' in spec:
1032             # Geek
1033             m = re.search(re_geek_range, spec.strip())
1034         else:
1035             # Native english
1036             m = re.search(re_range, spec.strip(), re.IGNORECASE)
1037         if m:
1038             self.from_value, self.to_value = m.groups()
1039             if self.from_value:
1040                 self.from_value = Type(self.from_value.strip(), **params)
1041             if self.to_value:
1042                 self.to_value = Type(self.to_value.strip(), **params)
1043         else:
1044             if allow_granularity:
1045                 self.from_value = Type(spec, **params)
1046                 self.to_value = Type(spec, add_granularity=True, **params)
1047             else:
1048                 raise ValueError, "Invalid range"
1050     def __str__(self):
1051         return "from %s to %s" % (self.from_value, self.to_value)
1053     def __repr__(self):
1054         return "<Range %s>" % self.__str__()
1056 def test_range():
1057     rspecs = ("from 2-12 to 4-2", "from 18:00 TO +2m", "12:00;", "tO +3d",
1058         "2002-11-10; 2002-12-12", "; 20:00 +1d", '2002-10-12')
1059     rispecs = ('from -1w 2d 4:32 to 4d', '-2w 1d')
1060     for rspec in rspecs:
1061         print '>>> Range("%s")' % rspec
1062         print `Range(rspec, Date)`
1063         print
1064     for rspec in rispecs:
1065         print '>>> Range("%s")' % rspec
1066         print `Range(rspec, Interval)`
1067         print
1069 def test():
1070     intervals = ("  3w  1  d  2:00", " + 2d", "3w")
1071     for interval in intervals:
1072         print '>>> Interval("%s")'%interval
1073         print `Interval(interval)`
1075     dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25",
1076         "08-13.22:13", "14:25", '2002-12')
1077     for date in dates:
1078         print '>>> Date("%s")'%date
1079         print `Date(date)`
1081     sums = ((". + 2d", "3w"), (".", "  3w  1  d  2:00"))
1082     for date, interval in sums:
1083         print '>>> Date("%s") + Interval("%s")'%(date, interval)
1084         print `Date(date) + Interval(interval)`
1086 if __name__ == '__main__':
1087     test()
1089 # vim: set filetype=python sts=4 sw=4 et si :