Add database and FUSE mapping
This commit is contained in:
parent
96c9e62354
commit
2cfe32b333
5 changed files with 425 additions and 3 deletions
|
|
@ -86,6 +86,13 @@ class Sqlite3Trove:
|
||||||
# CRUD operations
|
# CRUD operations
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_object_type(self, object_id: int) -> str | None:
|
||||||
|
"""Return the type column for an object, or None if not found."""
|
||||||
|
row = self._con.execute(
|
||||||
|
"SELECT type FROM objects WHERE id = ?", (object_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row["type"] if row else None
|
||||||
|
|
||||||
def read_object(self, object_id: int) -> bytes | None:
|
def read_object(self, object_id: int) -> bytes | None:
|
||||||
"""Return raw data for a blob object, or None if not found."""
|
"""Return raw data for a blob object, or None if not found."""
|
||||||
row = self._con.execute(
|
row = self._con.execute(
|
||||||
|
|
@ -104,6 +111,14 @@ class Sqlite3Trove:
|
||||||
return None
|
return None
|
||||||
return bytes(row["value"]) if row["value"] is not None else b""
|
return bytes(row["value"]) if row["value"] is not None else b""
|
||||||
|
|
||||||
|
def write_metadata(self, object_id: int, key: str, value: bytes) -> None:
|
||||||
|
"""Upsert a metadata row. db.py has no write_metadata, so we go direct."""
|
||||||
|
self._con.execute(
|
||||||
|
"INSERT OR REPLACE INTO metadata (id, key, value) VALUES (?, ?, ?)",
|
||||||
|
(object_id, key, value),
|
||||||
|
)
|
||||||
|
self._con.commit()
|
||||||
|
|
||||||
def _write_object(self, data: bytes, dtype: str, object_id: int | None = None) -> int:
|
def _write_object(self, data: bytes, dtype: str, object_id: int | None = None) -> int:
|
||||||
"""
|
"""
|
||||||
Insert or replace an object. Returns the id.
|
Insert or replace an object. Returns the id.
|
||||||
|
|
@ -117,6 +132,7 @@ class Sqlite3Trove:
|
||||||
(dtype, data, modified)
|
(dtype, data, modified)
|
||||||
)
|
)
|
||||||
self._con.commit()
|
self._con.commit()
|
||||||
|
assert cur.lastrowid is not None
|
||||||
return cur.lastrowid
|
return cur.lastrowid
|
||||||
else:
|
else:
|
||||||
self._con.execute(
|
self._con.execute(
|
||||||
|
|
@ -126,12 +142,16 @@ class Sqlite3Trove:
|
||||||
self._con.commit()
|
self._con.commit()
|
||||||
return object_id
|
return object_id
|
||||||
|
|
||||||
def write_blob(self, data: bytes, existing_id: int | None = None) -> int:
|
def write_blob(self, data: bytes, object_id: int | None = None) -> int:
|
||||||
"""
|
"""
|
||||||
Insert or replace a blob. Returns the id.
|
Insert or replace a blob. Returns the id.
|
||||||
Pass existing_id to update an existing object.
|
Pass object_id to update an existing object.
|
||||||
"""
|
"""
|
||||||
return self._write_object(data, "blob", existing_id)
|
return self._write_object(data, "blob", object_id)
|
||||||
|
|
||||||
|
def write_tree(self, data: bytes, object_id: int | None = None) -> int:
|
||||||
|
"""Write a tree-typed object. Returns the assigned id."""
|
||||||
|
return self._write_object(data, "tree", object_id)
|
||||||
|
|
||||||
def delete_object(self, object_id: int) -> bool:
|
def delete_object(self, object_id: int) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
14
trovedb/fuse/__main__.py
Normal file
14
trovedb/fuse/__main__.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from . import server
|
||||||
|
from trovedb import trovedb
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = ArgumentParser()
|
||||||
|
parser.add_argument("db", help="Path to the database file")
|
||||||
|
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)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
240
trovedb/fuse/server.py
Normal file
240
trovedb/fuse/server.py
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
"""
|
||||||
|
fuse.py — FUSE filesystem layer for Trove, backed by pyfuse3 + trio.
|
||||||
|
|
||||||
|
Blob objects are exposed as regular files.
|
||||||
|
Tree objects are exposed as directories.
|
||||||
|
|
||||||
|
Inode numbers map 1:1 to Trove object IDs (NODE_ROOT_ID == pyfuse3 root inode == 1).
|
||||||
|
|
||||||
|
Entry point: serve(trove, mountpoint)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import errno
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pyfuse3
|
||||||
|
import trio
|
||||||
|
|
||||||
|
from trovedb.trove import Trove, Note, Tree as TroveTree, TreeNote, Blob as TroveBlob
|
||||||
|
|
||||||
|
|
||||||
|
class TroveFuseOps(pyfuse3.Operations):
|
||||||
|
|
||||||
|
enable_writeback_cache = False
|
||||||
|
|
||||||
|
def __init__(self, trove: Trove):
|
||||||
|
super().__init__()
|
||||||
|
self._trove = trove
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _note_or_error(self, inode: int):
|
||||||
|
note = self._trove.get_raw_note(inode)
|
||||||
|
if note is None:
|
||||||
|
raise pyfuse3.FUSEError(errno.ENOENT)
|
||||||
|
return note
|
||||||
|
|
||||||
|
def _make_attr(self, inode: int, is_tree: bool, size: int = 0) -> pyfuse3.EntryAttributes:
|
||||||
|
attr = pyfuse3.EntryAttributes()
|
||||||
|
attr.st_ino = pyfuse3.InodeT(inode)
|
||||||
|
attr.st_nlink = 1
|
||||||
|
attr.st_uid = os.getuid()
|
||||||
|
attr.st_gid = os.getgid()
|
||||||
|
now_ns = int(time.time() * 1e9)
|
||||||
|
attr.st_atime_ns = now_ns
|
||||||
|
attr.st_mtime_ns = now_ns
|
||||||
|
attr.st_ctime_ns = now_ns
|
||||||
|
attr.generation = 0
|
||||||
|
attr.entry_timeout = 5.0
|
||||||
|
attr.attr_timeout = 5.0
|
||||||
|
if is_tree:
|
||||||
|
attr.st_mode = stat.S_IFDIR | 0o755
|
||||||
|
attr.st_size = 0
|
||||||
|
attr.st_blksize = 512
|
||||||
|
attr.st_blocks = 0
|
||||||
|
else:
|
||||||
|
attr.st_mode = stat.S_IFREG | 0o644
|
||||||
|
attr.st_size = size
|
||||||
|
attr.st_blksize = 512
|
||||||
|
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 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 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 forget(self, inode_list) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Directory ops
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def opendir(self, inode: int, ctx) -> pyfuse3.FileHandleT:
|
||||||
|
note = self._note_or_error(inode)
|
||||||
|
if not isinstance(note, TroveTree):
|
||||||
|
raise pyfuse3.FUSEError(errno.ENOTDIR)
|
||||||
|
return pyfuse3.FileHandleT(inode)
|
||||||
|
|
||||||
|
async def readdir(self, fh: int, start_id: int, token) -> None:
|
||||||
|
note = self._note_or_error(fh)
|
||||||
|
if not isinstance(note, TroveTree):
|
||||||
|
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)
|
||||||
|
if not pyfuse3.readdir_reply(token, name.encode(), attr, idx + 1):
|
||||||
|
break
|
||||||
|
|
||||||
|
async def releasedir(self, fh: int) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def mkdir(self, parent_inode: int, name: bytes, mode: int, ctx) -> pyfuse3.EntryAttributes:
|
||||||
|
parent = self._note_or_error(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)
|
||||||
|
|
||||||
|
async def rmdir(self, parent_inode: int, name: bytes, ctx) -> None:
|
||||||
|
parent = self._note_or_error(parent_inode)
|
||||||
|
if not isinstance(parent, TroveTree):
|
||||||
|
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)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 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 create(self, parent_inode: int, name: bytes, mode: int, flags, ctx) -> tuple:
|
||||||
|
parent = self._note_or_error(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)
|
||||||
|
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]
|
||||||
|
|
||||||
|
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):])
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
async def release(self, fh: int) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def unlink(self, parent_inode: int, name: bytes, ctx) -> None:
|
||||||
|
parent = self._note_or_error(parent_inode)
|
||||||
|
if not isinstance(parent, TroveTree):
|
||||||
|
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 isinstance(child, TroveTree):
|
||||||
|
raise pyfuse3.FUSEError(errno.EISDIR)
|
||||||
|
parent.unlink(name_str)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Serve
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _run(ops: TroveFuseOps, mountpoint: str) -> None:
|
||||||
|
options = set(pyfuse3.default_options)
|
||||||
|
options.add("fsname=trove")
|
||||||
|
pyfuse3.init(ops, mountpoint, options)
|
||||||
|
try:
|
||||||
|
await pyfuse3.main()
|
||||||
|
finally:
|
||||||
|
pyfuse3.close()
|
||||||
|
|
||||||
|
|
||||||
|
def serve(trove: Trove, mountpoint: str) -> None:
|
||||||
|
"""
|
||||||
|
Mount a Trove store at mountpoint and serve until KeyboardInterrupt.
|
||||||
|
Runs a trio event loop internally.
|
||||||
|
"""
|
||||||
|
ops = TroveFuseOps(trove)
|
||||||
|
try:
|
||||||
|
trio.run(_run, ops, mountpoint)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
@ -47,6 +47,10 @@ class Tree(Protocol):
|
||||||
"""Create a new Tree with the given name."""
|
"""Create a new Tree with the given name."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def list(self) -> dict[str, int]:
|
||||||
|
"""Return all entries as {name: object_id}."""
|
||||||
|
...
|
||||||
|
|
||||||
class BlobNote(Note, Blob):
|
class BlobNote(Note, Blob):
|
||||||
"""Blob Note"""
|
"""Blob Note"""
|
||||||
|
|
||||||
|
|
|
||||||
144
trovedb/trovedb.py
Normal file
144
trovedb/trovedb.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
"""
|
||||||
|
trovedb.py — Concrete implementation of Trove protocols backed by Sqlite3Trove.
|
||||||
|
|
||||||
|
Implements BlobNote, TreeNote, and Trove protocols defined in trove.py.
|
||||||
|
Depends on db.py (Sqlite3Trove) and tree.py (Tree) for storage and
|
||||||
|
tree serialization respectively.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, Self
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .db import Sqlite3Trove
|
||||||
|
from .tree import Tree as TreeData
|
||||||
|
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
|
||||||
|
self._object_id = object_id
|
||||||
|
|
||||||
|
# Note protocol
|
||||||
|
@property
|
||||||
|
def object_id(self) -> int:
|
||||||
|
return self._object_id
|
||||||
|
|
||||||
|
def get_raw_metadata(self, key: str) -> Optional[bytes]:
|
||||||
|
return self._db.read_metadata(self._object_id, key)
|
||||||
|
|
||||||
|
def set_raw_metadata(self, key: str, value: bytes) -> None:
|
||||||
|
self._db.write_metadata(self._object_id, key, value)
|
||||||
|
|
||||||
|
|
||||||
|
class BlobNoteImpl(NoteImpl, BlobNote):
|
||||||
|
"""Concrete BlobNote: a blob object in the store with metadata access."""
|
||||||
|
|
||||||
|
# Blob protocol
|
||||||
|
def read(self) -> bytes:
|
||||||
|
data = self._db.read_object(self._object_id)
|
||||||
|
return data if data is not None else b""
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> None:
|
||||||
|
self._db.write_blob(data, self._object_id)
|
||||||
|
|
||||||
|
|
||||||
|
class TreeNoteImpl(NoteImpl, TreeNote):
|
||||||
|
"""Concrete TreeNote: a tree object in the store with metadata access."""
|
||||||
|
|
||||||
|
def _read_tree(self) -> TreeData:
|
||||||
|
data = self._db.read_object(self._object_id)
|
||||||
|
return TreeData(data if data else None)
|
||||||
|
|
||||||
|
def _flush_tree(self, tree: TreeData) -> None:
|
||||||
|
self._db.write_tree(tree.serialize(), self._object_id)
|
||||||
|
|
||||||
|
# Tree protocol
|
||||||
|
def link(self, name: str, note: Note) -> None:
|
||||||
|
"""Link name to an existing note (blob or tree)."""
|
||||||
|
tree = self._read_tree()
|
||||||
|
tree.set_entry(name, note.object_id)
|
||||||
|
self._flush_tree(tree)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def mkdir(self, name: str) -> 'TreeNoteImpl':
|
||||||
|
"""Create a new empty tree, link it under name, and return it."""
|
||||||
|
|
||||||
|
# Create the new node
|
||||||
|
new_id = self._db.write_tree(TreeData().serialize())
|
||||||
|
tree = TreeNoteImpl(self._db, new_id)
|
||||||
|
|
||||||
|
# Update our node
|
||||||
|
self.link(name, tree)
|
||||||
|
return tree
|
||||||
|
|
||||||
|
def list(self) -> dict[str, int]:
|
||||||
|
"""Return all entries as {name: object_id}."""
|
||||||
|
return self._read_tree().list()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Trove
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TroveImpl:
|
||||||
|
"""
|
||||||
|
Concrete Trove: top-level API backed by a Sqlite3Trove database.
|
||||||
|
|
||||||
|
Use TroveImpl.open() to get an instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: Sqlite3Trove):
|
||||||
|
self._db = db
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def open(cls, path: str | Path, create: bool = False) -> "TroveImpl":
|
||||||
|
db = Sqlite3Trove.open(path, create=create)
|
||||||
|
trove = cls(db)
|
||||||
|
if create:
|
||||||
|
# Root was written as a blob by Sqlite3Trove.open(); fix its type.
|
||||||
|
db._con.execute(
|
||||||
|
"UPDATE objects SET type = 'tree' WHERE id = ?", (NODE_ROOT_ID,)
|
||||||
|
)
|
||||||
|
db._con.commit()
|
||||||
|
return trove
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._db.close()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
# Trove protocol
|
||||||
|
def get_raw_note(self, note_id: int) -> Optional[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
|
||||||
|
if ot == "blob":
|
||||||
|
return BlobNoteImpl(self._db, note_id)
|
||||||
|
if ot == "tree":
|
||||||
|
return TreeNoteImpl(self._db, 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)
|
||||||
|
|
||||||
|
def get_root(self) -> TreeNote:
|
||||||
|
"""Return the root TreeNote (always id=NODE_ROOT_ID)."""
|
||||||
|
return TreeNoteImpl(self._db, 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