Day -2 Advanced Python 🐍 ...
Tags: beginners, programming, python, ai
Published: 2024-02-27 07:17:44

## Hey, fellow code adventurers! Get ready to hop on the advanced Python, very excited to move next step, 
Today's agenda - 
1. **Advanced Data Handling**
    1. List Operations
       - Techniques for efficient list manipulation.
    2. Tuple and Set Operations
       - Understanding the unique attributes of tuples and sets.
    3. Dictionary Operations
       - Exploring the functionalities of Python dictionaries.

2. **Functionality Elegance**
    1. User-Defined Functions
       - Crafting reusable and modular code.
    2. Lambda Functions
       - Introduction to concise, anonymous functions.
    3. Recursion
       - Understanding recursive function implementation.

3. **Error Handling and Logging**
    1. Exception Handling
       - Strategies for identifying and managing errors.
    2. User-Defined Exceptions
       - Creating custom exception classes.
    3. Logging
       - Implementing effective logging practices.

4. **Object-Oriented Programming (OOP)**
    1. Inheritance and Classes
       - Grasping the principles of inheritance and class structures.
    2. Access Modifiers
       - Understanding access control within classes.


<hr>

### Let the Python begin! 🎉🐍

## Operations on List -
### Top Operations on list
<ol>
<li> Slicing</li>
<li> Accessing Elements</li>
<li> Appending and Extending</li>
<li> Inserting and Deleting Elements</li>
<li> Sorting and Reversing:</li>
<li>List Comprehensions</li>
<li> Finding Length</li>
<li> Checking Membership</li>
<li> Loop</li>
</ol>

