<jupyter_start><jupyter_text>Introduction  **What?** Coding interview cheat-sheet  Built-In Data Structures  -  Python also has several built-in compound types| Type Name | Example                   |Description                            ||-----------|---------------------------|---------------------------------------|| `list`  | ``[1, 2, 3]``             | Ordered collection                    || ``tuple`` | ``(1, 2, 3)``             | Immutable ordered collection          || ``dict``  | ``{'a':1, 'b':2, 'c':3}`` | Unordered (key,value) mapping         || ``set``   | ``{1, 2, 3}``             | Unordered collection of unique values |<jupyter_code># Python data structures can be created using formal name
formal_list = list() 
formal_dict = dict() 
formal_tuple = tuple()
# Python data structures can be created using literal syntax
literal_list = [] 
literal_dict = {} 
literal_tuple = ()<jupyter_output><empty_output><jupyter_text>Numbers manipulation  **Division reminder**<jupyter_code>print(10%7)
print(10/7)
print(3/7)<jupyter_output>3
1.4285714285714286
0.42857142857142855<jupyter_text>Flow control statement  **while**<jupyter_code>i = 0
while i < 10:
    print(i, end=' ')
    i += 1<jupyter_output>0 1 2 3 4 5 6 7 8 9<jupyter_text>**continue** - The `continue` statement continues with the next iteration of the loop<jupyter_code>"""
Find odd number given a range
An odd number is a number divisable by 2
"""

for num in range(2,10):
    # if the remainder of n / 2 is 0, skip the rest of the loop
    if num % 2 == 0:
        print("Found an even number", num)
        continue
    print("Not even number", num)
"""
What happens if you ommit continue?
"""
for num in range(2,10):
    # if the remainder of n / 2 is 0, skip the rest of the loop
    if num % 2 == 0:
        print("Found an even number", num)
        # If you ommit it, it'll change behaviour! Not what you wanted!
        #continue
    print("Not even number", num)
"""
What happens if you use pass instead of continue!
You are getting a different behaviour.
"""
for num in range(2,10):
    # if the remainder of n / 2 is 0, skip the rest of the loop
    if num % 2 == 0:
        print("Found an even number", num)
        pass
    print("Not even number", num)
"""
to recover the proper behaviour you need to add an else clause.
The bottom line? Continue seems to be more convenient
"""
for n in range(2,10):
    # if the remainder of n / 2 is 0, skip the rest of the loop
    if n % 2 == 0:
        print("Found an even number", n)        
    else:        
        print("Not even number", n)<jupyter_output>Found an even number 2
Not even number 3
Found an even number 4
Not even number 5
Found an even number 6
Not even number 7
Found an even number 8
Not even number 9<jupyter_text>**break** - The `break` statement breaks-out of the loop entirely- The `continue` statement **skips the remainder of the current loop**, and goes to the next iteration<jupyter_code>for i in range(5):
    print("Outermost loop", i)
    for j in range(5):
        print("    Innermost loop", j)
        if j == 3:            
            break<jupyter_output>Outermost loop 0
    Innermost loop 0
    Innermost loop 1
    Innermost loop 2
    Innermost loop 3
Outermost loop 1
    Innermost loop 0
    Innermost loop 1
    Innermost loop 2
    Innermost loop 3
Outermost loop 2
    Innermost loop 0
    Innermost loop 1
    Innermost loop 2
    Innermost loop 3
Outermost loop 3
    Innermost loop 0
    Innermost loop 1
    Innermost loop 2
    Innermost loop 3
Outermost loop 4
    Innermost loop 0
    Innermost loop 1
    Innermost loop 2
    Innermost loop 3<jupyter_text>**for-else with a break**<jupyter_code>for i in range(5):
    print("Outermost loop", i)
    for j in range(5):
        print("    Innermost loop", j)
        if j == 6:            
            break
    else:
        """
        Essentialy if the break is NOT hit by the end of the main loop.
        We'll enter this part.
        """
        print("!")<jupyter_output>Outermost loop 0
    Innermost loop 0
    Innermost loop 1
    Innermost loop 2
    Innermost loop 3
    Innermost loop 4
