Session-2-4-Examples

This page shows the source code for Session-2-4-Examples.py with teaching notes, PURPOSE comments, and VS Code instructions.

Source File Session-2-4-Examples.py
Folder Chapter-2-Basic-Sessions

import sys
sys.stdout.reconfigure(encoding='utf-8')

"""
Session 4: Functions & Modules - Code Examples
Introduction to Python Course
"""

# ============================================
# PART 1: Defining Functions
# ============================================
print("=" * 50)
print("PART 1: Defining Functions")
print("=" * 50)
# ======================================================
# WHAT THIS SECTION DOES:
# 1) PURPOSE: A function is a named, reusable block of code.
#    Instead of writing the same logic over and over, you
#    define it once and call it by name as many times as needed.
#    Functions make programs shorter, easier to read, and
#    easier to fix (change the function once, fixed everywhere).
#
# 2) VS CODE: Run each Example block with Shift+Enter.
#    Pay close attention to Example 6 (return vs print) -
#    it is the most important concept in this section and
#    the one that trips up beginners most often.
# ======================================================

# ------------------------------------------------------
# HOW FUNCTIONS WORK
# ------------------------------------------------------
#
#   DEFINE a function with the 'def' keyword:
#       def function_name(parameters):
#           <indented code block>
#           return value           <- optional
#
#   CALL a function by writing its name followed by ():
#       function_name()            <- no arguments
#       function_name(value)       <- with arguments
#
#   KEY VOCABULARY:
#   - PARAMETER: the variable name listed in the def line
#                def greet(name):  <- 'name' is the parameter
#   - ARGUMENT:  the actual value passed when calling
#                greet("Alice")    <- "Alice" is the argument
#   - RETURN:    sends a value BACK to the caller
#                def add(a, b): return a + b
#                result = add(3, 5)   <- result is now 8
#
#   IMPORTANT: defining a function does NOT run it.
#   The code inside only runs when you CALL the function.
#
#       def greet():               <- defines it (no output yet)
#           print("Hello!")
#
#       greet()                    <- NOW it runs and prints
# ------------------------------------------------------

# Example 1: Basic function
print("\n--- Example 1: Simple Function ---")
# ======================================================
# WHAT: The simplest function - no inputs, no return value.
#       It just runs its block whenever called.
#       Called twice here to show functions are reusable.
# TRY:  Call greet() five more times - the same message
#       prints each time without rewriting the code.
# ======================================================
def greet():
    print("Hello! Welcome to Python functions!")

greet()          # first call
greet()          # second call - same function, called again

# Example 2: Function with parameter
print("\n--- Example 2: Function with Parameter ---")
# ======================================================
# WHAT: The parameter 'name' is a placeholder. Each call
#       passes a different argument and gets a personalised
#       message. One function definition handles all names.
# TRY:  Add a call greet_person("your own name") and run it.
# ======================================================
def greet_person(name):              # 'name' is the parameter
    print(f"Hello, {name}! Nice to meet you!")

greet_person("Alice")                # "Alice" is the argument
greet_person("Bob")
greet_person("Charlie")

# Example 3: Function with multiple parameters
print("\n--- Example 3: Multiple Parameters ---")
# ======================================================
# WHAT: Multiple parameters are separated by commas in both
#       the definition AND the call. Arguments are matched
#       to parameters LEFT TO RIGHT by position.
#       introduce("Alice", 25, "New York"):
#           name = "Alice", age = 25, city = "New York"
# TRY:  Add a call with your own name, age, and city.
# ======================================================
def introduce(name, age, city):
    print(f"My name is {name}, I'm {age} years old, and I live in {city}.")

introduce("Alice", 25, "New York")   # name="Alice", age=25, city="New York"
introduce("Bob",   30, "Boston")

# Example 4: Function with return value
print("\n--- Example 4: Return Values ---")
# ======================================================
# WHAT: 'return' sends the result BACK to wherever the
#       function was called from. The caller can store it,
#       print it, or use it in further calculations.
#       Without return, the function gives back None.
# TRY:  Store add(7, 8) in a variable, then multiply it by 3.
#       You can do math with the returned value.
# ======================================================
def add(a, b):
    return a + b                     # sends the result back to the caller

