Module 7: Object Oriented Python

M7: Object-Oriented Programming — Python for Corporate Professionals | OTLMS
M7 · Object-Oriented Programming Python for Corporate Professionals

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.

5 topics ~85 min read Intermediate level All roles
📖
Concept — Classes, Objects, and Inheritance
From dictionaries to proper types — defining behaviour and structure together

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.

Topic 1

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.

Class (the blueprint)
class Server:
  name
  cpu
  env
creates
Server(“web-01”, 45, “prod”)
Server(“db-01”, 82, “prod”)
Server(“dev-01”, 12, “dev”)

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.

Topic 2

__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.

class Server: def __init__(self, name, cpu, env=“production”): self.name = name self.cpu = cpu self.env = env self.alerts = [] # starts empty for every new server def is_healthy(self): return self.cpu < 80
class name (convention: CapitalizedWords) __init__ — runs automatically on creation self — always the first parameter of any method

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.

Attributes vs methods: An attribute is a piece of data stored on an object (server.cpu). A method is a function defined inside the class that operates on that data (server.is_healthy()). Methods always need parentheses to call; attributes don’t.
Topic 3

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.

Python
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.

Topic 4

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.

📦 Server (base class — common attributes & methods)
↳ WebServer adds: port, requests_per_sec
↳ DatabaseServer adds: connections, max_connections
↳ CacheServer adds: hit_rate, eviction_count

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.

Python
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.

Topic 5

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.

Python
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__
💡 Define __str__ on every class you write for production code. It costs three lines and turns every print() and log statement involving your objects from useless memory addresses into genuinely useful output.
✏️
Syntax Reference
Every class definition pattern you’ll use, with annotations

Defining a class and creating objects

Python
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

Python
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()

Python
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

Python
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]
💡
Examples — Classes Modelling Real Entities
Five programs where classes replace dictionaries with structured, self-describing types

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.

Example 1 — IT Support: Ticket class with state transitions
IT SupportAll Roles

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.

Python
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)
Output
[TKT-1041] VPN not connecting (HIGH, open)
Age: 0.0 hours
[TKT-1041] VPN not connecting (HIGH, closed)
Error: TKT-1041 is already closed
[TKT-1041] VPN not connecting (HIGH, open)
Example 2 — Database Developer: Connection class with context manager
Database Dev

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.

Python
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}")
Output
Opening connection to ora-prod-01/ORCL…
[Result for: SELECT * FROM employees]
[Result for: SELECT * FROM departments]
Closed connection. Queries run: 2
Connection still open? False
Example 3 — DevOps: Server class hierarchy with inheritance
DevOps

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.

Python
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__
Output
web-01         CPU: 45% Mem: 60%  [NEEDS ATTENTION]
web-02         CPU: 50% Mem: 55%  [HEALTHY]
db-01          CPU: 62% Mem: 91%  [NEEDS ATTENTION]
db-02          CPU: 40% Mem: 50%  [HEALTHY]
Example 4 — AI / ML: Dataset class with validation and statistics
AI / ML

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.

Python
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}")
Output
response_time_ms: n=7, mean=180.71, std=121.41
Number of readings: 7
response_time_ms_normalised: n=7, mean=0.00, std=1.00
Validation caught: broken: all values must be numeric
Example 5 — Automation: Fleet manager composing multiple objects
AutomationDevOps

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.

Python
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}")
Output
Fleet: Production Web Tier (4 servers, avg CPU 52.5%)
  ✓ 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
🏋️
Practice Exercises
Four exercises moving from a single class to inheritance and composition

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.

1
Basic class with a method. Write a class called BankAccount with attributes owner and balance (default 0), and methods deposit(amount) and withdraw(amount). withdraw() should raise a ValueError if the amount exceeds the current balance. Add a __str__ method that prints the owner and current balance neatly. Create two accounts and perform several deposits/withdrawals on each, confirming they don’t affect each other.
__init__(self, owner, balance=0) sets both attributes. deposit: self.balance += amount. withdraw: check if amount > self.balance: raise ValueError(…) first, then subtract. For __str__: return f”{self.owner}: ₹{self.balance}”.
2
Class attribute as a counter. Modify your BankAccount class to include a class attribute total_accounts starting at 0, incremented by 1 every time a new account is created (inside __init__). After creating 4 accounts, print BankAccount.total_accounts to confirm it tracks the count correctly across all instances.
Declare total_accounts = 0 directly inside the class body, above __init__. Inside __init__, add the line BankAccount.total_accounts += 1 (use the class name, not self, when modifying a class attribute — using self.total_accounts += 1 would create a new instance attribute instead, which is a common mistake).
3
Inheritance with method override. Create a base class Employee with name, base_salary, and a method monthly_pay() that simply returns base_salary. Create a subclass SalesEmployee(Employee) that adds a commission attribute and overrides monthly_pay() to return base_salary + commission (using super() to reuse the parent’s calculation rather than repeating base_salary). Create one of each and print both their monthly pay.
In SalesEmployee.__init__, call super().__init__(name, base_salary) then set self.commission = commission. In the overridden monthly_pay(): return super().monthly_pay() + self.commission — this reuses the parent’s logic instead of duplicating self.base_salary directly.
4
Composition — a manager class. Create a class Department that owns a list of Employee objects (reuse your class from Exercise 3, or a simpler version). Give Department an add_employee(emp) method and a total_payroll() method that sums every employee’s monthly_pay(). Add at least 3 employees of mixed types and print the department’s total payroll.
Department.__init__ sets self.employees = []. add_employee just appends. total_payroll: return sum(e.monthly_pay() for e in self.employees) — note this works correctly even with a mix of Employee and SalesEmployee objects, because each knows how to calculate its own pay.
📋
Assignment — M7
A complete server fleet management system using classes — estimated 60–75 minutes

📋 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.

  1. 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.
  2. Two subclassesWebServer(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.
  3. 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__.
  4. 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.
  5. 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.
Your system is complete when polymorphism works correctly — calling .is_healthy() or printing any object in the fleet list produces the right type-specific behaviour without your demo code needing to check what type each server is. Share your script and output in the comments.
🧠
Quiz — Check Your Understanding
5 questions · instant feedback · retake as many times as you need

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.

1. What is the relationship between a class and an object (instance)?
2. In the code below, what does self refer to inside deposit() when you call acc.deposit(500)?
class Account:
    def deposit(self, amount):
        self.balance += amount
3. You define discount_rate = 0.1 directly inside a class body (not inside __init__), shared by all instances. What kind of attribute is this, and what happens if one object does self.discount_rate = 0.2?
4. Why do you call super().__init__(…) inside a subclass’s __init__ method?
5. What does defining __str__ on a class actually change?
You’ve completed the Foundation curriculum — every learner needs everything up to here.
Specialty tracks begin next: choose your path based on your role — Automation, Database, DevOps, or AI/ML — for hands-on, job-specific Python skills.
Choose Your Track →
Scroll to Top