Skip to content

Commit

Permalink
Add API for expanding recurring Todo items (#281)
Browse files Browse the repository at this point in the history
* Add view for repeated todo items

* Revert mypy changes and add gitignore

* Address lint errors

* Simplify handling of combining todo items with the same uid

* Update test coverage for start validation and improve types

* Update test coverage for updating the entire series

* Add Todo test coverage

* Update computed duration checking

* Simplify todo list view to improve test coverage
  • Loading branch information
allenporter authored Feb 3, 2024
1 parent 368ba48 commit d7f1ca1
Show file tree
Hide file tree
Showing 8 changed files with 447 additions and 23 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,5 @@ dmypy.json

# Pyre type checker
.pyre/

.DS_Store
9 changes: 9 additions & 0 deletions ical/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from collections.abc import Iterable
import datetime
import itertools
import logging
Expand All @@ -21,6 +22,7 @@
from .parsing.property import ParsedProperty
from .timeline import Timeline, calendar_timeline
from .timezone import Timezone, TimezoneModel, IcsTimezoneInfo
from .list import todo_list_view
from .todo import Todo
from .util import local_timezone, prodid_factory

Expand Down Expand Up @@ -80,6 +82,13 @@ def timeline_tz(self, tzinfo: datetime.tzinfo | None = None) -> Timeline:
"""
return calendar_timeline(self.events, tzinfo=tzinfo or local_timezone())

def todo_list(self, tzinfo: datetime.tzinfo | None = None) -> Iterable[Todo]:
"""Return a list of all todos on the calendar.
This view accounts for recurring todos.
"""
return todo_list_view(self.todos, tzinfo=tzinfo or local_timezone())

@root_validator(pre=True)
def _propagate_timezones(cls, values: dict[str, Any]) -> dict[str, Any]:
"""Propagate timezone information down to date-time objects.
Expand Down
113 changes: 113 additions & 0 deletions ical/list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""A List is a set of objects on a calendar.
A List is used to iterate over all objects, including expanded recurring
objects. A List is similar to a Timeline, except it does not repeat recurring
objects on the list and they are only shown once. A list does not repeat
forever.
"""

import datetime
from collections.abc import Generator, Iterable
import logging

from .todo import Todo
from .iter import (
LazySortableItem,
MergedIterable,
RecurIterable,
SortableItem,
SortableItemValue,
)
from .types.recur import RecurrenceId


_LOGGER = logging.getLogger(__name__)
_SortableTodoItem = SortableItem[datetime.datetime | datetime.date | None, Todo]


class RecurAdapter:
"""An adapter that expands an Todo instance for a recurrence rule.
This adapter is given an todo, then invoked with a specific date/time instance
that the todo is due from a recurrence rule. The todo is copied with
necessary updated fields to act as a flattened instance of the todo item.
"""

def __init__(self, todo: Todo, tzinfo: datetime.tzinfo | None = None):
"""Initialize the RecurAdapter."""
self._todo = todo
self._duration = todo.computed_duration
self._tzinfo = tzinfo

def get(self, dtstart: datetime.datetime | datetime.date) -> _SortableTodoItem:
"""Return a lazy sortable item."""

recur_id_dt = dtstart
# Make recurrence_id floating time to avoid dealing with serializing
# TZID. This value will still be unique within the series and is in
# the context of dtstart which may have a timezone.
if isinstance(recur_id_dt, datetime.datetime) and recur_id_dt.tzinfo:
recur_id_dt = recur_id_dt.replace(tzinfo=None)
recurrence_id = RecurrenceId.__parse_property_value__(recur_id_dt)

def build() -> Todo:
updates = {
"dtstart": dtstart,
"recurrence_id": recurrence_id,
}
if self._todo.due and self._duration:
updates["due"] = dtstart + self._duration
return self._todo.copy(update=updates)

return LazySortableItem(dtstart, build)


def _todos_by_uid(todos: list[Todo]) -> dict[str, list[Todo]]:
todos_by_uid: dict[str, list[Todo]] = {}
for todo in todos:
if todo.uid is None:
raise ValueError("Todo must have a UID")
if todo.uid not in todos_by_uid:
todos_by_uid[todo.uid] = []
todos_by_uid[todo.uid].append(todo)
return todos_by_uid


def _pick_todo(todos: list[Todo], tzinfo: datetime.tzinfo) -> Todo:
"""Pick a todo to return in a list from a list of recurring todos.
The items passed in must all be for the same original todo (either a
single todo or instance of a recurring todo including any edits). An
edited instance of a recurring todo has a recurrence-id that is
different from the original todo. This function will return the
next todo that is incomplete and has the latest due date.
"""
# For a recurring todo, the dtstart is after the last due date. Therefore
# we can stort items by dtstart and pick the last one that hasn't happened
iters: list[Iterable[_SortableTodoItem]] = []
for todo in todos:
if not (recur := todo.as_rrule()):
iters.append([SortableItemValue(todo.dtstart, todo)])
continue
iters.append(RecurIterable(RecurAdapter(todo, tzinfo=tzinfo).get, recur))

root_iter = MergedIterable(iters)

# Pick the first todo that hasn't started yet based on its dtstart
now = datetime.datetime.now(tzinfo)
it = iter(root_iter)
last = next(it)
while cur := next(it, None):
if cur.item.start_datetime is None or cur.item.start_datetime > now:
break
last = cur
return last.item


def todo_list_view(
todos: list[Todo], tzinfo: datetime.tzinfo
) -> Generator[Todo, None, None]:
"""Create a list view for todos on a calendar, including recurrence."""
todos_by_uid = _todos_by_uid(todos)
for todos in todos_by_uid.values():
yield _pick_todo(todos, tzinfo=tzinfo)
74 changes: 64 additions & 10 deletions ical/todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from collections.abc import Iterable
import datetime
import enum
from typing import Any, Optional, Union
Expand All @@ -13,6 +14,8 @@

from .alarm import Alarm
from .component import ComponentModel, validate_until_dtstart, validate_recurrence_dates
from .exceptions import CalendarParseError
from .iter import RulesetIterable
from .parsing.property import ParsedProperty
from .types import (
CalAddress,
Expand Down Expand Up @@ -87,15 +90,6 @@ class Todo(ComponentModel):
duration: Optional[datetime.timedelta] = None
"""The duration of the item as an alternative to an explicit end date/time."""

exdate: list[Union[datetime.datetime, datetime.date]] = Field(default_factory=list)
"""Defines the list of exceptions for recurring todo item.
The exception dates are used in computing the recurrence set. The recurrence set is
the complete set of recurrence instances for a calendar component (based on rrule, rdate,
exdate). The recurrence set is generated by gathering the rrule and rdate properties
then excluding any times specified by exdate.
"""

geo: Optional[Geo] = None
"""Specifies a latitude and longitude global position for the activity."""

Expand Down Expand Up @@ -143,6 +137,24 @@ class Todo(ComponentModel):
sure all instances have the same start time regardless of time zone changing.
"""

rdate: list[Union[datetime.datetime, datetime.date]] = Field(default_factory=list)
"""Defines the list of date/time values for recurring events.
Can appear along with the rrule property to define a set of repeating occurrences of the
event. The recurrence set is the complete set of recurrence instances for a calendar component
(based on rrule, rdate, exdate). The recurrence set is generated by gathering the rrule
and rdate properties then excluding any times specified by exdate.
"""

exdate: list[Union[datetime.datetime, datetime.date]] = Field(default_factory=list)
"""Defines the list of exceptions for recurring events.
The exception dates are used in computing the recurrence set. The recurrence set is
the complete set of recurrence instances for a calendar component (based on rrule, rdate,
exdate). The recurrence set is generated by gathering the rrule and rdate properties
then excluding any times specified by exdate.
"""

sequence: Optional[int] = None
"""The revision sequence number in the calendar component.
Expand Down Expand Up @@ -189,6 +201,48 @@ def start_datetime(self) -> datetime.datetime | None:
return None
return normalize_datetime(self.dtstart).astimezone(tz=datetime.timezone.utc)

@property
def computed_duration(self) -> datetime.timedelta | None:
"""Return the event duration."""
if self.due is None or self.dtstart is None:
return None
return self.due - self.dtstart

@property
def recurring(self) -> bool:
"""Return true if this Todo is recurring.
A recurring event is typically evaluated specially on the list. The
data model has a single todo, but the timeline evaluates the recurrence
to expand and copy the the event to multiple places on the timeline
using `as_rrule`.
"""
if self.rrule or self.rdate:
return True
return False

def as_rrule(self) -> Iterable[datetime.datetime | datetime.date] | None:
"""Return an iterable containing the occurrences of a recurring todo.
A recurring todo is typically evaluated specially on the todo list. The
data model has a single todo item, but the timeline evaluates the recurrence
to expand and copy the the item to multiple places on the timeline.
This is only valid for events where `recurring` is True.
"""
if not self.rrule and not self.rdate:
return None
if not self.start:
raise CalendarParseError("Event must have a start date to be recurring")
if not self.due:
raise CalendarParseError("Event must have a due date to be recurring")
return RulesetIterable(
self.start,
[self.rrule.as_rrule(self.start)] if self.rrule else [],
self.rdate,
self.exdate,
)

@root_validator
def validate_one_due_or_duration(cls, values: dict[str, Any]) -> dict[str, Any]:
"""Validate that only one of duration or end date may be set."""
Expand All @@ -198,7 +252,7 @@ def validate_one_due_or_duration(cls, values: dict[str, Any]) -> dict[str, Any]:

@root_validator
def validate_duration_requires_start(cls, values: dict[str, Any]) -> dict[str, Any]:
"""Validate that only one of duration or end date may be set."""
"""Validate that a duration requires the dtstart."""
if values.get("duration") and not values.get("dtstart"):
raise ValueError("Duration requires that dtstart is specified")
return values
Expand Down
107 changes: 107 additions & 0 deletions tests/test_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Tests for list view of todo items."""

import datetime

import freezegun
import pytest

from ical.list import todo_list_view
from ical.todo import Todo
from ical.types.recur import Recur


def test_empty_list() -> None:
"""Test an empty list."""
view = todo_list_view([], tzinfo=datetime.timezone.utc)
assert list(view) == []


@pytest.mark.parametrize(
("status"),
[
("NEEDS-ACTION"),
("IN-PROCESS"),
],
)
def test_daily_recurring_item_due_today_incomplete(status: str) -> None:
"""Test a daily recurring item that is due today ."""
with freezegun.freeze_time("2024-01-10T10:05:00-05:00"):
todo = Todo(
dtstart=datetime.date.today() - datetime.timedelta(days=1),
summary="Daily incomplete",
due=datetime.date.today(),
rrule=Recur.from_rrule("FREQ=DAILY"),
status=status,
)
view = list(todo_list_view([todo], tzinfo=datetime.timezone.utc))

assert len(view) == 1
assert view[0].summary == todo.summary
assert view[0].dtstart == datetime.date(2024, 1, 10)
assert view[0].due == datetime.date(2024, 1, 11)
assert view[0].recurrence_id == "20240110"


@pytest.mark.parametrize(
("status"),
[
("NEEDS-ACTION"),
("IN-PROCESS"),
],
)
def test_daily_recurring_item_due_tomorrow(status: str) -> None:
"""Test a daily recurring item that is due tomorrow."""
with freezegun.freeze_time("2024-01-10T10:05:00-05:00"):
todo = Todo(
dtstart=datetime.date.today(),
summary="Daily incomplete",
due=datetime.date.today() + datetime.timedelta(days=1),
rrule=Recur.from_rrule("FREQ=DAILY"),
status=status,
)
view = list(todo_list_view([todo], tzinfo=datetime.timezone.utc))

assert len(view) == 1
assert view[0].summary == todo.summary
assert view[0].dtstart == datetime.date(2024, 1, 10)
assert view[0].due == datetime.date(2024, 1, 11)
assert view[0].recurrence_id == "20240110"


@pytest.mark.parametrize(
("status"),
[
("NEEDS-ACTION"),
("IN-PROCESS"),
],
)
def test_daily_recurring_item_due_yesterday(status: str) -> None:
"""Test a daily recurring item that is due yesterday ."""

with freezegun.freeze_time("2024-01-10T10:05:00-05:00"):
todo = Todo(
dtstart=datetime.date.today() - datetime.timedelta(days=1),
summary="Daily incomplete",
due=datetime.date.today(),
rrule=Recur.from_rrule("FREQ=DAILY"),
status=status,
)
view = list(todo_list_view([todo], tzinfo=datetime.timezone.utc))

# The item should be returned with a recurrence_id of today
assert len(view) == 1
assert view[0].summary == todo.summary
assert view[0].dtstart == datetime.date(2024, 1, 10)
assert view[0].due == datetime.date(2024, 1, 11)
assert view[0].recurrence_id == "20240110"
assert view[0].status == status

with freezegun.freeze_time("2024-01-11T08:05:00-05:00"):
view = list(todo_list_view([todo], tzinfo=datetime.timezone.utc))

assert len(view) == 1
assert view[0].summary == todo.summary
assert view[0].dtstart == datetime.date(2024, 1, 11)
assert view[0].due == datetime.date(2024, 1, 12)
assert view[0].recurrence_id == "20240111"
assert view[0].status == status
Loading

0 comments on commit d7f1ca1

Please sign in to comment.