result = add(5, 3)                   # return value stored in 'result'
print(f"5 + 3 = {result}")

print(f"10 + 20 = {add(10, 20)}")   # return value used directly in print

# Example 5: Multiple return values
print("\n--- Example 5: Multiple Return Values ---")
# ======================================================
# WHAT: Python functions can return more than one value by
#       separating them with commas - Python packs them into
#       a tuple automatically. The caller unpacks them into
#       separate variables using the same comma syntax.
# TRY:  Call get_stats([5, 5, 5, 5]) - what do you expect?
#       All three values should be 5.
# ======================================================
def get_stats(numbers):
    minimum = min(numbers)
    maximum = max(numbers)
    average = sum(numbers) / len(numbers)
    return minimum, maximum, average  # returns a tuple (min, max, avg)

nums = [10, 20, 30, 40, 50]
min_val, max_val, avg_val = get_stats(nums)   # unpack all three at once
print(f"Numbers: {nums}")
print(f"Min: {min_val}, Max: {max_val}, Average: {avg_val}")

# Example 6: Return vs Print
print("\n--- Example 6: Return vs Print ---")
# ======================================================
# WHAT: This is the most important distinction in this session.
#
#   print_sum(a, b):  shows the result on screen but DISCARDS it.
#                     The value is GONE - you cannot use it later.
#
#   return_sum(a, b): sends the result BACK to the caller.
#                     You can store it, reuse it, do math with it.
#
#   RULE OF THUMB: use 'return' in almost every function.
#   Use 'print' only when the sole purpose is display.
#   A function that only prints is hard to reuse in other
#   calculations.
# TRY:  Try: x = print_sum(5, 3) then print(x).
#       You will see None printed - because print_sum returns nothing.
#       Then try: x = return_sum(5, 3) then print(x).
#       Now x holds 8 and you can use it.
# ======================================================
def print_sum(a, b):
    print(a + b)              # shows result, but returns None

def return_sum(a, b):
    return a + b              # sends result back - caller can use it

print("Using print_sum (result is lost):")
print_sum(5, 3)               # prints 8 but nothing is stored

print("\nUsing return_sum (result is kept):")
result = return_sum(5, 3)     # result = 8, stored for later use
print(f"Result stored: {result}")
print(f"Can use in calculations: {result * 2}")   # 16 - impossible with print_sum


# ============================================
# PART 2: Function Parameters
# ============================================
print("\n" + "=" * 50)
print("PART 2: Function Parameters")
print("=" * 50)
# ======================================================
# WHAT THIS SECTION DOES:
# 1) PURPOSE: Python offers several advanced ways to pass
#    data into functions, making them flexible and easy to call:
#    - Default parameters  : give a parameter a fallback value
#    - Keyword arguments   : pass by name, in any order
#    - *args               : accept any number of positional args
#    - **kwargs            : accept any number of named args
#
# 2) VS CODE: Run each Example with Shift+Enter. Focus on
#    understanding WHY each feature exists - what problem
#    does it solve that the basic version could not?
# ======================================================

# ------------------------------------------------------
# HOW ADVANCED PARAMETERS WORK
# ------------------------------------------------------
#
#   DEFAULT PARAMETERS - used when caller omits that argument:
#       def greet(name, greeting="Hello"):
#       greet("Alice")             -> greeting uses default "Hello"
#       greet("Alice", "Hi")       -> greeting uses "Hi" instead
#
#   KEYWORD ARGUMENTS - pass by name (order doesn't matter):
#       def info(name, age, city):
#       info(age=30, city="NYC", name="Bob")   <- any order!
#
#   *args - collect any number of positional arguments into a tuple:
#       def sum_all(*numbers):      <- * means "pack extras into tuple"
#       sum_all(1, 2, 3)            <- numbers = (1, 2, 3)
#       sum_all(10, 20, 30, 40)     <- numbers = (10, 20, 30, 40)
#       Use when you don't know how many arguments will be passed.
#
#   **kwargs - collect any number of NAMED arguments into a dict:
#       def print_info(**info):     <- ** means "pack extras into dict"
#       print_info(name="Alice", age=25)   <- info = {"name":"Alice","age":25}
#       Use when you don't know what named arguments will be passed.
#
#   RULE: parameter order in def must be:
#       def f(normal, default=val, *args, **kwargs)
# ------------------------------------------------------

