Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add API for expanding recurring Todo items #281

Merged
merged 9 commits into from
Feb 3, 2024
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