def turn_clockwise(direction):
    compass = {"N":"E" , "E": "S", "S":"W", "W":"N"}
    return compass[direction]

assert turn_clockwise("N") == "E"
assert turn_clockwise("W") == "N"

turn_clockwise("N")

"""
Explanation: wk3.0
Warm-up

The four compass points can be abbreviated by single-letter strings as “N”, “E”, “S”, and “W”. Write a function turn_clockwise that takes one of these four compass points as its parameter, and returns the next compass point in the clockwise direction. Here are some tests that should pass:

assert turn_clockwise("N") == "E"
assert turn_clockwise("W") == "N"
End of explanation
"""




def turn_clockwise(direction):
    compass = {"N":"E" , "E": "S", "S":"W", "W":"N"}
    try:
        return compass[direction]
    except KeyError:
        print("That's not a direction ya dingus!")
        return None

try:
    turn_clockwise()
except KeyError:
    print("Enter a direction")
"""
assert turn_clockwise() == None
assert turn_clockwise(47) == None
assert turn_clockwise("rubbish") == None
"""

"""
Explanation: You might ask “What if the argument to the function is some other value?” For all other cases, the function should return the value None:

assert turn_clockwise(42) == None
assert turn_clockwise("rubbish") == None
End of explanation
"""


def day_name(num):
    days = ("Sun", "Mon", "Tues", "Wed", "Thur")
    try:
        return days[num]
    except:
        return None

assert day_name(3) == "Wed"
assert day_name(0) == "Sun"
assert day_name(42) == None
day_name(2) 

"""
Explanation: Write a function day_name that converts an integer number 0 to 6 into the name of a day. Assume day 0 is “Sunday”. Once again, return None if the arguments to the function are not valid. Here are some tests that should pass:

assert day_name(3) == "Wednesday"
assert day_name(6) == "Saturday"
assert day_name(42) == None
End of explanation
"""


days = ("Sun", "Mon", "Tues", "Wed", "Thur")

days.index("Mon")

lst = []
for day in days:
    lst.append((days.index(day), day) )

dct = dict(lst)
dct


"""
Explanation: Write the inverse function day_num which is given a day name, and returns its number:

assert day_num("Friday") == 5
assert day_num("Sunday") == 0
assert day_num(day_name(3)) == 3
assert day_name(day_num("Thursday")) == "Thursday"
End of explanation
"""



def day_name(num):
    """Takes in day index and returns day name"""
    days = ("Sun", "Mon", "Tues", "Wed", "Thur", "Fri", 
            "Sat")   
    try:
        return days[num]
    except:
        return None

def day_num(day):
    days = ("Sun", "Mon", "Tues", "Wed", "Thur", "Fri", 
            "Sat")
    try:
        return days.index(day)
    except:
        return None

def day_add(day, delta):
    number = (day_num(day) + delta) % 7
    return day_name(number)


assert day_name(3) == "Wed"
assert day_name(0) == "Sun"
assert day_name(42) == None

assert day_num("Fri") == 5
assert day_num("Sun") == 0
assert day_num(day_name(3)) == 3

assert day_add("Tues", 0) == "Tues"
assert day_add("Fri", 3) == "Mon"

assert day_add("Fri", -2) == "Wed"

-1%7

"""
Explanation: Once again, if this function is given an invalid argument, it should return None:

assert day_num("Halloween") == None

Write a function that helps answer questions like ‘“Today is Wednesday. I leave on holiday in 19 days time. What day will that be?”’ So the function must take a day name and a delta argument — the number of days to add — and should return the resulting day name:

assert day_add("Monday", 4) ==  "Friday"
assert day_add("Tuesday", 0) == "Tuesday"
test(day_add("Tuesday", 14) == "Tuesday"
test(day_add("Sunday", 100) == "Tuesday"
Hint: use the first two functions written above to help you write this one.
End of explanation
"""



def mult(x1, x2, x3, x4):
    multi = x1*x2*x3*x4
    add = x1 + x2 + x3 + x4
    return multi*add

