Code

8382ec3fd162c78d01189080c9c0489895b596a5
[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.14 2001-11-22 15:46:42 jhermann Exp $
20 __doc__ = """
21 Date, time and time interval handling.
22 """
24 import time, re, calendar
26 class Date:
27     '''
28     As strings, date-and-time stamps are specified with the date in
29     international standard format (yyyy-mm-dd) joined to the time
30     (hh:mm:ss) by a period ("."). Dates in this form can be easily compared
31     and are fairly readable when printed. An example of a valid stamp is
32     "2000-06-24.13:03:59". We'll call this the "full date format". When
33     Timestamp objects are printed as strings, they appear in the full date
34     format with the time always given in GMT. The full date format is
35     always exactly 19 characters long. 
37     For user input, some partial forms are also permitted: the whole time
38     or just the seconds may be omitted; and the whole date may be omitted
39     or just the year may be omitted. If the time is given, the time is
40     interpreted in the user's local time zone. The Date constructor takes
41     care of these conversions. In the following examples, suppose that yyyy
42     is the current year, mm is the current month, and dd is the current day
43     of the month; and suppose that the user is on Eastern Standard Time.
45       "2000-04-17" means <Date 2000-04-17.00:00:00>
46       "01-25" means <Date yyyy-01-25.00:00:00>
47       "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
48       "08-13.22:13" means <Date yyyy-08-14.03:13:00>
49       "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
50       "14:25" means <Date yyyy-mm-dd.19:25:00>
51       "8:47:11" means <Date yyyy-mm-dd.13:47:11>
52       "." means "right now"
54     The Date class should understand simple date expressions of the form
55     stamp + interval and stamp - interval. When adding or subtracting
56     intervals involving months or years, the components are handled
57     separately. For example, when evaluating "2000-06-25 + 1m 10d", we
58     first add one month to get 2000-07-25, then add 10 days to get
59     2000-08-04 (rather than trying to decide whether 1m 10d means 38 or 40
60     or 41 days).
62     Example usage:
63         >>> Date(".")
64         <Date 2000-06-26.00:34:02>
65         >>> _.local(-5)
66         "2000-06-25.19:34:02"
67         >>> Date(". + 2d")
68         <Date 2000-06-28.00:34:02>
69         >>> Date("1997-04-17", -5)
70         <Date 1997-04-17.00:00:00>
71         >>> Date("01-25", -5)
72         <Date 2000-01-25.00:00:00>
73         >>> Date("08-13.22:13", -5)
74         <Date 2000-08-14.03:13:00>
75         >>> Date("14:25", -5)
76         <Date 2000-06-25.19:25:00>
77     '''
78     def __init__(self, spec='.', offset=0):
79         """Construct a date given a specification and a time zone offset.
81           'spec' is a full date or a partial form, with an optional
82                  added or subtracted interval. Or a date 9-tuple.
83         'offset' is the local time zone offset from GMT in hours.
84         """
85         if type(spec) == type(''):
86             self.set(spec, offset=offset)
87         else:
88             y,m,d,H,M,S,x,x,x = spec
89             ts = calendar.timegm((y,m,d,H+offset,M,S,0,0,0))
90             self.year, self.month, self.day, self.hour, self.minute, \
91                 self.second, x, x, x = time.gmtime(ts)
93     def applyInterval(self, interval):
94         ''' Apply the interval to this date
95         '''
96         t = (self.year + interval.year,
97              self.month + interval.month,
98              self.day + interval.day,
99              self.hour + interval.hour,
100              self.minute + interval.minute,
101              self.second + interval.second, 0, 0, 0)
102         self.year, self.month, self.day, self.hour, self.minute, \
103             self.second, x, x, x = time.gmtime(calendar.timegm(t))
105     def __add__(self, other):
106         """Add an interval to this date to produce another date."""
107         t = (self.year + other.sign * other.year,
108             self.month + other.sign * other.month,
109             self.day + other.sign * other.day,
110             self.hour + other.sign * other.hour,
111             self.minute + other.sign * other.minute,
112             self.second + other.sign * other.second, 0, 0, 0)
113         return Date(time.gmtime(calendar.timegm(t)))
115     # XXX deviates from spec to allow subtraction of dates as well
116     def __sub__(self, other):
117         """ Subtract:
118              1. an interval from this date to produce another date.
119              2. a date from this date to produce an interval.
120         """
121         if isinstance(other, Date):
122             # TODO this code will fall over laughing if the dates cross
123             # leap years, phases of the moon, ....
124             a = calendar.timegm((self.year, self.month, self.day, self.hour,
125                 self.minute, self.second, 0, 0, 0))
126             b = calendar.timegm((other.year, other.month, other.day, other.hour,
127                 other.minute, other.second, 0, 0, 0))
128             diff = a - b
129             if diff < 0:
130                 sign = -1
131                 diff = -diff
132             else:
133                 sign = 1
134             S = diff%60
135             M = (diff/60)%60
136             H = (diff/(60*60))%60
137             if H>1: S = 0
138             d = (diff/(24*60*60))%30
139             if d>1: H = S = M = 0
140             m = (diff/(30*24*60*60))%12
141             if m>1: H = S = M = 0
142             y = (diff/(365*24*60*60))
143             if y>1: d = H = S = M = 0
144             return Interval((y, m, d, H, M, S), sign=sign)
145         t = (self.year - other.sign * other.year,
146              self.month - other.sign * other.month,
147              self.day - other.sign * other.day,
148              self.hour - other.sign * other.hour,
149              self.minute - other.sign * other.minute,
150              self.second - other.sign * other.second, 0, 0, 0)
151         return Date(time.gmtime(calendar.timegm(t)))
153     def __cmp__(self, other):
154         """Compare this date to another date."""
155         for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
156             r = cmp(getattr(self, attr), getattr(other, attr))
157             if r: return r
158         return 0
160     def __str__(self):
161         """Return this date as a string in the yyyy-mm-dd.hh:mm:ss format."""
162         return '%4d-%02d-%02d.%02d:%02d:%02d'%(self.year, self.month, self.day,
163             self.hour, self.minute, self.second)
165     def pretty(self):
166         ''' print up the date date using a pretty format...
167         '''
168         return time.strftime('%e %B %Y', (self.year, self.month,
169             self.day, self.hour, self.minute, self.second, 0, 0, 0))
171     def set(self, spec, offset=0, date_re=re.compile(r'''
172               (((?P<y>\d\d\d\d)-)?((?P<m>\d\d)-(?P<d>\d\d))?)? # yyyy-mm-dd
173               (?P<n>\.)?                                       # .
174               (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d))?)?    # hh:mm:ss
175               (?P<o>.+)?                                       # offset
176               ''', re.VERBOSE)):
177         ''' set the date to the value in spec
178         '''
179         m = date_re.match(spec)
180         if not m:
181             raise ValueError, 'Not a date spec: [[yyyy-]mm-dd].[[h]h:mm[:ss]] [offset]'
182         info = m.groupdict()
184         # get the current date/time using the offset
185         y,m,d,H,M,S,x,x,x = time.gmtime(time.time())
187         # override year, month, day parts
188         if info['m'] is not None and info['d'] is not None:
189             m = int(info['m'])
190             d = int(info['d'])
191             if info['y'] is not None: y = int(info['y'])
192             H = M = S = 0
194         # override hour, minute, second parts
195         if info['H'] is not None and info['M'] is not None:
196             H = int(info['H']) - offset
197             M = int(info['M'])
198             S = 0
199             if info['S'] is not None: S = int(info['S'])
201         # now handle the adjustment of hour
202         ts = calendar.timegm((y,m,d,H,M,S,0,0,0))
203         self.year, self.month, self.day, self.hour, self.minute, \
204             self.second, x, x, x = time.gmtime(ts)
206         if info['o']:
207             self.applyInterval(Interval(info['o']))
209     def __repr__(self):
210         return '<Date %s>'%self.__str__()
212     def local(self, offset):
213         """Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone."""
214         t = (self.year, self.month, self.day, self.hour + offset, self.minute,
215              self.second, 0, 0, 0)
216         self.year, self.month, self.day, self.hour, self.minute, \
217             self.second, x, x, x = time.gmtime(calendar.timegm(t))
219     def get_tuple(self):
220         return (self.year, self.month, self.day, self.hour, self.minute,
221             self.second, 0, 0, 0)
223 class Interval:
224     '''
225     Date intervals are specified using the suffixes "y", "m", and "d". The
226     suffix "w" (for "week") means 7 days. Time intervals are specified in
227     hh:mm:ss format (the seconds may be omitted, but the hours and minutes
228     may not).
230       "3y" means three years
231       "2y 1m" means two years and one month
232       "1m 25d" means one month and 25 days
233       "2w 3d" means two weeks and three days
234       "1d 2:50" means one day, two hours, and 50 minutes
235       "14:00" means 14 hours
236       "0:04:33" means four minutes and 33 seconds
238     Example usage:
239         >>> Interval("  3w  1  d  2:00")
240         <Interval 22d 2:00>
241         >>> Date(". + 2d") - Interval("3w")
242         <Date 2000-06-07.00:34:02>
243     '''
244     def __init__(self, spec, sign=1):
245         """Construct an interval given a specification."""
246         if type(spec) == type(''):
247             self.set(spec)
248         else:
249             self.sign = sign
250             self.year, self.month, self.day, self.hour, self.minute, \
251                 self.second = spec
253     def __cmp__(self, other):
254         """Compare this interval to another interval."""
255         for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
256             r = cmp(getattr(self, attr), getattr(other, attr))
257             if r: return r
258         return 0
259         
260     def __str__(self):
261         """Return this interval as a string."""
262         sign = {1:'+', -1:'-'}[self.sign]
263         l = [sign]
264         if self.year: l.append('%sy'%self.year)
265         if self.month: l.append('%sm'%self.month)
266         if self.day: l.append('%sd'%self.day)
267         if self.second:
268             l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second))
269         elif self.hour or self.minute:
270             l.append('%d:%02d'%(self.hour, self.minute))
271         return ' '.join(l)
273     def set(self, spec, interval_re = re.compile('''
274             \s*
275             (?P<s>[-+])?         # + or -
276             \s*
277             ((?P<y>\d+\s*)y)?    # year
278             \s*
279             ((?P<m>\d+\s*)m)?    # month
280             \s*
281             ((?P<w>\d+\s*)w)?    # week
282             \s*
283             ((?P<d>\d+\s*)d)?    # day
284             \s*
285             (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d))?)?   # time
286             \s*
287             ''', re.VERBOSE)):
288         ''' set the date to the value in spec
289         '''
290         self.year = self.month = self.week = self.day = self.hour = \
291             self.minute = self.second = 0
292         self.sign = 1
293         m = interval_re.match(spec)
294         if not m:
295             raise ValueError, 'Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]'
297         info = m.groupdict()
298         for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day',
299                 'H':'hour', 'M':'minute', 'S':'second'}.items():
300             if info[group] is not None:
301                 setattr(self, attr, int(info[group]))
303         if self.week:
304             self.day = self.day + self.week*7
306         if info['s'] is not None:
307             self.sign = {'+':1, '-':-1}[info['s']]
309     def __repr__(self):
310         return '<Interval %s>'%self.__str__()
312     def pretty(self, threshold=('d', 5)):
313         ''' print up the date date using one of these nice formats..
314             < 1 minute
315             < 15 minutes
316             < 30 minutes
317             < 1 hour
318             < 12 hours
319             < 1 day
320             otherwise, return None (so a full date may be displayed)
321         '''
322         if self.year or self.month > 2:
323             return None
324         if self.month or self.day > 13:
325             days = (self.month * 30) + self.day
326             if days > 28:
327                 if int(days/30) > 1:
328                     return '%s months'%int(days/30)
329                 else:
330                     return '1 month'
331             else:
332                 return '%s weeks'%int(days/7)
333         if self.day > 7:
334             return '1 week'
335         if self.day > 1:
336             return '%s days'%self.day
337         if self.day == 1 or self.hour > 12:
338             return 'yesterday'
339         if self.hour > 1:
340             return '%s hours'%self.hour
341         if self.hour == 1:
342             if self.minute < 15:
343                 return 'an hour'
344             quart = self.minute/15
345             if quart == 2:
346                 return '1 1/2 hours'
347             return '1 %s/4 hours'%quart
348         if self.minute < 1:
349             return 'just now'
350         if self.minute == 1:
351             return '1 minute'
352         if self.minute < 15:
353             return '%s minutes'%self.minute
354         quart = int(self.minute/15)
355         if quart == 2:
356             return '1/2 an hour'
357         return '%s/4 hour'%quart
359     def get_tuple(self):
360         return (self.year, self.month, self.day, self.hour, self.minute,
361             self.second)
364 def test():
365     intervals = ("  3w  1  d  2:00", " + 2d", "3w")
366     for interval in intervals:
367         print '>>> Interval("%s")'%interval
368         print `Interval(interval)`
370     dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25",
371         "08-13.22:13", "14:25")
372     for date in dates:
373         print '>>> Date("%s")'%date
374         print `Date(date)`
376     sums = ((". + 2d", "3w"), (".", "  3w  1  d  2:00"))
377     for date, interval in sums:
378         print '>>> Date("%s") + Interval("%s")'%(date, interval)
379         print `Date(date) + Interval(interval)`
381 if __name__ == '__main__':
382     test()
385 # $Log: not supported by cvs2svn $
386 # Revision 1.13  2001/09/18 22:58:37  richard
388 # Added some more help to roundu-admin
390 # Revision 1.12  2001/08/17 03:08:11  richard
391 # fixed prettification of intervals of 1 week
393 # Revision 1.11  2001/08/15 23:43:18  richard
394 # Fixed some isFooTypes that I missed.
395 # Refactored some code in the CGI code.
397 # Revision 1.10  2001/08/07 00:24:42  richard
398 # stupid typo
400 # Revision 1.9  2001/08/07 00:15:51  richard
401 # Added the copyright/license notice to (nearly) all files at request of
402 # Bizar Software.
404 # Revision 1.8  2001/08/05 07:46:12  richard
405 # Changed date.Date to use regular string formatting instead of strftime -
406 # win32 seems to have problems with %T and no hour... or something...
408 # Revision 1.7  2001/08/02 00:27:04  richard
409 # Extended the range of intervals that are pretty-printed before actual dates
410 # are displayed.
412 # Revision 1.6  2001/07/31 09:54:18  richard
413 # Fixed the 2.1-specific gmtime() (no arg) call in roundup.date. (Paul Wright)
415 # Revision 1.5  2001/07/29 07:01:39  richard
416 # Added vim command to all source so that we don't get no steenkin' tabs :)
418 # Revision 1.4  2001/07/25 04:09:34  richard
419 # Fixed offset handling (shoulda read the spec a little better)
421 # Revision 1.3  2001/07/23 07:56:05  richard
422 # Storing only marshallable data in the db - no nasty pickled class references.
424 # Revision 1.2  2001/07/22 12:09:32  richard
425 # Final commit of Grande Splite
427 # Revision 1.1  2001/07/22 11:58:35  richard
428 # More Grande Splite
431 # vim: set filetype=python ts=4 sw=4 et si