Compare commits

..

8 commits

Author SHA1 Message Date
3a64668d89 🚧 api: building redis cache decorator 2026-02-25 23:00:09 +01:00
21defd1e3d Merge branch 'develop' into feature/webdav-rework 2026-02-25 22:55:03 +01:00
75b3f01651 🚧 api: begin building own redis cache decorator 2026-02-25 22:51:37 +01:00
1ca9a2083e api: admin/credentials: only allow certain "name" values 2026-02-25 22:50:24 +01:00
7451205bf4 ⬆️ ui: upgrade bulma-toast 2026-02-22 16:51:34 +00:00
049ae8fc56 🚧 webdav rework
- use instance instead of class methods
- prettier cache keys
2026-02-22 16:39:25 +00:00
7b65d8c9b5 🧹 production script: use pydantic constrained integers 2026-02-22 16:38:50 +00:00
09b9886ee7 Merge tag '0.2.0' into develop
 re-scaffolded both projects

- api: "poetry", "isort", "black", "flake8" -> astral.sh tooling
- ui: "webpack" -> "vite"
2026-02-22 13:31:53 +00:00
15 changed files with 245 additions and 90 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

@ -0,0 +1,113 @@
import inspect
from base64 import urlsafe_b64encode
from collections.abc import Callable
from dataclasses import dataclass
from functools import wraps
from itertools import chain
from typing import Iterable, ParamSpec, TypeVar
from redis import Redis
@dataclass(frozen=True, kw_only=True, slots=True)
class Config:
redis: Redis
prefix: str | None = "cache"
ttl_fresh: int = 600
ttl_stale: int | None = None
def qualified_name(callable_obj) -> str:
# callable classes/instances
if hasattr(callable_obj, "__call__") and not inspect.isroutine(callable_obj):
# callable instance: use its class
cls = callable_obj.__class__
module = getattr(cls, "__module__", None)
qual = getattr(cls, "__qualname__", cls.__name__)
return f"{module}.{qual}" if module else qual
# functions, methods, builtins
# unwrap descriptors like staticmethod/classmethod
if isinstance(callable_obj, staticmethod | classmethod):
callable_obj = callable_obj.__func__
# bound method
if inspect.ismethod(callable_obj):
func = callable_obj.__func__
owner = getattr(func, "__qualname__", func.__name__).rsplit(".", 1)[0]
module = getattr(func, "__module__", None)
qual = f"{owner}.{func.__name__}"
return f"{module}.{qual}" if module else qual
# regular function or builtin
if inspect.isfunction(callable_obj) or inspect.isbuiltin(callable_obj):
module = getattr(callable_obj, "__module__", None)
qual = getattr(callable_obj, "__qualname__", callable_obj.__name__)
return f"{module}.{qual}" if module else qual
# fallback for other callables (functors, functools.partial, etc.)
try:
module = getattr(callable_obj, "__module__", None)
qual = getattr(callable_obj, "__qualname__", None) or getattr(
callable_obj, "__name__", type(callable_obj).__name__
)
return f"{module}.{qual}" if module else qual
except Exception:
return urlsafe_b64encode(repr(callable_obj).encode("utf-8")).decode("utf-8")
P = ParamSpec("P")
R = TypeVar("R")
def args_slice(func: Callable[P, R], *args: Iterable) -> tuple:
if hasattr(func, "__call__") and not inspect.isroutine(func):
return args[1:]
if isinstance(func, staticmethod | classmethod):
func = func.__func__
if inspect.ismethod(func):
return args[1:]
return tuple(*args)
def cache_key(func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> str:
"""Return a cache key for use with cached methods."""
kwargs_by_key = sorted(kwargs.items(), key=lambda kv: kv[0])
parts = chain(
(qualified_name(func),),
# positional args
(repr(arg) for arg in args_slice(func, args)),
# keyword args
(f"{k}={v!r}" for k, v in kwargs_by_key),
)
return ":".join(parts)
def redis_cached(cfg: Config) -> Callable[[Callable[P, R]], Callable[P, R]]:
""" """
def decorator(func: Callable[P, R]) -> Callable[P, R]:
""" """
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
key = cache_key(func, *args, **kwargs)
if cfg.prefix is not None:
key = f"{cfg.prefix}:{key}"
# pre-hook
result = func(*args, **kwargs)
# post-hook
return result
return wrapper
return decorator

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
"webdav_hostname": SETTINGS.webdav.url,
"webdav_login": SETTINGS.webdav.auth.username,
"webdav_password": SETTINGS.webdav.auth.password,
}
)
_cache = RedisCache( def __init__(self, settings: Settings, redis: Redis, ttl_sec: int) -> None:
cache=Redis( try:
host=SETTINGS.redis.host, self._webdav_client = WebDAVclient(
port=SETTINGS.redis.port, {
db=SETTINGS.redis.db, "webdav_hostname": settings.url,
protocol=SETTINGS.redis.protocol, "webdav_login": settings.username,
), "webdav_password": settings.password,
ttl=SETTINGS.webdav.cache_ttl, }
) )
assert self._webdav_client.check() is True
except AssertionError:
raise RuntimeError("WebDAV connection failed!")
self._cache = RedisCache(cache=redis, ttl=ttl_sec)
@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

