Grade by Grade
C. Designing Modular Programs · Modular and Object-Oriented Design

Putting It Together: A Modular Object-Oriented Project

A2.1 A2.2 A2.3 C1.1 C1.2 C1.3 C1.4 A4.3 A4.4About 75 minutes
Learning goals
  • I can take a problem from requirements to a CRC and UML design, then to a package of modules and classes.
  • I can combine encapsulation, an abstract class, inheritance, polymorphism and an ADT in one program.
  • I can test a multi-file program automatically and document it for programmers and for users.
  • I can enhance my own finished program without breaking its tests.

Words to know

requirements
A clear list of what the program must do, written before design starts.
package layout
How a program's modules are arranged in folders, and which imports which.
dependency
A module or class that another one needs (imports or uses).
version number
A label such as 1.0 or 1.1 that identifies one release of a program; a change in the second number often means new features that stay backward compatible.
test suite
The collection of automatic tests for a program, run after every change.

The arena, start to finish

Throughout this unit you have met pieces of an ice-arena booking program: time ranges in the documentation lesson, --help in the user documentation lesson. Now you will build the whole thing, the way a small professional project is built: requirements, design, modules, classes, tests, documentation, and then a feature request after version 1.0 ships.

Read every file. Then do the activity, where you extend it yourself. This lesson brings together every expectation in the unit.

