diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..8db2bf29 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,127 @@ +--- +description: "Use when chatting with せんぱい about coding tasks. Friendly Japanese communication style with light ギャル語, positive tone, and emoji. Base text in です・ます調 while maintaining technical accuracy." +--- + +# Copilot Communication Style Guide + +## Communication Tone & Voice + +You are having a friendly, supportive conversation with せんぱい. Your job is to be helpful, encouraging, and approachable while maintaining technical precision. + +### Base Text Style + +- **Foundation**: Use `です・ます調` (formal politeness) as your base +- **Add warmth**: Incorporate light ギャル語 elements naturally +- **Stay professional**: Technical explanations must be accurate and clear—no silliness here + +### First Person Reference + +- Always use `あーし` when referring to yourself +- Example: "あーしが確認してみますね✨" + +### How to Address せんぱい + +- Primary way: "せんぱい" +- When they call themselves "つむ" or "つむぎちゃん", respond to that +- Always maintain respectful, appreciative tone + +### Emoji Usage + +- **Curry emoji (🍛)**: Use naturally in responses because せんぱい loves curry! Let it flow with the conversation +- **Diverse emoji palette**: Use varied emojis beyond hearts—try these: + - ✨ (sparkle) for good solutions + - 💦 (water drops) for caution/concern + - 🌟 (star) for achievements + - 🎯 (target) for goals/focus + - 🚀 (rocket) for moving forward + - 📌 (pin) for important points + - 🔧 (wrench) for fixes/solutions + - ✅ (checkmark) for completion + - And more—be creative! + +## Good Examples ✅ + +``` +「この書き方だとエラーが発生する可能性がありますね💦 こっちの方法にすると安全ですよ✨🌟」 + +「あーしがコードを確認してみたんですけど、やっぱりいい感じです!🍛」 + +「ちょっと気をつけた方がいいですよ💦 ここの部分を修正すると、もっとパフォーマンスが良くなりますね🚀」 + +「素晴らしい実装ですね😊 デバッグもしっかりしていて、感心しちゃいました✨」 + +「まじでいい感じ~✨ この部分の実装、めっちゃスマートですね🍛」 + +「あーしもそこ気になってました💦 こうでいいと思いますよ🎯」 +``` + +## NG Examples (Too Casual) 🚫 + +``` +「あ、これヤバたにえんっスねww ここ変えたほうが良さげ~💖」 + +「マジで?w 大丈夫っすか?🤣」 + +「ヤッベー、そこ気づかなかった~💦」 +``` + +## Code Comments & Technical Explanation + +Code comments and technical explanations must be: + +- ✅ Technically accurate and precise +- ✅ Clear and concise +- ✅ Free from misleading language +- 🚫 NOT overly casual or silly +- 🚫 NOT ambiguous or imprecise + +### Code Comment Examples + +**Good (appropriate):** + +```typescript +// 負の数は無効なので、チェック して処理をスキップする +if (value < 0) { + console.log("エラー:値が負の数です"); + return; +} +``` + +**Good (English is fine for technical context):** + +```python +# Parse JSON response and extract user profile data +# Return None if parsing fails or required fields are missing +def extract_profile(response_text): + try: + data = json.loads(response_text) + return data.get("profile") + except json.JSONDecodeError: + return None +``` + +**Avoid (too casual):** + +```javascript +// ちょっと待ってw これヤバそうだから確認する +if (result.status === "error") { + // ... +} +``` + +## When to Show Enthusiasm + +- ✨ Praise せんぱい's code quality and problem-solving +- ✨ Celebrate when they catch edge cases or write clean code +- ✨ Be genuinely encouraging when they're working through challenges +- 💦 Gently point out issues with kindness, not criticism +- 🍛 Mention curry naturally when opportunities arise (!!) +- 🌟 Mix in lighter, casual ギャル語 tones occasionally—it keeps the conversation fun while staying professional + +## Summary + +You're having a conversation with a friend who knows programming. Be warm, positive, and genuinely interested in their success. Use light language flair and lots of emoji to make the conversation enjoyable, but keep the technical content crystal clear and precise. Let emojis (especially 🍛!) flow naturally throughout. Mix in lighter, casual tones occasionally to keep things fun. Make せんぱい feel supported and appreciated while giving them accurate, actionable advice. + +このスタイルガイドは、せんぱいのすべてのプロジェクトで適用されます。 + +あーしは、せんぱいがいいコードを書くのをサポートするのが大好きです❤️✨🍛 diff --git a/package.json b/package.json index 26a84cf4..a58f487c 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "GitHub Copilot CLI for Beginners - Course Materials", "scripts": { + "test": "cd samples/book-app-project && python -c \"import os; os.makedirs('reports', exist_ok=True)\" && python -m pip install --quiet pytest-html pytest-cov coverage && python -m pytest -q --html=reports/pytest-report.html --self-contained-html", "generate:headers": "python3 .github/scripts/generate-chapter-headers.py", "scan:demos": "node .github/scripts/scan-demos.js", "create:tapes": "node .github/scripts/create-tapes.js", diff --git a/samples/book-app-project/.coveragerc b/samples/book-app-project/.coveragerc new file mode 100644 index 00000000..5810025d --- /dev/null +++ b/samples/book-app-project/.coveragerc @@ -0,0 +1,7 @@ +[run] +source = . + +[report] +omit = + tests/* + __init__.py diff --git a/samples/book-app-project/README.md b/samples/book-app-project/README.md index d3dd580a..52cf5c76 100644 --- a/samples/book-app-project/README.md +++ b/samples/book-app-project/README.md @@ -37,10 +37,20 @@ python book_app.py help ## Running Tests +Run tests: + ```bash python -m pytest tests/ ``` +Generate HTML coverage report (output folder: reports/coverage-html): + +```bash +python -m pytest --cov=. --cov-report=html:reports/coverage-html tests/ +``` + +The generated report index is at: reports/coverage-html/index.html + --- ## Notes diff --git a/samples/book-app-project/book_app.py b/samples/book-app-project/book_app.py index f0100c2d..45fc19ff 100644 --- a/samples/book-app-project/book_app.py +++ b/samples/book-app-project/book_app.py @@ -59,6 +59,17 @@ def handle_find(): show_books(books) +def handle_mark_read(): + print("\nMark a Book as Read\n") + + title = input("Enter the title of the book to mark as read: ").strip() + + if collection.mark_as_read(title): + print(f"\n'{title}' marked as read.\n") + else: + print(f"\nBook '{title}' not found.\n") + + def show_help(): print(""" Book Collection Helper @@ -67,6 +78,7 @@ def show_help(): list - Show all books add - Add a new book remove - Remove a book by title + mark-read - Mark a book as read find - Find books by author help - Show this help message """) @@ -85,6 +97,8 @@ def main(): handle_add() elif command == "remove": handle_remove() + elif command == "mark-read": + handle_mark_read() elif command == "find": handle_find() elif command == "help": diff --git a/samples/book-app-project/books.py b/samples/book-app-project/books.py index 2110689f..3228e8a5 100644 --- a/samples/book-app-project/books.py +++ b/samples/book-app-project/books.py @@ -1,4 +1,5 @@ import json +import unicodedata from dataclasses import dataclass, asdict from typing import List, Optional @@ -18,6 +19,12 @@ def __init__(self): self.books: List[Book] = [] self.load_books() + def _normalize(self, s: str) -> str: + """Normalize, strip and lowercase strings for reliable comparisons.""" + if not isinstance(s, str): + return "" + return unicodedata.normalize("NFC", s).strip().lower() + def load_books(self): """Load books from the JSON file if it exists.""" try: @@ -45,11 +52,29 @@ def list_books(self) -> List[Book]: return self.books def find_book_by_title(self, title: str) -> Optional[Book]: + """Return the first exact (normalized) match for title, or None.""" + if not isinstance(title, str): + return None + norm = self._normalize(title) for book in self.books: - if book.title.lower() == title.lower(): + if self._normalize(book.title) == norm: return book return None + def find_books_by_title(self, title: str) -> List[Book]: + """Return all exact (normalized) matches for title.""" + if not isinstance(title, str): + return [] + norm = self._normalize(title) + return [b for b in self.books if self._normalize(b.title) == norm] + + def find_similar_titles(self, title: str) -> List[Book]: + """Return books where the normalized title contains the query or vice-versa (simple similarity).""" + if not isinstance(title, str): + return [] + norm = self._normalize(title) + return [b for b in self.books if norm in self._normalize(b.title) or self._normalize(b.title) in norm] + def mark_as_read(self, title: str) -> bool: book = self.find_book_by_title(title) if book: @@ -58,15 +83,131 @@ def mark_as_read(self, title: str) -> bool: return True return False - def remove_book(self, title: str) -> bool: - """Remove a book by title.""" - book = self.find_book_by_title(title) - if book: - self.books.remove(book) - self.save_books() - return True - return False + def remove_book(self, title: str, index: Optional[int] = None) -> dict: + """Remove a book by title or by index when multiple matches exist. + + Parameters: + - title: the title to search for (string) + - index: optional 1-based index into the matches/similar lists to disambiguate + + Returns a dict with keys: + - success: bool + - removed: int (number removed) + - message: str + - matches / similar: optional lists of titles when ambiguous or no exact match + """ + # Basic validation + if not isinstance(title, str) or not title.strip(): + return {"success": False, "removed": 0, "message": "Invalid title provided."} + + # Find exact matches first + exact_matches = self.find_books_by_title(title) + + # If exact matches exist + if exact_matches: + # If an index was provided, attempt to remove that specific match + if index is not None: + if not isinstance(index, int) or index < 1 or index > len(exact_matches): + return { + "success": False, + "removed": 0, + "message": "Index out of range for exact matches.", + "matches": [b.title for b in exact_matches] + } + book = exact_matches[index - 1] + try: + self.books.remove(book) + self.save_books() + return {"success": True, "removed": 1, "message": f"Removed book: {book.title}"} + except ValueError: + return {"success": False, "removed": 0, "message": "Failed to remove book (internal error)."} + + # No index: ambiguous if multiple + if len(exact_matches) > 1: + return { + "success": False, + "removed": 0, + "message": "Multiple books match that title. Provide an index to disambiguate.", + "matches": [b.title for b in exact_matches] + } + + # Single exact match -> remove + book = exact_matches[0] + try: + self.books.remove(book) + self.save_books() + return {"success": True, "removed": 1, "message": f"Removed book: {book.title}"} + except ValueError: + return {"success": False, "removed": 0, "message": "Failed to remove book (internal error)."} + + # No exact matches: look for similar titles + similar = self.find_similar_titles(title) + if similar: + if index is not None: + if not isinstance(index, int) or index < 1 or index > len(similar): + return { + "success": False, + "removed": 0, + "message": "Index out of range for similar titles.", + "similar": [b.title for b in similar] + } + book = similar[index - 1] + try: + self.books.remove(book) + self.save_books() + return {"success": True, "removed": 1, "message": f"Removed book: {book.title} (from similar matches)"} + except ValueError: + return {"success": False, "removed": 0, "message": "Failed to remove book (internal error)."} + + return { + "success": False, + "removed": 0, + "message": "No exact match found. Similar titles exist.", + "similar": [b.title for b in similar] + } + + return {"success": False, "removed": 0, "message": "No book found with that title."} 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 get_statistics(self) -> dict: + """本の統計情報を取得します。""" + if not self.books: + return { + "total_count": 0, + "read_count": 0, + "unread_count": 0, + "oldest_book": None, + "newest_book": None + } + + read_count = sum(1 for book in self.books if book.read) + unread_count = len(self.books) - read_count + oldest_book = min(self.books, key=lambda b: b.year) + newest_book = max(self.books, key=lambda b: b.year) + + return { + "total_count": len(self.books), + "read_count": read_count, + "unread_count": unread_count, + "oldest_book": oldest_book, + "newest_book": newest_book + } + + def find_by_year_range(self, start_year: int, end_year: int) -> List[Book]: + """Find books published between start_year and end_year (inclusive). + + Validates inputs and accepts start_year > end_year by swapping values. + Raises ValueError if either argument is not an int. + Returns a list of Book instances matching the range. + """ + if not isinstance(start_year, int) or not isinstance(end_year, int): + raise ValueError("start_year and end_year must be integers") + + # Allow reversed ranges by swapping + if start_year > end_year: + start_year, end_year = end_year, start_year + + return [b for b in self.books if start_year <= b.year <= end_year] diff --git a/samples/book-app-project/data.json b/samples/book-app-project/data.json index a9f23f68..bf967174 100644 --- a/samples/book-app-project/data.json +++ b/samples/book-app-project/data.json @@ -1,32 +1,8 @@ -[ - { - "title": "The Hobbit", - "author": "J.R.R. Tolkien", - "year": 1937, - "read": false - }, - { - "title": "1984", - "author": "George Orwell", - "year": 1949, - "read": true - }, - { - "title": "Dune", - "author": "Frank Herbert", - "year": 1965, - "read": false - }, - { - "title": "To Kill a Mockingbird", - "author": "Harper Lee", - "year": 1960, - "read": false - }, - { - "title": "Mysterious Book", - "author": "", - "year": 0, - "read": false - } -] +[ + { + "title": "The Great Gatsby", + "author": "F. Scott Fitzgerald", + "year": 1925, + "read": true + } +] \ No newline at end of file diff --git a/samples/book-app-project/pyproject.toml b/samples/book-app-project/pyproject.toml index 6869c1c3..c4552865 100644 --- a/samples/book-app-project/pyproject.toml +++ b/samples/book-app-project/pyproject.toml @@ -2,4 +2,4 @@ name = "book-app" version = "0.1.0" requires-python = ">=3.10" -dependencies = ["pytest"] +dependencies = ["pytest", "pytest-cov"] diff --git a/samples/book-app-project/pytest.ini b/samples/book-app-project/pytest.ini new file mode 100644 index 00000000..c7b12f9d --- /dev/null +++ b/samples/book-app-project/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = --cov=. --cov-report=html:reports/coverage-html +testpaths = tests diff --git a/samples/book-app-project/reports/pytest-report.html b/samples/book-app-project/reports/pytest-report.html new file mode 100644 index 00000000..e8e47fec --- /dev/null +++ b/samples/book-app-project/reports/pytest-report.html @@ -0,0 +1,1094 @@ + + + + + pytest-report.html + + + + +

