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.md
.claude/
# python
__pycache__/

View File

@ -12,17 +12,19 @@ you `delete` or `clear` them.
`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:
```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).
Drop the `@v0.1.1` suffix from the line above to install the latest unpinned.
## Usage
```python
@ -60,8 +62,11 @@ Prefer `AioKV` in new code.
## Durability
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
previous good file intact, and a reader never observes a partial file. A single
`os.replace()`d over the target (atomic on POSIX). A **process** crash mid-write leaves
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
are consistent and no update is lost. All blocking filesystem calls run via
`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
- `get` / `set` / `get_all` raise on unexpected I/O (and `_load` raises on a
truncated/corrupt file) so a real failure is visible rather than silently masked.
- `get` / `set` / `get_all` raise on unexpected I/O. `_load` raises `JSONDecodeError`
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.
## 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]
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."
requires-python = ">=3.10"
dependencies = [

View File

@ -82,11 +82,17 @@ class AioKV:
return False
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:
async with self.lock:
if await asyncio.to_thread(os.path.exists, self.file):
try:
await asyncio.to_thread(os.remove, self.file)
except FileNotFoundError:
pass
return True
except Exception:
log.exception("aiokv.clear() failed")
@ -107,7 +113,7 @@ class AioKV:
"""
if not await asyncio.to_thread(os.path.exists, self.file):
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()
if not data:
return {}
@ -120,7 +126,10 @@ class AioKV:
"""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.
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 "."
await asyncio.to_thread(os.makedirs, directory, exist_ok=True)
@ -128,7 +137,7 @@ class AioKV:
payload = json.dumps(cache)
tmp = f"{self.file}.{os.getpid()}.tmp"
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 asyncio.to_thread(os.replace, tmp, self.file)
except Exception: