LEARN PYTHON | 06 OBJECT-ORIENTED PROGRAMMING

Build it. Test it. Explain it.

Give objects clear responsibilities and test the state changes they promise. Work independently before opening a hint, then compare your tested solution with one possible approach.

YOUR CHALLENGE ROUTE

Complete these tasks in your Python editor. The page is a challenge guide, not a general Python runner. Use fictional details where helpful. Work in order or choose a task matching an area you need to practise.

These tasks combine object-oriented design with earlier functions, collections, validation and tests. Run Python in your editor; the page does not execute arbitrary source. Capstone checks require the companion project modules. Use fictional names and records.

TRACK YOUR TESTED SOLUTIONS

Tick a task only after you have run it, checked at least two relevant cases and explained one line. These are self-recorded checks, not an automatic assessment of your source code.

Progress lasts while this page is open. Save your actual Python files in your project folder.

01 | FOUNDATION

CREATE INDEPENDENT ROBOTS

Create two instances, each with a name, battery value and independent history.

YOUR REQUIREMENTS

  • Use __init__ and self.
  • Create a fresh history list in each initializer.
  • Append a message only to Scout.
  • Print both histories.
  • Explain why class creation is separate from instance creation.
Hint: where to begin

A mutable history in the class body would risk sharing. Execute self.history = [] for each new instance.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Scout history[started]
Rover history[]
Second fresh instanceOwn empty history, not Scout’s list
Reveal one possible solution and explanation
class Robot:
    def __init__(self, name, battery):
        self.name = name
        self.battery = battery
        self.history = []

scout = Robot("Scout", 80)
rover = Robot("Rover", 60)
scout.history.append("started")
print(scout.name, scout.history)
print(rover.name, rover.history)

The initializer creates a new list per instance. The example assumes suitable name and battery arguments; a production boundary would validate them.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Add constructor checks for non-blank text and a whole battery percentage.

Revisit the related lesson

02 | REPAIR REFERENCES

REPAIR A SHARED CLASS LIST

Two reading objects unexpectedly share titles. Move the list to the correct creation point and compare identity with aliasing.

YOUR REQUIREMENTS

  • Create self.titles inside __init__.
  • Add one title only to Maya.
  • Create alias = maya.
  • Show alias is maya and maya is noor.
  • Explain why assignment does not copy an instance.

SOURCE TO REPAIR

class ReadingList:
    titles = []

a = ReadingList()
b = ReadingList()
a.titles.append("Python Adventures")
print(b.titles)
Hint: where to begin

An instance attribute name does not help unless its mutable value is created independently.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Maya after alias append[Python Adventures]
Noor[]
IdentityTrue, then False
Reveal one possible solution and explanation
class ReadingList:
    def __init__(self, name):
        self.name = name
        self.titles = []

maya = ReadingList("Maya")
noor = ReadingList("Noor")
alias = maya
alias.titles.append("Python Adventures")
print(maya.titles)
print(noor.titles)
print(alias is maya, maya is noor)

The two initializers created distinct lists. The alias deliberately refers to Maya’s object, so its mutation is visible through Maya too.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Demonstrate the original class-list bug separately and explain the different before and after state.

Revisit the related lesson

03 | METHODS AND INVARIANTS

ENCAPSULATE A BATTERY

Create a battery with a read-only public level and a checked consumption method.

YOUR REQUIREMENTS

  • Validate the starting whole percentage.
  • Keep internal state under _level.
  • Provide @property level.
  • Reject negative, excessive and Boolean consumption.
  • Leave state unchanged after rejection.
Hint: where to begin

Validate before subtraction. A read-only property does not make underscore access impossible.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Consume twenty from eighty60
Request sixty-one nextValueError; still 60
ZeroAccepted with no change
TrueRejected under exact-int rule
Reveal one possible solution and explanation
class Battery:
    def __init__(self, level=100):
        if type(level) is not int or not 0 <= level <= 100:
            raise ValueError("Use 0 to 100")
        self._level = level

    @property
    def level(self):
        return self._level

    def use_energy(self, amount):
        if type(amount) is not int or not 0 <= amount <= self._level:
            raise ValueError("Invalid consumption")
        self._level -= amount
        return self._level

