imip-agent

imiptools/dates.py

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