Python classes exercises solutions encapsulation property cheat sheet FP2

Python classes exercises — master objects and encapsulation

Python classes exercises are where object-oriented thinking becomes natural. You’ve seen the theory and built three complete programs. Now it’s time to design and implement classes on your own — a Student, a Vehicle and a Stack.

As always: try to solve it yourself, use the hint if stuck for more than 10 minutes, and compare with the commented solution. Use pythontutor.com to step through your solution and watch the objects being created and modified in memory.


Python classes exercises — Basic Level

Exercise 1 — Student class

Design and implement a Student class that models a university student. It must store personal data and academic records, and provide meaningful operations.

Requirements:

Attributes:
  name         — string, cannot be empty
  student_id   — string, 8 characters starting with 'S'
  degree       — string
  grades       — dictionary: {subject_name: grade}

Methods:
  add_grade(subject, grade)   — adds or updates a grade (0-10)
  average()                   — returns average of all grades (0 if no grades)
  is_passing()                — True if average >= 5.0
  best_subject()              — subject with highest grade
  worst_subject()             — subject with lowest grade
  transcript()                — prints formatted academic record

Magic methods:
  __str__   → "Sergio Medina (S12345678) — GCID — Average: 7.50"
  __repr__  → "Student(name='Sergio', id='S12345678', degree='GCID')"
  __eq__    → equal if same student_id
  __lt__    → compare by average (enables sorting)
  __bool__  → True if is_passing()

Expected output:

=== STUDENT ===
Sergio Medina (S12345678) — GCID — Average: 7.17

Transcript:
  FP1:         7.50
  IC2:         8.00
  Maths:       6.00
  Statistics:  7.20
  Databases:   7.00 (highest)
  Programming: 6.50

Best subject:  Databases — 8.00
Worst subject: Maths — 6.00
Passing?: True

Sorted by average:
  1. Ana López — 8.25
  2. Sergio Medina — 7.17
  3. Carlos Ruiz — 6.50

💡 Hints:

  • Validate student_id in __init__: len(id) == 9 and id.startswith('S') and id[1:].isdigit()
  • average(): sum(self._grades.values()) / len(self._grades) — handle empty dict
  • best_subject(): max(self._grades, key=self._grades.get)
  • Make grades private with _grades — expose through methods only

Exercise 2 — Vehicle class

Design and implement a Vehicle class that models a vehicle with fuel tracking and trip history.

Requirements:

Attributes:
  plate          — string, 7 characters (e.g. '1234ABC')
  make           — string
  model          — string
  year           — int, between 1900 and current year
  _fuel          — float, 0 to tank_capacity (private)
  _tank_capacity — float, positive (private)
  _odometer      — float, read-only (private)
  _trips         — list of trip records

Methods:
  refuel(litres)          — add fuel, cannot exceed tank capacity
  drive(km)               — consume fuel (8L/100km), add to odometer
  fuel_range()            — how many km can still be driven
  add_trip(name, km)      — named trip with distance

Properties:
  fuel           → current fuel (read-only externally)
  tank_capacity  → tank size (read-only)
  odometer       → total km driven (read-only)
  fuel_percent   → fuel as percentage of tank

Magic methods:
  __str__  → "1234ABC — Toyota Yaris 2022 | Odometer: 15342km | Fuel: 45.2%"
  __eq__   → equal if same plate
  __lt__   → compare by year (older < newer)

Expected output:

=== VEHICLE ===
1234ABC — Toyota Yaris 2022 | Odometer: 0km | Fuel: 50.0%

Refuelled 20L → 45.0/50.0L (90.0%)
Drove 50km → consumed 4.0L, remaining 41.0L (82.0%)
Range remaining: 512.5 km

Trip log:
  1. Trip to ULPGC — 15km
  2. Weekend drive — 120km

Odometer: 185km

💡 Hints:

  • drive(km): consumption = km * 8 / 100, raise ValueError if insufficient fuel
  • fuel_range(): self._fuel / 8 * 100 — km per litre × remaining fuel
  • year validation: 1900 <= year <= datetime.now().year
  • plate validation: 7 chars, e.g. digits + letters but keep it simple for FP2

