<SYSTEM_TASK:>
Given the following text description, write Python code to implement the functionality described below step by step
<END_TASK>
<USER_TASK:>
Description:
 This is code for Python versions >= 3.3.
Iterables and Iterators in Python
Iterators
Iterator objects in Python provide a __next__ method. If the iteration has reached the end this is signaled by raising a StopIteration exception.
Step1:  When you perform the iteration manually you should use the builtin next function to call the magic __next__ method.
Step2:  Of course you can also use a standard for-loop. However, the for-loop actually expects to be given a so called iterable object, not an iterator.
Step3:  The same is the case for list constructors.
Step4:  Iterables
Iterables are defined by having an __iter__ method that return an iterator.
Step5:  Now we can finally use the standard for-loop
Step6:  This is convenient, because all the standard container classes are iterable. So you can directly put them into a for-loop or list constructor, without first having to manually create an iterator first.
Step7:  Usually on etherefore does not have to use the __iter__ method manually. But if you do, use the builtin iter function instead.
Step8:  It would be anoying (and quite surprising) to not be able to use iterators with for-loops. Therefore iterators in Python must include an __iter__ method as well, returning the iterator itself.
Step9:  We can now use this iterator as expected. When the for-loop applies the iter function this works and has no effect on the iterator.
Step10:  But there is an important semantic difference between the __iter__ of iterables and iterators
Step11:  This can cause subtle bugs and is actually a nice example for the pitfalls of duck typing. One possible way to safeguard against this is by testing the semantics of __iter__
Step12:  Generators
Generator Basics in Python
Every function that contains a yield keyword is a generator function. A generator function returns a generator object, which is a special case of an iterator (i.e., an object with a __next__ method and an __iter__ method that returns self).
Step13:  The iteration can be performed using the standard iterator API.
Step14:  A generator object can be used anywhere an iterator is supported, e.g., for loops.
Step15:  Generators as Coroutines
Python 2.5 added the ability to not only get data from a generator, but also to send data to it. yield turned from a statement into an expression. Functions that use this feature are called coroutines.
Step16:  Note that next(t) is equivalent to t.send(None).
Forwarding an iterator is easy
Step17:  Doing the same with a coroutine on the other hand is quite hard (see PEP 380), so Python 3.3 introduced yield from.
yield from
Wrapping/forwarding coroutines with yield from is easy. This is, for example, important if you want to refactor a coroutine by extracting a sub-coroutine.
Step18:  The same PEP also introduced return statements in coroutines, to transport a return value via StopIteration.
Step19:  The return value also becomes the value of yield from
Step20:  So yield from transparently pipes through the iterations and provides the end result value.
More random info about Generators
Yield and List Comprehensions (or Generator Expressions)
In older versions of Python the variables in list comprehensions would leak out. In Python 3 this is no longer the case
Step21:  List comprehensions now have their own execution context, just like functions and generator expressions.
Step22:  A side effect of this is that a yield statement in some parts of a list comprehension causes it to evaluate to a generator object.
Step23:  This can be surprising at first.
Step24:  Only the expression list part is not affected by this. A yield statement in this part of the list comprehension works as normally expected (i.e., it refers to the surrounding generator function).
Step25:  Generator expressions have always behaved like described above (since they are executed lazily they always had to store their context).
Step26:  Set and Dict comprehensions of course act like just list comprehensions.
Step27:  With yield from we get the same behavior as with yield.
Step28:  Beware of StopIteration
A generator can be exited explicity by raising StopIteration. Unfortunately it doesn't matter from where this is raised. It might come from another iteration inside a nested function that is not caught properly.
Step29:  So a simple error in setting up your mocks can silently cause an unexpected abortion in your asynchronois test code!
GeneratorExit, close and throw
As a counterpart to StopIteration you can signal a generator from the outside that it should finish. This is done by calling close() on the generator, which will raise a GeneratorExit exception.
Step30:  Catching the GeneratorExit is not really necessary here. But if the generator has any resources that need cleanup then one can use a try ... finally or a context manager to perform this.
Step31:  Yielding values after the exception was raised is not supported.
Step32:  Note that throwing the GeneratorExit exception manually does not have the same effect as calling close.
<ASSISTANT_TASK:>
Python Code:
class TestIterator:
    
    def __init__(self, max_value):
        self._current_value = 0
        self._max_value = max_value
    
    def __next__(self):
        self._current_value += 1
        if self._current_value > self._max_value:
            raise StopIteration()
        return self._current_value
