3  Control Flow, Collections, Functions, Modules, and Files

3.1 Learning objectives

After completing this primer, you will be able to:

  • choose a list, tuple, dict, or set to suit the purpose of the data;
  • read and modify collections using indices and slices;
  • write explicit branches and for and while loops before expressing them more concisely as a comprehension;
  • define functions with clear parameters, return values, and local scope;
  • reject invalid input with informative exceptions;
  • separate importable code from command-line actions through modules and if __name__ == "__main__";
  • read and write text, CSV, and JSON using Path; and
  • carry out a real red-green testing cycle with unittest.

Local prerequisite: Primer P01. This primer assumes no previous programming experience and requires no calculus, linear algebra, or differential equations.

3.2 Four basic collections

A collection stores several values, but each kind of collection expresses a different contract.

Type Example When to choose it Important property
list [4, 1, 4] a sequence that can be changed ordered; duplicates allowed
tuple (4, 1, 4) ordered values to be treated as fixed ordered; immutable
dict {"S01": 4} a mapping from keys to values each key is unique
set {1, 4} membership tests or removal of duplicates has no indices

Use square brackets to retrieve an element from a list or tuple. Python indices start at zero; negative indices count from the end.

values = [4, 1, 4, 2]
first = values[0]
last = values[-1]
middle = values[1:-1]
(first, last, middle)
Listing 3.1
(4, 2, [1, 4])

The slice values[start:stop] includes position start but stops before stop. Thus values[1:3] takes positions 1 and 2. The form values[:] creates a new list with the same values; it is not a second name for the old list.

Lists can be changed. The append method adds one element to the same list. Tuples do not support that modification.

measurements = [2, 5]
measurements.append(7)
frozen_measurements = tuple(measurements)
(measurements, frozen_measurements)
Listing 3.2
([2, 5, 7], (2, 5, 7))

A dictionary uses keys, not positions, to retrieve values. The get method can supply a default value when a key is absent. Use ordinary indexing if a missing key is instead an error that should remain visible.

by_sample = {"S01": 2, "S02": 5, "S03": 2}
sample_ids = set(by_sample)
unique_values = set(by_sample.values())
(by_sample["S02"], by_sample.get("S99"), sample_ids, unique_values)
Listing 3.3
(5, None, {'S01', 'S02', 'S03'}, {2, 5})

Do not rely on the display order of a set. If a result must be stable for reading or hashing, convert it to a sorted list, for example sorted(unique_values). Python dictionaries preserve insertion order, but this project’s canonical JSON still sorts keys so that the incidental order of dictionary construction does not change the output bytes.

3.3 Branching with if

A branch selects an action based on a boolean value. Python executes only the first block whose condition is true.

residual = 0.0008
tolerance = 0.001

if residual < 0:
    status = "error: residual must not be negative"
elif residual <= tolerance:
    status = "passes at the specified tolerance"
else:
    status = "does not yet pass"

status
Listing 3.4
'passes at the specified tolerance'

Indentation determines the blocks. Four spaces are the convention in this project. The condition residual <= tolerance is a checkable claim about one result; it does not prove that the algorithm is correct for every input.

Comparison operators produce True or False. Combine conditions with and, or, and not, but do not make a line so dense that domain rules become difficult to review.

3.4 Explicit loops: for, then while

Use for when you want to visit every element of a collection. The following example selects the even numbers and then stores their squares.

values = [5, 2, -4, 3, 0]
even_squares = []

for value in values:
    if value % 2 == 0:
        even_squares.append(value * value)

even_squares
Listing 3.5
[4, 16, 0]

The loop’s state can be read step by step: value is the element currently being examined and even_squares holds the results accepted so far. Master this explicit form before moving to the following compact version.

same_result = [value * value for value in values if value % 2 == 0]
same_result == even_squares
Listing 3.6
True

A list comprehension is not a new algorithm; here it expresses the same loop and branch more concisely. If a transformation needs several conditions, error handling, or intermediate logging, an explicit loop is usually clearer.