Python classes exercises — Intermediate Level → Final Challenge

Exercise 3 — Generic Stack class

Implement a generic stack (LIFO — Last In, First Out) as a Python class. It must work with any data type and support all standard stack operations.

Requirements:

Methods:
  push(item)    — add item to top
  pop()         — remove and return top item
  peek()        — return top item without removing
  clear()       — empty the stack
  to_list()     — return contents as list (bottom to top)

Properties:
  size          → number of items
  is_empty      → True if no items
  is_full       → True if at max_size (if set)

Magic methods:
  __str__    → "Stack([1, 2, 3]) — top: 3"
  __len__    → number of items
  __bool__   → True if not empty
  __contains__ → 'in' operator: 3 in stack
  __iter__   → iterate from bottom to top

Optional: max_size parameter — None means unlimited

Expected output:

=== STACK ===
Stack([]) — empty

Push 1,2,3:    Stack([1, 2, 3]) — top: 3
Peek:          3 (stack unchanged)
Pop:           3 → Stack([1, 2]) — top: 2
Pop:           2 → Stack([1]) — top: 1
Size:          1
Contains 1?:   True
Contains 5?:   False

Push strings:
Stack(['hello', 'world', 'python']) — top: python

Iterate (bottom to top):
  hello
  world
  python

Sorted list: ['hello', 'python', 'world']

=== STACK WITH SIZE LIMIT ===
Stack of max 3:
  Push 10 ✓
  Push 20 ✓
  Push 30 ✓
  Push 40 ✗ Stack is full (max 3)

💡 Hints:

  • Internal storage: self._items = []
  • push: append to list — self._items.append(item)
  • pop: self._items.pop() — raises IndexError if empty (catch and re-raise as StackError)
  • peek: self._items[-1] — raises error if empty
  • __contains__: return item in self._items
  • __iter__: return iter(self._items) — iterates bottom to top
  • Create StackError(Exception) as custom exception

Commented solutions

Solution Exercise 1

class Student:
    def __init__(self, name, student_id, degree):
        if not name.strip():
            raise ValueError('Name cannot be empty')
        if not (len(student_id) == 9 and
                student_id.startswith('S') and
                student_id[1:].isdigit()):
            raise ValueError(
                f'Invalid student ID: {student_id} '
                f'(must be S followed by 8 digits)'
            )
        self.name = name.strip().title()
        self.student_id = student_id
        self.degree = degree
        self._grades = {}    # subject → grade

    def add_grade(self, subject, grade):
        if not 0 <= grade <= 10:
            raise ValueError(f'Grade must be 0-10, got {grade}')
        if not subject.strip():
            raise ValueError('Subject name cannot be empty')
        self._grades[subject.strip()] = round(grade, 2)

    def average(self):
        if not self._grades:
            return 0
        return round(sum(self._grades.values()) / len(self._grades), 2)

    def is_passing(self):
        return self.average() >= 5.0

    def best_subject(self):
        if not self._grades:
            return None, 0
        subject = max(self._grades, key=self._grades.get)
        return subject, self._grades[subject]

    def worst_subject(self):
        if not self._grades:
            return None, 0
        subject = min(self._grades, key=self._grades.get)
        return subject, self._grades[subject]

    def transcript(self):
        best = self.best_subject()[0]
        worst = self.worst_subject()[0]
        print('Transcript:')
        for subject, grade in self._grades.items():
            notes = []
            if subject == best:  notes.append('highest')
            if subject == worst and subject != best: notes.append('lowest')
            note = f' ({", ".join(notes)})' if notes else ''
            print(f'  {subject:<12} {grade:.2f}{note}')

    def __str__(self):
        return (f'{self.name} ({self.student_id}) — '
                f'{self.degree} — Average: {self.average():.2f}')

    def __repr__(self):
        return (f"Student(name='{self.name}', "
                f"id='{self.student_id}', degree='{self.degree}')")

    def __eq__(self, other):
        return isinstance(other, Student) and self.student_id == other.student_id

    def __lt__(self, other):
        return self.average() < other.average()

    def __bool__(self):
        return self.is_passing()


