Skip to main content
Learning Center
Python Programming

Module 4: Functions and Program Structure

Decomposing the Shortlist Script

Turning a working script into named functions with contracts and type hints, keeping behaviour identical, and reading a call chain from the top down.

Lesson 15 of 46 in the recommended order · About 30 min (estimate)

On this page

Outcome

By the end of this lesson you can take a working script apart into named functions with stated contracts, prove the behaviour did not change, and read the resulting call chain from the entry point down.

Why it matters

The shortlist script from Module 3 works. It is also a single block in which the filtering rule, the loop, and the report are tangled together, so none of them can be changed, tested, or reused without touching the others.

Refactoring is changing structure while keeping behaviour identical. It is the most common activity in real software work and the most common thing to get wrong, because "I improved it" and "I broke it" look the same until something is checked. The discipline is to change shape only, and to run the program before and after.

Well-shaped functions are also what makes AI assistance safe. "Add a closing-date rule to qualifies" is a bounded request with a reviewable diff. "Add a closing-date rule to the script" is an invitation to rewrite everything.

Concept

Look for the seams. In almost any small data script there are three, and they usually appear in this order:

  1. A decision about one item. "Does this record qualify?" Takes one record, returns a boolean or a verdict, and does not mutate state or print.
  2. A traversal over many items. "Which records qualify?" Takes a collection and the decision, returns a collection.
  3. A presentation of the result. "Show me the shortlist." Takes the result, produces text or output.

Split along those seams and each part gains a name, a contract, and a test. Do not split further just to make functions smaller; a function that exists only because someone counted lines makes the call chain longer without making anything clearer.

Type hints annotate the contract: def qualifies(record: dict, minimum: int = 100000) -> bool:. Python does not enforce them at runtime; they are for readers and for tools. Their value is that a wrong assumption becomes visible in the signature rather than three calls away.

Keep constants out of the middle of the logic. A threshold written as a literal inside a loop is a policy decision hidden in an implementation detail. As a defaulted parameter, or a named module-level constant, it can be seen, documented, and overridden by a test.

Finally, protect the refactor. Run the original, keep its output, run the new version, compare. Module 5 replaces that manual comparison with tests, which is the same idea automated.

Read the code

Before, as Module 3 left it:

batch = [
    {"notice_id": "GSA-2026-0731", "estimated_value": 310000, "set_aside": "Total Small Business"},
    {"notice_id": "SPE-2026-0412", "estimated_value": 505000, "set_aside": "8(a)"},
]

selected = []
for record in batch:
    if record["estimated_value"] >= 100000 and record.get("set_aside") == "Total Small Business":
        selected.append(record["notice_id"])

print(len(selected), "of", len(batch))
for notice_id in selected:
    print("-", notice_id)

After:

MINIMUM_VALUE = 100000
REQUIRED_SET_ASIDE = "Total Small Business"


def qualifies(record: dict, minimum: int = MINIMUM_VALUE) -> bool:
    """True when the record meets the value floor and the required
    set-aside. A record with no published set-aside does not qualify."""
    big_enough = record["estimated_value"] >= minimum
    category_matches = record.get("set_aside") == REQUIRED_SET_ASIDE
    return big_enough and category_matches


def shortlist(batch: list, minimum: int = MINIMUM_VALUE) -> list:
    """Return the records from batch that qualify. Does not print."""
    return [record for record in batch if qualifies(record, minimum)]


def render(selected: list, total: int) -> str:
    """Return the printable report for a shortlist. Does not print."""
    lines = [f"{len(selected)} of {total}"]
    for record in selected:
        lines.append(f"- {record['notice_id']}")
    return "\n".join(lines)


batch = [
    {"notice_id": "GSA-2026-0731", "estimated_value": 310000, "set_aside": "Total Small Business"},
    {"notice_id": "SPE-2026-0412", "estimated_value": 505000, "set_aside": "8(a)"},
]

print(render(shortlist(batch), len(batch)))

Read the call chain from the bottom:

  1. print(render(shortlist(batch), len(batch))) is the entry call that produces output. The earlier top-level statements also assign constants, define functions and build batch.
  2. shortlist(batch) walks the batch and asks qualifies about each record.
  3. qualifies answers about one record using its minimum argument and REQUIRED_SET_ASIDE. Its default minimum was captured from MINIMUM_VALUE when the function was defined.
  4. render turns the result into text, and the single print sends it to the screen.

In the refactored version, only the outer print produces output. The three functions return values without printing, which lets you test their results directly in the next module.

One deliberate change of shape: shortlist returns whole records rather than identifiers. Deciding what to keep is the caller's job, and a shortlist of identifiers cannot later be written to a CSV without going back to the batch.

Inspect the three function boundaries

Three responsibilities, three testable boundaries

At the entry call: shortlist finishes before render; print receives the returned report.

