Add fs implementation and add to trove
This commit is contained in:
parent
2cfe32b333
commit
f80f4d12a2
5 changed files with 308 additions and 8 deletions
231
trovedb/fs.py
Normal file
231
trovedb/fs.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}."""
|
||||
...
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue