Exception Handling

Exception handling allows you to gracefully handle errors that occur during program execution, preventing crashes and providing meaningful error messages.

Try-Except Block

The basic structure for handling exceptions:

Exception Handling Basics
# Basic try-except
try:
    number = int(input("Enter a number: "))
    result = 10 / number
    print(f"Result: {result}")
except ValueError:
    print("Invalid input! Please enter a number.")
except ZeroDivisionError:
    print("Cannot divide by zero!")

# Using else and finally
try:
    file = open("data.txt", "r")
    content = file.read()
except FileNotFoundError:
    print("File not found!")
else:
    print("File read successfully!")
    print(content)
finally:
    print("This always executes")
    # Clean up code here

Common Exception Types

Exception Description Example
ValueError Invalid value for operation int("hello")
TypeError Operation on wrong type "2" + 2
ZeroDivisionError Division by zero 10 / 0
IndexError Invalid list index [1,2,3][10]
KeyError Key not found in dict {'a':1}['b']
FileNotFoundError File doesn't exist open("none.txt")
AttributeError Invalid attribute access "hello".append()

Raising Exceptions

Raising Custom Exceptions
# Raising exceptions
def set_age(age):
    if age < 0:
        raise ValueError("Age cannot be negative!")
    if age > 150:
        raise ValueError("Age seems unrealistic!")
    return age

try:
    user_age = set_age(-5)
except ValueError as e:
    print(f"Error: {e}")

# Creating custom exception classes
class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(f"Cannot withdraw {amount}. Balance: {balance}")

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount

try:
    new_balance = withdraw(100, 150)
except InsufficientFundsError as e:
    print(e)  # Cannot withdraw 150. Balance: 100

Object-Oriented Programming

OOP is a programming paradigm that organizes code into objects, which are instances of classes. It helps in creating modular, reusable, and maintainable code.

Classes and Objects

Creating Classes
# Defining a class
class Student:
    # Class variable (shared by all instances)
    school_name = "Python Academy"
    
    # Constructor (initializer)
    def __init__(self, name, age, grade):
        # Instance variables (unique to each object)
        self.name = name
        self.age = age
        self.grade = grade
    
    # Instance method
    def introduce(self):
        return f"Hi, I'm {self.name}, {self.age} years old, in grade {self.grade}"
    
    # Method to modify data
    def promote(self):
        self.grade += 1
        print(f"{self.name} promoted to grade {self.grade}!")

# Creating objects (instances)
student1 = Student("Alice", 15, 10)
student2 = Student("Bob", 16, 11)

# Accessing attributes and methods
print(student1.name)           # Alice
print(student1.introduce())    # Hi, I'm Alice, 15 years old, in grade 10
print(Student.school_name)     # Python Academy

# Modifying object
student1.promote()             # Alice promoted to grade 11!

Encapsulation

Encapsulation restricts direct access to some components, protecting data integrity:

Encapsulation Example
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.__balance = balance  # Private attribute (double underscore)
    
    # Getter method
    def get_balance(self):
        return self.__balance
    
    # Setter with validation
    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            print(f"Deposited ₹{amount}. New balance: ₹{self.__balance}")
        else:
            print("Invalid amount!")
    
    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
            print(f"Withdrew ₹{amount}. Remaining: ₹{self.__balance}")
        else:
            print("Insufficient funds or invalid amount!")

# Using the class
account = BankAccount("Rishi", 1000)
account.deposit(500)          # Deposited ₹500. New balance: ₹1500
account.withdraw(200)         # Withdrew ₹200. Remaining: ₹1300
print(account.get_balance())  # 1300

# Cannot directly access private variable
# print(account.__balance)    # AttributeError!

Properties (Getters and Setters)

Using @property Decorator
class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        """Getter for radius"""
        return self._radius
    
    @radius.setter
    def radius(self, value):
        """Setter with validation"""
        if value > 0:
            self._radius = value
        else:
            raise ValueError("Radius must be positive!")
    
    @property
    def area(self):
        """Calculated property"""
        import math
        return math.pi * self._radius ** 2
    
    @property
    def circumference(self):
        import math
        return 2 * math.pi * self._radius

