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