OOP Project ยท Abstraction in Action

Online Course
Platform โ€”
One Interface, Many Lessons.

A learning platform serves videos, quizzes, and articles side by side โ€” yet the course engine never asks "what kind of lesson is this?" Every lesson conforms to one abstract Content interface, and render() and is_complete() behave polymorphically.

In this project you'll build that engine with Python's abc module, extend it with a brand-new lesson type without touching existing code, apply the Template Method and Strategy patterns, and try a live course player right in your browser.

๐Ÿ“… October 19, 2026 โฑ 40 min read ๐ŸŽ“ Intermediate ๐Ÿ Python 3.11+
Try the Live Player Abstraction Guide

What We're Building

๐ŸŽ“ A Polymorphic Learning Platform

This project applies the second pillar of OOP to a problem every e-learning product faces: many lesson formats, one course experience. Learners see a smooth path through a course; the engine underneath treats every lesson identically.

The key is an abstract Content base class that defines a contract โ€” how a lesson is rendered and how we know it is complete โ€” while each concrete lesson type decides the details.

Every Python example below is interactive: copy it or run it in your browser with Pyodide. Then jump to the live demo to see the same design running as a working course player.

5 live code examples with full walkthroughs 6 hands-on exercises with solutions Interactive course player 5 knowledge-check questions Best practices & cheat sheet

The Problem

Without abstraction, a course engine tends to grow an if/elif ladder on lesson type โ€” one in the renderer, another in progress tracking, another in the certificate logic. Each new format (say, a coding exercise) forces edits across the whole codebase.

Smell: if adding "Assignment" means editing Course, Renderer, and Certificate, the design is leaking. Abstraction fixes the leak by moving type-specific behavior into the lesson classes.

Goal: adding a new lesson type should mean writing one new class and registering it โ€” nothing else changes.

Key Ideas Before the Code

This project uses a handful of terms again and again. Skim this glossary now, and come back whenever a word in the walkthroughs feels unfamiliar.

Glossary

TermPlain-English meaningWhere you'll see it
AbstractionFocusing on what something does and hiding how.The whole Content design
Abstract base class (ABC)A class meant to be inherited from, never instantiated directly.Content, CompletionPolicy
Abstract methodA method with a name and signature but no real body; every concrete subclass must provide one.render, is_complete
Concrete classA class with no abstract members left, so it can be instantiated.Video, Quiz, Article
Contract / interfaceThe set of promises an object makes to its callers.The three members of Content
PolymorphismThe same call behaves differently depending on the object receiving it.lesson.render()
Dynamic dispatchPython picks which method to run by looking at the object's real type at runtime.l.is_complete(...) in Course
Template MethodA base-class method with a fixed skeleton that calls abstract steps.label()
FactoryCode that builds the right object from data.Content.from_dict()
RegistryA lookup table from names to classes.Content.registry
StrategySwappable behavior behind a shared interface.CompletionPolicy
MetaclassThe "class of a class" โ€” what builds class objects. ABCMeta is the one that tracks abstract methods.Behind ABC

A 30-Second Experiment: Watch Abstractness Disappear

This tiny program uses shapes instead of lessons so you can focus on one mechanism: Python's list of still-unimplemented methods. We inherit twice and print that list at each stage.

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        ...

    @abstractmethod
    def name(self):
        ...

print(type(Shape))
print(sorted(Shape.__abstractmethods__))

class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side ** 2

print(sorted(Square.__abstractmethods__))
try:
    Square(3)
except TypeError as err:
    print("TypeError:", err)

class FullSquare(Square):
    def name(self):
        return "square"

print(sorted(FullSquare.__abstractmethods__))
sq = FullSquare(3)
print(sq.name(), sq.area())
Expected output
<class 'abc.ABCMeta'> ['area', 'name'] ['name'] TypeError: Can't instantiate abstract class Square without an implementation for abstract method 'name' [] square 9
๐Ÿ“ How to read this
  1. type(Shape) prints ABCMeta: inheriting from ABC changed which machinery builds the class.
  2. Shape owes two methods (area, name). Square pays one of the two debts, so one remains.
  3. Square(3) fails because one debt remains. The check happens at instantiation, not when Square was defined โ€” the class definition itself was accepted.
  4. FullSquare pays the last debt, the set becomes empty, and instantiation works. Abstractness is just "is this list empty?"

Error Message Decoder

You will meet these errors while experimenting. Each one points at a specific design mistake.

MessageWhat it meansFix
TypeError: Can't instantiate abstract class Xโ€ฆThe class still has unimplemented abstract methods (they're listed in the message).Implement every method named.
AttributeError: 'X' object has no attribute 'title'The parent __init__ never ran.Call super().__init__(...) first.
KeyError: 'watched_pct'You used dict[key] on a key nobody recorded yet.Use .get(key, default).
ValueError: Unknown lesson typeThe type string isn't in the registry.Check the spelling, or make sure the class's module was imported.
ZeroDivisionErrorA percentage was computed over zero items.Guard the empty case.
Output shows <bound method โ€ฆ>You forgot @property on an override.Add the decorator.

How to study the four programs: (1) read the code once without running it; (2) predict the output; (3) press Run and compare; (4) read the deep dive; (5) do the "Try it yourself" experiments. Predicting before running is what turns reading into learning.

The Architecture

Who Knows What

The design has three layers. The abstract contract sits in the middle; the course engine depends only on it, and concrete lessons implement it.

