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": {
|
"env": {
|
||||||
"PYDEVD_DISABLE_FILE_VALIDATION": "1",
|
"PYDEVD_DISABLE_FILE_VALIDATION": "1",
|
||||||
|
"LOG_LEVEL": "DEBUG",
|
||||||
|
"WEBDAV__CACHE_TTL": "30",
|
||||||
},
|
},
|
||||||
"justMyCode": true
|
"justMyCode": true
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ from logging.config import dictConfig
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .settings import SETTINGS
|
from .core.settings import SETTINGS
|
||||||
|
|
||||||
|
|
||||||
class LogConfig(BaseModel):
|
class LogConfig(BaseModel):
|
||||||
|
|
|
@ -10,9 +10,9 @@ from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .core.settings import SETTINGS
|
||||||
from .dav_common import webdav_check
|
from .dav_common import webdav_check
|
||||||
from .routers import v1_router
|
from .routers import v1_router
|
||||||
from .settings import SETTINGS
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="OVDashboard API",
|
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
|
import tomllib
|
||||||
from io import BytesIO
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Any
|
from typing import Any, Self
|
||||||
|
|
||||||
import tomli_w
|
import tomli_w
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from webdav3.exceptions import RemoteResourceNotFound
|
from webdav3.exceptions import RemoteResourceNotFound
|
||||||
|
|
||||||
from .dav_common import caldav_list
|
from .caldav import CalDAV
|
||||||
from .dav_file import DavFile
|
|
||||||
from .settings import SETTINGS
|
from .settings import SETTINGS
|
||||||
|
from .webdav import WebDAV
|
||||||
|
|
||||||
_logger = getLogger(__name__)
|
_logger = getLogger(__name__)
|
||||||
|
|
||||||
|
@ -111,27 +110,26 @@ class Config(BaseModel):
|
||||||
calendar: CalendarConfig = CalendarConfig()
|
calendar: CalendarConfig = CalendarConfig()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get(cls) -> "Config":
|
async def get(cls) -> Self:
|
||||||
"""
|
"""
|
||||||
Load the configuration instance from the server using `TOML`.
|
Load the configuration instance from the server using `TOML`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
dav_file = DavFile(SETTINGS.config_path)
|
|
||||||
|
|
||||||
try:
|
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:
|
except RemoteResourceNotFound:
|
||||||
_logger.warning(
|
_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 = cls()
|
||||||
cfg.calendar.aggregates["All Events"] = list(await caldav_list())
|
cfg.calendar.aggregates["All Events"] = list(await CalDAV.calendars)
|
||||||
|
|
||||||
buffer = BytesIO()
|
await WebDAV.write_str(
|
||||||
tomli_w.dump(cfg.model_dump(), buffer)
|
SETTINGS.webdav.config_filename,
|
||||||
buffer.seek(0)
|
tomli_w.dumps(cfg.model_dump()),
|
||||||
await dav_file.write(buffer.read())
|
)
|
||||||
|
|
||||||
return cfg
|
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 time import sleep
|
||||||
from typing import Any, Iterator
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
from asyncify import asyncify
|
||||||
from caldav import DAVClient as CalDAVclient
|
from caldav import DAVClient as CalDAVclient
|
||||||
from caldav import Principal as CalDAVPrincipal
|
from caldav import Principal as CalDAVPrincipal
|
||||||
from webdav3.client import Client as WebDAVclient
|
from webdav3.client import Client as WebDAVclient
|
||||||
from webdav3.client import Resource as WebDAVResource
|
from webdav3.client import Resource as WebDAVResource
|
||||||
|
|
||||||
from . import __file__ as OVD_INIT
|
from . import __file__ as OVD_INIT
|
||||||
from .async_helpers import run_in_executor
|
from .core.settings import SETTINGS
|
||||||
from .settings import SETTINGS
|
|
||||||
|
|
||||||
_WEBDAV_CLIENT = WebDAVclient(
|
_WEBDAV_CLIENT = WebDAVclient(
|
||||||
{
|
{
|
||||||
|
@ -41,7 +41,7 @@ def webdav_check() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
if SETTINGS.production_mode:
|
if SETTINGS.production_mode:
|
||||||
for _ in range(SETTINGS.webdav_retries):
|
for _ in range(SETTINGS.webdav.retries):
|
||||||
if _WEBDAV_CLIENT.check(""):
|
if _WEBDAV_CLIENT.check(""):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
@ -139,7 +139,7 @@ def webdav_resource(remote_path: Any) -> WebDAVResource:
|
||||||
return _WEBDAV_CLIENT.resource(f"{SETTINGS.webdav_prefix}/{remote_path}")
|
return _WEBDAV_CLIENT.resource(f"{SETTINGS.webdav_prefix}/{remote_path}")
|
||||||
|
|
||||||
|
|
||||||
@run_in_executor
|
@asyncify
|
||||||
def webdav_list(remote_path: str) -> list[str]:
|
def webdav_list(remote_path: str) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Asynchronously lists a WebDAV path using the main WebDAV client.
|
Asynchronously lists a WebDAV path using the main WebDAV client.
|
||||||
|
@ -163,7 +163,7 @@ def caldav_principal() -> CalDAVPrincipal:
|
||||||
return _CALDAV_CLIENT.principal()
|
return _CALDAV_CLIENT.principal()
|
||||||
|
|
||||||
|
|
||||||
@run_in_executor
|
@asyncify
|
||||||
def caldav_list() -> Iterator[str]:
|
def caldav_list() -> Iterator[str]:
|
||||||
"""
|
"""
|
||||||
Asynchronously lists all calendars using the main WebDAV client.
|
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 uvicorn import run as uvicorn_run
|
||||||
|
|
||||||
from .settings import SETTINGS
|
from .core.settings import SETTINGS
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
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"},
|
{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]]
|
[[package]]
|
||||||
name = "caldav"
|
name = "caldav"
|
||||||
version = "1.3.6"
|
version = "1.3.6"
|
||||||
|
@ -252,6 +267,17 @@ isort = ">=5.0.0,<6"
|
||||||
[package.extras]
|
[package.extras]
|
||||||
test = ["pytest"]
|
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]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
|
@ -1264,7 +1290,22 @@ files = [
|
||||||
icalendar = "*"
|
icalendar = "*"
|
||||||
pytz = "*"
|
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]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.12"
|
python-versions = "^3.12"
|
||||||
content-hash = "cc77f4d7f7a03c79b6d3022c83c16bb8133638fd2cb0c06b146d03aea82ae78f"
|
content-hash = "367a94d4b3b395034ee18e3e383c2f6bd127ccf0f33218c6eb13c669310b5a9f"
|
||||||
|
|
|
@ -17,6 +17,7 @@ python-magic = "^0.4.27"
|
||||||
tomli-w = "^1.0.0"
|
tomli-w = "^1.0.0"
|
||||||
uvicorn = {extras = ["standard"], version = "^0.23.2"}
|
uvicorn = {extras = ["standard"], version = "^0.23.2"}
|
||||||
webdavclient3 = "^3.14.6"
|
webdavclient3 = "^3.14.6"
|
||||||
|
asyncify = "^0.9.2"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
flake8 = "^6.1.0"
|
flake8 = "^6.1.0"
|
||||||
|
|
Loading…
Reference in a new issue