# Usage
print('=== STUDENT ===')

s = Student('Sergio Medina', 'S12345678', 'GCID')
s.add_grade('FP1', 7.5)
s.add_grade('IC2', 8.0)
s.add_grade('Maths', 6.0)
s.add_grade('Statistics', 7.2)
s.add_grade('Databases', 7.0)
s.add_grade('Programming', 6.5)

print(s)
print()
s.transcript()

best_subj, best_grade = s.best_subject()
worst_subj, worst_grade = s.worst_subject()
print(f'\nBest subject:  {best_subj} — {best_grade:.2f}')
print(f'Worst subject: {worst_subj} — {worst_grade:.2f}')
print(f'Passing?: {bool(s)}')

# Sorting
students = [
    s,
    Student('Ana López', 'S98765432', 'GCID'),
    Student('Carlos Ruiz', 'S11223344', 'Informatics')
]
students[1].add_grade('FP1', 8.5)
students[1].add_grade('IC2', 8.0)
students[2].add_grade('FP1', 7.0)
students[2].add_grade('IC2', 6.0)

print('\nSorted by average:')
for i, st in enumerate(sorted(students, reverse=True), 1):
    print(f'  {i}. {st.name} — {st.average():.2f}')

Solution Exercise 2

from datetime import datetime

class Vehicle:
    CONSUMPTION_PER_100KM = 8.0    # litres per 100km

    def __init__(self, plate, make, model, year,
                 tank_capacity=50.0, initial_fuel=None):
        current_year = datetime.now().year
        if not (1900 <= year <= current_year):
            raise ValueError(f'Year must be between 1900 and {current_year}')
        if tank_capacity <= 0:
            raise ValueError('Tank capacity must be positive')
        if len(plate) != 7:
            raise ValueError(f'Plate must be 7 characters: {plate}')

        self.plate = plate.upper()
        self.make = make
        self.model = model
        self.year = year
        self._tank_capacity = tank_capacity
        self._fuel = initial_fuel if initial_fuel is not None else tank_capacity / 2
        self._odometer = 0.0
        self._trips = []

    @property
    def fuel(self):
        return round(self._fuel, 1)

    @property
    def tank_capacity(self):
        return self._tank_capacity

    @property
    def odometer(self):
        return round(self._odometer, 1)

    @property
    def fuel_percent(self):
        return round(self._fuel / self._tank_capacity * 100, 1)

    def refuel(self, litres):
        if litres <= 0:
            raise ValueError(f'Litres must be positive: {litres}')
        space = self._tank_capacity - self._fuel
        if litres > space:
            litres = space
            print(f'  Tank almost full — added {litres:.1f}L only')
        self._fuel = round(self._fuel + litres, 2)
        print(f'Refuelled {litres}L → '
              f'{self._fuel:.1f}/{self._tank_capacity:.1f}L '
              f'({self.fuel_percent}%)')

    def drive(self, km):
        if km <= 0:
            raise ValueError(f'Distance must be positive: {km}')
        consumption = round(km * self.CONSUMPTION_PER_100KM / 100, 2)
        if consumption > self._fuel:
            max_km = round(self._fuel / self.CONSUMPTION_PER_100KM * 100, 1)
            raise ValueError(
                f'Insufficient fuel for {km}km. '
                f'Max range with current fuel: {max_km}km'
            )
        self._fuel = round(self._fuel - consumption, 2)
        self._odometer = round(self._odometer + km, 1)
        print(f'Drove {km}km → consumed {consumption}L, '
              f'remaining {self._fuel}L ({self.fuel_percent}%)')

    def fuel_range(self):
        return round(self._fuel / self.CONSUMPTION_PER_100KM * 100, 1)

    def add_trip(self, name, km):
        self.drive(km)
        self._trips.append({'name': name, 'km': km})

    def trip_log(self):
        if not self._trips:
            print('  No trips recorded')
            return
        print('Trip log:')
        for i, trip in enumerate(self._trips, 1):
            print(f'  {i}. {trip["name"]} — {trip["km"]}km')

    def __str__(self):
        return (f'{self.plate} — {self.make} {self.model} {self.year} | '
                f'Odometer: {self._odometer:.0f}km | '
                f'Fuel: {self.fuel_percent}%')

    def __repr__(self):
        return (f"Vehicle(plate='{self.plate}', make='{self.make}', "
                f"model='{self.model}', year={self.year})")

    def __eq__(self, other):
        return isinstance(other, Vehicle) and self.plate == other.plate

    def __lt__(self, other):
        return self.year < other.year


