โ† Back to Training

๐Ÿ Python Programming Masterclass

๐Ÿ“š 20 lessons โฑ๏ธ 5 hours ๐ŸŽฏ Beginner to Advanced โญ Most Popular ๐Ÿ’ป 100% Online ๐Ÿ“– Self-Paced

๐Ÿ“– Course Overview

Format: 20 structured lessons, 15 minutes each. Complete at your own pace with hands-on coding exercises.

Master Python from zero to professional level in just 5 hours of focused learning. This self-study course is designed for maximum efficiency, covering computer science fundamentals, Python mastery, object-oriented programming, design patterns, testing, and real-world applications. Each 15-minute lesson includes theory, code examples, and practical exercises.

Course Structure:

  • ๏ฟฝ Lessons 1-5: Python basics, data types, control flow, functions (75 min)
  • ๐Ÿ“Š Lessons 6-10: Data structures, OOP, classes, inheritance (75 min)
  • ๐Ÿ—๏ธ Lessons 11-15: Design patterns, SOLID principles, testing (75 min)
  • ๏ฟฝ Lessons 16-20: Web development, APIs, databases, deployment (75 min)

Learning Outcomes:

  • โœ… Write professional Python code following industry best practices
  • โœ… Understand algorithms, data structures, and Big O complexity
  • โœ… Build web applications with Flask and Django
  • โœ… Apply SOLID principles and design patterns
  • โœ… Work with databases, APIs, and external libraries
  • โœ… Test, debug, and deploy production-ready applications

Build professional applications including scalable web APIs, data pipelines, automation systems, and machine learning models. Master Python's ecosystem including Django, Flask, FastAPI, NumPy, Pandas, SQLAlchemy, and more. Learn industry best practices used at companies like Google, Microsoft, and Amazon.

Prerequisites: No programming experience required. Critical thinking and problem-solving mindset recommended.

Learning Outcomes: After completing this course, you'll be able to:

  • Write production-quality Python code following PEP 8 and industry standards
  • Design and implement complex software architectures
  • Analyze algorithm complexity and optimize code performance
  • Build scalable web applications and RESTful APIs
  • Implement comprehensive testing strategies (unit, integration, E2E)
  • Debug production issues using logging, profiling, and monitoring tools
  • Work with databases, ORMs, and data persistence layers
  • Apply design patterns and architectural principles

๐Ÿ“‹ Course Curriculum - 20 Lessons

Each lesson is 15 minutes of focused learning with theory, examples, and exercises.

Part 1: Python Fundamentals (Lessons 1-5) - 75 min

  • Lesson 1: Python Setup, Variables, Data Types, Basic Operators
  • Lesson 2: Control Flow - If/Elif/Else, For Loops, While Loops
  • Lesson 3: Functions, Parameters, Return Values, Scope
  • Lesson 4: Lists, Tuples, List Comprehensions
  • Lesson 5: Dictionaries, Sets, String Manipulation

Part 2: OOP & Advanced Python (Lessons 6-10) - 75 min

  • Lesson 6: Classes, Objects, Methods, __init__
  • Lesson 7: Inheritance, Polymorphism, Encapsulation
  • Lesson 8: Decorators, Generators, Iterators
  • Lesson 9: File I/O, JSON, CSV, Exception Handling
  • Lesson 10: Modules, Packages, pip, Virtual Environments

Part 3: Professional Development (Lessons 11-15) - 75 min

  • Lesson 11: Algorithm Complexity, Big O Notation, Common Algorithms
  • Lesson 12: Design Patterns - Singleton, Factory, Observer
  • Lesson 13: SOLID Principles, Clean Code, Best Practices
  • Lesson 14: Testing with pytest, Unit Tests, TDD
  • Lesson 15: Debugging, Logging, Profiling

Part 4: Real-World Applications (Lessons 16-20) - 75 min

  • Lesson 16: Web Development with Flask - Routes, Templates, Forms
  • Lesson 17: REST APIs, JSON, HTTP Methods, Status Codes
  • Lesson 18: Database Integration - SQLAlchemy, CRUD Operations
  • Lesson 19: Data Analysis with Pandas - DataFrames, Cleaning, Visualization
  • Lesson 20: Final Project - Build Complete Web Application

๐Ÿ“š Detailed Lesson Content

Below you'll find the complete material for all 20 lessons. Study at your own pace, practice with code examples, and complete the exercises.

Lesson 1: Python Setup & Basics (15 min)

๐ŸŽฏ Learning Objectives

  • Understand what Python is and why it's popular
  • Learn how to store information in variables
  • Master the fundamental data types (numbers, text, true/false)
  • Perform basic mathematical operations

๐Ÿ“– What is Python?

Python is a programming language created in 1991 by Guido van Rossum. Think of it as a way to communicate with computers using English-like commands. It's one of the easiest programming languages to learn, which is why millions of beginners start here. Companies like Google, Netflix, and NASA use Python daily!

Why Python is popular: It reads almost like English, has a massive community for help, and can do everything from building websites to analyzing data to creating AI. You can write Python code in any text editor, but we recommend VS Code or PyCharm for beginners.

๐Ÿ’ก Variables: Storing Information

A variable is like a labeled box where you store information. You give it a name, and Python remembers what's inside. Think of it like this: if you have a box labeled "age" with the number 25 inside, whenever you ask for "age", Python gives you 25.

# Creating variables - it's as simple as: name = value
name = "Alice"              # Text (called a "string")
age = 25                    # Whole number (called an "integer")  
height = 5.7                # Decimal number (called a "float")
is_student = True           # True or False (called a "boolean")

# You can change variables anytime
age = 26                    # Alice had a birthday!
print(age)                  # Output: 26

# Python is smart - it knows what type each variable is
print(type(name))           #  means string
print(type(age))            #  means integer
print(type(height))         #  means floating-point number
print(type(is_student))     #  means boolean

# Variable naming rules:
# โœ… GOOD: user_name, total_price, age2
# โŒ BAD: 2age (can't start with number), user-name (no hyphens), class (reserved word)

๐Ÿ”ข Numbers and Math Operations

Python can perform all basic math operations just like a calculator. Here's what each symbol means and how to use it:

# Addition (+) - adds two numbers
total = 10 + 5              # Result: 15
print(f"10 + 5 = {total}")  # Output: 10 + 5 = 15

# Subtraction (-) - subtracts second from first
difference = 10 - 5         # Result: 5
print(f"10 - 5 = {difference}")

# Multiplication (*) - multiplies two numbers
product = 10 * 5            # Result: 50
print(f"10 ร— 5 = {product}")

# Division (/) - always gives a decimal result
division = 10 / 3           # Result: 3.3333333...
print(f"10 รท 3 = {division}")

# Floor Division (//) - divides and rounds DOWN to nearest whole number
floor_div = 10 // 3         # Result: 3 (not 3.33...)
print(f"10 รท 3 (rounded down) = {floor_div}")

# Modulo (%) - gives the REMAINDER after division
# Think: "10 divided by 3 goes 3 times with 1 left over"
remainder = 10 % 3          # Result: 1 (the leftover)
print(f"10 mod 3 = {remainder}")
# Practical use: Check if number is even (num % 2 == 0)

# Exponentiation (**) - raises to a power
power = 2 ** 3              # Result: 8 (2 ร— 2 ร— 2)
squared = 5 ** 2            # Result: 25 (5 ร— 5)
print(f"2ยณ = {power}")
print(f"5ยฒ = {squared}")

# Order of operations follows PEMDAS (like math class!)
# Parentheses, Exponents, Multiplication/Division, Addition/Subtraction
result = 2 + 3 * 4          # Result: 14 (not 20, because * goes first)
result2 = (2 + 3) * 4       # Result: 20 (parentheses first!)

# Practical example: Calculate total price with tax
price = 100
tax_rate = 0.08             # 8% tax
tax = price * tax_rate      # Calculate tax amount: 8
total_price = price + tax   # Add tax to price: 108
print(f"Price: ${price}, Tax: ${tax}, Total: ${total_price}")

๐Ÿ“ Working with Text (Strings)

Strings are sequences of characters (letters, numbers, symbols) surrounded by quotes. You can use single quotes ('text') or double quotes ("text") - they work the same. Strings are used for names, messages, emails, basically any text data.

# Creating strings - use quotes!
first_name = "Alice"
last_name = 'Smith'         # Single quotes work too!
full_name = first_name + " " + last_name  # Joining strings (concatenation)
print(full_name)            # Output: Alice Smith

# String length - count characters
length = len(full_name)     # Result: 11 (including the space!)
print(f"'{full_name}' has {length} characters")

# F-strings - the modern way to insert variables into text
# Put 'f' before the quotes and use {variable_name} inside
age = 25
message = f"{first_name} is {age} years old"  
print(message)              # Output: Alice is 25 years old

# You can even do math inside f-strings!
print(f"In 5 years, {first_name} will be {age + 5}")
# Output: In 5 years, Alice will be 30

# Old way (you'll see this in older code)
old_way = "My name is " + first_name + " and I am " + str(age) + " years old"
# F-strings are much cleaner!

# Multiline strings - use three quotes
long_text = """
This is a long message
that spans multiple lines.
Very useful for long descriptions!
"""

# Common string operations
text = "Hello World"
print(text.upper())         # HELLO WORLD (all uppercase)
print(text.lower())         # hello world (all lowercase)
print(text.replace("World", "Python"))  # Hello Python
print(text.split())         # ['Hello', 'World'] (splits into list)

# Check if text contains something
email = "alice@email.com"
if "@" in email:
    print("Valid email format!")  # This will print

โœ… True or False (Booleans)

Booleans can only be True or False (notice the capital letters!). They're used for yes/no questions: Is the user logged in? Is the age valid? Did the payment succeed?

# Boolean values
is_student = True
is_graduated = False
has_license = True

# Comparison operators create booleans
age = 25
is_adult = age >= 18        # True (25 is greater than or equal to 18)
is_teenager = age < 20      # False (25 is NOT less than 20)
is_exactly_25 = age == 25   # True (equal comparison uses ==, not =)
is_not_30 = age != 30       # True (not equal to 30)

print(f"Is adult? {is_adult}")           # Output: Is adult? True
print(f"Is teenager? {is_teenager}")     # Output: Is teenager? False

# Combining conditions with AND, OR, NOT
has_id = True
is_over_21 = age >= 21

can_enter_club = has_id and is_over_21   # True (both must be true)
print(f"Can enter club? {can_enter_club}")

is_weekend = True
is_holiday = False
can_sleep_in = is_weekend or is_holiday  # True (at least one is true)
print(f"Can sleep in? {can_sleep_in}")

is_awake = True
is_sleeping = not is_awake               # False (opposite of True)
print(f"Is sleeping? {is_sleeping}")

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Create variables for your name, age, and favorite color. Print them in a sentence.
  2. Calculate your age in months (age ร— 12) and days (age ร— 365).
  3. Create two numbers and print their sum, difference, product, and division.
  4. Check if your age is greater than 18 and print the result.

โš ๏ธ Common Mistakes to Avoid

  • Using = instead of ==: age = 25 assigns a value, age == 25 compares
  • Forgetting quotes for strings: name = Alice โŒ should be name = "Alice" โœ…
  • Mixing types: "5" + 5 โŒ causes error. Use int("5") + 5 โœ…
  • Case sensitivity: Name and name are different variables!

Lesson 2: Control Flow (15 min)

๐ŸŽฏ Learning Objectives

  • Make decisions in your code with if/elif/else statements
  • Repeat actions automatically with loops
  • Understand when to use for vs while loops
  • Control loop execution with break and continue

๐Ÿค” Making Decisions (If Statements)

Programs need to make decisions, just like humans. "If it's raining, bring an umbrella. Otherwise, don't." In Python, we use if, elif (else if), and else to create these decision branches.

Important: Python uses indentation (spaces or tabs) to know what code belongs to the if statement. This is different from many other languages! Always indent 4 spaces after a colon (:).

# Simple if statement - like asking a yes/no question
age = 20
if age >= 18:
    print("You are an adult")           # This runs if condition is True
    print("You can vote")               # Also part of the if block (indented)
print("This always runs")               # Not indented = outside if block

# if-else - choose between two options
temperature = 75
if temperature > 80:
    print("It's hot! ๐ŸŒž")
else:
    print("It's nice out! ๐ŸŒค๏ธ")

# if-elif-else - multiple conditions (like a decision tree)
score = 85

if score >= 90:                         # First check
    grade = "A"
    print("Excellent! ๐ŸŒŸ")
elif score >= 80:                       # Only checked if first is False
    grade = "B"
    print("Great job! ๐Ÿ‘")
elif score >= 70:                       # Only checked if above are False
    grade = "C"
    print("Good! ๐Ÿ‘Œ")
elif score >= 60:
    grade = "D"
    print("You passed ๐Ÿ˜…")
else:                                   # If all above are False
    grade = "F"
    print("Need to study more ๐Ÿ“š")

print(f"Your grade is: {grade}")

# Real-world example: Login validation
username = "alice"
password = "secret123"
stored_username = "alice"
stored_password = "secret123"

if username == stored_username and password == stored_password:
    print("โœ… Login successful! Welcome!")
elif username == stored_username:
    print("โŒ Incorrect password")
else:
    print("โŒ User not found")

# Nested if statements - if inside if
age = 25
has_license = True

if age >= 18:
    print("Old enough to drive")
    if has_license:
        print("You can drive! ๐Ÿš—")
    else:
        print("Get your license first! ๐Ÿ“")
