Python Part I · Module 16 · Object-Oriented Programming
From Loose Variables to One Structured Result
Thirteen chapters of object-oriented machinery have arrived without anyone asking whether a flow script needs it, and that question deserves an honest answer rather than an assumption. This chapter takes one entirely ordinary thing — the result of running one test — and follows it through four representations, every one of which is already available to you from Modules 6 to 8. Each is written, run, and then broken in the specific way it breaks, because the argument for a better structure is only as good as the failure it prevents.
Intermediate16 min readPythonPart IFundamentalsOOPData Modelling
Part I · Module 16 · Chapter 16.14 · From Loose Variables to One Structured Result
1. One Result, Four Shapes
A test run produces four facts. Here is the progression this chapter walks:
# name, seed, ok, runtime
# -> (name, seed, ok, runtime)
# -> {"name": ..., "seed": ..., "ok": ..., "runtime_s": ...}
# -> {"test": {...}, "timing": {...}}Every one of these is valid Python you already know. The question is not which is correct — all four work — but where each one stops scaling.
2. Separate Variables
def summarise(name, seed, ok, runtime_s):
status = "PASS" if ok else "FAIL"
return f"{name}/{seed} {status} {runtime_s}s"
print(summarise("smoke", 1234, True, 182.4))
print(summarise("cdc", 5678, False, 204.1))smoke/1234 PASS 182.4s
cdc/5678 FAIL 204.1sFine for one function. The problem appears the moment the result travels:
def summarise(name, seed, ok, runtime_s):
status = "PASS" if ok else "FAIL"
return f"{name}/{seed} {status} {runtime_s}s"
def load():
return "smoke", 1234, True, 182.4
name, seed, ok, runtime_s = load()
print("correct:", summarise(name, seed, ok, runtime_s))
print("swapped:", summarise(name, runtime_s, ok, seed))correct: smoke/1234 PASS 182.4s
swapped: smoke/182.4 PASS 1234sThe second call is wrong and raised nothing. seed and runtime_s are both
numbers, so swapping them produces a plausible sentence with the seed in the
runtime column.
Every function that handles a result now needs four parameters, in an order nobody enforces, and adding a fifth field means editing every signature.
3. A Tuple
Grouping them at least makes the result one value:
def load():
return ("smoke", 1234, True, 182.4)
def summarise(result):
status = "PASS" if result[2] else "FAIL"
return f"{result[0]}/{result[1]} {status} {result[3]}s"
r = load()
print("one value :", r)
print("summary :", summarise(r))
print("unpacked :", "name, seed, ok, runtime = r")
name, seed, ok, runtime = r
print(" ", name, seed, ok, runtime)one value : ('smoke', 1234, True, 182.4)
summary : smoke/1234 PASS 182.4s
unpacked : name, seed, ok, runtime = r
smoke 1234 True 182.4One value travels, and unpacking (Chapter 11.9) restores readable names. But
result[2] inside summarise means nothing to a reader, and the real failure
is what happens when the shape changes:
def load():
"""A 'status' field was inserted in the middle by a colleague."""
return ("smoke", 1234, "TIMEOUT", True, 182.4)
def summarise(result):
status = "PASS" if result[2] else "FAIL"
return f"{result[0]}/{result[1]} {status} {result[3]}s"
print(summarise(load()))
name, seed, ok, runtime = load()smoke/1234 PASS Trues
Traceback (most recent call last):
File "reorder.py", line 13, in <module>
name, seed, ok, runtime = load()
^^^^^^^^^^^^^^^^^^^^^^^
ValueError: too many values to unpack (expected 4)summarise printed garbage and did not raise. result[2] is now the string
"TIMEOUT", which is truthy, so it reported PASS for a timed-out test — and
result[3] is the boolean, printed as Trues.
The unpacking on the last line did raise, which is the only reason anybody would find out. A tuple's meaning is positional, so inserting a field silently reinterprets every index after it.
4. A Dictionary
Names instead of positions:
def load():
return {"name": "smoke", "seed": 1234, "ok": True, "runtime_s": 182.4}
def summarise(r):
status = "PASS" if r["ok"] else "FAIL"
return f"{r['name']}/{r['seed']} {status} {r['runtime_s']}s"
r = load()
print("summary:", summarise(r))
r["status"] = "TIMEOUT" # a new field, inserted
print("after adding a field:", summarise(r))
print("keys:", sorted(r))summary: smoke/1234 PASS 182.4s
after adding a field: smoke/1234 PASS 182.4s
keys: ['name', 'ok', 'runtime_s', 'seed', 'status']Adding a field broke nothing, which is the headline improvement. Each access names what it wants, so new fields are invisible to code that does not use them.
This is genuinely good, and a large amount of production Python stops here. The two things it cannot do:
def load():
return {"name": "smoke", "seed": 1234, "ok": True, "runtime_s": 182.4}
def summarise(r):
status = "PASS" if r["ok"] else "FAIL"
return f"{r['name']}/{r['seed']} {status} {r['runtime']}s"
print("about to summarise")
print(summarise(load()))about to summarise
Traceback (most recent call last):
File "typo.py", line 11, in <module>
print(summarise(load()))
~~~~~~~~~^^^^^^^^
File "typo.py", line 7, in summarise
return f"{r['name']}/{r['seed']} {status} {r['runtime']}s"
~^^^^^^^^^^^
KeyError: 'runtime'A typo is a runtime error, found only when that line executes — which for a reporting branch may be during the one failing nightly you needed it for.
And a missing field is the same error from the other direction:
def load_incomplete():
"""The parser could not determine a runtime, so it omitted the key."""
return {"name": "smoke", "seed": 1234, "ok": True}
def summarise(r):
status = "PASS" if r["ok"] else "FAIL"
return f"{r['name']}/{r['seed']} {status} {r['runtime_s']}s"
print(summarise(load_incomplete()))Traceback (most recent call last):
File "missing.py", line 11, in <module>
print(summarise(load_incomplete()))
~~~~~~~~~^^^^^^^^^^^^^^^^^^^
File "missing.py", line 8, in summarise
return f"{r['name']}/{r['seed']} {status} {r['runtime_s']}s"
~^^^^^^^^^^^^^
KeyError: 'runtime_s'The dictionary was constructed successfully with three of four fields. Nothing objected at creation; the gap surfaced one function later.
5. A Nested Dictionary
Results grow, and grouping related fields is the natural next move:
def load():
return {
"test": {"name": "smoke", "seed": 1234},
"outcome": {"ok": True},
"timing": {"runtime_s": 182.4, "queued_s": 12.0},
}
def summarise(r):
status = "PASS" if r["outcome"]["ok"] else "FAIL"
return f"{r['test']['name']}/{r['test']['seed']} {status} {r['timing']['runtime_s']}s"
print(summarise(load()))
print("top-level keys:", sorted(load()))smoke/1234 PASS 182.4s
top-level keys: ['outcome', 'test', 'timing']It works, and something has quietly gone wrong: there is now a format, and it is written down nowhere.
def load_old_format():
"""An older producer put runtime at the top level."""
return {
"test": {"name": "smoke", "seed": 1234},
"outcome": {"ok": True},
"runtime_s": 182.4,
}
def summarise(r):
status = "PASS" if r["outcome"]["ok"] else "FAIL"
return f"{r['test']['name']}/{r['test']['seed']} {status} {r['timing']['runtime_s']}s"
print(summarise(load_old_format()))Traceback (most recent call last):
File "nested.py", line 15, in <module>
print(summarise(load_old_format()))
~~~~~~~~~^^^^^^^^^^^^^^^^^^^
File "nested.py", line 12, in summarise
return f"{r['test']['name']}/{r['test']['seed']} {status} {r['timing']['runtime_s']}s"
~^^^^^^^^^^
KeyError: 'timing'Read what survived before the failure. The outcome and test lookups both
worked, because this producer supplies those; the status line completed
normally. What is missing is the timing group — this producer put runtime_s
at the top level instead — and the caret sits precisely under r['timing'],
the one subscript of three on that line that failed.
Nothing here is a typo. Two producers disagree about a structure that neither of them declares anywhere, and the only record of the expected shape is the subscripts in the consumer.
6. Where It Hurts, Precisely
An honest summary, because a dict is good enough for a great deal of real work:
| Shape | Travels as one value | Self-describing | New field is safe | Missing field found |
|---|---|---|---|---|
| separate variables | no | no | no — every signature changes | at the call, silently wrong |
| tuple | yes | no | no — every index shifts | at unpack, if you unpack |
| dict | yes | at each use | yes | at first use, as KeyError |
| nested dict | yes | at each use | yes | at first use, two levels deep |
The dict fixed almost everything. What remains is two specific gaps:
- The fields are not stated anywhere. No reader, editor or tool can list what a result contains without reading every producer. The shape exists only as a habit.
- A missing field is found at first use, not at construction. The object that is wrong and the line that fails are in different functions, which is Chapter 14.2 Section 5's problem by design rather than by accident.
Neither is solved by trying harder. Both are solved by naming the structure and declaring its fields, which is exactly what the next chapter does — and the reason it arrives now rather than at the start of the module.
7. Predict, Run, Observe
loose = ("smoke", 1234, True, 182.4)
named = {"name": "smoke", "seed": 1234, "ok": True, "runtime_s": 182.4}
print("A:", loose[1], named["seed"])
print("B:", len(loose), len(named))
grown_tuple = loose + ("TIMEOUT",)
grown_dict = dict(named)
grown_dict["status"] = "TIMEOUT"
print("C:", grown_tuple[1], grown_dict["seed"])
print("D:", "seed" in named, 1234 in loose)
print("E:", sorted(named) == sorted(grown_dict))A: 1234 1234
B: 4 4
C: 1234 1234
D: True True
E: FalseA and C — both shapes survived a field being appended. Appending to a tuple is safe; Section 3's failure needed an insertion, which is the case nobody controls.
D is the asymmetry. "seed" in named asks about a field; 1234 in loose
asks about a value and happens to be true because that number is in there. A
tuple cannot be asked whether it has a seed at all.
E confirms the dict grew a key. There is no equivalent question to ask of
the tuple — len went from 4 to 5 and the meaning of index 4 is whatever the
author remembers.
8. Exercises
Exercise 1 — four parameters. Write a function taking four loose values and call it with two of them swapped. Confirm the result is wrong and silent.
Exercise 2 — the tuple insert. Insert a field into the middle of a returned tuple without changing the consumer. Record both what printed and what raised.
Exercise 3 — index to name. Convert a tuple-based function to a dict-based one. Count the places the change was needed.
Exercise 4 — the typo. Misspell a dict key in a branch that only runs on failure. Say when you would discover it.
Exercise 5 — the missing field. Build a result dict with one field omitted. Show that construction succeeds and name the function where it fails.
Exercise 6 — two producers. Write two functions returning the same data in differently nested shapes. Feed both to one consumer and record what happens.
Exercise 7 — the table. Reproduce Section 6's table from your own experiments rather than from the page.
Exercise 8 — state the fields. Without using any new syntax, write down somewhere in your program what fields a result has. Say why a comment is not quite enough.
9. Summary
- One result can be carried four ways, all of them already available from Modules 6 to 8.
- Separate variables do not travel: every function needs four parameters in an order nobody enforces, and swapping two of the same type is silent.
- A tuple travels as one value but is positional, so inserting a field silently reinterprets every index after it.
- Tuple failures are usually silent; only unpacking the wrong count raises.
- A dict is readable and tolerant of new fields, which is why a great deal of real Python stops here.
- A dict's weaknesses are specific: a typo'd key is a runtime
KeyError, and a missing field is found at first use rather than at construction. - A nested dict adds a format nobody declared, and two producers can disagree about it without either being obviously wrong.
- The caret line tells you which nested lookup failed when a key error is two levels deep.
- The two remaining gaps are stating the fields and failing at construction — and neither is fixed by discipline.
The next chapter closes both gaps with four lines.
Where this fits
Part of the Python curriculum.
