imip-agent

imiptools/dates.py

1026:4a0226da2137
2016-01-29 Paul Boddie Make a scheduling package to potentially support multiple scheduling modules.
     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_date(offset=None):   447    448     """   449     Return the current date, offset by the given timedelta 'offset' if   450     specified. The returned date will not be positioned in any time zone.   451     """   452    453     offset = offset or timedelta(0)   454     return date.today() + offset   455    456 def get_time(offset=None):   457    458     """   459     Return the current time, offset by the given timedelta 'offset' if   460     specified. The returned time will be in the UTC time zone.   461     """   462    463     offset = offset or timedelta(0)   464     return to_timezone(datetime.utcnow(), "UTC") + offset   465    466 def get_tzid(dtstart_attr, dtend_attr):   467    468     """   469     Return any time regime details from the given 'dtstart_attr' and   470     'dtend_attr' attribute collections.   471     """   472    473     return dtstart_attr and dtstart_attr.get("TZID") or dtend_attr and dtend_attr.get("TZID") or None   474    475 def get_recurrence_start(recurrenceid):   476    477     """   478     Return 'recurrenceid' in a form suitable for comparison with period start   479     dates or datetimes. The 'recurrenceid' should be an identifier normalised to   480     a UTC datetime or employing a date or floating datetime representation where   481     no time zone information was originally provided.   482     """   483    484     return get_datetime(recurrenceid)   485    486 def get_recurrence_start_point(recurrenceid, tzid):   487    488     """   489     Return 'recurrenceid' in a form suitable for comparison with free/busy start   490     datetimes, using 'tzid' to convert recurrence identifiers that are dates.   491     The 'recurrenceid' should be an identifier normalised to a UTC datetime or   492     employing a date or floating datetime representation where no time zone   493     information was originally provided.   494     """   495    496     return to_utc_datetime(get_datetime(recurrenceid), tzid)   497    498 # Time corrections.   499    500 class ValidityError(Exception):   501     pass   502    503 def check_permitted_values(dt, permitted_values):   504    505     "Check the datetime 'dt' against the 'permitted_values' list."   506    507     if not isinstance(dt, datetime):   508         raise ValidityError   509    510     hours, minutes, seconds = permitted_values   511     errors = []   512    513     if hours and dt.hour not in hours:   514         errors.append("hour")   515     if minutes and dt.minute not in minutes:   516         errors.append("minute")   517     if seconds and dt.second not in seconds:   518         errors.append("second")   519    520     return errors   521    522 def correct_datetime(dt, permitted_values):   523    524     "Correct 'dt' using the given 'permitted_values' details."   525    526     carry, hour, minute, second = correct_value((dt.hour, dt.minute, dt.second), permitted_values)   527     return datetime(dt.year, dt.month, dt.day, hour, minute, second, dt.microsecond, dt.tzinfo) + \   528            (carry and timedelta(1) or timedelta(0))   529    530 def correct_value(value, permitted_values):   531    532     """   533     Correct the given (hour, minute, second) tuple 'value' according to the   534     'permitted_values' details.   535     """   536    537     limits = 23, 59, 59   538    539     corrected = []   540     reset = False   541    542     # Find invalid values and reset all following values.   543    544     for v, values, limit in zip(value, permitted_values, limits):   545         if reset:   546             if values:   547                 v = values[0]   548             else:   549                 v = 0   550    551         elif values and v not in values:   552             reset = True   553    554         corrected.append(v)   555    556     value = corrected   557     corrected = []   558     carry = 0   559    560     # Find invalid values and update them to the next valid value, updating more   561     # significant values if the next valid value is the first in the appropriate   562     # series.   563    564     for v, values, limit in zip(value, permitted_values, limits)[::-1]:   565         if carry:   566             v += 1   567             if v > limit:   568                 if values:   569                     v = values[0]   570                 else:   571                     v = 0   572                 corrected.append(v)   573                 continue   574             else:   575                 carry = 0   576    577         if values:   578             i = bisect_left(values, v)   579             if i < len(values):   580                 v = values[i]   581             else:   582                 v = values[0]   583                 carry = 1   584    585         corrected.append(v)   586    587     return [carry] + corrected[::-1]   588    589 # vim: tabstop=4 expandtab shiftwidth=4