mirror of
https://github.com/blshaer/python-by-example.git
synced 2026-03-27 23:29:25 +01:00
feat: Introduce several new Python projects, unit testing fundamentals, and update project dependencies.
This commit is contained in:
parent
d1c6102700
commit
63dc2ff15a
36
11_testing/01_test_basics.py
Normal file
36
11_testing/01_test_basics.py
Normal file
|
|
@ -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")
|
||||
68
README.md
68
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:
|
|||
<td>
|
||||
|
||||
10. [Best Practices](./10_best_practices/)
|
||||
11. [Testing](./11_testing/)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
58
projects/01_number_guessing_game/README.md
Normal file
58
projects/01_number_guessing_game/README.md
Normal file
|
|
@ -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
|
||||
159
projects/01_number_guessing_game/solution.py
Normal file
159
projects/01_number_guessing_game/solution.py
Normal file
|
|
@ -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()
|
||||
68
projects/02_expense_tracker/README.md
Normal file
68
projects/02_expense_tracker/README.md
Normal file
|
|
@ -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
|
||||
244
projects/02_expense_tracker/solution.py
Normal file
244
projects/02_expense_tracker/solution.py
Normal file
|
|
@ -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()
|
||||
65
projects/03_library_management/README.md
Normal file
65
projects/03_library_management/README.md
Normal file
|
|
@ -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)
|
||||
389
projects/03_library_management/solution.py
Normal file
389
projects/03_library_management/solution.py
Normal file
|
|
@ -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()
|
||||
62
projects/04_weather_cli/README.md
Normal file
62
projects/04_weather_cli/README.md
Normal file
|
|
@ -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
|
||||
259
projects/04_weather_cli/solution.py
Normal file
259
projects/04_weather_cli/solution.py
Normal file
|
|
@ -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()
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
requirements.txt
|
||||
pytest
|
||||
mypy
|
||||
black
|
||||
isort
|
||||
Loading…
Reference in New Issue
Block a user