Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support disk/embedded/remote store via libsql #272

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand All @@ -46,6 +47,10 @@ or
> pip install "fastapi-cache2[dynamodb]"
```

```shell
> pip install "fastapi-cache2[libsql]"
```

## Usage

### Quick Start
Expand Down
7 changes: 7 additions & 0 deletions fastapi_cache/backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,10 @@
pass
else:
__all__ += ["redis"]

try:
from fastapi_cache.backends import libsql
except ImportError:
pass
else:
__all__ += ["libsql"]
114 changes: 114 additions & 0 deletions fastapi_cache/backends/libsql.py
Original file line number Diff line number Diff line change
@@ -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"]
aausch marked this conversation as resolved.
Show resolved Hide resolved
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.rowcount # type: ignore
aausch marked this conversation as resolved.
Show resolved Hide resolved
elif key:
result_set = await self._make_request(f"DELETE FROM {self.table_name} WHERE key = ?", # noqa: S608
[key])
return result_set.rowcount # type: ignore
aausch marked this conversation as resolved.
Show resolved Hide resolved
return 0
20 changes: 18 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = ["."]
Expand Down