Add database and FUSE mapping

This commit is contained in:
Andrew Mulbrook 2026-03-16 22:45:27 -05:00
parent 96c9e62354
commit 2cfe32b333
5 changed files with 425 additions and 3 deletions

View file

@ -86,6 +86,13 @@ class Sqlite3Trove:
# CRUD operations # CRUD operations
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def get_object_type(self, object_id: int) -> str | None:
"""Return the type column for an object, or None if not found."""
row = self._con.execute(
"SELECT type FROM objects WHERE id = ?", (object_id,)
).fetchone()
return row["type"] if row else None
def read_object(self, object_id: int) -> bytes | None: def read_object(self, object_id: int) -> bytes | None:
"""Return raw data for a blob object, or None if not found.""" """Return raw data for a blob object, or None if not found."""
row = self._con.execute( row = self._con.execute(
@ -104,6 +111,14 @@ class Sqlite3Trove:
return None return None
return bytes(row["value"]) if row["value"] is not None else b"" return bytes(row["value"]) if row["value"] is not None else b""
def write_metadata(self, object_id: int, key: str, value: bytes) -> None:
"""Upsert a metadata row. db.py has no write_metadata, so we go direct."""
self._con.execute(
"INSERT OR REPLACE INTO metadata (id, key, value) VALUES (?, ?, ?)",
(object_id, key, value),
)
self._con.commit()
def _write_object(self, data: bytes, dtype: str, object_id: int | None = None) -> int: def _write_object(self, data: bytes, dtype: str, object_id: int | None = None) -> int:
""" """
Insert or replace an object. Returns the id. Insert or replace an object. Returns the id.
@ -117,6 +132,7 @@ class Sqlite3Trove:
(dtype, data, modified) (dtype, data, modified)
) )
self._con.commit() self._con.commit()
assert cur.lastrowid is not None
return cur.lastrowid return cur.lastrowid
else: else:
self._con.execute( self._con.execute(
@ -126,12 +142,16 @@ class Sqlite3Trove:
self._con.commit() self._con.commit()
return object_id return object_id
def write_blob(self, data: bytes, existing_id: int | None = None) -> int: def write_blob(self, data: bytes, object_id: int | None = None) -> int:
""" """
Insert or replace a blob. Returns the id. Insert or replace a blob. Returns the id.
Pass existing_id to update an existing object. Pass object_id to update an existing object.
""" """
return self._write_object(data, "blob", existing_id) return self._write_object(data, "blob", object_id)
def write_tree(self, data: bytes, object_id: int | None = None) -> int:
"""Write a tree-typed object. Returns the assigned id."""
return self._write_object(data, "tree", object_id)
def delete_object(self, object_id: int) -> bool: def delete_object(self, object_id: int) -> bool:
""" """

14
trovedb/fuse/__main__.py Normal file
View file

@ -0,0 +1,14 @@
from . import server
from trovedb import trovedb
from argparse import ArgumentParser
def main():
parser = ArgumentParser()
parser.add_argument("db", help="Path to the database file")
parser.add_argument("mountpoint", help="Path to the mount point")
args = parser.parse_args()
server.serve(trovedb.TroveImpl.open(args.db, create=True), args.mountpoint)
if __name__ == '__main__':
main()

240
trovedb/fuse/server.py Normal file
View file

@ -0,0 +1,240 @@
"""
fuse.py FUSE filesystem layer for Trove, backed by pyfuse3 + trio.
Blob objects are exposed as regular files.
Tree objects are exposed as directories.
Inode numbers map 1:1 to Trove object IDs (NODE_ROOT_ID == pyfuse3 root inode == 1).
Entry point: serve(trove, mountpoint)
"""
import errno
import os
import stat
import time
import pyfuse3
import trio
from trovedb.trove import Trove, Note, Tree as TroveTree, TreeNote, Blob as TroveBlob
class TroveFuseOps(pyfuse3.Operations):
enable_writeback_cache = False
def __init__(self, trove: Trove):
super().__init__()
self._trove = trove
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _note_or_error(self, inode: int):
note = self._trove.get_raw_note(inode)
if note is None:
raise pyfuse3.FUSEError(errno.ENOENT)
return note
def _make_attr(self, inode: int, is_tree: bool, size: int = 0) -> pyfuse3.EntryAttributes:
attr = pyfuse3.EntryAttributes()
attr.st_ino = pyfuse3.InodeT(inode)
attr.st_nlink = 1
attr.st_uid = os.getuid()
attr.st_gid = os.getgid()
now_ns = int(time.time() * 1e9)
attr.st_atime_ns = now_ns
attr.st_mtime_ns = now_ns
attr.st_ctime_ns = now_ns
attr.generation = 0
attr.entry_timeout = 5.0
attr.attr_timeout = 5.0
if is_tree:
attr.st_mode = stat.S_IFDIR | 0o755
attr.st_size = 0
attr.st_blksize = 512
attr.st_blocks = 0
else:
attr.st_mode = stat.S_IFREG | 0o644
attr.st_size = size
attr.st_blksize = 512
attr.st_blocks = (size + 511) // 512
return attr
def _attr_for_note(self, note: Note) -> pyfuse3.EntryAttributes:
size = 0
is_tree = True
if isinstance(note, TroveBlob):
size = len(note.read())
is_tree = False
return self._make_attr(note.object_id, is_tree, size)
# ------------------------------------------------------------------
# Stat / lookup
# ------------------------------------------------------------------
async def getattr(self, inode: int, ctx=None) -> pyfuse3.EntryAttributes:
note = self._note_or_error(inode)
return self._attr_for_note(note)
async def lookup(self, parent_inode: int, name: bytes, ctx=None) -> pyfuse3.EntryAttributes:
parent = self._note_or_error(parent_inode)
if not isinstance(parent, TroveTree):
raise pyfuse3.FUSEError(errno.ENOTDIR)
entries = parent.list()
name_str = name.decode()
if name_str not in entries:
raise pyfuse3.FUSEError(errno.ENOENT)
child = self._trove.get_raw_note(entries[name_str])
if child is None:
raise pyfuse3.FUSEError(errno.ENOENT)
return self._attr_for_note(child)
async def setattr(self, inode: int, attr, fields, fh, ctx) -> pyfuse3.EntryAttributes:
note = self._note_or_error(inode)
if fields.update_size and not isinstance(note, TroveTree):
current = note.read()
new_size = attr.st_size
if new_size < len(current):
note.write(current[:new_size])
elif new_size > len(current):
note.write(current + b"\x00" * (new_size - len(current)))
return self._attr_for_note(note)
async def forget(self, inode_list) -> None:
pass
# ------------------------------------------------------------------
# Directory ops
# ------------------------------------------------------------------
async def opendir(self, inode: int, ctx) -> pyfuse3.FileHandleT:
note = self._note_or_error(inode)
if not isinstance(note, TroveTree):
raise pyfuse3.FUSEError(errno.ENOTDIR)
return pyfuse3.FileHandleT(inode)
async def readdir(self, fh: int, start_id: int, token) -> None:
note = self._note_or_error(fh)
if not isinstance(note, TroveTree):
raise pyfuse3.FUSEError(errno.ENOTDIR)
entries = list(note.list().items()) # [(name, object_id), ...]
for idx, (name, child_id) in enumerate(entries):
if idx < start_id:
continue
child = self._trove.get_raw_note(child_id)
if child is None:
continue
attr = self._attr_for_note(child)
if not pyfuse3.readdir_reply(token, name.encode(), attr, idx + 1):
break
async def releasedir(self, fh: int) -> None:
pass
async def mkdir(self, parent_inode: int, name: bytes, mode: int, ctx) -> pyfuse3.EntryAttributes:
parent = self._note_or_error(parent_inode)
if not isinstance(parent, TreeNote):
raise pyfuse3.FUSEError(errno.ENOTDIR)
name_str = name.decode()
if name_str in parent.list():
raise pyfuse3.FUSEError(errno.EEXIST)
new_tree: TreeNote = parent.mkdir(name_str)
return self._make_attr(new_tree.object_id, True, 0)
async def rmdir(self, parent_inode: int, name: bytes, ctx) -> None:
parent = self._note_or_error(parent_inode)
if not isinstance(parent, TroveTree):
raise pyfuse3.FUSEError(errno.ENOTDIR)
name_str = name.decode()
entries = parent.list()
if name_str not in entries:
raise pyfuse3.FUSEError(errno.ENOENT)
child = self._trove.get_raw_note(entries[name_str])
if child is None:
raise pyfuse3.FUSEError(errno.ENOENT)
if not isinstance(child, TroveTree):
raise pyfuse3.FUSEError(errno.ENOTDIR)
if child.list():
raise pyfuse3.FUSEError(errno.ENOTEMPTY)
parent.unlink(name_str)
# ------------------------------------------------------------------
# File ops
# ------------------------------------------------------------------
async def open(self, inode: int, flags, ctx) -> pyfuse3.FileInfo:
note = self._note_or_error(inode)
if isinstance(note, TroveTree):
raise pyfuse3.FUSEError(errno.EISDIR)
return pyfuse3.FileInfo(fh=pyfuse3.FileHandleT(inode))
async def create(self, parent_inode: int, name: bytes, mode: int, flags, ctx) -> tuple:
parent = self._note_or_error(parent_inode)
if not isinstance(parent, TroveTree):
raise pyfuse3.FUSEError(errno.ENOTDIR)
name_str = name.decode()
if name_str in parent.list():
raise pyfuse3.FUSEError(errno.EEXIST)
blob = self._trove.create_blob(b"")
parent.link(name_str, blob)
attr = self._make_attr(blob.object_id, False, 0)
return pyfuse3.FileInfo(fh=pyfuse3.FileHandleT(blob.object_id)), attr
async def read(self, fh: int, offset: int, length: int) -> bytes:
note = self._note_or_error(fh)
return note.read()[offset:offset + length]
async def write(self, fh: int, offset: int, data: bytes) -> int:
note = self._note_or_error(fh)
existing = note.read()
if offset > len(existing):
existing = existing + b"\x00" * (offset - len(existing))
note.write(existing[:offset] + data + existing[offset + len(data):])
return len(data)
async def release(self, fh: int) -> None:
pass
async def unlink(self, parent_inode: int, name: bytes, ctx) -> None:
parent = self._note_or_error(parent_inode)
if not isinstance(parent, TroveTree):
raise pyfuse3.FUSEError(errno.ENOTDIR)
name_str = name.decode()
entries = parent.list()
if name_str not in entries:
raise pyfuse3.FUSEError(errno.ENOENT)
child = self._trove.get_raw_note(entries[name_str])
if child is None:
raise pyfuse3.FUSEError(errno.ENOENT)
if isinstance(child, TroveTree):
raise pyfuse3.FUSEError(errno.EISDIR)
parent.unlink(name_str)
# ------------------------------------------------------------------
# Serve
# ------------------------------------------------------------------
async def _run(ops: TroveFuseOps, mountpoint: str) -> None:
options = set(pyfuse3.default_options)
options.add("fsname=trove")
pyfuse3.init(ops, mountpoint, options)
try:
await pyfuse3.main()
finally:
pyfuse3.close()
def serve(trove: Trove, mountpoint: str) -> None:
"""
Mount a Trove store at mountpoint and serve until KeyboardInterrupt.
Runs a trio event loop internally.
"""
ops = TroveFuseOps(trove)
try:
trio.run(_run, ops, mountpoint)
except KeyboardInterrupt:
pass

View file

@ -47,6 +47,10 @@ class Tree(Protocol):
"""Create a new Tree with the given name.""" """Create a new Tree with the given name."""
... ...
def list(self) -> dict[str, int]:
"""Return all entries as {name: object_id}."""
...
class BlobNote(Note, Blob): class BlobNote(Note, Blob):
"""Blob Note""" """Blob Note"""

144
trovedb/trovedb.py Normal file
View file

@ -0,0 +1,144 @@
"""
trovedb.py Concrete implementation of Trove protocols backed by Sqlite3Trove.
Implements BlobNote, TreeNote, and Trove protocols defined in trove.py.
Depends on db.py (Sqlite3Trove) and tree.py (Tree) for storage and
tree serialization respectively.
"""
from typing import Optional, Self
from pathlib import Path
from .db import Sqlite3Trove
from .tree import Tree as TreeData
from .trove import NODE_ROOT_ID, Note, Trove, TreeNote, BlobNote
class NoteImpl(Note):
"""Concrete not implementation"""
def __init__(self, db: Sqlite3Trove, object_id: int):
self._db = db
self._object_id = object_id
# Note protocol
@property
def object_id(self) -> int:
return self._object_id
def get_raw_metadata(self, key: str) -> Optional[bytes]:
return self._db.read_metadata(self._object_id, key)
def set_raw_metadata(self, key: str, value: bytes) -> None:
self._db.write_metadata(self._object_id, key, value)
class BlobNoteImpl(NoteImpl, BlobNote):
"""Concrete BlobNote: a blob object in the store with metadata access."""
# Blob protocol
def read(self) -> bytes:
data = self._db.read_object(self._object_id)
return data if data is not None else b""
def write(self, data: bytes) -> None:
self._db.write_blob(data, self._object_id)
class TreeNoteImpl(NoteImpl, TreeNote):
"""Concrete TreeNote: a tree object in the store with metadata access."""
def _read_tree(self) -> TreeData:
data = self._db.read_object(self._object_id)
return TreeData(data if data else None)
def _flush_tree(self, tree: TreeData) -> None:
self._db.write_tree(tree.serialize(), self._object_id)
# Tree protocol
def link(self, name: str, note: Note) -> None:
"""Link name to an existing note (blob or tree)."""
tree = self._read_tree()
tree.set_entry(name, note.object_id)
self._flush_tree(tree)
def unlink(self, name: str) -> None:
"""Remove an entry by name. Raises KeyError if not found."""
tree = self._read_tree()
tree.rm_entry(name)
self._flush_tree(tree)
def mkdir(self, name: str) -> 'TreeNoteImpl':
"""Create a new empty tree, link it under name, and return it."""
# Create the new node
new_id = self._db.write_tree(TreeData().serialize())
tree = TreeNoteImpl(self._db, new_id)
# Update our node
self.link(name, tree)
return tree
def list(self) -> dict[str, int]:
"""Return all entries as {name: object_id}."""
return self._read_tree().list()
# ---------------------------------------------------------------------------
# Trove
# ---------------------------------------------------------------------------
class TroveImpl:
"""
Concrete Trove: top-level API backed by a Sqlite3Trove database.
Use TroveImpl.open() to get an instance.
"""
def __init__(self, db: Sqlite3Trove):
self._db = db
@classmethod
def open(cls, path: str | Path, create: bool = False) -> "TroveImpl":
db = Sqlite3Trove.open(path, create=create)
trove = cls(db)
if create:
# Root was written as a blob by Sqlite3Trove.open(); fix its type.
db._con.execute(
"UPDATE objects SET type = 'tree' WHERE id = ?", (NODE_ROOT_ID,)
)
db._con.commit()
return trove
def close(self) -> None:
self._db.close()
def __enter__(self):
return self
def __exit__(self, *_):
self.close()
# Trove protocol
def get_raw_note(self, note_id: int) -> Optional[Note]:
"""Return a BlobNote or TreeNote for the given id, or None if not found."""
ot = self._db.get_object_type(note_id)
if ot is None:
return None
if ot == "blob":
return BlobNoteImpl(self._db, note_id)
if ot == "tree":
return TreeNoteImpl(self._db, note_id)
raise ValueError(f"Unknown object type '{ot}' for id {note_id}")
def create_blob(self, data: bytes | None = None) -> BlobNote:
"""Create a new blob object and return a BlobNote for it."""
obj_id = self._db.write_blob(data or b"")
return BlobNoteImpl(self._db, obj_id)
def get_root(self) -> TreeNote:
"""Return the root TreeNote (always id=NODE_ROOT_ID)."""
return TreeNoteImpl(self._db, NODE_ROOT_ID)
def open_trove(path: str | Path, create: bool = False) -> Trove:
return TroveImpl.open(path, create=create)