diff --git a/ical/todo.py b/ical/todo.py index c9bdb58..034650a 100644 --- a/ical/todo.py +++ b/ical/todo.py @@ -25,7 +25,7 @@ from .component import ComponentModel, validate_until_dtstart, validate_recurrence_dates from .exceptions import CalendarParseError from .iter import RulesetIterable -from .parsing.property import ParsedProperty +from .parsing.property import ParsedProperty, ParsedPropertyParameter from .timespan import Timespan from .types import ( CalAddress, @@ -37,6 +37,7 @@ RequestStatus, Uri, RelatedTo, + date_time, ) from .util import dtstamp_factory, normalize_datetime, uid_factory, local_timezone @@ -364,7 +365,46 @@ def _validate_due_later(cls, values: dict[str, Any]) -> dict[str, Any]: values["dtstart"] = due - datetime.timedelta(days=1) return values + @classmethod + def _parse_single_property(cls, field_type: type, prop: ParsedProperty) -> Any: + """Parse an individual field as a single type.""" + try: + return super()._parse_single_property(field_type, prop) + except ValueError as err: + if ( + prop.name == "dtstart" + and field_type == datetime.datetime + and prop.params is not None + ): + new_prop = ParsedProperty( + prop.name, prop.value, _repair_tzid_param(prop.params) + ) + _LOGGER.debug( + "Applying todo dtstart repair for invalid timezone: %s", new_prop + ) + try: + return date_time.parse_property_value(new_prop) + except ValueError as repair_err: + _LOGGER.debug( + "To-do dtstart repair failed %s, raising original error", + repair_err, + ) + raise err + _validate_until_dtstart = root_validator(allow_reuse=True)(validate_until_dtstart) _validate_recurrence_dates = root_validator(allow_reuse=True)( validate_recurrence_dates ) + + +def _repair_tzid_param( + params: list[ParsedPropertyParameter], +) -> list[ParsedPropertyParameter]: + """Repair the TZID parameter.""" + result_params = [] + for param in params: + if param.name == "TZID": + result_params.append(ParsedPropertyParameter(param.name, ["UTC"])) + continue + result_params.append(param) + return result_params diff --git a/ical/types/date_time.py b/ical/types/date_time.py index ee3dc82..8b4a5a6 100644 --- a/ical/types/date_time.py +++ b/ical/types/date_time.py @@ -20,6 +20,46 @@ ATTR_VALUE = "VALUE" +def parse_property_value( + prop: ParsedProperty, allow_invalid_timezone: bool = False +) -> datetime.datetime: + """Parse a rfc5545 into a datetime.datetime.""" + if not (match := DATETIME_REGEX.fullmatch(prop.value)): + raise ValueError(f"Expected value to match DATE-TIME pattern: {prop.value}") + + # Example: TZID=America/New_York:19980119T020000 + timezone: datetime.tzinfo | None = None + if param := prop.get_parameter(TZID): + if param.values and (value := param.values[0]): + if isinstance(value, datetime.tzinfo): + timezone = value + else: + try: + timezone = zoneinfo.ZoneInfo(value) + except zoneinfo.ZoneInfoNotFoundError: + if allow_invalid_timezone: + timezone = None + raise ValueError( + f"Expected DATE-TIME TZID value '{value}' to be valid timezone" + ) + elif match.group(3): # Example: 19980119T070000Z + timezone = datetime.timezone.utc + + # Example: 19980118T230000 + date_value = match.group(1) + year = int(date_value[0:4]) + month = int(date_value[4:6]) + day = int(date_value[6:]) + time_value = match.group(2) + hour = int(time_value[0:2]) + minute = int(time_value[2:4]) + second = int(time_value[4:6]) + + result = datetime.datetime(year, month, day, hour, minute, second, tzinfo=timezone) + _LOGGER.debug("DateTimeEncoder returned %s", result) + return result + + @DATA_TYPE.register("DATE-TIME", parse_order=2) class DateTimeEncoder: """Class to handle encoding for a datetime.datetime.""" @@ -31,40 +71,7 @@ def __property_type__(cls) -> type: @classmethod def __parse_property_value__(cls, prop: ParsedProperty) -> datetime.datetime: """Parse a rfc5545 into a datetime.datetime.""" - if not (match := DATETIME_REGEX.fullmatch(prop.value)): - raise ValueError(f"Expected value to match DATE-TIME pattern: {prop.value}") - - # Example: TZID=America/New_York:19980119T020000 - timezone: datetime.tzinfo | None = None - if param := prop.get_parameter(TZID): - if param.values and (value := param.values[0]): - if isinstance(value, datetime.tzinfo): - timezone = value - else: - try: - timezone = zoneinfo.ZoneInfo(value) - except zoneinfo.ZoneInfoNotFoundError: - raise ValueError( - f"Expected DATE-TIME TZID value '{value}' to be valid timezone" - ) - elif match.group(3): # Example: 19980119T070000Z - timezone = datetime.timezone.utc - - # Example: 19980118T230000 - date_value = match.group(1) - year = int(date_value[0:4]) - month = int(date_value[4:6]) - day = int(date_value[6:]) - time_value = match.group(2) - hour = int(time_value[0:2]) - minute = int(time_value[2:4]) - second = int(time_value[4:6]) - - result = datetime.datetime( - year, month, day, hour, minute, second, tzinfo=timezone - ) - _LOGGER.debug("DateTimeEncoder returned %s", result) - return result + return parse_property_value(prop, allow_invalid_timezone=False) @classmethod def __encode_property_json__(cls, value: datetime.datetime) -> str | dict[str, str]: diff --git a/tests/__snapshots__/test_calendar_stream.ambr b/tests/__snapshots__/test_calendar_stream.ambr index dc8c1dc..85704ef 100644 --- a/tests/__snapshots__/test_calendar_stream.ambr +++ b/tests/__snapshots__/test_calendar_stream.ambr @@ -1088,6 +1088,41 @@ ]), }) # --- +# name: test_parse[todo-invalid-dtstart-tzid] + dict({ + 'calendars': list([ + dict({ + 'prodid': '-//hacksw/handcal//NONSGML v1.0//EN', + 'todos': list([ + dict({ + 'categories': list([ + 'FAMILY', + 'FINANCE', + ]), + 'classification': 'CONFIDENTIAL', + 'dtstamp': '2007-03-13T12:34:32+00:00', + 'dtstart': '2007-05-01T11:00:00+00:00', + 'due': '2007-05-01T11:00:00-07:00', + 'status': 'NEEDS-ACTION', + 'summary': 'Submit Quebec Income Tax Return for 2006', + 'uid': '20070313T123432Z-456553@example.com', + }), + dict({ + 'completed': '2007-07-07T10:00:00+00:00', + 'dtstamp': '2007-05-14T10:32:11+00:00', + 'dtstart': '2007-05-14T11:00:00+00:00', + 'due': '2007-07-09T13:00:00+00:00', + 'priority': 1, + 'status': 'NEEDS-ACTION', + 'summary': 'Submit Revised Internet-Draft', + 'uid': '20070514T103211Z-123404@example.com', + }), + ]), + 'version': '2.0', + }), + ]), + }) +# --- # name: test_parse[todo] dict({ 'calendars': list([ @@ -1799,6 +1834,35 @@ END:VCALENDAR ''' # --- +# name: test_serialize[todo-invalid-dtstart-tzid] + ''' + BEGIN:VCALENDAR + PRODID:-//hacksw/handcal//NONSGML v1.0//EN + VERSION:2.0 + BEGIN:VTODO + DTSTAMP:20070313T123432Z + UID:20070313T123432Z-456553@example.com + CATEGORIES:FAMILY + CATEGORIES:FINANCE + CLASS:CONFIDENTIAL + DTSTART:20070501T110000Z + DUE;TZID=America/Los_Angeles:20070501T110000 + STATUS:NEEDS-ACTION + SUMMARY:Submit Quebec Income Tax Return for 2006 + END:VTODO + BEGIN:VTODO + DTSTAMP:20070514T103211Z + UID:20070514T103211Z-123404@example.com + COMPLETED:20070707T100000Z + DTSTART:20070514T110000Z + DUE:20070709T130000Z + PRIORITY:1 + STATUS:NEEDS-ACTION + SUMMARY:Submit Revised Internet-Draft + END:VTODO + END:VCALENDAR + ''' +# --- # name: test_serialize[todo] ''' BEGIN:VCALENDAR diff --git a/tests/testdata/todo-invalid-dtstart-tzid.ics b/tests/testdata/todo-invalid-dtstart-tzid.ics new file mode 100644 index 0000000..a0973ee --- /dev/null +++ b/tests/testdata/todo-invalid-dtstart-tzid.ics @@ -0,0 +1,24 @@ +BEGIN:VCALENDAR +PRODID:-//hacksw/handcal//NONSGML v1.0//EN +VERSION:2.0 +BEGIN:VTODO +UID:20070313T123432Z-456553@example.com +DTSTAMP:20070313T123432Z +SUMMARY:Submit Quebec Income Tax Return for 2006 +DTSTART;TZID=CST:20070501T110000 +DUE;TZID=America/Los_Angeles:20070501T110000 +CLASS:CONFIDENTIAL +CATEGORIES:FAMILY,FINANCE +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:20070514T103211Z-123404@example.com +DTSTAMP:20070514T103211Z +SUMMARY:Submit Revised Internet-Draft +DTSTART;TZID=CST:20070514T110000 +DUE:20070709T130000Z +COMPLETED:20070707T100000Z +PRIORITY:1 +STATUS:NEEDS-ACTION +END:VTODO +END:VCALENDAR