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.
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.
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
| Term | Plain-English meaning | Where you'll see it |
|---|---|---|
| Abstraction | Focusing 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 method | A method with a name and signature but no real body; every concrete subclass must provide one. | render, is_complete |
| Concrete class | A class with no abstract members left, so it can be instantiated. | Video, Quiz, Article |
| Contract / interface | The set of promises an object makes to its callers. | The three members of Content |
| Polymorphism | The same call behaves differently depending on the object receiving it. | lesson.render() |
| Dynamic dispatch | Python picks which method to run by looking at the object's real type at runtime. | l.is_complete(...) in Course |
| Template Method | A base-class method with a fixed skeleton that calls abstract steps. | label() |
| Factory | Code that builds the right object from data. | Content.from_dict() |
| Registry | A lookup table from names to classes. | Content.registry |
| Strategy | Swappable behavior behind a shared interface. | CompletionPolicy |
| Metaclass | The "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())type(Shape)printsABCMeta: inheriting fromABCchanged which machinery builds the class.Shapeowes two methods (area,name).Squarepays one of the two debts, so one remains.Square(3)fails because one debt remains. The check happens at instantiation, not whenSquarewas defined โ the class definition itself was accepted.FullSquarepays 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.
| Message | What it means | Fix |
|---|---|---|
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 type | The type string isn't in the registry. | Check the spelling, or make sure the class's module was imported. |
ZeroDivisionError | A 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.
| Layer | Class | Responsibility |
|---|---|---|
| Engine | Course | Holds lessons, records progress, computes percent complete. Knows only Content. |
| Contract | Content (ABC) | Declares kind, render(), is_complete(). Shares label(). |
| Concrete | Video, Quiz, Article | Each defines its own rendering and its own completion rule. |
| Policy | CompletionPolicy (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.
| Lesson | render() | is_complete() |
|---|---|---|
| ๐ฌ Video | Plays the stream | Watched at least 90% |
| ๐ Article | Shows the text | Scrolled to the end |
| โ Quiz | Lists the questions | Score 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())- The contract:
Contentdeclares what every lesson can do. The attempt to buildContent("Nope", 1)raisesTypeError, proving the class is truly abstract. - Shared behavior:
label()is concrete. It calls the abstractkindproperty, so it works for every subclass without knowing which one it has. - 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. - 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.
| Moment | What Python does | Result 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
from abc import ABC, abstractmethodโABCis a ready-made base class whose metaclass isABCMeta.abstractmethodis the decorator that flags a method as "must be overridden".class Content(ABC):โ inheriting fromABCis what switches on the abstract-class machinery. Without it,@abstractmethoddoes nothing at all.__init__(self, title, minutes)โ shared state lives here once. Every lesson has a title and a duration, so subclasses reuse this throughsuper().__init__(...)instead of copying it.@propertyabove@abstractmethodonkindโ this declares an abstract read-only attribute. Callers writeitem.kindwith no parentheses. The body is...(Ellipsis), a placeholder meaning "intentionally empty". Decorators apply bottom-up:abstractmethodfirst, thenpropertywraps the result.- Abstract
render()andis_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. label()(concrete) โ a normal method that every subclass inherits. Inside it,self.kindis looked up at runtime on the real object, so aVideoprints[Video]and aQuizprints[Quiz]. This is the Template Method idea: fixed skeleton in the base, variable details in subclasses.class Video(Content)andsuper().__init__(title, minutes)โVideoneeds an extraurl, so it has its own__init__. It must call the parent's first, otherwiseself.titleandself.minutesare never set.kindreturns a constant โ because it is a property with no setter,video.kind = "X"raisesAttributeError. The lesson's type cannot be changed by accident.progress.get("watched_pct", 0) >= 90โ.get(key, default)returns0when nothing has been recorded yet. Writingprogress["watched_pct"]would crash withKeyErrorfor a brand-new learner.pass_mark=0.7inQuizโ 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.try: Content("Nope", 1)โ a live proof that the class is abstract. We catch theTypeErrorand print it so you can read the message Python gives.- The final
forloop โ it callslabel()andrender()on three different types with identical code. Noif, noisinstance. That is polymorphism.
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
renderfromQuizand create aQuiz. Read the error โ which method does it name? - Swap the order of
@propertyand@abstractmethodonkind. What happens, and at which moment (class definition or instantiation)? - Remove
@propertyfromVideo.kind. Run the loop and look at the first label. - Print
Video.__abstractmethods__andContent.__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()}%")- Type-agnostic engine:
status()callsis_complete()on each lesson. There is noisinstancecheck anywhere โ that is the point of the abstraction. - Why
add()returnsself: it allows fluent chaining when building a course. - 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 runs | What Python decides |
|---|---|---|
| 1 | percent_complete() calls self.status() | Ordinary method call on Course. |
| 2 | status() loops over self._lessons | Lessons come back in the order they were added. |
| 3 | For "Welcome": self._progress.get("Welcome", {}) | Finds {"watched_pct": 95}. |
| 4 | l.is_complete(...) with l being a Video | Dynamic dispatch: Python looks at the actual type of l, finds Video.is_complete, runs it: 95 >= 90 โ True. |
| 5 | Same expression, l now an Article | Python picks Article.is_complete: 60 >= 100 โ False. |
| 6 | Same expression, l now a Quiz | Python picks Quiz.is_complete: 0.8 >= 0.7 โ True. |
| 7 | sum(1 for _, ok in ... if ok) | Counts the True results: 2. |
| 8 | round(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
- The compact
Contentat the top โ this example repeats a trimmed contract so it runs on its own. It keeps onlytitleandis_complete, because those are the only partsCourseneeds. In a real project you wouldimportthe full class from one module. - Three tiny subclasses โ each holds only its completion rule. They inherit
__init__fromContent, soVideo("Welcome")works without writing a constructor. self._lessons = []andself._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.add(self, lesson: Content)โ the type hint says "anything that is aContent". It documents the dependency on the abstraction. Returningselflets you chain:course.add(a).add(b).add(c).record(self, title, **data)โ**datacollects keyword arguments into a dict, sorecord("Welcome", watched_pct=95)givesdata = {"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.status()list comprehension โ builds(title, done)tuples. Notice the engine never asks what kind of lesson it has. There is noisinstanceanywhere.percent_complete()โfor _, ok in self.status()unpacks each tuple and ignores the title with_.sum(1 for ... if ok)counts the finished lessons.- The fluent
course.add(...).add(...).add(...)โ builds the course in one expression, thanks toreturn self. - Three
recordcalls โ simulate a learner: watched 95% of the video, scrolled 60% of the article, scored 80% on the quiz. - The printing loop โ shows
โfor done andยทfor not done using a conditional expression:"โ" if ok else "ยท".
โ ๏ธ 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
recordfor it. Which results doesstatus()show, and why doesn't it crash? - Change the quiz score to
80instead of0.8. It still "passes" โ why is that a bug in disguise? - Replace
.get("watched_pct", 0)with["watched_pct"]inVideoand run it on an unrecorded lesson. Read theKeyError.
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)- Self-registering subclasses:
__init_subclass__runs whenever a subclass is defined, adding it toContent.registry. DefiningCodeExerciseis all it takes to make it loadable. - Factory classmethod:
from_dict()turns plain data into the right concrete lesson, so storage formats stay decoupled from class names. - Open/Closed Principle: the system is open to new types and closed to modification โ no existing class changed to add
CodeExercise. - Contribution to output: it prints each lesson's class and render line, then
Truefor 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 happens | Content.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
registry = {}insideContentโ a class attribute: one dict shared byContentand every subclass. That sharing is exactly what we want here, because all subclasses must write into the same table.def __init_subclass__(cls, **kwargs)โ a special hook Python calls every time someone subclassesContent.clsis the new subclass (for exampleVideo), notContent. It is an implicit class method, so no decorator is needed.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.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.- Abstract
renderandis_completeโ the contract is unchanged. The registry adds a way to find classes; it does not replace the contract. @classmethod def from_dict(cls, data)โ an alternative constructor. A class method receives the class as its first argument, so it can be called asContent.from_dict(...)without creating an instance first.if kind not in cls.registry: raise ValueError(...)โ fail loudly with a clear message. ReturningNoneinstead would let a bad lesson slip into a course and cause a confusing error much later.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.CodeExercise.is_completeโ usesprogress.get("tests_total", -1). The default of-1can never equal a real count of passed tests, so a lesson with no recorded results is never accidentally "complete" (0 == 0would be a bug).- The
cataloglist of dicts โ stands in for data loaded from JSON or a database. Only strings, no Python classes, so storage stays independent of code. - The final
try/exceptโ shows the failure path: an unknown type produces a readableValueError.
โ ๏ธ 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
Articleclass belowCodeExerciseand printContent.registry. Did you have to changefrom_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_completefromCodeExerciseand callfrom_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}")- A second abstraction: where
Contentabstracts lessons,CompletionPolicyabstracts business rules. A course can be configured with any policy at runtime. - Parameterized strategies:
Threshold(60)carries its own configuration, yet exposes the same interface as the others. - Contribution to output: with two of three lessons done,
AllLessonssays no,Threshold(60)says yes (67%), andMustPassQuizzessays 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.
| Role | In this code | Job |
|---|---|---|
| Strategy interface | CompletionPolicy | Declares what a rule must offer: a name and earns_certificate(results). |
| Concrete strategies | AllLessons, Threshold, MustPassQuizzes | Each implements the rule differently. |
| Context (the user of a strategy) | The for policy in [...] loop | Holds 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
CompletionPolicywith an abstractnameproperty โ lets any policy describe itself, which is handy for dashboards and logs ("Why didn't I get my certificate?").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 fromContent.AllLessons:all(results.values())โall()returnsTrueonly if every item is true, and it stops at the firstFalse, so it is also efficient.Threshold.__init__(self, percent)โ strategies can carry settings.Threshold(60)andThreshold(80)are two different rules created from one class.sum(results.values())โ in PythonTrueequals1andFalseequals0, so summing booleans counts the trues. Then100 * done / len(results)uses true division and gives66.666โฆfor 2 of 3.f"Finish at least {self.percent}%"โ thenameis built from configuration, so eachThresholddescribes itself accurately.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.- The
resultsdict โ in a real system you would build this fromcourse.status()in Step 2. for policy in [AllLessons(), Threshold(60), MustPassQuizzes()]โ a list holding three different classes. They are interchangeable because all share the interface โ the same reason aCoursecan hold three lesson types.f"{policy.name:<24} {verdict}"โ the:<24format spec left-aligns the name inside 24 characters so the verdicts line up in a column.
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-upTask: 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}))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?
EasyTask: 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())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
MediumTask: 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")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
MediumTask: 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))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
MediumTask: 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())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
MediumTask: 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}))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.
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.
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
| Class | Role |
|---|---|
| Content | Abstract lesson contract |
| Video / Article / Quiz | Concrete lessons |
| Course | Engine, depends on Content only |
| CompletionPolicy | Abstract certificate rule |
โ๏ธ Key Syntax
| Snippet | Meaning |
|---|---|
| class X(ABC) | Abstract base |
| @abstractmethod | Must override |
| @property above @abstractmethod | Abstract property |
| __init_subclass__ | Auto-register subclasses |
โ ๏ธ Common Pitfalls
| Mistake | Fix |
|---|---|
| isinstance in Course | Move logic into lesson |
| Forgot an abstract method | Implement all, or TypeError |
| Unknown type in data | Raise ValueError early |
| Fat contract | Split into smaller interfaces |
โจ Extending It
| Idea | How |
|---|---|
| New lesson type | Subclass Content |
| New certificate rule | Subclass CompletionPolicy |
| Load from JSON | Content.from_dict() |
| Test the engine | Use a FakeContent |
Quick Knowledge Check
Five questions on the ideas behind this project.
Great effort โ review any missed questions above.
Abstraction in Action โ Other Projects
Each project applies the same idea โ a stable interface with interchangeable implementations โ to a different domain.
Product catalogue, cart, and checkout behind a unified ABC interface.
๐ช Commerce OOPEach fruit type is a concrete class extending an abstract Produce base.
๐ฟ Inventory OOPPricing abstracted behind a Beverage ABC.
๐พ Catalogue OOPGrain varieties with abstract stock controls.
๐ฆ Retail OOPCommodity trading with abstract market pricing.
โ๏ธ Commodity OOPE-books, audiobooks, and comics behind one Media interface.
๐ Media OOPWarrior, Mage, Archer extend an abstract Character base.
โ๏ธ Game OOPDoctors, nurses, and admins share a common Person ABC.
๐ Health OOPEach vehicle implements its own fare strategy.
๐ Mobility OOPYou are here โ one Content ABC, many lesson types.
๐ LMS OOP ยท CurrentExplore the Full OOP Series
This project applies Abstraction and Polymorphism together. Revisit the pillars or move on to the next.
Bundle data with methods. Control access. Protect state.
Explore โ AppliedThe theory behind this project's Content contract.
Read the guide โBuild on existing classes. Reuse code elegantly.
Explore โ AppliedOne interface, many forms โ the engine behind render().
Explore โ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.
Key Takeaways
- The engine depends on the abstraction โ
CourseknowsContent, neverVideo. - 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
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.
๐ Your email is never shared or sold.