else:
    print("Too young to drive")

๐Ÿ”„ For Loops - Repeating Actions

A for loop repeats code a specific number of times or goes through items in a list. Think of it as: "For each item in this collection, do something." It's perfect when you know how many times you want to repeat.

# range(n) - generates numbers from 0 to n-1
# range(5) gives you: 0, 1, 2, 3, 4
print("Counting to 5:")
for i in range(5):
    print(f"Number: {i}")               # Runs 5 times
# Output: Number: 0, Number: 1, Number: 2, Number: 3, Number: 4

# range(start, stop) - custom range
print("\nCounting from 1 to 5:")
for i in range(1, 6):                   # Starts at 1, stops before 6
    print(i, end=" ")                   # end=" " prints on same line
# Output: 1 2 3 4 5

# range(start, stop, step) - count by different amounts
print("\nEven numbers:")
for i in range(0, 11, 2):               # Count by 2s
    print(i, end=" ")
# Output: 0 2 4 6 8 10

# Looping through lists - very common!
fruits = ["apple", "banana", "orange", "grape"]
print("\n\nFruits in my basket:")
for fruit in fruits:                    # 'fruit' takes each value in turn
    print(f"  - {fruit}")
    
# Get index AND value with enumerate()
print("\nNumbered list:")
for index, fruit in enumerate(fruits, start=1):
    print(f"{index}. {fruit}")
# Output: 1. apple, 2. banana, etc.

# Practical example: Calculate total
prices = [10.99, 5.49, 3.99, 12.50]
total = 0
for price in prices:
    total = total + price               # Add each price to total
    # Shorthand: total += price
print(f"Total: ${total:.2f}")          # :.2f means 2 decimal places

# Nested loops - loop inside loop
print("\nMultiplication table:")
for i in range(1, 4):                  # Outer loop (rows)
    for j in range(1, 4):              # Inner loop (columns)
        result = i * j
        print(f"{i}ร—{j}={result}", end="  ")
    print()                             # New line after each row
# Output:
# 1ร—1=1  1ร—2=2  1ร—3=3  
# 2ร—1=2  2ร—2=4  2ร—3=6  
# 3ร—1=3  3ร—2=6  3ร—3=9

๐Ÿ” While Loops - Repeat Until Condition Changes

A while loop keeps repeating as long as a condition is True. Use it when you don't know exactly how many times you need to repeat. Think: "While the door is locked, keep trying keys."

โš ๏ธ Warning: Make sure the condition eventually becomes False, or you'll create an infinite loop that never stops!

# Basic while loop
count = 0
while count < 5:                       # Keep going while count is less than 5
    print(f"Count is: {count}")
    count = count + 1                  # IMPORTANT: Change the condition!
    # Shorthand: count += 1
print("Loop finished!")

# Real example: User input validation
# Keep asking until user enters valid input
attempts = 0
max_attempts = 3

while attempts < max_attempts:
    password = input("Enter password: ")
    if password == "secret123":
        print("โœ… Access granted!")
        break                          # Exit loop immediately
    else:
        attempts += 1
        remaining = max_attempts - attempts
        if remaining > 0:
            print(f"โŒ Wrong! {remaining} attempts left")
        else:
            print("โŒ Account locked!")

# While True - intentional infinite loop (use with break!)
print("\nSimple calculator:")
while True:
    user_input = input("Enter a number (or 'quit'): ")
    
    if user_input.lower() == 'quit':
        print("Goodbye!")
        break                          # Only way to exit
    
    number = int(user_input)
    squared = number ** 2
    print(f"{number} squared is {squared}")

# Countdown example
print("\nRocket launch countdown:")
seconds = 10
while seconds > 0:
    print(seconds)
    seconds -= 1
    # In real program: time.sleep(1) would wait 1 second
print("๐Ÿš€ Blast off!")

๐ŸŽฎ Loop Control: Break and Continue

Sometimes you need to skip an iteration or exit a loop early. break stops the loop completely. continue skips to the next iteration.

# break - exit loop immediately
print("Finding first even number:")
numbers = [1, 3, 5, 8, 9, 10, 12]
for num in numbers:
    if num % 2 == 0:                   # Check if even
        print(f"Found it: {num}")
        break                          # Stop searching
    print(f"{num} is odd, keep looking...")

# continue - skip to next iteration
print("\nPrint only positive numbers:")
numbers = [5, -2, 8, -1, 3, -4, 7]
for num in numbers:
    if num < 0:
        continue                       # Skip negative numbers
    print(num)                         # Only prints positive ones
# Output: 5, 8, 3, 7

# Practical example: Process valid data only
print("\nProcessing emails:")
emails = ["alice@email.com", "invalid", "bob@email.com", "", "charlie@email.com"]
valid_count = 0

for email in emails:
    if not email:                      # Skip empty strings
        continue
    if "@" not in email:               # Skip invalid emails
        print(f"โš ๏ธ  Skipping invalid: {email}")
        continue
    
    # This code only runs for valid emails
    print(f"โœ… Sending to: {email}")
    valid_count += 1

print(f"\nSent {valid_count} emails")

# else with loops - runs if loop completes WITHOUT break
print("\nSearching for 'python':")
words = ["java", "javascript", "ruby", "go"]
for word in words:
    if word == "python":
        print("Found Python!")
        break
else:
    # This runs because loop finished without finding python
    print("Python not in list")

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Write an if statement that checks if a number is positive, negative, or zero.
  2. Use a for loop to print numbers from 10 down to 1, then print "Happy New Year!"
  3. Create a list of prices and use a loop to calculate the average.
  4. Write a while loop that keeps doubling a number until it's greater than 100.
  5. Use a for loop with continue to print only numbers divisible by 3 from 1 to 20.

โš ๏ธ Common Mistakes to Avoid

  • Forgetting to indent: Code after if/for/while MUST be indented
  • Infinite while loops: Always ensure the condition will eventually be False
  • Off-by-one errors: range(5) gives 0-4, not 1-5
  • Using = instead of ==: if x = 5 โŒ should be if x == 5 โœ…

Lesson 3: Functions & Parameters (15 min)

๐ŸŽฏ Learning Objectives

  • Create reusable code blocks with functions
  • Pass data to functions using parameters and arguments
  • Return results from functions
  • Understand scope (where variables exist)

๐Ÿ”ง What Are Functions?

Functions are like mini-programs inside your program. Instead of writing the same code multiple times, you write it once as a function and call it whenever needed. Think of a function like a recipe: you write the recipe once, then follow it any time you want to make that dish.

Benefits: Less code duplication, easier to test, easier to fix bugs (fix once, works everywhere), makes code more readable.

# Without functions - repetitive code ๐Ÿ˜ซ
print("Welcome to our store!")
print("We're glad you're here!")
print("---")

print("Welcome to our store!")
print("We're glad you're here!")
print("---")

# With functions - DRY (Don't Repeat Yourself) ๐Ÿ˜Ž
def welcome_message():
    """Display welcome message to user."""
    print("Welcome to our store!")
    print("We're glad you're here!")
    print("---")

# Now just call it
welcome_message()    # First time
welcome_message()    # Second time
# Much cleaner!

# Function anatomy:
# def        = keyword to define function
# function_name = name you choose (use descriptive names!)
# ()         = parameters go here (empty if none)
# :          = colon starts function body
# body       = indented code that runs when function is called
# return     = optional, sends data back to caller

๐Ÿ“จ Parameters - Giving Data to Functions

Parameters are like placeholders in a function definition. Arguments are the actual values you pass when calling. Think of parameters as empty boxes in the recipe, and arguments as the ingredients you put in those boxes.

# Function with one parameter
def greet(name):                       # 'name' is the parameter
    """Greet a person by name."""
    message = f"Hello, {name}! ๐Ÿ‘‹"
    print(message)

greet("Alice")                         # "Alice" is the argument
greet("Bob")                           # "Bob" is the argument
# Output:
# Hello, Alice! ๐Ÿ‘‹
# Hello, Bob! ๐Ÿ‘‹

# Multiple parameters
def introduce(name, age, city):
    """Introduce a person with their details."""
    print(f"My name is {name}.")
    print(f"I'm {age} years old.")
    print(f"I live in {city}.")
    print()

introduce("Alice", 25, "New York")
introduce("Bob", 30, "Los Angeles")

# Default parameter values - optional arguments
def order_coffee(size="medium", sugar=1):
    """Order coffee with optional customization."""
    print(f"Ordering {size} coffee with {sugar} sugar(s)")

order_coffee()                         # Uses defaults: medium, 1 sugar
order_coffee("large")                  # Large, 1 sugar (uses default sugar)
order_coffee("small", 2)               # Small, 2 sugars
order_coffee(sugar=3, size="large")    # Named arguments - can be in any order!

# Real-world example: Calculate price with discount
def calculate_price(original_price, discount_percent=0):
    """Calculate final price after discount.
    
    Args:
        original_price: The original price before discount
        discount_percent: Discount as percentage (default 0)
    
    Returns:
        Final price after discount
    """
    discount_amount = original_price * (discount_percent / 100)
    final_price = original_price - discount_amount
    return final_price

# Usage
price1 = calculate_price(100)          # No discount
price2 = calculate_price(100, 20)      # 20% off
price3 = calculate_price(100, discount_percent=15)  # Named argument

print(f"Regular: ${price1}")
print(f"20% off: ${price2}")
print(f"15% off: ${price3}")

โ†ฉ๏ธ Return Values - Getting Results Back

The return statement sends a value back to wherever the function was called. Without return, the function does its work but gives you nothing back. With return, you can save and use the result.

# Function without return - just prints
def print_square(number):
    result = number ** 2
    print(f"{number} squared is {result}")

print_square(5)                        # Prints: 5 squared is 25
# But you can't save the result!

# Function with return - gives back a value
def calculate_square(number):
    """Calculate and return the square of a number."""
    result = number ** 2
    return result                      # Send result back
    # Code after return never runs!

square = calculate_square(5)           # Save the result
print(f"The square is: {square}")
# Now you can use it in calculations:
total = square + 10
print(f"Square + 10 = {total}")

# Return multiple values (actually returns a tuple)
def get_stats(numbers):
    """Calculate min, max, and average of a list."""
    minimum = min(numbers)
    maximum = max(numbers)
    average = sum(numbers) / len(numbers)
    
    return minimum, maximum, average   # Return three values

data = [10, 20, 30, 40, 50]
min_val, max_val, avg_val = get_stats(data)  # Unpack into 3 variables

print(f"Min: {min_val}")
print(f"Max: {max_val}")
print(f"Average: {avg_val}")

# Real-world example: Validate email
def is_valid_email(email):
    """Check if email address is valid (simple check).
    
    Returns:
        True if valid, False otherwise
    """
    if "@" not in email:
        return False                   # Early return if invalid
    if "." not in email:
        return False
    if len(email) < 5:
        return False
    return True                        # All checks passed

# Usage with if statement
email1 = "alice@email.com"
email2 = "invalid"

if is_valid_email(email1):
    print(f"{email1} is valid โœ…")
else:
    print(f"{email1} is invalid โŒ")

if is_valid_email(email2):
    print(f"{email2} is valid โœ…")
else:
    print(f"{email2} is invalid โŒ")

# Return None explicitly (optional - functions return None by default)
def save_file(filename, data):
    """Save data to file (simplified example)."""
    if not filename:
        return None                    # Indicate failure
    # In real program: write to file here
    print(f"Saved to {filename}")
    return True                        # Indicate success

result = save_file("data.txt", "Hello")
if result:
    print("Save successful!")
else:
    print("Save failed!")

๐ŸŒ Variable Scope - Where Variables Live

Scope determines where a variable can be used. Variables created inside a function only exist inside that function (local scope). Variables outside functions can be used anywhere (global scope).

# Global variable - accessible everywhere
player_name = "Alice"                  # Global scope

def show_score():
    score = 100                        # Local variable - only exists in this function
    print(f"{player_name} scored {score}")

show_score()                           # Works: can see player_name
# print(score)                         # ERROR! score doesn't exist here

# Function parameters are also local variables
def calculate(x, y):
    result = x + y                     # x, y, and result are all local
    return result

answer = calculate(5, 3)
# print(x)                             # ERROR! x only exists inside calculate()

# Modifying global variables (use sparingly!)
total_sales = 0                        # Global variable

def make_sale(amount):
    global total_sales                 # Tell Python we want to modify global
    total_sales += amount
    print(f"Sale recorded: ${amount}")
    print(f"Total sales: ${total_sales}")

make_sale(50)
make_sale(75)
print(f"Final total: ${total_sales}") # 125

# Best practice: Return values instead of modifying globals
def add_to_total(current_total, amount):
    """Better approach - return new total instead of modifying global."""
    return current_total + amount

my_total = 0
my_total = add_to_total(my_total, 50)
my_total = add_to_total(my_total, 75)
print(f"Final: ${my_total}")

๐Ÿš€ Advanced: Lambda Functions

Lambda functions are small, anonymous functions written in one line. Use them for simple operations, especially with functions like map(), filter(), and sorted().

# Regular function
def square(x):
    return x ** 2

print(square(5))                       # 25

# Same thing as lambda (anonymous function)
square_lambda = lambda x: x ** 2
print(square_lambda(5))                # 25

# Lambda with multiple parameters
multiply = lambda x, y: x * y
print(multiply(4, 5))                  # 20

