Create your own
Lesson illustration

Implementing a Custom Sequence Collection

Hello again. You have just established how individual Job entities behave as values for identity and hashing. Now move one level up: a production system also needs well-defined groups of domain objects.

In this lesson, you will build an immutable, ordered JobBatch collection that behaves like a native Python sequence. It will support positional indexing, negative indexes, slices, len(), iteration, membership checks, reversed(), .index(), and .count()—without manually implementing every one of those operations. The key is to implement the small core required by collections.abc.Sequence and let its mixins supply the rest.


A sequence is an ordered, positional contract

Square brackets alone do not make something a sequence. Both a list and a dictionary support expressions such as container[key], but they mean fundamentally different things:

Collection kindWhat [] meansOrder and position are part of the API?
list, tuple, rangeRetrieve an item by integer positionYes
dictRetrieve a value by keyNo
setDoes not support retrieval by positionNo

A sequence promises that its elements have a stable order and can be retrieved by position. In practice, users expect behavior such as:

batch[0]       # first job
batch[-1]      # final job
batch[1:3]     # a subsequence
len(batch)     # number of jobs
job in batch   # membership by equality
reversed(batch)

For the capstone, a useful domain example is an in-memory, ordered snapshot of jobs returned by an application service. A JobBatch can preserve the order supplied by that service while making it explicit that callers are working with a sequence, not a database repository or a mutable list.

That distinction matters. A repository should expose explicit query operations such as “find jobs for owner, ordered by creation time.” It should not pretend that arbitrary positional lookup is a cheap or stable database operation. JobBatch, in contrast, is a fully materialized snapshot, so sequence behavior is an appropriate contract.

The formal Sequence abstract base class requires only two methods:

  • __len__
  • __getitem__

It supplies useful mixin behavior, including iteration, containment, reversal, .index(), and .count().

collections.abc — Abstract Base Classes for Containers

Read the relevant parts of the official Python documentation to distinguish direct inheritance from virtual registration and to see exactly what Sequence provides.

In the opening numbered discussion, read the direct inheritance pattern. Notice that a subclass implements required abstract methods and receives the remaining mixin methods through inheritance. Then find the collections.abc.Sequence entry and read through the Sequence performance note. Focus on the required __getitem__ and __len__ methods, the inherited operations, and why the cost of __getitem__ affects the performance of those mixins.


Design the collection before implementing it

Our JobBatch has these deliberately narrow semantics:

  1. It preserves the incoming job order.
  2. It is structurally immutable: callers cannot replace, append, or delete jobs through the batch.
  3. It rejects duplicate job_id values. A batch represents one ordered snapshot containing each durable job at most once.
  4. A slice produces another JobBatch, preserving the domain collection type rather than leaking a raw tuple.
  5. It does not imply that each contained Job is immutable. A Job may still transition from "queued" to "running"; the batch only prevents changes to its own membership and order.

Using a tuple internally supports the second point. It also avoids aliasing: if a caller builds the batch from a list and subsequently mutates that list, the batch remains unchanged.

Create app/domain/job_batch.py:

from __future__ import annotations

from collections.abc import Iterable, Sequence
from typing import overload
from uuid import UUID

from .job import Job


class JobBatch(Sequence[Job]):
    """An ordered, duplicate-free, in-memory snapshot of jobs."""

    def __init__(self, jobs: Iterable[Job]) -> None:
        self._jobs = tuple(jobs)

        job_ids: list[UUID] = [job.job_id for job in self._jobs]
        if len(job_ids) != len(set(job_ids)):
            raise ValueError("A job batch cannot contain duplicate job IDs")

    def __repr__(self) -> str:
        return f"{type(self).__name__}({list(self._jobs)!r})"

    def __len__(self) -> int:
        return len(self._jobs)

    @overload
    def __getitem__(self, index: int) -> Job: ...

    @overload
    def __getitem__(self, index: slice) -> JobBatch: ...

    def __getitem__(self, index: int | slice) -> Job | JobBatch:
        if isinstance(index, slice):
            return JobBatch(self._jobs[index])

        return self._jobs[index]

The central implementation is short because tuple already has correct sequence behavior. Delegating to it gives JobBatch several important properties without reimplementing fragile indexing logic:

  • Positive indexes work.
  • Negative indexes work.
  • Out-of-range integer indexes raise IndexError.
  • Slices use Python’s usual inclusive-start, exclusive-stop rules.
  • Slices with a step, such as batch[::2], work naturally.
  • A slice of an empty batch remains a valid empty JobBatch.

The duplicate check also connects to the previous lesson. It creates a set of UUID values, not a set of mutable Job objects. A UUID is an appropriate stable hash key, and job_id is the durable identity already used by Job.__eq__ and Job.__hash__.

