Module 4: Functions & Modules

M4: Functions & Modules — Python for Corporate Professionals | OTLMS
M4 · Functions & Modules Python for Corporate Professionals

Writing Reusable Code with Functions & Modules

Up to now, every program you’ve written runs top to bottom, once. Functions change that — they let you name a block of code and call it as many times as you need. Modules let you bring in code that other people have already written and tested. Together, these two ideas are what turn a script into a proper, maintainable program.

6 topics ~75 min read Foundation level All roles
📖
Concept — From Scripts to Programs
Why functions exist, how scope works, and what Python’s standard library gives you for free
Topic 1

Defining Functions — The def Keyword

A function is a named block of code that performs a specific task. You define it once with the def keyword, and then call it from anywhere in your program — as many times as you need. This is the most important tool for avoiding repetition in code.

Think about the log-line parsing you did in M2. You wrote that logic once, inside a single script. But what if you need to parse log lines in five different scripts? Without functions, you’d copy and paste the same code five times. When the log format changes, you’d need to update it in five places. With a function, you update it once and every script immediately benefits.

def check_server_health(server_namecpumemthreshold=80): ← function signature
    “””Check if a server’s resources exceed the threshold.””” ← docstring
    worst = max(cpumem) ← function body (indented)
    return worst > threshold ← return value
def — keyword blue — function name orange — parameters green — default value

Every function has a name (snake_case, verb-based — check_health, not health), zero or more parameters, an optional docstring, a body, and an optional return statement. Functions without a return statement implicitly return None.

Topic 2

Parameters — How Functions Receive Data

Parameters are the inputs your function expects. Python gives you several ways to define them.

Positional parameters — the caller must provide them in order. def greet(name, role) expects exactly two arguments.

Default parameters have a fallback used when the caller doesn’t provide that argument: def connect(host, port=5432). Defaults must come after required parameters.

Keyword arguments let the caller name the arguments at the call site, making order irrelevant: connect(port=1521, host=”ora-prod”).

*args collects any number of extra positional arguments into a tuple. **kwargs collects any number of keyword arguments into a dictionary. You’ll see both in library code constantly.

A function should do one thing well. If you find yourself naming a function get_data_and_format_and_send_email, split it into three functions. Each should be small enough to fit on one screen, testable in isolation, and named so clearly you know what it does without reading the body.
Topic 3

Return Values — Getting Data Back Out

A function’s return statement does two things: sends a value back to the caller, and immediately exits the function. Anything after a return in the same code path never executes.

Functions can return any Python value — a number, string, list, dictionary, boolean, even another function. When a function returns multiple values, Python packs them into a tuple automatically, and you can unpack them at the call site: status, score = analyse_server(server). This is a very common and clean Python pattern.

Topic 4

Scope — Where Variables Live

Scope determines which parts of your program can see a given variable. Variables created inside a function exist only inside that function — they’re local. Variables created outside all functions exist in the global scope and can be read from anywhere.

Global scope
MAX_RETRIES = 5
company = “OTLMS”
Local scope — inside connect()
attempt = 1    ← only visible inside connect()
timeout = 30   ← destroyed when function returns
MAX_RETRIES ✓  ← can READ global variable
To modify a global from inside a function, declare it with the global keyword — but prefer passing values in and returning them out instead.
Topic 5

Lambda Functions — One-Line Anonymous Functions

A lambda is a tiny, anonymous function written on a single line. It takes arguments and returns the result of a single expression. You can’t put loops or multiple statements in a lambda — it’s for very short, simple logic only.

The most common corporate use case is sorting: pass a lambda as the key argument to sorted() or .sort() to sort a list of dictionaries by a specific field. You’ll also see lambdas with map() and filter(). If a lambda starts getting complicated, write a proper def function instead.

Topic 6

Importing Modules — Python’s Standard Library

Python ships with hundreds of built-in modules. These are all installed with Python — no pip needed. You bring a module’s tools into your script using import: the whole module (import os), specific names (from datetime import date), or with an alias (import datetime as dt).

os
File system, environment variables, running shell commands
os.path.exists(“file.txt”)
sys
Python interpreter info, command-line arguments, exit codes
sys.argv, sys.exit(1)
datetime
Dates, times, time differences, formatting timestamps
datetime.date.today()
math
Mathematical functions and constants
math.ceil(), math.sqrt()
random
Random numbers, shuffling, sampling from sequences
random.choice(), random.randint()
re
Regular expressions — pattern matching in strings
re.findall(), re.sub()
💡 When you need a module not in the standard library — like requests for HTTP or pandas for data — install it first with pip install module_name, then import it the same way. You’ll do this constantly in M8–M11 (the specialty modules).
✏️
Syntax Reference
Every function pattern and key module usage, annotated

Defining and calling functions

Python
# Basic function — no parameters, no return value
def print_divider():
    """Print a standard divider line."""
    print("-" * 48)

print_divider()

# Function with required parameters and return value
def format_ticket(ticket_id, priority, summary):
    """Return a formatted ticket string."""
    return f"[{priority.upper()}] {ticket_id}: {summary}"

line = format_ticket("TKT-1041", "high", "Email not loading")
print(line)
# [HIGH] TKT-1041: Email not loading

# Default parameters
def is_critical(value, threshold=90):
    """Return True if value exceeds threshold."""
    return value > threshold

is_critical(95)                   # True  — uses default 90
is_critical(95, threshold=80)     # True  — custom threshold
is_critical(75)                   # False

# Returning multiple values (Python packs them as a tuple)
def analyse_server(name, cpu, mem, disk):
    """Return server status and worst metric."""
    worst = max(cpu, mem, disk)
    if worst >= 90:   status = "critical"
    elif worst >= 75: status = "warning"
    else:             status = "healthy"
    return status, worst

status, peak = analyse_server("db-01", 82, 91, 55)
print(f"Status: {status}, Peak: {peak}%")
# Status: critical, Peak: 91%

*args and **kwargs

Python
# *args — any number of positional arguments (tuple inside)
def total_tickets(*counts):
    return sum(counts)

total_tickets(12, 8)           # 20
total_tickets(12, 8, 5, 19)    # 44

# **kwargs — any number of keyword arguments (dict inside)
def create_ticket(**fields):
    print("New ticket:")
    for key, val in fields.items():
        print(f"  {key}: {val}")

create_ticket(title="VPN down", priority="high", assignee="Priya")

Scope — local vs global

Python
MAX_RETRIES = 5              # global

def connect(host):
    attempt = 1               # local — destroyed when function returns
    print(f"Max retries: {MAX_RETRIES}")   # can READ global

# To modify a global — use 'global' keyword (prefer returning values instead)
retry_count = 0

def increment_retries():
    global retry_count
    retry_count += 1

# Cleaner pattern: pass in, return out
def increment(count):
    return count + 1

retry_count = increment(retry_count)   # explicit, testable

Lambda functions

Python
# Syntax: lambda arguments: expression
double = lambda x: x * 2
double(5)                           # 10

fleet = [
    {"name": "web-01", "cpu": 45},
    {"name": "db-01",  "cpu": 82},
    {"name": "api-01", "cpu": 33},
]

# Sort by cpu descending
by_cpu  = sorted(fleet, key=lambda s: s["cpu"], reverse=True)

# Filter: only high-cpu servers
high    = list(filter(lambda s: s["cpu"] > 50, fleet))

# Extract one field (list comprehension is usually cleaner)
cpu_vals = [s["cpu"] for s in fleet]   # [45, 82, 33]

Key standard library modules

Python
import os, sys, math, random
from datetime import date, datetime, timedelta

# ── os ──
os.getcwd()                             # current directory
os.listdir(".")                         # list files
os.path.exists("config.txt")           # True / False
os.path.join("logs", "app.log")        # OS-safe path
os.environ.get("DB_HOST", "localhost") # env variable with default
os.makedirs("output", exist_ok=True)   # create dirs safely

# ── sys ──
sys.version                             # Python version string
sys.argv                                # command-line arguments list
sys.exit(0)                             # exit with success (0) or error (1)

# ── datetime ──
today     = date.today()                # 2026-06-14
now       = datetime.now()              # 2026-06-14 09:33:12
tomorrow  = today + timedelta(days=1)  # 2026-06-15
formatted = now.strftime("%d-%b-%Y")  # "14-Jun-2026"
parsed    = datetime.strptime("14-06-2026", "%d-%m-%Y")

# ── math ──
math.ceil(4.2)       # 5  — always round up
math.floor(4.9)      # 4  — always round down
math.sqrt(144)       # 12.0
math.log(1000, 10)   # 3.0 — log base 10

# ── random ──
random.randint(1, 100)           # random integer 1–100
random.choice(["a", "b", "c"])  # pick one at random
random.shuffle(my_list)            # shuffle in place
random.sample(my_list, 3)         # 3 unique random items

Docstrings — documenting your functions

Python
def calculate_sla_status(age_hours, priority):
    """
    Determine SLA compliance status for a support ticket.

    Args:
        age_hours (int): How long the ticket has been open, in hours.
        priority (str): 'Critical', 'High', 'Medium', or 'Low'.

    Returns:
        str: One of 'OK', 'AT RISK', or 'BREACHED'.
    """
    limits = {"Critical": 1, "High": 8, "Medium": 24, "Low": 72}
    limit  = limits.get(priority, 24)
    pct    = (age_hours / limit) * 100
    if age_hours > limit:   return "BREACHED"
    elif pct >= 80:         return "AT RISK"
    else:                   return "OK"

help(calculate_sla_status)   # Python reads the docstring automatically
💡
Examples — Functions Making Scripts Reusable
Five programs where functions genuinely change how the code is structured

Notice in each example how the main logic at the bottom becomes short and readable because the heavy lifting is inside named functions. The main block reads almost like a checklist — that’s the goal.

Example 1 — IT Support: Reusable ticket formatter library
IT SupportAll Roles

Small, focused functions that can be reused across any IT support script. The main code at the bottom is just function calls — easy to read, easy to change, each piece independently testable.

Python
from datetime import datetime

def format_ticket_id(num):
    """Return zero-padded ticket ID e.g. TKT-00041."""
    return f"TKT-{num:05d}"

def get_sla_hours(priority):
    """Return SLA time limit in hours for given priority."""
    limits = {"Critical": 1, "High": 8, "Medium": 24, "Low": 72}
    return limits.get(priority, 24)

def sla_status(age_hours, priority):
    """Return SLA status string and percentage used."""
    limit = get_sla_hours(priority)
    pct   = (age_hours / limit) * 100
    if age_hours > limit:  return "✗ BREACHED", pct
    elif pct >= 80:        return "⚠ AT RISK", pct
    else:                  return "✓ OK", pct

def print_ticket_row(num, priority, summary, age_hours):
    """Print one formatted ticket row."""
    tid = format_ticket_id(num)
    status, pct = sla_status(age_hours, priority)
    print(f"{tid}  {priority:10} {age_hours:>3}h  {pct:>5.0f}%  {status:12} {summary}")

# ── Main ──
tickets = [
    (1041, "High",     "Email not loading",    5),
    (1035, "Critical", "Production DB down",   2),
    (1029, "Medium",   "Printer offline",       26),
    (1018, "Low",      "Request new keyboard", 48),
]

print(f"{'Ticket':10} {'Priority':10} {'Age':>4} {'SLA%':>6}  {'Status':13} Summary")
print("-" * 72)
for t in tickets:
    print_ticket_row(*t)   # * unpacks the tuple into positional args
Output
Ticket     Priority     Age   SLA%   Status         Summary
————————————————————————
TKT-01041 High          5h    63%   ✓ OK          Email not loading
TKT-01035 Critical      2h   200%   ✗ BREACHED   Production DB down
TKT-01029 Medium       26h   108%   ✗ BREACHED   Printer offline
TKT-01018 Low          48h    67%   ✓ OK          Request new keyboard
Example 2 — Database Developer: SQL query builder functions
Database Dev

Functions that build SQL query strings from parameters — a safe, reusable pattern. Shows how functions with defaults and **kwargs make complex operations readable and maintainable.

