Function contracts
- Lesson 7
- 40 min
- Module 3: Functions and program structure
Lesson 7 of 18 · Python for Practical AI EngineeringFunction contracts
1. Computer and environment basics
2. Values and control flow
3. Functions and program structure
4. Files, data and external services
5. Debugging and reliability
6. From notebook to maintainable project
What you will be able to do
Split count_tickets.py into small functions, each with one job and a clear contract: what goes in, what comes out, and what it does not change. Then you can check each part alone, and use it again in other programs.
NotebookDo this lesson in a notebook
The notebook has every example, task and check of this lesson, with the instructions, and the same count_tickets.py code. It needs no install and no local project: only Python. Read the explanations here, and run the code there.
Colab and Kaggle open your own copy. They need a free Google or Kaggle account. Stuck? See the solution notebook.
Before you startWhat to know and to have ready
What you need to know
- A loop that builds a result, and counting with
.get(key, 0) + 1, from Decisions and repetition. - The check for empty input, from Decisions and repetition: Your turn.
- Two names for one list, from Collections: Two names, one list. The common mistake at the end uses it.
What you need on your computer (only for the local route; the notebook and the boxes on this page need none of it)
count_tickets.pyin~/projects/ticket-cleaner. It filters the six tickets by status, counts them by category, and handles empty input. You made it in Decisions and repetition: Guided practice, and added the empty-input check and the average in Your turn: no matching tickets. If it is missing or different, do those two sections again. The answer is under Show one answer.- The project’s environment
.venv, active in your terminal (the prompt starts with(.venv)). You made it in Isolate dependencies: Guided practice. If it is missing or broken, delete it and make it again from the lock file, as in Make the environment again from nothing.
Tested with Python 3.14 (terminal) and Pyodide 314.0 (browser). The notebook was tested in Jupyter.
The problem with one long script
Section titled “The problem with one long script”At the end of Module 2, count_tickets.py does everything from top to bottom: filter, count, average and print. It works, but three things are hard:
- To check one part, you must run all of it, and read the printed output.
- To use one part again, for example the count in a different report, you must copy the lines.
- To read it, you must follow every variable from the top, because any line can change any variable.
A function is a named block of code that you can run many times. It takes input values, does one job, and gives back a result. You already use functions that Python provides: len(), print() and round(). Now you write your own.
Anatomy of a function
Section titled “Anatomy of a function”def count_by_category(tickets): """Return the number of tickets in each category.""" counts = {} for ticket in tickets: category = ticket["category"] counts[category] = counts.get(category, 0) + 1 return counts| Part | Meaning |
|---|---|
def |
Starts a function definition. Python remembers the function. It does not run the body yet. |
count_by_category |
The name. Use a verb that says what the function does. |
(tickets) |
The parameter: a name for the value that the caller gives. |
"""Return ...""" |
The docstring: one sentence that says what the function gives back. |
| The indented body | The work. It runs each time that someone calls the function. |
return counts |
Ends the function, and gives the value back to the caller. |
To call the function, write its name with a value in brackets. The value is the argument. Python gives the parameter tickets the argument’s value, runs the body, and replaces the call with the returned value:
category_counts = count_by_category(tickets)The contract
Section titled “The contract”A function’s contract is the promise that it makes to the code that calls it. Write it in plain words before you write the body:
| Question | count_by_category |
|---|---|
| What goes in? | A list of ticket dictionaries. Each one has a "category" key. |
| What comes out? | A dictionary: category → number of tickets. Empty input gives {}. |
| What does it not do? | It does not change the list, and it does not print. |
The last row matters most. A function that only reads its input and returns a result is a pure function. For the same input, it always gives the same output, and it has no other effect. Pure functions are the easiest to check: give an input, compare the output. Keep the parts that have effects, such as print or writing a file, in a few places at the edge of the program.
Return, not print
Section titled “Return, not print”Beginners often write a function that prints its result. That function can show a result, but no other code can use it:
def show_count(tickets): print(len(tickets))
result = show_count(tickets) # prints 6print(result) # prints NoneA function without a return statement gives back None. So result is None, and you cannot calculate with it. Return the value, and let the caller decide what to do with it: print it, save it, or check it.
Local variables
Section titled “Local variables”The variables in a function body, such as counts and category, are local: they exist only while the function runs. After the call, they are gone. Outside the function, print(counts) stops with NameError: name 'counts' is not defined.
This is a protection, not a limitation. A function cannot change a variable of its caller by accident, and two functions can both use the name counts without affecting each other. The only way to get a value out is return.
Run Python in your browser
Call a function, and use the result
The same function runs three times with different arguments. Add a line at the end: print(counts). Predict what happens, then run it.
Tab adds four spaces. Press Esc, then Tab, to leave the box. Ctrl+Enter runs the code.
Defaults
Section titled “Defaults”A parameter can have a default value. The caller can leave out that argument, and the function uses the default:
def filter_by_status(tickets, status="open"): """Return the tickets that have the given status.""" selected = [] for ticket in tickets: if ticket["status"] == status: selected.append(ticket) return selected
filter_by_status(tickets) # the open ticketsfilter_by_status(tickets, "closed") # the closed ticketsfilter_by_status(tickets, status="closed")The last call names the argument: status="closed". Named arguments make a call easier to read, especially when a function has more than two parameters. Parameters with a default come after the parameters without one.
Worked example: count_tickets.py as functions
Section titled “Worked example: count_tickets.py as functions”Here is the Module 2 script, rewritten as functions. Each function has one job. main() calls them in order, and it is the only function that prints.
TICKETS = [ {"id": "T-1001", "status": "open", "category": "billing", "priority": 2}, {"id": "T-1002", "status": "closed", "category": "login", "priority": 3}, {"id": "T-1003", "status": "open", "category": "login", "priority": 1}, {"id": "T-1004", "status": "pending", "category": "billing", "priority": 2}, {"id": "T-1005", "status": "open", "category": "billing", "priority": 3}, {"id": "T-1006", "status": "closed", "category": "shipping", "priority": 1},]
def filter_by_status(tickets, status="open"): """Return the tickets that have the given status.""" selected = [] for ticket in tickets: if ticket["status"] == status: selected.append(ticket) return selected
def count_by_category(tickets): """Return the number of tickets in each category, sorted by category.""" counts = {} for ticket in tickets: category = ticket["category"] counts[category] = counts.get(category, 0) + 1 return dict(sorted(counts.items()))
def average_priority(tickets): """Return the average priority, or None when there are no tickets.""" if not tickets: return None total = 0 for ticket in tickets: total = total + ticket["priority"] return round(total / len(tickets), 1)
def main(): status = "open" selected = filter_by_status(TICKETS, status) print(f"Tickets with status '{status}': {len(selected)}") if not selected: print("No tickets to count.") return for category, count in count_by_category(selected).items(): print(f" {category}: {count}") print(f"Average priority: {average_priority(selected)}")
main()Three details:
TICKETSis in capital letters. That is the Python way to say “a constant: this value does not change while the program runs”.average_priorityreturnsNonefor empty input, instead of stopping withZeroDivisionError. The docstring says so, so the caller knows to check.returnwith no value inmain()ends the function early. Here it is an early exit for empty input, likebreakin a loop.
The last line, main(), calls main. Without it, the script defines four functions and then ends without doing anything.
Guided practice: refactor your script
Section titled “Guided practice: refactor your script”Refactor means “change the structure of code without changing what it does”. The output must be the same before and after.
-
Run your current
count_tickets.py. Copy its output into a comment at the end of the file, or into a text file. This is the output to compare with. -
Replace the file’s contents with the worked example. Keep your own ticket data if you changed it.
-
Run it:
python count_tickets.py.Output Tickets with status 'open': 3billing: 2login: 1Average priority: 2.0 -
Compare it with the output from step 1. It must be the same. If it is different, find which function gives a different value. Print its result in
main(). -
Change
statusinmain()to"resolved", and run it again. The output is the count0andNo tickets to count.
Check each function alone
Section titled “Check each function alone”Each function is pure, so a few lines can check it. Call it with a small input that you can calculate in your head, and compare the result. assert stops the program with an error if a condition is False, and does nothing if it is True.
Run Python in your browser
Check the functions
Each assert is one small fact about the contract. Add the missing check at the end. Then break the function on purpose: change return None to return 0, and run it again. Which line fails?
Tab adds four spaces. Press Esc, then Tab, to leave the box. Ctrl+Enter runs the code.
In Module 5, you move checks like these into test files, and a tool called pytest runs them for you.
Your turn: a new function
Section titled “Your turn: a new function”Add a function urgent_ids(tickets) to count_tickets.py. Its contract:
| Question | Answer |
|---|---|
| What goes in? | A list of ticket dictionaries. |
| What comes out? | A list of the IDs of the tickets with priority 1, in the same order. Empty input gives []. |
| What does it not do? | It does not change the list, and it does not print. |
Call it in main() for the selected tickets, and print the result. With the six tickets and status "open", the new line is Urgent: ['T-1003'].
Show one answer
def urgent_ids(tickets): """Return the IDs of the tickets with priority 1, in order.""" return [ticket["id"] for ticket in tickets if ticket["priority"] == 1]In main(), after the average: print(f"Urgent: {urgent_ids(selected)}").
Common mistake
A default list that remembers
Symptom: a function gives a different result each time that you call it with the same arguments:
def add_problem(problem, problems=[]): problems.append(problem) return problems
print(add_problem("missing id")) # ['missing id']print(add_problem("unknown status")) # ['missing id', 'unknown status']The second call should return only ['unknown status'].
Cause: Python makes the default value once, when it runs the def line, not on each call. A list can change, so every call without the argument gets the same list, and each append adds to it. This is the shared-reference problem from Collections, in a less visible place.
Fix: use None as the default, and make a new list in the body:
def add_problem(problem, problems=None): if problems is None: problems = [] problems.append(problem) return problemsUse only values that cannot change as defaults: numbers, strings, True, False and None. Never a list, a dictionary or a set.
Check your understanding
A function ends with print(total) and has no return. What does result = the_function(...) give, and why is that a problem?
Show the answer
result is None, because a function without return returns None. The total is shown on the screen, but the program cannot use it: it cannot compare it, save it, or check it with assert. Return the value, and print it where you call the function.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Module check, part 1: explain the contract of filter_by_status in plain words, then prove two parts of it with assert.
Show the answer
In: a list of ticket dictionaries, and a status (default "open"). Out: a new list with only the tickets that have that status, in the same order. Empty input gives an empty list. It does not change the input list and does not print. Two checks: assert filter_by_status([]) == [] and, for a list with one open and one closed ticket, assert len(filter_by_status(two, "closed")) == 1.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- A function has parameters, a body and a
return. Calling it gives the parameters the argument values, and the call becomes the returned value. - Write the contract first: what goes in, what comes out, and what it does not change.
- A pure function only reads its input and returns a result. It is the easiest kind to check with
assert. - Variables in a function are local. Values leave a function only through
return. - Never use a list, dictionary or set as a default value. Use
None, then make a new one in the body.
Next, you move the functions out of the script into a module, so other programs can import them.
Next: Modules and imports