# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# + [markdown] nbpresent={"id": "e757f315-bfd0-42b8-afab-0c1a9873bfc9"}
# ![image.png](attachment:image.png)
#
#
# # Python Coding Club
#
# This series is to introduce Python as a programming language to be used for data analysis, scientific computing and plotting. It is by __no means comprehensive__ but will provide a basis for further investigation and exploration into this powerful language. These notes are __best visualised__ in a __Jupyter Notebook__ and I encourage you to __follow along__ in your __preferred IDE__.

# + [markdown] nbpresent={"id": "6b1a0a06-ef50-452d-af02-313d96023654"}
# # Part 2: Basics object types and statements
#
# Part 2 of this series involves introducing basics of Python programming. In particular it will introduce the concept of Python keywords, syntax, variables, built-in functions and operators.  

# + [markdown] nbpresent={"id": "1836b91a-3ea2-4b7c-b898-469659937523"}
# ## Part 2.1: Python basics
#
# ### Python keywords
#
# Python contains certain keywords that are interpreted when typed in a __specific manner__. A list of these keywords and their __actions__ in alphabetical order is shown below:
#
# | __Keyword__ | __Description__ |
# |:--|:--|
# | `and` |	A logical operator |
# | `as`  | To create an alias |
# | `assert` | For debugging |
# | `break` | To break out of a loop |
# | `class` | To define a class |
# | `continue` | To continue to the next iteration of a loop |
# | `def`	| To define a function |
# | `del`	| To delete an object |
# | `elif` | Used in conditional statements, same as else if |
# | `else` | Used in conditional statements |
# | `except` | Used with exceptions, what to do when an exception occurs |
# | `False` | Boolean value, result of comparison operations |
# | `finally` | Used with exceptions, a block of code that will be executed no matter if there is an exception or not| 
# | `for`	| To create a for loop |
# | `from` |	To import specific parts of a module |
# | `global`	| To declare a global variable |
# | `if`	| To make a conditional statement |
# | `import` |	To import a module |
# | `in` |	To check if a value is present in a list, tuple, etc. |
# | `is`	| To test if two variables are equal |
# | `lambda` |	To create an anonymous function |
# | `None` |	Represents a null value |
# | `nonlocal` |	To declare a non-local variable |
# | `not` |	A logical operator |
# | `or`	 | A logical operator |
# | `pass` |	A null statement, a statement that will do nothing |
# | `raise` |	To raise an exception |
# | `return` |	To exit a function and return a value |
# | `True` |	Boolean value, result of comparison operations |
# | `try` |	To make a try...except statement |
# | `while` |	To create a while loop |
# | `with` |	Used to simplify exception handling |
# | `yield` |	To end a function, returns a generator |
#
# These keywords may not mean that much to you currently but essentially they are __special words__ that implement specific actions in the Python interpreter. 
#
# - For instance the `import` __keyword__ starts an __import statement__ to import a __specific module__. The `from` __keyword__ is used in conjunction with `import` to import a __specific part__ of a module whilst the `as` keyword changes the __alias__ of the __module__ to a given name (i.e. `import numpy as np`).   
# - The `if` __keyword__ starts a __conditional statement__ which checks if the statement is `True`. The `elif` __keyword__  stands for 'else if' and starts a second conditional statement after an 'if' statement is `False`. The `else` __keyword__ starts the code block to execute if __all conditional statements__ are evaluated as `False`.
# - `True` and `False` are __keywords__ that designate the corresponding __boolean value__ (1 and 0 respectively).
# - The `def` and `class` __keywords__ start a __function defintion__ and __class definition__ respectively.
# - The `for` and `while` __keywords__ start __for__ and __while loops__ of code respectively.
#
# More on statements and loops later.

# + [markdown] nbpresent={"id": "8ae9758d-46f2-4848-bac5-c0fa6206a7ef"}
# ### Whitespace and syntax in Python
#
# Python is a very __visual programming language__ and therefore it utilises __whitespace (i.e. tabs and spaces)__ to denote new __code blocks__ to execute under statements.
#
# __Syntax__ is the rules that Python code is written with. This will be shown in more detail at the end of this part however for now take note that syntax is the way the code is written so that it is __interpretable__ by the __Python interpreter__ when the script is __executed (run)__.
#
# __Syntax errors__ are very easy to __debug__ in an IDE as the IDE will typically show areas where the syntax is not correct such as:
# - __unclosed parentheses__ 
# - __a missing colon__ 
# - __a missing tab indent__ 
#
# These `SyntaxError`'s are common when first learning a new programming language but with more practice they can be overcome quite easily. 
#
# Execute the two cells below to show a syntax error and how to avoid it.

# + [markdown] nbpresent={"id": "e038cfb9-4cb4-47ab-90be-0c1f83038b5d"}
# This cell has a missing parenthesis (i.e. an open bracket) and therefore will raise a `SyntaxError`. Execute the cell (__Shift+Enter__ when the cell is selected).

