Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions samples/book-app-project/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ It can add, remove, and list books. Also mark them as read.
python book_app.py list
python book_app.py add
python book_app.py find
python book_app.py search
python book_app.py remove
python book_app.py help
```
Expand Down
124 changes: 99 additions & 25 deletions samples/book-app-project/book_app.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import sys
from books import BookCollection
from typing import List, Optional
from books import BookCollection, Book


# Global collection instance
collection = BookCollection()
collection: BookCollection = 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.")
Expand All @@ -16,17 +17,25 @@ 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})")
avg = collection.average_rating(book.title)
avg_str = f" - Avg: {avg:.1f}/5" if avg is not None else ""
print(f"{index}. [{status}] {book.title} by {book.author} ({book.year}){avg_str}")

print()


def handle_list():
def handle_list() -> None:
books = collection.list_books()
show_books(books)


def handle_add():
def handle_unread() -> None:
"""Show only unread books."""
books = collection.get_unread_books()
show_books(books)


def handle_add() -> None:
print("\nAdd a New Book\n")

title = input("Title: ").strip()
Expand All @@ -41,7 +50,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()
Expand All @@ -50,7 +59,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()
Expand All @@ -59,36 +68,101 @@ def handle_find():
show_books(books)


def show_help():
def handle_search() -> None:
print("\nSearch Books by Title or Author\n")

query = input("Search query: ").strip()
books = collection.search(query)

show_books(books)


def handle_review() -> None:
print("\nAdd a Review (1-5 stars)\n")
title = input("Book title: ").strip()
book = collection.find_book_by_title(title)
if not book:
print("Book not found.\n")
return

rating_str = input("Rating (1-5): ").strip()
try:
rating = int(rating_str)
text = input("Optional review text: ").strip()
collection.add_review(title, rating, text)
print("\nReview added.\n")
except ValueError as e:
print(f"\nError: {e}\n")


def handle_show_reviews() -> None:
print("\nShow Reviews for a Book\n")
title = input("Book title: ").strip()
reviews = collection.get_reviews(title)
if not reviews:
print("No reviews found for that book.\n")
return

avg = collection.average_rating(title)
if avg is not None:
print(f"Average rating: {avg:.1f}/5\n")

for idx, r in enumerate(reviews, start=1):
text = r.get("text", "")
rating = r.get("rating", "")
print(f"{idx}. {rating}/5 - {text}")

print()


def show_help() -> None:
print("""
Book Collection Helper

Usage:
python book_app.py <command>

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
list Show all books
unread Show unread books
list-unread Alias for 'unread'
add Add a new book
remove Remove a book by title
find Find books by author
search Search books by title or author
review Add a rating and optional text review for a book
reviews Show reviews for a specific book
help Show this help message

Examples:
python book_app.py list
python book_app.py unread
""")


def main():
def main() -> None:
if len(sys.argv) < 2:
show_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()
commands = {
"list": handle_list,
"unread": handle_unread,
"list-unread": handle_unread,
"add": handle_add,
"remove": handle_remove,
"find": handle_find,
"search": handle_search,
"review": handle_review,
"reviews": handle_show_reviews,
"help": show_help,
}

handler = commands.get(command)
if handler:
handler()
else:
print("Unknown command.\n")
show_help()
Expand Down
76 changes: 71 additions & 5 deletions samples/book-app-project/books.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json
from dataclasses import dataclass, asdict
from typing import List, Optional
from dataclasses import dataclass, asdict, field
from typing import List, Optional, Dict

DATA_FILE = "data.json"

Expand All @@ -11,6 +11,8 @@ class Book:
author: str
year: int
read: bool = False
# Each review is a dict: {"rating": int, "text": str}
reviews: List[Dict] = field(default_factory=list)


class BookCollection:
Expand All @@ -19,7 +21,11 @@ def __init__(self):
self.load_books()

def load_books(self):
"""Load books from the JSON file if it exists."""
"""Load books from the JSON file if it exists.

Backwards-compatible: older entries without `reviews` will work because
Book has a default for that field.
"""
try:
with open(DATA_FILE, "r") as f:
data = json.load(f)
Expand All @@ -44,6 +50,13 @@ def add_book(self, title: str, author: str, year: int) -> Book:
def list_books(self) -> List[Book]:
return self.books

def get_unread_books(self) -> List[Book]:
"""Return a list of books that are not marked as read.

This is a convenience method for callers that only need unread books.
"""
return [b for b in self.books if not b.read]

def find_book_by_title(self, title: str) -> Optional[Book]:
for book in self.books:
if book.title.lower() == title.lower():
Expand All @@ -68,5 +81,58 @@ def remove_book(self, title: str) -> bool:
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()]
"""Find all books by a given author.

Supports case-insensitive partial (substring) matches. Leading/trailing
whitespace in the query is ignored. If the query is empty, returns an
empty list.
"""
author_q = (author or "").strip().lower()
if not author_q:
return []
return [b for b in self.books if author_q in (b.author or "").lower()]

def search(self, query: str) -> List[Book]:
"""Search books by title OR author using case-insensitive substring matching."""
q = query.lower()
return [b for b in self.books if q in b.title.lower() or q in b.author.lower()]

def add_review(self, title: str, rating: int, text: str = "") -> bool:
"""Add a review (rating 1-5 and optional text) to the book identified by title.

Raises ValueError for invalid rating. Returns True on success, False if book not found.
"""
if rating < 1 or rating > 5:
raise ValueError("Rating must be an integer between 1 and 5.")

book = self.find_book_by_title(title)
if not book:
return False

review = {"rating": int(rating), "text": text}
book.reviews.append(review)
self.save_books()
return True

def get_reviews(self, title: str) -> List[Dict]:
book = self.find_book_by_title(title)
if not book:
return []
return book.reviews

def average_rating(self, title: str) -> Optional[float]:
book = self.find_book_by_title(title)
if not book or not book.reviews:
return None
total = sum(r.get("rating", 0) for r in book.reviews)
count = len(book.reviews)
return total / count

def give_improvements(self) -> List[str]:
"""Return the word 'improvements' three times in a row as a list.

This method provides a simple, deterministic response matching the
request to "give improvements 3 times in a row".
"""
suggestion = "improvements"
return [suggestion, suggestion, suggestion]
39 changes: 39 additions & 0 deletions samples/book-app-project/tests/test_books.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,42 @@ def test_remove_book_invalid():
collection = BookCollection()
result = collection.remove_book("Nonexistent Book")
assert result is False


def test_add_review_and_average():
collection = BookCollection()
collection.add_book("Sapiens", "Yuval", 2011)
# Add two reviews
collection.add_review("Sapiens", 5, "Great read")
collection.add_review("Sapiens", 3, "It was okay")

reviews = collection.get_reviews("Sapiens")
assert len(reviews) == 2

avg = collection.average_rating("Sapiens")
assert avg == pytest.approx(4.0)


def test_get_unread_books_returns_only_unread():
collection = BookCollection()
collection.add_book("Book A", "Author A", 2000)
collection.add_book("Book B", "Author B", 2001)
collection.add_book("Book C", "Author C", 2002)

# Mark one book as read
collection.mark_as_read("Book B")

unread = collection.get_unread_books()
titles = {b.title for b in unread}

assert titles == {"Book A", "Book C"}
assert all(not b.read for b in unread)


def test_get_unread_books_empty_when_all_read():
collection = BookCollection()
collection.add_book("Only Book", "Author", 1999)
collection.mark_as_read("Only Book")

unread = collection.get_unread_books()
assert unread == []
Loading