Within shortlist, qualifies decides once per record. Only the outer print writes the report to the screen.

Decide · qualifies

Takes
One record and a minimum value; default minimum is 100000.
Returns
A boolean: value meets the floor and set-aside matches.
Test boundary
Pass one record and inspect True or False.

The default minimum is captured at definition time; REQUIRED_SET_ASIDE is read when called. This worked boolean filter treats a missing set-aside as False.

Traverse · shortlist

Takes
The batch and a minimum value.
Calls
qualifies(record, minimum) once per record.
Returns
A new list of qualifying whole records, in batch order.
Test boundary
Inspect the returned records; no output is printed.

The new list contains the original record objects. It does not make independent copies of the records.

Present · render

Takes
Selected records and the original batch count.
Returns
A report string: count line followed by selected notice IDs.
Test boundary
Compare the returned text directly.

render does not print. The outer print adds the final newline when displaying its returned string.

The module sets up constants, function definitions and the batch before the entry call. The three contracts separate the rule, iteration and report format. Each boundary can be checked directly, and the refactor preserves the shown program's printed output.

Predict the output

Both programs print. Predict the "after" version's output, and say whether it matches the "before" version exactly.

Check your prediction

The before version prints:

1 of 2
- GSA-2026-0731

The after version prints the same two lines. print(len(selected), "of", len(batch)) joins its arguments with single spaces, and the f-string in render produces the identical text.

Matching output is the evidence that the refactor was a refactor. If the two had differed, the honest response would be to find out why before keeping the new version, not to decide the new output looks better.

Modify the code

Change the call to render(shortlist(batch, minimum=600000), len(batch)). Predict the output, then say which functions had to change to support it.

What changes, and why

It prints:

0 of 2

with no bullet lines, because no record reaches 600,000.

No function had to change. The threshold was already a parameter with a default, so a caller can override the policy without touching the filtering code. That is the practical payoff of pulling constants out to the signature: in the "before" version the same experiment meant editing the condition inside the loop, and editing logic to run an experiment is how experiments become permanent by accident.

Debug the bug

An assistant was asked to extract the filtering rule into a function. It produced this and said behaviour is unchanged.

selected = []


def qualifies(record):
    if record["estimated_value"] >= 100000:
        selected.append(record["notice_id"])
        return True
    return False


def shortlist(batch):
    for record in batch:
        qualifies(record)
    return selected


batch = [{"notice_id": "A-1", "estimated_value": 300000}]

print(shortlist(batch))
print(shortlist(batch))
What's actually wrong

It prints ['A-1'] and then ['A-1', 'A-1'].

qualifies is supposed to answer a question about one record. Instead it also appends to a list defined outside itself, so calling it changes the program's state. shortlist then returns that same shared list rather than a fresh result, so a second call accumulates on top of the first.

Three concrete consequences. Running the same query twice gives different answers. A test cannot run twice without resetting a global. And the name qualifies is now a lie: a reader who takes it at face value will not expect it to write anything.

The repair is the version in Read the Code: qualifies returns a boolean and touches nothing else, and shortlist builds and returns its own list. The general rule to carry into every review is that a function whose name asks a question should not change anything, and that a returned collection should be built by the call that returns it.

Try it yourself

The calling code and both contracts are written. Fill in the two function bodies so the report comes out right.

Loading this exercise…

Practical challenge (optional)

Optional: add a fourth function, explain(record), that returns the reason a record did not qualify, reusing the Module 2 verdict-and-reason pattern, and extend render to include the reason beside each rejected record in its returned report. Then check that qualifies still returns a plain boolean and still changes nothing. Keeping the decision and its explanation as two functions rather than one is a design choice the capstone revisits, and having formed a view now will make that discussion concrete.

Sign in to track your progress on this exercise.

AI collaboration

Checkpoint

  1. What has to be true about a change for it to count as a refactor?
  2. Why should qualifies return a boolean rather than append to a shortlist?
  3. What does a type hint do at runtime?
  4. Why is a threshold better as a defaulted parameter than as a literal inside a loop?
Answers
  1. Observable behaviour is identical before and after. Same inputs, same outputs, same side effects.
  2. Because a function named as a question should not change state. Returning a value without mutation or printing makes its result directly testable. Repeating the call gives the same result when its inputs and the configuration it reads are unchanged.
  3. Nothing. Python does not check hints while running; they are for readers and for tools such as type checkers and editors.
  4. It makes the policy visible in the signature, lets a caller or a test override it without editing logic, and keeps the filtering code free of decisions that are not its own.

Sign in to track your progress on this exercise.

Summary and next step

The assistant is now three small functions and one print: a decision, a traversal, and a report, each with a contract, none of them entangled. Module 5 takes advantage of that immediately, with tracebacks, exceptions, and the tests that stop a future change from quietly breaking any of it.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.