Explanation: This is code for Python versions >= 3.3.
Iterables and Iterators in Python
Iterators
Iterator objects in Python provide a __next__ method. If the iteration has reached the end this is signaled by raising a StopIteration exception.
End of explanation
iterator = TestIterator(3)
try:
    while True:
        print(next(iterator))
except StopIteration:
    pass
Explanation: When you perform the iteration manually you should use the builtin next function to call the magic __next__ method.
End of explanation
for i in TestIterator(3):
    print(i)
Explanation: Of course you can also use a standard for-loop. However, the for-loop actually expects to be given a so called iterable object, not an iterator.
End of explanation
list(TestIterator(3))
Explanation: The same is the case for list constructors.
End of explanation
class TestIterable:
    
    def __init__(self, max_value):
        self._max_value = max_value
        
    def __iter__(self):
        return TestIterator(self._max_value)
Explanation: Iterables
Iterables are defined by having an __iter__ method that return an iterator.
End of explanation
for i in TestIterable(3):
    print(i)
Explanation: Now we can finally use the standard for-loop:
End of explanation
for i in [1, 2, 3]:
    print(i)
Explanation: This is convenient, because all the standard container classes are iterable. So you can directly put them into a for-loop or list constructor, without first having to manually create an iterator first.
End of explanation
test_iterable = TestIterable(3)
test_iterator = iter(test_iterable)
print(test_iterable)
Explanation: Usually on etherefore does not have to use the __iter__ method manually. But if you do, use the builtin iter function instead.
End of explanation
class RealTestIterator(TestIterator):
    
    def __iter__(self):
        return self
Explanation: It would be anoying (and quite surprising) to not be able to use iterators with for-loops. Therefore iterators in Python must include an __iter__ method as well, returning the iterator itself.
End of explanation
for i in RealTestIterator(3):
    print(i)
Explanation: We can now use this iterator as expected. When the for-loop applies the iter function this works and has no effect on the iterator.
End of explanation
iterator = RealTestIterator(3)
for i in iterator:
    print(i)
for i in iterator:
    # iterator directly raises StopIteration, so this is never reached
    print(i)
Explanation: But there is an important semantic difference between the __iter__ of iterables and iterators: iterables provide a fresh iterator object on each call and can therefore be iterated over multiple times. Iterators on the other hand are spent after the first iteration.
End of explanation
def is_iterator(it):
    return iter(it) is it
print(is_iterator(RealTestIterator(3)))
print(is_iterator(TestIterable(3)))
Explanation: This can cause subtle bugs and is actually a nice example for the pitfalls of duck typing. One possible way to safeguard against this is by testing the semantics of __iter__:
End of explanation
def test():
    yield 1
    yield 2
    
print(test)
print(test())
Explanation: Generators
Generator Basics in Python
Every function that contains a yield keyword is a generator function. A generator function returns a generator object, which is a special case of an iterator (i.e., an object with a __next__ method and an __iter__ method that returns self).
End of explanation
t = test()
try:
    while True:
        print(next(t))
except StopIteration:
    print('done')
Explanation: The iteration can be performed using the standard iterator API.
End of explanation
for i in test():
    print(i)
Explanation: A generator object can be used anywhere an iterator is supported, e.g., for loops.
End of explanation
def test():
    x = yield 1
    yield x**2
    
t = test()
print(next(t))  # go to the first yield
print(t.send(3))
Explanation: Generators as Coroutines
Python 2.5 added the ability to not only get data from a generator, but also to send data to it. yield turned from a statement into an expression. Functions that use this feature are called coroutines.
End of explanation
def test():
    yield 1
    yield 2
    
def wrapper():
    for i in test():
        yield i
        
for i in wrapper():
    print(i)
Explanation: Note that next(t) is equivalent to t.send(None).
Forwarding an iterator is easy:
End of explanation
def test():
    x = yield 1
    yield x**2
    
def wrapper():
    yield from test()
    
