diff --git a/trovedb/fs.py b/trovedb/fs.py new file mode 100644 index 0000000..44f961e --- /dev/null +++ b/trovedb/fs.py @@ -0,0 +1,231 @@ +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 + + +class FSNote(Note): + def __init__(self, trove: 'FSTrove', *, inode: int | None = None, path: Path | None = None): + self._trove: FSTrove = trove + self._fs_path: Path | None = path + self._inode: int | None = inode + + if self._fs_path is not None: + inode = self._fs_path.stat().st_ino + if self._inode != inode and self._inode is not None and self._inode != NODE_ROOT_ID: + raise ValueError(f"Inconsistent inode: {self._inode} vs {inode}") + self._inode = inode + + @property + def object_id(self) -> int: + if self._inode is None: + raise ValueError("Note not yet saved to disk") + return self._inode + + @property + def _path(self) -> Path: + if self._fs_path is not None: + return self._fs_path + if self._inode is None: + raise ValueError("Note not yet saved to disk") + self._fs_path = self._trove.get_path_by_inode(self._inode) + assert self._fs_path is not None + return self._fs_path + + def get_raw_metadata(self, key: str) -> Optional[bytes]: + return self._trove._get_metadata(self._inode, key) + + def set_raw_metadata(self, key: str, value: bytes) -> None: + self._trove._set_metadata(self._inode, key, value) + +class FSBlobNote(FSNote, BlobNote): + def read(self) -> bytes: + return self._path.read_bytes() + + def write(self, data: bytes) -> None: + self._path.write_bytes(data) + # Update cache just in case inode changed (some editors do this) + try: + new_inode = self._path.stat().st_ino + if new_inode != self._inode: + self._trove._update_cache(new_inode, self._path) + self._inode = new_inode + except OSError: + pass + +class FSTreeNote(FSNote, TreeNote): + def link(self, name: str, note: Note): + if not isinstance(note, FSBlobNote): + raise BadNoteType("Only blob notes can be linked") + + target_path = self._path / name + if target_path.exists(): + self.unlink(name) + + note_path = note._path + + # If the note is in .working, move it to the new location. + if self._trove.working in note_path.parents: + os.rename(note_path, target_path) + self._trove._update_cache(note.object_id, target_path) + else: + # If it's already linked somewhere, create a hard link if it's a file. + if note_path.is_file(): + try: + os.link(note_path, target_path) + except OSError: + # Fallback to rename if link fails (e.g. cross-device, though we assume single FS) + os.rename(note_path, target_path) + else: + # Directories cannot be hardlinked. + # We move it to the new location. + os.rename(note_path, target_path) + + self._trove._update_cache(note.object_id, target_path) + + def unlink(self, name: str): + target_path = self._path / name + if not target_path.exists(): + return + if target_path.is_dir(): + target_path.rmdir() + else: + target_path.unlink() + + def mkdir(self, name: str) -> 'FSTreeNote': + target_path = self._path / name + target_path.mkdir(exist_ok=True) + inode = target_path.stat().st_ino + self._trove._update_cache(inode, target_path) + return FSTreeNote(self._trove, inode=inode, path=target_path) + + def list(self) -> dict[str, int]: + res = {} + try: + for item in self._path.iterdir(): + if item.name == ".trove": + continue + res[item.name] = item.stat().st_ino + self._trove._update_cache(res[item.name], item) + except OSError: + pass + return res + +class FSTrove(Trove): + def __init__(self, root: str | Path): + self.root = Path(root).absolute() + self.dot_trove = self.root / ".trove" + self.working = self.dot_trove / ".working" + + self.dot_trove.mkdir(exist_ok=True) + self.working.mkdir(exist_ok=True) + + db_path = self.dot_trove / "trovecache.db" + self.con = sqlite3.connect(str(db_path)) + self._init_db() + + # Ensure root mapping. + self._update_cache(NODE_ROOT_ID, self.root) + + @classmethod + def open(cls, path: str | Path, create: bool = False) -> 'FSTrove': + p = Path(path) + if not p.exists(): + if not create: + raise FileNotFoundError(f"Root path not found: {p}") + p.mkdir(parents=True) + return cls(p) + + def _init_db(self): + self.con.execute("CREATE TABLE IF NOT EXISTS cache (inode INTEGER PRIMARY KEY, path TEXT)") + self.con.execute("CREATE TABLE IF NOT EXISTS metadata (inode INTEGER, key TEXT, value BLOB, PRIMARY KEY(inode, key))") + self.con.commit() + + def _update_cache(self, inode: int, path: Path): + try: + rel_path = path.relative_to(self.root) + path_str = str(rel_path) + if path_str == ".": + path_str = "" + except ValueError: + # Path not under root, maybe it's the root itself? + if path == self.root: + path_str = "" + else: + return # Not under root, don't cache + + self.con.execute("INSERT OR REPLACE INTO cache (inode, path) VALUES (?, ?)", (inode, path_str)) + self.con.commit() + + def get_path_by_inode(self, inode: int) -> Optional[Path]: + if inode == NODE_ROOT_ID: + return self.root + + row = self.con.execute("SELECT path FROM cache WHERE inode = ?", (inode,)).fetchone() + if row: + p = self.root / row[0] + try: + if p.exists() and p.stat().st_ino == inode: + return p + except OSError: + pass + + # search + for root_dir, dirs, files in os.walk(self.root): + # Skip .trove + if ".trove" in dirs: + dirs.remove(".trove") + + for name in dirs + files: + p = Path(root_dir) / name + try: + st = p.stat() + if st.st_ino == inode: + self._update_cache(inode, p) + return p + except OSError: + continue + return None + + def get_raw_note(self, note_id: int) -> Optional[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) + + def create_blob(self, data: bytes | None = None) -> BlobNote: + fd, temp_path = tempfile.mkstemp(dir=self.working) + try: + if data: + os.write(fd, data) + finally: + os.close(fd) + p = Path(temp_path) + inode = p.stat().st_ino + self._update_cache(inode, p) + return FSBlobNote(self, inode=inode, path=p) + + def get_root(self) -> TreeNote: + return FSTreeNote(self, inode=NODE_ROOT_ID, 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() + return row[0] if row else None + + def _set_metadata(self, inode: int, key: str, value: bytes): + self.con.execute("INSERT OR REPLACE INTO metadata (inode, key, value) VALUES (?, ?, ?)", (inode, key, value)) + self.con.commit() + + def close(self): + self.con.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() diff --git a/trovedb/fuse/__main__.py b/trovedb/fuse/__main__.py index f0c7381..b7d7d45 100644 --- a/trovedb/fuse/__main__.py +++ b/trovedb/fuse/__main__.py @@ -1,5 +1,7 @@ +from pathlib import Path from . import server from trovedb import trovedb +from trovedb import fs from argparse import ArgumentParser def main(): @@ -8,7 +10,14 @@ def main(): 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) + + file = Path(args.db) + if not file.exists(): + print(f"Database not found: {file}") + return + + impl = trovedb.TroveImpl.open(str(file)) if not file.is_dir() else fs.FSTrove.open(str(file)) + server.serve(impl, args.mountpoint) if __name__ == '__main__': main() diff --git a/trovedb/fuse/server.py b/trovedb/fuse/server.py index 8bcc9d6..9a7498f 100644 --- a/trovedb/fuse/server.py +++ b/trovedb/fuse/server.py @@ -213,6 +213,36 @@ class TroveFuseOps(pyfuse3.Operations): raise pyfuse3.FUSEError(errno.EISDIR) parent.unlink(name_str) + 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): + raise pyfuse3.FUSEError(errno.ENOTDIR) + + name_old_str = name_old.decode() + name_new_str = name_new.decode() + + old_entries = old_parent.list() + if name_old_str not in old_entries: + raise pyfuse3.FUSEError(errno.ENOENT) + + 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) + old_parent.unlink(name_old_str) + # ------------------------------------------------------------------ # Serve diff --git a/trovedb/trove.py b/trovedb/trove.py index d358d74..3f3e6f5 100644 --- a/trovedb/trove.py +++ b/trovedb/trove.py @@ -4,6 +4,10 @@ from pathlib import PurePosixPath NODE_ROOT_ID = 1 +class BadNoteType(TypeError): + """Raised when an invalid note type is encountered.""" + + @runtime_checkable class Note(Protocol): """ @@ -47,6 +51,14 @@ class Tree(Protocol): """Create a new Tree with the given name.""" ... + def rmdir(self, name: str) -> None: + """Remove a directory from the tree.""" + ... + + def child(self, name: str) -> Note: + """Retrieve a child not by name.""" + ... + def list(self) -> dict[str, int]: """Return all entries as {name: object_id}.""" ... diff --git a/trovedb/trovedb.py b/trovedb/trovedb.py index 0f57c7f..8cfaf45 100644 --- a/trovedb/trovedb.py +++ b/trovedb/trovedb.py @@ -17,8 +17,9 @@ 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 + def __init__(self, parent: 'TroveImpl', object_id: int): + self._parent = parent + self._db = parent.db self._object_id = object_id # Note protocol @@ -73,12 +74,25 @@ class TreeNoteImpl(NoteImpl, TreeNote): # Create the new node new_id = self._db.write_tree(TreeData().serialize()) - tree = TreeNoteImpl(self._db, new_id) + tree = TreeNoteImpl(self._parent, new_id) # Update our node self.link(name, tree) return tree + def rmdir(self, name: str) -> None: + """Remove a directory from the tree.""" + self.unlink(name) + + def child(self, name: str) -> Note: + """Retrieve a child note by name.""" + tree = self._read_tree() + entries = tree.list() + if name not in entries: + raise KeyError(f"Entry '{name}' not found") + child_id = entries[name] + return self._parent.get_raw_note(child_id) + def list(self) -> dict[str, int]: """Return all entries as {name: object_id}.""" return self._read_tree().list() @@ -110,6 +124,10 @@ class TroveImpl: db._con.commit() return trove + @property + def db(self) -> Sqlite3Trove: + return self._db + def close(self) -> None: self._db.close() @@ -126,19 +144,19 @@ class TroveImpl: if ot is None: return None if ot == "blob": - return BlobNoteImpl(self._db, note_id) + return BlobNoteImpl(self, note_id) if ot == "tree": - return TreeNoteImpl(self._db, note_id) + return TreeNoteImpl(self, 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) + return BlobNoteImpl(self, obj_id) def get_root(self) -> TreeNote: """Return the root TreeNote (always id=NODE_ROOT_ID).""" - return TreeNoteImpl(self._db, NODE_ROOT_ID) + return TreeNoteImpl(self, NODE_ROOT_ID) def open_trove(path: str | Path, create: bool = False) -> Trove: return TroveImpl.open(path, create=create)