From 22a9c686114b84625c7339080dcb5b6dad6e5309 Mon Sep 17 00:00:00 2001 From: Andrew Mulbrook Date: Sat, 21 Mar 2026 22:25:32 -0500 Subject: [PATCH] Add initial CLI support --- pyproject.toml | 6 ++++++ trovedb/cli/__init__.py | 5 +++++ trovedb/cli/__main__.py | 5 +++++ trovedb/cli/_cli_main.py | 38 ++++++++++++++++++++++++++++++++++++++ trovedb/cli/cli_common.py | 29 +++++++++++++++++++++++++++++ trovedb/cli/ls.py | 16 ++++++++++++++++ trovedb/cli/status.py | 13 +++++++++++++ trovedb/db.py | 3 ++- trovedb/fs.py | 7 ++----- trovedb/fuse/__main__.py | 13 +++---------- trovedb/trove.py | 13 ++++++++----- trovedb/trove_factory.py | 14 ++++++++++++++ trovedb/trovedb.py | 13 ++++++++----- trovedb/user_env.py | 19 +++++++++++++++++++ 14 files changed, 168 insertions(+), 26 deletions(-) create mode 100644 trovedb/cli/__init__.py create mode 100644 trovedb/cli/__main__.py create mode 100644 trovedb/cli/_cli_main.py create mode 100644 trovedb/cli/cli_common.py create mode 100644 trovedb/cli/ls.py create mode 100644 trovedb/cli/status.py create mode 100644 trovedb/trove_factory.py create mode 100644 trovedb/user_env.py diff --git a/pyproject.toml b/pyproject.toml index fae14b4..39c7876 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/trovedb/cli/__init__.py b/trovedb/cli/__init__.py new file mode 100644 index 0000000..3137194 --- /dev/null +++ b/trovedb/cli/__init__.py @@ -0,0 +1,5 @@ +"""Common Environment for CLI""" + +from .cli_common import CliEnv + +from ._cli_main import main as cli_main diff --git a/trovedb/cli/__main__.py b/trovedb/cli/__main__.py new file mode 100644 index 0000000..ce6f6e9 --- /dev/null +++ b/trovedb/cli/__main__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +"""Command-line interface for trovedb""" + +from ._cli_main import main +main() diff --git a/trovedb/cli/_cli_main.py b/trovedb/cli/_cli_main.py new file mode 100644 index 0000000..19a3ec7 --- /dev/null +++ b/trovedb/cli/_cli_main.py @@ -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() diff --git a/trovedb/cli/cli_common.py b/trovedb/cli/cli_common.py new file mode 100644 index 0000000..1b6fe5b --- /dev/null +++ b/trovedb/cli/cli_common.py @@ -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) diff --git a/trovedb/cli/ls.py b/trovedb/cli/ls.py new file mode 100644 index 0000000..0bec2d0 --- /dev/null +++ b/trovedb/cli/ls.py @@ -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) + + diff --git a/trovedb/cli/status.py b/trovedb/cli/status.py new file mode 100644 index 0000000..1ff63e2 --- /dev/null +++ b/trovedb/cli/status.py @@ -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") diff --git a/trovedb/db.py b/trovedb/db.py index 935501e..e811028 100644 --- a/trovedb/db.py +++ b/trovedb/db.py @@ -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 ( diff --git a/trovedb/fs.py b/trovedb/fs.py index f4f8982..ff6eece 100644 --- a/trovedb/fs.py +++ b/trovedb/fs.py @@ -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) diff --git a/trovedb/fuse/__main__.py b/trovedb/fuse/__main__.py index b7d7d45..3ce7772 100644 --- a/trovedb/fuse/__main__.py +++ b/trovedb/fuse/__main__.py @@ -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() diff --git a/trovedb/trove.py b/trovedb/trove.py index 690c69c..c9d0be9 100644 --- a/trovedb/trove.py +++ b/trovedb/trove.py @@ -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): """ diff --git a/trovedb/trove_factory.py b/trovedb/trove_factory.py new file mode 100644 index 0000000..d6178ac --- /dev/null +++ b/trovedb/trove_factory.py @@ -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}") diff --git a/trovedb/trovedb.py b/trovedb/trovedb.py index 9599fc9..1cf2f2e 100644 --- a/trovedb/trovedb.py +++ b/trovedb/trovedb.py @@ -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) diff --git a/trovedb/user_env.py b/trovedb/user_env.py new file mode 100644 index 0000000..6a2ba34 --- /dev/null +++ b/trovedb/user_env.py @@ -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()