# Usage
print('=== VEHICLE ===')

car = Vehicle('1234ABC', 'Toyota', 'Yaris', 2022,
              tank_capacity=50, initial_fuel=25)
print(car)
print()

car.refuel(20)
car.drive(50)
print(f'Range remaining: {car.fuel_range()} km')
print()

car.add_trip('Trip to ULPGC', 15)
car.add_trip('Weekend drive', 120)
print()
car.trip_log()
print(f'\nOdometer: {car.odometer}km')

try:
    car.drive(1000)
except ValueError as err:
    print(f'✗ {err}')

Solution Exercise 3

class StackError(Exception):
    pass

class Stack:
    def __init__(self, max_size=None):
        self._items = []
        self._max_size = max_size

    @property
    def size(self):
        return len(self._items)

    @property
    def is_empty(self):
        return len(self._items) == 0

    @property
    def is_full(self):
        if self._max_size is None:
            return False
        return len(self._items) >= self._max_size

    def push(self, item):
        if self.is_full:
            raise StackError(f'Stack is full (max {self._max_size})')
        self._items.append(item)

    def pop(self):
        if self.is_empty:
            raise StackError('Cannot pop from empty stack')
        return self._items.pop()

    def peek(self):
        if self.is_empty:
            raise StackError('Cannot peek empty stack')
        return self._items[-1]

    def clear(self):
        self._items.clear()

    def to_list(self):
        return list(self._items)

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

    def __bool__(self):
        return not self.is_empty

    def __contains__(self, item):
        return item in self._items

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

    def __str__(self):
        if self.is_empty:
            return 'Stack([]) — empty'
        return f'Stack({self._items}) — top: {self._items[-1]}'

    def __repr__(self):
        return f'Stack(items={self._items}, max_size={self._max_size})'


# Usage
print('=== STACK ===')

s = Stack()
print(s)
print()

print('Push 1,2,3:   ', end='')
s.push(1)
s.push(2)
s.push(3)
print(s)

print(f'Peek:          {s.peek()} (stack unchanged)')

val = s.pop()
print(f'Pop:           {val} → {s}')

val = s.pop()
print(f'Pop:           {val} → {s}')

print(f'Size:          {s.size}')
print(f'Contains 1?:   {1 in s}')
print(f'Contains 5?:   {5 in s}')

# String stack
s2 = Stack()
s2.push('hello')
s2.push('world')
s2.push('python')

print(f'\nPush strings:\n{s2}')

print('\nIterate (bottom to top):')
for item in s2:
    print(f'  {item}')

print(f'\nSorted list: {sorted(s2)}')

# Stack with size limit
print('\n=== STACK WITH SIZE LIMIT ===')
limited = Stack(max_size=3)
print('Stack of max 3:')
for val in [10, 20, 30, 40]:
    try:
        limited.push(val)
        print(f'  Push {val} ✓')
    except StackError as err:
        print(f'  Push {val} ✗ {err}')

# Empty stack errors
empty = Stack()
try:
    empty.pop()
except StackError as err:
    print(f'\nPop from empty: {err}')

try:
    empty.peek()
except StackError as err:
    print(f'Peek at empty:  {err}')

Visualise with Python Tutor

Copy this code into pythontutor.com and step through it:

class Stack:
    def __init__(self):
        self._items = []

    def push(self, item):
        self._items.append(item)

    def pop(self):
        if not self._items:
            raise IndexError('Empty stack')
        return self._items.pop()

    @property
    def top(self):
        return self._items[-1] if self._items else None

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

    def __str__(self):
        return f'Stack({self._items})'

