diff --git a/CHANGES.rst b/CHANGES.rst index 86588af2..cc4d5b98 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,29 @@ Changelog ========= +5.0 (unreleased) +---------------- + +Breaking changes: + +- Added strong typing of property values. Unknown properties with VALUE parameter + should now be represented as the appropriate type + Refs #187 + [stlaz] + +- Improved error handling. The value and parameters of a property should no longer + be lost upon error. + Refs #158 #174 + [stlaz] + +New features: + +- *add item here* + +Bug fixes: + +- *add item here* + 5.0.0a2 (unreleased) -------------------- diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index 57eefc04..e7f41be1 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -113,7 +113,7 @@ def is_broken(self): ############################# # handling of property values - def _encode(self, name, value, parameters=None, encode=1): + def _encode(self, name, value, parameters=None, encode=True): """Encode values to icalendar property values. :param name: Name of the property. @@ -139,19 +139,27 @@ def _encode(self, name, value, parameters=None, encode=1): if isinstance(value, types_factory.all_types): # Don't encode already encoded values. return value - klass = types_factory.for_property(name) - obj = klass(value) if parameters: if isinstance(parameters, dict): params = Parameters() for key, item in parameters.items(): params[key] = item parameters = params + klass = types_factory.for_property( + name, + valuetype=parameters.get('VALUE') if parameters else None, + nativetype=type(value) + ) + if types_factory.is_date_list_property(name): + obj = vDDDLists(value, klass) + else: + obj = klass(value) + if parameters: assert isinstance(parameters, Parameters) obj.params = parameters return obj - def add(self, name, value, parameters=None, encode=1): + def add(self, name, value, parameters=None, encode=True): """Add a property. :param name: Name of the property. @@ -172,7 +180,7 @@ def add(self, name, value, parameters=None, encode=1): :returns: None """ - if isinstance(value, datetime) and\ + if type(value) is datetime and\ name.lower() in ('dtstamp', 'created', 'last-modified'): # RFC expects UTC for those... force value conversion. if getattr(value, 'tzinfo', False) and value.tzinfo is not None: @@ -183,7 +191,8 @@ def add(self, name, value, parameters=None, encode=1): # encode value if encode and isinstance(value, list) \ - and name.lower() not in ['rdate', 'exdate', 'categories']: + and not types_factory.is_date_list_property(name)\ + and name.lower() not in ('categories',): # Individually convert each value to an ical type except rdate and # exdate, where lists of dates might be passed to vDDDLists. value = [self._encode(name, v, parameters, encode) for v in value] @@ -215,7 +224,11 @@ def _decode(self, name, value): if isinstance(value, vDDDLists): # TODO: Workaround unfinished decoding return value - decoded = types_factory.from_ical(name, value) + try: + valtype = value.params['VALUE'] + except (AttributeError, KeyError): + valtype = None + decoded = types_factory.from_ical(name, value, valtype) # TODO: remove when proper decoded is implemented in every prop.* class # Workaround to decode vText properly if isinstance(decoded, vText): @@ -223,11 +236,8 @@ def _decode(self, name, value): return decoded def decoded(self, name, default=_marker): - """Returns decoded value of property. + """Returns value of a property as a python native type. """ - # XXX: fail. what's this function supposed to do in the end? - # -rnix - if name in self: value = self[name] if isinstance(value, list): @@ -243,7 +253,7 @@ def decoded(self, name, default=_marker): # Inline values. A few properties have multiple values inlined in in one # property line. These methods are used for splitting and joining these. - def get_inline(self, name, decode=1): + def get_inline(self, name, decode=True): """Returns a list of values (split on comma). """ vals = [v.strip('" ') for v in q_split(self[name])] @@ -251,12 +261,12 @@ def get_inline(self, name, decode=1): return [self._decode(name, val) for val in vals] return vals - def set_inline(self, name, values, encode=1): - """Converts a list of values into comma separated string and sets value + def set_inline(self, name, values, encode=True): + """Converts a list of values into comma seperated string and sets value to that. """ if encode: - values = [self._encode(name, value, encode=1) for value in values] + values = [self._encode(name, value, encode=True) for value in values] self[name] = types_factory['inline'](q_join(values)) ######################### @@ -367,21 +377,43 @@ def from_ical(cls, st, multiple=False): _timezone_cache[component['TZID']] = component.to_tz() # we are adding properties to the current top of the stack else: - factory = types_factory.for_property(name) component = stack[-1] if stack else None if not component: raise ValueError('Property "{prop}" does not have ' 'a parent component.'.format(prop=name)) - datetime_names = ('DTSTART', 'DTEND', 'RECURRENCE-ID', 'DUE', - 'FREEBUSY', 'RDATE', 'EXDATE') + try: - if name in datetime_names and 'TZID' in params: + factory = types_factory.for_property(name, + valuetype=params.get('VALUE')) + except ValueError as e: + if not component.ignore_exceptions: + raise + else: + # add error message and fall back to vText value type + component.errors.append((uname, str(e))) + factory = types_factory['text'] + + try: + if (types_factory.is_date_list_property(name) and + factory != vText): + # TODO: list type currenty supports only datetime types + vals = vDDDLists( + vDDDLists.from_ical(vals, params.get('TZID'), + factory)) + elif uname in types_factory.datetime_names and 'TZID' in params: vals = factory(factory.from_ical(vals, params['TZID'])) else: vals = factory(factory.from_ical(vals)) + except ValueError as e: if not component.ignore_exceptions: raise +# component.errors.append((uname, unicode_type(e))) +# # fallback to vText and store the original value +# vals = types_factory['text'](vals) +# +# vals.params = params +# component.add(name, vals, encode=0) component.errors.append((uname, str(e))) component.add(name, None, encode=0) else: @@ -594,9 +626,9 @@ def to_tz(self): dst = {} tznames = set() for component in self.walk(): - if type(component) == Timezone: + if type(component) is Timezone: continue - assert isinstance(component['DTSTART'].dt, datetime), ( + assert type(component['DTSTART'].dt) is datetime, ( "VTIMEZONEs sub-components' DTSTART must be of type datetime, not date" ) try: diff --git a/src/icalendar/parser.py b/src/icalendar/parser.py index 6896f9fb..bf23a0e6 100644 --- a/src/icalendar/parser.py +++ b/src/icalendar/parser.py @@ -192,7 +192,7 @@ def params(self): # TODO? # Later, when I get more time... need to finish this off now. The last major # thing missing. -# def _encode(self, name, value, cond=1): +# def _encode(self, name, value, cond=True): # # internal, for conditional convertion of values. # if cond: # klass = types_factory.for_property(name) diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index b6395df4..b7e0aa0f 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -237,13 +237,22 @@ def from_ical(cls, ical): class vDDDLists: """A list of vDDDTypes values. """ - def __init__(self, dt_list): + def __init__(self, dt_list, type_class=None): + if type_class is None: + type_class = vDDDTypes if not hasattr(dt_list, '__iter__'): dt_list = [dt_list] vDDD = [] tzid = None + if dt_list: + # we have some values, all should have the same type + ltype = type(dt_list[0]) for dt in dt_list: - dt = vDDDTypes(dt) + # raise ValueError if type of the input values differs + if not type(dt) == ltype: + raise ValueError("Trying to insert '%s' value into a list " + "of '%s'".format(type(dt), ltype)) + dt = type_class(dt) vDDD.append(dt) if 'TZID' in dt.params: tzid = dt.params['TZID'] @@ -257,12 +266,16 @@ def to_ical(self): dts_ical = (dt.to_ical() for dt in self.dts) return b",".join(dts_ical) - @staticmethod - def from_ical(ical, timezone=None): + @classmethod + def from_ical(cls, ical, timezone=None, unit_type=None): + if isinstance(ical, cls): + return ical.dts + if unit_type is None: + unit_type = vDDDTypes out = [] ical_dates = ical.split(",") for ical_dt in ical_dates: - out.append(vDDDTypes.from_ical(ical_dt, timezone=timezone)) + out.append(unit_type.from_ical(ical_dt, timezone=timezone)) return out class vCategory: @@ -271,6 +284,7 @@ def __init__(self, c_list): if not hasattr(c_list, '__iter__'): c_list = [c_list] self.cats = [vText(c) for c in c_list] + self.params = Parameters() def to_ical(self): return b",".join([c.to_ical() for c in self.cats]) @@ -288,20 +302,19 @@ class vDDDTypes: So this is practical. """ def __init__(self, dt): - if not isinstance(dt, (datetime, date, timedelta, time, tuple)): + if type(dt) not in (datetime, date, timedelta, time, tuple): raise ValueError('You must use datetime, date, timedelta, ' 'time or tuple (for periods)') - if isinstance(dt, datetime): + if type(dt) is datetime: self.params = Parameters({'value': 'DATE-TIME'}) - elif isinstance(dt, date): + elif type(dt) is date: self.params = Parameters({'value': 'DATE'}) - elif isinstance(dt, time): + elif type(dt) is time: self.params = Parameters({'value': 'TIME'}) - elif isinstance(dt, tuple): + elif type(dt) is tuple: self.params = Parameters({'value': 'PERIOD'}) - if (isinstance(dt, datetime) or isinstance(dt, time))\ - and getattr(dt, 'tzinfo', False): + if type(dt) in (datetime, time) and hasattr(dt, 'tzinfo'): tzinfo = dt.tzinfo tzid = tzid_from_dt(dt) if tzid != 'UTC': @@ -310,15 +323,15 @@ def __init__(self, dt): def to_ical(self): dt = self.dt - if isinstance(dt, datetime): + if type(dt) is datetime: return vDatetime(dt).to_ical() - elif isinstance(dt, date): + elif type(dt) is date: return vDate(dt).to_ical() - elif isinstance(dt, timedelta): + elif type(dt) is timedelta: return vDuration(dt).to_ical() - elif isinstance(dt, time): + elif type(dt) is time: return vTime(dt).to_ical() - elif isinstance(dt, tuple) and len(dt) == 2: + elif type(dt) is tuple and len(dt) == 2: return vPeriod(dt).to_ical() else: raise ValueError(f'Unknown date type: {type(dt)}') @@ -349,7 +362,7 @@ class vDate: """Render and generates iCalendar date format. """ def __init__(self, dt): - if not isinstance(dt, date): + if type(dt) is not date: raise ValueError('Value MUST be a date instance') self.dt = dt self.params = Parameters({'value': 'DATE'}) @@ -358,9 +371,14 @@ def to_ical(self): s = "%04d%02d%02d" % (self.dt.year, self.dt.month, self.dt.day) return s.encode('utf-8') - @staticmethod - def from_ical(ical): + @classmethod + def from_ical(cls, ical, timezone=None): + # timezone is a dummy in this method + if isinstance(ical, cls): + return ical.dt try: + if len(ical) != 8: # YYYYMMDD is 8 digits + raise ValueError timetuple = ( int(ical[:4]), # year int(ical[4:6]), # month @@ -368,7 +386,7 @@ def from_ical(ical): ) return date(*timetuple) except Exception: - raise ValueError('Wrong date format %s' % ical) + raise ValueError("Wrong date format '%s'" % ical) class vDatetime: @@ -384,7 +402,15 @@ class vDatetime: """ def __init__(self, dt): self.dt = dt - self.params = Parameters() + self.params = Parameters({'value': 'DATE-TIME'}) + if hasattr(dt, 'tzinfo'): + tzinfo = dt.tzinfo + if tzinfo is not pytz.utc and\ + (tzutc is None or not isinstance(tzinfo, tzutc)): + # set the timezone as a parameter to the property + tzid = tzid_from_dt(dt) + if tzid: + self.params.update({'TZID': tzid}) def to_ical(self): dt = self.dt @@ -404,8 +430,10 @@ def to_ical(self): self.params.update({'TZID': tzid}) return s.encode('utf-8') - @staticmethod - def from_ical(ical, timezone=None): + @classmethod + def from_ical(cls, ical, timezone=None): + if isinstance(ical, cls): + return ical.dt tzinfo = None if timezone: try: @@ -417,6 +445,10 @@ def from_ical(ical, timezone=None): tzinfo = _timezone_cache.get(timezone, None) try: + if len(ical) not in (15, 16): + raise ValueError + if ical[8] != 'T': + raise ValueError timetuple = ( int(ical[:4]), # year int(ical[4:6]), # month @@ -429,12 +461,12 @@ def from_ical(ical, timezone=None): return tzinfo.localize(datetime(*timetuple)) elif not ical[15:]: return datetime(*timetuple) - elif ical[15:16] == 'Z': + elif ical[15] == 'Z': return pytz.utc.localize(datetime(*timetuple)) else: raise ValueError(ical) except Exception: - raise ValueError('Wrong datetime format: %s' % ical) + raise ValueError("Wrong datetime format '%s'" % ical) class vDuration: @@ -443,10 +475,16 @@ class vDuration: """ def __init__(self, td): - if not isinstance(td, timedelta): + if type(td) is not timedelta: raise ValueError('Value MUST be a timedelta instance') self.td = td - self.params = Parameters() + self.params = Parameters({'value': 'DURATION'}) + + @property + def dt(self): + # BBB: Backwards compatibility. + # Might be removed in future version. + return self.td def to_ical(self): sign = "" @@ -474,8 +512,10 @@ def to_ical(self): str(abs(td.days)).encode('utf-8') + b'D' + str(timepart).encode('utf-8')) - @staticmethod - def from_ical(ical): + @classmethod + def from_ical(cls, ical): + if isinstance(ical, cls): + return ical.td try: match = DURATION_REGEX.match(ical) sign, weeks, days, hours, minutes, seconds = match.groups() @@ -498,15 +538,13 @@ class vPeriod: """ def __init__(self, per): start, end_or_duration = per - if not (isinstance(start, datetime) or isinstance(start, date)): + if type(start) not in (datetime, date): raise ValueError('Start value MUST be a datetime or date instance') - if not (isinstance(end_or_duration, datetime) or - isinstance(end_or_duration, date) or - isinstance(end_or_duration, timedelta)): + if type(end_or_duration) not in (datetime, date, timedelta): raise ValueError('end_or_duration MUST be a datetime, ' 'date or timedelta instance') by_duration = 0 - if isinstance(end_or_duration, timedelta): + if type(end_or_duration) is timedelta: by_duration = 1 duration = end_or_duration end = start + duration @@ -516,7 +554,7 @@ def __init__(self, per): if start > end: raise ValueError("Start time is greater than end time") - self.params = Parameters() + self.params = Parameters({'value': 'PERIOD'}) # set the timezone identifier # does not support different timezones for start and end tzid = tzid_from_dt(start) @@ -547,12 +585,14 @@ def to_ical(self): return (vDatetime(self.start).to_ical() + b'/' + vDatetime(self.end).to_ical()) - @staticmethod - def from_ical(ical): + @classmethod + def from_ical(cls, ical, timezone=None): + if isinstance(ical, cls): + return (self.start, self.end) try: start, end_or_duration = ical.split('/') - start = vDDDTypes.from_ical(start) - end_or_duration = vDDDTypes.from_ical(end_or_duration) + start = vDDDTypes.from_ical(start, timezone) + end_or_duration = vDDDTypes.from_ical(end_or_duration, timezone) return (start, end_or_duration) except Exception: raise ValueError('Expected period format, got: %s' % ical) @@ -735,12 +775,20 @@ class vTime: def __init__(self, *args): if len(args) == 1: - if not isinstance(args[0], (time, datetime)): + if type(args[0]) not in (time, datetime): raise ValueError('Expected a datetime.time, got: %s' % args[0]) self.dt = args[0] else: self.dt = time(*args) self.params = Parameters({'value': 'TIME'}) + if hasattr(self.dt, 'tzinfo'): + tzinfo = self.dt.tzinfo + if tzinfo is not pytz.utc and\ + (tzutc is None or not isinstance(tzinfo, tzutc)): + # set the timezone as a parameter to the property + tzid = tzid_from_dt(self.dt) + if tzid: + self.params.update({'TZID': tzid}) def to_ical(self): return self.dt.strftime("%H%M%S") @@ -749,10 +797,12 @@ def to_ical(self): def from_ical(ical): # TODO: timezone support try: + if len(ical) not in (6,7): + raise ValueError timetuple = (int(ical[:2]), int(ical[2:4]), int(ical[4:6])) return time(*timetuple) except Exception: - raise ValueError('Expected time, got: %s' % ical) + raise ValueError("Expected time, got: '%s'" % ical) class vUri(str): @@ -814,7 +864,7 @@ class vUTCOffset: # propagate upwards def __init__(self, td): - if not isinstance(td, timedelta): + if not type(td) is timedelta: raise ValueError('Offset value MUST be a timedelta instance') self.td = td self.params = Parameters() @@ -916,9 +966,9 @@ def __init__(self, *args, **kwargs): self['binary'] = vBinary self['boolean'] = vBoolean self['cal-address'] = vCalAddress - self['date'] = vDDDTypes - self['date-time'] = vDDDTypes - self['duration'] = vDDDTypes + self['date'] = vDate + self['date-time'] = vDatetime + self['duration'] = vDuration self['float'] = vFloat self['integer'] = vInt self['period'] = vPeriod @@ -945,7 +995,7 @@ def __init__(self, *args, **kwargs): 'prodid': 'text', 'version': 'text', # Descriptive Component Properties - 'attach': 'uri', + 'attach': ('uri', 'binary'), 'categories': 'categories', 'class': 'text', 'comment': 'text', @@ -959,9 +1009,9 @@ def __init__(self, *args, **kwargs): 'summary': 'text', # Date and Time Component Properties 'completed': 'date-time', - 'dtend': 'date-time', - 'due': 'date-time', - 'dtstart': 'date-time', + 'dtend': ('date-time', 'date'), + 'due': ('date-time', 'date'), + 'dtstart': ('date-time', 'date'), 'duration': 'duration', 'freebusy': 'period', 'transp': 'text', @@ -975,23 +1025,23 @@ def __init__(self, *args, **kwargs): 'attendee': 'cal-address', 'contact': 'text', 'organizer': 'cal-address', - 'recurrence-id': 'date-time', + 'recurrence-id': ('date-time', 'date'), 'related-to': 'text', 'url': 'uri', 'uid': 'text', # Recurrence Component Properties - 'exdate': 'date-time-list', - 'exrule': 'recur', - 'rdate': 'date-time-list', + 'exdate': ('date-time', 'date'), # list + 'exrule': 'recur', # deprecated in RFC 5545 + 'rdate': ('date-time', 'date', 'period'), # list 'rrule': 'recur', # Alarm Component Properties 'action': 'text', 'repeat': 'integer', - 'trigger': 'duration', + 'trigger': ('duration', 'date-time'), # if datetime, must be UTC format # Change Management Component Properties - 'created': 'date-time', - 'dtstamp': 'date-time', - 'last-modified': 'date-time', + 'created': 'date-time', # must be in UTC time format + 'dtstamp': 'date-time', # must be in UTC time format + 'last-modified': 'date-time', # must be in UTC time format 'sequence': 'integer', # Miscellaneous Component Properties 'request-status': 'text', @@ -1015,14 +1065,80 @@ def __init__(self, *args, **kwargs): 'role': 'text', 'rsvp': 'boolean', 'sent-by': 'cal-address', - 'tzid': 'text', + # 'tzid': 'text', would be an overlapping duplicate 'value': 'text', }) - def for_property(self, name): - """Returns a the default type for a property or parameter + native_type_map = { + datetime: 'date-time', + date: 'date', + } + + datetime_names = ( + 'COMPLETED', + 'CREATED', + 'DTEND', + 'DTSTAMP', + 'DTSTART', + 'DUE', + 'DURATION', + 'EXDATE' + 'FREEBUSY', + 'LAST-MODIFIED', + 'RDATE', + 'RECURRENCE-ID', + 'TRIGGER', + ) + date_list_properties = ('EXDATE', 'RDATE') + + def is_date_list_property(self, name): + if name.upper() in self.date_list_properties: + return True + return False + + def for_property(self, name, valuetype=None, nativetype=None): + """Returns inner representation type for a property + @param valuetype: the value of the VALUE parameter if set """ - return self[self.types_map.get(name, 'text')] + res_type = self.types_map.get(name) + _nativetype = self.native_type_map.get(nativetype) + + if res_type is None: + # Unknown property + + if valuetype and valuetype.upper() in list(self.keys()): + return self[valuetype] + + if _nativetype: + return self[_nativetype] + + return self['text'] # Default fallback + + if isinstance(res_type, tuple): + # List of values should have the same type + + if valuetype: + # VALUE was set + valuetype = valuetype.lower() + if valuetype not in res_type: + raise ValueError("The VALUE parameter of {name} property " + "is not supported: '{type}'" + .format(name=name, type=valuetype.upper()) + ) + # The type in VALUE can be used + return self[valuetype] + + if _nativetype: + return self[_nativetype] + + return self[res_type[0]] # Fallback, use first type of tuple. + + elif valuetype and valuetype.lower() != res_type: + raise ValueError("The VALUE parameter of {name} property is " + "not supported: '{type}'" + .format(name=name, type=valuetype.upper())) + + return self[res_type] def to_ical(self, name, value): """Encodes a named value from a primitive python type to an icalendar @@ -1031,10 +1147,14 @@ def to_ical(self, name, value): type_class = self.for_property(name) return type_class(value).to_ical() - def from_ical(self, name, value): + def from_ical(self, name, value, valuetype=None): """Decodes a named property or parameter value from an icalendar encoded string to a primitive python type. """ - type_class = self.for_property(name) - decoded = type_class.from_ical(value) + type_class = self.for_property(name, valuetype) + if name.upper() in self.date_list_properties: + # this property is of list type + decoded = vDDDLists.from_ical(value, unit_type=type_class) + else: + decoded = type_class.from_ical(value) return decoded diff --git a/src/icalendar/tests/decoding.ics b/src/icalendar/tests/decoding.ics new file mode 100644 index 00000000..bd9a410a --- /dev/null +++ b/src/icalendar/tests/decoding.ics @@ -0,0 +1,77 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Plone.org//NONSGML plone.app.event//EN +X-WR-TIMEZONE:Europe/Vienna +BEGIN:VTIMEZONE +TZID:Europe/Vienna +X-LIC-LOCATION:Europe/Vienna +BEGIN:DAYLIGHT +DTSTART;VALUE=DATE-TIME:20130331T030000 +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +END:VTIMEZONE + +BEGIN:VEVENT +SUMMARY:e1 +DESCRIPTION:A basic event with many properties. +DTSTART;TZID=Europe/Vienna;VALUE=DATE-TIME:20130719T120000 +DTEND;TZID=Europe/Vienna;VALUE=DATE-TIME:20130720T130000 +DTSTAMP;VALUE=DATE-TIME:20130719T125936Z +UID:48f1a7ad64e847568d860cd092344970 +ATTENDEE;CN=attendee1;ROLE=REQ-PARTICIPANT:attendee1 +ATTENDEE;CN=attendee2;ROLE=REQ-PARTICIPANT:attendee2 +ATTENDEE;CN=attendee3;ROLE=REQ-PARTICIPANT:attendee3 +CONTACT:testcontactname\, 1234\, test@contact.email\, http://test.url +CREATED;VALUE=DATE-TIME:20130719T105931Z +LAST-MODIFIED;VALUE=DATE-TIME:20130719T105931Z +LOCATION:testlocation +URL:http://localhost:8080/Plone/testevent +END:VEVENT + +BEGIN:VEVENT +SUMMARY:e2 +DESCRIPTION:A recurring event with exdates +DTSTART:19960401T010000 +DTEND:19960401T020000 +RRULE:FREQ=DAILY;COUNT=100 +EXDATE:19960402T010000Z,19960403T010000Z,19960404T010000Z +UID:48f1a7ad64e847568d860cd0923449702 +LAST-MODIFIED;VALUE=DATE-TIME:20130719T105931Z +END:VEVENT + +BEGIN:VEVENT +SUMMARY:e3 +DESCRIPTION:A Recurring event with multiple exdates, one per line. +DTSTART;TZID=Europe/Vienna:20120327T100000 +DTEND;TZID=Europe/Vienna:20120327T180000 +RRULE:FREQ=WEEKLY;UNTIL=20120703T080000Z;BYDAY=TU +EXDATE;TZID=Europe/Vienna:20120529T100000 +EXDATE;TZID=Europe/Vienna:20120403T100000 +EXDATE;TZID=Europe/Vienna:20120410T100000 +EXDATE;TZID=Europe/Vienna:20120501T100000 +EXDATE;TZID=Europe/Vienna:20120417T100000 +DTSTAMP:20130716T120638Z +UID:48f1a7ad64e847568d860cd0923449703 +LAST-MODIFIED;VALUE=DATE-TIME:20130719T105931Z +END:VEVENT + +BEGIN:VEVENT +SUMMARY:e4 +DESCRIPTION:Whole day event +DTSTART;VALUE=DATE:20130404 +DTEND;VALUE=DATE:20130404 +UID:48f1a7ad64e847568d860cd0923449704 +LAST-MODIFIED;VALUE=DATE-TIME:20130719T105931Z +END:VEVENT + +BEGIN:VEVENT +SUMMARY:e5 +DESCRIPTION:Open end event +DTSTART:20130402T120000 +UID:48f1a7ad64e847568d860cd0923449705 +LAST-MODIFIED;VALUE=DATE-TIME:20130719T105931Z +END:VEVENT + +END:VCALENDAR diff --git a/src/icalendar/tests/decoding2.ics b/src/icalendar/tests/decoding2.ics new file mode 100644 index 00000000..f6340959 --- /dev/null +++ b/src/icalendar/tests/decoding2.ics @@ -0,0 +1,75 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Plone.org//NONSGML plone.app.event//EN +X-WR-TIMEZONE:Europe/Vienna +BEGIN:VTIMEZONE +TZID:Europe/Vienna +X-LIC-LOCATION:Europe/Vienna +BEGIN:DAYLIGHT +DTSTART;VALUE=DATE-TIME:20130331T030000 +TZNAME:CEST +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +END:VTIMEZONE + +BEGIN:VEVENT +SUMMARY:e1 with new title, more recent last modified +DESCRIPTION:A basic event with many properties, updated. +DTSTART;TZID=Europe/Vienna;VALUE=DATE-TIME:20130819T120000 +DTEND;TZID=Europe/Vienna;VALUE=DATE-TIME:20130820T130000 +DTSTAMP;VALUE=DATE-TIME:20130719T125936Z +UID:48f1a7ad64e847568d860cd092344970 +ATTENDEE;CN=attendee1;ROLE=REQ-PARTICIPANT:attendee1 +ATTENDEE;CN=attendee2;ROLE=REQ-PARTICIPANT:attendee2 +ATTENDEE;CN=attendee3;ROLE=REQ-PARTICIPANT:attendee3 +CONTACT:testcontactname\, 1234\, test@contact.email\, http://test.url +CREATED;VALUE=DATE-TIME:20130719T105931Z +LAST-MODIFIED;VALUE=DATE-TIME:20130819T105931Z +LOCATION:testlocation +URL:http://localhost:8080/Plone/testevent +END:VEVENT + +BEGIN:VEVENT +SUMMARY:e2, updated +DESCRIPTION:A recurring event with exdates, updated. +DTSTART:21000401T010000 +DTEND:21000401T020000 +UID:48f1a7ad64e847568d860cd0923449702 +LAST-MODIFIED;VALUE=DATE-TIME:20130719T105931Z +END:VEVENT + +BEGIN:VEVENT +SUMMARY:e3 (older version) +DESCRIPTION:A Recurring event with multiple exdates, one per line. +DTSTART;TZID=Europe/Vienna:20120327T100000 +DTEND;TZID=Europe/Vienna:20120327T180000 +RRULE:FREQ=WEEKLY;UNTIL=20120703T080000Z;BYDAY=TU +EXDATE;TZID=Europe/Vienna:20120529T100000 +EXDATE;TZID=Europe/Vienna:20120403T100000 +EXDATE;TZID=Europe/Vienna:20120410T100000 +EXDATE;TZID=Europe/Vienna:20120501T100000 +EXDATE;TZID=Europe/Vienna:20120417T100000 +DTSTAMP:20130716T120638Z +UID:48f1a7ad64e847568d860cd0923449703 +LAST-MODIFIED;VALUE=DATE-TIME:20130718T105931Z +END:VEVENT + +BEGIN:VEVENT +SUMMARY:e4 +DESCRIPTION:Whole day event +DTSTART;VALUE=DATE:20130404 +DTEND;VALUE=DATE:20130404 +UID:48f1a7ad64e847568d860cd0923449704 +LAST-MODIFIED;VALUE=DATE-TIME:20130719T105931Z +END:VEVENT + +BEGIN:VEVENT +SUMMARY:e5 +DESCRIPTION:Open end event +DTSTART:20130402T120000 +UID:48f1a7ad64e847568d860cd0923449705 +LAST-MODIFIED;VALUE=DATE-TIME:20130719T105931Z +END:VEVENT + +END:VCALENDAR diff --git a/src/icalendar/tests/test_decoding.py b/src/icalendar/tests/test_decoding.py new file mode 100644 index 00000000..75a6bb18 --- /dev/null +++ b/src/icalendar/tests/test_decoding.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +import icalendar +import unittest +import os + + +def _get_props(item): + ret = [] + for prop_name, _ in item.items(): + ret.append(item.decoded(prop_name)) + return ret + + +class DecodeIssues(unittest.TestCase): + + def test_icalendar_1(self): + directory = os.path.dirname(__file__) + ics = open(os.path.join(directory, 'decoding.ics'), 'rb') + cal = icalendar.Calendar.from_ical(ics.read()) + ics.close() + cal.to_ical() + for item in cal.walk('VEVENT'): + prop_list = _get_props(item) + + def test_icalendar_2(self): + directory = os.path.dirname(__file__) + ics = open(os.path.join(directory, 'decoding2.ics'), 'rb') + cal = icalendar.Calendar.from_ical(ics.read()) + ics.close() + cal.to_ical() + for item in cal.walk('VEVENT'): + prop_list = _get_props(item) diff --git a/src/icalendar/tests/test_fixed_issues.py b/src/icalendar/tests/test_fixed_issues.py index 423e71bf..45b3e653 100644 --- a/src/icalendar/tests/test_fixed_issues.py +++ b/src/icalendar/tests/test_fixed_issues.py @@ -274,7 +274,7 @@ def test_issue_116(self): b'BEGIN:VEVENT\r\nX-APPLE-STRUCTURED-LOCATION;VALUE=URI;' b'X-ADDRESS="367 George Street Sydney \r\n CBD NSW 2000";' b'X-APPLE-RADIUS=72;X-TITLE="367 George Street":' - b'geo:-33.868900\r\n \\,151.207000\r\nEND:VEVENT\r\n' + b'geo:-33.868900\r\n ,151.207000\r\nEND:VEVENT\r\n' ) # roundtrip @@ -402,14 +402,14 @@ def test_issue_178(self): 'DTSTAMP:20150121T080000', 'BEGIN:VEVENT', 'UID:12345', - 'DTSTART:20150122', + 'DTSTART;VALUE=DATE:20150122', 'END:VEVENT', 'END:MYCOMPTOO']) cal = icalendar.Calendar.from_ical(ical_str) self.assertEqual(cal.errors, []) self.assertEqual(cal.to_ical(), b'BEGIN:MYCOMPTOO\r\nDTSTAMP:20150121T080000\r\n' - b'BEGIN:VEVENT\r\nDTSTART:20150122\r\nUID:12345\r\n' + b'BEGIN:VEVENT\r\nDTSTART;VALUE=DATE:20150122\r\nUID:12345\r\n' b'END:VEVENT\r\nEND:MYCOMPTOO\r\n') def test_issue_184(self): @@ -476,6 +476,93 @@ def test_issue_237(self): self.assertEqual(dtstart.tzinfo.zone, expected_zone) self.assertEqual(dtstart.tzname(), expected_tzname) + def test_issue_187(self): + """Issue 184: The property VALUE parameter is being ignored during + parsing. Also, the types are not strong as the RFC 5545 requires.""" + + orig_str = ['BEGIN:VEVENT', + 'DTSTAMP:20150217T095800', + 'DTSTART:20150408T120001', + 'RDATE:20150408T120001', + 'END:VEVENT' + ] + ical_str = orig_str[:] + # correct setup + event = icalendar.Event.from_ical('\r\n'.join(ical_str)) + self.assertEqual(event.to_ical(), + b'BEGIN:VEVENT\r\n' + b'DTSTART:20150408T120001\r\n' + b'DTSTAMP:20150217T095800\r\n' + b'RDATE:20150408T120001\r\n' + b'END:VEVENT\r\n') + self.assertEqual(event.errors, []) + # correct setup with an unknown property + ical_str.insert(4, 'MYPROP:200512,143022,064530') + event = icalendar.Event.from_ical('\r\n'.join(ical_str)) + self.assertTrue(isinstance(event['MYPROP'], icalendar.vText)) + # correct setup with unknown property - VALUE is set + ical_str[4] = 'MYPROP;VALUE=DATE-TIME:20050520T200505' + event = icalendar.Event.from_ical('\r\n'.join(ical_str)) + self.assertTrue(isinstance(event['MYPROP'], icalendar.vDatetime)) + # wrong setup with unknown property when VALUE is set + ical_str[4] = 'MYPROP;VALUE=TIME:20050520T200505' + event = icalendar.Event.from_ical('\r\n'.join(ical_str)) + self.assertEqual(event.errors, + [('MYPROP', "Expected time, got: '20050520T200505'")]) + self.assertEqual(event['MYPROP'], + icalendar.prop.vText('20050520T200505')) + + # Wrong default property value (DATE instead of DATE-TIME) + ical_str = orig_str[:] + ical_str[2] = 'DTSTART:20150408' + event = icalendar.Event.from_ical('\r\n'.join(ical_str)) + self.assertEqual(event.errors, + [('DTSTART', "Wrong datetime format '20150408'")]) + self.assertEqual(event['DTSTART'], + icalendar.prop.vText('20150408')) + + # -------- Wrong vDDDLists setups follow -------- + ical_str = orig_str[:] + # DATE-TIME value at EXDATE with VALUE:DATE + ical_str[3] = 'RDATE;VALUE=DATE:20150217T095800' + event = icalendar.Event.from_ical('\r\n'.join(ical_str)) + self.assertEqual(event.errors, + [('RDATE', "Wrong date format '20150217T095800'")]) + self.assertEqual(event['RDATE'], + icalendar.prop.vText('20150217T095800')) + + ical_str[3] = ('RDATE;FMTTYPE=text/plain;ENCODING=BASE64;VALUE=BINARY:' + 'c3RsYXo=') + event = icalendar.Event.from_ical('\r\n'.join(ical_str)) + self.assertEqual(event.errors, + [('RDATE', "The VALUE parameter of RDATE property " + "is not supported: 'BINARY'")]) + self.assertEqual(event['RDATE'], + icalendar.prop.vText('c3RsYXo=')) + self.assertEqual(event['RDATE'].params, + {'VALUE': 'BINARY', 'ENCODING': 'BASE64', + 'FMTTYPE': 'text/plain'}) + + def test_pr_196__1(self): + """Test case from comment + https://github.com/collective/icalendar/pull/196#issuecomment-317485774 + """ + event = icalendar.Event() + event.add('DTSTART', datetime.date(2021, 10, 12)) + self.assertEqual( + event.to_ical(), + b'BEGIN:VEVENT\r\nDTSTART;VALUE=DATE:20211012\r\nEND:VEVENT\r\n' + ) + + def test_pr_196__2(self): + """Test case from comment + https://github.com/collective/icalendar/pull/196#issuecomment-318034052 + """ + event = icalendar.Event() + event.add('DURATION', datetime.timedelta(hours=2)) + self.assertEqual(event["DURATION"].td, datetime.timedelta(seconds=7200)) # Official API + self.assertEqual(event["DURATION"].dt, datetime.timedelta(seconds=7200)) # Backwards compatibility + def test_issue_345(self): """Issue #345 - Why is tools.UIDGenerator a class (that must be instantiated) instead of a module? """ uid1 = icalendar.tools.UIDGenerator.uid() @@ -487,4 +574,4 @@ def test_issue_345(self): self.assertEqual(uid2.split('@')[1], 'test.test') self.assertEqual(uid3.split('-')[1], '123@example.com') self.assertEqual(uid4.split('-')[1], '123@test.test') - + diff --git a/src/icalendar/tests/test_unit_cal.py b/src/icalendar/tests/test_unit_cal.py index f1e4bc81..a88ace28 100644 --- a/src/icalendar/tests/test_unit_cal.py +++ b/src/icalendar/tests/test_unit_cal.py @@ -430,7 +430,7 @@ def test_cal_Calendar(self): self.assertEqual( [e.errors for e in icalendar.cal.Calendar.from_ical(s).walk('VEVENT')], - [[], [('EXDATE', "Expected datetime, date, or time, got: ''")]] + [[], [('EXDATE', "Wrong date format ''")]] ) def test_cal_strict_parsing(self): diff --git a/src/icalendar/tests/test_unit_prop.py b/src/icalendar/tests/test_unit_prop.py index acfc7880..0fdbaf8a 100644 --- a/src/icalendar/tests/test_unit_prop.py +++ b/src/icalendar/tests/test_unit_prop.py @@ -94,6 +94,13 @@ def test_prop_vDDDLists(self): dt_list = vDDDLists([datetime(2000, 1, 1), datetime(2000, 11, 11)]) self.assertEqual(dt_list.to_ical(), b'20000101T000000,20001111T000000') + def test_prop_vDDDLists_same_types(self): + """vDDDLists should raise an error when initialized with different date/time types. + """ + from ..prop import vDDDLists + with self.assertRaises(ValueError): + vDDDLists([date.today(), datetime.now()]) + def test_prop_vDDDTypes(self): from ..prop import vDDDTypes @@ -496,6 +503,72 @@ def test_prop_TypesFactory(self): 'Rasmussen, Max M\xf8ller' ) + def test_prop_TypesFactory_for_property(self): + """Test all the different supported cases of for_property + """ + from icalendar import prop + factory = prop.TypesFactory() + + today = date.today() + now = datetime.now() + + self.assertEqual( + factory.for_property("version"), + prop.vText + ) + + self.assertEqual( + factory.for_property("rrule"), + prop.vRecur + ) + + # dtstart can be date-time or date + # Default + self.assertEqual( + factory.for_property("dtstart"), + prop.vDatetime + ) + # Specifying the valuetype + self.assertEqual( + factory.for_property("dtstart", valuetype="date"), + prop.vDate + ) + # Passing a native type + self.assertEqual( + factory.for_property("dtstart", nativetype=type(today)), + prop.vDate + ) + # valuetype takes precedence + self.assertEqual( + factory.for_property("dtstart", valuetype="date", nativetype=type(now)), + prop.vDate + ) + + # Asking for not existent types raises ValueError + with self.assertRaises(ValueError): + factory.for_property("dtstart", valuetype="does-not-exist") + with self.assertRaises(ValueError): + factory.for_property("version", valuetype="does-not-exist") + + # Unsresolved name fallbacks + self.assertEqual( + factory.for_property("does-not-exist"), + prop.vText + ) + self.assertEqual( + factory.for_property("does-not-exist", valuetype="date"), + prop.vDate + ) + self.assertEqual( + factory.for_property("does-not-exist", nativetype=type(today)), + prop.vDate + ) + # valuetype takes precedence + self.assertEqual( + factory.for_property("does-not-exist", valuetype="date", nativetype=type(now)), + prop.vDate + ) + class TestPropertyValues(unittest.TestCase):