diff --git a/trovedb/fs.py b/trovedb/fs.py index 44f961e..f4f8982 100644 --- a/trovedb/fs.py +++ b/trovedb/fs.py @@ -2,8 +2,8 @@ import os import sqlite3 import tempfile from pathlib import Path -from typing import Optional, Dict, List, Self -from .trove import NODE_ROOT_ID, Note, Trove, TreeNote, BlobNote, Blob, Tree, BadNoteType +from typing import Optional, Dict, List, Self, Iterable +from .trove import NODE_ROOT_ID, Note, Trove, TreeNote, BlobNote, Blob, Tree, BadNoteType, TreeEntry, NoteNotFound class FSNote(Note): @@ -27,7 +27,9 @@ class FSNote(Note): @property def _path(self) -> Path: if self._fs_path is not None: - return self._fs_path + if self._fs_path.exists(): + return self._fs_path + self._fs_path = None if self._inode is None: raise ValueError("Note not yet saved to disk") self._fs_path = self._trove.get_path_by_inode(self._inode) @@ -42,6 +44,8 @@ class FSNote(Note): class FSBlobNote(FSNote, BlobNote): def read(self) -> bytes: + if self._inode is None: + return b"" return self._path.read_bytes() def write(self, data: bytes) -> None: @@ -101,6 +105,17 @@ class FSTreeNote(FSNote, TreeNote): self._trove._update_cache(inode, target_path) return FSTreeNote(self._trove, inode=inode, path=target_path) + def entries(self) -> Iterable[TreeEntry]: + try: + for item in self._path.iterdir(): + if item.name == ".trove": + continue + inode = item.stat().st_ino + self._trove._update_cache(inode, item) + yield TreeEntry(name=item.name, object_id=inode) + except OSError: + pass + def list(self) -> dict[str, int]: res = {} try: @@ -113,9 +128,16 @@ class FSTreeNote(FSNote, TreeNote): pass return res + def child(self, name: str) -> Note: + """Retrieve a child not by name.""" + target_path = self._path / name + return self._trove.get_raw_note_by_path(target_path) + + class FSTrove(Trove): def __init__(self, root: str | Path): self.root = Path(root).absolute() + self._root_inode = self.root.stat().st_ino self.dot_trove = self.root / ".trove" self.working = self.dot_trove / ".working" @@ -160,7 +182,7 @@ class FSTrove(Trove): self.con.commit() def get_path_by_inode(self, inode: int) -> Optional[Path]: - if inode == NODE_ROOT_ID: + if inode == self._root_inode: return self.root row = self.con.execute("SELECT path FROM cache WHERE inode = ?", (inode,)).fetchone() @@ -189,14 +211,21 @@ class FSTrove(Trove): continue return None - def get_raw_note(self, note_id: int) -> Optional[Note]: + def get_raw_note_by_path(self, target_path: Path) -> Note: + if not target_path.exists(): + raise NoteNotFound(target_path.relative_to(self.root)) + note_id = target_path.stat().st_ino + if target_path.is_dir(): + return FSTreeNote(self, inode=note_id, path=target_path) + else: + return FSBlobNote(self, inode=note_id, path=target_path) + + def get_raw_note(self, note_id: int) -> Note: p = self.get_path_by_inode(note_id) if not p: - return None - if p.is_dir(): - return FSTreeNote(self, inode=note_id, path=p) - else: - return FSBlobNote(self, inode=note_id, path=p) + raise NoteNotFound(note_id) + return self.get_raw_note_by_path(p) + def create_blob(self, data: bytes | None = None) -> BlobNote: fd, temp_path = tempfile.mkstemp(dir=self.working) @@ -211,7 +240,7 @@ class FSTrove(Trove): return FSBlobNote(self, inode=inode, path=p) def get_root(self) -> TreeNote: - return FSTreeNote(self, inode=NODE_ROOT_ID, path=self.root) + return FSTreeNote(self, inode=self._root_inode, path=self.root) def _get_metadata(self, inode: int, key: str) -> Optional[bytes]: row = self.con.execute("SELECT value FROM metadata WHERE inode = ? AND key = ?", (inode, key)).fetchone() diff --git a/trovedb/fuse/server.py b/trovedb/fuse/server.py index 9a7498f..2441314 100644 --- a/trovedb/fuse/server.py +++ b/trovedb/fuse/server.py @@ -13,34 +13,184 @@ import errno import os import stat import time +import logging +from typing import Sequence, Tuple, cast import pyfuse3 import trio +from pyfuse3 import InodeT, FileHandleT -from trovedb.trove import Trove, Note, Tree as TroveTree, TreeNote, Blob as TroveBlob +from trovedb.trove import Trove, Note, Tree as TroveTree, TreeNote, Blob as TroveBlob, ObjectId, TreeExists + +logger = logging.getLogger(__name__) + +class _TroveEntry: + __slots__ = [ 'object_id', 'sys_inode', 'ref_count' ] + def __init__(self, sys_inode: InodeT, object_id: ObjectId | None): + self.object_id: ObjectId | None = object_id + self.sys_inode: InodeT = sys_inode + self.ref_count = 0 + + def ref(self) -> None: + self.ref_count += 1 + + def deref(self, count: int = 1) -> bool: + assert self.ref_count > 0 + self.ref_count -= count + return self.ref_count <= 0 + +class _TroveHandle: + __slots__ = [ 'inode_id', 'ref_count', 'note', 'handle_id' ] + def __init__(self, inode_id: InodeT, handle_id: FileHandleT, note: Note): + self.inode_id = inode_id + self.handle_id = handle_id + self.note = note + +class _TroveHandleTree(_TroveHandle): + @property + def tree(self) -> TreeNote: + return cast(TreeNote, self.note) class TroveFuseOps(pyfuse3.Operations): - - enable_writeback_cache = False - def __init__(self, trove: Trove): super().__init__() self._trove = trove + # Inode Cache + self._next_inode = 2 + self._inode_cache: dict[InodeT, _TroveEntry] = {} + self._inode_reverse_cache: dict[ObjectId, InodeT] = {} + + # Cache and Lock Root Inode + node_root = trove.get_root() + self._inode_cache[pyfuse3.ROOT_INODE] = _TroveEntry(pyfuse3.ROOT_INODE, node_root.object_id) + self._inode_reverse_cache[node_root.object_id] = pyfuse3.ROOT_INODE + self._inode_cache[pyfuse3.ROOT_INODE].ref() + self._inode_cache[pyfuse3.ROOT_INODE].ref() + + # Handles + self._next_handle = 2 + self._handles: dict[int, _TroveHandle] = {} + # ------------------------------------------------------------------ - # Helpers + # Entry Management [entries relate inode to Note] # ------------------------------------------------------------------ - def _note_or_error(self, inode: int): - note = self._trove.get_raw_note(inode) + def _get_ent_from_inode(self, inode: InodeT) -> _TroveEntry: + """Get entry from predefined inode""" + if inode not in self._inode_cache: + logger.debug("inode not found in cache: %d", inode) + raise pyfuse3.FUSEError(errno.ENOENT) + value = self._inode_cache[inode] + return value + + def _create_get_ent_from_note(self, note: Note) -> _TroveEntry: + """Create entry from note. Inode is reserved but not saved in cache.""" + if note.object_id in self._inode_reverse_cache: + sys_inode = self._inode_reverse_cache[note.object_id] + return self._inode_cache[sys_inode] + sys_inode = InodeT(self._next_inode) + self._next_inode += 1 + return _TroveEntry(sys_inode=sys_inode, object_id=note.object_id) + + def _ref_entry(self, ent: _TroveEntry) -> None: + """Ref entry. If it is not in cache, it is added to cache.""" + if ent.sys_inode not in self._inode_cache: + self._inode_cache[ent.sys_inode] = ent + self._inode_reverse_cache[ent.object_id] = ent.sys_inode + ent.ref() + + def _deref_entry(self, ent: _TroveEntry, count: int = 1) -> None: + """Deref entry. Remove from cache if count hits 0""" + if ent.deref(count): + if ent.sys_inode in self._inode_cache: + logger.debug("free inode: %d", ent.sys_inode) + del self._inode_cache[ent.sys_inode] + del self._inode_reverse_cache[ent.object_id] + + def _get_inode_note(self, inode: InodeT) -> Note: + """Get note from Inode, inode must be reserved""" + ent = self._get_ent_from_inode(inode) + return self._get_ent_note(ent) + + def _get_ent_note(self, ent: _TroveEntry) -> Note: + """Get note from entry.""" + note = self._trove.get_raw_note(ent.object_id) if note is None: + logger.debug("note lookup failed: %s", ent.object_id) raise pyfuse3.FUSEError(errno.ENOENT) return note - def _make_attr(self, inode: int, is_tree: bool, size: int = 0) -> pyfuse3.EntryAttributes: + def _lookup_update_object(self, object_id: ObjectId) -> _TroveEntry: + if object_id in self._inode_reverse_cache: + inode = self._inode_reverse_cache[object_id] + return self._lookup_existing(inode) + else: + inode_id = InodeT(self._next_inode) + self._next_inode += 1 + inode = _TroveEntry(sys_inode=inode_id, object_id=object_id) + self._inode_cache[inode_id] = inode + self._inode_reverse_cache[object_id] = inode_id + return inode + + def _lookup_child(self, parent_inode: InodeT, name: bytes) -> Tuple[_TroveEntry, Note]: + parent = self._get_inode_note(parent_inode) + if not isinstance(parent, TreeNote): + raise pyfuse3.FUSEError(errno.ENOTDIR) + try: + note = parent.child(name.decode()) + except KeyError: + logger.debug("lookup failed: %d -> %s", parent_inode, name.decode()) + raise pyfuse3.FUSEError(errno.ENOENT) from None + ent = self._create_get_ent_from_note(note) + return ent, note + + + def _get_sys_inode_id(self, object_id: ObjectId) -> InodeT: + if object_id in self._inode_reverse_cache: + return self._inode_reverse_cache[object_id] + else: + raise pyfuse3.FUSEError(errno.ENOENT) + + # ------------------------------------------------------------------ + # Handle Management + # ------------------------------------------------------------------ + + def _open_handle(self, inode: InodeT) -> _TroveHandle: + note = self._get_inode_note(inode) + + handle_id = FileHandleT(self._next_handle) + self._next_handle += 1 + + handle: _TroveHandle + if isinstance(note, TreeNote): + handle = _TroveHandleTree(inode_id=inode, handle_id=handle_id, note=note) + else: + handle = _TroveHandle(inode_id=inode, handle_id=handle_id, note=note) + + self._handles[handle_id] = handle + return handle + + def _get_handle(self, handle_id: FileHandleT) -> _TroveHandle: + if not handle_id in self._handles: + raise pyfuse3.FUSEError(errno.EBADF) + return self._handles[handle_id] + + def _close_handle(self, handle: _TroveHandle): + del self._handles[handle.handle_id] + + def _get_attr(self, ent: _TroveEntry, note: Note) -> pyfuse3.EntryAttributes: + # Determine basic information + is_tree = True + size = 0 + if isinstance(note, TroveBlob): + size = len(note.read()) + is_tree = False + + # Create and fill attr structure attr = pyfuse3.EntryAttributes() - attr.st_ino = pyfuse3.InodeT(inode) + attr.st_ino = ent.sys_inode attr.st_nlink = 1 attr.st_uid = os.getuid() attr.st_gid = os.getgid() @@ -63,116 +213,128 @@ class TroveFuseOps(pyfuse3.Operations): 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 getattr(self, inode: InodeT, ctx=None) -> pyfuse3.EntryAttributes: + logger.debug("getattr inode:%d", inode) + ent = self._get_ent_from_inode(inode) + note = self._get_ent_note(ent) + return self._get_attr(ent, 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 lookup(self, parent_inode: InodeT, name: bytes, ctx=None) -> pyfuse3.EntryAttributes: + logger.debug("lookup inode:%d name:%s", parent_inode, name) + ent, child = self._lookup_child(parent_inode, name) + self._ref_entry(ent) + return self._get_attr(ent, 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 setattr(self, inode: InodeT, attr, fields, fh: FileHandleT | None, ctx) -> pyfuse3.EntryAttributes: + ent = self._get_ent_from_inode(inode) + note = self._get_ent_note(ent) + if fields.update_size: + if isinstance(note, TroveBlob): + 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))) + else: + raise pyfuse3.FUSEError(errno.EINVAL) + return self._get_attr(ent, note) - async def forget(self, inode_list) -> None: - pass + def forget(self, inode_list: Sequence[Tuple[InodeT, int]]) -> None: + for inode, nlookup in inode_list: + try: + logger.debug("deref inode:%d count:%d", inode, nlookup) + self._deref_entry(self._get_ent_from_inode(inode), nlookup) + except pyfuse3.FUSEError as e: + logger.warning("Failed to deref inode %d: %s", inode, str(e)) # ------------------------------------------------------------------ # Directory ops # ------------------------------------------------------------------ - async def opendir(self, inode: int, ctx) -> pyfuse3.FileHandleT: - note = self._note_or_error(inode) - if not isinstance(note, TroveTree): + async def opendir(self, inode: InodeT, ctx) -> FileHandleT: + handle = self._open_handle(inode) + if not isinstance(handle, _TroveHandleTree): + logger.debug("attempted opendir on %d not a tree", inode) + self._close_handle(handle) raise pyfuse3.FUSEError(errno.ENOTDIR) - return pyfuse3.FileHandleT(inode) + logger.debug("opened dir inode %d -> handle %d", inode, handle.handle_id) + return handle.handle_id - async def readdir(self, fh: int, start_id: int, token) -> None: - note = self._note_or_error(fh) + async def readdir(self, fh: FileHandleT, start_id: int, token) -> None: + logger.debug("readdir %d start_id %d", fh, start_id) + handle = self._get_handle(fh) + note = handle.note if not isinstance(note, TroveTree): + logger.debug("attempted readdir on %d not a tree", fh) 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) + + child_ent = self._create_get_ent_from_note(child) + attr = self._get_attr(child_ent, child) + self._ref_entry(child_ent) + if not pyfuse3.readdir_reply(token, name.encode(), attr, idx + 1): break - async def releasedir(self, fh: int) -> None: - pass + async def releasedir(self, fh: FileHandleT) -> None: + logger.debug("releasedir %d", fh) + handle = self._get_handle(fh) + self._close_handle(handle) - async def mkdir(self, parent_inode: int, name: bytes, mode: int, ctx) -> pyfuse3.EntryAttributes: - parent = self._note_or_error(parent_inode) + async def mkdir(self, parent_inode: InodeT, name: bytes, mode: int, ctx) -> pyfuse3.EntryAttributes: + logger.debug("mkdir inode:%d name:%s", parent_inode, name) + # Grab parent note, verify is tree + parent = self._get_inode_note(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) + # Create new directory in note + try: + new_tree: TreeNote = parent.mkdir(name.decode()) + except TreeExists: + raise pyfuse3.FUSEError(errno.EEXIST) from None + # Grab entity for kernel + ent = self._create_get_ent_from_note(new_tree) + self._ref_entry(ent) + return self._get_attr(ent, new_tree) - async def rmdir(self, parent_inode: int, name: bytes, ctx) -> None: - parent = self._note_or_error(parent_inode) - if not isinstance(parent, TroveTree): + async def rmdir(self, parent_inode: InodeT, name: bytes, ctx) -> None: + logger.debug("rmdir inode:%d name:%s", parent_inode, name) + parent = self._get_inode_note(parent_inode) + if not isinstance(parent, TreeNote): 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) + try: + parent.unlink(name.decode()) + except KeyError: + raise pyfuse3.FUSEError(errno.ENOENT) from None + # ------------------------------------------------------------------ # 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 open(self, inode: InodeT, flags, ctx) -> pyfuse3.FileInfo: + handle = self._open_handle(inode) + if isinstance(handle.note, TroveTree): + self._close_handle(handle) + raise pyfuse3.FUSEError(errno.EISDIR) + return pyfuse3.FileInfo(fh=handle.handle_id) - async def create(self, parent_inode: int, name: bytes, mode: int, flags, ctx) -> tuple: - parent = self._note_or_error(parent_inode) + async def create(self, parent_inode: InodeT, name: bytes, mode: int, flags, ctx) -> tuple: + logger.debug("create inode:%d name:%s", parent_inode, name) + parent = self._get_inode_note(parent_inode) if not isinstance(parent, TroveTree): raise pyfuse3.FUSEError(errno.ENOTDIR) name_str = name.decode() @@ -180,67 +342,66 @@ class TroveFuseOps(pyfuse3.Operations): 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] + ent = self._create_get_ent_from_note(blob) + self._ref_entry(ent) - 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):]) + handle = self._open_handle(ent.sys_inode) + attr = self._get_attr(ent, blob) + return pyfuse3.FileInfo(fh=handle.handle_id), attr + + async def read(self, fh: FileHandleT, offset: int, length: int) -> bytes: + logger.debug("read fh:%d offset:%d length:%d", fh, offset, length) + handle = self._get_handle(fh) + note = handle.note + if isinstance(note, TroveBlob): + return note.read()[offset:offset + length] + raise pyfuse3.FUSEError(errno.EBADF) + + async def write(self, fh: FileHandleT, offset: int, data: bytes) -> int: + handle = self._get_handle(fh) + note = handle.note + if isinstance(note, TroveBlob): + 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 release(self, fh: FileHandleT) -> None: + handle = self._get_handle(fh) + self._close_handle(handle) - async def unlink(self, parent_inode: int, name: bytes, ctx) -> None: - parent = self._note_or_error(parent_inode) - if not isinstance(parent, TroveTree): + async def unlink(self, parent_inode: InodeT, name: bytes, ctx) -> None: + parent_note = self._get_inode_note(parent_inode) + if not isinstance(parent_note, TroveTree): raise pyfuse3.FUSEError(errno.ENOTDIR) name_str = name.decode() - entries = parent.list() - if name_str not in entries: + if name_str not in parent_note.list(): 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) + parent_note.unlink(name.decode()) - async def rename(self, parent_inode_old, name_old, parent_inode_new, name_new, flags, ctx): - old_parent = self._note_or_error(parent_inode_old) - new_parent = self._note_or_error(parent_inode_new) - if not isinstance(old_parent, TroveTree) or not isinstance(new_parent, TroveTree): + async def rename(self, parent_inode_old: InodeT, name_old: bytes, parent_inode_new: InodeT, name_new: bytes, flags, ctx): + # Decode / validate names + name_new_str = name_new.decode() + name_old_str = name_old.decode() + + # Grab the parents + new_parent = self._get_inode_note(parent_inode_new) + if not isinstance(new_parent, TroveTree): + raise pyfuse3.FUSEError(errno.ENOTDIR) + old_parent = self._get_inode_note(parent_inode_old) + if not isinstance(old_parent, TroveTree): raise pyfuse3.FUSEError(errno.ENOTDIR) - name_old_str = name_old.decode() - name_new_str = name_new.decode() + # We want to maintain the inode - find the note via the internal entity + ent, note = self._lookup_child(parent_inode_old, name_old) - old_entries = old_parent.list() - if name_old_str not in old_entries: - raise pyfuse3.FUSEError(errno.ENOENT) + # Remove existing target + new_parent.unlink(name_new_str) - child_id = old_entries[name_old_str] - child = self._trove.get_raw_note(child_id) - if child is None: - raise pyfuse3.FUSEError(errno.ENOENT) - - # Remove existing target if present - new_entries = new_parent.list() - if name_new_str in new_entries: - target = self._trove.get_raw_note(new_entries[name_new_str]) - if target is not None and isinstance(target, TroveTree): - if target.list(): - raise pyfuse3.FUSEError(errno.ENOTEMPTY) - new_parent.unlink(name_new_str) - - new_parent.link(name_new_str, child) + # Link to new parent, unlink from old + new_parent.link(name_new_str, note) old_parent.unlink(name_old_str) @@ -249,6 +410,8 @@ class TroveFuseOps(pyfuse3.Operations): # ------------------------------------------------------------------ async def _run(ops: TroveFuseOps, mountpoint: str) -> None: + logging.basicConfig(level=logging.DEBUG) + options = set(pyfuse3.default_options) options.add("fsname=trove") pyfuse3.init(ops, mountpoint, options) diff --git a/trovedb/trove.py b/trovedb/trove.py index 3f3e6f5..690c69c 100644 --- a/trovedb/trove.py +++ b/trovedb/trove.py @@ -1,12 +1,20 @@ -from typing import Protocol, runtime_checkable, Optional, Dict, List, Self +from typing import Protocol, runtime_checkable, Optional, Dict, List, Self, NamedTuple, Iterable, MappingView from uuid import UUID from pathlib import PurePosixPath -NODE_ROOT_ID = 1 + +type ObjectId = int + +NODE_ROOT_ID: ObjectId = 1 class BadNoteType(TypeError): """Raised when an invalid note type is encountered.""" +class TreeExists(TypeError): + """Raised when a label already exists.""" + +class NoteNotFound(KeyError): + """Raised when a note is not found.""" @runtime_checkable class Note(Protocol): @@ -15,7 +23,7 @@ class Note(Protocol): Represents access to an individual note's content and metadata. """ @property - def object_id(self) -> int: + def object_id(self) -> ObjectId: """The unique identifier for this note.""" ... @@ -37,6 +45,11 @@ class Blob(Protocol): """Write new content to the note.""" ... + +class TreeEntry(NamedTuple): + name: str + object_id: ObjectId + @runtime_checkable class Tree(Protocol): def link(self, name: str, note: Note): @@ -56,7 +69,11 @@ class Tree(Protocol): ... def child(self, name: str) -> Note: - """Retrieve a child not by name.""" + """Retrieve a child note by name.""" + ... + + def entries(self) -> Iterable[TreeEntry]: + """Return all entries in the directory""" ... def list(self) -> dict[str, int]: @@ -78,8 +95,8 @@ class Trove(Protocol): Provides high-level access to notes and trees. """ - def get_raw_note(self, note: int) -> Optional[Note]: - """Retrieve a note by a UUID""" + def get_raw_note(self, note: ObjectId) -> Note: + """Retrieve a note by a object id""" ... def create_blob(self, data: bytes | None = None) -> BlobNote: diff --git a/trovedb/trovedb.py b/trovedb/trovedb.py index 8cfaf45..9599fc9 100644 --- a/trovedb/trovedb.py +++ b/trovedb/trovedb.py @@ -11,7 +11,7 @@ from pathlib import Path from .db import Sqlite3Trove from .tree import Tree as TreeData -from .trove import NODE_ROOT_ID, Note, Trove, TreeNote, BlobNote +from .trove import NODE_ROOT_ID, Note, Trove, TreeNote, BlobNote, TreeEntry, NoteNotFound class NoteImpl(Note): @@ -65,9 +65,12 @@ class TreeNoteImpl(NoteImpl, TreeNote): 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) + try: + tree = self._read_tree() + tree.rm_entry(name) + self._flush_tree(tree) + except KeyError: + pass def mkdir(self, name: str) -> 'TreeNoteImpl': """Create a new empty tree, link it under name, and return it.""" @@ -91,7 +94,16 @@ class TreeNoteImpl(NoteImpl, TreeNote): if name not in entries: raise KeyError(f"Entry '{name}' not found") child_id = entries[name] - return self._parent.get_raw_note(child_id) + value = self._parent.get_raw_note(child_id) + if value is None: + raise KeyError(f"Entry '{name}' has no value") + return value + + def entries(self): + """Return all entries as an iterable of TreeEntry.""" + tree = self._read_tree() + for name, object_id in tree.list().items(): + yield TreeEntry(name, object_id) def list(self) -> dict[str, int]: """Return all entries as {name: object_id}.""" @@ -138,11 +150,11 @@ class TroveImpl: self.close() # Trove protocol - def get_raw_note(self, note_id: int) -> Optional[Note]: + def get_raw_note(self, note_id: int) -> 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 + raise NoteNotFound(note_id) if ot == "blob": return BlobNoteImpl(self, note_id) if ot == "tree":