From dd0e747b79234254bdd435a8445395310f2af77d Mon Sep 17 00:00:00 2001 From: Shofwan Date: Sun, 19 Apr 2026 19:31:30 +0700 Subject: [PATCH 1/2] chapter 1 changes --- mark_as_read_plan.md | 39 +++++++++++++++++++ samples/book-app-project/book_app.py | 38 +++++++++--------- samples/book-app-project/utils.py | 58 ++++++++++++++++++++-------- 3 files changed, 101 insertions(+), 34 deletions(-) create mode 100644 mark_as_read_plan.md diff --git a/mark_as_read_plan.md b/mark_as_read_plan.md new file mode 100644 index 00000000..11d52419 --- /dev/null +++ b/mark_as_read_plan.md @@ -0,0 +1,39 @@ +# Plan: Add "mark as read" CLI command to the book app + +## Current State + +- `books.py` — `BookCollection.mark_as_read(title)` already exists and is tested +- `book_app.py` — Has CLI handlers for `list`, `add`, `remove`, `find`, `help`, but **no `read` command** +- `tests/test_books.py` — Has `test_mark_book_as_read` and `test_mark_book_as_read_invalid` covering the model layer + +## What's Missing + +Only the CLI layer needs work: +1. A `handle_read()` function in `book_app.py` +2. Wire it into `main()` dispatch (add `elif command == "read":`) +3. Update `show_help()` to document the new command + +## Approach + +- Prompt the user for a book title +- Call `collection.mark_as_read(title)` +- Print a success or "not found" message based on the return value + +## Files to Change + +| File | Change | +|------|--------| +| `samples/book-app-project/book_app.py` | Add `handle_read()`, update `main()` dispatch, update `show_help()` | + +## Out of Scope + +- No changes to `books.py` (backend already done) +- No new tests needed for the model layer (already covered) +- JS/C# variants are not in scope unless requested + +## Todos + +1. Add `handle_read()` function to `book_app.py` +2. Wire `read` command in `main()` dispatch +3. Update `show_help()` with the new command +4. Verify with a quick manual test / existing pytest run diff --git a/samples/book-app-project/book_app.py b/samples/book-app-project/book_app.py index f0100c2d..316e3218 100644 --- a/samples/book-app-project/book_app.py +++ b/samples/book-app-project/book_app.py @@ -1,12 +1,12 @@ import sys -from books import BookCollection +from books import Book, BookCollection # Global collection instance collection = BookCollection() -def show_books(books): +def show_books(books: list[Book]) -> None: """Display books in a user-friendly format.""" if not books: print("No books found.") @@ -21,12 +21,12 @@ def show_books(books): print() -def handle_list(): +def handle_list() -> None: books = collection.list_books() show_books(books) -def handle_add(): +def handle_add() -> None: print("\nAdd a New Book\n") title = input("Title: ").strip() @@ -41,7 +41,7 @@ def handle_add(): print(f"\nError: {e}\n") -def handle_remove(): +def handle_remove() -> None: print("\nRemove a Book\n") title = input("Enter the title of the book to remove: ").strip() @@ -50,7 +50,7 @@ def handle_remove(): print("\nBook removed if it existed.\n") -def handle_find(): +def handle_find() -> None: print("\nFind Books by Author\n") author = input("Author name: ").strip() @@ -59,7 +59,7 @@ def handle_find(): show_books(books) -def show_help(): +def show_help() -> None: print(""" Book Collection Helper @@ -72,23 +72,25 @@ def show_help(): """) -def main(): +COMMANDS = { + "list": handle_list, + "add": handle_add, + "remove": handle_remove, + "find": handle_find, + "help": show_help, +} + + +def main() -> None: if len(sys.argv) < 2: show_help() return command = sys.argv[1].lower() + handler = COMMANDS.get(command) - 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() + if handler: + handler() else: print("Unknown command.\n") show_help() diff --git a/samples/book-app-project/utils.py b/samples/book-app-project/utils.py index 4151dcda..6b98bc11 100644 --- a/samples/book-app-project/utils.py +++ b/samples/book-app-project/utils.py @@ -1,4 +1,9 @@ -def print_menu(): +from typing import List, Tuple + +from books import Book + + +def print_menu() -> None: print("\nšŸ“š Book Collection App") print("1. Add a book") print("2. List books") @@ -8,24 +13,45 @@ def print_menu(): def get_user_choice() -> str: - return input("Choose an option (1-5): ").strip() - - -def get_book_details(): - title = input("Enter book title: ").strip() - author = input("Enter author: ").strip() - - year_input = input("Enter publication year: ").strip() - try: - year = int(year_input) - except ValueError: - print("Invalid year. Defaulting to 0.") - year = 0 - + valid_choices = {"1", "2", "3", "4", "5"} + while True: + choice = input("Choose an option (1-5): ").strip() + if choice in valid_choices: + return choice + print("Invalid option. Please enter a number between 1 and 5.") + + +def _prompt_required(prompt: str) -> str: + """Prompt the user until a non-empty value is provided.""" + while True: + value = input(prompt).strip() + if value: + return value + print("This field cannot be empty. Please try again.") + + +def _prompt_year() -> int: + """Prompt the user until a valid publication year is provided.""" + while True: + year_input = input("Enter publication year: ").strip() + try: + year = int(year_input) + if year < 1 or year > 2100: + print("Please enter a year between 1 and 2100.") + continue + return year + except ValueError: + print("Invalid year. Please enter a number.") + + +def get_book_details() -> Tuple[str, str, int]: + title = _prompt_required("Enter book title: ") + author = _prompt_required("Enter author: ") + year = _prompt_year() return title, author, year -def print_books(books): +def print_books(books: List[Book]) -> None: if not books: print("No books in your collection.") return From 22579bc42cce36b15eb4bf4994189e20c5786cd3 Mon Sep 17 00:00:00 2001 From: Shofwan Date: Sun, 19 Apr 2026 19:51:18 +0700 Subject: [PATCH 2/2] Implement search feature --- samples/book-app-project/book_app.py | 13 ++++++- samples/book-app-project/books.py | 8 ++++ samples/book-app-project/tests/test_books.py | 41 ++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/samples/book-app-project/book_app.py b/samples/book-app-project/book_app.py index 316e3218..67b2c790 100644 --- a/samples/book-app-project/book_app.py +++ b/samples/book-app-project/book_app.py @@ -59,6 +59,15 @@ def handle_find() -> None: show_books(books) +def handle_search() -> None: + print("\nSearch Books\n") + + query = input("Search by title or author: ").strip() + books = collection.search_books(query) + + show_books(books) + + def show_help() -> None: print(""" Book Collection Helper @@ -67,7 +76,8 @@ def show_help() -> None: list - Show all books add - Add a new book remove - Remove a book by title - find - Find books by author + find - Find books by author (exact match) + search - Search books by title or author (partial match) help - Show this help message """) @@ -77,6 +87,7 @@ def show_help() -> None: "add": handle_add, "remove": handle_remove, "find": handle_find, + "search": handle_search, "help": show_help, } diff --git a/samples/book-app-project/books.py b/samples/book-app-project/books.py index 2110689f..d3b1ea4d 100644 --- a/samples/book-app-project/books.py +++ b/samples/book-app-project/books.py @@ -70,3 +70,11 @@ def remove_book(self, title: str) -> bool: 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()] + + def search_books(self, query: str) -> List[Book]: + """Search books by partial title or author match (case-insensitive).""" + q = query.lower() + return [ + b for b in self.books + if q in b.title.lower() or q in b.author.lower() + ] diff --git a/samples/book-app-project/tests/test_books.py b/samples/book-app-project/tests/test_books.py index 061149c5..74f4a66c 100644 --- a/samples/book-app-project/tests/test_books.py +++ b/samples/book-app-project/tests/test_books.py @@ -51,3 +51,44 @@ def test_remove_book_invalid(): collection = BookCollection() result = collection.remove_book("Nonexistent Book") assert result is False + + +def test_search_by_title_partial(): + collection = BookCollection() + collection.add_book("The Hobbit", "J.R.R. Tolkien", 1937) + collection.add_book("1984", "George Orwell", 1949) + results = collection.search_books("Hobb") + assert len(results) == 1 + assert results[0].title == "The Hobbit" + + +def test_search_by_author_partial(): + collection = BookCollection() + collection.add_book("The Hobbit", "J.R.R. Tolkien", 1937) + collection.add_book("1984", "George Orwell", 1949) + results = collection.search_books("Tolk") + assert len(results) == 1 + assert results[0].author == "J.R.R. Tolkien" + + +def test_search_case_insensitive(): + collection = BookCollection() + collection.add_book("Dune", "Frank Herbert", 1965) + results = collection.search_books("dune") + assert len(results) == 1 + results = collection.search_books("FRANK") + assert len(results) == 1 + + +def test_search_no_results(): + collection = BookCollection() + collection.add_book("Dune", "Frank Herbert", 1965) + results = collection.search_books("xyz_no_match") + assert results == [] + + +def test_search_deduplication(): + collection = BookCollection() + collection.add_book("Frank's Story", "Frank Herbert", 1965) + results = collection.search_books("frank") + assert len(results) == 1