diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..05110b3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "aiokv" +version = "0.1.0" +description = "Async file-backed key-value store for single-process local state — atomic writes, no TTL, config-free, installable." +requires-python = ">=3.10" +dependencies = [ + "aiofiles>=23.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/aiokv"] diff --git a/src/aiokv/__init__.py b/src/aiokv/__init__.py new file mode 100644 index 0000000..8c9624f --- /dev/null +++ b/src/aiokv/__init__.py @@ -0,0 +1,3 @@ +from .aiokv import AioKV, aiocache + +__all__ = ["AioKV", "aiocache"] diff --git a/src/aiokv/aiokv.py b/src/aiokv/aiokv.py new file mode 100644 index 0000000..b61f5ab --- /dev/null +++ b/src/aiokv/aiokv.py @@ -0,0 +1,138 @@ +""" +async file-backed key-value store for single-process local state + +a persist-forever KV store (last-used-command, rate-limit timestamps, seen-ids, +simple bot state) backed by a JSON file. NOT a cache: no TTL, no expiry, no +eviction — values live until you delete or clear them. + + from aiokv import AioKV + + kv = AioKV("state.json") + await kv.set("last_seen", 12345) + await kv.set("ran_cleanup") # value omitted -> stores int(time.time()) + when = await kv.get("ran_cleanup") + await kv.delete("last_seen") + +durability: writes are atomic — data is written to a temp file in the same +directory and os.replace()d over the target, so a crash mid-write never corrupts +the store and readers never see a partial file. a single asyncio.Lock guards every +read and write, so concurrent operations on one instance are consistent. + +scope: SINGLE-PROCESS, single-instance local state only. the lock is per-instance — +two AioKV instances (or two processes) pointing at the same file are NOT safe and +will clobber each other. for shared cross-process/cross-bot state, use a database +(e.g. mongo), not this. + +config-free: the file path is passed at construction; nothing is read from a global +config. errors in delete/clear are logged and swallowed (returning False); get/set +raise on unexpected i/o so a real failure is visible. +""" + +import os +import json +import time +import asyncio +import logging +from typing import Any, Dict + +import aiofiles + +log = logging.getLogger(__name__) + + +class AioKV: + """async file-backed key-value store for single-process local state""" + + def __init__(self, file: str = "aiokv.json"): + """initialize the store backed by the given json file path + + args: + file: path to the backing json file (created on first write) + """ + self.file = file + self.lock = asyncio.Lock() + + async def set(self, key: str, value: Any = None) -> None: + """set a value; if value is omitted or None, stores int(time.time()) + + the timestamp default exists for "mark that i saw/did X at time T" usage. + """ + async with self.lock: + cache = await self._load() + cache[key] = value if value is not None else int(time.time()) + await self._save(cache) + + async def get(self, key: str, default: Any = None) -> Any: + """return the value for key, or default if absent""" + async with self.lock: + cache = await self._load() + return cache.get(key, default) + + async def delete(self, key: str) -> bool: + """delete a key; returns True if removed or absent, False on error""" + try: + async with self.lock: + cache = await self._load() + if key in cache: + del cache[key] + await self._save(cache) + return True + except Exception: + log.exception("aiokv.delete() failed for key %s", key) + return False + + async def clear(self) -> bool: + """remove the backing file entirely; returns True on success, False on error""" + try: + async with self.lock: + if await asyncio.to_thread(os.path.exists, self.file): + await asyncio.to_thread(os.remove, self.file) + return True + except Exception: + log.exception("aiokv.clear() failed") + return False + + async def get_all(self) -> Dict[str, Any]: + """return a dict of every key/value in the store""" + async with self.lock: + return await self._load() + + async def _load(self) -> Dict[str, Any]: + """load the store from disk, returning {} if the file is absent or empty + + a truncated/corrupt file raises JSONDecodeError — surfaced to the caller + rather than silently masking a real corruption. + """ + if not await asyncio.to_thread(os.path.exists, self.file): + return {} + async with aiofiles.open(self.file, mode="r") as f: + data = await f.read() + return json.loads(data) if data else {} + + async def _save(self, cache: Dict[str, Any]) -> None: + """write the store atomically: temp file in the same dir, then os.replace + + os.replace is atomic on POSIX, so a reader never sees a partial file and a + crash mid-write leaves the previous good file intact. + """ + directory = os.path.dirname(self.file) or "." + await asyncio.to_thread(os.makedirs, directory, exist_ok=True) + + payload = json.dumps(cache) + tmp = f"{self.file}.{os.getpid()}.tmp" + try: + async with aiofiles.open(tmp, mode="w") as f: + await f.write(payload) + await asyncio.to_thread(os.replace, tmp, self.file) + except Exception: + if await asyncio.to_thread(os.path.exists, tmp): + try: + await asyncio.to_thread(os.remove, tmp) + except Exception: + log.exception("aiokv: failed to clean up temp file %s", tmp) + raise + + +# back-compat: this lib was originally named aiocache; legacy call sites using +# `aiocache(...)` keep working via this alias. prefer AioKV in new code. +aiocache = AioKV