diff --git a/CHANGES.rst b/CHANGES.rst index d399490a..05c67f86 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -29,6 +29,8 @@ Bug fixes: - Multivalue FREEBUSY property is now parsed properly Ref: #27 [jacadzaca] +- Compare equality and inequality of calendars more completely + Ref: #570 - Use non legacy timezone name. Ref: #567 - Add some compare functions. @@ -36,7 +38,7 @@ Bug fixes: - Change OSS Fuzz build script to point to harnesses in fuzzing directory Ref: #574 -5.0.10 (unreleased) +5.0.10 (2023-09-26) ------------------- Bug fixes: @@ -92,7 +94,7 @@ Minor changes: Minor changes: -- Added support for BYWEEKDAY in vRecur ref: #268 +- Added support for BYWEEKDAY in vRecur ref: #268 Bug fixes: @@ -104,7 +106,7 @@ Bug fixes: Minor changes: - Improved documentation - Ref: #503, #504 + Ref: #503, #504 Bug fixes: diff --git a/src/icalendar/cal.py b/src/icalendar/cal.py index 0fd8ec2b..9bfed800 100644 --- a/src/icalendar/cal.py +++ b/src/icalendar/cal.py @@ -447,7 +447,7 @@ class Component(CaselessDict): return f"{self.name or type(self).__name__}({dict(self)}{', ' + subs if subs else ''})" def __eq__(self, other): - if not len(self.subcomponents) == len(other.subcomponents): + if len(self.subcomponents) != len(other.subcomponents): return False properties_equal = super().__eq__(other) @@ -465,7 +465,6 @@ class Component(CaselessDict): return True - ####################################### # components defined in RFC 5545 diff --git a/src/icalendar/caselessdict.py b/src/icalendar/caselessdict.py index c3671456..097dcd99 100644 --- a/src/icalendar/caselessdict.py +++ b/src/icalendar/caselessdict.py @@ -88,6 +88,9 @@ class CaselessDict(OrderedDict): def __eq__(self, other): return self is other or dict(self.items()) == dict(other.items()) + def __ne__(self, other): + return not self == other + # A list of keys that must appear first in sorted_keys and sorted_items; # must be uppercase. canonical_order = None diff --git a/src/icalendar/prop.py b/src/icalendar/prop.py index 131567f7..c7e7840a 100644 --- a/src/icalendar/prop.py +++ b/src/icalendar/prop.py @@ -145,6 +145,10 @@ class vBinary: except UnicodeError: raise ValueError('Not valid base 64 encoding.') + def __eq__(self, other): + """self == other""" + return isinstance(other, vBinary) and self.obj == other.obj + class vBoolean(int): """Returns specific string according to state. @@ -269,6 +273,7 @@ class vCategory: if not hasattr(c_list, '__iter__') or isinstance(c_list, str): 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]) @@ -279,8 +284,24 @@ class vCategory: out = unescape_char(ical).split(',') return out + def __eq__(self, other): + """self == other""" + return isinstance(other, vCategory) and self.cats == other.cats -class vDDDTypes: +class TimeBase: + """Make classes with a datetime/date comparable.""" + + def __eq__(self, other): + """self == other""" + if isinstance(other, TimeBase): + return self.params == other.params and self.dt == other.dt + return False + + def __hash__(self): + return hash(self.dt) + + +class vDDDTypes(TimeBase): """A combined Datetime, Date or Duration parser/generator. Their format cannot be confused, and often values can be of either types. So this is practical. @@ -290,7 +311,7 @@ class vDDDTypes: if not isinstance(dt, (datetime, date, timedelta, time, tuple)): raise ValueError('You must use datetime, date, timedelta, ' 'time or tuple (for periods)') - if isinstance(dt, datetime): + if isinstance(dt, (datetime, timedelta)): self.params = Parameters() elif isinstance(dt, date): self.params = Parameters({'value': 'DATE'}) @@ -320,14 +341,6 @@ class vDDDTypes: else: raise ValueError(f'Unknown date type: {type(dt)}') - def __eq__(self, other): - if isinstance(other, vDDDTypes): - return self.params == other.params and self.dt == other.dt - return False - - def __hash__(self): - return hash(self.dt) - @classmethod def from_ical(cls, ical, timezone=None): if isinstance(ical, cls): @@ -349,8 +362,11 @@ class vDDDTypes: f"Expected datetime, date, or time, got: '{ical}'" ) + def __repr__(self): + """repr(self)""" + return f"{self.__class__.__name__}({self.dt}, {self.params})" -class vDate: +class vDate(TimeBase): """Render and generates iCalendar date format. """ @@ -377,7 +393,7 @@ class vDate: raise ValueError(f'Wrong date format {ical}') -class vDatetime: +class vDatetime(TimeBase): """Render and generates icalendar datetime format. vDatetime is timezone aware and uses the pytz library, an implementation of @@ -438,7 +454,7 @@ class vDatetime: raise ValueError(f'Wrong datetime format: {ical}') -class vDuration: +class vDuration(TimeBase): """Subclass of timedelta that renders itself in the iCalendar DURATION format. """ @@ -495,8 +511,12 @@ class vDuration: return value + @property + def dt(self): + """The time delta for compatibility.""" + return self.td -class vPeriod: +class vPeriod(TimeBase): """A precise period of time. """ @@ -520,7 +540,7 @@ class vPeriod: 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) @@ -532,17 +552,6 @@ class vPeriod: self.by_duration = by_duration self.duration = duration - def __cmp__(self, other): - if not isinstance(other, vPeriod): - raise NotImplementedError( - f'Cannot compare vPeriod with {other!r}') - return cmp((self.start, self.end), (other.start, other.end)) - - def __eq__(self, other): - if not isinstance(other, vPeriod): - return False - return (self.start, self.end) == (other.start, other.end) - def overlaps(self, other): if self.start > other.start: return other.overlaps(self) @@ -574,6 +583,10 @@ class vPeriod: p = (self.start, self.end) return f'vPeriod({p!r})' + @property + def dt(self): + """Make this cooperate with the other vDDDTypes.""" + return (self.start, (self.duration if self.by_duration else self.end)) class vWeekday(str): """This returns an unquoted weekday abbrevation. @@ -740,7 +753,7 @@ class vText(str): return cls(ical_unesc) -class vTime: +class vTime(TimeBase): """Render and generates iCalendar time format. """ @@ -814,6 +827,8 @@ class vGeo: except Exception: raise ValueError(f"Expected 'float;float' , got: {ical}") + def __eq__(self, other): + return self.to_ical() == other.to_ical() class vUTCOffset: """Renders itself as a utc offset. diff --git a/src/icalendar/tests/america_new_york.ics b/src/icalendar/tests/calendars/america_new_york.ics similarity index 100% rename from src/icalendar/tests/america_new_york.ics rename to src/icalendar/tests/calendars/america_new_york.ics diff --git a/src/icalendar/tests/pacific_fiji.ics b/src/icalendar/tests/calendars/pacific_fiji.ics similarity index 100% rename from src/icalendar/tests/pacific_fiji.ics rename to src/icalendar/tests/calendars/pacific_fiji.ics diff --git a/src/icalendar/tests/calendars/pr_480_summary_with_colon.ics b/src/icalendar/tests/calendars/pr_480_summary_with_colon.ics index 438c7011..96b69179 100644 --- a/src/icalendar/tests/calendars/pr_480_summary_with_colon.ics +++ b/src/icalendar/tests/calendars/pr_480_summary_with_colon.ics @@ -4,4 +4,4 @@ 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 +END:VEVENT diff --git a/src/icalendar/tests/time.ics b/src/icalendar/tests/calendars/time.ics similarity index 100% rename from src/icalendar/tests/time.ics rename to src/icalendar/tests/calendars/time.ics diff --git a/src/icalendar/tests/timezone_rdate.ics b/src/icalendar/tests/calendars/timezone_rdate.ics similarity index 100% rename from src/icalendar/tests/timezone_rdate.ics rename to src/icalendar/tests/calendars/timezone_rdate.ics diff --git a/src/icalendar/tests/timezone_same_start.ics b/src/icalendar/tests/calendars/timezone_same_start.ics similarity index 100% rename from src/icalendar/tests/timezone_same_start.ics rename to src/icalendar/tests/calendars/timezone_same_start.ics diff --git a/src/icalendar/tests/timezone_same_start_and_offset.ics b/src/icalendar/tests/calendars/timezone_same_start_and_offset.ics similarity index 100% rename from src/icalendar/tests/timezone_same_start_and_offset.ics rename to src/icalendar/tests/calendars/timezone_same_start_and_offset.ics diff --git a/src/icalendar/tests/timezoned.ics b/src/icalendar/tests/calendars/timezoned.ics similarity index 100% rename from src/icalendar/tests/timezoned.ics rename to src/icalendar/tests/calendars/timezoned.ics diff --git a/src/icalendar/tests/conftest.py b/src/icalendar/tests/conftest.py index 0b1296b3..4a954a29 100644 --- a/src/icalendar/tests/conftest.py +++ b/src/icalendar/tests/conftest.py @@ -15,10 +15,16 @@ class DataSource: self._parser = parser self._data_source_folder = data_source_folder + def keys(self): + """Return all the files that could be used.""" + return [file[:-4] for file in os.listdir(self._data_source_folder) if file.lower().endswith(".ics")] + def __getattr__(self, attribute): """Parse a file and return the result stored in the attribute.""" source_file = attribute.replace('-', '_') + '.ics' source_path = os.path.join(self._data_source_folder, source_file) + if not os.path.isfile(source_path): + raise AttributeError(f"{source_path} does not exist.") with open(source_path, 'rb') as f: raw_ics = f.read() source = self._parser(raw_ics) @@ -40,20 +46,23 @@ class DataSource: HERE = os.path.dirname(__file__) CALENDARS_FOLDER = os.path.join(HERE, 'calendars') +CALENDARS = DataSource(CALENDARS_FOLDER, icalendar.Calendar.from_ical) TIMEZONES_FOLDER = os.path.join(HERE, 'timezones') +TIMEZONES = DataSource(TIMEZONES_FOLDER, icalendar.Timezone.from_ical) EVENTS_FOLDER = os.path.join(HERE, 'events') +EVENTS = DataSource(EVENTS_FOLDER, icalendar.Event.from_ical) -@pytest.fixture +@pytest.fixture() def calendars(): - return DataSource(CALENDARS_FOLDER, icalendar.Calendar.from_ical) + return CALENDARS -@pytest.fixture +@pytest.fixture() def timezones(): - return DataSource(TIMEZONES_FOLDER, icalendar.Timezone.from_ical) + return TIMEZONES -@pytest.fixture +@pytest.fixture() def events(): - return DataSource(EVENTS_FOLDER, icalendar.Event.from_ical) + return EVENTS @pytest.fixture(params=[ pytz.utc, @@ -71,3 +80,19 @@ def utc(request): ]) def in_timezone(request): return request.param + + +@pytest.fixture(params=[ + (data, key) + for data in [CALENDARS, TIMEZONES, EVENTS] + for key in data.keys() if key not in + ( # exclude broken calendars here + "big_bad_calendar", "issue_104_broken_calendar", "small_bad_calendar", + "multiple_calendar_components", "pr_480_summary_with_colon" + ) +]) +def ics_file(request): + """An example ICS file.""" + data, key = request.param + print(key) + return data[key] diff --git a/src/icalendar/tests/test_equality.py b/src/icalendar/tests/test_equality.py new file mode 100644 index 00000000..b491e372 --- /dev/null +++ b/src/icalendar/tests/test_equality.py @@ -0,0 +1,131 @@ +"""Test the equality and inequality of components.""" +import copy +import pytz +from icalendar.prop import * +from datetime import datetime, date, timedelta +import pytest + + +def test_parsed_calendars_are_equal_if_parsed_again(ics_file): + """Ensure that a calendar equals the same calendar.""" + copy_of_calendar = ics_file.__class__.from_ical(ics_file.to_ical()) + assert copy_of_calendar == ics_file + assert not copy_of_calendar != ics_file + + +def test_parsed_calendars_are_equal_if_from_same_source(ics_file): + """Ensure that a calendar equals the same calendar.""" + cal1 = ics_file.__class__.from_ical(ics_file.raw_ics) + cal2 = ics_file.__class__.from_ical(ics_file.raw_ics) + assert cal1 == cal2 + assert not cal1 != cal2 + + +def test_copies_are_equal(ics_file): + """Ensure that copies are equal.""" + copy1 = ics_file.copy(); copy1.subcomponents = ics_file.subcomponents + copy2 = ics_file.copy(); copy2.subcomponents = ics_file.subcomponents[:] + assert copy1 == copy2 + assert copy1 == ics_file + assert copy2 == ics_file + assert not copy1 != copy2 + assert not copy1 != ics_file + assert not copy2 != ics_file + + +def test_copy_does_not_copy_subcomponents(calendars): + """If we copy the subcomponents, assumptions around copies will be broken.""" + assert calendars.timezoned.subcomponents + assert not calendars.timezoned.copy().subcomponents + + +def test_deep_copies_are_equal(ics_file): + """Ensure that deep copies are equal.""" + try: + assert copy.deepcopy(ics_file) == copy.deepcopy(ics_file) + assert copy.deepcopy(ics_file) == ics_file + assert not copy.deepcopy(ics_file) != copy.deepcopy(ics_file) + assert not copy.deepcopy(ics_file) != ics_file + except pytz.UnknownTimeZoneError: + # Ignore errors when a custom time zone is used. + # This is still covered by the parsing test. + pass + + +def test_vGeo(): + """Check the equality of vGeo.""" + assert vGeo(("100", "12.33")) == vGeo(("100.00", "12.330")) + assert vGeo(("100", "12.331")) != vGeo(("100.00", "12.330")) + assert vGeo(("10", "12.33")) != vGeo(("100.00", "12.330")) + + +def test_vBinary(): + assert vBinary('asd') == vBinary('asd') + assert vBinary('asdf') != vBinary('asd') + + +def test_vBoolean(): + assert vBoolean.from_ical('TRUE') == vBoolean.from_ical('TRUE') + assert vBoolean.from_ical('FALSE') == vBoolean.from_ical('FALSE') + assert vBoolean.from_ical('TRUE') != vBoolean.from_ical('FALSE') + + +def test_vCategory(): + assert vCategory("HELLO") == vCategory("HELLO") + assert vCategory(["a","b"]) == vCategory(["a","b"]) + assert vCategory(["a","b"]) != vCategory(["a","b", "c"]) + + +def test_vText(): + assert vText("HELLO") == vText("HELLO") + assert not vText("HELLO") != vText("HELLO") + assert vText("HELLO1") != vText("HELLO") + assert not vText("HELLO1") == vText("HELLO") + + +@pytest.mark.parametrize( + "vType,v1,v2", + [ + (vDatetime, datetime(2023, 11, 1, 10, 11), datetime(2023, 11, 1, 10, 10)), + (vDate, date(2023, 11, 1), date(2023, 10, 31)), + (vDuration, timedelta(3, 11, 1), timedelta(23, 10, 31)), + (vPeriod, (datetime(2023, 11, 1, 10, 11), timedelta(3, 11, 1)), (datetime(2023, 11, 1, 10, 11), timedelta(23, 10, 31))), + (vPeriod, (datetime(2023, 11, 1, 10, 1), timedelta(3, 11, 1)), (datetime(2023, 11, 1, 10, 11), timedelta(3, 11, 1))), + (vPeriod, (datetime(2023, 11, 1, 10, 1), datetime(2023, 11, 1, 10, 3)), (datetime(2023, 11, 1, 10, 1), datetime(2023, 11, 1, 10, 2))), + (vTime, time(10, 10, 10), time(10, 10, 11)), + ] +) +@pytest.mark.parametrize("eq", ["==", "!="]) +@pytest.mark.parametrize("cls1", [0, 1]) +@pytest.mark.parametrize("cls2", [0, 1]) +@pytest.mark.parametrize("hash", [lambda x:x, hash]) +def test_vDDDTypes_and_others(vType, v1, v2, cls1, cls2, eq, hash): + """Check equality and inequality.""" + t1 = (vType, vDDDTypes)[cls1] + t2 = (vType, vDDDTypes)[cls2] + if eq == "==": + assert hash(v1) == hash(v1) + assert hash(t1(v1)) == hash(t2(v1)) + assert not hash(t1(v1)) != hash(t2(v1)) + else: + assert hash(v1) != hash(v2) + assert hash(t1(v1)) != hash(t2(v2)) + + +def test_repr_vDDDTypes(): + assert "vDDDTypes" in repr(vDDDTypes(timedelta(3, 11, 1))) + + +vDDDLists_examples = [ + vDDDLists([]), + vDDDLists([datetime(2023, 11, 1, 10, 1)]), + vDDDLists([datetime(2023, 11, 1, 10, 1), date(2023, 11, 1)]), +] +@pytest.mark.parametrize("l1", vDDDLists_examples) +@pytest.mark.parametrize("l2", vDDDLists_examples) +def test_vDDDLists(l1, l2): + """Check the equality functions of vDDDLists.""" + equal = l1 is l2 + l2 = copy.deepcopy(l2) + assert equal == (l1 == l2) + assert equal != (l1 != l2) diff --git a/src/icalendar/tests/test_examples.py b/src/icalendar/tests/test_examples.py index ad10e174..0c1c132e 100644 --- a/src/icalendar/tests/test_examples.py +++ b/src/icalendar/tests/test_examples.py @@ -1,11 +1,10 @@ '''tests ensuring that *the* way of doing things works''' import datetime - from icalendar import Calendar, Event - import pytest + def test_creating_calendar_with_unicode_fields(calendars, utc): ''' create a calendar with events that contain unicode characters in their fields ''' cal = Calendar() @@ -38,4 +37,3 @@ def test_creating_calendar_with_unicode_fields(calendars, utc): cal.add_component(event2) assert cal.to_ical() == calendars.created_calendar_with_unicode_fields.raw_ics - diff --git a/src/icalendar/tests/test_time.py b/src/icalendar/tests/test_time.py index 27a2dad0..f2aa36c7 100644 --- a/src/icalendar/tests/test_time.py +++ b/src/icalendar/tests/test_time.py @@ -15,7 +15,7 @@ class TestTime(unittest.TestCase): def test_create_from_ical(self): directory = os.path.dirname(__file__) - ics = open(os.path.join(directory, 'time.ics'), 'rb') + ics = open(os.path.join(directory, 'calendars', 'time.ics'), 'rb') cal = icalendar.Calendar.from_ical(ics.read()) ics.close() diff --git a/src/icalendar/tests/test_timezoned.py b/src/icalendar/tests/test_timezoned.py index 2f284f89..d5b33ac6 100644 --- a/src/icalendar/tests/test_timezoned.py +++ b/src/icalendar/tests/test_timezoned.py @@ -10,11 +10,13 @@ try: except: from backports import zoneinfo +HERE = os.path.dirname(__file__) +CALENDARS_DIRECTORY = os.path.join(HERE, 'calendars') + class TestTimezoned(unittest.TestCase): def test_create_from_ical_zoneinfo(self): - directory = os.path.dirname(__file__) - with open(os.path.join(directory, 'timezoned.ics'), 'rb') as fp: + with open(os.path.join(CALENDARS_DIRECTORY, 'timezoned.ics'), 'rb') as fp: data = fp.read() cal = icalendar.Calendar.from_ical(data) @@ -46,8 +48,7 @@ class TestTimezoned(unittest.TestCase): ) def test_create_from_ical_pytz(self): - directory = os.path.dirname(__file__) - with open(os.path.join(directory, 'timezoned.ics'), 'rb') as fp: + with open(os.path.join(CALENDARS_DIRECTORY, 'timezoned.ics'), 'rb') as fp: data = fp.read() cal = icalendar.Calendar.from_ical(data) @@ -269,9 +270,7 @@ class TestTimezoneCreation(unittest.TestCase): def test_create_america_new_york(self): """testing America/New_York, the most complex example from the RFC""" - - directory = os.path.dirname(__file__) - with open(os.path.join(directory, 'america_new_york.ics'), 'rb') as fp: + with open(os.path.join(CALENDARS_DIRECTORY, 'america_new_york.ics'), 'rb') as fp: data = fp.read() cal = icalendar.Calendar.from_ical(data) @@ -307,8 +306,7 @@ class TestTimezoneCreation(unittest.TestCase): one RDATE property per subcomponent""" self.maxDiff = None - directory = os.path.dirname(__file__) - with open(os.path.join(directory, 'pacific_fiji.ics'), 'rb') as fp: + with open(os.path.join(CALENDARS_DIRECTORY, 'pacific_fiji.ics'), 'rb') as fp: data = fp.read() cal = icalendar.Calendar.from_ical(data) @@ -442,8 +440,7 @@ class TestTimezoneCreation(unittest.TestCase): def test_same_start_date(self): """testing if we can handle VTIMEZONEs whose different components have the same start DTIMEs.""" - directory = os.path.dirname(__file__) - with open(os.path.join(directory, 'timezone_same_start.ics'), 'rb') as fp: + with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_same_start.ics'), 'rb') as fp: data = fp.read() cal = icalendar.Calendar.from_ical(data) d = cal.subcomponents[1]['DTSTART'].dt @@ -452,8 +449,7 @@ class TestTimezoneCreation(unittest.TestCase): def test_same_start_date_and_offset(self): """testing if we can handle VTIMEZONEs whose different components have the same DTSTARTs, TZOFFSETFROM, and TZOFFSETTO.""" - directory = os.path.dirname(__file__) - with open(os.path.join(directory, 'timezone_same_start_and_offset.ics'), 'rb') as fp: + with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_same_start_and_offset.ics'), 'rb') as fp: data = fp.read() cal = icalendar.Calendar.from_ical(data) d = cal.subcomponents[1]['DTSTART'].dt @@ -462,8 +458,7 @@ class TestTimezoneCreation(unittest.TestCase): def test_rdate(self): """testing if we can handle VTIMEZONEs who only have an RDATE, not RRULE """ - directory = os.path.dirname(__file__) - with open(os.path.join(directory, 'timezone_rdate.ics'), 'rb') as fp: + with open(os.path.join(CALENDARS_DIRECTORY, 'timezone_rdate.ics'), 'rb') as fp: data = fp.read() cal = icalendar.Calendar.from_ical(data) vevent = cal.walk('VEVENT')[0]