Skip to main content

Abstract Base Classes - Enforcing Interfaces at Engineering Depth

Reading time: ~30 minutes | Level: Intermediate → Engineering

Before reading further, predict what happens when you run this code:

from abc import ABC, abstractmethod

class Storage(ABC):
@abstractmethod
def read(self, key: str) -> bytes: ...

@abstractmethod
def write(self, key: str, data: bytes) -> None: ...

class BadStorage(Storage):
def read(self, key: str) -> bytes:
return b"data"
# Missing: write()

obj = BadStorage()

You might expect this to fail when write is called - at runtime, when the missing method is invoked. But Python fails earlier: it raises TypeError: Can't instantiate abstract class BadStorage with abstract method write at the line obj = BadStorage().

This is the key distinction. ABCs enforce the contract at class instantiation time, not at method call time. This is a fundamentally different failure mode - you catch the missing implementation immediately, not buried in a runtime error hours into production.

warning

Instantiating an ABC subclass with unimplemented abstract methods raises TypeError immediately at the obj = MyClass() line - not when you later call the missing method. This is intentional and is the entire point of ABCs. Do not attempt to work around this by catching TypeError - fix the missing implementation.

What You Will Learn

  • Why ABCs exist and what problem they solve that duck typing cannot
  • How abc.ABC and @abstractmethod work under the hood (ABCMeta)
  • @abstractclassmethod, @abstractstaticmethod, and @abstractproperty
  • Virtual subclasses via register() - the escape hatch
  • collections.abc - the built-in protocol hierarchy (Sequence, Mapping, Iterable, Iterator, MutableMapping, and more)
  • Using ABCs in type hints and what isinstance checks mean
  • ABCs vs typing.Protocol - nominal vs structural typing, and when to use each

Prerequisites

  • Lessons 01–08 of this module
  • Understanding of inheritance, MRO, and typing.Protocol from Lessons 06–07
  • Familiarity with @property and decorators

Part 1 - Why ABCs Exist

Duck Typing Is Not Enough for Interfaces

Python's default philosophy is duck typing: if an object has the right methods, it works. This is powerful and flexible, but it has a critical weakness - the failure happens at the point of use, not at the point of definition.

class FileStorage:
def read(self, key: str) -> bytes:
with open(key, "rb") as f:
return f.read()
# Developer forgot to implement write()

# This works fine - no error yet
storage = FileStorage()

# Error happens here - potentially in production, potentially in a code path
# that is rarely exercised
storage.write("key", b"data") # AttributeError: 'FileStorage' object has no attribute 'write'

With duck typing, the error happens at the call site. If write() is rarely called, this bug could survive in production for months.

ABCs Catch Missing Implementations at Instantiation

from abc import ABC, abstractmethod

class Storage(ABC):
@abstractmethod
def read(self, key: str) -> bytes: ...

@abstractmethod
def write(self, key: str, data: bytes) -> None: ...

class FileStorage(Storage):
def read(self, key: str) -> bytes:
with open(key, "rb") as f:
return f.read()
# Forgot write()

# Error caught HERE - when you try to create an instance
storage = FileStorage()
# TypeError: Can't instantiate abstract class FileStorage with abstract method write

The contract is enforced at instantiation time. This is why ABCs exist: they move interface violations from runtime call sites to object creation time, giving you earlier, clearer error messages.

What ABCs Are Not

ABCs are not interfaces in the Java/C# sense - they can contain implementation. An ABC can provide default implementations for some methods while requiring others to be overridden. This makes them closer to Java abstract classes.

Part 2 - abc.ABC and ABCMeta

The Two Ways to Create an ABC

from abc import ABC, ABCMeta, abstractmethod

# Method 1: inherit from ABC (recommended, cleaner)
class MyABC(ABC):
@abstractmethod
def required_method(self) -> str: ...

# Method 2: use ABCMeta directly (necessary when you have a metaclass conflict)
class MyABC(metaclass=ABCMeta):
@abstractmethod
def required_method(self) -> str: ...

ABC is simply a convenience class defined as:

class ABC(metaclass=ABCMeta):
pass

The real mechanism is ABCMeta. When you inherit from ABC, your class gets ABCMeta as its metaclass, which intercepts __new__ and checks for unimplemented abstract methods.

How ABCMeta Works Internally

