better async webdav implementation
This commit is contained in:
		
							parent
							
								
									5e5b2b164e
								
							
						
					
					
						commit
						d1cde05be7
					
				
					 5 changed files with 137 additions and 62 deletions
				
			
		| 
						 | 
				
			
			@ -84,10 +84,15 @@ 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),
 | 
			
		||||
    )
 | 
			
		||||
    __DIR = "/images_auto"
 | 
			
		||||
 | 
			
		||||
    return [
 | 
			
		||||
        f"{__DIR}/{file}"
 | 
			
		||||
        for file in await WebDAV.list_files(
 | 
			
		||||
            directory=__DIR,
 | 
			
		||||
            regex=re.compile(r"\.(gif|jpe?g|tiff?|png|bmp)$", flags=re.IGNORECASE),
 | 
			
		||||
        )
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def load_image(file_name: str) -> Image.Image:
 | 
			
		||||
| 
						 | 
				
			
			@ -95,7 +100,7 @@ async def load_image(file_name: str) -> Image.Image:
 | 
			
		|||
    Versuche, Bild aus Datei zu laden
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    if not await WebDAV.file_exists(file_name):
 | 
			
		||||
    if not await WebDAV.exists(file_name):
 | 
			
		||||
        raise RuntimeError(f"DAV-File {file_name} does not exist!")
 | 
			
		||||
 | 
			
		||||
    return Image.open(BytesIO(await WebDAV.read_bytes(file_name)))
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,6 +20,7 @@ class DavSettings(BaseModel):
 | 
			
		|||
    password: str = "password"
 | 
			
		||||
 | 
			
		||||
    cache_ttl: int = 60 * 30
 | 
			
		||||
    cache_size: int = 1024
 | 
			
		||||
    config_filename: str = "config.toml"
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,16 +1,44 @@
 | 
			
		|||
import asyncio
 | 
			
		||||
import functools
 | 
			
		||||
import logging
 | 
			
		||||
import re
 | 
			
		||||
from io import BytesIO
 | 
			
		||||
 | 
			
		||||
from cache import AsyncTTL
 | 
			
		||||
from cache.key import KEY
 | 
			
		||||
import requests
 | 
			
		||||
from asyncify import asyncify
 | 
			
		||||
from cachetools import TTLCache, cachedmethod
 | 
			
		||||
from cachetools.keys import hashkey
 | 
			
		||||
from webdav3.client import Client as WebDAVclient
 | 
			
		||||
 | 
			
		||||
from .settings import SETTINGS
 | 
			
		||||
 | 
			
		||||
_logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def davkey(name, _, *args, **kwargs):
 | 
			
		||||
    """Return a cache key for use with cached methods."""
 | 
			
		||||
 | 
			
		||||
    return hashkey(name, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class WebDAV:
 | 
			
		||||
    _webdav_client = WebDAVclient(
 | 
			
		||||
    class __WebDAVclient(WebDAVclient):
 | 
			
		||||
        def execute_request(
 | 
			
		||||
            self,
 | 
			
		||||
            action,
 | 
			
		||||
            path,
 | 
			
		||||
            data=None,
 | 
			
		||||
            headers_ext=None,
 | 
			
		||||
        ) -> requests.Response:
 | 
			
		||||
            res = super().execute_request(action, path, data, headers_ext)
 | 
			
		||||
 | 
			
		||||
            # the "Content-Length" header can randomly be missing on txt files,
 | 
			
		||||
            # this should fix that (probably serverside bug)
 | 
			
		||||
            if action == "download" and "Content-Length" not in res.headers:
 | 
			
		||||
                res.headers["Content-Length"] = str(len(res.text))
 | 
			
		||||
 | 
			
		||||
            return res
 | 
			
		||||
 | 
			
		||||
    _webdav_client = __WebDAVclient(
 | 
			
		||||
        {
 | 
			
		||||
            "webdav_hostname": SETTINGS.webdav.url,
 | 
			
		||||
            "webdav_login": SETTINGS.webdav.username,
 | 
			
		||||
| 
						 | 
				
			
			@ -18,55 +46,60 @@ class WebDAV:
 | 
			
		|||
        }
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    _cache = TTLCache(
 | 
			
		||||
        ttl=SETTINGS.webdav.cache_ttl,
 | 
			
		||||
        maxsize=SETTINGS.webdav.cache_size,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    @AsyncTTL(time_to_live=SETTINGS.webdav.cache_ttl, skip_args=1)
 | 
			
		||||
    async def list_files(
 | 
			
		||||
    @asyncify
 | 
			
		||||
    @cachedmethod(
 | 
			
		||||
        cache=lambda cls: cls._cache,
 | 
			
		||||
        key=functools.partial(davkey, "list_files"),
 | 
			
		||||
    )
 | 
			
		||||
    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
 | 
			
		||||
        List files in directory `directory` matching RegEx `regex`
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        loop = asyncio.get_running_loop()
 | 
			
		||||
        ls = await loop.run_in_executor(
 | 
			
		||||
            None,
 | 
			
		||||
            cls._webdav_client.list,
 | 
			
		||||
            directory,
 | 
			
		||||
        )
 | 
			
		||||
        _logger.debug(f"list_files {directory!r}")
 | 
			
		||||
        ls = cls._webdav_client.list(directory)
 | 
			
		||||
 | 
			
		||||
        return [f"{directory}/{path}" for path in ls if regex.search(path)]
 | 
			
		||||
        return [path for path in ls if regex.search(path)]
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    @AsyncTTL(time_to_live=SETTINGS.webdav.cache_ttl, skip_args=1)
 | 
			
		||||
    async def file_exists(cls, path: str) -> bool:
 | 
			
		||||
    @asyncify
 | 
			
		||||
    @cachedmethod(
 | 
			
		||||
        cache=lambda cls: cls._cache,
 | 
			
		||||
        key=functools.partial(davkey, "exists"),
 | 
			
		||||
    )
 | 
			
		||||
    def exists(cls, path: str) -> bool:
 | 
			
		||||
        """
 | 
			
		||||
        `True`, wenn an Pfad `path` eine Datei existiert
 | 
			
		||||
        `True` iff there is a WebDAV resource at `path`
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        loop = asyncio.get_running_loop()
 | 
			
		||||
        return await loop.run_in_executor(
 | 
			
		||||
            None,
 | 
			
		||||
            cls._webdav_client.check,
 | 
			
		||||
            path,
 | 
			
		||||
        )
 | 
			
		||||
        _logger.debug(f"file_exists {path!r}")
 | 
			
		||||
        return cls._webdav_client.check(path)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    @(_rb_ttl := AsyncTTL(time_to_live=SETTINGS.webdav.cache_ttl, skip_args=1))
 | 
			
		||||
    async def read_bytes(cls, path: str) -> bytes:
 | 
			
		||||
    @asyncify
 | 
			
		||||
    @cachedmethod(
 | 
			
		||||
        cache=lambda cls: cls._cache,
 | 
			
		||||
        key=functools.partial(davkey, "read_bytes"),
 | 
			
		||||
    )
 | 
			
		||||
    def read_bytes(cls, path: str) -> bytes:
 | 
			
		||||
        """
 | 
			
		||||
        Datei aus Pfad `path` als bytes laden
 | 
			
		||||
        Load WebDAV file from `path` as bytes
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        _logger.debug(f"read_bytes {path!r}")
 | 
			
		||||
        buffer = BytesIO()
 | 
			
		||||
        loop = asyncio.get_running_loop()
 | 
			
		||||
        await loop.run_in_executor(
 | 
			
		||||
            None,
 | 
			
		||||
            cls._webdav_client.resource(path).write_to,
 | 
			
		||||
            buffer,
 | 
			
		||||
        )
 | 
			
		||||
        cls._webdav_client.download_from(buffer, path)
 | 
			
		||||
        buffer.seek(0)
 | 
			
		||||
 | 
			
		||||
        return buffer.read()
 | 
			
		||||
| 
						 | 
				
			
			@ -74,37 +107,30 @@ class WebDAV:
 | 
			
		|||
    @classmethod
 | 
			
		||||
    async def read_str(cls, path: str, encoding="utf-8") -> str:
 | 
			
		||||
        """
 | 
			
		||||
        Datei aus Pfad `path` als string laden
 | 
			
		||||
        Load WebDAV file from `path` as string
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        _logger.debug(f"read_str {path!r}")
 | 
			
		||||
        return (await cls.read_bytes(path)).decode(encoding=encoding).strip()
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    async def write_bytes(cls, path: str, buffer: bytes) -> None:
 | 
			
		||||
    @asyncify
 | 
			
		||||
    def write_bytes(cls, path: str, buffer: bytes) -> None:
 | 
			
		||||
        """
 | 
			
		||||
        Bytes `buffer` in Datei in Pfad `path` schreiben
 | 
			
		||||
        Write bytes from `buffer` into WebDAV file at `path`
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        loop = asyncio.get_running_loop()
 | 
			
		||||
        await loop.run_in_executor(
 | 
			
		||||
            None,
 | 
			
		||||
            cls._webdav_client.resource(path).read_from,
 | 
			
		||||
            buffer,
 | 
			
		||||
        )
 | 
			
		||||
        _logger.debug(f"write_bytes {path!r}")
 | 
			
		||||
        cls._webdav_client.upload_to(buffer, path)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            # hack: zugehörigen Cache-Eintrag entfernen
 | 
			
		||||
            # -> AsyncTTL._TTL.__contains__
 | 
			
		||||
            del cls._rb_ttl.ttl[KEY((path,), {})]
 | 
			
		||||
 | 
			
		||||
        except KeyError:
 | 
			
		||||
            # Cache-Eintrag existierte nicht
 | 
			
		||||
            pass
 | 
			
		||||
        # invalidate cache entry
 | 
			
		||||
        cls._cache.pop(hashkey("read_bytes", path))
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    async def write_str(cls, path: str, content: str, encoding="utf-8") -> None:
 | 
			
		||||
        """
 | 
			
		||||
        String `content` in Datei in Pfad `path` schreiben
 | 
			
		||||
        Write string from `content` into WebDAV file at `path`
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        _logger.debug(f"write_str {path!r}")
 | 
			
		||||
        await cls.write_bytes(path, content.encode(encoding=encoding))
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										54
									
								
								api/poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										54
									
								
								api/poetry.lock
									
									
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -32,13 +32,29 @@ test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=
 | 
			
		|||
trio = ["trio (<0.22)"]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "async-cache"
 | 
			
		||||
version = "1.1.1"
 | 
			
		||||
description = "An asyncio Cache"
 | 
			
		||||
name = "asyncify"
 | 
			
		||||
version = "0.9.2"
 | 
			
		||||
description = "sync 2 async"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.3"
 | 
			
		||||
python-versions = ">=3.7,<4.0"
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "async-cache-1.1.1.tar.gz", hash = "sha256:81aa9ccd19fb06784aaf30bd5f2043dc0a23fc3e998b93d0c2c17d1af9803393"},
 | 
			
		||||
    {file = "asyncify-0.9.2-py3-none-any.whl", hash = "sha256:ee7efe8ecc11f348d4f25d4d1c5fb2f56a187aaa907aea3608106359728a2cdd"},
 | 
			
		||||
    {file = "asyncify-0.9.2.tar.gz", hash = "sha256:5f06016a5d805354505e98e9c009595cba7905ceb767ed7cd61bf60f2341d896"},
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[package.dependencies]
 | 
			
		||||
funkify = ">=0.4.0,<0.5.0"
 | 
			
		||||
xtyping = ">=0.5.0"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "cachetools"
 | 
			
		||||
version = "5.3.2"
 | 
			
		||||
description = "Extensible memoizing collections and decorators"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.7"
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "cachetools-5.3.2-py3-none-any.whl", hash = "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1"},
 | 
			
		||||
    {file = "cachetools-5.3.2.tar.gz", hash = "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2"},
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
| 
						 | 
				
			
			@ -197,6 +213,17 @@ mccabe = ">=0.7.0,<0.8.0"
 | 
			
		|||
pycodestyle = ">=2.11.0,<2.12.0"
 | 
			
		||||
pyflakes = ">=3.1.0,<3.2.0"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "funkify"
 | 
			
		||||
version = "0.4.5"
 | 
			
		||||
description = "Funkify modules so that they are callable"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.7,<4.0"
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "funkify-0.4.5-py3-none-any.whl", hash = "sha256:43f1e6c27263468a60ba560dfc13e6e4df57aa75376438a62f741ffc7c83cdfe"},
 | 
			
		||||
    {file = "funkify-0.4.5.tar.gz", hash = "sha256:42df845f4afa63e0e66239a986d26b6572ab0b7ad600d7d6365d44d8a0cff3d5"},
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "h11"
 | 
			
		||||
version = "0.14.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -1106,7 +1133,22 @@ files = [
 | 
			
		|||
    {file = "websockets-11.0.3.tar.gz", hash = "sha256:88fc51d9a26b10fc331be344f1781224a375b78488fc343620184e95a4b27016"},
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "xtyping"
 | 
			
		||||
version = "0.7.0"
 | 
			
		||||
description = "xtyping = typing + typing_extensions"
 | 
			
		||||
optional = false
 | 
			
		||||
python-versions = ">=3.7,<4.0"
 | 
			
		||||
files = [
 | 
			
		||||
    {file = "xtyping-0.7.0-py3-none-any.whl", hash = "sha256:5b72b08d5b4775c1ff34a8b7bbdfaae92249aaa11c53b33f26a0a788ca209fda"},
 | 
			
		||||
    {file = "xtyping-0.7.0.tar.gz", hash = "sha256:441e597b227fcb51645e33de7cb47b7b23c014ee7c487a996b312652b8cacde0"},
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[package.dependencies]
 | 
			
		||||
annotated-types = ">=0.5.0"
 | 
			
		||||
typing-extensions = ">=4.4.0"
 | 
			
		||||
 | 
			
		||||
[metadata]
 | 
			
		||||
lock-version = "2.0"
 | 
			
		||||
python-versions = ">=3.11,<3.13"
 | 
			
		||||
content-hash = "5b956fd0da3635be7d6b62b220cb962ba676b0673b9d7f520f49da35c524d2c3"
 | 
			
		||||
content-hash = "5fe0d4c2bffe2fed7a521efa95c416944bc413714eb69a1e13caa8dbff20d1ca"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,7 +10,8 @@ version = "0.1.0"
 | 
			
		|||
 | 
			
		||||
[tool.poetry.dependencies]
 | 
			
		||||
Pillow = "^10.0.1"
 | 
			
		||||
async-cache = "^1.1.1"
 | 
			
		||||
asyncify = "^0.9.2"
 | 
			
		||||
cachetools = "^5.3.2"
 | 
			
		||||
fastapi = "^0.103.1"
 | 
			
		||||
numpy = "^1.26.0"
 | 
			
		||||
pydantic-settings = "^2.0.3"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in a new issue