# Example 1: Default parameters
print("\n--- Example 1: Default Parameters ---")
# ======================================================
# WHAT: greeting="Hello" is the default - used when the
#       caller does not provide a second argument.
#       When "Hi" or "Hey" is passed, it overrides the default.
#       Default parameters must come AFTER non-default ones.
# TRY:  Call greet("Diana", "Good morning") to override default.
# ======================================================
def greet(name, greeting="Hello"):   # "Hello" is the default
    print(f"{greeting}, {name}!")

greet("Alice")                       # uses default -> "Hello, Alice!"
greet("Bob",     "Hi")               # overrides default -> "Hi, Bob!"
greet("Charlie", "Hey")              # overrides default -> "Hey, Charlie!"

# Example 2: Keyword arguments
print("\n--- Example 2: Keyword Arguments ---")
# ======================================================
# WHAT: When calling with keyword arguments you write
#       parameter_name=value. This lets you pass them in
#       any order and makes the call self-documenting
#       (the reader can see what each value means).
# TRY:  Call create_profile with ALL keyword arguments,
#       in a different order than the definition - it works!
# ======================================================
def create_profile(name, age, city, country="USA"):
    print(f"Name: {name}")
    print(f"Age: {age}")
    print(f"Location: {city}, {country}")
    print()

create_profile("Alice", 25, "NYC")                          # positional only
create_profile(city="Boston", age=30, name="Bob")           # keyword, any order
create_profile("Charlie", age=28, city="LA", country="USA") # mixed

# Example 3: Variable arguments (*args)
print("\n--- Example 3: Variable Arguments (*args) ---")
# ======================================================
# WHAT: The * in *numbers tells Python to collect ALL
#       positional arguments into one tuple called 'numbers'.
#       This lets you call the function with 1 argument,
#       or 5, or 100 - the function handles any count.
# TRY:  Call sum_all() with no arguments at all - what happens?
#       Then call sum_all(1, 2, 3, 4, 5, 6, 7, 8, 9, 10).
# ======================================================
def sum_all(*numbers):               # * packs all args into tuple 'numbers'
    total = sum(numbers)
    print(f"Sum of {numbers} = {total}")
    return total

sum_all(1, 2, 3)                     # numbers = (1, 2, 3)
sum_all(10, 20, 30, 40, 50)          # numbers = (10, 20, 30, 40, 50)
sum_all(5)                           # numbers = (5,)  single-item tuple

# Example 4: Keyword variable arguments (**kwargs)
print("\n--- Example 4: Keyword Arguments (**kwargs) ---")
# ======================================================
# WHAT: The ** in **info tells Python to collect ALL
#       name=value arguments into a dictionary called 'info'.
#       This is great when the set of properties is flexible
#       (e.g. a product might have different attributes than a person).
# TRY:  Add a third call: print_info(course="Python", level="Beginner",
#       year=2025) and see how the loop prints the new keys.
# ======================================================
def print_info(**info):              # ** packs all name=val args into dict
    print("Information:")
    for key, value in info.items():  # loop over the collected dictionary
        print(f"  {key}: {value}")
    print()

print_info(name="Alice", age=25, city="NYC", job="Engineer")
print_info(product="Laptop", price=999, brand="TechCorp")


# ============================================
# PART 3: Variable Scope
# ============================================
print("\n" + "=" * 50)
print("PART 3: Variable Scope")
print("=" * 50)
# ======================================================
# WHAT THIS SECTION DOES:
# 1) PURPOSE: "Scope" defines WHERE a variable can be seen
#    and used. Python has two main scopes:
#    LOCAL  : a variable created INSIDE a function - it only
#             exists while the function is running and is
#             invisible outside it.
#    GLOBAL : a variable created OUTSIDE all functions - it
#             is visible everywhere in the file.
#    Understanding scope prevents a whole category of bugs
#    where you accidentally overwrite a variable.
#
# 2) VS CODE: Run each Example with Shift+Enter. Example 3
#    shows the BEST PRACTICE - avoid globals by using return.
# ======================================================

# ------------------------------------------------------
# HOW SCOPE WORKS
# ------------------------------------------------------
#
#   GLOBAL variable - defined at the top level:
#       x = 10                 <- global, visible everywhere
#
#   LOCAL variable - defined inside a function:
#       def f():
#           y = 20             <- local, only visible inside f()
#
#   READING a global from inside a function is fine:
#       def f():
#           print(x)           <- OK, can read global x
#
#   WRITING to a global from inside a function requires
#   the 'global' keyword (otherwise Python creates a NEW local):
#       def f():
#           global x
#           x = 99             <- now modifies the global x
#
#   BEST PRACTICE: avoid 'global' whenever possible.
#   Instead, pass the value IN as a parameter and return
#   the new value OUT. This makes functions predictable
#   and safe to reuse anywhere.
# ------------------------------------------------------

# Example 1: Local vs Global
print("\n--- Example 1: Local vs Global Variables ---")
# ======================================================
# WHAT: global_var is defined outside any function, so it
#       is visible both inside test_scope() and outside it.
#       local_var is defined INSIDE test_scope() so it only
#       exists during that function call. Trying to print
#       local_var outside the function causes a NameError.
# TRY:  Uncomment the last print(local_var) line, run it,
#       read the NameError, then re-comment it.
# ======================================================
global_var = "I'm global"

def test_scope():
    local_var = "I'm local"              # exists ONLY inside this function
    print(f"Inside function - Global: {global_var}")   # can read global
    print(f"Inside function - Local:  {local_var}")

test_scope()
print(f"Outside function - Global: {global_var}")
# print(local_var)   # <- Uncomment to see NameError: local_var not defined

# Example 2: Modifying global variables
print("\n--- Example 2: Modifying Global Variables ---")
# ======================================================
# WHAT: Without 'global counter', Python would create a NEW
#       local variable called counter inside increment() and
#       leave the global counter unchanged.
#       'global counter' tells Python to use the existing global.
#       This works, but see Example 3 for the better approach.
# TRY:  Remove the 'global counter' line, run it - you will
#       get an UnboundLocalError. Then put it back.
# ======================================================
counter = 0

def increment():
    global counter               # without this, counter stays 0 outside
    counter += 1
    print(f"Counter inside function: {counter}")

print(f"Counter before: {counter}")
increment()
increment()
increment()
print(f"Counter after: {counter}")

# Example 3: Better approach - return values
print("\n--- Example 3: Better Approach (No Globals) ---")
# ======================================================
# WHAT: Instead of modifying a global, pass the current
#       value IN as a parameter and return the new value OUT.
#       The caller controls the variable - the function is
#       just a pure calculation. This is cleaner, safer,
#       and easier to test.
# TRY:  Change the starting count to 10 and call
#       increment_better three times - it should reach 13.
# ======================================================
def increment_better(value):
    return value + 1             # pure calculation: no globals touched

count = 0
print(f"Count: {count}")
count = increment_better(count)  # pass in, get new value back
print(f"Count: {count}")
count = increment_better(count)
print(f"Count: {count}")


# ============================================
# PART 4: Importing Modules
# ============================================
print("\n" + "=" * 50)
print("PART 4: Importing Modules")
print("=" * 50)
# ======================================================
# WHAT THIS SECTION DOES:
# 1) PURPOSE: A module is a file of ready-made functions you
#    can reuse instead of writing from scratch. Python ships
#    with hundreds of built-in modules (the Standard Library).
#    'import' loads a module and makes its functions available.
#    This session uses three of the most useful modules:
#      math     : mathematical constants and functions
#      random   : random number generation
#      datetime : working with dates and times
#
# 2) VS CODE: Run each Example with Shift+Enter. Try changing
#    the values passed to each function to build familiarity
#    with what each module can do.
# ======================================================

# ------------------------------------------------------
# HOW IMPORTING WORKS
# ------------------------------------------------------
#
#   FULL IMPORT - loads the whole module under its name:
#       import math
#       math.sqrt(16)        <- must prefix with "math."
#
#   IMPORT WITH ALIAS - gives the module a shorter name:
#       import random as rnd
#       rnd.randint(1, 6)    <- use "rnd." instead of "random."
#
#   IMPORT SPECIFIC ITEMS - brings just those names in directly:
#       from math import sqrt, pi
#       sqrt(25)             <- no "math." prefix needed
#       pi                   <- same, use directly
#
#   IMPORT EVERYTHING (avoid in large programs):
#       from math import *
#       sqrt(25)             <- works, but hard to track where it came from
#
#   WHEN TO USE WHICH:
#   - import math         : when you use many math functions
#   - from math import sqrt: when you use only one or two functions
#   - import math as m    : when the name is long and used a lot
# ------------------------------------------------------

# Example 1: math module
print("\n--- Example 1: Math Module ---")
# ======================================================
# WHAT: math provides mathematical constants (pi) and
#       functions beyond Python's built-ins.
#       math.sqrt()  -> square root
#       math.pow()   -> power (like ** but returns a float)
#       math.ceil()  -> round UP to nearest integer
#       math.floor() -> round DOWN to nearest integer
#       math.fabs()  -> absolute value (always positive)
# TRY:  Try math.sqrt(2) - you get the irrational number.
#       Try math.log(100, 10) - log base 10 of 100 = 2.0
# ======================================================
import math

print(f"Pi:                    {math.pi}")
print(f"sqrt(16):              {math.sqrt(16)}")
print(f"pow(2, 3):             {math.pow(2, 3)}")
print(f"ceil(4.2):             {math.ceil(4.2)}")    # rounds UP  -> 5
print(f"floor(4.8):            {math.floor(4.8)}")   # rounds DOWN -> 4
print(f"fabs(-5):              {math.fabs(-5)}")     # absolute value -> 5.0

# Example 2: random module
print("\n--- Example 2: Random Module ---")
# ======================================================
# WHAT: random generates pseudo-random values.
#       randint(a, b)  -> random integer between a and b INCLUSIVE
#       random()       -> random float between 0.0 and 1.0
#       choice(list)   -> picks one random item from a list
#       shuffle(list)  -> rearranges the list IN PLACE (no return)
# TRY:  Run this block several times - the output changes each time.
#       That is the point of random! randint(1,10) gives 1,2,...or 10.
# ======================================================
import random

print(f"Random integer (1-10): {random.randint(1, 10)}")     # inclusive both ends
print(f"Random float  (0-1):   {random.random():.4f}")       # 4 decimal places

colors = ["red", "green", "blue", "yellow"]
print(f"Random choice:         {random.choice(colors)}")     # one random item

numbers = [1, 2, 3, 4, 5]
random.shuffle(numbers)                                       # shuffles IN PLACE
print(f"Shuffled numbers:      {numbers}")

# Example 3: datetime module
print("\n--- Example 3: Datetime Module ---")
# ======================================================
# WHAT: datetime handles dates and times.
#       datetime.now()    -> current date AND time
#       date.today()      -> today's date only
#       .strftime()       -> format a date as a string
#                           %Y=year, %m=month, %d=day
#                           %H=hour, %M=minute, %S=second
#       timedelta(days=N) -> a duration you can add/subtract
# TRY:  Create a date 7 days from today:
#       today + timedelta(days=7)
# ======================================================
from datetime import datetime, date, timedelta

now   = datetime.now()
print(f"Current datetime:  {now}")

today = date.today()
print(f"Today's date:      {today}")

formatted = now.strftime("%Y-%m-%d %H:%M:%S")   # custom format string
print(f"Formatted:         {formatted}")

tomorrow = today + timedelta(days=1)             # add 1 day
print(f"Tomorrow:          {tomorrow}")

# Example 4: Import with alias
print("\n--- Example 4: Import with Alias ---")
# ======================================================
# WHAT: 'as rnd' means "call it rnd from now on in this file".
#       Useful when the module name is long or conflicts with
#       a variable name you already have.
#       Convention: import numpy as np, import pandas as pd.
# TRY:  Add a second alias: import math as m
#       Then use m.pi and m.sqrt(9) - same functions, shorter prefix.
# ======================================================
import random as rnd                  # rnd is now the alias for random

dice_roll = rnd.randint(1, 6)         # use rnd. instead of random.
print(f"Dice roll: {dice_roll}")

# Example 5: Import specific functions
print("\n--- Example 5: Import Specific Functions ---")
# ======================================================
# WHAT: 'from math import sqrt, pi, pow' brings those three
#       names directly into the current scope. No prefix needed.
#       Use this style when you only need a few items from a module
#       and want cleaner-looking code.
# TRY:  Add 'log' to the import line and then call log(1000, 10).
# ======================================================
from math import sqrt, pi, pow       # import specific names - no prefix

print(f"Pi:              {pi}")
print(f"sqrt(25):        {sqrt(25)}")
print(f"pow(3, 4):       {pow(3, 4)}")


# ============================================
# PART 5: Exception Handling
# ============================================
print("\n" + "=" * 50)
print("PART 5: Exception Handling")
print("=" * 50)
# ======================================================
# WHAT THIS SECTION DOES:
# 1) PURPOSE: An exception is an error that happens while
#    the program is running (not a typo caught at load time).
#    Without handling, exceptions CRASH the program.
#    try/except lets you CATCH the error, handle it gracefully,
#    and keep the program running. This is essential for any
#    program that deals with user input, files, or networks.
#
# 2) VS CODE: Run each Example with Shift+Enter. To really
#    understand this section, deliberately trigger errors:
#    change "abc" to a number and see the except block is skipped.
#    Then put "abc" back - the except catches it again.
# ======================================================

# ------------------------------------------------------
# HOW try / except / else / finally WORKS
# ------------------------------------------------------
#
#   STRUCTURE (all four clauses):
#       try:
#           <code that might fail>
#       except SomeError:
#           <runs if SomeError occurs in try block>
#       except AnotherError:
#           <runs if AnotherError occurs>
#       except Exception as e:
#           <catches ANY remaining error; 'e' has the message>
#       else:
#           <runs ONLY if try block succeeded with NO error>
#       finally:
#           <ALWAYS runs, error or not - used for cleanup>
#
#   COMMON EXCEPTION TYPES:
#       ValueError        : right type, wrong value (int("abc"))
#       ZeroDivisionError : dividing by zero (10 / 0)
#       TypeError         : wrong type for operation ("a" + 1)
#       IndexError        : list index out of range ([1,2][5])
#       KeyError          : dictionary key not found (d["missing"])
#       FileNotFoundError : file does not exist
#
#   FLOW:
#   - If try succeeds  -> else runs, then finally runs
#   - If try raises    -> matching except runs, then finally runs
#   - finally ALWAYS runs regardless of what happened
# ------------------------------------------------------

# Example 1: Basic try-except
print("\n--- Example 1: Basic Try-Except ---")
# ======================================================
# WHAT: int("abc") raises a ValueError because "abc" cannot
#       be converted to an integer. Without try/except, this
#       crashes the program. With it, the except block runs
#       and the program continues normally after.
# TRY:  Change "abc" to "42" - the except is skipped because
#       no error occurs. Then change back to "abc".
# ======================================================
try:
    number = int("abc")          # raises ValueError
except ValueError:
    print("Error: Cannot convert 'abc' to integer!")

print("Program continues after the try-except block...")

# Example 2: Multiple exceptions
print("\n--- Example 2: Multiple Exceptions ---")
# ======================================================
# WHAT: One function, three call scenarios - each triggers
#       a different exception (or none at all).
#       Python checks except clauses TOP TO BOTTOM and runs
#       the FIRST one that matches.
#       10/2  -> no error
#       10/0  -> ZeroDivisionError
#       10/"2"-> TypeError
# TRY:  Add another except for TypeError and call safe_divide(10, "x").
# ======================================================
def safe_divide(a, b):
    try:
        result = a / b
        return result
    except ZeroDivisionError:
        return "Error: Cannot divide by zero!"
    except TypeError:
        return "Error: Please provide numbers!"

print(safe_divide(10, 2))        # normal -> 5.0
print(safe_divide(10, 0))        # ZeroDivisionError caught
print(safe_divide(10, "2"))      # TypeError caught

# Example 3: Generic exception
print("\n--- Example 3: Generic Exception ---")
# ======================================================
# WHAT: 'except Exception as e' is a catch-all.
#       'e' is the actual exception object.
#       type(e).__name__ gives its class name (e.g. "TypeError").
#       str(e) or just e gives the error message.
#       Use this as a last resort after specific except clauses.
# TRY:  Call safe_operation("10", 2, "add") - "10"+2 raises
#       a TypeError which is caught by the generic except.
# ======================================================
def safe_operation(a, b, operation):
    try:
        if operation == "add":
            return a + b
        elif operation == "divide":
            return a / b
        elif operation == "power":
            return a ** b
    except Exception as e:               # catches anything not caught above
        return f"Error: {type(e).__name__} - {e}"

print(safe_operation(10,   2,  "divide"))   # 5.0
print(safe_operation(10,   0,  "divide"))   # ZeroDivisionError caught
print(safe_operation("10", 2,  "add"))      # TypeError caught

# Example 4: try-except-else-finally
print("\n--- Example 4: Try-Except-Else-Finally ---")
# ======================================================
# WHAT: Shows all four clauses together.
#       else:    only runs when NO exception occurred in try
#       finally: always runs - perfect for cleanup tasks
#                (closing a file, releasing a resource, logging)
#   The demo below skips the input() call and uses num=5 directly
#   so it runs without pausing for keyboard input.
# TRY:  Change num to 0 - ZeroDivisionError is raised,
#       else is SKIPPED, but finally STILL runs.
# ======================================================
try:
    num    = 5                   # change to 0 to trigger ZeroDivisionError
    result = 100 / num
except ZeroDivisionError:
    print("Cannot divide by zero!")
else:
    print(f"100 / {num} = {result}")    # only runs when try succeeded
finally:
    print("Finally block ALWAYS executes - runs every time")


# ============================================
# PART 6: Practical Examples
# ============================================
print("\n" + "=" * 50)
print("PART 6: Practical Examples")
print("=" * 50)
# ======================================================
# WHAT THIS SECTION DOES:
# 1) PURPOSE: Five realistic functions that combine everything
#    from Parts 1-5. Each one shows a pattern you will use
#    constantly in real Python projects: conversion functions,
#    math helpers, validators, simulators, and calculators.
#    Reading and understanding these is excellent exam prep.
#
# 2) VS CODE: Run each example individually. Then try calling
#    the function with different inputs BEFORE looking at the
#    code - predict the output, then verify. This is the fastest
#    way to internalise how functions, return, and parameters work.
# ======================================================

# Example 1: Temperature converter
print("\n--- Example 1: Temperature Converter ---")
# ======================================================
# WHAT: Two functions, each doing one clear job.
#       The docstring (text in triple quotes right after def)
#       documents what the function does. You can read it with
#       help(celsius_to_fahrenheit) in the Python terminal.
# TRY:  Convert 0C (freezing) and 100C (boiling) - you should
#       get 32F and 212F. These are good sanity-check values.
# ======================================================
def celsius_to_fahrenheit(celsius):
    """Convert Celsius to Fahrenheit."""
    return (celsius * 9/5) + 32

def fahrenheit_to_celsius(fahrenheit):
    """Convert Fahrenheit to Celsius."""
    return (fahrenheit - 32) * 5/9

temp_c = 25
print(f"{temp_c}C = {celsius_to_fahrenheit(temp_c)}F")

temp_f = 77
print(f"{temp_f}F = {fahrenheit_to_celsius(temp_f):.1f}C")

# Example 2: Calculate circle properties
print("\n--- Example 2: Circle Calculator ---")
# ======================================================
# WHAT: Uses math.pi (imported earlier) and returns TWO values.
#       ** is exponentiation: radius**2 = radius squared.
#       :.2f in the f-string rounds the output to 2 decimal places.
# TRY:  Call calculate_circle(1) - area should be ~3.14 (pi * 1^2).
#       Call calculate_circle(10) and verify: area = pi * 100.
# ======================================================
def calculate_circle(radius):
    """Calculate area and circumference of a circle."""
    area          = math.pi * radius ** 2    # pi * r squared
    circumference = 2 * math.pi * radius     # 2 * pi * r
    return area, circumference               # returns two values

radius = 5
area, circ = calculate_circle(radius)        # unpack both return values
print(f"Radius: {radius}")
print(f"Area:          {area:.2f}")
print(f"Circumference: {circ:.2f}")

# Example 3: Password validator
print("\n--- Example 3: Password Validator ---")
# ======================================================
# WHAT: Returns TWO values: a boolean (pass/fail) and a message.
#       any(char.isdigit() for char in password) is a compact
#       way to ask "does at least ONE character satisfy this?"
#       It is like a for loop that stops as soon as it finds a match.
# TRY:  Add "my_password" = "abc" (too short) and
#       "MyPassword!" (no digit) to the list and run.
# ======================================================
def validate_password(password):
    """Check if password meets requirements."""
    if len(password) < 8:
        return False, "Password must be at least 8 characters"

    has_digit = any(char.isdigit() for char in password)  # True if any digit found
    has_upper = any(char.isupper() for char in password)  # True if any uppercase found
    has_lower = any(char.islower() for char in password)  # True if any lowercase found

    if not (has_digit and has_upper and has_lower):
        return False, "Password must have uppercase, lowercase, and digit"

    return True, "Password is strong"

passwords = ["weak", "StrongPass123", "NoDigits", "ALLUPPER123"]
for pwd in passwords:
    is_valid, message = validate_password(pwd)
    print(f"'{pwd}': {message}")

# Example 4: Dice rolling simulator
print("\n--- Example 4: Dice Rolling Simulator ---")
# ======================================================
# WHAT: Uses default parameters (num_dice=1, sides=6) so the
#       most common case (one 6-sided die) needs no arguments.
#       The list comprehension [random.randint(1, sides) for _ in range(num_dice)]
#       builds the list of rolls in one line.
#       _ is used as the loop variable when you don't need its value.
# TRY:  Call roll_dice() with no arguments - rolls one standard die.
#       Call roll_dice(4, sides=8) for four 8-sided dice.
# ======================================================
def roll_dice(num_dice=1, sides=6):
    """Roll dice and return results."""
    rolls = [random.randint(1, sides) for _ in range(num_dice)]
    return rolls, sum(rolls)

rolls, total = roll_dice(2)
print(f"Rolling 2d6:    {rolls}, Total: {total}")

rolls, total = roll_dice(3, sides=20)
print(f"Rolling 3d20:   {rolls}, Total: {total}")

# Example 5: Grade calculator
print("\n--- Example 5: Grade Calculator ---")
# ======================================================
# WHAT: Combines a guard clause (empty list check), average
#       calculation, and an if/elif chain into one clean function.
#       Guard clause: if not scores -> handles empty input safely
#       before doing any math that would fail on an empty list.
# TRY:  Call calculate_grade([]) - the guard clause returns (0, "N/A").
#       Call calculate_grade([100, 100, 100]) - should give A.
#       Call calculate_grade([55, 58, 52]) - should give F.
# ======================================================
def calculate_grade(scores):
    """Calculate average and letter grade."""
    if not scores:               # guard clause: handle empty list first
        return 0, "N/A"

    average = sum(scores) / len(scores)

    if average >= 90:
        letter = "A"
    elif average >= 80:
        letter = "B"
    elif average >= 70:
        letter = "C"
    elif average >= 60:
        letter = "D"
    else:
        letter = "F"

    return average, letter       # return both values

student_scores = [85, 92, 78, 90, 88]
avg, grade = calculate_grade(student_scores)
print(f"Scores:  {student_scores}")
print(f"Average: {avg:.2f}")
print(f"Grade:   {grade}")

print("\nSession 4 completed successfully!")
print("Functions make your code reusable and organized!")