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 isNotImplemented. There are no other implied relationships among the comparison operators, for example, the truth of(x<y or x==y)does not implyx<=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