Why overload __getitem__?

At runtime, a single __getitem__ method handles both integers and slices. However, those inputs have different output types:

job = batch[0]       # Job
tail = batch[1:]     # JobBatch

The @overload declarations make this distinction visible to a type checker and to readers of the class. The final method is the actual runtime implementation; the overloads describe its public interface.

The next module on typed capstone design will develop overloads in greater depth. For now, treat them as a precise way to preserve the familiar indexing contract.


Let Sequence supply the rest of the protocol

Notice what JobBatch does not implement:

# No __iter__
# No __contains__
# No __reversed__
# No index
# No count

Because it subclasses Sequence, these operations are inherited as mixins. With only __len__ and __getitem__, the following all work:

from collections.abc import Sequence

batch = JobBatch([first_job, second_job, third_job])

assert isinstance(batch, Sequence)

assert len(batch) == 3
assert batch[0] is first_job
assert batch[-1] is third_job

assert list(batch) == [first_job, second_job, third_job]
assert second_job in batch
assert batch.index(second_job) == 1
assert batch.count(second_job) == 1

assert list(reversed(batch)) == [
    third_job,
    second_job,
    first_job,
]

This is protocol-oriented design: standard Python syntax and generic library code can work with the collection because it fulfills an agreed interface.

The relationships are worth making explicit:

User-facing operationBehavior supplied by
len(batch)Your JobBatch.__len__
batch[1]Your JobBatch.__getitem__
batch[1:]Your slice branch in __getitem__
for job in batchSequence mixin based on indexing
job in batchSequence mixin
reversed(batch)Sequence mixin using length and indexing
batch.index(job)Sequence mixin
batch.count(job)Sequence mixin

The “mixins based on indexing” detail has a performance consequence. A tuple retrieves self._jobs[index] in constant time, so iterating, reversing, or searching a JobBatch has the expected linear behavior.

A linked list is different. If its __getitem__ must traverse nodes from the start, a single lookup is linear. A mixin such as .index(), which repeatedly indexes, can then become quadratic. For an internal linked-list structure, it is often necessary to provide efficient custom __iter__, __contains__, or .index() implementations.

This short video uses a linked list to demonstrate the connection between bracket syntax and collection special methods. Its implementation covers more mutation methods than JobBatch needs, but the __len__, __getitem__, and membership portions reinforce the dispatch model.

Please Master This MAGIC Python Feature... 🪄

Watch “Please Master This MAGIC Python Feature...” by Tech With Tim for a concrete linked-list example of collection behavior implemented through special methods.

Watch the linked-list example. Focus on how square-bracket access invokes __getitem__, how len() uses __len__, and how in maps to membership behavior. For JobBatch, direct Sequence inheritance means you intentionally implement fewer methods than the linked-list example.


Slices should preserve your intended abstraction

This line is a domain decision, not merely a type-checking convenience:

return JobBatch(self._jobs[index])

It means:

tail = batch[1:]

assert isinstance(tail, JobBatch)

Compare that with a direct delegation approach:

def __getitem__(self, index: int | slice) -> Job | tuple[Job, ...]:
    return self._jobs[index]

That version is valid Python, but batch[1:] would return a raw tuple. The caller would silently lose the batch’s invariant that job IDs are unique and lose its meaningful representation, JobBatch([...]).

There is no universal rule that a custom sequence slice must return the same type. For example, a view-like collection may intentionally return a tuple to communicate detachment from the original abstraction. The important engineering choice is to decide and document the behavior rather than accidentally exposing the internal storage type.

Our constructor validation runs again for sliced batches. That is not costly in most application-level batch sizes, and it protects the invariant if the class changes later. If profiling eventually showed that very large slice-heavy workloads made this validation expensive, that would be evidence for revisiting the design—not a reason to weaken it preemptively.


Verify behavior through the public protocol

The tests should interact with JobBatch as callers do. Avoid assertions about the private _jobs tuple. The collection’s public contract is its sequence behavior and its duplicate-ID validation.

Using the make_job() helper from the previous lessons, add tests in tests/domain/test_job_batch.py:

from collections.abc import Sequence
from uuid import UUID

import pytest

from app.domain.job_batch import JobBatch


def test_job_batch_behaves_as_a_read_only_sequence() -> None:
    first = make_job(job_id=UUID(int=1))
    second = make_job(job_id=UUID(int=2))
    third = make_job(job_id=UUID(int=3))

    batch = JobBatch([first, second, third])

    assert isinstance(batch, Sequence)
    assert len(batch) == 3
    assert bool(batch) is True

    assert batch[0] is first
    assert batch[-1] is third
    assert list(batch) == [first, second, third]

    assert second in batch
    assert batch.index(second) == 1
    assert batch.count(second) == 1

    assert list(reversed(batch)) == [third, second, first]

