🚧 webdav rework

- use instance instead of class methods
- prettier cache keys
This commit is contained in:
Jörn-Michael Miehe 2026-02-22 17:36:35 +01:00
parent 7b65d8c9b5
commit 049ae8fc56
10 changed files with 110 additions and 75 deletions

View file

@ -21,7 +21,7 @@
"${workspaceFolder}/advent22_api" "${workspaceFolder}/advent22_api"
], ],
"env": { "env": {
"ADVENT22__WEBDAV__CACHE_TTL": "30" "ADVENT22__REDIS__CACHE_TTL": "30"
}, },
"justMyCode": true "justMyCode": true
} }

View file

@ -5,7 +5,7 @@ from fastapi import Depends
from pydantic import BaseModel from pydantic import BaseModel
from .config import Config, get_config from .config import Config, get_config
from .dav.webdav import WebDAV from .settings import WEBDAV
class DoorSaved(BaseModel): class DoorSaved(BaseModel):
@ -37,7 +37,7 @@ class CalendarConfig(BaseModel):
Kalender Konfiguration ändern Kalender Konfiguration ändern
""" """
await WebDAV.write_str( await WEBDAV.write_str(
path=f"files/{cfg.calendar}", path=f"files/{cfg.calendar}",
content=tomli_w.dumps(self.model_dump()), content=tomli_w.dumps(self.model_dump()),
) )
@ -50,5 +50,5 @@ async def get_calendar_config(
Kalender Konfiguration lesen Kalender Konfiguration lesen
""" """
txt = await WebDAV.read_str(path=f"files/{cfg.calendar}") txt = await WEBDAV.read_str(path=f"files/{cfg.calendar}")
return CalendarConfig.model_validate(tomllib.loads(txt)) return CalendarConfig.model_validate(tomllib.loads(txt))

View file

@ -3,8 +3,7 @@ import tomllib
from markdown import markdown from markdown import markdown
from pydantic import BaseModel, ConfigDict, field_validator from pydantic import BaseModel, ConfigDict, field_validator
from .dav.webdav import WebDAV from .settings import SETTINGS, WEBDAV, Credentials
from .settings import SETTINGS, Credentials
from .transformed_string import TransformedString from .transformed_string import TransformedString
@ -77,5 +76,5 @@ async def get_config() -> Config:
Globale Konfiguration lesen Globale Konfiguration lesen
""" """
txt = await WebDAV.read_str(path=SETTINGS.webdav.config_filename) txt = await WEBDAV.read_str(path=SETTINGS.webdav.config_filename)
return Config.model_validate(tomllib.loads(txt)) return Config.model_validate(tomllib.loads(txt))

View file

@ -1,8 +1,8 @@
from itertools import chain
from json import JSONDecodeError from json import JSONDecodeError
from typing import Callable, Hashable from typing import Any, Callable
import requests import requests
from cachetools.keys import hashkey
from CacheToolsUtils import RedisCache as __RedisCache from CacheToolsUtils import RedisCache as __RedisCache
from redis.typing import EncodableT, ResponseT from redis.typing import EncodableT, ResponseT
from webdav3.client import Client as __WebDAVclient from webdav3.client import Client as __WebDAVclient
@ -11,12 +11,18 @@ from webdav3.client import Client as __WebDAVclient
def davkey( def davkey(
name: str, name: str,
slice: slice = slice(1, None), slice: slice = slice(1, None),
) -> Callable[..., tuple[Hashable, ...]]: ) -> Callable[..., str]:
def func(*args, **kwargs) -> tuple[Hashable, ...]: def func(*args: Any, **kwargs: Any) -> str:
"""Return a cache key for use with cached methods.""" """Return a cache key for use with cached methods."""
key = hashkey(name, *args[slice], **kwargs) call_args = chain(
return hashkey(*(str(key_item) for key_item in key)) # positional args
(f"{arg!r}" for arg in args[slice]),
# keyword args
(f"{k}:{v!r}" for k, v in kwargs.items()),
)
return f"{name}({', '.join(call_args)})"
return func return func

View file

@ -1,108 +1,113 @@
import logging import logging
import re import re
from dataclasses import dataclass
from io import BytesIO from io import BytesIO
from asyncify import asyncify from asyncify import asyncify
from cachetools import cachedmethod from cachetools import cachedmethod
from redis import Redis from redis import Redis
from ..settings import SETTINGS
from .helpers import RedisCache, WebDAVclient, davkey from .helpers import RedisCache, WebDAVclient, davkey
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@dataclass(kw_only=True, frozen=True, slots=True)
class Settings:
url: str
username: str = "johndoe"
password: str = "s3cr3t!"
class WebDAV: class WebDAV:
_webdav_client = WebDAVclient( _webdav_client: WebDAVclient
_cache: RedisCache
def __init__(self, settings: Settings, redis: Redis, ttl_sec: int) -> None:
try:
self._webdav_client = WebDAVclient(
{ {
"webdav_hostname": SETTINGS.webdav.url, "webdav_hostname": settings.url,
"webdav_login": SETTINGS.webdav.auth.username, "webdav_login": settings.username,
"webdav_password": SETTINGS.webdav.auth.password, "webdav_password": settings.password,
} }
) )
assert self._webdav_client.check() is True
_cache = RedisCache( except AssertionError:
cache=Redis( raise RuntimeError("WebDAV connection failed!")
host=SETTINGS.redis.host,
port=SETTINGS.redis.port, self._cache = RedisCache(cache=redis, ttl=ttl_sec)
db=SETTINGS.redis.db,
protocol=SETTINGS.redis.protocol,
),
ttl=SETTINGS.webdav.cache_ttl,
)
@classmethod
@asyncify @asyncify
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("list_files")) @cachedmethod(cache=lambda self: self._cache, key=davkey("list_files"))
def list_files( def _list_files(self, directory: str = "") -> list[str]:
cls,
directory: str = "",
*,
regex: re.Pattern[str] = re.compile(""),
) -> list[str]:
""" """
List files in directory `directory` matching RegEx `regex` List files in directory `directory` matching RegEx `regex`
""" """
_logger.debug(f"list_files {directory!r}") return self._webdav_client.list(directory)
ls = cls._webdav_client.list(directory)
async def list_files(
self,
directory: str = "",
*,
regex: re.Pattern[str] = re.compile(""),
) -> list[str]:
_logger.debug(f"list_files {directory!r} ({regex!r})")
ls = await self._list_files(directory)
return [path for path in ls if regex.search(path)] return [path for path in ls if regex.search(path)]
@classmethod
@asyncify @asyncify
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("exists")) @cachedmethod(cache=lambda self: self._cache, key=davkey("exists"))
def exists(cls, path: str) -> bool: def exists(self, path: str) -> bool:
""" """
`True` iff there is a WebDAV resource at `path` `True` iff there is a WebDAV resource at `path`
""" """
_logger.debug(f"file_exists {path!r}") _logger.debug(f"file_exists {path!r}")
return cls._webdav_client.check(path) return self._webdav_client.check(path)
@classmethod
@asyncify @asyncify
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("read_bytes")) @cachedmethod(cache=lambda self: self._cache, key=davkey("read_bytes"))
def read_bytes(cls, path: str) -> bytes: def read_bytes(self, path: str) -> bytes:
""" """
Load WebDAV file from `path` as bytes Load WebDAV file from `path` as bytes
""" """
_logger.debug(f"read_bytes {path!r}") _logger.debug(f"read_bytes {path!r}")
buffer = BytesIO() buffer = BytesIO()
cls._webdav_client.download_from(buffer, path) self._webdav_client.download_from(buffer, path)
buffer.seek(0) buffer.seek(0)
return buffer.read() return buffer.read()
@classmethod async def read_str(self, path: str, encoding="utf-8") -> str:
async def read_str(cls, path: str, encoding="utf-8") -> str:
""" """
Load WebDAV file from `path` as string Load WebDAV file from `path` as string
""" """
_logger.debug(f"read_str {path!r}") _logger.debug(f"read_str {path!r}")
return (await cls.read_bytes(path)).decode(encoding=encoding).strip() return (await self.read_bytes(path)).decode(encoding=encoding).strip()
@classmethod
@asyncify @asyncify
def write_bytes(cls, path: str, buffer: bytes) -> None: def write_bytes(self, path: str, buffer: bytes) -> None:
""" """
Write bytes from `buffer` into WebDAV file at `path` Write bytes from `buffer` into WebDAV file at `path`
""" """
_logger.debug(f"write_bytes {path!r}") _logger.debug(f"write_bytes {path!r}")
cls._webdav_client.upload_to(buffer, path) self._webdav_client.upload_to(buffer, path)
# invalidate cache entry # invalidate cache entry
# explicit slice as there is no "cls" argument # begin slice at 0 (there is no "self" argument)
del cls._cache[davkey("read_bytes", slice(0, None))(path)] del self._cache[davkey("read_bytes", slice(0, None))(path)]
@classmethod async def write_str(self, path: str, content: str, encoding="utf-8") -> None:
async def write_str(cls, path: str, content: str, encoding="utf-8") -> None:
""" """
Write string from `content` into WebDAV file at `path` Write string from `content` into WebDAV file at `path`
""" """
_logger.debug(f"write_str {path!r}") _logger.debug(f"write_str {path!r}")
await cls.write_bytes(path, content.encode(encoding=encoding)) await self.write_bytes(path, content.encode(encoding=encoding))