w = wrapper()
print(next(w))
print(w.send(3))
Explanation: Doing the same with a coroutine on the other hand is quite hard (see PEP 380), so Python 3.3 introduced yield from.
yield from
Wrapping/forwarding coroutines with yield from is easy. This is, for example, important if you want to refactor a coroutine by extracting a sub-coroutine.
End of explanation
def test():
    for i in range(3):
        yield i
    return 'done'
for i in test():
    print(i)
t = test()
try:
    while True:
        print(next(t))
except StopIteration as e:
    print(e.value)
Explanation: The same PEP also introduced return statements in coroutines, to transport a return value via StopIteration.
End of explanation
def wrapper():
    value = yield from test()
    print('wrapper got:', value)
    return 'wrapper done'
for i in wrapper():
    print(i)
Explanation: The return value also becomes the value of yield from:
End of explanation
[xy for xy in range(3)]
xy
Explanation: So yield from transparently pipes through the iterations and provides the end result value.
More random info about Generators
Yield and List Comprehensions (or Generator Expressions)
In older versions of Python the variables in list comprehensions would leak out. In Python 3 this is no longer the case:
End of explanation
(xy for xy in range(3))
xy
Explanation: List comprehensions now have their own execution context, just like functions and generator expressions.
End of explanation
[i for i in range(3) if (yield i)]
Explanation: A side effect of this is that a yield statement in some parts of a list comprehension causes it to evaluate to a generator object.
End of explanation
set([i**2 for i in range(3) if (yield i)])
set([(yield i**2) for i in range(3)])
Explanation: This can be surprising at first.
End of explanation
def g():
    return [i for i in (yield range(3))]
next(g())
Explanation: Only the expression list part is not affected by this. A yield statement in this part of the list comprehension works as normally expected (i.e., it refers to the surrounding generator function).
End of explanation
set(i**2 for i in range(3) if (yield i))
Explanation: Generator expressions have always behaved like described above (since they are executed lazily they always had to store their context).
End of explanation
{i**2 for i in range(3) if (yield i)}
{i: i**2 for i in range(3) if (yield i)}
Explanation: Set and Dict comprehensions of course act like just list comprehensions.
End of explanation
[i for i in range(3) if (yield from i)]
set([i for i in range(3) if (yield from i)])
Explanation: With yield from we get the same behavior as with yield.
End of explanation
import unittest.mock as mock
m = mock.Mock(side_effect=[1, 2])
def test():
    yield m()
    yield m()
    yield m()
for i in test():
    print(i)
Explanation: Beware of StopIteration
A generator can be exited explicity by raising StopIteration. Unfortunately it doesn't matter from where this is raised. It might come from another iteration inside a nested function that is not caught properly.
End of explanation
def test():
    try:
        i = 1
        while True:
            yield i
            i += 1
    except GeneratorExit:
        print('done')
    print('bye')
    
t = test()
print(next(t))
print(next(t))
t.close()
try:
    print(next(t))
except StopIteration:
    print('no more values')
Explanation: So a simple error in setting up your mocks can silently cause an unexpected abortion in your asynchronois test code!
GeneratorExit, close and throw
As a counterpart to StopIteration you can signal a generator from the outside that it should finish. This is done by calling close() on the generator, which will raise a GeneratorExit exception.
End of explanation
def test():
    i = 1
    while True:
        yield i
        i += 1
    
t = test()
print(next(t))
print(next(t))
t.close()
try:
    print(next(t))
except StopIteration:
    print('no more values')
Explanation: Catching the GeneratorExit is not really necessary here. But if the generator has any resources that need cleanup then one can use a try ... finally or a context manager to perform this.
End of explanation
def test():
    try:
        i = 1
        while True:
            yield i
            i += 1
    except GeneratorExit:
        print('done')
    yield 'just one more value'
        
    
t = test()
print(next(t))
print(next(t))
t.close()
Explanation: Yielding values after the exception was raised is not supported.
End of explanation
def test():
    try:
        i = 1
        while True:
            yield i
            i += 1
    except GeneratorExit:
        print('done')
    yield 'one more value'
    yield 'and another one'
t = test()
print(next(t))
print(next(t))
print(t.throw(GeneratorExit()))
print(next(t))
Explanation: Note that throwing the GeneratorExit exception manually does not have the same effect as calling close.
End of explanation
<END_TASK>