Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9334b1f
[WIP] new implementation of event timespan
N-Coder Feb 2, 2020
649fe3d
remove arrow
N-Coder Feb 8, 2020
8919ae0
autoformat / clean-up imports
N-Coder Feb 28, 2020
f415fd8
mypy fixes and minor code cleanup
N-Coder Feb 28, 2020
ecf2fa3
add `attrs` goodness ✨
N-Coder Feb 28, 2020
17db714
bugfixes for cyclic / type-checking imports
N-Coder Feb 28, 2020
6f1d682
make mypy and static analysis happier
N-Coder Feb 29, 2020
37adb97
fixes for attrs and mypy, proper comparision for timespan
N-Coder Mar 2, 2020
519b4fb
properly support DTSTAMP, CREATED and LAST-MODIFIED for events and todos
N-Coder Mar 2, 2020
363159b
allow setting version and prodid to custom values
N-Coder Mar 2, 2020
ba5fdcc
validation
N-Coder Mar 8, 2020
088d9eb
bugfixes
N-Coder Mar 8, 2020
62055bf
remove arrow from testsuite and fix as many as possible
N-Coder Mar 8, 2020
8e68717
more test fixes
N-Coder Mar 8, 2020
842f0df
enable runtime validation and conversion of attribute values
N-Coder Mar 11, 2020
76da4ef
also check item types of Container
N-Coder Mar 11, 2020
00876d1
bug and test fixes
N-Coder Mar 11, 2020
62ea485
implement order-comparision using tuples, functools.total_ordering an…
N-Coder Mar 11, 2020
266cace
properly implement and document event ordering
N-Coder Mar 14, 2020
eb83970
mypy and test fixes
N-Coder Mar 14, 2020
2ab15b9
docstring fixes, order Todos with due first
N-Coder Mar 15, 2020
e030745
clean-up
N-Coder Mar 15, 2020
f498eda
Apply suggested docstring improvements from code review
N-Coder Mar 21, 2020
bf024cd
small docstring improvements
N-Coder Mar 21, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dev/requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ pytest-cov
pytest-flakes
pytest-pep8
pytest-sugar
mypy
mypy>=0.770
351 changes: 351 additions & 0 deletions doc/event-cmp.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
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), display_text="Alarm 1"))
>>> e1.alarms.append(ics.DisplayAlarm(trigger=td(hours=-1), display_text="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

Private attributes, such as Components’ ``_classmethod_args``,
``_classmethod_kwargs`` and iCalendars’ ``_timezones`` are excluded from
comparision. 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
<floating Event>
>>> str(e) # doctest: +ELLIPSIS
'BEGIN:VEVENT\r\nDTSTAMP:2020...\r\nUID:...@....org\r\nEND:VEVENT'
>>> import attr, pprint
>>> pprint.pprint(attr.asdict(e)) # doctest: +ELLIPSIS
{'_classmethod_args': None,
'_classmethod_kwargs': None,
'_timespan': {'begin_time': None,
'duration': None,
'end_time': None,
'precision': 'second'},
'alarms': [],
'attendees': [],
'categories': [],
'classification': None,
'created': None,
'description': None,
'dtstamp': datetime.datetime(2020, ...),
'extra': [],
'geo': None,
'last_modified': None,
'location': None,
'name': None,
'organizer': None,
'status': None,
'transparent': None,
'uid': '...@....org',
'url': None}

Ordering
--------

TL;DR: ``Event``\ s are ordered by their attributes ``begin``, ``end``,
and ``name``, in that exact order. For ``Todo``\ s the order is ``due``,
``begin``, then ``name``. 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(1, 1, 1, 0, 0, tzinfo=tzlocal()), end=datetime.datetime(1, 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 ``name`` 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, name="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, name="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 <https://docs.python.org/3/reference/datamodel.html#object.__lt__>`__:

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 <https://stackoverflow.com/a/5292332/805569>`__. 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 <http://www.attrs.org/en/stable/api.html#attr.s>`__:

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 <https://docs.python.org/3/library/time.html#time.tzset>`__ we
are all living in Berlin, Germany and have the corresponding timezone
set:

::

>>> import os, time
>>> os.environ['TZ'] = "Europe/Berlin"
>>> time.tzset()
>>> time.tzname
('CET', 'CEST')

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_utc < dt_ny
True
>>> dt_local < dt_utc # this always holds as tzlocal is Europe/Berlin
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())
(1582226400.0, 1582226400.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
Loading