ical.calendar_stream

The core, a collection of Calendar and Scheduling objects.

This is an example of parsing an ics file as a stream of calendar objects:

from pathlib import Path
from ical.calendar_stream import IcsCalendarStream

filename = Path("example/calendar.ics")
with filename.open() as ics_file:
    stream = IcsCalendarStream.from_ics(ics_file.read())
    print("File contains %s calendar(s)", len(stream.calendars))

You can encode a calendar stream as ics content calling the ics() method on the IcsCalendarStream:

from pathlib import Path

filename = Path("/tmp/output.ics")
with filename.open(mode="w") as ics_file:
    ics_file.write(stream.ics())

Fetching a remote ics file without blocking the event loop requires the optional ical[async] extra (which installs aiohttp):

from ical.calendar_stream import IcsCalendarStream

stream = await IcsCalendarStream.from_url("https://example.com/calendar.ics")
  1"""The core, a collection of Calendar and Scheduling objects.
  2
  3This is an example of parsing an ics file as a stream of calendar objects:
  4```python
  5from pathlib import Path
  6from ical.calendar_stream import IcsCalendarStream
  7
  8filename = Path("example/calendar.ics")
  9with filename.open() as ics_file:
 10    stream = IcsCalendarStream.from_ics(ics_file.read())
 11    print("File contains %s calendar(s)", len(stream.calendars))
 12```
 13
 14You can encode a calendar stream as ics content calling the `ics()` method on
 15the `IcsCalendarStream`:
 16
 17```python
 18from pathlib import Path
 19
 20filename = Path("/tmp/output.ics")
 21with filename.open(mode="w") as ics_file:
 22    ics_file.write(stream.ics())
 23```
 24
 25Fetching a remote ics file without blocking the event loop requires the
 26optional `ical[async]` extra (which installs `aiohttp`):
 27
 28```python
 29from ical.calendar_stream import IcsCalendarStream
 30
 31stream = await IcsCalendarStream.from_url("https://example.com/calendar.ics")
 32```
 33
 34"""
 35
 36# ty: allow-any-generics
 37
 38from __future__ import annotations
 39
 40import logging
 41from typing import TYPE_CHECKING
 42
 43from pydantic import Field, field_serializer
 44
 45from .calendar import Calendar
 46from .component import ComponentModel
 47from .parsing.component import encode_content, parse_content
 48from .types.data_types import serialize_field
 49from .exceptions import CalendarFetchError, CalendarParseError
 50from pydantic import ConfigDict
 51
 52if TYPE_CHECKING:
 53    import aiohttp
 54
 55_LOGGER = logging.getLogger(__name__)
 56
 57__all__ = ["CalendarStream", "IcsCalendarStream"]
 58
 59_ASYNC_EXTRA_ERROR = (
 60    "The aiohttp library is required for async URL fetching. Install it "
 61    "with the optional extra: `pip install ical[async]`"
 62)
 63
 64
 65class CalendarStream(ComponentModel):
 66    """A container that is a collection of calendaring information.
 67
 68    This object supports parsing an rfc5545 calendar file, but does not
 69    support encoding. See `IcsCalendarStream` instead for encoding ics files.
 70    """
 71
 72    calendars: list[Calendar] = Field(alias="vcalendar", default_factory=list)
 73
 74    @classmethod
 75    def from_ics(cls, content: str) -> "CalendarStream":
 76        """Factory method to create a new instance from an rfc5545 iCalendar content."""
 77        components = parse_content(content)
 78        result: dict[str, list] = {"vcalendar": []}
 79        for component in components:
 80            result.setdefault(component.name, [])
 81            result[component.name].append(component.as_dict())
 82        _LOGGER.debug("Parsing object %s", result)
 83        return cls(**result)
 84
 85    @classmethod
 86    async def from_url(
 87        cls, url: str, session: "aiohttp.ClientSession | None" = None
 88    ) -> "CalendarStream":
 89        """Async factory method to fetch and parse an rfc5545 iCalendar url.
 90
 91        This avoids blocking the event loop while fetching a remote ics file
 92        (e.g. a calendar subscription url), which is useful for applications
 93        such as Home Assistant that are built on asyncio. Parsing the fetched
 94        content happens synchronously since it is fast and CPU-bound.
 95
 96        This requires the optional `aiohttp` dependency, installable with the
 97        `ical[async]` extra. An existing `aiohttp.ClientSession` may be
 98        passed in to reuse connection pooling; otherwise a session is created
 99        and closed automatically for this single request.
100        """
101        try:
102            import aiohttp  # noqa: PLC0415
103        except ImportError as err:
104            raise ImportError(_ASYNC_EXTRA_ERROR) from err
105
106        async def _fetch(active_session: aiohttp.ClientSession) -> str:
107            try:
108                async with active_session.get(url) as response:
109                    response.raise_for_status()
110                    return await response.text()
111            except aiohttp.ClientError as err:
112                raise CalendarFetchError(
113                    f"Failed to fetch calendar from url {url!r}: {err}"
114                ) from err
115
116        if session is not None:
117            content = await _fetch(session)
118        else:
119            async with aiohttp.ClientSession() as owned_session:
120                content = await _fetch(owned_session)
121        return cls.from_ics(content)
122
123    def ics(self) -> str:
124        """Encode the calendar stream as an rfc5545 iCalendar Stream content."""
125        return encode_content(self.__encode_component_root__().components)
126
127
128class IcsCalendarStream(CalendarStream):
129    """A calendar stream that supports parsing and encoding ICS."""
130
131    @classmethod
132    def calendar_from_ics(cls, content: str) -> Calendar:
133        """Load a single calendar from an ics string."""
134        stream = cls.from_ics(content)
135        return cls._single_calendar(stream)
136
137    @classmethod
138    async def calendar_from_url(
139        cls, url: str, session: "aiohttp.ClientSession | None" = None
140    ) -> Calendar:
141        """Async convenience method to fetch and load a single calendar from a url.
142
143        See `from_url` for details on the optional `ical[async]` dependency
144        and the `session` argument.
145        """
146        stream = await cls.from_url(url, session=session)
147        return cls._single_calendar(stream)
148
149    @staticmethod
150    def _single_calendar(stream: "CalendarStream") -> Calendar:
151        """Return the single calendar in the stream, or raise an error."""
152        if len(stream.calendars) == 1:
153            return stream.calendars[0]
154        if len(stream.calendars) == 0:
155            return Calendar()
156        raise CalendarParseError("Calendar Stream had more than one calendar")
157
158    @classmethod
159    def calendar_to_ics(cls, calendar: Calendar) -> str:
160        """Serialize a calendar as an ICS stream."""
161        stream = cls(vcalendar=[calendar])
162        return stream.ics()
163
164    model_config = ConfigDict(
165        validate_assignment=True,
166        populate_by_name=True,
167    )
168    serialize_fields = field_serializer("*")(serialize_field)  # type: ignore[pydantic-field]
class CalendarStream(ical.component.ComponentModel):
 66class CalendarStream(ComponentModel):
 67    """A container that is a collection of calendaring information.
 68
 69    This object supports parsing an rfc5545 calendar file, but does not
 70    support encoding. See `IcsCalendarStream` instead for encoding ics files.
 71    """
 72
 73    calendars: list[Calendar] = Field(alias="vcalendar", default_factory=list)
 74
 75    @classmethod
 76    def from_ics(cls, content: str) -> "CalendarStream":
 77        """Factory method to create a new instance from an rfc5545 iCalendar content."""
 78        components = parse_content(content)
 79        result: dict[str, list] = {"vcalendar": []}
 80        for component in components:
 81            result.setdefault(component.name, [])
 82            result[component.name].append(component.as_dict())
 83        _LOGGER.debug("Parsing object %s", result)
 84        return cls(**result)
 85
 86    @classmethod
 87    async def from_url(
 88        cls, url: str, session: "aiohttp.ClientSession | None" = None
 89    ) -> "CalendarStream":
 90        """Async factory method to fetch and parse an rfc5545 iCalendar url.
 91
 92        This avoids blocking the event loop while fetching a remote ics file
 93        (e.g. a calendar subscription url), which is useful for applications
 94        such as Home Assistant that are built on asyncio. Parsing the fetched
 95        content happens synchronously since it is fast and CPU-bound.
 96
 97        This requires the optional `aiohttp` dependency, installable with the
 98        `ical[async]` extra. An existing `aiohttp.ClientSession` may be
 99        passed in to reuse connection pooling; otherwise a session is created
100        and closed automatically for this single request.
101        """
102        try:
103            import aiohttp  # noqa: PLC0415
104        except ImportError as err:
105            raise ImportError(_ASYNC_EXTRA_ERROR) from err
106
107        async def _fetch(active_session: aiohttp.ClientSession) -> str:
108            try:
109                async with active_session.get(url) as response:
110                    response.raise_for_status()
111                    return await response.text()
112            except aiohttp.ClientError as err:
113                raise CalendarFetchError(
114                    f"Failed to fetch calendar from url {url!r}: {err}"
115                ) from err
116
117        if session is not None:
118            content = await _fetch(session)
119        else:
120            async with aiohttp.ClientSession() as owned_session:
121                content = await _fetch(owned_session)
122        return cls.from_ics(content)
123
124    def ics(self) -> str:
125        """Encode the calendar stream as an rfc5545 iCalendar Stream content."""
126        return encode_content(self.__encode_component_root__().components)

