diff --git a/samples/book-app-buggy/books_buggy.py b/samples/book-app-buggy/books_buggy.py index 8bfa0a2e..fc94920f 100644 --- a/samples/book-app-buggy/books_buggy.py +++ b/samples/book-app-buggy/books_buggy.py @@ -32,13 +32,22 @@ def load_books(self): def save_books(self): """Save the current book collection to JSON.""" - # BUG 2: Doesn't handle file permission errors - crashes silently - f = open(DATA_FILE, "w") - json.dump([asdict(b) for b in self.books], f, indent=2) - # Missing f.close() - file handle leak + try: + with open(DATA_FILE, "w", encoding="utf-8") as f: + json.dump([asdict(b) for b in self.books], f, indent=2) + except OSError as e: + print(f"Error saving {DATA_FILE}: {e}") def add_book(self, title: str, author: str, year: int) -> Book: - # BUG 3: Year validation accepts negative numbers and future years + # Validate inputs: year must be int within reasonable bounds + try: + year = int(year) + except (TypeError, ValueError): + raise ValueError("Year must be an integer") + from datetime import datetime + current_year = datetime.utcnow().year + if year < 0 or year > current_year: + raise ValueError(f"Year must be between 0 and {current_year}") book = Book(title=title, author=author, year=year) self.books.append(book) self.save_books() @@ -48,33 +57,37 @@ def list_books(self) -> List[Book]: return self.books def find_book_by_title(self, title: str) -> Optional[Book]: - # BUG 1: Case-sensitive comparison - "the hobbit" won't find "The Hobbit" + # Case-insensitive, stripped comparison + if title is None: + return None + t = title.strip().lower() for book in self.books: - if book.title == title: + if isinstance(book.title, str) and book.title.strip().lower() == t: return book return None def mark_as_read(self, title: str) -> bool: - # BUG 5: Marks ALL books as read instead of just the matching one + # Mark only matching book as read book = self.find_book_by_title(title) if book: - for b in self.books: - b.read = True - self.save_books() + if not book.read: + book.read = True + self.save_books() return True return False def remove_book(self, title: str) -> bool: - """Remove a book by title.""" - # BUG 4: Uses 'in' check - removing "Dune" also matches "Dune Messiah" - for book in self.books: - if title in book.title: + """Remove a book by title (exact case-insensitive match).""" + for book in list(self.books): + if isinstance(book.title, str) and book.title.strip().lower() == title.strip().lower(): self.books.remove(book) self.save_books() return True return False def find_by_author(self, author: str) -> List[Book]: - """Find all books by a given author.""" - # BUG 6: Exact match instead of partial - "Tolkien" won't find "J.R.R. Tolkien" - return [b for b in self.books if b.author == author] + """Find all books by a given author (case-insensitive substring match).""" + if not author: + return [] + a = author.strip().lower() + return [b for b in self.books if isinstance(b.author, str) and a in b.author.strip().lower()] diff --git a/samples/book-app-project/book_app.py b/samples/book-app-project/book_app.py index f0100c2d..0f3cc43f 100644 --- a/samples/book-app-project/book_app.py +++ b/samples/book-app-project/book_app.py @@ -2,96 +2,160 @@ from books import BookCollection -# Global collection instance -collection = BookCollection() +from utils import print_books, print_help, get_book_details +# Global collection instance (lazy-initialized) +collection = None -def show_books(books): - """Display books in a user-friendly format.""" - if not books: - print("No books found.") - return - print("\nYour Book Collection:\n") +def get_collection(): + """Return a lazily-created global BookCollection instance. - for index, book in enumerate(books, start=1): - status = "āœ“" if book.read else " " - print(f"{index}. [{status}] {book.title} by {book.author} ({book.year})") + Avoid creating the collection (and touching the data file) at import time so + importing this module is side-effect free and easier to test. + """ + global collection + if collection is None: + collection = BookCollection() + return collection - print() +# Initialize collection at import time so tests that reload this module after +# monkeypatching books.DATA_FILE get a collection bound to the patched path. +# This keeps the test expectation while still allowing explicit injection by +# passing data_file to BookCollection in other code paths. +collection = get_collection() def handle_list(): - books = collection.list_books() - show_books(books) + books = get_collection().list_books() + print_books(books) + + +def handle_list_unread(): + books = get_collection().get_unread_books() + print_books(books) def handle_add(): print("\nAdd a New Book\n") + try: + title, author, year = get_book_details() + except ValueError as e: + print(f"\nError: {e}\n") + return - title = input("Title: ").strip() - author = input("Author: ").strip() - year_str = input("Year: ").strip() + if not title: + print("\nError: Title cannot be empty.\n") + return + if not author: + print("\nError: Author cannot be empty.\n") + return try: - year = int(year_str) if year_str else 0 - collection.add_book(title, author, year) + get_collection().add_book(title, author, year) print("\nBook added successfully.\n") except ValueError as e: print(f"\nError: {e}\n") + except Exception as e: + print(f"\nUnexpected error adding book: {e}\n") def handle_remove(): print("\nRemove a Book\n") title = input("Enter the title of the book to remove: ").strip() - collection.remove_book(title) + if not title: + print("\nError: Title cannot be empty.\n") + return - print("\nBook removed if it existed.\n") + try: + removed = get_collection().remove_book(title) + if removed: + print(f"\nBook '{title}' removed.\n") + else: + print(f"\nBook '{title}' not found.\n") + except Exception as e: + print(f"\nUnexpected error removing book: {e}\n") def handle_find(): print("\nFind Books by Author\n") author = input("Author name: ").strip() - books = collection.find_by_author(author) + if not author: + print("\nError: Author name cannot be empty.\n") + return - show_books(books) + books = get_collection().find_by_author(author) + print_books(books) -def show_help(): - print(""" -Book Collection Helper +def handle_search(query: str = None): + print("\nSearch Books by Title or Author\n") + if not query: + query = input("Search query: ").strip() + if not query: + print("\nError: Search query cannot be empty.\n") + return + try: + books = get_collection().search(query) + print_books(books) + except Exception as e: + print(f"\nUnexpected error searching books: {e}\n") + + +def handle_mark(title: str = None): + print("\nMark a Book as Read\n") + if not title: + title = input("Enter the title of the book to mark as read: ").strip() + if not title: + print("\nError: Title cannot be empty.\n") + return + try: + if get_collection().mark_as_read(title): + print(f"\nBook '{title}' marked as read.\n") + else: + print(f"\nBook '{title}' not found.\n") + except Exception as e: + print(f"\nUnexpected error marking book as read: {e}\n") + -Commands: - list - Show all books - add - Add a new book - remove - Remove a book by title - find - Find books by author - help - Show this help message -""") def main(): if len(sys.argv) < 2: - show_help() + print_help() return command = sys.argv[1].lower() - - if command == "list": - handle_list() - elif command == "add": - handle_add() - elif command == "remove": - handle_remove() - elif command == "find": - handle_find() - elif command == "help": - show_help() - else: - print("Unknown command.\n") - show_help() + args = sys.argv[2:] + + # Helper wrappers for commands that accept optional argv arguments + def _search_wrapper(): + query = " ".join(args) if args else None + return handle_search(query) + + def _mark_wrapper(): + title = " ".join(args) if args else None + return handle_mark(title) + + dispatch = { + "list": handle_list, + "list-unread": handle_list_unread, + "add": handle_add, + "remove": handle_remove, + "find": handle_find, + "search": _search_wrapper, + "mark": _mark_wrapper, + "help": print_help, + } + + func = dispatch.get(command) + if func: + return func() + + print("Unknown command.\n") + print_help() if __name__ == "__main__": diff --git a/samples/book-app-project/books.py b/samples/book-app-project/books.py index 2110689f..5685ad09 100644 --- a/samples/book-app-project/books.py +++ b/samples/book-app-project/books.py @@ -1,10 +1,91 @@ +"""books.py — Book collection utilities. + +This module provides a lightweight Book dataclass and a BookCollection class +that loads/saves a JSON file and offers basic search and mutation helpers. + +Notes: +- Data file defaults to "data.json" in the current working directory. +- Uses logging (not print) for diagnostics. +- Implements a simple advisory lock via a ".lock" file to reduce + race conditions when multiple processes write the same file. +""" + import json +import os +import tempfile +import time +import logging +from contextlib import contextmanager from dataclasses import dataclass, asdict -from typing import List, Optional +from datetime import datetime +from typing import Any, ContextManager, List, Optional + +logger = logging.getLogger(__name__) DATA_FILE = "data.json" +def _parse_bool(value: Any) -> bool: + """Parse a boolean-like value from various JSON representations. + + Accepts booleans, integers (0/1), and common strings ('true','false','1','0','yes','no'). + """ + if isinstance(value, bool): + return value + if isinstance(value, int): + return bool(value) + if isinstance(value, str): + v = value.strip().lower() + return v in ("true", "1", "yes", "y") + return False + + +@contextmanager +def _file_lock(path: str, timeout: float = 5.0, poll: float = 0.05) -> ContextManager[None]: + """Simple advisory lock using an exclusive lockfile. + + Creates '.lock' atomically and removes it on exit. Retries until timeout. + This is not a POSIX flock; it's a lightweight cross-process guard suitable for + simple local use. + """ + lockfile = f"{path}.lock" + deadline = time.time() + timeout + fd = None + while True: + try: + # O_EXCL + O_CREAT ensures atomic creation failure if exists + fd = os.open(lockfile, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + # write PID for diagnostics + try: + os.write(fd, str(os.getpid()).encode("utf-8")) + except OSError: + pass + break + except FileExistsError: + if time.time() > deadline: + raise TimeoutError(f"Timeout acquiring lock for {path}") + time.sleep(poll) + try: + yield + finally: + try: + if fd is not None: + os.close(fd) + if os.path.exists(lockfile): + os.remove(lockfile) + except OSError: + logger.debug("Failed to remove lockfile %s", lockfile, exc_info=True) + + + + + + + + + + + @dataclass class Book: title: str @@ -14,43 +95,174 @@ class Book: class BookCollection: - def __init__(self): + """Manage a collection of Book objects persisted to a JSON file. + + Public methods provide simple operations for adding, finding, listing, + marking read, removing and searching books. + """ + + def __init__(self, data_file: Optional[str] = None) -> None: + # Use provided data_file or fall back to module-level DATA_FILE. Defaulting + # to DATA_FILE in the function signature would capture the value at import + # time and make monkeypatching DATA_FILE in tests ineffective. + self.data_file = data_file if data_file is not None else DATA_FILE self.books: List[Book] = [] self.load_books() - def load_books(self): - """Load books from the JSON file if it exists.""" + def load_books(self) -> None: + """Load books from the JSON file. + + Skips malformed entries but loads valid ones. Uses logging for diagnostics. + """ try: - with open(DATA_FILE, "r") as f: - data = json.load(f) - self.books = [Book(**b) for b in data] - except FileNotFoundError: - self.books = [] + if not os.path.exists(self.data_file): + self.books = [] + return + + # Acquire a short lock to avoid reading during a write + try: + with _file_lock(self.data_file, timeout=0.5): + with open(self.data_file, "r", encoding="utf-8") as f: + data = json.load(f) + except TimeoutError: + # If lock cannot be acquired quickly, try reading without it + with open(self.data_file, "r", encoding="utf-8") as f: + data = json.load(f) + + if not isinstance(data, list): + logger.warning("%s does not contain a list. Starting with empty collection.", self.data_file) + self.books = [] + return + + books: List[Book] = [] + current_year = datetime.utcnow().year + for i, entry in enumerate(data): + try: + if not isinstance(entry, dict): + raise ValueError("entry is not a JSON object") + + title = entry.get("title") + author = entry.get("author") + + if not title or not author: + logger.warning("Skipping invalid book entry at index %d (missing title/author).", i) + continue + + if "year" not in entry: + logger.warning("Skipping book '%s' at index %d: missing 'year'.", title, i) + continue + + try: + year = int(entry.get("year")) + except (TypeError, ValueError): + logger.warning("Skipping book '%s' at index %d: invalid 'year'.", title, i) + continue + + if year < 0 or year > current_year: + logger.warning("Skipping book '%s' at index %d: year %s out of range.", title, i, year) + continue + + read = _parse_bool(entry.get("read", False)) + + books.append(Book(title=title, author=author, year=year, read=read)) + except (TypeError, ValueError) as e: + logger.warning("Skipping malformed book entry at index %d: %s", i, e) + + self.books = books + except json.JSONDecodeError: - print("Warning: data.json is corrupted. Starting with empty collection.") + logger.warning("%s is corrupted or contains invalid JSON. Starting with empty collection.", self.data_file) self.books = [] + except OSError as e: + logger.error("Error reading %s: %s. Starting with empty collection.", self.data_file, e) + self.books = [] + + def save_books(self) -> None: + """Atomically save the current book collection to JSON. + + Writes to a temporary file in the same directory and then replaces the + target file. Uses a simple advisory lock to reduce concurrent writers. + """ + try: + dirpath = os.path.dirname(os.path.abspath(self.data_file)) or "." + # Ensure directory exists + os.makedirs(dirpath, exist_ok=True) - def save_books(self): - """Save the current book collection to JSON.""" - with open(DATA_FILE, "w") as f: - json.dump([asdict(b) for b in self.books], f, indent=2) + with _file_lock(self.data_file): + fd, tmp_path = tempfile.mkstemp(prefix="tmp-", dir=dirpath, text=True) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump([asdict(b) for b in self.books], f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, self.data_file) + finally: + if os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except OSError: + logger.debug("Failed to remove temp file %s", tmp_path, exc_info=True) + except TimeoutError as e: + logger.error("Could not acquire lock to save books: %s", e) + except OSError as e: + logger.error("Error saving books to %s: %s", self.data_file, e) def add_book(self, title: str, author: str, year: int) -> Book: + """Add a new book to the collection and persist it. + + Raises ValueError for invalid input or if a book with the same title + (case-insensitive) already exists. + """ + title = title.strip() if isinstance(title, str) else "" + author = author.strip() if isinstance(author, str) else "" + + if not title: + raise ValueError("Title cannot be empty.") + if not author: + raise ValueError("Author cannot be empty.") + + try: + year = int(year) + except (TypeError, ValueError): + raise ValueError("Year must be an integer.") + + current_year = datetime.utcnow().year + if year < 0 or year > current_year: + raise ValueError(f"Year must be between 0 and {current_year}.") + + # Prevent exact-title duplicates (case-insensitive) + if any(b.title.lower() == title.lower() for b in self.books): + raise ValueError(f"A book with the title '{title}' already exists.") + book = Book(title=title, author=author, year=year) self.books.append(book) self.save_books() return book def list_books(self) -> List[Book]: + """Return the list of books in insertion order.""" return self.books + def get_unread_books(self) -> List[Book]: + """Return books that are unread (read == False). + + This convenience method keeps callers from filtering the list themselves + and documents the intended behavior in the public API. + """ + return [b for b in self.books if not getattr(b, "read", False)] + def find_book_by_title(self, title: str) -> Optional[Book]: + """Find a book by exact title (case-insensitive). Returns None if not found.""" + if title is None: + return None + t = title.strip().lower() for book in self.books: - if book.title.lower() == title.lower(): + if book.title.lower() == t: return book return None def mark_as_read(self, title: str) -> bool: + """Mark a book as read by title. Returns True if updated.""" book = self.find_book_by_title(title) if book: book.read = True @@ -59,14 +271,30 @@ def mark_as_read(self, title: str) -> bool: return False def remove_book(self, title: str) -> bool: - """Remove a book by title.""" + """Remove a book by title. Returns True if removed.""" book = self.find_book_by_title(title) if book: - self.books.remove(book) - self.save_books() - return True + try: + self.books.remove(book) + self.save_books() + return True + except ValueError: + logger.warning("Failed to remove book '%s' — not found in list.", title) + return False return False def find_by_author(self, author: str) -> List[Book]: - """Find all books by a given author.""" - return [b for b in self.books if b.author.lower() == author.lower()] + """Return books that match the author using case-insensitive substring matching.""" + if not author: + return [] + a = author.strip().lower() + return [b for b in self.books if isinstance(b.author, str) and a in b.author.strip().lower()] + + def search(self, query: str) -> List[Book]: + """Search books by title or author using case-insensitive substring matching.""" + if not query: + return [] + q = str(query).strip().lower() + if not q: + return [] + return [b for b in self.books if q in b.title.lower() or q in b.author.lower()] \ No newline at end of file diff --git a/samples/book-app-project/copilot-cli-for-beginners b/samples/book-app-project/copilot-cli-for-beginners new file mode 160000 index 00000000..6835ef5a --- /dev/null +++ b/samples/book-app-project/copilot-cli-for-beginners @@ -0,0 +1 @@ +Subproject commit 6835ef5a4ea017a25634318aa5317710e3eebba2 diff --git a/samples/book-app-project/tests/test_books.py b/samples/book-app-project/tests/test_books.py index 061149c5..7cd69087 100644 --- a/samples/book-app-project/tests/test_books.py +++ b/samples/book-app-project/tests/test_books.py @@ -51,3 +51,101 @@ def test_remove_book_invalid(): collection = BookCollection() result = collection.remove_book("Nonexistent Book") assert result is False + + +def test_search_by_title_and_author(): + collection = BookCollection() + collection.add_book("Dune", "Frank Herbert", 1965) + collection.add_book("Dune Messiah", "Frank Herbert", 1969) + collection.add_book("1984", "George Orwell", 1949) + + # Title partial match + results = collection.search("Dune") + assert len(results) == 2 + + # Author partial match + results = collection.search("Orwell") + assert len(results) == 1 + assert results[0].title == "1984" + + # Case-insensitive search + results = collection.search("frank") + assert len(results) == 2 + + +def test_find_by_author_partial(): + collection = BookCollection() + collection.add_book("The Hobbit", "J.R.R. Tolkien", 1937) + collection.add_book("Another Book", "Someone Else", 2000) + + res = collection.find_by_author("Tolkien") + assert len(res) == 1 + assert res[0].title == "The Hobbit" + + # Partial, case-insensitive + res2 = collection.find_by_author("r.r.") + assert any("Tolkien" in b.author for b in res2) + + +def test_add_book_duplicate_case_insensitive(): + collection = BookCollection() + collection.add_book("Unique Title", "Author", 2001) + with pytest.raises(ValueError): + collection.add_book("unique title", "Author2", 2002) + + +def test_save_and_load_persistence(): + coll1 = BookCollection() + coll1.add_book("Persisted", "Author", 1999) + + coll2 = BookCollection() + titles = [b.title for b in coll2.list_books()] + assert "Persisted" in titles + + +def test_get_unread_books_mixed_returns_only_unread(): + collection = BookCollection() + collection.add_book("A", "Author A", 2000) + collection.add_book("B", "Author B", 2001) + collection.add_book("C", "Author C", 2002) + collection.mark_as_read("B") + titles = {b.title for b in collection.get_unread_books()} + assert titles == {"A", "C"} + + +def test_get_unread_books_all_read_returns_empty(): + collection = BookCollection() + collection.add_book("One", "Author One", 1990) + collection.add_book("Two", "Author Two", 1991) + collection.mark_as_read("One") + collection.mark_as_read("Two") + assert collection.get_unread_books() == [] + + +def test_get_unread_books_no_books_returns_empty(): + collection = BookCollection() + assert collection.get_unread_books() == [] + + +def test_get_unread_books_treats_none_as_unread(): + from books import Book + collection = BookCollection() + # append a book with a non-boolean 'read' value to observe behavior + collection.books.append(Book("Maybe", "Author Maybe", 2000, read=None)) + assert [b.title for b in collection.get_unread_books()] == ["Maybe"] + + +def test_cli_list_unread_prints_only_unread(capsys): + import importlib + import book_app + importlib.reload(book_app) + coll = book_app.get_collection() + coll.add_book("A", "Author A", 2000) + coll.add_book("B", "Author B", 2001) + coll.add_book("C", "Author C", 2002) + coll.mark_as_read("B") + book_app.handle_list_unread() + out = capsys.readouterr().out + import re + titles = set(re.findall(r'^\s*\d+\.\s*(.+?)\s+by\s', out, flags=re.MULTILINE)) + assert titles == {"A", "C"} diff --git a/samples/book-app-project/tests/test_cli.py b/samples/book-app-project/tests/test_cli.py new file mode 100644 index 00000000..55b341c3 --- /dev/null +++ b/samples/book-app-project/tests/test_cli.py @@ -0,0 +1,56 @@ +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import importlib +import books + + +def test_mark_command_interactive(tmp_path, monkeypatch): + temp_file = tmp_path / "data.json" + temp_file.write_text("[]") + monkeypatch.setattr(books, "DATA_FILE", str(temp_file)) + # Reload book_app after DATA_FILE is patched so it uses the temp file + import book_app + importlib.reload(book_app) + + # Add a book and mark it via interactive prompt + book_app.collection.add_book("Dune", "Frank Herbert", 1965) + monkeypatch.setattr('builtins.input', lambda prompt='': "Dune") + monkeypatch.setattr(sys, 'argv', ['book_app.py', 'mark']) + book_app.main() + + book = book_app.collection.find_book_by_title("Dune") + assert book.read is True + + +def test_mark_command_with_arg(tmp_path, monkeypatch): + temp_file = tmp_path / "data.json" + temp_file.write_text("[]") + monkeypatch.setattr(books, "DATA_FILE", str(temp_file)) + import book_app + importlib.reload(book_app) + + book_app.collection.add_book("1984", "George Orwell", 1949) + monkeypatch.setattr(sys, 'argv', ['book_app.py', 'mark', '1984']) + book_app.main() + + book = book_app.collection.find_book_by_title("1984") + assert book.read is True + + +def test_search_command_with_arg(tmp_path, monkeypatch): + temp_file = tmp_path / "data.json" + temp_file.write_text("[]") + monkeypatch.setattr(books, "DATA_FILE", str(temp_file)) + import book_app + importlib.reload(book_app) + + book_app.collection.add_book("Dune", "Frank Herbert", 1965) + book_app.collection.add_book("1984", "George Orwell", 1949) + + monkeypatch.setattr(sys, 'argv', ['book_app.py', 'search', 'Dune']) + book_app.main() + + results = book_app.collection.search("Dune") + assert len(results) == 1 + assert results[0].title == "Dune" diff --git a/samples/book-app-project/utils.py b/samples/book-app-project/utils.py index 4151dcda..94fed0bb 100644 --- a/samples/book-app-project/utils.py +++ b/samples/book-app-project/utils.py @@ -1,4 +1,14 @@ -def print_menu(): +"""Utility helpers for the Book Collection CLI. + +Provides simple IO helpers used by the sample CLI. All functions are +annotated for clearer typing and easier testing. +""" + +from typing import Sequence, Tuple +from datetime import datetime + + +def print_menu() -> None: print("\nšŸ“š Book Collection App") print("1. Add a book") print("2. List books") @@ -11,26 +21,55 @@ def get_user_choice() -> str: return input("Choose an option (1-5): ").strip() -def get_book_details(): +def get_book_details() -> Tuple[str, str, int]: title = input("Enter book title: ").strip() author = input("Enter author: ").strip() year_input = input("Enter publication year: ").strip() + if not year_input: + raise ValueError("Year is required.") + try: year = int(year_input) except ValueError: - print("Invalid year. Defaulting to 0.") - year = 0 + raise ValueError("Year must be an integer.") + + current_year = datetime.utcnow().year + if year < 0 or year > current_year: + raise ValueError(f"Year must be between 0 and {current_year}.") return title, author, year -def print_books(books): +def print_books(books: Sequence['Book']) -> None: + """Print a numbered list of books. + + The annotation uses a forward reference to a Book-like object. This keeps + utils decoupled from the books module while still providing useful typing + information for callers and linters. + """ if not books: print("No books in your collection.") return print("\nYour Books:") for index, book in enumerate(books, start=1): - status = "āœ… Read" if book.read else "šŸ“– Unread" - print(f"{index}. {book.title} by {book.author} ({book.year}) - {status}") + status = "āœ… Read" if getattr(book, "read", False) else "šŸ“– Unread" + title = getattr(book, "title", "") + author = getattr(book, "author", "") + year = getattr(book, "year", "?") + print(f"{index}. {title} by {author} ({year}) - {status}") + + +def print_help() -> None: + """Print the help text for the book collection CLI.""" + print("Book Collection Helper\n") + print("Commands:") + print(" list - Show all books") + print(" list-unread - Show only unread books") + print(" add - Add a new book") + print(" remove - Remove a book by title") + print(" find - Find books by author") + print(" search - Search books by title or author") + print(" mark - Mark a book as read") + print(" help - Show this help message")