Python
def build_select(table, columns="*", where=None, order_by=None, limit=None):
    """Build a SELECT statement from components."""
    if isinstance(columns, list):
        columns = ", ".join(columns)
    query = f"SELECT {columns} FROM {table}"
    if where:    query += f" WHERE {where}"
    if order_by: query += f" ORDER BY {order_by}"
    if limit:    query += f" FETCH FIRST {limit} ROWS ONLY"
    return query

def build_insert(table, **data):
    """Build an INSERT statement from keyword arguments."""
    cols = ", ".join(data.keys())
    vals = ", ".join([f"'{v}'" if isinstance(v, str) else str(v)
                    for v in data.values()])
    return f"INSERT INTO {table} ({cols}) VALUES ({vals})"

def preview_query(query, label="Query"):
    """Print a query with a label for review."""
    print(f"\n── {label} ──\n  {query};")

# ── Main ──
preview_query(
    build_select("employees",
                 columns=["emp_id", "name", "salary"],
                 where="dept = 'IT' AND active = 1",
                 order_by="salary DESC",
                 limit=10),
    "IT salary report"
)

preview_query(
    build_insert("audit_log",
                 event="LOGIN", user_id=1042, status="SUCCESS"),
    "Audit insert"
)
Output
── IT salary report ──
  SELECT emp_id, name, salary FROM employees WHERE dept = ‘IT’ AND active = 1 ORDER BY salary DESC FETCH FIRST 10 ROWS ONLY;

── Audit insert ──
  INSERT INTO audit_log (event, user_id, status) VALUES (‘LOGIN’, 1042, ‘SUCCESS’);
Example 3 — DevOps: Environment-aware config loader
DevOps

Uses os.environ to read environment variables with safe defaults, exposed through a clean get_config() function. This is exactly how real DevOps configuration loading is structured.

Python
import os
from datetime import datetime

def get_env(key, default=None, required=False):
    """Read an environment variable safely."""
    value = os.environ.get(key, default)
    if required and value is None:
        raise EnvironmentError(f"Required env var '{key}' is not set.")
    return value

def get_config():
    """Build application configuration from environment variables."""
    return {
        "env":       get_env("APP_ENV",   default="development"),
        "db_host":   get_env("DB_HOST",   default="localhost"),
        "db_port":   int(get_env("DB_PORT", default="5432")),
        "log_level": get_env("LOG_LEVEL", default="INFO").upper(),
        "max_conn":  int(get_env("MAX_CONN", default="10")),
    }

def print_config(cfg):
    """Print config in a readable format."""
    sensitive = {"db_password", "api_key"}
    ts = datetime.now().strftime("%H:%M:%S")
    print(f"Configuration loaded at {ts}\n")
    for key, value in cfg.items():
        display = "***" if key in sensitive else value
        print(f"  {key:14}: {display}")

# ── Main ──
config = get_config()
print_config(config)

if config["env"] == "production":
    print("\n⚠ Production environment — handle with care.")
Output
Configuration loaded at 09:33:12

  env            : development
  db_host        : localhost
  db_port        : 5432
  log_level      : INFO
  max_conn       : 10
Example 4 — AI / ML: Composable data preprocessing pipeline
AI / ML

A data cleaning pipeline built from small, composable functions — each doing one thing. A run_pipeline() function chains them. This reflects exactly how ML engineers structure preprocessing before feeding data to a model.

Python
import math

def remove_nulls(records, required_keys):
    """Remove records missing any required key."""
    return [r for r in records
            if all(r.get(k) is not None for k in required_keys)]

def clamp(records, field, min_val, max_val):
    """Cap field values to [min_val, max_val]."""
    for r in records:
        r[field] = max(min_val, min(max_val, r[field]))
    return records

def normalise(records, field):
    """Min-max normalise a numeric field to 0.0–1.0."""
    values = [r[field] for r in records]
    lo, hi = min(values), max(values)
    rng    = hi - lo or 1
    for r in records:
        r[f"{field}_norm"] = round((r[field] - lo) / rng, 4)
    return records

