diff --git a/api/ovdashboard_api/__init__.py b/api/ovdashboard_api/__init__.py index 5780659..36d78ee 100644 --- a/api/ovdashboard_api/__init__.py +++ b/api/ovdashboard_api/__init__.py @@ -1,3 +1,10 @@ +""" +Package `ovdashboard_api`: Contains the API powering the +"OVDashboard" application. + +This file: Sets up logging. +""" + import logging.config from .config import LogConfig diff --git a/api/ovdashboard_api/async_helpers.py b/api/ovdashboard_api/async_helpers.py index ce0ac4f..a1de3dc 100644 --- a/api/ovdashboard_api/async_helpers.py +++ b/api/ovdashboard_api/async_helpers.py @@ -1,3 +1,7 @@ +""" +Some useful helpers for working in async contexts. +""" + from asyncio import get_running_loop from functools import partial, wraps from time import time @@ -7,7 +11,7 @@ from async_lru import alru_cache def run_in_executor(f): """ - Decorator to make blocking function call asyncio compatible + Decorator to make blocking a function call asyncio compatible. https://stackoverflow.com/questions/41063331/how-to-use-asyncio-with-existing-blocking-library/ """ @@ -24,13 +28,19 @@ def run_in_executor(f): def get_ttl_hash(seconds: int = 20) -> int: """ - Return the same value within `seconds` time period + Return the same value within `seconds` time period. https://stackoverflow.com/a/55900800 """ + return round(time() / seconds) def timed_alru_cache(**decorator_kwargs): + """ + Decorator which adds an (unused) param `ttl_hash` + and the `@alru_cache` annotation to a function. + """ + def decorate(f): @alru_cache(**decorator_kwargs) @wraps(f) diff --git a/api/ovdashboard_api/config.py b/api/ovdashboard_api/config.py index 83e61a4..c124076 100644 --- a/api/ovdashboard_api/config.py +++ b/api/ovdashboard_api/config.py @@ -1,3 +1,7 @@ +""" +Python representation of the "config.txt" file inside the WebDAV directory. +""" + from io import BytesIO from typing import Any @@ -11,8 +15,8 @@ from .dav_file import DavFile class LogConfig(BaseModel): """ + Logging configuration to be set for the server. https://stackoverflow.com/a/67937084 - Logging configuration to be set for the server """ LOG_FORMAT: str = "%(levelprefix)s [%(asctime)s] %(name)s: %(message)s" @@ -41,6 +45,10 @@ class LogConfig(BaseModel): class ImageConfig(BaseModel): + """ + Sections "[image.*]" in "config.txt". + """ + mode: str = "RGB" save_params: dict[str, Any] = { "format": "JPEG", @@ -49,12 +57,20 @@ class ImageConfig(BaseModel): class Config(BaseModel): + """ + Main representation of "config.txt". + """ + ticker_separator: str = " +++ " image: ImageConfig = ImageConfig() @classmethod async def get(cls) -> "Config": + """ + Load the configuration instance from the server using `TOML`. + """ + dav_file = DavFile("config.txt") try: diff --git a/api/ovdashboard_api/dav_calendar.py b/api/ovdashboard_api/dav_calendar.py index cea94f0..7e10dd5 100644 --- a/api/ovdashboard_api/dav_calendar.py +++ b/api/ovdashboard_api/dav_calendar.py @@ -1,3 +1,9 @@ +""" +Definition of an asyncio compatible CalDAV calendar. + +Caches events using `timed_alru_cache`. +""" + import logging from dataclasses import dataclass from datetime import datetime, timedelta @@ -17,11 +23,25 @@ _logger = logging.getLogger(__name__) def _string_strip(in_str: str) -> str: + """ + Wrapper for str.strip(). + + Used to define `pydantic` validators. + """ return in_str.strip() @total_ordering class CalEvent(BaseModel): + """ + A CalDAV calendar event. + + Properties are to be named as in the EVENT component of + RFC5545 (iCalendar). + + https://icalendar.org/iCalendar-RFC-5545/3-6-1-event-component.html + """ + summary: str = "" description: str = "" dtstart: datetime = datetime.utcnow() @@ -31,9 +51,17 @@ class CalEvent(BaseModel): frozen = True def __lt__(self, other: "CalEvent") -> bool: + """ + Order Events by start time. + """ + return self.dtstart < other.dtstart def __eq__(self, other: "CalEvent") -> bool: + """ + Compare all properties. + """ + return self.dict() == other.dict() _validate_summary = validator( @@ -48,6 +76,10 @@ class CalEvent(BaseModel): @classmethod def from_vevent(cls, event: VEvent) -> "CalEvent": + """ + Create a CalEvent instance from a `VObject.VEvent` object. + """ + data = {} for key in cls().dict().keys(): @@ -64,6 +96,9 @@ class CalEvent(BaseModel): async def _get_calendar( calendar_name: str, ) -> Calendar: + """ + Get a calendar by name using the CalDAV principal object. + """ @run_in_executor def _inner() -> Calendar: @@ -76,9 +111,21 @@ async def _get_calendar( async def _get_calendar_events( calendar_name: str, ) -> list[CalEvent]: + """ + Get a sorted list of events by CalDAV calendar name. + + Do not return an iterator here - this result is cached and + an iterator would get consumed. + """ @run_in_executor def _inner() -> Iterator[VEvent]: + """ + Get events by CalDAV calendar name. + + This can return an iterator - only the outer function is + cached. + """ _logger.info(f"updating {calendar_name!r} ...") calendar = caldav_principal().calendar(calendar_name) @@ -119,10 +166,18 @@ async def _get_calendar_events( @dataclass(frozen=True) class DavCalendar: + """ + Object representation of a CalDAV calendar. + """ + calendar_name: str @property async def calendar(self) -> Calendar: + """ + Calendar as `caldav` library representation. + """ + return await _get_calendar( ttl_hash=get_ttl_hash(SETTINGS.cache_seconds), calendar_name=self.calendar_name, @@ -130,6 +185,10 @@ class DavCalendar: @property async def events(self) -> list[CalEvent]: + """ + Calendar events in object representation. + """ + return await _get_calendar_events( ttl_hash=get_ttl_hash(SETTINGS.cache_seconds), calendar_name=self.calendar_name, diff --git a/api/ovdashboard_api/dav_common.py b/api/ovdashboard_api/dav_common.py index f57934c..5654b0b 100644 --- a/api/ovdashboard_api/dav_common.py +++ b/api/ovdashboard_api/dav_common.py @@ -1,3 +1,7 @@ +""" +Definition of WebDAV and CalDAV clients. +""" + from functools import lru_cache from typing import Any @@ -18,11 +22,19 @@ _WEBDAV_CLIENT = WebDAVclient({ @lru_cache def webdav_resource(remote_path: Any) -> WebDAVResource: + """ + Gets a resource using the main WebDAV client. + """ + return _WEBDAV_CLIENT.resource(remote_path) @run_in_executor -def webdav_list(remote_path: str) -> list: +def webdav_list(remote_path: str) -> list[str]: + """ + Asynchroneously lists a WebDAV path using the main WebDAV client. + """ + return _WEBDAV_CLIENT.list(remote_path) @@ -34,4 +46,8 @@ _CALDAV_CLIENT = CalDAVclient( def caldav_principal() -> CalDAVPrincipal: + """ + Gets the `Principal` object of the main CalDAV client. + """ + return _CALDAV_CLIENT.principal() diff --git a/api/ovdashboard_api/dav_file.py b/api/ovdashboard_api/dav_file.py index acaa22c..991d9a3 100644 --- a/api/ovdashboard_api/dav_file.py +++ b/api/ovdashboard_api/dav_file.py @@ -1,3 +1,9 @@ +""" +Definition of an asyncio compatible WebDAV file. + +Caches files using `timed_alru_cache`. +""" + import logging from dataclasses import dataclass from io import BytesIO @@ -16,6 +22,9 @@ _logger = logging.getLogger(__name__) async def _get_buffer( remote_path: Any, ) -> BytesIO: + """ + Download file contents into a new `BytesIO` object. + """ @run_in_executor def _inner(resource: Resource) -> BytesIO: @@ -30,21 +39,37 @@ async def _get_buffer( @dataclass(frozen=True) class DavFile: + """ + Object representation of a WebDAV file. + """ + remote_path: str + @property + def resource(self) -> Resource: + """ + WebDAV file handle. + """ + + return webdav_resource(self.remote_path) + @property async def __buffer(self) -> BytesIO: + """ + File contents as binary stream. + """ + return await _get_buffer( ttl_hash=get_ttl_hash(SETTINGS.cache_seconds), remote_path=self.remote_path, ) - @property - def resource(self) -> Resource: - return webdav_resource(self.remote_path) - @property async def bytes(self) -> bytes: + """ + File contents as binary data. + """ + buffer = await self.__buffer buffer.seek(0) @@ -52,10 +77,18 @@ class DavFile: @property async def string(self) -> str: + """ + File contents as string. + """ + bytes = await self.bytes return bytes.decode(encoding="utf-8") async def dump(self, content: bytes) -> None: + """ + Write bytes into file. + """ + @run_in_executor def _inner() -> None: buffer = BytesIO(content) diff --git a/api/ovdashboard_api/main.py b/api/ovdashboard_api/main.py index 77e1311..c133181 100644 --- a/api/ovdashboard_api/main.py +++ b/api/ovdashboard_api/main.py @@ -1,11 +1,9 @@ #!/usr/bin/env python3 """ -Main executable of `ovdashboard_api`. +Main script for `ovdashboard_api` module. Creates the main `FastAPI` app. - -If run directly, uses `uvicorn` to run the app. """ import uvicorn @@ -30,6 +28,10 @@ app.include_router(main_router) def main() -> None: + """ + If the `main` script is run, `uvicorn` is used to run the app. + """ + uvicorn.run( app="ovdashboard_api.main:app", host="0.0.0.0", diff --git a/api/ovdashboard_api/routers/_common.py b/api/ovdashboard_api/routers/_common.py index 2ea5373..2b5f4ed 100644 --- a/api/ovdashboard_api/routers/_common.py +++ b/api/ovdashboard_api/routers/_common.py @@ -1,3 +1,7 @@ +""" +Dependables for defining Routers. +""" + import re from dataclasses import dataclass from typing import Iterator, Protocol @@ -10,7 +14,11 @@ from ..dav_common import caldav_principal, webdav_list @dataclass(frozen=True) class NameLister(Protocol): - def __call__(self) -> Iterator[str]: + """ + Can be called to create an iterator containing some names. + """ + + async def __call__(self) -> Iterator[str]: ... @@ -23,6 +31,12 @@ _RESPONSE_OK = { @dataclass(frozen=True) class FileNameLister: + """ + Can be called to create an iterator containing file names. + + File names listed will be in `remote_path` and will match the RegEx `re`. + """ + remote_path: str re: re.Pattern[str] @@ -52,6 +66,10 @@ class FileNameLister: @dataclass(frozen=True) class CalendarNameLister: + """ + Can be called to create an iterator containing calendar names. + """ + async def __call__(self) -> Iterator[str]: return ( cal.name @@ -61,6 +79,13 @@ class CalendarNameLister: @dataclass(frozen=True) class PrefixFinder: + """ + Can be called to create an iterator containing some names, all starting + with a given prefix. + + All names will be taken from the list produced by the called `lister`. + """ + lister: NameLister @property @@ -84,6 +109,14 @@ class PrefixFinder: @dataclass(frozen=True) class PrefixUnique: + """ + Can be called to determine if a given prefix is unique in the list + produced by the called `finder`. + + On success, produces the unique name with that prefix. Otherwise, + throws a HTTPException. + """ + finder: PrefixFinder @property diff --git a/api/ovdashboard_api/routers/calendar.py b/api/ovdashboard_api/routers/calendar.py index d8edd3d..e19c4f1 100644 --- a/api/ovdashboard_api/routers/calendar.py +++ b/api/ovdashboard_api/routers/calendar.py @@ -1,3 +1,11 @@ +""" +Router "calendar" provides: + +- listing calendars +- finding calendars by name prefix +- getting calendar events by calendar name prefix +""" + from typing import Iterator from fastapi import APIRouter, Depends diff --git a/api/ovdashboard_api/routers/image.py b/api/ovdashboard_api/routers/image.py index ec8fe62..a2652c0 100644 --- a/api/ovdashboard_api/routers/image.py +++ b/api/ovdashboard_api/routers/image.py @@ -1,3 +1,11 @@ +""" +Router "image" provides: + +- listing image files +- finding image files by name prefix +- getting image files in a uniform format by name prefix +""" + import re from io import BytesIO from typing import Iterator diff --git a/api/ovdashboard_api/routers/text.py b/api/ovdashboard_api/routers/text.py index 168eb2a..58c92c3 100644 --- a/api/ovdashboard_api/routers/text.py +++ b/api/ovdashboard_api/routers/text.py @@ -1,3 +1,11 @@ +""" +Router "text" provides: + +- listing text files +- finding text files by name prefix +- getting image file content as converted Markdown by name prefix +""" + import re from typing import Iterator diff --git a/api/ovdashboard_api/settings.py b/api/ovdashboard_api/settings.py index 8cd9aaf..aeae1e5 100644 --- a/api/ovdashboard_api/settings.py +++ b/api/ovdashboard_api/settings.py @@ -35,11 +35,19 @@ class Settings(BaseSettings): @property def caldav_url(self) -> str: + """ + Combined CalDAV URL. + """ + return f"{self.dav_protocol}://" + \ f"{self.dav_host}{self.caldav_base_url}" @property def webdav_url(self) -> str: + """ + Combined WebDAV URL. + """ + return f"{self.dav_protocol}://" + \ f"{self.dav_host}{self.webdav_base_url}"