1 # -*- coding: iso-8859-1 -*- 2 """ 3 MoinMoin - DateSupport library (derived from EventAggregatorSupport) 4 5 @copyright: 2008, 2009, 2010, 2011, 2012 by Paul Boddie <paul@boddie.org.uk> 6 @license: GNU GPL (v2 or later), see COPYING.txt for details. 7 """ 8 9 import calendar 10 import datetime 11 import re 12 import bisect 13 14 try: 15 import pytz 16 except ImportError: 17 pytz = None 18 19 __version__ = "0.1" 20 21 # Date labels. 22 23 month_labels = ["January", "February", "March", "April", "May", "June", 24 "July", "August", "September", "October", "November", "December"] 25 weekday_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] 26 27 # Month, date, time and datetime parsing. 28 29 month_regexp_str = ur'(?P<year>[0-9]{4})-(?P<month>[0-9]{2})' 30 date_regexp_str = ur'(?P<year>[0-9]{4})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})' 31 time_regexp_str = ur'(?P<hour>[0-2][0-9]):(?P<minute>[0-5][0-9])(?::(?P<second>[0-6][0-9]))?' 32 timezone_offset_str = ur'(?P<offset>(UTC)?(?:(?P<sign>[-+])(?P<hours>[0-9]{2})(?::?(?P<minutes>[0-9]{2}))?))' 33 timezone_olson_str = ur'(?P<olson>[a-zA-Z]+(?:/[-_a-zA-Z]+){1,2})' 34 timezone_utc_str = ur'UTC' 35 timezone_regexp_str = ur'(?P<zone>' + timezone_offset_str + '|' + timezone_olson_str + '|' + timezone_utc_str + ')' 36 datetime_regexp_str = date_regexp_str + ur'(?:\s+' + time_regexp_str + ur'(?:\s+' + timezone_regexp_str + ur')?)?' 37 38 month_regexp = re.compile(month_regexp_str, re.UNICODE) 39 date_regexp = re.compile(date_regexp_str, re.UNICODE) 40 time_regexp = re.compile(time_regexp_str, re.UNICODE) 41 timezone_olson_regexp = re.compile(timezone_olson_str, re.UNICODE) 42 timezone_offset_regexp = re.compile(timezone_offset_str, re.UNICODE) 43 datetime_regexp = re.compile(datetime_regexp_str, re.UNICODE) 44 45 # iCalendar date and datetime parsing. 46 47 date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})' 48 datetime_icalendar_regexp_str = date_icalendar_regexp_str + \ 49 ur'(?:' \ 50 ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \ 51 ur'(?P<utc>Z)?' \ 52 ur')?' 53 54 date_icalendar_regexp = re.compile(date_icalendar_regexp_str, re.UNICODE) 55 datetime_icalendar_regexp = re.compile(datetime_icalendar_regexp_str, re.UNICODE) 56 57 # Utility functions. 58 59 def int_or_none(x): 60 if x is None: 61 return x 62 else: 63 return int(x) 64 65 def getMonthLabel(month): 66 67 "Return an unlocalised label for the given 'month'." 68 69 return month_labels[month - 1] # zero-based labels 70 71 def getDayLabel(weekday): 72 73 "Return an unlocalised label for the given 'weekday'." 74 75 return weekday_labels[weekday] 76 77 # Interfaces. 78 79 class ActsAsTimespan: 80 pass 81 82 # Date-related functions. 83 84 def cmp_dates_as_day_start(a, b): 85 86 """ 87 Compare dates/datetimes 'a' and 'b' treating dates without time information 88 as the earliest time in a particular day. 89 """ 90 91 are_equal = a == b 92 93 if are_equal: 94 a2 = a.as_datetime_or_date() 95 b2 = b.as_datetime_or_date() 96 97 if isinstance(a2, Date) and isinstance(b2, DateTime): 98 return -1 99 elif isinstance(a2, DateTime) and isinstance(b2, Date): 100 return 1 101 102 return cmp(a, b) 103 104 class Convertible: 105 106 "Support for converting temporal objects." 107 108 def _get_converter(self, resolution): 109 if resolution == "month": 110 return lambda x: x and x.as_month() 111 elif resolution == "date": 112 return lambda x: x and x.as_date() 113 elif resolution == "datetime": 114 return lambda x: x and x.as_datetime_or_date() 115 else: 116 return lambda x: x 117 118 class Temporal(Convertible): 119 120 "A simple temporal representation, common to dates and times." 121 122 def __init__(self, data): 123 self.data = list(data) 124 125 def __repr__(self): 126 return "%s(%r)" % (self.__class__.__name__, self.data) 127 128 def __hash__(self): 129 return hash(self.as_tuple()) 130 131 def as_tuple(self): 132 return tuple(self.data) 133 134 def convert(self, resolution): 135 return self._get_converter(resolution)(self) 136 137 def __cmp__(self, other): 138 139 """ 140 The result of comparing this instance with 'other' is derived from a 141 comparison of the instances' date(time) data at the highest common 142 resolution, meaning that if a date is compared to a datetime, the 143 datetime will be considered as a date. Thus, a date and a datetime 144 referring to the same date will be considered equal. 145 """ 146 147 if not isinstance(other, Temporal): 148 return NotImplemented 149 else: 150 data = self.as_tuple() 151 other_data = other.as_tuple() 152 length = min(len(data), len(other_data)) 153 return cmp(data[:length], other_data[:length]) 154 155 def __sub__(self, other): 156 157 """ 158 Return the difference between this object and the 'other' object at the 159 highest common accuracy of both objects. 160 """ 161 162 if not isinstance(other, Temporal): 163 return NotImplemented 164 else: 165 data = self.as_tuple() 166 other_data = other.as_tuple() 167 if len(data) < len(other_data): 168 return len(self.until(other)) 169 else: 170 return len(other.until(self)) 171 172 def _until(self, start, end, nextfn, prevfn): 173 174 """ 175 Return a collection of units of time by starting from the given 'start' 176 and stepping across intervening units until 'end' is reached, using the 177 given 'nextfn' and 'prevfn' to step from one unit to the next. 178 """ 179 180 current = start 181 units = [current] 182 if current < end: 183 while current < end: 184 current = nextfn(current) 185 units.append(current) 186 elif current > end: 187 while current > end: 188 current = prevfn(current) 189 units.append(current) 190 return units 191 192 def ambiguous(self): 193 194 "Only times can be ambiguous." 195 196 return 0 197 198 class Month(Temporal): 199 200 "A simple year-month representation." 201 202 def __str__(self): 203 return "%04d-%02d" % self.as_tuple()[:2] 204 205 def as_datetime(self, day, hour, minute, second, zone): 206 return DateTime(self.as_tuple() + (day, hour, minute, second, zone)) 207 208 def as_date(self, day): 209 if day < 0: 210 weekday, ndays = self.month_properties() 211 day = ndays + 1 + day 212 return Date(self.as_tuple() + (day,)) 213 214 def as_month(self): 215 return self 216 217 def year(self): 218 return self.data[0] 219 220 def month(self): 221 return self.data[1] 222 223 def month_properties(self): 224 225 """ 226 Return the weekday of the 1st of the month, along with the number of 227 days, as a tuple. 228 """ 229 230 year, month = self.as_tuple()[:2] 231 return calendar.monthrange(year, month) 232 233 def month_update(self, n=1): 234 235 "Return the month updated by 'n' months." 236 237 year, month = self.as_tuple()[:2] 238 return Month((year + (month - 1 + n) / 12, (month - 1 + n) % 12 + 1)) 239 240 update = month_update 241 242 def next_month(self): 243 244 "Return the month following this one." 245 246 return self.month_update(1) 247 248 next = next_month 249 250 def previous_month(self): 251 252 "Return the month preceding this one." 253 254 return self.month_update(-1) 255 256 previous = previous_month 257 258 def months_until(self, end): 259 260 "Return the collection of months from this month until 'end'." 261 262 return self._until(self.as_month(), end.as_month(), Month.next_month, Month.previous_month) 263 264 until = months_until 265 266 class Date(Month): 267 268 "A simple year-month-day representation." 269 270 def constrain(self): 271 year, month, day = self.as_tuple()[:3] 272 273 month = max(min(month, 12), 1) 274 wd, last_day = calendar.monthrange(year, month) 275 day = max(min(day, last_day), 1) 276 277 self.data[1:3] = month, day 278 279 def __str__(self): 280 return "%04d-%02d-%02d" % self.as_tuple()[:3] 281 282 def as_datetime(self, hour, minute, second, zone): 283 return DateTime(self.as_tuple() + (hour, minute, second, zone)) 284 285 def as_start_of_day(self): 286 return self.as_datetime(None, None, None, None) 287 288 def as_date(self): 289 return self 290 291 def as_datetime_or_date(self): 292 return self 293 294 def as_month(self): 295 return Month(self.data[:2]) 296 297 def day(self): 298 return self.data[2] 299 300 def day_update(self, n=1): 301 302 "Return the month updated by 'n' days." 303 304 delta = datetime.timedelta(n) 305 dt = datetime.date(*self.as_tuple()[:3]) 306 dt_new = dt + delta 307 return Date((dt_new.year, dt_new.month, dt_new.day)) 308 309 update = day_update 310 311 def next_day(self): 312 313 "Return the date following this one." 314 315 year, month, day = self.as_tuple()[:3] 316 _wd, end_day = calendar.monthrange(year, month) 317 if day == end_day: 318 if month == 12: 319 return Date((year + 1, 1, 1)) 320 else: 321 return Date((year, month + 1, 1)) 322 else: 323 return Date((year, month, day + 1)) 324 325 next = next_day 326 327 def previous_day(self): 328 329 "Return the date preceding this one." 330 331 year, month, day = self.as_tuple()[:3] 332 if day == 1: 333 if month == 1: 334 return Date((year - 1, 12, 31)) 335 else: 336 _wd, end_day = calendar.monthrange(year, month - 1) 337 return Date((year, month - 1, end_day)) 338 else: 339 return Date((year, month, day - 1)) 340 341 previous = previous_day 342 343 def days_until(self, end): 344 345 "Return the collection of days from this date until 'end'." 346 347 return self._until(self.as_date(), end.as_date(), Date.next_day, Date.previous_day) 348 349 until = days_until 350 351 class DateTime(Date): 352 353 "A simple date plus time representation." 354 355 def constrain(self): 356 Date.constrain(self) 357 358 hour, minute, second = self.as_tuple()[3:6] 359 360 if self.has_time(): 361 hour = max(min(hour, 23), 0) 362 minute = max(min(minute, 59), 0) 363 364 if second is not None: 365 second = max(min(second, 60), 0) # support leap seconds 366 367 self.data[3:6] = hour, minute, second 368 369 def __str__(self): 370 return Date.__str__(self) + self.time_string() 371 372 def time_string(self, zone_as_offset=0): 373 if self.has_time(): 374 data = self.as_tuple() 375 time_str = " %02d:%02d" % data[3:5] 376 if data[5] is not None: 377 time_str += ":%02d" % data[5] 378 if data[6] is not None: 379 if zone_as_offset: 380 utc_offset = self.utc_offset() 381 if utc_offset: 382 time_str += " %+03d:%02d" % utc_offset 383 else: 384 time_str += " %s" % data[6] 385 return time_str 386 else: 387 return "" 388 389 def as_HTTP_datetime_string(self): 390 weekday = calendar.weekday(*self.data[:3]) 391 return "%s, %02d %s %04d %02d:%02d:%02d GMT" % (( 392 getDayLabel(weekday), 393 self.data[2], 394 getMonthLabel(self.data[1]), 395 self.data[0] 396 ) + tuple(self.data[3:6])) 397 398 def as_ISO8601_datetime_string(self): 399 return Date.__str__(self) + self.time_string(1) 400 401 def as_datetime(self): 402 return self 403 404 def as_date(self): 405 return Date(self.data[:3]) 406 407 def as_datetime_or_date(self): 408 409 """ 410 Return a date for this datetime if fields are missing. Otherwise, return 411 this datetime itself. 412 """ 413 414 if not self.has_time(): 415 return self.as_date() 416 else: 417 return self 418 419 def __cmp__(self, other): 420 421 """ 422 The result of comparing this instance with 'other' is, if both instances 423 are datetime instances, derived from a comparison of the datetimes 424 converted to UTC. If one or both datetimes cannot be converted to UTC, 425 the datetimes are compared using the basic temporal comparison which 426 compares their raw time data. 427 """ 428 429 this = self 430 431 if this.has_time(): 432 if isinstance(other, DateTime): 433 if other.has_time(): 434 this_utc = this.to_utc() 435 other_utc = other.to_utc() 436 if this_utc is not None and other_utc is not None: 437 return cmp(this_utc.as_tuple(), other_utc.as_tuple()) 438 else: 439 other = other.padded() 440 else: 441 this = this.padded() 442 443 return Date.__cmp__(this, other) 444 445 def has_time(self): 446 447 """ 448 Return whether this object has any time information. Objects without 449 time information can refer to the very start of a day. 450 """ 451 452 return self.data[3] is not None and self.data[4] is not None 453 454 def time(self): 455 return self.data[3:] 456 457 def seconds(self): 458 return self.data[5] 459 460 def time_zone(self): 461 return self.data[6] 462 463 def set_time_zone(self, value): 464 self.data[6] = value 465 466 def padded(self, empty_value=0): 467 468 """ 469 Return a datetime with missing fields defined as being the given 470 'empty_value' or 0 if not specified. 471 """ 472 473 data = [] 474 for x in self.data[:6]: 475 if x is None: 476 data.append(empty_value) 477 else: 478 data.append(x) 479 480 data += self.data[6:] 481 return DateTime(data) 482 483 def to_utc(self): 484 485 """ 486 Return this object converted to UTC, or None if such a conversion is not 487 defined. 488 """ 489 490 if not self.has_time(): 491 return None 492 493 offset = self.utc_offset() 494 if offset: 495 hours, minutes = offset 496 497 # Invert the offset to get the correction. 498 499 hours, minutes = -hours, -minutes 500 501 # Get the components. 502 503 hour, minute, second, zone = self.time() 504 date = self.as_date() 505 506 # Add the minutes and hours. 507 508 minute += minutes 509 if minute < 0 or minute > 59: 510 hour += minute / 60 511 minute = minute % 60 512 513 # NOTE: This makes various assumptions and probably would not work 514 # NOTE: for general arithmetic. 515 516 hour += hours 517 if hour < 0: 518 date = date.previous_day() 519 hour += 24 520 elif hour > 23: 521 date = date.next_day() 522 hour -= 24 523 524 return date.as_datetime(hour, minute, second, "UTC") 525 526 # Cannot convert. 527 528 else: 529 return None 530 531 def utc_offset(self): 532 533 "Return the UTC offset in hours and minutes." 534 535 zone = self.time_zone() 536 if not zone: 537 return None 538 539 # Support explicit UTC zones. 540 541 if zone == "UTC": 542 return 0, 0 543 544 # Attempt to return a UTC offset where an explicit offset has been set. 545 546 match = timezone_offset_regexp.match(zone) 547 if match: 548 if match.group("sign") == "-": 549 sign = -1 550 else: 551 sign = 1 552 553 hours = int(match.group("hours")) * sign 554 minutes = int(match.group("minutes") or 0) * sign 555 return hours, minutes 556 557 # Attempt to handle Olson time zone identifiers. 558 559 dt = self.as_olson_datetime() 560 if dt: 561 seconds = dt.utcoffset().seconds + dt.utcoffset().days * 24 * 3600 562 hours = seconds / 3600 563 minutes = (seconds % 3600) / 60 564 return hours, minutes 565 566 # Otherwise return None. 567 568 return None 569 570 def olson_identifier(self): 571 572 "Return the Olson identifier from any zone information." 573 574 zone = self.time_zone() 575 if not zone: 576 return None 577 578 # Attempt to match an identifier. 579 580 match = timezone_olson_regexp.match(zone) 581 if match: 582 return match.group("olson") 583 else: 584 return None 585 586 def _as_olson_datetime(self, hours=None): 587 588 """ 589 Return a Python datetime object for this datetime interpreted using any 590 Olson time zone identifier and the given 'hours' offset, raising one of 591 the pytz exceptions in case of ambiguity. 592 """ 593 594 olson = self.olson_identifier() 595 if olson and pytz: 596 tz = pytz.timezone(olson) 597 data = self.padded().as_tuple()[:6] 598 dt = datetime.datetime(*data) 599 600 # With an hours offset, find a time probably in a previously 601 # applicable time zone. 602 603 if hours is not None: 604 td = datetime.timedelta(0, hours * 3600) 605 dt += td 606 607 ldt = tz.localize(dt, None) 608 609 # With an hours offset, adjust the time to define it within the 610 # previously applicable time zone but at the presumably intended 611 # position. 612 613 if hours is not None: 614 ldt -= td 615 616 return ldt 617 else: 618 return None 619 620 def as_olson_datetime(self): 621 622 """ 623 Return a Python datetime object for this datetime interpreted using any 624 Olson time zone identifier, choosing the time from the zone before the 625 period of ambiguity. 626 """ 627 628 try: 629 return self._as_olson_datetime() 630 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 631 632 # Try again, using an earlier local time and then stepping forward 633 # in the chosen zone. 634 # NOTE: Four hours earlier seems reasonable. 635 636 return self._as_olson_datetime(-4) 637 638 def ambiguous(self): 639 640 "Return whether the time is local and ambiguous." 641 642 try: 643 self._as_olson_datetime() 644 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 645 return 1 646 647 return 0 648 649 class Timespan(ActsAsTimespan, Convertible): 650 651 """ 652 A period of time which can be compared against others to check for overlaps. 653 """ 654 655 def __init__(self, start, end): 656 self.start = start 657 self.end = end 658 659 # NOTE: Should perhaps catch ambiguous time problems elsewhere. 660 661 if self.ambiguous() and self.start is not None and self.end is not None and start > end: 662 self.start, self.end = end, start 663 664 def __repr__(self): 665 return "%s(%r, %r)" % (self.__class__.__name__, self.start, self.end) 666 667 def __hash__(self): 668 return hash((self.start, self.end)) 669 670 def as_timespan(self): 671 return self 672 673 def as_limits(self): 674 return self.start, self.end 675 676 def ambiguous(self): 677 return self.start is not None and self.start.ambiguous() or self.end is not None and self.end.ambiguous() 678 679 def convert(self, resolution): 680 return Timespan(*map(self._get_converter(resolution), self.as_limits())) 681 682 def is_before(self, a, b): 683 684 """ 685 Return whether 'a' is before 'b'. Since the end datetime of one period 686 may be the same as the start datetime of another period, and yet the 687 first period is intended to be concluded by the end datetime and not 688 overlap with the other period, a different test is employed for datetime 689 comparisons. 690 """ 691 692 # Datetimes without times can be equal to dates and be considered as 693 # occurring before those dates. Generally, datetimes should not be 694 # produced without time information as getDateTime converts such 695 # datetimes to dates. 696 697 if isinstance(a, DateTime) and (isinstance(b, DateTime) or not a.has_time()): 698 return a <= b 699 else: 700 return a < b 701 702 def __contains__(self, other): 703 704 """ 705 This instance is considered to contain 'other' if one is not before or 706 after the other. If this instance overlaps or coincides with 'other', 707 then 'other' is regarded as belonging to this instance's time period. 708 """ 709 710 return self == other 711 712 def __cmp__(self, other): 713 714 """ 715 Return whether this timespan occupies the same period of time as the 716 'other'. Timespans are considered less than others if their end points 717 precede the other's start point, and are considered greater than others 718 if their start points follow the other's end point. 719 """ 720 721 if isinstance(other, ActsAsTimespan): 722 other = other.as_timespan() 723 724 if self.end is not None and other.start is not None and self.is_before(self.end, other.start): 725 return -1 726 elif self.start is not None and other.end is not None and self.is_before(other.end, self.start): 727 return 1 728 else: 729 return 0 730 731 else: 732 if self.end is not None and self.is_before(self.end, other): 733 return -1 734 elif self.start is not None and self.is_before(other, self.start): 735 return 1 736 else: 737 return 0 738 739 class TimespanCollection: 740 741 """ 742 A class providing a list-like interface supporting membership tests at a 743 particular resolution in order to maintain a collection of non-overlapping 744 timespans. 745 """ 746 747 def __init__(self, resolution, values=None): 748 self.resolution = resolution 749 self.values = values or [] 750 751 def as_timespan(self): 752 return Timespan(*self.as_limits()) 753 754 def as_limits(self): 755 756 "Return the earliest and latest points in time for this collection." 757 758 if not self.values: 759 return None, None 760 else: 761 first, last = self.values[0], self.values[-1] 762 if isinstance(first, ActsAsTimespan): 763 first = first.as_timespan().start 764 if isinstance(last, ActsAsTimespan): 765 last = last.as_timespan().end 766 return first, last 767 768 def convert(self, value): 769 if isinstance(value, ActsAsTimespan): 770 ts = value.as_timespan() 771 return ts and ts.convert(self.resolution) 772 else: 773 return value.convert(self.resolution) 774 775 def __iter__(self): 776 return iter(self.values) 777 778 def __len__(self): 779 return len(self.values) 780 781 def __getitem__(self, i): 782 return self.values[i] 783 784 def __setitem__(self, i, value): 785 self.values[i] = value 786 787 def __contains__(self, value): 788 test_value = self.convert(value) 789 return test_value in self.values 790 791 def append(self, value): 792 self.values.append(value) 793 794 def insert(self, i, value): 795 self.values.insert(i, value) 796 797 def pop(self): 798 return self.values.pop() 799 800 def insert_in_order(self, value): 801 bisect.insort_left(self, value) 802 803 def getDate(s): 804 805 "Parse the string 's', extracting and returning a date object." 806 807 dt = getDateTime(s) 808 if dt is not None: 809 return dt.as_date() 810 else: 811 return None 812 813 def getDateTime(s): 814 815 """ 816 Parse the string 's', extracting and returning a datetime object where time 817 information has been given or a date object where time information is 818 absent. 819 """ 820 821 m = datetime_regexp.search(s) 822 if m: 823 groups = list(m.groups()) 824 825 # Convert date and time data to integer or None. 826 827 return DateTime(map(int_or_none, groups[:6]) + [m.group("zone")]).as_datetime_or_date() 828 else: 829 return None 830 831 def getDateFromCalendar(s): 832 833 """ 834 Parse the iCalendar format string 's', extracting and returning a date 835 object. 836 """ 837 838 dt = getDateTimeFromCalendar(s) 839 if dt is not None: 840 return dt.as_date() 841 else: 842 return None 843 844 def getDateTimeFromCalendar(s): 845 846 """ 847 Parse the iCalendar format datetime string 's', extracting and returning a 848 datetime object where time information has been given or a date object where 849 time information is absent. 850 """ 851 852 m = datetime_icalendar_regexp.search(s) 853 if m: 854 groups = list(m.groups()) 855 856 # Convert date and time data to integer or None. 857 858 return DateTime(map(int_or_none, groups[:6]) + [m.group("utc") and "UTC" or None]).as_datetime_or_date() 859 else: 860 return None 861 862 def getDateStrings(s): 863 864 "Parse the string 's', extracting and returning all date strings." 865 866 start = 0 867 m = date_regexp.search(s, start) 868 l = [] 869 while m: 870 l.append("-".join(m.groups())) 871 m = date_regexp.search(s, m.end()) 872 return l 873 874 def getMonth(s): 875 876 "Parse the string 's', extracting and returning a month object." 877 878 m = month_regexp.search(s) 879 if m: 880 return Month(map(int, m.groups())) 881 else: 882 return None 883 884 def getCurrentDate(): 885 886 "Return the current date as a (year, month, day) tuple." 887 888 today = datetime.date.today() 889 return Date((today.year, today.month, today.day)) 890 891 def getCurrentMonth(): 892 893 "Return the current month as a (year, month) tuple." 894 895 today = datetime.date.today() 896 return Month((today.year, today.month)) 897 898 def getCurrentYear(): 899 900 "Return the current year." 901 902 today = datetime.date.today() 903 return today.year 904 905 # vim: tabstop=4 expandtab shiftwidth=4