foo = 'bar'

# The 'if' statment will set a criteria, and 'if' the condition is met, it will print 'Condition satisfied'
if foo == 'bar':
    print('Condition satisfied')

x = -15

# If x is equal to zero print x, "is zero"
if x == 0:
    print(x, "is zero")
    
# Other wise if x is greater than zero print x, "is positive"
elif x > 0:
    print(x, "is positive")
    
# If all else fails print x, "is negative"
else:
    print(x, "is negative")

"""
Explanation: Control Flow

Without Control flow, a program is simply a list of statements that are sequentially executed.
With control flow, you can execute certain code blocks conditionally and/or repeatedly.
Basic building blocks are:
conditional statements (including "if", "elif", and "else")
loop statements (including "for" and "while" and the accompanying "break", "continue", and "pass").



Conditional Statements: if-elif-else:
Conditional statements, often referred to as if-then statements, allow the programmer to execute certain pieces of code depending on some condition.
A basic example of a Python conditional statement is this (we need to use a colon to specify an 'if, elif or else' statement:
End of explanation
"""


for N in [2, 3, 5, 7]: 
    print(N) 

"""
Explanation: Note especially the use of colons (:) and whitespace to denote separate blocks of code.
for loops
Loops in Python are a way to repeatedly execute some code statement.
This saves time rather than writing out each line, but can also be computationally demanding!
So, for example, if we'd like to print each of the items in a list, we can use a for loop:
End of explanation
"""


for N in [2, 3, 5, 7]: # you can replace 'N' with anything you like, but make sure to replace it below too!
    print(N, end=' ') # print all on same line

"""
Explanation: Now lets print it on the same line for ease:
A 'for loop' is a basic and extremely helpful tool in python. 
What we are doing here is replace the 'N' with each number within the list ie. [2,3,5,7] sequentionally, and printing the output. 
For each number you printed, you can see it has been placed on a new line. This signifies the loop has restarted on the next value in the list. 
Now let's do the same but this time put all the numbers on the same line for visual appearance.
End of explanation
"""


for i in range(10):   # Here we have replaced 'N' with 'i' 
    print(i)          #print(i, end=' ')

"""
Explanation: range(start, stop[, step])
One of the most commonly-used iterators in Python is the range object, which generates a sequence of numbers:
End of explanation
"""


# range from 5 to 10
list(range(5, 10))

# range from 0 to 10 by 2
list(range(0, 10, 2))

"""
Explanation: Range objects can also have more complicated values:
End of explanation
"""


i = 0
while i < 10:
    print(i, end=' ')
    i += 1

"""
Explanation: while loops
The other type of loop in Python is a while loop, which iterates until some condition is met.
It can also be used as a continous loop until you 'break' it!
End of explanation
"""


for n in range(20):
    # check if n is even
    if n % 2 == 0:
        continue
    print(n,end=" ")

"""
Explanation: The argument of the while loop is evaluated as a boolean statement, and the loop is executed until the statement evaluates to False.
break and continue: Fine-tuning your For and  While loops

break statement breaks-out of the loop entirely
continue statement skips the remainder of the current loop, and goes to the next iteration

Let's print a string of odd numbers.
End of explanation
"""


a, b = 0, 1
amax = 100
L = []

while True:
    (a, b) = (b, a + b) 
    if a > amax:
        break
    L.append(a)

print(L)

"""
Explanation: In this case, the result could be accomplished just as well with an if-else statement, but sometimes the continue statement can be a more convenient way to express the idea you have in mind:
Here is an example of a break statement used for a less trivial task.
This loop will fill a list with all Fibonacci numbers up to a certain value:
End of explanation
"""


import sys
if sys.version_info < (3,0):
    range = xrange

"""
Explanation: Notice that we use a while True loop, which will loop forever unless we have a break statement! Above we set a value of 100 to break if the value 'L' becomes greater than 100.
Exercise 1
Write a program that can print the following pattern for a given triangle size N.
For N=5:
<code>
* 
* * 
* * * 
* * * * 
* * * * *
print('*',end=' ')   # to print '*' without changing line
print('')            # change line
</code>
Iterators
range(): A List Is Not Always a List
The range() function in Python 3 (named xrange() in Python 2), returns not a list, but a special range() object:
End of explanation
"""


iter(range(10))

"""
Explanation: range, like a list, exposes an iterator:
End of explanation
"""


for i in range(2):
    print(i)

"""
Explanation: So Python knows to treat it as if it's a list:
End of explanation
"""


N = 10 ** 9             
for i in range(N):            # this would take > 4 GBytes to store in memory
    if i >= 10: break
    print(i)                   # print(i, end=', ') in python 3

sys.getsizeof(range(N))

"""
Explanation: In this indirect iterator the full list is never explicitly created!
Let's try something sily:
End of explanation
"""


from itertools import count

for i in count():
    if i >= 10:
        break
    print(i, end=', ')

"""
Explanation: If range were to actually create that list of one trillion values, it would occupy tens of terabytes of machine memory: a waste, given the fact that we're ignoring all but the first 10 values!
In fact, there's no reason that iterators ever have to end at all! Python's itertools library contains a count function that acts as an infinite range:
End of explanation
"""


L = [2, 4, 6, 8, 10]
for i in range(len(L)):
    print(i, L[i])

"""
Explanation: Had we not thrown-in a loop break here, it would go on happily counting until the process is manually interrupted or killed (using, for example, ctrl-C).
If range were to actually create that list of one trillion values, it would occupy tens of GB of machine memory: a waste, given the fact that we're ignoring all but the first 10 values!
Useful Iterators
enumerate
Often you need to iterate not only the values in an array, but also keep track of the index.
You might be tempted to do things this way:
End of explanation
"""


for i, val in enumerate(L):
    print(i, val)

"""
Explanation: Although this does work, Python provides a cleaner syntax using the enumerate iterator:
End of explanation
"""


L = [3, 3, 6, 8, 15]
M = [2, 6, 8, 10, 12]
R = [1, 4, 5, 9, 10]
for lval, mval, rval in zip(L, M, R):
    print(max(lval, mval, rval))
    
#Alternative to
for i in range(len(L)):
    max(L[i], M[i], R[i])

"""
Explanation: zip([iterable, ...])
Iterate through several iterables at the same time.
End of explanation
"""


list(map(max, L, M, R))

"""
Explanation: Any number of iterables can be zipped together, and if they are different lengths, the shortest will determine the length of the zip.
map(function, iterable, ...)
Apply function to every item of iterable and return a list of the results.
End of explanation
"""


from itertools import permutations
p = permutations(range(3))
print(*p)

"""
Explanation: itertools
More specialised iterators are available in a built-in itertools module. Example:
End of explanation
"""


import sys
### Handling exceptions
try:
    open('myfile')
except IOError:
    err = sys.exc_info()[1]
    print(err)
    print('Cannot read file so will use  default values')
    # Do alternate action
    
else:
    print('Values were read from myfile')

"""
Explanation: Errors and exceptions
These things happen...
Mistakes come in three basic flavors:

Syntax errors: Errors where the code is not valid Python (generally easy to fix)
Runtime errors: Errors where syntactically valid code fails to execute, perhaps due to invalid user input (sometimes easy to fix)
Semantic errors: Errors in logic: code executes without a problem, but the result is not what you expect (often very difficult to track-down and fix)

Here we're going to focus on how to deal cleanly with runtime errors.
Catch what you can handle vs. let it fail
Errors that go unoticed can be difficult to detect, so often the best option is to let the exception stop the program.
try and except
Sometimes specific errors can be handled and an alternate course of action taken.
End of explanation
"""


N=5
#Solution
for i in range(N):
    for j in range(i+1):
        print ('*', end=' ')
    print('')
print('')

#Solution using break
for i in range(N):
    for j in range(N):
        if (j > i):
            break
        print('*', end=' ')
    print('')

"""
Explanation: References
A Whirlwind Tour of Python by Jake VanderPlas (O’Reilly). Copyright 2016 O’Reilly Media, Inc., 978-1-491-96465-1
Solutions to Exercise 1
End of explanation
"""