# Using properties like attributes
circle = Circle(5)
print(circle.radius)         # 5
print(f"Area: {circle.area:.2f}")           # Area: 78.54
print(f"Circumference: {circle.circumference:.2f}")  # Circumference: 31.42

circle.radius = 10           # Uses setter
print(circle.area)           # 314.159...

Inheritance

Inheritance allows a class to inherit attributes and methods from another class, promoting code reuse.

Single Inheritance

Basic Inheritance
# Parent class (Base class)
class Animal:
    def __init__(self, name, species):
        self.name = name
        self.species = species
    
    def make_sound(self):
        return "Some generic sound"
    
    def info(self):
        return f"{self.name} is a {self.species}"

# Child class (Derived class)
class Dog(Animal):
    def __init__(self, name, breed):
        # Call parent constructor
        super().__init__(name, "Dog")
        self.breed = breed
    
    # Override parent method
    def make_sound(self):
        return "Woof! Woof!"
    
    # New method specific to Dog
    def fetch(self):
        return f"{self.name} is fetching the ball!"

class Cat(Animal):
    def __init__(self, name, indoor=True):
        super().__init__(name, "Cat")
        self.indoor = indoor
    
    def make_sound(self):
        return "Meow!"

# Creating objects
dog = Dog("Buddy", "Golden Retriever")
cat = Cat("Whiskers")

print(dog.info())           # Buddy is a Dog
print(dog.make_sound())     # Woof! Woof!
print(dog.fetch())          # Buddy is fetching the ball!

print(cat.info())           # Whiskers is a Cat
print(cat.make_sound())     # Meow!

Multiple Inheritance

Multiple Inheritance
# Multiple parent classes
class Flyable:
    def fly(self):
        return "Flying high!"

class Swimmable:
    def swim(self):
        return "Swimming smoothly!"

class Walkable:
    def walk(self):
        return "Walking on land!"

# Duck inherits from multiple classes
class Duck(Flyable, Swimmable, Walkable):
    def __init__(self, name):
        self.name = name
    
    def quack(self):
        return "Quack! Quack!"

# Duck can do everything!
duck = Duck("Donald")
print(duck.fly())    # Flying high!
print(duck.swim())   # Swimming smoothly!
print(duck.walk())   # Walking on land!
print(duck.quack())  # Quack! Quack!

# Check inheritance
print(isinstance(duck, Flyable))    # True
print(isinstance(duck, Swimmable))  # True

Polymorphism

