diff --git a/CHANGELOG.md b/CHANGELOG.md index 179cd21..c213222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the chang ## 0.2 +### 0.2.2 + +- Support `libsql` backend. + ### 0.2.1 - Fix picklecoder - Fix connection failure transparency and add logging diff --git a/README.md b/README.md index 4bac139..7ebc947 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 23bd0a5..11cf5b1 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 0000000..12179af --- /dev/null +++ b/fastapi_cache/backends/libsql.py @@ -0,0 +1,99 @@ +import time +from typing import 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) + +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 = table_name + + @property + def now(self) -> int: + return int(time.time()) + + async def _make_request(self, request: str) -> ResultSet: + # TODO: Exception handling. Return EmptyResultSet on error? + async with libsql_client.create_client(self.libsql_url) as client: + return await client.execute(request) + + + async def create_and_flush(self) -> None: + await self._make_request("CREATE TABLE IF NOT EXISTS `{}` " + "(key STRING PRIMARY KEY, value BLOB, expire INTEGER);" + .format(self.table_name)) + await self._make_request("DELETE FROM `{}`;".format(self.table_name)) + + return None + + async def _get(self, key: str) -> Tuple[int, Optional[bytes]]: + result_set = await self._make_request("SELECT * from `{}` WHERE key = \"{}\"" + .format(self.table_name,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("DELETE FROM `{}` WHERE key = \"{}\"" + .format(self.table_name, 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("INSERT OR REPLACE INTO `{}`(\"key\", \"value\", \"expire\") " + "VALUES('{}','{}',{});" + .format(self.table_name, key, value.decode("utf-8"), ttl)) + return None + + async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int: + + if namespace: + result_set = await self._make_request("DELETE FROM `{}` WHERE key = \"{}%\"" + .format(self.table_name, namespace)) + return result_set.rowcount # type: ignore + elif key: + result_set = await self._make_request("DELETE FROM `{}` WHERE key = \"{}\"" + .format(self.table_name, key)) + return result_set.rowcount # type: ignore + return 0 diff --git a/poetry.lock b/poetry.lock index 78b80c5..8ba4e9f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -988,6 +988,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" @@ -2487,12 +2502,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 = "1d3f1bea1e9e956afd436a09a934a94f8f59dc223cc7d20b9e877f1962cfc06e" +content-hash = "1c91b7855d6ae5a943e7ca40d11b9eaa333cfaf9e558f30fad8a991acb16c01f" diff --git a/pyproject.toml b/pyproject.toml index cb834b0..20f7045 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 = ["."]