def add_log_feature(records, field):
    """Add log1p transform of field as a new feature."""
    for r in records:
        r[f"{field}_log"] = round(math.log1p(r[field]), 4)
    return records

def run_pipeline(raw):
    """Run the full preprocessing pipeline."""
    data = remove_nulls(raw, ["age", "salary"])
    data = clamp(data, "age", 18, 65)
    data = normalise(data, "salary")
    data = add_log_feature(data, "salary")
    return data

# ── Main ──
raw = [
    {"id": 1, "age": 28, "salary": 55000},
    {"id": 2, "age": 72, "salary": 92000},  # age clamped to 65
    {"id": 3, "age": None, "salary": 48000}, # removed — missing age
    {"id": 4, "age": 35, "salary": 78000},
]
clean = run_pipeline(raw)
print(f"{'ID':>3} {'Age':>5} {'Salary':>8} {'Norm':>7} {'Log':>8}")
print("-" * 36)
for r in clean:
    print(f"{r['id']:>3} {r['age']:>5} {r['salary']:>8,} {r['salary_norm']:>7.4f} {r['salary_log']:>8.4f}")
Output
 ID   Age   Salary    Norm      Log
————————————
  1    28   55,000  0.0000  10.9152
  2    65   92,000  1.0000  11.4297
  4    35   78,000  0.6216  11.2647
Example 5 — Automation: Structured report generator
AutomationDevOps

A complete automation script structured entirely around functions. Uses datetime for timestamps, os for paths, and lambda for sorting. The main block reads almost like a specification — which is exactly what well-structured function-based code should feel like.

Python
import os
from datetime import datetime

def get_report_filename(name, ext="txt"):
    """Generate a timestamped filename."""
    ts   = datetime.now().strftime("%Y%m%d_%H%M")
    safe = name.lower().replace(" ", "_")
    return f"{safe}_{ts}.{ext}"

def ensure_output_dir(directory="reports"):
    """Create output directory if it doesn't exist."""
    os.makedirs(directory, exist_ok=True)
    return directory

def summarise_metrics(records, value_key, label_key):
    """Return summary stats for a list of records."""
    values = [r[value_key] for r in records]
    return {
        "count":   len(values),
        "total":   sum(values),
        "average": round(sum(values) / len(values), 1),
        "highest": max(records, key=lambda r: r[value_key])[label_key],
        "lowest":  min(records, key=lambda r: r[value_key])[label_key],
    }

def build_report_text(title, stats, generated_at):
    """Format report as a text block."""
    lines = [
        f"{'='*50}", f"  {title}", f"  Generated: {generated_at}", f"{'='*50}",
        f"  Records  : {stats['count']}",
        f"  Total    : {stats['total']:,}",
        f"  Average  : {stats['average']:,}",
        f"  Top      : {stats['highest']}",
        f"  Bottom   : {stats['lowest']}",
        f"{'='*50}",
    ]
    return "\n".join(lines)

# ── Main ──
sales = [
    {"region": "North", "revenue": 1_240_000},
    {"region": "South", "revenue": 980_000},
    {"region": "East",  "revenue": 1_560_000},
    {"region": "West",  "revenue": 820_000},
]

stats  = summarise_metrics(sales, "revenue", "region")
report = build_report_text("Regional Revenue Report", stats,
             datetime.now().strftime("%d %b %Y %H:%M"))
print(report)
Output
==================================================
  Regional Revenue Report
  Generated: 14 Jun 2026 09:33
==================================================
  Records : 4
  Total : 4,600,000
  Average : 1,150,000.0
  Top : East
  Bottom : West
==================================================
🏋️
Practice Exercises
Four tasks — each one requires you to write at least one function from scratch

Before writing any code, ask yourself: what are the inputs, what should it return, and what should it be called? Getting those three things right is 80% of writing a good function.