3.4.1 From comprehensions to generator expressions

Square brackets build the entire list immediately. The same form inside parentheses creates a generator expression: values are produced one at a time as they are consumed. The following call to tuple(...) exhausts the generator and stores its results as a tuple.

generated = (value * value for value in values if value % 2 == 0)
frozen_result = tuple(generated)
(frozen_result, tuple(generated))
Listing 3.7
((4, 16, 0), ())

The second result is an empty tuple because a generator can be traversed only once. Thus [expression for ...] produces a list immediately, whereas tuple(expression for ...) consumes a lazy stream and freezes it. Unit 2 uses the second form to accept any iterable without storing two intermediate copies.

Use while when repetition should continue as long as a condition remains true. There must be progress that eventually makes the condition false.

current = 3
countdown = []

while current > 0:
    countdown.append(current)
    current -= 1

countdown.append(0)
countdown
Listing 3.8
[3, 2, 1, 0]

On each iteration, current decreases by one and is bounded below by zero. This fact explains why the loop stops. Without the line current -= 1, the condition remains true and the loop never finishes.

3.5 Functions, parameters, return, and scope

A function names a rule that can be called repeatedly. Parameters are local names that receive values from the caller. return sends a result back to the caller.

def clipped_mean(values, lower=0, upper=10):
    selected = []
    for value in values:
        if lower <= value <= upper:
            selected.append(value)
    if not selected:
        raise ValueError("no values within the range")
    return sum(selected) / len(selected)

clipped_mean([-4, 2, 6, 20])
Listing 3.9
4.0

values, lower, upper, and selected are local to the call. The name selected is not available outside the function. The defaults lower=0 and upper=10 are used only if the caller does not override them.

Avoid making a mathematical function depend on global variables that can change out of sight. It is more transparent to accept decisions as parameters and return results. This makes inputs, outputs, and tests easier to read.

A function without an explicit return returns None. Printing is not the same as returning: print sends text to the screen, whereas return supplies a value that a subsequent calculation can use.

3.6 Informative exceptions

Input outside the contract should fail clearly. Use TypeError when the value’s type is unsuitable and ValueError when its type is correct but its value is outside the domain.

def reciprocal(value):
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise TypeError("value must be a number")
    if value == 0:
        raise ValueError("value must not be zero")
    return 1 / value

(reciprocal(4), reciprocal(-2))
Listing 3.10
(0.25, -0.5)

Catch an exception only if that layer knows how to handle it. The following parser adds a line number and then propagates the failure.

def parse_integer_lines(text):
    values = []
    for line_number, raw_line in enumerate(text.splitlines(), start=1):
        token = raw_line.strip()
        if not token:
            continue
        try:
            values.append(int(token))
        except ValueError as error:
            raise ValueError(
                f"line {line_number} is not an integer: {token!r}"
            ) from error
    return values

parse_integer_lines("2\n\n-3\n")
Listing 3.11
[2, -3]

Do not use except Exception: pass. That form erases evidence of failure and may let an experiment produce apparently complete output even though part of the processing never happened.

3.7 Modules and the __main__ boundary

A Python file is a module. Suppose positive_math.py contains the following functions.

positive_math.py
def positive_total(values):
    total = 0
    for value in values:
        if value > 0:
            total += value
    return total


def main():
    print(positive_total([3, -8, 2]))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

When run as python positive_math.py, Python gives the module the special name "__main__" and calls main. When a test runs from positive_math import positive_total, that block does not execute. Importing therefore obtains the function without silently printing, reading files, or overwriting output.

Standard-library and third-party imports go at the top of a file. Local imports refer to modules that are part of the project. A module name is not text to be executed; it marks a boundary that makes functions usable and testable from other files.

3.8 Paths and three file formats

Path constructs paths without manually joining Windows or POSIX separators.

from pathlib import Path

project_root = Path.cwd()
input_path = project_root / "data" / "measurements.csv"
input_path.as_posix().endswith("data/measurements.csv")
Listing 3.12
True

Store paths relative to the project root in configurations and manifests. Do not store personal account folder names or assume that the working directory is always the same.

3.8.1 Text

Text files suit simple lines of data. Specify UTF-8 encoding. When bytes must be deterministic across systems, specify LF line endings.

write_text_example.py
from pathlib import Path

path = Path("output") / "values.txt"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("2\n-1\n5\n", encoding="utf-8", newline="\n")

3.8.2 CSV

CSV stores tables. Use the csv module so that commas, quotation marks, and line endings are handled consistently. Open the file with newline="".

First create the course’s small sample table by running python source/code/primer02_control_files.py --output-dir output/primer02-demo from the project root. That command creates the CSV file used below; the example does not assume that an external data file already exists.

read_csv_example.py
import csv
from pathlib import Path

rows = []
with Path("output/primer02-demo/measurements.csv").open(
    "r", encoding="utf-8", newline=""
) as stream:
    for row in csv.DictReader(stream):
        rows.append({
            "sample_id": row["sample_id"],
            "value": int(row["value"]),
        })

Values read from CSV start out as strings. Conversion with int or float is part of validation, not merely a cosmetic step.

3.8.3 JSON

JSON stores lists, mappings, strings, numbers, booleans, and null. It does not directly store Path objects, sets, or functions. Convert the data to a supported form and specify serialization rules.

The independent three-sample object below illustrates JSON syntax; it is not a summary computed from the four-row CSV example above.

import json

summary = {"count": 3, "sample_ids": ["S01", "S02", "S03"]}
json_text = json.dumps(
    summary, ensure_ascii=False, indent=2, sort_keys=True
) + "\n"
json_text
Listing 3.13
'{\n  "count": 3,\n  "sample_ids": [\n    "S01",\n    "S02",\n    "S03"\n  ]\n}\n'

CSV works well for flat tables; JSON works well for nested structures; plain text works well for very simple, explicitly specified formats. A file extension does not prove that its contents are valid. Always parse and validate the structure you need.

3.9 Red-green testing with unittest

Red-green testing means seeing a test fail because the behavior is not yet correct, fixing the code, and then seeing the same test pass. Do not write code and tests that are already green and claim that the red stage was checked.

Create two files in an empty practice directory. The first version of the module is deliberately wrong:

positive_math.py
def positive_total(values):
    return sum(values)  # wrong: includes negative values in the sum

The test states the intended behavior:

test_positive_math.py
import unittest

from positive_math import positive_total


class PositiveTotalTests(unittest.TestCase):
    def test_only_positive_values_are_added(self):
        self.assertEqual(positive_total([3, -8, 2, 0]), 5)


if __name__ == "__main__":
    unittest.main()

Run:

python -m unittest -v test_positive_math.py

The test should be red: the incorrect function produces -3, not 5. Fix the module using the loop and if from the preceding section, then run the same command. The test should be green. Add a case with no positive values and a case with rejected input. Record the command and Python version; the interface color is not evidence, but the process status and test listing provide a checkable result.

A green test provides evidence of correct behavior for the cases executed. It does not prove a theorem about all programs or all inputs. Function contracts, choices of boundary cases, and mathematical reasoning remain necessary.

3.10 Deterministic companion artifacts

The original MIT-licensed companion code is located in source/code/primer02_control_files.py. From the project root, run:

python source/code/primer02_control_files.py --output-dir output/primer02-demo

The command creates values.txt, measurements.csv, summary.json, and manifest.json. All outputs use UTF-8, LF, a fixed field order, and canonical JSON serialization. The manifest records the relative paths, byte sizes, and SHA-256 hashes of the three data artifacts. It uses no network, current time, user folder, or random numbers.

Run the companion tests with:

python tests/test_primer02.py -v

These tests check contracts, failure types, imports without side effects, the four output files, hash bindings, and two byte-identical bundles.

3.11 Exercises

3.11.1 Exercise 1 - collections and slices

In a learner module named latihan_p02.py, write collection_report(values) for a nonempty list of integers. Return a dictionary containing the first element, the last element, a slice without either endpoint, the first two elements as a tuple, the sorted unique values, and a positions dictionary mapping string-form indices to values. Reject an empty list with ValueError.

Use indices 0 and -1, the slice 1:-1, tuple(values[:2]), sorted(set(values)), and enumerate to construct positions.

From the project root, run:

python tests/test_primer02.py --learner-module latihan_p02.py --exercise 01

The check loads the latihan_p02.py you wrote, not the project’s answer module. It checks all fields, result ordering, and rejection of an empty list.

def collection_report(values):
    if not values:
        raise ValueError("values must not be empty")
    return {
        "first": values[0],
        "last": values[-1],
        "middle": values[1:-1],
        "first_pair": tuple(values[:2]),
        "unique_sorted": sorted(set(values)),
        "positions": {
            str(index): value for index, value in enumerate(values)
        },
    }

collection_report([4, 1, 4, 2])
Listing 3.14
{'first': 4,
 'last': 2,
 'middle': [1, 4],
 'first_pair': (4, 1),
 'unique_sorted': [1, 2, 4],
 'positions': {'0': 4, '1': 1, '2': 4, '3': 2}}

The set is used only for uniqueness; sorted restores deterministic order. Per-element type validation can be added before constructing the report.

3.11.2 Exercise 2 - control flow before comprehensions

Add even_squares_explicit(values) to latihan_p02.py. For [5, 2, -4, 3, 0], it should build the list of squares of the even numbers using for, if, and append. Write an equivalent even_squares_comprehension(values). Add countdown(start) using while to produce [start, ..., 1, 0] and reject start < 0.

An even number satisfies value % 2 == 0. In the while loop, decrease the control variable exactly once on every iteration.

python tests/test_primer02.py --learner-module latihan_p02.py --exercise 02

The check imports all three functions from the learner module, compares the explicit form with the comprehension, and checks that the countdown stops at zero and rejects a negative starting value.

def even_squares_explicit(values):
    result = []
    for value in values:
        if value % 2 == 0:
            result.append(value * value)
    return result

def even_squares_comprehension(values):
    return [value * value for value in values if value % 2 == 0]

def countdown(start):
    if start < 0:
        raise ValueError("start must be nonnegative")
    result = []
    while start > 0:
        result.append(start)
        start -= 1
    result.append(0)
    return result

values = [5, 2, -4, 3, 0]
explicit = even_squares_explicit(values)
compact = even_squares_comprehension(values)
(explicit, compact, countdown(3))
Listing 3.15
([4, 16, 0], [4, 16, 0], [3, 2, 1, 0])

Both lists of squares should equal [4, 16, 0]. The explicit form shows the sequence of operations that the comprehension then expresses more concisely.

3.11.3 Exercise 3 - functions, scope, and errors

Add safe_mean(values) to latihan_p02.py. The function should return the mean as a float, use a local variable total, reject an empty collection with ValueError, and reject nonnumeric elements with TypeError. Show that total cannot be accessed after the function finishes.

Freeze the iterable as a tuple, check for emptiness, and then sum through a loop. Reject bool even though it is technically a subclass of int.

python tests/test_primer02.py --learner-module latihan_p02.py --exercise 03

The check calls safe_mean from the learner module and requires the correct mean for the example, with distinct exception types for an empty collection and text elements.

def safe_mean(values):
    frozen = tuple(values)
    if not frozen:
        raise ValueError("values must not be empty")
    total = 0.0
    for value in frozen:
        if isinstance(value, bool) or not isinstance(value, (int, float)):
            raise TypeError("each element must be a number")
        total += value
    return total / len(frozen)

safe_mean([2, 4, 9])
Listing 3.16
5.0