@ -2,14 +2,14 @@ import os
from granian import Granian from granian import Granian
from granian.constants import Interfaces, Loops from granian.constants import Interfaces, Loops
from pydantic import BaseModel, Field from pydantic import BaseModel, PositiveInt
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
class WorkersSettings(BaseModel): class WorkersSettings(BaseModel):
per_core: int = Field(1, ge=1) per_core: PositiveInt = 1
max: int | None = Field(None, ge=1) max: PositiveInt | None = None
exact: int | None = Field(None, ge=1) exact: PositiveInt | None = None
@property @property
def count(self) -> int: def count(self) -> int:

View file

@ -1,6 +1,7 @@
from datetime import date from datetime import date
from enum import Enum
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from advent22_api.core.helpers import EventDates from advent22_api.core.helpers import EventDates
@ -59,7 +60,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 +113,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,
}, },
} }
@ -174,16 +174,21 @@ async def put_doors(
await cal_cfg.change(cfg) await cal_cfg.change(cfg)
class CredentialsName(str, Enum):
DAV = "dav"
UI = "ui"
@router.get("/credentials/{name}") @router.get("/credentials/{name}")
async def get_credentials( async def get_credentials(
name: str, name: CredentialsName,
_: None = Depends(require_admin), _: None = Depends(require_admin),
cfg: Config = Depends(get_config), cfg: Config = Depends(get_config),
) -> Credentials: ) -> Credentials:
if name == "dav": if name == CredentialsName.DAV:
return SETTINGS.webdav.auth return SETTINGS.webdav.auth
elif name == "ui": elif name == CredentialsName.UI:
return cfg.admin return cfg.admin
else: else:
return Credentials() raise HTTPException(status.HTTP_400_BAD_REQUEST)

View file

@ -36,7 +36,7 @@
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"axios": "^1.13.5", "axios": "^1.13.5",
"bulma": "^1.0.4", "bulma": "^1.0.4",
"bulma-toast": "2.4.3", "bulma-toast": "2.4.4",
"eslint": "^10.0.1", "eslint": "^10.0.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.49.0", "eslint-plugin-oxlint": "~1.49.0",

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;
}; };
} }

View file

@ -17,6 +17,7 @@ advent22Store().init();
app.mount("#app"); app.mount("#app");
toast_set_defaults({ toast_set_defaults({
message: "",
duration: 10e3, duration: 10e3,
pauseOnHover: true, pauseOnHover: true,
dismissible: true, dismissible: true,

View file

@ -2075,7 +2075,7 @@ __metadata:
animate.css: "npm:^4.1.1" animate.css: "npm:^4.1.1"
axios: "npm:^1.13.5" axios: "npm:^1.13.5"
bulma: "npm:^1.0.4" bulma: "npm:^1.0.4"
bulma-toast: "npm:2.4.3" bulma-toast: "npm:2.4.4"
eslint: "npm:^10.0.1" eslint: "npm:^10.0.1"
eslint-config-prettier: "npm:^10.1.8" eslint-config-prettier: "npm:^10.1.8"
eslint-plugin-oxlint: "npm:~1.49.0" eslint-plugin-oxlint: "npm:~1.49.0"
@ -2311,10 +2311,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"bulma-toast@npm:2.4.3": "bulma-toast@npm:2.4.4":
version: 2.4.3 version: 2.4.4
resolution: "bulma-toast@npm:2.4.3" resolution: "bulma-toast@npm:2.4.4"
checksum: 10c0/40dd9668643338496eb28caca9b772a6002d9c6fbdc5d76237cbdaaa8f56c8ced39965705f92f5d5a29f3e6df57f70a8fa311cf05c14075aacf93f96f7338470 checksum: 10c0/ccb36b5c632585e9e5bca4b7da7fa5f5e0e87da6244cca580bbb95fc8f3d0dc78d8b279fe0dfe024818baa1c47c2139e50d052447b130fb525ae5ffdb297acfd
languageName: node languageName: node
linkType: hard linkType: hard