1
Password validator function. Write validate_password(password, min_length=8) that returns a tuple: a boolean (True if valid) and a list of failure reasons if invalid. Rules: must meet min_length, contain at least one uppercase letter, at least one digit. Test with four passwords — two valid, two invalid — and print each result.
Build a reasons = [] list inside the function. Append a reason string for each failing rule. Return len(reasons) == 0, reasons. At the call site: valid, issues = validate_password(“myPass1”). Check uppercase with any(c.isupper() for c in password).
2
Date utilities using datetime. Write three functions: (a) days_until(date_str) — takes “2026-12-31” and returns days from today, (b) is_business_day(d) — returns True if Monday–Friday, (c) next_business_day() — returns tomorrow if it’s a business day, otherwise skips to Monday. Test each one and print results.
Parse a date string: datetime.strptime(s, “%Y-%m-%d”).date(). Subtract two dates to get a timedelta — use .days. For weekday: d.weekday() returns 0 for Monday, 6 for Sunday — so <= 4 means business day. Add timedelta(days=1) to get tomorrow.
3
Lambda-based sorter. Start with 6 employee dictionaries (name, dept, salary). Use lambda and sorted() to produce three views: (a) alphabetically by name, (b) salary descending, (c) department first, then salary descending within each department. Print each with a label.
For (a): sorted(employees, key=lambda e: e[“name”]). For (c): key=lambda e: (e[“dept”], -e[“salary”]) — tuples sort element by element, negating salary reverses it within each group.
4
Refactor a flat script into functions. Take this code and refactor it into at least three named functions, then write a clean main section:

servers = [{"name":"web","cpu":45},{"name":"db","cpu":92},{"name":"api","cpu":70}]
total = 0
for s in servers: total += s["cpu"]
avg = total / len(servers)
for s in servers:
    if s["cpu"] > 80: print(f"ALERT: {s['name']} cpu={s['cpu']}%")
print(f"Average CPU: {avg:.1f}%")
Three natural functions: average_cpu(servers) returns a float, find_alerts(servers, threshold=80) returns a filtered list, print_report(alerts, avg) handles all printing. Main code becomes 3 lines.
📋
Assignment — M4
A reusable monitoring toolkit — estimated 45–55 minutes

📋 Server Monitoring Toolkit

Build monitor_toolkit.py structured entirely around reusable functions. Every piece of logic must live inside a named function — the main block should only call functions and print results.

  1. calculate_health_score(cpu, mem, disk) — returns a score 0–100. Deduct: 1 point per % CPU above 60, 1 point per % memory above 70, 2 points per % disk above 80. Minimum 0.
  2. classify_server(score) — returns a tuple of status string and emoji: “Healthy” ≥80, “Degraded” ≥55, “Critical” <55.
  3. generate_server_report(servers) — accepts a list of server dicts (name, env, cpu, mem, disk). Calls the above two functions for each, returns enriched list with score and status added.
  4. summary_by_environment(report) — groups by env, returns dict showing server count, average score, and status category counts per environment.
  5. print_report(report) and print_summary(summary) — two separate print functions with aligned f-string column formatting.
  6. Use from datetime import datetime and import os genuinely: timestamp in the report header, and read a MONITOR_ENV env variable (default “local”) displayed in the header.
Paste your full script in the comments. Key check: could someone import your individual functions into another script and use them directly? If yes — you’ve written truly reusable code. You’re ready for M5: File Handling & I/O.
🧠
Quiz — Check Your Understanding
5 questions · instant feedback · retake as many times as you need

These questions test how functions behave — parameters, scope, return values, and imports. Some require you to trace through code mentally before answering.

1. What does this return when called as greet(“Priya”)?

def greet(name, role="Engineer"):
    return f"Hello, {name}. Welcome, {role}."
2. A variable counter is defined at global scope. Inside a function you write counter = counter + 1 without the global keyword. What happens?
3. What is the output of this code?

def get_status(cpu, threshold=75):
    if cpu >= threshold:
        return "alert"
    return "ok"

a = get_status(80)
b = get_status(80, threshold=90)
print(a, b)
4. You want to use today’s date in a script. Which import and call is correct?
5. Which lambda expression correctly sorts a list of dictionaries by “score” in descending order?
Your code is now organised into reusable functions. Time to make it talk to the file system.
M5: File Handling & I/O — Read and write text files, CSVs, JSON, and config files.
Start M5 →
Scroll to Top