Polymorphism Example
# Different shapes with same interface
class Shape:
    def area(self):
        pass
    
    def perimeter(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height
    
    def perimeter(self):
        return 2 * (self.width + self.height)

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        import math
        return math.pi * self.radius ** 2
    
    def perimeter(self):
        import math
        return 2 * math.pi * self.radius

# Polymorphism in action - same method, different behavior
shapes = [Rectangle(4, 5), Circle(3), Rectangle(2, 8)]

for shape in shapes:
    print(f"Area: {shape.area():.2f}, Perimeter: {shape.perimeter():.2f}")

Modules & Imports

Modules are Python files containing code that can be reused in other programs. They help organize code into manageable, logical units.

Importing Modules

Different Import Methods
# Method 1: Import entire module
import math
print(math.sqrt(16))    # 4.0
print(math.pi)          # 3.14159...

# Method 2: Import with alias
import numpy as np
arr = np.array([1, 2, 3])

# Method 3: Import specific items
from math import sqrt, pi, ceil
print(sqrt(25))         # 5.0
print(ceil(4.2))        # 5

# Method 4: Import all (not recommended)
from math import *
print(sin(0))           # 0.0

# Method 5: Import with alias for specific item
from datetime import datetime as dt
now = dt.now()
print(now)

Creating Your Own Module

mymodule.py - Custom Module
# File: mymodule.py
"""My custom utility module"""

PI = 3.14159
AUTHOR = "Rishi"

def greet(name):
    """Return a greeting message"""
    return f"Hello, {name}! Welcome to Python."

def calculate_area(radius):
    """Calculate circle area"""
    return PI * radius ** 2

class Calculator:
    """Simple calculator class"""
    @staticmethod
    def add(a, b):
        return a + b
    
    @staticmethod
    def multiply(a, b):
        return a * b

# This runs only when module is executed directly
if __name__ == "__main__":
    print("Testing mymodule...")
    print(greet("World"))
    print(calculate_area(5))
Using Custom Module
# File: main.py
import mymodule

print(mymodule.greet("Alice"))
print(mymodule.calculate_area(10))
print(mymodule.PI)

calc = mymodule.Calculator()
print(calc.add(5, 3))

# Or import specific items
from mymodule import greet, Calculator
print(greet("Bob"))

Useful Built-in Modules

Module Purpose Example Usage
os Operating system interface os.getcwd(), os.listdir()
sys System-specific parameters sys.path, sys.exit()
datetime Date and time operations datetime.now()
random Random number generation random.randint(1, 100)
json JSON encoding/decoding json.loads(), json.dumps()
re Regular expressions re.search(), re.findall()
collections Specialized containers Counter, defaultdict

List Comprehensions

List comprehensions provide a concise way to create lists. They are more readable and often faster than traditional loops.

Basic Syntax

List Comprehension Basics
# Traditional way
squares = []
for x in range(10):
    squares.append(x ** 2)

# List comprehension (same result)
squares = [x ** 2 for x in range(10)]
print(squares)  # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

# With condition (filter)
even_squares = [x ** 2 for x in range(10) if x % 2 == 0]
print(even_squares)  # [0, 4, 16, 36, 64]

# With if-else (transform)
labels = ["even" if x % 2 == 0 else "odd" for x in range(5)]
print(labels)  # ['even', 'odd', 'even', 'odd', 'even']

# String manipulation
words = ["hello", "world", "python"]
upper_words = [word.upper() for word in words]
print(upper_words)  # ['HELLO', 'WORLD', 'PYTHON']

# Nested comprehension
matrix = [[i * j for j in range(1, 4)] for i in range(1, 4)]
print(matrix)  # [[1, 2, 3], [2, 4, 6], [3, 6, 9]]

Dictionary & Set Comprehensions

Dict and Set Comprehensions
# Dictionary comprehension
squares_dict = {x: x**2 for x in range(6)}
print(squares_dict)  # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

# From two lists
names = ["Alice", "Bob", "Charlie"]
ages = [25, 30, 35]
name_age = {name: age for name, age in zip(names, ages)}
print(name_age)  # {'Alice': 25, 'Bob': 30, 'Charlie': 35}

# Filtering dictionary
scores = {"Alice": 85, "Bob": 62, "Charlie": 91, "Diana": 78}
passed = {name: score for name, score in scores.items() if score >= 70}
print(passed)  # {'Alice': 85, 'Charlie': 91, 'Diana': 78}

# Set comprehension (unique values)
numbers = [1, 2, 2, 3, 3, 3, 4, 4, 4, 4]
unique_squares = {x**2 for x in numbers}
print(unique_squares)  # {16, 1, 4, 9}

# Generator expression (memory efficient)
sum_of_squares = sum(x**2 for x in range(1000000))
print(sum_of_squares)

When to Use Comprehensions

  • Simple transformations and filtering
  • Creating new collections from existing ones
  • When readability is maintained (avoid complex nested comprehensions)

Lambda Functions

Lambda functions are small, anonymous functions defined with the lambda keyword. They can have any number of arguments but only one expression.

Lambda Syntax

Lambda Function Basics
# Regular function
def add(a, b):
    return a + b

# Equivalent lambda
add_lambda = lambda a, b: a + b

print(add(3, 5))        # 8
print(add_lambda(3, 5)) # 8

# More lambda examples
square = lambda x: x ** 2
print(square(4))  # 16

greet = lambda name: f"Hello, {name}!"
print(greet("Python"))  # Hello, Python!

# Lambda with conditional
max_val = lambda a, b: a if a > b else b
print(max_val(10, 20))  # 20

Lambda with Built-in Functions

Lambda with map, filter, sorted
# map() - apply function to all items
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x**2, numbers))
print(squared)  # [1, 4, 9, 16, 25]