battery = Battery(80)
print(battery.use_energy(20))
before = battery.level
try:
    battery.use_energy(61)
except ValueError:
    assert battery.level == before
print("After rejection:", battery.level)

The property exposes observation while the method owns valid transitions. The underscore is a design convention, not a security barrier.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Write unittest cases for zero, exact remaining energy and a rejected negative request.

Revisit the related lesson

04 | ENCAPSULATION

RETURN A SNAPSHOT, NOT A MUTABLE INTERNAL LIST

Build a reading list with an add method and tuple report. Confirm an earlier snapshot does not grow after a later addition.

YOUR REQUIREMENTS

  • Validate non-blank title text.
  • Store titles internally.
  • Return a tuple of immutable strings.
  • Capture a snapshot, then add another title.
  • Compare the snapshot with the current report.
Hint: where to begin

A tuple creates a new outer view. Its safety here depends on its contents being immutable strings.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Earlier snapshotOne title
Current reportTwo titles
Blank addValueError before mutation
Reveal one possible solution and explanation
class ReadingList:
    def __init__(self):
        self._titles = []

    def add(self, title):
        if type(title) is not str or not title.strip():
            raise ValueError("Use non-blank text")
        self._titles.append(title.strip())

    def titles(self):
        return tuple(self._titles)

reading = ReadingList()
reading.add("Python Adventures")
snapshot = reading.titles()
reading.add("Robots in Action")
print("Earlier:", snapshot)
print("Current:", reading.titles())

The supported add method preserves the title rule. Returning the actual internal list would allow unchecked appends. A tuple of mutable records would require further attention to their contents.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Explain how frozen record fields can help a catalogue snapshot without claiming absolute privacy.

Revisit the related lesson

05 | INHERITANCE

EXTEND A ROBOT WITH SUPER

Create a specialised delivery robot that preserves the base describe contract.

YOUR REQUIREMENTS

  • Initialise a name in Robot.
  • Initialise destination in DeliveryRobot after super.
  • Override describe with no new compulsory arguments.
  • Return text rather than print inside describe.
  • Extend the base message.
Hint: where to begin

super delegates for the same instance; it does not create a separate base robot component.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
DescriptionRobot Courier delivering to Library
Inherited nameCourier
isinstance baseTrue
Reveal one possible solution and explanation
class Robot:
    def __init__(self, name):
        self.name = name

    def describe(self):
        return f"Robot {self.name}"

class DeliveryRobot(Robot):
    def __init__(self, name, destination):
        super().__init__(name)
        self.destination = destination

    def describe(self):
        return super().describe() + f" delivering to {self.destination}"

robot = DeliveryRobot("Courier", "Library")
print(robot.describe())
print(isinstance(robot, Robot))

The override remains callable in the same way and returns the same kind of result. The base initialisation supplies inherited state.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Add a survey subclass and test that both satisfy the no-argument text-report contract.

Revisit the related lesson

06 | POLYMORPHISM

USE ONE CALLER WITH DIFFERENT OBJECTS

Write a reporting function that works with two compatible objects without checking their class names.

YOUR REQUIREMENTS

  • Give each object a describe method.
  • Use one report function.
  • Return text consistently.
  • Avoid isinstance branches in the caller.
  • Explain that this example uses duck typing without a shared custom base.
Hint: where to begin

The caller needs behaviour, not a manually enumerated class list. Both objects must still satisfy the contract.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Two compatible objectsBoth text descriptions in order
Empty list[]
Object without describeAttributeError; incompatible interface
Reveal one possible solution and explanation
class DeliveryRobot:
    def describe(self):
        return "Delivering a book"

class SurveyRobot:
    def describe(self):
        return "Measuring a room"

def report(objects):
    return [obj.describe() for obj in objects]

print(report([DeliveryRobot(), SurveyRobot()]))

Polymorphism can work without shared implementation inheritance. Compatibility still matters; the function assumes a suitable describe method on each supplied object.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Add a third compatible class and verify the caller needs no change.

Revisit the related lesson

07 | COMPOSITION

COMPOSE A ROBOT WITH A TEST SENSOR

Supply a fixed sensor to a robot, then substitute a second sensor with a different reading.

