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"
dependencies = []
[project.scripts]
trove = "trovedb.cli:cli_main"
[project.optional-dependencies]
dev = [
"pytest",
@ -19,6 +22,9 @@ dev = [
[tool.setuptools_scm]
[tool.setuptools.packages.find]
include = ["trovedb*"]
[tool.ruff]
line-length = 88
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
from datetime import datetime, timezone
from pathlib import Path
from .trove import NODE_ROOT_ID
NOTE_ROOT_ID = 1
_SCHEMA = """
CREATE TABLE IF NOT EXISTS objects (

View file

@ -3,7 +3,7 @@ import sqlite3
import tempfile
from pathlib import Path
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):
@ -14,7 +14,7 @@ class FSNote(Note):
if self._fs_path is not None:
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}")
self._inode = inode
@ -148,9 +148,6 @@ class FSTrove(Trove):
self.con = sqlite3.connect(str(db_path))
self._init_db()
# Ensure root mapping.
self._update_cache(NODE_ROOT_ID, self.root)
@classmethod
def open(cls, path: str | Path, create: bool = False) -> 'FSTrove':
p = Path(path)

View file

@ -1,7 +1,5 @@
from pathlib import Path
from . import server
from trovedb import trovedb
from trovedb import fs
from trovedb import trove_factory
from argparse import ArgumentParser
def main():
@ -11,13 +9,8 @@ def main():
args = parser.parse_args()
file = Path(args.db)
if not file.exists():
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)
inst = trove_factory.get_trove(args.db)
server.serve(inst, args.mountpoint)
if __name__ == '__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 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):
"""Raised when an invalid note type is encountered."""
class TreeExists(TypeError):
class TreeExists(RuntimeError):
"""Raised when a label already exists."""
class NoteNotFound(KeyError):
"""Raised when a note is not found."""
class OpenArguments(TypedDict):
create: bool
@runtime_checkable
class Note(Protocol):
"""
@ -87,7 +91,6 @@ class TreeNote(Note, Tree):
"""Tree Note"""
@runtime_checkable
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 pathlib import Path
from .db import Sqlite3Trove
from .db import Sqlite3Trove, NOTE_ROOT_ID
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):
@ -131,7 +134,7 @@ class TroveImpl:
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,)
"UPDATE objects SET type = 'tree' WHERE id = ?", (NOTE_ROOT_ID,)
)
db._con.commit()
return trove
@ -168,7 +171,7 @@ class TroveImpl:
def get_root(self) -> TreeNote:
"""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)

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()