Events, Todos and also the Timespans they represent can be compared using the usual python comparision operators <, >, <=, >=, ==, !=. This also means that a list of Events can be sorted by a call to sort. See the following sections for details on how this works in different cases.

Equality

The methods __eq__ and __ne__ implementing == and != are generated by attrs based on all public attributes of the respective class. For an Event, this also includes things like the automatically generated UID and the timestamps created, last_modified and dtstamp, where the latter defaults to datetime.now. As the UIDs are randomly generated and also even two consecutive calls to datetime.now() usually yield different results, the same holds for constructing two events in sequence:

>>> from datetime import datetime
>>> datetime.now() == datetime.now()
False
>>> import ics
>>> e1, e2 = ics.Event(), ics.Event()
>>> e1 == e2
False
>>> e1.uid = e2.uid = "event1"
>>> e1.dtstamp = e2.dtstamp = datetime.now()
>>> e1 == e2
True

Also note that for any list of objects, e.g. the list of alarms of an Event, the order is important…

>>> from datetime import datetime as dt, timedelta as td
>>> e1.alarms.append(ics.DisplayAlarm(trigger=td(days=-1), description="Alarm 1"))
>>> e1.alarms.append(ics.DisplayAlarm(trigger=td(hours=-1), description="Alarm 2"))
>>> e2.alarms = list(reversed(e1.alarms))
>>> e1 == e2
False
>>> e2.alarms = list(e1.alarms)
>>> e1 == e2
True

…and also the extra Container with custom ContentLines, which is especially important when parsing ics files that contain unknown properties.

>>> e1.extra.append(ics.ContentLine("X-PRIORITY", value="HIGH"))
>>> e1 == e2
False

If you want to know the exact differences between two Events, either convert the events to their ics representation using str(e) or use the attr.asdict method to get a dict with all attributes.

>>> e = ics.Event()
>>> e 
Event(extra=Container('VEVENT', []), extra_params={}, timespan=EventTimespan(begin_time=None, end_time=None, duration=None, precision='second'), summary=None, uid='...@....org', description=None, location=None, url=None, status=None, created=None, last_modified=None, dtstamp=datetime.datetime(..., tzinfo=Timezone.from_tzid('UTC')), alarms=[], attach=[], classification=None, transparent=None, organizer=None, geo=None, attendees=[], categories=[])
>>> str(e)
'<floating Event>'
>>> print(e.serialize())  
BEGIN:VEVENT
UID:...@...org
DTSTAMP:...T...Z
END:VEVENT
>>> import attr, pprint
>>> pprint.pprint(attr.asdict(e)) 
{'alarms': [],
 'attach': [],
 'attendees': [],
 'categories': [],
 'classification': None,
 'created': None,
 'description': None,
 'dtstamp': datetime.datetime(..., tzinfo=Timezone.from_tzid('UTC')),
 'extra': {'data': [], 'name': 'VEVENT'},
 'extra_params': {},
 'geo': None,
 'last_modified': None,
 'location': None,
 'organizer': None,
 'status': None,
 'summary': None,
 'timespan': {'begin_time': None,
              'duration': None,
              'end_time': None,
              'precision': 'second'},
 'transparent': None,
 'uid': '...@....org',
 'url': None}

Ordering

TL;DR: Events are ordered by their attributes begin, end, and summary, in that exact order. For Todos the order is due, begin, then summary. It doesn’t matter whether duration is set instead of end or due, as the effective end / due time will be compared. Instances where an attribute isn’t set will be sorted before instances where the respective attribute is set. Naive datetimes (those without a timezone) will be compared in local time.

Implementation

The class EventTimespan used by Event to represent begin and end times or durations has a method cmp_tuple returning the respective instance as a tuple (begin_time, effective_end_time):

