Files
syn-chat-bot/.venv/lib/python3.9/site-packages/recurring_ical_events/query.py
Hyungi Ahn c2257d3a86 fix: 포트 충돌 회피 — note_bridge 8098, intent_service 8099
Jellyfin(8096), OrbStack(8097) 포트 충돌으로 변경.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:53:55 +09:00

369 lines
14 KiB
Python

"""Core functionality: querying the calendar for occurrences."""
from __future__ import annotations
import contextlib
import datetime
import itertools
import sys
from typing import TYPE_CHECKING, ClassVar, Generator, Optional, Sequence
try:
from typing import TypeAlias
except ImportError:
from typing_extensions import TypeAlias
import icalendar
from recurring_ical_events.adapters.component import ComponentAdapter
from recurring_ical_events.constants import DATE_MAX_DT, DATE_MIN_DT
from recurring_ical_events.errors import (
BadRuleStringFormat,
InvalidCalendar,
PeriodEndBeforeStart,
)
from recurring_ical_events.occurrence import OccurrenceID
from recurring_ical_events.pages import Pages
from recurring_ical_events.selection.base import SelectComponents
from recurring_ical_events.util import compare_greater
if TYPE_CHECKING:
from icalendar import Component
from recurring_ical_events.occurrence import Occurrence
from recurring_ical_events.series import Series
from recurring_ical_events.types import (
DateArgument,
Time,
)
if sys.version_info >= (3, 10):
T_COMPONENTS: TypeAlias = Sequence[str | type[ComponentAdapter] | SelectComponents]
else:
# see https://github.com/python/cpython/issues/86399#issuecomment-1093889925
T_COMPONENTS: TypeAlias = Sequence[str]
class CalendarQuery:
"""Query a calendar for occurrences.
Functions like :meth:`at`, :meth:`between` andm :meth:`after`
can be used to query the selected components.
If any malformed icalendar information is found,
an :class:`InvalidCalendar` exception is raised.
For other bad arguments, you should expect a :class:`ValueError`.
Attributes:
suppressed_errors: a list of errors to suppress when
skip_bad_series is True
"""
suppressed_errors: ClassVar[type[Exception]] = [
BadRuleStringFormat,
PeriodEndBeforeStart,
icalendar.InvalidCalendar,
]
from recurring_ical_events.selection.name import ComponentsWithName
def __init__(
self,
calendar: Component,
keep_recurrence_attributes: bool = False, # noqa: FBT001
components: T_COMPONENTS = ("VEVENT",),
skip_bad_series: bool = False, # noqa: FBT001
):
"""Create an unfoldable calendar from a given calendar.
Arguments:
calendar: an :class:`icalendar.cal.Calendar` component like
:class:`icalendar.cal.Calendar`.
keep_recurrence_attributes: Whether to keep attributes that are only used
to calculate the recurrence (``RDATE``, ``EXDATE``, ``RRULE``).
components: A list of component type names of which the recurrences
should be returned. This can also be instances of
:class:`SelectComponents`.
Examples: ``("VEVENT", "VTODO", "VJOURNAL", "VALARM")``
skip_bad_series: Whether to skip series of components that contain
errors. You can use :attr:`CalendarQuery.suppressed_errors` to
specify which errors to skip.
"""
self.keep_recurrence_attributes = keep_recurrence_attributes
if calendar.get("CALSCALE", "GREGORIAN") != "GREGORIAN":
# https://www.kanzaki.com/docs/ical/calscale.html
raise InvalidCalendar("Only Gregorian calendars are supported.")
self.series: list[Series] = [] # component
self._skip_errors = tuple(self.suppressed_errors) if skip_bad_series else ()
for component_adapter_id in components:
if isinstance(component_adapter_id, str):
component_adapter = self.ComponentsWithName(component_adapter_id)
else:
component_adapter = component_adapter_id
self.series.extend(
component_adapter.collect_series_from(calendar, self._skip_errors)
)
@staticmethod
def to_datetime(date: DateArgument):
"""Convert date inputs of various sorts into a datetime object.
Arguments:
date: A date specification.
Date Specification:
- a year like ``(2019,)`` or ``2019`` (:class:`int`)
- a month like ``(2019, 1)`` for January of 2019
- a day like ``(2019, 1, 19)`` for the first of January 2019
- a day with hours, ``(2019, 1, 19, 1)``
- a day with minutes, ``(2019, 1, 19, 13, 30 )``
- a day with seconds, ``(2019, 1, 19, 13, 30, 59)``
- a :class:`datetime.datetime` or :class:`datetime.date`
- a :class:`str` in the format ``yyyymmdd``
- a :class:`str` in the format ``yyyymmddThhmmssZ``
"""
if isinstance(date, int):
date = (date,)
if isinstance(date, tuple):
date += (1,) * (3 - len(date))
return datetime.datetime(*date) # noqa: DTZ001
if isinstance(date, str):
# see https://docs.python.org/2/library/datetime.html#strftime-strptime-behavior
if len(date) == 8:
return datetime.datetime.strptime(date, "%Y%m%d") # noqa: DTZ007
return datetime.datetime.strptime(date, "%Y%m%dT%H%M%SZ") # noqa: DTZ007
return date
def all(self) -> Generator[Component]:
"""Generate all Components.
The Components are sorted from the first to the last Occurrence.
Calendars can contain millions of Occurrences. This iterates
safely across all of them.
"""
# MAX and MIN values may change in the future
return self.after(DATE_MIN_DT)
_DELTAS = [
datetime.timedelta(days=1),
datetime.timedelta(hours=1),
datetime.timedelta(minutes=1),
datetime.timedelta(seconds=1),
]
def at(self, date: DateArgument):
"""Return all events within the next 24 hours of starting at the given day.
Arguments:
date: A date specification, see :meth:`to_datetime`.
This is translated to :meth:`between` in the following way:
- A year returns all occurrences within that year.
Example: ``(2019,)``, ``2019``
- A month returns all occurrences within that month.
Example: ``(2019, 1)``
- A day returns all occurrences within that day.
Examples:
- ``(2019, 1, 19)``
- ``datetime.date(2019, 1, 19)``
- ``"20190101"``
- An hour returns all occurrences within that hour.
Example: ``(2019, 1, 19, 1)``
- A minute returns all occurrences within that minute.
Example: ``(2019, 1, 19, 13, 30 )``
- A second returns all occurrences at that exact second.
Examples:
- ``(2019, 1, 19, 13, 30, 59)``
- ``datetime.datetime(2019, 1, 19, 13, 30, 59)``
- ``datetime.datetime(2019, 1, 19, tzinfo=datetime.timezone.utc)``,
- ``datetime.datetime(2019, 1, 19, 13, 30, 59, tzinfo=ZoneInfo('Europe/London'))``
- ``"20190119T133059Z"``
""" # noqa: E501
if isinstance(date, int):
date = (date,)
if isinstance(date, str):
if len(date) != 8 or not date.isdigit():
raise ValueError(f"Format yyyymmdd expected for {date!r}.")
date = (int(date[:4], 10), int(date[4:6], 10), int(date[6:]))
if isinstance(date, datetime.datetime):
return self.between(date, date)
if isinstance(date, datetime.date):
return self.between(date, date + datetime.timedelta(days=1))
if len(date) == 1:
return self.between((date[0], 1, 1), (date[0] + 1, 1, 1))
if len(date) == 2:
year, month = date
if month == 12:
return self.between((year, 12, 1), (year + 1, 1, 1))
return self.between((year, month, 1), (year, month + 1, 1))
dt = self.to_datetime(date)
return self._between(dt, dt + self._DELTAS[len(date) - 3])
def between(self, start: DateArgument, stop: DateArgument | datetime.timedelta):
"""Return events at a time between start (inclusive) and end (inclusive)
Arguments:
start: A date specification. See :meth:`to_datetime`.
stop: A date specification or a :class:`datetime.timedelta`
relative to start.
.. warning::
If you pass a :class:`datetime.datetime` to both ``start`` and
``stop``, make sure the :attr:`datetime.datetime.tzinfo` is
the same.
"""
start = self.to_datetime(start)
stop = (
start + stop
if isinstance(stop, datetime.timedelta)
else self.to_datetime(stop)
)
return self._between(start, stop)
def _occurrences_to_components(
self, occurrences: list[Occurrence]
) -> list[Component]:
"""Map occurrences to components."""
return [
occurrence.as_component(self.keep_recurrence_attributes)
for occurrence in occurrences
]
def _between(self, start: Time, end: Time) -> list[Component]:
"""Return the occurrences between the start and the end."""
return self._occurrences_to_components(self._occurrences_between(start, end))
def _occurrences_between(self, start: Time, end: Time) -> list[Occurrence]:
"""Return the components between the start and the end."""
occurrences: list[Occurrence] = []
for series in self.series:
with contextlib.suppress(self._skip_errors):
occurrences.extend(series.between(start, end))
return occurrences
def after(self, earliest_end: DateArgument) -> Generator[Component]:
"""Iterate over components happening during or after earliest_end.
Arguments:
earliest_end: A date specification. See :meth:`to_datetime`.
Anything happening during or after earliest_end is returned
in the order of start time.
"""
earliest_end = self.to_datetime(earliest_end)
for occurrence in self._after(earliest_end):
yield occurrence.as_component(self.keep_recurrence_attributes)
def _after(self, earliest_end: Time) -> Generator[Occurrence]:
"""Iterate over occurrences happening during or after earliest_end."""
time_span = datetime.timedelta(days=1)
min_time_span = datetime.timedelta(minutes=15)
done = False
result_ids: set[OccurrenceID] = set()
while not done:
try:
next_end = earliest_end + time_span
except OverflowError:
# We ran to the end
next_end = DATE_MAX_DT
if compare_greater(earliest_end, next_end):
return # we might run too far
done = True
occurrences = self._occurrences_between(earliest_end, next_end)
occurrences.sort()
for occurrence in occurrences:
if occurrence.id not in result_ids:
yield occurrence
result_ids.add(occurrence.id)
# prepare next query
time_span = max(
time_span / 2 if occurrences else time_span * 2,
min_time_span,
) # binary search to improve speed
earliest_end = next_end
def count(self) -> int:
"""Return the amount of recurring components in this calendar.
.. warning::
Do not use this in production as it generates all occurrences.
"""
i = 0
for _ in self.all():
i += 1
return i
@property
def first(self) -> Component:
"""Return the first recurring component in this calendar.
Returns:
The first recurring component in this calendar.
Raises:
IndexError: if the calendar is empty
"""
for component in self.all():
return component
raise IndexError("No components found.")
def paginate(
self,
page_size: int,
earliest_end: Optional[DateArgument] = None,
latest_start: Optional[DateArgument] = None,
next_page_id: str = "",
) -> Pages:
"""Return pages for pagination.
Args:
page_size: the number of components per page
earliest_end: the start of the first page
All components occur after this date.
See :meth:`to_datetime` for possible values.
latest_start: the end of the last page
All components occur before this date.
See :meth:`to_datetime` for possible values.
next_page_id: The id of the next page.
This is optional for the first page.
These are safe to pass outside of the application and back in.
"""
latest_start = None if latest_start is None else self.to_datetime(latest_start)
earliest_end = (
DATE_MIN_DT if earliest_end is None else self.to_datetime(earliest_end)
)
if next_page_id:
first_occurrence_id = OccurrenceID.from_string(next_page_id)
if not compare_greater(earliest_end, first_occurrence_id.start):
iterator = self._after(first_occurrence_id.start)
lost_occurrences = [] # in case we do not find the event
for occurrence in iterator:
lost_occurrences.append(occurrence)
oid = occurrence.id
if oid == first_occurrence_id:
iterator = itertools.chain([occurrence], iterator)
break
if compare_greater(oid.start, first_occurrence_id.start):
iterator = itertools.chain(lost_occurrences, iterator)
break
else:
iterator = self._after(earliest_end)
else:
iterator = self._after(earliest_end)
return Pages(
occurrence_iterator=iterator,
size=page_size,
stop=latest_start,
keep_recurrence_attributes=self.keep_recurrence_attributes,
)
__all__ = ["T_COMPONENTS", "CalendarQuery"]