ABCMeta tracks abstract methods via the __abstractmethods__ frozenset on each class:

from abc import ABC, abstractmethod

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

@abstractmethod
def perimeter(self) -> float: ...

# Check what ABCMeta recorded
print(Shape.__abstractmethods__) # frozenset({'area', 'perimeter'})

class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius

def area(self) -> float:
import math
return math.pi * self.radius ** 2

# Still missing perimeter()

print(Circle.__abstractmethods__) # frozenset({'perimeter'})

class FullCircle(Shape):
def __init__(self, radius: float):
self.radius = radius

def area(self) -> float:
import math
return math.pi * self.radius ** 2

def perimeter(self) -> float:
import math
return 2 * math.pi * self.radius

print(FullCircle.__abstractmethods__) # frozenset() - empty, all implemented

obj = FullCircle(5.0) # works
print(obj.area()) # 78.53981633974483

When __abstractmethods__ is non-empty, ABCMeta.__call__ raises TypeError at instantiation. When it is empty, instantiation proceeds normally.

tip

Use ABCs to document and enforce interfaces in team codebases. When you write class MyStorage(Storage) and the CI pipeline fails with TypeError: Can't instantiate abstract class MyStorage with abstract method write, the error message is self-explanatory. Compare this to an AttributeError surfacing in production at 3am when a rarely-exercised code path finally runs.

Part 3 - Abstract Decorators

@abstractmethod - The Core Decorator

from abc import ABC, abstractmethod

class DataStore(ABC):

@abstractmethod
def get(self, key: str) -> object | None:
"""Return the value for key, or None if not found."""
...

@abstractmethod
def set(self, key: str, value: object) -> None:
"""Store value at key."""
...

@abstractmethod
def delete(self, key: str) -> bool:
"""Delete key. Return True if deleted, False if not found."""
...

def get_or_default(self, key: str, default: object) -> object:
"""Concrete method - available to all subclasses."""
result = self.get(key)
return result if result is not None else default

@abstractmethod methods can have a body. The body becomes the default implementation accessible via super():

class DataStore(ABC):
@abstractmethod
def validate(self, key: str) -> bool:
# Default implementation - subclasses can call super().validate()
return isinstance(key, str) and len(key) > 0

class RedisStore(DataStore):
def validate(self, key: str) -> bool:
# Extend the base validation
base_valid = super().validate(key)
return base_valid and not key.startswith("_")

@abstractclassmethod and @abstractstaticmethod

These are deprecated in Python 3.3+ in favour of stacking decorators:

from abc import ABC, abstractmethod

class Serializable(ABC):

@classmethod
@abstractmethod
def from_dict(cls, data: dict) -> "Serializable":
"""Factory method - must be implemented by subclasses."""
...

@staticmethod
@abstractmethod
def schema() -> dict:
"""Return the JSON schema for this type."""
...


class User(Serializable):
def __init__(self, username: str, email: str):
self.username = username
self.email = email

@classmethod
def from_dict(cls, data: dict) -> "User":
return cls(data["username"], data["email"])

@staticmethod
def schema() -> dict:
return {
"type": "object",
"properties": {
"username": {"type": "string"},
"email": {"type": "string"},
},
"required": ["username", "email"],
}


u = User.from_dict({"username": "alice", "email": "[email protected]"})
print(User.schema())

@abstractproperty - Also Deprecated; Use @property + @abstractmethod

from abc import ABC, abstractmethod

class Configuration(ABC):

@property
@abstractmethod
def host(self) -> str:
"""Database host - must be defined by subclass."""
...

@property
@abstractmethod
def port(self) -> int:
"""Database port - must be defined by subclass."""
...

def connection_string(self) -> str:
"""Concrete method using abstract properties."""
return f"postgresql://{self.host}:{self.port}/db"


class ProductionConfig(Configuration):
@property
def host(self) -> str:
return "db.production.example.com"

@property
def port(self) -> int:
return 5432


class DevelopmentConfig(Configuration):
@property
def host(self) -> str:
return "localhost"

@property
def port(self) -> int:
return 5432


prod = ProductionConfig()
print(prod.connection_string()) # postgresql://db.production.example.com:5432/db

Stack @property on top of @abstractmethod - the property decorator must be the outermost.

Part 4 - Virtual Subclasses and register()

The Problem register() Solves

