Add initial CLI support

This commit is contained in:
Andrew Mulbrook 2026-03-21 22:25:32 -05:00
parent e16d67e2f8
commit 22a9c68611
14 changed files with 168 additions and 26 deletions

View file

@ -10,6 +10,9 @@ license = "AGPL-3.0-or-later"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [] dependencies = []
[project.scripts]
trove = "trovedb.cli:cli_main"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest", "pytest",
@ -19,6 +22,9 @@ dev = [
[tool.setuptools_scm] [tool.setuptools_scm]
[tool.setuptools.packages.find]
include = ["trovedb*"]
[tool.ruff] [tool.ruff]
line-length = 88 line-length = 88
target-version = "py311" target-version = "py311"

5
trovedb/cli/__init__.py Normal file
View file

@ -0,0 +1,5 @@
"""Common Environment for CLI"""
from .cli_common import CliEnv
from ._cli_main import main as cli_main

5
trovedb/cli/__main__.py Normal file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env python3
"""Command-line interface for trovedb"""
from ._cli_main import main
main()

38
trovedb/cli/_cli_main.py Normal file
View file

@ -0,0 +1,38 @@
import argparse
import importlib
import pkgutil
import trovedb.cli as cli
from . import cli_common
def _discover_commands():
commands = {}
for info in pkgutil.iter_modules(cli.__path__):
if info.name.startswith('_'):
continue
mod = importlib.import_module(f'trovedb.cli.{info.name}')
if hasattr(mod, 'setup') and hasattr(mod, 'run'):
# strip trailing _ so 'import_' becomes 'import'
name = info.name.rstrip('_')
commands[name] = mod
return commands
def main():
parser = argparse.ArgumentParser(prog='trove')
cli_common.common_args(parser)
subparsers = parser.add_subparsers(dest='command', required=True)
commands = _discover_commands()
for name, mod in sorted(commands.items()):
sub = subparsers.add_parser(name, help=getattr(mod, '__doc__', None))
mod.setup(sub)
args = parser.parse_args()
env = cli_common.get_env(args)
commands[args.command].run(env, args)
if __name__ == "__main__":
main()

29
trovedb/cli/cli_common.py Normal file
View file

@ -0,0 +1,29 @@
"""Common code for CLI subcommands"""
import argparse
import sys
from trovedb import trove_factory, user_env, trove as tr
def display_error(msg: str) -> None:
print(f"Error: {msg}", file=sys.stderr)
class CliEnv:
"""Environment for CLI subcommands"""
def __init__(self, verbose: bool = False):
self.verbose = verbose
self.local_trove = trove_factory.get_trove(user_env.TROVEBASE) if user_env.TROVEBASE else None
def checked_trove(self) -> tr.Trove:
if self.local_trove is None:
display_error("No local trove found")
raise SystemExit()
return self.local_trove
def common_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument('-v', '--verbose', action='store_true')
def get_env(args: argparse.Namespace) -> CliEnv:
return CliEnv(args.verbose)

16
trovedb/cli/ls.py Normal file
View file

@ -0,0 +1,16 @@
"""Note List"""
import argparse
from trovedb.cli import CliEnv
def setup(parser: argparse.ArgumentParser) -> None:
"""Configure this subcommand's arguments."""
pass
def run(env: CliEnv, args: argparse.Namespace) -> None:
"""Entry point when this subcommand is invoked."""
trove = env.checked_trove()
for entry in trove.get_root().entries():
print(entry)

13
trovedb/cli/status.py Normal file
View file

@ -0,0 +1,13 @@
"""System Status"""
import argparse
from trovedb.cli import CliEnv
def setup(parser: argparse.ArgumentParser) -> None:
"""Configure this subcommand's arguments."""
pass
def run(env: CliEnv, args: argparse.Namespace) -> None:
"""Entry point when this subcommand is invoked."""
if env.local_trove:
print("Trove Detected")

View file

@ -11,7 +11,8 @@ import sqlite3
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from .trove import NODE_ROOT_ID
NOTE_ROOT_ID = 1
_SCHEMA = """ _SCHEMA = """
CREATE TABLE IF NOT EXISTS objects ( CREATE TABLE IF NOT EXISTS objects (

View file

@ -3,7 +3,7 @@ import sqlite3
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, List, Self, Iterable from typing import Optional, Dict, List, Self, Iterable
from .trove import NODE_ROOT_ID, Note, Trove, TreeNote, BlobNote, Blob, Tree, BadNoteType, TreeEntry, NoteNotFound from .trove import Note, Trove, TreeNote, BlobNote, Blob, Tree, BadNoteType, TreeEntry, NoteNotFound
class FSNote(Note): class FSNote(Note):
@ -14,7 +14,7 @@ class FSNote(Note):
if self._fs_path is not None: if self._fs_path is not None:
inode = self._fs_path.stat().st_ino inode = self._fs_path.stat().st_ino
if self._inode != inode and self._inode is not None and self._inode != NODE_ROOT_ID: if self._inode != inode and self._inode is not None:
raise ValueError(f"Inconsistent inode: {self._inode} vs {inode}") raise ValueError(f"Inconsistent inode: {self._inode} vs {inode}")
self._inode = inode self._inode = inode
@ -148,9 +148,6 @@ class FSTrove(Trove):
self.con = sqlite3.connect(str(db_path)) self.con = sqlite3.connect(str(db_path))
self._init_db() self._init_db()
# Ensure root mapping.
self._update_cache(NODE_ROOT_ID, self.root)
@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)

View file

@ -1,7 +1,5 @@
from pathlib import Path
from . import server from . import server
from trovedb import trovedb from trovedb import trove_factory
from trovedb import fs
from argparse import ArgumentParser from argparse import ArgumentParser
def main(): def main():
@ -11,13 +9,8 @@ def main():
args = parser.parse_args() args = parser.parse_args()
file = Path(args.db) inst = trove_factory.get_trove(args.db)
if not file.exists(): server.serve(inst, args.mountpoint)
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__': if __name__ == '__main__':
main() main()

View file

@ -1,21 +1,25 @@
from typing import Protocol, runtime_checkable, Optional, Dict, List, Self, NamedTuple, Iterable, MappingView from typing import Protocol, runtime_checkable, Optional, Dict, List, Self, NamedTuple, Iterable, TypedDict
from uuid import UUID from uuid import UUID
from pathlib import PurePosixPath from pathlib import PurePosixPath
type ObjectId = int type ObjectId = int | str | UUID
NODE_ROOT_ID: ObjectId = 1 class TroveError(Exception):
"""Base class for all Trove errors."""
class BadNoteType(TypeError): class BadNoteType(TypeError):
"""Raised when an invalid note type is encountered.""" """Raised when an invalid note type is encountered."""
class TreeExists(TypeError): class TreeExists(RuntimeError):
"""Raised when a label already exists.""" """Raised when a label already exists."""
class NoteNotFound(KeyError): class NoteNotFound(KeyError):
"""Raised when a note is not found.""" """Raised when a note is not found."""
class OpenArguments(TypedDict):
create: bool
@runtime_checkable @runtime_checkable
class Note(Protocol): class Note(Protocol):
""" """
@ -87,7 +91,6 @@ class TreeNote(Note, Tree):
"""Tree Note""" """Tree Note"""
@runtime_checkable @runtime_checkable
class Trove(Protocol): class Trove(Protocol):
""" """

14
trovedb/trove_factory.py Normal file
View file

@ -0,0 +1,14 @@
from pathlib import Path
from . import trove as tr
from .fs import FSTrove
from .trovedb import open_db_trove
def get_trove(trove_base: str | Path, **kwargs: tr.OpenArguments) -> tr.Trove:
path = Path(trove_base)
if path.exists():
if path.is_dir():
return FSTrove(path)
elif path.is_file():
return open_db_trove(path, **kwargs)
raise tr.TroveError(f"Unable to find trove {path}")

View file

@ -9,9 +9,12 @@ tree serialization respectively.
from typing import Optional, Self from typing import Optional, Self
from pathlib import Path from pathlib import Path
from .db import Sqlite3Trove from .db import Sqlite3Trove, NOTE_ROOT_ID
from .tree import Tree as TreeData from .tree import Tree as TreeData
from .trove import NODE_ROOT_ID, Note, Trove, TreeNote, BlobNote, TreeEntry, NoteNotFound
from . import trove as tr
from .trove import Note, Trove, TreeNote, BlobNote, TreeEntry, NoteNotFound
class NoteImpl(Note): class NoteImpl(Note):
@ -131,7 +134,7 @@ class TroveImpl:
if create: if create:
# Root was written as a blob by Sqlite3Trove.open(); fix its type. # Root was written as a blob by Sqlite3Trove.open(); fix its type.
db._con.execute( db._con.execute(
"UPDATE objects SET type = 'tree' WHERE id = ?", (NODE_ROOT_ID,) "UPDATE objects SET type = 'tree' WHERE id = ?", (NOTE_ROOT_ID,)
) )
db._con.commit() db._con.commit()
return trove return trove
@ -168,7 +171,7 @@ class TroveImpl:
def get_root(self) -> TreeNote: def get_root(self) -> TreeNote:
"""Return the root TreeNote (always id=NODE_ROOT_ID).""" """Return the root TreeNote (always id=NODE_ROOT_ID)."""
return TreeNoteImpl(self, NODE_ROOT_ID) return TreeNoteImpl(self, NOTE_ROOT_ID)
def open_trove(path: str | Path, create: bool = False) -> Trove: def open_db_trove(path: str | Path, create: bool = False, **kwargs: tr.OpenArguments) -> Trove:
return TroveImpl.open(path, create=create) return TroveImpl.open(path, create=create)

19
trovedb/user_env.py Normal file
View file

@ -0,0 +1,19 @@
"""Environment for Trove"""
import os
from pathlib import Path
TROVE_HOME_DIR: Path = Path.home()
TROVE_USER_CONFIG_PATH: Path = TROVE_HOME_DIR / ".trove_config"
_ENV_TROVEBASE: str|None = os.environ.get("TROVEBASE", None)
def _search_trovebase() -> Path | str | None:
if _ENV_TROVEBASE is not None:
return Path(_ENV_TROVEBASE)
for path in Path.cwd().parents:
if (path / ".trove").exists():
return path
return None
TROVEBASE: Path | str | None = _search_trovebase()