Skip to content

Commit

Permalink
feat(cli): implement CLI layer with tests
Browse files Browse the repository at this point in the history
- Add BaseCommand abstract class with CommandResult
- Implement core CLI commands:
  - AddCommand for creating new books
  - DeleteCommand for removing books
  - ListCommand for displaying all books
  - SearchCommand for finding books
  - StatusCommand for updating book status
- Add BookManagerCLI application class:
  - Command registration and routing
  - Argument parsing
  - Error handling
  - Logging
- Create comprehensive test suite:
  - Unit tests for each command
  - Integration tests for CLI app
  - Mock commands and fixtures
  - Error scenarios coverage
  - Help message testing
- Add proper type hints and documentation
- Fix test execution issues:
  - CommandResult implementation
  - CLI argument handling
  - Mock command implementation
  - System exit handling

This commit completes the CLI layer implementation, providing
a robust command-line interface for the book management system.
All core operations are now available through CLI with proper
error handling and user feedback.
  • Loading branch information
TheFoxKD committed Nov 23, 2024
1 parent 2d490d6 commit e87cb5c
Show file tree
Hide file tree
Showing 15 changed files with 952 additions and 2 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ pytest
pytest --cov=./src --cov-report=term-missing

# Run type checking
mypy src tests
mypy src

# Run linting
ruff check .
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ pytest-cov==6.0.0
mypy==1.13.0
ruff==0.8.0
pre-commit==4.0.1
rich==13.9.4
File renamed without changes.
110 changes: 110 additions & 0 deletions src/cli/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# src/cli/app.py
import argparse
import logging
import sys
from collections.abc import Sequence
from pathlib import Path

from src.cli.commands.base import BaseCommand
from src.storage.abstract import AbstractStorage
from src.storage.json_storage import StorageError


class BookManagerCLI:
"""Main CLI application class."""

def __init__(
self, storage: AbstractStorage, commands: Sequence[BaseCommand] | None = None
) -> None:
"""Initialize CLI application."""
self.storage = storage
self.logger = logging.getLogger(__name__)

self.parser = argparse.ArgumentParser(
description="Book Manager CLI",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
self.subparsers = self.parser.add_subparsers(
dest="command", help="Available commands"
)

self._register_commands(commands or [])

def _register_commands(self, commands: Sequence[BaseCommand]) -> None:
"""Register CLI commands."""
self.commands = {}
for cmd in commands:
subparser = self.subparsers.add_parser(cmd.name)
cmd.configure(subparser)
self.commands[cmd.name] = cmd

def run(self, args: list[str] | None = None) -> int:
"""Run the CLI application with the given arguments."""
if not args:
self.parser.print_help(sys.stdout)
return 0

try:
parsed_args = self.parser.parse_args(args)
if not parsed_args.command:
self.parser.print_help(sys.stdout)
return 0

command = self.commands[parsed_args.command]
result = command.execute(parsed_args)

if not result.success:
self.logger.error(result.message)
return 1

self.logger.info(result.message)
return 0

except StorageError as e:
self.logger.error("Storage error: %s", str(e))
return 2

except Exception as e:
self.logger.error("Unexpected error: %s", str(e))
return 3


def main() -> None:
"""CLI entry point."""
try:
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)

# Setup storage
storage_path = Path.home() / ".book-manager" / "books.json"
storage_path.parent.mkdir(parents=True, exist_ok=True)

from src.cli.commands.add import AddCommand
from src.cli.commands.delete import DeleteCommand
from src.cli.commands.list import ListCommand
from src.cli.commands.search import SearchCommand
from src.cli.commands.status import StatusCommand
from src.storage.json_storage import JsonStorage

storage = JsonStorage(storage_path)
commands = [
AddCommand(storage),
DeleteCommand(storage),
ListCommand(storage),
SearchCommand(storage),
StatusCommand(storage),
]

app = BookManagerCLI(storage, commands)
sys.exit(app.run(sys.argv[1:]))

except Exception as e:
logging.error("Failed to initialize application: %s", str(e))
sys.exit(1)


if __name__ == "__main__":
main()
Empty file added src/cli/commands/__init__.py
Empty file.
41 changes: 41 additions & 0 deletions src/cli/commands/add.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# src/cli/commands/add.py
from argparse import ArgumentParser, Namespace
from dataclasses import dataclass

from src.cli.commands.base import BaseCommand, CommandResult
from src.models.book import Book
from src.storage.abstract import AbstractStorage


@dataclass
class AddCommandResult(CommandResult):
"""Result of add command execution."""


class AddCommand(BaseCommand):
"""Command for adding a new book to the library."""

def __init__(self, storage: AbstractStorage) -> None:
super().__init__()
self.storage = storage

def configure(self, parser: ArgumentParser) -> None:
parser.add_argument("title", help="Book title")
parser.add_argument("author", help="Book author")
parser.add_argument("year", type=int, help="Publication year")

def execute(self, args: Namespace) -> CommandResult:
try:
book = Book.create(title=args.title, author=args.author, year=args.year)
self.storage.add(book)

return AddCommandResult(
success=True,
message=f"Book '{book.title.value}' added successfully",
data=book.to_dict(),
)

except ValueError as e:
return AddCommandResult(success=False, message=f"Failed to add book: {e!s}")
except Exception as e:
return AddCommandResult(success=False, message=f"Unexpected error: {e!s}")
32 changes: 32 additions & 0 deletions src/cli/commands/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# src/cli/commands/base.py

from abc import ABC, abstractmethod
from argparse import ArgumentParser, Namespace
from dataclasses import dataclass
from typing import Any