YOUR REQUIREMENTS

  • Define a sensor read method.
  • Accept the component in Robot.__init__.
  • Delegate the operation.
  • Do not make Robot inherit from Sensor.
  • Use fixed readings for repeatable tests.
Hint: where to begin

Composition stores the supplied reference. A fixed test collaborator can avoid a hardware dependency.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
First component25
Second component40
RelationshipRobot has a sensor, rather than is a sensor
Reveal one possible solution and explanation
class FixedSensor:
    def __init__(self, reading):
        self._reading = reading

    def read(self):
        return self._reading

class Robot:
    def __init__(self, sensor):
        self._sensor = sensor

    def distance(self):
        return self._sensor.read()

print(Robot(FixedSensor(25)).distance())
print(Robot(FixedSensor(40)).distance())

Supplying a collaborator makes the dependency explicit and easy to replace in tests. The robot owns the delegation, not the sensor’s measurement implementation.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Write a Protocol describing read() -> int after explaining the compatible-interface rule.

Revisit the related lesson

08 | RECORD DATA

CREATE DATACLASSES WITH INDEPENDENT DEFAULTS

Use a dataclass for a named reading list and prove both field equality and independent default lists.

YOUR REQUIREMENTS

  • Use annotated fields.
  • Use field(default_factory=list).
  • Compare two initially equal same-name records.
  • Append to only one.
  • Explain == versus is.
Hint: where to begin

The factory is a callable. Dataclass equality compares fields; equal values do not prove identical objects.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Initial comparisonTrue equality; False identity
After appendOnly a has the title
New comparisonFalse equality
Reveal one possible solution and explanation
from dataclasses import dataclass, field

@dataclass
class ReadingList:
    name: str
    titles: list[str] = field(default_factory=list)

a = ReadingList("Maya")
b = ReadingList("Maya")
print("Initially:", a == b, a is b)
a.titles.append("Python Adventures")
print(a.titles, b.titles)
print("After change:", a == b)

The generated equality observes field values. Each omitted titles argument receives a fresh list. An explicitly shared caller list could still create aliasing.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Repeat with frozen=True and explain why the contained list can still be appended to.

Revisit the related lesson

09 | DATACLASS BOUNDARIES

VALIDATE A FROZEN RECORD

Create a frozen book record that rejects blank titles and blocks ordinary later field assignment.

YOUR REQUIREMENTS

  • Use @dataclass(frozen=True).
  • Validate title in __post_init__.
  • Construct a valid record.
  • Catch FrozenInstanceError for a deliberate reassignment test.
  • Avoid claiming that all frozen nested objects are deeply immutable.
Hint: where to begin

The generated initializer assigns fields, then post-initialisation checks them. Type hints do not supply that check automatically.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Valid recordPython Adventures
Later assignmentFrozenInstanceError; title unchanged
Blank titleValueError from post-initialisation
Reveal one possible solution and explanation
from dataclasses import dataclass, FrozenInstanceError

@dataclass(frozen=True)
class Book:
    copy_id: str
    title: str

    def __post_init__(self):
        if type(self.title) is not str or not self.title.strip():
            raise ValueError("Use a non-blank title")

book = Book("B001", "Python Adventures")
try:
    book.title = "Changed"
except FrozenInstanceError:
    print("Ordinary reassignment blocked")
print(book.title)

Frozen fields protect ordinary record use. This small exercise validates the title only; ID format and collection uniqueness need their own explicit requirements.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Add the B-plus-three-digits ID rule and test invalid construction separately.

Revisit the related lesson

10 | TYPE HINTS

DOCUMENT AN OPTIONAL SEARCH RESULT

Annotate a linear search and document its absent result, first-match policy and non-mutation.

YOUR REQUIREMENTS

  • Use list[int], int and int | None.
  • Return the first matching index.
  • Use is not None for a zero index.
  • Write a docstring explaining effects and result meaning.
  • Explain why annotations do not validate every supplied list.
Hint: where to begin

Type hints communicate expectations. The search still needs a clear value contract and tests.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Target eightFirst index zero, reported as found
Absent targetNone
After searchInput unchanged
Reveal one possible solution and explanation
def find_first(values: list[int], target: int) -> int | None:
    """Return the first target index or None; do not mutate values."""
    for index, value in enumerate(values):
        if value == target:
            return index
    return None