>>> t0 = ics.EventTimespan()
>>> t0.cmp_tuple()
TimespanTuple(begin=datetime.datetime(1900, 1, 1, 0, 0, tzinfo=tzlocal()), end=datetime.datetime(1900, 1, 1, 0, 0, tzinfo=tzlocal()))
>>> t1 = ics.EventTimespan(begin_time=dt(2020, 2, 20,  20, 20))
>>> t1.cmp_tuple()
TimespanTuple(begin=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), end=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()))
>>> t2 = ics.EventTimespan(begin_time=dt(2020, 2, 20,  20, 20), end_time=dt(2020, 2, 22,  20, 20))
>>> t2.cmp_tuple()
TimespanTuple(begin=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), end=datetime.datetime(2020, 2, 22, 20, 20, tzinfo=tzlocal()))

It doesn’t matter whether an end time or a duration was specified for the timespan, as only the effective end time is compared.

>>> t3 = ics.EventTimespan(begin_time=dt(2020, 2, 20,  20, 20), duration=td(days=2))
>>> t2 < t3
False
>>> t3 < t2
False

The classes Event and Todo build on this methods, by appending their summary to the returned tuple:

>>> e11 = ics.Event(timespan=t1)
>>> e11.cmp_tuple()
(datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), '')
>>> e12 = ics.Event(timespan=t1, summary="An Event")
>>> e12.cmp_tuple()
(datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), 'An Event')

We define __lt__ (i.e. lower-than, or <) explicitly for Timespan, Event and Todo based on comparing their cmp_tuples component-wise (as is the default for comparing python tuples). Please note that neither str nor datetime are comparable as less-than or greater-than None. So string values are replaced by the empty string "" and the datetime values are replaced by datetime.min. This means that instances having no value for a certain parameter will always be sorted before instances where the parameter is set:

>>> ics.Event(timespan=t0) < ics.Event(timespan=t1)
True
>>> ics.Event(timespan=t1) < ics.Event(timespan=t2)
True
>>> ics.Event(timespan=t2) < ics.Event(timespan=t2, summary="Event Name")
True

The functions __gt__, __le__, __ge__ all behave similarly by applying the respective operation to the cmp_tuples. Note that for Todos the attribute due has higher priority than begin:

>>> x1 = ics.Todo(begin=dt(2020, 2, 20, 20, 20))
>>> x2 = ics.Todo(due=dt(2020, 2, 22, 20, 20))
>>> x3 = ics.Todo(begin=dt(2020, 2, 20, 20, 20), due=dt(2020, 2, 22, 20, 20))
>>> x1 < x2
True
>>> x1.begin = dt(2020, 4, 4, 20, 20)
>>> x1.begin > x2.due
True
>>> x1 < x2 # even altough x2 now completely lies before x1
True
>>> x2 < x3
True

Comparison Caveats

To understand how comparison of events works and what might go wrong in special cases, one first needs to understand how the “rich comparision” operators (__lt__ and the like) are defined:

By default, __ne__() delegates to __eq__() and inverts the result unless it is NotImplemented. There are no other implied relationships among the comparison operators, for example, the truth of (x<y or x==y) does not imply x<=y.

Ordering events relies on comparing the tuples returned by cmp_tuple and thus follows the same rules as comparing tuples. Additionally, as these tuples only represent a part of the instance, the order is not total and the following caveats need to be considered. The equality part in <= only holds for the compared tuples, but not all the remaining event attributes, thus (x<=y and not x<y) does not imply x==y. Moreover, not (x < y) and not (x > y) does also not imply i == y. See the end of the next section, where this is shown for two Timespans that refer to the same timestamps, but in different timezones.

Unlike all ordering functions, the equality comparision functions __eq__ and __ne__ are generated by attr.s(eq=True, ord=False) as defined here:

They compare the instances as if they were tuples of their attrs attributes, but only iff the types of both classes are identical!

This is similar to defining the operations as follows:

if other.__class__ is self.__class__:
    return attrs_to_tuple(self) <OP> attrs_to_tuple(other)
else:
    return NotImplemented

Note that equality, unlike ordering, thus takes all attributes and also the specific class into account.

Comparing datetimes with and without timezones

