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