From 5cf0846c6814e41d8d7bccef871f7c5435071c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 01:19:15 +0000 Subject: [PATCH 01/15] basic readability --- api/advent22_api/dav_common.py | 2 +- api/advent22_api/routers/_misc.py | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/api/advent22_api/dav_common.py b/api/advent22_api/dav_common.py index 264182a..6f6e3c2 100644 --- a/api/advent22_api/dav_common.py +++ b/api/advent22_api/dav_common.py @@ -17,7 +17,7 @@ _WEBDAV_CLIENT = WebDAVclient( @AsyncTTL(time_to_live=SETTINGS.cache_ttl) -async def dav_list_files(regex: re.Pattern, directory: str = "") -> list[str]: +async def dav_list_files(regex: re.Pattern[str], directory: str = "") -> list[str]: ls = _WEBDAV_CLIENT.list(directory) return [f"{directory}/{path}" for path in ls if regex.search(path)] diff --git a/api/advent22_api/routers/_misc.py b/api/advent22_api/routers/_misc.py index ec44dd2..899bf88 100644 --- a/api/advent22_api/routers/_misc.py +++ b/api/advent22_api/routers/_misc.py @@ -51,17 +51,15 @@ async def get_letter( return (await shuffle(cfg.puzzle.solution))[index] -_RE_IMAGE_FILE = re.compile( - r"\.(gif|jpe?g|tiff?|png|bmp)$", - flags=re.IGNORECASE, -) - - async def list_images_auto() -> list[str]: """ Finde alle Bilder im "automatisch"-Verzeichnis """ - ls = await dav_list_files(_RE_IMAGE_FILE, "/images_auto") + + ls = await dav_list_files( + re.compile(r"\.(gif|jpe?g|tiff?|png|bmp)$", flags=re.IGNORECASE), + "/images_auto", + ) ls = await set_length(ls, 24) return await shuffle(ls) From 30c5788d4d698556e35d2b568c3ebb245b4d5a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 01:39:53 +0000 Subject: [PATCH 02/15] cache_ttl default --- api/.vscode/launch.json | 1 + api/advent22_api/settings.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/api/.vscode/launch.json b/api/.vscode/launch.json index 88c3bb3..5324b52 100644 --- a/api/.vscode/launch.json +++ b/api/.vscode/launch.json @@ -14,6 +14,7 @@ ], "env": { "PYDEVD_DISABLE_FILE_VALIDATION": "1", + "CACHE_TTL": "30", }, "justMyCode": true, } diff --git a/api/advent22_api/settings.py b/api/advent22_api/settings.py index 2adaa8a..6b79a60 100644 --- a/api/advent22_api/settings.py +++ b/api/advent22_api/settings.py @@ -58,7 +58,7 @@ class Settings(BaseSettings): webdav: DavSettings = DavSettings() - cache_ttl: int = 30 + cache_ttl: int = 60 * 30 config_filename: str = "config.toml" From 452040a0ae9187e5e6a1f2947a7f0f1816b45cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 02:45:00 +0000 Subject: [PATCH 03/15] WIP: core module --- api/advent22_api/core/advent_image.py | 133 ++++++++++++++++++++++++++ api/advent22_api/core/random.py | 19 ++++ api/advent22_api/core/webdav.py | 94 ++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 api/advent22_api/core/advent_image.py create mode 100644 api/advent22_api/core/random.py create mode 100644 api/advent22_api/core/webdav.py diff --git a/api/advent22_api/core/advent_image.py b/api/advent22_api/core/advent_image.py new file mode 100644 index 0000000..8a071a0 --- /dev/null +++ b/api/advent22_api/core/advent_image.py @@ -0,0 +1,133 @@ +import colorsys +from dataclasses import dataclass +from typing import Self + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + + +@dataclass(slots=True, frozen=True) +class AdventImage: + img: Image.Image + + @classmethod + async def from_img(cls, img: Image.Image) -> Self: + """ + Einen quadratischen Ausschnitt aus der Mitte des Bilds nehmen + """ + + # Farbmodell festlegen + img = img.convert(mode="RGB") + + # Größen bestimmen + width, height = img.size + square = min(width, height) + + # zuschneiden + img = img.crop( + box=( + int((width - square) / 2), + int((height - square) / 2), + int((width + square) / 2), + int((height + square) / 2), + ) + ) + + # skalieren + return cls( + img.resize( + size=(500, 500), + resample=Image.LANCZOS, + ) + ) + + async def get_text_box( + self, + xy: tuple[float, float], + text: str | bytes, + font: "ImageFont._Font", + anchor: str | None = "mm", + **text_kwargs, + ) -> Image._Box | None: + """ + Koordinaten (links, oben, rechts, unten) des betroffenen + Rechtecks bestimmen, wenn das Bild mit einem Text + versehen wird + """ + + # Neues 1-Bit Bild, gleiche Größe + mask = Image.new(mode="1", size=self.img.size, color=0) + + # Text auf Maske auftragen + ImageDraw.Draw(mask).text( + xy=xy, + text=text, + font=font, + anchor=anchor, + fill=1, + **text_kwargs, + ) + + # betroffenen Pixelbereich bestimmen + return mask.getbbox() + + async def get_average_color( + self, + box: Image._Box, + ) -> tuple[int, int, int]: + """ + Durchschnittsfarbe eines rechteckigen Ausschnitts in + einem Bild berechnen + """ + + pixel_data = self.img.crop(box).getdata() + mean_color: np.ndarray = np.mean(a=pixel_data, axis=0) + + return tuple(mean_color.astype(int)[:3]) + + async def hide_text( + self, + xy: tuple[float, float], + text: str | bytes, + font: "ImageFont._Font", + anchor: str | None = "mm", + **text_kwargs, + ) -> None: + """ + Text `text` in Bild an Position `xy` verstecken. + Weitere Parameter wie bei `ImageDraw.text()`. + """ + + # betroffenen Bildbereich bestimmen + text_box = await self.get_text_box( + xy=xy, text=text, font=font, anchor=anchor, **text_kwargs + ) + + if text_box is not None: + # Durchschnittsfarbe bestimmen + text_color = await self.get_average_color( + box=text_box, + ) + + # etwas heller/dunkler machen + tc_h, tc_s, tc_v = colorsys.rgb_to_hsv(*text_color) + tc_v = int((tc_v - 127) * 0.97) + 127 + + if tc_v < 127: + tc_v += 3 + + else: + tc_v -= 3 + + text_color = colorsys.hsv_to_rgb(tc_h, tc_s, tc_v) + text_color = tuple(int(val) for val in text_color) + + # Buchstaben verstecken + ImageDraw.Draw(self.img).text( + xy=xy, + text=text, + font=font, + fill=text_color, + anchor=anchor, + **text_kwargs, + ) diff --git a/api/advent22_api/core/random.py b/api/advent22_api/core/random.py new file mode 100644 index 0000000..e45026f --- /dev/null +++ b/api/advent22_api/core/random.py @@ -0,0 +1,19 @@ +import random +from typing import Any, Self, Sequence + +from ..config import get_config + + +class Random(random.Random): + @classmethod + async def get(cls, bonus_salt: Any = "") -> Self: + cfg = await get_config() + return cls(f"{cfg.puzzle.solution}{bonus_salt}{cfg.puzzle.random_pepper}") + + +async def shuffle(seq: Sequence, rnd: random.Random | None = None) -> list: + # Zufallsgenerator + rnd = rnd or await Random.get() + + # Elemente mischen + return rnd.sample(seq, len(seq)) diff --git a/api/advent22_api/core/webdav.py b/api/advent22_api/core/webdav.py new file mode 100644 index 0000000..0553312 --- /dev/null +++ b/api/advent22_api/core/webdav.py @@ -0,0 +1,94 @@ +import re +from contextlib import contextmanager +from io import BytesIO, TextIOWrapper +from typing import ContextManager, Iterator + +from cache import AsyncTTL +from webdav3.client import Client as WebDAVclient + +from ..settings import SETTINGS + + +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, + } + ) + + @AsyncTTL(time_to_live=SETTINGS.cache_ttl) + @classmethod + 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 + """ + + ls = cls._webdav_client.list(directory) + + return [f"{directory}/{path}" for path in ls if regex.search(path)] + + @AsyncTTL(time_to_live=SETTINGS.cache_ttl) + @classmethod + async def file_exists(cls, path: str) -> bool: + """ + `True`, wenn an Pfad `path` eine Datei existiert + """ + + return cls._webdav_client.check(path) + + @AsyncTTL(time_to_live=SETTINGS.cache_ttl) + @classmethod + async def read_buffer(cls, path: str) -> ContextManager[BytesIO]: + """ + Datei aus Pfad `path` in einen `BytesIO` Puffer laden + """ + + buffer = BytesIO() + cls._webdav_client.resource(path).write_to(buffer) + + @contextmanager + def ctx() -> Iterator[BytesIO]: + buffer.seek(0) + yield buffer + + return ctx() + + @AsyncTTL(time_to_live=SETTINGS.cache_ttl) + @classmethod + async def read_text(cls, path: str, encoding="utf-8") -> str: + """ + Datei aus Pfad `path` als string zurückgeben + """ + + with await cls.read_buffer(path) as buffer: + tio = TextIOWrapper(buffer, encoding=encoding) + tio.seek(0) + + return tio.read().strip() + + @classmethod + async def write_buffer(cls, path: str, buffer: BytesIO) -> None: + """ + Puffer `buffer` in Datei in Pfad `path` schreiben + """ + + cls._webdav_client.resource(path).read_from(buffer) + + @classmethod + async def write_text(cls, path: str, content: str, encoding="utf-8") -> None: + """ + String `content` in Datei in Pfad `path` schreiben + """ + + buffer = BytesIO(content.encode(encoding=encoding)) + buffer.seek(0) + + await cls.write_buffer(path, buffer) From 74688f45b866c7770eca7f7be50e123f804c1ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 11:27:23 +0000 Subject: [PATCH 04/15] typing issues --- api/advent22_api/core/advent_image.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/api/advent22_api/core/advent_image.py b/api/advent22_api/core/advent_image.py index 8a071a0..4a14774 100644 --- a/api/advent22_api/core/advent_image.py +++ b/api/advent22_api/core/advent_image.py @@ -1,10 +1,13 @@ import colorsys from dataclasses import dataclass -from typing import Self +from typing import Self, TypeAlias, cast import numpy as np from PIL import Image, ImageDraw, ImageFont +_RGB: TypeAlias = tuple[int, int, int] +_XY: TypeAlias = tuple[float, float] + @dataclass(slots=True, frozen=True) class AdventImage: @@ -43,7 +46,7 @@ class AdventImage: async def get_text_box( self, - xy: tuple[float, float], + xy: _XY, text: str | bytes, font: "ImageFont._Font", anchor: str | None = "mm", @@ -81,13 +84,13 @@ class AdventImage: """ pixel_data = self.img.crop(box).getdata() - mean_color: np.ndarray = np.mean(a=pixel_data, axis=0) + mean_color: np.ndarray = np.mean(pixel_data, axis=0) - return tuple(mean_color.astype(int)[:3]) + return cast(_RGB, tuple(mean_color.astype(int))) async def hide_text( self, - xy: tuple[float, float], + xy: _XY, text: str | bytes, font: "ImageFont._Font", anchor: str | None = "mm", @@ -127,7 +130,7 @@ class AdventImage: xy=xy, text=text, font=font, - fill=text_color, + fill=cast(_RGB, text_color), anchor=anchor, **text_kwargs, ) From d27d952d389ba0747ea54102672dd17f8cab8ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 15:30:52 +0000 Subject: [PATCH 05/15] async context manager for WebDAV.read_buffer --- api/advent22_api/core/webdav.py | 21 +++++++------ api/asynccontextmanager.py | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 api/asynccontextmanager.py diff --git a/api/advent22_api/core/webdav.py b/api/advent22_api/core/webdav.py index 0553312..66d1e20 100644 --- a/api/advent22_api/core/webdav.py +++ b/api/advent22_api/core/webdav.py @@ -1,7 +1,7 @@ import re -from contextlib import contextmanager +from contextlib import asynccontextmanager from io import BytesIO, TextIOWrapper -from typing import ContextManager, Iterator +from typing import AsyncContextManager, AsyncIterator from cache import AsyncTTL from webdav3.client import Client as WebDAVclient @@ -44,18 +44,21 @@ class WebDAV: return cls._webdav_client.check(path) - @AsyncTTL(time_to_live=SETTINGS.cache_ttl) @classmethod - async def read_buffer(cls, path: str) -> ContextManager[BytesIO]: + async def read_buffer(cls, path: str) -> AsyncContextManager[BytesIO]: """ Datei aus Pfad `path` in einen `BytesIO` Puffer laden """ - buffer = BytesIO() - cls._webdav_client.resource(path).write_to(buffer) + @AsyncTTL(time_to_live=SETTINGS.cache_ttl) + async def inner() -> BytesIO: + buffer = BytesIO() + cls._webdav_client.resource(path).write_to(buffer) + return buffer - @contextmanager - def ctx() -> Iterator[BytesIO]: + @asynccontextmanager + async def ctx() -> AsyncIterator[BytesIO]: + buffer = await inner() buffer.seek(0) yield buffer @@ -68,7 +71,7 @@ class WebDAV: Datei aus Pfad `path` als string zurückgeben """ - with await cls.read_buffer(path) as buffer: + async with await cls.read_buffer(path) as buffer: tio = TextIOWrapper(buffer, encoding=encoding) tio.seek(0) diff --git a/api/asynccontextmanager.py b/api/asynccontextmanager.py new file mode 100644 index 0000000..fba5832 --- /dev/null +++ b/api/asynccontextmanager.py @@ -0,0 +1,52 @@ +import asyncio +import functools +from contextlib import asynccontextmanager, contextmanager +from io import BytesIO +from typing import AsyncContextManager, AsyncIterator, ContextManager, Iterator + + +def get_buf_sync() -> ContextManager[BytesIO]: + @functools.lru_cache + def inner() -> BytesIO: + with open(__file__, "rb") as file: + return BytesIO(file.readline()) + + @contextmanager + def ctx() -> Iterator[BytesIO]: + buf = inner() + buf.seek(0) + yield buf + + return ctx() + + +def main_sync() -> None: + for _ in range(2): + with get_buf_sync() as buffer: + print(buffer.read()) + + +async def get_buf_async() -> AsyncContextManager[BytesIO]: + @functools.lru_cache + async def inner() -> BytesIO: + with open(__file__, "rb") as file: + return BytesIO(file.readline()) + + @asynccontextmanager + async def ctx() -> AsyncIterator[BytesIO]: + buf = await inner() + buf.seek(0) + yield buf + + return ctx() + + +async def main_async() -> None: + for _ in range(2): + async with await get_buf_async() as buffer: + print(buffer.read()) + + +if __name__ == "__main__": + main_sync() + asyncio.run(main_async()) From 5223efddb44bbee838cbacb506a5ddb80f4d4d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 15:54:11 +0000 Subject: [PATCH 06/15] wrapping experiments --- api/asynccontextmanager.py | 45 ++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/api/asynccontextmanager.py b/api/asynccontextmanager.py index fba5832..c226773 100644 --- a/api/asynccontextmanager.py +++ b/api/asynccontextmanager.py @@ -4,18 +4,27 @@ from contextlib import asynccontextmanager, contextmanager from io import BytesIO from typing import AsyncContextManager, AsyncIterator, ContextManager, Iterator +from cache import AsyncLRU + +# +# sync impl +# + def get_buf_sync() -> ContextManager[BytesIO]: - @functools.lru_cache - def inner() -> BytesIO: - with open(__file__, "rb") as file: - return BytesIO(file.readline()) + if getattr(get_buf_sync, "inner", None) is None: + + @functools.lru_cache + def inner() -> bytes: + print("sync open") + with open(__file__, "rb") as file: + return file.readline() + + setattr(get_buf_sync, "inner", inner) @contextmanager def ctx() -> Iterator[BytesIO]: - buf = inner() - buf.seek(0) - yield buf + yield BytesIO(get_buf_sync.inner()) return ctx() @@ -26,17 +35,25 @@ def main_sync() -> None: print(buffer.read()) +# +# async impl +# + + async def get_buf_async() -> AsyncContextManager[BytesIO]: - @functools.lru_cache - async def inner() -> BytesIO: - with open(__file__, "rb") as file: - return BytesIO(file.readline()) + if getattr(get_buf_async, "inner", None) is None: + + @AsyncLRU() + async def inner() -> bytes: + print("async open") + with open(__file__, "rb") as file: + return file.readline() + + setattr(get_buf_async, "inner", inner) @asynccontextmanager async def ctx() -> AsyncIterator[BytesIO]: - buf = await inner() - buf.seek(0) - yield buf + yield BytesIO(await get_buf_async.inner()) return ctx() From e894aad746fd3472eeec14f80ba95297d8303d80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 16:08:10 +0000 Subject: [PATCH 07/15] experimental success --- api/asynccontextmanager.py | 59 +++++++++++++++----------------------- 1 file changed, 23 insertions(+), 36 deletions(-) diff --git a/api/asynccontextmanager.py b/api/asynccontextmanager.py index c226773..25abf5c 100644 --- a/api/asynccontextmanager.py +++ b/api/asynccontextmanager.py @@ -1,38 +1,32 @@ import asyncio import functools -from contextlib import asynccontextmanager, contextmanager from io import BytesIO -from typing import AsyncContextManager, AsyncIterator, ContextManager, Iterator from cache import AsyncLRU +FILES = __file__, "pyproject.toml" +REPS = 3 + # # sync impl # -def get_buf_sync() -> ContextManager[BytesIO]: - if getattr(get_buf_sync, "inner", None) is None: +@functools.lru_cache +def get_bytes_sync(fn) -> bytes: + print("sync open") + with open(fn, "rb") as file: + return file.readline() - @functools.lru_cache - def inner() -> bytes: - print("sync open") - with open(__file__, "rb") as file: - return file.readline() - setattr(get_buf_sync, "inner", inner) - - @contextmanager - def ctx() -> Iterator[BytesIO]: - yield BytesIO(get_buf_sync.inner()) - - return ctx() +def get_buf_sync(fn) -> BytesIO: + return BytesIO(get_bytes_sync(fn)) def main_sync() -> None: - for _ in range(2): - with get_buf_sync() as buffer: - print(buffer.read()) + for fn in FILES: + for _ in range(REPS): + print(get_buf_sync(fn).read()) # @@ -40,28 +34,21 @@ def main_sync() -> None: # -async def get_buf_async() -> AsyncContextManager[BytesIO]: - if getattr(get_buf_async, "inner", None) is None: +@AsyncLRU() +async def get_bytes_async(fn) -> bytes: + print("async open") + with open(fn, "rb") as file: + return file.readline() - @AsyncLRU() - async def inner() -> bytes: - print("async open") - with open(__file__, "rb") as file: - return file.readline() - setattr(get_buf_async, "inner", inner) - - @asynccontextmanager - async def ctx() -> AsyncIterator[BytesIO]: - yield BytesIO(await get_buf_async.inner()) - - return ctx() +async def get_buf_async(fn) -> BytesIO: + return BytesIO(await get_bytes_async(fn)) async def main_async() -> None: - for _ in range(2): - async with await get_buf_async() as buffer: - print(buffer.read()) + for fn in FILES: + for _ in range(REPS): + print((await get_buf_async(fn)).read()) if __name__ == "__main__": From d51db8b836cb2f9453034c60fda0544dfdcaf817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 16:19:26 +0000 Subject: [PATCH 08/15] apply experiments to WebDAV helper --- api/advent22_api/core/webdav.py | 37 +++++++--------------- api/asynccontextmanager.py | 56 --------------------------------- 2 files changed, 11 insertions(+), 82 deletions(-) delete mode 100644 api/asynccontextmanager.py diff --git a/api/advent22_api/core/webdav.py b/api/advent22_api/core/webdav.py index 66d1e20..d10de41 100644 --- a/api/advent22_api/core/webdav.py +++ b/api/advent22_api/core/webdav.py @@ -1,7 +1,5 @@ import re -from contextlib import asynccontextmanager -from io import BytesIO, TextIOWrapper -from typing import AsyncContextManager, AsyncIterator +from io import BytesIO from cache import AsyncTTL from webdav3.client import Client as WebDAVclient @@ -44,38 +42,25 @@ class WebDAV: return cls._webdav_client.check(path) + @AsyncTTL(time_to_live=SETTINGS.cache_ttl) @classmethod - async def read_buffer(cls, path: str) -> AsyncContextManager[BytesIO]: + async def read_bytes(cls, path: str) -> bytes: """ - Datei aus Pfad `path` in einen `BytesIO` Puffer laden + Datei aus Pfad `path` als bytes laden """ - @AsyncTTL(time_to_live=SETTINGS.cache_ttl) - async def inner() -> BytesIO: - buffer = BytesIO() - cls._webdav_client.resource(path).write_to(buffer) - return buffer - - @asynccontextmanager - async def ctx() -> AsyncIterator[BytesIO]: - buffer = await inner() - buffer.seek(0) - yield buffer - - return ctx() + buffer = BytesIO() + cls._webdav_client.resource(path).write_to(buffer) + return buffer.read() @AsyncTTL(time_to_live=SETTINGS.cache_ttl) @classmethod - async def read_text(cls, path: str, encoding="utf-8") -> str: + async def read_str(cls, path: str, encoding="utf-8") -> str: """ - Datei aus Pfad `path` als string zurückgeben + Datei aus Pfad `path` als string laden """ - async with await cls.read_buffer(path) as buffer: - tio = TextIOWrapper(buffer, encoding=encoding) - tio.seek(0) - - return tio.read().strip() + return (await cls.read_bytes(path)).decode(encoding=encoding).strip() @classmethod async def write_buffer(cls, path: str, buffer: BytesIO) -> None: @@ -86,7 +71,7 @@ class WebDAV: cls._webdav_client.resource(path).read_from(buffer) @classmethod - async def write_text(cls, path: str, content: str, encoding="utf-8") -> None: + async def write_str(cls, path: str, content: str, encoding="utf-8") -> None: """ String `content` in Datei in Pfad `path` schreiben """ diff --git a/api/asynccontextmanager.py b/api/asynccontextmanager.py deleted file mode 100644 index 25abf5c..0000000 --- a/api/asynccontextmanager.py +++ /dev/null @@ -1,56 +0,0 @@ -import asyncio -import functools -from io import BytesIO - -from cache import AsyncLRU - -FILES = __file__, "pyproject.toml" -REPS = 3 - -# -# sync impl -# - - -@functools.lru_cache -def get_bytes_sync(fn) -> bytes: - print("sync open") - with open(fn, "rb") as file: - return file.readline() - - -def get_buf_sync(fn) -> BytesIO: - return BytesIO(get_bytes_sync(fn)) - - -def main_sync() -> None: - for fn in FILES: - for _ in range(REPS): - print(get_buf_sync(fn).read()) - - -# -# async impl -# - - -@AsyncLRU() -async def get_bytes_async(fn) -> bytes: - print("async open") - with open(fn, "rb") as file: - return file.readline() - - -async def get_buf_async(fn) -> BytesIO: - return BytesIO(await get_bytes_async(fn)) - - -async def main_async() -> None: - for fn in FILES: - for _ in range(REPS): - print((await get_buf_async(fn)).read()) - - -if __name__ == "__main__": - main_sync() - asyncio.run(main_async()) From b1748ea0fbaf54bf890329333525af5a927c3d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 18:17:18 +0000 Subject: [PATCH 09/15] "core" module alpha state --- api/advent22_api/app.py | 2 +- api/advent22_api/calendar_config.py | 53 ------ api/advent22_api/core/advent_image.py | 4 +- api/advent22_api/core/calendar_config.py | 25 +++ api/advent22_api/{ => core}/config.py | 11 -- api/advent22_api/core/depends.py | 152 ++++++++++++++++++ api/advent22_api/core/image_helpers.py | 46 ++++++ .../core/{random.py => sequence_helpers.py} | 13 +- api/advent22_api/{ => core}/settings.py | 0 api/advent22_api/core/webdav.py | 4 +- api/advent22_api/dav_common.py | 55 ------- api/advent22_api/main.py | 2 +- api/advent22_api/routers/_image.py | 137 ---------------- api/advent22_api/routers/_misc.py | 150 ----------------- 14 files changed, 240 insertions(+), 414 deletions(-) delete mode 100644 api/advent22_api/calendar_config.py create mode 100644 api/advent22_api/core/calendar_config.py rename api/advent22_api/{ => core}/config.py (72%) create mode 100644 api/advent22_api/core/depends.py create mode 100644 api/advent22_api/core/image_helpers.py rename api/advent22_api/core/{random.py => sequence_helpers.py} (58%) rename api/advent22_api/{ => core}/settings.py (100%) delete mode 100644 api/advent22_api/dav_common.py delete mode 100644 api/advent22_api/routers/_image.py delete mode 100644 api/advent22_api/routers/_misc.py diff --git a/api/advent22_api/app.py b/api/advent22_api/app.py index 7c17a7a..92ab036 100644 --- a/api/advent22_api/app.py +++ b/api/advent22_api/app.py @@ -2,8 +2,8 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from .core.settings import SETTINGS from .routers import router -from .settings import SETTINGS app = FastAPI( title="Advent22 API", diff --git a/api/advent22_api/calendar_config.py b/api/advent22_api/calendar_config.py deleted file mode 100644 index e496012..0000000 --- a/api/advent22_api/calendar_config.py +++ /dev/null @@ -1,53 +0,0 @@ -import tomllib -from typing import TypeAlias - -import tomli_w -from pydantic import BaseModel - -from .config import get_config -from .dav_common import dav_get_textfile_content, dav_write_textfile_content -from .settings import SETTINGS - - -class DoorSaved(BaseModel): - # Tag, an dem die Tür aufgeht - day: int - - # Koordinaten für zwei Eckpunkte - x1: int - y1: int - x2: int - y2: int - - -DoorsSaved: TypeAlias = list[DoorSaved] - - -class CalendarConfig(BaseModel): - # Dateiname Hintergrundbild - background: str = "adventskalender.jpg" - - # Türen für die UI - doors: DoorsSaved = [] - - -async def get_calendar_config() -> CalendarConfig: - cfg = await get_config() - - txt = await dav_get_textfile_content( - path=f"files/{cfg.puzzle.calendar}", - ) - - return CalendarConfig.model_validate(tomllib.loads(txt)) - - -async def set_calendar_config(cal_cfg: CalendarConfig) -> None: - await dav_write_textfile_content( - path=SETTINGS.config_filename, - content=tomli_w.dumps( - cal_cfg.model_dump( - exclude_defaults=True, - exclude_unset=True, - ) - ), - ) diff --git a/api/advent22_api/core/advent_image.py b/api/advent22_api/core/advent_image.py index 4a14774..de8b66b 100644 --- a/api/advent22_api/core/advent_image.py +++ b/api/advent22_api/core/advent_image.py @@ -48,7 +48,7 @@ class AdventImage: self, xy: _XY, text: str | bytes, - font: "ImageFont._Font", + font: ImageFont._Font, anchor: str | None = "mm", **text_kwargs, ) -> Image._Box | None: @@ -92,7 +92,7 @@ class AdventImage: self, xy: _XY, text: str | bytes, - font: "ImageFont._Font", + font: ImageFont._Font, anchor: str | None = "mm", **text_kwargs, ) -> None: diff --git a/api/advent22_api/core/calendar_config.py b/api/advent22_api/core/calendar_config.py new file mode 100644 index 0000000..b029919 --- /dev/null +++ b/api/advent22_api/core/calendar_config.py @@ -0,0 +1,25 @@ +from typing import TypeAlias + +from pydantic import BaseModel + + +class DoorSaved(BaseModel): + # Tag, an dem die Tür aufgeht + day: int + + # Koordinaten für zwei Eckpunkte + x1: int + y1: int + x2: int + y2: int + + +DoorsSaved: TypeAlias = list[DoorSaved] + + +class CalendarConfig(BaseModel): + # Dateiname Hintergrundbild + background: str = "adventskalender.jpg" + + # Türen für die UI + doors: DoorsSaved = [] diff --git a/api/advent22_api/config.py b/api/advent22_api/core/config.py similarity index 72% rename from api/advent22_api/config.py rename to api/advent22_api/core/config.py index ef1c566..6da9173 100644 --- a/api/advent22_api/config.py +++ b/api/advent22_api/core/config.py @@ -1,10 +1,5 @@ -import tomllib - from pydantic import BaseModel -from .dav_common import dav_get_textfile_content -from .settings import SETTINGS - class User(BaseModel): name: str @@ -43,9 +38,3 @@ class Config(BaseModel): admin: User server: Server puzzle: Puzzle - - -async def get_config() -> Config: - txt = await dav_get_textfile_content(path=SETTINGS.config_filename) - - return Config.model_validate(tomllib.loads(txt)) diff --git a/api/advent22_api/core/depends.py b/api/advent22_api/core/depends.py new file mode 100644 index 0000000..71322b6 --- /dev/null +++ b/api/advent22_api/core/depends.py @@ -0,0 +1,152 @@ +import tomllib +from typing import cast + +import tomli_w +from fastapi import Depends +from PIL import Image, ImageFont + +from .advent_image import _XY, AdventImage +from .calendar_config import CalendarConfig +from .config import Config +from .image_helpers import list_images_auto, load_image +from .sequence_helpers import Random, set_len, shuffle +from .settings import SETTINGS +from .webdav import WebDAV + + +class AllTime: + @staticmethod + async def get_config() -> Config: + """ + Globale Konfiguration lesen + """ + + txt = await WebDAV.read_str(path=SETTINGS.config_filename) + return Config.model_validate(tomllib.loads(txt)) + + @staticmethod + async def get_calendar_config( + cfg: Config = Depends(get_config), + ) -> CalendarConfig: + """ + Kalender Konfiguration lesen + """ + + txt = await WebDAV.read_str(path=f"files/{cfg.puzzle.calendar}") + return CalendarConfig.model_validate(tomllib.loads(txt)) + + @staticmethod + async def set_calendar_config( + cal_cfg: CalendarConfig, + cfg: Config = Depends(get_config), + ) -> None: + """ + Kalender Konfiguration ändern + """ + + await WebDAV.write_str( + path=f"files/{cfg.puzzle.calendar}", + content=tomli_w.dumps( + cal_cfg.model_dump( + exclude_defaults=True, + exclude_unset=True, + ) + ), + ) + + @staticmethod + async def shuffle_solution( + cfg: Config = Depends(get_config), + ) -> str: + """ + Lösung: Reihenfolge zufällig bestimmen + """ + + return "".join(await shuffle(cfg.puzzle.solution)) + + @staticmethod + async def shuffle_images_auto( + images: list[str] = Depends(list_images_auto), + ) -> list[str]: + """ + Bilder: Reihenfolge zufällig bestimmen + """ + + ls = set_len(images, 24) + return await shuffle(ls) + + +class Today: + @staticmethod + async def get_part( + day: int, + shuffled_solution: str = Depends(AllTime.shuffle_solution), + ) -> str: + """ + Heute angezeigter Teil der Lösung + """ + + return shuffled_solution[day] + + @staticmethod + async def get_random( + day: int, + ) -> Random: + """ + Tagesabhängige Zufallszahlen + """ + + return await Random.get(day) + + @staticmethod + async def gen_auto_image( + day: int, + images: list[str] = Depends(AllTime.shuffle_images_auto), + cfg: Config = Depends(AllTime.get_config), + rnd: Random = Depends(get_random), + part: str = Depends(get_part), + ) -> Image.Image: + """ + Automatisch generiertes Bild erstellen + """ + + # Datei existiert garantiert! + img = await load_image(images[day]) + image = await AdventImage.from_img(img) + + font = ImageFont.truetype( + font=await WebDAV.read_bytes(f"files/{cfg.server.font}"), + size=50, + ) + + # Buchstaben verstecken + for letter in part: + await image.hide_text( + xy=cast(_XY, tuple(rnd.choices(range(30, 470), k=2))), + text=letter, + font=font, + ) + + return image.img + + @staticmethod + async def get_image( + day: int, + images: list[str] = Depends(AllTime.shuffle_images_auto), + cfg: Config = Depends(AllTime.get_config), + rnd: Random = Depends(get_random), + part: str = Depends(get_part), + ) -> Image.Image: + """ + Bild für einen Tag abrufen + """ + + try: + # Versuche, aus "manual"-Ordner zu laden + return await load_image(f"images_manual/{day}.jpg") + + except RuntimeError: + # Erstelle automatisch generiertes Bild + return await Today.gen_auto_image( + day=day, images=images, cfg=cfg, rnd=rnd, part=part + ) diff --git a/api/advent22_api/core/image_helpers.py b/api/advent22_api/core/image_helpers.py new file mode 100644 index 0000000..d494d7c --- /dev/null +++ b/api/advent22_api/core/image_helpers.py @@ -0,0 +1,46 @@ +import re +from io import BytesIO + +from fastapi.responses import StreamingResponse +from PIL import Image + +from .webdav import WebDAV + + +async def list_images_auto() -> list[str]: + """ + Finde alle Bilddateien im "automatisch"-Verzeichnis + """ + + return await WebDAV.list_files( + directory="/images_auto", + regex=re.compile(r"\.(gif|jpe?g|tiff?|png|bmp)$", flags=re.IGNORECASE), + ) + + +async def load_image(file_name: str) -> Image.Image: + """ + Versuche, Bild aus Datei zu laden + """ + + if not await WebDAV.file_exists(file_name): + raise RuntimeError(f"DAV-File {file_name} does not exist!") + + return Image.open(await WebDAV.read_bytes(file_name)) + + +async def api_return_image(img: Image.Image) -> StreamingResponse: + """ + Bild mit API zurückgeben + """ + + # JPEG-Daten in Puffer speichern + img_buffer = BytesIO() + img.save(img_buffer, format="JPEG", quality=85) + img_buffer.seek(0) + + # zurückgeben + return StreamingResponse( + media_type="image/jpeg", + content=img_buffer, + ) diff --git a/api/advent22_api/core/random.py b/api/advent22_api/core/sequence_helpers.py similarity index 58% rename from api/advent22_api/core/random.py rename to api/advent22_api/core/sequence_helpers.py index e45026f..6c550a2 100644 --- a/api/advent22_api/core/random.py +++ b/api/advent22_api/core/sequence_helpers.py @@ -1,13 +1,14 @@ +import itertools import random from typing import Any, Self, Sequence -from ..config import get_config +from .depends import AllTime class Random(random.Random): @classmethod async def get(cls, bonus_salt: Any = "") -> Self: - cfg = await get_config() + cfg = await AllTime.get_config() return cls(f"{cfg.puzzle.solution}{bonus_salt}{cfg.puzzle.random_pepper}") @@ -17,3 +18,11 @@ async def shuffle(seq: Sequence, rnd: random.Random | None = None) -> list: # Elemente mischen return rnd.sample(seq, len(seq)) + + +def set_len(seq: Sequence, length: int) -> list: + # `seq` unendlich wiederholen + infinite = itertools.cycle(seq) + + # Die ersten `length` einträge nehmen + return list(itertools.islice(infinite, length)) diff --git a/api/advent22_api/settings.py b/api/advent22_api/core/settings.py similarity index 100% rename from api/advent22_api/settings.py rename to api/advent22_api/core/settings.py diff --git a/api/advent22_api/core/webdav.py b/api/advent22_api/core/webdav.py index d10de41..6277090 100644 --- a/api/advent22_api/core/webdav.py +++ b/api/advent22_api/core/webdav.py @@ -4,7 +4,7 @@ from io import BytesIO from cache import AsyncTTL from webdav3.client import Client as WebDAVclient -from ..settings import SETTINGS +from .settings import SETTINGS class WebDAV: @@ -21,8 +21,8 @@ class WebDAV: @classmethod async def list_files( cls, - *, directory: str = "", + *, regex: re.Pattern[str] = re.compile(""), ) -> list[str]: """ diff --git a/api/advent22_api/dav_common.py b/api/advent22_api/dav_common.py deleted file mode 100644 index 6f6e3c2..0000000 --- a/api/advent22_api/dav_common.py +++ /dev/null @@ -1,55 +0,0 @@ -import re -from io import BytesIO, TextIOWrapper - -from cache import AsyncTTL -from webdav3.client import Client as WebDAVclient - -from .settings import SETTINGS - -_WEBDAV_CLIENT = WebDAVclient( - { - "webdav_hostname": SETTINGS.webdav.url, - "webdav_login": SETTINGS.webdav.username, - "webdav_password": SETTINGS.webdav.password, - "disable_check": SETTINGS.webdav.disable_check, - } -) - - -@AsyncTTL(time_to_live=SETTINGS.cache_ttl) -async def dav_list_files(regex: re.Pattern[str], directory: str = "") -> list[str]: - ls = _WEBDAV_CLIENT.list(directory) - return [f"{directory}/{path}" for path in ls if regex.search(path)] - - -@AsyncTTL(time_to_live=SETTINGS.cache_ttl) -async def dav_file_exists(path: str) -> bool: - return _WEBDAV_CLIENT.check(path) - - -@AsyncTTL(time_to_live=SETTINGS.cache_ttl) -async def dav_get_file(path: str) -> BytesIO: - resource = _WEBDAV_CLIENT.resource(path) - buffer = BytesIO() - resource.write_to(buffer) - - return buffer - - -@AsyncTTL(time_to_live=SETTINGS.cache_ttl) -async def dav_get_textfile_content(path: str, encoding="utf-8") -> str: - buffer = await dav_get_file(path) - tio = TextIOWrapper(buffer, encoding=encoding) - tio.seek(0) - return tio.read().strip() - - -async def dav_write_file(path: str, buffer: BytesIO) -> None: - resource = _WEBDAV_CLIENT.resource(path) - resource.read_from(buffer) - - -async def dav_write_textfile_content(path: str, content: str, encoding="utf-8") -> None: - buffer = BytesIO(content.encode(encoding=encoding)) - buffer.seek(0) - await dav_write_file(path, buffer) diff --git a/api/advent22_api/main.py b/api/advent22_api/main.py index bd639a6..336a245 100644 --- a/api/advent22_api/main.py +++ b/api/advent22_api/main.py @@ -2,7 +2,7 @@ import uvicorn -from .settings import SETTINGS +from .core.settings import SETTINGS def main() -> None: diff --git a/api/advent22_api/routers/_image.py b/api/advent22_api/routers/_image.py deleted file mode 100644 index b89e6aa..0000000 --- a/api/advent22_api/routers/_image.py +++ /dev/null @@ -1,137 +0,0 @@ -import colorsys -from dataclasses import dataclass -from typing import Self, TypeAlias, cast - -import numpy as np -from PIL import Image, ImageDraw, ImageFont - -_RGB: TypeAlias = tuple[int, int, int] -_XY: TypeAlias = tuple[float, float] - - -@dataclass -class AdventImage: - img: Image.Image - - @classmethod - async def load_standard(cls, fp) -> Self: - """ - Bild laden und einen quadratischen Ausschnitt - aus der Mitte nehmen - """ - - # Bild laden - img = Image.open(fp=fp) - - # Größen bestimmen - width, height = img.size - square = min(width, height) - - # Bild zuschneiden und skalieren - img = img.crop( - box=( - int((width - square) / 2), - int((height - square) / 2), - int((width + square) / 2), - int((height + square) / 2), - ) - ) - - img = img.resize( - size=(500, 500), - resample=Image.LANCZOS, - ) - - # Farbmodell festlegen - return cls(img=img.convert("RGB")) - - async def get_text_box( - self, - xy: _XY, - text: str | bytes, - font: "ImageFont._Font", - anchor: str | None = "mm", - **text_kwargs, - ) -> tuple[int, int, int, int] | None: - """ - Koordinaten (links, oben, rechts, unten) des betroffenen - Rechtecks bestimmen, wenn das Bild mit einem Text - versehen wird - """ - - # Neues 1-Bit Bild, gleiche Größe - mask = Image.new(mode="1", size=self.img.size, color=0) - - # Text auf Maske auftragen - ImageDraw.Draw(mask).text( - xy=xy, - text=text, - font=font, - anchor=anchor, - fill=1, - **text_kwargs, - ) - - # betroffenen Pixelbereich bestimmen - return mask.getbbox() - - async def get_average_color( - self, - box: tuple[int, int, int, int], - ) -> _RGB: - """ - Durchschnittsfarbe eines rechteckigen Ausschnitts in - einem Bild berechnen - """ - - pixel_data = self.img.crop(box).getdata() - mean_color: np.ndarray = np.mean(pixel_data, axis=0) - - return cast(_RGB, tuple(mean_color.astype(int))) - - async def hide_text( - self, - xy: _XY, - text: str | bytes, - font: "ImageFont._Font", - anchor: str | None = "mm", - **text_kwargs, - ) -> None: - """ - Text `text` in Bild an Position `xy` verstecken. - Weitere Parameter wie bei `ImageDraw.text()`. - """ - - # betroffenen Bildbereich bestimmen - text_box = await self.get_text_box( - xy=xy, text=text, font=font, anchor=anchor, **text_kwargs - ) - - if text_box is not None: - # Durchschnittsfarbe bestimmen - text_color = await self.get_average_color( - box=text_box, - ) - - # etwas heller/dunkler machen - tc_h, tc_s, tc_v = colorsys.rgb_to_hsv(*text_color) - tc_v = int((tc_v - 127) * 0.97) + 127 - - if tc_v < 127: - tc_v += 3 - - else: - tc_v -= 3 - - text_color = colorsys.hsv_to_rgb(tc_h, tc_s, tc_v) - text_color = tuple(int(val) for val in text_color) - - # Buchstaben verstecken - ImageDraw.Draw(self.img).text( - xy=xy, - text=text, - font=font, - fill=cast(_RGB, text_color), - anchor=anchor, - **text_kwargs, - ) diff --git a/api/advent22_api/routers/_misc.py b/api/advent22_api/routers/_misc.py deleted file mode 100644 index 2a75c0c..0000000 --- a/api/advent22_api/routers/_misc.py +++ /dev/null @@ -1,150 +0,0 @@ -import itertools -import random -import re -from io import BytesIO -from typing import Any, Self, Sequence, cast - -from fastapi import Depends -from fastapi.responses import StreamingResponse -from PIL import Image, ImageFont - -from ..config import Config, get_config -from ..dav_common import dav_file_exists, dav_get_file, dav_list_files -from ._image import _XY, AdventImage - -########## -# RANDOM # -########## - - -class Random(random.Random): - @classmethod - async def get(cls, bonus_salt: Any = "") -> Self: - cfg = await get_config() - return cls(f"{cfg.puzzle.solution}{bonus_salt}{cfg.puzzle.random_pepper}") - - -async def set_length(seq: Sequence, length: int) -> list: - # `seq` unendlich wiederholen - infinite = itertools.cycle(seq) - # Die ersten `length` einträge nehmen - return list(itertools.islice(infinite, length)) - - -async def shuffle(seq: Sequence, rnd: random.Random | None = None) -> list: - # Zufallsgenerator - rnd = rnd or await Random.get() - - # Elemente mischen - return rnd.sample(seq, len(seq)) - - -######### -# IMAGE # -######### - - -async def get_letter( - index: int, - cfg: Config = Depends(get_config), -) -> str: - return (await shuffle(cfg.puzzle.solution))[index] - - -async def list_images_auto() -> list[str]: - """ - Finde alle Bilder im "automatisch"-Verzeichnis - """ - - ls = await dav_list_files( - re.compile(r"\.(gif|jpe?g|tiff?|png|bmp)$", flags=re.IGNORECASE), - "/images_auto", - ) - ls = await set_length(ls, 24) - - return await shuffle(ls) - - -async def load_image( - file_name: str, -) -> AdventImage: - """ - Versuche, Bild aus Datei zu laden - """ - - if not await dav_file_exists(file_name): - raise RuntimeError(f"DAV-File {file_name} does not exist!") - - img_buffer = await dav_get_file(file_name) - img_buffer.seek(0) - return await AdventImage.load_standard(img_buffer) - - -async def get_auto_image( - index: int, - letter: str, - images: list[str], - cfg: Config, -) -> AdventImage: - """ - Erstelle automatisch generiertes Bild - """ - - # hier niemals RuntimeError! - image = await load_image(images[index]) - rnd = await Random.get(index) - - font = await dav_get_file(f"files/{cfg.server.font}") - font.seek(0) - - # Buchstabe verstecken - await image.hide_text( - xy=cast(_XY, tuple(rnd.choices(range(30, 470), k=2))), - text=letter, - font=ImageFont.truetype(font, 50), - ) - - return image - - -async def get_image( - index: int, - letter: str = Depends(get_letter), - images: list[str] = Depends(list_images_auto), - cfg: Config = Depends(get_config), -) -> AdventImage: - """ - Bild für einen Tag erstellen - """ - - try: - # Versuche, aus "manual"-Ordner zu laden - return await load_image(f"images_manual/{index}.jpg") - - except RuntimeError: - # Erstelle automatisch generiertes Bild - return await get_auto_image( - index=index, - letter=letter, - images=images, - cfg=cfg, - ) - - -async def api_return_image( - img: Image.Image, -) -> StreamingResponse: - """ - Bild mit API zurückgeben - """ - - # Bilddaten in Puffer laden - img_buffer = BytesIO() - img.save(img_buffer, format="JPEG", quality=85) - img_buffer.seek(0) - - # zurückgeben - return StreamingResponse( - content=img_buffer, - media_type="image/jpeg", - ) From af00dafb6cda82e35700d3226ab2c8e4eb339305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 19:08:13 +0000 Subject: [PATCH 10/15] router integration: stuck apparently, a @staticmethod that Depends on another @staticmethod in the same class is bad --- api/advent22_api/core/advent_image.py | 8 ++-- api/advent22_api/core/calendar_config.py | 35 +++++++++++++++ api/advent22_api/core/config.py | 14 ++++++ api/advent22_api/core/depends.py | 52 +++-------------------- api/advent22_api/core/image_helpers.py | 2 +- api/advent22_api/core/sequence_helpers.py | 6 +-- api/advent22_api/core/webdav.py | 19 ++++----- api/advent22_api/routers/days.py | 32 +++++++------- api/advent22_api/routers/general.py | 23 +++------- api/advent22_api/routers/user.py | 4 +- 10 files changed, 96 insertions(+), 99 deletions(-) diff --git a/api/advent22_api/core/advent_image.py b/api/advent22_api/core/advent_image.py index de8b66b..3a9b585 100644 --- a/api/advent22_api/core/advent_image.py +++ b/api/advent22_api/core/advent_image.py @@ -48,10 +48,10 @@ class AdventImage: self, xy: _XY, text: str | bytes, - font: ImageFont._Font, + font: "ImageFont._Font", anchor: str | None = "mm", **text_kwargs, - ) -> Image._Box | None: + ) -> "Image._Box | None": """ Koordinaten (links, oben, rechts, unten) des betroffenen Rechtecks bestimmen, wenn das Bild mit einem Text @@ -76,7 +76,7 @@ class AdventImage: async def get_average_color( self, - box: Image._Box, + box: "Image._Box", ) -> tuple[int, int, int]: """ Durchschnittsfarbe eines rechteckigen Ausschnitts in @@ -92,7 +92,7 @@ class AdventImage: self, xy: _XY, text: str | bytes, - font: ImageFont._Font, + font: "ImageFont._Font", anchor: str | None = "mm", **text_kwargs, ) -> None: diff --git a/api/advent22_api/core/calendar_config.py b/api/advent22_api/core/calendar_config.py index b029919..0e2107e 100644 --- a/api/advent22_api/core/calendar_config.py +++ b/api/advent22_api/core/calendar_config.py @@ -1,7 +1,13 @@ +import tomllib from typing import TypeAlias +import tomli_w +from fastapi import Depends from pydantic import BaseModel +from .config import Config +from .webdav import WebDAV + class DoorSaved(BaseModel): # Tag, an dem die Tür aufgeht @@ -23,3 +29,32 @@ class CalendarConfig(BaseModel): # Türen für die UI doors: DoorsSaved = [] + + @staticmethod + async def get_calendar_config( + cfg: Config = Depends(Config.get_config), + ) -> "CalendarConfig": + """ + Kalender Konfiguration lesen + """ + + txt = await WebDAV.read_str(path=f"files/{cfg.puzzle.calendar}") + return CalendarConfig.model_validate(tomllib.loads(txt)) + + async def set_calendar_config( + self, + cfg: Config = Depends(Config.get_config), + ) -> None: + """ + Kalender Konfiguration ändern + """ + + await WebDAV.write_str( + path=f"files/{cfg.puzzle.calendar}", + content=tomli_w.dumps( + self.model_dump( + exclude_defaults=True, + exclude_unset=True, + ) + ), + ) diff --git a/api/advent22_api/core/config.py b/api/advent22_api/core/config.py index 6da9173..d4a2a4c 100644 --- a/api/advent22_api/core/config.py +++ b/api/advent22_api/core/config.py @@ -1,5 +1,10 @@ +import tomllib + from pydantic import BaseModel +from .settings import SETTINGS +from .webdav import WebDAV + class User(BaseModel): name: str @@ -38,3 +43,12 @@ class Config(BaseModel): admin: User server: Server puzzle: Puzzle + + @staticmethod + async def get_config() -> "Config": + """ + Globale Konfiguration lesen + """ + + txt = await WebDAV.read_str(path=SETTINGS.config_filename) + return Config.model_validate(tomllib.loads(txt)) diff --git a/api/advent22_api/core/depends.py b/api/advent22_api/core/depends.py index 71322b6..40a4cd3 100644 --- a/api/advent22_api/core/depends.py +++ b/api/advent22_api/core/depends.py @@ -1,62 +1,20 @@ -import tomllib +from io import BytesIO from typing import cast -import tomli_w from fastapi import Depends from PIL import Image, ImageFont from .advent_image import _XY, AdventImage -from .calendar_config import CalendarConfig from .config import Config from .image_helpers import list_images_auto, load_image from .sequence_helpers import Random, set_len, shuffle -from .settings import SETTINGS from .webdav import WebDAV class AllTime: - @staticmethod - async def get_config() -> Config: - """ - Globale Konfiguration lesen - """ - - txt = await WebDAV.read_str(path=SETTINGS.config_filename) - return Config.model_validate(tomllib.loads(txt)) - - @staticmethod - async def get_calendar_config( - cfg: Config = Depends(get_config), - ) -> CalendarConfig: - """ - Kalender Konfiguration lesen - """ - - txt = await WebDAV.read_str(path=f"files/{cfg.puzzle.calendar}") - return CalendarConfig.model_validate(tomllib.loads(txt)) - - @staticmethod - async def set_calendar_config( - cal_cfg: CalendarConfig, - cfg: Config = Depends(get_config), - ) -> None: - """ - Kalender Konfiguration ändern - """ - - await WebDAV.write_str( - path=f"files/{cfg.puzzle.calendar}", - content=tomli_w.dumps( - cal_cfg.model_dump( - exclude_defaults=True, - exclude_unset=True, - ) - ), - ) - @staticmethod async def shuffle_solution( - cfg: Config = Depends(get_config), + cfg: Config = Depends(Config.get_config), ) -> str: """ Lösung: Reihenfolge zufällig bestimmen @@ -102,7 +60,7 @@ class Today: async def gen_auto_image( day: int, images: list[str] = Depends(AllTime.shuffle_images_auto), - cfg: Config = Depends(AllTime.get_config), + cfg: Config = Depends(Config.get_config), rnd: Random = Depends(get_random), part: str = Depends(get_part), ) -> Image.Image: @@ -115,7 +73,7 @@ class Today: image = await AdventImage.from_img(img) font = ImageFont.truetype( - font=await WebDAV.read_bytes(f"files/{cfg.server.font}"), + font=BytesIO(await WebDAV.read_bytes(f"files/{cfg.server.font}")), size=50, ) @@ -133,7 +91,7 @@ class Today: async def get_image( day: int, images: list[str] = Depends(AllTime.shuffle_images_auto), - cfg: Config = Depends(AllTime.get_config), + cfg: Config = Depends(Config.get_config), rnd: Random = Depends(get_random), part: str = Depends(get_part), ) -> Image.Image: diff --git a/api/advent22_api/core/image_helpers.py b/api/advent22_api/core/image_helpers.py index d494d7c..8c67b29 100644 --- a/api/advent22_api/core/image_helpers.py +++ b/api/advent22_api/core/image_helpers.py @@ -26,7 +26,7 @@ async def load_image(file_name: str) -> Image.Image: if not await WebDAV.file_exists(file_name): raise RuntimeError(f"DAV-File {file_name} does not exist!") - return Image.open(await WebDAV.read_bytes(file_name)) + return Image.open(BytesIO(await WebDAV.read_bytes(file_name))) async def api_return_image(img: Image.Image) -> StreamingResponse: diff --git a/api/advent22_api/core/sequence_helpers.py b/api/advent22_api/core/sequence_helpers.py index 6c550a2..049fc15 100644 --- a/api/advent22_api/core/sequence_helpers.py +++ b/api/advent22_api/core/sequence_helpers.py @@ -2,14 +2,14 @@ import itertools import random from typing import Any, Self, Sequence -from .depends import AllTime +from .config import Config class Random(random.Random): @classmethod async def get(cls, bonus_salt: Any = "") -> Self: - cfg = await AllTime.get_config() - return cls(f"{cfg.puzzle.solution}{bonus_salt}{cfg.puzzle.random_pepper}") + cfg = await Config.get_config() + return cls(f"{cfg.puzzle.solution}{cfg.puzzle.random_pepper}{bonus_salt}") async def shuffle(seq: Sequence, rnd: random.Random | None = None) -> list: diff --git a/api/advent22_api/core/webdav.py b/api/advent22_api/core/webdav.py index 6277090..034fadb 100644 --- a/api/advent22_api/core/webdav.py +++ b/api/advent22_api/core/webdav.py @@ -17,8 +17,8 @@ class WebDAV: } ) - @AsyncTTL(time_to_live=SETTINGS.cache_ttl) @classmethod + @AsyncTTL(time_to_live=SETTINGS.cache_ttl) async def list_files( cls, directory: str = "", @@ -33,8 +33,8 @@ class WebDAV: return [f"{directory}/{path}" for path in ls if regex.search(path)] - @AsyncTTL(time_to_live=SETTINGS.cache_ttl) @classmethod + @AsyncTTL(time_to_live=SETTINGS.cache_ttl) async def file_exists(cls, path: str) -> bool: """ `True`, wenn an Pfad `path` eine Datei existiert @@ -42,8 +42,8 @@ class WebDAV: return cls._webdav_client.check(path) - @AsyncTTL(time_to_live=SETTINGS.cache_ttl) @classmethod + @AsyncTTL(time_to_live=SETTINGS.cache_ttl) async def read_bytes(cls, path: str) -> bytes: """ Datei aus Pfad `path` als bytes laden @@ -51,10 +51,12 @@ class WebDAV: buffer = BytesIO() cls._webdav_client.resource(path).write_to(buffer) + buffer.seek(0) + return buffer.read() - @AsyncTTL(time_to_live=SETTINGS.cache_ttl) @classmethod + @AsyncTTL(time_to_live=SETTINGS.cache_ttl) async def read_str(cls, path: str, encoding="utf-8") -> str: """ Datei aus Pfad `path` als string laden @@ -63,9 +65,9 @@ class WebDAV: return (await cls.read_bytes(path)).decode(encoding=encoding).strip() @classmethod - async def write_buffer(cls, path: str, buffer: BytesIO) -> None: + async def write_bytes(cls, path: str, buffer: bytes) -> None: """ - Puffer `buffer` in Datei in Pfad `path` schreiben + Bytes `buffer` in Datei in Pfad `path` schreiben """ cls._webdav_client.resource(path).read_from(buffer) @@ -76,7 +78,4 @@ class WebDAV: String `content` in Datei in Pfad `path` schreiben """ - buffer = BytesIO(content.encode(encoding=encoding)) - buffer.seek(0) - - await cls.write_buffer(path, buffer) + await cls.write_bytes(path, content.encode(encoding=encoding)) diff --git a/api/advent22_api/routers/days.py b/api/advent22_api/routers/days.py index ef88346..9a2feea 100644 --- a/api/advent22_api/routers/days.py +++ b/api/advent22_api/routers/days.py @@ -2,10 +2,11 @@ from datetime import date from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import StreamingResponse +from PIL import Image -from ..config import Config, get_config -from ._image import AdventImage -from ._misc import api_return_image, get_image, shuffle +from ..core.config import Config +from ..core.depends import AllTime, Today +from ..core.image_helpers import api_return_image from .user import user_is_admin router = APIRouter(prefix="/days", tags=["days"]) @@ -13,17 +14,16 @@ router = APIRouter(prefix="/days", tags=["days"]) @router.on_event("startup") async def startup() -> None: - cfg = await get_config() + cfg = await Config.get_config() print(cfg.puzzle.solution) - print("".join(await shuffle(cfg.puzzle.solution))) + + shuffled_solution = await AllTime.shuffle_solution(cfg) + print(shuffled_solution) -@router.get("/letter/{index}") -async def get_letter( - index: int, - cfg: Config = Depends(get_config), -) -> str: - return (await shuffle(cfg.puzzle.solution))[index] +@router.get("/part/{day}") +async def get_part(part: str = Depends(Today.get_part)) -> str: + return part @router.get("/date") @@ -45,17 +45,17 @@ async def get_visible_days() -> int: async def user_can_view( - index: int, + day: int, ) -> bool: - return index < await get_visible_days() + return day < await get_visible_days() @router.get( - "/image/{index}", + "/image/{day}", response_class=StreamingResponse, ) async def get_image_for_day( - image: AdventImage = Depends(get_image), + image: Image.Image = Depends(Today.get_image), can_view: bool = Depends(user_can_view), is_admin: bool = Depends(user_is_admin), ) -> StreamingResponse: @@ -66,4 +66,4 @@ async def get_image_for_day( if not (can_view or is_admin): raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wie unhöflich!!!") - return await api_return_image(image.img) + return await api_return_image(image) diff --git a/api/advent22_api/routers/general.py b/api/advent22_api/routers/general.py index ff4eb0b..2661374 100644 --- a/api/advent22_api/routers/general.py +++ b/api/advent22_api/routers/general.py @@ -1,15 +1,8 @@ from fastapi import APIRouter, Depends from fastapi.responses import StreamingResponse -from PIL import Image -from ..calendar_config import ( - CalendarConfig, - DoorsSaved, - get_calendar_config, - set_calendar_config, -) -from ..dav_common import dav_get_file -from ._misc import api_return_image +from ..core.calendar_config import CalendarConfig, DoorsSaved +from ..core.image_helpers import api_return_image, load_image router = APIRouter(prefix="/general", tags=["general"]) @@ -19,20 +12,18 @@ router = APIRouter(prefix="/general", tags=["general"]) response_class=StreamingResponse, ) async def get_image_for_day( - cal_cfg: CalendarConfig = Depends(get_calendar_config), + cal_cfg: CalendarConfig = Depends(CalendarConfig.get_calendar_config), ) -> StreamingResponse: """ Hintergrundbild laden """ - return await api_return_image( - Image.open(await dav_get_file(f"files/{cal_cfg.background}")) - ) + return await api_return_image(await load_image(f"files/{cal_cfg.background}")) @router.get("/doors") async def get_doors( - cal_cfg: CalendarConfig = Depends(get_calendar_config), + cal_cfg: CalendarConfig = Depends(CalendarConfig.get_calendar_config), ) -> DoorsSaved: """ Türchen lesen @@ -44,7 +35,7 @@ async def get_doors( @router.put("/doors") async def put_doors( doors: DoorsSaved, - cal_cfg: CalendarConfig = Depends(get_calendar_config), + cal_cfg: CalendarConfig = Depends(CalendarConfig.get_calendar_config), ) -> None: """ Türchen setzen @@ -54,4 +45,4 @@ async def put_doors( doors, key=lambda door: door.day, ) - await set_calendar_config(cal_cfg) + await cal_cfg.set_calendar_config() diff --git a/api/advent22_api/routers/user.py b/api/advent22_api/routers/user.py index d9f412a..255b54b 100644 --- a/api/advent22_api/routers/user.py +++ b/api/advent22_api/routers/user.py @@ -3,7 +3,7 @@ import secrets from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import HTTPBasic, HTTPBasicCredentials -from ..config import Config, get_config +from ..core.config import Config router = APIRouter(prefix="/user", tags=["user"]) security = HTTPBasic() @@ -11,7 +11,7 @@ security = HTTPBasic() async def user_is_admin( credentials: HTTPBasicCredentials = Depends(security), - config: Config = Depends(get_config), + config: Config = Depends(Config.get_config), ) -> bool: username_correct = secrets.compare_digest(credentials.username, config.admin.name) From 29f02d2545f98dc92c526d2bc2d8254a06a462e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 19:19:08 +0000 Subject: [PATCH 11/15] remove "namespace classes" AllTime and Today --- api/advent22_api/core/depends.py | 160 +++++++++++++++---------------- api/advent22_api/routers/days.py | 16 ++-- 2 files changed, 86 insertions(+), 90 deletions(-) diff --git a/api/advent22_api/core/depends.py b/api/advent22_api/core/depends.py index 40a4cd3..6b814e1 100644 --- a/api/advent22_api/core/depends.py +++ b/api/advent22_api/core/depends.py @@ -11,100 +11,96 @@ from .sequence_helpers import Random, set_len, shuffle from .webdav import WebDAV -class AllTime: - @staticmethod - async def shuffle_solution( - cfg: Config = Depends(Config.get_config), - ) -> str: - """ - Lösung: Reihenfolge zufällig bestimmen - """ +async def shuffle_solution( + cfg: Config = Depends(Config.get_config), +) -> str: + """ + Lösung: Reihenfolge zufällig bestimmen + """ - return "".join(await shuffle(cfg.puzzle.solution)) - - @staticmethod - async def shuffle_images_auto( - images: list[str] = Depends(list_images_auto), - ) -> list[str]: - """ - Bilder: Reihenfolge zufällig bestimmen - """ - - ls = set_len(images, 24) - return await shuffle(ls) + return "".join(await shuffle(cfg.puzzle.solution)) -class Today: - @staticmethod - async def get_part( - day: int, - shuffled_solution: str = Depends(AllTime.shuffle_solution), - ) -> str: - """ - Heute angezeigter Teil der Lösung - """ +async def shuffle_images_auto( + images: list[str] = Depends(list_images_auto), +) -> list[str]: + """ + Bilder: Reihenfolge zufällig bestimmen + """ - return shuffled_solution[day] + ls = set_len(images, 24) + return await shuffle(ls) - @staticmethod - async def get_random( - day: int, - ) -> Random: - """ - Tagesabhängige Zufallszahlen - """ - return await Random.get(day) +async def get_part( + day: int, + shuffled_solution: str = Depends(shuffle_solution), +) -> str: + """ + Heute angezeigter Teil der Lösung + """ - @staticmethod - async def gen_auto_image( - day: int, - images: list[str] = Depends(AllTime.shuffle_images_auto), - cfg: Config = Depends(Config.get_config), - rnd: Random = Depends(get_random), - part: str = Depends(get_part), - ) -> Image.Image: - """ - Automatisch generiertes Bild erstellen - """ + return shuffled_solution[day] - # Datei existiert garantiert! - img = await load_image(images[day]) - image = await AdventImage.from_img(img) - font = ImageFont.truetype( - font=BytesIO(await WebDAV.read_bytes(f"files/{cfg.server.font}")), - size=50, +async def get_random( + day: int, +) -> Random: + """ + Tagesabhängige Zufallszahlen + """ + + return await Random.get(day) + + +async def gen_auto_image( + day: int, + auto_images: list[str] = Depends(shuffle_images_auto), + cfg: Config = Depends(Config.get_config), + rnd: Random = Depends(get_random), + part: str = Depends(get_part), +) -> Image.Image: + """ + Automatisch generiertes Bild erstellen + """ + + # Datei existiert garantiert! + img = await load_image(auto_images[day]) + image = await AdventImage.from_img(img) + + font = ImageFont.truetype( + font=BytesIO(await WebDAV.read_bytes(f"files/{cfg.server.font}")), + size=50, + ) + + # Buchstaben verstecken + for letter in part: + await image.hide_text( + xy=cast(_XY, tuple(rnd.choices(range(30, 470), k=2))), + text=letter, + font=font, ) - # Buchstaben verstecken - for letter in part: - await image.hide_text( - xy=cast(_XY, tuple(rnd.choices(range(30, 470), k=2))), - text=letter, - font=font, - ) + return image.img - return image.img - @staticmethod - async def get_image( - day: int, - images: list[str] = Depends(AllTime.shuffle_images_auto), - cfg: Config = Depends(Config.get_config), - rnd: Random = Depends(get_random), - part: str = Depends(get_part), - ) -> Image.Image: - """ - Bild für einen Tag abrufen - """ +async def get_image( + day: int, + auto_images: list[str] = Depends(shuffle_images_auto), + cfg: Config = Depends(Config.get_config), + rnd: Random = Depends(get_random), + part: str = Depends(get_part), +) -> Image.Image: + """ + Bild für einen Tag abrufen + """ - try: - # Versuche, aus "manual"-Ordner zu laden - return await load_image(f"images_manual/{day}.jpg") + try: + # Versuche, aus "manual"-Ordner zu laden + return await load_image(f"images_manual/{day}.jpg") - except RuntimeError: - # Erstelle automatisch generiertes Bild - return await Today.gen_auto_image( - day=day, images=images, cfg=cfg, rnd=rnd, part=part - ) + except RuntimeError: + # Erstelle automatisch generiertes Bild + return await gen_auto_image( + day=day, auto_images=auto_images, cfg=cfg, rnd=rnd, part=part + ) diff --git a/api/advent22_api/routers/days.py b/api/advent22_api/routers/days.py index 9a2feea..e101317 100644 --- a/api/advent22_api/routers/days.py +++ b/api/advent22_api/routers/days.py @@ -5,7 +5,7 @@ from fastapi.responses import StreamingResponse from PIL import Image from ..core.config import Config -from ..core.depends import AllTime, Today +from ..core.depends import get_image, get_part, shuffle_solution from ..core.image_helpers import api_return_image from .user import user_is_admin @@ -17,15 +17,10 @@ async def startup() -> None: cfg = await Config.get_config() print(cfg.puzzle.solution) - shuffled_solution = await AllTime.shuffle_solution(cfg) + shuffled_solution = await shuffle_solution(cfg) print(shuffled_solution) -@router.get("/part/{day}") -async def get_part(part: str = Depends(Today.get_part)) -> str: - return part - - @router.get("/date") async def get_date() -> str: return date.today().isoformat() @@ -50,12 +45,17 @@ async def user_can_view( return day < await get_visible_days() +@router.get("/part/{day}") +async def get_part_for_day(part: str = Depends(get_part)) -> str: + return part + + @router.get( "/image/{day}", response_class=StreamingResponse, ) async def get_image_for_day( - image: Image.Image = Depends(Today.get_image), + image: Image.Image = Depends(get_image), can_view: bool = Depends(user_can_view), is_admin: bool = Depends(user_is_admin), ) -> StreamingResponse: From 973f7546b69361f034baeb2720ad6b25683c962a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 19:33:43 +0000 Subject: [PATCH 12/15] module routers._security for user/admin stuff --- api/advent22_api/routers/_security.py | 47 +++++++++++++++++++++++++++ api/advent22_api/routers/days.py | 34 +++++++++---------- api/advent22_api/routers/user.py | 28 ++-------------- 3 files changed, 66 insertions(+), 43 deletions(-) create mode 100644 api/advent22_api/routers/_security.py diff --git a/api/advent22_api/routers/_security.py b/api/advent22_api/routers/_security.py new file mode 100644 index 0000000..aff2ab5 --- /dev/null +++ b/api/advent22_api/routers/_security.py @@ -0,0 +1,47 @@ +import secrets +from datetime import date + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials + +from ..core.config import Config + +security = HTTPBasic() + + +async def user_is_admin( + credentials: HTTPBasicCredentials = Depends(security), + config: Config = Depends(Config.get_config), +) -> bool: + username_correct = secrets.compare_digest(credentials.username, config.admin.name) + + password_correct = secrets.compare_digest( + credentials.password, config.admin.password + ) + + return username_correct and password_correct + + +async def require_admin( + is_admin: bool = Depends(user_is_admin), +) -> None: + if not is_admin: + raise HTTPException(status.HTTP_401_UNAUTHORIZED) + + +async def user_visible_days() -> int: + today = date.today() + + if today.month == 12: + return today.day + + if today.month in (1, 2, 3): + return 24 + + return 0 + + +async def user_can_view_day( + day: int, +) -> bool: + return day < await user_visible_days() diff --git a/api/advent22_api/routers/days.py b/api/advent22_api/routers/days.py index e101317..991d66f 100644 --- a/api/advent22_api/routers/days.py +++ b/api/advent22_api/routers/days.py @@ -7,7 +7,7 @@ from PIL import Image from ..core.config import Config from ..core.depends import get_image, get_part, shuffle_solution from ..core.image_helpers import api_return_image -from .user import user_is_admin +from ._security import user_can_view_day, user_is_admin, user_visible_days router = APIRouter(prefix="/days", tags=["days"]) @@ -23,30 +23,30 @@ async def startup() -> None: @router.get("/date") async def get_date() -> str: + """ + Aktuelles Server-Datum + """ + return date.today().isoformat() @router.get("/visible_days") async def get_visible_days() -> int: - today = date.today() + """ + Sichtbare Türchen + """ - if today.month == 12: - return today.day - - if today.month in (1, 2, 3): - return 24 - - return 0 - - -async def user_can_view( - day: int, -) -> bool: - return day < await get_visible_days() + return await user_visible_days() @router.get("/part/{day}") -async def get_part_for_day(part: str = Depends(get_part)) -> str: +async def get_part_for_day( + part: str = Depends(get_part), +) -> str: + """ + Heutiger Lösungsteil + """ + return part @@ -56,7 +56,7 @@ async def get_part_for_day(part: str = Depends(get_part)) -> str: ) async def get_image_for_day( image: Image.Image = Depends(get_image), - can_view: bool = Depends(user_can_view), + can_view: bool = Depends(user_can_view_day), is_admin: bool = Depends(user_is_admin), ) -> StreamingResponse: """ diff --git a/api/advent22_api/routers/user.py b/api/advent22_api/routers/user.py index 255b54b..46fa28b 100644 --- a/api/advent22_api/routers/user.py +++ b/api/advent22_api/routers/user.py @@ -1,32 +1,8 @@ -import secrets +from fastapi import APIRouter, Depends -from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.security import HTTPBasic, HTTPBasicCredentials - -from ..core.config import Config +from ._security import require_admin router = APIRouter(prefix="/user", tags=["user"]) -security = HTTPBasic() - - -async def user_is_admin( - credentials: HTTPBasicCredentials = Depends(security), - config: Config = Depends(Config.get_config), -) -> bool: - username_correct = secrets.compare_digest(credentials.username, config.admin.name) - - password_correct = secrets.compare_digest( - credentials.password, config.admin.password - ) - - return username_correct and password_correct - - -async def require_admin( - is_admin: bool = Depends(user_is_admin), -) -> None: - if not is_admin: - raise HTTPException(status.HTTP_401_UNAUTHORIZED) @router.get("/admin") From 11daf2bf8b72e9fcb572518e11579790a38824d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 19:43:53 +0000 Subject: [PATCH 13/15] bug: `set_calendar_config` nicht "dependable" --- api/advent22_api/core/calendar_config.py | 5 +---- api/advent22_api/routers/general.py | 4 +++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/api/advent22_api/core/calendar_config.py b/api/advent22_api/core/calendar_config.py index 0e2107e..18949c9 100644 --- a/api/advent22_api/core/calendar_config.py +++ b/api/advent22_api/core/calendar_config.py @@ -41,10 +41,7 @@ class CalendarConfig(BaseModel): txt = await WebDAV.read_str(path=f"files/{cfg.puzzle.calendar}") return CalendarConfig.model_validate(tomllib.loads(txt)) - async def set_calendar_config( - self, - cfg: Config = Depends(Config.get_config), - ) -> None: + async def set_calendar_config(self, cfg: Config) -> None: """ Kalender Konfiguration ändern """ diff --git a/api/advent22_api/routers/general.py b/api/advent22_api/routers/general.py index 2661374..4787624 100644 --- a/api/advent22_api/routers/general.py +++ b/api/advent22_api/routers/general.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends from fastapi.responses import StreamingResponse from ..core.calendar_config import CalendarConfig, DoorsSaved +from ..core.config import Config from ..core.image_helpers import api_return_image, load_image router = APIRouter(prefix="/general", tags=["general"]) @@ -35,6 +36,7 @@ async def get_doors( @router.put("/doors") async def put_doors( doors: DoorsSaved, + cfg: Config = Depends(Config.get_config), cal_cfg: CalendarConfig = Depends(CalendarConfig.get_calendar_config), ) -> None: """ @@ -45,4 +47,4 @@ async def put_doors( doors, key=lambda door: door.day, ) - await cal_cfg.set_calendar_config() + await cal_cfg.set_calendar_config(cfg) From 21279f747c114f609efa11eb28a1cbbf1bb824d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 19:44:41 +0000 Subject: [PATCH 14/15] documentation --- api/advent22_api/routers/_security.py | 29 +++++++++++++++++++-------- api/advent22_api/routers/days.py | 6 +++--- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/api/advent22_api/routers/_security.py b/api/advent22_api/routers/_security.py index aff2ab5..1bfcbe2 100644 --- a/api/advent22_api/routers/_security.py +++ b/api/advent22_api/routers/_security.py @@ -11,13 +11,14 @@ security = HTTPBasic() async def user_is_admin( credentials: HTTPBasicCredentials = Depends(security), - config: Config = Depends(Config.get_config), + cfg: Config = Depends(Config.get_config), ) -> bool: - username_correct = secrets.compare_digest(credentials.username, config.admin.name) + """ + True iff der user "admin" ist + """ - password_correct = secrets.compare_digest( - credentials.password, config.admin.password - ) + username_correct = secrets.compare_digest(credentials.username, cfg.admin.name) + password_correct = secrets.compare_digest(credentials.password, cfg.admin.password) return username_correct and password_correct @@ -25,11 +26,19 @@ async def user_is_admin( async def require_admin( is_admin: bool = Depends(user_is_admin), ) -> None: + """ + HTTP 401 iff der user nicht "admin" ist + """ + if not is_admin: raise HTTPException(status.HTTP_401_UNAUTHORIZED) -async def user_visible_days() -> int: +async def user_visible_doors() -> int: + """ + Anzahl der user-sichtbaren Türchen + """ + today = date.today() if today.month == 12: @@ -41,7 +50,11 @@ async def user_visible_days() -> int: return 0 -async def user_can_view_day( +async def user_can_view_door( day: int, ) -> bool: - return day < await user_visible_days() + """ + True iff das Türchen von Tag `day` user-sichtbar ist + """ + + return day < await user_visible_doors() diff --git a/api/advent22_api/routers/days.py b/api/advent22_api/routers/days.py index 991d66f..d74ae26 100644 --- a/api/advent22_api/routers/days.py +++ b/api/advent22_api/routers/days.py @@ -7,7 +7,7 @@ from PIL import Image from ..core.config import Config from ..core.depends import get_image, get_part, shuffle_solution from ..core.image_helpers import api_return_image -from ._security import user_can_view_day, user_is_admin, user_visible_days +from ._security import user_can_view_door, user_is_admin, user_visible_doors router = APIRouter(prefix="/days", tags=["days"]) @@ -36,7 +36,7 @@ async def get_visible_days() -> int: Sichtbare Türchen """ - return await user_visible_days() + return await user_visible_doors() @router.get("/part/{day}") @@ -56,7 +56,7 @@ async def get_part_for_day( ) async def get_image_for_day( image: Image.Image = Depends(get_image), - can_view: bool = Depends(user_can_view_day), + can_view: bool = Depends(user_can_view_door), is_admin: bool = Depends(user_is_admin), ) -> StreamingResponse: """ From f9f9989414ba8038ce637da4f745bf1467bd29ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn-Michael=20Miehe?= Date: Fri, 8 Sep 2023 19:53:35 +0000 Subject: [PATCH 15/15] remove `(Calendar)Config`'s static methods for good measure --- api/advent22_api/core/calendar_config.py | 26 +++++++++++------------ api/advent22_api/core/config.py | 14 ++++++------ api/advent22_api/core/depends.py | 8 +++---- api/advent22_api/core/sequence_helpers.py | 4 ++-- api/advent22_api/routers/_security.py | 4 ++-- api/advent22_api/routers/days.py | 4 ++-- api/advent22_api/routers/general.py | 14 ++++++------ 7 files changed, 37 insertions(+), 37 deletions(-) diff --git a/api/advent22_api/core/calendar_config.py b/api/advent22_api/core/calendar_config.py index 18949c9..9b3e618 100644 --- a/api/advent22_api/core/calendar_config.py +++ b/api/advent22_api/core/calendar_config.py @@ -5,7 +5,7 @@ import tomli_w from fastapi import Depends from pydantic import BaseModel -from .config import Config +from .config import Config, get_config from .webdav import WebDAV @@ -30,18 +30,7 @@ class CalendarConfig(BaseModel): # Türen für die UI doors: DoorsSaved = [] - @staticmethod - async def get_calendar_config( - cfg: Config = Depends(Config.get_config), - ) -> "CalendarConfig": - """ - Kalender Konfiguration lesen - """ - - txt = await WebDAV.read_str(path=f"files/{cfg.puzzle.calendar}") - return CalendarConfig.model_validate(tomllib.loads(txt)) - - async def set_calendar_config(self, cfg: Config) -> None: + async def change(self, cfg: Config) -> None: """ Kalender Konfiguration ändern """ @@ -55,3 +44,14 @@ class CalendarConfig(BaseModel): ) ), ) + + +async def get_calendar_config( + cfg: Config = Depends(get_config), +) -> CalendarConfig: + """ + Kalender Konfiguration lesen + """ + + txt = await WebDAV.read_str(path=f"files/{cfg.puzzle.calendar}") + return CalendarConfig.model_validate(tomllib.loads(txt)) diff --git a/api/advent22_api/core/config.py b/api/advent22_api/core/config.py index d4a2a4c..002cff2 100644 --- a/api/advent22_api/core/config.py +++ b/api/advent22_api/core/config.py @@ -44,11 +44,11 @@ class Config(BaseModel): server: Server puzzle: Puzzle - @staticmethod - async def get_config() -> "Config": - """ - Globale Konfiguration lesen - """ - txt = await WebDAV.read_str(path=SETTINGS.config_filename) - return Config.model_validate(tomllib.loads(txt)) +async def get_config() -> "Config": + """ + Globale Konfiguration lesen + """ + + txt = await WebDAV.read_str(path=SETTINGS.config_filename) + return Config.model_validate(tomllib.loads(txt)) diff --git a/api/advent22_api/core/depends.py b/api/advent22_api/core/depends.py index 6b814e1..cf1b9aa 100644 --- a/api/advent22_api/core/depends.py +++ b/api/advent22_api/core/depends.py @@ -5,14 +5,14 @@ from fastapi import Depends from PIL import Image, ImageFont from .advent_image import _XY, AdventImage -from .config import Config +from .config import Config, get_config from .image_helpers import list_images_auto, load_image from .sequence_helpers import Random, set_len, shuffle from .webdav import WebDAV async def shuffle_solution( - cfg: Config = Depends(Config.get_config), + cfg: Config = Depends(get_config), ) -> str: """ Lösung: Reihenfolge zufällig bestimmen @@ -56,7 +56,7 @@ async def get_random( async def gen_auto_image( day: int, auto_images: list[str] = Depends(shuffle_images_auto), - cfg: Config = Depends(Config.get_config), + cfg: Config = Depends(get_config), rnd: Random = Depends(get_random), part: str = Depends(get_part), ) -> Image.Image: @@ -87,7 +87,7 @@ async def gen_auto_image( async def get_image( day: int, auto_images: list[str] = Depends(shuffle_images_auto), - cfg: Config = Depends(Config.get_config), + cfg: Config = Depends(get_config), rnd: Random = Depends(get_random), part: str = Depends(get_part), ) -> Image.Image: diff --git a/api/advent22_api/core/sequence_helpers.py b/api/advent22_api/core/sequence_helpers.py index 049fc15..27fa454 100644 --- a/api/advent22_api/core/sequence_helpers.py +++ b/api/advent22_api/core/sequence_helpers.py @@ -2,13 +2,13 @@ import itertools import random from typing import Any, Self, Sequence -from .config import Config +from .config import get_config class Random(random.Random): @classmethod async def get(cls, bonus_salt: Any = "") -> Self: - cfg = await Config.get_config() + cfg = await get_config() return cls(f"{cfg.puzzle.solution}{cfg.puzzle.random_pepper}{bonus_salt}") diff --git a/api/advent22_api/routers/_security.py b/api/advent22_api/routers/_security.py index 1bfcbe2..e2c828e 100644 --- a/api/advent22_api/routers/_security.py +++ b/api/advent22_api/routers/_security.py @@ -4,14 +4,14 @@ from datetime import date from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBasic, HTTPBasicCredentials -from ..core.config import Config +from ..core.config import Config, get_config security = HTTPBasic() async def user_is_admin( credentials: HTTPBasicCredentials = Depends(security), - cfg: Config = Depends(Config.get_config), + cfg: Config = Depends(get_config), ) -> bool: """ True iff der user "admin" ist diff --git a/api/advent22_api/routers/days.py b/api/advent22_api/routers/days.py index d74ae26..20d9d80 100644 --- a/api/advent22_api/routers/days.py +++ b/api/advent22_api/routers/days.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import StreamingResponse from PIL import Image -from ..core.config import Config +from ..core.config import get_config from ..core.depends import get_image, get_part, shuffle_solution from ..core.image_helpers import api_return_image from ._security import user_can_view_door, user_is_admin, user_visible_doors @@ -14,7 +14,7 @@ router = APIRouter(prefix="/days", tags=["days"]) @router.on_event("startup") async def startup() -> None: - cfg = await Config.get_config() + cfg = await get_config() print(cfg.puzzle.solution) shuffled_solution = await shuffle_solution(cfg) diff --git a/api/advent22_api/routers/general.py b/api/advent22_api/routers/general.py index 4787624..556698b 100644 --- a/api/advent22_api/routers/general.py +++ b/api/advent22_api/routers/general.py @@ -1,8 +1,8 @@ from fastapi import APIRouter, Depends from fastapi.responses import StreamingResponse -from ..core.calendar_config import CalendarConfig, DoorsSaved -from ..core.config import Config +from ..core.calendar_config import CalendarConfig, DoorsSaved, get_calendar_config +from ..core.config import Config, get_config from ..core.image_helpers import api_return_image, load_image router = APIRouter(prefix="/general", tags=["general"]) @@ -13,7 +13,7 @@ router = APIRouter(prefix="/general", tags=["general"]) response_class=StreamingResponse, ) async def get_image_for_day( - cal_cfg: CalendarConfig = Depends(CalendarConfig.get_calendar_config), + cal_cfg: CalendarConfig = Depends(get_calendar_config), ) -> StreamingResponse: """ Hintergrundbild laden @@ -24,7 +24,7 @@ async def get_image_for_day( @router.get("/doors") async def get_doors( - cal_cfg: CalendarConfig = Depends(CalendarConfig.get_calendar_config), + cal_cfg: CalendarConfig = Depends(get_calendar_config), ) -> DoorsSaved: """ Türchen lesen @@ -36,8 +36,8 @@ async def get_doors( @router.put("/doors") async def put_doors( doors: DoorsSaved, - cfg: Config = Depends(Config.get_config), - cal_cfg: CalendarConfig = Depends(CalendarConfig.get_calendar_config), + cfg: Config = Depends(get_config), + cal_cfg: CalendarConfig = Depends(get_calendar_config), ) -> None: """ Türchen setzen @@ -47,4 +47,4 @@ async def put_doors( doors, key=lambda door: door.day, ) - await cal_cfg.set_calendar_config(cfg) + await cal_cfg.change(cfg)