Skip to content
VLSI Mentor

Python Part I · Module 11 · Functions

Decomposing a Script Into Reusable, Testable Functions

Every rule in this module has been a small one: pass what you need, return rather than print, do not reach for shared state, say what the function promises. This chapter is what they add up to. A regression checker has two kinds of work in it — deciding things from data, and talking to the outside world — and almost every quality a script can have depends on keeping those two apart. Mixed together, nothing can be checked without a simulator, a filesystem and twenty minutes. Separated, the part that holds all the actual decisions can be verified in milliseconds with four lines of ordinary code, and this chapter ends by doing exactly that.

Intermediate17 min readPythonPart IFundamentalsFunctionsDesign

Part I · Module 11 · Chapter 11.15 · Decomposing a Script Into Reusable, Testable Functions

1. The Script Everyone Writes First

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
logs = {
    "axi_smoke": ["UVM_INFO @1: ok"],
    "axi_burst": ["UVM_ERROR @1: crc"],
    "reset_seq": ["UVM_FATAL @1: hang"],
}

for name in sorted(logs):
    errors = 0
    fatals = 0
    for line in logs[name]:
        if "UVM_FATAL" in line:
            fatals += 1
        elif "UVM_ERROR" in line:
            errors += 1
    if fatals > 0:
        print(name, "FATAL")
    elif errors > 0:
        print(name, "FAIL")
    else:
        print(name, "PASS")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
axi_burst FAIL
axi_smoke PASS
reset_seq FATAL

It works. In a real script the dictionary would be replaced by reading files and the print by writing a report, and at that point the program has a property worth noticing: there is no way to check that the classification rule is right without running the whole thing. The rule is welded to the loop, the loop to the data source, the data source to the filesystem.

2. Two Kinds of Work

Look at what that loop body actually does. Two different things, interleaved:

  • Deciding. Given some lines, work out a verdict. Needs nothing but the lines.
  • Touching the world. Get the lines from somewhere; put the answer somewhere.

The second kind is where the simulator, the filesystem, the queue and the terminal live. The first kind is pure arithmetic over values you were handed.

The useful names:

  • A pure function's result depends only on its arguments, and it changes nothing outside itself. Call it twice with the same input and you get the same answer twice.
  • An impure function reads or affects something beyond its arguments — a file, a tool, the terminal, a shared name.

Chapter 11.4 pushed toward pure functions without naming them: everything the result depends on should arrive through the parameters. Chapter 11.8 pushed the other half: return the result instead of printing it. Chapter 11.10 pushed the third: do not reach for shared state. A function that obeys all three is pure.

Three blocks in a row joined by two arrows. On the left, an impure input edge that reads a log or queries a tool and hands over lines. In the middle, a pure core that takes lines and returns a verdict, touching nothing outside itself. On the right, an impure output edge that prints a report or writes a file. An arrow labelled lines goes from the input edge to the core, and an arrow labelled result goes from the core to the output edge.Impure edge: inputreads a log, queries a toolPure corelines in, verdict outImpure edge: outputprints, writes a reportlinesresult12
Figure 1 — the shape to aim for. The parts that touch the outside world are pushed to the edges and kept thin; everything that decides anything sits in the middle, depending only on the values handed to it. The middle is the part that can be checked without a simulator, reused from another script, and reasoned about on its own.

3. The Pure Core

Pull the deciding out, unchanged:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def classify(log_lines):
    """Return PASS, FAIL or FATAL for one log's lines."""
    errors = 0
    fatals = 0
    for line in log_lines:
        if "UVM_FATAL" in line:
            fatals += 1
        elif "UVM_ERROR" in line:
            errors += 1
    if fatals > 0:
        return "FATAL"
    if errors > 0:
        return "FAIL"
    return "PASS"


print(classify(["UVM_INFO @1: ok"]))
print(classify(["UVM_ERROR @1: crc"]))
print(classify(["UVM_ERROR @1: crc", "UVM_FATAL @2: hang"]))
print(classify([]))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
PASS
FAIL
FATAL
PASS

Four calls, no files, no simulator, no terminal involvement beyond the printing this script chose to do. classify takes lines and returns a string, and that is the whole of its relationship with the world.

A second pure function for the counting:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def summarise(verdicts):
    """Return a dict of verdict to how many tests got it."""
    counts = {}
    for verdict in verdicts:
        counts[verdict] = counts.get(verdict, 0) + 1
    return counts


print(summarise(["PASS", "FAIL", "PASS"]))
print(summarise([]))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
{'PASS': 2, 'FAIL': 1}
{}

Chapter 10.10's dictionary accumulator with Chapter 8.3's .get(), wrapped in a name.

4. The Impure Edges

Two functions whose entire job is to touch something:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def fetch_log(name):
    """Return the lines of one test's log. STAND-IN: see the note below."""
    recorded = {
        "axi_smoke": ["UVM_INFO @1: ok"],
        "axi_burst": ["UVM_ERROR @1: crc"],
        "reset_seq": ["UVM_FATAL @1: hang"],
    }
    return recorded[name]


def report(counts):
    """Print one line per verdict, in alphabetical order."""
    for verdict in sorted(counts):
        print(verdict, counts[verdict])


def summarise(verdicts):
    """Return a dict of verdict to how many tests got it."""
    counts = {}
    for verdict in verdicts:
        counts[verdict] = counts.get(verdict, 0) + 1
    return counts


report(summarise(["PASS", "FAIL", "PASS"]))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
FAIL 1
PASS 2

report is the one function allowed to print, for the reason Chapter 11.8 Section 7 gave: it is the edge of the program, and showing a person the answer is its job.

5. The Script That Remains

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def classify(log_lines):
    """Return PASS, FAIL or FATAL for one log's lines."""
    errors = 0
    fatals = 0
    for line in log_lines:
        if "UVM_FATAL" in line:
            fatals += 1
        elif "UVM_ERROR" in line:
            errors += 1
    if fatals > 0:
        return "FATAL"
    if errors > 0:
        return "FAIL"
    return "PASS"


def summarise(verdicts):
    """Return a dict of verdict to how many tests got it."""
    counts = {}
    for verdict in verdicts:
        counts[verdict] = counts.get(verdict, 0) + 1
    return counts


def fetch_log(name):
    """Return the lines of one test's log."""
    recorded = {
        "axi_smoke": ["UVM_INFO @1: ok"],
        "axi_burst": ["UVM_ERROR @1: crc"],
        "reset_seq": ["UVM_FATAL @1: hang"],
    }
    return recorded[name]


def report(counts):
    """Print one line per verdict, in alphabetical order."""
    for verdict in sorted(counts):
        print(verdict, counts[verdict])


names = ["axi_smoke", "axi_burst", "reset_seq"]

verdicts = []
for name in names:
    verdicts.append(classify(fetch_log(name)))

report(summarise(verdicts))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
FAIL 1
FATAL 1
PASS 1

The bottom six lines are the whole program: get the names, classify each log, summarise, report. That is Chapter 11.2's convention — definitions at the top, the work at the bottom — and the bottom now reads as a sentence describing what the script does.

6. The Payoff: Checking It Without a Simulator

Here is what the boundary was for.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def classify(log_lines):
    """Return PASS, FAIL or FATAL for one log's lines."""
    errors = 0
    fatals = 0
    for line in log_lines:
        if "UVM_FATAL" in line:
            fatals += 1
        elif "UVM_ERROR" in line:
            errors += 1
    if fatals > 0:
        return "FATAL"
    if errors > 0:
        return "FAIL"
    return "PASS"


checks = [
    ([], "PASS"),
    (["UVM_INFO @1: ok"], "PASS"),
    (["UVM_ERROR @1: a"], "FAIL"),
    (["UVM_ERROR @1: a", "UVM_FATAL @2: b"], "FATAL"),
]

failures = 0
for lines, expected in checks:
    actual = classify(lines)
    if actual != expected:
        failures += 1
        print("WRONG:", lines, "->", actual, "expected", expected)

print(len(checks) - failures, "of", len(checks), "checks passed")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
4 of 4 checks passed

Four cases — including the empty log and the one where a fatal must outrank an error — verified in milliseconds, with no simulator, no filesystem and no waiting. The checks are a list of tuples, a loop, unpacking and a counter: Modules 6, 10 and 11, nothing more.

And when a rule changes — say UVM_WARNING should produce a new verdict — the change is one function, the checks say immediately whether it still handles the old cases, and nothing else in the script is touched.

7. Keep the Core Big and the Edges Thin

The practical rule that follows: push the parts that touch the world outwards, and keep them as small as you can.

fetch_log should get the lines and nothing else — no filtering, no counting, no deciding. The moment it starts classifying, that classification can only be exercised by going through the filesystem again.

The same for report: format and print, decide nothing. If report works out which verdict is worst, that logic has just left the testable half.

FunctionKindTouchesCan be checked cheaply
classifypurenothingyes
summarisepurenothingyes
fetch_logimpurea file or a toolno — needs the real thing
reportimpurethe terminalnot usefully