```python
## Asinging a list

numbers= [0,1,2,3,4,5,6,7,8,9]

# Slicing

# Get the first 5 items
print(numbers[:5])  # Output: [0, 1, 2, 3, 4]

# Get the items from index 5 to the end
print(numbers[5:])  # Output: [5, 6, 7, 8, 9]

# Get the last 3 items
print(numbers[-3:])  # Output: [7, 8, 9]

# Get the items from index 2 to index 5 (exclusive)
print(numbers[2:5])  # Output: [2, 3, 4]

# Get every second item in the range from index 1 to index 6
print(numbers[1:6:2])  # Output: [1, 3, 5]

# Reverse the list
print(numbers[::-1])  # Output: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

# Get every second item in the reversed list
print(numbers[::-2])  # Output: [9, 7, 5, 3, 1]

# Get a sublist in reverse order (e.g., items from index 2 to index 6)
print(numbers[6:1:-1])  # Output: [6, 5, 4, 3, 2]

# Slicing with negative step, starting from end
print(numbers[8:2:-2])  # Output: [8, 6, 4]

# Example List
fruits = ['apple', 'banana', 'cherry', 'apple', 'elderberry']

# Change an item by index
print("Before changing item by index: ", fruits)
fruits[0] = 'kiwi'
print("After changing item by index: ", fruits)  # Output: ['kiwi', 'banana', 'cherry', 'apple', 'elderberry']

# Change multiple items by slicing
print("\nBefore changing items by slicing: ", fruits)
fruits[1:3] = ['blackcurrant', 'cantaloupe']
print("After changing items by slicing: ", fruits)  # Output: ['kiwi', 'blackcurrant', 'cantaloupe', 'apple', 'elderberry']

# Change items based on condition
print("\nBefore changing items based on condition: ", fruits)
fruits = ['kiwi' if fruit == 'apple' else fruit for fruit in fruits]
print("After changing items based on condition: ", fruits)  # Output: ['kiwi', 'blackcurrant', 'cantaloupe', 'kiwi', 'elderberry']

# Use list method to replace
print("\nBefore using list method to replace: ", fruits)
fruits = ['apple', 'banana', 'cherry']
fruits.insert(1, 'kiwi')  # Insert 'kiwi' at index 1
fruits.pop(2)  # Remove the item at index 2 ('banana')
print("After using list method to replace: ", fruits)  # Output: ['apple', 'kiwi', 'cherry']

# Example List
fruits = ['apple', 'banana', 'cherry']

# Adding an item to the end of the list using append()
print("Before append: ", fruits)
fruits.append('dragonfruit')
print("After append: ", fruits)  # Output: ['apple', 'banana', 'cherry', 'dragonfruit']

# Adding an item at a specific position using insert()
print("\nBefore insert: ", fruits)
fruits.insert(1, 'kiwi')
print("After insert: ", fruits)  # Output: ['apple', 'kiwi', 'banana', 'cherry', 'dragonfruit']

# Adding multiple items to the end of the list using extend()
print("\nBefore extend: ", fruits)
fruits.extend(['mango', 'nectarine'])
print("After extend: ", fruits)  # Output: ['apple', 'kiwi', 'banana', 'cherry', 'dragonfruit', 'mango', 'nectarine']

# Adding multiple items at a specific position by slicing
print("\nBefore slicing: ", fruits)
fruits[2:2] = ['blackcurrant', 'cantaloupe']
print("After slicing: ", fruits)  # Output: ['apple', 'kiwi', 'blackcurrant', 'cantaloupe', 'banana', 'cherry', 'dragonfruit', 'mango', 'nectarine']

# Adding items from another list using the "+" operator
print("\nBefore adding another list: ", fruits)
other_fruits = ['orange', 'papaya']
fruits = fruits + other_fruits
print("After adding another list: ", fruits)  # Output: ['apple', 'kiwi', 'blackcurrant', 'cantaloupe', 'banana', 'cherry', 'dragonfruit', 'mango', 'nectarine', 'orange', 'papaya']

# Example List
fruits = ['apple', 'kiwi', 'blackcurrant', 'cantaloupe', 'kiwi', 'elderberry']

# Removing an item by index using pop()
print("Before pop: ", fruits)
removed = fruits.pop(1)
print(f"After pop: {fruits}, removed item: {removed}")  # Output: ['apple', 'blackcurrant', 'cantaloupe', 'kiwi', 'elderberry'], removed item: kiwi

# Removing an item by value using remove()
print("\nBefore remove: ", fruits)
fruits.remove('blackcurrant')
print("After remove: ", fruits)  # Output: ['apple', 'cantaloupe', 'kiwi', 'elderberry']

# Removing multiple items by slicing
print("\nBefore slicing: ", fruits)
del fruits[1:3]
print("After slicing: ", fruits)  # Output: ['apple', 'elderberry']

# Removing all items using clear()
print("\nBefore clear: ", fruits)
fruits.clear()
print("After clear: ", fruits)  # Output: []

# Example List
fruits = ['apple', 'kiwi', 'blackcurrant', 'cantaloupe', 'kiwi', 'elderberry']

# Looping through a list using a for loop
print("Looping through list using a for loop:")
for fruit in fruits:
    print(fruit)

# Looping through a list by index using a for loop and range()
print("\nLooping through list by index using a for loop and range():")
for i in range(len(fruits)):
    print(f"Item at index {i} is {fruits[i]}")

# Looping through a list and creating a new list (list comprehension)
print("\nLooping through a list and creating a new list (list comprehension):")
capitalized_fruits = [fruit.upper() for fruit in fruits]
print(capitalized_fruits)  # Output: ['APPLE', 'KIWI', 'BLACKCURRANT', 'CANTALOUPE', 'KIWI', 'ELDERBERRY']

# Looping through a list using enumerate() to get both the index and the value
print("\nLooping through a list using enumerate() to get both the index and the value:")
for i, fruit in enumerate(fruits):
    print(f"Item at index {i} is {fruit}")

# Looping through a list using a while loop
print("\nLooping through a list using a while loop:")
i = 0
while i < len(fruits):
    print(fruits[i])
    i += 1


# Original list
fruits = ['grapes', 'apple', 'banana', 'cherry', 'mangoes']
print("Original list: ", fruits)

# List Comprehension
lengths = [len(fruit) for fruit in fruits]
print("\nList comprehension (lengths): ", lengths)  # Output: [6, 5, 6, 6, 7]

# Sort Lists
fruits.sort()
print("\nSorted list (ascending): ", fruits)  # Output: ['apple', 'banana', 'cherry', 'grapes', 'mangoes']

fruits.sort(reverse=True)
print("Sorted list (descending): ", fruits)  # Output: ['mangoes', 'grapes', 'cherry', 'banana', 'apple']

# Copy Lists
fruits_copy = fruits.copy()
print("\nCopied list: ", fruits_copy)  # Output: ['mangoes', 'grapes', 'cherry', 'banana', 'apple']

# Join Lists
fruits_extra = ['elderberry', 'fig', 'grapefruit']
joined_list = fruits + fruits_extra
print("\nJoined list: ", joined_list)  # Output: ['mangoes', 'grapes', 'cherry', 'banana', 'apple', 'elderberry', 'fig', 'grapefruit']

# List Methods
print("\nList methods:")
fruits.append('honeydew')
print("After append: ", fruits)  # Output: ['mangoes', 'grapes', 'cherry', 'banana', 'apple', 'honeydew']

fruits.remove('banana')
print("After remove: ", fruits)  # Output: ['mangoes', 'grapes', 'cherry', 'apple', 'honeydew']

index = fruits.index('cherry')
print("Index of 'cherry': ", index)  # Output: 2

count = fruits.count('apple')
print("Count of 'apple': ", count)  # Output: 1

fruits.pop(2)
print("After pop at index 2: ", fruits)  # Output: ['mangoes', 'grapes', 'apple', 'honeydew']

fruits.clear()
print("After clear: ", fruits)  # Output: []




```

