py3.12 and refactoring: "core" module [wip]

This commit is contained in:
Jörn-Michael Miehe 2023-10-18 18:55:54 +02:00
parent b0e95af44e
commit ddde3bd024
16 changed files with 524 additions and 458 deletions

View file

@ -14,6 +14,8 @@
],
"env": {
"PYDEVD_DISABLE_FILE_VALIDATION": "1",
"LOG_LEVEL": "DEBUG",
"WEBDAV__CACHE_TTL": "30",
},
"justMyCode": true
}

View file

@ -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):

View file

@ -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",

View file

@ -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

View 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)

View 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)

View file

@ -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

View 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()

View 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))

View file

@ -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,
)

View file

@ -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.

View file

@ -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()

View file

@ -1,6 +1,6 @@
from uvicorn import run as uvicorn_run
from .settings import SETTINGS
from .core.settings import SETTINGS
def main() -> None:

View file

@ -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
View file

@ -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"

View file

@ -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"