imip-agent

imiptools/dates.py

1069:37921ab84c01
2016-03-06 Paul Boddie Moved imip_store into a new imiptools.stores package as the file module.
     1 #!/usr/bin/env python     2      3 """     4 Date processing functions.     5      6 Copyright (C) 2014, 2015, 2016 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 def format_duration(td):    93     94     "Format the timedelta 'td' as an iCalendar-compatible string."    95     96     if not td:    97         return None    98     else:    99         day_portion = td.days and "%dD" % td.days or ""   100         time_portion = td.seconds and "T%dS" % td.seconds or ""   101         if not day_portion and not time_portion:   102             time_portion = "T0S"   103         return "P%s%s" % (day_portion, time_portion)   104    105 # Parsing of datetime and related information.   106    107 def get_datetime(value, attr=None):   108    109     """   110     Return a datetime object from the given 'value' in iCalendar format, using   111     the 'attr' mapping (if specified) to control the conversion.   112     """   113    114     if not value:   115         return None   116    117     if len(value) > 9 and (not attr or attr.get("VALUE") in (None, "DATE-TIME")):   118         m = match_datetime_icalendar(value)   119         if m:   120             year, month, day, hour, minute, second = map(m.group, [   121                 "year", "month", "day", "hour", "minute", "second"   122                 ])   123    124             if hour and minute and second:   125                 dt = datetime(   126                     int(year), int(month), int(day), int(hour), int(minute), int(second)   127                     )   128    129                 # Impose the indicated timezone.   130                 # NOTE: This needs an ambiguity policy for DST changes.   131    132                 return to_timezone(dt, m.group("utc") and "UTC" or attr and attr.get("TZID") or None)   133    134         return None   135    136     # Permit dates even if the VALUE is not set to DATE.   137    138     if not attr or attr.get("VALUE") in (None, "DATE"):   139         m = match_date_icalendar(value)   140         if m:   141             year, month, day = map(m.group, ["year", "month", "day"])   142             return date(int(year), int(month), int(day))   143    144     return None   145    146 def get_duration(value):   147    148     """   149     Return a duration for the given 'value' as a timedelta object.   150     Where no valid duration is specified, None is returned.   151     """   152    153     if not value:   154         return None   155    156     m = match_duration_icalendar(value)   157     if m:   158         weeks, days, hours, minutes, seconds = 0, 0, 0, 0, 0   159         for s in m.groups():   160             if not s: continue   161             if s[-1] == "W": weeks += int(s[:-1])   162             elif s[-1] == "D": days += int(s[:-1])   163             elif s[-1] == "H": hours += int(s[:-1])   164             elif s[-1] == "M": minutes += int(s[:-1])   165             elif s[-1] == "S": seconds += int(s[:-1])   166         return timedelta(   167             int(weeks) * 7 + int(days),   168             (int(hours) * 60 + int(minutes)) * 60 + int(seconds)   169             )   170     else:   171         return None   172    173 def get_period(value, attr=None):   174    175     """   176     Return a tuple of the form (start, end) for the given 'value' in iCalendar   177     format, using the 'attr' mapping (if specified) to control the conversion.   178     """   179    180     if not value or attr and attr.get("VALUE") and attr.get("VALUE") != "PERIOD":   181         return None   182    183     t = value.split("/")   184     if len(t) != 2:   185         return None   186    187     dtattr = {}   188     if attr:   189         dtattr.update(attr)   190         if dtattr.has_key("VALUE"):   191             del dtattr["VALUE"]   192    193     start = get_datetime(t[0], dtattr)   194     if t[1].startswith("P"):   195         end = start + get_duration(t[1])   196     else:   197         end = get_datetime(t[1], dtattr)   198    199     return start, end   200    201 # Time zone conversions and retrieval.   202    203 def ends_on_same_day(dt, end, tzid):   204    205     """   206     Return whether 'dt' ends on the same day as 'end', testing the date   207     components of 'dt' and 'end' against each other, but also testing whether   208     'end' is the actual end of the day in which 'dt' is positioned.   209    210     Since time zone transitions may occur within a day, 'tzid' is required to   211     determine the end of the day in which 'dt' is positioned, using the zone   212     appropriate at that point in time, not necessarily the zone applying to   213     'dt'.   214     """   215    216     return (   217         to_timezone(dt, tzid).date() == to_timezone(end, tzid).date() or   218         end == get_end_of_day(dt, tzid)   219         )   220    221 def get_default_timezone():   222    223     "Return the system time regime."   224    225     filename = "/etc/timezone"   226    227     if exists(filename):   228         f = open(filename)   229         try:   230             return f.read().strip()   231         finally:   232             f.close()   233     else:   234         return None   235    236 def get_end_of_day(dt, tzid):   237    238     """   239     Get the end of the day in which 'dt' is positioned, using the given 'tzid'   240     to obtain a datetime in the appropriate time zone. Where time zone   241     transitions occur within a day, the zone of 'dt' may not be the eventual   242     zone of the returned object.   243     """   244    245     return get_start_of_day(dt + timedelta(1), tzid)   246    247 def get_start_of_day(dt, tzid):   248    249     """   250     Get the start of the day in which 'dt' is positioned, using the given 'tzid'   251     to obtain a datetime in the appropriate time zone. Where time zone   252     transitions occur within a day, the zone of 'dt' may not be the eventual   253     zone of the returned object.   254     """   255    256     start = datetime(dt.year, dt.month, dt.day, 0, 0)   257     return to_timezone(start, tzid)   258    259 def get_start_of_next_day(dt, tzid):   260    261     """   262     Get the start of the day after the day in which 'dt' is positioned. This   263     function is intended to extend either dates or datetimes to the end of a   264     day for the purpose of generating a missing end date or datetime for an   265     event.   266    267     If 'dt' is a date and not a datetime, a plain date object for the next day   268     will be returned.   269    270     If 'dt' is a datetime, the given 'tzid' is used to obtain a datetime in the   271     appropriate time zone. Where time zone transitions occur within a day, the   272     zone of 'dt' may not be the eventual zone of the returned object.   273     """   274    275     if isinstance(dt, datetime):   276         return get_end_of_day(dt, tzid)   277     else:   278         return dt + timedelta(1)   279    280 def get_datetime_tzid(dt):   281    282     "Return the time zone identifier from 'dt' or None if unknown."   283    284     if not isinstance(dt, datetime):   285         return None   286     elif dt.tzname() == "UTC":   287         return "UTC"   288     elif dt.tzinfo and hasattr(dt.tzinfo, "zone"):   289         return dt.tzinfo.zone   290     else:   291         return None   292    293 def get_period_tzid(start, end):   294    295     "Return the time zone identifier for 'start' and 'end' or None if unknown."   296    297     if isinstance(start, datetime) or isinstance(end, datetime):   298         return get_datetime_tzid(start) or get_datetime_tzid(end)   299     else:   300         return None   301    302 def to_date(dt):   303    304     "Return the date of 'dt'."   305    306     return date(dt.year, dt.month, dt.day)   307    308 def to_datetime(dt, tzid):   309    310     """   311     Return a datetime for 'dt', using the start of day for dates, and using the   312     'tzid' for the conversion.   313     """   314    315     if isinstance(dt, datetime):   316         return to_timezone(dt, tzid)   317     else:   318         return get_start_of_day(dt, tzid)   319    320 def to_utc_datetime(dt, tzid=None):   321    322     """   323     Return a datetime corresponding to 'dt' in the UTC time zone. If 'tzid'   324     is specified, dates and floating datetimes are converted to UTC datetimes   325     using the time zone information; otherwise, such dates and datetimes remain   326     unconverted.   327     """   328    329     if not dt:   330         return None   331     elif get_datetime_tzid(dt):   332         return to_timezone(dt, "UTC")   333     elif tzid:   334         return to_timezone(to_datetime(dt, tzid), "UTC")   335     else:   336         return dt   337    338 def to_timezone(dt, tzid):   339    340     """   341     Return a datetime corresponding to 'dt' in the time regime having the given   342     'tzid'.   343     """   344    345     try:   346         tz = tzid and timezone(tzid) or None   347     except UnknownTimeZoneError:   348         tz = None   349     return to_tz(dt, tz)   350    351 def to_tz(dt, tz):   352    353     "Return a datetime corresponding to 'dt' employing the pytz.timezone 'tz'."   354    355     if tz is not None and isinstance(dt, datetime):   356         if not dt.tzinfo:   357             return tz.localize(dt)   358         else:   359             return dt.astimezone(tz)   360     else:   361         return dt   362    363 # iCalendar-related conversions.   364    365 def end_date_from_calendar(dt):   366    367     """   368     Change end dates to refer to the actual dates, not the iCalendar "next day"   369     dates.   370     """   371    372     if not isinstance(dt, datetime):   373         return dt - timedelta(1)   374     else:   375         return dt   376    377 def end_date_to_calendar(dt):   378    379     """   380     Change end dates to refer to the iCalendar "next day" dates, not the actual   381     dates.   382     """   383    384     if not isinstance(dt, datetime):   385         return dt + timedelta(1)   386     else:   387         return dt   388    389 def get_datetime_attributes(dt, tzid=None):   390    391     """   392     Return attributes for the 'dt' date or datetime object with 'tzid'   393     indicating the time zone if not otherwise defined.   394     """   395    396     if isinstance(dt, datetime):   397         attr = {"VALUE" : "DATE-TIME"}   398         tzid = get_datetime_tzid(dt) or tzid   399         if tzid:   400             attr["TZID"] = tzid   401         return attr   402     else:   403         return {"VALUE" : "DATE"}   404    405 def get_datetime_item(dt, tzid=None):   406    407     """   408     Return an iCalendar-compatible string and attributes for 'dt' using any   409     specified 'tzid' to assert a particular time zone if not otherwise defined.   410     """   411    412     if not dt:   413         return None, None   414     if not get_datetime_tzid(dt):   415         dt = to_timezone(dt, tzid)   416     value = format_datetime(dt)   417     attr = get_datetime_attributes(dt, tzid)   418     return value, attr   419    420 def get_period_attributes(start, end, tzid=None):   421    422     """   423     Return attributes for the 'start' and 'end' datetime objects with 'tzid'   424     indicating the time zone if not otherwise defined.   425     """   426    427     attr = {"VALUE" : "PERIOD"}   428     tzid = get_period_tzid(start, end) or tzid   429     if tzid:   430         attr["TZID"] = tzid   431     return attr   432    433 def get_period_item(start, end, tzid=None):   434    435     """   436     Return an iCalendar-compatible string and attributes for 'start', 'end' and   437     'tzid'.   438     """   439    440     if start and end:   441         attr = get_period_attributes(start, end, tzid)   442         start_value = format_datetime(to_timezone(start, attr.get("TZID")))   443         end_value = format_datetime(to_timezone(end, attr.get("TZID")))   444         return "%s/%s" % (start_value, end_value), attr   445     elif start:   446         attr = get_datetime_attributes(start, tzid)   447         start_value = format_datetime(to_timezone(start, attr.get("TZID")))   448         return start_value, attr   449     else:   450         return None, None   451    452 def get_timestamp(offset=None):   453    454     "Return the current time as an iCalendar-compatible string."   455    456     offset = offset or timedelta(0)   457     return format_datetime(to_timezone(datetime.utcnow(), "UTC") + offset)   458    459 def get_date(offset=None):   460    461     """   462     Return the current date, offset by the given timedelta 'offset' if   463     specified. The returned date will not be positioned in any time zone.   464     """   465    466     offset = offset or timedelta(0)   467     return date.today() + offset   468    469 def get_time(offset=None):   470    471     """   472     Return the current time, offset by the given timedelta 'offset' if   473     specified. The returned time will be in the UTC time zone.   474     """   475    476     offset = offset or timedelta(0)   477     return to_timezone(datetime.utcnow(), "UTC") + offset   478    479 def get_tzid(dtstart_attr, dtend_attr):   480    481     """   482     Return any time regime details from the given 'dtstart_attr' and   483     'dtend_attr' attribute collections.   484     """   485    486     return dtstart_attr and dtstart_attr.get("TZID") or dtend_attr and dtend_attr.get("TZID") or None   487    488 def get_recurrence_start(recurrenceid):   489    490     """   491     Return 'recurrenceid' in a form suitable for comparison with period start   492     dates or datetimes. The 'recurrenceid' should be an identifier normalised to   493     a UTC datetime or employing a date or floating datetime representation where   494     no time zone information was originally provided.   495     """   496    497     return get_datetime(recurrenceid)   498    499 def get_recurrence_start_point(recurrenceid, tzid):   500    501     """   502     Return 'recurrenceid' in a form suitable for comparison with free/busy start   503     datetimes, using 'tzid' to convert recurrence identifiers that are dates.   504     The 'recurrenceid' should be an identifier normalised to a UTC datetime or   505     employing a date or floating datetime representation where no time zone   506     information was originally provided.   507     """   508    509     return to_utc_datetime(get_datetime(recurrenceid), tzid)   510    511 # Time corrections.   512    513 class ValidityError(Exception):   514     pass   515    516 def check_permitted_values(dt, permitted_values):   517    518     "Check the datetime 'dt' against the 'permitted_values' list."   519    520     if not isinstance(dt, datetime):   521         raise ValidityError   522    523     hours, minutes, seconds = permitted_values   524     errors = []   525    526     if hours and dt.hour not in hours:   527         errors.append("hour")   528     if minutes and dt.minute not in minutes:   529         errors.append("minute")   530     if seconds and dt.second not in seconds:   531         errors.append("second")   532    533     return errors   534    535 def correct_datetime(dt, permitted_values):   536    537     "Correct 'dt' using the given 'permitted_values' details."   538    539     carry, hour, minute, second = correct_value((dt.hour, dt.minute, dt.second), permitted_values)   540     return datetime(dt.year, dt.month, dt.day, hour, minute, second, dt.microsecond, dt.tzinfo) + \   541            (carry and timedelta(1) or timedelta(0))   542    543 def correct_value(value, permitted_values):   544    545     """   546     Correct the given (hour, minute, second) tuple 'value' according to the   547     'permitted_values' details.   548     """   549    550     limits = 23, 59, 59   551    552     corrected = []   553     reset = False   554    555     # Find invalid values and reset all following values.   556    557     for v, values, limit in zip(value, permitted_values, limits):   558         if reset:   559             if values:   560                 v = values[0]   561             else:   562                 v = 0   563    564         elif values and v not in values:   565             reset = True   566    567         corrected.append(v)   568    569     value = corrected   570     corrected = []   571     carry = 0   572    573     # Find invalid values and update them to the next valid value, updating more   574     # significant values if the next valid value is the first in the appropriate   575     # series.   576    577     for v, values, limit in zip(value, permitted_values, limits)[::-1]:   578         if carry:   579             v += 1   580             if v > limit:   581                 if values:   582                     v = values[0]   583                 else:   584                     v = 0   585                 corrected.append(v)   586                 continue   587             else:   588                 carry = 0   589    590         if values:   591             i = bisect_left(values, v)   592             if i < len(values):   593                 v = values[i]   594             else:   595                 v = values[0]   596                 carry = 1   597    598         corrected.append(v)   599    600     return [carry] + corrected[::-1]   601    602 # vim: tabstop=4 expandtab shiftwidth=4