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

Fix for dtstart persisted with an invalid timezone #311

Merged
merged 2 commits into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion ical/todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -37,6 +37,7 @@
RequestStatus,
Uri,
RelatedTo,
date_time,
)
from .util import dtstamp_factory, normalize_datetime, uid_factory, local_timezone

Expand Down Expand Up @@ -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
75 changes: 41 additions & 34 deletions ical/types/date_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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]:
Expand Down
64 changes: 64 additions & 0 deletions tests/__snapshots__/test_calendar_stream.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions tests/testdata/todo-invalid-dtstart-tzid.ics
Original file line number Diff line number Diff line change
@@ -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