!
Outermost loop 1
    Innermost loop 0
    Innermost loop 1
    Innermost loop 2
    Innermost loop 3
    Innermost loop 4
!
Outermost loop 2
    Innermost loop 0
    Innermost loop 1
    Innermost loop 2
    Innermost loop 3
    Innermost loop 4
!
Outermost loop 3
    Innermost loop 0
    Innermost loop 1
    Innermost loop 2
    Innermost loop 3
    Innermost loop 4
!
Outermost loop 4
    Innermost loop 0
    Innermost loop 1
    Innermost loop 2
    Innermost loop 3
    Innermost loop 4
!<jupyter_text>List/array manipulation  **Get rid of duplicates**<jupyter_code>import numpy as np
A = [6, 5, 4, 4] 

# Get rid of duplicates but maintain order
dummy = [value for index, value in enumerate(A) if value not in A[:index]]
print(dummy)

dummy = set(A)
print(list(dummy))<jupyter_output>[6, 5, 4]
[4, 5, 6]<jupyter_text>**Check where a condition is respected**<jupyter_code>A = [6, 5,7,8,9,11]
print("Are they all greater than 10?", all(np.array(A) >10))
print("Is there at least one greater than 10?", any(np.array(A) >10))<jupyter_output>Are they all greater than 10? False
Is there at least one greater than 10? True<jupyter_text>**get frequency**<jupyter_code>"""
Find the Most Frequently Items in a Sequence Using Counter
Counter is a dictionary
"""

from collections import Counter

words = [
   'look', 'into', 'my', 'eyes', 'look', 'into', 'my', 'eyes',
   'the', 'eyes', 'the', 'eyes', 'the', 'eyes', 'not', 'around', 'the',
   'eyes', "don't", 'look', 'around', 'the', 'eyes', 'look', 'into',
   'my', 'eyes', "you're", 'under'
]
word_counts = Counter(words)
top_three = word_counts.most_common(3)
print(top_three)
morewords = ['why', 'are', 'you', 'not', 'looking', 'in', 'my', 'eyes']
for word in morewords:
    word_counts[word] += 1

word_counts['eyes']
# we can also perform math operations between them
a = Counter(words)
b = Counter(morewords)
print(a + b)
# It works with numbers as wells
a = [1,2,2,3]
num_counts = Counter(a)
num_counts<jupyter_output><empty_output><jupyter_text>**list comprehension**<jupyter_code># Simple if condition
[val for val in range(20) if val % 3 > 0]
# Simple if and else conditions
[val if val % 2 else -val for val in range(20) if val % 3]<jupyter_output><empty_output><jupyter_text>Class  **What is self?** - `self` is the instance itself!- When we say `withdraw(self, amount)`, we're saying: "here's how you  withdraw money from a Customer object (called `self`) and a money figure (called amount)."- Put in another way `customerA.withdraw(10)` is totally equivalent to `Customer.withdraw(customerA, 10)`.- But how can pass `customerA` is there is not `customerA` as yet? This is where `__init__` comes in.- Python allows us to extend the `self` pattern also to objects that are being constructed.- **This is the reason why** when we call `__init__` we initialise object by saying `self.name = name`. **@classmethod** - `@classmethod` is bound to a class rather than an object.- Class methods can be called by both class and object. - These methods can be called with a class **or** with an object.<jupyter_code>from datetime import date
  
class Person:
    def __init__(self, name, age):
        """
        This will create the object by name and age
        """
        self.name = name
        self.age = age
  
    """
    This will create the object by name and year
    """
    @classmethod
    def fromBirthYear(cls, name, year):
        return cls(name, date.today().year - year)
  
    def display(self):
        print("Name : ", self.name, "Age : ", self.age)