mult(1,1,1,2)

%quickref

"""
Explanation: Can your day_add function already work with negative deltas? For example, -1 would be yesterday, or -7 would be a week ago:
assert day_add("Sunday", -1) == "Saturday"
assert day_add("Sunday", -7) == "Sunday"
assert day_add("Tuesday", -100) == "Sunday"


If your function already works, explain why. If it does not work, make it work.
Hint: Play with some cases of using the modulus function % (introduced at the beginning of the previous chapter). Specifically, explore what happens to x % 7 when x is negative.


Write a function days_in_month which takes the name of a month, and returns the number of days in the month. Ignore leap years:
assert days_in_month("February") == 28
assert days_in_month("December") == 31
If the function is given invalid arguments, it should return None.


Write a function to_secs that converts hours, minutes and seconds to a total number of seconds. Here are some tests that should pass:
assert to_secs(2, 30, 10) == 9010
assert to_secs(2, 0, 0) == 7200
assert to_secs(0, 2, 0) == 120
assert to_secs(0, 0, 42) == 42
assert to_secs(0, -10, 10) == -590


Extend to_secs so that it can cope with real values as inputs. It should always return an integer number of seconds (truncated towards zero):
assert to_secs(2.5, 0, 10.71) == 9010
assert to_secs(2.433,0,0) == 8758


Write three functions that are the “inverses” of to_secs:

hours_in returns the whole integer number of hours represented by a total number of seconds.
minutes_in returns the whole integer number of left over minutes in a total number of seconds, once the hours have been taken out.
seconds_in returns the left over seconds represented by a total number of seconds.
You may assume that the total number of seconds passed to these functions is an integer. Here are some test cases:
assert hours_in(9010) == 2
assert minutes_in(9010) == 30
assert seconds_in(9010) == 10

Fruitful functions

temporary variables
End of explanation
"""


def bad():
    print("Hi")
    return "bye"
    
bad()

"""
Explanation: dead code, or unreachable code
End of explanation
"""


def bad_absolute_value(x):
    if x <= 0:
        return -x
    elif x > 0:
        return x
    
bad_absolute_value(0)

"""
Explanation: Make sure that your code accesses the whole range of input.
Ex. 
def bad_absolute_value(x):
    if x &lt; 0:
        return -x
    elif x &gt; 0:
        return x
End of explanation
"""


def find_first_2_letter_word(xs):
    """ Returns the first two letter word in a list. If no two letter word exists, returns an empty string"""
    for index, wd in enumerate(xs):
        if len(wd) == 2:
            
            return (wd, index)
    return ("", index)

print('res1', find_first_2_letter_word(["This",  "is", "a", "dead", "parrot"]))

print('res2', find_first_2_letter_word(["I",  "like",  "cheese", "bah"]))


"""
Explanation: Sometimes sticking a return in a for loop is a good idea:
End of explanation
"""


day_name(day_num("Wed"))

"""
Explanation: Incremental development
The key aspects of the process are:
1. Start with a working skeleton program and make small incremental changes. At any point, if there is an error, you will know exactly where it is.
2. Use temporary variables to refer to intermediate values so that you can easily inspect and check them.
3. Once the program is working, relax, sit back, and play around with your options. (There is interesting research that links “playfulness” to better understanding, better learning, more enjoyment, and a more positive mindset about what you can achieve — so spend some time fiddling around!) You might want to consolidate multiple statements into one bigger compound expression, or rename the variables you’ve used, or see if you can make the function shorter. A good guideline is to aim for making code as easy as possible for others to read.
Debugging
Another powerful technique for debugging (an alternative to single-stepping and inspection of program variables), is to insert extra print functions in carefully selected places in your code. Then, by inspecting the output of the program, you can check whether the algorithm is doing what you expect it to. Be clear about the following, however:


You must have a clear solution to the problem, and must know what should happen before you can debug a program. Work on solving the problem on a piece of paper (perhaps using a flowchart to record the steps you take) before you concern yourself with writing code. Writing a program doesn’t solve the problem — it simply automates the manual steps you would take. So first make sure you have a pen-and-paper manual solution that works. Programming then is about making those manual steps happen automatically.


Do not write chatterbox functions. A chatterbox is a fruitful function that, in addition to its primary task, also asks the user for input, or prints output, when it would be more useful if it simply shut up and did its work quietly.


For example, we’ve seen built-in functions like range, max and abs. None of these would be useful building blocks for other programs if they prompted the user for input, or printed their results while they performed their tasks.
So a good tip is to avoid calling print and input functions inside fruitful functions, unless the primary purpose of your function is to perform input and output. The one exception to this rule might be to temporarily sprinkle some calls to print into your code to help debug and understand what is happening when the code runs, but these will then be removed once you get things working.
Composition
End of explanation
"""


