Add initial CLI support
This commit is contained in:
parent
e16d67e2f8
commit
22a9c68611
14 changed files with 168 additions and 26 deletions
|
|
@ -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
5
trovedb/cli/__init__.py
Normal 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
5
trovedb/cli/__main__.py
Normal 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
38
trovedb/cli/_cli_main.py
Normal 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
29
trovedb/cli/cli_common.py
Normal 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
16
trovedb/cli/ls.py
Normal 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
13
trovedb/cli/status.py
Normal 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")
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
14
trovedb/trove_factory.py
Normal 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}")
|
||||||
|
|
@ -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
19
trovedb/user_env.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue