Programming with PythonControl Flow and Collections › Day 54

Hands-on lab — Day 54: Tuples, Sets, and Choosing a Collection

Commands

Setup

cd labs/sections/programming-with-python/day-054-tuples-sets-and-choosing-a-collection
python3 --version

Run

python3 examples/collections_tool.py "apple,banana,apple,cherry" "banana,cherry,date,date"
python3 examples/collections_tool.py "banana" "banana,cherry"
python3 starter/collections_tool.py "apple,banana,apple" "banana,cherry"

Test

bash tests/run_tests.sh

File tree

examples/collections_tool.py
expected-output/FIELDS.md
expected-output/sample-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
security.md
starter/collection-choice-worksheet.md
starter/collections_tool.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 054 lab — Choosing Collections

Lesson

Purpose

Day 54's lesson teaches the last two core Python collections — tuples and sets — and, just as importantly, how to choose among list, tuple, set, and dict. This lab makes that concrete: you build a small, complete program that dedupes items with a set, computes the set algebra between two lists (common, only-in-A, only-in-B, symmetric difference), and returns the results as immutable tuple records. Alongside the program you fill in a short worksheet that has you justify a collection choice for eight real scenarios. The result is a working tool and a decision habit you will reuse in every data pipeline you build.

Learning objectives

  • Use a set to remove duplicates and to test membership in one fast step.
  • Compute set algebra — intersection (&), difference (-), and symmetric difference (^) — between two collections.
  • Return results as immutable tuple records (here, a namedtuple) that a caller cannot accidentally change.
  • Choose the right collection (list / tuple / set / dict) by asking four questions: mutable? ordered? unique? fast lookup?
  • Run and test a small program without a human: known inputs, checked output and exit code, and imported functions checked by return value.

Prerequisites

  • The Day 54 lesson (read it first — it explains tuples, sets, and the decision framework this lab applies).
  • Day 52 (lists) and Day 53 (dictionaries), plus Day 49's program structure (functions, main, the main guard, input validation).
  • A text editor and a terminal. No programming experience beyond this week is assumed.

Supported operating systems

  • macOS — fully supported (tested on macOS with Apple Silicon, Python 3.14.0).
  • Linux — fully supported (any distribution with Python 3 and bash).
  • Windows — use WSL and follow the Linux path, or substitute python for python3 if that is how Python is exposed. The program is pure standard-library Python and behaves identically everywhere.

Hardware requirements

Any computer that runs Python 3. The program does only string splitting and set arithmetic on small inputs; it needs no special memory, disk, or GPU.

Required software

  • python3 (3.8 or newer; tested on 3.14.0).
  • bash for the test runner (preinstalled on macOS and Linux).
  • Standard library only — sys and collections.namedtuple ship with Python. No packages to install. See requirements/README.md.

Free and open-source options

Everything here is free and open source: Python, bash, and the standard library. No account, API key, network access, or purchase is needed.

Installation

None beyond Python itself. Move into this directory and you are ready:

cd labs/sections/programming-with-python/day-054-tuples-sets-and-choosing-a-collection
python3 --version   # confirm Python 3.8+ is available

File structure

day-054-tuples-sets-and-choosing-a-collection/
├── README.md                            ← you are here
├── metadata.yml                         ← machine-readable lab metadata
├── starter/
│   ├── collections_tool.py              ← YOUR working file (5 numbered exercises)
│   └── collection-choice-worksheet.md   ← choose a collection per scenario
├── examples/
│   └── collections_tool.py              ← complete reference implementation
├── tests/
│   └── run_tests.sh                     ← automated checks (dedupe, set algebra, imports)
├── expected-output/
│   ├── sample-run.txt                   ← real captured run of the reference
│   ├── test-run.txt                     ← real captured run of the test suite
│   └── FIELDS.md                        ← required behaviour on every platform
├── requirements/
│   └── README.md                        ← dependency statement (Python 3 only)
├── troubleshooting.md
└── security.md

How to run

From this directory:

## 1. See the finished program first, on good and bad input
python3 examples/collections_tool.py "apple,banana,apple,cherry" "banana,cherry,date,date"
python3 examples/collections_tool.py "banana" "banana,cherry"
python3 examples/collections_tool.py "apple,banana"          # prints an error, exits non-zero

## 2. Your task: complete the five exercises in the starter, then run it
python3 starter/collections_tool.py "apple,banana,apple" "banana,cherry"

## 3. Prove the module is importable (the payoff of the main guard)
python3 -c "import sys; sys.path.insert(0, 'examples'); from collections_tool import dedupe; print(dedupe(('a', 'a', 'b')))"

## 4. Check your work
bash tests/run_tests.sh

What the commands do

  • python3 examples/collections_tool.py "apple,banana,apple,cherry" "banana,cherry,date,date" — runs the complete reference program: it parses each list into a tuple in parse_items, dedupes with a set, computes the set algebra in compare, formats with format_report, and prints a six-line report. Bad input (only one argument, or a list that is empty after cleaning) prints a clear error to standard error and exits with code 1.
  • python3 starter/collections_tool.py ... — runs your version. The starter ships with five exercises stubbed out (each raising NotImplementedError until you finish it): return an immutable tuple, dedupe with a set, do the set algebra, format the report, and add the main guard.
  • python3 -c "...dedupe..." — imports one function from the module and calls it, without running the whole program. This works only because the main guard holds main back on import.
  • bash tests/run_tests.sh — runs the reference on good and bad inputs (checking output and exit code), imports dedupe and compare to check their return values (including that dedupe returns an immutable tuple), and checks your starter (structurally until you finish, strictly afterwards). Exits 0 only if every check passes.

Expected output

See expected-output/sample-run.txt — a real captured run:

$ python3 examples/collections_tool.py "apple,banana,apple,cherry" "banana,cherry,date,date"
List A: 4 items read, 3 unique after dedupe
List B: 4 items read, 3 unique after dedupe
Common (A & B): banana, cherry
Only in A (A - B): apple
Only in B (B - A): date
Symmetric difference (A ^ B): apple, date

Every group is sorted before printing, so the program is deterministic and your output will match exactly — even though a set itself is unordered. expected-output/FIELDS.md lists the required behaviour for every input on every platform.

Validation steps

  1. Run the first command above — it must print List A: 4 items read, 3 unique after dedupe and Common (A & B): banana, cherry.
  2. Run python3 examples/collections_tool.py "apple,banana"; echo $? — it must print an error and then 1.
  3. Complete the five exercises in starter/collections_tool.py, then run it on the same inputs and confirm it matches the reference.
  4. Fill in starter/collection-choice-worksheet.md — a collection choice and a one-sentence reason for each of the eight scenarios.
  5. Run the tests (next section) — every check must pass.

Tests

bash tests/run_tests.sh

Expected final line while the starter is unfinished: 13 checks, 0 failure(s). Once you complete all five starter exercises, seven more checks run your version through the same good/bad inputs plus the main-guard check, giving 20 checks, 0 failure(s). The command exits 0 on success and non-zero on any failure, so it can run in CI. A full captured run is in expected-output/test-run.txt.

Cleanup

Nothing to clean up: the program and tests read only their command-line arguments and write nothing outside their own console output (no files, no network, no settings). To reset your work, restore the starter from git: git checkout -- starter/collections_tool.py.

Troubleshooting

See troubleshooting.md for the full list: python vs python3, the deliberate NotImplementedError stubs, why output stays sorted, the unhashable type: 'list' error, quoting your two lists, and importing vs running.

Security notes

See security.md. Short version: the program makes no network calls, writes no files, and needs no privileges. It turns text into data (splits and sets), never into code — so it never touches eval(). Sets power fast, correct membership checks, which are a real security primitive for allow-lists and block-lists.

Extension exercises

  1. Add a --sorted-by-length style option: read a third argument and, when it is len, sort each output group by length then alphabetically. Keep the default (plain alphabetical) unchanged and keep the program deterministic.
  2. Add a union line to the report using a | b, and confirm by hand that len(union) == len(common) + len(symmetric) for your inputs.
  3. Write your own tests/test_tool.py that imports dedupe and compare and asserts several known results (including that dedupe returns a tuple and compare returns a SetReport), printing all tests passed only if every assertion holds; run it with python3 tests/test_tool.py.
  • Previous day: Day 53 — Dictionaries: Key-Value Data (labs/sections/programming-with-python/day-053-.../).
  • Next day: Day 55 — Comprehensions and Iterator Thinking (labs/sections/programming-with-python/day-055-.../, to be written).
  • Week 8 project: builds on lists, dictionaries, tuples, and sets together — choosing the right collection for each part of a small data task.

Expected output

FIELDS.md

# Expected output — Day 054 lab

This directory holds real captured runs from the authoring machine
(macOS, Apple Silicon, Python 3.14.0, bash 3.2, 2026-07-13). Your numbers
will match exactly, because the program is deterministic — every group is
**sorted** before printing, so the same input always produces the same output
on every platform, regardless of the fact that a `set` itself is unordered.

## Files

- `sample-run.txt` — the reference program run on good and bad inputs, plus
  two `python3 -c` import checks (`dedupe` and `compare`).
- `test-run.txt` — a full run of `bash tests/run_tests.sh` with the starter
  still unfinished (13 checks, 0 failures).

## Required behaviour on every platform

A correct program must, for these inputs, produce exactly:

| Command (arguments abbreviated) | Key output line | Exit code |
| ------------------------------- | --------------- | --------- |
| `"apple,banana,apple,cherry" "banana,cherry,date,date"` | `List A: 4 items read, 3 unique after dedupe` | 0 |
| (same) | `Common (A & B): banana, cherry` | 0 |
| (same) | `Only in A (A - B): apple` | 0 |
| (same) | `Only in B (B - A): date` | 0 |
| (same) | `Symmetric difference (A ^ B): apple, date` | 0 |
| `"banana" "banana,cherry"` | `Only in A (A - B): (none)` | 0 |
| `"apple,banana"` (one argument) | (stderr) `error: expected 2 arguments: ...` | 1 |
| `" , ," "banana"` (empty after cleaning) | (stderr) `error: each list must contain at least one item` | 1 |

The only platform difference is the shell prompt shown before each command
(`$` here); the program's own output is identical everywhere Python 3 runs.
Because every group is sorted, the ordering you see is guaranteed — it does
**not** depend on set iteration order.

## Test-suite counts

- With the starter unfinished: `13 checks, 0 failure(s).`
- Once you complete all five starter exercises: `20 checks, 0 failure(s).`
  (the seven extra checks run your starter through the same good/bad inputs as
  the reference, plus a check that it has the main guard).

## Windows note

On native Windows (outside WSL), use `python` instead of `python3` if that is
how Python is exposed, and run the test suite from Git Bash or WSL (it is a
bash script). The program's output is byte-for-byte identical; only the
launcher name and the shell differ.

sample-run.txt

$ python3 examples/collections_tool.py "apple,banana,apple,cherry" "banana,cherry,date,date"
List A: 4 items read, 3 unique after dedupe
List B: 4 items read, 3 unique after dedupe
Common (A & B): banana, cherry
Only in A (A - B): apple
Only in B (B - A): date
Symmetric difference (A ^ B): apple, date

$ python3 examples/collections_tool.py "banana" "banana,cherry"
List A: 1 items read, 1 unique after dedupe
List B: 2 items read, 2 unique after dedupe
Common (A & B): banana
Only in A (A - B): (none)
Only in B (B - A): cherry
Symmetric difference (A ^ B): cherry

$ python3 examples/collections_tool.py "apple,banana"   ; echo "exit: $?"
error: expected 2 arguments: "<list A>" "<list B>"
usage: python3 collections_tool.py "a,b,c" "b,c,d"
exit: 1

$ python3 examples/collections_tool.py " , ," "banana"   ; echo "exit: $?"
error: each list must contain at least one item
usage: python3 collections_tool.py "a,b,c" "b,c,d"
exit: 1

$ python3 -c "import sys; sys.path.insert(0, 'examples'); from collections_tool import dedupe; print(dedupe(('a', 'a', 'b')))"
('a', 'b')

$ python3 -c "import sys; sys.path.insert(0, 'examples'); from collections_tool import compare; print(compare(('a', 'b'), ('b', 'c')))"
SetReport(common=('b',), only_in_a=('a',), only_in_b=('c',), symmetric=('a', 'c'))

test-run.txt

Testing <repo>/labs/sections/programming-with-python/day-054-tuples-sets-and-choosing-a-collection/examples/collections_tool.py ...
  ok: A dedupes 4->3
  ok: common intersection
  ok: only in A difference
  ok: only in B difference
  ok: symmetric difference
  ok: empty group -> (none)
  ok: wrong arg count
  ok: empty list rejected
Testing importability of examples/collections_tool.py ...
  ok: import dedupe returns sorted immutable tuple
  ok: import compare set algebra correct
Testing starter/collections_tool.py ...
  ok: starter is valid Python
Note: starter/collections_tool.py still has unfinished exercises — testing structure only.
  ok: starter defines parse_items
  ok: starter defines compare

13 checks, 0 failure(s).

Source files

examples/collections_tool.py (3524 bytes)
#!/usr/bin/env python3
"""Choosing Collections — dedupe items and compute set algebra between two lists.

A complete, small, real program: it reads two comma-separated lists from the
command line, removes duplicates with a set, computes the set algebra between
them (common / only-in-A / only-in-B / symmetric difference), and reports the
results as immutable tuple records.

Usage:
    python3 collections_tool.py "<list A>" "<list B>"

Each list is a comma-separated string of items.

Example:
    python3 collections_tool.py "apple,banana,apple,cherry" "banana,cherry,date,date"
"""
import sys
from collections import namedtuple

# An immutable record of one comparison. namedtuple gives named fields on top
# of a plain tuple: it cannot be changed after creation, so it is a safe
# return value that callers cannot accidentally mutate.
SetReport = namedtuple("SetReport", ["common", "only_in_a", "only_in_b", "symmetric"])


def parse_items(text):
    """Split a comma-separated string into a tuple of cleaned items.

    Returns an immutable tuple (a fixed record of what was read), dropping
    blank entries and trimming surrounding spaces.
    """
    return tuple(part.strip() for part in text.split(",") if part.strip())


def dedupe(items):
    """Return the unique items as a sorted tuple.

    Building a set removes duplicates in one step (membership is O(1)); we
    sort so the output is deterministic, and return a tuple so the result is
    an immutable record.
    """
    return tuple(sorted(set(items)))


def compare(a_items, b_items):
    """Compute set algebra between two item collections.

    Returns an immutable SetReport of sorted tuples:
      common     = A & B  (intersection: in both)
      only_in_a  = A - B  (difference: in A, not B)
      only_in_b  = B - A  (difference: in B, not A)
      symmetric  = A ^ B  (symmetric difference: in exactly one)
    """
    a = set(a_items)
    b = set(b_items)
    return SetReport(
        common=tuple(sorted(a & b)),
        only_in_a=tuple(sorted(a - b)),
        only_in_b=tuple(sorted(b - a)),
        symmetric=tuple(sorted(a ^ b)),
    )


def format_report(a_items, b_items, report):
    """Return the multi-line, human-readable report string."""

    def show(values):
        return ", ".join(values) if values else "(none)"

    lines = [
        f"List A: {len(a_items)} items read, {len(set(a_items))} unique after dedupe",
        f"List B: {len(b_items)} items read, {len(set(b_items))} unique after dedupe",
        f"Common (A & B): {show(report.common)}",
        f"Only in A (A - B): {show(report.only_in_a)}",
        f"Only in B (B - A): {show(report.only_in_b)}",
        f"Symmetric difference (A ^ B): {show(report.symmetric)}",
    ]
    return "\n".join(lines)


def main(argv):
    """Entry point. Returns an exit code: 0 on success, 1 on bad input."""
    if len(argv[1:]) != 2:
        print('error: expected 2 arguments: "<list A>" "<list B>"', file=sys.stderr)
        print('usage: python3 collections_tool.py "a,b,c" "b,c,d"', file=sys.stderr)
        return 1
    a_items = parse_items(argv[1])
    b_items = parse_items(argv[2])
    if not a_items or not b_items:
        print("error: each list must contain at least one item", file=sys.stderr)
        print('usage: python3 collections_tool.py "a,b,c" "b,c,d"', file=sys.stderr)
        return 1
    report = compare(a_items, b_items)
    print(format_report(a_items, b_items, report))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
metadata.yml (796 bytes)
lesson_id: D054
day: 54
kind: python-program
languages: [python]
setup_commands:
  - cd labs/sections/programming-with-python/day-054-tuples-sets-and-choosing-a-collection
  - python3 --version
run_commands:
  - python3 examples/collections_tool.py "apple,banana,apple,cherry" "banana,cherry,date,date"
  - python3 examples/collections_tool.py "banana" "banana,cherry"
  - python3 starter/collections_tool.py "apple,banana,apple" "banana,cherry"
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - 'git checkout -- starter/collections_tool.py  # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-13'
executed_on: 'macOS (Apple Silicon), Python 3.14.0, bash tests/run_tests.sh -> 13 checks, 0 failure(s), exit 0'
requirements/README.md (907 bytes)
# Dependencies — Day 054 lab

**Python 3 only. No third-party packages.**

- `python3` (3.8 or newer; tested on 3.14.0). Preinstalled on most Linux
  distributions and installable on macOS; you set this up on Day 43.
- `bash` for the test runner (preinstalled on macOS and Linux).
- Only the Python standard library is used — specifically `sys` (for the
  command line and exit code) and `collections.namedtuple` (for the immutable
  record), both of which ship with Python. There is deliberately no
  `requirements.txt`: choosing collections is core-language work that should
  run on a plain Python install with nothing to install first.

Check your Python is present and new enough:

```bash
python3 --version
```

If that prints `Python 3.8` or higher, you are ready. Windows users: run the
commands inside WSL, or use `python` in place of `python3` if that is how
Python is exposed on your system.
starter/collection-choice-worksheet.md (1950 bytes)
# Collection-choice worksheet — Day 054

Choosing the right collection is a design decision you make *before* you code.
For each scenario below, pick ONE of `list`, `tuple`, `set`, or `dict`, and
justify it against the four questions that decide the answer:

1. **Mutable?** Will the data change after you build it?
2. **Ordered / indexed?** Do you need to keep insertion order or reach an item
   by position?
3. **Unique?** Must duplicates be removed automatically?
4. **Fast lookup by key?** Do you need to check membership or fetch a value in
   roughly one step regardless of size?

Fill in a choice and a one-sentence reason for each. There is a model answer
for every row in the instructor solution — try it yourself first.

## Scenarios

| # | Scenario | Your choice | Why (one sentence) |
|---|----------|-------------|--------------------|
| 1 | The (latitude, longitude) of a city, passed around and never changed | | |
| 2 | A growing to-do list the user adds items to and reorders | | |
| 3 | The set of stop-words to strip from text before feeding it to a model | | |
| 4 | A phone book mapping each name to a phone number | | |
| 5 | The unique words (vocabulary) seen while scanning a large document | | |
| 6 | The RGB colour of a pixel, used as a key to count how often it appears | | |
| 7 | An ordered playlist where the same song may appear twice | | |
| 8 | Deduplicating a million scraped URLs, then testing "have I seen this one?" | | |

## Reflection (answer after building the program)

1. Which single property most often decided your answer above — mutability,
   order, uniqueness, or lookup speed? Give an example.

   > 

2. Scenario 6 uses a colour as a **dictionary key**. Why must that key be a
   tuple, not a list? (Hint: hashable.)

   > 

3. In the program you built, `compare()` returns a `SetReport` — an immutable
   tuple record. Name one bug that immutability prevents a caller from causing.

   > 
starter/collections_tool.py (3854 bytes)
#!/usr/bin/env python3
"""Choosing Collections — YOUR working file.

Build this program one exercise at a time. Each numbered exercise below names
exactly what to write. The finished reference is in
examples/collections_tool.py — try each exercise yourself before peeking.

When you have completed all five exercises, this file should behave just like
the reference:

    python3 starter/collections_tool.py "apple,banana,apple" "banana,cherry"
      ->  a six-line report ending in the symmetric difference

Then run:  bash tests/run_tests.sh
"""
import sys
from collections import namedtuple

# An immutable record of one comparison (a tuple with named fields).
SetReport = namedtuple("SetReport", ["common", "only_in_a", "only_in_b", "symmetric"])


def parse_items(text):
    """Split a comma-separated string into a tuple of cleaned items."""
    # Exercise 1: RETURN AN IMMUTABLE TUPLE.
    # Split `text` on commas, strip spaces off each part, drop blank parts,
    # and return the result as a TUPLE (not a list), e.g.
    #   parse_items("apple, banana ,,apple") -> ("apple", "banana", "apple")
    raise NotImplementedError("Exercise 1: implement parse_items")


def dedupe(items):
    """Return the unique items as a sorted tuple (a set removes duplicates)."""
    # Exercise 2: DEDUPE WITH A SET.
    # Build a set from `items` to drop duplicates, sort it for a deterministic
    # order, and return a tuple, e.g.
    #   dedupe(("a", "a", "b")) -> ("a", "b")
    raise NotImplementedError("Exercise 2: implement dedupe")


def compare(a_items, b_items):
    """Compute set algebra between two item collections; return a SetReport."""
    # Exercise 3: SET ALGEBRA.
    # Turn each argument into a set, then build a SetReport of SORTED TUPLES:
    #   common    = a & b   (intersection)
    #   only_in_a = a - b   (difference)
    #   only_in_b = b - a   (difference)
    #   symmetric = a ^ b   (symmetric difference)
    # Return SetReport(common=..., only_in_a=..., only_in_b=..., symmetric=...).
    raise NotImplementedError("Exercise 3: implement compare")


def format_report(a_items, b_items, report):
    """Return the multi-line, human-readable report string."""
    # Exercise 4: FORMAT OUTPUT.
    # Build a six-line string. Use the helper below so empty groups read
    # "(none)" instead of a blank. The lines are (exact text matters):
    #   List A: <N> items read, <U> unique after dedupe
    #   List B: <N> items read, <U> unique after dedupe
    #   Common (A & B): <items>
    #   Only in A (A - B): <items>
    #   Only in B (B - A): <items>
    #   Symmetric difference (A ^ B): <items>
    # where N is len(a_items), U is len(set(a_items)), and <items> is the
    # comma-joined group from `report`. Return the lines joined by "\n".
    def show(values):
        return ", ".join(values) if values else "(none)"

    raise NotImplementedError("Exercise 4: implement format_report")


def main(argv):
    """Entry point. Returns an exit code: 0 on success, 1 on bad input."""
    if len(argv[1:]) != 2:
        print('error: expected 2 arguments: "<list A>" "<list B>"', file=sys.stderr)
        print('usage: python3 collections_tool.py "a,b,c" "b,c,d"', file=sys.stderr)
        return 1
    a_items = parse_items(argv[1])
    b_items = parse_items(argv[2])
    if not a_items or not b_items:
        print("error: each list must contain at least one item", file=sys.stderr)
        print('usage: python3 collections_tool.py "a,b,c" "b,c,d"', file=sys.stderr)
        return 1
    report = compare(a_items, b_items)
    print(format_report(a_items, b_items, report))
    return 0


# Exercise 5: ADD THE MAIN GUARD.
# Below this comment, add the guard so the program runs only when this file
# is executed directly (not when it is imported):
#
#     if __name__ == "__main__":
#         sys.exit(main(sys.argv))
tests/run_tests.sh (4890 bytes)
#!/usr/bin/env bash
# Tests for the Day 054 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# Exercises the complete reference program (examples/collections_tool.py) on
# known good and bad inputs, checking both the printed output and the process
# exit code, then imports functions from the module and checks their return
# values (dedupe returns an immutable tuple; compare's set algebra is correct).
# Finally it checks the learner's starter: structurally while exercises are
# unfinished, and to the same strict standard once they are complete.
# No network, non-interactive. Exits 0 only if every check passes.
set -u