Sometimes you need a class to satisfy an ABC interface without modifying its source code. This happens when:

  • The class is from a third-party library
  • The class was written before the ABC existed
  • You are retrofitting an interface onto legacy code

register() is the escape hatch.

from abc import ABC, abstractmethod

class Drawable(ABC):
@abstractmethod
def draw(self) -> None: ...

# Third-party class - you cannot modify it
class ThirdPartyWidget:
def draw(self) -> None:
print("ThirdPartyWidget drawn")

# Register it as a virtual subclass
Drawable.register(ThirdPartyWidget)

# Now isinstance() returns True
widget = ThirdPartyWidget()
print(isinstance(widget, Drawable)) # True
print(issubclass(ThirdPartyWidget, Drawable)) # True

# But no enforcement - register() takes your word for it
class BrokenWidget:
pass # No draw() method

Drawable.register(BrokenWidget)
print(isinstance(BrokenWidget(), Drawable)) # True - but draw() doesn't exist!
BrokenWidget().draw() # AttributeError - no enforcement at all

This is the critical distinction: register() provides nominal subclass registration without enforcement. Use it only when you are certain the registered class genuinely implements the interface.

Decorator Form of register()

from abc import ABC, abstractmethod

class Loggable(ABC):
@abstractmethod
def log(self, message: str) -> None: ...

@Loggable.register
class ExternalLogger:
"""Third-party logger - satisfies Loggable by convention."""

def log(self, message: str) -> None:
print(f"[EXTERNAL] {message}")

print(isinstance(ExternalLogger(), Loggable)) # True

__subclasshook__ - Structural Registration

__subclasshook__ allows an ABC to define custom isinstance logic - making it behave structurally (like a Protocol) rather than nominally.

from abc import ABC, abstractmethod

class Sized(ABC):
@abstractmethod
def __len__(self) -> int: ...

@classmethod
def __subclasshook__(cls, subclass):
"""
Return True if the subclass has __len__.
This makes isinstance(obj, Sized) work for any class with __len__,
without explicit registration or inheritance.
"""
if cls is Sized:
return hasattr(subclass, "__len__")
return NotImplemented


print(isinstance([], Sized)) # True - list has __len__
print(isinstance({}, Sized)) # True - dict has __len__
print(isinstance("hello", Sized)) # True - str has __len__
print(isinstance(42, Sized)) # False - int has no __len__

This is how collections.abc works internally - the built-in ABCs use __subclasshook__ to support structural matching.

Part 5 - collections.abc - The Built-In Protocol Hierarchy

collections.abc provides ABCs for the core Python data structures. These are the ABCs you will encounter most often in production code.

The Hierarchy

Hashable
Iterable
Iterator
Generator
Sized
Container
Callable
Sequence (Reversible, Collection)
MutableSequence
Mapping (Collection)
MutableMapping
MappingView
Set (Collection)
MutableSet

Key ABCs and Their Required Methods

from collections.abc import (
Iterable, Iterator, Sequence, MutableSequence,
Mapping, MutableMapping, Callable, Hashable
)

# Iterable: requires __iter__
class NumberRange:
def __init__(self, start: int, stop: int):
self.start = start
self.stop = stop

def __iter__(self):
return iter(range(self.start, self.stop))

print(isinstance(NumberRange(1, 5), Iterable)) # True - has __iter__

# Iterator: requires __iter__ AND __next__
class CountUp:
def __init__(self, limit: int):
self.limit = limit
self._current = 0

def __iter__(self):
return self

def __next__(self):
if self._current >= self.limit:
raise StopIteration
self._current += 1
return self._current

print(isinstance(CountUp(5), Iterator)) # True

# Sequence: requires __getitem__ and __len__
# Provides: __contains__, __iter__, __reversed__, index(), count()
class FibSequence(Sequence):
def __init__(self, length: int):
self._length = length
self._cache = {}

def __len__(self) -> int:
return self._length

def __getitem__(self, index: int) -> int:
if index < 0:
index = self._length + index
if index >= self._length:
raise IndexError(index)
return self._fib(index)

def _fib(self, n: int) -> int:
if n in self._cache:
return self._cache[n]
if n <= 1:
return n
result = self._fib(n - 1) + self._fib(n - 2)
self._cache[n] = result
return result

