Start API refactor to remove separate Tree

We want trees to just be notes. We want to reference notes
by Path instead of object ids. Stop thinking about the
fs implementation and the fuse service with the same terms -
they will be different backends.
This commit is contained in:
Andrew Mulbrook 2026-03-26 21:00:35 -05:00
parent abbef64bbc
commit 41480a39c9
5 changed files with 245 additions and 234 deletions

View file

@ -213,6 +213,7 @@ class Sqlite3Trove:
if object_id is None: if object_id is None:
object_id = uuid.uuid4() object_id = uuid.uuid4()
sid = _sql_id(object_id) sid = _sql_id(object_id)
assert sid is not None
# Preserve created timestamp on update # Preserve created timestamp on update
row = self._con.execute( row = self._con.execute(

View file

@ -2,99 +2,132 @@ import os
import sqlite3 import sqlite3
import tempfile import tempfile
import datetime as dt import datetime as dt
from pathlib import Path from pathlib import Path, PurePosixPath
from typing import Optional, Dict, List, Self, Iterable from typing import Optional, Dict, List, Self, Iterable, Iterator
from .trove import Note, Trove, TreeNote, BadNoteType, TreeEntry, NoteNotFound from .trove import Note, Trove, TreeNote, BadNoteType, TreeEntry, NoteNotFound, ObjectId
from . import fs_util as fsu from . import fs_util as fsu
from . import trove as tr
class FSNote(Note): class FSNote(Note):
def __init__(self, trove: 'FSTrove', *, inode: int | None = None, path: Path | None = None): def __init__(self, trove: 'FSTrove', path: Path):
self._trove: FSTrove = trove self._trove: FSTrove = trove
self._fs_path: Path | None = path self._fs_path: Path = path.resolve()
self._inode: int | None = inode if not self._fs_path.is_relative_to(trove.root):
raise ValueError("Path must be relative to the root directory")
if self._fs_path is not None: self._object_id: str = path.relative_to(trove.root).as_posix()
inode = self._fs_path.stat().st_ino
if self._inode != inode and self._inode is not None:
raise ValueError(f"Inconsistent inode: {self._inode} vs {inode}")
self._inode = inode
@property @property
def object_id(self) -> int: def object_id(self) -> tr.ObjectId:
if self._inode is None: return self._object_id
raise ValueError("Note not yet saved to disk")
return self._inode @property
def fs_path(self) -> Path:
return self._fs_path
@property @property
def mtime(self): def mtime(self):
"""Return modification time as datetime.""" """Return modification time as datetime."""
stat = self._path.stat() stat = self._fs_path.stat()
return dt.datetime.fromtimestamp(stat.st_mtime, tz=dt.timezone.utc) return dt.datetime.fromtimestamp(stat.st_mtime, tz=dt.timezone.utc)
@property @property
def readonly(self) -> bool: def readonly(self) -> bool:
"""Check if the note is readonly based on file permissions.""" """Check if the note is readonly based on file permissions."""
if self._inode is None: return not os.access(self._fs_path, os.W_OK)
return False
return not os.access(self._path, os.W_OK)
@property @property
def mime(self) -> str: def mime(self) -> str:
"""Return MIME type, defaulting to generic binary stream.""" """Return MIME type, defaulting to generic binary stream."""
if self._fs_path.is_dir():
return "inode/directory"
return "application/octet-stream" return "application/octet-stream"
@property
def _path(self) -> Path:
if self._fs_path is not None:
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)
assert self._fs_path is not None
return self._fs_path
def get_raw_metadata(self, key: str) -> Optional[bytes]: def get_raw_metadata(self, key: str) -> Optional[bytes]:
return self._trove._get_metadata(self._inode, key) # TODO: FIXME
return None
def set_raw_metadata(self, key: str, value: bytes) -> None: def set_raw_metadata(self, key: str, value: bytes) -> None:
self._trove._set_metadata(self._inode, key, value) # TODO: FIXME
pass
def read_content(self) -> bytes: def read_content(self) -> bytes:
"""Read the raw content of the note.""" """Read the raw content of the note."""
content_file = fsu.get_content_path(self._path) if self._fs_path.is_file():
if content_file.exists(): return self._fs_path.read_bytes()
return content_file.read_bytes()
return b"" return b""
def write_content(self, data:bytes) -> None: def write_content(self, data:bytes) -> None:
"""Write the raw content of the note.""" """Write the raw content of the note."""
content_file = fsu.get_content_path(self._path) self._fs_path.write_bytes(data)
content_file.write_bytes(data)
def _new_child_subdir(self, name: str, exist_ok: bool = True) -> Path:
ex_path = self._fs_path / name
try:
ex_path.mkdir(exist_ok=exist_ok)
return ex_path
except FileExistsError:
raise tr.ErrorExists(str(ex_path)) from None
except OSError as e:
raise tr.ErrorWithErrno(e.errno, str(e)) from None
def new_child(self, name: str, mime: str, content: bytes | None, executable: bool, hidden: bool) -> Note:
"""Create a new child note."""
if content is None:
content = b""
if mime == 'inode/directory':
if content is not None:
raise NotImplementedError("FSNote does not support children")
return FSTreeNote(self._trove, self._new_child_subdir(name, False))
ex_path = self._fs_path / name
ex_path.write_bytes(content)
return FSNote(self._trove, ex_path)
def child(self, name: str) -> Note:
"""Retrieve a child not by name."""
target_path = self._fs_path / name
return self._trove.get_raw_note_by_path(target_path)
def rm_child(self, name: str, recurse: bool):
target_path = self._fs_path / name
if not target_path.exists():
raise tr.ErrorNotFound(name)
if target_path.is_dir():
if recurse:
raise NotImplementedError("Recursive deletion not supported")
else:
target_path.rmdir()
else:
target_path.unlink()
# TODO: remove meta directory!
def children(self) -> Iterator[TreeEntry]:
"""Get all children of this note."""
if not self._fs_path.is_dir():
return
for item in self._fs_path.iterdir():
if item.name == ".trove":
continue
yield TreeEntry(name=item.name, object_id=item.stat().st_ino)
class FSTreeNote(FSNote, TreeNote): class FSTreeNote(FSNote, TreeNote):
@property
def mime(self) -> str:
"""Return MIME type for directory/tree nodes."""
return "inode/directory"
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._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._path note_path = note._fs_path
# If the note is in .working, move it to the new location. # If the note is in .working, move it to the new location.
if self._trove.working in note_path.parents: if self._trove.working in note_path.parents:
os.rename(note_path, target_path) os.rename(note_path, target_path)
self._trove._update_cache(note.object_id, target_path)
else: else:
# If it's already linked somewhere, create a hard link if it's a file. # If it's already linked somewhere, create a hard link if it's a file.
if note_path.is_file(): if note_path.is_file():
@ -107,11 +140,9 @@ class FSTreeNote(FSNote, TreeNote):
# Directories cannot be hardlinked. # Directories cannot be hardlinked.
# 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)
self._trove._update_cache(note.object_id, target_path)
def unlink(self, name: str): def unlink(self, name: str):
target_path = self._path / name target_path = self._fs_path / name
if not target_path.exists(): if not target_path.exists():
return return
if target_path.is_dir(): if target_path.is_dir():
@ -120,39 +151,20 @@ class FSTreeNote(FSNote, TreeNote):
target_path.unlink() target_path.unlink()
def mkdir(self, name: str) -> 'FSTreeNote': def mkdir(self, name: str) -> 'FSTreeNote':
target_path = self._path / name target_path = self._fs_path / name
target_path.mkdir(exist_ok=True) target_path.mkdir(exist_ok=True)
inode = target_path.stat().st_ino return FSTreeNote(self._trove, path=target_path)
self._trove._update_cache(inode, target_path)
return FSTreeNote(self._trove, inode=inode, path=target_path)
def entries(self) -> Iterable[TreeEntry]: def entries(self) -> Iterable[TreeEntry]:
try: try:
for item in self._path.iterdir(): for item in self._fs_path.iterdir():
if item.name == ".trove": if item.name == ".trove":
continue continue
inode = item.stat().st_ino yield TreeEntry(name=item.name, object_id=str(item))
self._trove._update_cache(inode, item)
yield TreeEntry(name=item.name, object_id=inode)
except OSError: except OSError:
pass pass
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
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): class FSTrove(Trove):
@ -161,14 +173,9 @@ class FSTrove(Trove):
self._root_inode = self.root.stat().st_ino self._root_inode = self.root.stat().st_ino
self.dot_trove = self.root / ".trove" self.dot_trove = self.root / ".trove"
self.working = self.dot_trove / ".working" self.working = self.dot_trove / ".working"
self.dot_trove.mkdir(exist_ok=True) self.dot_trove.mkdir(exist_ok=True)
self.working.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()
@classmethod @classmethod
def open(cls, path: str | Path, create: bool = False) -> 'FSTrove': def open(cls, path: str | Path, create: bool = False) -> 'FSTrove':
p = Path(path) p = Path(path)
@ -178,101 +185,30 @@ class FSTrove(Trove):
p.mkdir(parents=True) p.mkdir(parents=True)
return cls(p) return cls(p)
def _init_db(self): def get_raw_note_by_path(self, path: Path) -> Note:
self.con.execute("CREATE TABLE IF NOT EXISTS cache (inode INTEGER PRIMARY KEY, path TEXT)") if not path.exists():
self.con.execute("CREATE TABLE IF NOT EXISTS metadata (inode INTEGER, key TEXT, value BLOB, PRIMARY KEY(inode, key))") raise tr.ErrorNotFound(str(path))
self.con.commit() if path.is_dir():
return FSTreeNote(self, path=path)
return FSNote(self, path=path)
def _update_cache(self, inode: int, path: Path): def get_raw_note(self, note_id: ObjectId) -> Note:
try: p = self.root / str(note_id)
rel_path = path.relative_to(self.root) if not p.exists():
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 == self._root_inode:
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_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 FSNote(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:
raise NoteNotFound(note_id) raise NoteNotFound(note_id)
return self.get_raw_note_by_path(p) return self.get_raw_note_by_path(p)
def create_blob(self, data: bytes | None = None) -> Note: def create_blob(self, data: bytes | None = None) -> Note:
fd, temp_path = tempfile.mkstemp(dir=self.working) raise NotImplementedError("FSTrove does not support blobs")
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 FSNote(self, inode=inode, path=p)
def get_root(self) -> TreeNote: def get_root(self) -> TreeNote:
return FSTreeNote(self, inode=self._root_inode, path=self.root) return FSTreeNote(self, path=self.root)
def _get_metadata(self, inode: int, key: str) -> Optional[bytes]: 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() raise NotImplementedError("FSTrove does not support metadata")
return row[0] if row else None
def _set_metadata(self, inode: int, key: str, value: bytes): 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)) raise NotImplementedError("FSTrove does not support metadata")
self.con.commit()
def close(self): def close(self):
self.con.close() pass
def __enter__(self):
return self
def __exit__(self, *args):
self.close()