# Practical use: with map() to transform a list
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x**2, numbers))
print(squared)                         # [1, 4, 9, 16, 25]

# With filter() to select items
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)                           # [2, 4, 6, 8, 10]

# With sorted() for custom sorting
students = [
    {"name": "Alice", "grade": 85},
    {"name": "Bob", "grade": 92},
    {"name": "Charlie", "grade": 78}
]
# Sort by grade
sorted_students = sorted(students, key=lambda s: s["grade"], reverse=True)
for student in sorted_students:
    print(f"{student['name']}: {student['grade']}")

๐Ÿ“ Docstrings - Documenting Functions

Always document your functions with docstrings (triple quotes). Explain what the function does, what parameters it takes, and what it returns. Future you will thank present you!

def calculate_bmi(weight_kg, height_m):
    """
    Calculate Body Mass Index (BMI).
    
    BMI = weight (kg) / height (m)ยฒ
    
    Args:
        weight_kg (float): Weight in kilograms
        height_m (float): Height in meters
    
    Returns:
        float: BMI value rounded to 1 decimal place
    
    Example:
        >>> calculate_bmi(70, 1.75)
        22.9
    """
    bmi = weight_kg / (height_m ** 2)
    return round(bmi, 1)

# View docstring
help(calculate_bmi)                    # Shows the documentation
print(calculate_bmi.__doc__)           # Prints docstring

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Create a function celsius_to_fahrenheit(celsius) that converts temperature. Formula: F = C ร— 9/5 + 32
  2. Write a function is_even(number) that returns True if number is even, False otherwise.
  3. Create get_full_name(first, last) that returns the full name with proper capitalization.
  4. Write calculate_interest(principal, rate, years) with rate defaulting to 5%.
  5. Create a function that takes a list of numbers and returns a tuple of (sum, count, average).

โš ๏ธ Common Mistakes to Avoid

  • Forgetting parentheses when calling: greet (function object) vs greet() (calls function)
  • Not returning a value: If you need the result, use return!
  • Trying to use local variables outside function: They don't exist outside their scope
  • Mutable default arguments: Don't use def func(list=[]) - use None instead

Lesson 4: Lists & Tuples (15 min)

๐ŸŽฏ Learning Objectives

  • Work with lists - Python's most versatile data structure
  • Master indexing and slicing to access data
  • Understand when to use tuples vs lists
  • Use list comprehensions for elegant code

๐Ÿ“‹ Lists - Ordered Collections

Lists are like containers that hold multiple items in order. Think of a shopping list, playlist, or to-do list. Lists are mutable (changeable) - you can add, remove, or modify items after creation.

# Creating lists
fruits = ["apple", "banana", "orange"]           # List of strings
numbers = [1, 2, 3, 4, 5]                        # List of numbers
mixed = [1, "hello", 3.14, True]                 # Lists can mix types!
nested = [[1, 2], [3, 4], [5, 6]]                # List of lists
empty = []                                        # Empty list

# Accessing items with indexing
# Index starts at 0 (first item is [0], not [1]!)
fruits = ["apple", "banana", "orange", "grape"]
print(fruits[0])                                  # apple (first item)
print(fruits[1])                                  # banana (second item)
print(fruits[-1])                                 # grape (last item)
print(fruits[-2])                                 # orange (second from end)

# Visual representation:
#  Index:    0        1         2        3
#  List:  ["apple", "banana", "orange", "grape"]
#  Neg:    -4       -3        -2        -1

# Modifying lists
fruits[1] = "mango"                               # Change banana to mango
print(fruits)                                     # ['apple', 'mango', 'orange', 'grape']

๐Ÿ”ช Slicing - Get Portions of Lists

Slicing lets you extract a portion of a list. Syntax: list[start:stop:step]. Remember: it includes start but excludes stop.

numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

# Basic slicing
print(numbers[2:5])                               # [2, 3, 4] - from index 2 to 4
print(numbers[:3])                                # [0, 1, 2] - from start to index 2
print(numbers[7:])                                # [7, 8, 9] - from index 7 to end
print(numbers[:])                                 # [0, 1, 2, ...] - copy entire list

# Step parameter - take every nth item
print(numbers[::2])                               # [0, 2, 4, 6, 8] - every 2nd item
print(numbers[1::2])                              # [1, 3, 5, 7, 9] - odd numbers
print(numbers[::-1])                              # [9, 8, 7, ... 0] - reverse list!

# Practical example: Get first 3 and last 3 items
scores = [95, 87, 92, 78, 88, 91, 85, 90, 93]
top_3 = scores[:3]                                # [95, 87, 92]
last_3 = scores[-3:]                              # [85, 90, 93]

๐Ÿ› ๏ธ List Methods - Built-in Operations

Lists come with powerful built-in methods to add, remove, sort, and search for items.

# Adding items
fruits = ["apple", "banana"]
fruits.append("orange")                           # Add to end: ["apple", "banana", "orange"]
fruits.insert(1, "mango")                         # Insert at position 1: ["apple", "mango", "banana", "orange"]
fruits.extend(["grape", "kiwi"])                  # Add multiple items
# Result: ["apple", "mango", "banana", "orange", "grape", "kiwi"]

# Removing items
fruits.remove("banana")                           # Remove specific item (first occurrence)
last_fruit = fruits.pop()                         # Remove and return last item
second_fruit = fruits.pop(1)                      # Remove and return item at index 1
fruits.clear()                                    # Remove all items (empty list)

# Searching and counting
numbers = [1, 2, 3, 2, 4, 2, 5]
position = numbers.index(3)                       # Find position of first 3 (returns 2)
count = numbers.count(2)                          # How many 2s? (returns 3)
exists = 4 in numbers                             # Check if 4 exists (returns True)

# Sorting
numbers = [5, 2, 8, 1, 9, 3]
numbers.sort()                                    # Sort in place: [1, 2, 3, 5, 8, 9]
numbers.sort(reverse=True)                        # Descending: [9, 8, 5, 3, 2, 1]
sorted_copy = sorted(numbers)                     # Create sorted copy (original unchanged)

words = ["zebra", "apple", "banana", "Cherry"]
words.sort()                                      # Alphabetical: ['Cherry', 'apple', 'banana', 'zebra']
words.sort(key=str.lower)                         # Case-insensitive: ['apple', 'banana', 'Cherry', 'zebra']

# Real-world example: Shopping cart
cart = []
cart.append({"item": "laptop", "price": 999})
cart.append({"item": "mouse", "price": 25})
cart.append({"item": "keyboard", "price": 75})

# Sort by price
cart.sort(key=lambda x: x["price"])
print("Cheapest first:")
for item in cart:
    print(f"  {item['item']}: ${item['price']}")

โšก List Comprehensions - Elegant List Creation

List comprehensions create new lists in one concise line. They're faster and more readable than loops for simple transformations.

# Traditional way with loop
squares = []
for x in range(10):
    squares.append(x ** 2)
print(squares)                                    # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

# List comprehension way - same result, one line!
squares = [x**2 for x in range(10)]
print(squares)                                    # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

# Syntax: [expression for item in iterable]

# With condition (filter)
evens = [x for x in range(20) if x % 2 == 0]
print(evens)                                      # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

# Transform strings
names = ["alice", "bob", "charlie"]
capitalized = [name.capitalize() for name in names]
print(capitalized)                                # ['Alice', 'Bob', 'Charlie']

# More complex: multiple conditions
# Numbers divisible by both 2 and 3
divisible = [x for x in range(30) if x % 2 == 0 if x % 3 == 0]
print(divisible)                                  # [0, 6, 12, 18, 24]

# Conditional expression (if-else in expression)
# Label numbers as even/odd
labels = ["even" if x % 2 == 0 else "odd" for x in range(5)]
print(labels)                                     # ['even', 'odd', 'even', 'odd', 'even']

# Practical example: Extract data from dictionaries
students = [
    {"name": "Alice", "grade": 92},
    {"name": "Bob", "grade": 87},
    {"name": "Charlie", "grade": 95}
]
names = [s["name"] for s in students]
high_achievers = [s["name"] for s in students if s["grade"] >= 90]
print(high_achievers)                             # ['Alice', 'Charlie']

๐Ÿ”’ Tuples - Immutable Lists

Tuples are like lists but immutable (unchangeable). Once created, you can't add, remove, or modify items. Use tuples for data that shouldn't change, like coordinates or RGB colors.

# Creating tuples
coordinates = (10, 20)                            # Tuple with parentheses
rgb_color = (255, 128, 0)                         # RGB: orange
person = ("Alice", 25, "Engineer")                # Mixed types
single = (42,)                                    # Single item - need comma!
# Note: (42) is just a number, (42,) is a tuple

# Accessing items (same as lists)
print(coordinates[0])                             # 10
print(coordinates[-1])                            # 20

# Unpacking - assign tuple values to variables
x, y = coordinates
print(f"x={x}, y={y}")                           # x=10, y=20

name, age, job = person
print(f"{name} is a {job}")                      # Alice is a Engineer

# Swap variables using tuple unpacking (Python magic!)
a = 5
b = 10
a, b = b, a                                       # Swap in one line!
print(a, b)                                       # 10 5

# Why use tuples?
# 1. Faster than lists
# 2. Protect data from accidental changes
# 3. Can be used as dictionary keys (lists can't)
# 4. Function can return multiple values as tuple

# Return multiple values from function
def get_min_max(numbers):
    return min(numbers), max(numbers)             # Returns tuple

minimum, maximum = get_min_max([3, 1, 4, 1, 5, 9])
print(f"Min: {minimum}, Max: {maximum}")         # Min: 1, Max: 9

# Tuple methods (only 2!)
numbers = (1, 2, 3, 2, 4, 2, 5)
count = numbers.count(2)                          # How many 2s? (3)
index = numbers.index(4)                          # Position of first 4 (4)

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Create a list of 10 numbers and use slicing to get every 3rd item.
  2. Use list comprehension to create a list of squares of numbers from 1 to 20.
  3. Given words = ["python", "java", "javascript", "ruby"], create a list of lengths of each word.
  4. Create a tuple with your name, age, and city. Unpack it into variables.
  5. Use list comprehension with condition to get all words longer than 5 characters from a list.

โš ๏ธ Common Mistakes to Avoid

  • Index out of range: List with 5 items has indices 0-4, not 1-5
  • Modifying list while iterating: Can cause unexpected behavior - create a copy first
  • Forgetting lists are mutable: list2 = list1 doesn't copy - use list2 = list1.copy()
  • Single item tuple: (42,) โœ… is tuple, (42) โŒ is just number

Lesson 5: Dictionaries & Sets (15 min)

Topics: Dictionaries, dict methods, sets, set operations

# Dictionaries - key-value pairs
person = {
    "name": "Alice",
    "age": 25,
    "city": "New York",
    "skills": ["Python", "JavaScript"]
}

# Access and modify
print(person["name"])              # Alice
person["email"] = "alice@email.com"  # Add new key
person["age"] = 26                  # Update value

# Safe access with .get()
email = person.get("phone", "Not provided")

# Dictionary comprehension
squared_dict = {x: x**2 for x in range(5)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}

# Iterate over dictionary
for key, value in person.items():
    print(f"{key}: {value}")

# Sets - unordered collections of unique elements
fruits = {"apple", "banana", "orange"}
fruits.add("grape")
fruits.remove("banana")

# Set operations
set_a = {1, 2, 3, 4}
set_b = {3, 4, 5, 6}
union = set_a | set_b          # {1, 2, 3, 4, 5, 6}
intersection = set_a & set_b   # {3, 4}
difference = set_a - set_b     # {1, 2}

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Create a dictionary for a book with keys: title, author, year, pages. Print each key-value pair.
  2. Count how many times each letter appears in "hello world" using a dictionary.
  3. Given two lists of numbers, find numbers that appear in both (use sets).
  4. Create a set of even numbers from 1-20, and a set of multiples of 3. Find their intersection.
  5. Build a simple phonebook dictionary with at least 3 contacts. Add search functionality.

โš ๏ธ Common Mistakes to Avoid

  • KeyError: Use .get() instead of [] when key might not exist
  • Mutable keys: Can't use lists as dictionary keys (use tuples instead)
  • Empty set: {} โŒ is an empty dict, set() โœ… is an empty set
  • Set order: Sets are unordered - can't rely on item order!

Lesson 6: Classes & Objects (15 min)

๐ŸŽฏ Learning Objectives

  • Create blueprints for objects using classes
  • Understand the difference between classes and objects (instances)
  • Use __init__ to initialize objects with data
  • Create methods to define object behavior

๐Ÿ—๏ธ What Are Classes and Objects?

Object-Oriented Programming (OOP) is a way to organize code around "objects" - bundles of data and functions that work together. Think of a class as a cookie cutter (blueprint), and objects as the actual cookies you make from it.

Real-world analogy: A "Car" class is the blueprint defining what all cars have (color, model, speed) and can do (drive, brake, honk). Each specific car (your Honda, my Toyota) is an object created from that blueprint.

# Simple class definition
class Dog:
    """A simple class representing a dog."""
    
    # The __init__ method runs when you create a new dog
    # 'self' refers to the specific dog being created
    def __init__(self, name, age):
        """Initialize a new dog with name and age."""
        self.name = name                          # Instance attribute
        self.age = age                            # Each dog has its own name/age
    
    # Methods - functions that belong to the class
    def bark(self):
        """Make the dog bark."""
        return f"{self.name} says Woof!"
    
    def get_info(self):
        """Return information about the dog."""
        return f"{self.name} is {self.age} years old"
    
    def birthday(self):
        """Celebrate birthday - increment age."""
        self.age += 1
        return f"Happy birthday {self.name}! Now {self.age} years old!"