## Operations on Tuples
*** Important to note *** 
Tuples are immutable; their elements cannot be modified after creation.
<hr>

### Top Operations on Tuples
<ol>
<li> Accessing Elements</li>
<li> Slicing</li>
<li> Concatenation</li>
<li> Repeating Elements</li>
<li> Tuple Unpacking</li>
<li> Length and Membership</li>
<li> Converting a List to a Tuple</li>
<li> Immutable Nature</li>
</ol>
```python
# Access Tuples
fruits_tuple = ('grapes', 'apple', 'banana', 'cherry', 'mangoes')
print("Original tuple: ", fruits_tuple)

print("\nAccessing elements in tuple:")
print("First fruit: ", fruits_tuple[0])  # Output: 'grapes'
print("Last fruit: ", fruits_tuple[-1])  # Output: 'mangoes'

# Update Tuples
# Tuples are immutable, so we can't directly change an element of a tuple. 
# But we can concatenate tuples or convert them to lists, modify them, and then convert back to tuples.
print("\nUpdating tuples:")
list_fruits = list(fruits_tuple)  
list_fruits[1] = 'kiwi'  # Changing 'apple' to 'kiwi'
fruits_tuple = tuple(list_fruits)
print("After updating tuple: ", fruits_tuple)  # Output: ('grapes', 'kiwi', 'banana', 'cherry', 'mangoes')

# Unpack Tuples
print("\nUnpacking tuples:")
fruit1, fruit2, fruit3, fruit4, fruit5 = fruits_tuple
print("Fruit1: ", fruit1)  # Output: 'grapes'
print("Fruit2: ", fruit2)  # Output: 'kiwi'
print("Fruit3: ", fruit3)  # Output: 'banana'
print("Fruit4: ", fruit4)  # Output: 'cherry'
print("Fruit5: ", fruit5)  # Output: 'mangoes'

# Original Tuple
fruits_tuple = ('grapes', 'kiwi', 'banana', 'cherry', 'mangoes')
print("Original tuple: ", fruits_tuple)

# Loop Tuples
print("\nLooping through tuple:")
for fruit in fruits_tuple:
    print(fruit)

# Join Tuples
print("\nJoining tuples:")
more_fruits = ('orange', 'peach')
joined_tuple = fruits_tuple + more_fruits
print("Joined tuple: ", joined_tuple)  # Output: ('grapes', 'kiwi', 'banana', 'cherry', 'mangoes', 'orange', 'peach')

# Tuple Methods
print("\nTuple methods:")
count = fruits_tuple.count('kiwi')
print("Count of 'kiwi': ", count)  # Output: 1

index = fruits_tuple.index('cherry')
print("Index of 'cherry': ", index)  # Output: 3

```