fib = FibSequence(10)
print(fib[0]) # 0
print(fib[9]) # 34
print(list(fib)) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
print(5 in fib) # True - __contains__ provided by Sequence mixin
print(fib.index(8)) # 6 - index() provided by Sequence mixin
note

collections.abc gives you free mixin methods when you implement the required abstract methods. For example: implement __getitem__ and __len__ in a Sequence subclass and you automatically get __contains__, __iter__, __reversed__, index(), and count() for free. Implement the five required methods of MutableMapping (__getitem__, __setitem__, __delitem__, __iter__, __len__) and you get the entire dict-like interface - get, keys, values, items, setdefault, pop, popitem, update, clear - all provided by the MutableMapping mixin methods.

MutableMapping - Building Custom Dict-Like Objects

MutableMapping requires __getitem__, __setitem__, __delitem__, __iter__, and __len__. It provides all other dict-like methods automatically.

from collections.abc import MutableMapping

class TTLDict(MutableMapping):
"""A dict where every value has a time-to-live."""

def __init__(self, default_ttl: float = 60.0):
import time
self._store: dict = {}
self._expiry: dict = {}
self._default_ttl = default_ttl
self._time = time.time

def __getitem__(self, key):
import time
if key in self._expiry and self._time() > self._expiry[key]:
del self._store[key]
del self._expiry[key]
raise KeyError(key)
return self._store[key]

def __setitem__(self, key, value):
self._store[key] = value
self._expiry[key] = self._time() + self._default_ttl

def __delitem__(self, key):
del self._store[key]
del self._expiry.get(key, None) # cleanup expiry too

def __iter__(self):
return iter(self._store)

def __len__(self):
return len(self._store)


cache = TTLDict(default_ttl=300)
cache["user:1"] = {"name": "Alice"}
print(cache["user:1"]) # {'name': 'Alice'}
print(cache.get("user:99")) # None - provided by MutableMapping
cache.setdefault("user:2", {"name": "Bob"}) # provided by MutableMapping
print(dict(cache)) # {'user:1': {...}, 'user:2': {...}}

By implementing just 5 methods, you get the full dict-like interface - get, keys, values, items, setdefault, pop, popitem, update, clear.

Checking Built-In Types Against collections.abc

from collections.abc import Sequence, Mapping, Iterable, MutableMapping

print(isinstance([], Sequence)) # True
print(isinstance((), Sequence)) # True
print(isinstance("hello", Sequence)) # True
print(isinstance({}, Sequence)) # False - dicts are Mappings, not Sequences
print(isinstance({}, Mapping)) # True
print(isinstance({}, MutableMapping)) # True
print(isinstance(None, Iterable)) # False
print(issubclass(list, MutableMapping)) # False

Use isinstance(obj, Sequence) in function signatures instead of isinstance(obj, list) - it accepts tuples, strings, and custom sequence types.

Part 6 - ABCs in Type Hints

ABCs from collections.abc are the correct types to use in function signatures when you want to accept any compatible implementation.

from collections.abc import Mapping, Sequence, Iterable, Callable
from typing import TypeVar

T = TypeVar("T")

def process_items(items: Iterable[T]) -> list[T]:
"""Accepts any iterable - list, tuple, generator, custom Iterable."""
return [item for item in items]

def merge_configs(*configs: Mapping[str, object]) -> dict[str, object]:
"""Accepts any mapping - dict, OrderedDict, ChainMap, custom Mapping."""
result: dict[str, object] = {}
for config in configs:
result.update(config)
return result

def apply_transform(data: Sequence[float], transform: Callable[[float], float]) -> list[float]:
"""Accepts any sequence and any callable."""
return [transform(x) for x in data]

# All of these work
process_items([1, 2, 3])
process_items((1, 2, 3))
process_items(x for x in range(3))

merge_configs({"a": 1}, {"b": 2})

import math
apply_transform([1.0, 4.0, 9.0], math.sqrt)
apply_transform((1.0, 4.0, 9.0), lambda x: x ** 2)

Using ABC types in signatures makes code more flexible and documents intent: "I need something iterable, not specifically a list."

Part 7 - ABCs vs typing.Protocol

This is the most practically important design decision when enforcing interfaces in Python.

Side-by-Side Comparison

from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable

# ABC approach - nominal typing
class Serializable(ABC):
@abstractmethod
def to_bytes(self) -> bytes: ...

