-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(cli): implement CLI layer with tests
- 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
Showing
15 changed files
with
952 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}" | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}" | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}" | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}" | ||
) |
Oops, something went wrong.