person = Person('mayank', 21)
person.display()
person1 = Person.fromBirthYear('mayank', 2000)
person1.display()
# called by the class
person1 = Person('just something', 1000)
# called by the object
person2 = person1.fromBirthYear('mayank', 2000)
person2.display()<jupyter_output>Name :  mayank Age :  21<jupyter_text>**@property** - The Pythonic way to introduce object/instance attributes is to make them public, and not introduce getters and setters to retrieve or change them.- Give access to the value like it is an attribute **instead** of a method- They are often used to **validate** the value of an attribute. - You can define three methods for a property:    - A `getter` - to access the value of the attribute.    - A `setter` - to set the value of the attribute.    - A `deleter` - to delete the instance attribute. - You don't necessarily have to define all three methods for every property.<jupyter_code>class House:    
    """House
    
    We'd like to create a class where it only
    attribute is its price. We'd like to enforce a check on
    this value: no negative value area allowed. So the user
    we'll never misuse them.
    """

    def __init__(self, price):        
        """
        Please note that there is un underscored in 
        front of price!
        """
        self._price = price

    # This is essentially the getter method
    @property
    def price(self):
        print("getter")
        return self._price

    @price.setter
    def price(self, new_price):
        print("setter")
        if new_price > 0 and isinstance(new_price, float):
            self._price = new_price
        else:
            print("Please enter a valid price: float and >0.0")

    @price.deleter
    def price(self):
        print("deleter")
        del self._price
house = House(10)
house.price
house.price = 15.0
house.price = -15.0
del house.price
house.price<jupyter_output>getter<jupyter_text>**@staticmethod** - There is a class methods that do not have access to `self.` This is called a **static method**.- Static methods like class attributes do not need a class instance (object) to be called.- On a more intuitive side `@staticmethod` describe something which belong to the class and not the object. So a car has always the same function regardless of the fact that is a Toyota or a BMW!<jupyter_code>class Car:
    
    # This is a class attribute
    wheels = 4
    
    def __init__(self, make, model):
        self.make = make
        self.model = model
        
    @staticmethod
    def purpouses():
        # If you try to call self.make it would throw you
        # an error because it does not have access to self!
        #print(self.make)
        print("It is used to go around!")
Car.wheels
Car.purpouses()
car1 = Car("BMW", "318is")
car1.make
car1.model
car1.purpouses()<jupyter_output>It is used to go around!<jupyter_text>**_ _call_ _ vs. _ _init_ _** - `__call__` is a built-in method which enables to write classes where the instances behave like functions and can be called like a function.- In practice: `object()` is shorthand for `object.__call__()`- `__init__()` is properly defined as Class Constructor which builds an instance of a class, whereas `__call__` makes such a instance callable as a function and therefore can be modifiable.- Technically `__init__` is called once by `__new__` when object is created, so that it can be initialised- But there are many scenarios where you might want to redefine your object, say you are done with your object, and may find a need for a new object. With `__call__` you can redefine the same object as if it were new.<jupyter_code>class Product():
    def __init__(self):
        print("Instance created")
    
    # Defining __call__ method
    def __call__(self, a, b):
        print("Instance is called via special method __call__")
        print(a*b)
# This two are equivalent
p = Product()
p.__init__()
# p is being call like if p was a function
p(2,3)
# this is another equivalent way of doing it
p.__call__(2,3)<jupyter_output>Instance is called via special method __call__
6
Instance is called via special method __call__
6<jupyter_text>**_ _repr_ _ and _ _str_ _** - They both represent the object but the `__repr__` method tells you more. So much more that you can also reconstruct the object based on that information. Also Python interpreter sessions use `__repr__` to inspect objects. - `__str__` should have a readable output.<jupyter_code>import numpy as np

class Tensor(object):
    
    def __init__(self, data):
        """__init__ method
        Given a list, tunrs it into an array
        """        
        self.data = np.array(data)
    
    def __add__(self, other):
        """__add__ method        
        Add two tensor together
        """
        return Tensor(self.data + other.data)
    
    def __repr__(self):
        print("calling __repr__")
        """__repr__ method
        Returns the object representation in string format.
        Retunrs and official string representation of the object,
        which can be ised to construt the object again.
        """
        return str(self.data.__repr__())
    
    def __str__(self):
        print("calling __str__")
        """
        Returns the string representation of the object. This method is called
        when print() or str() function is invoked on an object. Retunrs a 
        human-redeable string format.
        """
        return str(self.data.__str__())
