Compare commits

..

5 Commits
v0.1.0 ... main

Author SHA1 Message Date
e68e0b9ccf chore: ignore .claude/ dir (CLAUDE.md now lives under .claude/)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:54:14 -04:00
1e364fcfdb fix: clear() treats a concurrent delete as success; explicit utf-8; durability prose
clear() handles FileNotFoundError as success (the goal state — no file — is reached)
instead of returning False. read/write open with explicit encoding='utf-8'. atomic-write
prose scoped to process-crash safety (NOT power-loss durability — no fsync), in module,
README, and CLAUDE.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:35:23 -04:00
8747b61705 docs: pin install line to release, note unpinned-latest option
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 18:13:30 -04:00
22e91d2b2d docs: show unpinned install line; note tag-pinning for reproducibility
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 18:07:15 -04:00
5ee0292dcb docs: document _load's ValueError-on-non-object-JSON in the error contract (v0.1.1)
README + CLAUDE.md error contract now note that _load raises ValueError on valid-but-
non-object JSON (bare list/number/string/null) in addition to JSONDecodeError on a
corrupt file, matching the module docstring (L1).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 17:57:37 -04:00
4 changed files with 30 additions and 14 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
# claude # claude
CLAUDE.md .claude/
# python # python
__pycache__/ __pycache__/

View File

@ -12,17 +12,19 @@ you `delete` or `clear` them.
`requirements.txt`: `requirements.txt`:
``` ```
aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.1.0 aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.1.1
``` ```
Direct: Direct:
```bash ```bash
pip install "aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.1.0" pip install "aiokv @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiokv.git@v0.1.1"
``` ```
Requires `aiofiles` (pulled transitively). Requires `aiofiles` (pulled transitively).
Drop the `@v0.1.1` suffix from the line above to install the latest unpinned.
## Usage ## Usage
```python ```python
@ -60,8 +62,11 @@ Prefer `AioKV` in new code.
## Durability ## Durability
Writes are **atomic**: data is written to a temp file in the same directory and Writes are **atomic**: data is written to a temp file in the same directory and
`os.replace()`d over the target (atomic on POSIX). A crash mid-write leaves the `os.replace()`d over the target (atomic on POSIX). A **process** crash mid-write leaves
previous good file intact, and a reader never observes a partial file. A single the previous good file intact, and a reader never observes a partial file. (This is
process-crash safety, not power-loss durability — there's no `fsync`, so an OS/power
failure could still lose the last write; fine for reconstructible single-process state.)
A single
`asyncio.Lock` guards every read and write, so concurrent operations on one instance `asyncio.Lock` guards every read and write, so concurrent operations on one instance
are consistent and no update is lost. All blocking filesystem calls run via are consistent and no update is lost. All blocking filesystem calls run via
`asyncio.to_thread`, so nothing stalls the event loop. `asyncio.to_thread`, so nothing stalls the event loop.
@ -77,10 +82,12 @@ are consistent and no update is lost. All blocking filesystem calls run via
## Error contract ## Error contract
- `get` / `set` / `get_all` raise on unexpected I/O (and `_load` raises on a - `get` / `set` / `get_all` raise on unexpected I/O. `_load` raises `JSONDecodeError`
truncated/corrupt file) so a real failure is visible rather than silently masked. on a truncated/corrupt file, and `ValueError` when the file holds valid JSON that
isn't an object (a bare list/number/string/null) — so corruption or a wrong-shaped
file is visible rather than silently masked.
- `delete` / `clear` log the exception and return `False` on error, `True` otherwise. - `delete` / `clear` log the exception and return `False` on error, `True` otherwise.
## Versioning ## Versioning
Tagged `vX.Y.Z`. Pin the tag in `requirements.txt`. Releases are tagged `vX.Y.Z`. The install line above pins a release; drop the `@vX.Y.Z` suffix to install the latest unpinned. Pin deliberately for reproducible installs.

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "aiokv" name = "aiokv"
version = "0.1.0" version = "0.1.1"
description = "Async file-backed key-value store for single-process local state — atomic writes, no TTL, config-free, installable." description = "Async file-backed key-value store for single-process local state — atomic writes, no TTL, config-free, installable."
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [

View File

@ -82,11 +82,17 @@ class AioKV:
return False return False
async def clear(self) -> bool: async def clear(self) -> bool:
"""remove the backing file entirely; returns True on success, False on error""" """remove the backing file entirely; returns True on success, False on error
a file already absent (or removed concurrently between the check and the remove)
is success the goal state, no file, is reached.
"""
try: try:
async with self.lock: async with self.lock:
if await asyncio.to_thread(os.path.exists, self.file): try:
await asyncio.to_thread(os.remove, self.file) await asyncio.to_thread(os.remove, self.file)
except FileNotFoundError:
pass
return True return True
except Exception: except Exception:
log.exception("aiokv.clear() failed") log.exception("aiokv.clear() failed")
@ -107,7 +113,7 @@ class AioKV:
""" """
if not await asyncio.to_thread(os.path.exists, self.file): if not await asyncio.to_thread(os.path.exists, self.file):
return {} return {}
async with aiofiles.open(self.file, mode="r") as f: async with aiofiles.open(self.file, mode="r", encoding="utf-8") as f:
data = await f.read() data = await f.read()
if not data: if not data:
return {} return {}
@ -120,7 +126,10 @@ class AioKV:
"""write the store atomically: temp file in the same dir, then os.replace """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 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. process crash mid-write leaves the previous good file intact. note this is
process-crash safety, NOT power-loss durability there is no fsync of the temp
file or the directory, so an OS/power failure could still lose the most recent
write (acceptable here: this is reconstructible single-process state, not a db).
""" """
directory = os.path.dirname(self.file) or "." directory = os.path.dirname(self.file) or "."
await asyncio.to_thread(os.makedirs, directory, exist_ok=True) await asyncio.to_thread(os.makedirs, directory, exist_ok=True)
@ -128,7 +137,7 @@ class AioKV:
payload = json.dumps(cache) payload = json.dumps(cache)
tmp = f"{self.file}.{os.getpid()}.tmp" tmp = f"{self.file}.{os.getpid()}.tmp"
try: try:
async with aiofiles.open(tmp, mode="w") as f: async with aiofiles.open(tmp, mode="w", encoding="utf-8") as f:
await f.write(payload) await f.write(payload)
await asyncio.to_thread(os.replace, tmp, self.file) await asyncio.to_thread(os.replace, tmp, self.file)
except Exception: except Exception: