This page shows the source code for Session-2-4-Examples.py with teaching notes, PURPOSE comments, and VS Code instructions.
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!")