# # 04 - More Types

# ## The `None` Type

# The `None` object is used to represent the absence of a value. It is similar to `null` in other programming languages.
#
# Like other "empty" values, such as `0`, `[]` and the empty string `""`, it is `False` when converted to a Boolean variable.
#
# When entered at the Python console, it is displayed as `None`.

# +
myNone = None
print(myNone)

print(None == None)
print(bool(None))
# -

# ## Tuples

# Tuples are very similar to lists, except that they are **immutable** (they cannot be changed). Also, they are created using parentheses (round brackets), rather than square brackets.
#
# You can access the values in the tuple with their index, just as you did with lists. **However, trying to reassign a value in a tuple causes a `TypeError`**.

# +
# Creating lists and tuples
myList = ["Foo", "Bar", "Spam", "Eggs"]   # This is a list
myTuple = ("Foo", "Bar", "Spam", "Eggs")  # This is a tuple

# Accessing values in lists and tuples
print(myList[0])    # Get first element in the list
print(myTuple[0])   # Get first element in the tuple

print(myList[2])    # Get second element in the list
print(myTuple[2])   # Get second element in the tuple

print(myList[-1])   # Get last element in the list
print(myTuple[-1])  # Get last element in th tuple

# We can only reassign values in lists, and not in tuples
myList[1] = "Sausage"
print(myList)

# myTuple[1] = "Sausage"  # This is illegal and causes a `TypeError`
# print(myTuple)
# -

# Note that, like lists, tuples can be nested within each other.

nestedTuple = ((1, 2), (3, 4), (5, 6), (7, 8))
print(nestedTuple)

# An empty tuple is created using an empty parenthesis pair.

emptyTuple = ()
print(emptyTuple)

# Tuples are commonly used if **reassignment of elements should be avoided**. For example, coordinates are usually created using tuples as changing a single ordinate in the coordinate is undesirable.

# **Exercise 04.01**: Write a program that calculates the straight-line distance between the two points in 3D space.
# - The first point has coordinates $(-1, 6, 4)$.
# - The second point has coordinates $(-4, 10, -8)$.
#
# *Note: for two points $(x_1, y_1, z_1)$ and $(x_2, y_2, z_2)$, the straight-line distance between them is given by $$\sqrt{(x_1-x_2)^2 + (y_1-y_2)^2+(z_1-z_2)^2}$$*

# +
# Write your code here
# -

# ## Dictionaries

# Dictionaries are data structures used to map arbitrary keys to values.
#
# Each element in a dictionary is represented by a `key: value` pair.
#
# Dictionaries can be indexed in the same way as lists, using square brackets containing keys.
#
# Trying to index a key that isn't part of the dictionary returns a `KeyError`.

# +
ages = {"Alex": 23, "Bob": 36, "Charlie": 45}  # Elements are key-value pairs

print(ages["Alex"])  # Get the value associated with the key "Alex"
print(ages["Bob"])
print(ages["Charlie"])

# print(ages[0])  # There is no key 0 so this doesn't work and raises a `KeyError`
# -

# A dictionary can store any type of data as **values**.

# +
colors = {
    "red":   [255, 0, 0],  # The value can be mutable
    "green": [0, 255, 0],
    "blue":  [0, 0, 255]
}

print(colors["red"])
print(colors["green"])
print(colors["blue"])
# -

# However, the **keys** of a dictionary are more restrictive. The keys of a dictionary must be **immutable** (i.e. non editable). Thus, the keys of a dictionary **cannot be lists, dictionaries, or sets** (explained later) as these are **mutable**.
#
# *Note: recall that strings are __immutable__ so they can be used as keys for dictionaries.*

# Uncomment the following lines to see the errors that Python produces
myDict = {
    # [1, 2, 3]: "List",              # Key is a list - not acceptable
    # {"A": 1, "B": 2}: "Dictionary"  # Key is a dictionary - not acceptable
}

# An empty dictionary is defined as `{}` or `dict()` (preferred).

print({})
print(dict())
print(dict() == {})

# Just like lists, dictionary keys can be assigned to different values.
#
# However, unlike lists, a new dictionary key can also be assigned a value, not just ones that already exist.

# +
squares = {1: 1, 2: 4, 3: "Error", 4: 16, 5: 25}
print(squares)

squares[3] = 9  # Assign the value of the element with key `3` to `9`
print(squares)

squares[8] = 64  # Make a new key-value pair of (8, 64)
print(squares)
# -

# To determine whether a **key** is in a dictionary, you can use `in` and `not in`, just as you can for a list.

# +
ages = {"Alex": 23, "Bob": 36, "Charlie": 45}

print("Alex" in ages)  # Is the key "Alex" in `ages`?
print("Dave" in ages)  # Is the key "Dave" in `ages`?
# -

# To get the **keys** of a dictionary, use the `.keys()` method of the dictionary. To get the **values** of a dictionary, use the `.values()` method of the dictionary.
#
# *Note: __none of the aforementioned methods returns a list__, so you will need to explicitly convert their outputs to a list to use list methods. However, you can __still perform `in` and `not in` on them__ without converting to a list*.

# +
ages = {"Alex": 23, "Bob": 36, "Charlie": 45}

print(list(ages.keys()))
print(list(ages.values()))

print("Alex" in ages.keys())  # Is "Alex" in the `ages`'s keys?
print(36 in ages.values())    # Is 36 in the `ages`'s values?
# -

# To get all the key-value pairs of a dictionary, we can use the `.items()` method. However, just like the other two methods, although `in` and `not in` can be used on it, **it is not a list**. Thus explicit conversion to a list is needed if you want it to be a list.

# +
ages = {"Alex": 23, "Bob": 36, "Charlie": 45}

print(ages.items())  # Not a list
print(list(ages.items()))
# -

# Running `list(dictionary.items())` returns a list of key-value pairs, where each key-value pair is in a **tuple**. The first item in the tuple is the key and the second item is the value.
#
# We can iterate through all key-value pairs by doing something like this:

# +
ages = {"Alex": 23, "Bob": 36, "Charlie": 45}

for keyValuePair in ages.items():
    print(keyValuePair[0], keyValuePair[1])  # Remember: index 0 is the key and index 1 is the value

# A more concise way of doing that is this:
for key, value in ages.items():  # Notice there are now two items before `in`
    print(key, value)
# -

# Another useful dictionary method is `get`. It does the same thing as indexing, but if the key is not found in the dictionary it returns another specified value instead (`None`, by default).

# +
ages = {"Alex": 23, "Bob": 36, "Charlie": 45}

print(ages.get("Alex"))  # Get the value associated with key "Alex"
print(ages.get("Dave"))  # Get the value associated with key "Dave"
print(ages.get("Dave", "This is returned if not found"))
# -

# **Exercise 04.02**: The file `titles.json` contains a dictionary containing key-value pairs. The key represents the [Project Gutenberg](https://www.gutenberg.org/) EBook ID and the value the title of the EBook. **This dictionary has the name `titles`**. Note that the key is a **string** representing an **integer**, representing the EBook ID.
#
# Write code that asks the user to input an EBook ID and returns the title.
# - You **should validate** that the input is between 1 and 1000 inclusive. **You do not need to validate whether the input is an integer or not**.
# - Print the corresponding title of the book if the input ID is valid; otherwise print `Not Found`.
#
# The reading and processing of the dictionary is handled for you.
#
# *Note: the ID `182` does not correspond to any book; you can use this to check your program.*

# +
# Reading in the dictionary; DO NOT MODIFY
import json

with open("titles.json", "r") as f:
    titles = json.load(f)

# Write your code here
# -

# ## List Slicing

# List slices provide a more advanced way of retrieving values from a list. Basic list slicing involves indexing a list with **two colon-separated integers**. This returns a new list containing all the values in the old list between the indices.

