imip-agent

imiptools/dates.py

759:796a915569f6
2015-09-19 Paul Boddie Changed the free/busy offer periods to use iCalendar period syntax. Made use of more convenience functions for timestamp-related tasks. imipweb-client-simplification
     1 #!/usr/bin/env python     2      3 """     4 Date processing functions.     5      6 Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>     7      8 This program is free software; you can redistribute it and/or modify it under     9 the terms of the GNU General Public License as published by the Free Software    10 Foundation; either version 3 of the License, or (at your option) any later    11 version.    12     13 This program is distributed in the hope that it will be useful, but WITHOUT    14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS    15 FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more    16 details.    17     18 You should have received a copy of the GNU General Public License along with    19 this program.  If not, see <http://www.gnu.org/licenses/>.    20 """    21     22 from bisect import bisect_left    23 from datetime import date, datetime, timedelta    24 from os.path import exists    25 from pytz import timezone, UnknownTimeZoneError    26 import re    27     28 # iCalendar date and datetime parsing (from DateSupport in MoinSupport).    29     30 _date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})'    31 date_icalendar_regexp_str = _date_icalendar_regexp_str + '$'    32     33 datetime_icalendar_regexp_str = _date_icalendar_regexp_str + \    34     ur'(?:' \    35     ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \    36     ur'(?P<utc>Z)?' \    37     ur')?$'    38     39 _duration_time_icalendar_regexp_str = \    40     ur'T' \    41     ur'(?:' \    42     ur'([0-9]+H)(?:([0-9]+M)([0-9]+S)?)?' \    43     ur'|' \    44     ur'([0-9]+M)([0-9]+S)?' \    45     ur'|' \    46     ur'([0-9]+S)' \    47     ur')'    48     49 duration_icalendar_regexp_str = ur'P' \    50     ur'(?:' \    51     ur'([0-9]+W)' \    52     ur'|' \    53     ur'(?:%s)' \    54     ur'|' \    55     ur'([0-9]+D)(?:%s)?' \    56     ur')$' % (_duration_time_icalendar_regexp_str, _duration_time_icalendar_regexp_str)    57     58 match_date_icalendar = re.compile(date_icalendar_regexp_str, re.UNICODE).match    59 match_datetime_icalendar = re.compile(datetime_icalendar_regexp_str, re.UNICODE).match    60 match_duration_icalendar = re.compile(duration_icalendar_regexp_str, re.UNICODE).match    61     62 # Datetime formatting.    63     64 def format_datetime(dt):    65     66     "Format 'dt' as an iCalendar-compatible string."    67     68     if not dt:    69         return None    70     elif isinstance(dt, datetime):    71         if dt.tzname() == "UTC":    72             return dt.strftime("%Y%m%dT%H%M%SZ")    73         else:    74             return dt.strftime("%Y%m%dT%H%M%S")    75     else:    76         return dt.strftime("%Y%m%d")    77     78 def format_time(dt):    79     80     "Format the time portion of 'dt' as an iCalendar-compatible string."    81     82     if not dt:    83         return None    84     elif isinstance(dt, datetime):    85         if dt.tzname() == "UTC":    86             return dt.strftime("%H%M%SZ")    87         else:    88             return dt.strftime("%H%M%S")    89     else:    90         return None    91     92 # Parsing of datetime and related information.    93     94 def get_datetime(value, attr=None):    95     96     """    97     Return a datetime object from the given 'value' in iCalendar format, using    98     the 'attr' mapping (if specified) to control the conversion.    99     """   100    101     if not value:   102         return None   103    104     if len(value) > 9 and (not attr or attr.get("VALUE") in (None, "DATE-TIME")):   105         m = match_datetime_icalendar(value)   106         if m:   107             year, month, day, hour, minute, second = map(m.group, [   108                 "year", "month", "day", "hour", "minute", "second"   109                 ])   110    111             if hour and minute and second:   112                 dt = datetime(   113                     int(year), int(month), int(day), int(hour), int(minute), int(second)   114                     )   115    116                 # Impose the indicated timezone.   117                 # NOTE: This needs an ambiguity policy for DST changes.   118    119                 return to_timezone(dt, m.group("utc") and "UTC" or attr and attr.get("TZID") or None)   120    121         return None   122    123     # Permit dates even if the VALUE is not set to DATE.   124    125     if not attr or attr.get("VALUE") in (None, "DATE"):   126         m = match_date_icalendar(value)   127         if m:   128             year, month, day = map(m.group, ["year", "month", "day"])   129             return date(int(year), int(month), int(day))   130    131     return None   132    133 def get_duration(value):   134    135     """   136     Return a duration for the given 'value' as a timedelta object.   137     Where no valid duration is specified, None is returned.   138     """   139    140     if not value:   141         return None   142    143     m = match_duration_icalendar(value)   144     if m:   145         weeks, days, hours, minutes, seconds = 0, 0, 0, 0, 0   146         for s in m.groups():   147             if not s: continue   148             if s[-1] == "W": weeks += int(s[:-1])   149             elif s[-1] == "D": days += int(s[:-1])   150             elif s[-1] == "H": hours += int(s[:-1])   151             elif s[-1] == "M": minutes += int(s[:-1])   152             elif s[-1] == "S": seconds += int(s[:-1])   153         return timedelta(   154             int(weeks) * 7 + int(days),   155             (int(hours) * 60 + int(minutes)) * 60 + int(seconds)   156             )   157     else:   158         return None   159    160 def get_period(value, attr=None):   161    162     """   163     Return a tuple of the form (start, end) for the given 'value' in iCalendar   164     format, using the 'attr' mapping (if specified) to control the conversion.   165     """   166    167     if not value or attr and attr.get("VALUE") and attr.get("VALUE") != "PERIOD":   168         return None   169    170     t = value.split("/")   171     if len(t) != 2:   172         return None   173    174     dtattr = {}   175     if attr:   176         dtattr.update(attr)   177         if dtattr.has_key("VALUE"):   178             del dtattr["VALUE"]   179    180     start = get_datetime(t[0], dtattr)   181     if t[1].startswith("P"):   182         end = start + get_duration(t[1])   183     else:   184         end = get_datetime(t[1], dtattr)   185    186     return start, end   187    188 # Time zone conversions and retrieval.   189    190 def ends_on_same_day(dt, end, tzid):   191    192     """   193     Return whether 'dt' ends on the same day as 'end', testing the date   194     components of 'dt' and 'end' against each other, but also testing whether   195     'end' is the actual end of the day in which 'dt' is positioned.   196    197     Since time zone transitions may occur within a day, 'tzid' is required to   198     determine the end of the day in which 'dt' is positioned, using the zone   199     appropriate at that point in time, not necessarily the zone applying to   200     'dt'.   201     """   202    203     return (   204         to_timezone(dt, tzid).date() == to_timezone(end, tzid).date() or   205         end == get_end_of_day(dt, tzid)   206         )   207    208 def get_default_timezone():   209    210     "Return the system time regime."   211    212     filename = "/etc/timezone"   213    214     if exists(filename):   215         f = open(filename)   216         try:   217             return f.read().strip()   218         finally:   219             f.close()   220     else:   221         return None   222    223 def get_end_of_day(dt, tzid):   224    225     """   226     Get the end of the day in which 'dt' is positioned, using the given 'tzid'   227     to obtain a datetime in the appropriate time zone. Where time zone   228     transitions occur within a day, the zone of 'dt' may not be the eventual   229     zone of the returned object.   230     """   231    232     return get_start_of_day(dt + timedelta(1), tzid)   233    234 def get_start_of_day(dt, tzid):   235    236     """   237     Get the start of the day in which 'dt' is positioned, using the given 'tzid'   238     to obtain a datetime in the appropriate time zone. Where time zone   239     transitions occur within a day, the zone of 'dt' may not be the eventual   240     zone of the returned object.   241     """   242    243     start = datetime(dt.year, dt.month, dt.day, 0, 0)   244     return to_timezone(start, tzid)   245    246 def get_start_of_next_day(dt, tzid):   247    248     """   249     Get the start of the day after the day in which 'dt' is positioned. This   250     function is intended to extend either dates or datetimes to the end of a   251     day for the purpose of generating a missing end date or datetime for an   252     event.   253    254     If 'dt' is a date and not a datetime, a plain date object for the next day   255     will be returned.   256    257     If 'dt' is a datetime, the given 'tzid' is used to obtain a datetime in the   258     appropriate time zone. Where time zone transitions occur within a day, the   259     zone of 'dt' may not be the eventual zone of the returned object.   260     """   261    262     if isinstance(dt, datetime):   263         return get_end_of_day(dt, tzid)   264     else:   265         return dt + timedelta(1)   266    267 def get_datetime_tzid(dt):   268    269     "Return the time zone identifier from 'dt' or None if unknown."   270    271     if not isinstance(dt, datetime):   272         return None   273     elif dt.tzname() == "UTC":   274         return "UTC"   275     elif dt.tzinfo and hasattr(dt.tzinfo, "zone"):   276         return dt.tzinfo.zone   277     else:   278         return None   279    280 def get_period_tzid(start, end):   281    282     "Return the time zone identifier for 'start' and 'end' or None if unknown."   283    284     if isinstance(start, datetime) or isinstance(end, datetime):   285         return get_datetime_tzid(start) or get_datetime_tzid(end)   286     else:   287         return None   288    289 def to_date(dt):   290    291     "Return the date of 'dt'."   292    293     return date(dt.year, dt.month, dt.day)   294    295 def to_datetime(dt, tzid):   296    297     """   298     Return a datetime for 'dt', using the start of day for dates, and using the   299     'tzid' for the conversion.   300     """   301    302     if isinstance(dt, datetime):   303         return to_timezone(dt, tzid)   304     else:   305         return get_start_of_day(dt, tzid)   306    307 def to_utc_datetime(dt, tzid=None):   308    309     """   310     Return a datetime corresponding to 'dt' in the UTC time zone. If 'tzid'   311     is specified, dates and floating datetimes are converted to UTC datetimes   312     using the time zone information; otherwise, such dates and datetimes remain   313     unconverted.   314     """   315    316     if not dt:   317         return None   318     elif get_datetime_tzid(dt):   319         return to_timezone(dt, "UTC")   320     elif tzid:   321         return to_timezone(to_datetime(dt, tzid), "UTC")   322     else:   323         return dt   324    325 def to_timezone(dt, tzid):   326    327     """   328     Return a datetime corresponding to 'dt' in the time regime having the given   329     'tzid'.   330     """   331    332     try:   333         tz = tzid and timezone(tzid) or None   334     except UnknownTimeZoneError:   335         tz = None   336     return to_tz(dt, tz)   337    338 def to_tz(dt, tz):   339    340     "Return a datetime corresponding to 'dt' employing the pytz.timezone 'tz'."   341    342     if tz is not None and isinstance(dt, datetime):   343         if not dt.tzinfo:   344             return tz.localize(dt)   345         else:   346             return dt.astimezone(tz)   347     else:   348         return dt   349    350 # iCalendar-related conversions.   351    352 def end_date_from_calendar(dt):   353    354     """   355     Change end dates to refer to the actual dates, not the iCalendar "next day"   356     dates.   357     """   358    359     if not isinstance(dt, datetime):   360         return dt - timedelta(1)   361     else:   362         return dt   363    364 def end_date_to_calendar(dt):   365    366     """   367     Change end dates to refer to the iCalendar "next day" dates, not the actual   368     dates.   369     """   370    371     if not isinstance(dt, datetime):   372         return dt + timedelta(1)   373     else:   374         return dt   375    376 def get_datetime_attributes(dt, tzid=None):   377    378     """   379     Return attributes for the 'dt' date or datetime object with 'tzid'   380     indicating the time zone if not otherwise defined.   381     """   382    383     if isinstance(dt, datetime):   384         attr = {"VALUE" : "DATE-TIME"}   385         tzid = get_datetime_tzid(dt) or tzid   386         if tzid:   387             attr["TZID"] = tzid   388         return attr   389     else:   390         return {"VALUE" : "DATE"}   391    392 def get_datetime_item(dt, tzid=None):   393    394     """   395     Return an iCalendar-compatible string and attributes for 'dt' using any   396     specified 'tzid' to assert a particular time zone if not otherwise defined.   397     """   398    399     if not dt:   400         return None, None   401     if not get_datetime_tzid(dt):   402         dt = to_timezone(dt, tzid)   403     value = format_datetime(dt)   404     attr = get_datetime_attributes(dt, tzid)   405     return value, attr   406    407 def get_period_attributes(start, end, tzid=None):   408    409     """   410     Return attributes for the 'start' and 'end' datetime objects with 'tzid'   411     indicating the time zone if not otherwise defined.   412     """   413    414     attr = {"VALUE" : "PERIOD"}   415     tzid = get_period_tzid(start, end) or tzid   416     if tzid:   417         attr["TZID"] = tzid   418     return attr   419    420 def get_period_item(start, end, tzid=None):   421    422     """   423     Return an iCalendar-compatible string and attributes for 'start', 'end' and   424     'tzid'.   425     """   426    427     if start and end:   428         attr = get_period_attributes(start, end, tzid)   429         start_value = format_datetime(to_timezone(start, attr.get("TZID")))   430         end_value = format_datetime(to_timezone(end, attr.get("TZID")))   431         return "%s/%s" % (start_value, end_value), attr   432     elif start:   433         attr = get_datetime_attributes(start, tzid)   434         start_value = format_datetime(to_timezone(start, attr.get("TZID")))   435         return start_value, attr   436     else:   437         return None, None   438    439 def get_timestamp(offset=None):   440    441     "Return the current time as an iCalendar-compatible string."   442    443     offset = offset or timedelta(0)   444     return format_datetime(to_timezone(datetime.utcnow(), "UTC") + offset)   445    446 def get_time(offset=None):   447    448     "Return the current time."   449    450     offset = offset or timedelta(0)   451     return to_timezone(datetime.utcnow(), "UTC") + offset   452    453 def get_tzid(dtstart_attr, dtend_attr):   454    455     """   456     Return any time regime details from the given 'dtstart_attr' and   457     'dtend_attr' attribute collections.   458     """   459    460     return dtstart_attr and dtstart_attr.get("TZID") or dtend_attr and dtend_attr.get("TZID") or None   461    462 def get_recurrence_start(recurrenceid):   463    464     """   465     Return 'recurrenceid' in a form suitable for comparison with period start   466     dates or datetimes. The 'recurrenceid' should be an identifier normalised to   467     a UTC datetime or employing a date or floating datetime representation where   468     no time zone information was originally provided.   469     """   470    471     return get_datetime(recurrenceid)   472    473 def get_recurrence_start_point(recurrenceid, tzid):   474    475     """   476     Return 'recurrenceid' in a form suitable for comparison with free/busy start   477     datetimes, using 'tzid' to convert recurrence identifiers that are dates.   478     The 'recurrenceid' should be an identifier normalised to a UTC datetime or   479     employing a date or floating datetime representation where no time zone   480     information was originally provided.   481     """   482    483     return to_utc_datetime(get_datetime(recurrenceid), tzid)   484    485 # Time corrections.   486    487 class ValidityError(Exception):   488     pass   489    490 def check_permitted_values(dt, permitted_values):   491    492     "Check the datetime 'dt' against the 'permitted_values' list."   493    494     if not isinstance(dt, datetime):   495         raise ValidityError   496    497     hours, minutes, seconds = permitted_values   498     errors = []   499    500     if hours and dt.hour not in hours:   501         errors.append("hour")   502     if minutes and dt.minute not in minutes:   503         errors.append("minute")   504     if seconds and dt.second not in seconds:   505         errors.append("second")   506    507     return errors   508    509 def correct_datetime(dt, permitted_values):   510    511     "Correct 'dt' using the given 'permitted_values' details."   512    513     carry, hour, minute, second = correct_value((dt.hour, dt.minute, dt.second), permitted_values)   514     return datetime(dt.year, dt.month, dt.day, hour, minute, second, dt.microsecond, dt.tzinfo) + \   515            (carry and timedelta(1) or timedelta(0))   516    517 def correct_value(value, permitted_values):   518    519     """   520     Correct the given (hour, minute, second) tuple 'value' according to the   521     'permitted_values' details.   522     """   523    524     limits = 23, 59, 59   525    526     corrected = []   527     reset = False   528    529     # Find invalid values and reset all following values.   530    531     for v, values, limit in zip(value, permitted_values, limits):   532         if reset:   533             if values:   534                 v = values[0]   535             else:   536                 v = 0   537    538         elif values and v not in values:   539             reset = True   540    541         corrected.append(v)   542    543     value = corrected   544     corrected = []   545     carry = 0   546    547     # Find invalid values and update them to the next valid value, updating more   548     # significant values if the next valid value is the first in the appropriate   549     # series.   550    551     for v, values, limit in zip(value, permitted_values, limits)[::-1]:   552         if carry:   553             v += 1   554             if v > limit:   555                 if values:   556                     v = values[0]   557                 else:   558                     v = 0   559                 corrected.append(v)   560                 continue   561             else:   562                 carry = 0   563    564         if values:   565             i = bisect_left(values, v)   566             if i < len(values):   567                 v = values[i]   568             else:   569                 v = values[0]   570                 carry = 1   571    572         corrected.append(v)   573    574     return [carry] + corrected[::-1]   575    576 # vim: tabstop=4 expandtab shiftwidth=4