Modelling Real-World Things as Code
Every server, ticket, employee, and dataset in your scripts so far has been a dictionary — a loose bag of fields with no guaranteed structure or behaviour. Classes let you define a proper blueprint: a Server type that always has a name and a CPU reading, and always knows how to check its own health. This is the last foundational module — after this, the curriculum splits into your specialty track.
You’ve actually been using objects all along — every string, list, and dictionary in Python is an object, which is why they all have methods like .append() or .upper(). Object-Oriented Programming (OOP) is simply the practice of defining your own types, with their own data and their own methods, tailored to your problem domain.
Classes and Objects — Blueprint vs Instance
A class is a blueprint — it defines what data and behaviour every object of that type will have, but it isn’t itself a usable thing. An object (also called an instance) is a specific thing built from that blueprint, with its own actual values. One class, many objects — exactly like one cookie cutter producing many cookies.
name
cpu
env
You define a class with the class keyword and create (or instantiate) objects from it by calling the class name like a function: my_server = Server(“web-01”, 45, “prod”). Each object has its own independent copy of the data — changing web_server.cpu has no effect on db_server.cpu.
__init__ and self — How Objects Get Their Data
__init__ is a special method that runs automatically the moment an object is created. Its job is to set up the object’s initial data — the attributes it will hold. Every method in a class, including __init__, takes self as its first parameter — a reference to “this specific object” — even though you never pass it explicitly when calling.
Inside any method, self.name means “this particular object’s name attribute.” When you write web01 = Server(“web-01”, 45), Python automatically calls __init__(web01, “web-01”, 45) behind the scenes — self becomes web01. You never type self as an argument when calling; Python supplies it for you.
Instance Attributes vs Class Attributes
Attributes set inside __init__ using self.x = … are instance attributes — every object gets its own independent copy. Attributes defined directly inside the class body (not inside a method) are class attributes — shared by every instance unless an individual object overrides it.
class Server:
company = "Acme Corp" # CLASS attribute — shared by all instances
cpu_warning_threshold = 80 # CLASS attribute — a shared default/setting
def __init__(self, name, cpu):
self.name = name # INSTANCE attribute — unique per object
self.cpu = cpu # INSTANCE attribute — unique per object
web01 = Server("web-01", 45)
db01 = Server("db-01", 82)
print(web01.company) # "Acme Corp" — shared
print(db01.company) # "Acme Corp" — same shared value
print(web01.name, db01.name) # "web-01 db-01" — independent
Use class attributes for genuinely shared constants or defaults — like a company name, a default threshold, or a counter of how many objects have been created. Use instance attributes for anything that varies per object, which is almost everything.
Inheritance — Sharing Behaviour Across Related Classes
Inheritance lets you define a general class, then create more specific versions that automatically get everything the general class has, plus their own additions or overrides. This avoids duplicating code across closely related types.
A subclass is declared with the parent class name in parentheses: class WebServer(Server):. Inside the subclass’s __init__, call super().__init__(…) to run the parent’s setup logic before adding the subclass’s own attributes. This avoids re-writing the shared logic.
class Server:
def __init__(self, name, cpu):
self.name = name
self.cpu = cpu
def is_healthy(self):
return self.cpu < 80
class WebServer(Server): # WebServer IS-A Server
def __init__(self, name, cpu, port):
super().__init__(name, cpu) # runs Server's __init__ first
self.port = port # then adds its own attribute
def url(self): # a NEW method only WebServer has
return f"http://{self.name}:{self.port}"
web01 = WebServer("web-01", 45, 8080)
print(web01.is_healthy()) # True — inherited from Server, no need to redefine
print(web01.url()) # "http://web-01:8080" — WebServer's own method
WebServer automatically has .is_healthy() without redefining it — that’s the entire benefit of inheritance. It also has its own .url() method that plain Server objects don’t have.
Dunder Methods — __str__ and __repr__
“Dunder” (double underscore) methods let your class hook into Python’s built-in behaviour. The two you’ll use constantly: __str__ controls what print(obj) shows — a readable, human-friendly description. __repr__ controls what you see when inspecting an object directly (e.g. in a list or in a debugger) — more technical, ideally something that could recreate the object.
Without either, printing an object gives an unhelpful default like <__main__.Server object at 0x7f8a2c1b3d90> — defining __str__ fixes this immediately.
class Server:
def __init__(self, name, cpu):
self.name = name
self.cpu = cpu
def __str__(self):
return f"{self.name} (CPU: {self.cpu}%)" # human-readable
def __repr__(self):
return f"Server(name={self.name!r}, cpu={self.cpu})" # technical
web01 = Server("web-01", 45)
print(web01) # web-01 (CPU: 45%) — uses __str__
print([web01]) # [Server(name='web-01', cpu=45)] — uses __repr__
Defining a class and creating objects
class Ticket:
"""Represents a single IT support ticket."""
def __init__(self, ticket_id, issue, priority="normal"):
self.ticket_id = ticket_id
self.issue = issue
self.priority = priority
self.status = "open" # sensible default, not a parameter
def close(self):
"""Mark this ticket as resolved."""
self.status = "closed"
def is_open(self):
return self.status == "open"
# Creating objects (instantiation)
t1 = Ticket("TKT-1041", "VPN not connecting")
t2 = Ticket("TKT-1038", "Email down", priority="critical")
# Accessing attributes and calling methods
print(t1.ticket_id) # "TKT-1041"
print(t1.is_open()) # True
t1.close()
print(t1.is_open()) # False — status changed
print(t2.is_open()) # True — t1 and t2 are independent objects
Class attributes vs instance attributes
class Ticket:
open_count = 0 # CLASS attribute — one shared counter
def __init__(self, ticket_id, issue):
self.ticket_id = ticket_id # INSTANCE attribute
self.issue = issue # INSTANCE attribute
Ticket.open_count += 1 # modify via class name, not self
def close(self):
Ticket.open_count -= 1
t1 = Ticket("TKT-1041", "VPN issue")
t2 = Ticket("TKT-1038", "Email down")
print(Ticket.open_count) # 2 — shared across all instances
t1.close()
print(Ticket.open_count) # 1
Inheritance and super()
class Employee:
def __init__(self, name, emp_id, base_salary):
self.name = name
self.emp_id = emp_id
self.base_salary = base_salary
def annual_salary(self):
return self.base_salary * 12
class Manager(Employee): # Manager IS-A Employee
def __init__(self, name, emp_id, base_salary, team_size):
super().__init__(name, emp_id, base_salary) # call parent's __init__
self.team_size = team_size # then add Manager-specific data
def annual_salary(self): # OVERRIDE — different calculation
base = super().annual_salary() # reuse parent logic, then extend it
bonus = self.team_size * 5000
return base + bonus
emp = Employee("Priya Sharma", "E001", 85000)
mgr = Manager("Rahul Mehta", "E002", 110000, team_size=6)
print(emp.annual_salary()) # 1,020,000 — base Employee calculation
print(mgr.annual_salary()) # 1,350,000 — Manager's overridden version
# isinstance() checks the inheritance relationship
isinstance(mgr, Manager) # True
isinstance(mgr, Employee) # True — Manager IS-A Employee too
isinstance(emp, Manager) # False — a plain Employee is not a Manager
Dunder methods — __str__, __repr__, and others
class Server:
def __init__(self, name, cpu, mem):
self.name = name
self.cpu = cpu
self.mem = mem
def __str__(self):
return f"{self.name}: CPU {self.cpu}%, Mem {self.mem}%"
def __repr__(self):
return f"Server({self.name!r}, cpu={self.cpu}, mem={self.mem})"
def __eq__(self, other):
"""Defines what == means for two Server objects."""
return self.name == other.name
def __lt__(self, other):
"""Defines what < means — enables sorted() to work directly."""
return self.cpu < other.cpu
s1 = Server("web-01", 45, 60)
s2 = Server("db-01", 82, 70)
print(s1) # web-01: CPU 45%, Mem 60% (__str__)
print(s1 == s2) # False (__eq__)
print(s1 < s2) # True (__lt__)
print(sorted([s2, s1])) # sorts by cpu using __lt__: [web-01, db-01]
Each example takes an entity you’ve previously modelled as a dictionary in earlier modules — a ticket, a server, an employee — and rebuilds it as a proper class. Compare how the logic that used to live in standalone functions now lives as methods directly on the object it concerns.
Models a support ticket as a class that tracks its own status, validates state transitions (you can’t close an already-closed ticket), and reports its own age. Compare this to M4’s dictionary-based ticket functions — the same logic, but now the data and behaviour live together.
from datetime import datetime
class InvalidTransitionError(Exception):
"""Raised when a ticket status change isn't allowed."""
pass
class Ticket:
"""Represents a single IT support ticket and its lifecycle."""
VALID_PRIORITIES = ("low", "normal", "high", "critical")
def __init__(self, ticket_id, issue, priority="normal"):
if priority not in self.VALID_PRIORITIES:
raise ValueError(f"Invalid priority: {priority}")
self.ticket_id = ticket_id
self.issue = issue
self.priority = priority
self.status = "open"
self.created_at = datetime.now()
self.closed_at = None
def close(self):
"""Close the ticket. Raises if already closed."""
if self.status == "closed":
raise InvalidTransitionError(f"{self.ticket_id} is already closed")
self.status = "closed"
self.closed_at = datetime.now()
def reopen(self):
"""Reopen a closed ticket."""
if self.status == "open":
raise InvalidTransitionError(f"{self.ticket_id} is already open")
self.status = "open"
self.closed_at = None
def age_hours(self):
"""Hours since the ticket was created."""
delta = datetime.now() - self.created_at
return round(delta.total_seconds() / 3600, 2)
def __str__(self):
return f"[{self.ticket_id}] {self.issue} ({self.priority.upper()}, {self.status})"
# ── Run ──
t1 = Ticket("TKT-1041", "VPN not connecting", priority="high")
print(t1)
print(f"Age: {t1.age_hours()} hours")
t1.close()
print(t1)
try:
t1.close() # already closed — should raise
except InvalidTransitionError as e:
print(f"Error: {e}")
t1.reopen()
print(t1)
Age: 0.0 hours
[TKT-1041] VPN not connecting (HIGH, closed)
Error: TKT-1041 is already closed
[TKT-1041] VPN not connecting (HIGH, open)
Builds a database connection class that implements __enter__ and __exit__, allowing it to be used with Python’s with statement — exactly like file objects. This is how real database libraries (psycopg2, cx_Oracle) provide their connection objects.
class DatabaseConnection:
"""A simulated DB connection that supports the 'with' statement."""
def __init__(self, host, database):
self.host = host
self.database = database
self.is_open = False
self.query_count = 0
def __enter__(self):
"""Called when entering the 'with' block — opens the connection."""
print(f"Opening connection to {self.host}/{self.database}...")
self.is_open = True
return self # this becomes the 'as' variable
def __exit__(self, exc_type, exc_value, traceback):
"""Called when leaving the 'with' block — always closes, even on error."""
self.is_open = False
print(f"Closed connection. Queries run: {self.query_count}")
return False # False means: don't suppress exceptions
def query(self, sql):
if not self.is_open:
raise RuntimeError("Cannot query — connection is closed")
self.query_count += 1
return f"[Result for: {sql}]"
# ── Run — using 'with' just like a file object ──
with DatabaseConnection("ora-prod-01", "ORCL") as conn:
result1 = conn.query("SELECT * FROM employees")
result2 = conn.query("SELECT * FROM departments")
print(result1)
print(result2)
# __exit__ runs automatically here — connection closed, even if an error had occurred
print(f"Connection still open? {conn.is_open}")
[Result for: SELECT * FROM employees]
[Result for: SELECT * FROM departments]
Closed connection. Queries run: 2
Connection still open? False
Builds a base Server class and two specialised subclasses — WebServer and DatabaseServer — each adding its own attributes and overriding the health check with role-specific logic, while sharing the common reporting method from the parent.
class Server:
"""Base class for any monitored server."""
def __init__(self, name, cpu, mem):
self.name = name
self.cpu = cpu
self.mem = mem
def is_healthy(self):
"""Default health rule — overridden by subclasses with stricter needs."""
return self.cpu < 80 and self.mem < 80
def report(self):
status = "HEALTHY" if self.is_healthy() else "NEEDS ATTENTION"
return f"{self.name:14} CPU:{self.cpu:>3}% Mem:{self.mem:>3}% [{status}]"
def __str__(self):
return self.report()
class WebServer(Server):
"""A server handling HTTP traffic."""
def __init__(self, name, cpu, mem, requests_per_sec):
super().__init__(name, cpu, mem)
self.requests_per_sec = requests_per_sec
def is_healthy(self):
# WebServer adds a throughput condition on top of the base rule
return super().is_healthy() and self.requests_per_sec < 1000
class DatabaseServer(Server):
"""A server hosting a database."""
def __init__(self, name, cpu, mem, active_connections, max_connections):
super().__init__(name, cpu, mem)
self.active_connections = active_connections
self.max_connections = max_connections
def is_healthy(self):
# DatabaseServer cares about connection pool saturation too
conn_ok = self.active_connections < self.max_connections * 0.9
return super().is_healthy() and conn_ok
# ── Run — a mixed fleet, all treated polymorphically ──
fleet = [
WebServer("web-01", cpu=45, mem=60, requests_per_sec=1200), # fails on throughput
WebServer("web-02", cpu=50, mem=55, requests_per_sec=400),
DatabaseServer("db-01", cpu=62, mem=91, active_connections=80, max_connections=100),
DatabaseServer("db-02", cpu=40, mem=50, active_connections=30, max_connections=100),
]
for server in fleet:
print(server) # every type, including subclasses, prints via __str__
web-02 CPU: 50% Mem: 55% [HEALTHY]
db-01 CPU: 62% Mem: 91% [NEEDS ATTENTION]
db-02 CPU: 40% Mem: 50% [HEALTHY]
Wraps a list of numeric readings in a class that validates input on creation, computes its own statistics on demand, and supports comparison via dunder methods — replacing scattered helper functions with one self-contained, reusable type.
import math
class FeatureSeries:
"""A named series of numeric readings with built-in statistics."""
def __init__(self, name, values):
if not values:
raise ValueError(f"{name}: cannot create an empty series")
if not all(isinstance(v, (int, float)) for v in values):
raise TypeError(f"{name}: all values must be numeric")
self.name = name
self.values = list(values)
def mean(self):
return sum(self.values) / len(self.values)
def std_dev(self):
m = self.mean()
variance = sum((v - m) ** 2 for v in self.values) / len(self.values)
return math.sqrt(variance)
def normalise(self):
"""Return a new FeatureSeries scaled to mean 0, std-dev 1."""
m, s = self.mean(), self.std_dev()
scaled = [(v - m) / s if s else 0 for v in self.values]
return FeatureSeries(f"{self.name}_normalised", scaled)
def __len__(self):
return len(self.values) # enables len(series)
def __str__(self):
return f"{self.name}: n={len(self)}, mean={self.mean():.2f}, std={self.std_dev():.2f}"
# ── Run ──
latency = FeatureSeries("response_time_ms", [120, 95, 340, 110, 88, 410, 102])
print(latency)
print(f"Number of readings: {len(latency)}")
normalised = latency.normalise()
print(normalised)
try:
bad = FeatureSeries("broken", [1, 2, "three"])
except TypeError as e:
print(f"Validation caught: {e}")
Number of readings: 7
response_time_ms_normalised: n=7, mean=0.00, std=1.00
Validation caught: broken: all values must be numeric
A Fleet class that owns and manages a collection of Server objects — demonstrating composition (one class containing many instances of another) alongside inheritance. This is the pattern behind most real automation tools: an orchestrator object managing many worker objects.
class Server:
def __init__(self, name, env, cpu, mem):
self.name = name
self.env = env
self.cpu = cpu
self.mem = mem
def is_healthy(self):
return self.cpu < 80 and self.mem < 80
def __str__(self):
flag = "✓" if self.is_healthy() else "✗"
return f"{flag} {self.name:14} ({self.env}) CPU:{self.cpu:>3}% Mem:{self.mem:>3}%"
class Fleet:
"""Manages a collection of Server objects — composition, not inheritance."""
def __init__(self, name):
self.name = name
self.servers = [] # Fleet HAS-A list of Server objects
def add(self, server):
self.servers.append(server)
def unhealthy(self):
return [s for s in self.servers if not s.is_healthy()]
def by_environment(self, env):
return [s for s in self.servers if s.env == env]
def average_cpu(self):
if not self.servers:
return 0
return round(sum(s.cpu for s in self.servers) / len(self.servers), 1)
def print_report(self):
print(f"Fleet: {self.name} ({len(self.servers)} servers, avg CPU {self.average_cpu()}%)")
for s in self.servers:
print(f" {s}")
# ── Run ──
fleet = Fleet("Production Web Tier")
fleet.add(Server("web-01", "prod", 45, 60))
fleet.add(Server("web-02", "prod", 88, 72))
fleet.add(Server("db-01", "prod", 62, 91))
fleet.add(Server("dev-01", "dev", 15, 30))
fleet.print_report()
print(f"\nUnhealthy servers: {len(fleet.unhealthy())}")
for s in fleet.unhealthy():
print(f" {s.name}")
✓ web-01 (prod) CPU: 45% Mem: 60%
✗ web-02 (prod) CPU: 88% Mem: 72%
✗ db-01 (prod) CPU: 62% Mem: 91%
✓ dev-01 (dev) CPU: 15% Mem: 30%
Unhealthy servers: 2
web-02
db-01
Start each exercise by deciding what attributes the class needs and what behaviour (methods) it should expose — sketch this out in plain English before writing any code. This habit, sometimes called “thinking in objects,” is the actual skill OOP is teaching you.
📋 Server Fleet Management System
Build a script called fleet_system.py that models a server fleet entirely with classes — no dictionaries representing servers anywhere in your code. This brings together inheritance, composition, dunder methods, and validation from this module.
- Server (base class) — attributes: name, env, cpu, mem, disk. Validate in __init__ that cpu/mem/disk are each between 0 and 100, raising ValueError otherwise. Method is_healthy() returns True if all three metrics are below 80. Implement __str__ for clean printing.
- Two subclasses — WebServer(Server) adding requests_per_sec with its own stricter is_healthy() (also requires requests_per_sec under some threshold you choose), and DatabaseServer(Server) adding active_connections and max_connections with its own is_healthy() override (also requires connection usage below 90%). Both must call super().__init__() and reuse the parent’s health logic via super().is_healthy() rather than re-checking cpu/mem/disk manually.
- Fleet class — owns a list of Server objects (composition). Methods: add(server), unhealthy() (returns list), by_environment(env) (returns list), average_cpu(), and print_report() that prints every server using its own __str__.
- Custom exception — define FleetCapacityError(Exception) and raise it from Fleet.add() if the fleet already has 20 or more servers (a capacity limit), with a clear message.
- Demo script — create a Fleet, add at least 6 servers (a mix of plain Server, WebServer, and DatabaseServer, with some intentionally unhealthy), print the full report, then print just the unhealthy ones and the average CPU across the whole fleet.
OOP questions are often about predicting behaviour correctly — what’s shared, what’s independent, what gets inherited. Take your time reasoning through each one rather than guessing.
def deposit(self, amount):
self.balance += amount