LayerClassResponsibility
EngineCourseHolds lessons, records progress, computes percent complete. Knows only Content.
ContractContent (ABC)Declares kind, render(), is_complete(). Shares label().
ConcreteVideo, Quiz, ArticleEach defines its own rendering and its own completion rule.
PolicyCompletionPolicy (ABC)Decides when a learner earns a certificate (Strategy pattern).

Dependency direction: Course โ†’ Content โ† Video / Quiz / Article. The engine and the lessons both depend on the abstraction, never on each other.

Completion Means Different Things

"Done" is not one rule. Each lesson type defines it for itself โ€” that is the polymorphic part.

Lessonrender()is_complete()
๐ŸŽฌ VideoPlays the streamWatched at least 90%
๐Ÿ“„ ArticleShows the textScrolled to the end
โ“ QuizLists the questionsScore meets the pass mark

Step-by-Step Implementation

Step 1 โ€” The Abstract Content Contract

We start with the contract. Content cannot be instantiated; every lesson must supply kind, render(), and is_complete().

from abc import ABC, abstractmethod

class Content(ABC):
    def __init__(self, title: str, minutes: int):
        self.title = title
        self.minutes = minutes

    @property
    @abstractmethod
    def kind(self) -> str:
        ...

    @abstractmethod
    def render(self) -> str:
        ...

    @abstractmethod
    def is_complete(self, progress: dict) -> bool:
        ...

    def label(self) -> str:
        return f"[{self.kind}] {self.title} ({self.minutes} min)"

class Video(Content):
    def __init__(self, title, minutes, url):
        super().__init__(title, minutes)
        self.url = url

    @property
    def kind(self):
        return "Video"

    def render(self):
        return f"โ–ถ Streaming {self.url}"

    def is_complete(self, progress):
        return progress.get("watched_pct", 0) >= 90

class Article(Content):
    def __init__(self, title, minutes, words):
        super().__init__(title, minutes)
        self.words = words

    @property
    def kind(self):
        return "Article"

    def render(self):
        return f"๐Ÿ“„ Showing {self.words} words"

    def is_complete(self, progress):
        return progress.get("scrolled_pct", 0) >= 100

class Quiz(Content):
    def __init__(self, title, minutes, questions, pass_mark=0.7):
        super().__init__(title, minutes)
        self.questions = questions
        self.pass_mark = pass_mark

    @property
    def kind(self):
        return "Quiz"

    def render(self):
        return f"โ“ Asking {len(self.questions)} questions"

    def is_complete(self, progress):
        return progress.get("score", 0) >= self.pass_mark

try:
    Content("Nope", 1)
except TypeError as err:
    print("Blocked:", err)

lessons = [
    Video("Welcome", 6, "welcome.mp4"),
    Article("Why Interfaces Matter", 8, 1200),
    Quiz("Checkpoint", 5, ["Q1", "Q2", "Q3"]),
]
for item in lessons:
    print(item.label())
    print("   ", item.render())
๐Ÿ“ Why This Code Was Used & How It Works
  1. The contract: Content declares what every lesson can do. The attempt to build Content("Nope", 1) raises TypeError, proving the class is truly abstract.
  2. Shared behavior: label() is concrete. It calls the abstract kind property, so it works for every subclass without knowing which one it has.
  3. Three completion rules: is_complete() takes a progress dict and each lesson reads only the keys it cares about โ€” watch percentage, scroll percentage, or quiz score.
  4. Contribution to output: the loop prints one label and one render line per lesson, using the same two calls on three different types โ€” polymorphism.

๐Ÿ” Step 1 Deep Dive โ€” How Python Enforces the Contract

Before reading the code line by line, learn when Python checks things. There are two separate moments, and mixing them up is the most common source of confusion with abstract classes.

MomentWhat Python doesResult here
Defining class Content(ABC)The metaclass ABCMeta scans the class body, collects every member marked abstract, and stores their names in Content.__abstractmethods__.No error. Defining an abstract class is always allowed. The set holds kind, render, is_complete.
Defining class Video(Content)Repeats the scan: every inherited abstract name is looked up on the new class. If Video supplies a real (non-abstract) version, that name is crossed off.Video.__abstractmethods__ is empty, so Video is concrete.
Calling Video(...)object.__new__ checks whether __abstractmethods__ is empty. If it is not, it raises before __init__ runs.The instance is created normally.
Calling Content("Nope", 1)Same check, but the set still has three names.TypeError โ€” and __init__ never ran.

Key idea: enforcement happens at instantiation, not at class definition. A half-finished subclass can sit in your code for weeks without complaint โ€” until someone tries to create an object from it.

๐Ÿ“– Line-by-line walkthrough

  1. from abc import ABC, abstractmethod โ€” ABC is a ready-made base class whose metaclass is ABCMeta. abstractmethod is the decorator that flags a method as "must be overridden".
  2. class Content(ABC): โ€” inheriting from ABC is what switches on the abstract-class machinery. Without it, @abstractmethod does nothing at all.
  3. __init__(self, title, minutes) โ€” shared state lives here once. Every lesson has a title and a duration, so subclasses reuse this through super().__init__(...) instead of copying it.
  4. @property above @abstractmethod on kind โ€” this declares an abstract read-only attribute. Callers write item.kind with no parentheses. The body is ... (Ellipsis), a placeholder meaning "intentionally empty". Decorators apply bottom-up: abstractmethod first, then property wraps the result.
  5. Abstract render() and is_complete(progress) โ€” the names and parameters are the contract. Python only checks that a method with that name exists; it does not verify the parameters match. Keeping signatures consistent is your discipline, which is why type hints and tests matter.
  6. label() (concrete) โ€” a normal method that every subclass inherits. Inside it, self.kind is looked up at runtime on the real object, so a Video prints [Video] and a Quiz prints [Quiz]. This is the Template Method idea: fixed skeleton in the base, variable details in subclasses.
  7. class Video(Content) and super().__init__(title, minutes) โ€” Video needs an extra url, so it has its own __init__. It must call the parent's first, otherwise self.title and self.minutes are never set.
  8. kind returns a constant โ€” because it is a property with no setter, video.kind = "X" raises AttributeError. The lesson's type cannot be changed by accident.
  9. progress.get("watched_pct", 0) >= 90 โ€” .get(key, default) returns 0 when nothing has been recorded yet. Writing progress["watched_pct"] would crash with KeyError for a brand-new learner.
  10. pass_mark=0.7 in Quiz โ€” a fraction from 0 to 1. The score stored in progress must use the same scale (0.8 means 80%). Mixing 0.8 with 80 is a classic unit bug.
  11. try: Content("Nope", 1) โ€” a live proof that the class is abstract. We catch the TypeError and print it so you can read the message Python gives.
  12. The final for loop โ€” it calls label() and render() on three different types with identical code. No if, no isinstance. That is polymorphism.