# +
squares = [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

print(squares[2:4])
print(squares[3:8])
print(squares[0:1])
# -

# Like the arguments to range, the first index provided in a slice is included in the result, but the second isn't.

# +
squares = [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

# Compare this...
listSlice = []
for i in range(3, 8):
    listSlice.append(squares[i])
print(listSlice)

# ...with this
print(squares[3:8])
# -

# If the first number in a slice is omitted, it is taken to be the start of the list. If the second number is omitted, it is taken to be the end.

# +
squares = [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

print(squares[:4])
print(squares[4:])
# -

# Slicing can also be done on tuples.

# +
cubes = (1, 8, 27, 64, 125)

print(cubes[1:4])
print(cubes[:4])
print(cubes[2:])
# -

# Just like `range`, list slices can also have a third number, **representing the step**, to include only certain values in the slice.

# +
squares = [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

print(squares[::2])  # Take only alternate values
print(squares[2:8:2])
print(squares[2:8:3])
# -

# **Discussion 04.01**: What is the output of this code?
# ```python
# squares = [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
#
# print(squares[2::4])
# print(squares[8:1:-1])
# print(squares[::-1])
# ```
# Try and predict the output of the code before running the code below.

# +
# Try out the code here
# -

# **Remark**: Using `[::-1]` as a slice is a common and idiomatic way to reverse a list.

# +
myList = ["A", "B", "C", "D", "E"]

print(myList)
print(myList[::-1])
# -

# **Exercise 04.03**: A list is given below.
# ```python
# myList = [1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169, 196, 225, 256, 289, 324, 361, 400]
# ```
# Write *concise* code that outputs the following:
# ```
# [169, 144, 121, 100, 81, 64, 49, 36, 25]
# ```

# +
# The list above is provided here
myList = [1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169, 196, 225, 256, 
          289, 324, 361, 400]

# Write your code here
# -

# ## List Comprehension

# List comprehensions are a useful way of quickly creating lists whose contents obey a simple rule.
#
# For example, we can do the following:

# A list comprehension
squares = [i**2 for i in range(10)]  # Generates the square numbers from 0 to 9 inclusive
print(squares)

# **Discussion 04.02**: What is the output of the following code?
# ```python
# print([i for i in range(1, 20)])
# print([x*2 for x in range(1, 20)])
# print([num**3 for num in range(5, 1, -1)])
# print([2*a+1 for a in range(9)])
# ```
# Try and predict the output of the code before running it below.

# +
# Try out the code here
# -

# A list comprehension can also contain an `if` statement to enforce a condition on values in the list.

# Adds the square of the current number only if `i % 2 == 0`
evenSquares = [i**2 for i in range(10) if i % 2 == 0]
print(evenSquares)

# **Exercise 04.04**: Create a list of the multiples of 3 from 0 to 19 inclusive.

# +
# Write your code here
# -

# Trying to create a list in a very extensive range will result in a `MemoryError`. This code shows an example where the list comprehension runs out of memory.
#
# This issue is solved by generators, which are covered in subsequent modules.

# +
# ohNoItsTooBig = [i for i in range(10 ** 100)]  # Will produce a `MemoryError` (after a long while)
# -

# **Exercise 04.05**: Create a list of multiples of 3 from 0 to `N` inclusive, where `N` is an integer that is entered by the user. You may assume that the user always enters an integer.
# - Validate that `N` is positive but less than 100. Continue asking for input until an integer that meets this condition is entered.
#
# *Sample Input* (without the comments):
# ```
# 1234  # Invalid
# 123   # Invalid
# -12   # Invalid
# 19    # Valid
# ```
#
# *Sample output*:
# ```
# [0, 3, 6, 9, 12, 15, 18]
# ```

# +
# Write your code here
# -

# ## String Formatting (using `f`-Strings)

# So far, to combine strings and non-strings, you've converted the non-strings to strings and added them.
#
# String formatting provides a more powerful way to embed non-strings within strings. From Python 3.6 onwards, we can use `f`-strings to substitute known values into strings.

# +
nums = [4, 5, 6]

# Variables are used by writing `{variable}` inside the `f`-string
message = f"My numbers are {nums[0]}, {nums[1]} and {nums[2]}."
print(message)
# -

# **Discussion 04.03**: What do you think the output of the following code will be?
# ```python
# myList = ["A", 2, 3.45, False, True]
#
# print(f"First element is {myList[0]}")
# print(f"Second element is {myList[1]}")
# print("What does this do? {myList[3]}")
# print(f"What about booleans? {myList[-1]}")
# ```
# Try and predict the output before running the code in the space provided below.

# +
# Try out the code here
# -

# **Discussion 04.04**: What do you think the following code will output?
# ```python
# var1 = 1
# var2 = 2
# var3 = 3
#
# print(f"{var1} {var1} {var1}")
# print(f"{var2} {var3} {var1}")
# print(f"{{var2}} {{var3}} {{var1}}")
# ```
# Try and predict the output before running the code in the space provided below.

# +
# Try out the code here
# -

# The discussion above brings up an important thing to note: to enter curly braces in an `f`-string, you **must use double of them**.
# - To type `{` in an `f`-string, write `{{`.
# - To type `}` in an `f`-string, write `}}`.

variable = [1, 2, 3]
print(f"These are curly braces ({{ and }}) and here's my list {variable}.")

# **Exercise 04.06**: Write a program that:
# - Accepts two strings as input.
# - Print the string `[First String] [First String] [Second String] [Second String] [First String] [Second String] [Second String] [First String]`, replacing `[First String]` and `[Second String]` with the appropriate string.

# +
# Write your code here
# -

# ## Useful Functions

# Python contains many useful built-in functions and methods to accomplish common tasks.

# ### Text Functions

# Here are some useful string functions.
# - `join`: joins a list of strings with another string as a separator.
# - `split`: turns a string with a certain separator into a list.
# - `replace`: replaces one substring in a string with another.
# - `startswith`: determine if there is a substring at the start of a string.
# - `endswith`: determine if there is a substring at the end of a string.

print(", ".join(["spam", "eggs", "ham"]))
print("A long string, separated by commas, wow!".split(","))
print("Hello me".replace("me", "world"))
print("this is a sentence".startswith("this"))
print("this is a sentence".endswith("sentence"))

# **Exercise 04.07**: Write code that splits the string `This is a test` into its individual words. Also write code that combines the list `["This", "is", "another", "test."]` into a single, grammatically sound sentence.

# +
# Write your code here
# -

# ### Numeric Functions

# - To find the maximum or minimum of some numbers or a list, you can use `max` or `min`.
# - To find the distance of a number from zero (its absolute value), use `abs`.
# - To round a number to a certain number of decimal places, use `round`.
# - To find the sum of numbers in a list, use `sum`.
#
# *Note: Python's `round` function uses the __[round half to even](https://en.m.wikipedia.org/wiki/Rounding#Round_half_to_even) method__ (also known as Banker's Rounding) for rounding numbers.*

# +
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
print(max(numbers))
print(min(numbers))
print(sum(numbers))

print(abs(54))
print(abs(-54))

print(round(2.5))
print(round(3.5))
print(round(4.7515244, 3))  # Round to 3 decimal places
print(round(2, 2))
# -

# **Exercise 04.08**: The list
# ```
# [1, -2, 3.04, 4.055, -5.605, 5.605, 7.123456, -7.123456, 0.005, -0.005]
# ```
# contains several numbers. Round each number to 1 decimal place. Then return the maximum and minimum of the absolute value of the rounded numbers.

# +
# Write your code here
# -

# ### List Functions

# Often used in conditional statements, `all` and `any` take a list as an argument, and return `True` if all or any (respectively) of their arguments evaluate to `True` (and `False` otherwise).

# +
nums = [55, 44, 33, 22, 11]

if all([num > 5 for num in nums]):
    print("All numbers greater than 5")

if any([num % 2 == 0 for num in nums]):
    print("At least one number is even")
# -

# The function `enumerate` can be used to iterate through the values and indices of a list simultaneously.

# +
myList = ["Value 1", "Value 2", "Value 3", "Value 4", "Value 5"]

for index, value in enumerate(myList):
    print(index, value)
# -

# **Exercise 04.09**: The list
# ```
# [1, 3, 6, 8, 12, 24, 14, 30, 20, 21]
# ```
# contains several integers. Write code that determines whether:
# - every value is at least twice its index (0-based indexing)
# - at least one value is a multiple of 4 when its index (0-based indexing) is also a multiple of 4

# +
# Write your code here
# -

# ## Assignment 04A
# The file `titles.json` contains a dictionary containing key-value pairs. The key represents the [Project Gutenberg](https://www.gutenberg.org/) EBook ID and the value the title of the EBook. **This dictionary has the name `titles`**. Note that the key is a **string** representing an **integer**, representing the EBook ID. The loading of this dictionary is handled for you below.
#
# ### Task
# Write code that does the following.
# - Determines whether any book title has longer than 50 characters.
# - Determines if every book title has the letter `e` in it (both uppercase and lowercase)
#
# In addition, generate a list of book titles which has **two consecutive spaces next to each other**. Print out the **first 3 elements of this list** using the format
# ```
# The first three titles which have two consecutive spaces are:
# [TITLE 1], [TITLE 2], and [TITLE 3].
# ```

# +
# Reading in the dictionary; DO NOT MODIFY
import json

with open("titles.json", "r") as f:
    titles = json.load(f)

# Write your code here
# -

# ## Assignment 04B
# The string
# ```
# 1,2 3,4 5,6 7,8 9,10 -1,-2 11,-12 -13,14 15,16 20,20
# ```
# represents a list of 10 tuples (separated by spaces), where each tuple is a pair of two numbers (separated by commas).
#
# Define the *norm* of a tuple $(x,y)$ to be
# $$\sqrt{x^2+y^2}$$
#
# ### Task
# Generate the list of norms, in the same order as the tuples in the string. Then,
# - print the list of norms, where each element is rounded to 2 decimal places.
# - find the maximum and minimum norm (**not** rounded to 2 decimal places) and print their sum
# - by considering list slicing, print out the 1st, 3rd, 5th, 7th, and 9th norms (1-based indexing, **not** rounded to 2 decimal places) in a list

# +
# The string defined earlier
theString = "1,2 3,4 5,6 7,8 9,10 -1,-2 11,-12 -13,14 15,16 20,20"

# Write your code here

StarcoderdataJupyterScriptsDedupFiltered