A container that is a collection of calendaring information.

This object supports parsing an rfc5545 calendar file, but does not support encoding. See IcsCalendarStream instead for encoding ics files.

calendars: list[ical.calendar.Calendar] = PydanticUndefined
@classmethod
def from_ics(cls, content: str) -> CalendarStream:
75    @classmethod
76    def from_ics(cls, content: str) -> "CalendarStream":
77        """Factory method to create a new instance from an rfc5545 iCalendar content."""
78        components = parse_content(content)
79        result: dict[str, list] = {"vcalendar": []}
80        for component in components:
81            result.setdefault(component.name, [])
82            result[component.name].append(component.as_dict())
83        _LOGGER.debug("Parsing object %s", result)
84        return cls(**result)

Factory method to create a new instance from an rfc5545 iCalendar content.

@classmethod
async def from_url( cls, url: str, session: aiohttp.client.ClientSession | None = None) -> CalendarStream:
 86    @classmethod
 87    async def from_url(
 88        cls, url: str, session: "aiohttp.ClientSession | None" = None
 89    ) -> "CalendarStream":
 90        """Async factory method to fetch and parse an rfc5545 iCalendar url.
 91
 92        This avoids blocking the event loop while fetching a remote ics file
 93        (e.g. a calendar subscription url), which is useful for applications
 94        such as Home Assistant that are built on asyncio. Parsing the fetched
 95        content happens synchronously since it is fast and CPU-bound.
 96
 97        This requires the optional `aiohttp` dependency, installable with the
 98        `ical[async]` extra. An existing `aiohttp.ClientSession` may be
 99        passed in to reuse connection pooling; otherwise a session is created
100        and closed automatically for this single request.
101        """
102        try:
103            import aiohttp  # noqa: PLC0415
104        except ImportError as err:
105            raise ImportError(_ASYNC_EXTRA_ERROR) from err
106
107        async def _fetch(active_session: aiohttp.ClientSession) -> str:
108            try:
109                async with active_session.get(url) as response:
110                    response.raise_for_status()
111                    return await response.text()
112            except aiohttp.ClientError as err:
113                raise CalendarFetchError(
114                    f"Failed to fetch calendar from url {url!r}: {err}"
115                ) from err
116
117        if session is not None:
118            content = await _fetch(session)
119        else:
120            async with aiohttp.ClientSession() as owned_session:
121                content = await _fetch(owned_session)
122        return cls.from_ics(content)

