Commit | Line | Data |
---|---|---|
c638d827 CR |
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 $ | |
19 | ||
20 | """Date, time and time interval handling. | |
21 | """ | |
22 | __docformat__ = 'restructuredtext' | |
23 | ||
24 | import calendar | |
25 | import datetime | |
26 | import time | |
27 | import re | |
28 | ||
29 | try: | |
30 | import pytz | |
31 | except ImportError: | |
32 | pytz = None | |
33 | ||
34 | from roundup import i18n | |
35 | ||
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) | |
48 | ||
49 | _timedelta0 = datetime.timedelta(0) | |
50 | ||
51 | # load UTC tzinfo | |
52 | if pytz: | |
53 | UTC = pytz.utc | |
54 | else: | |
55 | # fallback implementation from Python Library Reference | |
56 | ||
57 | class _UTC(datetime.tzinfo): | |
58 | ||
59 | """Universal Coordinated Time zoneinfo""" | |
60 | ||
61 | def utcoffset(self, dt): | |
62 | return _timedelta0 | |
63 | ||
64 | def tzname(self, dt): | |
65 | return "UTC" | |
66 | ||
67 | def dst(self, dt): | |
68 | return _timedelta0 | |
69 | ||
70 | def __repr__(self): | |
71 | return "<UTC>" | |
72 | ||
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. | |
77 | ||
78 | def normalize(self, dt, is_dst=False): | |
79 | return dt.replace(tzinfo=self) | |
80 | ||
81 | def localize(self, dt, is_dst=False): | |
82 | return dt.replace(tzinfo=self) | |
83 | ||
84 | UTC = _UTC() | |
85 | ||
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): | |
89 | ||
90 | """Simple zoneinfo with fixed numeric offset and no daylight savings""" | |
91 | ||
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 | |
99 | ||
100 | def utcoffset(self, dt): | |
101 | return datetime.timedelta(hours=self.offset) | |
102 | ||
103 | def tzname(self, dt): | |
104 | return self.name | |
105 | ||
106 | def dst(self, dt): | |
107 | return _timedelta0 | |
108 | ||
109 | def __repr__(self): | |
110 | return "<%s: %s>" % (self.__class__.__name__, self.name) | |
111 | ||
112 | # pytz adjustments interface | |
113 | ||
114 | def normalize(self, dt): | |
115 | return dt.replace(tzinfo=self) | |
116 | ||
117 | def localize(self, dt, is_dst=False): | |
118 | return dt.replace(tzinfo=self) | |
119 | ||
120 | # simple timezones with fixed offset | |
121 | _tzoffsets = dict(GMT=0, UCT=0, EST=5, MST=7, HST=10) | |
122 | ||
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 | |
147 | ||
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) | |
155 | ||
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) | |
161 | ||
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. | |
172 | ||
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:: | |
181 | ||
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" | |
192 | ||
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:: | |
200 | ||
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> | |
215 | ||
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. | |
219 | ||
220 | The date class handles basic arithmetic:: | |
221 | ||
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 | ''' | |
236 | ||
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. | |
240 | ||
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,) | |
274 | ||
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 | ''' | |
279 | ||
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 | |
288 | ||
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"') | |
295 | ||
296 | info = m.groupdict() | |
297 | ||
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')) | |
316 | ||
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. | |
321 | ||
322 | # whether we need to convert to UTC | |
323 | adjust = False | |
324 | ||
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 | |
339 | ||
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 | |
348 | ||
349 | ||
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. | |
360 | ||
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,) | |
368 | ||
369 | # adjust by added granularity | |
370 | if add_granularity: | |
371 | self.applyInterval(add_granularity) | |
372 | self.applyInterval(Interval('- 00:00:01')) | |
373 | ||
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 | |
386 | ||
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 | |
397 | ||
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 | |
402 | ||
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] | |
407 | ||
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 | |
418 | ||
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 | |
423 | ||
424 | return (year, month, day, hour, minute, second, 0, 0, 0) | |
425 | ||
426 | def differenceDate(self, other): | |
427 | "Return the difference between this date and another date" | |
428 | return self - other | |
429 | ||
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) | |
435 | ||
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) | |
440 | ||
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) | |
451 | ||
452 | assert isinstance(other, Date), 'May only subtract Dates or Intervals' | |
453 | ||
454 | return self.dateDelta(other) | |
455 | ||
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) | |
479 | ||
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) | |
494 | ||
495 | def __str__(self): | |
496 | """Return this date as a string in the yyyy-mm-dd.hh:mm:ss format.""" | |
497 | return self.formal() | |
498 | ||
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) | |
503 | ||
504 | def pretty(self, format='%d %B %Y'): | |
505 | ''' print up the date date using a pretty format... | |
506 | ||
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) | |
514 | ||
515 | # handle zero day by removing it | |
516 | if format.startswith('%d') and str[0] == '0': | |
517 | return ' ' + str[1:] | |
518 | return str | |
519 | ||
520 | def __repr__(self): | |
521 | return '<Date %s>'%self.formal(sec='%06.3f') | |
522 | ||
523 | def local(self, offset): | |
524 | """ Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone. | |
525 | """ | |
526 | y, m, d, H, M, S = _utc_to_local(self.year, self.month, self.day, | |
527 | self.hour, self.minute, self.second, offset) | |
528 | return Date((y, m, d, H, M, S, 0, 0, 0), translator=self.translator) | |
529 | ||
530 | def __deepcopy__(self, memo): | |
531 | return Date((self.year, self.month, self.day, self.hour, | |
532 | self.minute, self.second, 0, 0, 0), translator=self.translator) | |
533 | ||
534 | def get_tuple(self): | |
535 | return (self.year, self.month, self.day, self.hour, self.minute, | |
536 | self.second, 0, 0, 0) | |
537 | ||
538 | def serialise(self): | |
539 | return '%04d%02d%02d%02d%02d%06.3f'%(self.year, self.month, | |
540 | self.day, self.hour, self.minute, self.second) | |
541 | ||
542 | def timestamp(self): | |
543 | ''' return a UNIX timestamp for this date ''' | |
544 | frac = self.second - int(self.second) | |
545 | ts = calendar.timegm((self.year, self.month, self.day, self.hour, | |
546 | self.minute, self.second, 0, 0, 0)) | |
547 | # we lose the fractional part | |
548 | return ts + frac | |
549 | ||
550 | def setTranslator(self, translator): | |
551 | """Replace the translation engine | |
552 | ||
553 | 'translator' | |
554 | is i18n module or one of gettext translation classes. | |
555 | It must have attributes 'gettext' and 'ngettext', | |
556 | serving as translation functions. | |
557 | """ | |
558 | self.translator = translator | |
559 | self._ = translator.gettext | |
560 | self.ngettext = translator.ngettext | |
561 | ||
562 | def fromtimestamp(cls, ts): | |
563 | """Create a date object from a timestamp. | |
564 | ||
565 | The timestamp may be outside the gmtime year-range of | |
566 | 1902-2038. | |
567 | """ | |
568 | usec = int((ts - int(ts)) * 1000000.) | |
569 | delta = datetime.timedelta(seconds = int(ts), microseconds = usec) | |
570 | return cls(datetime.datetime(1970, 1, 1) + delta) | |
571 | fromtimestamp = classmethod(fromtimestamp) | |
572 | ||
573 | class Interval: | |
574 | ''' | |
575 | Date intervals are specified using the suffixes "y", "m", and "d". The | |
576 | suffix "w" (for "week") means 7 days. Time intervals are specified in | |
577 | hh:mm:ss format (the seconds may be omitted, but the hours and minutes | |
578 | may not). | |
579 | ||
580 | "3y" means three years | |
581 | "2y 1m" means two years and one month | |
582 | "1m 25d" means one month and 25 days | |
583 | "2w 3d" means two weeks and three days | |
584 | "1d 2:50" means one day, two hours, and 50 minutes | |
585 | "14:00" means 14 hours | |
586 | "0:04:33" means four minutes and 33 seconds | |
587 | ||
588 | Example usage: | |
589 | >>> Interval(" 3w 1 d 2:00") | |
590 | <Interval + 22d 2:00> | |
591 | >>> Date(". + 2d") + Interval("- 3w") | |
592 | <Date 2000-06-07.00:34:02> | |
593 | >>> Interval('1:59:59') + Interval('00:00:01') | |
594 | <Interval + 2:00> | |
595 | >>> Interval('2:00') + Interval('- 00:00:01') | |
596 | <Interval + 1:59:59> | |
597 | >>> Interval('1y')/2 | |
598 | <Interval + 6m> | |
599 | >>> Interval('1:00')/2 | |
600 | <Interval + 0:30> | |
601 | >>> Interval('2003-03-18') | |
602 | <Interval + [number of days between now and 2003-03-18]> | |
603 | >>> Interval('-4d 2003-03-18') | |
604 | <Interval + [number of days between now and 2003-03-14]> | |
605 | ||
606 | Interval arithmetic is handled in a couple of special ways, trying | |
607 | to cater for the most common cases. Fundamentally, Intervals which | |
608 | have both date and time parts will result in strange results in | |
609 | arithmetic - because of the impossibility of handling day->month->year | |
610 | over- and under-flows. Intervals may also be divided by some number. | |
611 | ||
612 | Intervals are added to Dates in order of: | |
613 | seconds, minutes, hours, years, months, days | |
614 | ||
615 | Calculations involving months (eg '+2m') have no effect on days - only | |
616 | days (or over/underflow from hours/mins/secs) will do that, and | |
617 | days-per-month and leap years are accounted for. Leap seconds are not. | |
618 | ||
619 | The interval format 'syyyymmddHHMMSS' (sign, year, month, day, hour, | |
620 | minute, second) is the serialisation format returned by the serialise() | |
621 | method, and is accepted as an argument on instatiation. | |
622 | ||
623 | TODO: more examples, showing the order of addition operation | |
624 | ''' | |
625 | def __init__(self, spec, sign=1, allowdate=1, add_granularity=False, | |
626 | translator=i18n | |
627 | ): | |
628 | """Construct an interval given a specification.""" | |
629 | self.setTranslator(translator) | |
630 | if isinstance(spec, (int, float, long)): | |
631 | self.from_seconds(spec) | |
632 | elif isinstance(spec, basestring): | |
633 | self.set(spec, allowdate=allowdate, add_granularity=add_granularity) | |
634 | elif isinstance(spec, Interval): | |
635 | (self.sign, self.year, self.month, self.day, self.hour, | |
636 | self.minute, self.second) = spec.get_tuple() | |
637 | else: | |
638 | if len(spec) == 7: | |
639 | self.sign, self.year, self.month, self.day, self.hour, \ | |
640 | self.minute, self.second = spec | |
641 | self.second = int(self.second) | |
642 | else: | |
643 | # old, buggy spec form | |
644 | self.sign = sign | |
645 | self.year, self.month, self.day, self.hour, self.minute, \ | |
646 | self.second = spec | |
647 | self.second = int(self.second) | |
648 | ||
649 | def __deepcopy__(self, memo): | |
650 | return Interval((self.sign, self.year, self.month, self.day, | |
651 | self.hour, self.minute, self.second), translator=self.translator) | |
652 | ||
653 | def set(self, spec, allowdate=1, interval_re=re.compile(''' | |
654 | \s*(?P<s>[-+])? # + or - | |
655 | \s*((?P<y>\d+\s*)y)? # year | |
656 | \s*((?P<m>\d+\s*)m)? # month | |
657 | \s*((?P<w>\d+\s*)w)? # week | |
658 | \s*((?P<d>\d+\s*)d)? # day | |
659 | \s*(((?P<H>\d+):(?P<M>\d+))?(:(?P<S>\d+))?)? # time | |
660 | \s*(?P<D> | |
661 | (\d\d\d\d[/-])?(\d\d?)?[/-](\d\d?)? # [yyyy-]mm-dd | |
662 | \.? # . | |
663 | (\d?\d:\d\d)?(:\d\d)? # hh:mm:ss | |
664 | )?''', re.VERBOSE), serialised_re=re.compile(''' | |
665 | (?P<s>[+-])?1?(?P<y>([ ]{3}\d|\d{4}))(?P<m>\d{2})(?P<d>\d{2}) | |
666 | (?P<H>\d{2})(?P<M>\d{2})(?P<S>\d{2})''', re.VERBOSE), | |
667 | add_granularity=False): | |
668 | ''' set the date to the value in spec | |
669 | ''' | |
670 | self.year = self.month = self.week = self.day = self.hour = \ | |
671 | self.minute = self.second = 0 | |
672 | self.sign = 1 | |
673 | m = serialised_re.match(spec) | |
674 | if not m: | |
675 | m = interval_re.match(spec) | |
676 | if not m: | |
677 | raise ValueError, self._('Not an interval spec:' | |
678 | ' [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]') | |
679 | else: | |
680 | allowdate = 0 | |
681 | ||
682 | # pull out all the info specified | |
683 | info = m.groupdict() | |
684 | if add_granularity: | |
685 | for gran in 'SMHdwmy': | |
686 | if info[gran] is not None: | |
687 | info[gran] = int(info[gran]) + (info['s']=='-' and -1 or 1) | |
688 | break | |
689 | ||
690 | valid = 0 | |
691 | for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day', | |
692 | 'H':'hour', 'M':'minute', 'S':'second'}.items(): | |
693 | if info.get(group, None) is not None: | |
694 | valid = 1 | |
695 | setattr(self, attr, int(info[group])) | |
696 | ||
697 | # make sure it's valid | |
698 | if not valid and not info['D']: | |
699 | raise ValueError, self._('Not an interval spec:' | |
700 | ' [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]') | |
701 | ||
702 | if self.week: | |
703 | self.day = self.day + self.week*7 | |
704 | ||
705 | if info['s'] is not None: | |
706 | self.sign = {'+':1, '-':-1}[info['s']] | |
707 | ||
708 | # use a date spec if one is given | |
709 | if allowdate and info['D'] is not None: | |
710 | now = Date('.') | |
711 | date = Date(info['D']) | |
712 | # if no time part was specified, nuke it in the "now" date | |
713 | if not date.hour or date.minute or date.second: | |
714 | now.hour = now.minute = now.second = 0 | |
715 | if date != now: | |
716 | y = now - (date + self) | |
717 | self.__init__(y.get_tuple()) | |
718 | ||
719 | def __cmp__(self, other): | |
720 | """Compare this interval to another interval.""" | |
721 | if other is None: | |
722 | # we are always larger than None | |
723 | return 1 | |
724 | for attr in 'sign year month day hour minute second'.split(): | |
725 | r = cmp(getattr(self, attr), getattr(other, attr)) | |
726 | if r: | |
727 | return r | |
728 | return 0 | |
729 | ||
730 | def __str__(self): | |
731 | """Return this interval as a string.""" | |
732 | l = [] | |
733 | if self.year: l.append('%sy'%self.year) | |
734 | if self.month: l.append('%sm'%self.month) | |
735 | if self.day: l.append('%sd'%self.day) | |
736 | if self.second: | |
737 | l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second)) | |
738 | elif self.hour or self.minute: | |
739 | l.append('%d:%02d'%(self.hour, self.minute)) | |
740 | if l: | |
741 | l.insert(0, {1:'+', -1:'-'}[self.sign]) | |
742 | else: | |
743 | l.append('00:00') | |
744 | return ' '.join(l) | |
745 | ||
746 | def __add__(self, other): | |
747 | if isinstance(other, Date): | |
748 | # the other is a Date - produce a Date | |
749 | return Date(other.addInterval(self), translator=self.translator) | |
750 | elif isinstance(other, Interval): | |
751 | # add the other Interval to this one | |
752 | a = self.get_tuple() | |
753 | asgn = a[0] | |
754 | b = other.get_tuple() | |
755 | bsgn = b[0] | |
756 | i = [asgn*x + bsgn*y for x,y in zip(a[1:],b[1:])] | |
757 | i.insert(0, 1) | |
758 | i = fixTimeOverflow(i) | |
759 | return Interval(i, translator=self.translator) | |
760 | # nope, no idea what to do with this other... | |
761 | raise TypeError, "Can't add %r"%other | |
762 | ||
763 | def __sub__(self, other): | |
764 | if isinstance(other, Date): | |
765 | # the other is a Date - produce a Date | |
766 | interval = Interval(self.get_tuple()) | |
767 | interval.sign *= -1 | |
768 | return Date(other.addInterval(interval), | |
769 | translator=self.translator) | |
770 | elif isinstance(other, Interval): | |
771 | # add the other Interval to this one | |
772 | a = self.get_tuple() | |
773 | asgn = a[0] | |
774 | b = other.get_tuple() | |
775 | bsgn = b[0] | |
776 | i = [asgn*x - bsgn*y for x,y in zip(a[1:],b[1:])] | |
777 | i.insert(0, 1) | |
778 | i = fixTimeOverflow(i) | |
779 | return Interval(i, translator=self.translator) | |
780 | # nope, no idea what to do with this other... | |
781 | raise TypeError, "Can't add %r"%other | |
782 | ||
783 | def __div__(self, other): | |
784 | """ Divide this interval by an int value. | |
785 | ||
786 | Can't divide years and months sensibly in the _same_ | |
787 | calculation as days/time, so raise an error in that situation. | |
788 | """ | |
789 | try: | |
790 | other = float(other) | |
791 | except TypeError: | |
792 | raise ValueError, "Can only divide Intervals by numbers" | |
793 | ||
794 | y, m, d, H, M, S = (self.year, self.month, self.day, | |
795 | self.hour, self.minute, self.second) | |
796 | if y or m: | |
797 | if d or H or M or S: | |
798 | raise ValueError, "Can't divide Interval with date and time" | |
799 | months = self.year*12 + self.month | |
800 | months *= self.sign | |
801 | ||
802 | months = int(months/other) | |
803 | ||
804 | sign = months<0 and -1 or 1 | |
805 | m = months%12 | |
806 | y = months / 12 | |
807 | return Interval((sign, y, m, 0, 0, 0, 0), | |
808 | translator=self.translator) | |
809 | ||
810 | else: | |
811 | # handle a day/time division | |
812 | seconds = S + M*60 + H*60*60 + d*60*60*24 | |
813 | seconds *= self.sign | |
814 | ||
815 | seconds = int(seconds/other) | |
816 | ||
817 | sign = seconds<0 and -1 or 1 | |
818 | seconds *= sign | |
819 | S = seconds%60 | |
820 | seconds /= 60 | |
821 | M = seconds%60 | |
822 | seconds /= 60 | |
823 | H = seconds%24 | |
824 | d = seconds / 24 | |
825 | return Interval((sign, 0, 0, d, H, M, S), | |
826 | translator=self.translator) | |
827 | ||
828 | def __repr__(self): | |
829 | return '<Interval %s>'%self.__str__() | |
830 | ||
831 | def pretty(self): | |
832 | ''' print up the date date using one of these nice formats.. | |
833 | ''' | |
834 | _quarters = self.minute / 15 | |
835 | if self.year: | |
836 | s = self.ngettext("%(number)s year", "%(number)s years", | |
837 | self.year) % {'number': self.year} | |
838 | elif self.month or self.day > 28: | |
839 | _months = max(1, int(((self.month * 30) + self.day) / 30)) | |
840 | s = self.ngettext("%(number)s month", "%(number)s months", | |
841 | _months) % {'number': _months} | |
842 | elif self.day > 7: | |
843 | _weeks = int(self.day / 7) | |
844 | s = self.ngettext("%(number)s week", "%(number)s weeks", | |
845 | _weeks) % {'number': _weeks} | |
846 | elif self.day > 1: | |
847 | # Note: singular form is not used | |
848 | s = self.ngettext('%(number)s day', '%(number)s days', | |
849 | self.day) % {'number': self.day} | |
850 | elif self.day == 1 or self.hour > 12: | |
851 | if self.sign > 0: | |
852 | return self._('tomorrow') | |
853 | else: | |
854 | return self._('yesterday') | |
855 | elif self.hour > 1: | |
856 | # Note: singular form is not used | |
857 | s = self.ngettext('%(number)s hour', '%(number)s hours', | |
858 | self.hour) % {'number': self.hour} | |
859 | elif self.hour == 1: | |
860 | if self.minute < 15: | |
861 | s = self._('an hour') | |
862 | elif _quarters == 2: | |
863 | s = self._('1 1/2 hours') | |
864 | else: | |
865 | s = self.ngettext('1 %(number)s/4 hours', | |
866 | '1 %(number)s/4 hours', _quarters)%{'number': _quarters} | |
867 | elif self.minute < 1: | |
868 | if self.sign > 0: | |
869 | return self._('in a moment') | |
870 | else: | |
871 | return self._('just now') | |
872 | elif self.minute == 1: | |
873 | # Note: used in expressions "in 1 minute" or "1 minute ago" | |
874 | s = self._('1 minute') | |
875 | elif self.minute < 15: | |
876 | # Note: used in expressions "in 2 minutes" or "2 minutes ago" | |
877 | s = self.ngettext('%(number)s minute', '%(number)s minutes', | |
878 | self.minute) % {'number': self.minute} | |
879 | elif _quarters == 2: | |
880 | s = self._('1/2 an hour') | |
881 | else: | |
882 | s = self.ngettext('%(number)s/4 hour', '%(number)s/4 hours', | |
883 | _quarters) % {'number': _quarters} | |
884 | # XXX this is internationally broken | |
885 | if self.sign < 0: | |
886 | s = self._('%s ago') % s | |
887 | else: | |
888 | s = self._('in %s') % s | |
889 | return s | |
890 | ||
891 | def get_tuple(self): | |
892 | return (self.sign, self.year, self.month, self.day, self.hour, | |
893 | self.minute, self.second) | |
894 | ||
895 | def serialise(self): | |
896 | sign = self.sign > 0 and '+' or '-' | |
897 | return '%s%04d%02d%02d%02d%02d%02d'%(sign, self.year, self.month, | |
898 | self.day, self.hour, self.minute, self.second) | |
899 | ||
900 | def as_seconds(self): | |
901 | '''Calculate the Interval as a number of seconds. | |
902 | ||
903 | Months are counted as 30 days, years as 365 days. Returns a Long | |
904 | int. | |
905 | ''' | |
906 | n = self.year * 365L | |
907 | n = n + self.month * 30 | |
908 | n = n + self.day | |
909 | n = n * 24 | |
910 | n = n + self.hour | |
911 | n = n * 60 | |
912 | n = n + self.minute | |
913 | n = n * 60 | |
914 | n = n + self.second | |
915 | return n * self.sign | |
916 | ||
917 | def from_seconds(self, val): | |
918 | '''Figure my second, minute, hour and day values using a seconds | |
919 | value. | |
920 | ''' | |
921 | val = int(val) | |
922 | if val < 0: | |
923 | self.sign = -1 | |
924 | val = -val | |
925 | else: | |
926 | self.sign = 1 | |
927 | self.second = val % 60 | |
928 | val = val / 60 | |
929 | self.minute = val % 60 | |
930 | val = val / 60 | |
931 | self.hour = val % 24 | |
932 | val = val / 24 | |
933 | self.day = val | |
934 | self.month = self.year = 0 | |
935 | ||
936 | def setTranslator(self, translator): | |
937 | """Replace the translation engine | |
938 | ||
939 | 'translator' | |
940 | is i18n module or one of gettext translation classes. | |
941 | It must have attributes 'gettext' and 'ngettext', | |
942 | serving as translation functions. | |
943 | """ | |
944 | self.translator = translator | |
945 | self._ = translator.gettext | |
946 | self.ngettext = translator.ngettext | |
947 | ||
948 | ||
949 | def fixTimeOverflow(time): | |
950 | """ Handle the overflow in the time portion (H, M, S) of "time": | |
951 | (sign, y,m,d,H,M,S) | |
952 | ||
953 | Overflow and underflow will at most affect the _days_ portion of | |
954 | the date. We do not overflow days to months as we don't know _how_ | |
955 | to, generally. | |
956 | """ | |
957 | # XXX we could conceivably use this function for handling regular dates | |
958 | # XXX too - we just need to interrogate the month/year for the day | |
959 | # XXX overflow... | |
960 | ||
961 | sign, y, m, d, H, M, S = time | |
962 | seconds = sign * (S + M*60 + H*60*60 + d*60*60*24) | |
963 | if seconds: | |
964 | sign = seconds<0 and -1 or 1 | |
965 | seconds *= sign | |
966 | S = seconds%60 | |
967 | seconds /= 60 | |
968 | M = seconds%60 | |
969 | seconds /= 60 | |
970 | H = seconds%24 | |
971 | d = seconds / 24 | |
972 | else: | |
973 | months = y*12 + m | |
974 | sign = months<0 and -1 or 1 | |
975 | months *= sign | |
976 | m = months%12 | |
977 | y = months/12 | |
978 | ||
979 | return (sign, y, m, d, H, M, S) | |
980 | ||
981 | class Range: | |
982 | """Represents range between two values | |
983 | Ranges can be created using one of theese two alternative syntaxes: | |
984 | ||
985 | 1. Native english syntax:: | |
986 | ||
987 | [[From] <value>][ To <value>] | |
988 | ||
989 | Keywords "From" and "To" are case insensitive. Keyword "From" is | |
990 | optional. | |
991 | ||
992 | 2. "Geek" syntax:: | |
993 | ||
994 | [<value>][; <value>] | |
995 | ||
996 | Either first or second <value> can be omitted in both syntaxes. | |
997 | ||
998 | Examples (consider local time is Sat Mar 8 22:07:48 EET 2003):: | |
999 | ||
1000 | >>> Range("from 2-12 to 4-2") | |
1001 | <Range from 2003-02-12.00:00:00 to 2003-04-02.00:00:00> | |
1002 | ||
1003 | >>> Range("18:00 TO +2m") | |
1004 | <Range from 2003-03-08.18:00:00 to 2003-05-08.20:07:48> | |
1005 | ||
1006 | >>> Range("12:00") | |
1007 | <Range from 2003-03-08.12:00:00 to None> | |
1008 | ||
1009 | >>> Range("tO +3d") | |
1010 | <Range from None to 2003-03-11.20:07:48> | |
1011 | ||
1012 | >>> Range("2002-11-10; 2002-12-12") | |
1013 | <Range from 2002-11-10.00:00:00 to 2002-12-12.00:00:00> | |
1014 | ||
1015 | >>> Range("; 20:00 +1d") | |
1016 | <Range from None to 2003-03-09.20:00:00> | |
1017 | ||
1018 | """ | |
1019 | def __init__(self, spec, Type, allow_granularity=True, **params): | |
1020 | """Initializes Range of type <Type> from given <spec> string. | |
1021 | ||
1022 | Sets two properties - from_value and to_value. None assigned to any of | |
1023 | this properties means "infinitum" (-infinitum to from_value and | |
1024 | +infinitum to to_value) | |
1025 | ||
1026 | The Type parameter here should be class itself (e.g. Date), not a | |
1027 | class instance. | |
1028 | """ | |
1029 | self.range_type = Type | |
1030 | re_range = r'(?:^|from(.+?))(?:to(.+?)$|$)' | |
1031 | re_geek_range = r'(?:^|(.+?));(?:(.+?)$|$)' | |
1032 | # Check which syntax to use | |
1033 | if ';' in spec: | |
1034 | # Geek | |
1035 | m = re.search(re_geek_range, spec.strip()) | |
1036 | else: | |
1037 | # Native english | |
1038 | m = re.search(re_range, spec.strip(), re.IGNORECASE) | |
1039 | if m: | |
1040 | self.from_value, self.to_value = m.groups() | |
1041 | if self.from_value: | |
1042 | self.from_value = Type(self.from_value.strip(), **params) | |
1043 | if self.to_value: | |
1044 | self.to_value = Type(self.to_value.strip(), **params) | |
1045 | else: | |
1046 | if allow_granularity: | |
1047 | self.from_value = Type(spec, **params) | |
1048 | self.to_value = Type(spec, add_granularity=True, **params) | |
1049 | else: | |
1050 | raise ValueError, "Invalid range" | |
1051 | ||
1052 | def __str__(self): | |
1053 | return "from %s to %s" % (self.from_value, self.to_value) | |
1054 | ||
1055 | def __repr__(self): | |
1056 | return "<Range %s>" % self.__str__() | |
1057 | ||
1058 | def test_range(): | |
1059 | rspecs = ("from 2-12 to 4-2", "from 18:00 TO +2m", "12:00;", "tO +3d", | |
1060 | "2002-11-10; 2002-12-12", "; 20:00 +1d", '2002-10-12') | |
1061 | rispecs = ('from -1w 2d 4:32 to 4d', '-2w 1d') | |
1062 | for rspec in rspecs: | |
1063 | print '>>> Range("%s")' % rspec | |
1064 | print `Range(rspec, Date)` | |
1065 | ||
1066 | for rspec in rispecs: | |
1067 | print '>>> Range("%s")' % rspec | |
1068 | print `Range(rspec, Interval)` | |
1069 | ||
1070 | ||
1071 | def test(): | |
1072 | intervals = (" 3w 1 d 2:00", " + 2d", "3w") | |
1073 | for interval in intervals: | |
1074 | print '>>> Interval("%s")'%interval | |
1075 | print `Interval(interval)` | |
1076 | ||
1077 | dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25", | |
1078 | "08-13.22:13", "14:25", '2002-12') | |
1079 | for date in dates: | |
1080 | print '>>> Date("%s")'%date | |
1081 | print `Date(date)` | |
1082 | ||
1083 | sums = ((". + 2d", "3w"), (".", " 3w 1 d 2:00")) | |
1084 | for date, interval in sums: | |
1085 | print '>>> Date("%s") + Interval("%s")'%(date, interval) | |
1086 | print `Date(date) + Interval(interval)` | |
1087 | ||
1088 | if __name__ == '__main__': | |
1089 | test() | |
1090 | ||
1091 | # vim: set filetype=python sts=4 sw=4 et si : |