Requirements (version 1.0)

  1. The arena is open from 6 to 23 (24-hour clock). Bookings are whole hours, and the end hour is not included.
  2. There are three kinds of booking. Practices cost $250 per hour; public skates are free; parties cost a flat $300 and last at most 2 hours. (These are this example arena's prices.)
  3. Two bookings may not overlap. A booking that clashes is refused with a clear message.
  4. Staff can cancel all of a group's bookings, list the day's plan in time order, and see the day's total revenue.

Noun and verb analysis gives the classes: Booking (with three kinds), Schedule (the day). Verbs become methods: add, cancel, list (day_plan), total (revenue), and cost on each kind of booking.

Worked example

Example 1: Design decisions

Explain the key design decisions for version 1.0 and which principle from this unit each one applies.

  1. Booking is abstract with an abstract cost(); Practice, PublicSkate and Party override it. Schedule adds up costs without knowing the kinds (polymorphism, A2.2). A new kind of booking later is one new class (open-closed).
  2. Booking is encapsulated (C1.2): _group, _start and _end are internal and exposed through read-only properties, and the constructor enforces the opening-hours invariant. Party extends the constructor to add its own rule, calling super().__init__ first.
  3. Schedule keeps the no-overlap invariant: the only way to add is add(), which checks clash() first.
  4. Functional decomposition (C1.3): clash() is a separate method because both add() and, later, the waitlist need it; the time-range test lives in one pure function, overlaps, in a module of its own.
  5. Modules by theme (A2.1, C1.4): timeutil (pure helpers), models (the booking classes), schedule (the day). Lower modules never import higher ones, and only main.py prints.
  6. Documentation (A4.3): every module, class and public method has a docstring; constants have line comments; invariants are stated in class docstrings.

The UML diagram and the module dependency diagram below show the result. Notice how each decision makes the program easier to test, change or reuse.

Schedule-_bookings: list-_waitlist: Queue+add(booking)+request(booking): bool+cancel(group): int+day_plan(): list+revenue(): int« abstract »Booking-_group, -_start, -_end+hours (property)+cost(): int {abstract}+__str__()10..∗Practicecost: hours × 25000PublicSkatecost: 0Partycost: 30000 flatQueueenqueue, dequeuewaitlist
The design. Schedule aggregates Bookings and owns a Queue for the waitlist. Booking is abstract; its three subclasses each override cost(), so Schedule.revenue() works by polymorphism. The waitlist (shaded differently) is the version 1.1 enhancement.
main.pyarena/schedule.pyarena/models.pyarena/adt.pyarena/timeutil.pyAn arrow means “imports”. No arrows point upward, so there are no circular imports.
How the files depend on each other. timeutil and adt are the most reusable: they import nothing from the package. main.py is the only file with user-facing output.
"""arena: book ice time at a community arena.
Modules:
timeutil -- time-range helpers
models -- Booking and its subclasses
schedule -- Schedule, which keeps a day's bookings clash-free
adt -- a Queue used for the waitlist
"""

arena/__init__.py: the package docstring is a map of the modules.

"""Time helpers for the arena package. Hours are whole numbers from 0 to 24."""
def overlaps(start_a, end_a, start_b, end_b):
"""Return True if [start_a, end_a) and [start_b, end_b) share any hour."""
return start_a < end_b and start_b < end_a
def hour_text(hour):
"""Return an hour as 24-hour clock text, for example 7 -> "07:00"."""
return f"{hour:02d}:00"

arena/timeutil.py

"""Booking classes for the arena package. Prices are in cents."""
from abc import ABC, abstractmethod
from arena.timeutil import hour_text
OPEN_HOUR = 6 # the arena opens at 6 a.m.
CLOSE_HOUR = 23 # and closes at 11 p.m.
class Booking(ABC):
"""One booking of the ice. Subclasses decide the price.
Invariant: OPEN_HOUR <= start < end <= CLOSE_HOUR.
"""
def __init__(self, group, start, end):
if not (OPEN_HOUR <= start < end <= CLOSE_HOUR):
raise ValueError(f"{group}: hours must be from {OPEN_HOUR} to {CLOSE_HOUR}")
self._group = group
self._start = start
self._end = end
@property
def group(self):
return self._group
@property
def start(self):
return self._start
@property
def end(self):
return self._end
@property
def hours(self):
return self._end - self._start
@abstractmethod
def cost(self):
"""Return the price of this booking in cents."""
def __str__(self):
times = f"{hour_text(self.start)}-{hour_text(self.end)}"
kind = type(self).__name__
return f"{times} {self.group} ({kind}) ${self.cost() / 100:.2f}"
class Practice(Booking):
"""A team practice, charged by the hour."""
HOURLY = 25000
def cost(self):
return self.hours * Practice.HOURLY
class PublicSkate(Booking):
"""A free public skate."""
def cost(self):
return 0
class Party(Booking):
"""A party: flat price, at most MAX_HOURS long."""
FLAT = 30000
MAX_HOURS = 2
def __init__(self, group, start, end):
super().__init__(group, start, end)
if self.hours > Party.MAX_HOURS:
raise ValueError(f"{group}: parties are at most {Party.MAX_HOURS} hours")
def cost(self):
return Party.FLAT

arena/models.py

"""The Schedule class: one day's bookings, never overlapping."""
from arena.timeutil import overlaps
def _start_of(booking):
"""Sort key: a booking's start hour."""
return booking.start
class Schedule:
"""All bookings for one day. Invariant: no two bookings overlap."""
def __init__(self):
self._bookings = []
def clash(self, new):
"""Return an existing booking that overlaps new, or None."""
for b in self._bookings:
if overlaps(b.start, b.end, new.start, new.end):
return b
return None
def add(self, booking):
"""Add a booking. Raise ValueError if it clashes with another."""
other = self.clash(booking)
if other is not None:
raise ValueError(f"{booking.group} clashes with {other.group}")
self._bookings.append(booking)
def cancel(self, group):
"""Remove every booking for group. Return how many were removed."""
before = len(self._bookings)
self._bookings = [b for b in self._bookings if b.group != group]
return before - len(self._bookings)
def day_plan(self):
"""Return the bookings sorted by start time."""
return sorted(self._bookings, key=_start_of)
def revenue(self):
"""Return the total price of all bookings, in cents."""
total = 0
for b in self._bookings:
total += b.cost()
return total

arena/schedule.py, version 1.0. The list comprehension in cancel keeps every booking whose group is different.

from arena.models import Practice, PublicSkate, Party
from arena.schedule import Schedule
day = Schedule()
requests = [Practice("Bantam A", 18, 20),
PublicSkate("Public skate", 13, 15),
Party("Mia's birthday", 15, 17),
Practice("Figure club", 19, 21)]
for r in requests:
try:
day.add(r)
except ValueError as err:
print("Refused:", err)
for b in day.day_plan():
print(b)
print(f"Revenue: ${day.revenue() / 100:.2f}")

main.py: the Figure club practice (19 to 21) clashes with Bantam A (18 to 20) and is refused. Revenue: $300 + $500 + $0 = $800.

Refused: Figure club clashes with Bantam A
13:00-15:00 Public skate (PublicSkate) $0.00
15:00-17:00 Mia's birthday (Party) $300.00
18:00-20:00 Bantam A (Practice) $500.00
Revenue: $800.00

Output of the program above (what Python prints).

Testing the package

A small test suite checks every requirement, including the edge cases: a booking before opening, a party that is too long, a clash, two bookings that only touch (20 to 21 right after 18 to 20, which is allowed because end hours are not included), and cancelling. expect_error is a tiny reusable helper that returns True if a call raises ValueError. It takes a function to call, so each test passes a lambda: a one-line function written in place, with no name.

"""test_arena.py: automatic tests for the arena package."""
from arena.models import Practice, Party
from arena.schedule import Schedule
from arena.timeutil import overlaps, hour_text
def expect_error(make):
"""Return True if calling make() raises ValueError."""
try:
make()
except ValueError:
return True
return False
assert overlaps(18, 20, 19, 21) and not overlaps(18, 19, 19, 20)
assert hour_text(7) == "07:00"
assert Practice("A", 6, 8).cost() == 50000
assert expect_error(lambda: Practice("A", 5, 7)) # before opening
assert expect_error(lambda: Party("B", 10, 13)) # party too long
day = Schedule()
day.add(Practice("A", 18, 20))
assert expect_error(lambda: day.add(Practice("B", 19, 20)))
day.add(Practice("C", 20, 21)) # touching is fine
assert day.cancel("A") == 1 and day.revenue() == 25000
print("all arena tests passed")

test_arena.py, saved next to main.py. Run it after every change. Output: all arena tests passed

Worked example

Example 2: User documentation for staff (A4.4)

Write the README section that tells arena staff how to use version 1.0.

  1. What it is: "Arena Booker keeps one day's ice bookings, refuses clashes, and totals the day's revenue."
  2. Start it: "Open a terminal in the arena_app folder and type python main.py." (In a real deployment, main.py would read requests typed by staff; this version uses a sample list.)
  3. Booking kinds and prices: Practice $250 per hour; Public skate free; Party $300 flat, up to 2 hours.
  4. Rules to know: hours use the 24-hour clock from 6 to 23; the end hour is not included, so 18 to 20 and 20 to 21 can both be booked.
  5. Messages and what to do: "X clashes with Y" means the time is taken; choose another time. "hours must be from 6 to 23" means the time is outside opening hours. "parties are at most 2 hours" means shorten the party.
  6. Version: 1.0. See CHANGELOG.md for changes.

A README like this is task-based, uses the program's exact messages, and is short enough to read before a shift. It is stored in the project folder and updated with each version.

The feature request (version 1.1)

The arena manager asks: "When a time is taken, put the request on a waitlist. If the booking is cancelled, give the slot to the first waiting group that fits."

Follow the safe process from the previous lesson: the tests already pass; the change belongs in Schedule; the waitlist is first-come, first-served, so it is a queue, and the Queue ADT from the ADT lesson can be reused unchanged in a new module, adt.py. Add, don't edit: a new request() method keeps add() exactly as it was, so all version 1.0 callers and tests keep working. The only edit to existing code is one line in cancel() that calls a new private helper.

"""Abstract data types used by the arena package."""
from collections import deque
class Queue:
"""A first-in, first-out (FIFO) collection."""
def __init__(self):
self._items = deque()
def enqueue(self, item):
self._items.append(item)
def dequeue(self):
if self.is_empty():
raise IndexError("dequeue from empty queue")
return self._items.popleft()
def is_empty(self):
return len(self._items) == 0
def __len__(self):
return len(self._items)

arena/adt.py: the Queue class, reused from the ADT lesson.

"""The Schedule class: one day's bookings, never overlapping.
Version 1.1 adds a waitlist: request() books a slot if it is free and
otherwise queues it; cancel() then books waiting requests that now fit.
"""
from arena.adt import Queue
from arena.timeutil import overlaps
def _start_of(booking):
"""Sort key: a booking's start hour."""
return booking.start
class Schedule:
"""All bookings for one day. Invariant: no two bookings overlap."""
def __init__(self):
self._bookings = []
self._waitlist = Queue() # new in 1.1
def clash(self, new):
"""Return an existing booking that overlaps new, or None."""
for b in self._bookings:
if overlaps(b.start, b.end, new.start, new.end):
return b
return None
def add(self, booking):
"""Add a booking. Raise ValueError if it clashes with another."""
other = self.clash(booking)
if other is not None:
raise ValueError(f"{booking.group} clashes with {other.group}")
self._bookings.append(booking)
def request(self, booking):
"""Book if the time is free, otherwise waitlist it. Return True if booked."""
if self.clash(booking) is None:
self._bookings.append(booking)
return True
self._waitlist.enqueue(booking)
return False
def cancel(self, group):
"""Remove every booking for group, then try the waitlist.
Return how many bookings were removed.
"""
before = len(self._bookings)
self._bookings = [b for b in self._bookings if b.group != group]
self._fill_from_waitlist() # new in 1.1
return before - len(self._bookings)
def _fill_from_waitlist(self):
"""Book waiting requests that now fit, in the order they arrived."""
still_waiting = Queue()
while not self._waitlist.is_empty():
b = self._waitlist.dequeue()
if self.clash(b) is None:
self._bookings.append(b)
else:
still_waiting.enqueue(b)
self._waitlist = still_waiting
def waiting(self):
"""Return how many requests are on the waitlist."""
return len(self._waitlist)
def day_plan(self):
"""Return the bookings sorted by start time."""
return sorted(self._bookings, key=_start_of)
def revenue(self):
"""Return the total price of all bookings, in cents."""
total = 0
for b in self._bookings:
total += b.cost()
return total

arena/schedule.py, version 1.1. New code is marked in comments; add, clash, day_plan and revenue are unchanged.

from arena.models import Practice
from arena.schedule import Schedule
day = Schedule()
print(day.request(Practice("Bantam A", 18, 20)))
print(day.request(Practice("Figure club", 19, 21)))
print("waiting:", day.waiting())
day.cancel("Bantam A")
for b in day.day_plan():
print(b)
print("waiting:", day.waiting())

demo_waitlist.py: Figure club waits, then gets the ice when Bantam A cancels.

True
False
waiting: 1
19:00-21:00 Figure club (Practice) $500.00
waiting: 0

Output of the program above (what Python prints).

"""test_arena.py: automatic tests for the arena package."""
from arena.models import Practice, Party
from arena.schedule import Schedule
from arena.timeutil import overlaps, hour_text
def expect_error(make):
"""Return True if calling make() raises ValueError."""
try:
make()
except ValueError:
return True
return False
assert overlaps(18, 20, 19, 21) and not overlaps(18, 19, 19, 20)
assert hour_text(7) == "07:00"
assert Practice("A", 6, 8).cost() == 50000
assert expect_error(lambda: Practice("A", 5, 7)) # before opening
assert expect_error(lambda: Party("B", 10, 13)) # party too long
day = Schedule()
day.add(Practice("A", 18, 20))
assert expect_error(lambda: day.add(Practice("B", 19, 20)))
day.add(Practice("C", 20, 21)) # touching is fine
assert day.cancel("A") == 1 and day.revenue() == 25000
print("all arena tests passed")

The unchanged test suite, run against version 1.1: no regressions. Output: all arena tests passed

Project checklist

Before calling a modular program finished, check: requirements written down; CRC cards or UML for the classes; modules with one theme each and no circular imports; invariants enforced by encapsulated classes; polymorphism instead of type checks; reused ADTs and helpers rather than copies; docstrings on every public module, class and method; an automatic test suite; a README for users; a changelog with a version number.

Just for fun

Version numbers are usually serious business, but not always: the TeX typesetting system's version numbers get one digit closer to π with each release (3.14, 3.141, 3.1415, and so on).

Hands-on activity

Your turn to extend the arena

You need: a computer with Python and the arena files from this lesson, typed into the folder layout shown (arena_app containing main.py, test_arena.py and an arena folder with the modules).

  1. Type in all the files and run main.py and test_arena.py. Both must match the outputs shown.
  2. Add a booking kind: Lesson (skating lessons) at $120 per hour, at most 1 hour long. Write it as a subclass in models.py. Add two tests.
  3. Add a feature: Schedule.free_slots() that returns the list of free one-hour slots, as start hours, between 6 and 23. Decompose it: you may reuse clash() with a temporary one-hour PublicSkate booking, or overlaps.
  4. Run the whole test suite after each change.
  5. Update the docstrings, add a CHANGELOG.md entry for version 1.2, and add the new booking kind and command to the README section from Example 2.
  6. Ask a family member to read only the README and tell you the price of a 2-hour practice and whether a 3-hour party is allowed.

What to look for: no existing test edited; Lesson needed no change to Schedule; free_slots for the day in this lesson includes 6 to 12, 17, and 20 to 22 (13 to 16 and 18 to 19 are taken: the 13:00 public skate, the 15:00 party, and Bantam A from 18:00).

Your turn

1

Which class is abstract in the arena design, and why?

2

What is the cost of Practice("U18 AA", 6, 9)? What does print() show for it?

3

Why does Party("Leo", 10, 13) raise ValueError, and which class enforces the rule?

4

Which module would you reuse in a gym-booking program without any change, and why?

5

Why did version 1.1 add request() instead of changing add() to waitlist clashes?

6

Trace: requests Practice A 18 to 20, Practice B 19 to 21, Practice C 20 to 22, all with request(). Then cancel("A"). What is booked, and what is waiting?

7

Name the three kinds of documentation in this project and the reader of each.

More practice: warm-up

1

What does hour_text(9) return?

2

Which file is the only one that prints for the user?

3

Which ADT is used for the waitlist?

4

Do 18 to 20 and 20 to 21 overlap?

5

What makes arena a package?

More practice: on your own

1

Write the Lesson subclass from the activity: $120 per hour, at most 1 hour.

2

Write two tests for Lesson using expect_error.

3

Write Schedule.free_slots() returning the start hours of free one-hour slots from 6 to 22.

4

Explain why revenue() needs no change when Lesson is added.

5

Write a CHANGELOG entry for versions 1.1 and 1.2.

Challenge

1

The manager wants each day saved to a text file and loaded the next morning. Which module would you add, what would its two main functions be, and how would you keep Schedule free of file code?

2

A reviewer says _fill_from_waitlist is unfair: a long request that arrived first can block shorter ones forever. Is that true with the current code? Explain with an example.

3

Draw (in words) a UML sequence of calls for day.cancel("Bantam A") in version 1.1, when Figure club is waiting.

Check your understanding

Quiz

1.

In the arena design, which principle lets revenue() work for every kind of booking without checking types?

2.

Which module in the arena package is the most reusable in other programs?

3.

What happens with day.add(Practice("B", 19, 20)) when Practice A 18 to 20 is booked?

4.

What is the price of Party("Mia", 15, 17) in cents?

5.

Why was the version 1.0 test suite run again after adding the waitlist?

6.

Where is the rule "bookings must be within opening hours" enforced?

For parents and teachers

Big idea: This lesson combines every expectation in the unit: modular programs across files (A2.1), reusable OO design (A2.2), enhancing existing code (A2.3), OO decomposition with CRC/UML and ADTs (C1.1), encapsulation (C1.2), functional decomposition (C1.3), reuse (C1.4), internal documentation (A4.3) and user documentation (A4.4). It is a model for the larger Software Development: Managing a Team Software Project unit.

How to help: The activity takes more than one sitting. Ask to see the test suite run after each change, and read the README your teen writes. Can you answer the price questions from it alone?

Common difficulties:

  • Typing errors when copying a multi-file program: check folder names and that arena/__init__.py exists.
  • Running files from inside the arena folder instead of from arena_app.
  • Editing old tests when they fail, instead of fixing the regression.
  • Putting print statements inside package modules.