Async factory method to fetch and parse an rfc5545 iCalendar url.

This avoids blocking the event loop while fetching a remote ics file (e.g. a calendar subscription url), which is useful for applications such as Home Assistant that are built on asyncio. Parsing the fetched content happens synchronously since it is fast and CPU-bound.

This requires the optional aiohttp dependency, installable with the ical[async] extra. An existing aiohttp.ClientSession may be passed in to reuse connection pooling; otherwise a session is created and closed automatically for this single request.

def ics(self) -> str:
124    def ics(self) -> str:
125        """Encode the calendar stream as an rfc5545 iCalendar Stream content."""
126        return encode_content(self.__encode_component_root__().components)

Encode the calendar stream as an rfc5545 iCalendar Stream content.

class IcsCalendarStream(CalendarStream):
129class IcsCalendarStream(CalendarStream):
130    """A calendar stream that supports parsing and encoding ICS."""
131
132    @classmethod
133    def calendar_from_ics(cls, content: str) -> Calendar:
134        """Load a single calendar from an ics string."""
135        stream = cls.from_ics(content)
136        return cls._single_calendar(stream)
137
138    @classmethod
139    async def calendar_from_url(
140        cls, url: str, session: "aiohttp.ClientSession | None" = None
141    ) -> Calendar:
142        """Async convenience method to fetch and load a single calendar from a url.
143
144        See `from_url` for details on the optional `ical[async]` dependency
145        and the `session` argument.
146        """
147        stream = await cls.from_url(url, session=session)
148        return cls._single_calendar(stream)
149
150    @staticmethod
151    def _single_calendar(stream: "CalendarStream") -> Calendar:
152        """Return the single calendar in the stream, or raise an error."""
153        if len(stream.calendars) == 1:
154            return stream.calendars[0]
155        if len(stream.calendars) == 0:
156            return Calendar()
157        raise CalendarParseError("Calendar Stream had more than one calendar")
158
159    @classmethod
160    def calendar_to_ics(cls, calendar: Calendar) -> str:
161        """Serialize a calendar as an ICS stream."""
162        stream = cls(vcalendar=[calendar])
163        return stream.ics()
164
165    model_config = ConfigDict(
166        validate_assignment=True,
167        populate_by_name=True,
168    )
169    serialize_fields = field_serializer("*")(serialize_field)  # type: ignore[pydantic-field]

