Mini Python Project | Python Course | Dataplexa

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.

What We Are Building

The Student Grade Tracker is a command-line application with five core features:

  1. Add students with an ID and name
  2. Record scores per subject per student
  3. Compute statistics — average, highest, lowest, grade letter
  4. Persist data to a CSV file so records survive program restarts
  5. 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 save

We 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 wrapper
# No output here — these are definitions only. # The decorator will print logs when methods that use it are called.

Why 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})")
# Test the Student class: # s = Student("s001", "alice smith") # s.add_score("Maths", 88) # s.add_score("English", 74) # print(s) # Student(id='S001', name='Alice Smith', avg=81.0, grade='A')

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()}
# When the program starts with no existing CSV: # (no output — _load() silently returns) # After adding students and calling save(): # [09:14:22] ✓ add_student completed # [09:14:22] ✓ record_score completed # [09:14:22] ✓ save completed

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"
======================================================================== STUDENT GRADE TRACKER — CLASS REPORT Generated: 2025-06-15 09:14 ======================================================================== Total students : 4 Class average : 74.4 (B) Top performer : Alice Smith (91.0) Needs support : Bob Jones (52.5) At-risk (<50) : 0 student(s) Strongest subj : Science (81.3) Weakest subj : History (64.0) ------------------------------------------------------------------------ Rank ID Name Avg Grade Scores ------------------------------------------------------------------------ 🥇1 S001 Alice Smith 91.0 A+ Art:95 English:88 Maths:90 🥈2 S002 Diana Osei 82.0 A English:80 Maths:84 🥉3 S003 Charlie Brown 72.0 B Maths:72 Science:72 4 S004 Bob Jones 52.5 D English:45 Maths:60 ------------------------------------------------------------------------ Subject Averages: Art 95.0 A+ ███████████████████ English 71.0 B ██████████████ Maths 76.5 B ███████████████ Science 72.0 B ██████████████ ========================================================================

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()
================================================== STUDENT GRADE TRACKER ================================================== --- Adding Students --- [09:14:22] ✓ add_student completed [09:14:22] ✓ add_student completed [09:14:22] ✓ add_student completed [09:14:22] ✓ add_student completed Caught: Student ID 'S001' already exists. Use a unique ID. --- Recording Scores --- [09:14:22] ✓ record_score completed ... (9 times) --- Individual Student Info --- Student(id='S001', name='Alice Smith', avg=91.0, grade='A+') Best subject : Art (95.0) Weak subject : English (88.0) --- Saving Data --- [09:14:22] ✓ save completed [full report printed as shown in Step 4]

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

SkillWhere UsedLesson
Classes and OOPStudent, GradeTracker31–35
Encapsulation / @propertyaverage, grade, scores properties34
Custom exceptionsDuplicateStudentError20
Decorators@log_action on mutating methods36
CSV file I/O_load() / save()42
NumPy statisticsnp.mean, subject_averages43
Exception handlingEvery try/except in main()20
F-strings and formattingReport layout, aligned columns3, 5
List / dict comprehensionssubject_averages(), at_risk_students()21, 22
Type hintsAll 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.

PROJECT 1

🏠 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) and FinanceTracker (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: InvalidTransactionError for negative amounts or unknown categories
  • Extra challenge: Add a budget limit per category and flag categories that exceed it in the report
PROJECT 2

📚 Library Book Manager

A system to track a personal or school library — books, borrowers, and due dates.

  • Classes: Book (ISBN, title, author, genre, available) and LibraryManager (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: BookUnavailableError when borrowing an already-borrowed book
  • Extra challenge: Add a @log_action decorator that writes to a library_log.txt file instead of printing
PROJECT 3

🌟 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) and Watchlist (manages the collection)
  • CSV + JSON: Save watchlist to CSV; fetch live movie data from the free OMDb API (http://www.omdbapi.com) using requests
  • 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: MovieNotFoundError for API lookups that return no results
  • Extra challenge: Use @retry decorator (from the Decorators lesson) to automatically retry failed API calls
PROJECT 4

📈 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) and PerformanceTracker (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: DuplicateEmployeeError and InvalidKPIError for 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
PROJECT 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), and Restaurant (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: MenuItemNotFoundError and EmptyOrderError
  • Extra challenge: Use a context manager (@contextmanager) to wrap the “open order” session — it automatically finalises and saves the order when the with block 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.

🎉
You Have Completed the Python Course!
45 lessons. Beginner to Advanced. OOP, concurrency, databases, APIs, ML — you built it all from the ground up. Now build something real with what you know.
← Back to Course Index