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 UID
s 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 ContentLine
s, 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: Event
s are ordered by their attributes begin
, end
,
and summary
, in that exact order. For Todo
s 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 datetime
s
(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_tuple
s 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
Todo
s 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 datetime
s with and without timezones¶
By default, datetime
s 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 datetime
s 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 datetime
s 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 datetime
s
with different timezones can’t be equal.
>>> e_local == e_floating
False