Expected output
Blocked: Can't instantiate abstract class Content without an implementation for abstract methods 'is_complete', 'kind', 'render' [Video] Welcome (6 min) โ–ถ Streaming welcome.mp4 [Article] Why Interfaces Matter (8 min) ๐Ÿ“„ Showing 1200 words [Quiz] Checkpoint (5 min) โ“ Asking 3 questions

The first line's wording depends on your Python version (older versions say "with abstract methods is_complete, kind, render"). The method names are listed alphabetically, not in the order you wrote them.

โš ๏ธ Common mistakes

Forgetting super().__init__(...). The object is created without error, then label() fails with AttributeError: 'Video' object has no attribute 'title'. The error appears far from the cause.

Defining kind as a plain method in a subclass. Python accepts this silently (a normal method satisfies the abstract name), but self.kind becomes a bound method, and label() prints something like <bound method Video.kind ...>. Always repeat @property when overriding a property.

Assuming Python checks signatures. A subclass can define render(self, extra) and pass the abstract check; the mismatch only explodes when the engine calls render() with no arguments.

๐Ÿงช Try it yourself

Copy the program into your editor (or the Code Runner) and change one thing at a time:

  • Delete render from Quiz and create a Quiz. Read the error โ€” which method does it name?
  • Swap the order of @property and @abstractmethod on kind. What happens, and at which moment (class definition or instantiation)?
  • Remove @property from Video.kind. Run the loop and look at the first label.
  • Print Video.__abstractmethods__ and Content.__abstractmethods__ to see the sets from the table above.

Step 2 โ€” The Course Engine

The Course class stores lessons and learner progress. Notice that it never checks a lesson's type.

from abc import ABC, abstractmethod

class Content(ABC):
    def __init__(self, title):
        self.title = title

    @abstractmethod
    def is_complete(self, progress: dict) -> bool:
        ...

class Video(Content):
    def is_complete(self, progress):
        return progress.get("watched_pct", 0) >= 90

class Article(Content):
    def is_complete(self, progress):
        return progress.get("scrolled_pct", 0) >= 100

class Quiz(Content):
    def is_complete(self, progress):
        return progress.get("score", 0) >= 0.7

class Course:
    def __init__(self, name):
        self.name = name
        self._lessons = []
        self._progress = {}

    def add(self, lesson: Content):
        self._lessons.append(lesson)
        return self

    def record(self, title, **data):
        self._progress.setdefault(title, {}).update(data)

    def status(self):
        return [(l.title, l.is_complete(self._progress.get(l.title, {}))) for l in self._lessons]

    def percent_complete(self):
        done = sum(1 for _, ok in self.status() if ok)
        return round(100 * done / len(self._lessons))

course = Course("Python OOP Foundations")
course.add(Video("Welcome")).add(Article("Interfaces")).add(Quiz("Checkpoint"))

course.record("Welcome", watched_pct=95)
course.record("Interfaces", scrolled_pct=60)
course.record("Checkpoint", score=0.8)

for title, ok in course.status():
    print("โœ“" if ok else "ยท", title)
print(f"Progress: {course.percent_complete()}%")
๐Ÿ“ Why This Code Was Used & How It Works
  1. Type-agnostic engine: status() calls is_complete() on each lesson. There is no isinstance check anywhere โ€” that is the point of the abstraction.
  2. Why add() returns self: it allows fluent chaining when building a course.
  3. Contribution to output: the video (95%) and quiz (0.8) pass their rules; the article (60%) does not. The result is Progress: 67%.

๐Ÿ” Step 2 Deep Dive โ€” Following One Call Through the Engine

The best way to understand polymorphism is to follow a single call and watch where Python chooses the code to run. Here is everything that happens when we call course.percent_complete().

#What runsWhat Python decides
1percent_complete() calls self.status()Ordinary method call on Course.
2status() loops over self._lessonsLessons come back in the order they were added.
3For "Welcome": self._progress.get("Welcome", {})Finds {"watched_pct": 95}.
4l.is_complete(...) with l being a VideoDynamic dispatch: Python looks at the actual type of l, finds Video.is_complete, runs it: 95 >= 90 โ†’ True.
5Same expression, l now an ArticlePython picks Article.is_complete: 60 >= 100 โ†’ False.
6Same expression, l now a QuizPython picks Quiz.is_complete: 0.8 >= 0.7 โ†’ True.
7sum(1 for _, ok in ... if ok)Counts the True results: 2.
8round(100 * 2 / 3)66.67 rounds to 67.

