trove/trovedb/fs.py
Andrew Mulbrook 41480a39c9 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.
2026-03-27 12:44:35 -05:00

214 lines
7.1 KiB
Python

import os
import sqlite3
import tempfile
import datetime as dt
from pathlib import Path, PurePosixPath
from typing import Optional, Dict, List, Self, Iterable, Iterator
from .trove import Note, Trove, TreeNote, BadNoteType, TreeEntry, NoteNotFound, ObjectId
from . import fs_util as fsu
from . import trove as tr
class FSNote(Note):
def __init__(self, trove: 'FSTrove', path: Path):
self._trove: FSTrove = trove
self._fs_path: Path = path.resolve()
if not self._fs_path.is_relative_to(trove.root):
raise ValueError("Path must be relative to the root directory")
self._object_id: str = path.relative_to(trove.root).as_posix()
@property
def object_id(self) -> tr.ObjectId:
return self._object_id
@property
def fs_path(self) -> Path:
return self._fs_path
@property
def mtime(self):
"""Return modification time as datetime."""
stat = self._fs_path.stat()
return dt.datetime.fromtimestamp(stat.st_mtime, tz=dt.timezone.utc)
@property
def readonly(self) -> bool:
"""Check if the note is readonly based on file permissions."""
return not os.access(self._fs_path, os.W_OK)
@property
def mime(self) -> str:
"""Return MIME type, defaulting to generic binary stream."""
if self._fs_path.is_dir():
return "inode/directory"
return "application/octet-stream"
def get_raw_metadata(self, key: str) -> Optional[bytes]:
# TODO: FIXME
return None
def set_raw_metadata(self, key: str, value: bytes) -> None:
# TODO: FIXME
pass
def read_content(self) -> bytes:
"""Read the raw content of the note."""
if self._fs_path.is_file():
return self._fs_path.read_bytes()
return b""
def write_content(self, data:bytes) -> None:
"""Write the raw content of the note."""
self._fs_path.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):
def link(self, name: str, note: Note):
if not isinstance(note, FSNote):
raise BadNoteType("Only blob notes can be linked")
target_path = self._fs_path / name
if target_path.exists():
self.unlink(name)
note_path = note._fs_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)
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)
def unlink(self, name: str):
target_path = self._fs_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._fs_path / name
target_path.mkdir(exist_ok=True)
return FSTreeNote(self._trove, path=target_path)
def entries(self) -> Iterable[TreeEntry]:
try:
for item in self._fs_path.iterdir():
if item.name == ".trove":
continue
yield TreeEntry(name=item.name, object_id=str(item))
except OSError:
pass
class FSTrove(Trove):
def __init__(self, root: str | Path):
self.root = Path(root).absolute()
self._root_inode = self.root.stat().st_ino
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)
@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 get_raw_note_by_path(self, path: Path) -> Note:
if not path.exists():
raise tr.ErrorNotFound(str(path))
if path.is_dir():
return FSTreeNote(self, path=path)
return FSNote(self, path=path)
def get_raw_note(self, note_id: ObjectId) -> Note:
p = self.root / str(note_id)
if not p.exists():
raise NoteNotFound(note_id)
return self.get_raw_note_by_path(p)
def create_blob(self, data: bytes | None = None) -> Note:
raise NotImplementedError("FSTrove does not support blobs")
def get_root(self) -> TreeNote:
return FSTreeNote(self, path=self.root)
def _get_metadata(self, inode: int, key: str) -> Optional[bytes]:
raise NotImplementedError("FSTrove does not support metadata")
def _set_metadata(self, inode: int, key: str, value: bytes):
raise NotImplementedError("FSTrove does not support metadata")
def close(self):
pass