A calendar stream that supports parsing and encoding ICS.

@classmethod
def calendar_from_ics(cls, content: str) -> ical.calendar.Calendar:
132    @classmethod
133    def calendar_from_ics(cls, content: str) -> Calendar:
134        """Load a single calendar from an ics string."""
135        stream = cls.from_ics(content)
136        return cls._single_calendar(stream)

Load a single calendar from an ics string.

@classmethod
async def calendar_from_url( cls, url: str, session: aiohttp.client.ClientSession | None = None) -> ical.calendar.Calendar:
138    @classmethod
139    async def calendar_from_url(
140        cls, url: str, session: "aiohttp.ClientSession | None" = None
141    ) -> Calendar:
142        """Async convenience method to fetch and load a single calendar from a url.
143
144        See `from_url` for details on the optional `ical[async]` dependency
145        and the `session` argument.
146        """
147        stream = await cls.from_url(url, session=session)
148        return cls._single_calendar(stream)

Async convenience method to fetch and load a single calendar from a url.

See from_url for details on the optional ical[async] dependency and the session argument.

@classmethod
def calendar_to_ics(cls, calendar: ical.calendar.Calendar) -> str:
159    @classmethod
160    def calendar_to_ics(cls, calendar: Calendar) -> str:
161        """Serialize a calendar as an ICS stream."""
162        stream = cls(vcalendar=[calendar])
163        return stream.ics()

Serialize a calendar as an ICS stream.

def serialize_fields( self: pydantic.main.BaseModel, value: Any, info: pydantic_core.core_schema.SerializationInfo) -> Any:
293def serialize_field(self: BaseModel, value: Any, info: SerializationInfo) -> Any:
294    if not info.context or not info.context.get("ics"):
295        return value
296    if isinstance(value, list):
297        res = []
298        for val in value:
299            for base in val.__class__.__mro__[:-1]:
300                if (func := DATA_TYPE.encode_property_json.get(base)) is not None:
301                    res.append(func(val))
302                    break
303            else:
304                res.append(val)
305        return res
306
307    for base in value.__class__.__mro__[:-1]:
308        if (func := DATA_TYPE.encode_property_json.get(base)) is not None:
309            return func(value)
310    return value