Skip to content
Open
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
49 changes: 31 additions & 18 deletions samples/book-app-buggy/books_buggy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()]
160 changes: 112 additions & 48 deletions samples/book-app-project/book_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
Loading