diff options
Diffstat (limited to 'venv/lib/python3.11/site-packages/litestar/stores')
12 files changed, 698 insertions, 0 deletions
diff --git a/venv/lib/python3.11/site-packages/litestar/stores/__init__.py b/venv/lib/python3.11/site-packages/litestar/stores/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/venv/lib/python3.11/site-packages/litestar/stores/__init__.py diff --git a/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/__init__.cpython-311.pyc b/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/__init__.cpython-311.pyc Binary files differnew file mode 100644 index 0000000..78604d0 --- /dev/null +++ b/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/__init__.cpython-311.pyc diff --git a/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/base.cpython-311.pyc b/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/base.cpython-311.pyc Binary files differnew file mode 100644 index 0000000..21d2fb4 --- /dev/null +++ b/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/base.cpython-311.pyc diff --git a/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/file.cpython-311.pyc b/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/file.cpython-311.pyc Binary files differnew file mode 100644 index 0000000..b39333c --- /dev/null +++ b/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/file.cpython-311.pyc diff --git a/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/memory.cpython-311.pyc b/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/memory.cpython-311.pyc Binary files differnew file mode 100644 index 0000000..c8e3b05 --- /dev/null +++ b/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/memory.cpython-311.pyc diff --git a/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/redis.cpython-311.pyc b/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/redis.cpython-311.pyc Binary files differnew file mode 100644 index 0000000..21e7e2f --- /dev/null +++ b/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/redis.cpython-311.pyc diff --git a/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/registry.cpython-311.pyc b/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/registry.cpython-311.pyc Binary files differnew file mode 100644 index 0000000..c87c31a --- /dev/null +++ b/venv/lib/python3.11/site-packages/litestar/stores/__pycache__/registry.cpython-311.pyc diff --git a/venv/lib/python3.11/site-packages/litestar/stores/base.py b/venv/lib/python3.11/site-packages/litestar/stores/base.py new file mode 100644 index 0000000..34aa514 --- /dev/null +++ b/venv/lib/python3.11/site-packages/litestar/stores/base.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Optional + +from msgspec import Struct +from msgspec.msgpack import decode as msgpack_decode +from msgspec.msgpack import encode as msgpack_encode + +if TYPE_CHECKING: + from types import TracebackType + + from typing_extensions import Self + + +__all__ = ("Store", "NamespacedStore", "StorageObject") + + +class Store(ABC): + """Thread and process safe asynchronous key/value store.""" + + @abstractmethod + async def set(self, key: str, value: str | bytes, expires_in: int | timedelta | None = None) -> None: + """Set a value. + + Args: + key: Key to associate the value with + value: Value to store + expires_in: Time in seconds before the key is considered expired + + Returns: + ``None`` + """ + raise NotImplementedError + + @abstractmethod + async def get(self, key: str, renew_for: int | timedelta | None = None) -> bytes | None: + """Get a value. + + Args: + key: Key associated with the value + renew_for: If given and the value had an initial expiry time set, renew the + expiry time for ``renew_for`` seconds. If the value has not been set + with an expiry time this is a no-op + + Returns: + The value associated with ``key`` if it exists and is not expired, else + ``None`` + """ + raise NotImplementedError + + @abstractmethod + async def delete(self, key: str) -> None: + """Delete a value. + + If no such key exists, this is a no-op. + + Args: + key: Key of the value to delete + """ + raise NotImplementedError + + @abstractmethod + async def delete_all(self) -> None: + """Delete all stored values.""" + raise NotImplementedError + + @abstractmethod + async def exists(self, key: str) -> bool: + """Check if a given ``key`` exists.""" + raise NotImplementedError + + @abstractmethod + async def expires_in(self, key: str) -> int | None: + """Get the time in seconds ``key`` expires in. If no such ``key`` exists or no + expiry time was set, return ``None``. + """ + raise NotImplementedError + + async def __aenter__(self) -> None: # noqa: B027 + pass + + async def __aexit__( # noqa: B027 + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + pass + + +class NamespacedStore(Store): + """A subclass of :class:`Store`, offering hierarchical namespacing. + + Bulk actions on a parent namespace should affect all child namespaces, whereas other operations on all namespaces + should be isolated. + """ + + @abstractmethod + def with_namespace(self, namespace: str) -> Self: + """Return a new instance of :class:`NamespacedStore`, which exists in a child namespace of the current namespace. + Bulk actions on the parent namespace should affect all child namespaces, whereas other operations on all + namespaces should be isolated. + """ + + +class StorageObject(Struct): + """:class:`msgspec.Struct` to store serialized data alongside with their expiry time.""" + + expires_at: Optional[datetime] # noqa: UP007 + data: bytes + + @classmethod + def new(cls, data: bytes, expires_in: int | timedelta | None) -> StorageObject: + """Construct a new :class:`StorageObject` instance.""" + if expires_in is not None and not isinstance(expires_in, timedelta): + expires_in = timedelta(seconds=expires_in) + return cls( + data=data, + expires_at=(datetime.now(tz=timezone.utc) + expires_in) if expires_in else None, + ) + + @property + def expired(self) -> bool: + """Return if the :class:`StorageObject` is expired""" + return self.expires_at is not None and datetime.now(tz=timezone.utc) >= self.expires_at + + @property + def expires_in(self) -> int: + """Return the expiry time of this ``StorageObject`` in seconds. If no expiry time + was set, return ``-1``. + """ + if self.expires_at: + return int(self.expires_at.timestamp() - datetime.now(tz=timezone.utc).timestamp()) + return -1 + + def to_bytes(self) -> bytes: + """Encode the instance to bytes""" + return msgpack_encode(self) + + @classmethod + def from_bytes(cls, raw: bytes) -> StorageObject: + """Load a previously encoded with :meth:`StorageObject.to_bytes`""" + return msgpack_decode(raw, type=cls) diff --git a/venv/lib/python3.11/site-packages/litestar/stores/file.py b/venv/lib/python3.11/site-packages/litestar/stores/file.py new file mode 100644 index 0000000..25c52eb --- /dev/null +++ b/venv/lib/python3.11/site-packages/litestar/stores/file.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import os +import shutil +import unicodedata +from tempfile import mkstemp +from typing import TYPE_CHECKING + +from anyio import Path + +from litestar.concurrency import sync_to_thread + +from .base import NamespacedStore, StorageObject + +__all__ = ("FileStore",) + + +if TYPE_CHECKING: + from datetime import timedelta + from os import PathLike + + +def _safe_file_name(name: str) -> str: + name = unicodedata.normalize("NFKD", name) + return "".join(c if c.isalnum() else str(ord(c)) for c in name) + + +class FileStore(NamespacedStore): + """File based, thread and process safe, asynchronous key/value store.""" + + __slots__ = {"path": "file path"} + + def __init__(self, path: PathLike[str]) -> None: + """Initialize ``FileStorage``. + + Args: + path: Path to store data under + """ + self.path = Path(path) + + def with_namespace(self, namespace: str) -> FileStore: + """Return a new instance of :class:`FileStore`, using a sub-path of the current store's path.""" + if not namespace.isalnum(): + raise ValueError(f"Invalid namespace: {namespace!r}") + return FileStore(self.path / namespace) + + def _path_from_key(self, key: str) -> Path: + return self.path / _safe_file_name(key) + + @staticmethod + async def _load_from_path(path: Path) -> StorageObject | None: + try: + data = await path.read_bytes() + return StorageObject.from_bytes(data) + except FileNotFoundError: + return None + + def _write_sync(self, target_file: Path, storage_obj: StorageObject) -> None: + try: + tmp_file_fd, tmp_file_name = mkstemp(dir=self.path, prefix=f"{target_file.name}.tmp") + renamed = False + try: + try: + os.write(tmp_file_fd, storage_obj.to_bytes()) + finally: + os.close(tmp_file_fd) + + os.replace(tmp_file_name, target_file) # noqa: PTH105 + renamed = True + finally: + if not renamed: + os.unlink(tmp_file_name) # noqa: PTH108 + except OSError: + pass + + async def _write(self, target_file: Path, storage_obj: StorageObject) -> None: + await sync_to_thread(self._write_sync, target_file, storage_obj) + + async def set(self, key: str, value: str | bytes, expires_in: int | timedelta | None = None) -> None: + """Set a value. + + Args: + key: Key to associate the value with + value: Value to store + expires_in: Time in seconds before the key is considered expired + + Returns: + ``None`` + """ + + await self.path.mkdir(exist_ok=True) + path = self._path_from_key(key) + if isinstance(value, str): + value = value.encode("utf-8") + storage_obj = StorageObject.new(data=value, expires_in=expires_in) + await self._write(path, storage_obj) + + async def get(self, key: str, renew_for: int | timedelta | None = None) -> bytes | None: + """Get a value. + + Args: + key: Key associated with the value + renew_for: If given and the value had an initial expiry time set, renew the + expiry time for ``renew_for`` seconds. If the value has not been set + with an expiry time this is a no-op + + Returns: + The value associated with ``key`` if it exists and is not expired, else + ``None`` + """ + path = self._path_from_key(key) + storage_obj = await self._load_from_path(path) + + if not storage_obj: + return None + + if storage_obj.expired: + await path.unlink(missing_ok=True) + return None + + if renew_for and storage_obj.expires_at: + await self.set(key, value=storage_obj.data, expires_in=renew_for) + + return storage_obj.data + + async def delete(self, key: str) -> None: + """Delete a value. + + If no such key exists, this is a no-op. + + Args: + key: Key of the value to delete + """ + path = self._path_from_key(key) + await path.unlink(missing_ok=True) + + async def delete_all(self) -> None: + """Delete all stored values. + + Note: + This deletes and recreates :attr:`FileStore.path` + """ + + await sync_to_thread(shutil.rmtree, self.path) + await self.path.mkdir(exist_ok=True) + + async def delete_expired(self) -> None: + """Delete expired items. + + Since expired items are normally only cleared on access (i.e. when calling + :meth:`.get`), this method should be called in regular intervals + to free disk space. + """ + async for file in self.path.iterdir(): + wrapper = await self._load_from_path(file) + if wrapper and wrapper.expired: + await file.unlink(missing_ok=True) + + async def exists(self, key: str) -> bool: + """Check if a given ``key`` exists.""" + path = self._path_from_key(key) + return await path.exists() + + async def expires_in(self, key: str) -> int | None: + """Get the time in seconds ``key`` expires in. If no such ``key`` exists or no + expiry time was set, return ``None``. + """ + if storage_obj := await self._load_from_path(self._path_from_key(key)): + return storage_obj.expires_in + return None diff --git a/venv/lib/python3.11/site-packages/litestar/stores/memory.py b/venv/lib/python3.11/site-packages/litestar/stores/memory.py new file mode 100644 index 0000000..1da8931 --- /dev/null +++ b/venv/lib/python3.11/site-packages/litestar/stores/memory.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import anyio +from anyio import Lock + +from .base import StorageObject, Store + +__all__ = ("MemoryStore",) + + +if TYPE_CHECKING: + from datetime import timedelta + + +class MemoryStore(Store): + """In memory, atomic, asynchronous key/value store.""" + + __slots__ = ("_store", "_lock") + + def __init__(self) -> None: + """Initialize :class:`MemoryStore`""" + self._store: dict[str, StorageObject] = {} + self._lock = Lock() + + async def set(self, key: str, value: str | bytes, expires_in: int | timedelta | None = None) -> None: + """Set a value. + + Args: + key: Key to associate the value with + value: Value to store + expires_in: Time in seconds before the key is considered expired + + Returns: + ``None`` + """ + if isinstance(value, str): + value = value.encode("utf-8") + async with self._lock: + self._store[key] = StorageObject.new(data=value, expires_in=expires_in) + + async def get(self, key: str, renew_for: int | timedelta | None = None) -> bytes | None: + """Get a value. + + Args: + key: Key associated with the value + renew_for: If given and the value had an initial expiry time set, renew the + expiry time for ``renew_for`` seconds. If the value has not been set + with an expiry time this is a no-op + + Returns: + The value associated with ``key`` if it exists and is not expired, else + ``None`` + """ + async with self._lock: + storage_obj = self._store.get(key) + + if not storage_obj: + return None + + if storage_obj.expired: + self._store.pop(key) + return None + + if renew_for and storage_obj.expires_at: + # don't use .set() here, so we can hold onto the lock for the whole operation + storage_obj = StorageObject.new(data=storage_obj.data, expires_in=renew_for) + self._store[key] = storage_obj + + return storage_obj.data + + async def delete(self, key: str) -> None: + """Delete a value. + + If no such key exists, this is a no-op. + + Args: + key: Key of the value to delete + """ + async with self._lock: + self._store.pop(key, None) + + async def delete_all(self) -> None: + """Delete all stored values.""" + async with self._lock: + self._store.clear() + + async def delete_expired(self) -> None: + """Delete expired items. + + Since expired items are normally only cleared on access (i.e. when calling + :meth:`.get`), this method should be called in regular intervals + to free memory. + """ + async with self._lock: + new_store = {} + for i, (key, storage_obj) in enumerate(self._store.items()): + if not storage_obj.expired: + new_store[key] = storage_obj + if i % 1000 == 0: + await anyio.sleep(0) + self._store = new_store + + async def exists(self, key: str) -> bool: + """Check if a given ``key`` exists.""" + return key in self._store + + async def expires_in(self, key: str) -> int | None: + """Get the time in seconds ``key`` expires in. If no such ``key`` exists or no + expiry time was set, return ``None``. + """ + if storage_obj := self._store.get(key): + return storage_obj.expires_in + return None diff --git a/venv/lib/python3.11/site-packages/litestar/stores/redis.py b/venv/lib/python3.11/site-packages/litestar/stores/redis.py new file mode 100644 index 0000000..6697962 --- /dev/null +++ b/venv/lib/python3.11/site-packages/litestar/stores/redis.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING, cast + +from redis.asyncio import Redis +from redis.asyncio.connection import ConnectionPool + +from litestar.exceptions import ImproperlyConfiguredException +from litestar.types import Empty, EmptyType +from litestar.utils.empty import value_or_default + +from .base import NamespacedStore + +__all__ = ("RedisStore",) + +if TYPE_CHECKING: + from types import TracebackType + + +class RedisStore(NamespacedStore): + """Redis based, thread and process safe asynchronous key/value store.""" + + __slots__ = ("_redis",) + + def __init__( + self, redis: Redis, namespace: str | None | EmptyType = Empty, handle_client_shutdown: bool = False + ) -> None: + """Initialize :class:`RedisStore` + + Args: + redis: An :class:`redis.asyncio.Redis` instance + namespace: A key prefix to simulate a namespace in redis. If not given, + defaults to ``LITESTAR``. Namespacing can be explicitly disabled by passing + ``None``. This will make :meth:`.delete_all` unavailable. + handle_client_shutdown: If ``True``, handle the shutdown of the `redis` instance automatically during the store's lifespan. Should be set to `True` unless the shutdown is handled externally + """ + self._redis = redis + self.namespace: str | None = value_or_default(namespace, "LITESTAR") + self.handle_client_shutdown = handle_client_shutdown + + # script to get and renew a key in one atomic step + self._get_and_renew_script = self._redis.register_script( + b""" + local key = KEYS[1] + local renew = tonumber(ARGV[1]) + + local data = redis.call('GET', key) + local ttl = redis.call('TTL', key) + + if ttl > 0 then + redis.call('EXPIRE', key, renew) + end + + return data + """ + ) + + # script to delete all keys in the namespace + self._delete_all_script = self._redis.register_script( + b""" + local cursor = 0 + + repeat + local result = redis.call('SCAN', cursor, 'MATCH', ARGV[1]) + for _,key in ipairs(result[2]) do + redis.call('UNLINK', key) + end + cursor = tonumber(result[1]) + until cursor == 0 + """ + ) + + async def _shutdown(self) -> None: + if self.handle_client_shutdown: + await self._redis.aclose(close_connection_pool=True) # type: ignore[attr-defined] + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self._shutdown() + + @classmethod + def with_client( + cls, + url: str = "redis://localhost:6379", + *, + db: int | None = None, + port: int | None = None, + username: str | None = None, + password: str | None = None, + namespace: str | None | EmptyType = Empty, + ) -> RedisStore: + """Initialize a :class:`RedisStore` instance with a new class:`redis.asyncio.Redis` instance. + + Args: + url: Redis URL to connect to + db: Redis database to use + port: Redis port to use + username: Redis username to use + password: Redis password to use + namespace: Virtual key namespace to use + """ + pool = ConnectionPool.from_url( + url=url, + db=db, + decode_responses=False, + port=port, + username=username, + password=password, + ) + return cls( + redis=Redis(connection_pool=pool), + namespace=namespace, + handle_client_shutdown=True, + ) + + def with_namespace(self, namespace: str) -> RedisStore: + """Return a new :class:`RedisStore` with a nested virtual key namespace. + The current instances namespace will serve as a prefix for the namespace, so it + can be considered the parent namespace. + """ + return type(self)( + redis=self._redis, + namespace=f"{self.namespace}_{namespace}" if self.namespace else namespace, + handle_client_shutdown=self.handle_client_shutdown, + ) + + def _make_key(self, key: str) -> str: + prefix = f"{self.namespace}:" if self.namespace else "" + return prefix + key + + async def set(self, key: str, value: str | bytes, expires_in: int | timedelta | None = None) -> None: + """Set a value. + + Args: + key: Key to associate the value with + value: Value to store + expires_in: Time in seconds before the key is considered expired + + Returns: + ``None`` + """ + if isinstance(value, str): + value = value.encode("utf-8") + await self._redis.set(self._make_key(key), value, ex=expires_in) + + async def get(self, key: str, renew_for: int | timedelta | None = None) -> bytes | None: + """Get a value. + + Args: + key: Key associated with the value + renew_for: If given and the value had an initial expiry time set, renew the + expiry time for ``renew_for`` seconds. If the value has not been set + with an expiry time this is a no-op. Atomicity of this step is guaranteed + by using a lua script to execute fetch and renewal. If ``renew_for`` is + not given, the script will be bypassed so no overhead will occur + + Returns: + The value associated with ``key`` if it exists and is not expired, else + ``None`` + """ + key = self._make_key(key) + if renew_for: + if isinstance(renew_for, timedelta): + renew_for = renew_for.seconds + data = await self._get_and_renew_script(keys=[key], args=[renew_for]) + return cast("bytes | None", data) + return await self._redis.get(key) + + async def delete(self, key: str) -> None: + """Delete a value. + + If no such key exists, this is a no-op. + + Args: + key: Key of the value to delete + """ + await self._redis.delete(self._make_key(key)) + + async def delete_all(self) -> None: + """Delete all stored values in the virtual key namespace. + + Raises: + ImproperlyConfiguredException: If no namespace was configured + """ + if not self.namespace: + raise ImproperlyConfiguredException("Cannot perform delete operation: No namespace configured") + + await self._delete_all_script(keys=[], args=[f"{self.namespace}*:*"]) + + async def exists(self, key: str) -> bool: + """Check if a given ``key`` exists.""" + return await self._redis.exists(self._make_key(key)) == 1 + + async def expires_in(self, key: str) -> int | None: + """Get the time in seconds ``key`` expires in. If no such ``key`` exists or no + expiry time was set, return ``None``. + """ + ttl = await self._redis.ttl(self._make_key(key)) + return None if ttl == -2 else ttl diff --git a/venv/lib/python3.11/site-packages/litestar/stores/registry.py b/venv/lib/python3.11/site-packages/litestar/stores/registry.py new file mode 100644 index 0000000..11a08c2 --- /dev/null +++ b/venv/lib/python3.11/site-packages/litestar/stores/registry.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from .base import Store + + +from .memory import MemoryStore + +__all__ = ("StoreRegistry",) + + +def default_default_factory(name: str) -> Store: + return MemoryStore() + + +class StoreRegistry: + """Registry for :class:`Store <.base.Store>` instances.""" + + __slots__ = ("_stores", "_default_factory") + + def __init__( + self, stores: dict[str, Store] | None = None, default_factory: Callable[[str], Store] = default_default_factory + ) -> None: + """Initialize ``StoreRegistry``. + + Args: + stores: A dictionary mapping store names to stores, used to initialize the registry + default_factory: A callable used by :meth:`StoreRegistry.get` to provide a store, if the requested name hasn't + been registered yet. This callable receives the requested name and should return a + :class:`Store <.base.Store>` instance. + """ + self._stores = stores or {} + self._default_factory = default_factory + + def register(self, name: str, store: Store, allow_override: bool = False) -> None: + """Register a new :class:`Store <.base.Store>`. + + Args: + name: Name to register the store under + store: The store to register + allow_override: Whether to allow overriding an existing store of the same name + + Raises: + ValueError: If a store is already registered under this name and ``override`` is not ``True`` + """ + if not allow_override and name in self._stores: + raise ValueError(f"Store with the name {name!r} already exists") + self._stores[name] = store + + def get(self, name: str) -> Store: + """Get a store registered under ``name``. If no such store is registered, create a store using the default + factory with ``name`` and register the returned store under ``name``. + + Args: + name: Name of the store + + Returns: + A :class:`Store <.base.Store>` + """ + if not self._stores.get(name): + self._stores[name] = self._default_factory(name) + return self._stores[name] |