The same line of code (step 4/5/6) ran three different functions. The engine wrote l.is_complete(...) once; the objects decided what that means. That is the whole payoff of the abstraction.

๐Ÿ“– Line-by-line walkthrough

  1. The compact Content at the top โ€” this example repeats a trimmed contract so it runs on its own. It keeps only title and is_complete, because those are the only parts Course needs. In a real project you would import the full class from one module.
  2. Three tiny subclasses โ€” each holds only its completion rule. They inherit __init__ from Content, so Video("Welcome") works without writing a constructor.
  3. self._lessons = [] and self._progress = {} โ€” the leading underscore is a convention meaning "internal; use the methods, not these attributes". Lessons are stored in a list because order matters; progress is a dict keyed by lesson title for quick lookup.
  4. add(self, lesson: Content) โ€” the type hint says "anything that is a Content". It documents the dependency on the abstraction. Returning self lets you chain: course.add(a).add(b).add(c).
  5. record(self, title, **data) โ€” **data collects keyword arguments into a dict, so record("Welcome", watched_pct=95) gives data = {"watched_pct": 95}. setdefault(title, {}) returns the existing dict for that title or creates an empty one, and .update(data) merges the new values in.
  6. status() list comprehension โ€” builds (title, done) tuples. Notice the engine never asks what kind of lesson it has. There is no isinstance anywhere.
  7. percent_complete() โ€” for _, ok in self.status() unpacks each tuple and ignores the title with _. sum(1 for ... if ok) counts the finished lessons.
  8. The fluent course.add(...).add(...).add(...) โ€” builds the course in one expression, thanks to return self.
  9. Three record calls โ€” simulate a learner: watched 95% of the video, scrolled 60% of the article, scored 80% on the quiz.
  10. The printing loop โ€” shows โœ“ for done and ยท for not done using a conditional expression: "โœ“" if ok else "ยท".
Expected output
โœ“ Welcome ยท Interfaces โœ“ Checkpoint Progress: 67%

โš ๏ธ Common mistakes and design weak spots

Empty course โ†’ crash. percent_complete() divides by len(self._lessons). With no lessons that is a ZeroDivisionError. Guard it: return 0 when the list is empty.

Titles as keys. Two lessons both called "Introduction" would share one progress record. Real systems give each lesson a unique id and key progress by that.

Progress can go backwards. record("Welcome", watched_pct=40) after 95 overwrites the value and un-completes the lesson. The live demo below avoids this with Math.max. Decide deliberately whether progress should ever decrease.

๐Ÿงช Try it yourself

Experiments:

  • Add a fourth lesson but never call record for it. Which results does status() show, and why doesn't it crash?
  • Change the quiz score to 80 instead of 0.8. It still "passes" โ€” why is that a bug in disguise?
  • Replace .get("watched_pct", 0) with ["watched_pct"] in Video and run it on an unrecorded lesson. Read the KeyError.

Step 3 โ€” Add a New Lesson Type (Zero Changes to Course)

Now the payoff. We add a CodeExercise lesson and a small registry that builds lessons from plain data, as if loaded from a JSON file or database.

from abc import ABC, abstractmethod

class Content(ABC):
    registry = {}

    def __init__(self, title):
        self.title = title

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        Content.registry[cls.__name__.lower()] = cls

    @abstractmethod
    def render(self) -> str:
        ...

    @abstractmethod
    def is_complete(self, progress: dict) -> bool:
        ...

    @classmethod
    def from_dict(cls, data: dict):
        kind = data["type"]
        if kind not in cls.registry:
            raise ValueError(f"Unknown lesson type: {kind}")
        return cls.registry[kind](data["title"])

class Video(Content):
    def render(self):
        return "โ–ถ Streaming video"
    def is_complete(self, progress):
        return progress.get("watched_pct", 0) >= 90

class CodeExercise(Content):
    def render(self):
        return "๐Ÿ’ป Opening code editor"
    def is_complete(self, progress):
        return progress.get("tests_passed", 0) == progress.get("tests_total", -1)

catalog = [
    {"type": "video", "title": "Welcome"},
    {"type": "codeexercise", "title": "Write your first ABC"},
]
lessons = [Content.from_dict(d) for d in catalog]
for lesson in lessons:
    print(type(lesson).__name__, "โ†’", lesson.render())

print(lessons[1].is_complete({"tests_passed": 5, "tests_total": 5}))

try:
    Content.from_dict({"type": "hologram", "title": "Future"})
except ValueError as err:
    print("Error:", err)
๐Ÿ“ Why This Code Was Used & How It Works
  1. Self-registering subclasses: __init_subclass__ runs whenever a subclass is defined, adding it to Content.registry. Defining CodeExercise is all it takes to make it loadable.
  2. Factory classmethod: from_dict() turns plain data into the right concrete lesson, so storage formats stay decoupled from class names.
  3. Open/Closed Principle: the system is open to new types and closed to modification โ€” no existing class changed to add CodeExercise.
  4. Contribution to output: it prints each lesson's class and render line, then True for the exercise, then a clear error for the unknown type.

๐Ÿ” Step 3 Deep Dive โ€” Self-Registering Classes

This step uses two features that look magical until you see the timeline: __init_subclass__ and class methods. Here is what happens as Python reads the file from top to bottom.

Python reachesโ€ฆWhat happensContent.registry afterwards
class Content(ABC)The class is created. __init_subclass__ is not called for the class that defines it, only for its children.{}
class Video(Content)After Video is created, Python calls Content.__init_subclass__(Video). The hook stores "video" โ†’ Video.{'video': Video}
class CodeExercise(Content)Same hook runs again with the new class.{'video': Video, 'codeexercise': CodeExercise}
Content.from_dict({...})Looks the type string up in the registry and calls the class it finds.Unchanged.