View file

@ -20,7 +20,9 @@ import pyfuse3
import trio import trio
from pyfuse3 import InodeT, FileHandleT from pyfuse3 import InodeT, FileHandleT
from trovedb.trove import Trove, Note, Tree as TroveTree, TreeNote, Blob as TroveBlob, ObjectId, TreeExists from trovedb.trove import Trove, Note, Tree as TroveTree, TreeNote, ObjectId, TreeExists
import trovedb.trove as tr
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -140,7 +142,7 @@ class TroveFuseOps(pyfuse3.Operations):
raise pyfuse3.FUSEError(errno.ENOTDIR) raise pyfuse3.FUSEError(errno.ENOTDIR)
try: try:
note = parent.child(name.decode()) note = parent.child(name.decode())
except KeyError: except (KeyError, tr.ErrorNotFound):
logger.debug("lookup failed: %d -> %s", parent_inode, name.decode()) logger.debug("lookup failed: %d -> %s", parent_inode, name.decode())
raise pyfuse3.FUSEError(errno.ENOENT) from None raise pyfuse3.FUSEError(errno.ENOENT) from None
ent = self._create_get_ent_from_note(note) ent = self._create_get_ent_from_note(note)
@ -184,8 +186,8 @@ class TroveFuseOps(pyfuse3.Operations):
# Determine basic information # Determine basic information
is_tree = True is_tree = True
size = 0 size = 0
if isinstance(note, TroveBlob): if not hasattr(note, 'mkdir'):
size = len(note.read()) size = len(note.read_content())
is_tree = False is_tree = False
# Create and fill attr structure # Create and fill attr structure
@ -239,13 +241,13 @@ class TroveFuseOps(pyfuse3.Operations):
ent = self._get_ent_from_inode(inode) ent = self._get_ent_from_inode(inode)
note = self._get_ent_note(ent) note = self._get_ent_note(ent)
if fields.update_size: if fields.update_size:
if isinstance(note, TroveBlob): if not hasattr(note, 'mkdir'):
current = note.read() current = note.read_content()
new_size = attr.st_size new_size = attr.st_size
if new_size < len(current): if new_size < len(current):
note.write(current[:new_size]) note.write_content(current[:new_size])
elif new_size > len(current): elif new_size > len(current):
note.write(current + b"\x00" * (new_size - len(current))) note.write_content(current + b"\x00" * (new_size - len(current)))
else: else:
raise pyfuse3.FUSEError(errno.EINVAL) raise pyfuse3.FUSEError(errno.EINVAL)
return self._get_attr(ent, note) return self._get_attr(ent, note)
@ -278,13 +280,13 @@ class TroveFuseOps(pyfuse3.Operations):
if not isinstance(note, TroveTree): if not isinstance(note, TroveTree):
logger.debug("attempted readdir on %d not a tree", fh) logger.debug("attempted readdir on %d not a tree", fh)
raise pyfuse3.FUSEError(errno.ENOTDIR) raise pyfuse3.FUSEError(errno.ENOTDIR)
entries = list(note.list().items()) # [(name, object_id), ...] entries = list(note.entries()) # [(name, object_id), ...]
for idx, (name, child_id) in enumerate(entries): for idx, entry in enumerate(entries):
if idx < start_id: if idx < start_id:
continue continue
child = self._trove.get_raw_note(child_id) child = self._trove.get_raw_note(entry.object_id)
if child is None: if child is None:
continue continue
@ -292,7 +294,7 @@ class TroveFuseOps(pyfuse3.Operations):
attr = self._get_attr(child_ent, child) attr = self._get_attr(child_ent, child)
self._ref_entry(child_ent) self._ref_entry(child_ent)
if not pyfuse3.readdir_reply(token, name.encode(), attr, idx + 1): if not pyfuse3.readdir_reply(token, entry.name.encode(), attr, idx + 1):
break break
async def releasedir(self, fh: FileHandleT) -> None: async def releasedir(self, fh: FileHandleT) -> None:
@ -302,19 +304,18 @@ class TroveFuseOps(pyfuse3.Operations):
async def mkdir(self, parent_inode: InodeT, name: bytes, mode: int, ctx) -> pyfuse3.EntryAttributes: async def mkdir(self, parent_inode: InodeT, name: bytes, mode: int, ctx) -> pyfuse3.EntryAttributes:
logger.debug("mkdir inode:%d name:%s", parent_inode, name) logger.debug("mkdir inode:%d name:%s", parent_inode, name)
# Grab parent note, verify is tree
parent = self._get_inode_note(parent_inode) parent = self._get_inode_note(parent_inode)
if not isinstance(parent, TreeNote): # TODO: consider implications here, maybe look at ext on dir for mime?
raise pyfuse3.FUSEError(errno.ENOTDIR)
# Create new directory in note
try: try:
new_tree: TreeNote = parent.mkdir(name.decode()) note = tr.new_child(parent, name.decode(), mime='inode/directory')
except TreeExists: except tr.ErrorWithErrno as e:
raise pyfuse3.FUSEError(errno.EEXIST) from None raise pyfuse3.FUSEError(e.errno) from None
# Grab entity for kernel # Grab entity for kernel
ent = self._create_get_ent_from_note(new_tree) ent = self._create_get_ent_from_note(note)
self._ref_entry(ent) self._ref_entry(ent)
return self._get_attr(ent, new_tree) return self._get_attr(ent, note)
async def rmdir(self, parent_inode: InodeT, name: bytes, ctx) -> None: async def rmdir(self, parent_inode: InodeT, name: bytes, ctx) -> None:
logger.debug("rmdir inode:%d name:%s", parent_inode, name) logger.debug("rmdir inode:%d name:%s", parent_inode, name)
@ -341,37 +342,35 @@ class TroveFuseOps(pyfuse3.Operations):
async def create(self, parent_inode: InodeT, name: bytes, mode: int, flags, ctx) -> tuple: async def create(self, parent_inode: InodeT, name: bytes, mode: int, flags, ctx) -> tuple:
logger.debug("create inode:%d name:%s", parent_inode, name) logger.debug("create inode:%d name:%s", parent_inode, name)
parent = self._get_inode_note(parent_inode) parent = self._get_inode_note(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)
ent = self._create_get_ent_from_note(blob) # TODO: handle mode
# TODO: handle flags
name_str = name.decode()
note = tr.new_child(parent, name_str)
ent = self._create_get_ent_from_note(note)
self._ref_entry(ent) self._ref_entry(ent)
handle = self._open_handle(ent.sys_inode) handle = self._open_handle(ent.sys_inode)
attr = self._get_attr(ent, blob) attr = self._get_attr(ent, note)
return pyfuse3.FileInfo(fh=handle.handle_id), attr return pyfuse3.FileInfo(fh=handle.handle_id), attr
async def read(self, fh: FileHandleT, offset: int, length: int) -> bytes: async def read(self, fh: FileHandleT, offset: int, length: int) -> bytes:
logger.debug("read fh:%d offset:%d length:%d", fh, offset, length) logger.debug("read fh:%d offset:%d length:%d", fh, offset, length)
handle = self._get_handle(fh) handle = self._get_handle(fh)
note = handle.note note = handle.note
if isinstance(note, TroveBlob): if not hasattr(note, 'mkdir'):
return note.read()[offset:offset + length] return note.read_content()[offset:offset + length]
raise pyfuse3.FUSEError(errno.EBADF) raise pyfuse3.FUSEError(errno.EBADF)
async def write(self, fh: FileHandleT, offset: int, data: bytes) -> int: async def write(self, fh: FileHandleT, offset: int, data: bytes) -> int:
handle = self._get_handle(fh) handle = self._get_handle(fh)
note = handle.note note = handle.note
if isinstance(note, TroveBlob): if not hasattr(note, 'mkdir'):
existing = note.read() existing = note.read_content()
if offset > len(existing): if offset > len(existing):
existing = existing + b"\x00" * (offset - len(existing)) existing = existing + b"\x00" * (offset - len(existing))
note.write(existing[:offset] + data + existing[offset + len(data):]) note.write_content(existing[:offset] + data + existing[offset + len(data):])
return len(data) return len(data)
async def release(self, fh: FileHandleT) -> None: async def release(self, fh: FileHandleT) -> None:
@ -380,12 +379,8 @@ class TroveFuseOps(pyfuse3.Operations):
async def unlink(self, parent_inode: InodeT, name: bytes, ctx) -> None: async def unlink(self, parent_inode: InodeT, name: bytes, ctx) -> None:
parent_note = self._get_inode_note(parent_inode) parent_note = self._get_inode_note(parent_inode)
if not isinstance(parent_note, TroveTree):
raise pyfuse3.FUSEError(errno.ENOTDIR)
name_str = name.decode() name_str = name.decode()
if name_str not in parent_note.list(): parent_note.rm_child(name_str, False)
raise pyfuse3.FUSEError(errno.ENOENT)
parent_note.unlink(name.decode())
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