@abstractmethod
def from_bytes(cls, data: bytes) -> "Serializable": ...

# Must explicitly inherit
class Message(Serializable):
def __init__(self, content: str):
self.content = content

def to_bytes(self) -> bytes:
return self.content.encode()

def from_bytes(cls, data: bytes) -> "Message":
return cls(data.decode())


# Protocol approach - structural typing
@runtime_checkable
class Serializable(Protocol):
def to_bytes(self) -> bytes: ...
def from_bytes(cls, data: bytes) -> "Serializable": ...

# No inheritance needed - just have the right methods
class Message:
def __init__(self, content: str):
self.content = content

def to_bytes(self) -> bytes:
return self.content.encode()

def from_bytes(cls, data: bytes) -> "Message":
return cls(data.decode())

print(isinstance(Message("hello"), Serializable)) # True with @runtime_checkable

Decision Framework

Use ABCs when:

  • You own both the interface definition and the implementing classes
  • You want enforcement at class instantiation time (not just type checker hints)
  • You want to provide default implementations (get_or_default, connection_string)
  • You are building a plugin system where implementers explicitly declare they support the contract
  • You are working within a framework that uses ABC-based dispatch (collections.abc)

Use Protocol when:

  • You do not own (or cannot modify) the implementing class
  • You want to retroactively describe an interface that existing code already satisfies
  • You want pure structural (duck-typing) semantics
  • You are describing an interface for type checking only - no runtime enforcement needed
  • You want to describe built-in types without registering them
# Protocol describes what list already satisfies - without modifying list
from typing import Protocol

class Stack(Protocol):
def append(self, item: object) -> None: ...
def pop(self) -> object: ...
def __len__(self) -> int: ...

def use_stack(s: Stack) -> None:
s.append(1)
s.append(2)
print(s.pop())

use_stack([]) # list satisfies Stack - no registration, no inheritance

Combining ABCs and Protocols

In large codebases, you often use both:

from abc import ABC, abstractmethod
from typing import Protocol


class Repository(Protocol):
"""Structural interface for type checkers - describes what external ORMs provide."""
def find(self, id: int) -> dict | None: ...
def save(self, entity: dict) -> None: ...


class BaseService(ABC):
"""Nominal ABC for internal service hierarchy - enforces business logic hooks."""

def __init__(self, repo: Repository):
self._repo = repo

@abstractmethod
def validate(self, entity: dict) -> None:
"""Subclasses must implement entity validation."""
...

def save(self, entity: dict) -> None:
self.validate(entity)
self._repo.save(entity)


class UserService(BaseService):
def validate(self, entity: dict) -> None:
if "username" not in entity:
raise ValueError("username required")
if "email" not in entity:
raise ValueError("email required")

Repository is a Protocol because the concrete implementations (SQLAlchemy, Django ORM, MongoDB ODM) are third-party and cannot inherit from your ABC. BaseService is an ABC because you control the service hierarchy and want enforcement.

Common Mistakes

Mistake 1 - Forgetting to Implement All Abstract Methods

from abc import ABC, abstractmethod

class Base(ABC):
@abstractmethod
def method_a(self): ...

@abstractmethod
def method_b(self): ...

class Partial(Base):
def method_a(self):
return "a"
# method_b not implemented

obj = Partial()
# TypeError: Can't instantiate abstract class Partial with abstract method method_b

The error is clear. Do not attempt to work around it by catching TypeError - fix the missing implementation.

Mistake 2 - Using register() Without Enforcing the Interface

from abc import ABC, abstractmethod

class Writer(ABC):
@abstractmethod
def write(self, data: bytes) -> None: ...

class RandomClass:
def do_something(self): pass # Does NOT have write()

Writer.register(RandomClass) # No error - but the contract is not satisfied
RandomClass().write(b"data") # AttributeError at runtime

register() is a trust-based mechanism. Only use it when the class genuinely implements the interface.

Mistake 3 - Stacking Property and Abstractmethod in Wrong Order

from abc import ABC, abstractmethod

class Bad(ABC):
@abstractmethod
@property # Wrong order - abstractmethod must be innermost
def value(self) -> int: ...

class Good(ABC):
@property
@abstractmethod # Correct - property is outermost
def value(self) -> int: ...

Mistake 4 - Using isinstance on ABCs as Type Filtering (Performance)

