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): 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 time_str += " %s" % data[6] 380 return time_str 381 else: 382 return "" 383 384 def as_HTTP_datetime_string(self): 385 weekday = calendar.weekday(*self.data[:3]) 386 return "%s, %02d %s %04d %02d:%02d:%02d GMT" % (( 387 getDayLabel(weekday), 388 self.data[2], 389 getMonthLabel(self.data[1]), 390 self.data[0] 391 ) + tuple(self.data[3:6])) 392 393 def as_datetime(self): 394 return self 395 396 def as_date(self): 397 return Date(self.data[:3]) 398 399 def as_datetime_or_date(self): 400 401 """ 402 Return a date for this datetime if fields are missing. Otherwise, return 403 this datetime itself. 404 """ 405 406 if not self.has_time(): 407 return self.as_date() 408 else: 409 return self 410 411 def __cmp__(self, other): 412 413 """ 414 The result of comparing this instance with 'other' is, if both instances 415 are datetime instances, derived from a comparison of the datetimes 416 converted to UTC. If one or both datetimes cannot be converted to UTC, 417 the datetimes are compared using the basic temporal comparison which 418 compares their raw time data. 419 """ 420 421 this = self 422 423 if this.has_time(): 424 if isinstance(other, DateTime): 425 if other.has_time(): 426 this_utc = this.to_utc() 427 other_utc = other.to_utc() 428 if this_utc is not None and other_utc is not None: 429 return cmp(this_utc.as_tuple(), other_utc.as_tuple()) 430 else: 431 other = other.padded() 432 else: 433 this = this.padded() 434 435 return Date.__cmp__(this, other) 436 437 def has_time(self): 438 439 """ 440 Return whether this object has any time information. Objects without 441 time information can refer to the very start of a day. 442 """ 443 444 return self.data[3] is not None and self.data[4] is not None 445 446 def time(self): 447 return self.data[3:] 448 449 def seconds(self): 450 return self.data[5] 451 452 def time_zone(self): 453 return self.data[6] 454 455 def set_time_zone(self, value): 456 self.data[6] = value 457 458 def padded(self, empty_value=0): 459 460 """ 461 Return a datetime with missing fields defined as being the given 462 'empty_value' or 0 if not specified. 463 """ 464 465 data = [] 466 for x in self.data[:6]: 467 if x is None: 468 data.append(empty_value) 469 else: 470 data.append(x) 471 472 data += self.data[6:] 473 return DateTime(data) 474 475 def to_utc(self): 476 477 """ 478 Return this object converted to UTC, or None if such a conversion is not 479 defined. 480 """ 481 482 if not self.has_time(): 483 return None 484 485 offset = self.utc_offset() 486 if offset: 487 hours, minutes = offset 488 489 # Invert the offset to get the correction. 490 491 hours, minutes = -hours, -minutes 492 493 # Get the components. 494 495 hour, minute, second, zone = self.time() 496 date = self.as_date() 497 498 # Add the minutes and hours. 499 500 minute += minutes 501 if minute < 0 or minute > 59: 502 hour += minute / 60 503 minute = minute % 60 504 505 # NOTE: This makes various assumptions and probably would not work 506 # NOTE: for general arithmetic. 507 508 hour += hours 509 if hour < 0: 510 date = date.previous_day() 511 hour += 24 512 elif hour > 23: 513 date = date.next_day() 514 hour -= 24 515 516 return date.as_datetime(hour, minute, second, "UTC") 517 518 # Cannot convert. 519 520 else: 521 return None 522 523 def utc_offset(self): 524 525 "Return the UTC offset in hours and minutes." 526 527 zone = self.time_zone() 528 if not zone: 529 return None 530 531 # Support explicit UTC zones. 532 533 if zone == "UTC": 534 return 0, 0 535 536 # Attempt to return a UTC offset where an explicit offset has been set. 537 538 match = timezone_offset_regexp.match(zone) 539 if match: 540 if match.group("sign") == "-": 541 sign = -1 542 else: 543 sign = 1 544 545 hours = int(match.group("hours")) * sign 546 minutes = int(match.group("minutes") or 0) * sign 547 return hours, minutes 548 549 # Attempt to handle Olson time zone identifiers. 550 551 dt = self.as_olson_datetime() 552 if dt: 553 seconds = dt.utcoffset().seconds 554 hours = seconds / 3600 555 minutes = (seconds % 3600) / 60 556 return hours, minutes 557 558 # Otherwise return None. 559 560 return None 561 562 def olson_identifier(self): 563 564 "Return the Olson identifier from any zone information." 565 566 zone = self.time_zone() 567 if not zone: 568 return None 569 570 # Attempt to match an identifier. 571 572 match = timezone_olson_regexp.match(zone) 573 if match: 574 return match.group("olson") 575 else: 576 return None 577 578 def _as_olson_datetime(self, hours=None): 579 580 """ 581 Return a Python datetime object for this datetime interpreted using any 582 Olson time zone identifier and the given 'hours' offset, raising one of 583 the pytz exceptions in case of ambiguity. 584 """ 585 586 olson = self.olson_identifier() 587 if olson and pytz: 588 tz = pytz.timezone(olson) 589 data = self.padded().as_tuple()[:6] 590 dt = datetime.datetime(*data) 591 592 # With an hours offset, find a time probably in a previously 593 # applicable time zone. 594 595 if hours is not None: 596 td = datetime.timedelta(0, hours * 3600) 597 dt += td 598 599 ldt = tz.localize(dt, None) 600 601 # With an hours offset, adjust the time to define it within the 602 # previously applicable time zone but at the presumably intended 603 # position. 604 605 if hours is not None: 606 ldt -= td 607 608 return ldt 609 else: 610 return None 611 612 def as_olson_datetime(self): 613 614 """ 615 Return a Python datetime object for this datetime interpreted using any 616 Olson time zone identifier, choosing the time from the zone before the 617 period of ambiguity. 618 """ 619 620 try: 621 return self._as_olson_datetime() 622 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 623 624 # Try again, using an earlier local time and then stepping forward 625 # in the chosen zone. 626 # NOTE: Four hours earlier seems reasonable. 627 628 return self._as_olson_datetime(-4) 629 630 def ambiguous(self): 631 632 "Return whether the time is local and ambiguous." 633 634 try: 635 self._as_olson_datetime() 636 except (pytz.UnknownTimeZoneError, pytz.AmbiguousTimeError): 637 return 1 638 639 return 0 640 641 class Timespan(ActsAsTimespan, Convertible): 642 643 """ 644 A period of time which can be compared against others to check for overlaps. 645 """ 646 647 def __init__(self, start, end): 648 self.start = start 649 self.end = end 650 651 # NOTE: Should perhaps catch ambiguous time problems elsewhere. 652 653 if self.ambiguous() and self.start is not None and self.end is not None and start > end: 654 self.start, self.end = end, start 655 656 def __repr__(self): 657 return "%s(%r, %r)" % (self.__class__.__name__, self.start, self.end) 658 659 def __hash__(self): 660 return hash((self.start, self.end)) 661 662 def as_timespan(self): 663 return self 664 665 def as_limits(self): 666 return self.start, self.end 667 668 def ambiguous(self): 669 return self.start is not None and self.start.ambiguous() or self.end is not None and self.end.ambiguous() 670 671 def convert(self, resolution): 672 return Timespan(*map(self._get_converter(resolution), self.as_limits())) 673 674 def is_before(self, a, b): 675 676 """ 677 Return whether 'a' is before 'b'. Since the end datetime of one period 678 may be the same as the start datetime of another period, and yet the 679 first period is intended to be concluded by the end datetime and not 680 overlap with the other period, a different test is employed for datetime 681 comparisons. 682 """ 683 684 # Datetimes without times can be equal to dates and be considered as 685 # occurring before those dates. Generally, datetimes should not be 686 # produced without time information as getDateTime converts such 687 # datetimes to dates. 688 689 if isinstance(a, DateTime) and (isinstance(b, DateTime) or not a.has_time()): 690 return a <= b 691 else: 692 return a < b 693 694 def __contains__(self, other): 695 696 """ 697 This instance is considered to contain 'other' if one is not before or 698 after the other. If this instance overlaps or coincides with 'other', 699 then 'other' is regarded as belonging to this instance's time period. 700 """ 701 702 return self == other 703 704 def __cmp__(self, other): 705 706 """ 707 Return whether this timespan occupies the same period of time as the 708 'other'. Timespans are considered less than others if their end points 709 precede the other's start point, and are considered greater than others 710 if their start points follow the other's end point. 711 """ 712 713 if isinstance(other, ActsAsTimespan): 714 other = other.as_timespan() 715 716 if self.end is not None and other.start is not None and self.is_before(self.end, other.start): 717 return -1 718 elif self.start is not None and other.end is not None and self.is_before(other.end, self.start): 719 return 1 720 else: 721 return 0 722 723 else: 724 if self.end is not None and self.is_before(self.end, other): 725 return -1 726 elif self.start is not None and self.is_before(other, self.start): 727 return 1 728 else: 729 return 0 730 731 class TimespanCollection: 732 733 """ 734 A class providing a list-like interface supporting membership tests at a 735 particular resolution in order to maintain a collection of non-overlapping 736 timespans. 737 """ 738 739 def __init__(self, resolution, values=None): 740 self.resolution = resolution 741 self.values = values or [] 742 743 def as_timespan(self): 744 return Timespan(*self.as_limits()) 745 746 def as_limits(self): 747 748 "Return the earliest and latest points in time for this collection." 749 750 if not self.values: 751 return None, None 752 else: 753 first, last = self.values[0], self.values[-1] 754 if isinstance(first, ActsAsTimespan): 755 first = first.as_timespan().start 756 if isinstance(last, ActsAsTimespan): 757 last = last.as_timespan().end 758 return first, last 759 760 def convert(self, value): 761 if isinstance(value, ActsAsTimespan): 762 ts = value.as_timespan() 763 return ts and ts.convert(self.resolution) 764 else: 765 return value.convert(self.resolution) 766 767 def __iter__(self): 768 return iter(self.values) 769 770 def __len__(self): 771 return len(self.values) 772 773 def __getitem__(self, i): 774 return self.values[i] 775 776 def __setitem__(self, i, value): 777 self.values[i] = value 778 779 def __contains__(self, value): 780 test_value = self.convert(value) 781 return test_value in self.values 782 783 def append(self, value): 784 self.values.append(value) 785 786 def insert(self, i, value): 787 self.values.insert(i, value) 788 789 def pop(self): 790 return self.values.pop() 791 792 def insert_in_order(self, value): 793 bisect.insort_left(self, value) 794 795 def getDate(s): 796 797 "Parse the string 's', extracting and returning a date object." 798 799 dt = getDateTime(s) 800 if dt is not None: 801 return dt.as_date() 802 else: 803 return None 804 805 def getDateTime(s): 806 807 """ 808 Parse the string 's', extracting and returning a datetime object where time 809 information has been given or a date object where time information is 810 absent. 811 """ 812 813 m = datetime_regexp.search(s) 814 if m: 815 groups = list(m.groups()) 816 817 # Convert date and time data to integer or None. 818 819 return DateTime(map(int_or_none, groups[:6]) + [m.group("zone")]).as_datetime_or_date() 820 else: 821 return None 822 823 def getDateFromCalendar(s): 824 825 """ 826 Parse the iCalendar format string 's', extracting and returning a date 827 object. 828 """ 829 830 dt = getDateTimeFromCalendar(s) 831 if dt is not None: 832 return dt.as_date() 833 else: 834 return None 835 836 def getDateTimeFromCalendar(s): 837 838 """ 839 Parse the iCalendar format datetime string 's', extracting and returning a 840 datetime object where time information has been given or a date object where 841 time information is absent. 842 """ 843 844 m = datetime_icalendar_regexp.search(s) 845 if m: 846 groups = list(m.groups()) 847 848 # Convert date and time data to integer or None. 849 850 return DateTime(map(int_or_none, groups[:6]) + [m.group("utc") and "UTC" or None]).as_datetime_or_date() 851 else: 852 return None 853 854 def getDateStrings(s): 855 856 "Parse the string 's', extracting and returning all date strings." 857 858 start = 0 859 m = date_regexp.search(s, start) 860 l = [] 861 while m: 862 l.append("-".join(m.groups())) 863 m = date_regexp.search(s, m.end()) 864 return l 865 866 def getMonth(s): 867 868 "Parse the string 's', extracting and returning a month object." 869 870 m = month_regexp.search(s) 871 if m: 872 return Month(map(int, m.groups())) 873 else: 874 return None 875 876 def getCurrentDate(): 877 878 "Return the current date as a (year, month, day) tuple." 879 880 today = datetime.date.today() 881 return Date((today.year, today.month, today.day)) 882 883 def getCurrentMonth(): 884 885 "Return the current month as a (year, month) tuple." 886 887 today = datetime.date.today() 888 return Month((today.year, today.month)) 889 890 def getCurrentYear(): 891 892 "Return the current year." 893 894 today = datetime.date.today() 895 return today.year 896 897 # vim: tabstop=4 expandtab shiftwidth=4