View file

@ -11,7 +11,7 @@ from PIL.Image import Image, Resampling
from pydantic import BaseModel from pydantic import BaseModel
from .config import get_config from .config import get_config
from .dav.webdav import WebDAV from .settings import WEBDAV
T = TypeVar("T") T = TypeVar("T")
RE_IMG = re.compile(r"\.(gif|jpe?g|tiff?|png|bmp)$", flags=re.IGNORECASE) RE_IMG = re.compile(r"\.(gif|jpe?g|tiff?|png|bmp)$", flags=re.IGNORECASE)
@ -94,7 +94,7 @@ def list_helper(
async def _list_helper() -> list[str]: async def _list_helper() -> list[str]:
return [ return [
f"{directory}/{file}" f"{directory}/{file}"
for file in await WebDAV.list_files(directory=directory, regex=regex) for file in await WEBDAV.list_files(directory=directory, regex=regex)
] ]
return _list_helper return _list_helper
@ -110,10 +110,10 @@ async def load_image(file_name: str) -> Image:
Versuche, Bild aus Datei zu laden Versuche, Bild aus Datei zu laden
""" """
if not await WebDAV.exists(file_name): if not await WEBDAV.exists(file_name):
raise RuntimeError(f"DAV-File {file_name} does not exist!") raise RuntimeError(f"DAV-File {file_name} does not exist!")
return PILImage.open(BytesIO(await WebDAV.read_bytes(file_name))) return PILImage.open(BytesIO(await WEBDAV.read_bytes(file_name)))
class ImageData(BaseModel): class ImageData(BaseModel):

View file

@ -1,5 +1,9 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from redis import ConnectionError, Redis
from .dav.webdav import Settings as WebDAVSettings
from .dav.webdav import WebDAV
class Credentials(BaseModel): class Credentials(BaseModel):
@ -22,7 +26,6 @@ class DavSettings(BaseModel):
password="password", password="password",
) )
cache_ttl: int = 60 * 10
config_filename: str = "config.toml" config_filename: str = "config.toml"
@property @property
@ -39,10 +42,12 @@ class RedisSettings(BaseModel):
Connection to a redis server. Connection to a redis server.
""" """
cache_ttl: int = 60 * 10
host: str = "localhost" host: str = "localhost"
port: int = 6379 port: int = 6379
db: int = 0 db: int = 0
protocol: int = 3 protocol_version: int = 3
class Settings(BaseSettings): class Settings(BaseSettings):
@ -98,3 +103,26 @@ class Settings(BaseSettings):
SETTINGS = Settings() SETTINGS = Settings()
try:
_REDIS = Redis(
host=SETTINGS.redis.host,
port=SETTINGS.redis.port,
db=SETTINGS.redis.db,
protocol=SETTINGS.redis.protocol_version,
)
_REDIS.ping()
except ConnectionError:
raise RuntimeError("Redis connection failed!")
WEBDAV = WebDAV(
WebDAVSettings(
url=SETTINGS.webdav.url,
username=SETTINGS.webdav.auth.username,
password=SETTINGS.webdav.auth.password,
),
_REDIS,
SETTINGS.redis.cache_ttl,
)

