Skip to content

Fix Office 365 calendars to be compatible with rfc5545 #144230

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

Merged
merged 2 commits into from
May 5, 2025
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
13 changes: 3 additions & 10 deletions homeassistant/components/remote_calendar/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@
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
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__)

Expand Down Expand Up @@ -64,15 +63,9 @@ async def async_step_user(
_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
Expand Down
12 changes: 3 additions & 9 deletions homeassistant/components/remote_calendar/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@

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
Expand All @@ -15,6 +13,7 @@
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DOMAIN
from .ics import InvalidIcsException, parse_calendar

type RemoteCalendarConfigEntry = ConfigEntry[RemoteCalendarDataUpdateCoordinator]

Expand Down Expand Up @@ -56,14 +55,9 @@ async def _async_update_data(self) -> 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",
Expand Down
44 changes: 44 additions & 0 deletions homeassistant/components/remote_calendar/ics.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions tests/components/remote_calendar/snapshots/test_calendar.ambr
Original file line number Diff line number Diff line change
@@ -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',
}),
])
# ---
30 changes: 30 additions & 0 deletions tests/components/remote_calendar/test_calendar.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,6 +23,13 @@

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(
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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