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.24 2002-08-21 07:07:27 richard Exp $
20 __doc__ = """
21 Date, time and time interval handling.
22 """
24 import time, re, calendar
25 from i18n import _
27 class Date:
28 '''
29 As strings, date-and-time stamps are specified with the date in
30 international standard format (yyyy-mm-dd) joined to the time
31 (hh:mm:ss) by a period ("."). Dates in this form can be easily compared
32 and are fairly readable when printed. An example of a valid stamp is
33 "2000-06-24.13:03:59". We'll call this the "full date format". When
34 Timestamp objects are printed as strings, they appear in the full date
35 format with the time always given in GMT. The full date format is
36 always exactly 19 characters long.
38 For user input, some partial forms are also permitted: the whole time
39 or just the seconds may be omitted; and the whole date may be omitted
40 or just the year may be omitted. If the time is given, the time is
41 interpreted in the user's local time zone. The Date constructor takes
42 care of these conversions. In the following examples, suppose that yyyy
43 is the current year, mm is the current month, and dd is the current day
44 of the month; and suppose that the user is on Eastern Standard Time.
46 "2000-04-17" means <Date 2000-04-17.00:00:00>
47 "01-25" means <Date yyyy-01-25.00:00:00>
48 "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
49 "08-13.22:13" means <Date yyyy-08-14.03:13:00>
50 "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
51 "14:25" means <Date yyyy-mm-dd.19:25:00>
52 "8:47:11" means <Date yyyy-mm-dd.13:47:11>
53 "." means "right now"
55 The Date class should understand simple date expressions of the form
56 stamp + interval and stamp - interval. When adding or subtracting
57 intervals involving months or years, the components are handled
58 separately. For example, when evaluating "2000-06-25 + 1m 10d", we
59 first add one month to get 2000-07-25, then add 10 days to get
60 2000-08-04 (rather than trying to decide whether 1m 10d means 38 or 40
61 or 41 days).
63 Example usage:
64 >>> Date(".")
65 <Date 2000-06-26.00:34:02>
66 >>> _.local(-5)
67 "2000-06-25.19:34:02"
68 >>> Date(". + 2d")
69 <Date 2000-06-28.00:34:02>
70 >>> Date("1997-04-17", -5)
71 <Date 1997-04-17.00:00:00>
72 >>> Date("01-25", -5)
73 <Date 2000-01-25.00:00:00>
74 >>> Date("08-13.22:13", -5)
75 <Date 2000-08-14.03:13:00>
76 >>> Date("14:25", -5)
77 <Date 2000-06-25.19:25:00>
79 The date format 'yyyymmddHHMMSS' (year, month, day, hour,
80 minute, second) is the serialisation format returned by the serialise()
81 method, and is accepted as an argument on instatiation.
82 '''
83 def __init__(self, spec='.', offset=0):
84 """Construct a date given a specification and a time zone offset.
86 'spec' is a full date or a partial form, with an optional
87 added or subtracted interval. Or a date 9-tuple.
88 'offset' is the local time zone offset from GMT in hours.
89 """
90 if type(spec) == type(''):
91 self.set(spec, offset=offset)
92 else:
93 y,m,d,H,M,S,x,x,x = spec
94 ts = calendar.timegm((y,m,d,H+offset,M,S,0,0,0))
95 self.year, self.month, self.day, self.hour, self.minute, \
96 self.second, x, x, x = time.gmtime(ts)
98 def addInterval(self, interval):
99 ''' Add the interval to this date, returning the date tuple
100 '''
101 # do the basic calc
102 sign = interval.sign
103 year = self.year + sign * interval.year
104 month = self.month + sign * interval.month
105 day = self.day + sign * interval.day
106 hour = self.hour + sign * interval.hour
107 minute = self.minute + sign * interval.minute
108 second = self.second + sign * interval.second
110 # now cope with under- and over-flow
111 # first do the time
112 while (second < 0 or second > 59 or minute < 0 or minute > 59 or
113 hour < 0 or hour > 59):
114 if second < 0: minute -= 1; second += 60
115 elif second > 59: minute += 1; second -= 60
116 if minute < 0: hour -= 1; minute += 60
117 elif minute > 59: hour += 1; minute -= 60
118 if hour < 0: day -= 1; hour += 24
119 elif hour > 59: day += 1; hour -= 24
121 # fix up the month so we're within range
122 while month < 1 or month > 12:
123 if month < 1: year -= 1; month += 12
124 if month > 12: year += 1; month -= 12
126 # now do the days, now that we know what month we're in
127 mdays = calendar.mdays
128 if month == 2 and calendar.isleap(year): month_days = 29
129 else: month_days = mdays[month]
130 while month < 1 or month > 12 or day < 0 or day > month_days:
131 # now to day under/over
132 if day < 0: month -= 1; day += month_days
133 elif day > month_days: month += 1; day -= month_days
135 # possibly fix up the month so we're within range
136 while month < 1 or month > 12:
137 if month < 1: year -= 1; month += 12
138 if month > 12: year += 1; month -= 12
140 # re-figure the number of days for this month
141 if month == 2 and calendar.isleap(year): month_days = 29
142 else: month_days = mdays[month]
143 return (year, month, day, hour, minute, second, 0, 0, 0)
145 def applyInterval(self, interval):
146 ''' Apply the interval to this date
147 '''
148 self.year, self.month, self.day, self.hour, self.minute, \
149 self.second, x, x, x = self.addInterval(interval)
151 def __add__(self, interval):
152 """Add an interval to this date to produce another date.
153 """
154 return Date(self.addInterval(interval))
156 # XXX deviates from spec to allow subtraction of dates as well
157 def __sub__(self, other):
158 """ Subtract:
159 1. an interval from this date to produce another date.
160 2. a date from this date to produce an interval.
161 """
162 if isinstance(other, Interval):
163 other = Interval(other.get_tuple(), sign=-other.sign)
164 return self.__add__(other)
166 assert isinstance(other, Date), 'May only subtract Dates or Intervals'
168 # TODO this code will fall over laughing if the dates cross
169 # leap years, phases of the moon, ....
170 a = calendar.timegm((self.year, self.month, self.day, self.hour,
171 self.minute, self.second, 0, 0, 0))
172 b = calendar.timegm((other.year, other.month, other.day,
173 other.hour, other.minute, other.second, 0, 0, 0))
174 diff = a - b
175 if diff < 0:
176 sign = 1
177 diff = -diff
178 else:
179 sign = -1
180 S = diff%60
181 M = (diff/60)%60
182 H = (diff/(60*60))%60
183 if H>1: S = 0
184 d = (diff/(24*60*60))%30
185 if d>1: H = S = M = 0
186 m = (diff/(30*24*60*60))%12
187 if m>1: H = S = M = 0
188 y = (diff/(365*24*60*60))
189 if y>1: d = H = S = M = 0
190 return Interval((y, m, d, H, M, S), sign=sign)
192 def __cmp__(self, other):
193 """Compare this date to another date."""
194 if other is None:
195 return 1
196 for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
197 if not hasattr(other, attr):
198 return 1
199 r = cmp(getattr(self, attr), getattr(other, attr))
200 if r: return r
201 return 0
203 def __str__(self):
204 """Return this date as a string in the yyyy-mm-dd.hh:mm:ss format."""
205 return '%4d-%02d-%02d.%02d:%02d:%02d'%(self.year, self.month, self.day,
206 self.hour, self.minute, self.second)
208 def pretty(self):
209 ''' print up the date date using a pretty format...
210 '''
211 str = time.strftime('%d %B %Y', (self.year, self.month,
212 self.day, self.hour, self.minute, self.second, 0, 0, 0))
213 if str[0] == '0': return ' ' + str[1:]
214 return str
216 def set(self, spec, offset=0, date_re=re.compile(r'''
217 (((?P<y>\d\d\d\d)-)?((?P<m>\d\d?)-(?P<d>\d\d?))?)? # yyyy-mm-dd
218 (?P<n>\.)? # .
219 (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d))?)? # hh:mm:ss
220 (?P<o>.+)? # offset
221 ''', re.VERBOSE), serialised_re=re.compile('''
222 (?P<y>\d{4})(?P<m>\d{2})(?P<d>\d{2}) # yyyymmdd
223 (?P<H>\d{2})(?P<M>\d{2})(?P<S>\d{2}) # HHMMSS
224 ''', re.VERBOSE)):
225 ''' set the date to the value in spec
226 '''
227 m = serialised_re.match(spec)
228 if not m:
229 m = date_re.match(spec)
230 if not m:
231 raise ValueError, _('Not a date spec: [[yyyy-]mm-dd].'
232 '[[h]h:mm[:ss]][offset]')
234 info = m.groupdict()
236 # get the current date/time using the offset
237 y,m,d,H,M,S,x,x,x = time.gmtime(time.time())
239 # override year, month, day parts
240 if info['m'] is not None and info['d'] is not None:
241 m = int(info['m'])
242 d = int(info['d'])
243 if info['y'] is not None: y = int(info['y'])
244 H = M = S = 0
246 # override hour, minute, second parts
247 if info['H'] is not None and info['M'] is not None:
248 H = int(info['H']) - offset
249 M = int(info['M'])
250 S = 0
251 if info['S'] is not None: S = int(info['S'])
253 # now handle the adjustment of hour
254 ts = calendar.timegm((y,m,d,H,M,S,0,0,0))
255 self.year, self.month, self.day, self.hour, self.minute, \
256 self.second, x, x, x = time.gmtime(ts)
258 if info.get('o', None):
259 self.applyInterval(Interval(info['o']))
261 def __repr__(self):
262 return '<Date %s>'%self.__str__()
264 def local(self, offset):
265 """Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone."""
266 t = (self.year, self.month, self.day, self.hour + offset, self.minute,
267 self.second, 0, 0, 0)
268 self.year, self.month, self.day, self.hour, self.minute, \
269 self.second, x, x, x = time.gmtime(calendar.timegm(t))
271 def get_tuple(self):
272 return (self.year, self.month, self.day, self.hour, self.minute,
273 self.second, 0, 0, 0)
275 def serialise(self):
276 return '%4d%02d%02d%02d%02d%02d'%(self.year, self.month,
277 self.day, self.hour, self.minute, self.second)
279 class Interval:
280 '''
281 Date intervals are specified using the suffixes "y", "m", and "d". The
282 suffix "w" (for "week") means 7 days. Time intervals are specified in
283 hh:mm:ss format (the seconds may be omitted, but the hours and minutes
284 may not).
286 "3y" means three years
287 "2y 1m" means two years and one month
288 "1m 25d" means one month and 25 days
289 "2w 3d" means two weeks and three days
290 "1d 2:50" means one day, two hours, and 50 minutes
291 "14:00" means 14 hours
292 "0:04:33" means four minutes and 33 seconds
294 Example usage:
295 >>> Interval(" 3w 1 d 2:00")
296 <Interval 22d 2:00>
297 >>> Date(". + 2d") + Interval("- 3w")
298 <Date 2000-06-07.00:34:02>
300 Intervals are added/subtracted in order of:
301 seconds, minutes, hours, years, months, days
303 Calculations involving monts (eg '+2m') have no effect on days - only
304 days (or over/underflow from hours/mins/secs) will do that, and
305 days-per-month and leap years are accounted for. Leap seconds are not.
307 The interval format 'syyyymmddHHMMSS' (sign, year, month, day, hour,
308 minute, second) is the serialisation format returned by the serialise()
309 method, and is accepted as an argument on instatiation.
311 TODO: more examples, showing the order of addition operation
312 '''
313 def __init__(self, spec, sign=1):
314 """Construct an interval given a specification."""
315 if type(spec) == type(''):
316 self.set(spec)
317 else:
318 self.sign = sign
319 self.year, self.month, self.day, self.hour, self.minute, \
320 self.second = spec
322 def __cmp__(self, other):
323 """Compare this interval to another interval."""
324 if other is None:
325 return 1
326 for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
327 if not hasattr(other, attr):
328 return 1
329 r = cmp(getattr(self, attr), getattr(other, attr))
330 if r: return r
331 return 0
333 def __str__(self):
334 """Return this interval as a string."""
335 sign = {1:'+', -1:'-'}[self.sign]
336 l = [sign]
337 if self.year: l.append('%sy'%self.year)
338 if self.month: l.append('%sm'%self.month)
339 if self.day: l.append('%sd'%self.day)
340 if self.second:
341 l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second))
342 elif self.hour or self.minute:
343 l.append('%d:%02d'%(self.hour, self.minute))
344 return ' '.join(l)
346 def set(self, spec, interval_re=re.compile('''
347 \s*(?P<s>[-+])? # + or -
348 \s*((?P<y>\d+\s*)y)? # year
349 \s*((?P<m>\d+\s*)m)? # month
350 \s*((?P<w>\d+\s*)w)? # week
351 \s*((?P<d>\d+\s*)d)? # day
352 \s*(((?P<H>\d+):(?P<M>\d+))?(:(?P<S>\d+))?)? # time
353 \s*''', re.VERBOSE), serialised_re=re.compile('''
354 (?P<s>[+-])(?P<y>\d{4})(?P<m>\d{2})(?P<d>\d{2})
355 (?P<H>\d{2})(?P<M>\d{2})(?P<S>\d{2})''', re.VERBOSE)):
356 ''' set the date to the value in spec
357 '''
358 self.year = self.month = self.week = self.day = self.hour = \
359 self.minute = self.second = 0
360 self.sign = 1
361 m = serialised_re.match(spec)
362 if not m:
363 m = interval_re.match(spec)
364 if not m:
365 raise ValueError, _('Not an interval spec: [+-] [#y] [#m] [#w] '
366 '[#d] [[[H]H:MM]:SS]')
368 info = m.groupdict()
369 for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day',
370 'H':'hour', 'M':'minute', 'S':'second'}.items():
371 if info.getr(group, None) is not None:
372 setattr(self, attr, int(info[group]))
374 if self.week:
375 self.day = self.day + self.week*7
377 if info['s'] is not None:
378 self.sign = {'+':1, '-':-1}[info['s']]
380 def __repr__(self):
381 return '<Interval %s>'%self.__str__()
383 def pretty(self):
384 ''' print up the date date using one of these nice formats..
385 '''
386 if self.year:
387 if self.year == 1:
388 return _('1 year')
389 else:
390 return _('%(number)s years')%{'number': self.year}
391 elif self.month or self.day > 13:
392 days = (self.month * 30) + self.day
393 if days > 28:
394 if int(days/30) > 1:
395 s = _('%(number)s months')%{'number': int(days/30)}
396 else:
397 s = _('1 month')
398 else:
399 s = _('%(number)s weeks')%{'number': int(days/7)}
400 elif self.day > 7:
401 s = _('1 week')
402 elif self.day > 1:
403 s = _('%(number)s days')%{'number': self.day}
404 elif self.day == 1 or self.hour > 12:
405 if self.sign > 0:
406 return _('tomorrow')
407 else:
408 return _('yesterday')
409 elif self.hour > 1:
410 s = _('%(number)s hours')%{'number': self.hour}
411 elif self.hour == 1:
412 if self.minute < 15:
413 s = _('an hour')
414 elif self.minute/15 == 2:
415 s = _('1 1/2 hours')
416 else:
417 s = _('1 %(number)s/4 hours')%{'number': self.minute/15}
418 elif self.minute < 1:
419 if self.sign > 0:
420 return _('in a moment')
421 else:
422 return _('just now')
423 elif self.minute == 1:
424 s = _('1 minute')
425 elif self.minute < 15:
426 s = _('%(number)s minutes')%{'number': self.minute}
427 elif int(self.minute/15) == 2:
428 s = _('1/2 an hour')
429 else:
430 s = _('%(number)s/4 hour')%{'number': int(self.minute/15)}
431 return s
433 def get_tuple(self):
434 return (self.sign, self.year, self.month, self.day, self.hour,
435 self.minute, self.second)
437 def serialise(self):
438 return '%s%4d%02d%02d%02d%02d%02d'%(self.sign, self.year, self.month,
439 self.day, self.hour, self.minute, self.second)
442 def test():
443 intervals = (" 3w 1 d 2:00", " + 2d", "3w")
444 for interval in intervals:
445 print '>>> Interval("%s")'%interval
446 print `Interval(interval)`
448 dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25",
449 "08-13.22:13", "14:25")
450 for date in dates:
451 print '>>> Date("%s")'%date
452 print `Date(date)`
454 sums = ((". + 2d", "3w"), (".", " 3w 1 d 2:00"))
455 for date, interval in sums:
456 print '>>> Date("%s") + Interval("%s")'%(date, interval)
457 print `Date(date) + Interval(interval)`
459 if __name__ == '__main__':
460 test()
462 #
463 # $Log: not supported by cvs2svn $
464 # Revision 1.23 2002/07/18 23:07:08 richard
465 # Unit tests and a few fixes.
466 #
467 # Revision 1.22 2002/07/14 06:05:50 richard
468 # . fixed the date module so that Date(". - 2d") works
469 #
470 # Revision 1.21 2002/05/15 06:32:46 richard
471 # . reverting to dates for intervals > 2 months sucks
472 #
473 # Revision 1.20 2002/02/21 23:34:51 richard
474 # Oops, there's 24 hours in a day, and subtraction of intervals now works
475 # properly.
476 #
477 # Revision 1.19 2002/02/21 23:11:45 richard
478 # . fixed some problems in date calculations (calendar.py doesn't handle over-
479 # and under-flow). Also, hour/minute/second intervals may now be more than
480 # 99 each.
481 #
482 # Revision 1.18 2002/01/23 20:00:50 jhermann
483 # %e is a UNIXism and not documented for Python
484 #
485 # Revision 1.17 2002/01/16 07:02:57 richard
486 # . lots of date/interval related changes:
487 # - more relaxed date format for input
488 #
489 # Revision 1.16 2002/01/08 11:56:24 richard
490 # missed an import _
491 #
492 # Revision 1.15 2002/01/05 02:27:00 richard
493 # I18N'ification
494 #
495 # Revision 1.14 2001/11/22 15:46:42 jhermann
496 # Added module docstrings to all modules.
497 #
498 # Revision 1.13 2001/09/18 22:58:37 richard
499 #
500 # Added some more help to roundu-admin
501 #
502 # Revision 1.12 2001/08/17 03:08:11 richard
503 # fixed prettification of intervals of 1 week
504 #
505 # Revision 1.11 2001/08/15 23:43:18 richard
506 # Fixed some isFooTypes that I missed.
507 # Refactored some code in the CGI code.
508 #
509 # Revision 1.10 2001/08/07 00:24:42 richard
510 # stupid typo
511 #
512 # Revision 1.9 2001/08/07 00:15:51 richard
513 # Added the copyright/license notice to (nearly) all files at request of
514 # Bizar Software.
515 #
516 # Revision 1.8 2001/08/05 07:46:12 richard
517 # Changed date.Date to use regular string formatting instead of strftime -
518 # win32 seems to have problems with %T and no hour... or something...
519 #
520 # Revision 1.7 2001/08/02 00:27:04 richard
521 # Extended the range of intervals that are pretty-printed before actual dates
522 # are displayed.
523 #
524 # Revision 1.6 2001/07/31 09:54:18 richard
525 # Fixed the 2.1-specific gmtime() (no arg) call in roundup.date. (Paul Wright)
526 #
527 # Revision 1.5 2001/07/29 07:01:39 richard
528 # Added vim command to all source so that we don't get no steenkin' tabs :)
529 #
530 # Revision 1.4 2001/07/25 04:09:34 richard
531 # Fixed offset handling (shoulda read the spec a little better)
532 #
533 # Revision 1.3 2001/07/23 07:56:05 richard
534 # Storing only marshallable data in the db - no nasty pickled class references.
535 #
536 # Revision 1.2 2001/07/22 12:09:32 richard
537 # Final commit of Grande Splite
538 #
539 # Revision 1.1 2001/07/22 11:58:35 richard
540 # More Grande Splite
541 #
542 #
543 # vim: set filetype=python ts=4 sw=4 et si