def tester(line):
    tests a bunch of stuff
    if all the stuff is good:
        return True
    else:
        return False

def main_func(emails):
    for line in emails:
        if tester(line):
            return line

"""
Explanation: Boolean functions for test hiding
End of explanation
"""


import random

# Create a black box object that generates random numbers
rng = random.Random()

dice_throw = rng.randrange(1,7)   # Return an int, one of 1,2,3,4,5,6
delay_in_seconds = rng.random() * 5.0

print('dice_throw', dice_throw)
print('delay_in_seconds', delay_in_seconds)

"""
Explanation: Lecture 2
Modules
Random numbers
A few uses of random numbers:
* To play a game of chance where the computer needs to throw some dice, pick a number, or flip a coin,
* To shuffle a deck of playing cards randomly,
* To allow/make an enemy spaceship appear at a random location and start shooting at the player,
* To simulate possible rainfall when we make a computerized model for estimating the environmental impact of building a dam,
* For encrypting banking sessions on the Internet.
End of explanation
"""



for num in range(10):
    print(rng.randrange(1,100,2))

"""
Explanation: How would we get odd numbers between 1 and 100 (exclusive)?
End of explanation
"""


random.random() # returns a number in interval [0,1). We need to scale it!

for num in range(10):
    print(random.random())

cards = list(range(52))  # Generate ints [0 .. 51]
                         #    representing a pack of cards.
rng.shuffle(cards)       # Shuffle the pack

cards


"""
Explanation: random.Random() returns a uniform distribution.


There are other distributions as well.
End of explanation
"""


drng = random.Random(15)  # Create generator with known starting state

for n in range(10):
    print(drng.randint(1,100)) # Always 7.

"""
Explanation: Repeatability and Testing

deterministic algorithm
pseudo-random generators
End of explanation
"""


import random

def make_random_ints(num, lower_bound, upper_bound):
   """
     Generate a list containing num random ints between lower_bound
     and upper_bound. upper_bound is an open bound.
   """
   rng = random.Random()  # Create a random number generator
   result = []
   for i in range(num):
      result.append(rng.randrange(lower_bound, upper_bound))
   return result

make_random_ints(4, 3,10)

 make_random_ints(5, 1, 13)  # Pick 5 random month numbers


"""
Explanation: Picking balls from bags, throwing dice, shuffling a pack of cards
End of explanation
"""


xs = list(range(1,13))  # Make list 1..12  (there are no duplicates)
rng = random.Random()   # Make a random number generator
rng.shuffle(xs)         # Shuffle the list
result = xs[:5]         # Take the first five elements

result

"""
Explanation: Getting unique values
End of explanation
"""


import random

def make_random_ints_no_dups(num, lower_bound, upper_bound):
    """
     Generate a list containing num random ints between
     lower_bound and upper_bound. upper_bound is an open bound.
     The result list cannot contain duplicates.
    """
    result = []
    rng = random.Random()
    for i in range(num):
        while True:
            candidate = rng.randrange(lower_bound, upper_bound)
            if candidate not in result:
                break
        result.append(candidate)
    return result