# Keep the working tree clean: do not let imported modules write __pycache__.
export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ref="${lab_dir}/examples/collections_tool.py"
starter="${lab_dir}/starter/collections_tool.py"
failures=0
checks=0

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

# check_run <label> <script> <expect_exit> <needle> <arg...>
# Runs the program, checks its exit code equals expect_exit and that its
# combined output contains needle.
check_run() {
  local label="$1" script="$2" expect_exit="$3" needle="$4"
  shift 4
  local out code
  out="$(python3 "${script}" "$@" 2>&1)"
  code=$?
  if [ "${code}" -eq "${expect_exit}" ] && printf '%s' "${out}" | grep -qF "${needle}"; then
    check "${label}" "yes"
  else
    check "${label}" "no"
    echo "    (exit ${code}, expected ${expect_exit}; output: ${out})"
  fi
}

run_program_checks() {
  local script="$1"
  echo "Testing ${script} ..."
  # Good input: dedupe count, set algebra, exit 0.
  check_run "A dedupes 4->3"        "${script}" 0 "List A: 4 items read, 3 unique" "apple,banana,apple,cherry" "banana,cherry,date,date"
  check_run "common intersection"   "${script}" 0 "Common (A & B): banana, cherry" "apple,banana,apple,cherry" "banana,cherry,date,date"
  check_run "only in A difference"  "${script}" 0 "Only in A (A - B): apple"       "apple,banana,apple,cherry" "banana,cherry,date,date"
  check_run "only in B difference"  "${script}" 0 "Only in B (B - A): date"        "apple,banana,apple,cherry" "banana,cherry,date,date"
  check_run "symmetric difference"  "${script}" 0 "Symmetric difference (A ^ B): apple, date" "apple,banana,apple,cherry" "banana,cherry,date,date"
  check_run "empty group -> (none)" "${script}" 0 "Only in A (A - B): (none)"      "banana" "banana,cherry"
  # Bad inputs: clear error, non-zero exit.
  check_run "wrong arg count"       "${script}" 1 "expected 2 arguments" "apple,banana"
  check_run "empty list rejected"   "${script}" 1 "at least one item"    " , ," "banana"
}

# --- Reference program: always tested strictly ---
run_program_checks "${ref}"

# --- Import functions and check return values (main-guard payoff) ---
echo "Testing importability of examples/collections_tool.py ..."
if python3 -c "import sys; sys.path.insert(0, '${lab_dir}/examples'); \
from collections_tool import dedupe; \
result = dedupe(('a', 'a', 'b', 'c', 'b')); \
assert result == ('a', 'b', 'c'), result; \
assert isinstance(result, tuple), 'dedupe must return an immutable tuple'"; then
  check "import dedupe returns sorted immutable tuple" "yes"
else
  check "import dedupe returns sorted immutable tuple" "no"
fi
if python3 -c "import sys; sys.path.insert(0, '${lab_dir}/examples'); \
from collections_tool import compare; \
r = compare(('a', 'b', 'c'), ('b', 'c', 'd')); \
assert r.common == ('b', 'c'), r.common; \
assert r.only_in_a == ('a',), r.only_in_a; \
assert r.symmetric == ('a', 'd'), r.symmetric"; then
  check "import compare set algebra correct" "yes"
else
  check "import compare set algebra correct" "no"
fi

# --- Learner starter ---
echo "Testing starter/collections_tool.py ..."
if python3 -c "compile(open('${starter}').read(), '${starter}', 'exec')" 2>/dev/null; then
  check "starter is valid Python" "yes"
else
  check "starter is valid Python" "no"
fi