View file

@ -1,14 +1,39 @@
from typing import Protocol, runtime_checkable, Optional, Dict, List, Self, NamedTuple, Iterable, TypedDict from typing import Protocol, runtime_checkable, Optional, Dict, List, Self, NamedTuple, Iterable, TypedDict, Iterator
from uuid import UUID from uuid import UUID
from pathlib import PurePosixPath
import datetime as dt import datetime as dt
import errno
type ObjectId = int | str | UUID type ObjectId = int | str | UUID
class TroveError(Exception): class TroveError(Exception):
"""Base class for all Trove errors.""" """Base class for all Trove errors."""
class ErrorWithErrno(TroveError):
"""Raised when an error occurs with an errno."""
def __init__(self, error: int, *args):
super().__init__(*args)
self.errno = error
class ErrorExists(ErrorWithErrno):
"""Raised when a note already exists."""
def __init__(self, *args):
super().__init__(errno.EEXIST, *args)
class ErrorNotFound(ErrorWithErrno):
"""Raised when a note is not found."""
def __init__(self, *args):
super().__init__(errno.ENOENT, *args)
class ErrorNotEmpty(ErrorWithErrno):
"""Raised when a directory is not empty."""
def __init__(self, *args):
super().__init__(errno.ENOTEMPTY, *args)
class BadNoteType(TypeError): class BadNoteType(TypeError):
"""Raised when an invalid note type is encountered.""" """Raised when an invalid note type is encountered."""
@ -21,6 +46,12 @@ class NoteNotFound(KeyError):
class OpenArguments(TypedDict): class OpenArguments(TypedDict):
create: bool create: bool
class TreeEntry(NamedTuple):
name: str
object_id: ObjectId
DEFAULT_MIME = "application/octet-stream"
@runtime_checkable @runtime_checkable
class Note(Protocol): class Note(Protocol):
""" """
@ -64,10 +95,30 @@ class Note(Protocol):
"""Write the raw content of the note.""" """Write the raw content of the note."""
... ...
def children(self) -> Iterator[TreeEntry]:
"""Get all children of this note."""
...
def child(self, name: str) -> 'Note':
"""Retrieve a child note by name."""
...
def new_child(self, name: str, mime: str, content: bytes | None, executable: bool, hidden: bool) -> 'Note':
"""Create a new child note."""
...
def rm_child(self, name: str, recurse: bool):
"""Remove a child note."""
...
def has_children(self) -> bool:
"""Check if note has children."""
return next(self.children(), None) is not None
def new_child(note: Note, name: str, mime: str = DEFAULT_MIME, content: bytes | None = None, executable: bool = False, hidden: bool = False) -> Note:
return note.new_child(name=name, mime=mime, content=content, executable=executable, hidden=hidden)
class TreeEntry(NamedTuple):
name: str
object_id: ObjectId
@runtime_checkable @runtime_checkable
class Tree(Protocol): class Tree(Protocol):
@ -87,17 +138,10 @@ class Tree(Protocol):
"""Remove a directory from the tree.""" """Remove a directory from the tree."""
... ...
def child(self, name: str) -> Note:
"""Retrieve a child note by name."""
...
def entries(self) -> Iterable[TreeEntry]: def entries(self) -> Iterable[TreeEntry]:
"""Return all entries in the directory""" """Return all entries in the directory"""
... ...
def list(self) -> dict[str, int]:
"""Return all entries as {name: object_id}."""
...
@runtime_checkable @runtime_checkable
class TreeNote(Note, Tree, Protocol): class TreeNote(Note, Tree, Protocol):

