From 96c9e62354cec997d692b213a16a9dd7f5493ca4 Mon Sep 17 00:00:00 2001 From: Andrew Mulbrook Date: Mon, 16 Mar 2026 01:14:07 -0500 Subject: [PATCH] Brainstorm initial API and modify db to be more FUSE friendly --- trovedb/db.py | 199 +++++++++++++++++++++++------------------------ trovedb/tree.py | 14 ++-- trovedb/trove.py | 75 ++++++++++++++++++ 3 files changed, 183 insertions(+), 105 deletions(-) create mode 100644 trovedb/trove.py diff --git a/trovedb/db.py b/trovedb/db.py index 62e5508..665e354 100644 --- a/trovedb/db.py +++ b/trovedb/db.py @@ -3,58 +3,49 @@ Python API for accessing underlying SQlite3 File Database A Sqlite3 database provides the basic underlying interface to the underlying notes interface. The low level database is an extremely simple model using -similar ideas to get storage. However, UUID are utilized for persistent object -references. +similar ideas to git storage. """ import argparse import sqlite3 import sys -import uuid as _uuid from datetime import datetime, timezone from pathlib import Path - +from .trove import NODE_ROOT_ID _SCHEMA = """ CREATE TABLE IF NOT EXISTS objects ( - uuid TEXT PRIMARY KEY, + id INTEGER PRIMARY KEY, type TEXT NOT NULL CHECK(type IN ('blob', 'tree')), data BLOB, - created TEXT NOT NULL, modified TEXT NOT NULL ); - + CREATE TABLE IF NOT EXISTS metadata ( - uuid TEXT NOT NULL REFERENCES objects(uuid) ON DELETE CASCADE, + id INTEGER NOT NULL REFERENCES objects(id) ON DELETE CASCADE, key TEXT NOT NULL, value BLOB, - PRIMARY KEY (uuid, key) + PRIMARY KEY (id, key) ); CREATE TABLE IF NOT EXISTS labels ( - label - TEXT - PRIMARY - KEY, - uuid - TEXT - NOT - NULL - REFERENCES - objects -( - uuid -) ON DELETE CASCADE - ); \ - """ + label TEXT PRIMARY KEY, + id INTEGER NOT NULL REFERENCES objects(id) ON DELETE CASCADE +); +""" def _now() -> str: return datetime.now(timezone.utc).isoformat() -class TroveDB: +def _initialize_db(con: sqlite3.Connection): + con.executescript(_SCHEMA) + con.commit() + + +class Sqlite3Trove: def __init__(self, con: sqlite3.Connection): self._con = con self._con.execute("PRAGMA foreign_keys = ON") @@ -65,24 +56,22 @@ class TroveDB: # ------------------------------------------------------------------ @classmethod - def init(cls, path: str | Path) -> "TroveDB": - """Create a new Trove database at path. Fails if file already exists.""" - p = Path(path) - if p.exists(): - raise FileExistsError(f"Database already exists: {p}") - con = sqlite3.connect(str(p)) - con.executescript(_SCHEMA) - con.commit() - return cls(con) - - @classmethod - def open(cls, path: str | Path) -> "TroveDB": + def open(cls, path: str | Path, create: bool = False) -> "Sqlite3Trove": """Open an existing Trove database.""" p = Path(path) + initialize = False if not p.exists(): - raise FileNotFoundError(f"Database not found: {p}") + if not create: + raise FileNotFoundError(f"Database not found: {p}") + initialize = True con = sqlite3.connect(str(p)) - return cls(con) + if initialize: + con.executescript(_SCHEMA) + con.commit() + obj = cls(con) + if initialize: + obj.write_blob(b"", NODE_ROOT_ID) + return obj def close(self): self._con.close() @@ -97,77 +86,84 @@ class TroveDB: # CRUD operations # ------------------------------------------------------------------ - def read_blob(self, uuid: str) -> bytes | None: + def read_object(self, object_id: int) -> bytes | None: """Return raw data for a blob object, or None if not found.""" row = self._con.execute( - "SELECT data, type FROM objects WHERE uuid = ?", (uuid,) + "SELECT data, type FROM objects WHERE id = ?", (object_id,) ).fetchone() if row is None: return None - if row["type"] != "blob": - raise TypeError(f"Object {uuid} is type '{row['type']}', not 'blob'") return bytes(row["data"]) if row["data"] is not None else b"" - def read_metadata(self, uuid: str, key: str) -> bytes | None: + def read_metadata(self, object_id: int, key: str) -> bytes | None: """Return raw metadata value for (uuid, key), or None if not found.""" row = self._con.execute( - "SELECT value FROM metadata WHERE uuid = ? AND key = ?", (uuid, key) + "SELECT value FROM metadata WHERE id = ? AND key = ?", (object_id, key) ).fetchone() if row is None: return None return bytes(row["value"]) if row["value"] is not None else b"" - def write_blob(self, data: bytes, existing_uuid: str | None = None) -> str: + def _write_object(self, data: bytes, dtype: str, object_id: int | None = None) -> int: """ - Insert or replace a blob. Returns the uuid. - Pass existing_uuid to update an existing object. + Insert or replace an object. Returns the id. + If object_id is None, creates a new object with auto-assigned ID. + If object_id is provided, updates or creates the object with that ID. """ - now = _now() - uid = existing_uuid or str(_uuid.uuid4()) - if existing_uuid: - self._con.execute( - "UPDATE objects SET data = ?, modified = ? WHERE uuid = ? AND type = 'blob'", - (data, now, uid), + modified = _now() + if object_id is None: + cur = self._con.execute( + "INSERT INTO objects (type, data, modified) VALUES (?, ?, ?)", + (dtype, data, modified) ) + self._con.commit() + return cur.lastrowid else: self._con.execute( - "INSERT INTO objects (uuid, type, data, created, modified) VALUES (?, 'blob', ?, ?, ?)", - (uid, data, now, now), + "INSERT OR REPLACE INTO objects (id, type, data, modified) VALUES (?, ?, ?, ?)", + (object_id, dtype, data, modified) ) - self._con.commit() - return uid + self._con.commit() + return object_id - def delete_blob(self, uuid: str) -> bool: + def write_blob(self, data: bytes, existing_id: int | None = None) -> int: + """ + Insert or replace a blob. Returns the id. + Pass existing_id to update an existing object. + """ + return self._write_object(data, "blob", existing_id) + + def delete_object(self, object_id: int) -> bool: """ Delete a blob and all its metadata rows. - Returns True if an object was deleted, False if uuid not found. + Returns True if an object was deleted, False if id not found. Foreign key cascade handles the metadata rows. """ cur = self._con.execute( - "DELETE FROM objects WHERE uuid = ?", (uuid,) + "DELETE FROM objects WHERE id = ?", (object_id,) ) self._con.commit() return cur.rowcount > 0 - def get_label(self, label: str) -> str | None: + def get_label(self, label: str) -> int | None: """ - Return the UUID associated with a label, or None if not found. + Return the ID associated with a label, or None if not found. """ row = self._con.execute( - "SELECT uuid FROM labels WHERE label = ?", (label,) + "SELECT id FROM labels WHERE label = ?", (label,) ).fetchone() if row is None: return None - return row["uuid"] + return row["id"] - def set_label(self, label: str, uuid: str) -> None: + def set_label(self, label: str, object_id: int) -> None: """ - Set a label to point to a UUID. Creates or updates the label. - The UUID must exist in the objects table. + Set a label to point to an ID. Creates or updates the label. + The ID must exist in the objects table. """ self._con.execute( - "INSERT OR REPLACE INTO labels (label, uuid) VALUES (?, ?)", - (label, uuid), + "INSERT OR REPLACE INTO labels (label, id) VALUES (?, ?)", + (label, object_id), ) self._con.commit() @@ -181,14 +177,14 @@ class TroveDB: self._con.commit() return cur.rowcount > 0 - def list_labels(self) -> list[tuple[str, str]]: + def list_labels(self) -> list[tuple[str, int]]: """ - Return all labels as a list of (label, uuid) tuples. + Return all labels as a list of (label, id) tuples. """ rows = self._con.execute( - "SELECT label, uuid FROM labels ORDER BY label" + "SELECT label, id FROM labels ORDER BY label" ).fetchall() - return [(row["label"], row["uuid"]) for row in rows] + return [(row["label"], row["id"]) for row in rows] def main(): @@ -202,22 +198,22 @@ def main(): subparsers.add_parser("create", help="Create a new database") # Get blob - get_parser = subparsers.add_parser("get", help="Get a blob object by UUID") - get_parser.add_argument("uuid", help="UUID of the blob to retrieve") + get_parser = subparsers.add_parser("get", help="Get a blob object by ID") + get_parser.add_argument("id", type=int, help="ID of the blob to retrieve") # Write blob write_parser = subparsers.add_parser("write", help="Write data to a blob") write_parser.add_argument("data", help="Data to write (as string, will be encoded as UTF-8)") - write_parser.add_argument("--uuid", help="UUID of existing blob to update (optional)") + write_parser.add_argument("--id", type=int, help="ID of existing blob to update (optional)") # Delete blob - delete_parser = subparsers.add_parser("delete", help="Delete a blob by UUID") - delete_parser.add_argument("uuid", help="UUID of the blob to delete") + delete_parser = subparsers.add_parser("delete", help="Delete a blob by ID") + delete_parser.add_argument("id", type=int, help="ID of the blob to delete") # Set label - setlabel_parser = subparsers.add_parser("setlabel", help="Create or update a label to point to a UUID") + setlabel_parser = subparsers.add_parser("setlabel", help="Create or update a label to point to an ID") setlabel_parser.add_argument("label", help="Label name") - setlabel_parser.add_argument("uuid", help="UUID to associate with the label") + setlabel_parser.add_argument("id", type=int, help="ID to associate with the label") # Remove label rmlabel_parser = subparsers.add_parser("rmlabel", help="Delete a label") @@ -231,59 +227,62 @@ def main(): try: match args.operation: case "create": - db = TroveDB.init(args.database) + if Path(args.database).exists(): + print(f"Database already exists: {args.database}", file=sys.stderr) + sys.exit(1) + db = Sqlite3Trove.open(args.database, create=True) db.close() print(f"Database created: {args.database}") case "get": - db = TroveDB.open(args.database) - data = db.read_blob(args.uuid) + db = Sqlite3Trove.open(args.database) + data = db.read_object(args.id) db.close() if data is None: - print(f"Blob not found: {args.uuid}", file=sys.stderr) + print(f"Blob not found: {args.id}") sys.exit(1) sys.stdout.buffer.write(data) case "write": data_bytes = args.data.encode("utf-8") - db = TroveDB.open(args.database) - uuid = db.write_blob(data_bytes, args.uuid) + db = Sqlite3Trove.open(args.database) + object_id = db.write_blob(data_bytes, args.id) db.close() - print(uuid) + print(object_id) case "delete": - db = TroveDB.open(args.database) - deleted = db.delete_blob(args.uuid) + db = Sqlite3Trove.open(args.database) + deleted = db.delete_object(args.id) db.close() if deleted: - print(f"Deleted blob: {args.uuid}") + print(f"Deleted blob: {args.id}") else: - print(f"Blob not found: {args.uuid}", file=sys.stderr) + print(f"Blob not found: {args.id}") sys.exit(1) case "setlabel": - db = TroveDB.open(args.database) - db.set_label(args.label, args.uuid) + db = Sqlite3Trove.open(args.database) + db.set_label(args.label, args.id) db.close() - print(f"Label '{args.label}' set to UUID: {args.uuid}") + print(f"Label '{args.label}' set to ID: {args.id}") case "rmlabel": - db = TroveDB.open(args.database) + db = Sqlite3Trove.open(args.database) deleted = db.delete_label(args.label) db.close() if deleted: print(f"Deleted label: {args.label}") else: - print(f"Label not found: {args.label}", file=sys.stderr) + print(f"Label not found: {args.label}") sys.exit(1) case "labels": - db = TroveDB.open(args.database) + db = Sqlite3Trove.open(args.database) labels = db.list_labels() db.close() if labels: - for label, uuid in labels: - print(f"{label}: {uuid}") + for label, id in labels: + print(f"{label}: {id}") else: print("No labels found.") diff --git a/trovedb/tree.py b/trovedb/tree.py index d4adc00..75c64ac 100644 --- a/trovedb/tree.py +++ b/trovedb/tree.py @@ -15,8 +15,8 @@ class Tree: Initialize a Tree. If data is provided, deserialize from UTF-8 JSON bytes. An empty Tree is created if data is None. """ - if data is None: - self._entries: dict[str, str] = {} + if not data: + self._entries: dict[str, int] = {} else: self._entries = json.loads(data.decode("utf-8")) @@ -24,14 +24,18 @@ class Tree: """Serialize the tree to UTF-8 JSON bytes.""" return json.dumps(self._entries).encode("utf-8") - def set_entry(self, name: str, uuid: str) -> None: + def set_entry(self, name: str, object_id: int) -> None: """Add or update an entry mapping name -> uuid.""" - self._entries[name] = uuid + self._entries[name] = object_id + + def get_entry(self, name: str) -> int: + """Get the uuid associated with a name, or raise KeyError if not found.""" + return self._entries[name] def rm_entry(self, name: str) -> None: """Remove an entry by name. Raises KeyError if not found.""" del self._entries[name] - def list(self) -> dict[str, str]: + def list(self) -> dict[str, int]: """Return a shallow copy of all entries as {name: uuid}.""" return dict(self._entries) diff --git a/trovedb/trove.py b/trovedb/trove.py new file mode 100644 index 0000000..12fc70b --- /dev/null +++ b/trovedb/trove.py @@ -0,0 +1,75 @@ +from typing import Protocol, runtime_checkable, Optional, Dict, List, Self +from uuid import UUID +from pathlib import PurePosixPath + +NODE_ROOT_ID = 1 + +@runtime_checkable +class Note(Protocol): + """ + Protocol for a Note item. + Represents access to an individual note's content and metadata. + """ + @property + def object_id(self) -> int: + """The unique identifier for this note.""" + ... + + def get_raw_metadata(self, key: str) -> Optional[bytes]: + """Retrieve metadata value for the given key.""" + ... + + def set_raw_metadata(self, key: str, value: bytes) -> None: + """Set metadata value for the given key.""" + ... + +@runtime_checkable +class Blob(Protocol): + def read(self) -> bytes: + """Read the raw content of the note.""" + ... + + def write(self, data: bytes) -> None: + """Write new content to the note.""" + ... + +@runtime_checkable +class Tree(Protocol): + def link(self, name: str, note: Note): + """Link name to a given note.""" + ... + + def unlink(self, name: str): + """Remove name from the tree.""" + ... + + def mkdir(self, name: str) -> Self: + """Create a new Tree with the given name.""" + ... + +class BlobNote(Note, Blob): + """Blob Note""" + +class TreeNote(Note, Tree): + """Tree Note""" + + + +@runtime_checkable +class Trove(Protocol): + """ + Protocol for the Trove database API. + Provides high-level access to notes and trees. + """ + + def get_raw_note(self, note: int) -> Optional[Note]: + """Retrieve a note by a UUID""" + ... + + def create_blob(self, data: bytes | None = None) -> BlobNote: + """Create a new blob node at the given path with content""" + ... + + def get_root(self) -> TreeNote: + """Get Tree Node at the given path""" + ...