human
Give me a the Python code you requested given the following:
I am working on the file "016 - Yak Shaving/func_py/func_py.ipynb".
The first task is:
Generators
A generator is a factory object that lazily produces values (i.e. generates values on demand). A generator is either:
1. A function that contains the yield keyword (yield expression).
    - When this function is called, it does not execute, but returns a generator object.
    - The first time that next() is called, when this function encounters the yield keyword it suspends execution at that point, saves its context and returns to the caller along with any value in expression.
    - When the caller invokes next() again, execution of the function continues till another yield or return is encountered or end of function is reached.
2. A generator expression, i.e. a syntactic construct for creating an anonymous generator object, following the form of mathematical set-builder notation. These are like list comprehensions but enclosed in () instead of [].
    - The semantics of a generator expression are equivalent to creating an anonymous generator function and calling it.
A generator is always an iterator, but not vice versa (iterator is a more general concept).
Can you write Python code for it?
assistant
#Here is the Python code you requested.

# generator expression
g = (x ** 2 for x in range(10))
print(next(g))
print(next(g))
print([i for i in g])

# generator function
def _gen(exp):
    for x in exp:
        yield x ** 2
g = _gen(range(10))
print(next(g))
print(next(g))
print([i for i in g])

# generator
def it_gen(text):
    for ch in text:
        yield ch.upper()

# generator expression
def it_genexp(text):
    return (ch.upper() for ch in text)

# iterator protocol (__iter__)
class ItIter():
    def __init__(self, text):
        self.text = text
        self.index = 0
    def __iter__(self):
        return self
    def __next__(self):
        try:
            result = self.text[self.index].upper()
        except IndexError:
            raise StopIteration
        self.index += 1
        return result

# iterator protocol (__getitem__)
class ItGetItem():
    def __init__(self, text):
        self.text = text
    def __getitem__(self, index):
        result = self.text[index].upper()
        return result

# an iterable of iterables (see what i did there? :P)
for iterator in (it_gen, it_genexp, ItIter, ItGetItem):
    for ch in iterator('abcde'):
        print(ch, end='')
    print('\n')

import collections

def flatten(container):
    """Flatten an iterable of arbitrary depth."""
    for item in container:
        if isinstance(item, collections.Iterable) and not isinstance(item, (str, bytes)):
            for element in flatten(item):
                yield element
        else:
            yield item

d3 = [[[i for i in range(2)] for j in range(3)] for k in range(4)]
print(d3)
print([k for i in d3 for j in i for k in j])
print([i for i in flatten(d3)])