MoinSupport

DateSupport.py

5:73cd25a7f800
2012-03-25 Paul Boddie Handle empty date parameters more generally.
     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