Skip to content

Commit

Permalink
Improve todo timespan handling (#291)
Browse files Browse the repository at this point in the history
  • Loading branch information
allenporter authored Feb 8, 2024
1 parent dee0625 commit 502b9e3
Show file tree
Hide file tree
Showing 3 changed files with 82 additions and 8 deletions.
26 changes: 19 additions & 7 deletions ical/todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
Uri,
RelatedTo,
)
from .util import dtstamp_factory, normalize_datetime, uid_factory
from .util import dtstamp_factory, normalize_datetime, uid_factory, local_timezone


class TodoStatus(str, enum.Enum):
Expand Down Expand Up @@ -213,16 +213,28 @@ def computed_duration(self) -> datetime.timedelta | None:
@property
def timespan(self) -> Timespan:
"""Return a timespan representing the item start and due date."""
if not self.start:
raise ValueError("Event must have a start and due date to calculate timespan")
return Timespan.of(self.start, self.due or self.start)
return self.timespan_of(local_timezone())

def timespan_of(self, tzinfo: datetime.tzinfo) -> Timespan:
"""Return a timespan representing the item start and due date."""
if not self.start:
raise ValueError("Event must have a start and due date to calculate timespan")
dtstart = self.dtstart
dtend = self.due
if dtstart is None:
if dtend is None:
# A component with the DTSTART and DUE specifies a to-do that
# will be associated with each successive calendar date, until
# it is completed.
dtstart = datetime.datetime.now(tzinfo).date()
dtend = dtstart + datetime.timedelta(days=1)
else:
# Component with a DTSTART but no DUE date will be sorted next
# to the due date.
dtstart = dtend
elif dtend is None:
# Component with a DTSTART but not DUE date will be sorted next to the start date
dtend = dtstart
return Timespan.of(
normalize_datetime(self.start, tzinfo), normalize_datetime(self.due or self.start, tzinfo)
normalize_datetime(dtstart, tzinfo), normalize_datetime(dtend, tzinfo)
)

@property
Expand Down
16 changes: 15 additions & 1 deletion tests/test_calendar_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@

from ical.exceptions import CalendarParseError
from ical.calendar_stream import CalendarStream, IcsCalendarStream
from ical.store import EventStore, TodoStore

MAX_ITERATIONS = 30
TESTDATA_PATH = pathlib.Path("tests/testdata/")
TESTDATA_FILES = list(TESTDATA_PATH.glob("*.ics"))
TESTDATA_IDS = [ x.stem for x in TESTDATA_FILES ]


def test_empty_ics(mock_prodid: Generator[None, None, None]) -> None:
"""Test serialization of an empty ics file."""
calendar = IcsCalendarStream.calendar_from_ics("")
Expand Down Expand Up @@ -72,7 +74,7 @@ def test_serialize(filename: pathlib.Path, snapshot: SnapshotAssertion) -> None:


@pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS)
def test_iteration(filename: pathlib.Path, snapshot: SnapshotAssertion) -> None:
def test_timeline_iteration(filename: pathlib.Path) -> None:
"""Fixture to ensure all calendar events are valid and support iteration."""
with filename.open() as f:
cal = IcsCalendarStream.from_ics(f.read())
Expand All @@ -83,6 +85,18 @@ def test_iteration(filename: pathlib.Path, snapshot: SnapshotAssertion) -> None:
assert event is not None


@pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS)
def test_todo_list_iteration(filename: pathlib.Path) -> None:
"""Fixture to read golden file and compare to golden output."""
cal = CalendarStream.from_ics(filename.read_text())
if not cal.calendars:
return
calendar = cal.calendars[0]
tl = TodoStore(calendar).todo_list()
for todo in itertools.islice(tl, MAX_ITERATIONS):
assert todo is not None


def test_invalid_ics() -> None:
"""Test parsing failures for ics content."""
with pytest.raises(CalendarParseError, match="Failed to parse calendar stream"):
Expand Down
48 changes: 48 additions & 0 deletions tests/test_todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any
from unittest.mock import patch

from freezegun import freeze_time
import pytest

from ical.exceptions import CalendarParseError
Expand Down Expand Up @@ -103,3 +104,50 @@ def test_is_recurring() -> None:
datetime.date(2024, 2, 3),
datetime.date(2024, 2, 4),
]


def test_timestamp_start_due() -> None:
"""Test a timespan of a Todo with a start and due date."""
todo = Todo(
summary="Example",
dtstart=datetime.date(2022, 8, 1),
due=datetime.date(2022, 8, 7),
)

with patch("ical.todo.local_timezone", return_value=zoneinfo.ZoneInfo("CET")):
ts = todo.timespan
assert ts.start.isoformat() == "2022-08-01T00:00:00+02:00"
assert ts.end.isoformat() == "2022-08-07T00:00:00+02:00"

ts = todo.timespan_of(zoneinfo.ZoneInfo("America/Regina"))
assert ts.start.isoformat() == "2022-08-01T00:00:00-06:00"
assert ts.end.isoformat() == "2022-08-07T00:00:00-06:00"


def test_timespan_missing_dtstart() -> None:
"""Test a timespan of a Todo without a dtstart."""
todo = Todo(summary="Example", due=datetime.date(2022, 8, 7))

with patch("ical.todo.local_timezone", return_value=zoneinfo.ZoneInfo("Pacific/Honolulu")):
ts = todo.timespan
assert ts.start.isoformat() == "2022-08-07T00:00:00-10:00"
assert ts.end.isoformat() == "2022-08-07T00:00:00-10:00"

ts = todo.timespan_of(zoneinfo.ZoneInfo("America/Regina"))
assert ts.start.isoformat() == "2022-08-07T00:00:00-06:00"
assert ts.end.isoformat() == "2022-08-07T00:00:00-06:00"


def test_timespan_fallback() -> None:
"""Test a timespan of a Todo with no explicit dtstart and due date"""

with freeze_time("2022-09-03T09:38:05", tz_offset=10), patch("ical.todo.local_timezone", return_value=zoneinfo.ZoneInfo("Pacific/Honolulu")):
todo = Todo(summary="Example")
ts = todo.timespan
assert ts.start.isoformat() == "2022-09-03T00:00:00-10:00"
assert ts.end.isoformat() == "2022-09-04T00:00:00-10:00"

with freeze_time("2022-09-03T09:38:05", tz_offset=10), patch("ical.todo.local_timezone", return_value=zoneinfo.ZoneInfo("Pacific/Honolulu")):
ts = todo.timespan_of(zoneinfo.ZoneInfo("America/Regina"))
assert ts.start.isoformat() == "2022-09-03T00:00:00-06:00"
assert ts.end.isoformat() == "2022-09-04T00:00:00-06:00"

0 comments on commit 502b9e3

Please sign in to comment.