By default, datetimes with timezones and those without timezones (so called naive datetimes) can’t directly be ordered. Furthermore, behaviour of some datetime depends on the local timezone, so let’s first assume we are all living in Berlin, Germany and have the corresponding timezone set:

>>> import os, time
>>> os.environ['TZ'] = "Etc/GMT-2"
>>> time.tzset()
>>> time.tzname
('+02', '+02')

We can easily compare datetime instances that have an explicit timezone specified:

>>> from dateutil.tz import tzutc, tzlocal, gettz
>>> dt_ny = dt(2020, 2, 20,  20, 20, tzinfo=gettz("America/New York"))
>>> dt_utc = dt(2020, 2, 20,  20, 20, tzinfo=tzutc())
>>> dt_local = dt(2020, 2, 20,  20, 20, tzinfo=tzlocal())
>>> dt_local.tzinfo.tzname(dt_local), dt_local.tzinfo.utcoffset(dt_local).total_seconds()
('+02', 7200.0)
>>> dt_utc < dt_ny
True
>>> dt_local < dt_utc # this always holds as tzlocal is +2:00 (i.e. European Summer Time)
True

We can also compare naive instances with naive ones, but we can’t compare naive ones with timezone-aware ones:

>>> dt_naive = dt(2020, 2, 20,  20, 20)
>>> dt_naive < dt_local  
Traceback (most recent call last):
  ...
TypeError: can't compare offset-naive and offset-aware datetimes

While comparision fails in this case, other methods of datetime treat naive instances as local times. This e.g. holds for `datetime.timestamp() <https://docs.python.org/3/library/datetime.html#datetime.datetime.timestamp>`__, which could also be used for comparing instances:

>>> (dt_utc.timestamp(), dt_ny.timestamp())
(1582230000.0, 1582248000.0)
>>> (dt_local.timestamp(), dt_naive.timestamp())
(1582222800.0, 1582222800.0)

This can be become an issue when you e.g. want to iterate all Events of an iCalendar that contains both floating and timezone-aware Events in order of their begin timestamp. Let’s consult RFC 5545 on what to do in this situation:

DATE-TIME values of this type are said to be “floating” and are not bound to any time zone in particular. They are used to represent the same hour, minute, and second value regardless of which time zone is currently being observed. For example, an event can be defined that indicates that an individual will be busy from 11:00 AM to 1:00 PM every day, no matter which time zone the person is in. In these cases, a local time can be specified. The recipient of an iCalendar object with a property value consisting of a local time, without any relative time zone information, SHOULD interpret the value as being fixed to whatever time zone the “ATTENDEE” is in at any given moment. This means that two “Attendees”, in different time zones, receiving the same event definition as a floating time, may be participating in the event at different actual times. Floating time SHOULD only be used where that is the reasonable behavior.

Thus, clients should default to local time when handling floating events, similar to what other datetime methods do. This is also what ics.py does, handling this in the cmp_tuple method by always converting naive datetimes to local ones:

>>> e_local, e_floating = ics.Event(begin=dt_local), ics.Event(begin=dt_naive)
>>> e_local.begin, e_floating.begin
(datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), datetime.datetime(2020, 2, 20, 20, 20))
>>> e_local.begin == e_floating.begin
False
>>> e_local.timespan.cmp_tuple()
TimespanTuple(begin=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), end=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()))
>>> e_floating.timespan.cmp_tuple()
TimespanTuple(begin=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()), end=datetime.datetime(2020, 2, 20, 20, 20, tzinfo=tzlocal()))
>>> e_local.timespan.cmp_tuple() == e_floating.timespan.cmp_tuple()
True

So, one floating Event and one Event with explicit timezones can still be compared, while their begin datetimes can’t be directly compared:

>>> e_local < e_floating
False
>>> e_local > e_floating
False
>>> e_local.begin < e_floating.begin  
Traceback (most recent call last):
  ...
TypeError: can't compare offset-naive and offset-aware datetimes

Note that neither being considered less than the other hints at both being ordered equally, but they aren’t exactly equal as datetimes with different timezones can’t be equal.

>>> e_local == e_floating
False