## Top Operations on Dictionaries
<ol>
<li> Creating a Dictionary</li>
<li> Accessing Values</li>
<li> Modifying Values</li>
<li> Adding New Key-Value Pairs</li>
<li> Deleting Key-Value Pairs</li>
<li> Checking if Key Exists</li>
<li> Getting Keys and Values</li>
<li> Dictionary Comprehensions</li>
<li> Checking Membership (Key)</li>
<li> Getting a Default Value with `get()`</li>
</ol>
```python 
# Original dictionary
fruits_dict = {'grapes': 5, 'kiwi': 2, 'banana': 6, 'cherry': 8, 'mangoes': 7}
print("Original dictionary: ", fruits_dict)

# Accessing dictionary items
print("\nAccessing dictionary items:")
print("Value of 'banana': ", fruits_dict['banana'])  # Output: 6

# Change dictionary items
print("\nChanging dictionary items:")
fruits_dict['banana'] = 10
print("Updated dictionary: ", fruits_dict)  # Output: {'grapes': 5, 'kiwi': 2, 'banana': 10, 'cherry': 8, 'mangoes': 7}

# Adding items to dictionary
print("\nAdding items to dictionary:")
fruits_dict['apple'] = 4
print("Updated dictionary: ", fruits_dict)  # Output: {'grapes': 5, 'kiwi': 2, 'banana': 10, 'cherry': 8, 'mangoes': 7, 'apple': 4}

# Removing items from dictionary
print("\nRemoving items from dictionary:")
fruits_dict.pop('kiwi')
print("Updated dictionary: ", fruits_dict)  # Output: {'grapes': 5, 'banana': 10, 'cherry': 8, 'mangoes': 7, 'apple': 4}

# Looping through a dictionary
print("\nLooping through a dictionary:")
for key, value in fruits_dict.items():
    print(key, value)

# Dictionary comprehension
print("\nDictionary comprehension:")
prices = {key: value * 2 for key, value in fruits_dict.items()}
print("New dictionary (prices): ", prices)  # Output: {'grapes': 10, 'banana': 20, 'cherry': 16, 'mangoes': 14, 'apple': 8}

# Copying a dictionary
print("\nCopying a dictionary:")
fruits_copy = fruits_dict.copy()
print("Copied dictionary: ", fruits_copy)  # Output: {'grapes': 5, 'banana': 10, 'cherry': 8, 'mangoes': 7, 'apple': 4}

# Merging dictionaries
print("\nMerging dictionaries:")
extra_fruits = {'peach': 3, 'orange': 4}
fruits_dict.update(extra_fruits)
print("Updated dictionary: ", fruits_dict)  # Output: {'grapes': 5, 'banana': 10, 'cherry': 8, 'mangoes': 7, 'apple': 4, 'peach': 3, 'orange': 4}

# Dictionary methods
print("\nDictionary methods:")
keys = fruits_dict.keys()
print("Keys: ", keys)  # Output: dict_keys(['grapes', 'banana', 'cherry', 'mangoes', 'apple', 'peach', 'orange'])

values = fruits_dict.values()
print("Values: ", values)  # Output: dict_values([5, 10, 8, 7, 4, 3, 4])

items = fruits_dict.items()
print("Items: ", items)  # Output: dict_items([('grapes', 5), ('banana', 10), ('cherry', 8), ('mangoes', 7), ('apple', 4), ('peach', 3), ('orange', 4)])

```

## Top Operations on Sets
<ol>
<li> Creating a Set</li>
<li> Adding Elements</li>
<li> Removing Elements</li>
<li> Checking Membership</li>
<li> Set Operations (Union, Intersection, Difference)</li>
<li> Removing Duplicates from a List using Set</li>
<li> Set Comprehensions</li>
<li> Checking Subset and Superset</li>
<li> Finding the Length of a Set</li>
<li> Discarding Elements</li>
</ol>
```python
# Original set
fruits_set = {'grapes', 'kiwi', 'banana', 'cherry', 'mangoes'}
print("Original set: ", fruits_set)

# Access set items
# We cannot access individual items in a set directly as sets are unordered, 
# but we can loop through the set or convert it to a list/tuple and access them.
print("\nAccessing set items (as a list):")
fruits_list = list(fruits_set)
print("First item: ", fruits_list[0])

# Add set items
print("\nAdding items to set:")
fruits_set.add('apple')
print("Updated set: ", fruits_set)

# Remove set items
print("\nRemoving items from set:")
fruits_set.remove('kiwi')
print("Updated set: ", fruits_set)

# Looping through a set
print("\nLooping through a set:")
for fruit in fruits_set:
    print(fruit)

# Set comprehension
print("\nSet comprehension:")
lengths = {len(fruit) for fruit in fruits_set}
print("New set (lengths): ", lengths)

# Copying a set
print("\nCopying a set:")
fruits_copy = fruits_set.copy()
print("Copied set: ", fruits_copy)

# Join sets
print("\nJoining sets:")
more_fruits = {'orange', 'peach'}
all_fruits = fruits_set.union(more_fruits)
print("Joined set: ", all_fruits)

# Set methods
print("\nSet methods:")
fruits_set.add('pear')
print("After add: ", fruits_set)
fruits_set.remove('apple')
print("After remove: ", fruits_set)
```
<hr> <hr>
Let us move towards second steps
## Functions-
Functions is a block of reusable code that performs a specific task. 
### Types of Function -
<ul>
<li>
<strong>Built-in Functions: </strong>
Built-in functions are functions that are already defined in Python and come as part of the standard Python library. These functions are ready to use and provide common functionalities that are widely used. Examples of built-in functions include `len(), print(), type(), sum()`, and many more. These functions are available without the need for additional import statements.
</li>
<li>
<strong> User-Defined Functions:</strong>
User-defined functions are functions created by the programmer to perform a specific task. These functions are defined using the def keyword, followed by the function name, parentheses for parameters (if any), and a colon. The function's code block is indented under the function definition.
</li>
</ul>
```python
def greet():
    print("Hello, World!")

# Calling the function
greet()  # Output: Hello, World!

def add_numbers(num1, num2):
    return num1 + num2

# Calling the function
result = add_numbers(4, 5)
print(result)  # Output: 9
```
<p><strong>Overall, the <code>return</code> statement is crucial</strong> in user-defined functions as it enables the functions to produce outputs, pass data, allow result reusability, handle conditional returns, and control the flow of the function's execution.</p>

