diff --git a/khal/icalendar.py b/khal/icalendar.py index 39e6eda8f..f87cfc926 100644 --- a/khal/icalendar.py +++ b/khal/icalendar.py @@ -35,10 +35,10 @@ from .parse_datetime import rrulefstr from .utils import generate_random_uid, localize_strip_tz, str2alarm, to_unix_time -logger = logging.getLogger('khal') +logger = logging.getLogger("khal") -def split_ics(ics: str, random_uid: bool=False, default_timezone=None) -> List: +def split_ics(ics: str, random_uid: bool = False, default_timezone=None) -> List: """split an ics string into several according to VEVENT's UIDs and sort the right VTIMEZONEs accordingly @@ -54,21 +54,21 @@ def split_ics(ics: str, random_uid: bool=False, default_timezone=None) -> List: # Since some events could have a Windows format timezone (e.g. 'New Zealand # Standard Time' for 'Pacific/Auckland' in Olson format), we convert any # Windows format timezones to Olson. - if item.name == 'VTIMEZONE': - if item['TZID'] in icalendar.windows_to_olson.WINDOWS_TO_OLSON: - key = icalendar.windows_to_olson.WINDOWS_TO_OLSON[item['TZID']] + if item.name == "VTIMEZONE": + if item["TZID"] in icalendar.windows_to_olson.WINDOWS_TO_OLSON: + key = icalendar.windows_to_olson.WINDOWS_TO_OLSON[item["TZID"]] else: - key = item['TZID'] + key = item["TZID"] tzs[key] = item - if item.name == 'VEVENT': - if 'UID' not in item: + if item.name == "VEVENT": + if "UID" not in item: logger.warning( f"Event with summary '{item['SUMMARY']}' doesn't have a unique ID." "A generated ID will be used instead." ) - item['UID'] = sha256(item.to_ical()).hexdigest() - events_grouped[item['UID']].append(item) + item["UID"] = sha256(item.to_ical()).hexdigest() + events_grouped[item["UID"]].append(item) else: continue out = [] @@ -77,7 +77,7 @@ def split_ics(ics: str, random_uid: bool=False, default_timezone=None) -> List: try: ics = ics_from_list(events, tzs, random_uid, default_timezone) except Exception as exception: - logger.warn(f'Error when trying to import the event {uid}') + logger.warn(f"Error when trying to import the event {uid}") saved_exception = exception else: out.append(ics) @@ -86,20 +86,21 @@ def split_ics(ics: str, random_uid: bool=False, default_timezone=None) -> List: return out -def new_vevent(locale, - dtstart: dt.date, - dtend: dt.date, - summary: str, - timezone: Optional[pytz.BaseTzInfo]=None, - allday: bool=False, - description: Optional[str]=None, - location: Optional[str]=None, - categories: Optional[Union[List[str], str]]=None, - repeat: Optional[str]=None, - until=None, - alarms: Optional[str]=None, - url: Optional[str]=None, - ) -> icalendar.Event: +def new_vevent( + locale, + dtstart: dt.date, + dtend: dt.date, + summary: str, + timezone: Optional[pytz.BaseTzInfo] = None, + allday: bool = False, + description: Optional[str] = None, + location: Optional[str] = None, + categories: Optional[Union[List[str], str]] = None, + repeat: Optional[str] = None, + until=None, + alarms: Optional[str] = None, + url: Optional[str] = None, +) -> icalendar.Event: """create a new event :param dtstart: starttime of that event @@ -120,35 +121,32 @@ def new_vevent(locale, dtend = timezone.localize(dtend) event = icalendar.Event() - event.add('dtstart', dtstart) - event.add('dtend', dtend) - event.add('dtstamp', dt.datetime.now()) - event.add('summary', summary) - event.add('uid', generate_random_uid()) + event.add("dtstart", dtstart) + event.add("dtend", dtend) + event.add("dtstamp", dt.datetime.now()) + event.add("summary", summary) + event.add("uid", generate_random_uid()) # event.add('sequence', 0) if description: - event.add('description', description) + event.add("description", description) if location: - event.add('location', location) + event.add("location", location) if categories: - event.add('categories', categories) + event.add("categories", categories) if url: - event.add('url', icalendar.vUri(url)) + event.add("url", icalendar.vUri(url)) if repeat and repeat != "none": - rrule = rrulefstr(repeat, until, locale, getattr(dtstart, 'tzinfo', None)) - event.add('rrule', rrule) + rrule = rrulefstr(repeat, until, locale, getattr(dtstart, "tzinfo", None)) + event.add("rrule", rrule) if alarms: - for alarm in str2alarm(alarms, description or ''): + for alarm in str2alarm(alarms, description or ""): event.add_component(alarm) return event def ics_from_list( - events: List[icalendar.Event], - tzs, - random_uid: bool=False, - default_timezone=None + events: List[icalendar.Event], tzs, random_uid: bool = False, default_timezone=None ) -> str: """convert an iterable of icalendar.Events to an icalendar str @@ -158,10 +156,8 @@ def ics_from_list( :type tzs: dict(icalendar.cal.Vtimzone """ calendar = icalendar.Calendar() - calendar.add('version', '2.0') - calendar.add( - 'prodid', '-//PIMUTILS.ORG//NONSGML khal / icalendar //EN' - ) + calendar.add("version", "2.0") + calendar.add("prodid", "-//PIMUTILS.ORG//NONSGML khal / icalendar //EN") if random_uid: new_uid = generate_random_uid() @@ -170,32 +166,46 @@ def ics_from_list( for sub_event in events: sub_event = sanitize(sub_event, default_timezone=default_timezone) if random_uid: - sub_event['UID'] = new_uid + sub_event["UID"] = new_uid # icalendar round-trip converts `TZID=a b` to `TZID="a b"` investigate, file bug XXX - for prop in ['DTSTART', 'DTEND', 'DUE', 'EXDATE', 'RDATE', 'RECURRENCE-ID', 'DUE']: + for prop in [ + "DTSTART", + "DTEND", + "DUE", + "EXDATE", + "RDATE", + "RECURRENCE-ID", + "DUE", + ]: if isinstance(sub_event.get(prop), list): items = sub_event.get(prop) else: items = [sub_event.get(prop)] for item in items: - if not (hasattr(item, 'dt') or hasattr(item, 'dts')): + if not (hasattr(item, "dt") or hasattr(item, "dts")): continue # if prop is a list, all items have the same parameters - datetime_ = item.dts[0].dt if hasattr(item, 'dts') else item.dt - if not hasattr(datetime_, 'tzinfo'): + datetime_ = item.dts[0].dt if hasattr(item, "dts") else item.dt + if not hasattr(datetime_, "tzinfo"): continue # check for datetimes' timezones which are not understood by # icalendar - if datetime_.tzinfo is None and 'TZID' in item.params and \ - item.params['TZID'] not in missing_tz: + if ( + datetime_.tzinfo is None + and "TZID" in item.params + and item.params["TZID"] not in missing_tz + ): logger.warning( f"Cannot find timezone `{item.params['TZID']}` in .ics file, " "using default timezone. This can lead to erroneous time shifts" ) - missing_tz.add(item.params['TZID']) - elif datetime_.tzinfo and datetime_.tzinfo != pytz.UTC and \ - datetime_.tzinfo not in needed_tz: + missing_tz.add(item.params["TZID"]) + elif ( + datetime_.tzinfo + and datetime_.tzinfo != pytz.UTC + and datetime_.tzinfo not in needed_tz + ): needed_tz.add(datetime_.tzinfo) for tzid in needed_tz: @@ -203,16 +213,17 @@ def ics_from_list( calendar.add_component(tzs[str(tzid)]) else: logger.warning( - f'Cannot find timezone `{tzid}` in .ics file, this could be a bug, ' - 'please report this issue at http://github.com/pimutils/khal/.') + f"Cannot find timezone `{tzid}` in .ics file, this could be a bug, " + "please report this issue at http://github.com/pimutils/khal/." + ) for sub_event in events: calendar.add_component(sub_event) - return calendar.to_ical().decode('utf-8') + return calendar.to_ical().decode("utf-8") def expand( vevent: icalendar.Event, - href: str='', + href: str = "", ) -> Optional[List[Tuple[dt.datetime, dt.datetime]]]: """ Constructs a list of start and end dates for all recurring instances of the @@ -230,26 +241,26 @@ def expand( :returns: list of start and end (date)times of the expanded event """ # we do this now and than never care about the "real" end time again - if 'DURATION' in vevent: - duration = vevent['DURATION'].dt + if "DURATION" in vevent: + duration = vevent["DURATION"].dt else: - duration = vevent['DTEND'].dt - vevent['DTSTART'].dt + duration = vevent["DTEND"].dt - vevent["DTSTART"].dt # if this vevent has a RECURRENCE_ID property, no expansion will be # performed - expand = not bool(vevent.get('RECURRENCE-ID')) + expand = not bool(vevent.get("RECURRENCE-ID")) - events_tz = getattr(vevent['DTSTART'].dt, 'tzinfo', None) - allday = not isinstance(vevent['DTSTART'].dt, dt.datetime) + events_tz = getattr(vevent["DTSTART"].dt, "tzinfo", None) + allday = not isinstance(vevent["DTSTART"].dt, dt.datetime) def sanitize_datetime(date: dt.date) -> dt.date: if allday and isinstance(date, dt.datetime): date = date.date() if events_tz is not None: - date = events_tz.localize(date) + date = date.replace(tzinfo=events_tz) return date - rrule_param = vevent.get('RRULE') + rrule_param = vevent.get("RRULE") if expand and rrule_param is not None: vevent = sanitize_rrule(vevent) @@ -257,7 +268,7 @@ def sanitize_datetime(date: dt.date) -> dt.date: # everything to naive datetime objects and transform back after # expanding # See https://github.com/dateutil/dateutil/issues/102 - dtstart = vevent['DTSTART'].dt + dtstart = vevent["DTSTART"].dt if events_tz: dtstart = dtstart.replace(tzinfo=None) @@ -277,35 +288,42 @@ def sanitize_datetime(date: dt.date) -> dt.date: # doesn't know any larger transition times rrule._until = dt.datetime(2037, 12, 31) # type: ignore else: - if events_tz and 'Z' in rrule_param.to_ical().decode(): + if events_tz and "Z" in rrule_param.to_ical().decode(): assert isinstance(rrule._until, dt.datetime) # type: ignore - rrule._until = pytz.UTC.localize( # type: ignore - rrule._until).astimezone(events_tz).replace(tzinfo=None) # type: ignore + rrule._until = ( + pytz.UTC.localize(rrule._until) # type: ignore + .astimezone(events_tz) + .replace(tzinfo=None) + ) # type: ignore # rrule._until and dtstart could be dt.date or dt.datetime. They # need to be the same for comparison testuntil = rrule._until # type: ignore - if (type(dtstart) == dt.date and type(testuntil) == dt.datetime): + if type(dtstart) == dt.date and type(testuntil) == dt.datetime: testuntil = testuntil.date() teststart = dtstart - if (type(testuntil) == dt.date and type(teststart) == dt.datetime): + if type(testuntil) == dt.date and type(teststart) == dt.datetime: teststart = teststart.date() if testuntil < teststart: logger.warning( - f'{href}: Unsupported recurrence. UNTIL is before DTSTART.\n' - 'This event will not be available in khal.') + f"{href}: Unsupported recurrence. UNTIL is before DTSTART.\n" + "This event will not be available in khal." + ) return None if rrule.count() == 0: logger.warning( - f'{href}: Recurrence defined but will never occur.\n' - 'This event will not be available in khal.') + f"{href}: Recurrence defined but will never occur.\n" + "This event will not be available in khal." + ) return None rrule = map(sanitize_datetime, rrule) # type: ignore - logger.debug(f'calculating recurrence dates for {href}, this might take some time.') + logger.debug( + f"calculating recurrence dates for {href}, this might take some time." + ) # RRULE and RDATE may specify the same date twice, it is recommended by # the RFC to consider this as only one instance @@ -313,7 +331,7 @@ def sanitize_datetime(date: dt.date) -> dt.date: if not dtstartl: raise UnsupportedRecurrence() else: - dtstartl = {vevent['DTSTART'].dt} + dtstartl = {vevent["DTSTART"].dt} def get_dates(vevent, key): # TODO replace with get_all_properties @@ -329,17 +347,18 @@ def get_dates(vevent, key): # include explicitly specified recursion dates if expand: - dtstartl.update(get_dates(vevent, 'RDATE') or ()) + dtstartl.update(get_dates(vevent, "RDATE") or ()) # remove excluded dates if expand: - for date in get_dates(vevent, 'EXDATE') or (): + for date in get_dates(vevent, "EXDATE") or (): try: dtstartl.remove(date) except KeyError: logger.warning( - f'In event {href}, excluded instance starting at {date} ' - 'not found, event might be invalid.') + f"In event {href}, excluded instance starting at {date} " + "not found, event might be invalid." + ) dtstartend = [(start, start + duration) for start in dtstartl] # not necessary, but I prefer deterministic output @@ -351,8 +370,8 @@ def assert_only_one_uid(cal: icalendar.Calendar): """assert that all VEVENTs in cal have the same UID""" uids = set() for item in cal.walk(): - if item.name == 'VEVENT': - uids.add(item['UID']) + if item.name == "VEVENT": + uids.add(item["UID"]) if len(uids) > 1: return False else: @@ -362,8 +381,8 @@ def assert_only_one_uid(cal: icalendar.Calendar): def sanitize( vevent: icalendar.Event, default_timezone: pytz.BaseTzInfo, - href: str='', - calendar: str='', + href: str = "", + calendar: str = "", ) -> icalendar.Event: """ clean up vevents we do not understand @@ -381,9 +400,9 @@ def sanitize( # convert localized datetimes with timezone information we don't # understand to the default timezone # TODO do this for everything where a TZID can appear (RDATE, EXDATE) - for prop in ['DTSTART', 'DTEND', 'DUE', 'RECURRENCE-ID']: + for prop in ["DTSTART", "DTEND", "DUE", "RECURRENCE-ID"]: if prop in vevent and invalid_timezone(vevent[prop]): - timezone = vevent[prop].params.get('TZID') + timezone = vevent[prop].params.get("TZID") value = default_timezone.localize(vevent.pop(prop).dt) vevent.add(prop, value) logger.warning( @@ -392,26 +411,27 @@ def sanitize( "event being wrongly displayed." ) - vdtstart = vevent.pop('DTSTART', None) - vdtend = vevent.pop('DTEND', None) - dtstart = getattr(vdtstart, 'dt', None) - dtend = getattr(vdtend, 'dt', None) + vdtstart = vevent.pop("DTSTART", None) + vdtend = vevent.pop("DTEND", None) + dtstart = getattr(vdtstart, "dt", None) + dtend = getattr(vdtend, "dt", None) # event with missing DTSTART if dtstart is None: - raise ValueError('Event has no start time (DTSTART).') + raise ValueError("Event has no start time (DTSTART).") dtstart, dtend = sanitize_timerange( - dtstart, dtend, duration=vevent.get('DURATION', None)) + dtstart, dtend, duration=vevent.get("DURATION", None) + ) - vevent.add('DTSTART', dtstart) + vevent.add("DTSTART", dtstart) if dtend is not None: - vevent.add('DTEND', dtend) + vevent.add("DTEND", dtend) return vevent def sanitize_timerange(dtstart, dtend, duration=None): - '''return sensible dtstart and end for events that have an invalid or - missing DTEND, assuming the event just lasts one hour.''' + """return sensible dtstart and end for events that have an invalid or + missing DTEND, assuming the event just lasts one hour.""" if isinstance(dtstart, dt.datetime) and isinstance(dtend, dt.datetime): if dtstart.tzinfo and not dtend.tzinfo: @@ -428,7 +448,8 @@ def sanitize_timerange(dtstart, dtend, duration=None): dtstart = dtend.tzinfo.localize(dtstart) if dtend is not None and type(dtstart) != type(dtend): raise ValueError( - 'The event\'s end time (DTEND) and start time (DTSTART) are not of the same type.') + "The event's end time (DTEND) and start time (DTSTART) are not of the same type." + ) if dtend is None and duration is None: if isinstance(dtstart, dt.datetime): @@ -437,8 +458,10 @@ def sanitize_timerange(dtstart, dtend, duration=None): dtend = dtstart + dt.timedelta(days=1) elif dtend is not None: if dtend < dtstart: - raise ValueError('The event\'s end time (DTEND) is older than ' - 'the event\'s start time (DTSTART).') + raise ValueError( + "The event's end time (DTEND) is older than " + "the event's start time (DTSTART)." + ) elif dtend == dtstart: logger.warning( "Event start time and end time are the same. " @@ -454,18 +477,18 @@ def sanitize_timerange(dtstart, dtend, duration=None): def sanitize_rrule(vevent): """fix problems with RRULE:UNTIL""" - if 'rrule' in vevent and 'UNTIL' in vevent['rrule']: - until = vevent['rrule']['UNTIL'][0] - dtstart = vevent['dtstart'].dt + if "rrule" in vevent and "UNTIL" in vevent["rrule"]: + until = vevent["rrule"]["UNTIL"][0] + dtstart = vevent["dtstart"].dt # DTSTART is date, UNTIL is datetime if not isinstance(dtstart, dt.datetime) and isinstance(until, dt.datetime): - vevent['rrule']['until'] = until.date() + vevent["rrule"]["until"] = until.date() return vevent def invalid_timezone(prop): """check if an icalendar property has a timezone attached we don't understand""" - if hasattr(prop.dt, 'tzinfo') and prop.dt.tzinfo is None and 'TZID' in prop.params: + if hasattr(prop.dt, "tzinfo") and prop.dt.tzinfo is None and "TZID" in prop.params: return True else: return False @@ -498,16 +521,18 @@ def delete_instance(vevent: icalendar.Event, instance: dt.datetime) -> None: """ # TODO check where this instance is coming from and only call the # appropriate function - if 'RRULE' in vevent: - exdates = _get_all_properties(vevent, 'EXDATE') + if "RRULE" in vevent: + exdates = _get_all_properties(vevent, "EXDATE") exdates += [instance] - vevent.pop('EXDATE') - vevent.add('EXDATE', exdates) - if 'RDATE' in vevent: - rdates = [one for one in _get_all_properties(vevent, 'RDATE') if one != instance] - vevent.pop('RDATE') + vevent.pop("EXDATE") + vevent.add("EXDATE", exdates) + if "RDATE" in vevent: + rdates = [ + one for one in _get_all_properties(vevent, "RDATE") if one != instance + ] + vevent.pop("RDATE") if rdates != []: - vevent.add('RDATE', rdates) + vevent.add("RDATE", rdates) def sort_key(vevent: icalendar.Event) -> Tuple[str, float]: @@ -517,12 +542,12 @@ def sort_key(vevent: icalendar.Event) -> Tuple[str, float]: :rtype: tuple(str, int) """ assert isinstance(vevent, icalendar.Event) - uid = str(vevent['UID']) - rec_id = vevent.get('RECURRENCE-ID') + uid = str(vevent["UID"]) + rec_id = vevent.get("RECURRENCE-ID") if rec_id is None: return uid, 0 - rrange = rec_id.params.get('RANGE') - if rrange == 'THISANDFUTURE': + rrange = rec_id.params.get("RANGE") + if rrange == "THISANDFUTURE": return uid, to_unix_time(rec_id.dt) else: return uid, 1 @@ -535,11 +560,14 @@ def cal_from_ics(ics: str) -> icalendar.Calendar: try: cal = icalendar.Calendar.from_ical(ics) except ValueError as error: - if (len(error.args) > 0 and isinstance(error.args[0], str) and - error.args[0].startswith('Offset must be less than 24 hours')): + if ( + len(error.args) > 0 + and isinstance(error.args[0], str) + and error.args[0].startswith("Offset must be less than 24 hours") + ): logger.warning( - 'Invalid timezone offset encountered, ' - 'timezone information may be wrong: ' + str(error.args[0]) + "Invalid timezone offset encountered, " + "timezone information may be wrong: " + str(error.args[0]) ) icalendar.vUTCOffset.ignore_exceptions = True cal = icalendar.Calendar.from_ical(ics)