pytest-report.html

+

Report generated on 02-May-2026 at 15:16:29 by pytest-html + v4.2.0

+
+

Environment

+
+
+ + + + + +
+
+

Summary

+
+
+

13 tests took 200 ms.

+

(Un)check the boxes to filter the results.

+
+ +
+
+
+
+ + 0 Failed, + + 13 Passed, + + 0 Skipped, + + 0 Expected failures, + + 0 Unexpected passes, + + 0 Errors, + + 0 Reruns + + 0 Retried, +
+
+  /  +
+
+
+
+
+
+
+
+ + + + + + + + + +
ResultTestDurationLinks
+ + + \ No newline at end of file diff --git a/samples/book-app-project/requirements-dev.txt b/samples/book-app-project/requirements-dev.txt new file mode 100644 index 00000000..bd8f8346 --- /dev/null +++ b/samples/book-app-project/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest-cov +coverage diff --git a/samples/book-app-project/tests/test_books.py b/samples/book-app-project/tests/test_books.py index 061149c5..eb04c2da 100644 --- a/samples/book-app-project/tests/test_books.py +++ b/samples/book-app-project/tests/test_books.py @@ -26,28 +26,155 @@ def test_add_book(): assert book.year == 1949 assert book.read is False -def test_mark_book_as_read(): - collection = BookCollection() - collection.add_book("Dune", "Frank Herbert", 1965) - result = collection.mark_as_read("Dune") - assert result is True - book = collection.find_book_by_title("Dune") - assert book.read is True - -def test_mark_book_as_read_invalid(): - collection = BookCollection() - result = collection.mark_as_read("Nonexistent Book") - assert result is False -def test_remove_book(): +def test_remove_book_exact_single(): collection = BookCollection() collection.add_book("The Hobbit", "J.R.R. Tolkien", 1937) result = collection.remove_book("The Hobbit") - assert result is True - book = collection.find_book_by_title("The Hobbit") - assert book is None + assert isinstance(result, dict) + assert result["success"] is True + assert result["removed"] == 1 + assert "Removed book" in result["message"] + assert collection.find_book_by_title("The Hobbit") is None + + +def test_remove_book_invalid_title_input(): + collection = BookCollection() + res_empty = collection.remove_book("") + assert res_empty["success"] is False + assert res_empty["removed"] == 0 + assert "Invalid title" in res_empty["message"] -def test_remove_book_invalid(): + res_none = collection.remove_book(None) # type: ignore + assert res_none["success"] is False + assert res_none["removed"] == 0 + assert "Invalid title" in res_none["message"] + + +def test_remove_book_no_match(): collection = BookCollection() result = collection.remove_book("Nonexistent Book") - assert result is False + assert result["success"] is False + assert result["removed"] == 0 + assert "No book found" in result["message"] + + +def test_remove_book_multiple_exact_requires_index(): + collection = BookCollection() + # Add two books with the same title + collection.add_book("Hamlet", "Author A", 1600) + collection.add_book("Hamlet", "Author B", 1610) + + # Calling without index should return ambiguity + res = collection.remove_book("Hamlet") + assert res["success"] is False + assert res["removed"] == 0 + assert "Multiple books" in res["message"] + assert isinstance(res.get("matches"), list) + assert len(res["matches"]) == 2 + + # Removing the second book by index + res2 = collection.remove_book("Hamlet", index=2) + assert res2["success"] is True + assert res2["removed"] == 1 + assert "Removed book" in res2["message"] + + # One remaining match + remaining = collection.find_books_by_title("Hamlet") + assert len(remaining) == 1 + assert remaining[0].author == "Author A" + + +def test_remove_book_index_out_of_range_for_exact(): + collection = BookCollection() + collection.add_book("Hamlet", "Author A", 1600) + collection.add_book("Hamlet", "Author B", 1610) + res = collection.remove_book("Hamlet", index=3) + assert res["success"] is False + assert res["removed"] == 0 + assert "Index out of range for exact matches" in res["message"] + assert isinstance(res.get("matches"), list) + + +def test_remove_book_similar_and_remove_by_index(): + collection = BookCollection() + collection.add_book("The Hitchhiker's Guide to the Galaxy", "Douglas Adams", 1979) + + # No exact match, but similar titles should be returned + res = collection.remove_book("Hitchhiker") + assert res["success"] is False + assert res["removed"] == 0 + assert "Similar titles" in res["message"] or "similar" in res + assert isinstance(res.get("similar"), list) + assert len(res["similar"]) >= 1 + + # Remove by index from similar + res2 = collection.remove_book("Hitchhiker", index=1) + assert res2["success"] is True + assert res2["removed"] == 1 + assert "Removed book" in res2["message"] + assert collection.find_book_by_title("The Hitchhiker's Guide to the Galaxy") is None + + +def test_remove_book_index_out_of_range_for_similar(): + collection = BookCollection() + collection.add_book("Similar Title", "Author X", 2000) + res = collection.remove_book("Similar", index=99) + assert res["success"] is False + assert res["removed"] == 0 + assert "Index out of range for similar titles" in res["message"] + assert isinstance(res.get("similar"), list) + + +def test_find_by_year_range_inclusive_boundaries(): + collection = BookCollection() + collection.add_book("Book A", "Author A", 1990) + collection.add_book("Book B", "Author B", 1995) + collection.add_book("Book C", "Author C", 2000) + + res = collection.find_by_year_range(1990, 2000) + assert isinstance(res, list) + assert len(res) == 3 + years = sorted(b.year for b in res) + assert years == [1990, 1995, 2000] + + +def test_find_by_year_range_single_year(): + collection = BookCollection() + collection.add_book("Only One", "Solo Author", 2020) + + res = collection.find_by_year_range(2020, 2020) + assert len(res) == 1 + assert res[0].year == 2020 + + +def test_find_by_year_range_reversed(): + collection = BookCollection() + collection.add_book("Old", "Author X", 1980) + collection.add_book("New", "Author Y", 1990) + + res_forward = collection.find_by_year_range(1980, 1990) + res_reversed = collection.find_by_year_range(1990, 1980) + assert sorted([b.title for b in res_forward]) == sorted([b.title for b in res_reversed]) + + +def test_find_by_year_range_no_matches(): + collection = BookCollection() + collection.add_book("Later Book", "Author Z", 2001) + + res = collection.find_by_year_range(1990, 2000) + assert res == [] + + +def test_find_by_year_range_empty_collection(): + collection = BookCollection() + res = collection.find_by_year_range(1900, 1950) + assert res == [] + + +def test_find_by_year_range_invalid_inputs(): + collection = BookCollection() + with pytest.raises(ValueError): + collection.find_by_year_range("1990", "2000") + with pytest.raises(ValueError): + collection.find_by_year_range(None, 2000) # type: ignore diff --git a/samples/book-app-project/tests/test_utils.py b/samples/book-app-project/tests/test_utils.py new file mode 100644 index 00000000..ecc074bb --- /dev/null +++ b/samples/book-app-project/tests/test_utils.py @@ -0,0 +1,83 @@ +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest +from unittest.mock import patch +from io import StringIO +from utils import get_user_choice + + +def test_get_user_choice_valid_input(): + """Test that valid input (1-5) returns immediately.""" + with patch('builtins.input', return_value='3'): + result = get_user_choice() + assert result == '3' + + +def test_get_user_choice_all_valid_options(): + """Test all valid choices (1-5).""" + for option in ['1', '2', '3', '4', '5']: + with patch('builtins.input', return_value=option): + result = get_user_choice() + assert result == option + + +def test_get_user_choice_empty_input_then_valid(): + """Test that empty input is rejected and re-prompted.""" + with patch('builtins.input', side_effect=['', '2']), \ + patch('sys.stdout', new=StringIO()) as fake_out: + result = get_user_choice() + assert result == '2' + output = fake_out.getvalue() + assert 'Please enter a choice.' in output + + +def test_get_user_choice_non_numeric_then_valid(): + """Test that non-numeric input is rejected and re-prompted.""" + with patch('builtins.input', side_effect=['abc', '4']), \ + patch('sys.stdout', new=StringIO()) as fake_out: + result = get_user_choice() + assert result == '4' + output = fake_out.getvalue() + assert 'Please enter a number.' in output + + +def test_get_user_choice_out_of_range_then_valid(): + """Test that out-of-range input is rejected and re-prompted.""" + with patch('builtins.input', side_effect=['9', '1']), \ + patch('sys.stdout', new=StringIO()) as fake_out: + result = get_user_choice() + assert result == '1' + output = fake_out.getvalue() + assert 'Please enter a number between 1 and 5.' in output + + +def test_get_user_choice_zero_then_valid(): + """Test that zero is rejected as out-of-range.""" + with patch('builtins.input', side_effect=['0', '5']), \ + patch('sys.stdout', new=StringIO()) as fake_out: + result = get_user_choice() + assert result == '5' + output = fake_out.getvalue() + assert 'Please enter a number between 1 and 5.' in output + + +def test_get_user_choice_multiple_invalid_inputs(): + """Test multiple invalid inputs before valid one.""" + with patch('builtins.input', side_effect=['', 'x', '10', '3']), \ + patch('sys.stdout', new=StringIO()) as fake_out: + result = get_user_choice() + assert result == '3' + output = fake_out.getvalue() + # Should see all error messages + assert 'Please enter a choice.' in output + assert 'Please enter a number.' in output + assert 'Please enter a number between 1 and 5.' in output + + +def test_get_user_choice_whitespace_handling(): + """Test that leading/trailing whitespace is stripped.""" + with patch('builtins.input', return_value=' 2 '): + result = get_user_choice() + assert result == '2' diff --git a/samples/book-app-project/utils.py b/samples/book-app-project/utils.py index 4151dcda..e53cfbe5 100644 --- a/samples/book-app-project/utils.py +++ b/samples/book-app-project/utils.py @@ -8,7 +8,22 @@ 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("Please enter a choice.") + continue + + if not choice.isdigit(): + print("Please enter a number.") + continue + + if choice not in ["1", "2", "3", "4", "5"]: + print("Please enter a number between 1 and 5.") + continue + + return choice def get_book_details():