if grep -q 'NotImplementedError' "${starter}"; then
  echo "Note: starter/collections_tool.py still has unfinished exercises — testing structure only."
  # Structural checks while the learner works.
  grep -q 'def parse_items' "${starter}" && check "starter defines parse_items" "yes" || check "starter defines parse_items" "no"
  grep -q 'def compare' "${starter}" && check "starter defines compare" "yes" || check "starter defines compare" "no"
else
  # Learner finished: hold the starter to the same strict standard.
  run_program_checks "${starter}"
  grep -q '__name__ == "__main__"' "${starter}" && check "starter has the main guard" "yes" || check "starter has the main guard" "no"
fi

echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]

Troubleshooting

Troubleshooting — Day 054 lab

python: command not found

Use python3 explicitly, as every command in this lab does. On macOS and most Linux systems, bare python may be missing or point to an old version. Check with python3 --version.

The starter raises NotImplementedError when I run it

That is expected until you finish the exercises. Each unfinished function raises NotImplementedError on purpose so you cannot accidentally think an empty function "works." Replace each raise NotImplementedError(...) line with the real body described in the comment above it. Once all five exercises are done, the file runs like the reference.

My output items are in a different order

They should not be — every group is passed through sorted() before it is printed, so the order is fixed and matches the captured output exactly. If your order differs, you probably returned the raw set (which is unordered) instead of tuple(sorted(...)). A set has no reliable order; sorting on the way out is what makes the program deterministic and testable.

TypeError: unhashable type: 'list'

A set (and a dict key) can only hold hashable values, and a list is not hashable because it can change. If you try set([["a"], ["b"]]) or use a list as a dict key, you get this error. Use tuples for grouped keys — a tuple is hashable as long as everything inside it is. This is exactly why the program parses items into tuples.

AttributeError: 'tuple' object has no attribute 'common'

Your compare() returned a plain tuple instead of a SetReport. Build and return SetReport(common=..., only_in_a=..., only_in_b=..., symmetric=...) so the caller can use the named fields (report.common).

Quotes: my two lists got split into many arguments

Wrap each list in quotes so the shell passes it as a single argument: python3 examples/collections_tool.py "apple,banana" "banana,cherry". Without quotes, a space inside a list would start a new argument and the program would see the wrong number of arguments.

Importing the file runs the whole program

This is exactly the problem the main guard prevents. If importing your module prints a report or exits, you either forgot if __name__ == "__main__": or wrote program-running code at the top level (outside any function). Only the guarded sys.exit(main(sys.argv)) should trigger execution — that is what lets a test import dedupe and compare without running the report.

bash: tests/run_tests.sh: Permission denied

Run it through bash explicitly (as the README shows) rather than executing it directly: bash tests/run_tests.sh. You do not need to chmod +x anything.

Security notes

Security notes — Day 054 lab

  • What the program does: reads two command-line arguments, splits them on commas, computes set algebra, and prints a report. It makes no network connections, reads and writes no files, and changes no settings. The test runner is equally self-contained and non-interactive.

  • Validate input; never execute it. This lab turns text into data — it splits strings and builds sets — and never turns text into code. Never use eval() or exec() to "parse" what a user typed: those functions run the string as Python, so a malicious value could delete files or open a network connection. Splitting on commas and building a set, as this program does, can only ever produce data.

  • Sets are a real security tool. Fast, correct membership testing is a security primitive: an allow-list or a block-list of terms, IDs, or hosts is naturally a set, and value in allowed is an O(1) check that stays fast at scale. Using a set for these checks (instead of scanning a list) is both faster and less error-prone.

  • Hashable keys only. A set and a dict key must be hashable — which means immutable. This is why the program uses tuples, not lists, for grouped values: a tuple can be a set member or a dict key, a list cannot. Reaching for the immutable type here is a habit that prevents a whole class of bugs.

  • Privileges: everything runs as your normal user. Nothing here needs sudo. As always in this course, if a script ever asks you to run it with elevated privileges, stop and read it first.

  • Reading before running: every file in this lab is short and commented. Read examples/collections_tool.py and tests/run_tests.sh before running them. Running unread scripts is one of the most common ways developers get compromised; the course's rule is that every lab script is small enough to read and understand first.