# Create objects (instances) from the class
dog1 = Dog("Buddy", 3)                            # First dog object
dog2 = Dog("Max", 5)                              # Second dog object
# Each object has its own data!

# Call methods on objects
print(dog1.bark())                                # Buddy says Woof!
print(dog2.bark())                                # Max says Woof!
print(dog1.get_info())                            # Buddy is 3 years old

# Modify object data
print(dog1.birthday())                            # Happy birthday Buddy! Now 4 years old!
print(dog1.age)                                   # 4

# Access attributes directly
dog1.name = "Buddy Jr."
print(dog1.name)                                  # Buddy Jr.

๐ŸŽจ Class vs Instance Attributes

Instance attributes (self.name, self.age) are unique to each object. Class attributes are shared by ALL objects of that class.

class Dog:
    # Class attribute - shared by ALL dogs
    species = "Canis familiaris"
    total_dogs = 0                                # Count all dogs created
    
    def __init__(self, name, age):
        # Instance attributes - unique to each dog
        self.name = name
        self.age = age
        Dog.total_dogs += 1                       # Increment class counter
    
    def get_species(self):
        return f"{self.name} is a {self.species}"

# Create dogs
dog1 = Dog("Buddy", 3)
dog2 = Dog("Max", 5)

# Class attribute - same for all
print(dog1.species)                               # Canis familiaris
print(dog2.species)                               # Canis familiaris
print(Dog.species)                                # Access via class name

# Instance attributes - different for each
print(dog1.name)                                  # Buddy
print(dog2.name)                                  # Max

# Check total dogs created
print(f"Total dogs: {Dog.total_dogs}")           # Total dogs: 2

๐Ÿ’ผ Real-World Example: Bank Account

class BankAccount:
    """Represents a bank account with balance and transactions."""
    
    def __init__(self, owner, balance=0):
        """Initialize account with owner and optional starting balance."""
        self.owner = owner
        self.balance = balance
        self.transactions = []                    # Track all transactions
    
    def deposit(self, amount):
        """Add money to account."""
        if amount <= 0:
            return "Deposit must be positive!"
        
        self.balance += amount
        self.transactions.append(f"Deposit: +${amount}")
        return f"Deposited ${amount}. New balance: ${self.balance}"
    
    def withdraw(self, amount):
        """Remove money from account."""
        if amount <= 0:
            return "Withdrawal must be positive!"
        
        if amount > self.balance:
            return "Insufficient funds!"
        
        self.balance -= amount
        self.transactions.append(f"Withdrawal: -${amount}")
        return f"Withdrew ${amount}. New balance: ${self.balance}"
    
    def get_balance(self):
        """Get current balance."""
        return f"{self.owner}'s balance: ${self.balance}"
    
    def get_statement(self):
        """Print all transactions."""
        print(f"\n=== Statement for {self.owner} ===")
        for transaction in self.transactions:
            print(f"  {transaction}")
        print(f"Current balance: ${self.balance}\n")

# Usage
account = BankAccount("Alice", 1000)
print(account.deposit(500))                       # Deposited $500. New balance: $1500
print(account.withdraw(200))                      # Withdrew $200. New balance: $1300
print(account.withdraw(2000))                     # Insufficient funds!
account.get_statement()
# Output:
#   Deposit: +$500
#   Withdrawal: -$200
#   Current balance: $1300

๐ŸŽญ Special Methods (Magic Methods)

Special methods with double underscores (dunder methods) let you customize how objects behave with built-in Python operations.

class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages
    
    # __str__ - for print() and str()
    def __str__(self):
        return f"'{self.title}' by {self.author}"
    
    # __repr__ - for debugging (official representation)
    def __repr__(self):
        return f"Book('{self.title}', '{self.author}', {self.pages})"
    
    # __len__ - for len()
    def __len__(self):
        return self.pages
    
    # __eq__ - for == comparison
    def __eq__(self, other):
        return self.title == other.title and self.author == other.author

book1 = Book("Python Basics", "John Doe", 300)
book2 = Book("Python Basics", "John Doe", 300)
book3 = Book("Advanced Python", "Jane Smith", 500)

print(book1)                                      # 'Python Basics' by John Doe
print(len(book1))                                 # 300
print(book1 == book2)                             # True (same title/author)
print(book1 == book3)                             # False

๐Ÿ”’ Encapsulation - Private Attributes

Use underscore prefix to indicate "private" attributes (convention, not enforced). Use @property decorator to create controlled access.

class Person:
    def __init__(self, name, age):
        self.name = name
        self._age = age                           # Single _ = "protected" (convention)
        self.__password = "secret"                # Double __ = "private" (name mangled)
    
    # Property - access _age like an attribute
    @property
    def age(self):
        """Get age."""
        return self._age
    
    # Setter - control how age is set
    @age.setter
    def age(self, value):
        """Set age with validation."""
        if value < 0 or value > 150:
            raise ValueError("Age must be between 0 and 150")
        self._age = value
    
    def get_info(self):
        return f"{self.name} is {self._age} years old"

person = Person("Alice", 25)
print(person.age)                                 # 25 (uses @property getter)
person.age = 26                                   # Uses @age.setter
print(person.age)                                 # 26

# Try to set invalid age
try:
    person.age = -5                               # ValueError!
except ValueError as e:
    print(f"Error: {e}")

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Create a Car class with brand, model, year. Add methods: start_engine(), honk().
  2. Build a Rectangle class with width and height. Add methods to calculate area and perimeter.
  3. Create a Student class with name and grades list. Add methods: add_grade(), get_average().
  4. Make a ShoppingCart class that can add items, remove items, and calculate total.
  5. Create a Temperature class with Celsius property. Add conversion methods to Fahrenheit and Kelvin.

โš ๏ธ Common Mistakes to Avoid

  • Forgetting self: First parameter of methods MUST be self
  • Not using self. for attributes: name = "Alice" โŒ should be self.name = "Alice" โœ…
  • Calling methods without parentheses: dog.bark โŒ should be dog.bark() โœ…
  • Modifying class attributes: Usually want instance attributes instead

Lesson 7: Inheritance & Polymorphism (15 min)

Topics: Inheritance, super(), method overriding, polymorphism

# Base class
class Animal:
    def __init__(self, name):
        self.name = name
    
    def speak(self):
        pass  # Abstract method

# Derived classes
class Dog(Animal):
    def speak(self):
        return f"{self.name} says Woof!"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says Meow!"

# Polymorphism - same method, different behavior
animals = [Dog("Buddy"), Cat("Whiskers"), Dog("Max")]
for animal in animals:
    print(animal.speak())

# Using super() to extend parent behavior
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary
    
    def get_info(self):
        return f"{self.name}: ${self.salary}"

class Manager(Employee):
    def __init__(self, name, salary, team_size):
        super().__init__(name, salary)  # Call parent __init__
        self.team_size = team_size
    
    def get_info(self):
        base_info = super().get_info()
        return f"{base_info}, Team: {self.team_size}"

manager = Manager("Alice", 80000, 5)
print(manager.get_info())

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Create a Vehicle base class and inherit Car, Bicycle, Motorcycle from it.
  2. Build a Shape class with Circle, Rectangle, Triangle children. Each calculates area differently.
  3. Create BankAccount and SavingsAccount (with interest) inheritance.
  4. Make a list of different shapes and calculate total area using polymorphism.
  5. Create Media class with Book, Movie, Music children. Each displays info differently.

โš ๏ธ Common Mistakes to Avoid

  • Forgetting super().__init__(): Parent attributes won't be initialized
  • Wrong super() syntax: Use super().__init__() not ParentClass.__init__(self)
  • Overriding without calling super(): You lose parent's functionality
  • Deep inheritance chains: Keep hierarchies shallow (2-3 levels max) for maintainability

Lesson 8: Decorators & Generators (15 min)

๐ŸŽฏ Learning Objectives

  • Understand and create function decorators to modify behavior
  • Use generators for memory-efficient iteration
  • Master the yield keyword and lazy evaluation
  • Optimize code for large datasets

๐ŸŽจ Decorators - Wrapping Functions

Decorators are functions that modify or enhance other functions. Think of them like gift wrapping: the gift (original function) stays the same, but you add something extra around it (wrapper). Use the @decorator_name syntax above a function.

Common uses: Logging, timing, authentication, caching, input validation.

# Basic decorator structure
def my_decorator(func):
    """Decorator that adds behavior before/after function."""
    def wrapper(*args, **kwargs):
        print("Before function runs")
        result = func(*args, **kwargs)              # Call original function
        print("After function runs")
        return result
    return wrapper

# Apply decorator with @ syntax
@my_decorator
def say_hello(name):
    print(f"Hello, {name}!")

say_hello("Alice")
# Output:
# Before function runs
# Hello, Alice!
# After function runs

# Without @ syntax (same thing):
def say_hi(name):
    print(f"Hi, {name}!")

say_hi = my_decorator(say_hi)                      # Manual decoration

# Practical example: Timer decorator
def timer(func):
    """Measure how long a function takes to run."""
    import time
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end-start:.4f} seconds")
        return result
    return wrapper

@timer
def slow_function():
    """Simulates slow operation."""
    import time
    time.sleep(1)
    return "Done!"

result = slow_function()
# Output: slow_function took 1.0004 seconds

# Decorator with arguments
def repeat(times):
    """Decorator that repeats function execution."""
    def decorator(func):
        def wrapper(*args, **kwargs):
            for i in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def greet(name):
    print(f"Hello, {name}!")

greet("Bob")
# Output:
# Hello, Bob!
# Hello, Bob!
# Hello, Bob!

# Multiple decorators - stack them!
@timer
@repeat(2)
def process_data():
    print("Processing...")
    import time
    time.sleep(0.5)

process_data()
# Runs twice, then times total duration

โšก Generators - Lazy Iteration

Generators produce values one at a time (on-demand) instead of creating entire lists in memory. Use yield instead of return. Perfect for large datasets, infinite sequences, or when you don't need all values at once.

Analogy: List = getting all books from library at once. Generator = checking out one book at a time as you read.

# Regular function - returns entire list (memory heavy)
def get_numbers_list(n):
    """Return list of n numbers."""
    result = []
    for i in range(n):
        result.append(i)
    return result                                   # Returns complete list

numbers = get_numbers_list(1000000)                # Uses lots of memory!

# Generator function - yields values one at a time (memory efficient)
def get_numbers_generator(n):
    """Generate numbers from 0 to n-1."""
    for i in range(n):
        yield i                                     # Yields one value at a time

gen = get_numbers_generator(1000000)               # Creates generator object (tiny memory!)
# Values are generated only when requested

# Using generator
for num in gen:
    print(num)                                      # Each number generated on-demand
    if num >= 4:                                    # Can stop early
        break
# Output: 0, 1, 2, 3, 4

# Generator with yield - pauses and resumes
def countdown(n):
    """Count down from n to 1."""
    print("Starting countdown")
    while n > 0:
        yield n                                     # Pause here, return n
        n -= 1                                      # Resume from here next time
    print("Blast off!")

counter = countdown(5)
print(next(counter))                                # 5 (calls next() manually)
print(next(counter))                                # 4
print(next(counter))                                # 3
# Or use in loop:
for num in countdown(3):
    print(num)
# Output: 3, 2, 1, Blast off!

# Generator expression - like list comprehension but with ()
squares_list = [x**2 for x in range(1000000)]      # List - uses ~8MB memory
squares_gen = (x**2 for x in range(1000000))       # Generator - uses ~200 bytes!

# Compare memory
import sys
print(f"List size: {sys.getsizeof(squares_list)} bytes")
print(f"Generator size: {sys.getsizeof(squares_gen)} bytes")

# Use generator
for square in squares_gen:
    if square > 100:
        break
    print(square)

๐Ÿ’ผ Real-World Generator Examples

# Example 1: Read large file without loading all into memory
def read_large_file(filename):
    """Read file line by line - memory efficient for huge files."""
    with open(filename, 'r') as file:
        for line in file:
            yield line.strip()                      # Yield one line at a time

# Usage - processes millions of lines without running out of memory
# for line in read_large_file('huge_log_file.txt'):
#     if 'ERROR' in line:
#         print(line)

# Example 2: Infinite sequence generator
def fibonacci():
    """Generate infinite Fibonacci sequence."""
    a, b = 0, 1
    while True:                                     # Infinite loop!
        yield a
        a, b = b, a + b

# Get first 10 Fibonacci numbers
fib_gen = fibonacci()
for i in range(10):
    print(next(fib_gen), end=' ')
# Output: 0 1 1 2 3 5 8 13 21 34

# Example 3: Data pipeline with generators
def read_csv_lines(filename):
    """Read CSV file."""
    with open(filename) as f:
        for line in f:
            yield line.strip()

def parse_csv_line(lines):
    """Parse CSV lines into lists."""
    for line in lines:
        yield line.split(',')

def convert_to_dict(parsed_lines):
    """Convert to dictionaries."""
    header = next(parsed_lines)                    # First line is header
    for values in parsed_lines:
        yield dict(zip(header, values))

# Chain generators - memory efficient pipeline!
# lines = read_csv_lines('data.csv')
# parsed = parse_csv_line(lines)
# records = convert_to_dict(parsed)
# for record in records:
#     print(record)

