add types
2 files changed, 85 insertions(+), 56 deletions(-)
changed files
M mycal.py → mycal.py
@@ -1,25 +1,27 @@ +import asyncio +import logging +import traceback +import urllib.parse +import zoneinfo +from asyncio import Condition +from dataclasses import dataclass, field +from datetime import date, datetime, time, timedelta +from os import environ +from typing import Callable from uuid import uuid4 -from icalendar.cal import Calendar, FreeBusy -import icalendar -import urllib.parse import aiohttp -import asyncio +import icalendar +from danoan.toml_dataclass import TomlDataClassIO +from icalendar.cal import Calendar, FreeBusy from quart import Quart -from datetime import date, datetime, timedelta, time -import zoneinfo -from dataclasses import dataclass, field -from danoan.toml_dataclass import TomlDataClassIO -from os import environ -import typing -import traceback -import logging -from asyncio import Condition + +type PeriodKey = tuple[datetime, datetime] @dataclass class CalendarConfig(TomlDataClassIO): - file: typing.Optional[str] = None - url: typing.Optional[str] = None + file: str | None = None + url: str | None = None all_tentative: bool = False @dataclass@@ -28,60 +30,68 @@ name: str email: str timezone: str update_interval: int = 3600 - calendars: typing.List[CalendarConfig] = field(default_factory=list) + calendars: list[CalendarConfig] = field(default_factory=list) -def load_config(file_path): +def load_config(file_path: str): with open(file_path, "r") as fr: - return Config.read(fr) + c = Config.read(fr) + if c is None: + raise ValueError("Failed to load configuration") + return c app = Quart(__name__) config = load_config(environ.get("CONFIG_FILE", "config.toml")) + tz = zoneinfo.ZoneInfo(config.timezone) utc = zoneinfo.ZoneInfo("UTC") -calendar_cache = { - 'last_updated': None, - 'busy_ical': "", - 'events_ical': "" -} +@dataclass +class CalendarCache: + condition: Condition = field(default_factory=Condition) + last_updated: datetime = field(default=datetime.fromtimestamp(0)) + store: dict[str, bytes] = field(default_factory=dict) + def __setitem__(self, key: str, value: bytes): + self.store[key] = value + + def __getitem__(self, key: str): + return self.store[key] + +calendar_cache = CalendarCache() next_update_seconds = 0 -cache_condition = Condition() -async def fetch_calendar_async(calendar_url): +async def fetch_calendar_async(calendar_url: str): async with aiohttp.ClientSession() as session: async with session.get(calendar_url) as response: return await response.read() -def read_calendar_file(calendar_file): +def read_calendar_file(calendar_file: str): with open(calendar_file, 'rb') as f: return f.read() -async def get_calendar_async(calendar_config): - if 'file' in calendar_config: - return read_calendar_file(calendar_config['file']) - elif 'url' in calendar_config: - u = urllib.parse.urlsplit(calendar_config['url']) +async def get_calendar_async(calendar_config: CalendarConfig): + if calendar_config.file is not None: + return read_calendar_file(calendar_config.file) + elif calendar_config.url is not None: + u = urllib.parse.urlsplit(calendar_config.url) if u.scheme == "webcal": u = u._replace(scheme="https") return await fetch_calendar_async(urllib.parse.urlunsplit(u)) else: raise ValueError("Calendar URL or file not configured.") -def fixup_date(dt): - if type(dt) == date: - return datetime.combine(dt, time.min, tzinfo=tz) - elif dt.tzinfo is None: +def fixup_date(dt: datetime) -> datetime: + if dt.tzinfo is None: return dt.replace(tzinfo=tz) else: return dt.astimezone(tz) -def looks_tentative(component): +def looks_tentative(component: icalendar.Component) -> bool: summary = str(component.get('summary', '')) return component.get('status') == 'TENTATIVE' or 'TBD' in summary or summary.endswith("?") -def make_clean_event(domain, component): +def make_clean_event(domain: str, component: icalendar.Component) -> icalendar.Event: new_event = icalendar.cal.Event() new_event.add('SUMMARY', f"{config.name} Busy")@@ -94,9 +104,14 @@ new_event.add('DTSTAMP', datetime.now(tz=utc)) return new_event -def make_busy_period(component, dtstart, dtend, is_tentative_fn): - vp = icalendar.prop.vPeriod([t.astimezone(utc) for t in [dtstart, dtend]]) - vp.params.pop('TZID') # remove timezone information +def make_busy_period( + component: icalendar.Component, + dtstart: datetime, + dtend: datetime, + is_tentative_fn: Callable[[icalendar.Component], bool] +) -> icalendar.prop.vPeriod: + vp = icalendar.prop.vPeriod((dtstart.astimezone(utc), dtend.astimezone(utc))) + del vp.params['TZID'] # remove timezone information if is_tentative_fn(component): vp.FBTYPE = icalendar.enums.FBTYPE.BUSY_TENTATIVE@@ -105,7 +120,7 @@ vp.FBTYPE = icalendar.enums.FBTYPE.BUSY_UNAVAILABLE return vp -def process_calendar_events(calendar, start_date, end_date): +def process_calendar_events(calendar: icalendar.Component, start_date: date, end_date: date) -> dict[PeriodKey, icalendar.Component]: """ Process events from a calendar and return both event components and busy periods.@@ -120,14 +135,14 @@ Returns: Dictionary of vPeriod objects with FBTYPE set to BUSY_TENTATIVE or BUSY_UNAVAILABLE """ - events = {} + events: dict[PeriodKey, icalendar.Component] = {} for component in calendar.walk(): if component.name == "VEVENT": dtstart = fixup_date(component.get('dtstart').dt) dtend = fixup_date(component.get('dtend', component.get('dtstart')).dt) if dtstart >= start_date and dtend <= end_date: - period_key = (dtstart, dtend) + period_key: PeriodKey = (dtstart, dtend) events[period_key] = component return events@@ -139,6 +154,9 @@ c.add('version', '2.0') return c +def always_tentative(_: icalendar.Component) -> bool: + return True + async def update_calendar_cache(): """ Update the global calendar cache with fresh data@@ -153,18 +171,18 @@ start_of_week = today - timedelta(days=today.weekday()) start_date = datetime.combine(start_of_week, time.min, tzinfo=tz) end_date = start_date + timedelta(days=30) - busy_periods = {} - events = {} + busy_periods: dict[PeriodKey, icalendar.vPeriod] = {} + events: dict[PeriodKey, icalendar.Event] = {} for calendar_config in config.calendars: try: - if 'all_tentative' in calendar_config and calendar_config['all_tentative']: - tentative_fn = lambda component: True + if calendar_config.all_tentative: + tentative_fn = always_tentative else: tentative_fn = looks_tentative calendar_data = await get_calendar_async(calendar_config) - input_calendar = Calendar.from_ical(calendar_data) + input_calendar = Calendar.from_ical(str(calendar_data)) calendar_events = process_calendar_events(input_calendar, start_date, end_date)@@ -201,20 +219,20 @@ for event_component in events.values(): events_calendar.add_component(event_component) # Update the global cache - async with cache_condition: - calendar_cache['last_updated'] = datetime.now(tz=utc) + async with calendar_cache.condition: + calendar_cache.last_updated = datetime.now(tz=utc) calendar_cache['busy_ical'] = busy_calendar.to_ical() calendar_cache['events_ical'] = events_calendar.to_ical() # Notify all waiting tasks that the update is complete - cache_condition.notify_all() + calendar_cache.condition.notify_all() logging.info(f"Calendar cache updated with {len(events)} events") except Exception as e: logging.error(f"Error updating calendar cache: {str(e)}") logging.error(traceback.format_exc()) # Notify waiters even on error - async with cache_condition: - cache_condition.notify_all() + async with calendar_cache.condition: + calendar_cache.condition.notify_all() def seconds_until_next_day(): """@@ -254,14 +272,14 @@ @app.route(f'/{str.lower(config.name)}.ics', defaults = {'key': 'busy_ical'}) @app.route(f'/busy/{str.lower(config.name)}.ics', defaults = {'key': 'busy_ical'}) @app.route(f'/events/{str.lower(config.name)}.ics', defaults = {'key': 'events_ical'}) -async def events(key): +async def events(key: str): try: - async with cache_condition: + async with calendar_cache.condition: if calendar_cache[key]: return calendar_cache[key], { "Content-Type": "text/calendar", "Cache-Control": f"max-age={next_update_seconds}", - "Last-Modified": calendar_cache['last_updated'].strftime("%a, %d %b %Y %H:%M:%S GMT") + "Last-Modified": calendar_cache.last_updated.strftime("%a, %d %b %Y %H:%M:%S GMT") } else: return "Calendar data not yet available. Please try again shortly.", 503
M pyproject.toml → pyproject.toml
@@ -20,3 +20,14 @@ [tool.poetry] name = "mycal" version = "0.1.0" + +[tool.basedpyright] +include = ["src"] +exclude = [ + "**/__pycache__", +] +reportMissingImports = "error" +reportMissingTypeStubs = false + +pythonVersion = "3.13" +pythonPlatform = "Linux"