diff --git a/11_testing/01_test_basics.py b/11_testing/01_test_basics.py new file mode 100644 index 0000000..50c7cc0 --- /dev/null +++ b/11_testing/01_test_basics.py @@ -0,0 +1,36 @@ +""" +================================================================================ +File: 01_test_basics.py +Topic: Introduction to Unit Testing with pytest +================================================================================ + +This file introduces unit testing, a professional practice used to ensure that +code behaves as expected. We use 'pytest', the industry standard for Python. + +Key Concepts: +- Writing test functions (starting with test_) +- Assertions (checking if conditions are True) +- Running tests from the terminal +================================================================================ +""" + +import pytest + +# A simple function we want to test +def add(a: int, b: int) -> int: + return a + b + +def test_add_positive(): + """Test addition of positive numbers.""" + assert add(2, 3) == 5 + +def test_add_negative(): + """Test addition of negative numbers.""" + assert add(-1, -1) == -2 + +def test_add_zero(): + """Test addition with zero.""" + assert add(5, 0) == 5 + +if __name__ == "__main__": + print("Run this file using: pytest 11_testing/01_test_basics.py") diff --git a/README.md b/README.md index 8b48fa3..e089489 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,6 @@ A practical, example-driven Python repository designed to help you learn Python Each file is self-contained and focuses on a single concept, making the repository suitable for structured learning, revision, or reference. -### 🌐 **Visit the Interactive Website** -Want to follow your progress? Visit the website at [pythonbyexample.page.gd](https://pythonbyexample.page.gd/) - > **If you like this project, please hit the ⭐ Star button and follow me on GitHub [@blshaer](https://github.com/blshaer)!** --- @@ -25,6 +22,7 @@ Want to follow your progress? Visit the website at [pythonbyexample.page.gd](htt - [Prerequisites](#-prerequisites) - [File Structure](#-file-structure) - [Learning Path](#-learning-path) +- [Projects](#-projects) --- @@ -119,21 +117,50 @@ This repository contains a complete Python tutorial designed for both beginners ## Getting Started +Follow these steps to clone the repository and set it up in your local editor: + 1. **Clone the repository** + Open your terminal and run: ```bash - git clone https://github.com/blshaer/python_review.git - cd python_review + git clone https://github.com/blshaer/python-by-example.git + cd python-by-example ``` -2. **Start from basics** +2. **Set up a Virtual Environment (Recommended)** + It's best practice to keep your projects isolated. Run these commands: + ```bash + python -m venv venv + # On Windows: + .\venv\Scripts\activate + # On macOS/Linux: + source venv/bin/activate + ``` + +3. **Install Dependencies** + Install the necessary tools for testing and formatting: + ```bash + pip install -r requirements.txt + ``` + +4. **Open in your code editor** + We recommend [Visual Studio Code](https://code.visualstudio.com/). Open the project directly: + ```bash + code . + ``` + +5. **Explore and Run** + Navigate to any example, like `01_basics`, and run the file: ```bash cd 01_basics python 01_print.py ``` -3. **Read the comments** — Each section is thoroughly explained +6. **Read the comments** — Detailed explanations are provided inline for every concept! -4. **Experiment** — Modify the code and observe the results +7. **Try the Tests** — Go to the new testing module to see how professional code is verified: + ```bash + pytest 11_testing/01_test_basics.py + ``` --- @@ -219,6 +246,7 @@ Key Concepts: 10. [Best Practices](./10_best_practices/) +11. [Testing](./11_testing/) @@ -229,11 +257,26 @@ Key Concepts: Beginner → 01_basics → 02_control_flow → 03_loops Intermediate → 04_data_structures → 05_functions → 06_modules Advanced → 07_error_handling → 08_oop → 09_advanced -Professional → 10_best_practices +Professional → 10_best_practices → 11_testing ``` --- +## Projects + +Put your skills to the test! Each project includes a **challenge description** (`README.md`) so you can try building it yourself before looking at the solution. + +| # | Project | Difficulty | Concepts Applied | +|:--|:--------|:-----------|:-----------------| +| 01 | [**Number Guessing Game**](./projects/01_number_guessing_game/) | 🟢 Beginner | Loops, Conditionals, Random module | +| 02 | [**Expense Tracker**](./projects/02_expense_tracker/) | 🟔 Intermediate | Dicts, File I/O (JSON), Functions | +| 03 | [**Library Management System**](./projects/03_library_management/) | 🟠 Advanced | OOP, Inheritance, Custom Exceptions | +| 04 | [**Real-time Weather CLI**](./projects/04_weather_cli/) | šŸš€ Professional | API Requests, Decorators, Type Hints | + +> šŸ’” **Tip:** Read the project `README.md` first and try to build it on your own before looking at `solution.py`! + +--- + ## Progress Tracker Use this checklist to track your learning progress: @@ -248,6 +291,13 @@ Use this checklist to track your learning progress: - [ ] **08. OOP** — Classes, Init, Inheritance, Polymorphism - [ ] **09. Advanced Python** — Comprehensions, Generators, Decorators - [ ] **10. Best Practices** — PEP8, Type Hints, Virtual Environments +- [ ] **11. Testing** — Unit Tests, Pytest, Assertions + +### šŸ—ļø Projects +- [ ] **Project 01** — Number Guessing Game (Beginner) +- [ ] **Project 02** — Expense Tracker (Intermediate) +- [ ] **Project 03** — Library Management System (Advanced) +- [ ] **Project 04** — Real-time Weather CLI (Professional) --- diff --git a/projects/01_number_guessing_game/README.md b/projects/01_number_guessing_game/README.md new file mode 100644 index 0000000..53820b4 --- /dev/null +++ b/projects/01_number_guessing_game/README.md @@ -0,0 +1,58 @@ +# šŸŽÆ Project 01: Number Guessing Game + +## Difficulty: 🟢 Beginner + +## Description +Build a command-line number guessing game where the computer picks a random number +and the player tries to guess it. The game should provide hints like "Too high!" or +"Too low!" after each guess. + +## Requirements + +1. Generate a random number between 1 and 100 +2. Ask the user for guesses until they get the correct number +3. After each guess, tell the user if their guess is too high, too low, or correct +4. Track the number of attempts and display it at the end +5. Handle invalid input gracefully (non-numeric input) +6. Ask the player if they want to play again after winning + +## Concepts You'll Practice + +- `while` loops +- `if/elif/else` conditionals +- `random` module +- `try/except` error handling +- `input()` function +- f-strings + +## Example Output + +``` +šŸŽÆ Welcome to the Number Guessing Game! +I'm thinking of a number between 1 and 100... + +Enter your guess: 50 +šŸ“‰ Too low! Try again. + +Enter your guess: 75 +šŸ“ˆ Too high! Try again. + +Enter your guess: 63 +šŸŽ‰ Congratulations! You guessed it in 3 attempts! + +Play again? (yes/no): no +Thanks for playing! Goodbye! šŸ‘‹ +``` + +## How to Run + +```bash +cd projects/01_number_guessing_game +python solution.py +``` + +## Bonus Challenges + +- [ ] Add difficulty levels (Easy: 1-50, Medium: 1-100, Hard: 1-500) +- [ ] Add a scoreboard that tracks best scores across games +- [ ] Set a maximum number of attempts based on difficulty diff --git a/projects/01_number_guessing_game/solution.py b/projects/01_number_guessing_game/solution.py new file mode 100644 index 0000000..2a41540 --- /dev/null +++ b/projects/01_number_guessing_game/solution.py @@ -0,0 +1,159 @@ +""" +================================================================================ +Project 01: Number Guessing Game +Difficulty: 🟢 Beginner +================================================================================ + +A command-line number guessing game where the computer picks a random number +and the player tries to guess it with hints provided after each attempt. + +Concepts Used: +- while loops +- if/elif/else conditionals +- random module +- try/except error handling +- input() function +- f-strings + +================================================================================ +""" + +import random + + +# ----------------------------------------------------------------------------- +# Game Configuration +# ----------------------------------------------------------------------------- + +DIFFICULTIES = { + "easy": {"min": 1, "max": 50, "attempts": 10}, + "medium": {"min": 1, "max": 100, "attempts": 7}, + "hard": {"min": 1, "max": 500, "attempts": 10}, +} + + +# ----------------------------------------------------------------------------- +# Helper Functions +# ----------------------------------------------------------------------------- + +def display_welcome(): + """Display the welcome banner.""" + print(""" +╔══════════════════════════════════════════╗ +ā•‘ šŸŽÆ NUMBER GUESSING GAME šŸŽÆ ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + """) + + +def choose_difficulty() -> dict: + """Let the player choose a difficulty level.""" + print("Choose your difficulty:") + print(" [1] 🟢 Easy (1-50, 10 attempts)") + print(" [2] 🟔 Medium (1-100, 7 attempts)") + print(" [3] šŸ”“ Hard (1-500, 10 attempts)") + + while True: + choice = input("\nYour choice (1/2/3): ").strip() + if choice == "1": + return DIFFICULTIES["easy"] + elif choice == "2": + return DIFFICULTIES["medium"] + elif choice == "3": + return DIFFICULTIES["hard"] + else: + print("āŒ Invalid choice. Please enter 1, 2, or 3.") + + +def get_guess(min_val: int, max_val: int) -> int: + """Get a valid integer guess from the player.""" + while True: + try: + guess = int(input(f"\nEnter your guess ({min_val}-{max_val}): ")) + if min_val <= guess <= max_val: + return guess + else: + print(f"āš ļø Please enter a number between {min_val} and {max_val}.") + except ValueError: + print("āš ļø That's not a valid number. Try again!") + + +def play_round(): + """Play a single round of the guessing game.""" + # Setup + config = choose_difficulty() + min_val = config["min"] + max_val = config["max"] + max_attempts = config["attempts"] + secret = random.randint(min_val, max_val) + attempts = 0 + + print(f"\nšŸ¤” I'm thinking of a number between {min_val} and {max_val}...") + print(f" You have {max_attempts} attempts. Good luck!\n") + + # Game loop + while attempts < max_attempts: + guess = get_guess(min_val, max_val) + attempts += 1 + remaining = max_attempts - attempts + + if guess < secret: + print(f"šŸ“‰ Too low! ({remaining} attempts remaining)") + elif guess > secret: + print(f"šŸ“ˆ Too high! ({remaining} attempts remaining)") + else: + print(f"\nšŸŽ‰ Congratulations! You guessed it in {attempts} attempt(s)!") + if attempts <= 3: + print("šŸ† Amazing! You're a natural!") + elif attempts <= 5: + print("šŸ‘ Well done!") + else: + print("šŸ˜… That was close!") + return True + + # Out of attempts + print(f"\nšŸ’€ Game over! The number was {secret}.") + return False + + +def play_again() -> bool: + """Ask the player if they want to play again.""" + while True: + answer = input("\nšŸ”„ Play again? (yes/no): ").strip().lower() + if answer in ("yes", "y"): + print() + return True + elif answer in ("no", "n"): + return False + else: + print("Please enter 'yes' or 'no'.") + + +# ----------------------------------------------------------------------------- +# Main Game Loop +# ----------------------------------------------------------------------------- + +def main(): + """Main entry point for the game.""" + display_welcome() + + wins = 0 + total = 0 + + while True: + total += 1 + if play_round(): + wins += 1 + + print(f"\nšŸ“Š Score: {wins} wins out of {total} games") + + if not play_again(): + break + + print(f"\n{'='*40}") + print(f" Final Score: {wins}/{total} games won") + print(f" Thanks for playing! Goodbye! šŸ‘‹") + print(f"{'='*40}\n") + + +if __name__ == "__main__": + main() diff --git a/projects/02_expense_tracker/README.md b/projects/02_expense_tracker/README.md new file mode 100644 index 0000000..7687f14 --- /dev/null +++ b/projects/02_expense_tracker/README.md @@ -0,0 +1,68 @@ +# šŸ’° Project 02: Expense Tracker + +## Difficulty: 🟔 Intermediate + +## Description +Build a command-line expense tracker that allows users to add, view, and analyze +their expenses. Data is saved to a JSON file so expenses persist between sessions. + +## Requirements + +1. Add new expenses with a description, amount, and category +2. View all expenses in a formatted table +3. Filter expenses by category +4. Show a summary with total spending and spending per category +5. Save and load expenses from a JSON file +6. Delete an expense by its ID + +## Concepts You'll Practice + +- Dictionaries and Lists +- File I/O (JSON) +- Functions with multiple parameters +- String formatting +- `datetime` module +- Data filtering and aggregation + +## Example Output + +``` +šŸ’° EXPENSE TRACKER +================== + +1. Add Expense +2. View All Expenses +3. View by Category +4. Summary +5. Delete Expense +6. Exit + +Choose an option: 1 + +Enter description: Coffee +Enter amount: 4.50 +Enter category (food/transport/entertainment/bills/other): food +āœ… Expense added successfully! + +Choose an option: 4 + +šŸ“Š EXPENSE SUMMARY +Total Expenses: $127.50 + šŸ” Food: $45.00 (35.3%) + šŸš— Transport: $32.50 (25.5%) + šŸŽ¬ Entertainment: $25.00 (19.6%) + šŸ“„ Bills: $25.00 (19.6%) +``` + +## How to Run + +```bash +cd projects/02_expense_tracker +python solution.py +``` + +## Bonus Challenges + +- [ ] Add monthly budget limits with warnings +- [ ] Export expenses to CSV +- [ ] Add date range filtering diff --git a/projects/02_expense_tracker/solution.py b/projects/02_expense_tracker/solution.py new file mode 100644 index 0000000..abae2e7 --- /dev/null +++ b/projects/02_expense_tracker/solution.py @@ -0,0 +1,244 @@ +""" +================================================================================ +Project 02: Expense Tracker +Difficulty: 🟔 Intermediate +================================================================================ + +A command-line expense tracker that lets users add, view, filter, and analyze +their expenses. Data is persisted to a JSON file between sessions. + +Concepts Used: +- Dictionaries and Lists +- File I/O (JSON) +- Functions with parameters +- String formatting +- datetime module +- Data filtering and aggregation + +================================================================================ +""" + +import json +import os +from datetime import datetime + + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- + +DATA_FILE = os.path.join(os.path.dirname(__file__), "expenses.json") + +CATEGORIES = { + "food": "šŸ”", + "transport": "šŸš—", + "entertainment": "šŸŽ¬", + "bills": "šŸ“„", + "other": "šŸ“¦", +} + + +# ----------------------------------------------------------------------------- +# Data Management +# ----------------------------------------------------------------------------- + +def load_expenses() -> list: + """Load expenses from the JSON file.""" + if os.path.exists(DATA_FILE): + with open(DATA_FILE, "r") as f: + return json.load(f) + return [] + + +def save_expenses(expenses: list) -> None: + """Save expenses to the JSON file.""" + with open(DATA_FILE, "w") as f: + json.dump(expenses, f, indent=2) + + +def generate_id(expenses: list) -> int: + """Generate a unique ID for a new expense.""" + if not expenses: + return 1 + return max(exp["id"] for exp in expenses) + 1 + + +# ----------------------------------------------------------------------------- +# Core Features +# ----------------------------------------------------------------------------- + +def add_expense(expenses: list) -> None: + """Add a new expense.""" + description = input(" Enter description: ").strip() + if not description: + print(" āŒ Description cannot be empty.") + return + + try: + amount = float(input(" Enter amount: $")) + if amount <= 0: + print(" āŒ Amount must be positive.") + return + except ValueError: + print(" āŒ Invalid amount.") + return + + print(f" Categories: {', '.join(CATEGORIES.keys())}") + category = input(" Enter category: ").strip().lower() + if category not in CATEGORIES: + print(f" āš ļø Unknown category. Defaulting to 'other'.") + category = "other" + + expense = { + "id": generate_id(expenses), + "description": description, + "amount": amount, + "category": category, + "date": datetime.now().strftime("%Y-%m-%d %H:%M"), + } + + expenses.append(expense) + save_expenses(expenses) + print(f" āœ… Expense added: {description} — ${amount:.2f} [{category}]") + + +def view_expenses(expenses: list, filter_category: str = None) -> None: + """Display expenses in a formatted table.""" + filtered = expenses + if filter_category: + filtered = [e for e in expenses if e["category"] == filter_category] + + if not filtered: + print(" šŸ“­ No expenses found.") + return + + # Header + print(f"\n {'ID':<4} {'Date':<18} {'Category':<15} {'Description':<20} {'Amount':>10}") + print(f" {'—'*4} {'—'*18} {'—'*15} {'—'*20} {'—'*10}") + + # Rows + for exp in filtered: + icon = CATEGORIES.get(exp["category"], "šŸ“¦") + print( + f" {exp['id']:<4} " + f"{exp['date']:<18} " + f"{icon} {exp['category']:<12} " + f"{exp['description']:<20} " + f"${exp['amount']:>8.2f}" + ) + + total = sum(e["amount"] for e in filtered) + print(f"\n {'Total:':<60} ${total:>8.2f}") + + +def view_by_category(expenses: list) -> None: + """Filter and view expenses by category.""" + print(f" Categories: {', '.join(CATEGORIES.keys())}") + category = input(" Enter category to filter: ").strip().lower() + if category not in CATEGORIES: + print(" āŒ Invalid category.") + return + view_expenses(expenses, filter_category=category) + + +def show_summary(expenses: list) -> None: + """Display spending summary with category breakdown.""" + if not expenses: + print(" šŸ“­ No expenses to summarize.") + return + + total = sum(e["amount"] for e in expenses) + + # Group by category + by_category = {} + for exp in expenses: + cat = exp["category"] + by_category[cat] = by_category.get(cat, 0) + exp["amount"] + + print(f"\n šŸ“Š EXPENSE SUMMARY") + print(f" {'='*40}") + print(f" Total Expenses: ${total:,.2f}") + print(f" Total Items: {len(expenses)}") + print(f" {'—'*40}") + + # Sort categories by amount (descending) + sorted_cats = sorted(by_category.items(), key=lambda x: x[1], reverse=True) + + for cat, amount in sorted_cats: + icon = CATEGORIES.get(cat, "šŸ“¦") + percentage = (amount / total) * 100 + bar = "ā–ˆ" * int(percentage / 5) + print(f" {icon} {cat:<15} ${amount:>8.2f} ({percentage:>5.1f}%) {bar}") + + +def delete_expense(expenses: list) -> None: + """Delete an expense by its ID.""" + view_expenses(expenses) + if not expenses: + return + + try: + exp_id = int(input("\n Enter expense ID to delete: ")) + except ValueError: + print(" āŒ Invalid ID.") + return + + for i, exp in enumerate(expenses): + if exp["id"] == exp_id: + removed = expenses.pop(i) + save_expenses(expenses) + print(f" šŸ—‘ļø Deleted: {removed['description']} — ${removed['amount']:.2f}") + return + + print(f" āŒ Expense with ID {exp_id} not found.") + + +# ----------------------------------------------------------------------------- +# Main Menu +# ----------------------------------------------------------------------------- + +def display_menu(): + """Display the main menu.""" + print(""" +╔══════════════════════════════════════════╗ +ā•‘ šŸ’° EXPENSE TRACKER šŸ’° ā•‘ +╠══════════════════════════════════════════╣ +ā•‘ 1. Add Expense ā•‘ +ā•‘ 2. View All Expenses ā•‘ +ā•‘ 3. View by Category ā•‘ +ā•‘ 4. Summary ā•‘ +ā•‘ 5. Delete Expense ā•‘ +ā•‘ 6. Exit ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + """) + + +def main(): + """Main entry point.""" + expenses = load_expenses() + + while True: + display_menu() + choice = input(" Choose an option (1-6): ").strip() + + if choice == "1": + add_expense(expenses) + elif choice == "2": + view_expenses(expenses) + elif choice == "3": + view_by_category(expenses) + elif choice == "4": + show_summary(expenses) + elif choice == "5": + delete_expense(expenses) + elif choice == "6": + print("\n šŸ‘‹ Goodbye! Keep tracking your expenses!") + break + else: + print(" āŒ Invalid option. Please choose 1-6.") + + input("\n Press Enter to continue...") + + +if __name__ == "__main__": + main() diff --git a/projects/03_library_management/README.md b/projects/03_library_management/README.md new file mode 100644 index 0000000..a7018e4 --- /dev/null +++ b/projects/03_library_management/README.md @@ -0,0 +1,65 @@ +# šŸ“š Project 03: Library Management System + +## Difficulty: 🟠 Advanced + +## Description +Build an object-oriented Library Management System where users can manage books, +members, and borrowing/returning of books. This project emphasizes classes, +inheritance, encapsulation, and error handling. + +## Requirements + +1. Create a `Book` class with title, author, ISBN, and availability status +2. Create a `Member` class with name, member ID, and borrowed books list +3. Create a `Library` class that manages books and members +4. Allow adding/removing books from the library catalog +5. Allow registering new members +6. Implement borrow and return functionality with proper validation +7. Handle edge cases: book already borrowed, member limit reached, book not found +8. Display library catalog and member details + +## Concepts You'll Practice + +- Classes and Objects +- Inheritance and Encapsulation +- Properties and Methods +- Custom Exceptions +- `__str__` and `__repr__` magic methods +- Composition (Library has Books and Members) + +## Example Output + +``` +šŸ“š LIBRARY MANAGEMENT SYSTEM +============================= + +1. View Catalog +2. Add Book +3. Register Member +4. Borrow Book +5. Return Book +6. View Member Info +7. Exit + +Choose: 4 + +Enter Member ID: M001 +Enter Book ISBN: 978-0-13-110362-7 + +āœ… "The C Programming Language" has been borrowed by Alice. + Due date: 2025-02-15 + Books borrowed: 1/3 +``` + +## How to Run + +```bash +cd projects/03_library_management +python solution.py +``` + +## Bonus Challenges + +- [ ] Add due dates and overdue book tracking +- [ ] Save library data to JSON for persistence +- [ ] Add search functionality (by title, author, or ISBN) diff --git a/projects/03_library_management/solution.py b/projects/03_library_management/solution.py new file mode 100644 index 0000000..e45dd7d --- /dev/null +++ b/projects/03_library_management/solution.py @@ -0,0 +1,389 @@ +""" +================================================================================ +Project 03: Library Management System +Difficulty: 🟠 Advanced +================================================================================ + +An object-oriented Library Management System for managing books, members, +and borrow/return operations with proper validation and error handling. + +Concepts Used: +- Classes and Objects +- Inheritance and Encapsulation +- Properties and Methods +- Custom Exceptions +- __str__ and __repr__ magic methods +- Composition (Library has Books and Members) + +================================================================================ +""" + +from datetime import datetime, timedelta + + +# ----------------------------------------------------------------------------- +# Custom Exceptions +# ----------------------------------------------------------------------------- + +class LibraryError(Exception): + """Base exception for library errors.""" + pass + + +class BookNotFoundError(LibraryError): + """Raised when a book is not found.""" + pass + + +class BookNotAvailableError(LibraryError): + """Raised when a book is already borrowed.""" + pass + + +class MemberNotFoundError(LibraryError): + """Raised when a member is not found.""" + pass + + +class BorrowLimitError(LibraryError): + """Raised when a member has reached their borrow limit.""" + pass + + +# ----------------------------------------------------------------------------- +# Book Class +# ----------------------------------------------------------------------------- + +class Book: + """Represents a book in the library.""" + + def __init__(self, title: str, author: str, isbn: str): + self._title = title + self._author = author + self._isbn = isbn + self._is_available = True + self._borrowed_by = None + self._due_date = None + + @property + def title(self) -> str: + return self._title + + @property + def author(self) -> str: + return self._author + + @property + def isbn(self) -> str: + return self._isbn + + @property + def is_available(self) -> bool: + return self._is_available + + @property + def due_date(self): + return self._due_date + + def borrow(self, member_id: str, days: int = 14) -> None: + """Mark the book as borrowed.""" + if not self._is_available: + raise BookNotAvailableError( + f'"{self._title}" is already borrowed.' + ) + self._is_available = False + self._borrowed_by = member_id + self._due_date = datetime.now() + timedelta(days=days) + + def return_book(self) -> None: + """Mark the book as returned.""" + self._is_available = True + self._borrowed_by = None + self._due_date = None + + def __str__(self) -> str: + status = "āœ… Available" if self._is_available else f"āŒ Borrowed (due: {self._due_date.strftime('%Y-%m-%d')})" + return f'"{self._title}" by {self._author} [ISBN: {self._isbn}] — {status}' + + def __repr__(self) -> str: + return f"Book(title='{self._title}', author='{self._author}', isbn='{self._isbn}')" + + +# ----------------------------------------------------------------------------- +# Member Class +# ----------------------------------------------------------------------------- + +class Member: + """Represents a library member.""" + + MAX_BORROW_LIMIT = 3 + + def __init__(self, name: str, member_id: str): + self._name = name + self._member_id = member_id + self._borrowed_books: list[str] = [] # List of ISBNs + + @property + def name(self) -> str: + return self._name + + @property + def member_id(self) -> str: + return self._member_id + + @property + def borrowed_books(self) -> list: + return self._borrowed_books.copy() + + @property + def borrow_count(self) -> int: + return len(self._borrowed_books) + + def can_borrow(self) -> bool: + """Check if the member can borrow more books.""" + return self.borrow_count < self.MAX_BORROW_LIMIT + + def add_book(self, isbn: str) -> None: + """Add a book ISBN to the borrowed list.""" + if not self.can_borrow(): + raise BorrowLimitError( + f"{self._name} has reached the borrow limit ({self.MAX_BORROW_LIMIT} books)." + ) + self._borrowed_books.append(isbn) + + def remove_book(self, isbn: str) -> None: + """Remove a book ISBN from the borrowed list.""" + if isbn in self._borrowed_books: + self._borrowed_books.remove(isbn) + + def __str__(self) -> str: + return ( + f"šŸ‘¤ {self._name} (ID: {self._member_id}) " + f"— Books borrowed: {self.borrow_count}/{self.MAX_BORROW_LIMIT}" + ) + + def __repr__(self) -> str: + return f"Member(name='{self._name}', member_id='{self._member_id}')" + + +# ----------------------------------------------------------------------------- +# Library Class (Composition) +# ----------------------------------------------------------------------------- + +class Library: + """Manages books and members.""" + + def __init__(self, name: str): + self.name = name + self._books: dict[str, Book] = {} # ISBN -> Book + self._members: dict[str, Member] = {} # member_id -> Member + + # --- Book Management --- + + def add_book(self, title: str, author: str, isbn: str) -> Book: + """Add a new book to the catalog.""" + if isbn in self._books: + print(f" āš ļø Book with ISBN {isbn} already exists.") + return self._books[isbn] + + book = Book(title, author, isbn) + self._books[isbn] = book + return book + + def find_book(self, isbn: str) -> Book: + """Find a book by ISBN.""" + if isbn not in self._books: + raise BookNotFoundError(f"No book found with ISBN: {isbn}") + return self._books[isbn] + + def search_books(self, query: str) -> list[Book]: + """Search books by title or author.""" + query_lower = query.lower() + return [ + book for book in self._books.values() + if query_lower in book.title.lower() or query_lower in book.author.lower() + ] + + # --- Member Management --- + + def register_member(self, name: str, member_id: str) -> Member: + """Register a new library member.""" + if member_id in self._members: + print(f" āš ļø Member ID {member_id} already exists.") + return self._members[member_id] + + member = Member(name, member_id) + self._members[member_id] = member + return member + + def find_member(self, member_id: str) -> Member: + """Find a member by ID.""" + if member_id not in self._members: + raise MemberNotFoundError(f"No member found with ID: {member_id}") + return self._members[member_id] + + # --- Borrow / Return --- + + def borrow_book(self, member_id: str, isbn: str) -> None: + """Allow a member to borrow a book.""" + member = self.find_member(member_id) + book = self.find_book(isbn) + + if not member.can_borrow(): + raise BorrowLimitError( + f"{member.name} has reached the borrow limit." + ) + + book.borrow(member_id) + member.add_book(isbn) + print(f' āœ… "{book.title}" has been borrowed by {member.name}.') + print(f" Due date: {book.due_date.strftime('%Y-%m-%d')}") + print(f" Books borrowed: {member.borrow_count}/{Member.MAX_BORROW_LIMIT}") + + def return_book(self, member_id: str, isbn: str) -> None: + """Allow a member to return a book.""" + member = self.find_member(member_id) + book = self.find_book(isbn) + + book.return_book() + member.remove_book(isbn) + print(f' āœ… "{book.title}" has been returned by {member.name}.') + + # --- Display --- + + def display_catalog(self) -> None: + """Display the full library catalog.""" + if not self._books: + print(" šŸ“­ No books in the catalog.") + return + + print(f"\n šŸ“š {self.name} — Catalog ({len(self._books)} books)") + print(f" {'='*65}") + for book in self._books.values(): + print(f" {book}") + + def display_members(self) -> None: + """Display all members.""" + if not self._members: + print(" šŸ“­ No registered members.") + return + + print(f"\n šŸ‘„ Registered Members ({len(self._members)})") + print(f" {'='*50}") + for member in self._members.values(): + print(f" {member}") + + +# ----------------------------------------------------------------------------- +# Interactive Menu +# ----------------------------------------------------------------------------- + +def display_menu(): + """Display the main menu.""" + print(""" +╔══════════════════════════════════════════╗ +ā•‘ šŸ“š LIBRARY MANAGEMENT SYSTEM šŸ“š ā•‘ +╠══════════════════════════════════════════╣ +ā•‘ 1. View Catalog ā•‘ +ā•‘ 2. Add Book ā•‘ +ā•‘ 3. Search Books ā•‘ +ā•‘ 4. Register Member ā•‘ +ā•‘ 5. Borrow Book ā•‘ +ā•‘ 6. Return Book ā•‘ +ā•‘ 7. View Member Info ā•‘ +ā•‘ 8. Exit ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + """) + + +def main(): + """Main entry point.""" + library = Library("City Public Library") + + # Pre-load some sample data + library.add_book("The C Programming Language", "Kernighan & Ritchie", "978-0-13-110362-7") + library.add_book("Clean Code", "Robert C. Martin", "978-0-13-235088-4") + library.add_book("Python Crash Course", "Eric Matthes", "978-1-59327-603-4") + library.add_book("Design Patterns", "Gang of Four", "978-0-20-163361-0") + library.add_book("The Pragmatic Programmer", "Hunt & Thomas", "978-0-20-161622-4") + + library.register_member("Alice", "M001") + library.register_member("Bob", "M002") + + while True: + display_menu() + choice = input(" Choose an option (1-8): ").strip() + + try: + if choice == "1": + library.display_catalog() + + elif choice == "2": + title = input(" Enter title: ").strip() + author = input(" Enter author: ").strip() + isbn = input(" Enter ISBN: ").strip() + if title and author and isbn: + library.add_book(title, author, isbn) + print(f' āœ… "{title}" added to the catalog.') + else: + print(" āŒ All fields are required.") + + elif choice == "3": + query = input(" Search (title or author): ").strip() + results = library.search_books(query) + if results: + print(f"\n šŸ” Found {len(results)} result(s):") + for book in results: + print(f" {book}") + else: + print(" šŸ“­ No matching books found.") + + elif choice == "4": + name = input(" Enter member name: ").strip() + member_id = input(" Enter member ID: ").strip() + if name and member_id: + library.register_member(name, member_id) + print(f" āœ… {name} registered as member {member_id}.") + else: + print(" āŒ All fields are required.") + + elif choice == "5": + library.display_members() + member_id = input("\n Enter Member ID: ").strip() + library.display_catalog() + isbn = input("\n Enter Book ISBN: ").strip() + library.borrow_book(member_id, isbn) + + elif choice == "6": + member_id = input(" Enter Member ID: ").strip() + isbn = input(" Enter Book ISBN: ").strip() + library.return_book(member_id, isbn) + + elif choice == "7": + library.display_members() + member_id = input("\n Enter Member ID for details: ").strip() + member = library.find_member(member_id) + print(f"\n {member}") + if member.borrowed_books: + print(" Borrowed books:") + for isbn in member.borrowed_books: + book = library.find_book(isbn) + print(f" šŸ“– {book}") + else: + print(" No books currently borrowed.") + + elif choice == "8": + print("\n šŸ‘‹ Thank you for using the Library Management System!") + break + + else: + print(" āŒ Invalid option. Please choose 1-8.") + + except LibraryError as e: + print(f" āŒ {e}") + + input("\n Press Enter to continue...") + + +if __name__ == "__main__": + main() diff --git a/projects/04_weather_cli/README.md b/projects/04_weather_cli/README.md new file mode 100644 index 0000000..fd55e5d --- /dev/null +++ b/projects/04_weather_cli/README.md @@ -0,0 +1,62 @@ +# šŸŒ¤ļø Project 04: Real-time Weather CLI + +## Difficulty: šŸš€ Professional + +## Description +Build a professional command-line weather application that fetches real-time weather +data from the Open-Meteo API (free, no API key needed!) and displays it beautifully +in the terminal. This project combines API consumption, decorators, type hinting, +error handling, and testing. + +## Requirements + +1. Fetch current weather data for any city using the Open-Meteo geocoding + weather API +2. Display temperature, humidity, wind speed, and weather condition +3. Support multiple cities in a single query +4. Cache API responses using a decorator to avoid repeated requests +5. Use type hints throughout the codebase +6. Handle network errors and invalid city names gracefully +7. Display weather data in a formatted, visually appealing way + +## Concepts You'll Practice + +- HTTP Requests (`urllib` — no external dependencies!) +- Decorators (caching) +- Type Hinting +- Error Handling (network, API, data) +- JSON parsing +- String formatting and CLI design + +## Example Output + +``` +šŸŒ¤ļø WEATHER CLI +=============== + +Enter city name: Gaza + +╔══════════════════════════════════════╗ +ā•‘ šŸ“ Gaza, Palestinian Territory ā•‘ +╠══════════════════════════════════════╣ +ā•‘ šŸŒ”ļø Temperature: 28.3°C ā•‘ +ā•‘ šŸ’§ Humidity: 65% ā•‘ +ā•‘ šŸ’Ø Wind Speed: 12.5 km/h ā•‘ +ā•‘ ā˜€ļø Condition: Clear sky ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• +``` + +## How to Run + +```bash +cd projects/04_weather_cli +python solution.py +``` + +> **Note:** This project uses the free [Open-Meteo API](https://open-meteo.com/) — +> no API key or sign-up required! + +## Bonus Challenges + +- [ ] Add 5-day forecast display +- [ ] Add temperature unit conversion (°C / °F) +- [ ] Save favorite cities to a config file diff --git a/projects/04_weather_cli/solution.py b/projects/04_weather_cli/solution.py new file mode 100644 index 0000000..de08f62 --- /dev/null +++ b/projects/04_weather_cli/solution.py @@ -0,0 +1,259 @@ +""" +================================================================================ +Project 04: Real-time Weather CLI +Difficulty: šŸš€ Professional +================================================================================ + +A professional command-line weather application that fetches real-time data +from the Open-Meteo API (free, no API key needed) and displays it beautifully. + +Concepts Used: +- HTTP Requests (urllib — no external dependencies) +- Decorators (caching) +- Type Hinting +- Error Handling (network, API, data) +- JSON parsing +- String formatting and CLI design + +================================================================================ +""" + +import json +import urllib.request +import urllib.parse +from typing import Optional +from functools import wraps + + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- + +GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search" +WEATHER_URL = "https://api.open-meteo.com/v1/forecast" + +# WMO Weather interpretation codes +WMO_CODES: dict[int, tuple[str, str]] = { + 0: ("ā˜€ļø", "Clear sky"), + 1: ("šŸŒ¤ļø", "Mainly clear"), + 2: ("ā›…", "Partly cloudy"), + 3: ("ā˜ļø", "Overcast"), + 45: ("šŸŒ«ļø", "Fog"), + 48: ("šŸŒ«ļø", "Depositing rime fog"), + 51: ("šŸŒ¦ļø", "Light drizzle"), + 53: ("šŸŒ¦ļø", "Moderate drizzle"), + 55: ("šŸŒ§ļø", "Dense drizzle"), + 61: ("šŸŒ§ļø", "Slight rain"), + 63: ("šŸŒ§ļø", "Moderate rain"), + 65: ("šŸŒ§ļø", "Heavy rain"), + 71: ("šŸŒØļø", "Slight snow"), + 73: ("šŸŒØļø", "Moderate snow"), + 75: ("ā„ļø", "Heavy snow"), + 80: ("šŸŒ§ļø", "Slight rain showers"), + 81: ("šŸŒ§ļø", "Moderate rain showers"), + 82: ("ā›ˆļø", "Violent rain showers"), + 95: ("ā›ˆļø", "Thunderstorm"), + 96: ("ā›ˆļø", "Thunderstorm with slight hail"), + 99: ("ā›ˆļø", "Thunderstorm with heavy hail"), +} + + +# ----------------------------------------------------------------------------- +# Caching Decorator +# ----------------------------------------------------------------------------- + +def cache_response(func): + """ + Decorator that caches function responses based on arguments. + + This avoids making redundant API calls for the same city + within a single session. + """ + _cache: dict[str, any] = {} + + @wraps(func) + def wrapper(*args, **kwargs): + # Create a cache key from the arguments + key = str(args) + str(sorted(kwargs.items())) + if key not in _cache: + _cache[key] = func(*args, **kwargs) + else: + print(" ⚔ Using cached data...") + return _cache[key] + + # Expose cache for testing / inspection + wrapper.cache = _cache + return wrapper + + +# ----------------------------------------------------------------------------- +# API Functions +# ----------------------------------------------------------------------------- + +def fetch_json(url: str) -> dict: + """Fetch JSON data from a URL using urllib (no external deps).""" + try: + req = urllib.request.Request(url, headers={"User-Agent": "PythonByExample/1.0"}) + with urllib.request.urlopen(req, timeout=10) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.URLError as e: + raise ConnectionError(f"Network error: {e}") + except json.JSONDecodeError: + raise ValueError("Failed to parse API response.") + + +@cache_response +def geocode_city(city_name: str) -> Optional[dict]: + """ + Look up a city's coordinates using the Open-Meteo Geocoding API. + Returns dict with name, country, latitude, longitude or None if not found. + """ + params = urllib.parse.urlencode({"name": city_name, "count": 1, "language": "en"}) + url = f"{GEOCODING_URL}?{params}" + + data = fetch_json(url) + + if "results" not in data or not data["results"]: + return None + + result = data["results"][0] + return { + "name": result.get("name", city_name), + "country": result.get("country", "Unknown"), + "latitude": result["latitude"], + "longitude": result["longitude"], + } + + +@cache_response +def get_weather(latitude: float, longitude: float) -> dict: + """ + Fetch current weather data for given coordinates. + Returns dict with temperature, humidity, wind_speed, and weather_code. + """ + params = urllib.parse.urlencode({ + "latitude": latitude, + "longitude": longitude, + "current_weather": "true", + "hourly": "relativehumidity_2m", + "timezone": "auto", + }) + url = f"{WEATHER_URL}?{params}" + + data = fetch_json(url) + + current = data.get("current_weather", {}) + hourly = data.get("hourly", {}) + + # Get current hour's humidity + humidity = None + if "relativehumidity_2m" in hourly and hourly["relativehumidity_2m"]: + from datetime import datetime + try: + current_time = datetime.fromisoformat(current.get("time", "")) + times = hourly.get("time", []) + for i, t in enumerate(times): + if datetime.fromisoformat(t) >= current_time: + humidity = hourly["relativehumidity_2m"][i] + break + except (ValueError, IndexError): + humidity = hourly["relativehumidity_2m"][0] if hourly["relativehumidity_2m"] else None + + return { + "temperature": current.get("temperature", 0), + "wind_speed": current.get("windspeed", 0), + "weather_code": current.get("weathercode", 0), + "humidity": humidity, + } + + +# ----------------------------------------------------------------------------- +# Display Functions +# ----------------------------------------------------------------------------- + +def get_weather_description(code: int) -> tuple[str, str]: + """Get weather icon and description from WMO code.""" + return WMO_CODES.get(code, ("ā“", "Unknown")) + + +def display_weather(city_info: dict, weather: dict) -> None: + """Display weather data in a beautiful formatted box.""" + icon, condition = get_weather_description(weather["weather_code"]) + location = f"{city_info['name']}, {city_info['country']}" + + # Calculate box width based on content + box_width = max(38, len(location) + 8) + inner = box_width - 4 + + print() + print(f" ā•”{'═' * (box_width - 2)}ā•—") + print(f" ā•‘ šŸ“ {location:<{inner - 4}} ā•‘") + print(f" ā• {'═' * (box_width - 2)}ā•£") + print(f" ā•‘ šŸŒ”ļø Temperature: {weather['temperature']:>6.1f}°C{' ' * (inner - 26)}ā•‘") + + if weather["humidity"] is not None: + print(f" ā•‘ šŸ’§ Humidity: {weather['humidity']:>5}%{' ' * (inner - 25)}ā•‘") + else: + print(f" ā•‘ šŸ’§ Humidity: N/A{' ' * (inner - 23)}ā•‘") + + print(f" ā•‘ šŸ’Ø Wind Speed: {weather['wind_speed']:>6.1f} km/h{' ' * (inner - 29)}ā•‘") + print(f" ā•‘ {icon} Condition: {condition:<{inner - 17}}ā•‘") + print(f" ā•š{'═' * (box_width - 2)}ā•") + + +def display_welcome() -> None: + """Display the welcome banner.""" + print(""" +╔══════════════════════════════════════════╗ +ā•‘ šŸŒ¤ļø WEATHER CLI šŸŒ¤ļø ā•‘ +ā•‘ Real-time weather at your terminal ā•‘ +ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā• + + Powered by Open-Meteo API (free, no API key needed!) + Type 'quit' to exit. + """) + + +# ----------------------------------------------------------------------------- +# Main Application +# ----------------------------------------------------------------------------- + +def main() -> None: + """Main entry point for the Weather CLI.""" + display_welcome() + + while True: + city_name = input("\n šŸ” Enter city name: ").strip() + + if not city_name: + print(" āš ļø Please enter a city name.") + continue + + if city_name.lower() in ("quit", "exit", "q"): + print("\n šŸ‘‹ Goodbye! Stay dry out there!") + break + + try: + # Step 1: Geocode the city + print(f" šŸ”„ Looking up '{city_name}'...") + city_info = geocode_city(city_name) + + if city_info is None: + print(f" āŒ City '{city_name}' not found. Try a different name.") + continue + + # Step 2: Fetch weather + weather = get_weather(city_info["latitude"], city_info["longitude"]) + + # Step 3: Display + display_weather(city_info, weather) + + except ConnectionError as e: + print(f" āŒ Connection error: {e}") + print(" šŸ’” Check your internet connection and try again.") + except Exception as e: + print(f" āŒ An unexpected error occurred: {e}") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..18fd1da --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +requirements.txt +pytest +mypy +black +isort