View file

@ -59,7 +59,6 @@ class AdminConfigModel(BaseModel):
class __WebDAV(BaseModel): class __WebDAV(BaseModel):
url: str url: str
cache_ttl: int
config_file: str config_file: str
solution: __Solution solution: __Solution
@ -113,7 +112,7 @@ async def get_config_model(
"redis": SETTINGS.redis, "redis": SETTINGS.redis,
"webdav": { "webdav": {
"url": SETTINGS.webdav.url, "url": SETTINGS.webdav.url,
"cache_ttl": SETTINGS.webdav.cache_ttl, "cache_ttl": SETTINGS.redis.cache_ttl,
"config_file": SETTINGS.webdav.config_filename, "config_file": SETTINGS.webdav.config_filename,
}, },
} }

View file

@ -140,9 +140,6 @@
</BulmaSecret> </BulmaSecret>
</dd> </dd>
<dt>Cache-Dauer</dt>
<dd>{{ admin_config_model.webdav.cache_ttl }} s</dd>
<dt>Konfigurationsdatei</dt> <dt>Konfigurationsdatei</dt>
<dd>{{ admin_config_model.webdav.config_file }}</dd> <dd>{{ admin_config_model.webdav.config_file }}</dd>
</dl> </dl>
@ -152,10 +149,11 @@
<h3>Sonstige</h3> <h3>Sonstige</h3>
<dl> <dl>
<dt>Redis</dt> <dt>Redis</dt>
<dd>Cache-Dauer: {{ admin_config_model.redis.cache_ttl }} s</dd>
<dd>Host: {{ admin_config_model.redis.host }}</dd> <dd>Host: {{ admin_config_model.redis.host }}</dd>
<dd>Port: {{ admin_config_model.redis.port }}</dd> <dd>Port: {{ admin_config_model.redis.port }}</dd>
<dd>Datenbank: {{ admin_config_model.redis.db }}</dd> <dd>Datenbank: {{ admin_config_model.redis.db }}</dd>
<dd>Protokoll: {{ admin_config_model.redis.protocol }}</dd> <dd>Protokoll: {{ admin_config_model.redis.protocol_version }}</dd>
<dt>UI-Admin</dt> <dt>UI-Admin</dt>
<dd class="is-family-monospace"> <dd class="is-family-monospace">
@ -219,14 +217,14 @@ const admin_config_model = ref<AdminConfigModel>({
}, },
fonts: [{ file: "consetetur", size: 0 }], fonts: [{ file: "consetetur", size: 0 }],
redis: { redis: {
cache_ttl: 0,
host: "0.0.0.0", host: "0.0.0.0",
port: 6379, port: 6379,
db: 0, db: 0,
protocol: 3, protocol_version: 3,
}, },
webdav: { webdav: {
url: "sadipscing elitr", url: "sadipscing elitr",
cache_ttl: 0,
config_file: "sed diam nonumy", config_file: "sed diam nonumy",
}, },
}); });

View file

@ -26,14 +26,14 @@ export interface AdminConfigModel {
}; };
fonts: { file: string; size: number }[]; fonts: { file: string; size: number }[];
redis: { redis: {
cache_ttl: number;
host: string; host: string;
port: number; port: number;
db: number; db: number;
protocol: number; protocol_version: number;
}; };
webdav: { webdav: {
url: string; url: string;
cache_ttl: number;
config_file: string; config_file: string;
}; };
} }