View file

@ -5,9 +5,10 @@ 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 from typing import Optional, Iterator
from pathlib import Path from pathlib import Path
import datetime as dt import datetime as dt
import uuid
from .db import Sqlite3Trove, NOTE_ROOT_ID from .db import Sqlite3Trove, NOTE_ROOT_ID
@ -20,9 +21,19 @@ class NoteImpl(Note):
"""Concrete note implementation.""" """Concrete note implementation."""
def __init__(self, parent: 'TroveImpl', object_id: ObjectId): def __init__(self, parent: 'TroveImpl', object_id: ObjectId):
if not isinstance(object_id, uuid.UUID):
object_id = uuid.UUID(str(object_id))
assert isinstance(object_id, uuid.UUID)
self._parent = parent self._parent = parent
self._db = parent.db self._db = parent.db
self._object_id = object_id self._object_id: uuid.UUID = object_id
@staticmethod
def get_impl_id(note: Note) -> uuid.UUID:
if not isinstance(note.object_id, uuid.UUID):
raise TypeError("Note not compatible with NoteImpl")
return note.object_id
# Note protocol # Note protocol
@property @property
@ -36,7 +47,8 @@ class NoteImpl(Note):
@property @property
def mtime(self) -> dt.datetime: def mtime(self) -> dt.datetime:
"""Return modification time as UTC datetime.""" """Return modification time as UTC datetime."""
return self._db.get_mtime(self._object_id) mtime = self._db.get_mtime(self._object_id)
return mtime if mtime is not None else dt.datetime.now(tz=dt.timezone.utc)
@property @property
def mime(self) -> str: def mime(self) -> str:
@ -57,6 +69,39 @@ class NoteImpl(Note):
def write_content(self, data: bytes) -> None: def write_content(self, data: bytes) -> None:
self._db.write_content(self._object_id, data) self._db.write_content(self._object_id, data)
def children(self) -> Iterator[TreeEntry]:
"""Get all children of this note."""
for name, object_id in self._db.list_tree(self._object_id).items():
yield TreeEntry(name, object_id)
def new_child(self, name: str, mime: str, content: bytes | None, executable: bool, hidden: bool) -> Note:
"""Create a new child note."""
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)
# TODO fix this
if mime == 'inode/directory':
return TreeNoteImpl(self._parent, object_id)
return NoteImpl(self._parent, object_id)
def child(self, name: str) -> Note:
"""Retrieve a child note by name."""
entries = self._db.list_tree(self._object_id)
if name not in entries:
raise tr.ErrorNotFound(name)
child_id = entries[name]
value = self._parent.get_raw_note(child_id)
if value is None:
raise tr.ErrorNotFound("dangling child link") # FIXME: better errors
return value
def rm_child(self, name: str, recurse: bool) -> None:
"""Remove a child note."""
note = self.child(name)
if note.has_children() and not recurse:
raise tr.ErrorNotEmpty(name)
self._db.unlink(self._object_id, name)
class TreeNoteImpl(NoteImpl, TreeNote): class TreeNoteImpl(NoteImpl, TreeNote):
"""Concrete TreeNote: a tree object backed by the tree_entries table.""" """Concrete TreeNote: a tree object backed by the tree_entries table."""
@ -64,7 +109,7 @@ class TreeNoteImpl(NoteImpl, TreeNote):
# Tree protocol # Tree protocol
def link(self, name: str, note: Note) -> None: def link(self, name: str, note: Note) -> None:
"""Link name to an existing note.""" """Link name to an existing note."""
self._db.link(self._object_id, name, note.object_id) self._db.link(self._object_id, name, NoteImpl.get_impl_id(note))
def unlink(self, name: str) -> None: def unlink(self, name: str) -> None:
"""Remove an entry by name.""" """Remove an entry by name."""
@ -81,25 +126,13 @@ class TreeNoteImpl(NoteImpl, TreeNote):
"""Remove a directory from the tree.""" """Remove a directory from the tree."""
self.unlink(name) self.unlink(name)
def child(self, name: str) -> Note:
"""Retrieve a child note by name."""
entries = self._db.list_tree(self._object_id)
if name not in entries:
raise KeyError(f"Entry '{name}' not found")
child_id = entries[name]
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): def entries(self):
"""Return all entries as an iterable of TreeEntry.""" """Return all entries as an iterable of TreeEntry."""
for name, object_id in self._db.list_tree(self._object_id).items(): for name, object_id in self._db.list_tree(self._object_id).items():
yield TreeEntry(name, object_id) yield TreeEntry(name, object_id)
def list(self) -> dict[str, ObjectId]:
"""Return all entries as {name: object_id}."""
return self._db.list_tree(self._object_id)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -137,6 +170,8 @@ class TroveImpl:
# Trove protocol # Trove protocol
def get_raw_note(self, note_id: ObjectId) -> Note: def get_raw_note(self, note_id: ObjectId) -> Note:
"""Return a BlobNote or TreeNote for the given id, or None if not found.""" """Return a BlobNote or TreeNote for the given id, or None if not found."""
if not isinstance(note_id, uuid.UUID):
note_id = uuid.UUID(str(note_id))
info = self._db.get_info(note_id) info = self._db.get_info(note_id)
if info is None: if info is None:
raise NoteNotFound(note_id) raise NoteNotFound(note_id)