x = Tensor([1,2,3,4,5])
x
repr(x)
print(x)
str(x)<jupyter_output>calling __str__<jupyter_text>**Abstract class** - A class is called an **abstract class** if it contains some abstract methods.- An **abstract method** is a method that is declared, but contains no implementation. - Abstract classes may not be instantiated, and its abstract methods must be implemented by its subclasses.<jupyter_code>import abc
class Shape(metaclass = abc.ABCMeta):
    @abc.abstractmethod
    def area(self):
        """
        A method being declared but not implemented.
        This will be implemented in the subclass
        or derived class.
        """
        pass
class Rectangle(Shape):
    def __init__(self, x,y):
        self.l = x
        self.b = y
    def area(self):
        return self.l*self.b
r = Rectangle(10,20)
print ('area: ', r.area())<jupyter_output>area:  200<jupyter_text>**super()** - While Python isn’t purely an object-oriented language, it’s flexible enough and powerful enough to allow you to build your applications using the object-oriented paradigm. - One of the ways in which Python achieves this is by supporting inheritance, which it does with `super()`.<jupyter_code># This is the base class
class Rectangle:
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def area(self):
        return self.length * self.width

    def perimeter(self):
        return 2 * self.length + 2 * self.width
# Derived class but with no inheritance
class Square:
    def __init__(self, length):
        self.length = length

    def area(self):
        return self.length * self.length

    def perimeter(self):
        return 4 * self.length
# Here we declare that the Square class inherits from the Rectangle class
class Square_m(Rectangle):
    def __init__(self, length):
        super().__init__(length, length)
rectangle = Rectangle(2,4)
print("Rectangle area", rectangle.area())

square = Square(4)
print("Square area (withouth super)", square.area())

square_m = Square_m(4)
print("Square area (with super)", square_m.area())<jupyter_output>Rectangle area 8
Square area (withouth super) 16
Square area (with super) 16<jupyter_text>**Public, Private, Protected attributes** There are two ways to restrict the access to class attributes:1. **protected**. First, we can prefix an attribute name with a leading underscore "_". It tells users of the class not to use this attribute unless, somebody writes a subclass.2. **private**. Second, we can prefix an attribute name with two leading underscores "__". The attribute is now inaccessible and invisible from outside. It's neither possible to read nor write to those attributes except inside of the class definition itself.- Whenever we assign or retrieve any object attribute Python searches it in the object's `__dict__` dictionary- When the Python compiler sees a private attribute, it actually transforms the actual name to `_[Class name]__[private attribute name]`. - **In practice** it is more common to use public and protected attribute,- **ATTENTION** This is a convention and good practice. In reality there is no such a concept of strick private method in python.<jupyter_code>class A:
    
    def __init__(self):               
        self.public = "I am public"
        self._protected = "I am protected"
        self.__private = "I am private" 

instantiationOfA = A()
print(instantiationOfA.public)
print(instantiationOfA._protected)
print(instantiationOfA._A__private)
print(instantiationOfA.__dict__)<jupyter_output>{'public': 'I am public', '_protected': 'I am protected', '_A__private': 'I am private'}<jupyter_text>Function  **args and kwargs** - `*args`   is used for specifying an unknown number of *positional* arguments.- `**kwargs` is used for specifying an unknown number of *keyword*    arguments. Double star allows us to pass through keyword arguments (and any number of them)<jupyter_code>def example_args(*dummy):
       for a in dummy:            
            print(a)
example_args([1,2,3,4])
example_args([1,2,3,4,5], [1,2])
def example_kargs(**dummy):
       for key, values in dummy.items():
            print(key, values)