isinstance with ABCs is slower than with concrete classes because it may trigger __subclasshook__. In hot paths, cache the result or use direct type checking.

# In a hot loop - avoid ABC isinstance per iteration
def process_batch(items):
for item in items:
if isinstance(item, Iterable): # __subclasshook__ called each time
...

# Better: check once, use duck typing in the loop
from collections.abc import Iterable

def process_item(item):
try:
iter(item)
except TypeError:
raise ValueError(f"Expected iterable, got {type(item).__name__}")

Engineering Checklist

Before moving to the next lesson, verify you can answer these without looking:

  1. At what point does Python enforce abstract method implementation - instantiation or method call?
  2. How does ABCMeta track which methods are abstract? What attribute stores this?
  3. What is __subclasshook__ and how does it make ABCs behave structurally?
  4. What is the difference between inheriting from an ABC and calling register()?
  5. What five methods does MutableMapping require, and what methods does it provide for free?
  6. Why should you use Iterable from collections.abc in function signatures instead of list?
  7. What is the correct decorator order for an abstract property?
  8. When should you use an ABC vs a typing.Protocol?
  9. How do you stack @classmethod with @abstractmethod in Python 3.3+?

Key Takeaways

  • ABCs move interface violations from runtime call sites to class instantiation time. TypeError: Can't instantiate abstract class X with abstract method y is a clear, early signal - not an AttributeError buried in production logs.
  • ABCMeta tracks unimplemented abstract methods via __abstractmethods__ - a frozenset on each class. When it is non-empty, instantiation raises TypeError. Inspect it directly to debug partial implementations.
  • @abstractmethod methods can have a body (default implementation) accessible via super(). ABCs are not pure interfaces - they can provide shared concrete behaviour.
  • register() is a trust-based escape hatch for third-party classes. It grants isinstance membership without enforcement - the registered class may not actually implement the interface. Use it only when you are certain.
  • __subclasshook__ lets an ABC define custom isinstance logic, enabling structural (duck-typed) matching - exactly how collections.abc works for built-in types.
  • collections.abc gives you free mixin methods: implement __getitem__ and __len__ in a Sequence subclass and get __contains__, __iter__, index(), and count() for free. Implement 5 methods in MutableMapping and get the full dict-like interface.
  • Use collections.abc types (Iterable, Sequence, Mapping) in function signatures rather than list or dict - this accepts tuples, generators, custom sequences, and any compatible implementation.
  • Use ABCs when you own both sides and want instantiation-time enforcement. Use typing.Protocol when you cannot modify the implementing class, want purely structural matching, or are describing an interface for a type checker only.
  • The correct decorator order for abstract properties is @property outermost, @abstractmethod innermost: @property / @abstractmethod stacked in that order.

Graded Practice

Level 1 - Predict the Output

Question 1

from abc import ABC, abstractmethod

class Animal(ABC):
@abstractmethod
def sound(self) -> str: ...

def describe(self) -> str:
return f"I say: {self.sound()}"

class Dog(Animal):
def sound(self) -> str:
return "woof"

d = Dog()
print(d.describe())
print(d.__abstractmethods__)
Show Answer
I say: woof
frozenset()

Dog implements sound(), so __abstractmethods__ is an empty frozenset and Dog() can be instantiated. describe() is a concrete method on Animal that calls self.sound() - which resolves to Dog.sound() via normal method resolution. ABCs can mix abstract and concrete methods freely.

Question 2

from abc import ABC, abstractmethod

class Base(ABC):
@abstractmethod
def run(self): ...

class Child(Base):
pass

try:
c = Child()
print("created")
except TypeError as e:
print(f"TypeError: {e}")
Show Answer
TypeError: Can't instantiate abstract class Child with abstract method run

Child inherits from Base but does not implement run(). Child.__abstractmethods__ is frozenset({'run'}). When Child() is called, ABCMeta.__call__ checks __abstractmethods__ and raises TypeError immediately - before __init__ is even called.

Question 3

from abc import ABC, abstractmethod

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

print(Shape.__abstractmethods__)

class Circle(Shape):
def __init__(self, r):
self.r = r
def area(self):
import math
return math.pi * self.r ** 2

print(Circle.__abstractmethods__)
print(isinstance(Circle(5), Shape))
Show Answer
frozenset({'area'})
frozenset()
True

