Add move operation, fix fuse server

This commit is contained in:
Andrew Mulbrook 2026-03-28 13:25:25 -05:00
parent 41480a39c9
commit 6470aee802
5 changed files with 90 additions and 45 deletions

View file

@ -14,6 +14,8 @@ from typing import NamedTuple
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from . import trove as tr
NOTE_ROOT_ID = uuid.UUID(int=0) NOTE_ROOT_ID = uuid.UUID(int=0)
class ObjectInfo(NamedTuple): class ObjectInfo(NamedTuple):
@ -288,13 +290,21 @@ class Sqlite3Trove:
# Tree entry operations # Tree entry operations
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def link(self, parent_id: SqlObjectId, name: str, child_id: SqlObjectId) -> None: def link(self, parent_id: SqlObjectId, name: str, child_id: SqlObjectId, overwrite: bool = True) -> None:
""" """
Link a child object into a tree under the given name. Link a child object into a tree under the given name.
Replaces any existing entry with the same name in this tree. Replaces any existing entry with the same name in this tree if overwrite=True.
Both parent_id and child_id must exist in the objects table Both parent_id and child_id must exist in the objects table
(enforced by FK constraints). (enforced by FK constraints).
""" """
if not overwrite:
existing = self._con.execute(
"SELECT 1 FROM tree_entries WHERE parent_id = ? AND name = ?",
(_sql_id(parent_id), name),
).fetchone()
if existing:
raise tr.ErrorExists(f"Entry '{name}' already exists in tree {parent_id}")
self._con.execute( self._con.execute(
"INSERT OR REPLACE INTO tree_entries (parent_id, name, child_id) " "INSERT OR REPLACE INTO tree_entries (parent_id, name, child_id) "
"VALUES (?, ?, ?)", "VALUES (?, ?, ?)",

View file

@ -1,12 +1,9 @@
import os import os
import sqlite3
import tempfile
import datetime as dt import datetime as dt
from pathlib import Path, PurePosixPath from pathlib import Path
from typing import Optional, Dict, List, Self, Iterable, Iterator from typing import Optional, Iterable, Iterator, override
from .trove import Note, Trove, TreeNote, BadNoteType, TreeEntry, NoteNotFound, ObjectId from .trove import Note, Trove, TreeNote, BadNoteType, TreeEntry, NoteNotFound, ObjectId
from . import fs_util as fsu
from . import trove as tr from . import trove as tr
class FSNote(Note): class FSNote(Note):
@ -78,7 +75,7 @@ class FSNote(Note):
content = b"" content = b""
if mime == 'inode/directory': if mime == 'inode/directory':
if content is not None: if content:
raise NotImplementedError("FSNote does not support children") raise NotImplementedError("FSNote does not support children")
return FSTreeNote(self._trove, self._new_child_subdir(name, False)) return FSTreeNote(self._trove, self._new_child_subdir(name, False))
@ -104,24 +101,22 @@ class FSNote(Note):
target_path.unlink() target_path.unlink()
# TODO: remove meta directory! # TODO: remove meta directory!
def unlink_(self, name: str):
def children(self) -> Iterator[TreeEntry]: target_path = self._fs_path / name
"""Get all children of this note.""" if not target_path.exists():
if not self._fs_path.is_dir():
return return
for item in self._fs_path.iterdir(): if target_path.is_dir():
if item.name == ".trove": target_path.rmdir()
continue else:
yield TreeEntry(name=item.name, object_id=item.stat().st_ino) target_path.unlink()
class FSTreeNote(FSNote, TreeNote): def link_(self, name: str, note: Note):
def link(self, name: str, note: Note):
if not isinstance(note, FSNote): if not isinstance(note, FSNote):
raise BadNoteType("Only blob notes can be linked") raise BadNoteType("Only blob notes can be linked")
target_path = self._fs_path / name target_path = self._fs_path / name
if target_path.exists(): if target_path.exists():
self.unlink(name) self.unlink_(name)
note_path = note._fs_path note_path = note._fs_path
@ -141,15 +136,16 @@ class FSTreeNote(FSNote, TreeNote):
# We move it to the new location. # We move it to the new location.
os.rename(note_path, target_path) os.rename(note_path, target_path)
def unlink(self, name: str): def children(self) -> Iterator[TreeEntry]:
target_path = self._fs_path / name """Get all children of this note."""
if not target_path.exists(): if not self._fs_path.is_dir():
return return
if target_path.is_dir(): for item in self._fs_path.iterdir():
target_path.rmdir() if item.name == ".trove":
else: continue
target_path.unlink() yield TreeEntry(name=item.name, object_id=item.stat().st_ino)
class FSTreeNote(FSNote, TreeNote):
def mkdir(self, name: str) -> 'FSTreeNote': def mkdir(self, name: str) -> 'FSTreeNote':
target_path = self._fs_path / name target_path = self._fs_path / name
target_path.mkdir(exist_ok=True) target_path.mkdir(exist_ok=True)
@ -164,6 +160,11 @@ class FSTreeNote(FSNote, TreeNote):
except OSError: except OSError:
pass pass
def unlink(self, name: str):
return self.unlink_(name)
def link(self, name: str, note: Note):
return self.link_(name, note)
@ -198,6 +199,25 @@ class FSTrove(Trove):
raise NoteNotFound(note_id) raise NoteNotFound(note_id)
return self.get_raw_note_by_path(p) return self.get_raw_note_by_path(p)
@override
def move(self, src_parent: Note, src_name: str, dst_parent: Note, dst_name: str, overwrite: bool):
"""Move a child note to a new location."""
src_note = src_parent.child(src_name)
if not isinstance(src_note, FSNote):
raise tr.ErrorBadType("not a valid DB note")
if not isinstance(dst_parent, FSNote):
raise tr.ErrorBadType("not a valid DB note")
if not dst_parent._fs_path.is_dir():
raise NotImplementedError("FSTrove promoting during move")
# Remove existing target
if overwrite:
dst_parent.unlink_(dst_name)
# Link to new parent, unlink from old
dst_parent.link_(dst_name, src_note)
src_parent.rm_child(src_name, True)
def create_blob(self, data: bytes | None = None) -> Note: def create_blob(self, data: bytes | None = None) -> Note:
raise NotImplementedError("FSTrove does not support blobs") raise NotImplementedError("FSTrove does not support blobs")

View file

@ -378,12 +378,15 @@ class TroveFuseOps(pyfuse3.Operations):
self._close_handle(handle) self._close_handle(handle)
async def unlink(self, parent_inode: InodeT, name: bytes, ctx) -> None: async def unlink(self, parent_inode: InodeT, name: bytes, ctx) -> None:
try:
parent_note = self._get_inode_note(parent_inode) parent_note = self._get_inode_note(parent_inode)
name_str = name.decode() name_str = name.decode()
parent_note.rm_child(name_str, False) parent_note.rm_child(name_str, False)
except tr.ErrorWithErrno as e:
raise pyfuse3.FUSEError(e.errno) from None
async def rename(self, parent_inode_old: InodeT, name_old: bytes, parent_inode_new: InodeT, name_new: bytes, flags, ctx): async def rename(self, parent_inode_old: InodeT, name_old: bytes, parent_inode_new: InodeT, name_new: bytes, flags, ctx):
# Decode / validate names # # Decode / validate names
name_new_str = name_new.decode() name_new_str = name_new.decode()
name_old_str = name_old.decode() name_old_str = name_old.decode()
@ -395,16 +398,8 @@ class TroveFuseOps(pyfuse3.Operations):
if not isinstance(old_parent, TroveTree): if not isinstance(old_parent, TroveTree):
raise pyfuse3.FUSEError(errno.ENOTDIR) raise pyfuse3.FUSEError(errno.ENOTDIR)
# We want to maintain the inode - find the note via the internal entity # Move!
ent, note = self._lookup_child(parent_inode_old, name_old) self._trove.move(old_parent, name_old_str, new_parent, name_new_str, overwrite=True)
# Remove existing target
new_parent.unlink(name_new_str)
# Link to new parent, unlink from old
new_parent.link(name_new_str, note)
old_parent.unlink(name_old_str)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Serve # Serve

View file

@ -33,6 +33,10 @@ class ErrorNotEmpty(ErrorWithErrno):
def __init__(self, *args): def __init__(self, *args):
super().__init__(errno.ENOTEMPTY, *args) super().__init__(errno.ENOTEMPTY, *args)
class ErrorBadType(TypeError):
"""Raised when an invalid type is encountered."""
...
class BadNoteType(TypeError): class BadNoteType(TypeError):
"""Raised when an invalid note type is encountered.""" """Raised when an invalid note type is encountered."""
@ -159,6 +163,10 @@ class Trove(Protocol):
"""Retrieve a note by a object id""" """Retrieve a note by a object id"""
... ...
def move(self, src_parent: Note, src_name: str, dst_parent: Note, dst_name: str, overwrite: bool):
"""Move a child note to a new location."""
...
def create_blob(self, data: bytes | None = None) -> Note: def create_blob(self, data: bytes | None = None) -> Note:
"""Create a new blob node at the given path with content""" """Create a new blob node at the given path with content"""
... ...

View file

@ -5,7 +5,7 @@ Implements BlobNote, TreeNote, and Trove protocols defined in trove.py.
Depends on db.py (Sqlite3Trove) for storage. Depends on db.py (Sqlite3Trove) for storage.
""" """
from typing import Optional, Iterator from typing import Optional, Iterator, override
from pathlib import Path from pathlib import Path
import datetime as dt import datetime as dt
import uuid import uuid
@ -78,6 +78,7 @@ class NoteImpl(Note):
"""Create a new child note.""" """Create a new child note."""
content = content if content is not None else b"" content = content if content is not None else b""
object_id = self._db.write_blob(data=content, object_id=None, dtype=mime, executable=executable, hidden=hidden) object_id = self._db.write_blob(data=content, object_id=None, dtype=mime, executable=executable, hidden=hidden)
self._db.link(self._object_id, name, object_id)
# TODO fix this # TODO fix this
if mime == 'inode/directory': if mime == 'inode/directory':
return TreeNoteImpl(self._parent, object_id) return TreeNoteImpl(self._parent, object_id)
@ -139,7 +140,7 @@ class TreeNoteImpl(NoteImpl, TreeNote):
# Trove # Trove
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TroveImpl: class TroveImpl(Trove):
""" """
Concrete Trove: top-level API backed by a Sqlite3Trove database. Concrete Trove: top-level API backed by a Sqlite3Trove database.
@ -179,6 +180,17 @@ class TroveImpl:
return TreeNoteImpl(self, note_id) return TreeNoteImpl(self, note_id)
return NoteImpl(self, note_id) return NoteImpl(self, note_id)
@override
def move(self, src_parent: Note, src_name: str, dst_parent: Note, dst_name: str, overwrite: bool):
"""Move a child note to a new location."""
src_note = src_parent.child(src_name)
if not isinstance(src_note, NoteImpl):
raise tr.ErrorBadType("not a valid DB note")
if not isinstance(dst_parent, NoteImpl):
raise tr.ErrorBadType("not a valid DB note")
self._db.link(dst_parent.object_id, dst_name, src_note.object_id)
self._db.unlink(src_parent.object_id, src_name)
def create_blob(self, data: bytes | None = None, def create_blob(self, data: bytes | None = None,
dtype: str = "application/octet-stream") -> Note: dtype: str = "application/octet-stream") -> Note:
"""Create a new blob object and return a BlobNote for it.""" """Create a new blob object and return a BlobNote for it."""