# filter() - filter items by condition
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)  # [2, 4, 6, 8, 10]

# sorted() - custom sorting
students = [
    {"name": "Alice", "grade": 85},
    {"name": "Bob", "grade": 92},
    {"name": "Charlie", "grade": 78}
]

# Sort by grade
by_grade = sorted(students, key=lambda s: s["grade"])
print([s["name"] for s in by_grade])  # ['Charlie', 'Alice', 'Bob']

# Sort by name (descending)
by_name_desc = sorted(students, key=lambda s: s["name"], reverse=True)
print([s["name"] for s in by_name_desc])  # ['Charlie', 'Bob', 'Alice']

# reduce() - cumulative operation
from functools import reduce
numbers = [1, 2, 3, 4, 5]
product = reduce(lambda a, b: a * b, numbers)
print(product)  # 120 (1*2*3*4*5)

Decorators

Decorators are a way to modify or extend the behavior of functions or methods without changing their source code.

Basic Decorator

Creating Decorators
# Simple decorator
def uppercase_decorator(func):
    def wrapper():
        result = func()
        return result.upper()
    return wrapper

@uppercase_decorator
def greet():
    return "hello, world!"

print(greet())  # HELLO, WORLD!

# Decorator with timing
import time

def timer_decorator(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.4f} seconds")
        return result
    return wrapper

@timer_decorator
def slow_function():
    time.sleep(1)
    return "Done!"

slow_function()  # slow_function took 1.0012 seconds

Practical Decorator Examples