Shape.__abstractmethods__ contains 'area' because area is declared abstract. Circle implements area, so Circle.__abstractmethods__ is an empty frozenset. isinstance(Circle(5), Shape) is True because Circle inherits from Shape - nominal typing via inheritance.

Question 4

from collections.abc import Sequence

class Pair(Sequence):
def __init__(self, a, b):
self._data = (a, b)

def __getitem__(self, index):
return self._data[index]

def __len__(self):
return 2

p = Pair(10, 20)
print(len(p))
print(p[0])
print(list(p))
print(10 in p)
print(p.index(20))
Show Answer
2
10
[10, 20]
True
1

Pair implements the two abstract methods required by Sequence (__getitem__ and __len__). The Sequence mixin provides __contains__, __iter__, __reversed__, index(), and count() automatically. list(p) works via the inherited __iter__. 10 in p works via the inherited __contains__. p.index(20) works via the inherited index().

Question 5

from abc import ABC, abstractmethod

class Writer(ABC):
@abstractmethod
def write(self, data: bytes) -> None: ...

class GoodWriter:
def write(self, data: bytes) -> None:
print(f"Writing {len(data)} bytes")

Writer.register(GoodWriter)

w = GoodWriter()
print(isinstance(w, Writer))
print(issubclass(GoodWriter, Writer))
print(GoodWriter.__abstractmethods__ if hasattr(GoodWriter, '__abstractmethods__') else "none")
Show Answer
True
True
none

GoodWriter does not inherit from Writer - it is registered as a virtual subclass via Writer.register(GoodWriter). After registration, isinstance and issubclass return True. But GoodWriter does not have __abstractmethods__ set - registration bypasses all enforcement. The ABC system takes your word for it.

Level 2 - Debug Challenge

The following ABC and implementation have two bugs. One prevents instantiation, one is a subtle decorator ordering mistake. Find and fix both.

from abc import ABC, abstractmethod

class DataModel(ABC):

@abstractmethod
@property
def name(self) -> str: ... # Bug 1: wrong decorator order

@abstractmethod
def save(self) -> None: ...

def describe(self) -> str:
return f"Model: {self.name}"


class UserModel(DataModel):
def __init__(self, username: str):
self._username = username

@property
def name(self) -> str:
return self._username

# Bug 2: save() not implemented


user = UserModel("alice") # Will this work?
print(user.describe())
Show Answer

Bug 1 - Wrong decorator order for abstract property:

@abstractmethod must be the innermost decorator (closest to the function). @property must be outermost. The current code has them reversed.

# Wrong:
@abstractmethod
@property
def name(self) -> str: ...

# Correct:
@property
@abstractmethod
def name(self) -> str: ...

Bug 2 - Missing implementation of save():

UserModel does not implement save(). UserModel.__abstractmethods__ would contain 'save', causing TypeError on instantiation.

Fixed code:

from abc import ABC, abstractmethod

class DataModel(ABC):

@property
@abstractmethod # Correct: property outermost, abstractmethod innermost
def name(self) -> str: ...

@abstractmethod
def save(self) -> None: ...

def describe(self) -> str:
return f"Model: {self.name}"


class UserModel(DataModel):
def __init__(self, username: str):
self._username = username

@property
def name(self) -> str:
return self._username

def save(self) -> None: # Fixed: implement save()
print(f"Saving user: {self._username}")


user = UserModel("alice")
print(user.describe()) # Model: alice
user.save() # Saving user: alice

Level 3 - Design Challenge

Design an ABC-based plugin system for a document converter. The system should:

  1. Define an abstract Converter base class with abstract methods can_convert(source_format, target_format) and convert(content, source_format, target_format)
  2. Implement at least two concrete converters: MarkdownToHtmlConverter and HtmlToTextConverter
  3. Implement a ConverterRegistry that finds the right converter for a given format pair
  4. Verify that instantiating an incomplete converter raises TypeError immediately
  5. Use __abstractmethods__ to demonstrate what ABCMeta tracks
Show Answer
from abc import ABC, abstractmethod
from typing import List


class Converter(ABC):
"""
Abstract base class for all document converters.

Subclasses must implement:
- can_convert(source_format, target_format) -> bool
- convert(content, source_format, target_format) -> str
"""

@abstractmethod
def can_convert(self, source_format: str, target_format: str) -> bool:
"""Return True if this converter handles the given format pair."""
...