# Example 4: Batch processing
def batch_generator(data, batch_size):
    """Split data into batches."""
    for i in range(0, len(data), batch_size):
        yield data[i:i + batch_size]

numbers = list(range(100))
for batch in batch_generator(numbers, 10):
    print(f"Processing batch: {batch[:3]}... (size: {len(batch)})")
    # Process batch
# Outputs 10 batches of 10 numbers each

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Create a @logger decorator that prints function name and arguments before execution.
  2. Build a generator that yields all even numbers from 0 to n.
  3. Create a @cache decorator that stores function results to avoid recalculation.
  4. Write a generator that yields squares of numbers but only if they're divisible by 3.
  5. Make a generator that yields prime numbers infinitely (use while True).

โš ๏ธ Common Mistakes to Avoid

  • Forgetting to return wrapper: Decorator must return the wrapper function
  • Generator exhaustion: Generators can only be iterated once - create new one if needed
  • Using return in generator: Use yield not return (return ends generator)
  • Not using *args, **kwargs in wrapper: Breaks functions with different arguments

Lesson 9: File I/O & Exception Handling (15 min)

๐ŸŽฏ Learning Objectives

  • Read and write files safely in Python
  • Work with JSON and CSV formats for data storage
  • Handle errors gracefully with try/except
  • Understand when and how to use exception handling

๐Ÿ“ File Operations - Reading & Writing

File I/O (Input/Output) lets programs read data from files and save data to files. Always use with statement to automatically close filesโ€”even if errors occur. Think of files as notebooks: you open, read/write, then close.

# Writing to a file - "w" mode (overwrites existing file)
with open("message.txt", "w") as file:
    file.write("Hello, World!\n")                  # \n adds new line
    file.write("Python is awesome!")
    # File automatically closes when 'with' block ends

# Reading entire file - "r" mode
with open("message.txt", "r") as file:
    content = file.read()                          # Read everything as string
    print(content)
# Output:
# Hello, World!
# Python is awesome!

# Reading line by line (memory efficient for large files)
with open("message.txt", "r") as file:
    for line in file:
        print(line.strip())                        # strip() removes \n

# Reading into list of lines
with open("message.txt", "r") as file:
    lines = file.readlines()                       # Returns list
    print(lines)                                   # ['Hello, World!\n', 'Python is awesome!']

# Append mode - "a" adds to end without erasing
with open("message.txt", "a") as file:
    file.write("\nNew line added!")

# File modes:
# "r"  - Read (default, error if file doesn't exist)
# "w"  - Write (creates new file, overwrites existing)
# "a"  - Append (adds to end, creates if doesn't exist)
# "r+" - Read and write
# "rb" - Read binary (for images, videos, etc.)

# Check if file exists before reading
import os
if os.path.exists("message.txt"):
    with open("message.txt", "r") as file:
        print(file.read())
else:
    print("File not found!")

# Write multiple lines at once
lines = ["First line", "Second line", "Third line"]
with open("output.txt", "w") as file:
    file.write("\n".join(lines))                   # Join with newlines

๐Ÿ“Š JSON - Working with Data

JSON (JavaScript Object Notation) is a universal format for storing structured data. Python dicts/lists convert perfectly to JSON. Used for APIs, config files, and data exchange between systems.

import json

# Python dict to JSON file
user_data = {
    "name": "Alice",
    "age": 25,
    "email": "alice@email.com",
    "skills": ["Python", "JavaScript", "SQL"],
    "active": True
}

# Write JSON to file
with open("user.json", "w") as file:
    json.dump(user_data, file, indent=2)           # indent=2 makes it readable
# Creates formatted JSON file

# Read JSON from file
with open("user.json", "r") as file:
    loaded_data = json.load(file)                  # Returns Python dict
    print(loaded_data["name"])                     # Alice
    print(loaded_data["skills"])                   # ['Python', 'JavaScript', 'SQL']

# Convert to/from JSON strings (for APIs)
json_string = json.dumps(user_data, indent=2)      # Dict โ†’ JSON string
print(json_string)

python_dict = json.loads(json_string)              # JSON string โ†’ Dict
print(python_dict)

# Real-world example: Config file
config = {
    "database": {
        "host": "localhost",
        "port": 5432,
        "name": "myapp_db"
    },
    "debug": True,
    "max_connections": 100
}

with open("config.json", "w") as file:
    json.dump(config, file, indent=2)

# Load and use config
with open("config.json", "r") as file:
    settings = json.load(file)
    db_host = settings["database"]["host"]
    print(f"Connecting to: {db_host}")

๐Ÿ“‘ CSV - Spreadsheet Data

CSV (Comma-Separated Values) is a simple format for tabular data (like Excel). Each line is a row, commas separate columns. Perfect for data analysis and exchange with spreadsheets.

import csv

# Write CSV file
with open("employees.csv", "w", newline="") as file:  # newline="" prevents extra blank lines
    writer = csv.writer(file)
    # Write header row
    writer.writerow(["Name", "Age", "Department", "Salary"])
    # Write data rows
    writer.writerow(["Alice", 28, "Engineering", 75000])
    writer.writerow(["Bob", 35, "Marketing", 65000])
    writer.writerow(["Charlie", 42, "Sales", 80000])

# Read CSV file
with open("employees.csv", "r") as file:
    reader = csv.reader(file)
    header = next(reader)                          # Get first row (headers)
    print(f"Headers: {header}")
    
    for row in reader:                             # Each row is a list
        name, age, dept, salary = row              # Unpack values
        print(f"{name} works in {dept}, earns ${salary}")

# CSV with DictReader - more convenient!
with open("employees.csv", "r") as file:
    reader = csv.DictReader(file)                  # Each row is a dict
    for row in reader:
        print(f"{row['Name']} is {row['Age']} years old")

# CSV with DictWriter
employees = [
    {"name": "Alice", "age": 28, "city": "NYC"},
    {"name": "Bob", "age": 35, "city": "LA"}
]

with open("people.csv", "w", newline="") as file:
    fieldnames = ["name", "age", "city"]
    writer = csv.DictWriter(file, fieldnames=fieldnames)
    
    writer.writeheader()                           # Write column names
    writer.writerows(employees)                    # Write all rows

๐Ÿ›ก๏ธ Exception Handling - Dealing with Errors

Exceptions are errors that occur during program execution. Use try/except to catch and handle errors gracefully instead of crashing. Like a safety net for your code.

# Basic try/except
try:
    result = 10 / 0                                # This will cause an error
except ZeroDivisionError:
    print("Cannot divide by zero!")                # Handles specific error
    result = 0

print(f"Result: {result}")                         # Program continues

# Multiple exception types
try:
    number = int(input("Enter a number: "))
    result = 100 / number
except ValueError:
    print("That's not a valid number!")
except ZeroDivisionError:
    print("Cannot divide by zero!")
except Exception as e:                             # Catch any other error
    print(f"Unexpected error: {e}")

# try/except/else/finally - complete structure
try:
    file = open("data.txt", "r")
    data = file.read()
except FileNotFoundError:
    print("File not found!")
    data = None
else:
    print("File read successfully!")              # Runs if NO exception
finally:
    print("Cleanup code")                          # ALWAYS runs
    # file.close()  # Usually done with 'with' instead

# Real example: Safe file reading
def read_config(filename):
    """Read JSON config file safely."""
    try:
        with open(filename, "r") as file:
            config = json.load(file)
            return config
    except FileNotFoundError:
        print(f"Config file '{filename}' not found. Using defaults.")
        return {"debug": False, "port": 8000}      # Default config
    except json.JSONDecodeError:
        print(f"Invalid JSON in '{filename}'")
        return None

config = read_config("settings.json")

# Raising exceptions
def withdraw(balance, amount):
    """Withdraw money from account."""
    if amount < 0:
        raise ValueError("Cannot withdraw negative amount!")
    if amount > balance:
        raise ValueError("Insufficient funds!")
    return balance - amount

try:
    new_balance = withdraw(100, 150)
except ValueError as e:
    print(f"Transaction failed: {e}")

# Custom exceptions
class InsufficientFundsError(Exception):
    """Custom exception for banking operations."""
    pass