The result is 5.0. The name total exists only inside the function; trying to read total from the global scope raises NameError. That exception is evidence of scope, not an error to hide.

3.11.4 Exercise 4 - a learner module and red-green tests

Create your own module named positive_math.py and a test file named test_positive_math.py. Start with the incorrect implementation using sum(values), run the test so that it fails for [3, -8, 2, 0], and then fix the function using a loop and if. Add a test for [-3, 0].

The first test should expect 5; the second should expect 0. Keep the demo call inside if __name__ == "__main__" so that importing the module in a test prints nothing.

In the directory containing the two learner files, run python -m unittest -v test_positive_math.py. The project’s reference check is also available:

python tests/test_primer02.py --learner-module positive_math.py --learner-test test_positive_math.py --exercise 04

Save the red-stage and green-stage outputs as evidence that the same test actually detected the repair. The project check above runs your test file twice in an isolated directory: the sum(values) implementation must be red and your final module must be green. The check also imports your positive_math.py and examines additional cases.

Final contents of positive_math.py:

positive_math.py
def positive_total(values):
    total = 0
    for value in values:
        if value > 0:
            total += value
    return total


def main():
    print(positive_total([3, -8, 2, 0]))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Final contents of test_positive_math.py:

test_positive_math.py
import unittest

from positive_math import positive_total


class PositiveTotalTests(unittest.TestCase):
    def test_mixed_values(self):
        self.assertEqual(positive_total([3, -8, 2, 0]), 5)

    def test_no_positive_values(self):
        self.assertEqual(positive_total([-3, 0]), 0)


if __name__ == "__main__":
    unittest.main()

The incorrect version is red because sum([3, -8, 2, 0]) == -3. The final version is green because it sums only values satisfying value > 0.

3.11.5 Exercise 5 - text, CSV, JSON, and a manifest

Add write_demo_bundle(output_dir) to latihan_p02.py. The function must create an output directory containing: text with one number per line; CSV with columns sample_id,value; a JSON summary consistent with both data files; and a manifest containing the relative paths, byte sizes, and SHA-256 hashes of the three artifacts. Add core_sha256 over the manifest’s core section. Run it twice into different directories and show that every pair of corresponding files is byte-identical.

Use Path, encoding="utf-8", newline="" for CSV, lineterminator="\n", and json.dumps(..., sort_keys=True, indent=2). Do not include the current time or absolute paths in the output.

python tests/test_primer02.py --learner-module latihan_p02.py --exercise 05

The check calls write_demo_bundle from the learner module in two temporary directories. It parses the numbers in the text, the CSV columns and rows, and the JSON summary; checks consistency of values and sample IDs; ensures that manifest paths are relative; binds sizes, SHA-256, and core_sha256 to the actual bytes; and then compares each file from the two runs. This exercise check therefore evaluates the output you created, rather than merely running tests against the project’s answer module.

The companion code’s write_demo_bundle function is the reference solution. It writes all three formats, reads the text and CSV back, creates the JSON summary, and then binds each artifact to the manifest. Run:

python source/code/primer02_control_files.py --output-dir output/primer02-demo
python tests/test_primer02.py -v

All four files must exist. manifest.json must use schema o002.p02.bundle-manifest.v1; its three artifact records must match the actual bytes. Matching hashes establish matching bytes for the runs checked, not the scientific correctness of the data or program.

3.12 Summary

  • Lists and tuples store sequences; dictionaries map keys; sets express unique membership without indices.
  • if, for, and while make control flow visible. Use comprehensions after understanding explicit loops.
  • Functions receive inputs through parameters, use local state, and send results back through return.
  • Exceptions should state contract violations and must not be swallowed without handling.
  • The __main__ boundary prevents importing a module from running its side effects.
  • Path, UTF-8, CSV/JSON parsers, and canonical serialization make file work more portable and checkable.
  • A red-green cycle shows that a test can detect one concrete defect; testing is still not a universal mathematical proof.