Code

Fix small typos.
[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."""
722         if other is None:
723             # we are always larger than None
724             return 1
725         for attr in 'sign year month day hour minute second'.split():
726             r = cmp(getattr(self, attr), getattr(other, attr))
727             if r:
728                 return r
729         return 0
731     def __str__(self):
732         """Return this interval as a string."""
733         l = []
734         if self.year: l.append('%sy'%self.year)
735         if self.month: l.append('%sm'%self.month)
736         if self.day: l.append('%sd'%self.day)
737         if self.second:
738             l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second))
739         elif self.hour or self.minute:
740             l.append('%d:%02d'%(self.hour, self.minute))
741         if l:
742             l.insert(0, {1:'+', -1:'-'}[self.sign])
743         else:
744             l.append('00:00')
745         return ' '.join(l)
747     def __add__(self, other):
748         if isinstance(other, Date):
749             # the other is a Date - produce a Date
750             return Date(other.addInterval(self), translator=self.translator)
751         elif isinstance(other, Interval):
752             # add the other Interval to this one
753             a = self.get_tuple()
754             asgn = a[0]
755             b = other.get_tuple()
756             bsgn = b[0]
757             i = [asgn*x + bsgn*y for x,y in zip(a[1:],b[1:])]
758             i.insert(0, 1)
759             i = fixTimeOverflow(i)
760             return Interval(i, translator=self.translator)
761         # nope, no idea what to do with this other...
762         raise TypeError, "Can't add %r"%other
764     def __sub__(self, other):
765         if isinstance(other, Date):
766             # the other is a Date - produce a Date
767             interval = Interval(self.get_tuple())
768             interval.sign *= -1
769             return Date(other.addInterval(interval),
770                 translator=self.translator)
771         elif isinstance(other, Interval):
772             # add the other Interval to this one
773             a = self.get_tuple()
774             asgn = a[0]
775             b = other.get_tuple()
776             bsgn = b[0]
777             i = [asgn*x - bsgn*y for x,y in zip(a[1:],b[1:])]
778             i.insert(0, 1)
779             i = fixTimeOverflow(i)
780             return Interval(i, translator=self.translator)
781         # nope, no idea what to do with this other...
782         raise TypeError, "Can't add %r"%other
784     def __div__(self, other):
785         """ Divide this interval by an int value.
787             Can't divide years and months sensibly in the _same_
788             calculation as days/time, so raise an error in that situation.
789         """
790         try:
791             other = float(other)
792         except TypeError:
793             raise ValueError, "Can only divide Intervals by numbers"
795         y, m, d, H, M, S = (self.year, self.month, self.day,
796             self.hour, self.minute, self.second)
797         if y or m:
798             if d or H or M or S:
799                 raise ValueError, "Can't divide Interval with date and time"
800             months = self.year*12 + self.month
801             months *= self.sign
803             months = int(months/other)
805             sign = months<0 and -1 or 1
806             m = months%12
807             y = months / 12
808             return Interval((sign, y, m, 0, 0, 0, 0),
809                 translator=self.translator)
811         else:
812             # handle a day/time division
813             seconds = S + M*60 + H*60*60 + d*60*60*24
814             seconds *= self.sign
816             seconds = int(seconds/other)
818             sign = seconds<0 and -1 or 1
819             seconds *= sign
820             S = seconds%60
821             seconds /= 60
822             M = seconds%60
823             seconds /= 60
824             H = seconds%24
825             d = seconds / 24
826             return Interval((sign, 0, 0, d, H, M, S),
827                 translator=self.translator)
829     def __repr__(self):
830         return '<Interval %s>'%self.__str__()
832     def pretty(self):
833         ''' print up the date date using one of these nice formats..
834         '''
835         _quarters = self.minute / 15
836         if self.year:
837             s = self.ngettext("%(number)s year", "%(number)s years",
838                 self.year) % {'number': self.year}
839         elif self.month or self.day > 28:
840             _months = max(1, int(((self.month * 30) + self.day) / 30))
841             s = self.ngettext("%(number)s month", "%(number)s months",
842                 _months) % {'number': _months}
843         elif self.day > 7:
844             _weeks = int(self.day / 7)
845             s = self.ngettext("%(number)s week", "%(number)s weeks",
846                 _weeks) % {'number': _weeks}
847         elif self.day > 1:
848             # Note: singular form is not used
849             s = self.ngettext('%(number)s day', '%(number)s days',
850                 self.day) % {'number': self.day}
851         elif self.day == 1 or self.hour > 12:
852             if self.sign > 0:
853                 return self._('tomorrow')
854             else:
855                 return self._('yesterday')
856         elif self.hour > 1:
857             # Note: singular form is not used
858             s = self.ngettext('%(number)s hour', '%(number)s hours',
859                 self.hour) % {'number': self.hour}
860         elif self.hour == 1:
861             if self.minute < 15:
862                 s = self._('an hour')
863             elif _quarters == 2:
864                 s = self._('1 1/2 hours')
865             else:
866                 s = self.ngettext('1 %(number)s/4 hours',
867                     '1 %(number)s/4 hours', _quarters)%{'number': _quarters}
868         elif self.minute < 1:
869             if self.sign > 0:
870                 return self._('in a moment')
871             else:
872                 return self._('just now')
873         elif self.minute == 1:
874             # Note: used in expressions "in 1 minute" or "1 minute ago"
875             s = self._('1 minute')
876         elif self.minute < 15:
877             # Note: used in expressions "in 2 minutes" or "2 minutes ago"
878             s = self.ngettext('%(number)s minute', '%(number)s minutes',
879                 self.minute) % {'number': self.minute}
880         elif _quarters == 2:
881             s = self._('1/2 an hour')
882         else:
883             s = self.ngettext('%(number)s/4 hour', '%(number)s/4 hours',
884                 _quarters) % {'number': _quarters}
885         # XXX this is internationally broken
886         if self.sign < 0:
887             s = self._('%s ago') % s
888         else:
889             s = self._('in %s') % s
890         return s
892     def get_tuple(self):
893         return (self.sign, self.year, self.month, self.day, self.hour,
894             self.minute, self.second)
896     def serialise(self):
897         sign = self.sign > 0 and '+' or '-'
898         return '%s%04d%02d%02d%02d%02d%02d'%(sign, self.year, self.month,
899             self.day, self.hour, self.minute, self.second)
901     def as_seconds(self):
902         '''Calculate the Interval as a number of seconds.
904         Months are counted as 30 days, years as 365 days. Returns a Long
905         int.
906         '''
907         n = self.year * 365L
908         n = n + self.month * 30
909         n = n + self.day
910         n = n * 24
911         n = n + self.hour
912         n = n * 60
913         n = n + self.minute
914         n = n * 60
915         n = n + self.second
916         return n * self.sign
918     def from_seconds(self, val):
919         '''Figure my second, minute, hour and day values using a seconds
920         value.
921         '''
922         val = int(val)
923         if val < 0:
924             self.sign = -1
925             val = -val
926         else:
927             self.sign = 1
928         self.second = val % 60
929         val = val / 60
930         self.minute = val % 60
931         val = val / 60
932         self.hour = val % 24
933         val = val / 24
934         self.day = val
935         self.month = self.year = 0
937     def setTranslator(self, translator):
938         """Replace the translation engine
940         'translator'
941            is i18n module or one of gettext translation classes.
942            It must have attributes 'gettext' and 'ngettext',
943            serving as translation functions.
944         """
945         self.translator = translator
946         self._ = translator.gettext
947         self.ngettext = translator.ngettext
950 def fixTimeOverflow(time):
951     """ Handle the overflow in the time portion (H, M, S) of "time":
952             (sign, y,m,d,H,M,S)
954         Overflow and underflow will at most affect the _days_ portion of
955         the date. We do not overflow days to months as we don't know _how_
956         to, generally.
957     """
958     # XXX we could conceivably use this function for handling regular dates
959     # XXX too - we just need to interrogate the month/year for the day
960     # XXX overflow...
962     sign, y, m, d, H, M, S = time
963     seconds = sign * (S + M*60 + H*60*60 + d*60*60*24)
964     if seconds:
965         sign = seconds<0 and -1 or 1
966         seconds *= sign
967         S = seconds%60
968         seconds /= 60
969         M = seconds%60
970         seconds /= 60
971         H = seconds%24
972         d = seconds / 24
973     else:
974         months = y*12 + m
975         sign = months<0 and -1 or 1
976         months *= sign
977         m = months%12
978         y = months/12
980     return (sign, y, m, d, H, M, S)
982 class Range:
983     """Represents range between two values
984     Ranges can be created using one of theese two alternative syntaxes:
986     1. Native english syntax::
988             [[From] <value>][ To <value>]
990        Keywords "From" and "To" are case insensitive. Keyword "From" is
991        optional.
993     2. "Geek" syntax::
995           [<value>][; <value>]
997     Either first or second <value> can be omitted in both syntaxes.
999     Examples (consider local time is Sat Mar  8 22:07:48 EET 2003)::
1001         >>> Range("from 2-12 to 4-2")
1002         <Range from 2003-02-12.00:00:00 to 2003-04-02.00:00:00>
1004         >>> Range("18:00 TO +2m")
1005         <Range from 2003-03-08.18:00:00 to 2003-05-08.20:07:48>
1007         >>> Range("12:00")
1008         <Range from 2003-03-08.12:00:00 to None>
1010         >>> Range("tO +3d")
1011         <Range from None to 2003-03-11.20:07:48>
1013         >>> Range("2002-11-10; 2002-12-12")
1014         <Range from 2002-11-10.00:00:00 to 2002-12-12.00:00:00>
1016         >>> Range("; 20:00 +1d")
1017         <Range from None to 2003-03-09.20:00:00>
1019     """
1020     def __init__(self, spec, Type, allow_granularity=True, **params):
1021         """Initializes Range of type <Type> from given <spec> string.
1023         Sets two properties - from_value and to_value. None assigned to any of
1024         this properties means "infinitum" (-infinitum to from_value and
1025         +infinitum to to_value)
1027         The Type parameter here should be class itself (e.g. Date), not a
1028         class instance.
1029         """
1030         self.range_type = Type
1031         re_range = r'(?:^|from(.+?))(?:to(.+?)$|$)'
1032         re_geek_range = r'(?:^|(.+?));(?:(.+?)$|$)'
1033         # Check which syntax to use
1034         if ';' in spec:
1035             # Geek
1036             m = re.search(re_geek_range, spec.strip())
1037         else:
1038             # Native english
1039             m = re.search(re_range, spec.strip(), re.IGNORECASE)
1040         if m:
1041             self.from_value, self.to_value = m.groups()
1042             if self.from_value:
1043                 self.from_value = Type(self.from_value.strip(), **params)
1044             if self.to_value:
1045                 self.to_value = Type(self.to_value.strip(), **params)
1046         else:
1047             if allow_granularity:
1048                 self.from_value = Type(spec, **params)
1049                 self.to_value = Type(spec, add_granularity=True, **params)
1050             else:
1051                 raise ValueError, "Invalid range"
1053     def __str__(self):
1054         return "from %s to %s" % (self.from_value, self.to_value)
1056     def __repr__(self):
1057         return "<Range %s>" % self.__str__()
1059 def test_range():
1060     rspecs = ("from 2-12 to 4-2", "from 18:00 TO +2m", "12:00;", "tO +3d",
1061         "2002-11-10; 2002-12-12", "; 20:00 +1d", '2002-10-12')
1062     rispecs = ('from -1w 2d 4:32 to 4d', '-2w 1d')
1063     for rspec in rspecs:
1064         print '>>> Range("%s")' % rspec
1065         print `Range(rspec, Date)`
1066         print
1067     for rspec in rispecs:
1068         print '>>> Range("%s")' % rspec
1069         print `Range(rspec, Interval)`
1070         print
1072 def test():
1073     intervals = ("  3w  1  d  2:00", " + 2d", "3w")
1074     for interval in intervals:
1075         print '>>> Interval("%s")'%interval
1076         print `Interval(interval)`
1078     dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25",
1079         "08-13.22:13", "14:25", '2002-12')
1080     for date in dates:
1081         print '>>> Date("%s")'%date
1082         print `Date(date)`
1084     sums = ((". + 2d", "3w"), (".", "  3w  1  d  2:00"))
1085     for date, interval in sums:
1086         print '>>> Date("%s") + Interval("%s")'%(date, interval)
1087         print `Date(date) + Interval(interval)`
1089 if __name__ == '__main__':
1090     test()
1092 # vim: set filetype=python sts=4 sw=4 et si :