# + nbpresent={"id": "ce05d50f-e3bf-4e16-baef-a14517b2cc83"}
print(5+10

# + [markdown] nbpresent={"id": "2ecc15d1-f6ce-40a9-bcdb-4ee04e4a4f2d"}
# This cell has the parentheses closed, therefore the `print` function knows what to evaluate and the code executes correctly.  
#   Execute the cell below (__Shift+Enter__).

# + nbpresent={"id": "5654d743-4cc4-46f3-9e13-d419eaf21fc6"}
print(5+10)

# + [markdown] nbpresent={"id": "594e51bd-c2b7-4c14-b99b-35b179a833c6"}
# The use of __whitespace__ is __particularly important__ when defining functions using `def` or classes with `class` and when writing in `if`, `elif` and `else` statement blocks and when using `for` and `while` loops.

# + [markdown] nbpresent={"id": "4c1fdfc2-3269-401e-8ff8-5560ab4d2900"}
# ### Comments
#
# Following on from Pythons visual design and __readable philosophy__, Python allows the ability to __add comments__ to a Python script. Comments are started with `#` character and are __ignored__ when the code is complied and interpreted. Therefore comments are a useful way to:
#
# - Help yourself read through what your code does.
# - Help other programmers who may read your code understand what the code does.
#
# The __best Python programs__ have extensive comments that allow your code to be __as readable as possible__.

# + [markdown] nbpresent={"id": "7f5f5a7f-f9a4-41ca-82c6-194b5d1a64e3"}
# This code will be executed

# + nbpresent={"id": "10ea1d87-dd06-49bf-880f-7c8ecfec73d3"}
print(5+10)

# + [markdown] nbpresent={"id": "6e80d15e-24d3-4f31-8ca3-7d3d156e4aa0"}
# However this code does not execute anything as the `#` starts the comment.

# + nbpresent={"id": "6dd93d13-bfea-494c-8e24-cb148c3b407a"}
#print(5+10)

# + [markdown] nbpresent={"id": "451784cd-6b88-4769-833f-be8552e03b26"}
# Comments are single line only, and start straight after the `#` symbol

# + nbpresent={"id": "7f694eaa-ecfd-4d65-8b19-0fa18bf9880b"}
print(7) # This is a comment still.
#print(90) will have no effect.
print(4) # This is another comment.

# + [markdown] nbpresent={"id": "fe41816f-1ca9-440f-aca5-34e323c7c834"}
# ### Variables
#
# Variables are fundamental to any programming language and Python is no exception to this.
#
# Variables are where information is __stored or referenced__ to using the assignment operator `=`.
#
# For instance compared to the above example, the sum of `5+10` is saved in the variable `x` which is then referred to when the `print(x)` function is called.

# + nbpresent={"id": "8b13bddd-5732-460f-a692-3f8d5aa70cde"}
x = 5+10
print(x)

# + [markdown] nbpresent={"id": "aaf81a07-c228-4b52-b826-33b5fa3ae198"}
# A variable can be assigned with any valid data type (mentioned below) even full objects.
#
# Since Python code is executed line-by-line, re-assigning a variable overwrites the previous assignment. 
#
# For instance:

# + nbpresent={"id": "0b1d6dbf-c717-4467-b11b-ad05e634c3a6"}
x = 5               # 'x' is assigned the value of 5 (int)
x = "Hello, World"  # 'x' is reassigned with the string 'Hello, World' (str)
print(x)

# + [markdown] nbpresent={"id": "7c5772eb-3437-4d79-b4a7-ad168540fcfa"}
# Variables can be named with any __legal__ name.

# + nbpresent={"id": "91573f8d-b6fd-43bb-9389-1040bc626d02"}
my_very_first_Variable = 89 - 46
print(my_very_first_Variable)

# + [markdown] nbpresent={"id": "9accae39-60bb-46af-ba93-51da0884b246"}
# Variable names __cannot start with a number__ or contain __spaces__ or __dashes__.   
# Variable names can only contain __alphanumeric characters__ and `_`'s (i.e. A-Z, 0-9, \_)  

# + nbpresent={"id": "f868845b-5263-4ee5-8255-14902c810ff8"}
#Legal variable names:
my_var = 56
myVar = "hello"
_my_var_ = "this is a variable"
my_var2 = "another variable"
MYVAR = 67

#Illegal variable names:
2nd_variable = "This starts with a number"
my variable = "This contains a space"
my-dashed-variable = "This contains dashes"
my.dashed.variable = "This contains '.' which is not alphanumeric or _"

# + [markdown] nbpresent={"id": "37cb541c-dd8c-4adf-9c7e-d4771a34151e"}
# Variable names __are case sensitive__.

# + nbpresent={"id": "a1d213ea-084c-4419-8223-4ec1b635d7d7"}
my_var = 56 # Assigning 'my_var' to the value 56
MY_VAR = 65 # Assigning 'MY_VAR' to the value 65

my_var == MY_VAR # Checking whether these are the same variable (True if same, False if not)

# + nbpresent={"id": "c000ea40-51b4-41c4-9a3e-bd5ad56dc016"}
my_var == my_var

# + nbpresent={"id": "1892d406-fe28-42fc-883b-218029514240"}
my_var

# + nbpresent={"id": "2c9b25bd-455b-4b4e-9b0f-8858481ea3ef"}
MY_VAR
# -

# Variables can also be __assigned together__ on one line, for instance:

x = y = 56
print(x, y)

# Again, since variables are separate objects, changing one does not change the other.

x+5
y

# Separate values can also be assigned on one line by separation with a comma.

v1, v2 = 35, "String"
print(v1, v2)

# + [markdown] nbpresent={"id": "608047c5-232e-4dbf-8222-3b9076d8f99c"}
# Variables are the __basis__ of any Python program. 
#
# Even though any _legal_ variable name can be used, there are some __naming conventions__ behind variable names.
#
# - A variable name should be descriptive enough without being too lengthy i.e. `Age` instead of `A` or `AgeOfPersonBornIn1990` for example.
# - Variable names are commonly written in underscore separations or upper Camel Case i.e. `date_of_birth` or `dateOfBirth`. 
#
# Whilst there is __no set way to name variables__, these styles will make it __easier for people__ to understand your code.

# + [markdown] nbpresent={"id": "cde10507-f2db-4e14-be74-671091af9b70"}
# ### Operators
#
# Operators in Python are special characters that perform operations on __two or more operands__. The operands are typically __any object__ including __core objects__ (`str`, `int`, `float`) but also __variables__ (`x`, `my_var`, `ageOfDan`).
#
# There are a couple of different operator groups namely:
# - Arithmetic operators
# - Assignment operators
# - Comparison operators
# - Logical operators
# - Identity operators
# - Membership operators
# - Bitwise operators

# + [markdown] nbpresent={"id": "aa850a51-b6b0-474e-9b3f-ab655369d77b"}
# #### Arithmetic operators
#
# Arithmetic operators act on operands depending on the object type. 
#
# A list of valid arithmetic operators is shown below for:

# + nbpresent={"id": "03677f3b-ca56-45be-aefd-4c687c5b8bcd"}
x = 25
y = 4

# + [markdown] nbpresent={"id": "580f2b3a-e4ef-4442-8c19-dd4568825e26"}
# **Operator**|**Name**|**Example** | **Output (from above)**
# :-----:|:-----:|:-----:| :-----:|
# `+`|Addition|`x + y`| 29
# `-`|Subtraction|`x - y` | 21
# `*`|Multiplication|`x * y`| 100
# `/`|Division|`x / y`| 6.25
# `%`|Modulus|`x % y`| 1
# `**`|Exponentiation|`x ** y`| 390625
# `//`|Floor division|`x // y`| 6

# + [markdown] nbpresent={"id": "3b7f43df-7dc0-4060-a37a-409b78775558"}
# These operators are very self explanatory, however the `%` or __Modulus operator__ divides the first operand by the second operand and __returns the remainder__ left over, hence `25/4 = 6 remainder 1` therefore `1` is returned.
#
# The Floor division operator returns the __integer division__ of the first operand by the second operand, therefore with the above values returns `6`.

# + [markdown] nbpresent={"id": "0fd066fa-a0d0-48d9-9804-2d8be3d41829"}
# #### Assignment operators
#
# Assignment operators work when assigning a variable with a value (or another variable).
#
# A list of assignment operators and how they work is shown below:

# + [markdown] nbpresent={"id": "3292ce63-2c5e-4dfe-9fde-c82fb5e59cb7"}
# **Operator**|**Example**|**Same As**
# :-----:|:-----:|:---------:
# `=`|x = 5|x = 5
# `+=`|x += 3|x = x + 3
# `-=`|x -= 3|x = x - 3
# `*=`|x *= 3|x = x * 3
# `/=`|x /= 3|x = x / 3
# `%=`|x %= 3|x = x % 3
# `//=`|x //= 3|x = x // 3
# `**=`|x **= 3|x = x ** 3
# `&=`|x &= 3|x = x & 3
# `\|=`|x \|= 3|x = x \| 3
# `^=`|x ^= 3|x = x ^ 3
# `>>=`|x >>= 3|x = x >> 3
# `<<=`|x <<= 3|x = x << 3

# + [markdown] nbpresent={"id": "610dd4cf-d23d-4e64-96a6-e5647b256650"}
# The familiar arithmetic operators that were shown above can be seen before the assignment operator here. Some other 'bitwise' operators (`&`, `|`, `^`, `>>` and `<<` are also shown on the prefix of the assignment operator. 

# + [markdown] nbpresent={"id": "d4d86e54-a91f-4cec-8ed6-5b3f3e1c8138"}
# #### Bitwise operators
#
# Bitwise operators are less commonly used but for completeness I shall list them here:
#
# |**Operator**|**Name**|**Description**|
# |:-----:|:-----:|:-----|
# |`&` |AND|Sets each bit to 1 if both bits are 1|
# |&#124; |OR|Sets each bit to 1 if one of two bits is 1|
# |`^`|XOR|Sets each bit to 1 if only one of two bits is 1|
# |`~` |NOT|Inverts all the bits|
# |`<<`|Zero fill left shift|Shift left by pushing zeros in from the right and let the leftmost bits fall off|
# |`>>`|Signed right shift|Shift right by pushing copies of the leftmost bit in from the left, and let the rightmost bits fall off|

# + [markdown] nbpresent={"id": "f709f43a-ede0-4e46-8c43-8715e39fb1c8"}
# Bitwise operators involve operating on numbers/values in binary and therefore is slightly less commonly used unless used in boolean expressions (these will be covered at the end of this part). 

# + [markdown] nbpresent={"id": "6ee58071-4f94-4326-ace2-3f914f14bb01"}
# #### Comparison operators
#
# Comparison operators are used to __compare__ the values of __two operands__. They are shown here:
#
# **Operator**|**Name**|**Example**
# :-----:|:-----:|:-----:
# `==`|Equal|x == y
# `!=`|Not equal|x != y
# `>`|Greater than|x > y
# `<`|Less than|x < y
# `>=`|Greater than or equal to|x >= y
# `<=`|Less than or equal to|x <= y
#
# These operators return __one of the boolean values__ `True` or `False` depending on the __evaluation__ of the statement and are heavily used in `if` __conditional statements__ and `while` __loops__.

# + [markdown] nbpresent={"id": "7bb1c31e-2ce1-4cd6-88b3-eef2909948d1"}
# #### Logical operators
#
# Logical operators are typically linked together with comparison or membership operators to compare multiple conditions or to invert the outcome of a logical expression. 
#
# The logical operators are:
#
# **Operator**|**Description**|**Example**
# :-----:|:-----:|:-----:
# `and` |Returns True if both statements are true|x < 5 and  x < 10
# `or`|Returns True if one of the statements is true|x < 5 or x < 4
# `not`|Reverse the result, returns False if the result is true|not(x < 5 and x < 10)
#
# The result of using logical operators is also either `True` or `False` depending on the evaluation of the statement. You may recognise these words as Python keywords listed above!

# + [markdown] nbpresent={"id": "d2b8e068-25a6-4d0f-ba6a-2a4cf00e6359"}
# #### Identity operators
#
# Identity operators are similar to comparison operators however they compare the actual object of the variable rather than simple the value. 
#
# These can be used to check if two variables/objects are a copy of each other (same values different object) or a reference to the exact same object in your computers memory. 
#
# The identity operators are also Python keywords shown by:
#
# **Operator**|**Description**|**Example**
# :-----:|:-----:|:-----:
# `is` |Returns True if both variables are the same object|x is y
# `is not`|Returns True if both variables are not the same object|x is not y
#
#
# This can be shown below from the two lists `x` and `y` (we will cover lists in a following part).

# + nbpresent={"id": "b58de391-1d73-42f3-8cc0-7ee2f0eaab92"}
x = [5, 10] # A list with values 5 and 10.
y = [5, 10] # This is a separate list with the same values.
x == y      # Therefore 'x' has the same values as 'y'.

# + nbpresent={"id": "eab86766-fb01-411a-ab45-ec368146e017"}
x is y     # The two lists are separate objects, I can change 'x' without changing 'y'

# + [markdown] nbpresent={"id": "fda7b631-197f-4dc2-907e-7dee5a51054b"}
# #### Membership operators
#
# Finally membership operators are used to evaluate if a specific container contains an object. The membership operators are:
#
# **Operator**|**Description**|**Example**
# :-----:|:-----:|:-----:
# `in` |Returns True if a sequence with the specified value is present in the object|x in y
# `not` in|Returns True if a sequence with the specified value is not present in the object|x not in y
#
# This can also be shown in a simple list example.

# + nbpresent={"id": "50dc2a9f-cdb7-44dd-a72f-04393db14553"}
list_of_numbers = [5, 45, 23, 767, 2]
45 in list_of_numbers

# + nbpresent={"id": "88229155-0ed5-4c14-ac87-1865f5c03f18"}
63 in list_of_numbers

# + [markdown] nbpresent={"id": "14ce5ca2-40ed-43a9-bcb1-5948b44a5ffe"}
# As well as built-in operators, Python comes loaded with built-in functions that can also provide logical and comparative analysis about objects. 

# + [markdown] nbpresent={"id": "0593d10b-12c4-4b26-af32-a033c58b3ddb"}
# ### Built-in functions
#
# Python contains many built-in functions upon installation. A list of the built-in functions can be shown alphabetically below:
#
# | __Function__ |	__Description__ |
# |:--|:--|
# | `abs()` |	Returns the absolute value of a number |
# | `all()`	| Returns True if all items in an iterable object are true |
# | `any()`	| Returns True if any item in an iterable object is true |
# | `ascii()` |	Returns a readable version of an object. Replaces none-ascii characters with escape character |
# | `bin()`	| Returns the binary version of a number |
# | `bool()` |	Returns the boolean value of the specified object |
# | `bytearray()` |	Returns an array of bytes |
# | `bytes()` |	Returns a bytes object |
# | `callable()` |	Returns True if the specified object is callable, otherwise False |
# | `chr()` |	Returns a character from the specified Unicode code. |
# | `classmethod()` |	Converts a method into a class method |
# | `compile()` |	Returns the specified source as an object, ready to be executed |
# | `complex()`	| Returns a complex number |
# | `delattr()` |	Deletes the specified attribute (property or method) from the specified object |
# | `dict()` |	Returns a dictionary (Array) |
# | `dir()` |	Returns a list of the specified object's properties and methods |
# | `divmod()` |	Returns the quotient and the remainder when argument1 is divided by argument2 |
# | `enumerate()` |	Takes a collection (e.g. a tuple) and returns it as an enumerate object |
# | `eval()` |	Evaluates and executes an expression |
# | `exec()` |	Executes the specified code (or object) |
# | `filter()` |	Use a filter function to exclude items in an iterable object |
# | `float()` |	Returns a floating point number |
# | `format()` |	Formats a specified value |
# | `frozenset()` |	Returns a frozenset object |
# | `getattr()`	| Returns the value of the specified attribute (property or method) |
# | `globals()` |	Returns the current global symbol table as a dictionary |
# | `hasattr()` |	Returns True if the specified object has the specified attribute (property/method) |
# | `hash()` |	Returns the hash value of a specified object |
# | `help()` |	Executes the built-in help system |
# | `hex()`	| Converts a number into a hexadecimal value |
# | `id()` |	Returns the id of an object |
# | `input()` |	Allowing user input |
# | `int()` |	Returns an integer number |
# | `isinstance()` |	Returns True if a specified object is an instance of a specified object |
# | `issubclass()` |	Returns True if a specified class is a subclass of a specified object |
# | `iter()` |	Returns an iterator object |
# | `len()`	| Returns the length of an object |
# | `list()` |	Returns a list |
# | `locals()` |	Returns an updated dictionary of the current local symbol table |
# | `map()` |	Returns the specified iterator with the specified function applied to each item |
# | `max()` |	Returns the largest item in an iterable |
# | `memoryview()` |	Returns a memory view object |
# | `min()` |	Returns the smallest item in an iterable |
# | `next()` |	Returns the next item in an iterable |
# | `object()` |	Returns a new object |
# | `oct()` |	Converts a number into an octal |
# | `open()` |	Opens a file and returns a file object |
# | `ord()`	| Convert an integer representing the Unicode of the specified character |
# | `pow()` |	Returns the value of x to the power of y |
# | `print()` |	Prints to the standard output device |
# | `property()` |	Gets, sets, deletes a property |
# | `range()` |	Returns a sequence of numbers, starting from 0 and increments by 1 (by default) |
# | `repr()` |	Returns a readable version of an object |
# | `reversed()` |	Returns a reversed iterator |
# | `round()` |	Rounds a numbers |
# | `set()` |	Returns a new set object |
# | `setattr()` |	Sets an attribute (property/method) of an object |
# | `slice()` |	Returns a slice object |
# | `sorted()` |	Returns a sorted list |
# | `@staticmethod()` |	Converts a method into a static method |
# | `str()`	| Returns a string object |
# | `sum()`	| Sums the items of an iterator |
# | `super()` | 	Returns an object that represents the parent class |
# | `tuple()` | 	Returns a tuple |
# | `type()` |	Returns the type of an object |
# | `vars()` |	Returns the `__dict__` property of an object |
# | `zip()`	| Returns an iterator, from two or more iterators |
#
# This again may not mean much to you right now, but the key to remember is that these are __functions__ that act on an __argument (placed inside the parentheses)__ and __produce a given outcome/action__. Most functions require at least one argument but some require multiple arguments.  
#
# For instance:
# - The `print()` function sends the argument to __print__ into the terminal/__Python console__.
# - The `input()` function allows the user to __input a string__ to the program.
# - The `min()` and `max()` functions return the __minimum__ and __maximum__ value of a given data container.
# - The `sum()` function __sums__ the values in a data container.
# - The `abs()` function returns the __absolute value__ of a numeric argument.
# - The `len()` function returns the __number of items/objects__ in a data container.
# - The `type()` function returns the __object type__ of a particular variable passed.
#
# A function executes a given action on an __argument__ __'passed'__ into the function. Writing code to tell a function to do an action is called executing a __function 'call'__. Therefore the `callable()` built-in function returns whether an object can be __called like a function__.
# -

# Below is an example of the `input()` function, execute the cell to have it ask you to enter a string. It then saves the string into the variable `user_input`. When `user_input` is printed, it returns the inputted string (remember to execute the top cell and then execute the second cell!

user_input = input("Please enter a string... ")

user_input

# Arguments in any function must be separated with a comma.
#
# For example, to print two separate variables `v1` and `v2` you must type `print(v1, v2)`. Or alternatively use separate `print()` function calls on each variable.

# + [markdown] nbpresent={"id": "7a814a95-9d4a-4def-8498-4ec07ea6367a"}
# ## Part 2.2: Data/Object types

# + [markdown] nbpresent={"id": "176f9f4d-a095-47af-83fb-333e45745979"}
# ### _What are data types?_
#
# A __data type or object__ is essentially what tells the Python interpreter how to interpret a line of code. The most common __core object types__ used in Python programming are:
#
# Briefly: 
# - __String__ objects or `str`'s are strings of characters together (i.e. words and sentances) i.e. `'Hello I am a string'`.
# - __Integer__ objects or `int`'s are exactly that, whole numbers i.e. `12`.
# - __Float__ objects or `float`'s are floating point or decimal point numbers i.e. `2.0`.
# - __Complex__ objects or `complex` are complex numbers with real and imaginary parts i.e. `3+4j` (j is the imaginary number in Python).
# - __Boolean__ objects or `bool`'s have one of two single bit values namely `True` or `False` (1 or 0 respectively) used for expression comparison.
# - __NoneType__ objects or `NoneType`'s are objects with an abscence of value. The only `NoneType` object is `None`.
#
# The following section will delve deeper into these object types and what their associated properties and methods are.

# + [markdown] nbpresent={"id": "14468aa7-21ed-4c8c-a61d-b4b7e9e7a996"}
# ### _What do you mean by objects?_
#
# Every **_thing_** in Python is an object. This means that __all variables__ are considered specific objects. Data types listed above are __core__ object __types__ but new objects can be made using the `class` keyword (more on this in Part 3). 
#
# Objects are essentially **_things_** in Python (i.e. an object could be a `Person` named `Dan`). Here `Person` would be the `class` of the `Dan` object. This means that `Person` it is the __object type__ and `Dan` is the individual object. 
#
# Objects have __attributes__ (or variables) associated with them for instance `Dan` the `Person` could have an associated `height` or `eye_colour` as attributes. These are __static things__ that __belong__ to the object. 
#
# Objects also have __methods__ (or functions) that __perform actions__ on the object. For instance the `Dan` object could `perform_handstand()` or `drink_coffee()`. __Methods/functions__ are easy to spot since they __end with parentheses__. This is because the __methods are functions__ meaning the `Dan` object could `drink_coffee(3)` for drinking three mugs of coffee for example. __Methods__ indicate __doing__ actions on an object.
#
# The built-in functions listed above __typically take the object to be the argument__ and returns some kind of attribute about the object back. 
#
# The concept of object creation will be further introduced in Part 3.

# + [markdown] nbpresent={"id": "36a0b275-d085-49f2-9d8a-66523e5ac6b3"}
# ### __Strings__ 
# A string is a 'strings' of characters joined together by open and closed quotation marks. 
#
# Jupyter notebooks automatically use the `print()` function on a statement if nothing else occurs therefore executing the below cell (__Shift+Enter__) will print `'This is a string'` to the ouput code underneath.

# + nbpresent={"id": "065efe3b-51f6-4b19-b7e6-a2f40cdbca44"}
'This is a string'

# + [markdown] nbpresent={"id": "f619fac5-e784-40b0-b171-55d3097a54d4"}
# This can also be shown to be a string by __'calling'__ the `type()` built-in function and __passing__ the string as an __argument__. 
#
# `str` is the abbreviation for the string object type.

# + nbpresent={"id": "37459da0-a8dd-4c88-a05d-cf746644bd02"}
type("This is a string")

# + [markdown] nbpresent={"id": "0b8c01ed-2ed9-478e-a337-e81984d7bdd8"}
# The built-in string constructor function `str()` can also be used to generate a string from a non-string type object.

# + nbpresent={"id": "18d4c6b3-5276-4290-a6b7-4c8b88b4a4e1"}
x = 5+10
x = str(x)
x

# + nbpresent={"id": "63e14f77-b593-4b01-8b96-a555d8f6daba"}
type(x)

# + [markdown] nbpresent={"id": "e895b274-19be-4b34-b612-1012f6998e05"}
# __Either__ placing characters between quotation marks __or__ calling the `str()` constructor both create a `str` object with the variable name and associated value.
#
# Therefore similar to above where `Person` was __the object type (or class)__ and `Dan` was the __object__, `str` is the __object type (or class)__ and `x` is the __object__. 

# + [markdown] nbpresent={"id": "190f1c11-fb1a-49a7-be30-675e857ce79c"}
# The sentance below however is not a string as it is not enclosed in quotation marks, therefore will raise a `SyntaxError` when executed.

# + nbpresent={"id": "8d286282-9ca4-4cab-b9c0-8f4483aed0ea"}
This is not a string

# + [markdown] nbpresent={"id": "927d29de-f7d9-4d9a-8751-65aa85be93e2"}
# Python is flexible and can use strings contained in '' single quotation marks or "" quotation marks as shown below:

# + nbpresent={"id": "9cc1c518-0dc6-4de3-a7db-ac50f2c4d5fc"}
'This is a string 1'

# + nbpresent={"id": "2a7c01b1-34e7-4c12-bf0e-48fe7a81c5b1"}
"This is also a string"

# + [markdown] nbpresent={"id": "9e44c257-fbb7-4ad4-b577-b81b84122c32"}
# This allows strings to use dialogue in the string if need be:

# + nbpresent={"id": "081a076a-5843-4f94-bbec-715c482bd7b5"}
'"Hello!" said Dan'

# + [markdown] nbpresent={"id": "438d7868-5392-4cd9-bc64-0a8be8a95d79"}
# Numeric characters in strings have no numeric function as they are simply evaluated as the character rather than the number.  
#
# Therefore the below code will evaulate the actual expression between the two integers:

# + nbpresent={"id": "a7d34799-48ba-4c59-b7cf-a2a27961493c"}
5+10

# + [markdown] nbpresent={"id": "01679170-dd7b-4b05-a502-7a670005c0f2"}
# Whereas the below code here only shows the string representation of the expression since it is expressed simply as a string of characters.

# + nbpresent={"id": "464a65e2-1882-47d1-a6fd-8b5c23473ab3"}
'5+10'

# + [markdown] nbpresent={"id": "fb17aa6e-d57f-4c53-ba16-ac3ca7adf969"}
# Using the built-in function `len()` on a string __returns the number of characters__ in the string including the space.

# + nbpresent={"id": "ab740ee2-42bd-4ec8-a1c6-91cff3d23780"}
len("Hello, World")

# + [markdown] nbpresent={"id": "48d7b1aa-3315-474e-ae21-edfa31549c1b"}
# The built-in function `format()` can be used on the __end of a string__ to insert values into a __specific place__ in the string denoted by the __curly braces__.

# + nbpresent={"id": "ae70c18f-9c8b-495a-bc07-cb33a982c855"}
"The sum of 5 + 10 is {}".format(5+10)

# + [markdown] nbpresent={"id": "5402965d-8607-4f27-a4ba-756b27f07d58"}
# Alternatively an __'f' string__ can be used which the output is __identical__ to the above cell, and is __simply easier to read__.

# + nbpresent={"id": "df7abcc1-e014-47b9-90ca-8c257eaf0ba7"}
f"The sum of 5 + 10 is {5+10}"
# -

# Floating point numbers can be formatted inside strings to show only a certain number of decimal places by putting `{number:.nf}` where `n` is the number of decimal points, for instance:

f"This is a floating point number {2.3456789:.3f}"

# + [markdown] nbpresent={"id": "ef4a046b-bab5-4254-a80b-e77ead848ae6"}
# Strings can be formatted and written using the __escape character__ `\`. This allows for special formatting and displaying of certain characters. A table of __some__ valid escape sequences and what they do is shown below:
#
# | __Escape sequence__ | __Description__ |
# |:--|:--|
# | `\\` | Produces a singular backslash `\`|
# | `\'` | Produces a singular quotation mark `'` |
# | `\"` | Produces a singular quotation mark `"` |
# | `\n` | Produces a newline in the string |
# | `\t` | Produces a horiztonal tab in the string |

# + [markdown] nbpresent={"id": "92197621-5a56-44ce-8118-4879e7502da2"}
# The below cell shows a new-line escape sequence.

# + nbpresent={"id": "885b7d03-e942-4079-b4ff-d86f59c091b8"}
print("This is on one line \nand this is on another")

# + [markdown] nbpresent={"id": "e24bbf2e-4ad1-4289-b6c5-1510db95f12c"}
# This cell shows the tab escape sequence.

# + nbpresent={"id": "4da730c9-8feb-4523-9e8c-7cc24e2312b6"}
print("This is left aligned\tand this is a tab away")

# + nbpresent={"id": "074a9719-0cc3-4a3f-836f-7870a79ae5c4"}
print('This \' has multiple \' \" quotation marks in it.')

# + nbpresent={"id": "4039a5ab-7dea-42e8-9dd3-98fcc4fc6cfb"}
print("This has a \\ singular backslash")

# + [markdown] nbpresent={"id": "eaf805da-166a-4495-9098-bf82ab02b03f"}
# #### String methods

# + [markdown] nbpresent={"id": "737466cd-a3fd-4a9c-be00-dabf54940f21"}
# Like any __class (object type)__ strings also have their own set of __methods__ (functions). This is analagous to the  `Person` __class__ having the __method__ `perform_handstand()` that can be used by the __object__ `Dan` which is a `Person` __type__ object.
#
# Instead of `perform_handstand()`, `str` __type objects__ have a specific set of __methods__ associated with them.
#
# A list of string methods and their descriptions is given below:
#
#  __Method__ | __Description__ 
# :--|:--
# `capitalize()`|Converts the first character to upper case
# `casefold()`|Converts string into lower case
# `center()`|Returns a centered string
# `count()`|Returns the number of times a specified value occurs in a string
# `encode()`|Returns an encoded version of the string
# `endswith()`|Returns true if the string ends with the specified value
# `expandtabs()`|Sets the tab size of the string
# `find()`|Searches the string for a specified value and returns the position of where it was found
# `format()`|Formats specified values in a string
# `format\_map()`|Formats specified values in a string
# `index()`|Searches the string for a specified value and returns the position of where it was found
# `isalnum()`|Returns True if all characters in the string are alphanumeric
# `isalpha()`|Returns True if all characters in the string are in the alphabet
# `isdecimal()`|Returns True if all characters in the string are decimals
# `isdigit()`|Returns True if all characters in the string are digits
# `isidentifier()`|Returns True if the string is an identifier
# `islower()`|Returns True if all characters in the string are lower case
# `isnumeric()`|Returns True if all characters in the string are numeric
# `isprintable()`|Returns True if all characters in the string are printable
# `isspace()`|Returns True if all characters in the string are whitespaces
# `istitle()`|Returns True if the string follows the rules of a title
# `isupper()`|Returns True if all characters in the string are upper case
# `join()`|Joins the elements of an iterable to the end of the string
# `ljust()`|Returns a left justified version of the string
# `lower()`|Converts a string into lower case
# `lstrip()`|Returns a left trim version of the string
# `maketrans()`|Returns a translation table to be used in translations
# `partition()`|Returns a tuple where the string is parted into three parts
# `replace()`|Returns a string where a specified value is replaced with a specified value
# `rfind()`|Searches the string for a specified value and returns the last position of where it was found
# `rindex()`|Searches the string for a specified value and returns the last position of where it was found
# `rjust()`|Returns a right justified version of the string
# `rpartition()`|Returns a tuple where the string is parted into three parts
# `rsplit()`|Splits the string at the specified separator, and returns a list
# `rstrip()`|Returns a right trim version of the string
# `split()`|Splits the string at the specified separator, and returns a list
# `splitlines()`|Splits the string at line breaks and returns a list
# `startswith()`|Returns true if the string starts with the specified value
# `strip()`|Returns a trimmed version of the string
# `swapcase()`|Swaps cases, lower case becomes upper case and vice versa
# `title()`|Converts the first character of each word to upper case
# `translate()`|Returns a translated string
# `upper()`|Converts a string into upper case
# `zfill()`|Fills the string with a specified number of 0 values at the beginning
#
# These __string methods__ can be used on a `str` (string object) to invoke a certain action. 
#
# __Object type or class__ methods are accessed by __dot notation__ on the object. 
#
# For example `str.capitalize()` is the `capitalize()` method acting on the `str`. 
#
# For instance:

# + nbpresent={"id": "ae4dc639-b627-42f9-a584-e12b0141aa41"}
"this sentence will start with a capital".capitalize()

# + nbpresent={"id": "e063ed2e-a98b-4a2e-a776-c3550c462d6c"}
"THIS WILL BE IN LOWERCASE".lower()

# + nbpresent={"id": "4133871d-ccd9-4723-93a9-7736de93eba0"}
"this will be in uppercase".upper()

# + nbpresent={"id": "2b304b4e-cc8d-40a4-94dd-ead0d8144673"}
"This Will Be In Opposite Case".swapcase()

# + nbpresent={"id": "e49464aa-8c90-4af3-830b-bdc8b194dcb1"}
"This will split the string into separate words".split()

# + nbpresent={"id": "74bb8048-eac9-40bd-93a4-99516f777de1"}
"This will split the string on the 'i' character".split('i')

# + nbpresent={"id": "7a2201b8-b0ce-4785-9a33-181b7d508a82"}
" ".join("This will add a space between each character of the string")

# + [markdown] nbpresent={"id": "a99cb8f6-5776-4b34-939b-013957118789"}
# #### String operations

# + [markdown] nbpresent={"id": "60380446-4d5e-4153-a7c7-1ba90c6f7e5c"}
# Strings can also be used with the operators mentioned above.
#
# >**Aside**: Using operators like the `+` operator actually invoke a special `str` **method** called `__add__()` which takes a second string as an argument. When creating your own `class` you can write your own `__add__()` method to determine the action of the `+` operator on objects of that `class`. There are similar *__magic methods__* for subtract, multplication and every other operator mentioned above, however these do not need to be specifically written when writing a new `class` unless you want them to do something specific. More on this during `class`'s

# + [markdown] nbpresent={"id": "84a9813a-0665-4469-82c2-30df10699c8d"}
# Addition of `str`'s causes direct **concatenation**:

# + nbpresent={"id": "121d031f-fd98-4c76-ba59-40395363cf9b"}
s1 = 'This is a string'
s2 = 'This is another string'
s1+s2

# + [markdown] nbpresent={"id": "e5631ea4-3d45-41c9-95d8-e75cda7277a0"}
# The `+` operator can only add certain object types together based on what type they are, for instance a `str` and an `int` cannot be added together and will `Raise` a `TypeError`.

# + nbpresent={"id": "7d94d4a8-1656-41ca-9733-b511c39b8205"}
num = 6
num_string = '9'
num_string+num

# + [markdown] nbpresent={"id": "ce7f7706-b017-4ef1-99de-013239331691"}
# Notice the order of addition here and the `TypeError` message. This provides information about the `Error`. If the order was reversed a different error message would appear.

# + nbpresent={"id": "75173d89-e9ab-4e3c-a027-56d772510cbf"}
num+num_string

# + [markdown] nbpresent={"id": "649b1e28-0429-46ac-b8b2-92e7a0b98dd0"}
# `str`'s do not support a subtraction operator as it is unclear what this would mean - should it return the first string minus the characters in the second string or minus the characters if they are in the same position along the string? The answer is undefined and therefore is not used as a `str` method.

# + nbpresent={"id": "5655a872-d1b5-4a29-82a3-4c318e60e9e7"}
s1 = 'This is a string'
s2 = 'This is another string'
s1-s2

# + [markdown] nbpresent={"id": "27b97cdf-52ec-44c7-b7ba-feaf88ced052"}
# __Strings__ form a basis for all character based variable types in Python and as such are an important __object type__ to get accustomed to.

# + [markdown] nbpresent={"id": "e801af24-7045-4730-8067-d7451a9e19bb"}
# ### Numeric objects
#
# Numeric data types consist of the `int`, `float` and `complex` types. 
#
# #### Integers
#
# These types all have their __own particular uses__, for instance `int` types are usually used for things that __change in specific integer steps__ rather than actual number crunching. 
#
# `int`'s can be of unlimited length.

# + nbpresent={"id": "3b8fa65f-6a24-456d-a48e-42fdb218ef53"}
x = 3
type(x)
# -

# `int`'s can be used for integer division operators (shown above) for `%` and `//`.

# + [markdown] nbpresent={"id": "1f516535-df41-48f3-9e83-c1d7cb03fa3f"}
# #### Floats
#
# __Mathematics__ and __data analysis__ is usually used with __floating point numbers__ or `float`'s. This consists of any __decimal point numbers__, even `2.0`.

# + nbpresent={"id": "c32cc030-b655-487d-bdd2-01d7096bc14c"}
x = 3.0
type(x)

# + [markdown] nbpresent={"id": "d66fccb4-efb7-4528-adc4-aa6a384eee19"}
# Floating point numbers are used in data analysis since most data does not consist of integer numbers. 
#
# However floating points have a specific precision to them. This is because numbers are interpreted by the machine in base 2 (binary) rather than base 10. Not all decimal values can be appropriately expressed in base 2 from base 10.
#
# >To better understand this, the __fraction__ `1/3` can be represented in __base 10__ as the decimal `0.3`. Better yet, the decimals `0.33` and `0.333333333` represent `1/3` better and better. This is due to `1/3` being an __infinitely recurring decimal__ in base 10 and therefore `1/3` cannot be __exactly__ represented by base 10. 
# <br><br>Similarly to this, the fraction `1/10` or `0.1` __cannot be exactly represented__ in __base 2__ (binary). Since all computers cannot exactly determine an __infinite recurring fraction__, an __approximation__ to the fraction is given. 

# + nbpresent={"id": "5611e36c-efc8-47e5-bc65-3bae055e94be"}
# %precision 25
x = 1.0
y = 0.1
y

# + [markdown] nbpresent={"id": "df6dbe87-5b1d-46b1-8fe3-8719b43acb5e"}
# Note that `y` does not exactly equal `0.1` even if the approximation is correct to a high number of significant figures.

# + [markdown] nbpresent={"id": "71ffe15f-6182-4e94-98d1-dfadb1a28990"}
# Note that this can affect equality relations like such below:

# + nbpresent={"id": "0175ca99-3d17-44f9-9475-90cbddc5008d"}
y + y + y == x*3

# + nbpresent={"id": "58da0fa5-2eef-4c18-9505-346b61f9d79d"}
x*3 

# + nbpresent={"id": "644d20fc-8f9a-4a10-a062-7556edd80193"}
y + y + y

# + [markdown] nbpresent={"id": "ec5794ed-c292-4418-9d20-b646f8a8a68c"}
# This is a minor point but it is worth noting when __testing equality__ between `float`'s.

# + [markdown] nbpresent={"id": "a5574d5c-bae9-4a69-9f12-c48cec7d2ec7"}
# Floating points can also be represented in scientific notation
# -

x = 1.35e2
print(x)

# Floating points can also be used for the modulus and floor division division operators

# %precision 
2.0//1.5

2.0%1.5

# #### Complex

# `complex` type can be used to display complex numbers by using the `z = a + bj` notation where `Re(z) = a` and `Im(z) = b` and `j` is the imaginary number.

x = 4+5j
type(x)

# Complex numbers can also be generated using the `complex()` constructor.

z = complex(4, 7)
z

# Complex numeric types (like everything) are __objects__. Special attributes for `complex` type objects include `complex.real`, `complex.imag`. `complex` types also have methods which includes `complex.conjugate()`.

x.real

x.imag

x.conjugate()

# #### Numeric conversion
#
# Different numeric types can be converted from one another using the `int()`, `float()` and `complex()` constructors.
#
# __Constructors__ are essentially a way of generating an __object__ with the specified type.
#
# `int`'s and `float`'s can be __interconverted__ however any decimal the `float` had before will be __lost__ when it is converted into an `int`. Likewise an `int` or `float` can be **converted** into a `complex` but `complex` types **cannot be converted** back into `int` or `float`'s.

# **Remember** in the following, __y is being reassigned__ from a `float` into an `int` then back to a `float`, then a `complex` then attempting to convert back into a `float`.

y = 2.53
y = int(y)
y

y = float(y)
y

y = complex(y)
y

y = float(y)
y

# Numeric types can also be generated from strings of numeric characters using the appropriate constructors.

number_string = '564'
number_float = float(number_string)
number_float + 2.0

# Without the constructor we see an `Error` as Python does not know how to add a `str` to a `float`.

number = number_string
number + 2.0

# ### Boolean type
#
# the `bool` or Boolean __object type__ refers to one of two values either `True` or `False` corresponding simple to `1` or `0` respectively. 
#
# __Boolean__ types are usually used in conditional expressions that evaluate to either `True` or `False`.
#
# Every other data type has an associated boolean value with it depending on what it contains. This can be found using the `bool()` constructor on an __object__ or __variable__ to return its boolean state.
#
# For instance, any __number__ is regarded as `True` unless the number is `0` then it is `False`. 
#
# Empty __strings__ (i.e. `""`) are regarded as `False` whilst any length string is `True`.

numeric = 0
bool(numeric)

numeric += 5
numeric

bool(numeric)

empty_string = ""
bool(empty_string)

filled_string = "hello, world"
filled_string

bool(filled_string)

type(bool(filled_string))

# As can be seen above, `bool` types are expressions that evaluate to either `True` or `False`. 
#
# Similarly expressions using __comparison__, __logical__, __identity__ and __membership__ operators all return a `bool` type object. 
#
# For instance:

type(1 == 0)

x, y = 56, 89
boolean_expression = x < y
type(boolean_expression)

# Knowing the boolean value of __objects__ can be especially useful in conditional `if` code blocks whilst the value of __boolean expressions__ is similarly useful in executing specific __code blocks__.

# ### NoneType objects

# `None` or `NoneType` objects have one value of simply `None`. They are to denote variables with an abscence of value. 
#
# `NoneType` objects always have the __boolean__ value of `False`. 
#
# `NoneType` objects have no methods or attributes.
#
# Typically `None` is to make a reference to a variable that will later be assigned. 

x = None
type(x)

bool(None)

# `NoneType`'s cannot be operated on either.

x += 5

# Initialising the variable `x` first allows the first `print()` function to still work. Then reassigning `x` later changes it from `NoneType` to `int`.

x = None
print(x)
x = 56
print(56)

# ## Part 2.3: Statements
#
# This section will be on conditional `if` statements and `for` and `while` loops of code. 
#
# These can provide conditional structure to a simple program or allow certain code to repeat itself automatically.

# ### Conditional statements
#
# `if` statements are used to test if a condition is `True` or `False`. 
#
# These statements automatically test the `bool(<expression>)` and then will execute _some code_ in a __code block__ if the expression evaluates to `True`.
#
# #### Boolean expressions

# Boolean expresions (as mentioned above) use the __comparison__, __membership__, __identity__ and __logical operators__ to produce a `bool` object (`True` or `False`).
#
# For instance, consider the following boolean expressions:

x = 45
y = 900
x < y

# The above is the same as saying `bool(x < y)` however the `<` automatically returns a `bool` object so the `bool()` constructor is not required. 
#
# In a similar fashion, the `if` keyword automatically evaluates the `bool(<some_expression>)`.

if x < y:
    print(f"{x} is less than {y}")

# In the above cell, the __boolean expression__ `x < y` was evaluated to be `True` since `x = 45` and `y = 900` (from above) therefore Python executed the __code block__ beneath the `if` statement. 
#
# #### If statements
#
# The whole line `if x < y:` is called the `if` statement. 
#
# Notice the syntax; the use of the `:` indicates the end of the `if` statement and the indent under the statement refers to the __code block__ to be executed if the statement is evaluated to `True`. 
#
# For instance:

if x > y:
    print(f'{x} is greater than {y}')

# When executing the above code it does not `print()` anything. This is because the `if` statement was evaluated as `False`.
#
# The `bool` value can be inverted by use of the `not` keyword/logical operator.

if not x > y:
    print(f'{x} is NOT greater than {y}')

# This then executes the __code block__ as `x > y` returns `False` then `not False` returns `True`.

# If statements can be linked together using `and` and `or` keywords. 
#
# The `and` keyword causes the `bool` value of the __expression__ to be `True` __only__ if __BOTH__ expressions evaluate as `True`. 
#
# The `or` keyword causes the `bool` value of the __expression__ to be `True` if __at least one__ expression evaluates as `True`. 
#
# >__Aside__: The `and` and `or` keywords are referred to as 'short-circuit' operators as the second expression will only be evaluated if the first is `True` for `and` or if the first expression is `False` for `or`. 

if x < y and x == 45:
    print(f'{x} is less than {y} AND equal to 45')

if x < y or y < x:
    print('at least one of these expressions is True')

# Therefore logical operators can be joined together to test the condition of a variable.

# Similarly, we can also provide `if` statements based on the `bool` state of the __object__.

empty_string = ""
if not empty_string:
    empty_string += "Now not empty."
empty_string

# The above `empty_string` would evaluate as `False` therefore adding `not` in the `if` statement changes the `bool` to `True` and executes the __code block__. 
#
# In the __code block__ the `empty_string` is concatenated with a new string `"Now not empty"`. 
#
# The reassigned `empty_string` __object__ then has the _value_ `'Now not empty'`.

# #### Else keyword
#
# The `else` keyword is used to execute some code __only if__ all other conditions evaluate to `False`.
#
# For instance:

if x > y:
    print(f'{x} is > {y}')
else:
    print(f'{x} is not > {y}')

# The above `else` __code block__ only executes if the `if` statement is `False`. Therefore it would be wrong to say that `x is less than y` as the `if` statement only evaluated if `x > y`, therefore disregarding the case where `x == y`. 

# #### Elif statements
#
# The `elif` keyword stands for `else if` in Python. This is to __evaluate another condition__ if the condition in the first `if` statement evaluates to `False`.
#
# You must have an `if` first to use an `elif` and you cannot use an `elif` without using an `else` at the end.

x = 89
if x % 2 == 0:    # This tests to see if the remainder of 89/2 is 0, if so then 89 is exactly divisible by 2.
    print('x is divisible by 2')
elif x % 3 == 0:  # This tests the same thing as before but for 3 rather than 2.
    print('x is divisible by 3')
elif x % 4 == 0:  # This tests for 4...
    print('x is divisible by 4')
elif x % 5 == 0:  # This tests for 5 and so on...
    print('x is divisible by 5')
elif x % 6 == 0:
    print('x is divisible by 6')
elif x % 7 == 0:
    print('x is divisible by 7')
elif x % 8 == 0:
    print('x is divisible by 8')
elif x % 9 == 0:
    print('x is divisible by 9')
else:
    print('x is not divisible by numbers 2-9')

# This shows that `89` is a prime number as it is not divisible by any number. 
#
# However the above code used on a different value of `x` shown below:

x = 75
if x % 2 == 0:    
    print('x is divisible by 2')
elif x % 3 == 0:  
    print('x is divisible by 3')
elif x % 4 == 0:  
    print('x is divisible by 4')
elif x % 5 == 0:  
    print('x is divisible by 5')
elif x % 6 == 0:
    print('x is divisible by 6')
elif x % 7 == 0:
    print('x is divisible by 7')
elif x % 8 == 0:
    print('x is divisible by 8')
elif x % 9 == 0:
    print('x is divisible by 9')
else:
    print('x is not divisible by numbers 2-9')

# Note that the above number `75` is divisible by both `3` and `5` however Python only executed the first `elif` statement to be `True` and does not execute subsequent statements. This is something to keep in mind when using `if` statements. 
#
# If we wanted to find all the numbers below 10 that a number is divisible by we could use a `list` object and a `for` loop (these will be explained in more detailed later).

x = 66 # Number to test
numbers = [2, 3, 4, 5, 6, 7, 8, 9] # List of numbers below 10
for number in numbers:
    if x % number == 0:
        print(f'{x} is divisible by {number}')
    else:
        print(f'{x} is NOT divisible by {number}')

# Try changing the above value for `x` and re-running the cell and you will see that it runs through each number in the `list` of numbers and checks the condition in the `if` statement. If `True` the code will `print()` that `x` is divisible by the current `number`. 

# A conditional code block cannot be empty, if for some reason you want to test for a condition but do nothing afterwards, the `pass` keyword can be used. For instance:

x = 100
if x % 10 == 0:
    pass

# The above code _does_ nothing but it still executes as opposed to:

x = 100
if x % 10 == 0:

# This recieves a `SyntaxError` as Python expects an indented __code block__ beneath the `if` statement even if you don't want it to do anything.

# #### Shorthand if statements

# If your code requires only one line of code to execute if a condition is `True`, this can be written all in one line like so:

# Note the use of the `:` still to indicate the end of the expression.

x, y = 56, 78
if x > y: print(f'{x} is > {y}')
else: print(f'{x} is not > {y}')

# The whole `if` `else` conditional here can also be written in one line:

x, y = 56, 78
print(f'{x} is > {y}') if x > y else print(f'{x} is not > {y}')

# This is still valid and returns the same result as the above examples showing how this can be written in __shorthand notation__.

# __Variable assignment__ can also be linked with short-hand `if` statements:

x, y = 56, 78
z = x - y if x < y else x + y   # 'z' is defined depending on the relationship between 'x' and 'y'.
z

# #### Nested if statements

# Another `if` statement can be __nested__ inside another `if` statement if required. This is true for any __code block__. Any code block can be __nested__ with another block.

# +
x = 100
if x % 10 == 0:                              # First condition checked, if 'True' moves onto next condition.
    print(f'{x} is divisible by 10')
    if x % 5 == 0:
        print(f'{x} is divisible by 5')      # Second condition checked, if 'True' moves onto next condition, etc.
        if x % 2 == 0:
            print(f'{x} is divisible by 2')

else: print(f'{x} not divisible by 10')     
# -

# In the above cell, the __second and third__ nested `if` statements are __only__ executed if the first (and second) statement is `True` otherwise that code __does not get executed__. Try changing the value of `x` to something that is divisible by 2 but not 10. This will only print what is in the `else` clause as the first `if` condition is evaluated as `False`.

# ### Loops
#
# Loops are used when a certain piece of code is required to continue until some value is reached. 
#
# There are two main types of loops that are started using their corresponding keyword.
#
# `for` loops typically iterate across an __iterator__ object (covered in Part 3) or for a given `range()` of values until a defined end-point. I will cover `for` loops in Part 3 with __iterator__ object types.
#
# `while` loops are executed _while_ a certain condition is `True`. `while` loops can be dangerous as if the condition in the loop is not reached they can infinitely loop and therefore crash your program. 

# #### While loops
#
# `while` loops are useful when you are incrementing a variable a certain amount. 
#
# Similarly `while` loops have the `:` syntax to end the conditional statement and have an __indented code block__ to execute whilst that condition evaluates to `True`.
#
# For instance:

i = 0
while i < 10:                     # Colon to indicate end of expression.
    print(f'{i} is less than 10') # Indented code block.
    i += 1

# Note that `i` never equals 10 as at this point `i == 10` and therefore `bool(i < 10) == False` and the while loop stops.
#
# Without the line `i += 1` the condition the loop will __loop infinitely__ and may crash your computer. 

# #### Break keyword

# The `break` keyword causes a break to stop the loop running. 
#
# For instance:

i = 1
while i < 100:
    if i % 5 == 0:
        print('STOP LOOP')
        break
    print(f'{i} not divisible by 5')
    i += 1

# Even though the end condition of the `while` loop is `i < 100`, the loop can break prematurely by using the `break` keyword.

# #### Continue keyword

# The `continue` keyword is similar to the `break` keyword, however it simply does not execute any extra code and continues to the next iteration.
#
# For instance:

i = 0
while i < 50:
    i += 1
    if i % 3 != 0:
        continue
    print(f'{i} is divisible by 3')

# As you can see, if `i % 3 != 0` i.e. if the remainder of `i` divided by `3` is not equal `!=` to `0` then `continue` means that when Python reaches the `continue` word, it tries to evaluate the `while` loop again and misses the code that says `print()`.
#
# Note that if the `i += 1` line had been below the `if` statement with the `continue` word, when the `continue` word was reached, the loop would no longer update `i += 1` as it would check the `if` condition first, therefore making a continous loop. 
#
# For this reason, `for` loops can be more useful with the `continue` keyword in order to _skip_ certain items in an __iterator__ depending on a specific condition. 

# Much like the `if` statements, `while` loops can be __nested__ together.

i = 0
while i <= 3:
    j = 0
    while j <= 3:
        print(f'i: {i}, j: {j}')
        j += 1
    i += 1

# As you can see from the output here, the first `while` loop executes which then executes the second `while` loop until `j > 3` at which point the code in the first `while` loop block under the second `while` loop executes. This has the effect of producing the numbers `i = 0` for `j = 0->5` until moving onto `i = 1` and so on. 

# ### Summary
#
# Now that you are equipped with knowing the basics of what Python can do, I encourage playing around with some of the `if` statements and with different __object types__ as seeing what their __methods__ and __attributes__ are. 
#
# In Part 3, I will cover __iterators__ such as `list`, `tuple` and `dict` objects and how they can be cycled through using `for` and `while` loops. 

StarcoderdataJupyterScriptsDedupFiltered