@dataclass
class CommandResult:
"""Base class for command execution results."""

success: bool
message: str
data: Any | None = None


class BaseCommand(ABC):
"""Base class for all CLI commands."""

def __init__(self) -> None:
self.name = self.__class__.__name__.lower().replace("command", "")

@abstractmethod
def configure(self, parser: ArgumentParser) -> None:
"""Configure command arguments and options."""
raise NotImplementedError

@abstractmethod
def execute(self, args: Namespace) -> CommandResult:
"""Execute the command logic."""
raise NotImplementedError
47 changes: 47 additions & 0 deletions src/cli/commands/delete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# src/cli/commands/delete.py
from argparse import ArgumentParser, Namespace
from dataclasses import dataclass

from src.cli.commands.base import BaseCommand, CommandResult
from src.storage.abstract import AbstractStorage


@dataclass
class DeleteCommandResult(CommandResult):
"""Result of delete command execution."""


class DeleteCommand(BaseCommand):
"""Command for deleting a book from the library."""

def __init__(self, storage: AbstractStorage) -> None:
super().__init__()
self.storage = storage

def configure(self, parser: ArgumentParser) -> None:
parser.add_argument("book_id", help="Book ID to delete")

def execute(self, args: Namespace) -> CommandResult:
try:
book = self.storage.get(args.book_id)
if not book:
return DeleteCommandResult(
success=False, message=f"Book with ID {args.book_id} not found"
)

self.storage.delete(args.book_id)

return DeleteCommandResult(
success=True,
message=f"Book '{book.title.value}' deleted successfully",
data=book.to_dict(),
)

except ValueError as e:
return DeleteCommandResult(
success=False, message=f"Failed to delete book: {e!s}"
)
except Exception as e:
return DeleteCommandResult(
success=False, message=f"Unexpected error: {e!s}"
)
42 changes: 42 additions & 0 deletions src/cli/commands/list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# src/cli/commands/list.py
from argparse import ArgumentParser, Namespace
from dataclasses import dataclass

from src.cli.commands.base import BaseCommand, CommandResult
from src.storage.abstract import AbstractStorage


@dataclass
class ListCommandResult(CommandResult):
"""Result of list command execution."""


class ListCommand(BaseCommand):
"""Command for listing all books in the library."""

def __init__(self, storage: AbstractStorage) -> None:
super().__init__()
self.storage = storage

def configure(self, parser: ArgumentParser) -> None:
pass # No additional arguments needed

def execute(self, args: Namespace) -> CommandResult:
try:
books = self.storage.list_all()

if not books:
return ListCommandResult(
success=True, message="No books found in the library", data=[]
)

return ListCommandResult(
success=True,
message=f"Found {len(books)} books",
data=[book.to_dict() for book in books],
)

except Exception as e:
return ListCommandResult(
success=False, message=f"Failed to list books: {e!s}"
)
52 changes: 52 additions & 0 deletions src/cli/commands/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# src/cli/commands/search.py
from argparse import ArgumentParser, Namespace
from dataclasses import dataclass

from src.cli.commands.base import BaseCommand, CommandResult
from src.storage.abstract import AbstractStorage


@dataclass
class SearchCommandResult(CommandResult):
"""Result of search command execution."""


class SearchCommand(BaseCommand):
"""Command for searching books in the library."""

def __init__(self, storage: AbstractStorage) -> None:
super().__init__()
self.storage = storage

def configure(self, parser: ArgumentParser) -> None:
parser.add_argument("query", help="Search query")
parser.add_argument(
"--field",
choices=["title", "author", "year"],
default="title",
help="Field to search in",
)

def execute(self, args: Namespace) -> CommandResult:
try:
books = self.storage.search(args.query, args.field)

if not books:
return SearchCommandResult(
success=True,
message="No books found matching the search criteria",
data=[],
)

return SearchCommandResult(
success=True,
message=f"Found {len(books)} matching books",
data=[book.to_dict() for book in books],
)

except ValueError as e:
return SearchCommandResult(success=False, message=f"Search failed: {e!s}")
except Exception as e:
return SearchCommandResult(
success=False, message=f"Unexpected error: {e!s}"
)
54 changes: 54 additions & 0 deletions src/cli/commands/status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# src/cli/commands/status.py
from argparse import ArgumentParser, Namespace
from dataclasses import dataclass

from src.cli.commands.base import BaseCommand, CommandResult
from src.models.book import BookStatus
from src.storage.abstract import AbstractStorage


@dataclass
class StatusCommandResult(CommandResult):
"""Result of status update command execution."""


class StatusCommand(BaseCommand):
"""Command for updating a book's status."""

def __init__(self, storage: AbstractStorage) -> None:
super().__init__()
self.storage = storage

def configure(self, parser: ArgumentParser) -> None:
parser.add_argument("book_id", help="Book ID")
parser.add_argument(
"status",
choices=[BookStatus.AVAILABLE, BookStatus.BORROWED],
help="New status",
)

def execute(self, args: Namespace) -> CommandResult:
try:
book = self.storage.get(args.book_id)
if not book:
return StatusCommandResult(
success=False, message=f"Book with ID {args.book_id} not found"
)

book.update_status(args.status)
self.storage.update(book)

return StatusCommandResult(
success=True,
message=f"Book status updated to {args.status}",
data=book.to_dict(),
)

except ValueError as e:
return StatusCommandResult(
success=False, message=f"Failed to update status: {e!s}"
)
except Exception as e:
return StatusCommandResult(
success=False, message=f"Unexpected error: {e!s}"
)
Loading

0 comments on commit e87cb5c

Please sign in to comment.