example_kargs(a = 3, b = 1 )
example_kargs(a = 3, b = 1, c = 3)
# Something a bit more complex
line = 'nobody:*:-2:-2:Unprivileged User:/var/empty:/usr/bin/false'
print(line.split(":"))
uname, *_, homedir, sh = line.split(':')
print(uname)
print(homedir)
print(sh)<jupyter_output>['nobody', '*', '-2', '-2', 'Unprivileged User', '/var/empty', '/usr/bin/false']
nobody
/var/empty
/usr/bin/false<jupyter_text>**Function decorator** - A decorator takes in a function, adds some functionality and **RETURNS** it.- If you're wondering why there us a ` @functools.wraps ` here while other example do not have it, it is because it is **good practice**. - This automatically adds a `__wrapped__` attribute that let you retrieve the original, undecorated function<jupyter_code>import functools

def ourDecorator(func):
    @functools.wraps(func)
    def functionWrapper(x):
        print("Decorating the function")
        func(x)        
    return functionWrapper
@ourDecorator
def foo(x):
    print("Hi, foo has been called with: " + str(x))
# Let us call foo
foo("Hi")
# This is equivalent to 
ourDecorator(foo("Hi"));
def make_pretty(func):
    def inner():
        print("I got decorated")
        func()
    return inner
@make_pretty
def ordinary():
    print("I am ordinary")
ordinary()
# Decorating. while passing a parameters
def smart_divide(func):
    def inner(a, b):
        print("I am going to divide", a, "and", b)
        if b == 0:
            print("Whoops! cannot divide")
            return

        return func(a, b)
    return inner
@smart_divide
def divide(a, b):
    print(a/b)
divide(4,0)<jupyter_output>I am going to divide 4 and 0
Whoops! cannot divide<jupyter_text>**partial function** - **Partial functions** allow one to derive a function with x parameters to a function with fewer parameters and fixed values set for the more limited function.- We are going to create a function that multiplies two numbers.- We then want to create a function that always multipluies y by 1, but we don't want to rewrite another function, insetad we'd like to use what we have.- **IMPORTANT:** the default values will start replacing variables from the left.<jupyter_code>from functools import partial
def multiply(x,y):
        print("x has value:", x)
        print("y has value:", y)
        return x * y
multiply(3,3)
pf_1 = partial(multiply,2)
pf_1(3)
pf_1(1)
pf_2 = partial(multiply, 2,2)
pf_2()<jupyter_output>x has value: 2
y has value: 2<jupyter_text>Iterators  **zip**<jupyter_code>L = [2, 4, 6, 8, 10]
R = [3, 6, 9, 12, 15]
# zip zips together iterables
for lval, rval in zip(L, R):
    print(lval, rval)
L1 = (1, 2, 3, 4)
L2 = ('a', 'b', 'c', 'd')

# Zipping then togeher
z = zip(L1, L2)
print(*z)

# Unzipping again
z = zip(L1, L2)
new_L1, new_L2 = zip(*z)
print(new_L1, new_L2)<jupyter_output>(1, 'a') (2, 'b') (3, 'c') (4, 'd')
(1, 2, 3, 4) ('a', 'b', 'c', 'd')<jupyter_text>- `map`, `filter` and `reduce` are all example of functional programming in python.- **Definition 1** style of programming in which functions are treated and manipulated as objects, i.e. functions can be assigned to variables, they can be passed as arguments, and they can be stored in containers along with other data. We can write parallel code that works by running lots of functions in parallel on large amounts of data.- **Definition 2** when you build your applications completely out of pure functions: Pure Function: Its return value is determined exclusively by it's arguments. **lambda** - `lambda` returns a function object which can be assigned to any variable.- `lambda` functions are passed as parameters to functions that expect function object as parameters such as map, reduce, and filter functions. - Lambda functions are single-expression  functions that **are not necessarily** bound to a name (they can be anonymous). - Lambda functions can't use regular Python statements and always include an implicit `return` statement.<jupyter_code># Can we achieve something like in a different way?
def add(x, y):
    return x + y
lambda x, y: x + y
add = lambda x, y: x + y
add(1, 2)<jupyter_output><empty_output><jupyter_text>**map**<jupyter_code>"""
The map iterator takes a function and applies it to the values in an iterator
    
Find the first 10 square numbers
"""
square = lambda x: x ** 2
for val in map(square, range(10)):
    print(val, end = ' ')<jupyter_output>0 1 4 9 16 25 36 49 64 81<jupyter_text>**filter**<jupyter_code>"""
The filter iterator looks similar, except it only passes-through values 
for which the filter function evaluates to True:

Find values up to 10 for which x % 2 is zero
"""

is_even = lambda x: x % 2 == 0
for val in filter(is_even, range(10)):
    print(val, end=' ')
# *args syntax works not just with sequences, but with any iterator:
print(*range(10))
"""
This means that problem like the one before: 
find values up to 10 for which x % 2 is zero

can be reduced down to
"""
print(*map(lambda x: x ** 2, range(10)))<jupyter_output>0 1 4 9 16 25 36 49 64 81<jupyter_text>Generators  - List comprehensions and generator expressions are not the same thing.- List comprehensions use **square brackets**, while generator expressions use **parentheses**.- A list is a collection of values, while a generator is a recipe for producing values- When you create a list, you are actually building a collection of values, and there is some memory cost associated with that. When you create a generator, you are not building a collection of values, but a recipe for producing those values.- The difference is that a generator expression does not actually compute the values until they are needed. This not only leads to **memory efficiency**, but to computational efficiency as well! This also means that while the size of a list is limited by available memory, the size of a generator expression is unlimited!<jupyter_code># Both expose the same iterator interface, as we can see here:
L = [n ** 2 for n in range(12)]
for val in L:
    print(val, end=' ')

print("")
G = (n ** 2 for n in range(12))
for val in G:
    print(val, end=' ')
# Will not print anything unless you reinitialise the generator!
for val in G:
    print(val, end=' ')
# This can be very useful because it means iteration can be stopped and started:
G = (n**2 for n in range(12))
for n in G:
    print(n, end=' ')
    if n > 30: break

print("\ndoing something in between")

for n in G:
    print(n, end=' ')<jupyter_output>0 1 4 9 16 25 36 
doing something in between
49 64 81 100 121<jupyter_text>- A **generator function** is a function that, rather than using return to return a value once, uses yield to yield a (potentially infinite) sequence of values. - Just as in generator expressions, the state of the generator is preserved between partial iterations, but if we want a fresh copy of the generator we can simply call the function again.<jupyter_code># Similarly, here we have two ways of constructing equivalent generators:    
G1 = (n ** 2 for n in range(12))
print(*G1)

def gen():
    for n in range(12):
        yield n ** 2

G2 = gen()

print(*G2)<jupyter_output>0 1 4 9 16 25 36 49 64 81 100 121
0 1 4 9 16 25 36 49 64 81 100 121<jupyter_text>Stack and queue  - Stack is a data structure where the last element addedis the first element retrieved -> **last-in, first-out**. - List can be very easily used as stacked.<jupyter_code>stack = [1,2,3]
stack.append(4)
stack.append(5)
stack
# the first to be removed is the the last that was added
stack.pop()
stack<jupyter_output><empty_output><jupyter_text>- Stack was last-in first-out.- Queue as the opposite firs-in first-out.- List are not **efficiently** used as a queue, because all the other elements have to be shifted by one.- To implement a queue use `collections.deque` which was designed to have fast append and pops from both ends.<jupyter_code>from collections import deque
a = ["Eric", "John", "Michael"]
queue = deque(a)
queue.append("Terry")
print(queue)
queue.append("Graham")
print(queue)
print(queue.popleft())
# removes the right-most element
print(queue.pop())
print(queue)<jupyter_output>Graham
deque(['John', 'Michael', 'Terry'])<jupyter_text>Dictionary  - Keeping Dictionaries in Order Using OrderedDict- Be aware that the size of an OrderedDict is more than **twice as large** as a normal dictionary! d = dict()d['foo'] = 1d['spam'] = 3d['bar'] = 2d['grok'] = 4for key in d:    print(key, d[key])<jupyter_code>from collections import OrderedDict
od = OrderedDict()
od['foo'] = 1
od['bar'] = 2
od['spam'] = 3
od['grok'] = 4

# the order remains the same compared to the dict
for key in od:
    print(key, od[key])
"""
Performing calculations (e.g., min, max, sorting, etc.) on a dictionary by convert the
it into tuples of ( value, key ).
"""

# company and stocks
prices = {'ACME': 45.23, 'AAPL': 612.78, 'IBM': 205.55,
          'HPQ': 37.20, 'FB': 10.75}

min_price = min(zip(prices.values(), prices.keys()))
print(min_price)
"""
We can perform common set operations with dictionary .keys() and .items() without 
having to convert them into a set as they are unique.
"""

a = {'x': 1, 'y': 2, 'z': 3}
b = {'w': 10, 'x': 11, 'y': 2}

# common keys
print(a.keys() & b.keys())

# keys in a that are not in b
print(a.keys() - b.keys())

# (key,value) pairs in common
print(a.items() & b.items())

# make a new dictionary with certain keys removed
c = {key: a[key] for key in a.keys() - {'z'}}
print(c)<jupyter_output>{'y', 'x'}
{'z'}
{('y', 2)}
{'y': 2, 'x': 1}<jupyter_text>Errors and exceptions      - There are three type of errors:    - **Syntax errors:** Errors where the code is not valid Python (generally easy to fix)    - **Runtime errors:** Errors where syntactically valid code fails to execute, perhaps due to invalid user input (sometimes easy to fix)    - **Semantic errors:** Errors in logic: code executes without a problem, but the result is not what you expect (often very difficult to track-down and fix)- Here we're going to focus on how to deal cleanly with *runtime errors*.- As we'll see, Python handles runtime errors via its *exception handling* framework. **Catching Exceptions: try and except**<jupyter_code>try:
    print("let's try something:")
    x = 1 / 0 # ZeroDivisionError
except:
    print("something bad happened!")
def safe_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return 1E100
safe_divide(1,0)
# We're now catching zero-division errors only, and letting all other errors pass through un-modified.
safe_divide(1, '2')<jupyter_output><empty_output><jupyter_text>Raising Exceptions: raise  - The way you raise your **OWN exceptions** is with the raise statement. - We are going to write a simple function that accepts only positive numbers.- Keep in mind that `raise` is a python name recognised by python but `ValueError` is something you decide. For instance,  something like `value_error` would have been equally OK.<jupyter_code>def function(N):
    if N < 0:
        raise ValueError("N must be non-negative")
    else:
        print("You entered", N)
        return N
function(10)
function(-10)<jupyter_output><empty_output><jupyter_text>**finally clause** - A `finally` clause is always executed before leaving the `try` statement, whether an exception has occured or not.- The `finally` clause is also executed on the way out when any other clause of the `try` statement is left via a `break`, `continue` or `return` statement.- In the real world, the `finally` clause is used for releasing external resources that are always useful regardless of what happens.<jupyter_code>def divide(x, y):
    try:
        result = x/y
    except ZeroDivisionError:
        print("Division by zero!")
    else:
        print("Result is:", result)
    finally:
        print("Executing finally clause")
divide(2,1)
divide(2,0)
divide("2", "1")<jupyter_output>Executing finally clause
{'fraction_non_alphanumeric': 0.10484617759028707, 'fraction_numerical': 0.01965222759543163, 'mean_word_length': 3.504557392244709, 'pattern_counts': {'":': 1, '<': 109, '<?xml version=': 0, '>': 114, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 1, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '33', 'dir': 'jupyter-structured-clean-dedup', 'id': '278539', 'license': 'OLDAP-2.3', 'n_tokens_mistral': 9242, 'n_tokens_neox': 8687, 'n_words': 3742, 'path': 'Coding_Interview_exercises/GitHub_MD_rendering/Coding interview cheat-sheet.ipynb', 'repo_name': 'kyaiooiayk/Python-Programming', 'size': '29157'}