diff --git a/README.md b/README.md index 9950c690..e1f9aaf9 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ ## Introduction `fastapi-cache` is a tool to cache FastAPI endpoint and function results, with -backends supporting Redis, Memcached, and Amazon DynamoDB. +backends supporting Redis, Memcached, libsql and Amazon DynamoDB. ## Features -- Supports `redis`, `memcache`, `dynamodb`, and `in-memory` backends. +- Supports `redis`, `memcache`, `dynamodb`, `libsql` and `in-memory` backends. - Easy integration with [FastAPI](https://fastapi.tiangolo.com/). - Support for HTTP cache headers like `ETag` and `Cache-Control`, as well as conditional `If-Match-None` requests. @@ -21,6 +21,7 @@ backends supporting Redis, Memcached, and Amazon DynamoDB. - `redis` when using `RedisBackend`. - `memcache` when using `MemcacheBackend`. - `aiobotocore` when using `DynamoBackend`. +- `libsql-client` when using `libsql` ## Install @@ -46,6 +47,10 @@ or > pip install "fastapi-cache2[dynamodb]" ``` +```shell +> pip install "fastapi-cache2[libsql]" +``` + ## Usage ### Quick Start diff --git a/fastapi_cache/backends/__init__.py b/fastapi_cache/backends/__init__.py index 23bd0a54..11cf5b1d 100644 --- a/fastapi_cache/backends/__init__.py +++ b/fastapi_cache/backends/__init__.py @@ -26,3 +26,10 @@ pass else: __all__ += ["redis"] + +try: + from fastapi_cache.backends import libsql +except ImportError: + pass +else: + __all__ += ["libsql"] diff --git a/fastapi_cache/backends/libsql.py b/fastapi_cache/backends/libsql.py new file mode 100644 index 00000000..62225581 --- /dev/null +++ b/fastapi_cache/backends/libsql.py @@ -0,0 +1,114 @@ +import codecs +import time +from typing import Any, Optional, Tuple + +import libsql_client +from libsql_client import ResultSet + +from fastapi_cache.types import Backend + +EmptyResultSet = ResultSet( + columns=(), + rows=[], + rows_affected=0, + last_insert_rowid=0) + +# see https://gist.github.com/jeremyBanks/1083518 +def quote_identifier(s:str, errors:str ="strict") -> str: + encodable = s.encode("utf-8", errors).decode("utf-8") + + nul_index = encodable.find("\x00") + + if nul_index >= 0: + error = UnicodeEncodeError("utf-8", encodable, nul_index, nul_index + 1, "NUL not allowed") + error_handler = codecs.lookup_error(errors) + replacement, _ = error_handler(error) + encodable = encodable.replace("\x00", replacement) # type: ignore + + return "\"" + encodable.replace("\"", "\"\"") + "\"" + + +class LibsqlBackend(Backend): + """ + libsql backend provider + + This backend requires a table name to be passed during initialization. The table + will be created if it does not exist. If the table does exists, it will be emptied during init + + Note that this backend does not fully support TTL. It will only delete outdated objects on get. + + Usage: + >> libsql_url = "file:local.db" + >> cache = LibsqlBackend(libsql_url=libsql_url, table_name="your-cache") + >> cache.create_and_flush() + >> FastAPICache.init(cache) + """ + + # client: libsql_client.Client + table_name: str + libsql_url: str + + def __init__(self, libsql_url: str, table_name: str): + self.libsql_url = libsql_url + self.table_name = quote_identifier(table_name) + + @property + def now(self) -> int: + return int(time.time()) + + async def _make_request(self, request: str, params: Any = None) -> ResultSet: + # TODO: Exception handling. Return EmptyResultSet on error? + async with libsql_client.create_client(self.libsql_url) as client: + return await client.execute(request, params) + + + async def create_and_flush(self) -> None: + await self._make_request(f"CREATE TABLE IF NOT EXISTS {self.table_name} " + "(key STRING PRIMARY KEY, value BLOB , expire INTEGER)") # noqa: S608 + await self._make_request(f"DELETE FROM {self.table_name}") # noqa: S608 + + return None + + async def _get(self, key: str) -> Tuple[int, Optional[bytes]]: + result_set = await self._make_request(f"SELECT * from {self.table_name} WHERE key = ?", # noqa: S608 + [key]) + if len(result_set.rows) == 0: + return (0,None) + + value = result_set.rows[0]["value"] + ttl_ts = result_set.rows[0]["expire"] + + if not value: + return (0,None) + if ttl_ts < self.now: + await self._make_request(f"DELETE FROM {self.table_name} WHERE key = ?", # noqa: S608 + [key]) + return (0, None) + + return(ttl_ts, value) # type: ignore[union-attr,no-any-return] + + async def get_with_ttl(self, key: str) -> Tuple[int, Optional[bytes]]: + return await self._get(key) + + async def get(self, key: str) -> Optional[bytes]: + _, value = await self._get(key) + return value + + async def set(self, key: str, value: bytes, expire: Optional[int] = None) -> None: + ttl = self.now + expire if expire else 0 + await self._make_request(f"INSERT OR REPLACE INTO {self.table_name}(\"key\", \"value\", \"expire\") " + "VALUES(?,?,?)", # noqa: S608 + [key, value, ttl]) + return None + + async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int: + + if namespace: + result_set = await self._make_request(f"DELETE FROM {self.table_name} WHERE key = ?", # noqa: S608 + [namespace + '%']) + return result_set.rows_affected # type: ignore + elif key: + result_set = await self._make_request(f"DELETE FROM {self.table_name} WHERE key = ?", # noqa: S608 + [key]) + return result_set.rows_affected # type: ignore + return 0 diff --git a/poetry.lock b/poetry.lock index 40831a02..24fb1b80 100644 --- a/poetry.lock +++ b/poetry.lock @@ -990,6 +990,21 @@ completion = ["shtab"] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +[[package]] +name = "libsql-client" +version = "0.3.0" +description = "Python SDK for libSQL" +optional = true +python-versions = ">=3.7,<4.0" +files = [ + {file = "libsql_client-0.3.0-py3-none-any.whl", hash = "sha256:b9edc4bc3b2c5f7e10a397e7a4e36451633a3ae4f2d7cec82c6767ccb9c34420"}, + {file = "libsql_client-0.3.0.tar.gz", hash = "sha256:8ed74e37601fc60498dfd70c5086252e8b8abb7974f7973be93d05dbdf589d05"}, +] + +[package.dependencies] +aiohttp = ">=3.0,<4.0" +typing-extensions = ">=4.5,<5.0" + [[package]] name = "markdown-it-py" version = "2.2.0" @@ -2503,12 +2518,13 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [extras] -all = ["aiobotocore", "aiomcache", "redis"] +all = ["aiobotocore", "aiomcache", "libsql-client", "redis"] dynamodb = ["aiobotocore"] +libsql = ["libsql-client"] memcache = ["aiomcache"] redis = ["redis"] [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "1687c7c3b559713695e1f2f2d609730b8ad80f779cacb999c01b9e81e6a152de" +content-hash = "c9b519d4fb89bb03228013670dc49648ce69e179b0cde2455d67b55d63ca5d04" diff --git a/pyproject.toml b/pyproject.toml index c49fd446..ed4cf3ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ pendulum = "*" aiobotocore = { version = ">=1.4.1,<3.0.0", optional = true } typing-extensions = { version = ">=4.1.0" } importlib-metadata = {version = "^6.6.0", python = "<3.8"} +libsql-client = { version = "^0.3.0", optional = true } [tool.poetry.group.linting] optional = true @@ -53,7 +54,8 @@ twine = { version = "^4.0.2", python = "^3.10" } redis = ["redis"] memcache = ["aiomcache"] dynamodb = ["aiobotocore"] -all = ["redis", "aiomcache", "aiobotocore"] +libsql = ["libsql-client"] +all = ["redis", "aiomcache", "aiobotocore", "libsql-client"] [tool.mypy] files = ["."]