In Python, iterators only make a single pass over the elements of an underlying series. After that pass, the iterator will continue to raise a StopIteration exception when __next__ is invoked. Many applications require iteration over elements multiple times. For example, we have to iterate over a list many times in order to enumerate all pairs of elements.

>>> def all_pairs(s):
            for item1 in s:
                for item2 in s:
                    yield (item1, item2)
    
>>> list(all_pairs([1, 2, 3]))
    [(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3)]
    

Sequences are not themselves iterators, but instead iterable objects. The iterable interface in Python consists of a single message, __iter__, that returns an iterator. The built-in sequence types in Python return new instances of iterators when their __iter__ methods are invoked. If an iterable object returns a fresh instance of an iterator each time __iter__ is called, then it can be iterated over multiple times.

New iterable classes can be defined by implementing the iterable interface. For example, the iterable LettersWithYield class below returns a new iterator over letters each time __iter__ is invoked.

>>> class LettersWithYield:
            def __init__(self, start='a', end='e'):
                self.start = start
                self.end = end
            def __iter__(self):
                next_letter = self.start
                while next_letter < self.end:
                    yield next_letter
                    next_letter = chr(ord(next_letter)+1)
    

The __iter__ method is a generator function; it returns a generator object that yields the letters 'a' through 'd' and then stops. Each time we invoke this method, a new generator starts a fresh pass through the sequential data.

>>> letters = LettersWithYield()
    >>> list(all_pairs(letters))[:5]
    [('a', 'a'), ('a', 'b'), ('a', 'c'), ('a', 'd'), ('b', 'a')]