#Declaring variables
a = 2 
print(a)
print(type(a)) #This is an integer 

#True and False are special keywords in python
boolean = True
print(boolean)
print(type(boolean))

#Here 7e-2 is 7x10^-2 and the 1j actually is the square root of -1
b = 7e-2 + (8e-2)*1j
print(b)
print(type(b)) # This is a complex number

#We will now try to re initiallize variable a and it will change its class
a = 7.2
print(a)
print(type(a)) #this is a float

string = "a word"
print(string)
print(type(string))

#Type conversions can be done too
a_s = str(a)
bool3 = bool(a)
print(a_s)
print(type(a_s))
print(bool3)
print(type(bool3))

#we also have lists, dictionaries and tuples
list1 = [1,2,3,4,5,6,"word"] # A list. Same as an array and is dynamic in length
dict1 = {1: 3, 'k':'f'} #A dictionary. This maps the first one as index to the second term as value
tuple1 = (1,2,4,5,0,-1,dict1) #A tuple. This is a collection of objects which get reordered are cannot be changed once assigned (immutable)
print(list1)
print(type(list1))
print(dict1)
print(type(dict1))
print(tuple1)
print(type(tuple1))

print(list1[-1])#Prints last index
print(dict1['k'])#Prints the mapped value to 'k'

#Using is
var1 = 78
tuple2 = (1,2,4,5,0,-1,dict1)
print(var1 is 78)#This will be true
print(tuple1 is tuple2)#This will be false since tuples,lists and dicts essentially are addresses
                       #Hence even when they have same value, is requires also same addresses
print(tuple1 == tuple2)#This shows true since == checks equality in values
print(tuple1[-1] is tuple1[-1])#This is true since these both point to the same variable

var2 = 1
print(var2 in list1)#Shows true since var2 is infact in this list

bool1 = True
bool2 = False
print(bool1 and bool2)
print(bool1 or bool2)
print(not bool2)

if bool1:
    print('bool1 is true')#This one is executed as per our declarations 
elif not bool2:
    print('bool2 is false and so is bool1')#This one isnt reached
else:
    print('bool1 is false but bool2 is true')#This one isnt reached

for i in list1:
    print(i)

for i in range(10):#iterates over numbers 0 to 9 both included
    print(i)
print("  ")
for i in range(1,10):#iterates over numbers 1 to 9 both included
    print(i)

for k,d in dict1.items():
    print(k,d)

g = 100
while(g > 0):
    g = g//2#This returns the integer division of g
    print(g)

def myfunc(a):#takes an input of a
    return a*2 #returns the double of a

print(myfunc(2))#Calls function giving 2 as input
print(myfunc(3.4 + 4j))
print(myfunc(list1))

def myfunc2(*args):# we use *args when we do not know how many arguments will be sent
    s = 0
    for i in args:
        s = s + i
    return s
print(myfunc2(34))
print(myfunc2(34,35,36))

def myfunc3(**kwargs):#we use **kwargs when we are to receive unkown number of keyword arguments
                      #A keyword argument is where you provide a name to the variable as you pass it into the function.
    for i,j in kwargs.items():
        print(i,j)
myfunc3(x1 = 1, x2 = "abcd", x3 = list1)

add = lambda a,b: a+b #A lamda function takes arguments and returns only one value

print(add(list1,list1)) #essentially adds the list to itself

import numpy as np #imports the package numpy and we have renamed it to np so our code looks cleaner
print(np.pi)#Good old pi
arr = np.array([[1,3],[4,2]])#Essentially an array and the argument given
print(arr)
print(arr.shape)#this gives the shape of the arr

arr2 = np.array([[0.3,8j],[9j,0]])
arr3 = np.matmul(arr2,arr)#this will give the matrix multiplication of arr2*arr
print(arr3)