
Python Course
Mini Python Project — Student Grade Tracker
This is your capstone lesson. You are going to build a complete, real-world Python application from scratch — a Student Grade Tracker CLI. It manages a class of students, records subject scores, persists data to CSV, computes statistics, and generates a formatted report. Every feature uses concepts from earlier lessons: OOP, encapsulation, file I/O, CSV, exception handling, data analysis with NumPy, and clean architecture.
Read through each section carefully. Every code block is explained line by line so you understand not just what the code does but why it is written that way.
PROJECT OVERVIEWWhat We Are Building
The Student Grade Tracker is a command-line application with five core features:
- Add students with an ID and name
- Record scores per subject per student
- Compute statistics — average, highest, lowest, grade letter
- Persist data to a CSV file so records survive program restarts
- Print a report — a formatted class summary with rankings
Concepts Used in This Project
📚 Object-Oriented Programming (Lessons 31–35)
The Student class encapsulates each student's data and behaviour. The GradeTracker class manages the collection of students. This keeps all logic organised, testable, and extensible — adding a new feature means adding a method, not rewriting scattered code.
📄 CSV File I/O (Lessons 19, 42)
Student records are saved to and loaded from a CSV file using csv.DictWriter and csv.DictReader. This gives the tracker persistent memory across sessions — data is never lost when the program exits.
🔥 Exception Handling (Lesson 20)
Every input that could fail is wrapped in try/except. Duplicate student IDs raise a custom DuplicateStudentError. Invalid scores raise ValueError. This prevents crashes and gives clear, helpful error messages.
📊 NumPy Statistics (Lesson 43)
Score statistics — mean, standard deviation, percentile rank — are computed using NumPy vectorised operations. This is identical to how data analysts work on real datasets.
✅ Decorators (Lesson 36)
A @log_action decorator logs every significant operation with a timestamp. This is how production applications track activity — the decorator adds logging without cluttering business logic methods.
File Structure
grade_tracker/
├── grade_tracker.py # main application — all classes and logic
└── students.csv # auto-created on first saveWe keep everything in one file to stay focused on Python concepts. In a production app you would split models.py, storage.py, reports.py, and main.py into separate modules.
Custom Exception and Logging Decorator
We start with two small but important pieces: a custom exception class and a logging decorator. Writing custom exceptions makes error handling specific and readable — DuplicateStudentError is far more informative than a plain ValueError. The decorator adds timestamped logging to any method without modifying the method itself.
import csv
import os
import numpy as np
from datetime import datetime
from functools import wraps
# ──────────────────────────────────────────────
# Custom exception
# ──────────────────────────────────────────────
class DuplicateStudentError(Exception):
"""Raised when a student ID already exists in the tracker."""
pass
# ──────────────────────────────────────────────
# Logging decorator
# ──────────────────────────────────────────────
def log_action(func):
"""Decorator — prints a timestamped log line for every significant action."""
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
timestamp = datetime.now().strftime("%H:%M:%S")
print(f" [{timestamp}] ✓ {func.__name__} completed")
return result
return wrapperWhy a custom exception? Raising DuplicateStudentError lets the caller catch exactly that error. If we raised a generic ValueError, the caller would not know if the value was bad or the ID was a duplicate — it would have to inspect the error message string, which is fragile.
Why a decorator? The @log_action decorator separates the what (add a student, save data) from the how (log it). Every method decorated with it gets automatic timestamped logging without a single extra line inside the method body.
The Student Class
The Student class represents one student and all their scores. The class uses @property for computed attributes like average and grade so they are always up to date — when a new score is added, the average recalculates automatically.
class Student:
"""
Represents one student and their subject scores.
Attributes:
student_id (str): Unique identifier (e.g. "S001")
name (str): Full name
scores (dict): {subject: score} — scores are 0–100 floats
"""
GRADE_BOUNDARIES = [
(90, "A+"), (80, "A"), (70, "B"), (60, "C"), (50, "D"), (0, "F")
]
def __init__(self, student_id: str, name: str):
if not student_id.strip():
raise ValueError("Student ID cannot be empty.")
if not name.strip():
raise ValueError("Student name cannot be empty.")
self._id = student_id.strip().upper()
self._name = name.strip().title()
self._scores: dict[str, float] = {}
# ── Read-only properties ──────────────────
@property
def student_id(self) -> str:
return self._id
@property
def name(self) -> str:
return self._name
@property
def scores(self) -> dict:
return dict(self._scores) # return a copy — protects internal state
# ── Computed properties ───────────────────
@property
def average(self) -> float:
"""Mean score across all subjects. Returns 0.0 if no scores recorded."""
if not self._scores:
return 0.0
return float(np.mean(list(self._scores.values())))
@property
def grade(self) -> str:
"""Letter grade derived from the average score."""
avg = self.average
for threshold, letter in self.GRADE_BOUNDARIES:
if avg >= threshold:
return letter
return "F"
@property
def highest_score(self) -> tuple[str, float]:
"""Returns (subject, score) for the best subject."""
if not self._scores:
return ("None", 0.0)
best = max(self._scores, key=self._scores.get)
return (best, self._scores[best])
@property
def lowest_score(self) -> tuple[str, float]:
"""Returns (subject, score) for the weakest subject."""
if not self._scores:
return ("None", 0.0)
worst = min(self._scores, key=self._scores.get)
return (worst, self._scores[worst])
# ── Methods ───────────────────────────────
def add_score(self, subject: str, score: float) -> None:
"""Record a score for a subject. Score must be between 0 and 100."""
subject = subject.strip().title()
if not (0 <= score <= 100):
raise ValueError(f"Score {score} is out of range (0–100).")
self._scores[subject] = round(float(score), 2)
def to_dict(self) -> dict:
"""Serialise to a flat dict suitable for CSV writing."""
return {
"student_id": self._id,
"name": self._name,
"scores": str(self._scores), # store as string repr
}
def __repr__(self) -> str:
return (f"Student(id={self._id!r}, name={self._name!r}, "
f"avg={self.average:.1f}, grade={self.grade!r})")Why .strip().upper() on the ID and .strip().title() on the name? Normalising input at construction time means "s001", "S001", and " S001 " all become the same ID. You handle messy user input once, at the boundary, rather than throughout the rest of the code.
Why return dict(self._scores) instead of self._scores? Returning the internal dict directly allows external code to modify it without calling add_score, bypassing validation. Returning a copy enforces encapsulation.
Why GRADE_BOUNDARIES as a class attribute? It is shared state that belongs to the concept of a Student, not to any individual student. Storing it on the class means changing the grading scale only requires editing one place.
The GradeTracker Class
The GradeTracker class manages the collection of students. It handles adding students, recording scores, saving and loading from CSV, and generating the report. The @log_action decorator is applied to the methods that modify state.
class GradeTracker:
"""
Manages a collection of Student objects.
Responsibilities:
- Add / look up students
- Record scores
- Persist to / load from CSV
- Generate class report
"""
CSV_FILE = "students.csv"
SUBJECTS = ["Maths", "English", "Science", "History", "Art"]
def __init__(self):
self._students: dict[str, Student] = {} # {student_id: Student}
self._load() # restore previous session on startup
# ── Internal helpers ──────────────────────
def _get(self, student_id: str) -> Student:
"""Retrieve a student by ID or raise KeyError with a helpful message."""
sid = student_id.strip().upper()
if sid not in self._students:
raise KeyError(f"No student found with ID '{sid}'.")
return self._students[sid]
# ── CSV Persistence ───────────────────────
def _load(self) -> None:
"""Load students from CSV on startup. Silently skips if file missing."""
if not os.path.exists(self.CSV_FILE):
return
with open(self.CSV_FILE, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
s = Student(row["student_id"], row["name"])
# ast.literal_eval safely parses the stored dict string
import ast
scores = ast.literal_eval(row["scores"]) if row["scores"] != "{}" else {}
for subject, score in scores.items():
s.add_score(subject, score)
self._students[s.student_id] = s
print(f" Loaded {len(self._students)} student(s) from {self.CSV_FILE}")
@log_action
def save(self) -> None:
"""Write all students to CSV."""
with open(self.CSV_FILE, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["student_id", "name", "scores"])
writer.writeheader()
for s in self._students.values():
writer.writerow(s.to_dict())
# ── Student management ────────────────────
@log_action
def add_student(self, student_id: str, name: str) -> Student:
"""Add a new student. Raises DuplicateStudentError if ID already exists."""
sid = student_id.strip().upper()
if sid in self._students:
raise DuplicateStudentError(
f"Student ID '{sid}' already exists. Use a unique ID."
)
student = Student(student_id, name)
self._students[sid] = student
return student
@log_action
def record_score(self, student_id: str, subject: str, score: float) -> None:
"""Record a subject score for a student."""
student = self._get(student_id)
student.add_score(subject, score)
def get_student(self, student_id: str) -> Student:
return self._get(student_id)
def all_students(self) -> list[Student]:
return list(self._students.values())
# ── Class-level statistics ─────────────────
def class_average(self) -> float:
"""Mean of all student averages. Returns 0.0 for empty tracker."""
students = self.all_students()
if not students:
return 0.0
return float(np.mean([s.average for s in students]))
def top_students(self, n: int = 3) -> list[Student]:
"""Return the top N students by average score."""
return sorted(self.all_students(),
key=lambda s: s.average, reverse=True)[:n]
def at_risk_students(self, threshold: float = 50.0) -> list[Student]:
"""Return students whose average is below the threshold."""
return [s for s in self.all_students() if s.average < threshold]
def subject_averages(self) -> dict[str, float]:
"""Compute the class average for each subject."""
subject_scores: dict[str, list] = {}
for s in self.all_students():
for subj, score in s.scores.items():
subject_scores.setdefault(subj, []).append(score)
return {subj: round(float(np.mean(scores)), 2)
for subj, scores in subject_scores.items()}Why use a dict keyed by student_id? Dictionary lookup is O(1) — finding a student by ID is instant regardless of how many students exist. A list would require scanning every element.
Why ast.literal_eval for loading scores? The scores dict is stored as its string representation. ast.literal_eval safely parses it back to a Python dict — it only evaluates literals (no arbitrary code), making it safe for user-controlled data.
Why setdefault in subject_averages? It initialises the list for a subject if it does not yet exist, in a single expression. The alternative — an if/else on every iteration — is more verbose and harder to read.
The Report Generator
The report is a formatted text output. Good output design matters — a readable, well-aligned report is what separates a script from a tool someone actually wants to use.
def print_report(tracker: GradeTracker) -> None:
"""Print a full formatted class report to the terminal."""
students = tracker.all_students()
if not students:
print(" No students recorded yet.")
return
# Sort by average descending for the ranking
ranked = sorted(students, key=lambda s: s.average, reverse=True)
# ── Header ──────────────────────────────────────────────────────────
width = 72
print("\n" + "=" * width)
print(" STUDENT GRADE TRACKER — CLASS REPORT ".center(width))
print(f" Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')} ".center(width))
print("=" * width)
# ── Class summary ────────────────────────────────────────────────────
class_avg = tracker.class_average()
top = ranked[0]
bottom = ranked[-1]
at_risk = tracker.at_risk_students()
subj_avgs = tracker.subject_averages()
print(f"\n Total students : {len(students)}")
print(f" Class average : {class_avg:.1f} ({_grade_from_avg(class_avg)})")
print(f" Top performer : {top.name} ({top.average:.1f})")
print(f" Needs support : {bottom.name} ({bottom.average:.1f})")
print(f" At-risk (<50) : {len(at_risk)} student(s)")
if subj_avgs:
best_subj = max(subj_avgs, key=subj_avgs.get)
worst_subj = min(subj_avgs, key=subj_avgs.get)
print(f" Strongest subj : {best_subj} ({subj_avgs[best_subj]:.1f})")
print(f" Weakest subj : {worst_subj} ({subj_avgs[worst_subj]:.1f})")
# ── Per-student table ────────────────────────────────────────────────
print("\n" + "-" * width)
print(f" {'Rank':<5} {'ID':<8} {'Name':<20} {'Avg':>6} {'Grade':>6} Scores")
print("-" * width)
for rank, s in enumerate(ranked, start=1):
score_str = " ".join(f"{subj}:{score:.0f}"
for subj, score in sorted(s.scores.items()))
medal = {1: "🏉", 2: "🥈", 3: "🥉"}.get(rank, " ")
print(f" {medal}{rank:<4} {s.student_id:<8} {s.name:<20} "
f"{s.average:>6.1f} {s.grade:>6} {score_str}")
# ── Subject averages ─────────────────────────────────────────────────
if subj_avgs:
print("\n" + "-" * width)
print(" Subject Averages:")
for subj, avg in sorted(subj_avgs.items()):
bar = "█" * int(avg / 5) # max 20 blocks at score=100
grade = _grade_from_avg(avg)
print(f" {subj:<12} {avg:>5.1f} {grade} {bar}")
# ── At-risk students ─────────────────────────────────────────────────
if at_risk:
print("\n" + "-" * width)
print(" ⚠ At-Risk Students (average < 50):")
for s in at_risk:
print(f" {s.name} ({s.student_id}) — avg {s.average:.1f}")
print("\n" + "=" * width + "\n")
def _grade_from_avg(avg: float) -> str:
"""Helper — letter grade from a numeric average."""
for threshold, letter in Student.GRADE_BOUNDARIES:
if avg >= threshold:
return letter
return "F"Why sort twice — once for ranked and once for subject averages? Each sort serves a different purpose: student ranking descends by average; subject averages display alphabetically. Keeping them separate makes both clear.
Why the bar chart with █? A visual bar makes relative subject performance scannable at a glance — you can see which subject the class struggles with before reading any number. This is exactly how data dashboards work.
The Main Program — Putting It All Together
def main():
print("\n" + "=" * 50)
print(" STUDENT GRADE TRACKER".center(50))
print("=" * 50)
tracker = GradeTracker() # loads from CSV automatically
# ── Add students ──────────────────────────────────
print("\n--- Adding Students ---")
try:
tracker.add_student("S001", "alice smith")
tracker.add_student("S002", "diana osei")
tracker.add_student("S003", "charlie brown")
tracker.add_student("S004", "bob jones")
tracker.add_student("S001", "duplicate") # will raise error
except DuplicateStudentError as e:
print(f" Caught: {e}")
except ValueError as e:
print(f" Validation error: {e}")
# ── Record scores ─────────────────────────────────
print("\n--- Recording Scores ---")
score_data = [
("S001", "Maths", 90), ("S001", "English", 88), ("S001", "Art", 95),
("S002", "Maths", 84), ("S002", "English", 80),
("S003", "Maths", 72), ("S003", "Science", 72),
("S004", "Maths", 60), ("S004", "English", 45),
]
for sid, subj, score in score_data:
try:
tracker.record_score(sid, subj, score)
except (KeyError, ValueError) as e:
print(f" Error: {e}")
# ── Individual lookups ────────────────────────────
print("\n--- Individual Student Info ---")
alice = tracker.get_student("S001")
print(f" {alice}")
best_subj, best_score = alice.highest_score
worst_subj, worst_score = alice.lowest_score
print(f" Best subject : {best_subj} ({best_score})")
print(f" Weak subject : {worst_subj} ({worst_score})")
# ── Save ──────────────────────────────────────────
print("\n--- Saving Data ---")
tracker.save()
# ── Full report ───────────────────────────────────
print_report(tracker)
if __name__ == "__main__":
main()Why if __name__ == "__main__"? This guard ensures main() only runs when the file is executed directly — not when it is imported as a module by another script or test. It is a Python convention that every script should follow.
Skills Applied in This Project
| Skill | Where Used | Lesson |
|---|---|---|
| Classes and OOP | Student, GradeTracker | 31–35 |
| Encapsulation / @property | average, grade, scores properties | 34 |
| Custom exceptions | DuplicateStudentError | 20 |
| Decorators | @log_action on mutating methods | 36 |
| CSV file I/O | _load() / save() | 42 |
| NumPy statistics | np.mean, subject_averages | 43 |
| Exception handling | Every try/except in main() | 20 |
| F-strings and formatting | Report layout, aligned columns | 3, 5 |
| List / dict comprehensions | subject_averages(), at_risk_students() | 21, 22 |
| Type hints | All method signatures | — |
🌟 Your Assignment — 5 Similar Projects to Build
Each project below is scoped to the same size as the Grade Tracker — one or two files, 150–300 lines. Each one applies the same set of Python skills you used today. Pick the one that interests you most, or work through all five.
🏠 Personal Finance Tracker
A CLI application that logs income and expense transactions, categorises them, and generates a monthly budget report.
- Classes:
Transaction(amount, category, date, description) andFinanceTracker(manages all transactions) - CSV persistence: Load and save all transactions; never lose data between sessions
- Statistics: Total income, total expenses, net balance, largest expense, category breakdown using NumPy
- Report: Monthly summary table with a category-wise bar chart of spending (█ characters like the Grade Tracker)
- Custom exception:
InvalidTransactionErrorfor negative amounts or unknown categories - Extra challenge: Add a budget limit per category and flag categories that exceed it in the report
📚 Library Book Manager
A system to track a personal or school library — books, borrowers, and due dates.
- Classes:
Book(ISBN, title, author, genre, available) andLibraryManager(catalogue, borrow/return tracking) - CSV persistence: Separate CSV for books and for borrow records
- Features: Borrow a book (marks unavailable + records due date), return a book, search by author or genre
- Late detection: Compare
datetime.now()to due date; flag overdue books in the report - Custom exception:
BookUnavailableErrorwhen borrowing an already-borrowed book - Extra challenge: Add a
@log_actiondecorator that writes to alibrary_log.txtfile instead of printing
🌟 Movie Watchlist & Recommender
Track movies you have watched, rate them, and get recommendations based on your top genres.
- Classes:
Movie(title, genre, year, rating, watched) andWatchlist(manages the collection) - CSV + JSON: Save watchlist to CSV; fetch live movie data from the free OMDb API (
http://www.omdbapi.com) usingrequests - Statistics: Average rating per genre, most-watched genre, top 5 rated movies using NumPy sorting
- Recommendation: Given the user’s top genre, suggest the highest-rated unwatched movies from the list
- Custom exception:
MovieNotFoundErrorfor API lookups that return no results - Extra challenge: Use
@retrydecorator (from the Decorators lesson) to automatically retry failed API calls
📈 Employee Performance Tracker
A system for tracking employee KPIs across departments, very similar in structure to the Grade Tracker but applied to a workplace context.
- Classes:
Employee(ID, name, department, KPI scores dict) andPerformanceTracker(manages all employees) - KPIs: Productivity, Quality, Punctuality, Communication, Initiative — each rated 0–10
- CSV persistence: Same pattern as Grade Tracker — load on startup, save on change
- Report: Department averages, top performer per department, employees below threshold (“needs improvement”) list
- Custom exception:
DuplicateEmployeeErrorandInvalidKPIErrorfor out-of-range scores - Extra challenge: Add a
promote()method that moves an employee to a “Senior” tier if their average KPI exceeds 8.5
🏥 Restaurant Order & Revenue Tracker
A simple point-of-sale system that takes orders, calculates totals with tax, and reports daily revenue.
- Classes:
MenuItem(name, category, price),Order(items list, table number, timestamp), andRestaurant(menu, orders) - CSV persistence: Save every completed order; reload today’s orders on startup
- Tax and discount: Apply a configurable tax rate; apply a discount if order total exceeds £50
- Report: Total revenue, revenue by category, most popular item, average order value, peak hour analysis using
datetime - Custom exception:
MenuItemNotFoundErrorandEmptyOrderError - Extra challenge: Use a context manager (
@contextmanager) to wrap the “open order” session — it automatically finalises and saves the order when thewithblock exits
💡 How to approach each project: Start with the data model classes and get them working first. Then add persistence. Then the report. Test each piece before building the next. This is exactly how professional developers build software — incrementally, one layer at a time.