This one confirms both methods you wrote and the inherited behavior you received from Sequence.

Next, test ordinary indexing failures and slicing:

def test_job_batch_supports_normal_indexing_and_preserves_its_type_on_slice() -> None:
    first = make_job(job_id=UUID(int=1))
    second = make_job(job_id=UUID(int=2))
    third = make_job(job_id=UUID(int=3))

    batch = JobBatch([first, second, third])

    with pytest.raises(IndexError):
        batch[3]

    tail = batch[1:]

    assert isinstance(tail, JobBatch)
    assert list(tail) == [second, third]
    assert list(batch[::2]) == [first, third]

Do not manually check every integer boundary before delegating to the tuple. Python’s standard IndexError behavior is exactly what users expect, and delegation preserves it.

Finally, test the domain-specific invariant:

def test_job_batch_rejects_distinct_snapshots_of_the_same_job() -> None:
    queued = make_job(
        job_id=UUID(int=1),
        status="queued",
    )
    running = make_job(
        job_id=UUID(int=1),
        status="running",
    )

    with pytest.raises(ValueError, match="duplicate job IDs"):
        JobBatch([queued, running])

The two Job objects can represent different snapshots of the same durable entity. Their mutable state differs, but the batch must not contain the same job_id twice.

To verify structural immutability, add:

def test_job_batch_rejects_item_assignment() -> None:
    batch = JobBatch([make_job(job_id=UUID(int=1))])

    with pytest.raises(TypeError):
        batch[0] = make_job(job_id=UUID(int=2))

Because JobBatch has no __setitem__, item assignment is unsupported. That is the correct behavior for a read-only Sequence.


Avoid the common near-misses

Using only __getitem__

A class with only __getitem__ may support some iteration through Python’s legacy indexing fallback, but it does not provide a complete, explicit sequence contract. It lacks a defined length and will not satisfy isinstance(value, Sequence).

Implement __len__ and inherit from Sequence when positional, ordered collection semantics are intentional.

Registering a class instead of inheriting

You could write:

Sequence.register(JobBatch)

Do not do this here. Registration only makes isinstance(batch, Sequence) return True; it does not inject Sequence’s mixin methods into JobBatch.

Direct inheritance is the right choice because you want the provided .index(), .count(), iteration, containment, and reversal behavior.

Exposing a mutable internal list

This is structurally unsafe:

class JobBatch(Sequence[Job]):
    def __init__(self, jobs: list[Job]) -> None:
        self.jobs = jobs

A caller can mutate the original list after batch construction:

jobs = [first_job]
batch = JobBatch(jobs)

jobs.append(second_job)

Now batch has changed externally, bypassing its constructor validation. Converting input to a tuple protects the batch’s own membership and order.

Assuming Sequence provides value equality

Sequence supplies operational mixins, but it does not define “two sequences with equal elements are equal” for your subclass. Therefore:

JobBatch([first_job]) == JobBatch([first_job])

uses ordinary object identity unless you explicitly implement __eq__.

Do not add equality and hashing casually. The previous lesson showed that defining value equality creates a corresponding hash-design obligation. This collection does not currently need value equality, so leaving that policy unspecified is cleaner than inventing one.

Choosing MutableSequence for convenience

MutableSequence is appropriate when in-place changes are intrinsic to the abstraction. It requires five core methods:

  • __len__
  • __getitem__
  • __setitem__
  • __delitem__
  • insert

It can then provide methods such as .append(), .extend(), .pop(), and .remove().

For a snapshot returned from an application service, mutation would blur ownership and validation responsibilities. A new JobBatch is simpler and safer than allowing callers to alter an existing one.


Takeaways

  • A sequence is an ordered, positional collection contract—not just an object that happens to support brackets.
  • Directly inheriting from collections.abc.Sequence requires __len__ and __getitem__, while providing iteration, membership, reversal, .index(), and .count() as mixins.
  • JobBatch delegates indexing to an internal tuple, gaining standard negative-index, slice, and IndexError behavior.
  • Returning JobBatch from slices preserves the collection’s domain abstraction and duplicate-ID invariant.
  • Structural immutability of the batch does not make mutable Job instances immutable.
  • Test the public sequence protocol rather than the private storage representation.
  • Prefer Sequence over MutableSequence when the collection is a stable in-memory snapshot.

Next, you will shift from instance behavior to controlled inheritance by using __init_subclass__ to configure and validate subclasses without introducing a metaclass.

Can't find a good explanation? Sign up and we'll make it for you

Sign up