Skip to main content
6 Templates · Copy & Run

Python Project Starter Templates for Coursework

Six scaffolds for the project types Python coursework hands out: a Flask web app, a Django app, a data science Jupyter notebook, an sklearn ML pipeline, a pytest package layout, and a CLI tool with argparse. Every file below is real, runnable on Python 3.10 or newer, and sized to fit a single assignment without trimming.

Python 3.10+ All files runnable as written Pinned dependencies
How to use these scaffolds

3 things to do before writing assignment code

Each template below is a working starting point sized to fit a single coursework assignment. The 3 setup moves are the same across all 6: create a virtual environment, install the pinned requirements.txt, and commit the unmodified scaffold so you have a clean revert point when an experiment breaks.

1. Always create a venv

python -m venv .venv then activate. Coursework graders sometimes test on a fresh Python install; if your code depends on a globally-installed library you forgot to list in requirements.txt, the grader's run fails and you lose points you would have kept with an isolated venv.

2. Pin every dependency

The requirements.txt in each template uses == version pins. After adding a new dependency, run pip freeze > requirements.txt so the grader installs the exact versions you tested against. Pinning fixes the single most common "works on my machine" submission failure.

3. Commit the scaffold first

Initialize a repo with git init, add the .gitignore shown in each template, then commit the untouched scaffold as your first commit. Every later experiment is one git reset --hard HEAD away from a clean restart, which matters at hour 4 of a debugging session.

Template 1 of 6

Flask web app with a JSON API endpoint

Fits CS50W, CMSC 388J, INFO 153B, and most "build a small web app" briefs. One route renders an HTML form. A second route accepts POST JSON at /api/echo and returns the message uppercased with a length count. Pinned to Flask 3.0.3.

To adapt this for a coursework brief, swap the echo function body for whatever the assignment asks (string reverse, palindrome check, temperature conversion, basic CRUD over an in-memory dict). The JSON validation pattern at the top of the route stays the same. Add new templates to templates/ and reference them with render_template. The 400 status response on invalid input is exactly what most rubrics test for.

Project tree
flask-app/
├── app.py
├── requirements.txt
├── .gitignore
├── templates/
│   └── index.html
└── static/
    └── style.css
app.py
# app.py
# Requires: pip install flask
from flask import Flask, render_template, request, jsonify

app = Flask(__name__)


@app.route("/")
def home():
    return render_template("index.html", title="Flask coursework starter")


@app.route("/api/echo", methods=["POST"])
def echo():
    payload = request.get_json(silent=True) or {}
    message = payload.get("message", "")
    if not isinstance(message, str) or not message.strip():
        return jsonify(error="message must be a non-empty string"), 400
    return jsonify(echoed=message.upper(), length=len(message))


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5000, debug=True)
requirements.txt
# requirements.txt
flask==3.0.3
templates/index.html
<!-- templates/index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>{{ title }}</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
  </head>
  <body>
    <main>
      <h1>{{ title }}</h1>
      <p>POST JSON to <code>/api/echo</code> with a <code>message</code> field.</p>
      <form id="echo-form">
        <input name="message" placeholder="type a message" required />
        <button type="submit">Echo</button>
      </form>
      <pre id="out"></pre>
    </main>
    <script>
      document.getElementById("echo-form").addEventListener("submit", async (e) => {
        e.preventDefault();
        const msg = e.target.message.value;
        const r = await fetch("/api/echo", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ message: msg }),
        });
        document.getElementById("out").textContent = JSON.stringify(await r.json(), null, 2);
      });
    </script>
  </body>
</html>
static/style.css
/* static/style.css */
body {
  font-family: -apple-system, system-ui, sans-serif;
  max-width: 640px;
  margin: 40px auto;
  padding: 0 16px;
  color: #1a1a1a;
}
input, button {
  padding: 8px 12px;
  font-size: 14px;
}
pre {
  background: #f4f4f4;
  padding: 12px;
  border-radius: 8px;
  white-space: pre-wrap;
}
.gitignore
# .gitignore
__pycache__/
*.pyc
.venv/
instance/
.env
Run it
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python app.py
# Open http://127.0.0.1:5000/
Template 2 of 6

Django app with a Book model and list-detail views

Fits CS50W, CSC 540, COMP 5347, and any brief that asks for a Django CRUD app with a single model. SQLite by default. Admin enabled. List view at /, detail view at /books/<id>/. Pinned to Django 5.0.6.

