24 hands-on projects, grouped into the four levels below. Each project has a short brief and an example — try building it yourself first, then open Show solution to check your approach. There's no in-browser runner here: write and run these in your own editor (VS Code, IDLE, or similar), the same as any real Python program.
The program picks a secret number between 1 and 100. The player keeps guessing until they get it right, and the program says whether each guess is too high or too low.
Guess the number: 50
Too high!
Guess the number: 25
Too low!
Guess the number: 37
Correct! You got it in 3 tries.
Concepts
How it was built
random.randint(1, 100) — it returns a random whole number between 1 and 100, inclusive.while True so the program keeps asking until the player wins — it never stops on its own, only break inside the loop does that.if / elif / else: too low, too high, or correct.attempts += 1 runs on every pass through the loop, so it's already counting guesses by the time break fires.Trace: number = 63. Guess 30 → too low. Guess 80 → too high. Guess 63 → correct, attempts is 3, loop stops.
Complete code
import random
number = random.randint(1, 100)
attempts = 0
while True:
guess = int(input("Guess the number: "))
attempts += 1
if guess < number:
print("Too low!")
elif guess > number:
print("Too high!")
else:
print(f"Correct! You got it in {attempts} tries.")
break
The loop keeps asking until `break` fires — everything else is just deciding when that should happen.
Ask the user for two numbers and an operator (+, -, *, /), then print the result. Handle division by zero without crashing.
Enter first number: 25
Enter operator (+, -, *, /): -
Enter second number: 8
Result: 17.0
Concepts
How it was built
float(input(...)) so decimals work, not just whole numbers — int() would reject "17.5".==, never converted to a number — it stays one of the characters "+", "-", "*", "/".if / elif / else checks the operator once and runs exactly one branch — Python stops checking further elifs the moment one matches.if to guard against num2 == 0 before dividing — otherwise it would crash with a ZeroDivisionError.Trace: num1 = 20, operator = "*", num2 = 5 → skips +, skips -, matches *, prints 100.
Complete code
num1 = float(input("Enter first number: "))
operator = input("Enter operator (+, -, *, /): ")
num2 = float(input("Enter second number: "))
if operator == "+":
print("Result:", num1 + num2)
elif operator == "-":
print("Result:", num1 - num2)
elif operator == "*":
print("Result:", num1 * num2)
elif operator == "/":
if num2 != 0:
print("Result:", num1 / num2)
else:
print("Cannot divide by zero!")
else:
print("Invalid operator!")
Every branch does exactly one job — that one-job-per-branch habit scales to much bigger programs later.
A menu-driven console app to add, view, and remove tasks from a list while the program is running.
1. Add Task
2. View Tasks
3. Remove Task
4. Exit
Enter your choice: 2
Your Tasks:
1. Study Python
2. Go for a walk
Concepts
How it was built
tasks starts as an empty list — .append() adds to the end, .pop(index) removes by position.while True so it keeps redrawing until the user picks Exit and break fires.enumerate(tasks, start=1) hands back a position and the item together, so the numbers shown to the user don't need a separate counter variable.n - 1 because the user sees 1-based numbers but Python lists are 0-based — task "2" on screen is index 1 in the list.Trace: adding "Study Python" then "Exercise" leaves tasks = ["Study Python", "Exercise"]; removing task 1 leaves ["Exercise"].
Complete code
tasks = []
while True:
print("\n1. Add Task\n2. View Tasks\n3. Remove Task\n4. Exit")
choice = input("Enter your choice: ")
if choice == "1":
tasks.append(input("Enter task: "))
print("Task added!")
elif choice == "2":
if not tasks:
print("No tasks.")
else:
for i, task in enumerate(tasks, start=1):
print(i, ".", task)
elif choice == "3":
n = int(input("Enter task number to remove: "))
if 1 <= n <= len(tasks):
tasks.pop(n - 1)
print("Task removed!")
else:
print("Invalid task number.")
elif choice == "4":
print("Goodbye!")
break
else:
print("Invalid choice!")
`enumerate(tasks, start=1)` gives both the position and the item, so the numbers shown to the user don't need a separate counter.
Ask for marks in a few subjects, then calculate and print the total, average, and letter grade.
Enter marks: 85
Enter marks: 72
Enter marks: 90
Total: 247
Average: 82.33
Grade: B
Concepts
How it was built
for loop and .append(), building the list one entry at a time.sum(marks) adds every item in the list in one call — no separate accumulator loop needed.len(marks) turns the total into an average that automatically adapts to however many marks were entered.elif chain is written highest-grade-first, because Python stops at the first true condition — checking >= 60 before >= 90 would give every passing student a D.Trace: marks = [85, 72, 90] → total 247, average 82.33 → checks 90 (no), 80 (yes) → grade B.
Complete code
marks = []
for i in range(3):
marks.append(float(input("Enter marks: ")))
total = sum(marks)
average = total / len(marks)
if average >= 90:
grade = "A"
elif average >= 80:
grade = "B"
elif average >= 70:
grade = "C"
elif average >= 60:
grade = "D"
else:
grade = "F"
print("\nTotal:", total)
print("Average:", round(average, 2))
print("Grade:", grade)
The `elif` chain is checked top to bottom and stops at the first true condition — that's why it's ordered from the highest grade down.
Let the user add items and prices one at a time until they type 'done', then show the cart and the total.
Enter item name (or 'done' to finish): Apple
Enter price: 50
...
Apple - 50
Milk - 60
Total: 110
Concepts
How it was built
items and prices, are built in lockstep — every append() to one is immediately followed by the matching append() to the other, so items[i] and prices[i] always describe the same product.while True with a typed "done" as the exit condition, not a fixed count — useful whenever you don't know in advance how many entries there will be.range(len(items))) specifically because it needs one shared index into two lists at once — a plain for item in items loop couldn't also reach into prices at the same position.Trace: Apple 50, Milk 60, Bread 40 typed in, then "done" → total 150 printed after the loop exits.
Complete code
items = []
prices = []
while True:
item = input("Enter item name (or 'done' to finish): ")
if item == "done":
break
prices.append(float(input("Enter price: ")))
items.append(item)
print("\nShopping Cart")
total = 0
for i in range(len(items)):
print(items[i], "-", prices[i])
total += prices[i]
print("Total:", total)
`items` and `prices` are two separate lists kept in sync by position — `items[i]` and `prices[i]` always describe the same product.
Check whether a password is at least 8 characters and contains a number, an uppercase letter, and a lowercase letter.
Enter your password: Hello123
Strong password!
Concepts
How it was built
any(c.isdigit() for c in password) checks every character and stops the moment it finds one that satisfies the condition — a compact way to ask "does at least one character match?"True/False.elif chain reports only the first problem it finds — length first, since a too-short password fails regardless of what characters it contains.Trace: "hello123" → length 8 ok, has a number, no uppercase → stops at the uppercase check, prints "add an uppercase letter."
Complete code
password = input("Enter your password: ")
has_number = any(c.isdigit() for c in password)
has_upper = any(c.isupper() for c in password)
has_lower = any(c.islower() for c in password)
if len(password) < 8:
print("Weak password - must have at least 8 characters.")
elif not has_number:
print("Weak password - add a number.")
elif not has_upper:
print("Weak password - add an uppercase letter.")
elif not has_lower:
print("Weak password - add a lowercase letter.")
else:
print("Strong password!")
`any(c.isdigit() for c in password)` is the same flag-while-looping idea as a `for` loop that sets `has_number = True` — just shorter.
Play rock-paper-scissors against the computer for 5 rounds and print the final score.
Round 1
Choose rock, paper, or scissors: rock
Computer chose: scissors
You win!
...
Final Score
You: 3
Computer: 2
Concepts
How it was built
random.choice(choices) is what makes the computer's move unpredictable — it picks one item at random from the list each round.elif with or — read each parenthesized pair as one sentence: "rock beats scissors."player in choices catches everything that isn't a draw and isn't a win, but is still a real choice — leaving the final else to catch genuinely invalid input like "banana".for i in range(5) repeats the whole round five times, with player_score and computer_score persisting across every pass since they're defined outside the loop.Trace: computer picks "paper", player types "rock" → not a draw, not a player-win combination, but "rock" is in choices → computer wins.
Complete code
import random
choices = ["rock", "paper", "scissors"]
player_score = 0
computer_score = 0
for i in range(5):
print("\nRound", i + 1)
player = input("Choose rock, paper, or scissors: ")
computer = random.choice(choices)
print("Computer chose:", computer)
if player == computer:
print("Draw!")
elif (player == "rock" and computer == "scissors") or \
(player == "paper" and computer == "rock") or \
(player == "scissors" and computer == "paper"):
print("You win!")
player_score += 1
elif player in choices:
print("Computer wins!")
computer_score += 1
else:
print("Invalid choice!")
print("\nFinal Score")
print("You:", player_score)
print("Computer:", computer_score)
The three winning combinations are just facts about the game, chained with `or` — read each line as one sentence: 'rock beats scissors'.
Ask a series of questions, check each answer, and print the score at the end.
Question 1
What is the capital of India?
Your answer: Delhi
Correct!
...
Your score: 3 / 4
Concepts
How it was built
questions and answers, are kept in step by index — the same relationship as Shopping Cart's items/prices.for i in range(len(questions)) is used instead of for q in questions specifically because the loop needs the same i to reach into both lists at once..lower() on the user's typed answer means "Delhi", "delhi", and "DELHI" all count as correct — capitalization stops being the difference between right and wrong.Trace: i = 2, questions[2] is "What is 5 + 5?", user types "10", answers[2] is "10" → matches, score increases.
Complete code
questions = [
"What is the capital of India?",
"How many days are in a week?",
"What is 5 + 5?",
"Which language are we learning?",
]
answers = ["delhi", "7", "10", "python"]
score = 0
for i in range(len(questions)):
print("\nQuestion", i + 1)
print(questions[i])
user_answer = input("Your answer: ")
if user_answer.lower() == answers[i]:
print("Correct!")
score += 1
else:
print("Wrong! Correct answer:", answers[i])
print("\nYour score:", score, "/", len(questions))
`.lower()` on the user's answer means 'Delhi', 'delhi', and 'DELHI' all count — without it, capitalization alone would fail a correct answer.
A menu-driven app to add, view, and search contacts stored as name/phone pairs.
1. Add Contact
2. View Contacts
3. Search Contact
4. Exit
Enter choice: 3
Enter name to search: Rahul
Name: Rahul
Phone: 9876543210
Concepts
How it was built
[name, phone] — a list living inside the bigger contacts list, which is why contact[0] and contact[1] work the way they do.found flag: start it False, flip it to True the moment a match turns up, then check it once after the loop to decide whether to print "not found.".lower() on both sides of the comparison makes the search match regardless of how the name was capitalized either time it was typed.Trace: searching "rahul" against a contact stored as "Rahul" → both sides lowered to "rahul" → match found.
Complete code
contacts = []
while True:
print("\n1. Add Contact\n2. View Contacts\n3. Search Contact\n4. Exit")
choice = input("Enter choice: ")
if choice == "1":
name = input("Enter name: ")
phone = input("Enter phone number: ")
contacts.append([name, phone])
print("Contact added!")
elif choice == "2":
if not contacts:
print("No contacts.")
else:
for contact in contacts:
print("Name:", contact[0], "| Phone:", contact[1])
elif choice == "3":
search = input("Enter name to search: ")
found = False
for contact in contacts:
if contact[0].lower() == search.lower():
print("Name:", contact[0])
print("Phone:", contact[1])
found = True
if not found:
print("Contact not found.")
elif choice == "4":
print("Goodbye!")
break
else:
print("Invalid choice!")
Each contact is a small list `[name, phone]` inside the bigger `contacts` list — a list of lists, the same shape you'll meet again as a list of dictionaries in the Intermediate level.
A menu-driven app to record expenses by category and amount, and show the running total.
1. Add Expense
2. View Expenses
3. Show Total
4. Exit
Enter choice: 3
Total expenses: 800
Concepts
How it was built
[category, amount] — a small list inside the bigger expenses list.sum(expense[1] for expense in expenses) is a generator expression: it visits every expense, pulls out just the amount (index 1), and adds them all up in one line. The loop-based equivalent would be total = 0 followed by total += expense[1] inside a for loop.Trace: expenses = [["Food", 200], ["Transport", 100]] → the generator sees 200 then 100 → total 300.
Complete code
expenses = []
while True:
print("\n1. Add Expense\n2. View Expenses\n3. Show Total\n4. Exit")
choice = input("Enter choice: ")
if choice == "1":
category = input("Enter category: ")
amount = float(input("Enter amount: "))
expenses.append([category, amount])
print("Expense added!")
elif choice == "2":
if not expenses:
print("No expenses recorded.")
else:
for expense in expenses:
print(expense[0], "-", expense[1])
elif choice == "3":
total = sum(expense[1] for expense in expenses)
print("Total expenses:", total)
elif choice == "4":
print("Goodbye!")
break
else:
print("Invalid choice!")
`sum(expense[1] for expense in expenses)` totals just the amounts, ignoring the categories — the same idea as the total loop, written as one line.
A menu-driven bank simulator using functions — check balance, deposit, and withdraw, each as its own function that returns the updated balance.
===== BANK =====
1. Check Balance
2. Deposit
3. Withdraw
4. Exit
Enter choice: 2
Enter amount: 500
Deposit successful!
Concepts
How it was built
show_balance, deposit, withdraw — so the menu's job shrinks to "figure out which function to call," not "do the work itself."deposit(balance) takes the current balance in as a parameter and returns the new balance out — it never touches a variable named balance that lives outside the function.balance = deposit(balance) is the pattern to remember: pass the current value in, capture what comes back, store it in the same name.Trace: balance = 1000, deposit() asks for 500, returns 1500, balance becomes 1500 back in the main program.
Complete code
def show_balance(balance):
print("Balance:", balance)
def deposit(balance):
amount = float(input("Enter amount to deposit: "))
print("Deposit successful!")
return balance + amount
def withdraw(balance):
amount = float(input("Enter amount to withdraw: "))
if amount <= balance:
print("Withdrawal successful!")
return balance - amount
print("Insufficient balance!")
return balance
balance = 1000
while True:
print("\n===== BANK =====\n1. Check Balance\n2. Deposit\n3. Withdraw\n4. Exit")
choice = input("Enter choice: ")
if choice == "1":
show_balance(balance)
elif choice == "2":
balance = deposit(balance)
elif choice == "3":
balance = withdraw(balance)
elif choice == "4":
print("Thank you!")
break
else:
print("Invalid choice!")
`balance = deposit(balance)` is the core pattern: pass the current value in, get the new value back, store it — no `global` needed.
Extend the To-Do List with a completed status per task, using functions for add / view / complete / delete.
1 ○ Learn Python
2 ✓ Exercise
Mark task 1 as done
1 ✓ Learn Python
2 ✓ Exercise
Concepts
How it was built
[text, False] instead of a plain string — a small list where index 1 is a Boolean tracking whether it's done, the same two-pieces-of-information idea as Expense Tracker's [category, amount].tasks.append(...) and tasks.pop(...) change the list that was passed in, in place — these functions never need to return tasks, unlike deposit()/withdraw() which had to return a new balance because numbers can't be mutated in place.tasks[index][1] = True reaches two levels deep: first to the task at that position, then to its second element.Trace: tasks[0] is ["Learn Python", False] → after tasks[0][1] = True it's ["Learn Python", True].
Complete code
def add_task(tasks):
tasks.append([input("Enter task: "), False])
print("Task added!")
def view_tasks(tasks):
if not tasks:
print("No tasks.")
return
for i, task in enumerate(tasks, start=1):
status = "✓" if task[1] else "○"
print(i, status, task[0])
def complete_task(tasks):
view_tasks(tasks)
n = int(input("Enter task number: "))
if 1 <= n <= len(tasks):
tasks[n - 1][1] = True
print("Task completed!")
def delete_task(tasks):
view_tasks(tasks)
n = int(input("Enter task number to delete: "))
if 1 <= n <= len(tasks):
tasks.pop(n - 1)
print("Task deleted!")
tasks = []
while True:
print("\n1. Add 2. View 3. Complete 4. Delete 5. Exit")
choice = input("Enter choice: ")
if choice == "1": add_task(tasks)
elif choice == "2": view_tasks(tasks)
elif choice == "3": complete_task(tasks)
elif choice == "4": delete_task(tasks)
elif choice == "5": break
else: print("Invalid choice!")
`tasks.append(...)` and `tasks.pop(...)` change the list in place, so these functions don't need to `return` it — only values like a number or a string need `return` to get back out.
Rebuild the Expense Tracker so expenses are stored as dictionaries and saved to a JSON file, surviving between runs.
expenses.json:
[{"category": "Food", "amount": 200}]
Total expenses: 200
Concepts
How it was built
{"category": ..., "amount": ...}, instead of a list — expense["amount"] says what it means; expense[1] didn't.json.dump(expenses, file) turns the whole Python list of dictionaries into JSON text and writes it to disk; json.load(file) does the reverse when the program starts again.try/except FileNotFoundError around the load means the very first run — before the file exists — starts from an empty list instead of crashing.Trace: first run, no file yet → FileNotFoundError caught → expenses = []. After adding one expense and saving, the next run's json.load() reconstructs it exactly.
Complete code
import json
def load_expenses():
try:
with open("expenses.json", "r") as file:
return json.load(file)
except FileNotFoundError:
return []
def save_expenses(expenses):
with open("expenses.json", "w") as file:
json.dump(expenses, file, indent=4)
def add_expense(expenses):
expense = {
"category": input("Enter category: "),
"amount": float(input("Enter amount: ")),
}
expenses.append(expense)
save_expenses(expenses)
print("Expense added!")
def show_total(expenses):
total = sum(e["amount"] for e in expenses)
print("Total expenses:", total)
expenses = load_expenses()
while True:
print("\n1. Add 2. View 3. Total 4. Exit")
choice = input("Enter choice: ")
if choice == "1": add_expense(expenses)
elif choice == "2":
for e in expenses: print(e["category"], "-", e["amount"])
elif choice == "3": show_total(expenses)
elif choice == "4": break
else: print("Invalid choice!")
`expense['amount']` instead of `expense[1]` is the whole point of switching from a list to a dictionary — the key says what the value means.
Load quiz questions from a separate questions.json file instead of hardcoding them, then run the quiz.
questions.json:
[{"question": "5 + 3?", "options": [...], "answer": "8"}]
Score: 2 / 3
Concepts
How it was built
question, options, answer — and the whole quiz is a list of these, loaded from questions.json instead of typed into the Python file directly.quiz.py only knows how to run a quiz, not which questions are in it.ask_question() returns True or False depending on whether the answer matched — sum(ask_question(q) for q in questions) works because Python counts True as 1 and False as 0 when summed.Trace: q["options"] is ["6", "7", "8", "9"], user types "3" → index 2 → q["options"][2] is "8", matches q["answer"] → returns True.
Complete code
import json
def load_questions():
try:
with open("questions.json", "r") as file:
return json.load(file)
except FileNotFoundError:
return []
def ask_question(q):
print("\n" + q["question"])
for i, option in enumerate(q["options"], start=1):
print(i, ".", option)
choice = int(input("Enter your answer: "))
if q["options"][choice - 1] == q["answer"]:
print("Correct!")
return True
print("Wrong! Correct answer:", q["answer"])
return False
questions = load_questions()
score = sum(ask_question(q) for q in questions)
print(f"\nScore: {score} / {len(questions)}")
`sum(ask_question(q) for q in questions)` works because `True` counts as `1` and `False` as `0` in Python — each correct answer adds one to the total.
Rebuild the Contact Book using a list of dictionaries, with search, update, and delete, saved to JSON.
Update Rahul
Enter new phone: 9999999999
Contact updated!
Concepts
How it was built
contact["phone"] instead of contact[1].c["phone"] = new_phone) — no removing and re-adding needed, unlike deleting..pop() takes an index — that's why it loops with enumerate() instead of a plain for c in contacts.save_contacts() right after — changing the list in memory doesn't touch the file on disk until that call happens.Trace: updating Rahul's phone finds the matching dictionary, sets c["phone"] on it directly, then saves — the dictionary's identity in the list never changes, only one value inside it does.
Complete code
import json
def load_contacts():
try:
with open("contacts.json", "r") as file:
return json.load(file)
except FileNotFoundError:
return []
def save_contacts(contacts):
with open("contacts.json", "w") as file:
json.dump(contacts, file, indent=4)
def add_contact(contacts):
contacts.append({
"name": input("Enter name: "),
"phone": input("Enter phone: "),
})
save_contacts(contacts)
def update_contact(contacts):
name = input("Enter name to update: ")
for c in contacts:
if c["name"].lower() == name.lower():
c["phone"] = input("Enter new phone: ")
save_contacts(contacts)
print("Contact updated!")
return
print("Contact not found.")
def delete_contact(contacts):
name = input("Enter name to delete: ")
for i, c in enumerate(contacts):
if c["name"].lower() == name.lower():
contacts.pop(i)
save_contacts(contacts)
print("Contact deleted!")
return
print("Contact not found.")
contacts = load_contacts()
# ... menu loop connecting add_contact / update_contact / delete_contact,
# the same shape as the Expense Tracker's menu above.
Updating just changes `c["phone"]` on the dictionary already inside the list — no need to remove and re-add it, unlike deleting.
Generate a random password of a length the user chooses, made up of letters, digits, and symbols.
Enter password length: 12
Generated password: aT9#kLp2!qXz
Concepts
How it was built
string.ascii_letters and string.digits are pre-built character sets from Python's standard library — the alphabet and digits without typing them out by hand.random.choice(characters) picks one random character from that combined set; running it once per position via a generator expression inside "".join(...) builds the whole password in one line.Trace: length 4 → four independent random.choice() calls → results joined into one string, e.g. "aT9#".
Complete code
import random
import string
length = int(input("Enter password length: "))
characters = string.ascii_letters + string.digits + "!@#$%^&*"
password = "".join(random.choice(characters) for _ in range(length))
print("Generated password:", password)
`string.ascii_letters` and `string.digits` are ready-made character sets from Python's standard library — no need to type out the alphabet by hand.
An ATM-style menu with a PIN check before allowing balance, deposit, or withdrawal — locking out after 3 wrong PIN attempts.
Enter PIN: 0000
Wrong PIN. 2 attempts left.
Enter PIN: 1234
Welcome!
Concepts
How it was built
check_pin() loops up to 3 times, returning True the instant the correct PIN is entered — an early return exits the function immediately, skipping the rest of the loop.return False.if not check_pin(): — a function's return value deciding what happens next, not just what the function itself does.Trace: wrong PIN twice, correct on the third try → check_pin() returns True on attempt 3 without ever reaching the fail branch.
Complete code
CORRECT_PIN = "1234"
balance = 1000
def check_pin():
for attempt in range(3):
pin = input("Enter PIN: ")
if pin == CORRECT_PIN:
return True
print(f"Wrong PIN. {2 - attempt} attempts left.")
return False
if not check_pin():
print("Too many wrong attempts. Card blocked.")
else:
print("Welcome!")
while True:
print("\n1. Balance 2. Deposit 3. Withdraw 4. Exit")
choice = input("Enter choice: ")
if choice == "1":
print("Balance:", balance)
elif choice == "2":
balance += float(input("Enter amount: "))
elif choice == "3":
amount = float(input("Enter amount: "))
if amount <= balance:
balance -= amount
else:
print("Insufficient balance!")
elif choice == "4":
break
else:
print("Invalid choice!")
`check_pin()` returns `True` or `False`, and the rest of the program only runs inside `if not check_pin():` — a function's return value deciding what happens next.
Track books as available or borrowed, with functions to add a book, borrow it, return it, and list what's available.
1. Add Book 2. Borrow 3. Return 4. List Available 5. Exit
Enter choice: 2
Enter title to borrow: Python Basics
Borrowed!
Concepts
How it was built
{"title": ..., "borrowed": False} — the same boolean-flag-in-a-dictionary idea as the Advanced To-Do App's completed status.borrow_book() only matches a book where borrowed is still False — trying to borrow an already-borrowed book falls through to "Not available."[b["title"] for b in books if not b["borrowed"]] is a list comprehension: the same filtering for loop, written as one expression instead of several lines.Trace: two books, one already borrowed → the list comprehension only includes the one still available.
Complete code
books = []
def add_book(title):
books.append({"title": title, "borrowed": False})
def borrow_book(title):
for book in books:
if book["title"] == title and not book["borrowed"]:
book["borrowed"] = True
print("Borrowed!")
return
print("Not available.")
def return_book(title):
for book in books:
if book["title"] == title and book["borrowed"]:
book["borrowed"] = False
print("Returned!")
return
print("That book wasn't borrowed.")
def list_available():
available = [b["title"] for b in books if not b["borrowed"]]
print("\n".join(available) if available else "No books available.")
`[b["title"] for b in books if not b["borrowed"]]` is a list comprehension — the same filtering `for` loop written in one line.
Track product stock levels, allowing restocking, selling (with a stock check), and a low-stock report.
1. Add Product 2. Restock 3. Sell 4. Low Stock Report 5. Exit
Enter choice: 3
Sold 5 units of Notebook.
Concepts
How it was built
inventory is a dictionary keyed directly by product name — inventory["Pen"] is the stock count for pens, no separate list of names needed.inventory.get(name, 0) returns 0 instead of crashing when a product hasn't been added yet — safer than inventory[name] for a key that might not exist.sell() checks there's enough stock before subtracting — the same "check before you act" pattern as the Bank Account Simulator's withdraw().Trace: inventory = {"Pen": 3}, selling 5 → 3 >= 5 is false → "Not enough stock" instead of a negative count.
Complete code
inventory = {}
def add_product(name, qty):
inventory[name] = inventory.get(name, 0) + qty
def sell(name, qty):
if inventory.get(name, 0) >= qty:
inventory[name] -= qty
print(f"Sold {qty} units of {name}.")
else:
print("Not enough stock.")
def low_stock_report(threshold=5):
for name, qty in inventory.items():
if qty < threshold:
print(name, "-", qty, "left")
`inventory.get(name, 0)` returns `0` instead of crashing when a product hasn't been added yet — a safer way to read a dictionary key that might not exist.
Combine everything from this level into one program: products with stock (dictionaries), a shopping cart, checkout that reduces stock, and a JSON-saved sales log.
1. Browse 2. Add to Cart 3. Checkout 4. Exit
Checkout total: ₹450
Sale recorded.
Concepts
How it was built
checkout() reads each cart item's price from the products dictionary, adds them into a total, and appends one record — {"items": ..., "total": ...} — to a growing sales history that gets saved to JSON after every sale.Trace: cart ["Pen", "Notebook"] with products = {"Pen": 10, "Notebook": 40} → total 50, one sale record appended and saved.
Complete code
import json
def load_sales():
try:
with open("sales.json", "r") as file:
return json.load(file)
except FileNotFoundError:
return []
def save_sales(sales):
with open("sales.json", "w") as file:
json.dump(sales, file, indent=4)
products = {"Pen": 10, "Notebook": 40, "Eraser": 5}
cart = []
sales = load_sales()
def checkout(cart):
total = sum(products[item] for item in cart)
sales.append({"items": cart, "total": total})
save_sales(sales)
print("Checkout total:", total)
print("Sale recorded.")
This is deliberately a combination, not a new idea — dictionaries for products, a list for the cart, and the same load/save JSON pattern from the Expense Tracker, all in one program.
The Simple Calculator from Level 1, rebuilt with buttons and text boxes using Tkinter instead of input() / print().
[ Entry: 25+8 ]
[ = ]
Result: 33
Concepts
How it was built
tk.Tk() — the root window — and one call to root.mainloop() at the very end, which is what keeps the window open and listening for clicks instead of closing immediately.Entry and Button are created, then placed with .pack() — creating a widget and placing it are two separate steps.command=calculate on the button means: call this function when clicked — no manual event-listener wiring needed, Tkinter does it through the command argument.Trace: typing 25+8 into the entry and clicking = calls calculate(), which evaluates the text and shows 33.
Complete code
import tkinter as tk
def calculate():
try:
result_var.set(str(eval(entry.get())))
except Exception:
result_var.set("Error")
root = tk.Tk()
root.title("Calculator")
entry = tk.Entry(root, font=("Arial", 18))
entry.pack(fill="x", padx=10, pady=10)
result_var = tk.StringVar()
tk.Label(root, textvariable=result_var, font=("Arial", 14)).pack()
tk.Button(root, text="=", command=calculate).pack(pady=5)
root.mainloop()
`root.mainloop()` is what keeps a GUI program running and listening for clicks — without it, the window would flash and close immediately, the same way a console program without a `while True` menu just ends after one pass.
A window that converts a temperature between Celsius and Fahrenheit as the user types, with no separate 'convert' click needed.
Celsius: [ 100 ] → Fahrenheit: [ 212.0 ]
Concepts
How it was built
celsius.trace_add("write", convert) makes convert() run automatically every time the Celsius field's text changes — this is what "event-driven" means: code that reacts to something happening, instead of running once from top to bottom.fahrenheit = celsius * 9 / 5 + 32 — nothing new there; what's new is when it runs.Trace: typing "100" into Celsius fires convert() on every keystroke, and once the full number lands, Fahrenheit shows 212.0.
Complete code
import tkinter as tk
def convert(*args):
try:
c = float(celsius.get())
fahrenheit_var.set(f"{c * 9 / 5 + 32:.1f}")
except ValueError:
fahrenheit_var.set("")
root = tk.Tk()
root.title("Temperature Converter")
celsius = tk.StringVar()
celsius.trace_add("write", convert)
fahrenheit_var = tk.StringVar()
tk.Label(root, text="Celsius").grid(row=0, column=0)
tk.Entry(root, textvariable=celsius).grid(row=0, column=1)
tk.Label(root, text="Fahrenheit").grid(row=1, column=0)
tk.Label(root, textvariable=fahrenheit_var).grid(row=1, column=1)
root.mainloop()
`celsius.trace_add("write", convert)` runs `convert()` automatically every time the text changes — this is what "event-driven" means: code that reacts to something happening, instead of running top to bottom once.
A small billing window: pick items and quantities, add them to a bill list shown in the window, and show a running total.
Item: [Notebook v] Qty: [3] [Add]
Notebook x3 - ₹120
Total: ₹120
Concepts
How it was built
global total is required because add_item() changes a variable that was created outside any function — the same rule first met in the Bank Account Simulator, just inside a button's callback instead of a menu function.bill_list.insert("end", ...) adds one line to a Listbox widget every time Add is clicked, building up a visible running record without redrawing the whole window.Trace: picking "Notebook" at quantity 3 and clicking Add computes 40 * 3 = 120, appends "Notebook x3 - ₹120" to the list, and updates the total label to ₹120.
Complete code
import tkinter as tk
PRICES = {"Notebook": 40, "Pen": 10, "Eraser": 5}
total = 0
def add_item():
global total
item = item_var.get()
qty = int(qty_entry.get())
cost = PRICES[item] * qty
total += cost
bill_list.insert("end", f"{item} x{qty} - ₹{cost}")
total_var.set(f"Total: ₹{total}")
root = tk.Tk()
root.title("Billing")
item_var = tk.StringVar(value="Notebook")
tk.OptionMenu(root, item_var, *PRICES.keys()).pack()
qty_entry = tk.Entry(root)
qty_entry.pack()
tk.Button(root, text="Add", command=add_item).pack()
bill_list = tk.Listbox(root)
bill_list.pack(fill="both", expand=True)
total_var = tk.StringVar(value="Total: ₹0")
tk.Label(root, textvariable=total_var).pack()
root.mainloop()
`global total` is needed here because `add_item()` changes a variable created outside any function — the same rule you first met in the Bank Account Simulator, just inside a GUI callback instead of a menu function.
Rebuild the Bank Account Simulator as a class — the account and its behavior (deposit, withdraw, balance) live together in one object instead of a loose balance variable and separate functions.
acc = BankAccount("Rahul", 1000)
acc.deposit(500)
acc.show_balance()
Rahul's balance: 1500
Concepts
How it was built
class BankAccount bundles data (owner, balance) and behavior (deposit, withdraw, show_balance) into one definition — a blueprint for creating account objects.__init__ runs once, automatically, when BankAccount(...) is called — it's where self.owner and self.balance get their starting values.self is how a method reaches the specific object it was called on — self.balance inside withdraw() means "this account's balance," not some balance floating outside the class.acc.deposit(500) to Level 2's balance = deposit(balance) — the account now carries its own data with it, so there's no passing balance in and getting a new one back; the method just updates self.balance directly.Trace: acc = BankAccount("Rahul", 1000), acc.deposit(500) sets self.balance to 1500 on that specific object, so acc.show_balance() prints "Rahul's balance: 1500".
Complete code
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
print("Deposit successful!")
def withdraw(self, amount):
if amount <= self.balance:
self.balance -= amount
print("Withdrawal successful!")
else:
print("Insufficient balance!")
def show_balance(self):
print(f"{self.owner}'s balance: {self.balance}")
acc = BankAccount("Rahul", 1000)
acc.deposit(500)
acc.withdraw(200)
acc.show_balance()
Compare this to the function-based version from Level 2: `self.balance` replaces passing `balance` in and out of every function — the account now carries its own data with it. That's the whole idea behind a class.
Once these feel comfortable without copying the solution, the Where to Go Next lesson covers virtual environments, pip, and which framework or library to look at first.