xs = make_random_ints_no_dups(5, 1, 10000000)
print(xs)

"""
def make_random_ints_no_dups(num, lower_bound, upper_bound):
    """
     Generate a list containing num random ints between
     lower_bound and upper_bound. upper_bound is an open bound.
     The result list cannot contain duplicates.
    """
    result = []
    rng = random.Random()
    while len(result) < num:
        candidate = rng.randrange(lower_bound, upper_bound)
        if candidate in result:
            continue
        result.append(candidate)
    return result

make_random_ints_no_dups(5, 1, 1000)
"""

"""
Explanation: The 'shuffle and slice' method is okay for small numbers but would not be so great if you only wanted a few elements, but from a very large domain. 
Suppose I wanted five numbers between one and ten million, without duplicates. Generating a list of ten million items, shuffling it, and then slicing off the first five would be a performance disaster! So let us have another try:
End of explanation
"""


xs = make_random_ints_no_dups(10, 1, 6) # Yikes!

"""
Explanation: This method is okay but still has some problems. 
Can you see what's going to happen in the next case?
End of explanation
"""


from timeit import default_timer as timer

t1 = timer()
print("hi")
t2 = timer()

print(t2 - t1)

from timeit import default_timer as timer

def do_my_sum(xs):
    sum = 0
    for v in xs:
        sum += v
    return sum

sz = 10000000        # Lets have 10 million elements in the list
testdata = range(sz)

t0 = timer()
my_result = do_my_sum(testdata)
t1 = timer()
print("my_result    = {0} (time taken = {1:.4f} seconds)"
        .format(my_result, t1-t0))

t2 = timer()
their_result = sum(testdata)
t3 = timer()
print("their_result = {0} (time taken = {1:.4f} seconds)"
        .format(their_result, t3-t2))


def do_my_sum(xs):
    sum = 0
    for v in xs:
        sum += v
    return sum

sz = 10000000        # Lets have 10 million elements in the list
testdata = range(sz)

%%timeit

my_result = do_my_sum(testdata)

%%timeit

their_result = sum(testdata)

"""
Explanation: The time module
Looking at code efficiency
End of explanation
"""


def range(n): 
    return 123*n

print(range(10)) # What will this print?

n = 10
m = 3
def f(n):
    m = 7
    return 2*n+m

print(f(5), n, m) # What about this one?

"""
Explanation: Creating your own modules

Save as a script and import!
The init.py file.

Namespaces

Each function, script, system has its own namespace.

Scope and lookup rules

The scope of an identifier is the region of program code in which the identifier can be accessed, or used.

There are three important scopes in Python:

Local scope refers to identifiers declared within a function. These identifiers are kept in the namespace that belongs to the function, and each function has its own namespace.
Global scope refers to all the identifiers declared within the current module, or file.
Built-in scope refers to all the identifiers built into Python — those like range and min that can be used without having to import anything, and are (almost) always available.

Python (like most other computer languages) uses precedence rules: the same name could occur in more than one of these scopes, but the innermost, or local scope, will always take precedence over the global scope, and the global scope always gets used in preference to the built-in scope. Let’s start with a simple example:
End of explanation
"""


import math
x = math.sqrt(10)

from math import cos, sin, sqrt
x = sqrt(10)

from math import *   # Import all the identifiers from math,
                     #   adding them to the current namespace.
x = sqrt(10)         # Use them without qualification.

# Here's a freebie since I like you guys
import math as m
m.pi


def area(radius):
    import math
    return math.pi * radius * radius

x = math.sqrt(10)      # This gives an error

"""
Explanation: Now we know why we use a return in our functions: to pass between namespaces!
Attributes and the dot operator
Variables defined inside a module are called attributes of the module. We’ve seen that objects have attributes too: for example, most objects have a doc attribute, some functions have a annotations attribute. Attributes are accessed using the dot operator (.). 
Three import statement variants
End of explanation
"""