## Lambda Function

<p>here are some key points about lambda functions in Python, each point is a one-liner:</p>
<ol>
<li>Lambda functions are small, anonymous functions defined with the <code>lambda</code> keyword.</li>
<li>They can take any number of arguments but can only have one expression.</li>
<li>The expression is evaluated and returned when the function is called.</li>
<li>Lambda functions do not require a <code>return</code> statement, the expression is implicitly returned.</li>
<li>They can be used wherever function objects are required, like inside functions like <code>map()</code>, <code>filter()</code>, and <code>reduce()</code>.</li>
<li>You can't include statements like loops, <code>if</code>, or <code>else</code> in lambda functions; only expressions are allowed.</li>
<li>Lambda functions are useful for small tasks that are not reused throughout your code.</li>
<li>They can be assigned to variables and used like regular functions.</li>
</ol>
```python 
# lambda arguments: expression

add = lambda x, y: x + y
print(add(5, 3))  # Output: 8

# A list of dictionaries is created, where each dictionary represents a product. 
# Each product has two properties: 'name' and 'price'.

products = [
    {'name': 'Product1', 'price': 50},
    {'name': 'Product2', 'price': 30},
    {'name': 'Product3', 'price': 40},
    {'name': 'Product4', 'price': 20},
]

# The products list and a lambda function are passed to the sorted() method. 
# The sorted() function's key parameter accepts a function as an argument. 
# This function generates a'sorting key' for each entry in the list.

# In this case, the lambda function lambda x: x['price'] takes an argument (a dictionary) and returns the value of its 'price' key. 
# These pricing values are used by the sorted() method to compare products and determine their rank in the sorted list.

sorted_products = sorted(products, key=lambda x: x['price'])

for product in sorted_products:
    print(product)
```

<hr>
## Recursion

<p><strong>Recursion</strong> refers to the programming technique in which a function defines itself by calling itself. Here are a few highlights:</p>
<ul>
<li>Recursion occurs when a function calls itself while providing a different input for each consecutive call.</li>
<li>Base Case: To prevent unbounded recursion, every recursive function should have a base case, which is a condition under which it stops invoking itself.</li>
<li>The execution context or state of each recursive call is different. The current values of a function's parameters and variables are placed onto the call stack when it calls itself.</li>
<li>Problem-Solving: Recursion is frequently used to solve problems that can be divided into smaller, simpler problems of the same type.</li>
<li>Memory: Because recursive functions must retain stack frames for all recursive calls, they consume more memory than iterative versions.</li>
<li>Efficiency: Because of the complexity of maintaining the stack, recursive functions can be less efficient and can result in a stack overflow for deep recursion.</li>
<li>Readability: Despite potential inefficiencies, recursion can result in easier-to-read and write code for certain issues, such as traversing tree-like data structures, sorting algorithms (such as quicksort and mergesort), solving Tower of Hanoi, Fibonacci series, and so on.</li>
<li>Remember that recursion is a tool, and whether or not to utilise it depends on the particular problem you're attempting to solve. Some issues lend themselves nicely to recursive solutions, while others may benefit from an iterative approach.</li>
</ul>
** Let's Take a classical example for Recusion that is Fabbionaci Series ** 
```python 
def my_fibonacci(n):
    a, b = 0, 1
    for i in range(n):
        print(a)
        a, b = b, a + b

my_fibonacci(7)
```
<hr> 
## Exception handling