Useful Decorators
# Logging decorator
def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args: {args}, kwargs: {kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned: {result}")
        return result
    return wrapper

@log_calls
def add(a, b):
    return a + b

add(3, 5)
# Calling add with args: (3, 5), kwargs: {}
# add returned: 8

# Authentication decorator
def require_auth(func):
    def wrapper(user, *args, **kwargs):
        if not user.get("authenticated", False):
            raise PermissionError("User not authenticated!")
        return func(user, *args, **kwargs)
    return wrapper

@require_auth
def view_dashboard(user):
    return f"Welcome to dashboard, {user['name']}!"

user = {"name": "Alice", "authenticated": True}
print(view_dashboard(user))  # Welcome to dashboard, Alice!

# Retry decorator
def retry(max_attempts=3):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Attempt {attempt + 1} failed: {e}")
            raise Exception(f"All {max_attempts} attempts failed")
        return wrapper
    return decorator

@retry(max_attempts=3)
def unstable_function():
    import random
    if random.random() < 0.7:
        raise ValueError("Random failure!")
    return "Success!"

Generators

Generators are functions that can pause and resume their execution, yielding values one at a time. They are memory-efficient for large datasets.

Creating Generators

Generator Functions
# Generator function using yield
def count_up_to(n):
    count = 1
    while count <= n:
        yield count
        count += 1

# Using the generator
counter = count_up_to(5)
print(next(counter))  # 1
print(next(counter))  # 2
print(next(counter))  # 3

# Or in a loop
for num in count_up_to(5):
    print(num, end=" ")  # 1 2 3 4 5

# Fibonacci generator
def fibonacci(limit):
    a, b = 0, 1
    while a < limit:
        yield a
        a, b = b, a + b

fib = list(fibonacci(100))
print(fib)  # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

# Infinite generator
def infinite_counter():
    num = 0
    while True:
        yield num
        num += 1

counter = infinite_counter()
for _ in range(5):
    print(next(counter), end=" ")  # 0 1 2 3 4

Generator Expressions

Generator Expressions
# List comprehension (creates list in memory)
squares_list = [x**2 for x in range(1000000)]

# Generator expression (lazy evaluation)
squares_gen = (x**2 for x in range(1000000))

# Memory comparison
import sys
print(f"List size: {sys.getsizeof(squares_list)} bytes")  # ~8 MB
print(f"Generator size: {sys.getsizeof(squares_gen)} bytes")  # ~120 bytes

# Using generator expression with sum
total = sum(x**2 for x in range(1000))
print(total)  # 332833500

# Chaining generators
numbers = range(100)
evens = (x for x in numbers if x % 2 == 0)
squares = (x**2 for x in evens)
result = sum(squares)
print(result)  # 161700

When to Use Generators

  • Processing large files line by line
  • Working with infinite sequences
  • Memory-constrained environments
  • Pipeline processing (chaining operations)

Regular Expressions

Regular expressions (regex) are powerful patterns for matching, searching, and manipulating text. Python's re module provides regex support.

Basic Pattern Matching

Regex Basics
import re

text = "The quick brown fox jumps over the lazy dog"

# search() - find first match
match = re.search(r"fox", text)
if match:
    print(f"Found: {match.group()}")  # Found: fox
    print(f"Position: {match.start()}-{match.end()}")  # Position: 16-19

# findall() - find all matches
vowels = re.findall(r"[aeiou]", text)
print(vowels)  # ['e', 'u', 'i', 'o', 'o', 'u', 'o', 'e', 'e', 'a', 'o']

# finditer() - iterator of matches
for match in re.finditer(r"\b\w{4}\b", text):  # 4-letter words
    print(match.group(), end=" ")  # over lazy

# match() - match at beginning
if re.match(r"The", text):
    print("Starts with 'The'")

# sub() - replace pattern
new_text = re.sub(r"fox", "cat", text)
print(new_text)  # The quick brown cat jumps over the lazy dog

Common Patterns

Pattern Description Example
\dAny digit\d{3} matches "123"
\wWord character (a-z, A-Z, 0-9, _)\w+ matches "hello_123"
\sWhitespace\s+ matches spaces/tabs
.Any character (except newline)a.c matches "abc", "a1c"
^Start of string^Hello matches start
$End of stringworld$ matches end
*0 or moreab* matches "a", "ab", "abb"
+1 or moreab+ matches "ab", "abb"
?0 or 1colou?r matches "color", "colour"
{n,m}n to m times\d{2,4} matches 2-4 digits

Practical Examples

Real-world Regex Patterns
import re

# Email validation
def is_valid_email(email):
    pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
    return bool(re.match(pattern, email))

print(is_valid_email("user@example.com"))  # True
print(is_valid_email("invalid.email"))     # False

# Phone number extraction
text = "Call me at 123-456-7890 or 9876543210"
phones = re.findall(r'\d{3}[-.]?\d{3}[-.]?\d{4}', text)
print(phones)  # ['123-456-7890', '9876543210']

# URL extraction
text = "Visit https://python.org or http://example.com"
urls = re.findall(r'https?://[\w\.-]+\.[\w]+', text)
print(urls)  # ['https://python.org', 'http://example.com']

# Password validation
def validate_password(password):
    """
    Password must have:
    - At least 8 characters
    - At least one uppercase letter
    - At least one lowercase letter
    - At least one digit
    - At least one special character
    """
    pattern = r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$'
    return bool(re.match(pattern, password))

print(validate_password("Weak"))           # False
print(validate_password("Strong@Pass1"))   # True

# Extract data from formatted string
data = "Name: John Doe, Age: 25, City: New York"
pattern = r'Name: ([\w\s]+), Age: (\d+), City: ([\w\s]+)'
match = re.search(pattern, data)
if match:
    name, age, city = match.groups()
    print(f"Name: {name}, Age: {age}, City: {city}")