Why this matters: registration is a side effect of defining the class. You never write registry["codeexercise"] = CodeExercise by hand, so you cannot forget it.

๐Ÿ“– Line-by-line walkthrough

  1. registry = {} inside Content โ€” a class attribute: one dict shared by Content and every subclass. That sharing is exactly what we want here, because all subclasses must write into the same table.
  2. def __init_subclass__(cls, **kwargs) โ€” a special hook Python calls every time someone subclasses Content. cls is the new subclass (for example Video), not Content. It is an implicit class method, so no decorator is needed.
  3. super().__init_subclass__(**kwargs) โ€” passes any extra arguments up the inheritance chain. It is good manners (cooperative inheritance): if another base class also uses this hook, it still works.
  4. Content.registry[cls.__name__.lower()] = cls โ€” cls.__name__ is the text "Video"; .lower() makes it "video". We store the class itself (not an instance) as the value, because classes are ordinary objects you can keep in a dict and call later.
  5. Abstract render and is_complete โ€” the contract is unchanged. The registry adds a way to find classes; it does not replace the contract.
  6. @classmethod def from_dict(cls, data) โ€” an alternative constructor. A class method receives the class as its first argument, so it can be called as Content.from_dict(...) without creating an instance first.
  7. if kind not in cls.registry: raise ValueError(...) โ€” fail loudly with a clear message. Returning None instead would let a bad lesson slip into a course and cause a confusing error much later.
  8. return cls.registry[kind](data["title"]) โ€” read it in two halves: cls.registry[kind] fetches a class; (data["title"]) calls it like any constructor. Two pairs of brackets, two different jobs.
  9. CodeExercise.is_complete โ€” uses progress.get("tests_total", -1). The default of -1 can never equal a real count of passed tests, so a lesson with no recorded results is never accidentally "complete" (0 == 0 would be a bug).
  10. The catalog list of dicts โ€” stands in for data loaded from JSON or a database. Only strings, no Python classes, so storage stays independent of code.
  11. The final try/except โ€” shows the failure path: an unknown type produces a readable ValueError.
Expected output
Video โ†’ โ–ถ Streaming video CodeExercise โ†’ ๐Ÿ’ป Opening code editor True Error: Unknown lesson type: hologram

โš ๏ธ Common mistakes

The class was never imported. Registration happens when the class statement runs. If CodeExercise lives in a module nobody imports, it is not in the registry and from_dict reports "Unknown lesson type". This is the number-one real-world bug with registries.

