Unit 16 Project: Build and Ship Your Capstone
Five days, one smallest useful version, and someone else able to run it
In this lesson
In Unit 15's project you wrote a plan. This lesson turns it into a thing that runs, on a schedule short enough that you will finish it: five days of a few hours each.
The shape is the same whichever track you chose. Cut the scope, build a core you can test without an interface, give it somewhere to keep data, put the thinnest possible interface on top, then make it runnable by someone else. That last step is the one people skip, and it is the one that makes it a project rather than a folder.
Explain it like I’m 5
Shipping means making a small version real enough that someone else can try it. Not finished. Real.
Start from the plan, then cut it in half
Open your plan from Unit 15 and find the feature list. Now cut it to the features without which the thing has no point at all.
That is not modesty, it is arithmetic: version 0.1 is the version that exists. Every feature you add before it works is a feature you might build twice, because the shape of the first three often changes what the fourth should be.
FEATURES = [
("add a link with a title", 2, True),
("list saved links", 2, True),
("mark one as read", 1, True),
("tags and tag filtering", 5, False),
("full-text search", 6, False),
("email digest every Friday", 8, False),
("dark mode", 3, False),
]
def split_scope(features):
"""The smallest useful version, and everything that waits."""
core = [(name, hours) for name, hours, is_core in features if is_core]
later = [name for name, _hours, is_core in features if not is_core]
return core, later
core, later = split_scope(FEATURES)
estimated = sum(hours for _name, hours in core)
print(f"v0.1: {estimated}h estimated, so budget {estimated * 2}h")
for name, hours in core:
print(f" {hours}h {name}")
print(f"later: {len(later)} ideas, none of them blocking")
for name in later:
print(f" {name}")
v0.1: 5h estimated, so budget 10h
2h add a link with a title
2h list saved links
1h mark one as read
later: 4 ideas, none of them blocking
tags and tag filtering
full-text search
email digest every Friday
dark mode
Build the core first, and keep it away from the interface
The single most useful structural decision is to write the logic as plain functions that know nothing about screens, requests, or files — then put the interface on top.
The reason is testing. A function that takes data and returns data can be checked in one line. The same logic tangled into a Flask route needs a running server and a browser to check at all.
def add_link(links, url, title):
"""Add a link, refusing a duplicate URL. Returns the new count."""
if any(link["url"] == url for link in links):
raise ValueError(f"already saved: {url}")
links.append({"url": url, "title": title, "read": False})
return len(links)
def mark_read(links, url):
"""Mark one link as read. True if that changed anything."""
for link in links:
if link["url"] == url:
was_read = link["read"]
link["read"] = True
return not was_read
return False
def summary(links):
"""One line describing the whole list."""
unread = sum(1 for link in links if not link["read"])
return f"{len(links)} saved, {unread} unread"
READING_LIST = []
add_link(READING_LIST, "https://example.com/a", "Descriptors explained")
add_link(READING_LIST, "https://example.com/b", "Reading a signature")
print(summary(READING_LIST))
print("marked read:", mark_read(READING_LIST, "https://example.com/a"))
print("again: ", mark_read(READING_LIST, "https://example.com/a"))
print(summary(READING_LIST))
try:
add_link(READING_LIST, "https://example.com/a", "Descriptors explained")
except ValueError as error:
print("refused: ", error)
2 saved, 2 unread marked read: True again: False 2 saved, 1 unread refused: already saved: https://example.com/a
Give it somewhere to keep things
Right now everything vanishes when the program ends, which is the problem Unit 8 exists to solve. For a capstone, sqlite3 is almost always the right answer: it is in the standard library, it is one file on disk, and it needs no server.
import sqlite3
connection = sqlite3.connect(":memory:") # a real file in your project
connection.execute("""
CREATE TABLE links (
url TEXT PRIMARY KEY,
title TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0
)
""")
ROWS = [("https://example.com/a", "Descriptors explained"),
("https://example.com/b", "Reading a signature")]
connection.executemany("INSERT INTO links (url, title) VALUES (?, ?)", ROWS)
connection.execute("UPDATE links SET read = 1 WHERE url = ?", (ROWS[0][0],))
connection.commit()
for title, read in connection.execute(
"SELECT title, read FROM links ORDER BY title"):
print(f"{'read ' if read else 'unread'} {title}")
(unread,) = connection.execute(
"SELECT COUNT(*) FROM links WHERE read = 0").fetchone()
print("unread count:", unread)
read Descriptors explained unread Reading a signature unread count: 1
The interface is the thin part
With the core built and tested, the interface becomes almost boring — which is the sign you did it in the right order. Whether it is a command-line tool or a Flask app, its only jobs are to collect input, call your functions, and show the result.
from flask import Flask, redirect, render_template, request, url_for
from reading_list.core import add_link, all_links, mark_read, summary
from reading_list.storage import connect
app = Flask(__name__)
@app.route("/")
def index():
links = all_links(connect())
return render_template("index.html", links=links, summary=summary(links))
@app.route("/add", methods=["POST"])
def add():
try:
add_link(connect(), request.form["url"], request.form["title"])
except ValueError as error:
return render_template("index.html", links=all_links(connect()),
summary="", error=str(error)), 400
return redirect(url_for("index"))
# A Flask app cannot run in the browser sandbox. Locally: # flask --app app run # * Running on http://127.0.0.1:5000
Five days, and what each one ends with
Each day ends with something that runs. That constraint is what stops day four being the first time you find out nothing works together.
Day 1 Setup a folder, a virtual environment, git init, one commit.
Ends with: an empty program that runs and prints its name.
Day 2 The core the plain functions that hold your logic.
Ends with: those functions callable from a Python prompt.
Day 3 Storage + input sqlite3 behind the core, and the thinnest interface.
Ends with: data that survives the program exiting.
Day 4 Tests + errors pytest over the core, and a clear message per bad input.
Ends with: a green test run and no traceback for a typo.
Day 5 Ship it README, install steps, sample data, real output pasted in.
Ends with: someone else can clone it and run it.
Commit as you go
Version control is not paperwork here, it is the ability to try something and get back. Commit at the end of every session with a message that says what changed, and push somewhere — a project that exists only on one laptop is one spilled coffee from never having existed.
git init
git add .
git commit -m "Reading list: core add/list/mark-read functions"
# after each session
git add -A
git commit -m "Store links in SQLite instead of a list"
# once, then git push on its own from then on
git remote add origin [email protected]:you/reading-list.git
git push -u origin main
Shipping means someone else can run it
Here is the bar, and it is lower than “finished”: a person who has never seen your project can clone it and run it without asking you anything.
Which comes down to a short list: a README that says what it does and how to install it, an install path that works from a clean checkout, sample data if it needs data, a clear message rather than a traceback when input is wrong, and no secrets in the repository. Unit 15's portfolio lesson has the README template; this is the checklist that says whether you are done.
Score your own project honestly. blocking() returns everything still stopping someone else from using it, sorted; shippable() is true only when nothing is outstanding. Build the second on top of the first, so they can never disagree.
# "Shipped" means someone else can run it. That is the whole bar.
CHECKS = {
"runs from a fresh clone": True,
"README says what it does": True,
"README says how to install it": True,
"one test for the core behavior": True,
"a clear message when the input is wrong": False,
"no secrets in the repository": True,
}
def blocking(checks):
"""Everything still stopping someone else from using this."""
return [] # TODO: the items whose value is False, sorted
def shippable(checks):
"""Shippable means nothing is outstanding."""
return False # TODO: build this on top of blocking()
print("outstanding:", blocking(CHECKS))
print("shippable: ", shippable(CHECKS))
CHECKS["a clear message when the input is wrong"] = True
print("after fixing that:", shippable(CHECKS))
blocking is one comprehension over checks.items() keeping the item when not done, wrapped in sorted(). Then shippable is not blocking(checks) — deriving it means a rule added to one is automatically respected by the other.
# "Shipped" means someone else can run it. That is the whole bar.
CHECKS = {
"runs from a fresh clone": True,
"README says what it does": True,
"README says how to install it": True,
"one test for the core behavior": True,
"a clear message when the input is wrong": False,
"no secrets in the repository": True,
}
def blocking(checks):
"""Everything still stopping someone else from using this."""
return sorted(item for item, done in checks.items() if not done)
def shippable(checks):
"""Shippable means nothing is outstanding."""
return not blocking(checks)
print("outstanding:", blocking(CHECKS))
print("shippable: ", shippable(CHECKS))
CHECKS["a clear message when the input is wrong"] = True
print("after fixing that:", shippable(CHECKS))
outstanding: ['a clear message when the input is wrong']
shippable: False
after fixing that: True
Common mistake: Adding features before the core works
The new feature is more interesting than finishing the boring one.
Get the three core features working end to end first, even crudely. Everything else is an idea for version 0.2, and some of those ideas will change once 0.1 exists.
Common mistake: Building the interface first
It is the visible part, so it feels like the most progress.
Write the logic as plain functions and test them from a Python prompt. Then the interface is a thin translation layer, and you can change your mind about whether it is a web page or a command.
Common mistake: Keeping the project only on one computer
Pushing feels like something you do when it is presentable.
Push on day one, when there is nothing to be embarrassed about. Private repositories are free, and a project only on one laptop is one accident from gone.
Common mistake: No instructions for anyone else
You know how to run it, so the gap is invisible from where you are standing.
Write the install and run commands, then follow them yourself from a fresh clone in a new folder. You will find two steps you had forgotten you ever did.
Common mistake: Waiting for it to be good before showing anyone
You can see every rough edge and assume everyone else will lead with those too.
Ship version 0.1 with a “what I would add next” section. Naming the limits yourself reads as judgment, and nothing is ever finished anyway.
What is the smallest useful version?
For a reading list: save a link, show the list, mark it read. Tags, search and dark mode are improvements to something that has to exist first.
Why write the logic as plain functions before any interface?
Data in, data out is checkable with a single assert. The same logic inside a route needs a running server to exercise at all.
What should each of the five days end with?
Ending each session with something runnable is what stops day four being the first time you discover the pieces do not fit together.
A feature is taking far longer than the whole day you allowed it. What now?
A finished three-feature tool beats an unfinished five-feature one. The cut features become the section that shows you know where the limits are.
Mini exercise (hard)
Turn a feature list into a five-day plan. schedule(features, hours_per_day, days=5) returns (day, feature, hours) for everything that fits, filling each day up to hours_per_day before moving to the next, in the order given. A feature larger than a whole day fits nowhere, so it is skipped. cut() then reports what did not make it.
Try it yourself. Complete the snippet and hit Run; it executes in your browser, no setup required.
FEATURES = [
("save a link with its title", 3),
("list what I saved", 2),
("mark one as read", 2),
("tag filtering", 5),
("weekly email digest", 8),
]
def schedule(features, hours_per_day, days=5):
"""(day, feature, hours) for everything that fits, in order."""
return [] # TODO: fill each day up to hours_per_day, then move on
# TODO: a feature bigger than one day goes nowhere - skip it
def cut(features, hours_per_day, days=5):
"""The features that did not fit at all."""
return [] # TODO: build this from schedule()
plan = schedule(FEATURES, 4)
for day, feature, hours in plan:
print(f"day {day} {hours}h {feature}")
print("cut:", cut(FEATURES, 4))
Track the current day and the hours used on it. continue past anything bigger than hours_per_day. When the next feature would overflow the day, move to the next day and reset used; when the day number passes days, stop. Build cut by asking schedule what it kept, rather than reimplementing the arithmetic.
FEATURES = [
("save a link with its title", 3),
("list what I saved", 2),
("mark one as read", 2),
("tag filtering", 5),
("weekly email digest", 8),
]
def schedule(features, hours_per_day, days=5):
"""(day, feature, hours) for everything that fits, in order."""
rows = []
day = 1
used = 0
for feature, hours in features:
if hours > hours_per_day:
continue
if used + hours > hours_per_day:
day += 1
used = 0
if day > days:
break
rows.append((day, feature, hours))
used += hours
return rows
def cut(features, hours_per_day, days=5):
"""The features that did not fit at all."""
kept = {feature for _day, feature, _hours in schedule(features, hours_per_day, days)}
return [feature for feature, _hours in features if feature not in kept]
plan = schedule(FEATURES, 4)
for day, feature, hours in plan:
print(f"day {day} {hours}h {feature}")
print("cut:", cut(FEATURES, 4))
day 1 3h save a link with its title
day 2 2h list what I saved
day 2 2h mark one as read
cut: ['tag filtering', 'weekly email digest']
plan = schedule(FEATURES, 4)
assert [row[0] for row in plan] == [1, 2, 2], "3h fills day 1; 2h + 2h share day 2"
assert cut(FEATURES, 4) == ["tag filtering", "weekly email digest"], "both are bigger than a 4-hour day, so neither fits anywhere"
assert schedule([], 4) == [], "no features, no plan"
assert schedule([("a", 4), ("b", 4)], 4) == [(1, "a", 4), (2, "b", 4)], "a feature that exactly fills a day is fine"
assert schedule([("a", 2)] * 12, 4, days=2) == [(1, "a", 2), (1, "a", 2), (2, "a", 2), (2, "a", 2)], "stop once the days run out"
assert cut([("a", 9)], 4) == ["a"], "a 9-hour feature cannot fit a 4-hour day"
print("\u2713 Looks good!")