From ddde3bd024d96e35f9d4f49590b7a74ba9a6e531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= <40151420+ldericher@users.noreply.github.com> Date: Wed, 18 Oct 2023 18:55:54 +0200 Subject: [PATCH] py3.12 and refactoring: "core" module [wip] --- api/.vscode/launch.json | 2 + api/ovdashboard_api/__init__.py | 2 +- api/ovdashboard_api/app.py | 2 +- api/ovdashboard_api/async_helpers.py | 27 ---- api/ovdashboard_api/core/caldav.py | 102 ++++++++++++ api/ovdashboard_api/core/calevent.py | 83 ++++++++++ api/ovdashboard_api/{ => core}/config.py | 26 ++- api/ovdashboard_api/core/settings.py | 161 +++++++++++++++++++ api/ovdashboard_api/core/webdav.py | 113 +++++++++++++ api/ovdashboard_api/dav_calendar.py | 192 ----------------------- api/ovdashboard_api/dav_common.py | 10 +- api/ovdashboard_api/dav_file.py | 98 ------------ api/ovdashboard_api/main.py | 2 +- api/ovdashboard_api/settings.py | 118 -------------- api/poetry.lock | 43 ++++- api/pyproject.toml | 1 + 16 files changed, 524 insertions(+), 458 deletions(-) delete mode 100644 api/ovdashboard_api/async_helpers.py create mode 100644 api/ovdashboard_api/core/caldav.py create mode 100644 api/ovdashboard_api/core/calevent.py rename api/ovdashboard_api/{ => core}/config.py (79%) create mode 100644 api/ovdashboard_api/core/settings.py create mode 100644 api/ovdashboard_api/core/webdav.py delete mode 100644 api/ovdashboard_api/dav_calendar.py delete mode 100644 api/ovdashboard_api/dav_file.py delete mode 100644 api/ovdashboard_api/settings.py diff --git a/api/.vscode/launch.json b/api/.vscode/launch.json index 257a470..d56e29d 100644 --- a/api/.vscode/launch.json +++ b/api/.vscode/launch.json @@ -14,6 +14,8 @@ ], "env": { "PYDEVD_DISABLE_FILE_VALIDATION": "1", + "LOG_LEVEL": "DEBUG", + "WEBDAV__CACHE_TTL": "30", }, "justMyCode": true } diff --git a/api/ovdashboard_api/__init__.py b/api/ovdashboard_api/__init__.py index 67b78ba..143645b 100644 --- a/api/ovdashboard_api/__init__.py +++ b/api/ovdashboard_api/__init__.py @@ -9,7 +9,7 @@ from logging.config import dictConfig from pydantic import BaseModel -from .settings import SETTINGS +from .core.settings import SETTINGS class LogConfig(BaseModel): diff --git a/api/ovdashboard_api/app.py b/api/ovdashboard_api/app.py index 74db30e..5b27a66 100644 --- a/api/ovdashboard_api/app.py +++ b/api/ovdashboard_api/app.py @@ -10,9 +10,9 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from .core.settings import SETTINGS from .dav_common import webdav_check from .routers import v1_router -from .settings import SETTINGS app = FastAPI( title="OVDashboard API", diff --git a/api/ovdashboard_api/async_helpers.py b/api/ovdashboard_api/async_helpers.py deleted file mode 100644 index b87af70..0000000 --- a/api/ovdashboard_api/async_helpers.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Some useful helpers for working in async contexts. -""" - -from asyncio import get_running_loop -from functools import partial, wraps -from typing import Awaitable, Callable, TypeVar - -RT = TypeVar("RT") - - -def run_in_executor(function: Callable[..., RT]) -> Callable[..., Awaitable[RT]]: - """ - Decorator to make blocking a function call asyncio compatible. - https://stackoverflow.com/questions/41063331/how-to-use-asyncio-with-existing-blocking-library/ - https://stackoverflow.com/a/53719009 - """ - - @wraps(function) - async def wrapper(*args, **kwargs) -> RT: - loop = get_running_loop() - return await loop.run_in_executor( - None, - partial(function, *args, **kwargs), - ) - - return wrapper diff --git a/api/ovdashboard_api/core/caldav.py b/api/ovdashboard_api/core/caldav.py new file mode 100644 index 0000000..7b24072 --- /dev/null +++ b/api/ovdashboard_api/core/caldav.py @@ -0,0 +1,102 @@ +import logging +from datetime import datetime, timedelta + +from asyncify import asyncify +from cache import AsyncTTL +from caldav import Calendar, DAVClient, Principal +from caldav.lib.error import ReportError +from vobject.base import Component + +from .calevent import CalEvent +from .config import Config +from .settings import SETTINGS + +_logger = logging.getLogger(__name__) + + +class CalDAV: + _caldav_client = DAVClient( + url=SETTINGS.caldav.url, + username=SETTINGS.caldav.username, + password=SETTINGS.caldav.password, + ) + + @property + @classmethod + def principal(cls) -> Principal: + """ + Gets the `Principal` object of the main CalDAV client. + """ + + _logger.debug("principal") + return cls._caldav_client.principal() + + @property + @AsyncTTL( + time_to_live=SETTINGS.caldav.cache_ttl, + maxsize=SETTINGS.caldav.cache_size, + skip_args=1, + ) + @asyncify + @classmethod + def calendars(cls) -> list[str]: + """ + Asynchroneously lists all calendars using the main WebDAV client. + """ + + _logger.debug("calendars") + return [str(cal.name) for cal in cls.principal.calendars()] + + @AsyncTTL( + time_to_live=SETTINGS.caldav.cache_ttl, + maxsize=SETTINGS.caldav.cache_size, + skip_args=1, + ) + @asyncify + @classmethod + def get_calendar(cls, calendar_name: str) -> Calendar: + """ + Get a calendar by name using the CalDAV principal object. + """ + + return cls.principal.calendar(calendar_name) + + @classmethod + def get_events(cls, calendar_name: str, cfg: Config) -> list[CalEvent]: + """ + Get a sorted list of events by CalDAV calendar name. + """ + + _logger.info(f"downloading {calendar_name!r} ...") + + search_span = timedelta(days=cfg.calendar.future_days) + calendar = cls.principal.calendar(calendar_name) + + date_start = datetime.utcnow().date() + time_min = datetime.min.time() + dt_start = datetime.combine(date_start, time_min) + dt_end = dt_start + search_span + + try: + search_result = calendar.date_search( + start=dt_start, + end=dt_end, + expand=True, + verify_expand=True, + ) + + except ReportError: + _logger.warning("CalDAV server does not support expanded search") + + search_result = calendar.date_search( + start=dt_start, + end=dt_end, + expand=False, + ) + + vevents = [] + for event in search_result: + vobject: Component = event.vobject_instance # type: ignore + vevents.extend(vobject.vevent_list) + + return sorted(CalEvent.from_vevent(vevent) for vevent in vevents) diff --git a/api/ovdashboard_api/core/calevent.py b/api/ovdashboard_api/core/calevent.py new file mode 100644 index 0000000..f927099 --- /dev/null +++ b/api/ovdashboard_api/core/calevent.py @@ -0,0 +1,83 @@ +""" +Definition of an asyncio compatible CalDAV calendar. + +Caches events using `timed_alru_cache`. +""" + +from datetime import datetime +from functools import total_ordering +from logging import getLogger +from typing import Annotated, Self + +from pydantic import AfterValidator, BaseModel, ConfigDict +from vobject.base import Component + +_logger = getLogger(__name__) +StrippedStr = Annotated[str, AfterValidator(lambda s: s.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 + """ + + model_config = ConfigDict(frozen=True) + + summary: StrippedStr = "" + description: StrippedStr = "" + dtstart: datetime = datetime.utcnow() + dtend: datetime = datetime.utcnow() + + def __lt__(self, other: Self) -> bool: + """ + Order Events by start time. + """ + + return self.dtstart < other.dtstart + + def __eq__(self, other: Self) -> bool: + """ + Compare all properties. + """ + + return self.model_dump() == other.model_dump() + + @classmethod + def from_vevent(cls, event: Component) -> Self: + """ + Create a CalEvent instance from a `VObject.VEvent` object. + """ + + data = {} + keys = ("summary", "description", "dtstart", "dtend", "duration") + + for key in keys: + try: + data[key] = event.contents[key][0].value # type: ignore + + except KeyError: + pass + + if "dtend" not in data: + data["dtend"] = data["dtstart"] + + if "duration" in data: + try: + data["dtend"] += data["duration"] + + except (ValueError, TypeError, AttributeError): + _logger.warn( + "Could not add duration %s to %s", + repr(data["duration"]), + repr(data["dtstart"]), + ) + + del data["duration"] + + return cls.model_validate(data) diff --git a/api/ovdashboard_api/config.py b/api/ovdashboard_api/core/config.py similarity index 79% rename from api/ovdashboard_api/config.py rename to api/ovdashboard_api/core/config.py index 776e800..604b56f 100644 --- a/api/ovdashboard_api/config.py +++ b/api/ovdashboard_api/core/config.py @@ -3,17 +3,16 @@ Python representation of the "config.txt" file inside the WebDAV directory. """ import tomllib -from io import BytesIO from logging import getLogger -from typing import Any +from typing import Any, Self import tomli_w from pydantic import BaseModel from webdav3.exceptions import RemoteResourceNotFound -from .dav_common import caldav_list -from .dav_file import DavFile +from .caldav import CalDAV from .settings import SETTINGS +from .webdav import WebDAV _logger = getLogger(__name__) @@ -111,27 +110,26 @@ class Config(BaseModel): calendar: CalendarConfig = CalendarConfig() @classmethod - async def get(cls) -> "Config": + async def get(cls) -> Self: """ Load the configuration instance from the server using `TOML`. """ - dav_file = DavFile(SETTINGS.config_path) - try: - cfg = cls.model_validate(tomllib.loads(await dav_file.as_string)) + cfg_str = await WebDAV.read_str(SETTINGS.webdav.config_filename) + cfg = cls.model_validate(tomllib.loads(cfg_str)) except RemoteResourceNotFound: _logger.warning( - f"Config file {SETTINGS.config_path!r} not found, creating ..." + f"Config file {SETTINGS.webdav.config_filename!r} not found, creating ..." ) cfg = cls() - cfg.calendar.aggregates["All Events"] = list(await caldav_list()) + cfg.calendar.aggregates["All Events"] = list(await CalDAV.calendars) - buffer = BytesIO() - tomli_w.dump(cfg.model_dump(), buffer) - buffer.seek(0) - await dav_file.write(buffer.read()) + await WebDAV.write_str( + SETTINGS.webdav.config_filename, + tomli_w.dumps(cfg.model_dump()), + ) return cfg diff --git a/api/ovdashboard_api/core/settings.py b/api/ovdashboard_api/core/settings.py new file mode 100644 index 0000000..ceb9e8b --- /dev/null +++ b/api/ovdashboard_api/core/settings.py @@ -0,0 +1,161 @@ +""" +Configuration definition. + +Converts per-run (environment) variables and config files into the +"python world" using `pydantic`. + +Pydantic models might have convenience methods attached. +""" + +from typing import Any + +from pydantic import BaseModel, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class DAVSettings(BaseModel): + """ + Connection to a DAV server. + """ + + protocol: str | None = None + host: str | None = None + path: str | None = None + + username: str | None = None + password: str | None = None + + cache_ttl: int = 60 * 30 + cache_size: int = 1024 + + @property + def url(self) -> str: + """ + Combined DAV URL. + """ + + return f"{self.protocol}://{self.host}{self.path}" + + +class WebDAVSettings(DAVSettings): + """ + Connection to a WebDAV server. + """ + + protocol: str = "https" + host: str = "example.com" + path: str = "/remote.php/dav" + prefix: str = "/ovdashboard" + + username: str = "ovd_user" + password: str = "password" + + config_filename: str = "config.txt" + + disable_check: bool = False + retries: int = 20 + prefix: str = "/ovdashboard" + + @property + def url(self) -> str: + """ + Combined DAV URL. + """ + + return f"{self.protocol}://{self.host}{self.path}{self.prefix}" + + +class Settings(BaseSettings): + """ + Per-run settings. + """ + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_nested_delimiter="__", + ) + + ##### + # general settings + ##### + + log_level: str = "INFO" + production_mode: bool = False + ui_directory: str = "/usr/local/share/ovdashboard_ui/html" + + # doesn't even have to be reachable + ping_host: str = "1.0.0.0" + ping_port: int = 1 + + ##### + # openapi settings + ##### + + def __dev_value[T](self, value: T) -> T | None: + if self.production_mode: + return None + + return value + + @property + def openapi_url(self) -> str | None: + return self.__dev_value("/api/openapi.json") + + @property + def docs_url(self) -> str | None: + return self.__dev_value("/api/docs") + + @property + def redoc_url(self) -> str | None: + return self.__dev_value("/api/redoc") + + ##### + # webdav settings + ##### + + webdav: WebDAVSettings = WebDAVSettings() + + ##### + # caldav settings + ##### + + caldav: DAVSettings = DAVSettings() + + @model_validator(mode='before') + @classmethod + def validate_dav_settings(cls, data) -> dict[str, Any]: + assert isinstance(data, dict) + + # ensure both settings dicts are created + for key in ("webdav", "caldav"): + if key not in data: + data[key] = {} + + default_dav = DAVSettings( + protocol="https", + host="example.com", + username="ovdashboard", + password="secret", + ).model_dump() + + for key in default_dav: + # if "webdav" value is not specified, use default + if key not in data["webdav"] or data["webdav"][key] is None: + data["webdav"][key] = default_dav[key] + + # if "caldav" value is not specified, use "webdav" value + if key not in data["caldav"] or data["caldav"][key] is None: + data["caldav"][key] = data["webdav"][key] + + # add default "path"s if None + if data["webdav"]["path"] is None: + data["webdav"]["path"] = "/remote.php/webdav" + + if data["caldav"]["path"] is None: + data["caldav"]["path"] = "/remote.php/dav" + + return data + + +SETTINGS = Settings() diff --git a/api/ovdashboard_api/core/webdav.py b/api/ovdashboard_api/core/webdav.py new file mode 100644 index 0000000..a907841 --- /dev/null +++ b/api/ovdashboard_api/core/webdav.py @@ -0,0 +1,113 @@ +import logging +import re +from io import BytesIO + +from asyncify import asyncify +from cache import AsyncTTL +from cache.key import KEY +from webdav3.client import Client as WebDAVclient + +from .settings import SETTINGS + +_logger = logging.getLogger(__name__) + + +class WebDAV: + _webdav_client = WebDAVclient( + { + "webdav_hostname": SETTINGS.webdav.url, + "webdav_login": SETTINGS.webdav.username, + "webdav_password": SETTINGS.webdav.password, + "disable_check": SETTINGS.webdav.disable_check, + } + ) + + @classmethod + @AsyncTTL( + time_to_live=SETTINGS.webdav.cache_ttl, + maxsize=SETTINGS.webdav.cache_size, + skip_args=1, + ) + async def list_files( + cls, + directory: str = "", + *, + regex: re.Pattern[str] = re.compile(""), + ) -> list[str]: + """ + Liste aller Dateien im Ordner `directory`, die zur RegEx `regex` passen + """ + + _logger.debug(f"list_files {directory!r}") + ls = await asyncify(cls._webdav_client.list)(directory) + + return [f"{directory}/{path}" for path in ls if regex.search(path)] + + @classmethod + @AsyncTTL( + time_to_live=SETTINGS.webdav.cache_ttl, + maxsize=SETTINGS.webdav.cache_size, + skip_args=1, + ) + async def file_exists(cls, path: str) -> bool: + """ + `True`, wenn an Pfad `path` eine Datei existiert + """ + + _logger.debug(f"file_exists {path!r}") + return await asyncify(cls._webdav_client.check)(path) + + @classmethod + @( + _rb_ttl := AsyncTTL( + time_to_live=SETTINGS.webdav.cache_ttl, + maxsize=SETTINGS.webdav.cache_size, + skip_args=1, + ) + ) + async def read_bytes(cls, path: str) -> bytes: + """ + Datei aus Pfad `path` als bytes laden + """ + + _logger.debug(f"read_bytes {path!r}") + buffer = BytesIO() + await asyncify(cls._webdav_client.resource(path).write_to)(buffer) + + return buffer.read() + + @classmethod + async def read_str(cls, path: str, encoding="utf-8") -> str: + """ + Datei aus Pfad `path` als string laden + """ + + _logger.debug(f"read_str {path!r}") + return (await cls.read_bytes(path)).decode(encoding=encoding).strip() + + @classmethod + async def write_bytes(cls, path: str, buffer: bytes) -> None: + """ + Bytes `buffer` in Datei in Pfad `path` schreiben + """ + + _logger.debug(f"write_bytes {path!r}") + await asyncify(cls._webdav_client.resource(path).read_from)(buffer) + + try: + # hack: zugehörigen Cache-Eintrag entfernen + # -> AsyncTTL._TTL.__contains__ + del cls._rb_ttl.ttl[KEY((path,), {})] + + except KeyError: + # Cache-Eintrag existierte nicht + pass + + @classmethod + async def write_str(cls, path: str, content: str, encoding="utf-8") -> None: + """ + String `content` in Datei in Pfad `path` schreiben + """ + + _logger.debug(f"write_str {path!r}") + await cls.write_bytes(path, content.encode(encoding=encoding)) diff --git a/api/ovdashboard_api/dav_calendar.py b/api/ovdashboard_api/dav_calendar.py deleted file mode 100644 index 5b69462..0000000 --- a/api/ovdashboard_api/dav_calendar.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -Definition of an asyncio compatible CalDAV calendar. - -Caches events using `timed_alru_cache`. -""" - -from dataclasses import dataclass -from datetime import datetime, timedelta -from functools import total_ordering -from logging import getLogger -from typing import Annotated, Iterator - -from cache import AsyncTTL -from caldav import Calendar -from caldav.lib.error import ReportError -from pydantic import AfterValidator, BaseModel -from vobject.base import Component - -from .async_helpers import run_in_executor -from .config import Config -from .dav_common import caldav_principal -from .settings import SETTINGS - -_logger = getLogger(__name__) -StrippedStr = Annotated[str, AfterValidator(lambda s: s.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: StrippedStr = "" - description: StrippedStr = "" - dtstart: datetime = datetime.utcnow() - dtend: datetime = datetime.utcnow() - - class Config: - 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.model_dump() == other.model_dump() - - @classmethod - def from_vevent(cls, event: Component) -> "CalEvent": - """ - Create a CalEvent instance from a `VObject.VEvent` object. - """ - - data = {} - keys = ("summary", "description", "dtstart", "dtend", "duration") - - for key in keys: - try: - data[key] = event.contents[key][0].value # type: ignore - - except KeyError: - pass - - if "dtend" not in data: - data["dtend"] = data["dtstart"] - - if "duration" in data: - try: - data["dtend"] += data["duration"] - - except (ValueError, TypeError, AttributeError): - _logger.warn( - "Could not add duration %s to %s", - repr(data["duration"]), - repr(data["dtstart"]), - ) - - del data["duration"] - - return cls.model_validate(data) - - -@AsyncTTL(time_to_live=SETTINGS.cache_time, maxsize=SETTINGS.cache_size) -async def _get_calendar( - calendar_name: str, -) -> Calendar: - """ - Get a calendar by name using the CalDAV principal object. - """ - - @run_in_executor - def _inner() -> Calendar: - return caldav_principal().calendar(calendar_name) - - return await _inner() - - -@AsyncTTL(time_to_live=SETTINGS.cache_time, maxsize=SETTINGS.cache_size) -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. - """ - - cfg = await Config.get() - search_span = timedelta(days=cfg.calendar.future_days) - - @run_in_executor - def _inner() -> Iterator[Component]: - """ - Get events by CalDAV calendar name. - - This can return an iterator - only the outer function is - cached. - """ - _logger.info(f"downloading {calendar_name!r} ...") - - calendar = caldav_principal().calendar(calendar_name) - - date_start = datetime.utcnow().date() - time_min = datetime.min.time() - dt_start = datetime.combine(date_start, time_min) - dt_end = dt_start + search_span - - try: - search_result = calendar.date_search( - start=dt_start, - end=dt_end, - expand=True, - verify_expand=True, - ) - - except ReportError: - _logger.warning("CalDAV server does not support expanded search") - - search_result = calendar.date_search( - start=dt_start, - end=dt_end, - expand=False, - ) - - for event in search_result: - vobject: Component = event.vobject_instance # type: ignore - yield from vobject.vevent_list - - return sorted([CalEvent.from_vevent(vevent) for vevent in await _inner()]) - - -@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( - calendar_name=self.calendar_name, - ) - - @property - async def events(self) -> list[CalEvent]: - """ - Calendar events in object representation. - """ - - return await _get_calendar_events( - calendar_name=self.calendar_name, - ) diff --git a/api/ovdashboard_api/dav_common.py b/api/ovdashboard_api/dav_common.py index 48357c2..326cdab 100644 --- a/api/ovdashboard_api/dav_common.py +++ b/api/ovdashboard_api/dav_common.py @@ -9,14 +9,14 @@ from pathlib import Path from time import sleep from typing import Any, Iterator +from asyncify import asyncify from caldav import DAVClient as CalDAVclient from caldav import Principal as CalDAVPrincipal from webdav3.client import Client as WebDAVclient from webdav3.client import Resource as WebDAVResource from . import __file__ as OVD_INIT -from .async_helpers import run_in_executor -from .settings import SETTINGS +from .core.settings import SETTINGS _WEBDAV_CLIENT = WebDAVclient( { @@ -41,7 +41,7 @@ def webdav_check() -> None: ) if SETTINGS.production_mode: - for _ in range(SETTINGS.webdav_retries): + for _ in range(SETTINGS.webdav.retries): if _WEBDAV_CLIENT.check(""): break @@ -139,7 +139,7 @@ def webdav_resource(remote_path: Any) -> WebDAVResource: return _WEBDAV_CLIENT.resource(f"{SETTINGS.webdav_prefix}/{remote_path}") -@run_in_executor +@asyncify def webdav_list(remote_path: str) -> list[str]: """ Asynchronously lists a WebDAV path using the main WebDAV client. @@ -163,7 +163,7 @@ def caldav_principal() -> CalDAVPrincipal: return _CALDAV_CLIENT.principal() -@run_in_executor +@asyncify def caldav_list() -> Iterator[str]: """ Asynchronously lists all calendars using the main WebDAV client. diff --git a/api/ovdashboard_api/dav_file.py b/api/ovdashboard_api/dav_file.py deleted file mode 100644 index d48e9eb..0000000 --- a/api/ovdashboard_api/dav_file.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Definition of an asyncio compatible WebDAV file. - -Caches files using `timed_alru_cache`. -""" - -from dataclasses import dataclass -from io import BytesIO -from logging import getLogger -from typing import Any - -from cache import AsyncTTL -from webdav3.client import Resource - -from .async_helpers import run_in_executor -from .dav_common import webdav_resource -from .settings import SETTINGS - -_logger = getLogger(__name__) - - -@AsyncTTL(time_to_live=SETTINGS.cache_time, maxsize=SETTINGS.cache_size) -async def _get_buffer( - remote_path: Any, -) -> BytesIO: - """ - Download file contents into a new `BytesIO` object. - """ - - @run_in_executor - def _inner() -> BytesIO: - _logger.info(f"downloading {remote_path!r} ...") - - resource = webdav_resource(remote_path) - buffer = BytesIO() - resource.write_to(buffer) - return buffer - - return await _inner() - - -@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( - remote_path=self.remote_path, - ) - - @property - async def as_bytes(self) -> bytes: - """ - File contents as binary data. - """ - - buffer = await self.__buffer - - buffer.seek(0) - return buffer.read() - - @property - async def as_string(self) -> str: - """ - File contents as string. - """ - - bytes = await self.as_bytes - return bytes.decode(encoding="utf-8") - - async def write(self, content: bytes) -> None: - """ - Write bytes into file. - """ - - @run_in_executor - def _inner() -> None: - buffer = BytesIO(content) - self.resource.read_from(buffer) - - await _inner() diff --git a/api/ovdashboard_api/main.py b/api/ovdashboard_api/main.py index 5413937..e6b75b8 100644 --- a/api/ovdashboard_api/main.py +++ b/api/ovdashboard_api/main.py @@ -1,6 +1,6 @@ from uvicorn import run as uvicorn_run -from .settings import SETTINGS +from .core.settings import SETTINGS def main() -> None: diff --git a/api/ovdashboard_api/settings.py b/api/ovdashboard_api/settings.py deleted file mode 100644 index 51720e5..0000000 --- a/api/ovdashboard_api/settings.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -Configuration definition. - -Converts per-run (environment) variables and config files into the -"python world" using `pydantic`. - -Pydantic models might have convenience methods attached. -""" - -from typing import Any - -from pydantic import BaseModel, root_validator -from pydantic_settings import BaseSettings - - -class DavSettings(BaseModel): - """ - Connection to a DAV server. - """ - - protocol: str | None = None - host: str | None = None - username: str | None = None - password: str | None = None - path: str | None = None - - @property - def url(self) -> str: - """ - Combined DAV URL. - """ - - return f"{self.protocol}://{self.host}{self.path}" - - -class Settings(BaseSettings): - """ - Per-run settings. - """ - - ##### - # general settings - ##### - - production_mode: bool = False - log_level: str = "INFO" if production_mode else "DEBUG" - ui_directory: str = "/html" - cache_time: int = 30 - cache_size: int = 30 - - # doesn't even have to be reachable - ping_host: str = "10.0.0.0" - ping_port: int = 1 - - ##### - # openapi settings - ##### - - openapi_url: str = "/openapi.json" - docs_url: str | None = None if production_mode else "/docs" - redoc_url: str | None = None if production_mode else "/redoc" - - ##### - # webdav settings - ##### - - webdav: DavSettings = DavSettings() - webdav_disable_check: bool = False - webdav_retries: int = 20 - webdav_prefix: str = "/ovdashboard" - config_path: str = "config.txt" - - ##### - # caldav settings - ##### - - caldav: DavSettings = DavSettings() - - class Config: - env_file = ".env" - env_file_encoding = "utf-8" - env_nested_delimiter = "__" - - @root_validator(pre=True) - @classmethod - def validate_dav_settings(cls, values: dict[str, Any]) -> dict[str, Any]: - # ensure both settings dicts are created - for key in ("webdav", "caldav"): - if key not in values: - values[key] = {} - - default_dav = DavSettings( - protocol="https", - host="example.com", - username="ovdashboard", - password="secret", - ).model_dump() - - for key in default_dav: - # if "webdav" value is not specified, use default - if key not in values["webdav"] or values["webdav"][key] is None: - values["webdav"][key] = default_dav[key] - - # if "caldav" value is not specified, use "webdav" value - if key not in values["caldav"] or values["caldav"][key] is None: - values["caldav"][key] = values["webdav"][key] - - # add default "path"s if None - if values["webdav"]["path"] is None: - values["webdav"]["path"] = "/remote.php/webdav" - - if values["caldav"]["path"] is None: - values["caldav"]["path"] = "/remote.php/dav" - - return values - - -SETTINGS = Settings() diff --git a/api/poetry.lock b/api/poetry.lock index 4c2671c..3bd0c63 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -41,6 +41,21 @@ files = [ {file = "async-cache-1.1.1.tar.gz", hash = "sha256:81aa9ccd19fb06784aaf30bd5f2043dc0a23fc3e998b93d0c2c17d1af9803393"}, ] +[[package]] +name = "asyncify" +version = "0.9.2" +description = "sync 2 async" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "asyncify-0.9.2-py3-none-any.whl", hash = "sha256:ee7efe8ecc11f348d4f25d4d1c5fb2f56a187aaa907aea3608106359728a2cdd"}, + {file = "asyncify-0.9.2.tar.gz", hash = "sha256:5f06016a5d805354505e98e9c009595cba7905ceb767ed7cd61bf60f2341d896"}, +] + +[package.dependencies] +funkify = ">=0.4.0,<0.5.0" +xtyping = ">=0.5.0" + [[package]] name = "caldav" version = "1.3.6" @@ -252,6 +267,17 @@ isort = ">=5.0.0,<6" [package.extras] test = ["pytest"] +[[package]] +name = "funkify" +version = "0.4.5" +description = "Funkify modules so that they are callable" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "funkify-0.4.5-py3-none-any.whl", hash = "sha256:43f1e6c27263468a60ba560dfc13e6e4df57aa75376438a62f741ffc7c83cdfe"}, + {file = "funkify-0.4.5.tar.gz", hash = "sha256:42df845f4afa63e0e66239a986d26b6572ab0b7ad600d7d6365d44d8a0cff3d5"}, +] + [[package]] name = "h11" version = "0.14.0" @@ -1264,7 +1290,22 @@ files = [ icalendar = "*" pytz = "*" +[[package]] +name = "xtyping" +version = "0.7.0" +description = "xtyping = typing + typing_extensions" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "xtyping-0.7.0-py3-none-any.whl", hash = "sha256:5b72b08d5b4775c1ff34a8b7bbdfaae92249aaa11c53b33f26a0a788ca209fda"}, + {file = "xtyping-0.7.0.tar.gz", hash = "sha256:441e597b227fcb51645e33de7cb47b7b23c014ee7c487a996b312652b8cacde0"}, +] + +[package.dependencies] +annotated-types = ">=0.5.0" +typing-extensions = ">=4.4.0" + [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "cc77f4d7f7a03c79b6d3022c83c16bb8133638fd2cb0c06b146d03aea82ae78f" +content-hash = "367a94d4b3b395034ee18e3e383c2f6bd127ccf0f33218c6eb13c669310b5a9f" diff --git a/api/pyproject.toml b/api/pyproject.toml index e0a88ec..7f2e246 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -17,6 +17,7 @@ python-magic = "^0.4.27" tomli-w = "^1.0.0" uvicorn = {extras = ["standard"], version = "^0.23.2"} webdavclient3 = "^3.14.6" +asyncify = "^0.9.2" [tool.poetry.group.dev.dependencies] flake8 = "^6.1.0"