The directory layout follows Django's startproject plus startapp convention, so a grader running python manage.py check sees the same structure they expect. To adapt for a coursework brief, replace the Book fields with the model the assignment names (Student, Order, Movie). The 2 templates, book_list.html and book_detail.html, are not shown above to keep the page short; create them under core/templates/core/ with the standard {% for book in books %} loop on list and {{ book.title }} bindings on detail.

Project tree
django-app/
├── manage.py
├── requirements.txt
├── library/
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── core/
    ├── __init__.py
    ├── admin.py
    ├── apps.py
    ├── models.py
    ├── urls.py
    ├── views.py
    └── templates/
        └── core/
            ├── book_list.html
            └── book_detail.html
manage.py
#!/usr/bin/env python
# manage.py
# Requires: pip install django
import os
import sys


def main():
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "library.settings")
    from django.core.management import execute_from_command_line
    execute_from_command_line(sys.argv)


if __name__ == "__main__":
    main()
requirements.txt
# requirements.txt
django==5.0.6
library/settings.py
# library/settings.py
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "dev-only-replace-in-production"
DEBUG = True
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "core",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
]

ROOT_URLCONF = "library.urls"
TEMPLATES = [{
    "BACKEND": "django.template.backends.django.DjangoTemplates",
    "DIRS": [],
    "APP_DIRS": True,
    "OPTIONS": {"context_processors": [
        "django.template.context_processors.debug",
        "django.template.context_processors.request",
        "django.contrib.auth.context_processors.auth",
        "django.contrib.messages.context_processors.messages",
    ]},
}]
WSGI_APPLICATION = "library.wsgi.application"

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    }
}

STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
library/urls.py
# library/urls.py
from django.contrib import admin
from django.urls import path, include


urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("core.urls")),
]
core/models.py
# core/models.py
from django.db import models


class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.CharField(max_length=120)
    pages = models.PositiveIntegerField()
    published = models.DateField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["title"]

    def __str__(self) -> str:
        return f"{self.title} by {self.author}"
core/views.py
# core/views.py
from django.shortcuts import render, get_object_or_404
from .models import Book


def book_list(request):
    books = Book.objects.all()
    return render(request, "core/book_list.html", {"books": books})


def book_detail(request, pk: int):
    book = get_object_or_404(Book, pk=pk)
    return render(request, "core/book_detail.html", {"book": book})
core/urls.py
# core/urls.py
from django.urls import path
from . import views


app_name = "core"
urlpatterns = [
    path("", views.book_list, name="book_list"),
    path("books/<int:pk>/", views.book_detail, name="book_detail"),
]
core/admin.py
# core/admin.py
from django.contrib import admin
from .models import Book


@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    list_display = ("title", "author", "pages", "published")
    search_fields = ("title", "author")
    list_filter = ("published",)
Run it
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python manage.py makemigrations core
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
# Open http://127.0.0.1:8000/  and  http://127.0.0.1:8000/admin/
Template 3 of 6

Data science EDA notebook with pandas, matplotlib, seaborn

Fits DATA 100, CSE 163, DS-GA 1001, and the "exploratory analysis on a provided dataset" brief. The notebook is shown as 8 cells in order. Drop a CSV into data/, update the filename in cell 3, run all cells, and fill the 5-sentence summary at the bottom.

The cell order matters for rubric points. Imports first, then load, then shape and dtype audit, then missing-value audit, then visualizations, then correlations, then a written summary. Graders scoring DATA 100 and CSE 163 notebooks score on this exact narrative. Skipping the missing-value audit drops 5 to 10 points on every notebook rubric we have seen. The histogram grid auto-scales rows based on the column count, so the same cell works for a 4-column dataset and a 24-column dataset without edits.

Project tree
ds-notebook/
├── analysis.ipynb
├── requirements.txt
├── .gitignore
└── data/
    └── .gitkeep
requirements.txt
# requirements.txt
numpy==1.26.4
pandas==2.2.2
matplotlib==3.8.4
seaborn==0.13.2
jupyter==1.0.0
analysis.ipynb — Cell 1 (markdown)
# Cell 1 (markdown)
# EDA scaffold for coursework

Goal: load the dataset at `data/<name>.csv`, inspect shape and dtypes,
audit missing values, plot 4 distributions, compute the correlation matrix,
and write a 5-sentence summary at the bottom of the notebook.
Cell 2 (code)
# Cell 2 (code): imports and notebook config
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

pd.set_option("display.max_columns", 80)
pd.set_option("display.width", 160)
sns.set_theme(style="whitegrid")
RANDOM_STATE = 42
Cell 3 (code)
# Cell 3 (code): load the dataset
DATA_PATH = "data/dataset.csv"  # replace with the actual filename from the brief
df = pd.read_csv(DATA_PATH)
print("Shape:", df.shape)
df.head()
Cell 4 (code)
# Cell 4 (code): schema and dtypes audit
df.info()
df.describe(include="all").T
Cell 5 (code)
# Cell 5 (code): missing-value audit
missing = df.isna().sum().sort_values(ascending=False)
missing_pct = (missing / len(df) * 100).round(2)
audit = pd.DataFrame({"missing": missing, "pct": missing_pct})
audit[audit["missing"] > 0]
Cell 6 (code)
# Cell 6 (code): histograms for every numeric column
numeric_cols = df.select_dtypes(include=["int64", "float64"]).columns
n = len(numeric_cols)
fig, axes = plt.subplots(nrows=(n + 2) // 3, ncols=3, figsize=(14, 3 * ((n + 2) // 3)))
for ax, col in zip(axes.flat, numeric_cols):
    df[col].plot(kind="hist", bins=30, ax=ax, edgecolor="black")
    ax.set_title(col)
for ax in axes.flat[n:]:
    ax.set_visible(False)
plt.tight_layout()
Cell 7 (code)
# Cell 7 (code): correlation matrix
corr = df.select_dtypes(include=["int64", "float64"]).corr()
fig, ax = plt.subplots(figsize=(10, 8))
sns.heatmap(corr, annot=True, fmt=".2f", cmap="Greys", vmin=-1, vmax=1, ax=ax)
ax.set_title("Pearson correlation, numeric features")
plt.tight_layout()
Cell 8 (markdown)
# Cell 8 (markdown): 5-sentence summary

1. Dataset has <ROWS> rows and <COLUMNS> columns after load.
2. Missing values appear in <N> columns; the worst is <COL> at <PCT>%.
3. The strongest positive correlation is between <A> and <B> at r = <VAL>.
4. The distribution of <TARGET_COL> is <SHAPE> with mean <X> and median <Y>.
5. Next modelling step: <one concrete step the brief implies>.
.gitignore
# .gitignore
.ipynb_checkpoints/
__pycache__/
*.pyc
.venv/
data/*
!data/.gitkeep
Run it
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Drop the CSV from the brief into data/ then:
jupyter notebook analysis.ipynb
Template 4 of 6

sklearn ML pipeline with ColumnTransformer and GridSearchCV

Fits CS229, CMU 10-601, CSE 446, and any brief that asks for "train a classifier and report metrics". End-to-end on the built-in wine dataset: load, split, ColumnTransformer for numeric features, RandomForestClassifier, GridSearchCV over 6 hyperparameter combinations, classification report. Swap load_data with your own pd.read_csv for coursework.

The Pipeline wrapper is the rubric piece. ML graders score on whether preprocessing and the model are bundled into a single fitted object that can be saved and reloaded for prediction. Building the imputer and the scaler outside the pipeline costs the points. The stratify=y argument on the train and test split keeps class proportions stable, which matters for the wine dataset (3 classes, imbalanced row counts). For a categorical-feature problem, add a second transformer to the ColumnTransformer with OneHotEncoder(handle_unknown="ignore").

Project tree
ml-pipeline/
├── train.py
├── requirements.txt
├── data/
│   └── .gitkeep
└── tests/
    └── test_train.py
requirements.txt
# requirements.txt
numpy==1.26.4
pandas==2.2.2
scikit-learn==1.4.2
pytest==8.2.0
train.py
# train.py
# Requires: pip install scikit-learn pandas numpy
from __future__ import annotations
import argparse
import numpy as np
import pandas as pd
from sklearn.datasets import load_wine
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.metrics import classification_report
from sklearn.model_selection import GridSearchCV, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import RandomForestClassifier

RANDOM_STATE = 42


def load_data() -> tuple[pd.DataFrame, pd.Series]:
    """Load the sklearn wine dataset as (X, y). Swap with your own pd.read_csv for coursework."""
    bunch = load_wine(as_frame=True)
    return bunch.data, bunch.target


def build_pipeline(numeric_features: list[str]) -> Pipeline:
    numeric_pipe = Pipeline([
        ("impute", SimpleImputer(strategy="median")),
        ("scale", StandardScaler()),
    ])
    preprocessor = ColumnTransformer(
        transformers=[("num", numeric_pipe, numeric_features)],
        remainder="drop",
    )
    return Pipeline([
        ("pre", preprocessor),
        ("clf", RandomForestClassifier(random_state=RANDOM_STATE)),
    ])


def train(X: pd.DataFrame, y: pd.Series) -> Pipeline:
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.25, random_state=RANDOM_STATE, stratify=y,
    )
    pipe = build_pipeline(numeric_features=list(X.columns))
    grid = {
        "clf__n_estimators": [100, 300],
        "clf__max_depth": [None, 8, 16],
    }
    search = GridSearchCV(pipe, param_grid=grid, cv=5, scoring="f1_macro", n_jobs=-1)
    search.fit(X_train, y_train)
    preds = search.predict(X_test)
    print("Best params:", search.best_params_)
    print("Best CV f1_macro:", round(search.best_score_, 4))
    print("\nTest-set report:")
    print(classification_report(y_test, preds))
    return search.best_estimator_


def main() -> None:
    parser = argparse.ArgumentParser(description="Train and evaluate a wine classifier.")
    parser.add_argument("--seed", type=int, default=RANDOM_STATE)
    args = parser.parse_args()
    np.random.seed(args.seed)
    X, y = load_data()
    train(X, y)


if __name__ == "__main__":
    main()
tests/test_train.py
# tests/test_train.py
import pytest
from sklearn.pipeline import Pipeline
from train import load_data, build_pipeline


def test_load_data_shapes():
    X, y = load_data()
    assert X.shape[0] == y.shape[0]
    assert X.shape[1] >= 10
    assert set(y.unique()) <= {0, 1, 2}


def test_pipeline_fits_and_predicts():
    X, y = load_data()
    pipe = build_pipeline(numeric_features=list(X.columns))
    assert isinstance(pipe, Pipeline)
    pipe.fit(X, y)
    preds = pipe.predict(X.head(5))
    assert len(preds) == 5
    assert set(preds).issubset({0, 1, 2})
.gitignore
# .gitignore
__pycache__/
*.pyc
.venv/
.pytest_cache/
data/*
!data/.gitkeep
Run it
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python train.py
python -m pytest tests/ -v
Template 5 of 6

pytest project layout with src package and 11 tests

Fits CS50P, COMP 110, CMSC 198, and any brief that requires a tested Python package with a pyproject.toml. A 4-function calculator at src/mathkit/. 11 pytest cases covering basics, error paths, a fixture, and parametrize. The expected terminal output is shown below the test file so you know what success looks like.

The src/ layout is the layout pytest's own docs recommend and the layout most rubrics expect. The pythonpath = ["src"] line in pyproject.toml is the single configuration that makes from mathkit import add work in tests without a messy sys.path hack. The @pytest.fixture, @pytest.mark.parametrize, and pytest.raises patterns in the test file cover the 3 pytest features most CS50P and COMP 110 graders probe for.

Project tree
calculator-pkg/
├── pyproject.toml
├── requirements.txt
├── src/
│   └── mathkit/
│       ├── __init__.py
│       └── calculator.py
└── tests/
    └── test_calculator.py
pyproject.toml
# pyproject.toml
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "mathkit"
version = "0.1.0"
description = "A 4-function calculator package for coursework."
requires-python = ">=3.10"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
requirements.txt
# requirements.txt
pytest==8.2.0
src/mathkit/__init__.py
# src/mathkit/__init__.py
from .calculator import add, subtract, multiply, divide

__all__ = ["add", "subtract", "multiply", "divide"]
__version__ = "0.1.0"
src/mathkit/calculator.py
# src/mathkit/calculator.py
from __future__ import annotations
from numbers import Real


def _check(x: object, name: str) -> Real:
    if not isinstance(x, Real) or isinstance(x, bool):
        raise TypeError(f"{name} must be a real number, got {type(x).__name__}")
    return x


def add(a: Real, b: Real) -> Real:
    return _check(a, "a") + _check(b, "b")


def subtract(a: Real, b: Real) -> Real:
    return _check(a, "a") - _check(b, "b")


def multiply(a: Real, b: Real) -> Real:
    return _check(a, "a") * _check(b, "b")


def divide(a: Real, b: Real) -> float:
    _check(a, "a")
    _check(b, "b")
    if b == 0:
        raise ZeroDivisionError("divide by zero")
    return a / b
tests/test_calculator.py
# tests/test_calculator.py
import pytest
from mathkit import add, subtract, multiply, divide


@pytest.fixture
def big_pair():
    return (1_000_000, 999_999)


def test_add_basic():
    assert add(2, 3) == 5


def test_subtract_basic():
    assert subtract(10, 4) == 6


def test_multiply_basic():
    assert multiply(6, 7) == 42


def test_divide_basic():
    assert divide(20, 4) == 5.0


def test_divide_by_zero_raises():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)


def test_type_check_rejects_strings():
    with pytest.raises(TypeError):
        add("2", 3)


def test_big_pair_fixture(big_pair):
    a, b = big_pair
    assert subtract(a, b) == 1


@pytest.mark.parametrize("a, b, expected", [
    (0, 0, 0),
    (-5, 5, 0),
    (1.5, 2.5, 4.0),
    (-1.25, 0.25, -1.0),
])
def test_add_parametrized(a, b, expected):
    assert add(a, b) == expected
Expected pytest output
# Expected output from: python -m pytest -v
==================== test session starts ====================
collected 11 items

tests/test_calculator.py::test_add_basic PASSED                [  9%]
tests/test_calculator.py::test_subtract_basic PASSED           [ 18%]
tests/test_calculator.py::test_multiply_basic PASSED           [ 27%]
tests/test_calculator.py::test_divide_basic PASSED             [ 36%]
tests/test_calculator.py::test_divide_by_zero_raises PASSED    [ 45%]
tests/test_calculator.py::test_type_check_rejects_strings PASSED [ 54%]
tests/test_calculator.py::test_big_pair_fixture PASSED         [ 63%]
tests/test_calculator.py::test_add_parametrized[0-0-0] PASSED  [ 72%]
tests/test_calculator.py::test_add_parametrized[-5-5-0] PASSED [ 81%]
tests/test_calculator.py::test_add_parametrized[1.5-2.5-4.0] PASSED [ 90%]
tests/test_calculator.py::test_add_parametrized[-1.25-0.25--1.0] PASSED [100%]

==================== 11 passed in 0.18s ====================
Run it
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pip install -e .
python -m pytest -v
Template 6 of 6

CLI tool with argparse subcommands and tested error paths

Fits CS50P final-project briefs, scripting coursework, and any "build a small CLI tool" assignment. Two subcommands: summary describes a CSV, filter filters rows where a named column equals a value. The CLI exits with status code 2 on missing files or missing columns. 4 pytest cases cover both happy paths and both error paths.

The build_parser function returns the parser independently of main, which lets the test suite call main(["summary", "-i", path]) directly without forking a subprocess. That is the pattern professional Python CLIs use (Click, Typer, and the stdlib examples all follow it). Subcommands are added with subs.add_parser and dispatched with args.func(args), which scales to 4 or 5 subcommands without restructuring. For a coursework brief that asks for a single command, delete add_subparsers and put the arguments directly on the top-level parser.

Project tree
csv-cli/
├── cli.py
├── requirements.txt
└── tests/
    └── test_cli.py
requirements.txt
# requirements.txt
pandas==2.2.2
pytest==8.2.0
cli.py
# cli.py
# Requires: pip install pandas
from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path
import pandas as pd


def setup_logging(verbose: bool) -> None:
    level = logging.DEBUG if verbose else logging.INFO
    logging.basicConfig(level=level, format="%(asctime)s [%(levelname)s] %(message)s")


def cmd_summary(args: argparse.Namespace) -> int:
    df = pd.read_csv(args.input)
    logging.debug("loaded %d rows, %d columns from %s", len(df), df.shape[1], args.input)
    summary = df.describe(include="all").T
    if args.output:
        summary.to_csv(args.output)
        logging.info("wrote summary to %s", args.output)
    else:
        print(summary)
    return 0


def cmd_filter(args: argparse.Namespace) -> int:
    df = pd.read_csv(args.input)
    if args.column not in df.columns:
        logging.error("column %r not in %s; available: %s",
                      args.column, args.input, list(df.columns))
        return 2
    out = df[df[args.column] == args.value]
    logging.info("kept %d of %d rows where %s == %r",
                 len(out), len(df), args.column, args.value)
    out.to_csv(args.output, index=False)
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="csv-cli", description="Quick CSV operations for coursework.")
    parser.add_argument("--verbose", "-v", action="store_true", help="enable debug logging")
    subs = parser.add_subparsers(dest="command", required=True)

    p_sum = subs.add_parser("summary", help="describe(include='all') for a CSV")
    p_sum.add_argument("--input", "-i", type=Path, required=True)
    p_sum.add_argument("--output", "-o", type=Path, default=None)
    p_sum.set_defaults(func=cmd_summary)

    p_flt = subs.add_parser("filter", help="filter rows where column == value")
    p_flt.add_argument("--input", "-i", type=Path, required=True)
    p_flt.add_argument("--output", "-o", type=Path, required=True)
    p_flt.add_argument("--column", "-c", required=True)
    p_flt.add_argument("--value", required=True)
    p_flt.set_defaults(func=cmd_filter)
    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    setup_logging(args.verbose)
    try:
        return args.func(args)
    except FileNotFoundError as exc:
        logging.error("file not found: %s", exc.filename)
        return 2


if __name__ == "__main__":
    sys.exit(main())
tests/test_cli.py
# tests/test_cli.py
import pandas as pd
import pytest
from pathlib import Path
from cli import main


@pytest.fixture
def sample_csv(tmp_path: Path) -> Path:
    p = tmp_path / "input.csv"
    pd.DataFrame({
        "id": [1, 2, 3, 4],
        "grade": ["A", "B", "A", "C"],
        "score": [92, 81, 95, 67],
    }).to_csv(p, index=False)
    return p


def test_summary_prints_to_stdout(sample_csv, capsys):
    rc = main(["summary", "-i", str(sample_csv)])
    captured = capsys.readouterr()
    assert rc == 0
    assert "score" in captured.out


def test_filter_writes_matching_rows(sample_csv, tmp_path):
    out = tmp_path / "out.csv"
    rc = main(["filter", "-i", str(sample_csv), "-o", str(out), "-c", "grade", "--value", "A"])
    assert rc == 0
    result = pd.read_csv(out)
    assert len(result) == 2
    assert set(result["score"]) == {92, 95}


def test_filter_missing_column_returns_2(sample_csv, tmp_path):
    out = tmp_path / "out.csv"
    rc = main(["filter", "-i", str(sample_csv), "-o", str(out), "-c", "nope", "--value", "x"])
    assert rc == 2


def test_missing_input_file_returns_2(tmp_path):
    rc = main(["summary", "-i", str(tmp_path / "nope.csv")])
    assert rc == 2
Run it
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python cli.py --help
python cli.py summary -i data/students.csv -o summary.csv
python cli.py filter -i data/students.csv -o passed.csv -c grade --value A
python -m pytest tests/ -v
Adaptation patterns

5 swaps to fit any brief

The 6 templates cover the structural patterns. Most coursework adaptations come down to 5 small swaps inside the scaffold rather than a rewrite. Apply them in order before adding new code.

Swap 1: the dataset

Replace load_wine() in the sklearn template or the hardcoded CSV path in the data science template with the file the brief provides. Drop the CSV into data/ so paths are reproducible across machines.

Swap 2: the model class

Replace RandomForestClassifier with whatever the brief specifies (LogisticRegression, KNeighborsClassifier, SVC, GradientBoostingClassifier). The Pipeline wrapper does not change. The GridSearchCV grid keys do; rename clf__n_estimators to the new model's hyperparameter name.

Swap 3: the route or view

In the Flask template, swap the body of /api/echo for the assignment's logic and rename the route. In the Django template, swap the Book model fields and the 2 view functions. The URL patterns and template names follow the model name.

Swap 4: the parametrize cases

In the pytest template, the parametrize block at the bottom of test_calculator.py is the example cases for add. Duplicate the block for every function the assignment defines and fill in the input/output tuples from the brief's example I/O.

Swap 5: the Python version

Every template targets Python 3.10 or newer because that is the version most US and EU universities have standardized on for the 2025 and 2026 academic years. If the brief mandates Python 3.9, replace the list[int] annotation syntax with List[int] plus an from typing import List import. The from __future__ import annotations line at the top of most files makes this swap a one-line change for type-hint syntax. For Python 3.8, additionally replace match statements (none used here) and walrus operator usage (none used here).

Need this template wired to your specific brief?

Send the assignment PDF and the template you want to start from. A named Python developer adapts the scaffold to your dataset, rubric, and Python version. Starts at $29. Pay 50% to start, 50% after the code runs on your data.