<ul>
<li>It is a mechanism in programming to handle runtime errors, which are known as exceptions.&nbsp;</li>
<li>It's a process where you define a block of code that will be executed if an error occurs when the program is running. This allows the program to continue running (or terminate gracefully) even if an error occurs.</li>
<li>Without exception handling, an error occurring in a program would cause the program to immediately stop. This can be very undesirable, especially in production software, as it can lead to a poor user experience or even data loss.</li>
<li>In Python, you use&nbsp;<code><strong>try</strong></code>&nbsp;and&nbsp;<code><strong>except</strong></code>&nbsp;blocks. The&nbsp;<code>try</code>&nbsp;block contains the code that might raise an exception, and the&nbsp;<code><strong>except</strong></code>&nbsp;block contains the code that will be executed if an exception is raised.</li>
<li>The <strong>else</strong> block allows you run code without errors.</li>
<li>The <strong>finally</strong> block executes code regardless of the try-and-except blocks.</li>
</ul>
<p><strong>Some of the most common types of exceptions are:</strong></p>
<ul>
<li>
<p><strong><code>ZeroDivisionError</code>:</strong> Raised when the second argument of a division or modulo operation is zero.</p>
</li>
<li>
<p><strong><code>TypeError</code>:</strong> Raised when an operation or function is applied to an object of inappropriate type.</p>
</li>
<li>
<p><strong><code>ValueError</code>:</strong> Raised when a built-in operation or function receives an argument that has the right type but an inappropriate value.</p>
</li>
<li>
<p><strong><code>IndexError</code>:</strong> Raised when a sequence subscript is out of range.</p>
</li>
<li>
<p><strong><code>KeyError</code>:</strong> Raised when a dictionary key is not found.</p>
</li>
<li>
<p><strong><code>FileNotFoundError</code>:</strong> Raised when a file or directory is requested but doesn&rsquo;t exist.</p>
</li>
<li>
<p><strong><code>IOError</code>:</strong> Raised when an I/O operation (such as a print statement, the built-in open() function or a method of a file object) fails for an I/O-related reason.</p>
</li>
<li>
<p><strong><code>ImportError</code>:</strong> Raised when an <code>import</code> statement fails to find the module definition or when a <code>from ... import</code> fails to find a name that is to be imported.</p>
</li>
<li>
<p><strong><code>MemoryError</code>:</strong> Raised when an operation runs out of memory.</p>
</li>
<li>
<p><strong><code>OverflowError</code>:</strong> Raised when the result of an arithmetic operation is too large to be expressed by the normal number format.</p>
</li>
<li>
<p><strong><code>AttributeError</code>:</strong> Raised when an attribute reference or assignment fails.</p>
</li>
<li>
<p><strong><code>SyntaxError</code>:</strong> Raised when the parser encounters a syntax error.</p>
</li>
<li>
<p><strong><code>IndentationError</code>:</strong> Raised when there is incorrect indentation.</p>
</li>
<li>
<p><strong><code>NameError</code>:</strong> Raised when a local or global name is not found.</p>
</li>
</ul>
<p><strong><span style="font-family: monospace;">Role of Try and Except:</span></strong></p>
<ul>
<li>
<p><strong><code>try</code> block:</strong> The code within the <code>try</code> block contains the statements that may potentially raise an exception. It allows you to specify the section of code that you want to monitor for exceptions.</p>
</li>
<li>
<p><strong><code>except</code> block:</strong> If an exception occurs within the <code>try</code> block, the corresponding <code>except</code> block(s) are executed. The <code>except</code> block allows you to define the actions or code that should be executed when a specific exception is raised. You can have multiple <code>except</code> blocks to handle different types of exceptions.</p>
</li>
<li>The <strong>else</strong> block allows you run code without errors.</li>
<li>The <strong>finally</strong> block executes code regardless of the try-and-except blocks.</li>
<li> Use the <strong>raise</strong> keyword to throw (or raise) an exception.</li>
</ul>
```python 
# **Try, Catch, else & Final**
# try raise an exception bec x is not defined
try:
  print(x)
except:
  print("Some issue with x")

"""You can use the "else" keyword to specify a block
   of code that will be performed if no errors are raised:"""
try:
  print("Good morning today is 17th June")
except:
  print("Some issue")
else:
  print("No issues")

"""If the "finally" block is supplied,
   it will be executed whether or not the try block raises an error."""

try:
  x = 2
  print(x)
except:
  print("There is no X")
finally:
  print("The 'try except' executed")
```

