# iterating over a list by object

x = ['bob', 'sue', 'mary']

for name in x: 
    print(name.upper() + ' WAS HERE')

# alternatively, you could iterate over position

for i in range(len(x)):
    print(x[i].upper() + ' WAS HERE')

dir(x)  # ignore the __ methods for now

"""
Explanation: More on data structures
Iterable vs. Iterators
Lists are examples of iterable data structures, which means that you can iterate over the actual objects in these data structures.
End of explanation
"""


y = (x*x for x in [1, 2, 3])

type(y)

dir(y)

y.send??

y[5]

next(y)

y.send(1)

next(y)  # run this cell twice - what happens?

"""
Explanation: generators return their contents 'lazily'. This leaves a minimal memory footprint, at the cost of making the generator nonreusable.
End of explanation
"""


z = range(10, 5, -1)

dir(range)

# let's filter that list a little
[x for x in dir(range) if not x.startswith('_')]

z.start

len(z)  # __ function - overloaded operator

"""
Explanation: 'range' is something like a generator, but with special properties because of its intended use case (in 'for' loops or similar structures.
End of explanation
"""


for i in z:
    print(i)

"""
Explanation: From the docs (https://docs.python.org/3/library/stdtypes.html#typesseq-range): The advantage of the range type over a regular list or tuple is that a range object will always take the same (small) amount of memory, no matter the size of the range it represents (as it only stores the start, stop and step values, calculating individual items and subranges as needed).
Range objects implement the collections.abc.Sequence ABC, and provide features such as containment tests, element index lookup, slicing and support for negative indices (see Sequence Types — list, tuple, range):
End of explanation
"""


GPA = zip(['bob', 'sue', 'mary'], [2.3, 4.0, 3.7])

type(GPA)

dir(GPA)

next(GPA)

next(GPA)[1]

"""
Explanation: zips produced iterators from pairs:
End of explanation
"""


dict?

"""
Explanation: More on Dicts
The dict data structure shows up all over Python.
End of explanation
"""


GPA_2 = dict(bob=2.0, sue=3.4, mary=4.0)

"""
Explanation: from assignment:
End of explanation
"""


names = ['bob', 'mary', 'sue', 'lisa']
gpas = [3.2, 4.0, 3.1, 2.8]

GPA_3 = dict(zip(names, gpas))
GPA_3

"""
Explanation: from iterator:
End of explanation
"""


# explicitly named arguments are also positional
# Anything after * in a function is a positional argument - tuple
# Anything after ** is a named argument 
# the latter are unpacked as dicts

def arg_explainer(x, y, *args, **kwargs):
    print('-'*30)
    print('x is %d, even though you didn\'t specify it, because of its position.' % x)
    print('same with y, which is %d.' %y)
    if args:
        print('-'*30)
        print('type(*args) = %s' % type(args))
        print('these are the *args arguments: ')
        for arg in args:
            print(arg)
    else:
        print('-'*30)
        print('no *args today!')
    if kwargs:
        print('-'*30)
        print('type(**kwargs) == %s' % type(kwargs))
        for key in kwargs:
            print(key, kwargs[key])
    else:
        print('-'*30)
        print('no **kwargs today!')
        print('-'*30)

arg_explainer(2, 4, 3, 7, 8, 9, 10, plot=True, sharey=True, rotate=False)

"""
Explanation: In function definitions:
End of explanation
"""


my_kwargs = {'plot': False, 'sharey': True}

arg_explainer(1, 2, **my_kwargs)

"""
Explanation: In function calls:
End of explanation
"""


import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline

?plt.plot

x = np.linspace(-5, 5, 100)
y1 = np.sin(x)
y2 = np.cos(x)

plt.plot(x, y1)  # all of these arguments are *args
plt.plot(x, y2, color='red', label='just on the cosine, for no reason at all')  # starting w/ color, **kwargs
plt.legend(loc='center');

"""
Explanation: This allows, for instance, matplotlibs plot function to accept a huge range of different plotting options, or few to none at all.
End of explanation
"""


