From 2b31b057a2519e5802c8007e2e942357aeb5e379 Mon Sep 17 00:00:00 2001 From: Daniel Labbe Date: Fri, 3 Apr 2026 16:44:40 +0100 Subject: [PATCH] gh copilot course --- samples/book-app-buggy/book_app_buggy.py | 24 +++-- samples/book-app-project/book_app.py | 107 +++++++++++++++---- samples/book-app-project/books.py | 19 ++++ samples/book-app-project/data.json | 22 ++-- samples/book-app-project/tests/test_books.py | 96 +++++++++++++++++ samples/book-app-project/utils.py | 60 ++++++++--- 6 files changed, 280 insertions(+), 48 deletions(-) diff --git a/samples/book-app-buggy/book_app_buggy.py b/samples/book-app-buggy/book_app_buggy.py index bfa88463..8de9faa9 100644 --- a/samples/book-app-buggy/book_app_buggy.py +++ b/samples/book-app-buggy/book_app_buggy.py @@ -6,7 +6,7 @@ collection = BookCollection() -def show_books(books): +def show_books(books: list) -> None: """Display books in a user-friendly format.""" if not books: print("No books found.") @@ -75,23 +75,25 @@ def show_help(): """) +COMMANDS = { + "list": handle_list, + "add": handle_add, + "remove": handle_remove, + "find": handle_find, + "help": show_help, +} + + def main(): 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/book_app.py b/samples/book-app-project/book_app.py index f0100c2d..6a827635 100644 --- a/samples/book-app-project/book_app.py +++ b/samples/book-app-project/book_app.py @@ -6,7 +6,7 @@ collection = BookCollection() -def show_books(books): +def show_books(books: list) -> None: """Display books in a user-friendly format.""" if not books: print("No books found.") @@ -16,17 +16,21 @@ def show_books(books): for index, book in enumerate(books, start=1): status = "✓" if book.read else " " - print(f"{index}. [{status}] {book.title} by {book.author} ({book.year})") + stars = "" + if book.rating: + stars = " " + "★" * book.rating + "☆" * (5 - book.rating) + review = f' — "{book.review}"' if book.review else "" + print(f"{index}. [{status}] {book.title} by {book.author} ({book.year}){stars}{review}") 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 +45,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 +54,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 +63,66 @@ def handle_find(): show_books(books) -def show_help(): +def handle_search() -> None: + print("\nSearch Books\n") + + query = input("Search by title or author: ").strip() + if not query: + print("\nPlease enter a search term.\n") + return + + results = collection.search(query) + + if results: + show_books(results) + else: + print(f"\nNo books found matching '{query}'.\n") + + +def handle_read() -> None: + print("\nMark a Book as Read\n") + + title = input("Enter the title of the book: ").strip() + success = collection.mark_as_read(title) + + if success: + print(f"\n\"{title}\" marked as read.\n") + else: + print(f"\nBook \"{title}\" not found.\n") + + +def handle_rate() -> None: + print("\nRate a Book\n") + + title = input("Enter the title of the book: ").strip() + rating_str = input("Rating (1–5 stars): ").strip() + + try: + rating = int(rating_str) + except ValueError: + print("\nInvalid rating. Please enter a number between 1 and 5.\n") + return + + review = input("Review (optional, press Enter to skip): ").strip() + + try: + success = collection.rate_book(title, rating, review) + except ValueError as e: + print(f"\nError: {e}\n") + return + + if success: + stars = "★" * rating + "☆" * (5 - rating) + print(f"\n\"{title}\" rated {stars}.\n") + else: + book = collection.find_book_by_title(title) + if book is None: + print(f"\nBook \"{title}\" not found.\n") + else: + print(f"\nCannot rate \"{title}\" — mark it as read first.\n") + + +def show_help() -> None: print(""" Book Collection Helper @@ -68,27 +131,35 @@ def show_help(): add - Add a new book remove - Remove a book by title find - Find books by author + search - Search books by title or author + read - Mark a book as read + rate - Rate and review a book (must be read) help - Show this help message """) -def main(): +COMMANDS = { + "list": handle_list, + "add": handle_add, + "remove": handle_remove, + "find": handle_find, + "search": handle_search, + "read": handle_read, + "rate": handle_rate, + "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/books.py b/samples/book-app-project/books.py index 2110689f..f3295471 100644 --- a/samples/book-app-project/books.py +++ b/samples/book-app-project/books.py @@ -11,6 +11,8 @@ class Book: author: str year: int read: bool = False + rating: Optional[int] = None + review: Optional[str] = None class BookCollection: @@ -70,3 +72,20 @@ 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(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()] + + def rate_book(self, title: str, rating: int, review: str = "") -> bool: + """Rate and review a book (must be marked as read). Rating must be 1–5.""" + if not 1 <= rating <= 5: + raise ValueError(f"Rating must be between 1 and 5, got {rating}.") + book = self.find_book_by_title(title) + if not book or not book.read: + return False + book.rating = rating + book.review = review.strip() if review else "" + self.save_books() + return True diff --git a/samples/book-app-project/data.json b/samples/book-app-project/data.json index a9f23f68..1ddde3f7 100644 --- a/samples/book-app-project/data.json +++ b/samples/book-app-project/data.json @@ -3,30 +3,40 @@ "title": "The Hobbit", "author": "J.R.R. Tolkien", "year": 1937, - "read": false + "read": true, + "rating": null, + "review": null }, { "title": "1984", "author": "George Orwell", "year": 1949, - "read": true + "read": true, + "rating": null, + "review": null }, { "title": "Dune", "author": "Frank Herbert", "year": 1965, - "read": false + "read": true, + "rating": 5, + "review": "Amazing adventure" }, { "title": "To Kill a Mockingbird", "author": "Harper Lee", "year": 1960, - "read": false + "read": false, + "rating": null, + "review": null }, { "title": "Mysterious Book", "author": "", "year": 0, - "read": false + "read": false, + "rating": null, + "review": null } -] +] \ No newline at end of file diff --git a/samples/book-app-project/tests/test_books.py b/samples/book-app-project/tests/test_books.py index 061149c5..9a600d3d 100644 --- a/samples/book-app-project/tests/test_books.py +++ b/samples/book-app-project/tests/test_books.py @@ -51,3 +51,99 @@ def test_remove_book_invalid(): collection = BookCollection() result = collection.remove_book("Nonexistent Book") assert result is False + +def test_handle_read(monkeypatch): + collection = BookCollection() + collection.add_book("Dune", "Frank Herbert", 1965) + monkeypatch.setattr("builtins.input", lambda _: "Dune") + import book_app + book_app.collection = collection + book_app.handle_read() + book = collection.find_book_by_title("Dune") + assert book.read is True + +def test_handle_read_not_found(monkeypatch, capsys): + collection = BookCollection() + monkeypatch.setattr("builtins.input", lambda _: "Nonexistent Book") + import book_app + book_app.collection = collection + book_app.handle_read() + captured = capsys.readouterr() + assert "not found" in captured.out + + +def test_search_by_partial_title(): + collection = BookCollection() + collection.add_book("The Hobbit", "J.R.R. Tolkien", 1937) + collection.add_book("1984", "George Orwell", 1949) + results = collection.search("hobbit") + assert len(results) == 1 + assert results[0].title == "The Hobbit" + +def test_search_by_partial_author(): + collection = BookCollection() + collection.add_book("1984", "George Orwell", 1949) + collection.add_book("Animal Farm", "George Orwell", 1945) + results = collection.search("orwell") + assert len(results) == 2 + +def test_search_multiple_results(): + collection = BookCollection() + collection.add_book("The Hobbit", "J.R.R. Tolkien", 1937) + collection.add_book("The Lord of the Rings", "J.R.R. Tolkien", 1954) + collection.add_book("1984", "George Orwell", 1949) + results = collection.search("the") + assert len(results) == 2 + +def test_search_no_match(): + collection = BookCollection() + collection.add_book("1984", "George Orwell", 1949) + results = collection.search("xyz_no_match") + assert results == [] + + +def test_rate_book_valid(): + collection = BookCollection() + collection.add_book("Dune", "Frank Herbert", 1965) + collection.mark_as_read("Dune") + result = collection.rate_book("Dune", 5, "A masterpiece!") + assert result is True + book = collection.find_book_by_title("Dune") + assert book.rating == 5 + assert book.review == "A masterpiece!" + + +def test_rate_book_unread(): + collection = BookCollection() + collection.add_book("Dune", "Frank Herbert", 1965) + result = collection.rate_book("Dune", 4) + assert result is False + book = collection.find_book_by_title("Dune") + assert book.rating is None + + +def test_rate_book_not_found(): + collection = BookCollection() + result = collection.rate_book("Nonexistent Book", 3) + assert result is False + + +def test_rate_book_invalid_rating(): + collection = BookCollection() + collection.add_book("Dune", "Frank Herbert", 1965) + collection.mark_as_read("Dune") + with pytest.raises(ValueError): + collection.rate_book("Dune", 6) + with pytest.raises(ValueError): + collection.rate_book("Dune", 0) + + +def test_rate_book_overwrite(): + collection = BookCollection() + collection.add_book("Dune", "Frank Herbert", 1965) + collection.mark_as_read("Dune") + collection.rate_book("Dune", 3, "Good") + collection.rate_book("Dune", 5, "Actually great!") + book = collection.find_book_by_title("Dune") + assert book.rating == 5 + assert book.review == "Actually great!" diff --git a/samples/book-app-project/utils.py b/samples/book-app-project/utils.py index 4151dcda..3e66807c 100644 --- a/samples/book-app-project/utils.py +++ b/samples/book-app-project/utils.py @@ -1,4 +1,4 @@ -def print_menu(): +def print_menu() -> None: print("\n📚 Book Collection App") print("1. Add a book") print("2. List books") @@ -8,24 +8,56 @@ def print_menu(): def get_user_choice() -> str: - return input("Choose an option (1-5): ").strip() + while True: + choice = input("Choose an option (1-5): ").strip() + if not choice: + print("Input cannot be empty. Please enter a number between 1 and 5.") + elif not choice.isdigit(): + print("Invalid input. Please enter a number between 1 and 5.") + else: + return choice -def get_book_details(): - title = input("Enter book title: ").strip() - author = input("Enter author: ").strip() +def get_book_details() -> tuple[str, str, int]: + """Interactively prompt the user for book details with input validation. - year_input = input("Enter publication year: ").strip() - try: - year = int(year_input) - except ValueError: - print("Invalid year. Defaulting to 0.") - year = 0 + Prompts the user to enter a book title, author, and publication year. + Each field is validated and re-prompted until valid input is provided: + - Title and author must be non-empty strings. + - Year must be an integer between 1 and 2100. + + Returns: + tuple[str, str, int]: A tuple of (title, author, year) where: + - title (str): The book's title. + - author (str): The book's author. + - year (int): The book's publication year. + """ + while True: + title = input("Enter book title: ").strip() + if title: + break + print("Title cannot be empty. Please try again.") + + while True: + author = input("Enter author: ").strip() + if author: + break + print("Author cannot be empty. Please try again.") + + while True: + year_input = input("Enter publication year: ").strip() + try: + year = int(year_input) + if 1 <= year <= 2100: + break + print("Please enter a valid year between 1 and 2100.") + except ValueError: + print("Invalid year. Please enter a number.") return title, author, year -def print_books(books): +def print_books(books: list) -> None: if not books: print("No books in your collection.") return @@ -33,4 +65,6 @@ def print_books(books): 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}") + stars = (" " + "★" * book.rating + "☆" * (5 - book.rating)) if book.rating else "" + review = f' — "{book.review}"' if book.review else "" + print(f"{index}. {book.title} by {book.author} ({book.year}) - {status}{stars}{review}")