def withdraw_v2(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(f"Need ${amount}, only have ${balance}")
    return balance - amount

try:
    withdraw_v2(100, 150)
except InsufficientFundsError as e:
    print(f"Error: {e}")

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Write a program that reads a text file and counts how many lines, words, and characters it contains.
  2. Create a function that saves a list of dictionaries to a JSON file, then loads them back.
  3. Build a contact manager: save contacts to CSV, load them, add new ones, search by name.
  4. Write a safe division function using try/except that handles division by zero and non-numeric inputs.
  5. Create a log file writer that appends timestamps and messages to a log.txt file.

โš ๏ธ Common Mistakes to Avoid

  • Not using 'with' statement: Files may not close properly, causing data loss or locks
  • Catching all exceptions blindly: Use specific exceptions, not bare except:
  • Forgetting newline="" in CSV: Windows adds extra blank lines without it
  • Not handling file not found: Always check if file exists or use try/except

Lesson 10: Modules & Packages (15 min)

๐ŸŽฏ Learning Objectives

  • Import and use Python's standard library modules
  • Create your own reusable modules and packages
  • Install external packages with pip
  • Manage dependencies with virtual environments

๐Ÿ“ฆ What Are Modules?

Modules are Python files containing reusable code (functions, classes, variables). Instead of copying code between projects, put it in a module and import it. Think of modules as toolboxes: each contains specific tools you can use.

Python Standard Library: Python comes with 200+ built-in modules (math, random, datetime, etc.) - no installation needed!

# Import entire module
import math
print(math.pi)                                     # 3.141592653589793
print(math.sqrt(16))                               # 4.0
print(math.floor(3.7))                             # 3
print(math.ceil(3.2))                              # 4

# Import specific items
from math import sqrt, pi, pow
print(sqrt(25))                                    # 5.0 (no math. prefix needed)
print(pi)                                          # 3.141592...

# Import with alias (shorter names)
import datetime as dt
now = dt.datetime.now()
print(now)

# Common standard library modules
import random
print(random.randint(1, 10))                       # Random number 1-10
print(random.choice(['red', 'blue', 'green']))    # Random choice

import os
print(os.getcwd())                                 # Current directory
# os.mkdir('new_folder')                           # Create directory

import sys
print(sys.version)                                 # Python version
print(sys.platform)                                # Operating system

from datetime import datetime, timedelta
now = datetime.now()
print(now.strftime("%Y-%m-%d %H:%M:%S"))          # Format date
tomorrow = now + timedelta(days=1)
print(f"Tomorrow: {tomorrow}")

# Import everything (not recommended - pollutes namespace)
# from math import *                               # Imports all, hard to track

๐Ÿ› ๏ธ Creating Your Own Modules

Any Python file (.py) is a module! Save functions/classes in a file, then import it from other files in the same directory.

# File: utils.py (your module)
"""Utility functions for common tasks."""

def greet(name):
    """Return greeting message."""
    return f"Hello, {name}!"

def add(a, b):
    """Add two numbers."""
    return a + b

def calculate_tax(price, tax_rate=0.08):
    """Calculate price with tax."""
    return price * (1 + tax_rate)

# Constants
PI = 3.14159
MAX_USERS = 100

# Private function (convention: starts with _)
def _internal_helper():
    """Not meant to be used outside this module."""
    pass

# Code that runs only when module is executed directly
if __name__ == "__main__":
    # This runs when you do: python utils.py
    # But NOT when you import utils
    print("Testing utils module...")
    print(greet("Alice"))
    print(add(5, 3))

# --------------------------------
# File: main.py (using the module)
import utils

print(utils.greet("Bob"))                          # Hello, Bob!
result = utils.add(10, 20)                         # 30
total = utils.calculate_tax(100)                   # 108.0
print(f"Max users: {utils.MAX_USERS}")            # 100

# Or import specific items
from utils import greet, add
print(greet("Charlie"))
print(add(7, 8))

# Import with alias
import utils as u
print(u.greet("Diana"))

๐Ÿ“š Packages - Organizing Multiple Modules

Packages are directories containing multiple modules. Create a folder with an __init__.py file (can be empty) to make it a package.

# Package structure:
myapp/
    __init__.py                # Makes it a package (can be empty or have init code)
    database.py                # Module for database operations
    auth.py                    # Module for authentication
    utils.py                   # Module for utilities
    models/                    # Subpackage
        __init__.py
        user.py
        product.py

# File: myapp/database.py
def connect():
    return "Connected to database"

def query(sql):
    return f"Executing: {sql}"

# File: myapp/auth.py
def login(username, password):
    return f"Logging in {username}"

def logout():
    return "Logged out"

# File: myapp/__init__.py (optional initialization)
"""MyApp package - main application package."""
VERSION = "1.0.0"

# --------------------------------
# Using the package:

# Import from package
from myapp import database
print(database.connect())

# Import specific function
from myapp.auth import login
print(login("alice", "secret"))

# Import from subpackage
from myapp.models.user import User

# Import entire package
import myapp
print(f"Version: {myapp.VERSION}")

# Relative imports (inside package files)
# In myapp/auth.py:
# from .database import connect      # Same level
# from ..utils import helper         # Parent level
# from .models.user import User      # Subdirectory

๐Ÿ“ฅ Installing External Packages with pip

pip is Python's package installer. Access 400,000+ packages from PyPI (Python Package Index). Popular packages: requests, pandas, numpy, django, flask.

# Install package (run in terminal/command prompt)
# pip install requests
# pip install pandas numpy matplotlib
# pip install django==4.2.0            # Specific version

# Uninstall
# pip uninstall requests

# List installed packages
# pip list

# Show package info
# pip show requests

# Save dependencies to file
# pip freeze > requirements.txt

# Install from requirements file
# pip install -r requirements.txt

# Example: Using installed package
import requests

response = requests.get('https://api.github.com')
print(response.status_code)                        # 200
data = response.json()
print(data)

# Example with pandas
import pandas as pd
data = {
    'Name': ['Alice', 'Bob', 'Charlie'],
    'Age': [25, 30, 35]
}
df = pd.DataFrame(data)
print(df)

๐ŸŒ Virtual Environments

Virtual environments create isolated Python installations for each project. Different projects can use different package versions without conflicts. Essential for professional development!

# Create virtual environment (terminal/command prompt)
# python -m venv myenv                 # Creates 'myenv' folder

# Activate virtual environment
# Windows:
# myenv\Scripts\activate
# Mac/Linux:
# source myenv/bin/activate

# When activated, you'll see (myenv) in terminal
# (myenv) $ 

# Now pip install only affects this environment
# (myenv) $ pip install requests pandas

# Deactivate when done
# (myenv) $ deactivate

# Best practice: .gitignore
"""
# Add to .gitignore:
myenv/
venv/
*.pyc
__pycache__/
"""

# Project structure with venv
myproject/
    myenv/                  # Virtual environment (don't commit to git!)
    src/
        main.py
        utils.py
    requirements.txt        # List of dependencies
    README.md

# requirements.txt example:
"""
requests==2.31.0
pandas==2.0.3
numpy==1.24.3
"""

# Workflow
# 1. Create project folder
# 2. Create virtual environment: python -m venv venv
# 3. Activate: venv\Scripts\activate (Windows)
# 4. Install packages: pip install requests pandas
# 5. Save deps: pip freeze > requirements.txt
# 6. Code your project
# 7. Share: Others run pip install -r requirements.txt

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Create a math_utils.py module with functions for circle area, rectangle area, and triangle area.
  2. Make a string_tools.py module with functions: reverse_string, count_vowels, is_palindrome.
  3. Create a package called 'mylib' with modules for math, strings, and dates.
  4. Use the random module to create a dice roller and coin flipper.
  5. Practice: Create virtual environment, install requests, make API call, save requirements.txt.

โš ๏ธ Common Mistakes to Avoid

  • Naming conflicts: Don't name your file math.py (conflicts with standard library)
  • Forgetting __init__.py: Required to make directory a package (Python 3.3+: optional but recommended)
  • Installing globally: Always use virtual environments for projects
  • Circular imports: Module A imports B, B imports A - causes errors

Lesson 11: Algorithms & Complexity (15 min)

module3.py """ # Virtual environments (run in terminal) # python -m venv myenv # myenv\Scripts\activate # Windows # source myenv/bin/activate # Mac/Linux # Package management with pip # pip install requests # pip install pandas numpy matplotlib # pip freeze > requirements.txt # pip install -r requirements.txt

๐ŸŽฏ Learning Objectives

  • Understand Big O notation for analyzing code efficiency
  • Recognize common time complexities (O(1), O(n), O(nยฒ), etc.)
  • Implement efficient search and sorting algorithms
  • Choose the right algorithm for your problem

โšก What is Big O Notation?

Big O notation describes how code performance changes as input size grows. It answers: "How much slower will my code be with 10ร— more data?" Essential for writing scalable applications that work with thousands/millions of items.

Analogy: Imagine counting books. O(1) = check if shelf is empty (instant). O(n) = count all books one by one (linear). O(nยฒ) = compare every book with every other book (exponential growth).

# Common Time Complexities (from best to worst):

# O(1) - CONSTANT - Always same time regardless of input size
def get_first_item(items):
    """Access list element - instant!"""
    return items[0]                                # Same speed for 10 or 10 million items

# O(log n) - LOGARITHMIC - Doubles data, adds one step
def binary_search(sorted_list, target):
    """Search sorted list by halving search space."""
    left, right = 0, len(sorted_list) - 1
    
    while left <= right:
        mid = (left + right) // 2
        if sorted_list[mid] == target:
            return mid                             # Found it!
        elif sorted_list[mid] < target:
            left = mid + 1                         # Search right half
        else:
            right = mid - 1                        # Search left half
    return -1                                      # Not found

# Example: Find 47 in [1,5,12,23,47,59,81,99]
# Step 1: Check middle (23) - too small
# Step 2: Check middle of right half (59) - too big
# Step 3: Check middle of that section (47) - found!
# Only 3 steps for 8 items, 10 steps for 1024 items!

# O(n) - LINEAR - Time grows with input size
def find_max(numbers):
    """Must check every number."""
    max_val = numbers[0]
    for num in numbers:                            # Visit each item once
        if num > max_val:
            max_val = num
    return max_val

# 100 items = 100 operations
# 1000 items = 1000 operations (10ร— slower)

# O(n log n) - LINEARITHMIC - Efficient sorting
def merge_sort(arr):
    """Divide and conquer sorting."""
    if len(arr) <= 1:
        return arr
    
    # Divide
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])                   # Sort left half
    right = merge_sort(arr[mid:])                  # Sort right half
    
    # Conquer (merge sorted halves)
    return merge(left, right)

def merge(left, right):
    """Merge two sorted lists."""
    result = []
    i = j = 0
    
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    
    result.extend(left[i:])                        # Add remaining
    result.extend(right[j:])
    return result

# O(nยฒ) - QUADRATIC - Nested loops (avoid for large data!)
def bubble_sort(arr):
    """Compare adjacent pairs - slow for large lists."""
    n = len(arr)
    for i in range(n):                             # Outer loop
        for j in range(n - i - 1):                 # Inner loop
            if arr[j] > arr[j+1]:
                arr[j], arr[j+1] = arr[j+1], arr[j]  # Swap
    return arr

# 100 items = 10,000 operations
# 1000 items = 1,000,000 operations (100ร— slower!)

# Visual comparison of growth:
# n = 10      n = 100     n = 1000
# O(1):       1           1           1           โœ… Best
# O(log n):   3           7           10          โœ… Great
# O(n):       10          100         1000        โœ… Good
# O(n log n): 33          664         9966        โš ๏ธ OK
# O(nยฒ):      100         10,000      1,000,000   โŒ Slow

๐Ÿ” Common Search Algorithms

# Linear Search - O(n) - Check each item
def linear_search(items, target):
    """Simple but slow for large lists."""
    for i, item in enumerate(items):
        if item == target:
            return i                               # Found at index i
    return -1                                      # Not found

numbers = [64, 34, 25, 12, 22, 11, 90]
print(linear_search(numbers, 22))                  # 4

# Binary Search - O(log n) - MUCH faster but needs sorted list!
def binary_search_recursive(arr, target, left, right):
    """Recursive version."""
    if left > right:
        return -1                                  # Base case: not found
    
    mid = (left + right) // 2
    if arr[mid] == target:
        return mid
    elif arr[mid] < target:
        return binary_search_recursive(arr, target, mid + 1, right)
    else:
        return binary_search_recursive(arr, target, left, mid - 1)

sorted_nums = [11, 12, 22, 25, 34, 64, 90]
print(binary_search_recursive(sorted_nums, 25, 0, len(sorted_nums)-1))  # 3

๐Ÿ”„ Common Sorting Algorithms

# Bubble Sort - O(nยฒ) - Simple but slow
def bubble_sort(arr):
    """Swap adjacent elements if out of order."""
    n = len(arr)
    for i in range(n):
        swapped = False
        for j in range(n - i - 1):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
                swapped = True
        if not swapped:                            # Already sorted
            break
    return arr

# Selection Sort - O(nยฒ) - Find min, move to front
def selection_sort(arr):
    """Find minimum, place at beginning."""
    for i in range(len(arr)):
        min_idx = i
        for j in range(i + 1, len(arr)):
            if arr[j] < arr[min_idx]:
                min_idx = j
        arr[i], arr[min_idx] = arr[min_idx], arr[i]
    return arr

# Quick Sort - O(n log n) average - Very fast in practice
def quick_sort(arr):
    """Pick pivot, partition around it."""
    if len(arr) <= 1:
        return arr
    
    pivot = arr[len(arr) // 2]
    left = [x for x in arr if x < pivot]
    middle = [x for x in arr if x == pivot]
    right = [x for x in arr if x > pivot]
    
    return quick_sort(left) + middle + quick_sort(right)

# Python's built-in sort() and sorted() use Timsort
# Timsort - O(n log n) - Hybrid of merge + insertion sort
numbers = [64, 34, 25, 12, 22, 11, 90]
numbers.sort()                                     # In-place sort
sorted_nums = sorted(numbers)                      # Returns new list

# Comparison:
import time
import random

data = [random.randint(1, 1000) for _ in range(1000)]

# Bubble sort (slow)
start = time.time()
bubble_sort(data.copy())
print(f"Bubble: {time.time() - start:.4f}s")

# Quick sort (fast)
start = time.time()
quick_sort(data.copy())
print(f"Quick: {time.time() - start:.4f}s")

# Python built-in (fastest)
start = time.time()
sorted(data)
print(f"Built-in: {time.time() - start:.4f}s")

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Implement binary search iteratively (without recursion).
  2. Write insertion sort algorithm (O(nยฒ) but efficient for small/nearly-sorted data).
  3. Create a function that finds duplicates in a list. Optimize for O(n) time using sets.
  4. Implement a function to find the second largest number in a list in O(n) time.
  5. Write a function to check if a string is a palindrome in O(n) time.

โš ๏ธ Common Mistakes to Avoid

  • Using O(nยฒ) algorithms on large data: 10,000 items = 100 million operations!
  • Binary search on unsorted data: Must sort first (or use linear search)
  • Premature optimization: Write correct code first, optimize bottlenecks later
  • Ignoring space complexity: Some algorithms use less time but more memory

Lesson 12: Design Patterns (15 min)

๐ŸŽฏ Learning Objectives

  • Understand what design patterns are and why they matter
  • Implement Singleton pattern for single-instance classes
  • Use Factory pattern to create objects flexibly
  • Apply Observer pattern for event-driven systems

๐Ÿ—๏ธ What Are Design Patterns?

Design patterns are proven solutions to common programming problems. Like architectural blueprints, they provide templates for writing maintainable, scalable code. Originally documented in the "Gang of Four" book (1994).

Analogy: Cooking recipes. You don't invent how to make bread from scratchโ€”you follow a proven pattern that works every time.

1๏ธโƒฃ Singleton Pattern - Only One Instance

Problem: You need exactly ONE instance of a class (database connection, config, logger).

# Singleton Pattern - Ensures only one instance exists

class Database:
    """Database connection - only need one!"""
    _instance = None                               # Class variable to store instance
    
    def __new__(cls):
        """Control object creation."""
        if cls._instance is None:
            print("Creating new database connection...")
            cls._instance = super().__new__(cls)
            cls._instance.connection = "Connected to MySQL"
            cls._instance.queries = []
        return cls._instance                       # Always return same instance
    
    def execute_query(self, query):
        """Execute SQL query."""
        self.queries.append(query)
        print(f"Executing: {query}")

# Test Singleton behavior
db1 = Database()                                   # Creates instance
db2 = Database()                                   # Returns SAME instance

print(db1 is db2)                                  # True - same object!
print(id(db1) == id(db2))                          # True - same memory address

db1.execute_query("SELECT * FROM users")
print(db2.queries)                                 # ['SELECT * FROM users'] - shared state!

# Real-world use cases:
# - Database connections (avoid multiple connections)
# - Configuration settings (one config for entire app)
# - Logging service (centralized logging)
# - Thread pools (manage limited resources)

2๏ธโƒฃ Factory Pattern - Flexible Object Creation

Problem: You need to create objects without knowing exact class names in advance.

# Factory Pattern - Create objects based on conditions

from abc import ABC, abstractmethod

# Define interface (contract)
class Animal(ABC):
    """All animals must be able to speak."""
    @abstractmethod
    def speak(self):
        pass
    
    @abstractmethod
    def move(self):
        pass

# Concrete implementations
class Dog(Animal):
    def speak(self):
        return "Woof! Woof!"
    
    def move(self):
        return "Running on four legs"

class Cat(Animal):
    def speak(self):
        return "Meow!"
    
    def move(self):
        return "Sneaking silently"

class Bird(Animal):
    def speak(self):
        return "Chirp! Chirp!"
    
    def move(self):
        return "Flying in the sky"

# Factory - Creates objects without exposing creation logic
class AnimalFactory:
    """Factory to create animals."""
    
    @staticmethod
    def create_animal(animal_type):
        """Create animal based on type string."""
        animals = {
            "dog": Dog,
            "cat": Cat,
            "bird": Bird
        }
        
        animal_class = animals.get(animal_type.lower())
        if animal_class:
            return animal_class()
        else:
            raise ValueError(f"Unknown animal type: {animal_type}")

# Usage - Clean and flexible!
def interact_with_animal(animal_type):
    """Create and interact with any animal."""
    animal = AnimalFactory.create_animal(animal_type)
    print(f"{animal_type.title()}: {animal.speak()}")
    print(f"Movement: {animal.move()}\n")

interact_with_animal("dog")                        # Create dog
interact_with_animal("cat")                        # Create cat
interact_with_animal("bird")                       # Create bird

# Add new animal without changing existing code!
class Fish(Animal):
    def speak(self):
        return "Blub blub"
    
    def move(self):
        return "Swimming underwater"

# Just register in factory dictionary

3๏ธโƒฃ Observer Pattern - Event Notification

Problem: You need to notify multiple objects when something changes (event system).

# Observer Pattern - Publish/Subscribe mechanism

class Subject:
    """Observable object - notifies observers of changes."""
    
    def __init__(self, name):
        self.name = name
        self._observers = []                       # List of observers
        self._state = None
    
    def attach(self, observer):
        """Subscribe observer to notifications."""
        if observer not in self._observers:
            self._observers.append(observer)
            print(f"{observer.name} subscribed to {self.name}")
    
    def detach(self, observer):
        """Unsubscribe observer."""
        self._observers.remove(observer)
        print(f"{observer.name} unsubscribed from {self.name}")
    
    def notify(self, message):
        """Notify all observers."""
        print(f"\n๐Ÿ“ข {self.name}: Broadcasting '{message}'")
        for observer in self._observers:
            observer.update(message)
    
    def change_state(self, new_state):
        """Change state and notify observers."""
        self._state = new_state
        self.notify(f"State changed to: {new_state}")

# Concrete Observers
class EmailObserver:
    """Sends email notifications."""
    def __init__(self, email):
        self.name = "EmailService"
        self.email = email
    
    def update(self, message):
        print(f"  ๐Ÿ“ง Email to {self.email}: {message}")

class SMSObserver:
    """Sends SMS notifications."""
    def __init__(self, phone):
        self.name = "SMSService"
        self.phone = phone
    
    def update(self, message):
        print(f"  ๐Ÿ“ฑ SMS to {self.phone}: {message}")

class LogObserver:
    """Logs all events."""
    def __init__(self):
        self.name = "Logger"
    
    def update(self, message):
        print(f"  ๐Ÿ“ LOG: {message}")

# Real-world example: Online store
store = Subject("OnlineStore")

# Subscribe observers
email_service = EmailObserver("user@example.com")
sms_service = SMSObserver("+1234567890")
logger = LogObserver()

store.attach(email_service)
store.attach(sms_service)
store.attach(logger)

# Trigger events - all observers notified automatically!
store.notify("Flash Sale: 50% off!")
store.change_state("New order received")
store.notify("Your package has shipped!")

# Unsubscribe SMS
store.detach(sms_service)
store.notify("Order delivered!")                   # SMS won't receive this

# Use cases:
# - News websites (notify subscribers of new articles)
# - Stock market apps (price change alerts)
# - Social media (notification system)
# - Game engines (event handling)

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Create a Logger singleton that writes to a file. Ensure only one file handle exists.
  2. Build a ShapeFactory that creates Circle, Rectangle, Triangle based on user input.
  3. Implement a Weather Station observer where multiple displays update when temperature changes.
  4. Create a Strategy pattern for payment methods (CreditCard, PayPal, Bitcoin).
  5. Build a Decorator pattern to add features to coffee orders (milk, sugar, whipped cream).

โš ๏ธ Common Mistakes to Avoid

  • Overusing patterns: Don't force patterns where simple code works better
  • Thread-unsafe Singleton: In multithreading, use locks to prevent multiple instances
  • Forgetting to detach observers: Causes memory leaks if observers aren't removed
  • Factory returning None: Always validate input and handle unknown types

Lesson 13: SOLID Principles (15 min)

๐ŸŽฏ Learning Objectives

  • Understand the 5 SOLID principles for clean code
  • Write classes with Single Responsibility
  • Design extensible code with Open/Closed Principle
  • Apply Dependency Inversion for flexible architectures

๐Ÿ›๏ธ What is SOLID?

SOLID is an acronym for 5 design principles that make software more maintainable, flexible, and scalable. Created by Robert C. Martin (Uncle Bob). These principles prevent "code rot" and make systems easier to change.

Analogy: Building a house. SOLID principles are like architectural rulesโ€”use strong foundations, separate plumbing from electricity, make rooms easy to renovate.

S - Single Responsibility Principle (SRP)

Rule: A class should have ONE reason to change. Each class should do ONE thing well.

# โŒ BAD - Class doing too many things
class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email
    
    def save_to_database(self):
        """Database logic - wrong place!"""
        print(f"Saving {self.name} to database")
    
    def send_email(self):
        """Email logic - wrong place!"""
        print(f"Sending email to {self.email}")
    
    def generate_report(self):
        """Report logic - wrong place!"""
        print(f"Generating report for {self.name}")

# Problem: If email system changes, User class must change!
# Problem: User class has 3 reasons to change (data, DB, email, reports)

# โœ… GOOD - Each class has ONE responsibility
class User:
    """Only handles user data."""
    def __init__(self, name, email):
        self.name = name
        self.email = email

class UserRepository:
    """Only handles database operations."""
    def save(self, user):
        print(f"Saving {user.name} to database")
        # Database logic here
    
    def find_by_email(self, email):
        print(f"Finding user with email: {email}")
        # Query logic here

class EmailService:
    """Only handles email sending."""
    def send_welcome_email(self, user):
        print(f"๐Ÿ“ง Welcome email sent to {user.email}")
        # Email logic here
    
    def send_password_reset(self, user):
        print(f"๐Ÿ“ง Password reset sent to {user.email}")

class ReportGenerator:
    """Only handles report generation."""
    def generate_user_report(self, user):
        print(f"๐Ÿ“Š Generating report for {user.name}")
        # Report logic here

# Usage - Clean separation of concerns!
user = User("Alice", "alice@example.com")
repository = UserRepository()
email_service = EmailService()
report_gen = ReportGenerator()

repository.save(user)
email_service.send_welcome_email(user)
report_gen.generate_user_report(user)

# Benefits:
# - Easy to test each class independently
# - Easy to change email logic without touching User class
# - Easy to reuse EmailService for other entities

O - Open/Closed Principle (OCP)

Rule: Classes should be OPEN for extension but CLOSED for modification. Add new features without changing existing code.

# โŒ BAD - Must modify class to add new discount types
class DiscountCalculator:
    def calculate(self, amount, discount_type):
        if discount_type == "seasonal":
            return amount * 0.9
        elif discount_type == "vip":
            return amount * 0.8
        elif discount_type == "student":            # Need to modify!
            return amount * 0.85
        # Adding new discount = modifying this class โŒ

# โœ… GOOD - Use abstraction to extend behavior
from abc import ABC, abstractmethod

class DiscountStrategy(ABC):
    """Abstract base for all discount strategies."""
    @abstractmethod
    def calculate(self, amount):
        pass

class SeasonalDiscount(DiscountStrategy):
    """Seasonal sale discount."""
    def calculate(self, amount):
        return amount * 0.9                        # 10% off

class VIPDiscount(DiscountStrategy):
    """VIP customer discount."""
    def calculate(self, amount):
        return amount * 0.8                        # 20% off

class StudentDiscount(DiscountStrategy):
    """Student discount - NEW, no modification needed!"""
    def calculate(self, amount):
        return amount * 0.85                       # 15% off

class BlackFridayDiscount(DiscountStrategy):
    """Black Friday discount - Also NEW!"""
    def calculate(self, amount):
        return amount * 0.5                        # 50% off!

# Discount calculator - NEVER needs modification
class ShoppingCart:
    def __init__(self):
        self.items = []
        self.discount_strategy = None
    
    def add_item(self, price):
        self.items.append(price)
    
    def set_discount(self, strategy: DiscountStrategy):
        """Set discount strategy."""
        self.discount_strategy = strategy
    
    def get_total(self):
        """Calculate total with discount."""
        total = sum(self.items)
        if self.discount_strategy:
            return self.discount_strategy.calculate(total)
        return total

# Usage - Easy to add new discounts!
cart = ShoppingCart()
cart.add_item(100)
cart.add_item(50)

cart.set_discount(VIPDiscount())
print(f"VIP Total: ${cart.get_total()}")          # $120

cart.set_discount(BlackFridayDiscount())
print(f"Black Friday: ${cart.get_total()}")       # $75

# Benefits:
# - Add new discounts without modifying ShoppingCart
# - Easy to test each discount in isolation
# - Following "D" in SOLID (next!)

L - Liskov Substitution Principle (LSP)

Rule: Subclasses should be substitutable for their parent classes without breaking the program.

# โŒ BAD - Square violates LSP
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def set_width(self, width):
        self.width = width
    
    def set_height(self, height):
        self.height = height
    
    def area(self):
        return self.width * self.height

class Square(Rectangle):
    """Square - width always equals height."""
    def set_width(self, width):
        self.width = width
        self.height = width                        # Problem!
    
    def set_height(self, height):
        self.width = height                        # Problem!
        self.height = height

# Test with Rectangle
def test_rectangle(rect):
    rect.set_width(5)
    rect.set_height(10)
    assert rect.area() == 50                       # 5 * 10 = 50

rect = Rectangle(0, 0)
test_rectangle(rect)                               # โœ… Works!

square = Square(0, 0)
test_rectangle(square)                             # โŒ FAILS! area = 100, not 50

# โœ… GOOD - Use composition, not inheritance
from abc import ABC, abstractmethod

class Shape(ABC):
    """Abstract shape."""
    @abstractmethod
    def area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height

class Square(Shape):
    def __init__(self, side):
        self.side = side
    
    def area(self):
        return self.side * self.side

# Now both work correctly without substitution issues!

I - Interface Segregation Principle (ISP)

Rule: Don't force classes to implement methods they don't need. Use small, focused interfaces.

# โŒ BAD - Fat interface
class Worker(ABC):
    @abstractmethod
    def work(self): pass
    
    @abstractmethod
    def eat(self): pass

class HumanWorker(Worker):
    def work(self):
        print("Human working")
    
    def eat(self):
        print("Human eating lunch")

class RobotWorker(Worker):
    def work(self):
        print("Robot working")
    
    def eat(self):
        """Robots don't eat!"""
        raise NotImplementedError("Robots don't eat")  # Problem!

# โœ… GOOD - Segregated interfaces
class Workable(ABC):
    @abstractmethod
    def work(self): pass

class Eatable(ABC):
    @abstractmethod
    def eat(self): pass

class HumanWorker(Workable, Eatable):
    def work(self):
        print("Human working")
    
    def eat(self):
        print("Human eating")

class RobotWorker(Workable):                       # Only implements Workable
    def work(self):
        print("Robot working 24/7")

# Now robots aren't forced to implement eat()!

D - Dependency Inversion Principle (DIP)

Rule: Depend on abstractions, not concrete implementations. High-level modules shouldn't depend on low-level modules.

# โŒ BAD - Direct dependency on concrete class
class MySQLDatabase:
    def save(self, data):
        print(f"Saving to MySQL: {data}")

class UserService:
    def __init__(self):
        self.database = MySQLDatabase()            # Tightly coupled!
    
    def create_user(self, name):
        self.database.save(name)

# Problem: Hard to switch to PostgreSQL or test with mock DB

# โœ… GOOD - Depend on abstraction
from abc import ABC, abstractmethod

class Database(ABC):
    """Abstract database interface."""
    @abstractmethod
    def save(self, data): pass
    
    @abstractmethod
    def find(self, id): pass

class MySQLDatabase(Database):
    def save(self, data):
        print(f"๐Ÿ’พ Saving to MySQL: {data}")
    
    def find(self, id):
        print(f"๐Ÿ” Finding in MySQL: {id}")

class PostgreSQLDatabase(Database):
    def save(self, data):
        print(f"๐Ÿ’พ Saving to PostgreSQL: {data}")
    
    def find(self, id):
        print(f"๐Ÿ” Finding in PostgreSQL: {id}")

class MockDatabase(Database):
    """For testing."""
    def save(self, data):
        print(f"โœ… Mock save: {data}")
    
    def find(self, id):
        return {"id": id, "name": "Test User"}

class UserService:
    def __init__(self, database: Database):        # Depends on abstraction!
        self.database = database
    
    def create_user(self, name):
        self.database.save(name)
    
    def get_user(self, id):
        return self.database.find(id)

# Usage - Easy to swap implementations!
mysql_service = UserService(MySQLDatabase())
mysql_service.create_user("Alice")

postgres_service = UserService(PostgreSQLDatabase())
postgres_service.create_user("Bob")

test_service = UserService(MockDatabase())         # For unit tests!
test_service.create_user("TestUser")

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Refactor a class that handles User data, validation, and email into 3 separate classes (SRP).
  2. Create a payment system that can add new payment methods without modifying existing code (OCP).
  3. Design a notification system with different channels (email, SMS, push) using DIP.
  4. Identify LSP violations in a Bird/Penguin inheritance hierarchy (penguins can't fly!).
  5. Separate a fat "Printer" interface into Print, Scan, Fax interfaces (ISP).

โš ๏ธ Common Mistakes to Avoid

  • Over-engineering: Don't apply SOLID to simple scriptsโ€”use for complex systems
  • Breaking SRP by mixing concerns: Keep business logic separate from infrastructure
  • Using inheritance when composition works better: "Favor composition over inheritance"
  • Creating god classes: If class has >300 lines, probably violates SRP

Lesson 14: Testing & TDD (15 min)

๐ŸŽฏ Learning Objectives

  • Write unit tests with unittest and pytest
  • Practice Test-Driven Development (TDD)
  • Use fixtures for reusable test setup
  • Mock external dependencies for isolated testing

๐Ÿงช Why Testing Matters

Automated testing is your safety netโ€”catch bugs before users do! Tests document how code should work, make refactoring safe, and save debugging time.

Testing with pytest

Topics: Unit tests, pytest, assertions, fixtures, TDD

# Install pytest: pip install pytest

# test_calculator.py
def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

# Test functions (prefix with test_)
def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0
    assert add(0, 0) == 0

def test_subtract():
    assert subtract(5, 3) == 2
    assert subtract(0, 5) == -5

def test_divide():
    assert divide(10, 2) == 5
    assert divide(9, 3) == 3

def test_divide_by_zero():
    import pytest
    with pytest.raises(ValueError):
        divide(10, 0)

# Fixtures - reusable test setup
import pytest

@pytest.fixture
def sample_data():
    return [1, 2, 3, 4, 5]

def test_sum(sample_data):
    assert sum(sample_data) == 15

def test_max(sample_data):
    assert max(sample_data) == 5

# Run tests: pytest test_calculator.py
# Run with coverage: pytest --cov=mymodule

# Test-Driven Development (TDD)
# 1. Write failing test
# 2. Write minimal code to pass
# 3. Refactor

Lesson 15: APIs & HTTP Requests (15 min)

๐ŸŽฏ Learning Objectives

  • Understand HTTP methods (GET, POST, PUT, DELETE)
  • Make API calls with the requests library
  • Handle JSON data from APIs
  • Build simple REST API with Flask

๐ŸŒ What are APIs?

API (Application Programming Interface) lets programs talk to each other. REST APIs use HTTP to send/receive data (usually JSON). Essential for modern web development!

Analogy: APIs are like restaurant menusโ€”you order (request) something specific, kitchen (server) prepares it, waiter (HTTP) brings it back (response).

Making HTTP Requests

# Install: pip install requests
import requests
import json

# GET - Retrieve data
response = requests.get('https://api.github.com/users/octocat')
print(response.status_code)                        # 200 = success
print(response.json())                             # Parse JSON response

# POST - Send data
data = {'name': 'Alice', 'email': 'alice@email.com'}
response = requests.post('https://api.example.com/users', json=data)
print(response.json())

# Query parameters
params = {'q': 'python', 'sort': 'stars'}
response = requests.get('https://api.github.com/search/repositories', params=params)

# Headers & Authentication
headers = {'Authorization': 'Bearer YOUR_TOKEN'}
response = requests.get('https://api.example.com/protected', headers=headers)

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Fetch weather data from OpenWeatherMap API
  2. Create a GitHub user info fetcher
  3. Build a simple REST API with Flask
  4. Make POST requests with authentication
  5. Handle API errors and rate limiting

โš ๏ธ Common Mistakes

  • Not checking status codes: Always check if request succeeded
  • Hardcoding API keys: Use environment variables
  • No error handling: APIs can failโ€”use try/except
  • Ignoring rate limits: Too many requests = blocked

Lesson 16: Web Scraping (15 min)

๐ŸŽฏ Learning Objectives

  • Scrape web pages with BeautifulSoup
  • Parse HTML and extract data
  • Handle dynamic content with Selenium
  • Follow web scraping ethics and robots.txt

๐Ÿ•ท๏ธ Web Scraping Basics

# Install: pip install beautifulsoup4 requests lxml
from bs4 import BeautifulSoup
import requests

# Fetch webpage
url = 'https://example.com'
response = requests.get(url)
soup = BeautifulSoup(response.content, 'lxml')

# Extract data
title = soup.find('h1').text
links = [a['href'] for a in soup.find_all('a')]
paragraphs = [p.text for p in soup.find_all('p')]

print(f"Title: {title}")
print(f"Links: {links}")

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Scrape product prices from e-commerce site
  2. Extract article headlines from news website
  3. Build a job listing scraper
  4. Create a movie rating collector
  5. Respect robots.txt and implement delays

Lesson 17: Data Analysis with pandas (15 min)

๐ŸŽฏ Learning Objectives

  • Load and explore data with pandas
  • Filter, group, and aggregate data
  • Visualize data with matplotlib
  • Handle missing values and clean data

๐Ÿ“Š pandas Fundamentals

# Install: pip install pandas matplotlib
import pandas as pd
import matplotlib.pyplot as plt

# Create DataFrame
data = {
    'Name': ['Alice', 'Bob', 'Charlie', 'Diana'],
    'Age': [25, 30, 35, 28],
    'Salary': [50000, 60000, 75000, 55000]
}
df = pd.DataFrame(data)

# Basic operations
print(df.head())                                   # First 5 rows
print(df.describe())                               # Statistics
print(df['Age'].mean())                            # Average age

# Filtering
high_salary = df[df['Salary'] > 55000]
print(high_salary)

# Grouping
avg_by_age = df.groupby('Age')['Salary'].mean()

# Visualization
df.plot(x='Name', y='Salary', kind='bar')
plt.show()

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Load CSV file and analyze sales data
  2. Find top 10 highest values in dataset
  3. Create pivot tables for summarization
  4. Handle missing values (fillna, dropna)
  5. Create multiple chart types (line, scatter, pie)

Lesson 18: Web Development with Flask (15 min)

๐ŸŽฏ Learning Objectives

  • Build web applications with Flask
  • Create routes and handle requests
  • Use templates for dynamic HTML
  • Build REST APIs with JSON responses

๐ŸŒ Flask Basics

# Install Flask: pip install flask

from flask import Flask, render_template, request, redirect, url_for

app = Flask(__name__)

# Route decorators
@app.route('/')
def home():
    return "Welcome to my Flask app!"

@app.route('/about')
def about():
    return "About page"

# Dynamic routes
@app.route('/user/')
def user_profile(username):
    return f"Profile page for {username}"

# Query parameters
@app.route('/search')
def search():
    query = request.args.get('q', '')
    return f"Searching for: {query}"

# HTML templates (create templates/index.html)
@app.route('/template')
def template_example():
    data = {
        'title': 'My Page',
        'users': ['Alice', 'Bob', 'Charlie']
    }
    return render_template('index.html', **data)

# Forms - POST request
@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        # Validate credentials
        return redirect(url_for('dashboard'))
    return render_template('login.html')

@app.route('/dashboard')
def dashboard():
    return "Welcome to dashboard!"

# Run the app
if __name__ == '__main__':
    app.run(debug=True)

# Access at: http://localhost:5000/

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Create a blog with posts and comments
  2. Build a contact form that saves to database
  3. Add user authentication with Flask-Login
  4. Create a REST API for todo list
  5. Deploy Flask app to Heroku or PythonAnywhere

Lesson 19: Database Integration (15 min)

๐ŸŽฏ Learning Objectives

  • Connect Python to databases with SQLAlchemy
  • Perform CRUD operations (Create, Read, Update, Delete)
  • Use ORMs to map objects to database tables
  • Handle database migrations and relationships

๐Ÿ’พ Database with SQLAlchemy

# Install: pip install flask-sqlalchemy

from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)

# Define models
class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    email = db.Column(db.String(120), unique=True, nullable=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    
    def to_dict(self):
        return {
            'id': self.id,
            'username': self.username,
            'email': self.email,
            'created_at': self.created_at.isoformat()
        }

class Post(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    content = db.Column(db.Text, nullable=False)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
    user = db.relationship('User', backref='posts')

# Create tables
with app.app_context():
    db.create_all()

# CRUD Operations

# Create
@app.route('/users', methods=['POST'])
def create_user():
    data = request.get_json()
    user = User(username=data['username'], email=data['email'])
    db.session.add(user)
    db.session.commit()
    return jsonify(user.to_dict()), 201

# Read
@app.route('/users', methods=['GET'])
def get_users():
    users = User.query.all()
    return jsonify([u.to_dict() for u in users])

@app.route('/users/', methods=['GET'])
def get_user(user_id):
    user = User.query.get_or_404(user_id)
    return jsonify(user.to_dict())

# Update
@app.route('/users/', methods=['PUT'])
def update_user(user_id):
    user = User.query.get_or_404(user_id)
    data = request.get_json()
    user.username = data.get('username', user.username)
    user.email = data.get('email', user.email)
    db.session.commit()
    return jsonify(user.to_dict())

# Delete
@app.route('/users/', methods=['DELETE'])
def delete_user(user_id):
    user = User.query.get_or_404(user_id)
    db.session.delete(user)
    db.session.commit()
    return jsonify({"message": "User deleted"})

# Queries
# User.query.filter_by(username='Alice').first()
# User.query.filter(User.email.like('%@gmail.com')).all()
# User.query.order_by(User.created_at.desc()).all()

๐ŸŽ“ Practice Exercises

Try these yourself:

  1. Create a blog database with Posts and Comments tables
  2. Implement one-to-many relationships (User has many Posts)
  3. Add search functionality to filter posts by keyword
  4. Create database migrations for schema changes
  5. Build a complete CRUD API with database backend

Lesson 20: Deployment & Best Practices (15 min)

๐ŸŽฏ Learning Objectives

  • Deploy Python applications to production
  • Use Git for version control
  • Write clean, maintainable code
  • Follow Python best practices (PEP 8)

๐Ÿš€ Deployment Essentials

# 1. Virtual Environment
python -m venv venv
source venv/bin/activate  # Linux/Mac
venv\Scripts\activate     # Windows

# 2. Requirements file
pip freeze > requirements.txt

# Install dependencies
pip install -r requirements.txt

# 3. Environment Variables
# .env file (don't commit!)
DATABASE_URL=postgresql://user:pass@localhost/db
SECRET_KEY=your-secret-key-here
DEBUG=False

# Load in Python
import os
from dotenv import load_dotenv
load_dotenv()

DATABASE_URL = os.getenv('DATABASE_URL')

# 4. Git Basics
git init
git add .
git commit -m "Initial commit"
git push origin main

# .gitignore
__pycache__/
*.pyc
venv/
.env
*.db

# 5. Deploy to Heroku
# Procfile
web: gunicorn app:app

# runtime.txt
python-3.11.0

# Deploy commands
heroku create myapp
git push heroku main
heroku open

โœจ Python Best Practices

# PEP 8 - Style Guide
# โœ… Good naming
user_name = "Alice"                                # Variables: snake_case
MAX_CONNECTIONS = 100                              # Constants: UPPER_CASE
class UserAccount:                                 # Classes: PascalCase
    pass

# โœ… Clear function names
def calculate_total_price(items):
    """Calculate total price of items."""
    return sum(item.price for item in items)

# โœ… Type hints (Python 3.5+)
def greet(name: str, age: int) -> str:
    return f"Hello {name}, you are {age} years old"

# โœ… List comprehensions
squares = [x**2 for x in range(10)]

# โœ… Context managers
with open('file.txt', 'r') as f:
    content = f.read()

# โœ… Error handling
try:
    result = risky_operation()
except ValueError as e:
    logger.error(f"Error: {e}")
    raise
finally:
    cleanup()

# โœ… Logging instead of print
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.info("Processing started")

# โœ… Documentation
def complex_function(param1, param2):
    """
    Brief description of function.
    
    Args:
        param1 (str): Description of param1
        param2 (int): Description of param2
    
    Returns:
        bool: True if successful, False otherwise
    
    Raises:
        ValueError: If param1 is empty
    """
    pass

๐ŸŽ“ Final Project Ideas

Build these to practice everything:

  1. Task Manager API: Full CRUD with authentication, database, tests
  2. Weather Dashboard: Fetch API data, visualize with matplotlib
  3. Blog Platform: Flask app with posts, comments, user accounts
  4. Data Analyzer: Upload CSV, analyze with pandas, show charts
  5. Web Scraper: Scrape job listings, save to database, send alerts

๐ŸŽ‰ Congratulations!

You've completed the Python Programming Course! ๐Ÿš€

You've learned: Variables โ†’ Control Flow โ†’ Functions โ†’ OOP โ†’ Design Patterns โ†’ Testing โ†’ APIs โ†’ Databases โ†’ Deployment

Next Steps: Build projects, contribute to open source, explore frameworks (Django, FastAPI), learn data science (NumPy, pandas, scikit-learn), or dive into automation and DevOps!

๐Ÿ“š Resources & Next Steps

๐Ÿš€ Continue Learning

  • Official Python Docs: docs.python.org
  • Practice: LeetCode, HackerRank, Codewars
  • Projects: Build web apps (Django/Flask), data analysis (pandas), automation scripts
  • Advanced Topics: Async programming, type hints, design patterns
  • Frameworks: Django, FastAPI, Flask for web development
  • Data Science: NumPy, pandas, scikit-learn, Matplotlib

๐ŸŽฏ Project Ideas

  • Web Scraper: Extract data from websites using BeautifulSoup or Scrapy
  • REST API: Build a backend API with FastAPI or Flask
  • CLI Tool: Create command-line utilities with argparse or Click
  • Data Analysis Dashboard: Analyze sales data with Pandas, create visualizations with Matplotlib/Seaborn
  • Automation Script: Automate file organization, email sending, or report generation with schedule
  • Personal Finance Tracker: Track income/expenses with charts and budget alerts
  • Weather App: Fetch real-time weather data from APIs and display forecasts
  • Machine Learning Predictor: Build models with scikit-learn for classification or regression

๐Ÿงช Knowledge Simulator

Test your Python knowledge with 20 random questions. Navigate one question at a time. Your progress is automatically saved!