values = [8, 5, 8]
position = find_first(values, 8)
if position is not None:
    print("Found at:", position)
print("Absent:", find_first(values, 99))
print("Input:", values)

The union captures the two result possibilities. Documentation adds first-match and non-mutation meaning that types alone do not express.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Add unittest checks for duplicates, empty input and unchanged values, then use a type checker separately.

Revisit the related lesson

11 | CAPSTONE

BUILD THE LIBRARY SERVICE

Build the project modules, then run this direct service-check script beside them.

YOUR REQUIREMENTS

  • Keep entities.py, lending.py, app.py and test_library.py together.
  • Use a physical copy ID distinct from title.
  • Check repeated lending fails without changing state.
  • Check the due date and overdue boundary.
  • Return the copy and check availability.
  • Run the bundled suite.
Hint: where to begin

The service receives dates explicitly and asks for no input. The check file can therefore test rules independently of the console.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Loan October firstDue October fifteenth
Double loanRejected; earlier record unchanged
Due day versus nextZero overdue, then one
After returnAvailable again
Reveal one possible solution and explanation
from datetime import date
from app import demo_library

library = demo_library()
loan = library.lend("B001", "M001", date(2026, 10, 1))
assert loan.due_on == date(2026, 10, 15)
assert not library.available("B001")
assert library.available("B002")
before = library.active_loans()
try:
    library.lend("B001", "M002", date(2026, 10, 1))
except ValueError:
    assert library.active_loans() == before
else:
    raise AssertionError("Repeated loan was not rejected")
assert library.overdue(date(2026, 10, 15)) == ()
assert len(library.overdue(date(2026, 10, 16))) == 1
library.return_book("B001", date(2026, 10, 16))
assert library.available("B001")
print("Library checks passed")

The check inspects both return values and state transitions. B002 remains independently available despite sharing B001’s title. Session records are not saved across restarts.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Add a controlled renewal requirement and tests before changing the service.

Revisit the related lesson

12 | TEST AND REFLECT

TEST LIMITS AND HAND OVER THE DESIGN

Test active-loan capacity, returned capacity and independent library instances. Provide a partner with a module map and honest data-lifetime description.

YOUR REQUIREMENTS

  • Borrow two copies for M001.
  • Reject a third under the demo policy.
  • Return one and successfully borrow another.
  • Check a fresh library has no active loans.
  • Explain composition rather than forcing Library to inherit Book.
  • Document the session-only limit.
Hint: where to begin

A rejection should leave two active records. A return changes capacity because the limit counts active loans, not historical borrowing.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Third simultaneous loanRejected
Return then lendCapacity available; two active records
Fresh instanceNo active loans
RestartSeeded demo recreated, not a saved history
Reveal one possible solution and explanation
from datetime import date
from app import demo_library

library = demo_library()
day = date(2026, 10, 1)
library.lend("B001", "M001", day)
library.lend("B002", "M001", day)
try:
    library.lend("B003", "M001", day)
except ValueError:
    print("Third active loan rejected")
else:
    raise AssertionError("Limit not enforced")
assert len(library.active_loans()) == 2
library.return_book("B001", day)
library.lend("B003", "M001", day)
assert len(library.active_loans()) == 2
assert demo_library().active_loans() == ()
print("Capacity and independence checks passed")

Fresh per-instance dictionaries prevent one library’s loans leaking into another. The handover tests communication as well as code: users should know which file starts the app and what is retained.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Plan persistence with a defined schema and consistency checks without claiming that dumping an object’s __dict__ restores every rule.

Revisit the related lesson

KEEP A SMALL TEST RECORD

Record construction arguments, the method call, expected result, expected state change and actual behaviour. For rejection, compare earlier state with the state afterwards.

Include identity versus equality, independent versus shared lists, exact policy endpoints and a snapshot taken before a later change. For the library, test the due day and next day separately. Document the runtime rules that annotations alone do not enforce.

READY FOR THE NEXT SECTION?

You are ready to move on when you can explain classes and instances, preserve state rules through methods, choose inheritance or composition, use dataclasses deliberately and distinguish hints from runtime checks.

If one skill is uncertain, revisit its lesson and change a worked example before repeating the challenge. The Section 6 workbook is coming soon.

Next: terminal commands and virtual environments Python course navigation