From b83e30982a583fe13f57dabf759732f9f99183ac Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 3 Feb 2024 12:40:28 -0800 Subject: [PATCH] Update test coverage for start validation and improve types --- ical/list.py | 14 +++++--------- ical/todo.py | 9 +++++++-- tests/test_todo.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/ical/list.py b/ical/list.py index b064d65..f142582 100644 --- a/ical/list.py +++ b/ical/list.py @@ -17,12 +17,12 @@ RecurIterable, SortableItem, SortableItemValue, - SortedItemIterable, ) from .types.recur import RecurrenceId _LOGGER = logging.getLogger(__name__) +_SortableTodoItem = SortableItem[datetime.datetime | datetime.date | None, Todo] class RecurAdapter: @@ -41,9 +41,7 @@ def __init__(self, todo: Todo, tzinfo: datetime.tzinfo | None = None): self._duration = todo.computed_duration self._tzinfo = tzinfo - def get( - self, dtstart: datetime.datetime | datetime.date - ) -> SortableItem[datetime.datetime | datetime.date | None, Todo]: + def get(self, dtstart: datetime.datetime | datetime.date) -> _SortableTodoItem: """Return a lazy sortable item.""" recur_id_dt = dtstart @@ -87,8 +85,8 @@ def _pick_todo(todos: list[Todo], tzinfo: datetime.tzinfo) -> Todo: 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 = [] + # 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)]) @@ -96,11 +94,9 @@ def _pick_todo(todos: list[Todo], tzinfo: datetime.tzinfo) -> Todo: 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) - last: Todo | None = None - it = iter(root_iter) last = next(it, None) if not last: diff --git a/ical/todo.py b/ical/todo.py index a9b5a69..1cb5b2b 100644 --- a/ical/todo.py +++ b/ical/todo.py @@ -14,6 +14,7 @@ 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 ( @@ -229,8 +230,12 @@ def as_rrule(self) -> Iterable[datetime.datetime | datetime.date] | None: This is only valid for events where `recurring` is True. """ - if not (self.rrule or self.rdate) or not self.computed_duration: + 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 [], @@ -247,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 diff --git a/tests/test_todo.py b/tests/test_todo.py index 3b5198e..b6b4213 100644 --- a/tests/test_todo.py +++ b/tests/test_todo.py @@ -4,12 +4,14 @@ import datetime import zoneinfo +from typing import Any from unittest.mock import patch import pytest from ical.exceptions import CalendarParseError from ical.todo import Todo +from ical.types.recur import Recur def test_empty() -> None: @@ -53,3 +55,35 @@ def test_duration() -> None: "ical.util.local_timezone", return_value=zoneinfo.ZoneInfo("America/Regina") ): assert todo.start_datetime.isoformat() == "2022-08-07T06:00:00+00:00" + + +@pytest.mark.parametrize( + ("params"), + [ + ({}), + ( + { + "start": datetime.datetime(2022, 9, 6, 6, 0, 0), + } + ), + ( + { + "due": datetime.datetime(2022, 9, 6, 6, 0, 0), + } + ), + ( + { + "duration": datetime.timedelta(hours=1), + } + ), + ], +) +def test_validate_rrule_required_fields(params: dict[str, Any]) -> None: + """Test that a Todo with an rrule requires a dtstart.""" + with pytest.raises(CalendarParseError): + event = Todo( + summary="Event 1", + rrule=Recur.from_rrule("FREQ=WEEKLY;BYDAY=WE,MO,TU,TH,FR;COUNT=3"), + **params, + ) + event.as_rrule()