diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9cf5b20..0ccd9b6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -24,7 +24,7 @@ Output: ``` -## Expected behavior** +## Expected behavior ## Environment diff --git a/CHANGES.rst b/CHANGES.rst index cb8b181..90cf1c9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,12 +1,16 @@ Changelog ========= -5.0.1 (unreleased) +5.0.2 (unreleased) ------------------ Minor changes: -- fixed setuptools deprecation warnings [mgorny] +- Refactored cal.py, tools.py and completed remaining minimal refactoring in parser.py. Ref: #481 [pronoym99] +- Calendar.from_ical no longer throws long errors + Ref: #473 + Fixes: #472 + [jacadzaca] Breaking changes: @@ -18,7 +22,24 @@ New features: Bug fixes: -- ... +- broken properties are not added to the parent component + Ref: #471 + Fixes: #464 + [jacadzaca] + +5.0.1 (2022-10-22) +------------------ + +Minor changes: + +- fixed setuptools deprecation warnings [mgorny] + +Bug fixes: + +- a well-known timezone timezone prefixed with a `/` is treated as if the slash wasn't present + Ref: #467 + Fixes: #466 + [jacadzaca] 5.0.0 (2022-10-17) ------------------ diff --git a/docs/credits.rst b/docs/credits.rst index 22952c4..10f269b 100644 --- a/docs/credits.rst +++ b/docs/credits.rst @@ -64,6 +64,7 @@ icalendar contributors - Mauro Amico - Alexander Pitkin - Michał Górny +- Pronoy Find out who contributed:: diff --git a/setup.cfg b/setup.cfg index a19032d..051c5c6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,10 +7,9 @@ ignore = [zest.releaser] python-file-with-version = src/icalendar/__init__.py -create-wheel = yes [bdist_wheel] -universal = 1 +universal = 0 [metadata] license_files = LICENSE.rst diff --git a/src/icalendar/__init__.py b/src/icalendar/__init__.py index 5bc8daf..9899953 100644 --- a/src/icalendar/__init__.py +++ b/src/icalendar/__init__.py @@ -1,4 +1,4 @@ -__version__ = '5.0.0' +__version__ = '5.0.1' from icalendar.cal import ( Calendar, diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index 57eefc0..44b1c3f 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -370,8 +370,7 @@ class Component(CaselessDict): 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)) + raise ValueError(f'Property "{name}" does not have a parent component.') datetime_names = ('DTSTART', 'DTEND', 'RECURRENCE-ID', 'DUE', 'FREEBUSY', 'RDATE', 'EXDATE') try: @@ -383,7 +382,6 @@ class Component(CaselessDict): if not component.ignore_exceptions: raise component.errors.append((uname, str(e))) - component.add(name, None, encode=0) else: vals.params = params component.add(name, vals, encode=0) @@ -391,14 +389,22 @@ class Component(CaselessDict): if multiple: return comps if len(comps) > 1: - raise ValueError(f'Found multiple components where ' - f'only one is allowed: {st!r}') + raise ValueError(cls._format_error( + 'Found multiple components where only one is allowed', st)) if len(comps) < 1: - raise ValueError(f'Found no components where ' - f'exactly one is required: ' - f'{st!r}') + raise ValueError(cls._format_error( + 'Found no components where exactly one is required', st)) return comps[0] + def _format_error(error_description, bad_input, elipsis='[...]'): + # there's three character more in the error, ie. ' ' x2 and a ':' + max_error_length = 100 - 3 + if len(error_description) + len(bad_input) + len(elipsis) > max_error_length: + truncate_to = max_error_length - len(error_description) - len(elipsis) + return f'{error_description}: {bad_input[:truncate_to]} {elipsis}' + else: + return f'{error_description}: {bad_input}' + def content_line(self, name, value, sorted=True): """Returns property as content line. """ @@ -427,12 +433,8 @@ class Component(CaselessDict): def __repr__(self): """String representation of class with all of it's subcomponents. """ - subs = ', '.join([str(it) for it in self.subcomponents]) - return '{}({}{})'.format( - self.name or type(self).__name__, - dict(self), - ', %s' % subs if subs else '' - ) + subs = ', '.join(str(it) for it in self.subcomponents) + return f"{self.name or type(self).__name__}({dict(self)}{', ' + subs if subs else ''})" ####################################### @@ -605,12 +607,10 @@ class Timezone(Component): tzname = component['TZNAME'].encode('ascii', 'replace') tzname = self._make_unique_tzname(tzname, tznames) except KeyError: - tzname = '{}_{}_{}_{}'.format( - zone, - component['DTSTART'].to_ical().decode('utf-8'), - component['TZOFFSETFROM'].to_ical(), # for whatever reason this is str/unicode - component['TZOFFSETTO'].to_ical(), # for whatever reason this is str/unicode - ) + # for whatever reason this is str/unicode + tzname = f"{zone}_{component['DTSTART'].to_ical().decode('utf-8')}_" + \ + f"{component['TZOFFSETFROM'].to_ical()}_" + \ + f"{component['TZOFFSETTO'].to_ical()}" tzname = self._make_unique_tzname(tzname, tznames) dst[tzname], component_transitions = self._extract_offsets( diff --git a/src/icalendar/parser.py b/src/icalendar/parser.py index 6896f9f..47f0b68 100644 --- a/src/icalendar/parser.py +++ b/src/icalendar/parser.py @@ -51,13 +51,14 @@ def tzid_from_dt(dt): if hasattr(dt.tzinfo, 'zone'): tzid = dt.tzinfo.zone # pytz implementation elif hasattr(dt.tzinfo, 'key'): - tzid = dt.tzinfo.key # ZoneInfo implementation + tzid = dt.tzinfo.key # ZoneInfo implementation elif hasattr(dt.tzinfo, 'tzname'): # dateutil implementation, but this is broken # See https://github.com/collective/icalendar/issues/333 for details tzid = dt.tzinfo.tzname(dt) return tzid + def foldline(line, limit=75, fold_sep='\r\n '): """Make a string folded as defined in RFC5545 Lines of text SHOULD NOT be longer than 75 octets, excluding the line @@ -142,7 +143,7 @@ def dquote(val): # so replace it with a single-quote character val = val.replace('"', "'") if QUOTABLE.search(val): - return '"%s"' % val + return f'"{val}"' return val @@ -158,8 +159,7 @@ def q_split(st, sep=',', maxsplit=-1): length = len(st) inquote = 0 splits = 0 - for i in range(length): - ch = st[i] + for i, ch in enumerate(st): if ch == '"': inquote = not inquote if not inquote and ch == sep: @@ -255,13 +255,13 @@ class Parameters(CaselessDict): else: result[key] = vals except ValueError as exc: - raise ValueError('%r is not a valid parameter string: %s' - % (param, exc)) + raise ValueError( + f'{param!r} is not a valid parameter string: {exc}') return result def escape_string(val): - # '%{:02X}'.format(i) + # f'{i:02X}' return val.replace(r'\,', '%2C').replace(r'\:', '%3A')\ .replace(r'\;', '%3B').replace(r'\\', '%5C') @@ -288,7 +288,7 @@ class Contentline(str): def __new__(cls, value, strict=False, encoding=DEFAULT_ENCODING): value = to_unicode(value, encoding=encoding) assert '\n' not in value, ('Content line can not contain unescaped ' - 'new line characters.') + 'new line characters.') self = super().__new__(cls, value) self.strict = strict return self @@ -346,9 +346,7 @@ class Contentline(str): return (name, params, values) except ValueError as exc: raise ValueError( - "Content line could not be parsed into parts: '%s': %s" - % (self, exc) - ) + f"Content line could not be parsed into parts: '{self}': {exc}") @classmethod def from_ical(cls, ical, strict=False): @@ -370,6 +368,7 @@ class Contentlines(list): Then this should be efficient. for Huge files, an iterator should probably be used instead. """ + def to_ical(self): """Simply join self. """ diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index 36d2b94..11f6208 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -90,6 +90,7 @@ DSTDIFF = DSTOFFSET - STDOFFSET class FixedOffset(tzinfo): """Fixed offset in minutes east from UTC. """ + def __init__(self, offset, name): self.__offset = timedelta(minutes=offset) self.__name = name @@ -107,17 +108,12 @@ class FixedOffset(tzinfo): class LocalTimezone(tzinfo): """Timezone of the machine where the code is running. """ + def utcoffset(self, dt): - if self._isdst(dt): - return DSTOFFSET - else: - return STDOFFSET + return DSTOFFSET if self._isdst(dt) else STDOFFSET def dst(self, dt): - if self._isdst(dt): - return DSTDIFF - else: - return ZERO + return DSTDIFF if self._isdst(dt) else ZERO def tzname(self, dt): return _time.tzname[self._isdst(dt)] @@ -140,7 +136,7 @@ class vBinary: self.params = Parameters(encoding='BASE64', value="BINARY") def __repr__(self): - return "vBinary('%s')" % self.to_ical() + return f"vBinary('{self.to_ical()}')" def to_ical(self): return binascii.b2a_base64(self.obj.encode('utf-8'))[:-1] @@ -164,16 +160,14 @@ class vBoolean(int): return self def to_ical(self): - if self: - return b'TRUE' - return b'FALSE' + return b'TRUE' if self else b'FALSE' @classmethod def from_ical(cls, ical): try: return cls.BOOL_MAP[ical] except Exception: - raise ValueError("Expected 'TRUE' or 'FALSE'. Got %s" % ical) + raise ValueError(f"Expected 'TRUE' or 'FALSE'. Got {ical}") class vCalAddress(str): @@ -186,7 +180,7 @@ class vCalAddress(str): return self def __repr__(self): - return "vCalAddress('%s')" % self.to_ical() + return f"vCalAddress('{self.to_ical()}')" def to_ical(self): return self.encode(DEFAULT_ENCODING) @@ -212,7 +206,7 @@ class vFloat(float): try: return cls(ical) except Exception: - raise ValueError('Expected float value, got: %s' % ical) + raise ValueError(f'Expected float value, got: {ical}') class vInt(int): @@ -231,12 +225,13 @@ class vInt(int): try: return cls(ical) except Exception: - raise ValueError('Expected int, got: %s' % ical) + raise ValueError(f'Expected int, got: {ical}') class vDDDLists: """A list of vDDDTypes values. """ + def __init__(self, dt_list): if not hasattr(dt_list, '__iter__'): dt_list = [dt_list] @@ -265,6 +260,7 @@ class vDDDLists: out.append(vDDDTypes.from_ical(ical_dt, timezone=timezone)) return out + class vCategory: def __init__(self, c_list): @@ -287,6 +283,7 @@ class vDDDTypes: cannot be confused, and often values can be of either types. So this is practical. """ + def __init__(self, dt): if not isinstance(dt, (datetime, date, timedelta, time, tuple)): raise ValueError('You must use datetime, date, timedelta, ' @@ -346,13 +343,14 @@ class vDDDTypes: return vTime.from_ical(ical) else: raise ValueError( - "Expected datetime, date, or time, got: '%s'" % ical + f"Expected datetime, date, or time, got: '{ical}'" ) class vDate: """Render and generates iCalendar date format. """ + def __init__(self, dt): if not isinstance(dt, date): raise ValueError('Value MUST be a date instance') @@ -360,7 +358,7 @@ class vDate: self.params = Parameters({'value': 'DATE'}) def to_ical(self): - s = "%04d%02d%02d" % (self.dt.year, self.dt.month, self.dt.day) + s = f"{self.dt.year:04}{self.dt.month:02}{self.dt.day:02}" return s.encode('utf-8') @staticmethod @@ -373,7 +371,7 @@ class vDate: ) return date(*timetuple) except Exception: - raise ValueError('Wrong date format %s' % ical) + raise ValueError(f'Wrong date format {ical}') class vDatetime: @@ -387,6 +385,7 @@ class vDatetime: created. Be aware that there are certain limitations with timezone naive DATE-TIME components in the icalendar standard. """ + def __init__(self, dt): self.dt = dt self.params = Parameters() @@ -395,14 +394,7 @@ class vDatetime: dt = self.dt tzid = tzid_from_dt(dt) - s = "%04d%02d%02dT%02d%02d%02d" % ( - dt.year, - dt.month, - dt.day, - dt.hour, - dt.minute, - dt.second - ) + s = f"{dt.year:04}{dt.month:02}{dt.day:02}T{dt.hour:02}{dt.minute:02}{dt.second:02}" if tzid == 'UTC': s += "Z" elif tzid: @@ -414,10 +406,11 @@ class vDatetime: tzinfo = None if timezone: try: - tzinfo = pytz.timezone(timezone) + tzinfo = pytz.timezone(timezone.strip('/')) except pytz.UnknownTimeZoneError: if timezone in WINDOWS_TO_OLSON: - tzinfo = pytz.timezone(WINDOWS_TO_OLSON.get(timezone)) + tzinfo = pytz.timezone( + WINDOWS_TO_OLSON.get(timezone.strip('/'))) else: tzinfo = _timezone_cache.get(timezone, None) @@ -439,7 +432,7 @@ class vDatetime: else: raise ValueError(ical) except Exception: - raise ValueError('Wrong datetime format: %s' % ical) + raise ValueError(f'Wrong datetime format: {ical}') class vDuration: @@ -466,18 +459,18 @@ class vDuration: minutes = td.seconds % 3600 // 60 seconds = td.seconds % 60 if hours: - timepart += "%dH" % hours + timepart += f"{hours}H" if minutes or (hours and seconds): - timepart += "%dM" % minutes + timepart += f"{minutes}M" if seconds: - timepart += "%dS" % seconds + timepart += f"{seconds}S" if td.days == 0 and timepart: - return (str(sign).encode('utf-8') + b'P' + - str(timepart).encode('utf-8')) + return (str(sign).encode('utf-8') + b'P' + + str(timepart).encode('utf-8')) else: - return (str(sign).encode('utf-8') + b'P' + - str(abs(td.days)).encode('utf-8') + - b'D' + str(timepart).encode('utf-8')) + return (str(sign).encode('utf-8') + b'P' + + str(abs(td.days)).encode('utf-8') + + b'D' + str(timepart).encode('utf-8')) @staticmethod def from_ical(ical): @@ -495,19 +488,20 @@ class vDuration: value = -value return value except Exception: - raise ValueError('Invalid iCalendar duration: %s' % ical) + raise ValueError(f'Invalid iCalendar duration: {ical}') class vPeriod: """A precise period of time. """ + def __init__(self, per): start, end_or_duration = per if not (isinstance(start, datetime) or isinstance(start, 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 not (isinstance(end_or_duration, datetime) + or isinstance(end_or_duration, date) + or isinstance(end_or_duration, timedelta)): raise ValueError('end_or_duration MUST be a datetime, ' 'date or timedelta instance') by_duration = 0 @@ -535,7 +529,8 @@ class vPeriod: def __cmp__(self, other): if not isinstance(other, vPeriod): - raise NotImplementedError('Cannot compare vPeriod with %r' % other) + raise NotImplementedError( + f'Cannot compare vPeriod with {other!r}') return cmp((self.start, self.end), (other.start, other.end)) def overlaps(self, other): @@ -547,10 +542,10 @@ class vPeriod: def to_ical(self): if self.by_duration: - return (vDatetime(self.start).to_ical() + b'/' + - vDuration(self.duration).to_ical()) - return (vDatetime(self.start).to_ical() + b'/' + - vDatetime(self.end).to_ical()) + return (vDatetime(self.start).to_ical() + b'/' + + vDuration(self.duration).to_ical()) + return (vDatetime(self.start).to_ical() + b'/' + + vDatetime(self.end).to_ical()) @staticmethod def from_ical(ical): @@ -560,7 +555,7 @@ class vPeriod: end_or_duration = vDDDTypes.from_ical(end_or_duration) return (start, end_or_duration) except Exception: - raise ValueError('Expected period format, got: %s' % ical) + raise ValueError(f'Expected period format, got: {ical}') def __repr__(self): if self.by_duration: @@ -582,13 +577,13 @@ class vWeekday(str): self = super().__new__(cls, value) match = WEEKDAY_RULE.match(self) if match is None: - raise ValueError('Expected weekday abbrevation, got: %s' % self) + raise ValueError(f'Expected weekday abbrevation, got: {self}') match = match.groupdict() sign = match['signal'] weekday = match['weekday'] relative = match['relative'] if weekday not in vWeekday.week_days or sign not in '+-': - raise ValueError('Expected weekday abbrevation, got: %s' % self) + raise ValueError(f'Expected weekday abbrevation, got: {self}') self.relative = relative and int(relative) or None self.params = Parameters() return self @@ -601,7 +596,7 @@ class vWeekday(str): try: return cls(ical.upper()) except Exception: - raise ValueError('Expected weekday abbrevation, got: %s' % ical) + raise ValueError(f'Expected weekday abbrevation, got: {ical}') class vFrequency(str): @@ -622,7 +617,7 @@ class vFrequency(str): value = to_unicode(value, encoding=encoding) self = super().__new__(cls, value) if self not in vFrequency.frequencies: - raise ValueError('Expected frequency, got: %s' % self) + raise ValueError(f'Expected frequency, got: {self}') self.params = Parameters() return self @@ -634,7 +629,7 @@ class vFrequency(str): try: return cls(ical.upper()) except Exception: - raise ValueError('Expected frequency, got: %s' % ical) + raise ValueError(f'Expected frequency, got: {ical}') class vRecur(CaselessDict): @@ -708,7 +703,7 @@ class vRecur(CaselessDict): recur[key] = cls.parse_type(key, vals) return dict(recur) except Exception: - raise ValueError('Error in recurrence rule: %s' % ical) + raise ValueError(f'Error in recurrence rule: {ical}') class vText(str): @@ -723,7 +718,7 @@ class vText(str): return self def __repr__(self): - return "vText('%s')" % self.to_ical() + return f"vText('{self.to_ical()}')" def to_ical(self): return escape_char(self).encode(self.encoding) @@ -741,7 +736,7 @@ class vTime: def __init__(self, *args): if len(args) == 1: if not isinstance(args[0], (time, datetime)): - raise ValueError('Expected a datetime.time, got: %s' % args[0]) + raise ValueError(f'Expected a datetime.time, got: {args[0]}') self.dt = args[0] else: self.dt = time(*args) @@ -757,7 +752,7 @@ class vTime: 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(f'Expected time, got: {ical}') class vUri(str): @@ -778,7 +773,7 @@ class vUri(str): try: return cls(ical) except Exception: - raise ValueError('Expected , got: %s' % ical) + raise ValueError(f'Expected , got: {ical}') class vGeo: @@ -806,7 +801,7 @@ class vGeo: latitude, longitude = ical.split(';') return (float(latitude), float(longitude)) except Exception: - raise ValueError("Expected 'float;float' , got: %s" % ical) + raise ValueError(f"Expected 'float;float' , got: {ical}") class vUTCOffset: @@ -814,9 +809,9 @@ class vUTCOffset: """ ignore_exceptions = False # if True, and we cannot parse this - # component, we will silently ignore - # it, rather than let the exception - # propagate upwards + # component, we will silently ignore + # it, rather than let the exception + # propagate upwards def __init__(self, td): if not isinstance(td, timedelta): @@ -840,9 +835,9 @@ class vUTCOffset: minutes = abs((seconds % 3600) // 60) seconds = abs(seconds % 60) if seconds: - duration = '%02i%02i%02i' % (hours, minutes, seconds) + duration = f'{hours:02}{minutes:02}{seconds:02}' else: - duration = '%02i%02i' % (hours, minutes) + duration = f'{hours:02}{minutes:02}' return sign % duration @classmethod @@ -856,10 +851,10 @@ class vUTCOffset: int(ical[5:7] or 0)) offset = timedelta(hours=hours, minutes=minutes, seconds=seconds) except Exception: - raise ValueError('Expected utc offset, got: %s' % ical) + raise ValueError(f'Expected utc offset, got: {ical}') if not cls.ignore_exceptions and offset >= timedelta(hours=24): raise ValueError( - 'Offset must be less than 24 hours, was %s' % ical) + f'Offset must be less than 24 hours, was {ical}') if sign == '-': return -offset return offset diff --git a/src/icalendar/tests/calendars/big_bad_calendar.ics b/src/icalendar/tests/calendars/big_bad_calendar.ics new file mode 100644 index 0000000..88fbc6f --- /dev/null +++ b/src/icalendar/tests/calendars/big_bad_calendar.ics @@ -0,0 +1,41 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT +BEGIN:VEVENT +END:VEVENT diff --git a/src/icalendar/tests/calendars/issue_466_convert_tzid_with_slash.ics b/src/icalendar/tests/calendars/issue_466_convert_tzid_with_slash.ics new file mode 100644 index 0000000..2ec7c85 --- /dev/null +++ b/src/icalendar/tests/calendars/issue_466_convert_tzid_with_slash.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +UID:0cab49a0-1167-40f0-bfed-ecb4d117047d +DTSTAMP:20221019T102950Z +DTSTART;TZID=/Europe/Stockholm:20221021T200000 +DTEND;TZID=/Europe/Stockholm:20221021T210000 +SUMMARY:Just chatting +DESCRIPTION:Just Chatting. +CATEGORIES:Just Chatting +RRULE:FREQ=WEEKLY;BYDAY=FR +END:VEVENT +END:VCALENDAR diff --git a/src/icalendar/tests/calendars/issue_466_respect_unique_timezone.ics b/src/icalendar/tests/calendars/issue_466_respect_unique_timezone.ics new file mode 100644 index 0000000..5cbebe7 --- /dev/null +++ b/src/icalendar/tests/calendars/issue_466_respect_unique_timezone.ics @@ -0,0 +1,29 @@ +BEGIN:VCALENDAR +BEGIN:VTIMEZONE +TZID:/Europe/CUSTOM +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +UID:0cab49a0-1167-40f0-bfed-ecb4d117047d +DTSTAMP:20221019T102950Z +DTSTART;TZID=/Europe/CUSTOM:20221021T200000 +DTEND;TZID=/Europe/CUSTOM:20221021T210000 +SUMMARY:Just chatting +DESCRIPTION:Just Chatting. +CATEGORIES:Just Chatting +RRULE:FREQ=WEEKLY;BYDAY=FR +END:VEVENT +END:VCALENDAR diff --git a/src/icalendar/tests/calendars/pr_480_summary_with_colon.ics b/src/icalendar/tests/calendars/pr_480_summary_with_colon.ics new file mode 100644 index 0000000..438c701 --- /dev/null +++ b/src/icalendar/tests/calendars/pr_480_summary_with_colon.ics @@ -0,0 +1,7 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +SUMMARY:Example calendar with a ': ' in the summary +END:VEVENT +BEGIN:VEVENT +SUMMARY:Another event with a ': ' in the summary +END:VEVENT \ No newline at end of file diff --git a/src/icalendar/tests/calendars/small_bad_calendar.ics b/src/icalendar/tests/calendars/small_bad_calendar.ics new file mode 100644 index 0000000..94228f1 --- /dev/null +++ b/src/icalendar/tests/calendars/small_bad_calendar.ics @@ -0,0 +1,3 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +END:VEVENT diff --git a/src/icalendar/tests/events/event_with_escaped_character1.ics b/src/icalendar/tests/events/event_with_escaped_character1.ics new file mode 100644 index 0000000..d082b9f --- /dev/null +++ b/src/icalendar/tests/events/event_with_escaped_character1.ics @@ -0,0 +1,3 @@ +BEGIN:VEVENT +ORGANIZER;CN=Society\, 2014:that +END:VEVENT diff --git a/src/icalendar/tests/events/event_with_escaped_character2.ics b/src/icalendar/tests/events/event_with_escaped_character2.ics new file mode 100644 index 0000000..88b849d --- /dev/null +++ b/src/icalendar/tests/events/event_with_escaped_character2.ics @@ -0,0 +1,3 @@ +BEGIN:VEVENT +ORGANIZER;CN=Society\\ 2014:that +END:VEVENT diff --git a/src/icalendar/tests/events/event_with_escaped_character3.ics b/src/icalendar/tests/events/event_with_escaped_character3.ics new file mode 100644 index 0000000..3c657c4 --- /dev/null +++ b/src/icalendar/tests/events/event_with_escaped_character3.ics @@ -0,0 +1,3 @@ +BEGIN:VEVENT +ORGANIZER;CN=Society\; 2014:that +END:VEVENT diff --git a/src/icalendar/tests/events/event_with_escaped_character4.ics b/src/icalendar/tests/events/event_with_escaped_character4.ics new file mode 100644 index 0000000..037c1c4 --- /dev/null +++ b/src/icalendar/tests/events/event_with_escaped_character4.ics @@ -0,0 +1,3 @@ +BEGIN:VEVENT +ORGANIZER;CN=Society\: 2014:that +END:VEVENT diff --git a/src/icalendar/tests/events/event_with_escaped_characters.ics b/src/icalendar/tests/events/event_with_escaped_characters.ics new file mode 100644 index 0000000..c9671d1 --- /dev/null +++ b/src/icalendar/tests/events/event_with_escaped_characters.ics @@ -0,0 +1,3 @@ +BEGIN:VEVENT +ORGANIZER;CN=that\, that\; %th%%at%\ that\::это\, то\; that\ %th%%at%\: +END:VEVENT diff --git a/src/icalendar/tests/events/event_with_unicode_organizer.ics b/src/icalendar/tests/events/event_with_unicode_organizer.ics new file mode 100644 index 0000000..2fb3be1 --- /dev/null +++ b/src/icalendar/tests/events/event_with_unicode_organizer.ics @@ -0,0 +1,3 @@ +BEGIN:VEVENT +ORGANIZER;CN="Джон Доу":mailto:john.doe@example.org +END:VEVENT diff --git a/src/icalendar/tests/events/issue_464_invalid_rdate.ics b/src/icalendar/tests/events/issue_464_invalid_rdate.ics new file mode 100644 index 0000000..f4e587d --- /dev/null +++ b/src/icalendar/tests/events/issue_464_invalid_rdate.ics @@ -0,0 +1,7 @@ +BEGIN:VEVENT +SUMMARY:RDATE period +DTSTART:19961230T020000Z +DTEND:19961230T060000Z +UID:rdate_period +RDATE;VALUE=PERIOD:19970101T180000Z/19970102T070000Z,199709T180000Z/PT5H30M +END:VEVENT diff --git a/src/icalendar/tests/test_components_break_on_bad_ics.py b/src/icalendar/tests/test_components_break_on_bad_ics.py new file mode 100644 index 0000000..6ac92ae --- /dev/null +++ b/src/icalendar/tests/test_components_break_on_bad_ics.py @@ -0,0 +1,41 @@ +import pytest + +from icalendar import Event, Calendar + +def test_ignore_exceptions_on_broken_events_issue_104(events): + ''' Issue #104 - line parsing error in a VEVENT + (which has ignore_exceptions). Should mark the event broken + but not raise an exception. + + https://github.com/collective/icalendar/issues/104 + ''' + assert events.issue_104_mark_events_broken.is_broken # TODO: REMOVE FOR NEXT MAJOR RELEASE + assert events.issue_104_mark_events_broken.errors == [(None, "Content line could not be parsed into parts: 'X': Invalid content line")] + +def test_dont_ignore_exceptions_on_broken_calendars_issue_104(calendars): + '''Issue #104 - line parsing error in a VCALENDAR + (which doesn't have ignore_exceptions). Should raise an exception. + ''' + with pytest.raises(ValueError): + calendars.issue_104_broken_calendar + +def test_rdate_dosent_become_none_on_invalid_input_issue_464(events): + '''Issue #464 - [BUG] RDATE can become None if value is invalid + https://github.com/collective/icalendar/issues/464 + ''' + assert events.issue_464_invalid_rdate.is_broken + assert ('RDATE', 'Expected period format, got: 199709T180000Z/PT5H30M') in events.issue_464_invalid_rdate.errors + assert not b'RDATE:None' in events.issue_464_invalid_rdate.to_ical() + +@pytest.mark.parametrize('calendar_name', [ + 'big_bad_calendar', + 'small_bad_calendar', + 'multiple_calendar_components', + 'pr_480_summary_with_colon', +]) +def test_error_message_doesnt_get_too_big(calendars, calendar_name): + with pytest.raises(ValueError) as exception: + calendars[calendar_name] + # Ignore part before first : for the test. + assert len(str(exception).split(': ', 1)[1]) <= 100 + diff --git a/src/icalendar/tests/test_encoding.py b/src/icalendar/tests/test_encoding.py index 18a3900..77bcb26 100644 --- a/src/icalendar/tests/test_encoding.py +++ b/src/icalendar/tests/test_encoding.py @@ -23,11 +23,14 @@ def test_event_from_ical_respects_unicode(test_input, field, expected_value, eve event = events[test_input] assert event[field].to_ical().decode('utf-8') == expected_value -def test_events_parameter_unicoded(events): - '''chokes on umlauts in ORGANIZER - https://github.com/collective/icalendar/issues/101 - ''' - assert events.issue_101_icalendar_chokes_on_umlauts_in_organizer['ORGANIZER'].params['CN'] == 'acme, ädmin' +@pytest.mark.parametrize('test_input, expected_output', [ + # chokes on umlauts in ORGANIZER + # https://github.com/collective/icalendar/issues/101 + ('issue_101_icalendar_chokes_on_umlauts_in_organizer', 'acme, ädmin'), + ('event_with_unicode_organizer', 'Джон Доу'), +]) +def test_events_parameter_unicoded(events, test_input, expected_output): + assert events[test_input]['ORGANIZER'].params['CN'] == expected_output def test_parses_event_with_non_ascii_tzid_issue_237(calendars, in_timezone): """Issue #237 - Fail to parse timezone with non-ascii TZID diff --git a/src/icalendar/tests/test_issue_104_mark_events_broken.py b/src/icalendar/tests/test_issue_104_mark_events_broken.py deleted file mode 100644 index cbeace0..0000000 --- a/src/icalendar/tests/test_issue_104_mark_events_broken.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest - -from icalendar import Event, Calendar - -def test_ignore_exceptions_on_broken_events_issue_104(events): - ''' Issue #104 - line parsing error in a VEVENT - (which has ignore_exceptions). Should mark the event broken - but not raise an exception. - - https://github.com/collective/icalendar/issues/104 - ''' - assert events.issue_104_mark_events_broken.is_broken # TODO: REMOVE FOR NEXT MAJOR RELEASE - assert events.issue_104_mark_events_broken.errors == [(None, "Content line could not be parsed into parts: 'X': Invalid content line")] - -def test_dont_ignore_exceptions_on_broken_calendars_issue_104(calendars): - '''Issue #104 - line parsing error in a VCALENDAR - (which doesn't have ignore_exceptions). Should raise an exception. - ''' - with pytest.raises(ValueError): - calendars.issue_104_broken_calendar diff --git a/src/icalendar/tests/test_parsing.py b/src/icalendar/tests/test_parsing.py index 9607d61..4147bf6 100644 --- a/src/icalendar/tests/test_parsing.py +++ b/src/icalendar/tests/test_parsing.py @@ -79,6 +79,7 @@ def test_issue_157_removes_trailing_semicolon(events): # PERIOD should be put back into shape 'issue_156_RDATE_with_PERIOD', 'issue_156_RDATE_with_PERIOD_list', + 'event_with_unicode_organizer', ]) def test_event_to_ical_is_inverse_of_from_ical(events, event_name): """Make sure that an event's ICS is equal to the ICS it was made from.""" @@ -160,3 +161,29 @@ def test_creates_event_with_base64_encoded_attachment_issue_82(events): event = Event() event.add('ATTACH', b) assert event.to_ical() == events.issue_82_expected_output.raw_ics + +@pytest.mark.parametrize('calendar_name', [ + # Issue #466 - [BUG] TZID timezone is ignored when forward-slash is used + # https://github.com/collective/icalendar/issues/466 + 'issue_466_respect_unique_timezone', + 'issue_466_convert_tzid_with_slash' +]) +def test_handles_unique_tzid(calendars, in_timezone, calendar_name): + calendar = calendars[calendar_name] + start_dt = calendar.walk('VEVENT')[0]['dtstart'].dt + end_dt = calendar.walk('VEVENT')[0]['dtend'].dt + assert start_dt == in_timezone(datetime(2022, 10, 21, 20, 0, 0), 'Europe/Stockholm') + assert end_dt == in_timezone(datetime(2022, 10, 21, 21, 0, 0), 'Europe/Stockholm') + +@pytest.mark.parametrize('event_name, expected_cn, expected_ics', [ + ('event_with_escaped_characters', r'that, that; %th%%at%\ that:', 'это, то; that\\ %th%%at%:'), + ('event_with_escaped_character1', r'Society, 2014', 'that'), + ('event_with_escaped_character2', r'Society\ 2014', 'that'), + ('event_with_escaped_character3', r'Society; 2014', 'that'), + ('event_with_escaped_character4', r'Society: 2014', 'that'), +]) +def test_escaped_characters_read(event_name, expected_cn, expected_ics, events): + event = events[event_name] + assert event['ORGANIZER'].params['CN'] == expected_cn + assert event['ORGANIZER'].to_ical() == expected_ics.encode('utf-8') + diff --git a/src/icalendar/tests/test_property_params.py b/src/icalendar/tests/test_property_params.py index 5ed583a..18ad9fb 100644 --- a/src/icalendar/tests/test_property_params.py +++ b/src/icalendar/tests/test_property_params.py @@ -1,12 +1,71 @@ -from icalendar import Calendar -from icalendar import Event -from icalendar import Parameters -from icalendar import vCalAddress -import unittest +import pytest +from icalendar import Calendar, Event, Parameters, vCalAddress +import unittest import icalendar import re +@pytest.mark.parametrize('parameter, expected', [ + # Simple parameter:value pair + (Parameters(parameter1='Value1'), b'PARAMETER1=Value1'), + # Parameter with list of values must be separated by comma + (Parameters({'parameter1': ['Value1', 'Value2']}), b'PARAMETER1=Value1,Value2'), + # Multiple parameters must be separated by a semicolon + (Parameters({'RSVP': 'TRUE', 'ROLE': 'REQ-PARTICIPANT'}), b'ROLE=REQ-PARTICIPANT;RSVP=TRUE'), + # Parameter values containing ',;:' must be double quoted + (Parameters({'ALTREP': 'http://www.wiz.org'}), b'ALTREP="http://www.wiz.org"'), + # list items must be quoted separately + (Parameters({'MEMBER': ['MAILTO:projectA@host.com', + 'MAILTO:projectB@host.com']}), + b'MEMBER="MAILTO:projectA@host.com","MAILTO:projectB@host.com"'), + (Parameters({'parameter1': 'Value1', + 'parameter2': ['Value2', 'Value3'], + 'ALTREP': ['http://www.wiz.org', 'value4']}), + b'ALTREP="http://www.wiz.org",value4;PARAMETER1=Value1;PARAMETER2=Value2,Value3'), + # Including empty strings + (Parameters({'PARAM': ''}), b'PARAM='), + # We can also parse parameter strings + (Parameters({'MEMBER': ['MAILTO:projectA@host.com', + 'MAILTO:projectB@host.com']}), + b'MEMBER="MAILTO:projectA@host.com","MAILTO:projectB@host.com"'), + # We can also parse parameter strings + (Parameters({'PARAMETER1': 'Value1', + 'ALTREP': ['http://www.wiz.org', 'value4'], + 'PARAMETER2': ['Value2', 'Value3']}), + b'ALTREP="http://www.wiz.org",value4;PARAMETER1=Value1;PARAMETER2=Value2,Value3'), +]) +def test_parameter_to_ical_is_inverse_of_from_ical(parameter, expected): + assert parameter.to_ical() == expected + assert Parameters.from_ical(expected.decode('utf-8')) == parameter + +def test_parse_parameter_string_without_quotes(): + assert Parameters.from_ical('PARAM1=Value 1;PARA2=Value 2') == Parameters({'PARAM1': 'Value 1', 'PARA2': 'Value 2'}) + +def test_parametr_is_case_insensitive(): + parameter = Parameters(parameter1='Value1') + assert parameter['parameter1'] == parameter['PARAMETER1'] == parameter['PaRaMeTer1'] + +def test_parameter_keys_are_uppercase(): + parameter = Parameters(parameter1='Value1') + assert list(parameter.keys()) == ['PARAMETER1'] + +@pytest.mark.parametrize('cn_param, cn_quoted', [ + # not double-quoted + ('Aramis', 'Aramis'), + # if a space is present - enclose in double quotes + ('Aramis Alameda', '"Aramis Alameda"'), + # a single quote in parameter value - double quote the value + ('Aramis d\'Alameda', '"Aramis d\'Alameda"'), + ('Арамис д\'Аламеда', '"Арамис д\'Аламеда"'), + # double quote is replaced with single quote + ('Aramis d\"Alameda', '"Aramis d\'Alameda"'), +]) +def test_quoting(cn_param, cn_quoted): + event = Event() + attendee = vCalAddress('test@example.com') + attendee.params['CN'] = cn_param + event.add('ATTENDEE', attendee) + assert f'ATTENDEE;CN={cn_quoted}:test@example.com' in event.to_ical().decode('utf-8') class TestPropertyParams(unittest.TestCase): @@ -30,146 +89,6 @@ class TestPropertyParams(unittest.TestCase): ical2 = Calendar.from_ical(ical_str) self.assertEqual(ical2.get('ORGANIZER').params.get('CN'), 'Doe, John') - def test_unicode_param(self): - cal_address = vCalAddress('mailto:john.doe@example.org') - cal_address.params["CN"] = "Джон Доу" - vevent = Event() - vevent['ORGANIZER'] = cal_address - self.assertEqual( - vevent.to_ical().decode('utf-8'), - 'BEGIN:VEVENT\r\n' - 'ORGANIZER;CN="Джон Доу":mailto:john.doe@example.org\r\n' - 'END:VEVENT\r\n' - ) - - self.assertEqual(vevent['ORGANIZER'].params['CN'], - 'Джон Доу') - - def test_quoting(self): - # not double-quoted - self._test_quoting("Aramis", 'Aramis') - # if a space is present - enclose in double quotes - self._test_quoting("Aramis Alameda", '"Aramis Alameda"') - # a single quote in parameter value - double quote the value - self._test_quoting("Aramis d'Alameda", '"Aramis d\'Alameda"') - # double quote is replaced with single quote - self._test_quoting("Aramis d\"Alameda", '"Aramis d\'Alameda"') - self._test_quoting("Арамис д'Аламеда", '"Арамис д\'Аламеда"') - - def _test_quoting(self, cn_param, cn_quoted): - """ - @param cn_param: CN parameter value to test for quoting - @param cn_quoted: expected quoted parameter in icalendar format - """ - vevent = Event() - attendee = vCalAddress('test@mail.com') - attendee.params['CN'] = cn_param - vevent.add('ATTENDEE', attendee) - self.assertEqual( - vevent.to_ical(), - b'BEGIN:VEVENT\r\nATTENDEE;CN=' + cn_quoted.encode('utf-8') + - b':test@mail.com\r\nEND:VEVENT\r\n' - ) - - def test_escaping(self): - # verify that escaped non safe chars are decoded correctly - NON_SAFE_CHARS = ',\\;:' - for char in NON_SAFE_CHARS: - cn_escaped = "Society\\%s 2014" % char - cn_decoded = "Society%s 2014" % char - vevent = Event.from_ical( - 'BEGIN:VEVENT\r\n' - 'ORGANIZER;CN=%s:that\r\n' - 'END:VEVENT\r\n' % cn_escaped - ) - self.assertEqual(vevent['ORGANIZER'].params['CN'], cn_decoded) - - vevent = Event.from_ical( - 'BEGIN:VEVENT\r\n' - 'ORGANIZER;CN=that\\, that\\; %th%%at%\\\\ that\\:' - ':это\\, то\\; that\\\\ %th%%at%\\:\r\n' - 'END:VEVENT\r\n' - ) - self.assertEqual( - vevent['ORGANIZER'].params['CN'], - r'that, that; %th%%at%\ that:' - ) - self.assertEqual( - vevent['ORGANIZER'].to_ical().decode('utf-8'), - 'это, то; that\\ %th%%at%:' - ) - - def test_parameters_class(self): - - # Simple parameter:value pair - p = Parameters(parameter1='Value1') - self.assertEqual(p.to_ical(), b'PARAMETER1=Value1') - - # keys are converted to upper - self.assertEqual(list(p.keys()), ['PARAMETER1']) - - # Parameters are case insensitive - self.assertEqual(p['parameter1'], 'Value1') - self.assertEqual(p['PARAMETER1'], 'Value1') - - # Parameter with list of values must be separated by comma - p = Parameters({'parameter1': ['Value1', 'Value2']}) - self.assertEqual(p.to_ical(), b'PARAMETER1=Value1,Value2') - - # Multiple parameters must be separated by a semicolon - p = Parameters({'RSVP': 'TRUE', 'ROLE': 'REQ-PARTICIPANT'}) - self.assertEqual(p.to_ical(), b'ROLE=REQ-PARTICIPANT;RSVP=TRUE') - - # Parameter values containing ',;:' must be double quoted - p = Parameters({'ALTREP': 'http://www.wiz.org'}) - self.assertEqual(p.to_ical(), b'ALTREP="http://www.wiz.org"') - - # list items must be quoted separately - p = Parameters({'MEMBER': ['MAILTO:projectA@host.com', - 'MAILTO:projectB@host.com']}) - self.assertEqual( - p.to_ical(), - b'MEMBER="MAILTO:projectA@host.com","MAILTO:projectB@host.com"' - ) - - # Now the whole sheebang - p = Parameters({'parameter1': 'Value1', - 'parameter2': ['Value2', 'Value3'], - 'ALTREP': ['http://www.wiz.org', 'value4']}) - self.assertEqual( - p.to_ical(), - (b'ALTREP="http://www.wiz.org",value4;PARAMETER1=Value1;' - b'PARAMETER2=Value2,Value3') - ) - - # We can also parse parameter strings - self.assertEqual( - Parameters.from_ical('PARAMETER1=Value 1;param2=Value 2'), - Parameters({'PARAMETER1': 'Value 1', 'PARAM2': 'Value 2'}) - ) - - # Including empty strings - self.assertEqual(Parameters.from_ical('param='), - Parameters({'PARAM': ''})) - - # We can also parse parameter strings - self.assertEqual( - Parameters.from_ical( - 'MEMBER="MAILTO:projectA@host.com","MAILTO:projectB@host.com"' - ), - Parameters({'MEMBER': ['MAILTO:projectA@host.com', - 'MAILTO:projectB@host.com']}) - ) - - # We can also parse parameter strings - self.assertEqual( - Parameters.from_ical('ALTREP="http://www.wiz.org",value4;' - 'PARAMETER1=Value1;PARAMETER2=Value2,Value3'), - Parameters({'PARAMETER1': 'Value1', - 'ALTREP': ['http://www.wiz.org', 'value4'], - 'PARAMETER2': ['Value2', 'Value3']}) - ) - def test_parse_and_access_property_params(self): """Parse an ics string and access some property parameters then. This is a follow-up of a question received per email. diff --git a/src/icalendar/tools.py b/src/icalendar/tools.py index e641e5b..b9af20b 100644 --- a/src/icalendar/tools.py +++ b/src/icalendar/tools.py @@ -30,6 +30,4 @@ class UIDGenerator: host_name = to_unicode(host_name) unique = unique or UIDGenerator.rnd_string() today = to_unicode(vDatetime(datetime.today()).to_ical()) - return vText('{}-{}@{}'.format(today, - unique, - host_name)) + return vText(f'{today}-{unique}@{host_name}')