Type strings drift from class names. Here "codeexercise" is simply the lowercase class name โ€” awkward. Many projects register an explicit short name instead (see Exercise 5's idea in the practice section).

Registering abstract intermediates. If you add class MediaContent(Content) as a half-finished helper, it is registered too. Asking for it raises the abstract TypeError. Skip classes that still have abstract methods if that matters to you.

๐Ÿงช Try it yourself

Experiments:

  • Add an Article class below CodeExercise and print Content.registry. Did you have to change from_dict?
  • Add print("registered", cls.__name__) inside __init_subclass__ and watch the messages appear as the classes are defined, before any lesson is created.
  • Delete is_complete from CodeExercise and call from_dict. Which exception do you get, and from which line?

Step 4 โ€” Certificates via the Strategy Pattern

Different courses award certificates differently. Rather than hard-coding one rule, abstract it behind a CompletionPolicy.

from abc import ABC, abstractmethod

class CompletionPolicy(ABC):
    @property
    @abstractmethod
    def name(self) -> str:
        ...

    @abstractmethod
    def earns_certificate(self, results: dict) -> bool:
        ...

class AllLessons(CompletionPolicy):
    @property
    def name(self):
        return "Finish every lesson"

    def earns_certificate(self, results):
        return all(results.values())

class Threshold(CompletionPolicy):
    def __init__(self, percent):
        self.percent = percent

    @property
    def name(self):
        return f"Finish at least {self.percent}%"

    def earns_certificate(self, results):
        done = sum(results.values())
        return 100 * done / len(results) >= self.percent

class MustPassQuizzes(CompletionPolicy):
    @property
    def name(self):
        return "Pass every quiz"

    def earns_certificate(self, results):
        return all(ok for title, ok in results.items() if title.startswith("Quiz:"))

results = {"Video: Welcome": True, "Article: Interfaces": False, "Quiz: Checkpoint": True}

for policy in [AllLessons(), Threshold(60), MustPassQuizzes()]:
    verdict = "๐ŸŽ“ Certificate" if policy.earns_certificate(results) else "โœ— Not yet"
    print(f"{policy.name:<24} {verdict}")
๐Ÿ“ Why This Code Was Used & How It Works
  1. A second abstraction: where Content abstracts lessons, CompletionPolicy abstracts business rules. A course can be configured with any policy at runtime.
  2. Parameterized strategies: Threshold(60) carries its own configuration, yet exposes the same interface as the others.
  3. Contribution to output: with two of three lessons done, AllLessons says no, Threshold(60) says yes (67%), and MustPassQuizzes says yes.

๐Ÿ” Step 4 Deep Dive โ€” The Strategy Pattern, Role by Role

Strategy has three participants. Naming them makes the pattern easy to spot in other codebases.

RoleIn this codeJob
Strategy interfaceCompletionPolicyDeclares what a rule must offer: a name and earns_certificate(results).
Concrete strategiesAllLessons, Threshold, MustPassQuizzesEach implements the rule differently.
Context (the user of a strategy)The for policy in [...] loopHolds a policy and calls it. It does not know which rule it got. In a real app this would be a CertificateService.

Without Strategy you would write one function full of if course.rule == "all": ... elif course.rule == "threshold": .... Every new rule edits that function and risks breaking the old ones. With Strategy, a new rule is a new class and the old code stays untouched.

๐Ÿ“– Line-by-line walkthrough

  1. CompletionPolicy with an abstract name property โ€” lets any policy describe itself, which is handy for dashboards and logs ("Why didn't I get my certificate?").
  2. earns_certificate(self, results) โ€” the policy receives a simple dict of {title: finished?}, not the lesson objects. Keeping the input small makes strategies trivial to test and keeps them independent from Content.
  3. AllLessons: all(results.values()) โ€” all() returns True only if every item is true, and it stops at the first False, so it is also efficient.
  4. Threshold.__init__(self, percent) โ€” strategies can carry settings. Threshold(60) and Threshold(80) are two different rules created from one class.
  5. sum(results.values()) โ€” in Python True equals 1 and False equals 0, so summing booleans counts the trues. Then 100 * done / len(results) uses true division and gives 66.666โ€ฆ for 2 of 3.
  6. f"Finish at least {self.percent}%" โ€” the name is built from configuration, so each Threshold describes itself accurately.
  7. MustPassQuizzes: the generator expression โ€” ok for title, ok in results.items() if title.startswith("Quiz:") keeps only quiz entries and yields their results; all(...) then requires every one to be true. It relies on a title prefix, a weakness discussed below.
  8. The results dict โ€” in a real system you would build this from course.status() in Step 2.
  9. for policy in [AllLessons(), Threshold(60), MustPassQuizzes()] โ€” a list holding three different classes. They are interchangeable because all share the interface โ€” the same reason a Course can hold three lesson types.
  10. f"{policy.name:<24} {verdict}" โ€” the :<24 format spec left-aligns the name inside 24 characters so the verdicts line up in a column.
Expected output
Finish every lesson โœ— Not yet Finish at least 60% ๐ŸŽ“ Certificate Pass every quiz ๐ŸŽ“ Certificate

Same learner, same results, three different outcomes โ€” only the strategy changed.

โš ๏ธ Common mistakes and design weak spots

Vacuous truth. all([]) is True. If a course has no quizzes, MustPassQuizzes hands out a certificate to everyone. Decide what "no quizzes" should mean and handle it explicitly.

Empty results. Threshold divides by len(results), so an empty dict raises ZeroDivisionError.

Strings as a type system. Detecting quizzes by "Quiz:" in a title is fragile: rename a lesson and the rule silently breaks. A sturdier design passes the lesson's kind (from Step 1) along with each result. Exercise 4 below pushes you toward this.

๐Ÿงช Try it yourself

Experiments:

  • Before running, predict the verdict for Threshold(80). Then run it and check your arithmetic.
  • Flip the article result to True. Which verdicts change?
  • Pass an empty dict to each policy and note which one crashes and which one "passes".

Hands-On Exercises

Reading code teaches recognition; writing it teaches understanding. Try each exercise on your own first โ€” use your editor or the Code Runner โ€” and only then open the solution. Every solution can be run right here.

Exercise 1 โ€” Add an Assignment lesson

Warm-up

Task: create Assignment(title, max_points) that extends Content. Its kind is "Assignment", render() returns a message mentioning the points, and it is complete only when the progress dict contains submitted: True.

Hint: copy the structure of Video. Remember super().__init__ for the title, and use .get so an empty progress dict does not crash.

Show solution & explanation
from abc import ABC, abstractmethod

class Content(ABC):
    def __init__(self, title):
        self.title = title

    @property
    @abstractmethod
    def kind(self) -> str:
        ...

    @abstractmethod
    def render(self) -> str:
        ...

    @abstractmethod
    def is_complete(self, progress: dict) -> bool:
        ...

class Assignment(Content):
    def __init__(self, title, max_points):
        super().__init__(title)
        self.max_points = max_points

    @property
    def kind(self):
        return "Assignment"

    def render(self):
        return f"๐Ÿ“ Submit your work ({self.max_points} points)"

    def is_complete(self, progress):
        return progress.get("submitted", False) is True

task = Assignment("Build a Shape ABC", 20)
print(task.kind, "|", task.render())
print(task.is_complete({}))
print(task.is_complete({"submitted": True}))
Expected output
Assignment | ๐Ÿ“ Submit your work (20 points) False True

Notice what you did not touch: no engine code, no other lesson. That is the Open/Closed Principle in practice. is True (instead of plain truthiness) prevents a stray value like "no" from counting as submitted.

Exercise 2 โ€” Which lesson is next?

Easy

Task: add next_lesson() to Course. It returns the title of the first lesson that is not yet complete, or None when everything is done.

Hint: loop through self._lessons in order and reuse is_complete โ€” do not check lesson types.

Show solution & explanation
from abc import ABC, abstractmethod

class Content(ABC):
    def __init__(self, title):
        self.title = title

    @abstractmethod
    def is_complete(self, progress: dict) -> bool:
        ...

class Video(Content):
    def is_complete(self, progress):
        return progress.get("watched_pct", 0) >= 90

class Course:
    def __init__(self):
        self._lessons = []
        self._progress = {}

    def add(self, lesson):
        self._lessons.append(lesson)
        return self

    def record(self, title, **data):
        self._progress.setdefault(title, {}).update(data)

    def next_lesson(self):
        for lesson in self._lessons:
            if not lesson.is_complete(self._progress.get(lesson.title, {})):
                return lesson.title
        return None

course = Course().add(Video("One")).add(Video("Two")).add(Video("Three"))
course.record("One", watched_pct=100)
print(course.next_lesson())
course.record("Two", watched_pct=95)
course.record("Three", watched_pct=92)
print(course.next_lesson())
Expected output
Two None

The loop returns on the first incomplete lesson, so order is respected. Falling off the end of the loop means every lesson passed, and we return None explicitly so the intent is visible.

Exercise 3 โ€” Test the engine with a fake

Medium

Task: write a FakeContent subclass whose completion is just a stored True/False, then use assert to test a percent_complete function โ€” including the empty-course case โ€” without any videos or browsers.

Hint: a fake is a tiny, boring implementation of the contract. Its simplicity is what makes tests fast and reliable.

Show solution & explanation
from abc import ABC, abstractmethod

class Content(ABC):
    def __init__(self, title):
        self.title = title

    @abstractmethod
    def is_complete(self, progress: dict) -> bool:
        ...

class FakeContent(Content):
    def __init__(self, title, done):
        super().__init__(title)
        self.done = done

    def is_complete(self, progress):
        return self.done

def percent_complete(lessons):
    if not lessons:
        return 0
    done = sum(1 for lesson in lessons if lesson.is_complete({}))
    return round(100 * done / len(lessons))

assert percent_complete([]) == 0
assert percent_complete([FakeContent("a", True), FakeContent("b", False)]) == 50
assert percent_complete([FakeContent("a", True)] * 3) == 100
print("All tests passed")
Expected output
All tests passed

This is a major reason abstraction pays off: because the engine depends on the interface, you can substitute a fake and test the engine's logic in isolation. The empty-list guard also fixes the ZeroDivisionError noted in Step 2.

Exercise 4 โ€” A weighted certificate policy

Medium

Task: write WeightedPolicy(percent, quiz_weight=2) as a new CompletionPolicy. Quizzes count quiz_weight times; other lessons count once. A certificate is earned when the weighted percentage reaches percent. For the sample results, weights are 1 + 1 + 2 = 4 and the learner earned 3.

Hint: accumulate total and earned in a loop. Handle an empty dict. Stretch goal: think about how to avoid the "Quiz:" title-prefix trick (for example, by passing each lesson's kind with its result).

Show solution & explanation
from abc import ABC, abstractmethod

class CompletionPolicy(ABC):
    @abstractmethod
    def earns_certificate(self, results: dict) -> bool:
        ...

class WeightedPolicy(CompletionPolicy):
    def __init__(self, percent, quiz_weight=2):
        self.percent = percent
        self.quiz_weight = quiz_weight

    def earns_certificate(self, results):
        if not results:
            return False
        total = earned = 0
        for title, ok in results.items():
            weight = self.quiz_weight if title.startswith("Quiz:") else 1
            total += weight
            if ok:
                earned += weight
        return 100 * earned / total >= self.percent

results = {"Video: Welcome": True, "Article: Interfaces": False, "Quiz: Checkpoint": True}
print(WeightedPolicy(70).earns_certificate(results))
print(WeightedPolicy(80).earns_certificate(results))
Expected output
True False

The learner earned 3 of 4 weighted points = 75%. That passes a 70% bar and misses an 80% bar. The new strategy slotted in without changing any existing policy โ€” and it returns False for empty input instead of crashing.

Exercise 5 โ€” Explicit registry names

Medium

Task: improve the Step 3 registry so a subclass can choose its own type name: class CodeExercise(Content, type_name="code"). When no name is given, fall back to the lowercase class name.

Hint: class definitions can take keyword arguments, and Python forwards them to __init_subclass__. Add a type_name=None parameter there.

Show solution & explanation
from abc import ABC, abstractmethod

class Content(ABC):
    registry = {}

    def __init__(self, title):
        self.title = title

    def __init_subclass__(cls, type_name=None, **kwargs):
        super().__init_subclass__(**kwargs)
        Content.registry[type_name or cls.__name__.lower()] = cls

    @abstractmethod
    def render(self) -> str:
        ...

class Video(Content):
    def render(self):
        return "โ–ถ video"

class CodeExercise(Content, type_name="code"):
    def render(self):
        return "๐Ÿ’ป editor"

print(sorted(Content.registry))
print(Content.registry["code"]("Hello").render())
Expected output
['code', 'video'] ๐Ÿ’ป editor

Now your stored data can say "type": "code" while your class keeps a descriptive Python name. Renaming the class no longer breaks saved courses, because the stable public name is chosen explicitly.

Exercise 6 โ€” Debug the broken lesson

Medium

Task: the program below is broken. Run it, read the error, fix it, run again, and keep going until render() works. Then add a working is_complete that does not crash when the learner has no recorded progress. Expect more than one bug.

from abc import ABC, abstractmethod

class Lesson(ABC):
    def __init__(self, title):
        self.title = title

    @abstractmethod
    def render(self):
        ...

    @abstractmethod
    def is_complete(self, progress):
        ...

class Reading(Lesson):
    def __init__(self, title, pages):
        self.pages = pages

    def render(self):
        return f"Reading {self.title}: {self.pages} pages"

lesson = Reading("Intro", 12)
print(lesson.render())

Hint: the first error names the missing method. After fixing it, a second error appears that mentions title โ€” ask yourself who is supposed to set it.

Show solution & explanation
from abc import ABC, abstractmethod

class Lesson(ABC):
    def __init__(self, title):
        self.title = title

    @abstractmethod
    def render(self):
        ...

    @abstractmethod
    def is_complete(self, progress):
        ...

class Reading(Lesson):
    def __init__(self, title, pages):
        super().__init__(title)
        self.pages = pages

    def render(self):
        return f"Reading {self.title}: {self.pages} pages"

    def is_complete(self, progress):
        return progress.get("pages_read", 0) >= self.pages

lesson = Reading("Intro", 12)
print(lesson.render())
print(lesson.is_complete({}))
print(lesson.is_complete({"pages_read": 12}))
Expected output
Reading Intro: 12 pages False True

Bug 1: Reading never implemented is_complete, so it was still abstract and instantiation raised TypeError. Bug 2: its __init__ skipped super().__init__(title), so self.title was never set and render() raised AttributeError. Bug 3 (the trap): writing progress["pages_read"] would crash with KeyError for a new learner; .get(..., 0) avoids it.

Try the Course Player

This player runs the same design in JavaScript: an abstract Content class that throws if you try to instantiate it, with VideoContent, ArticleContent, and QuizContent each implementing render() and isComplete(). The player loop only ever calls those two methods. Progress is saved in your browser.

๐ŸŽ“ Python OOP Foundations
0%
    Complete every lesson to earn a certificate.

    What to notice: the player never asks a lesson what type it is. It calls render() to build the stage and isComplete() to decide whether to show the โœ“ โ€” each type answers in its own way.

    Advertisement

    Design Patterns in This Project

    Abstraction is the foundation for several classic patterns. This one small project uses four of them.

    ๐Ÿงฑ

    Polymorphism

    One call โ€” render() โ€” produces a video player, an article, or a quiz depending on the object. The caller never branches.

    โ™Ÿ๏ธ

    Strategy

    CompletionPolicy swaps certificate rules at runtime without changing Course.

    ๐Ÿญ

    Factory + Registry

    Content.from_dict() builds the right lesson from data. Subclasses register themselves automatically.

    ๐Ÿ“

    Template Method

    Shared methods like label() live in the ABC and call abstract hooks that each subclass fills in.

    ๐Ÿ”“

    Open/Closed

    New lesson types extend the platform without editing tested code โ€” fewer regressions.

    ๐Ÿงช

    Easy Testing

    A tiny FakeContent subclass lets you test Course without videos, browsers, or databases.

    Best Practices for Platform Abstractions

    Lessons From This Project

    โœ“ DO: Keep the contract small. Content has three abstract members. Every extra one is a burden on every future lesson type.

    โœ“ DO: Put shared logic in the ABC. label() and title handling live once, not in every subclass.

    โœ“ DO: Fail loudly on unknown types. The registry raises a clear ValueError instead of silently skipping a lesson.

    โœ— DON'T: Use isinstance in the engine. If Course checks for Video, the abstraction has failed. Move that behavior into the lesson.

    โœ— DON'T: Let the contract grow with every feature. Not every lesson needs download() or transcript(). Use optional mixins or a separate interface instead.

    โœ— DON'T: Abstract too early. This design is justified because there are three real lesson types. With a single type, a plain class is enough.

    Project Cheat Sheet

    ๐ŸŽ“ Class Map

    ClassRole
    ContentAbstract lesson contract
    Video / Article / QuizConcrete lessons
    CourseEngine, depends on Content only
    CompletionPolicyAbstract certificate rule

    โš™๏ธ Key Syntax

    SnippetMeaning
    class X(ABC)Abstract base
    @abstractmethodMust override
    @property above @abstractmethodAbstract property
    __init_subclass__Auto-register subclasses

    โš ๏ธ Common Pitfalls

    MistakeFix
    isinstance in CourseMove logic into lesson
    Forgot an abstract methodImplement all, or TypeError
    Unknown type in dataRaise ValueError early
    Fat contractSplit into smaller interfaces

    โœจ Extending It

    IdeaHow
    New lesson typeSubclass Content
    New certificate ruleSubclass CompletionPolicy
    Load from JSONContent.from_dict()
    Test the engineUse a FakeContent

    Quick Knowledge Check

    Five questions on the ideas behind this project.

    0/5

    Great effort โ€” review any missed questions above.

    Wrapping Up

    ๐Ÿ What You've Built

    You designed a course platform whose engine is completely ignorant of lesson types โ€” and that is exactly why it is easy to grow. A new format is one class and one registry entry away.

    Along the way you used abstract base classes, abstract properties, a self-registering factory, and the Strategy pattern: program to an interface, not an implementation.

    2 abstract contracts 4 design patterns 1 working course player

    Key Takeaways

    • The engine depends on the abstraction โ€” Course knows Content, never Video.
    • Behavior belongs in the subclass โ€” each lesson defines its own rendering and completion.
    • Registries make extension cheap โ€” new types register themselves on definition.
    • Strategies capture business rules โ€” certificate logic swaps without touching the course.
    • Keep contracts small โ€” fewer abstract members mean easier implementations.

    โœจ A good course feels like one path, whatever the lessons are made of. A good codebase feels the same way โ€” one interface, many forms.

    โ€” Glenn Junsay Pansensoy, Poetic Bytes

    Return to Main Hub Back to Top

    Share This Project

    Subscribe to Poetic Bytes

    Level up your Python knowledge.

    In-depth Python tutorials, AI insights, and software engineering guides โ€” delivered straight to your inbox. No spam, ever.

    Weekly Python tutorials AI & ML deep-dives OOP series updates Free, cancel anytime

    ๐Ÿ”’ Your email is never shared or sold.

    Follow Poetic Bytes

    Stay connected for Python tips, OOP insights, and new project walkthroughs.

    Advertisement