Jellyfin(8096), OrbStack(8097) 포트 충돌으로 변경. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
369 lines
14 KiB
Python
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"]
|