s = Stack()
s.push(10)
s.push(20)
s.push(30)
print(s)
print(f'Top: {s.top}')
print(f'Popped: {s.pop()}')
print(s)

Step through and observe how push calls self._items.append(item) — the stack’s internal list grows with each push. When pop() is called, self._items.pop() removes and returns the last element — always the most recently pushed. The @property top reads self._items[-1] without modifying the list — that’s the difference between peek (non-destructive) and pop (destructive). Watch how len(s) calls __len__ automatically — Python calls magic methods transparently.


Cheat sheet — Python classes

# ============================================
# CHEAT SHEET — Python Classes
# Sergio Learns · sergiolearns.com
# ============================================

# CLASS DEFINITION
class MyClass:
    class_attr = 'shared'        # class attribute

    def __init__(self, value):   # constructor
        self.value = value       # instance attribute
        self._private = value    # convention: don't access directly
        self.__mangled = value   # name mangling: _MyClass__mangled

    def method(self):            # instance method
        return self.value

    @classmethod
    def class_method(cls):       # class method
        return cls.class_attr

    @staticmethod
    def static_method():         # no self or cls
        return 42

# CREATE OBJECTS
obj = MyClass(10)
print(obj.value)       # → 10
print(obj.method())    # → 10

# @PROPERTY — controlled access
@property
def balance(self):           # getter — called on read
    return self._balance

@balance.setter
def balance(self, v):        # setter — called on write
    if v < 0: raise ValueError('Negative')
    self._balance = v

@property
def read_only(self):         # no setter → read-only
    return self._value * 2

# Usage
obj.balance = 100    # calls setter
print(obj.balance)   # calls getter

# PRIVATE ATTRIBUTES
self._protected      # convention: be careful
self.__private       # name mangled: _ClassName__private

# MAGIC METHODS
def __str__(self):       return 'human readable'  # print()
def __repr__(self):      return "Class(value=x)"  # repr(), shell
def __eq__(self, o):     return self.v == o.v      # ==
def __lt__(self, o):     return self.v < o.v       # < (enables sorted())
def __len__(self):       return len(self._items)   # len()
def __bool__(self):      return self.v > 0         # if obj:
def __contains__(self, x): return x in self._items # x in obj
def __iter__(self):      return iter(self._items)  # for x in obj:

# CLASS vs INSTANCE ATTRIBUTES
MyClass.class_attr         # access on class
obj.class_attr             # access on instance (reads class)
obj.class_attr = 'new'     # creates INSTANCE attr — shadows class

# TYPICAL CLASS PATTERNS

# 1. Validation in __init__
def __init__(self, age):
    if age < 0: raise ValueError('Age cannot be negative')
    self._age = age

# 2. Read-only @property
@property
def age(self): return self._age

# 3. Computed @property (no setter)
@property
def is_adult(self): return self._age >= 18

# 4. Setter with validation
@age.setter
def age(self, v):
    if v < 0: raise ValueError('Negative')
    self._age = v

# SORTING WITH __lt__
students = [Student('B', 8.0), Student('A', 7.0)]
sorted(students)                        # uses __lt__
sorted(students, key=lambda s: s.name) # custom key

# EQUALITY
s1 = Student('Sergio', 'S001', 'GCID')
s2 = Student('Sergio', 'S001', 'GCID')
s1 == s2    # True only if __eq__ defined (else: different objects)

# BOOL IN IF STATEMENTS
account = BankAccount('Sergio', 0)
if account:         # calls __bool__
    print('Has balance')

# OBJECTS CONTAINING OBJECTS
class Library:
    def __init__(self):
        self._books = {}   # dict of Book objects

    def add(self, book):
        self._books[book.isbn] = book

# COMMON ERRORS
# 1. Forgetting self in method → TypeError
# 2. self.attr = value in method body (not __init__) → undefined elsewhere
# 3. Accessing __private → AttributeError (use _protected instead)
# 4. Adding @property setter before getter → SyntaxError
# 5. Mutating class attribute → accidentally creates instance attribute

Similar Posts

One Comment

Leave a Reply

Your email address will not be published. Required fields are marked *