Two of the four can be verified in milliseconds, and between them they hold every decision the script makes. The two that cannot be checked do no deciding — which means the untestable part of the program is also the part where there is least to get wrong.

8. Four Questions for Any Function

Each traces back to a chapter in this module:

  1. Does everything it needs arrive as an argument? If not, it has a hidden input and the same call can give different answers. (11.4)
  2. Does it return its result rather than printing it? If not, nothing can use it. (11.8)
  3. Does it change anything outside itself? A shared name, or a caller's list it was not asked to modify. (11.7, 11.10)
  4. Does its name and docstring say what it promises? Including any mutation. (11.11)

A function answering yes, yes, no, yes is pure and testable. One that cannot — because its job is to touch something — belongs at an edge, and should do nothing but touch that thing.

9. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def double(n):
    return n * 2

TOTAL = 0

def accumulate(n):
    global TOTAL
    TOTAL += n

def show(n):
    print("value:", n)

print("A:", double(3), double(3))
accumulate(5)
accumulate(5)
print("B:", TOTAL)
print("C:", show(1))

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: 6 6
B: 10
value: 1
C: None

Classify each: double is pure — same input, same answer, twice. accumulate is impure and order-dependent; calling it twice gives a different result from calling it once, so no check of it can be written without first controlling TOTAL. show is impure and returns None, so it belongs at an edge and its output cannot be used by anything.

Note the last two lines. One statement — print("C:", show(1)) — produced both of them. show(1) was evaluated first and printed value: 1 from inside the function; then the outer print showed what it handed back, C: None. Two lines from one statement, with the label attached to the wrong one, is Chapter 11.8's signature for a function that prints instead of returning.

10. Exercises

Exercise 1 — classify them. For each, say pure or impure and why: (a) returns the longest name in a list (b) reads a file and returns its lines (c) returns a dict of severity counts (d) prints a summary table (e) appends to a module-level list (f) returns a job id from a test name and a seed.

Exercise 2 — split one. Take this function and separate it into a pure part and an impure part:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def check(name):
    lines = ["UVM_ERROR @1: a"]
    n = 0
    for line in lines:
        if "UVM_ERROR" in line:
            n += 1
    print(name, "has", n, "errors")

Exercise 3 — write the checks. For your pure function from Exercise 2, write four checks like Section 6's, including an empty input. Print how many passed.

Exercise 4 — change a rule. Add UVM_WARNING handling to classify so a log with warnings and nothing worse returns "WARN". Run Section 6's checks and confirm the four old cases still pass, then add a fifth.

Exercise 5 — find the leak. This edge function is doing too much. Say which part belongs in the pure core and rewrite both.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
def fetch_and_count(name):
    lines = ["UVM_ERROR @1: a", "UVM_INFO @2: b"]
    errors = 0
    for line in lines:
        if "UVM_ERROR" in line:
            errors += 1
    return errors

Exercise 6 — the four questions. Apply Section 8's questions to three functions you wrote earlier in this module. For any answer that is wrong, say what you would change.

Exercise 7 — read the bottom. Write the bottom-of-file section for a script that gets a test list, runs each, classifies, summarises and reports. Use only call lines; define nothing. Say whether it reads as a description of the program.

Exercise 8 — argue the cost. In four sentences, explain to a colleague why splitting a 30-line working script into five functions is worth doing, without using the words "clean" or "best practice".

11. Summary

  • A script does two kinds of work: deciding things from data, and touching the outside world.
  • A pure function's result depends only on its arguments and it changes nothing outside itself.
  • An impure function reads or affects something else — a file, a tool, the terminal, a shared name.
  • Push the impure parts to the edges and keep them thin. Everything that decides anything belongs in the middle.
  • The pure core can be checked in milliseconds with a list of cases and a loop — no simulator, no filesystem, no waiting.
  • Those checks are impossible against a tangled script, because there is no function to call.
  • Edge functions should do nothing but touch their thing. A fetch that classifies has moved logic out of the testable half.
  • The untestable part should also be the part with least to get wrong.
  • Four questions for any function: does everything arrive as an argument, does it return rather than print, does it change anything outside itself, does its name and docstring say what it promises.
  • When the real tool replaces the stand-in, the core does not change — it never knew where its input came from.

That closes Module 11. You can define and call functions, supply arguments positionally and by name, accept any number of them, pass collections understanding what the function receives, return one value or several, keep names local, document a contract, annotate types, recurse where recursion fits, write a sort key inline, and split a script along the boundary that decides what can be checked.

Every function here was written with an explicit loop. The next module is about the cases where that loop is three lines of ceremony around one idea — and about the point past which the compact form stops being an improvement.

Where this fits

Part of the Python curriculum.