py3.12 and refactoring: "core" module [wip]
This commit is contained in:
parent
b0e95af44e
commit
ddde3bd024
16 changed files with 524 additions and 458 deletions
2
api/.vscode/launch.json
vendored
2
api/.vscode/launch.json
vendored
|
@ -14,6 +14,8 @@
|
|||
],
|
||||
"env": {
|
||||
"PYDEVD_DISABLE_FILE_VALIDATION": "1",
|
||||
"LOG_LEVEL": "DEBUG",
|
||||
"WEBDAV__CACHE_TTL": "30",
|
||||
},
|
||||
"justMyCode": true
|
||||
}
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
102
api/ovdashboard_api/core/caldav.py
Normal file
102
api/ovdashboard_api/core/caldav.py
Normal file
|
@ -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)
|
83
api/ovdashboard_api/core/calevent.py
Normal file
83
api/ovdashboard_api/core/calevent.py
Normal file
|
@ -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)
|
|
@ -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
|
161
api/ovdashboard_api/core/settings.py
Normal file
161
api/ovdashboard_api/core/settings.py
Normal file
|
@ -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()
|
113
api/ovdashboard_api/core/webdav.py
Normal file
113
api/ovdashboard_api/core/webdav.py
Normal file
|
@ -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))
|
|
@ -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,
|
||||
)
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
|
@ -1,6 +1,6 @@
|
|||
from uvicorn import run as uvicorn_run
|
||||
|
||||
from .settings import SETTINGS
|
||||
from .core.settings import SETTINGS
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
|
|
@ -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()
|
43
api/poetry.lock
generated
43
api/poetry.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue