Fix Office 365 calendars to be compatible with rfc5545 (#144230)

pull/144107/head
Allen Porter 2025-05-05 04:13:08 -07:00 committed by GitHub
parent 445b38f25d
commit 3390dc0dbb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 157 additions and 19 deletions

View File

@ -5,8 +5,6 @@ import logging
from typing import Any
from httpx import HTTPError, InvalidURL
from ical.calendar_stream import IcsCalendarStream
from ical.exceptions import CalendarParseError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@ -14,6 +12,7 @@ from homeassistant.const import CONF_URL
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_CALENDAR_NAME, DOMAIN
from .ics import InvalidIcsException, parse_calendar
_LOGGER = logging.getLogger(__name__)
@ -64,15 +63,9 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("An error occurred: %s", err)
else:
try:
await self.hass.async_add_executor_job(
IcsCalendarStream.calendar_from_ics, res.text
)
except CalendarParseError as err:
await parse_calendar(self.hass, res.text)
except InvalidIcsException:
errors["base"] = "invalid_ics_file"
_LOGGER.error("Error reading the calendar information: %s", err.message)
_LOGGER.debug(
"Additional calendar error detail: %s", str(err.detailed_error)
)
else:
return self.async_create_entry(
title=user_input[CONF_CALENDAR_NAME], data=user_input

View File

@ -5,8 +5,6 @@ import logging
from httpx import HTTPError, InvalidURL
from ical.calendar import Calendar
from ical.calendar_stream import IcsCalendarStream
from ical.exceptions import CalendarParseError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
@ -15,6 +13,7 @@ from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
from .ics import InvalidIcsException, parse_calendar
type RemoteCalendarConfigEntry = ConfigEntry[RemoteCalendarDataUpdateCoordinator]
@ -56,14 +55,9 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]):
translation_placeholders={"err": str(err)},
) from err
try:
# calendar_from_ics will dynamically load packages
# the first time it is called, so we need to do it
# in a separate thread to avoid blocking the event loop
self.ics = res.text
return await self.hass.async_add_executor_job(
IcsCalendarStream.calendar_from_ics, self.ics
)
except CalendarParseError as err:
return await parse_calendar(self.hass, res.text)
except InvalidIcsException as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="unable_to_parse",

View File

@ -0,0 +1,44 @@
"""Module for parsing ICS content.
This module exists to fix known issues where calendar providers return calendars
that do not follow rfcc5545. This module will attempt to fix the calendar and return
a valid calendar object.
"""
import logging
from ical.calendar import Calendar
from ical.calendar_stream import IcsCalendarStream
from ical.compat import enable_compat_mode
from ical.exceptions import CalendarParseError
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
class InvalidIcsException(Exception):
"""Exception to indicate that the ICS content is invalid."""
def _compat_calendar_from_ics(ics: str) -> Calendar:
"""Parse the ICS content and return a Calendar object.
This function is called in a separate thread to avoid blocking the event
loop while loading packages or parsing the ICS content for large calendars.
It uses the `enable_compat_mode` context manager to fix known issues with
calendar providers that return invalid calendars.
"""
with enable_compat_mode(ics) as compat_ics:
return IcsCalendarStream.calendar_from_ics(compat_ics)
async def parse_calendar(hass: HomeAssistant, ics: str) -> Calendar:
"""Parse the ICS content and return a Calendar object."""
try:
return await hass.async_add_executor_job(_compat_calendar_from_ics, ics)
except CalendarParseError as err:
_LOGGER.error("Error parsing calendar information: %s", err.message)
_LOGGER.debug("Additional calendar error detail: %s", str(err.detailed_error))
raise InvalidIcsException(err.message) from err

View File

@ -0,0 +1,19 @@
# serializer version: 1
# name: test_calendar_examples[office365_invalid_tzid]
list([
dict({
'description': None,
'end': dict({
'dateTime': '2024-04-26T15:00:00-06:00',
}),
'location': '',
'recurrence_id': None,
'rrule': None,
'start': dict({
'dateTime': '2024-04-26T14:00:00-06:00',
}),
'summary': 'Uffe',
'uid': '040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000010000000309AE93C8C3A94489F90ADBEA30C2F2B',
}),
])
# ---

View File

@ -1,11 +1,13 @@
"""Tests for calendar platform of Remote Calendar."""
from datetime import datetime
import pathlib
import textwrap
from httpx import Response
import pytest
import respx
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
@ -21,6 +23,13 @@ from .conftest import (
from tests.common import MockConfigEntry
# Test data files with known calendars from various sources. You can add a new file
# in the testdata directory and add it will be parsed and tested.
TESTDATA_FILES = sorted(
pathlib.Path("tests/components/remote_calendar/testdata/").glob("*.ics")
)
TESTDATA_IDS = [f.stem for f in TESTDATA_FILES]
@respx.mock
async def test_empty_calendar(
@ -392,3 +401,24 @@ async def test_all_day_iter_order(
events = await get_events("2022-10-06T00:00:00Z", "2022-10-09T00:00:00Z")
assert [event["summary"] for event in events] == event_order
@respx.mock
@pytest.mark.parametrize("ics_filename", TESTDATA_FILES, ids=TESTDATA_IDS)
async def test_calendar_examples(
hass: HomeAssistant,
config_entry: MockConfigEntry,
get_events: GetEventsFn,
ics_filename: pathlib.Path,
snapshot: SnapshotAssertion,
) -> None:
"""Test parsing known calendars form test data files."""
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=ics_filename.read_text(),
)
)
await setup_integration(hass, config_entry)
events = await get_events("1997-07-14T00:00:00", "2025-07-01T00:00:00")
assert events == snapshot

View File

@ -0,0 +1,58 @@
BEGIN:VCALENDAR
METHOD:PUBLISH
PRODID:Microsoft Exchange Server 2010
VERSION:2.0
X-WR-CALNAME:Kalender
BEGIN:VTIMEZONE
TZID:W. Europe Standard Time
BEGIN:STANDARD
DTSTART:16010101T030000
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T020000
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VTIMEZONE
TZID:UTC
BEGIN:STANDARD
DTSTART:16010101T000000
TZOFFSETFROM:+0000
TZOFFSETTO:+0000
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:16010101T000000
TZOFFSETFROM:+0000
TZOFFSETTO:+0000
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
UID:040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000
010000000309AE93C8C3A94489F90ADBEA30C2F2B
SUMMARY:Uffe
DTSTART;TZID=Customized Time Zone:20240426T140000
DTEND;TZID=Customized Time Zone:20240426T150000
CLASS:PUBLIC
PRIORITY:5
DTSTAMP:20250417T155647Z
TRANSP:OPAQUE
STATUS:CONFIRMED
SEQUENCE:0
LOCATION:
X-MICROSOFT-CDO-APPT-SEQUENCE:0
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
X-MICROSOFT-CDO-IMPORTANCE:1
X-MICROSOFT-CDO-INSTTYPE:0
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
X-MICROSOFT-DISALLOW-COUNTER:FALSE
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
END:VEVENT
END:VCALENDAR