##  User-defined Exceptions

<p><strong>By deriving a new class</strong> from the default Exception class in Python, we can define our own exception types.</p>
```python 
#MyCustomError is derived from the built-in Exception class. You can use this in your code by using the raise statement.

class MyCustomError(Exception):
    pass

raise MyCustomError("This is a custom error")

# define user-defined exceptions
class WrongAge(Exception):
    "Raised when the input value is less than 100"
    pass

# you need to guess this number
n = 18

try:
    input_num = int(input("Enter a age: "))
    if input_num < n:
        raise WrongAge # calling your custom exception
    else:
        print("You can work")
except WrongAge:
    print("Invalid Age: You are not allowed to work")
```
<hr>
<br>
## Logging

<ul>
<li><strong>Logging</strong> is a technique for monitoring events that take place when some software is in use.</li>
<li>For the creation, operation, and debugging of software, logging is crucial.</li>
<li>There are very few odds that you would find the source of the issue if your program fails and you don't have any logging records.</li>
<li>Additionally, it will take a lot of time to identify the cause.&nbsp;</li>
</ul>
<ul>
<li>Some programmers utilise the idea of&nbsp;<strong>"Printing"</strong> the statements to check whether they were correctly performed or if an error had occurred.</li>
<li>However, printing is not a smart move. For basic scripts, it might be the answer to your problems, however the printing solution will fall short for complex programmes.</li>
<li>A built-in Python package called logging enables publishing status messages to files or other output streams. The file may provide details about which portion of the code is run and any issues that have come up. </li>
</ul>
<ul>
<li>
<p>Here are the different log levels in increasing order of severity:</p>
<ul>
<li>DEBUG: Detailed information, typically of interest only when diagnosing problems.</li>
<li>INFO: Confirmation that things are working as expected.</li>
<li>WARNING: An indication that something unexpected happened, or may happen in the future (e.g. &lsquo;disk space low&rsquo;). The software is still working as expected.</li>
<li>ERROR: More serious problem that prevented the software from performing a function.</li>
<li>CRITICAL: A very serious error, indicating that the program itself may be unable to continue running.</li>
</ul>
</li>
</ul>
```python
## Basic setup

import logging

logging.basicConfig(level=logging.CRITICAL)


def LetUsCheckSystem(sys):
    if sys != 'OK':
        logging.critical('System failure: %s', sys)

LetUsCheckSystem('You need to handle the issue now')
```
## Save to file
```python
# Set up logging
# Get a logger instance (this will fetch the root logger)
logger = logging.getLogger()

# Set the level of the logger to CRITICAL
# This means it will handle events of level CRITICAL and above
logger.setLevel(logging.CRITICAL)

# Create a FileHandler instance to write logs to a file
handler = logging.FileHandler(full_path)

# Set the format of the logs using a Formatter
# This format includes the log timestamp, log level and log message
handler.setFormatter(logging.Formatter('%(asctime)s:%(levelname)s:%(message)s'))

# Add the handler to the logger
# This connects the logger to the handler so that logs get written to the file
logger.addHandler(handler)


def LetUsCheckSystem(sys):
    if sys != 'OK':
        logging.critical('System failure: %s', sys)

LetUsCheckSystem('You need to handle the issue now')
handler.close()
```
## Output in log file
```
2024-02-27 07:05:01,415:CRITICAL: System failure: You need to handle the issue now
```

We will continue this for Oops in the next blog. Stay connected. Pls visit the [github](https://github.com/Pranjal-sharma-SDE/AI_Mind_Hub) for code - 
[Colab](https://colab.research.google.com/drive/1m_xWzYtFZuG-7TTB-vATO1Nk3_JMF6DS?usp=sharing)

Drop by our [Telegram Channel](https://t.me/+J2qk3bDFR-piZmU1) and let the adventure begin! See you there, Data Explorer! 🌐🚀