Lesson 15 – Python Functions, Arguments, *args / **kwargs, and Scope
Lesson 15 – Python Functions, Arguments, *args / **kwargs, and Scope
Lesson Introduction
Imagine you are baking a cake. Every time a customer orders one, you don’t invent a brand-new recipe from scratch — you follow the same recipe each time, maybe swapping the flavour depending on what the customer wants. That recipe is a function.
In Python, a function is a reusable block of code that you write once and run as many times as you need, with different values each time if required. Functions are the single most important building block of any real program. Without them, you would copy and paste the same code over and over — messy, error-prone, and hard to fix.
This lesson covers four closely connected topics from the W3Schools Python tutorials, merged into one smooth, progressive lesson:
| Topic | What You Will Learn |
|---|---|
| Functions | How to create and call functions |
| Arguments | How to pass values into functions |
| *args and **kwargs | How to handle an unknown number of arguments |
| Scope | Where variables live and who can see them |
By the end of this lesson you will be able to write your own functions, pass data into them flexibly, and fully understand why variables sometimes seem to “disappear” — and why that is actually a good thing.
Prerequisite Concepts
Before we begin, here are the concepts you must already understand. If any of these feel unfamiliar, take a few minutes to review them.
- Variables — storing a value in a name (e.g.,
x = 5) print()— displaying output in the terminal- Basic data types — strings (
"hello"), integers (5), floats (3.14), lists ([1, 2, 3]) if/else— making decisions in codeforloops — repeating an action multiple times
No prior experience with functions or scope is needed.
Part 1 – Python Functions
1.1 What Is a Function?
A function is a named, reusable block of code. You define it once, and then you can call (run) it as many times as you like from anywhere in your program.
Real-world analogy:
Think of a function like a vending machine button. The machine (your function) is built once. Every time someone pushes the button (calls the function), the machine does the same job and delivers a result.
Why do functions exist?
- DRY principle — “Don’t Repeat Yourself.” Write the logic once, use it everywhere.
- Readability — Code broken into named functions is far easier to read and understand.
- Maintenance — If a rule changes, you fix it in one place instead of hunting through hundreds of lines.
- Testing — Small, focused functions are much easier to test and debug.
1.2 Defining a Function
In Python, you create a function using the def keyword.
Syntax:
def function_name():
# code that runs when the function is called
Breaking down the syntax line by line:
| Part | What It Means |
|---|---|
def |
Short for “define” — tells Python you are creating a function |
function_name |
The name you give your function (use lowercase and underscores) |
() |
Parentheses — this is where inputs (arguments) will go later |
: |
The colon signals the start of the function body |
| Indented block | All code indented underneath belongs to this function |
1.3 Your First Function
def say_hello():
print("Hello from inside a function!")
Important: Defining a function does not run it. It just stores the recipe. Nothing appears on screen yet.
1.4 Calling a Function
To actually run the function, you call it by writing its name followed by parentheses:
def say_hello():
print("Hello from inside a function!")
say_hello()
Expected output:
Hello from inside a function!
You can call the same function multiple times:
say_hello()
say_hello()
say_hello()
Expected output:
Hello from inside a function!
Hello from inside a function!
Hello from inside a function!
💡 Think about it: What happens if you never call the function? Try removing
say_hello()from the code. What do you see?
1.5 Why Indentation Matters
Python uses indentation (spaces at the start of a line) to know which lines belong inside a function. This is not optional — it is the rule.
def greet():
print("Line 1 – inside the function")
print("Line 2 – also inside the function")
print("Line 3 – OUTSIDE the function, runs immediately")
greet()
Expected output:
Line 3 – OUTSIDE the function, runs immediately
Line 1 – inside the function
Line 2 – also inside the function
🧠 Notice:
Line 3runs first because it is outside the function and sits at the top level of the program.Line 1andLine 2only run whengreet()is called.
1.6 Common Beginner Mistake — Forgetting the Call
# ❌ WRONG – defines the function but never calls it
def show_score():
print("Your score is 100")
# Nothing is printed because we forgot to call it!
Corrected version:
# ✅ CORRECT
def show_score():
print("Your score is 100")
show_score() # This line actually runs the function
Expected output:
Your score is 100
1.7 Common Beginner Mistake — Indentation Error
# ❌ WRONG – missing indentation inside function body
def greet():
print("Hello") # IndentationError!
Corrected version:
# ✅ CORRECT
def greet():
print("Hello") # 4 spaces of indentation
1.8 The pass Statement
Sometimes you want to create a function skeleton now and fill in the logic later. An empty function body causes a syntax error — use pass as a placeholder:
def my_future_function():
pass # Does nothing, but Python is happy
my_future_function() # No output, no error
This is common in professional development when planning out a project structure before writing all the logic.
Part 2 – Arguments and Parameters
2.1 What Are Arguments?
So far our functions do the exact same thing every time. But real functions need to work with different data each time — like a calculator that can add any two numbers, not just 2 + 2.
Parameters and arguments let you pass information into a function.
The difference:
| Word | Meaning | Where |
|---|---|---|
| Parameter | The variable name listed in the function definition | Inside def parentheses |
| Argument | The actual value you pass when calling the function | Inside the call parentheses |
Think of a parameter as an empty slot, and an argument as the value you drop into that slot.
2.2 A Function with One Parameter
def greet_person(name): # 'name' is the parameter
print("Hello, " + name + "!")
greet_person("Alice") # "Alice" is the argument
greet_person("Bob") # "Bob" is the argument
greet_person("Dr. Johnson") # "Dr. Johnson" is the argument
Expected output:
Hello, Alice!
Hello, Bob!
Hello, Dr. Johnson!
🧠 What changed? The same function ran three times. Each time, the
nameparameter held a different value. The function code never changed — only the input did.
2.3 A Function with Multiple Parameters
You can define as many parameters as you need, separated by commas:
def describe_student(first_name, subject, grade):
print(first_name + " studies " + subject + " and scored " + str(grade) + "%.")
describe_student("Maria", "Maths", 94)
describe_student("James", "Biology", 78)
Expected output:
Maria studies Maths and scored 94%.
James studies Biology and scored 78%.
Note:
str(grade)converts the number94into the string"94"so it can be joined with the other strings using+. This is called type conversion.
2.4 Positional Arguments
When you pass arguments in order, Python matches them to parameters by position:
def show_order(item, quantity, price):
print(item + " × " + str(quantity) + " = £" + str(price))
show_order("Apples", 6, 3.00)
# item = "Apples", quantity = 6, price = 3.00
Expected output:
Apples × 6 = £3.0
⚠️ Order matters! If you mix up the order, you get incorrect results — or an error:
show_order(6, "Apples", 3.00) # quantity and item are swapped!
Incorrect output:
6 × Apples = £3.0
2.5 Keyword Arguments
You can also pass arguments using the parameter name so order does not matter:
def show_order(item, quantity, price):
print(item + " × " + str(quantity) + " = £" + str(price))
show_order(quantity=6, price=3.00, item="Apples")
Expected output:
Apples × 6 = £3.0
Using keyword arguments makes your code self-documenting — anyone reading it knows exactly what each value means.
2.6 Default Parameter Values
You can give a parameter a default value. If the caller does not provide that argument, the default is used automatically:
def greet(name, language="English"):
if language == "English":
print("Hello, " + name + "!")
elif language == "Spanish":
print("Hola, " + name + "!")
elif language == "French":
print("Bonjour, " + name + "!")
greet("Alice") # uses default: English
greet("Carlos", "Spanish") # overrides default
greet("Marie", "French") # overrides default
Expected output:
Hello, Alice!
Hola, Carlos!
Bonjour, Marie!
💡 Rule: Parameters with defaults must come after parameters without defaults in the function definition.
# ❌ WRONG
def greet(language="English", name): # SyntaxError
pass
# ✅ CORRECT
def greet(name, language="English"):
pass
2.7 Returning Values with return
So far our functions have printed things. But often you want a function to calculate a result and send it back so your program can use it elsewhere.
The return keyword sends a value back to wherever the function was called:
def add(a, b):
result = a + b
return result
total = add(10, 25)
print("The total is:", total)
Expected output:
The total is: 35
What happens line by line:
add(10, 25)is called —a = 10,b = 25- Inside:
result = 10 + 25→result = 35 return resultsends35back to the callertotalreceives the returned value35print(...)displays it
🛑 After
return, the function stops. Any code afterreturnin the same function body will never run.
def demo():
return "I am returned"
print("This line never runs!") # unreachable code
value = demo()
print(value)
Expected output:
I am returned
2.8 Practical Example — Temperature Converter
def celsius_to_fahrenheit(celsius):
fahrenheit = (celsius * 9 / 5) + 32
return fahrenheit
temp_c = 100
temp_f = celsius_to_fahrenheit(temp_c)
print(str(temp_c) + "°C = " + str(temp_f) + "°F")
print(str(0) + "°C = " + str(celsius_to_fahrenheit(0)) + "°F")
print(str(37) + "°C = " + str(celsius_to_fahrenheit(37)) + "°F")
Expected output:
100°C = 212.0°F
0°C = 32.0°F
37°C = 98.6°F
💡 Real-world use: Weather apps, scientific instruments, and cooking sites use this exact type of function. Write it once, use it everywhere.
2.9 Passing a List as an Argument
Any data type can be passed as an argument, including lists:
def show_fruits(fruits):
for fruit in fruits:
print("🍎 " + fruit)
my_fruits = ["Apple", "Banana", "Mango", "Grape"]
show_fruits(my_fruits)
Expected output:
🍎 Apple
🍎 Banana
🍎 Mango
🍎 Grape
Part 3 – *args and **kwargs
3.1 The Problem: What If You Don’t Know How Many Arguments There Will Be?
Imagine writing a function that adds numbers. How many numbers should it accept? Two? Five? Twenty?
If you hard-code three parameters, a caller who wants to sum six numbers is stuck. Python solves this with arbitrary arguments.
3.2 *args — Arbitrary Positional Arguments
Adding a single * before a parameter name tells Python: “collect all the extra positional arguments into a tuple.”
By convention, the name used is *args, but any name after * works.
def add_all(*numbers):
total = 0
for n in numbers:
total = total + n
return total
print(add_all(1, 2))
print(add_all(1, 2, 3, 4, 5))
print(add_all(10, 20, 30, 40, 50, 60))
Expected output:
3
15
210
What is numbers here? It is a tuple — an immutable, ordered collection of all the values passed in:
def inspect_args(*items):
print(type(items)) # <class 'tuple'>
print(items)
inspect_args("cat", "dog", "fish")
Expected output:
<class 'tuple'>
('cat', 'dog', 'fish')
3.3 *args With Other Parameters
*args can be combined with regular parameters. Regular parameters must come before *args:
def show_menu(restaurant_name, *dishes):
print("Restaurant: " + restaurant_name)
print("Today's menu:")
for dish in dishes:
print(" - " + dish)
show_menu("La Piazza", "Pasta", "Pizza", "Tiramisu", "Risotto")
Expected output:
Restaurant: La Piazza
Today's menu:
- Pasta
- Pizza
- Tiramisu
- Risotto
🧠 How it works:
"La Piazza"fills therestaurant_nameparameter. Everything else —"Pasta","Pizza","Tiramisu","Risotto"— is collected into thedishestuple.
3.4 **kwargs — Arbitrary Keyword Arguments
**kwargs does the same thing as *args, but for keyword arguments (name=value pairs). The double ** collects them into a dictionary.
By convention the name is **kwargs (keyword arguments), but you can use any name after **.
def describe_person(**details):
print("Person Profile:")
for key, value in details.items():
print(" " + key + ": " + str(value))
describe_person(name="Sarah", age=28, city="London", job="Engineer")
Expected output:
Person Profile:
name: Sarah
age: 28
city: London
job: Engineer
What is details here? It is a dictionary:
def inspect_kwargs(**info):
print(type(info)) # <class 'dict'>
print(info)
inspect_kwargs(colour="blue", size=12)
Expected output:
<class 'dict'>
{'colour': 'blue', 'size': 12}
3.5 **kwargs — Practical Example: Product Builder
def create_product(**specs):
print("=== New Product ===")
for spec, value in specs.items():
print(spec.capitalize() + ": " + str(value))
print()
create_product(name="Laptop", brand="TechPro", ram="16GB", price=999.99)
create_product(name="Headphones", brand="SoundWave", colour="Black", wireless=True)
Expected output:
=== New Product ===
Name: Laptop
Brand: TechPro
Ram: 16GB
Price: 999.99
=== New Product ===
Name: Headphones
Brand: SoundWave
Colour: Black
Wireless: True
💡 Real-world use: Web frameworks like Flask and Django use
**kwargseverywhere. When you create a database model or an HTML element with attributes, the framework accepts them as keyword arguments.
3.6 Combining Regular, *args, and **kwargs
You can use all three together in one function. The order must be:
def function(regular_params, *args, **kwargs):
def full_order(table_number, *items, **extras):
print("Table " + str(table_number) + " ordered:")
for item in items:
print(" - " + item)
if extras:
print(" Special requests:")
for key, val in extras.items():
print(" " + key + ": " + str(val))
full_order(7, "Pizza", "Salad", "Water", sauce="extra", gluten_free=True)
Expected output:
Table 7 ordered:
- Pizza
- Salad
- Water
Special requests:
sauce: extra
gluten_free: True
3.7 The * and ** Unpacking Operators in Function Calls
The * and ** can also be used when calling a function, to unpack a list or dictionary into separate arguments:
def add(a, b, c):
return a + b + c
numbers = [1, 2, 3]
print(add(*numbers)) # unpacks list into: add(1, 2, 3)
Expected output:
6
def greet(name, language):
print("Hello " + name + " in " + language)
settings = {"name": "Aiko", "language": "Japanese"}
greet(**settings) # unpacks dict into: greet(name="Aiko", language="Japanese")
Expected output:
Hello Aiko in Japanese
3.8 Common Beginner Mistakes with *args and **kwargs
Mistake 1 — Wrong order:
# ❌ WRONG – **kwargs before *args
def bad_func(**kwargs, *args): # SyntaxError
pass
# ✅ CORRECT – *args always before **kwargs
def good_func(*args, **kwargs):
pass
Mistake 2 — Treating *args as a list (it’s a tuple):
def my_func(*numbers):
numbers.append(99) # AttributeError! Tuples don't have append
# ✅ CORRECT – convert to list if you need to modify it
def my_func(*numbers):
nums_list = list(numbers)
nums_list.append(99)
print(nums_list)
my_func(1, 2, 3)
Expected output:
[1, 2, 3, 99]
Part 4 – Python Scope
4.1 What Is Scope?
Scope describes where in your code a variable is visible and accessible. Not every variable can be seen from every part of a program — and this is intentional.
Why does scope exist?
Imagine a huge program with hundreds of functions. If every variable was visible everywhere, any function could accidentally overwrite another function’s total or count. Scope creates walls between different parts of code, preventing accidental interference.
Analogy: Think of scope like rooms in a house. A lamp in your bedroom (local scope) only lights your bedroom. It does not light the kitchen. The main electricity supply (global scope) powers the entire house.
Python has four levels of scope, remembered with the acronym LEGB:
| Letter | Name | Where |
|---|---|---|
| L | Local | Inside the current function |
| E | Enclosing | Inside an outer function (for nested functions) |
| G | Global | At the top level of the file |
| B | Built-in | Python’s built-in names (e.g., print, len, range) |
We will focus on Local and Global — the two you will encounter most.
4.2 Local Scope
A variable created inside a function is local — it only exists while the function is running, and it is invisible outside.
def my_function():
local_variable = "I only exist inside this function"
print(local_variable)
my_function()
print(local_variable) # NameError! local_variable doesn't exist here
Expected output:
I only exist inside this function
Traceback (most recent call last):
...
NameError: name 'local_variable' is not defined
🧠 Why? When
my_function()finishes, Python destroys all local variables. They are temporary — like writing on a whiteboard that gets erased when the meeting ends.
4.3 Local Variables in Different Functions Don’t Interfere
Each function has its own private workspace. Two functions can both use a variable called x without conflict:
def function_a():
x = 100
print("Inside function_a, x =", x)
def function_b():
x = 999
print("Inside function_b, x =", x)
function_a()
function_b()
Expected output:
Inside function_a, x = 100
Inside function_b, x = 999
Both functions have their own x. They never interfere with each other. This is the power of local scope.
4.4 Global Scope
A variable created outside all functions is global — it is visible to all code in the file:
greeting = "Good morning" # global variable
def say_it():
print(greeting) # can READ the global variable
def shout_it():
print(greeting.upper()) # can also READ it
say_it()
shout_it()
Expected output:
Good morning
GOOD MORNING
4.5 Can a Function Modify a Global Variable?
By default, reading a global variable inside a function is fine. But modifying it is blocked — Python creates a new local variable instead:
counter = 0 # global
def increment():
counter = counter + 1 # ❌ UnboundLocalError!
increment()
Why the error? Python sees counter = on the left side of = inside the function, assumes you mean a new local variable, then tries to read the (not-yet-created local) counter on the right side — and fails.
4.6 The global Keyword
To tell Python “I want to modify the actual global variable, not create a local one,” use the global keyword:
counter = 0 # global
def increment():
global counter # declare intent to use the global
counter = counter + 1
print("Counter:", counter)
increment()
increment()
increment()
print("Final counter:", counter)
Expected output:
Counter: 1
Counter: 2
Counter: 3
Final counter: 3
⚠️ Use
globalsparingly. Overusingglobalis considered bad practice because it makes code hard to trace and debug. The professional approach is to pass values as arguments and return results instead.
Professional alternative to global:
# ❌ Using global (not ideal)
total = 0
def add_to_total(n):
global total
total += n
# ✅ Better approach – pass and return
def add_to_total(current_total, n):
return current_total + n
total = 0
total = add_to_total(total, 5)
total = add_to_total(total, 10)
print(total) # 15
4.7 Local Takes Priority Over Global
If a local variable and a global variable share the same name, the local one wins inside the function:
name = "Global Name"
def show_name():
name = "Local Name" # this is a NEW local variable
print(name)
show_name()
print(name) # global variable unchanged
Expected output:
Local Name
Global Name
🧠 Notice: Assigning inside the function did NOT change the global
name. The global is completely untouched.
4.8 The nonlocal Keyword (Bonus: Enclosing Scope)
When you have a function inside another function (a nested function), the inner function can access the outer function’s variables as enclosing scope. To modify them, use nonlocal:
def outer():
message = "Hello from outer"
def inner():
nonlocal message
message = "Modified by inner"
print("Inner says:", message)
inner()
print("Outer says:", message)
outer()
Expected output:
Inner says: Modified by inner
Outer says: Modified by inner
This is an advanced pattern. You will encounter it when learning about closures and decorators — but
nonlocalfollows exactly the same logic asglobal, just one level in.
4.9 Built-in Scope
Python has many built-in names that are always available — like print, len, range, type, int, str, list. These live in the built-in scope, the outermost layer:
print(len("Hello")) # 'len' and 'print' are built-in
print(type(42)) # 'type' is built-in
Expected output:
5
<class 'int'>
⚠️ Never shadow built-in names! Don’t create variables called
list,input,id, etc. — this overwrites the built-in and causes confusing errors.
# ❌ WRONG – never do this
list = [1, 2, 3] # You've now overwritten the built-in 'list'
print(list([1,2,3])) # TypeError – list is now just a list object, not a function
# ✅ CORRECT – use a descriptive name
my_list = [1, 2, 3]
Guided Practice Exercises
Exercise 1 – Grading Function
Objective: Write a function that takes a student’s score and returns a letter grade.
Scenario: A teacher needs a quick tool to convert any numeric score to a grade.
Steps:
- Define a function called
get_gradethat accepts one parameter:score - Inside, use
if / elif / elseto determine the grade:- 90–100 →
"A" - 80–89 →
"B" - 70–79 →
"C" - 60–69 →
"D" - Below 60 →
"F"
- 90–100 →
- Return the grade letter
- Call the function with at least five different scores and print each result
Hint: Use return to send the grade back, then print() outside the function.
Solution:
def get_grade(score):
if score >= 90:
return "A"
elif score >= 80:
return "B"
elif score >= 70:
return "C"
elif score >= 60:
return "D"
else:
return "F"
scores = [95, 83, 72, 61, 45, 100, 59]
for s in scores:
print("Score:", s, "→ Grade:", get_grade(s))
Expected output:
Score: 95 → Grade: A
Score: 83 → Grade: B
Score: 72 → Grade: C
Score: 61 → Grade: D
Score: 45 → Grade: F
Score: 100 → Grade: A
Score: 59 → Grade: F
Self-check questions:
- What grade does
get_grade(60)return? Why? - What happens if you pass
get_grade(105)? What should you do to handle that?
Exercise 2 – Shopping Bill with *args
Objective: Use *args to calculate a total from any number of prices.
Scenario: A checkout system needs to add up a variable number of item prices.
def calculate_bill(customer_name, *prices):
total = sum(prices)
print("Customer: " + customer_name)
print("Items: " + str(len(prices)))
print("Total: £" + str(round(total, 2)))
print()
calculate_bill("Alice", 4.99, 1.49, 7.25)
calculate_bill("Bob", 12.99, 3.49, 8.00, 2.50, 1.99)
calculate_bill("Carol", 35.00)
Expected output:
Customer: Alice
Items: 3
Total: £13.73
Customer: Bob
Items: 5
Total: £28.97
Customer: Carol
Items: 1
Total: £35.0
What-if challenge: What happens if you call calculate_bill("Dave") with no prices? Try it. Is the result sensible? How would you handle an empty bill?
Exercise 3 – User Profile with **kwargs
Objective: Use **kwargs to build flexible user profiles.
def create_profile(username, **attributes):
print("=== Profile: " + username + " ===")
for key, value in attributes.items():
print(" " + key.replace("_", " ").title() + ": " + str(value))
print()
create_profile("coder99", age=22, city="Berlin", language="Python", years_experience=3)
create_profile("data_queen", age=30, field="Data Science", tools=["pandas", "numpy", "sklearn"])
Expected output:
=== Profile: coder99 ===
Age: 22
City: Berlin
Language: Python
Years Experience: 3
=== Profile: data_queen ===
Age: 30
Field: Data Science
Tools: ['pandas', 'numpy', 'sklearn']
Exercise 4 – Scope Experiment
Objective: Understand what happens when local and global variables share a name.
x = "I am global"
def test_local():
x = "I am local"
print("Inside function:", x)
test_local()
print("Outside function:", x)
Expected output:
Inside function: I am local
Outside function: I am global
Questions:
- Did the function change the global
x? Why not? - What would you add to the function to actually change the global
x?
Mini Project – Student Report Generator
This project combines functions, arguments, *args, **kwargs, and scope into one realistic application.
Project Goal
Build a Student Report Generator that:
- Accepts student data (name, scores for multiple subjects, personal notes)
- Calculates the average score
- Assigns a letter grade
- Prints a formatted report
Stage 1 – Helper Functions (Setup)
def calculate_average(*scores):
"""Calculate the average of any number of scores."""
if len(scores) == 0:
return 0
return round(sum(scores) / len(scores), 1)
def get_grade(average):
"""Convert an average score to a letter grade."""
if average >= 90:
return "A – Excellent"
elif average >= 80:
return "B – Good"
elif average >= 70:
return "C – Satisfactory"
elif average >= 60:
return "D – Needs Improvement"
else:
return "F – Unsatisfactory"
# Test Stage 1
avg = calculate_average(85, 90, 78, 92)
print("Average:", avg)
print("Grade:", get_grade(avg))
Expected milestone output:
Average: 86.2
Grade: B – Good
Stage 2 – Report Builder (Core Logic)
def generate_report(student_name, *scores, **subject_scores):
"""
Generate a full student report.
student_name : the student's name (str)
*scores : optional raw scores as positional args
**subject_scores : subject name = score pairs as keyword args
"""
print("=" * 40)
print("STUDENT REPORT")
print("=" * 40)
print("Name: " + student_name)
print()
# Show subject-by-subject breakdown if provided
if subject_scores:
print("Subject Scores:")
all_scores = []
for subject, score in subject_scores.items():
print(" " + subject.capitalize() + ": " + str(score) + "%")
all_scores.append(score)
avg = calculate_average(*all_scores)
# Fall back to raw positional scores
elif scores:
print("Scores: " + str(scores))
avg = calculate_average(*scores)
else:
print("No scores provided.")
avg = 0
print()
print("Average Score: " + str(avg) + "%")
print("Final Grade: " + get_grade(avg))
print("=" * 40)
print()
# Test Stage 2
generate_report("Emma Clarke",
maths=88, science=92, english=75, history=84, art=97)
generate_report("Liam Patel",
maths=65, science=70, english=60, history=55, art=72)
Expected milestone output:
========================================
STUDENT REPORT
========================================
Name: Emma Clarke
Subject Scores:
Maths: 88%
Science: 92%
English: 75%
History: 84%
Art: 97%
Average Score: 87.2%
Final Grade: B – Good
========================================
========================================
STUDENT REPORT
========================================
Name: Liam Patel
Subject Scores:
Maths: 65%
Science: 70%
English: 60%
History: 55%
Art: 72%
Average Score: 64.4%
Final Grade: D – Needs Improvement
========================================
Stage 3 – Batch Reporting (Final Output)
def print_class_summary(class_name, *student_reports):
"""Print a summary table for all students."""
print()
print("CLASS SUMMARY: " + class_name)
print("-" * 40)
for student in student_reports:
name, avg = student
print(" {:<20} {:.1f}% {}".format(name, avg, get_grade(avg)[:1]))
print("-" * 40)
print()
# Full batch run
students = [
("Emma Clarke", 88, 92, 75, 84, 97),
("Liam Patel", 65, 70, 60, 55, 72),
("Sofia Nguyen", 95, 98, 91, 93, 96),
("Marcus Webb", 72, 68, 80, 74, 78),
]
summaries = []
for name, *scores in students:
avg = calculate_average(*scores)
summaries.append((name, avg))
print_class_summary("Year 10 Science Group", *summaries)
Expected final output:
CLASS SUMMARY: Year 10 Science Group
----------------------------------------
Emma Clarke 87.2% B
Liam Patel 64.4% D
Sofia Nguyen 94.6% A
Marcus Webb 74.4% C
----------------------------------------
Reflection questions:
- Where is
calculate_averagedefined, and why can all functions call it? - What scope does
class_namebelong to insideprint_class_summary? - How would you modify the project to also calculate and print the class average?
Optional extensions:
- Add a
pass_failfield based on whether the average is ≥ 60 - Sort students from highest to lowest average before printing
- Allow the teacher to add a custom comment per student using
**kwargs
Common Beginner Mistakes — Full Summary
| Mistake | Example | Fix |
|---|---|---|
| Defining but not calling a function | def greet(): print("Hi") with no call |
Add greet() after the definition |
| Wrong indentation | def f(): then print(...) at column 0 |
Indent the body with 4 spaces |
| Passing wrong number of arguments | def add(a, b): ... called as add(1) |
Match the number of arguments to parameters |
Confusing print with return |
Function prints but you try to store the result | Use return for values you need to use later |
| Wrong order: **kwargs before *args | def f(**kw, *ar) |
Must be def f(*ar, **kw) |
Modifying a global without global keyword |
count = 0 → def f(): count += 1 |
Add global count inside the function |
| Shadowing built-in names | list = [1,2,3] |
Use descriptive names like my_list |
| Treating *args as a list | args.append(x) |
args is a tuple; convert with list(args) first |
| Default parameter before non-default | def f(x=1, y) |
Non-defaults must come first: def f(y, x=1) |
Reflection Questions
- What is the difference between a parameter and an argument? Give your own example.
- When would you choose
*argsover a regular list parameter? - When would
**kwargsbe more useful than a regular dictionary parameter? - Why is it generally better to use
returnthanglobal? - A local variable has the same name as a global variable. Which one does the function use? Why?
- What does LEGB stand for, and in what order does Python search these scopes?
- What error do you get if you try to use a local variable outside its function?
- Why is it a bad idea to name your variable
listorprint?
Completion Checklist
Before moving on, confirm you can do all of the following:
- Define and call a function using
defand() - Pass one or more arguments to a function
- Use both positional and keyword arguments
- Set default parameter values
- Use
returnto send a value back from a function - Use
*argsto accept a variable number of positional arguments - Use
**kwargsto accept a variable number of keyword arguments - Combine regular parameters,
*args, and**kwargsin the correct order - Explain the difference between local and global scope
- Use the
globalkeyword correctly (and know when NOT to) - Explain the LEGB rule
- Avoid shadowing Python built-in names
Lesson Summary
| Concept | Key Point | Keyword |
|---|---|---|
| Function definition | Write once, run many times | def |
| Calling a function | Execute the function by name + () |
function_name() |
| Parameters | Named slots in the function definition | def f(param): |
| Arguments | Values passed when calling | f(value) |
| Positional args | Matched by order | f(1, 2) |
| Keyword args | Matched by name | f(x=1, y=2) |
| Default values | Used when no argument provided | def f(x=5): |
| Return value | Send a result back to the caller | return |
| *args | Collect extra positional args into a tuple | *args |
| **kwargs | Collect extra keyword args into a dict | **kwargs |
| Local scope | Variable exists only inside its function | — |
| Global scope | Variable exists at the top level | — |
global keyword |
Modify a global variable from inside a function | global x |
| LEGB rule | Python’s scope search order | Local → Enclosing → Global → Built-in |
✅ You have completed Lesson 15. You can now define reusable functions, pass any amount of data into them flexibly, and fully understand where your variables live in a Python program. These skills are the foundation of every real Python project — from data analysis scripts to web applications.