@abstractmethod
def convert(self, content: str, source_format: str, target_format: str) -> str:
"""Convert content from source_format to target_format."""
...

def __repr__(self) -> str:
return f"{type(self).__name__}()"


# Demonstrate what ABCMeta tracks
print(f"Converter abstract methods: {Converter.__abstractmethods__}")
# frozenset({'can_convert', 'convert'})


# Concrete implementation 1
class MarkdownToHtmlConverter(Converter):

def can_convert(self, source_format: str, target_format: str) -> bool:
return source_format.lower() == "md" and target_format.lower() == "html"

def convert(self, content: str, source_format: str, target_format: str) -> str:
# Simplified: just wrap paragraphs
lines = content.strip().split("\n")
html_lines = []
for line in lines:
if line.startswith("# "):
html_lines.append(f"<h1>{line[2:]}</h1>")
elif line.startswith("## "):
html_lines.append(f"<h2>{line[3:]}</h2>")
elif line.strip():
html_lines.append(f"<p>{line}</p>")
return "\n".join(html_lines)


# Concrete implementation 2
class HtmlToTextConverter(Converter):

def can_convert(self, source_format: str, target_format: str) -> bool:
return source_format.lower() == "html" and target_format.lower() == "txt"

def convert(self, content: str, source_format: str, target_format: str) -> str:
import re
# Strip all HTML tags
text = re.sub(r"<[^>]+>", "", content)
# Collapse whitespace
text = re.sub(r"\s+", " ", text).strip()
return text


# Incomplete converter - for demonstrating TypeError
class IncompleteConverter(Converter):
def can_convert(self, source_format: str, target_format: str) -> bool:
return False
# Missing: convert()

print(f"IncompleteConverter abstract methods: {IncompleteConverter.__abstractmethods__}")
# frozenset({'convert'})

try:
bad = IncompleteConverter()
except TypeError as e:
print(f"TypeError caught: {e}")
# TypeError caught: Can't instantiate abstract class IncompleteConverter
# with abstract method convert


# Registry
class ConverterRegistry:
def __init__(self, converters: List[Converter]):
self._converters = converters

def get_converter(self, source_format: str, target_format: str) -> Converter:
for conv in self._converters:
if conv.can_convert(source_format, target_format):
return conv
raise ValueError(
f"No converter found for {source_format!r} -> {target_format!r}"
)

def convert(self, content: str, source_format: str, target_format: str) -> str:
converter = self.get_converter(source_format, target_format)
print(f"Using {converter!r}")
return converter.convert(content, source_format, target_format)


# Wire it up
registry = ConverterRegistry([
MarkdownToHtmlConverter(),
HtmlToTextConverter(),
])

md_content = "# Hello\n\nThis is a paragraph.\n\n## Section Two\n\nAnother paragraph."
html = registry.convert(md_content, "md", "html")
print(html)

text = registry.convert(html, "html", "txt")
print(text)

# Attempt unknown format
try:
registry.convert("content", "pdf", "docx")
except ValueError as e:
print(f"ValueError: {e}")

Output:

Converter abstract methods: frozenset({'can_convert', 'convert'})
IncompleteConverter abstract methods: frozenset({'convert'})
TypeError caught: Can't instantiate abstract class IncompleteConverter with abstract method convert
Using MarkdownToHtmlConverter()
<h1>Hello</h1>
<p>This is a paragraph.</p>
<h2>Section Two</h2>
<p>Another paragraph.</p>
Using HtmlToTextConverter()
Hello This is a paragraph. Section Two Another paragraph.
ValueError: No converter found for 'pdf' -> 'docx'

Key design points:

  • Converter ABC enforces can_convert and convert at instantiation time
  • IncompleteConverter cannot be instantiated - caught immediately, not at the point of use
  • ConverterRegistry accepts List[Converter] - any concrete converter can be injected
  • Adding a new format pair requires a new Converter subclass with zero changes to ConverterRegistry

What's Next

Lesson 10 covers Dataclasses - the @dataclass decorator that generates __init__, __repr__, __eq__, and optionally __hash__, __lt__, and more from field declarations. Dataclasses are widely used in production for configuration objects, API request/response models (FastAPI), value objects, and any place where you need a class whose primary purpose is to hold data.

© 2026 EngineersOfAI. All rights reserved.