Python & FastAPI · Lesson 3 of 10
Classes & OOP: From Basics to Protocols
Why OOP in Python?
Python lets you write perfectly good code without any classes. But when you're building pipelines, CLI tools, or frameworks, classes give you:
- Encapsulation — bundle state and logic that belong together
- Reuse — inheritance and composition avoid repetition
- Contracts — ABCs and Protocols define what something must support
- Readability —
user.deactivate()is clearer thandeactivate_user(user)
1. Class Basics
class User:
def __init__(self, name: str, email: str) -> None:
self.name = name
self.email = email
self.is_active: bool = True
def deactivate(self) -> None:
self.is_active = False
def __repr__(self) -> str:
return f"User(name={self.name!r}, email={self.email!r})"
user = User("Asma", "asma@example.com")
user.deactivate()
print(user) # User(name='Asma', email='asma@example.com')Instance vs class vs static
class Counter:
total_created: int = 0 # class variable — shared across all instances
def __init__(self) -> None:
self.count: int = 0
Counter.total_created += 1
def increment(self) -> None: # instance method — has `self`
self.count += 1
@classmethod
def reset_total(cls) -> None: # class method — has `cls` (the class itself)
cls.total_created = 0
@staticmethod
def validate_count(n: int) -> bool: # static method — no implicit arg
return n >= 02. Inheritance
class Animal:
def __init__(self, name: str) -> None:
self.name = name
def speak(self) -> str:
raise NotImplementedError
class Dog(Animal):
def speak(self) -> str:
return f"{self.name} says Woof!"
class Cat(Animal):
def speak(self) -> str:
return f"{self.name} says Meow!"
animals: list[Animal] = [Dog("Rex"), Cat("Whiskers")]
for a in animals:
print(a.speak())super() — calling the parent
class AdminUser(User):
def __init__(self, name: str, email: str, role: str) -> None:
super().__init__(name, email) # call User.__init__
self.role = role
def __repr__(self) -> str:
base = super().__repr__()
return f"Admin({base}, role={self.role!r})"Multiple inheritance and MRO
Python resolves method calls using the Method Resolution Order (MRO) — left to right, then up.
class Logger:
def log(self, msg: str) -> None:
print(f"[LOG] {msg}")
class Serializable:
def to_dict(self) -> dict:
return self.__dict__
class Pipeline(Logger, Serializable): # inherits both
def run(self) -> None:
self.log("Starting")
p = Pipeline()
p.log("Running") # from Logger
p.to_dict() # from Serializable
# inspect MRO
print(Pipeline.__mro__)3. Properties
Use @property to control attribute access with getter/setter logic.
class Temperature:
def __init__(self, celsius: float) -> None:
self._celsius = celsius # underscore = "private by convention"
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("Below absolute zero")
self._celsius = value
@property
def fahrenheit(self) -> float:
return self._celsius * 9/5 + 32
t = Temperature(100)
print(t.fahrenheit) # 212.0
t.celsius = -300 # raises ValueError4. Dataclasses
@dataclass auto-generates __init__, __repr__, __eq__, and optionally __hash__ and __lt__.
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Order:
id: int
product: str
quantity: int
price: float
created_at: datetime = field(default_factory=datetime.utcnow)
tags: list[str] = field(default_factory=list)
@property
def total(self) -> float:
return self.quantity * self.price
order = Order(id=1, product="Widget", quantity=5, price=9.99)
print(order.total) # 49.95
print(order) # Order(id=1, product='Widget', ...)Frozen dataclasses (immutable)
@dataclass(frozen=True)
class Point:
x: float
y: float
p = Point(1.0, 2.0)
p.x = 3.0 # raises FrozenInstanceErrorDataclass vs dict: when to use each
| Situation | Use |
|-----------|-----|
| Structured config with defaults | @dataclass |
| Validated API models | Pydantic BaseModel |
| Arbitrary key-value data | dict |
| Immutable record | @dataclass(frozen=True) |
5. Abstract Base Classes (ABCs)
ABCs define a contract — subclasses must implement certain methods.
from abc import ABC, abstractmethod
class DataSource(ABC):
@abstractmethod
def read(self) -> list[dict]:
...
@abstractmethod
def close(self) -> None:
...
def read_all(self) -> list[dict]: # concrete method — shared logic
try:
return self.read()
finally:
self.close()
class CSVSource(DataSource):
def __init__(self, path: str) -> None:
self.path = path
def read(self) -> list[dict]:
import csv
with open(self.path) as f:
return list(csv.DictReader(f))
def close(self) -> None:
pass # files are closed by context manager
class DatabaseSource(DataSource):
def read(self) -> list[dict]:
... # query the database
def close(self) -> None:
... # close connection
# DataSource() # raises TypeError — can't instantiate abstract class
source = CSVSource("data.csv")6. Protocols (Structural Typing)
Protocols check for capability, not ancestry. A class doesn't need to inherit — it just needs the right methods.
from typing import Protocol
class Runnable(Protocol):
def run(self) -> None:
...
class Worker:
def run(self) -> None:
print("Worker running")
class Pipeline:
def run(self) -> None:
print("Pipeline running")
def start(task: Runnable) -> None:
task.run()
start(Worker()) # works — Worker has .run()
start(Pipeline()) # works — Pipeline has .run()
# No inheritance from Runnable neededProtocol with runtime check
from typing import runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None:
...
class Connection:
def close(self) -> None:
...
isinstance(Connection(), Closable) # True7. Dunder (Magic) Methods
Dunder methods let your class integrate with Python's built-in syntax.
from typing import Iterator
class NumberRange:
def __init__(self, start: int, stop: int) -> None:
self.start = start
self.stop = stop
def __len__(self) -> int:
return self.stop - self.start
def __contains__(self, item: int) -> bool:
return self.start <= item < self.stop
def __iter__(self) -> Iterator[int]:
return iter(range(self.start, self.stop))
def __repr__(self) -> str:
return f"NumberRange({self.start}, {self.stop})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, NumberRange):
return NotImplemented
return self.start == other.start and self.stop == other.stop
r = NumberRange(1, 11)
len(r) # 10
5 in r # True
list(r) # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]Context manager protocol
class Timer:
def __enter__(self) -> "Timer":
import time
self._start = time.perf_counter()
return self
def __exit__(self, *args: object) -> None:
self.elapsed = time.perf_counter() - self._start
print(f"Elapsed: {self.elapsed:.3f}s")
with Timer() as t:
sum(range(1_000_000))
print(t.elapsed)8. Composition Over Inheritance
Inheritance is often overused. Prefer composition when you want to reuse behaviour without coupling classes.
class FileWriter:
def write(self, path: str, content: str) -> None:
with open(path, "w") as f:
f.write(content)
class JsonSerializer:
def serialize(self, data: dict) -> str:
import json
return json.dumps(data, indent=2)
class ReportExporter:
def __init__(self) -> None:
self._writer = FileWriter() # composed
self._serializer = JsonSerializer() # composed
def export(self, data: dict, path: str) -> None:
content = self._serializer.serialize(data)
self._writer.write(path, content)9. Real Pattern: Base Pipeline Step
This pattern is used in ETL pipelines, data processing tools, and framework internals.
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class StepResult:
success: bool
data: list[dict]
errors: list[str]
class PipelineStep(ABC):
name: str = "unnamed"
@abstractmethod
def process(self, data: list[dict]) -> StepResult:
...
def __call__(self, data: list[dict]) -> StepResult:
print(f"[{self.name}] Processing {len(data)} records")
result = self.process(data)
if not result.success:
print(f"[{self.name}] Errors: {result.errors}")
return result
class FilterNulls(PipelineStep):
name = "filter_nulls"
def process(self, data: list[dict]) -> StepResult:
clean = [r for r in data if all(v is not None for v in r.values())]
removed = len(data) - len(clean)
return StepResult(success=True, data=clean, errors=[f"removed {removed} null rows"])
class NormalizeEmails(PipelineStep):
name = "normalize_emails"
def process(self, data: list[dict]) -> StepResult:
for row in data:
if "email" in row:
row["email"] = row["email"].strip().lower()
return StepResult(success=True, data=data, errors=[])
# run the pipeline
steps: list[PipelineStep] = [FilterNulls(), NormalizeEmails()]
current_data = [{"email": " USER@EXAMPLE.COM "}, {"email": None}]
for step in steps:
result = step(current_data)
current_data = result.dataExercises
Exercise 1: Create a BankAccount class with deposit, withdraw, and a balance property. Raise ValueError on negative deposits or withdrawals that exceed balance.
Exercise 2: Define a Sortable Protocol with a sort_key property that returns a float. Write a function sort_items(items: list[Sortable]) -> list[Sortable] that uses it.
Exercise 3: Write a retry_on_error context manager class (using __enter__/__exit__) that catches exceptions and re-runs the block up to N times.
Summary
| Tool | Use When |
|------|---------|
| Plain class | Stateful objects with methods |
| @dataclass | Structured records, config, DTOs |
| Inheritance | "is-a" relationship, shared concrete logic |
| ABC | Enforcing contracts in base classes |
| Protocol | Duck-typing — structural interface without inheritance |
| Properties | Computed attrs, validation on set |
| Composition | Combining behaviours without tight coupling |
| Dunder methods | Integrating with Python syntax (len, in, with) |
Next: file handling, scripting patterns, and CLI tools with argparse/Typer.