Python

Complete Python Learning Roadmap

Professional Guide from Beginner to Expert


Table of Contents

  1. Introduction to Python
  2. Environment Setup & Tools
  3. Python Basics
  4. Data Types & Structures
  5. Control Flow
  6. Functions & Modules
  7. Object-Oriented Programming (OOP)
  8. File Handling & I/O
  9. Exception Handling
  10. Advanced Python Concepts
  11. Standard Library Deep Dive
  12. Working with Data
  13. Web Development
  14. Database Programming
  15. Testing & Debugging
  16. Best Practices & Design Patterns
  17. Real-World Projects
  18. Interview Questions & Answers

1. Introduction to Python

What is Python?

Python is a high-level, interpreted, general-purpose programming language created by Guido van Rossum in 1991.

Key Features:

  • Easy to Learn: Simple, readable syntax
  • Interpreted: No compilation needed
  • Dynamically Typed: No need to declare variable types
  • Multi-paradigm: Supports procedural, OOP, and functional programming
  • Extensive Libraries: Rich ecosystem for virtually any task
  • Cross-platform: Runs on Windows, Mac, Linux

Use Cases:

  • Web Development (Django, Flask)
  • Data Science & AI (NumPy, Pandas, TensorFlow)
  • Automation & Scripting
  • Desktop Applications
  • Game Development
  • DevOps & Cloud

Python Philosophy (The Zen of Python):

import this

Output:

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Readability counts.

2. Environment Setup & Tools

Installation

Windows

# Download from python.org
# During installation, check "Add Python to PATH"

# Verify installation
python --version
pip --version

Mac/Linux

# Mac (using Homebrew)
brew install python3

# Linux (Ubuntu/Debian)
sudo apt update
sudo apt install python3 python3-pip

# Verify
python3 --version
pip3 --version

Setting Up Development Environment

Option 1: VS Code (Recommended)

  1. Download from code.visualstudio.com
  2. Install Python extension
  3. Configure Python interpreter
  4. Enable linting and formatting

Option 2: PyCharm

  • Professional IDE for Python
  • Free Community Edition available

Option 3: Jupyter Notebook

pip install jupyter
jupyter notebook

Virtual Environments

# Create virtual environment
python -m venv myenv

# Activate (Windows)
myenv\Scripts\activate

# Activate (Mac/Linux)
source myenv/bin/activate

# Install packages
pip install package_name

# Freeze dependencies
pip freeze > requirements.txt

# Install from requirements
pip install -r requirements.txt

# Deactivate
deactivate

Your First Python Program

# hello_world.py
print("Hello, World!")

# Run the program
# python hello_world.py

3. Python Basics

Comments

# Single line comment

"""
Multi-line comment
or docstring
"""

'''
Another multi-line
comment
'''

Variables and Assignment

# Variable assignment (no declaration needed)
name = "John"
age = 30
height = 5.9
is_student = True

# Multiple assignment
x, y, z = 1, 2, 3
a = b = c = 0

# Swapping variables
x, y = y, x

# Variable naming rules
valid_name = "OK"
_private = "OK"
name2 = "OK"
# 2name = "Invalid"  # Cannot start with number
# my-name = "Invalid"  # No hyphens

Input and Output

# Output
print("Hello")
print("Name:", name, "Age:", age)
print(f"My name is {name} and I am {age} years old")  # f-strings
print("Name: {} Age: {}".format(name, age))  # format method

# Input
user_name = input("Enter your name: ")
user_age = int(input("Enter your age: "))  # Convert to integer
user_height = float(input("Enter your height: "))  # Convert to float

print(f"Hello {user_name}, you are {user_age} years old")

Operators

# Arithmetic Operators
a, b = 10, 3
print(a + b)   # Addition: 13
print(a - b)   # Subtraction: 7
print(a * b)   # Multiplication: 30
print(a / b)   # Division: 3.333...
print(a // b)  # Floor division: 3
print(a % b)   # Modulus: 1
print(a ** b)  # Exponentiation: 1000

# Comparison Operators
print(a == b)  # Equal: False
print(a != b)  # Not equal: True
print(a > b)   # Greater than: True
print(a < b)   # Less than: False
print(a >= b)  # Greater or equal: True
print(a <= b)  # Less or equal: False

# Logical Operators
x, y = True, False
print(x and y)  # AND: False
print(x or y)   # OR: True
print(not x)    # NOT: False

# Assignment Operators
a = 10
a += 5   # a = a + 5
a -= 3   # a = a - 3
a *= 2   # a = a * 2
a /= 4   # a = a / 4
a //= 2  # a = a // 2
a %= 3   # a = a % 3
a **= 2  # a = a ** 2

# Identity Operators
x = [1, 2, 3]
y = [1, 2, 3]
z = x

print(x is z)      # True (same object)
print(x is y)      # False (different objects)
print(x == y)      # True (same values)
print(x is not y)  # True

# Membership Operators
list_items = [1, 2, 3, 4, 5]
print(3 in list_items)      # True
print(10 not in list_items) # True

# Bitwise Operators
a, b = 5, 3  # 101, 011 in binary
print(a & b)   # AND: 1 (001)
print(a | b)   # OR: 7 (111)
print(a ^ b)   # XOR: 6 (110)
print(~a)      # NOT: -6
print(a << 1)  # Left shift: 10 (1010)
print(a >> 1)  # Right shift: 2 (010)

4. Data Types & Structures

Basic Data Types

# Integer
age = 25
big_number = 1_000_000  # Underscores for readability

# Float
price = 19.99
scientific = 1.5e-4  # 0.00015

# String
name = "John"
message = 'Hello'
multiline = """This is
a multiline
string"""

# Boolean
is_active = True
is_deleted = False

# None (null equivalent)
value = None

# Type checking
print(type(age))      # <class 'int'>
print(type(price))    # <class 'float'>
print(type(name))     # <class 'str'>
print(isinstance(age, int))  # True

# Type conversion
num_str = "123"
num_int = int(num_str)
num_float = float(num_str)
str_num = str(123)
bool_val = bool(1)  # True

Strings

# String creation
text = "Hello, World!"
single = 'Single quotes'
multiline = """Multiple
lines"""

# String indexing and slicing
text = "Python"
print(text[0])      # 'P'
print(text[-1])     # 'n'
print(text[0:3])    # 'Pyt'
print(text[:3])     # 'Pyt'
print(text[3:])     # 'hon'
print(text[::2])    # 'Pto' (every 2nd character)
print(text[::-1])   # 'nohtyP' (reverse)

# String methods
text = "  Hello, World!  "
print(text.lower())           # '  hello, world!  '
print(text.upper())           # '  HELLO, WORLD!  '
print(text.strip())           # 'Hello, World!'
print(text.replace("World", "Python"))  # '  Hello, Python!  '
print(text.split(","))        # ['  Hello', ' World!  ']
print(text.startswith("  H"))  # True
print(text.endswith("!  "))    # True
print(text.find("World"))      # 9
print(text.count("l"))         # 3

# String formatting
name = "Alice"
age = 30

# f-strings (Python 3.6+)
print(f"Name: {name}, Age: {age}")
print(f"{name.upper()}")
print(f"Result: {10 + 20}")
print(f"{name:>10}")  # Right align
print(f"{age:05d}")   # Zero padding: 00030

# format() method
print("Name: {}, Age: {}".format(name, age))
print("Name: {0}, Age: {1}".format(name, age))
print("Name: {n}, Age: {a}".format(n=name, a=age))

# % operator (old style)
print("Name: %s, Age: %d" % (name, age))

# String concatenation
greeting = "Hello" + " " + "World"
repeated = "Ha" * 3  # 'HaHaHa'

# Raw strings (ignore escape characters)
path = r"C:\Users\name\folder"

Lists

# List creation
numbers = [1, 2, 3, 4, 5]
mixed = [1, "two", 3.0, True, None]
nested = [[1, 2], [3, 4], [5, 6]]
empty = []

# List indexing and slicing
numbers = [0, 1, 2, 3, 4, 5]
print(numbers[0])      # 0
print(numbers[-1])     # 5
print(numbers[1:4])    # [1, 2, 3]
print(numbers[:3])     # [0, 1, 2]
print(numbers[3:])     # [3, 4, 5]
print(numbers[::2])    # [0, 2, 4]
print(numbers[::-1])   # [5, 4, 3, 2, 1, 0]

# List methods
fruits = ["apple", "banana", "cherry"]

# Adding elements
fruits.append("date")              # Add to end
fruits.insert(1, "blueberry")      # Insert at index
fruits.extend(["elderberry", "fig"])  # Add multiple

# Removing elements
fruits.remove("banana")  # Remove first occurrence
popped = fruits.pop()    # Remove and return last
popped = fruits.pop(0)   # Remove and return at index
del fruits[0]            # Delete by index
fruits.clear()           # Remove all elements

# Other methods
fruits = ["apple", "banana", "cherry", "banana"]
print(fruits.index("banana"))    # 1 (first occurrence)
print(fruits.count("banana"))    # 2
fruits.sort()                    # Sort in place
fruits.reverse()                 # Reverse in place
copy = fruits.copy()             # Create shallow copy

# List operations
list1 = [1, 2, 3]
list2 = [4, 5, 6]
combined = list1 + list2         # [1, 2, 3, 4, 5, 6]
repeated = list1 * 3             # [1, 2, 3, 1, 2, 3, 1, 2, 3]
print(2 in list1)                # True
print(len(list1))                # 3

# List comprehension
squares = [x**2 for x in range(10)]
evens = [x for x in range(20) if x % 2 == 0]
matrix = [[i*j for j in range(3)] for i in range(3)]

# Advanced list comprehension
numbers = [1, 2, 3, 4, 5]
result = [x*2 if x % 2 == 0 else x for x in numbers]  # [1, 4, 3, 8, 5]

Tuples

# Tuple creation (immutable)
point = (10, 20)
single = (1,)  # Note the comma
mixed = (1, "two", 3.0)
nested = ((1, 2), (3, 4))

# Tuple unpacking
x, y = point
a, b, c = mixed

# Tuple methods
numbers = (1, 2, 3, 2, 4, 2)
print(numbers.count(2))   # 3
print(numbers.index(3))   # 2

# Tuples are immutable
# numbers[0] = 10  # TypeError

# Use cases for tuples
# 1. Return multiple values from function
def get_coordinates():
    return (10, 20)

# 2. Dictionary keys (tuples are hashable)
locations = {
    (0, 0): "origin",
    (1, 1): "point A"
}

# 3. Data integrity (cannot be modified)
config = ("localhost", 8080, "admin")

Sets

# Set creation (unordered, unique elements)
numbers = {1, 2, 3, 4, 5}
mixed = {1, "two", 3.0}
empty = set()  # {} creates empty dict

# Set operations
set1 = {1, 2, 3, 4, 5}
set2 = {4, 5, 6, 7, 8}

# Union
print(set1 | set2)            # {1, 2, 3, 4, 5, 6, 7, 8}
print(set1.union(set2))

# Intersection
print(set1 & set2)            # {4, 5}
print(set1.intersection(set2))

# Difference
print(set1 - set2)            # {1, 2, 3}
print(set1.difference(set2))

# Symmetric difference
print(set1 ^ set2)            # {1, 2, 3, 6, 7, 8}
print(set1.symmetric_difference(set2))

# Set methods
numbers = {1, 2, 3}
numbers.add(4)              # Add element
numbers.update([5, 6, 7])   # Add multiple
numbers.remove(1)           # Remove (raises error if not found)
numbers.discard(10)         # Remove (no error if not found)
popped = numbers.pop()      # Remove and return arbitrary
numbers.clear()             # Remove all

# Set comprehension
squares = {x**2 for x in range(10)}

# Membership testing (very fast)
large_set = set(range(1000000))
print(999999 in large_set)  # O(1) time complexity

Dictionaries

# Dictionary creation (key-value pairs)
person = {
    "name": "John",
    "age": 30,
    "city": "New York"
}

# Alternative creation
person = dict(name="John", age=30, city="New York")

# Accessing values
print(person["name"])        # "John"
print(person.get("age"))     # 30
print(person.get("email", "N/A"))  # "N/A" (default value)

# Modifying dictionary
person["age"] = 31           # Update
person["email"] = "john@email.com"  # Add new
del person["city"]           # Delete

# Dictionary methods
person = {"name": "John", "age": 30, "city": "New York"}

print(person.keys())         # dict_keys(['name', 'age', 'city'])
print(person.values())       # dict_values(['John', 30, 'New York'])
print(person.items())        # dict_items([('name', 'John'), ...])

# Iterating
for key in person:
    print(key, person[key])

for key, value in person.items():
    print(f"{key}: {value}")

# Dictionary operations
person.update({"age": 31, "country": "USA"})  # Update/add multiple
email = person.pop("email", None)             # Remove and return
person.clear()                                # Remove all

# Dictionary comprehension
squares = {x: x**2 for x in range(5)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}

# Nested dictionaries
users = {
    "user1": {"name": "Alice", "age": 25},
    "user2": {"name": "Bob", "age": 30}
}

print(users["user1"]["name"])  # "Alice"

# Default dictionaries
from collections import defaultdict

word_count = defaultdict(int)
text = "hello world hello"
for word in text.split():
    word_count[word] += 1

print(dict(word_count))  # {'hello': 2, 'world': 1}

5. Control Flow

If-Elif-Else Statements

# Basic if statement
age = 18
if age >= 18:
    print("You are an adult")

# If-else
age = 15
if age >= 18:
    print("You are an adult")
else:
    print("You are a minor")

# If-elif-else
score = 85

if score >= 90:
    grade = "A"
elif score >= 80:
    grade = "B"
elif score >= 70:
    grade = "C"
elif score >= 60:
    grade = "D"
else:
    grade = "F"

print(f"Your grade is: {grade}")

# Nested if
age = 20
has_license = True

if age >= 18:
    if has_license:
        print("You can drive")
    else:
        print("You need a license")
else:
    print("You are too young to drive")

# Ternary operator
age = 20
status = "adult" if age >= 18 else "minor"

# Multiple conditions
x = 15
if x > 10 and x < 20:
    print("x is between 10 and 20")

if 10 < x < 20:  # Pythonic way
    print("x is between 10 and 20")

# Checking multiple values
fruit = "apple"
if fruit in ["apple", "banana", "orange"]:
    print("Valid fruit")

# Truthy and Falsy values
# Falsy: False, None, 0, 0.0, "", [], {}, ()
# Everything else is Truthy

name = ""
if name:
    print(f"Hello, {name}")
else:
    print("Name is empty")

# Short-circuit evaluation
def expensive_operation():
    print("This is expensive!")
    return True

x = 0
if x != 0 and expensive_operation():  # expensive_operation() not called
    print("Both conditions met")

For Loops

# Basic for loop
for i in range(5):
    print(i)  # 0, 1, 2, 3, 4

# Range with start, stop, step
for i in range(1, 10, 2):
    print(i)  # 1, 3, 5, 7, 9

# Iterating over list
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
    print(fruit)

# Enumerate (get index and value)
for index, fruit in enumerate(fruits):
    print(f"{index}: {fruit}")

for index, fruit in enumerate(fruits, start=1):
    print(f"{index}: {fruit}")

# Iterating over dictionary
person = {"name": "John", "age": 30, "city": "New York"}

for key in person:
    print(key)

for value in person.values():
    print(value)

for key, value in person.items():
    print(f"{key}: {value}")

# Iterating over string
for char in "Python":
    print(char)

# Nested loops
for i in range(3):
    for j in range(3):
        print(f"({i}, {j})", end=" ")
    print()

# Loop with else (executes if loop completes normally)
for i in range(5):
    if i == 10:
        break
else:
    print("Loop completed normally")

# Zip (iterate over multiple sequences)
names = ["Alice", "Bob", "Charlie"]
ages = [25, 30, 35]

for name, age in zip(names, ages):
    print(f"{name} is {age} years old")

# Practical examples
# Sum of numbers
total = 0
for i in range(1, 101):
    total += i
print(f"Sum: {total}")

# Multiplication table
n = 5
for i in range(1, 11):
    print(f"{n} x {i} = {n*i}")

# Finding maximum
numbers = [3, 7, 2, 9, 4]
max_num = numbers[0]
for num in numbers:
    if num > max_num:
        max_num = num
print(f"Maximum: {max_num}")

While Loops

# Basic while loop
count = 0
while count < 5:
    print(count)
    count += 1

# While with condition
password = ""
while password != "secret":
    password = input("Enter password: ")
print("Access granted!")

# While with break
count = 0
while True:
    print(count)
    count += 1
    if count >= 5:
        break

# While with continue
count = 0
while count < 10:
    count += 1
    if count % 2 == 0:
        continue
    print(count)  # Only odd numbers

# While with else
count = 0
while count < 5:
    print(count)
    count += 1
else:
    print("Loop completed")

# Infinite loop with break condition
while True:
    user_input = input("Enter 'quit' to exit: ")
    if user_input.lower() == 'quit':
        break
    print(f"You entered: {user_input}")

# Practical examples
# Factorial
n = 5
factorial = 1
i = 1
while i <= n:
    factorial *= i
    i += 1
print(f"Factorial of {n}: {factorial}")

# Guessing game
import random
target = random.randint(1, 100)
attempts = 0
while True:
    guess = int(input("Guess the number (1-100): "))
    attempts += 1
    if guess == target:
        print(f"Correct! You got it in {attempts} attempts")
        break
    elif guess < target:
        print("Too low!")
    else:
        print("Too high!")

Loop Control Statements

# break - exit loop immediately
for i in range(10):
    if i == 5:
        break
    print(i)  # 0, 1, 2, 3, 4

# continue - skip rest of current iteration
for i in range(10):
    if i % 2 == 0:
        continue
    print(i)  # 1, 3, 5, 7, 9

# pass - do nothing (placeholder)
for i in range(5):
    if i == 2:
        pass  # Will implement later
    else:
        print(i)

# Combining break and continue
numbers = [1, 2, -1, 4, 5, -2, 7]
for num in numbers:
    if num < 0:
        break  # Stop at first negative
    if num % 2 == 0:
        continue  # Skip even numbers
    print(num)  # Only odd positive numbers

6. Functions & Modules

Defining Functions

# Basic function
def greet():
    print("Hello, World!")

greet()  # Call function

# Function with parameters
def greet_person(name):
    print(f"Hello, {name}!")

greet_person("Alice")

# Function with multiple parameters
def add(a, b):
    return a + b

result = add(5, 3)
print(result)  # 8

# Function with default parameters
def greet_with_title(name, title="Mr."):
    print(f"Hello, {title} {name}")

greet_with_title("Smith")           # Hello, Mr. Smith
greet_with_title("Smith", "Dr.")    # Hello, Dr. Smith

# Keyword arguments
def describe_person(name, age, city):
    print(f"{name} is {age} years old and lives in {city}")

describe_person(age=30, city="NYC", name="John")

# *args - variable number of arguments
def sum_all(*numbers):
    total = 0
    for num in numbers:
        total += num
    return total

print(sum_all(1, 2, 3))           # 6
print(sum_all(1, 2, 3, 4, 5))     # 15

# **kwargs - variable keyword arguments
def print_info(**info):
    for key, value in info.items():
        print(f"{key}: {value}")

print_info(name="John", age=30, city="NYC")

# Combining args and kwargs
def complex_function(a, b, *args, **kwargs):
    print(f"a: {a}, b: {b}")
    print(f"args: {args}")
    print(f"kwargs: {kwargs}")

complex_function(1, 2, 3, 4, 5, x=10, y=20)

# Return multiple values
def get_stats(numbers):
    return min(numbers), max(numbers), sum(numbers) / len(numbers)

minimum, maximum, average = get_stats([1, 2, 3, 4, 5])

# Return dictionary
def get_person():
    return {"name": "John", "age": 30}

person = get_person()

Function Scope

# Global vs Local scope
global_var = "I'm global"

def my_function():
    local_var = "I'm local"
    print(global_var)  # Can access global
    print(local_var)

my_function()
# print(local_var)  # Error: local_var not defined

# Global keyword
counter = 0

def increment():
    global counter
    counter += 1

increment()
print(counter)  # 1

# Nonlocal keyword (for nested functions)
def outer():
    x = "outer"

    def inner():
        nonlocal x
        x = "inner"

    inner()
    print(x)  # "inner"

outer()

# LEGB Rule (Local, Enclosing, Global, Built-in)
x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(x)  # "local"

    inner()
    print(x)  # "enclosing"

outer()
print(x)  # "global"

Lambda Functions

# Basic lambda
square = lambda x: x ** 2
print(square(5))  # 25

# Lambda with multiple parameters
add = lambda a, b: a + b
print(add(3, 5))  # 8

# Lambda in map()
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x**2, numbers))
print(squared)  # [1, 4, 9, 16, 25]

# Lambda in filter()
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)  # [2, 4]

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

sorted_students = sorted(students, key=lambda x: x["grade"], reverse=True)

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

Built-in Functions

# Numeric functions
print(abs(-5))           # 5
print(pow(2, 3))         # 8
print(round(3.14159, 2)) # 3.14
print(max(1, 5, 3))      # 5
print(min(1, 5, 3))      # 1
print(sum([1, 2, 3, 4])) # 10

# Type conversion
print(int("123"))        # 123
print(float("3.14"))     # 3.14
print(str(123))          # "123"
print(list("hello"))     # ['h', 'e', 'l', 'l', 'o']
print(tuple([1, 2, 3]))  # (1, 2, 3)
print(set([1, 2, 2, 3])) # {1, 2, 3}

# Sequence functions
numbers = [3, 1, 4, 1, 5, 9, 2, 6]
print(len(numbers))      # 8
print(sorted(numbers))   # [1, 1, 2, 3, 4, 5, 6, 9]
print(reversed(numbers)) # reverse iterator

# Map, filter, reduce
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x**2, numbers))
evens = list(filter(lambda x: x % 2 == 0, numbers))

from functools import reduce
product = reduce(lambda x, y: x * y, numbers)  # 120

# Zip
names = ["Alice", "Bob"]
ages = [25, 30]
combined = list(zip(names, ages))
# [('Alice', 25), ('Bob', 30)]

# Enumerate
fruits = ["apple", "banana", "cherry"]
for i, fruit in enumerate(fruits, start=1):
    print(f"{i}. {fruit}")

# Any and All
numbers = [2, 4, 6, 8]
print(all(x % 2 == 0 for x in numbers))  # True
print(any(x > 5 for x in numbers))       # True

# Range
print(list(range(5)))          # [0, 1, 2, 3, 4]
print(list(range(1, 6)))       # [1, 2, 3, 4, 5]
print(list(range(0, 10, 2)))   # [0, 2, 4, 6, 8]

Modules and Packages

# Importing modules
import math
print(math.pi)        # 3.141592653589793
print(math.sqrt(16))  # 4.0

# Import specific functions
from math import pi, sqrt
print(pi)
print(sqrt(16))

# Import with alias
import datetime as dt
now = dt.datetime.now()

from pandas as pd
import numpy as np

# Import all (not recommended)
from math import *

# Creating your own module
# mymodule.py
def greet(name):
    return f"Hello, {name}!"

PI = 3.14159

# Using your module
# import mymodule
# print(mymodule.greet("Alice"))
# print(mymodule.PI)

# Package structure
"""
mypackage/
    __init__.py
    module1.py
    module2.py
    subpackage/
        __init__.py
        module3.py
"""

# Using packages
# from mypackage import module1
# from mypackage.subpackage import module3

# Useful standard library modules
import os        # Operating system interface
import sys       # System-specific parameters
import json      # JSON encoding/decoding
import random    # Random number generation
import datetime  # Date and time
import re        # Regular expressions
import collections  # Specialized container datatypes
import itertools    # Iterator functions
import functools    # Higher-order functions

7. Object-Oriented Programming (OOP)

Classes and Objects

# Defining a class
class Person:
    # Class variable (shared by all instances)
    species = "Homo sapiens"

    # Constructor
    def __init__(self, name, age):
        # Instance variables
        self.name = name
        self.age = age

    # Instance method
    def introduce(self):
        return f"Hi, I'm {self.name} and I'm {self.age} years old"

    # Method with parameters
    def have_birthday(self):
        self.age += 1
        return f"Happy birthday! Now {self.age} years old"

# Creating objects
person1 = Person("Alice", 25)
person2 = Person("Bob", 30)

# Accessing attributes
print(person1.name)  # "Alice"
print(person1.age)   # 25

# Calling methods
print(person1.introduce())
person1.have_birthday()

# Accessing class variables
print(Person.species)
print(person1.species)

Encapsulation

class BankAccount:
    def __init__(self, account_number, balance):
        self.account_number = account_number  # Public
        self._balance = balance               # Protected (convention)
        self.__pin = "1234"                   # Private (name mangling)

    # Getter
    def get_balance(self):
        return self._balance

    # Setter
    def deposit(self, amount):
        if amount > 0:
            self._balance += amount
            return True
        return False

    def withdraw(self, amount):
        if 0 < amount <= self._balance:
            self._balance -= amount
            return True
        return False

    # Private method
    def __validate_pin(self, pin):
        return pin == self.__pin

account = BankAccount("123456", 1000)
account.deposit(500)
print(account.get_balance())  # 1500

# Cannot access private directly
# print(account.__pin)  # AttributeError

# But can access with name mangling (not recommended)
# print(account._BankAccount__pin)

# Property decorators
class Person:
    def __init__(self, name, age):
        self._name = name
        self._age = age

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if isinstance(value, str) and value:
            self._name = value
        else:
            raise ValueError("Name must be a non-empty string")

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if isinstance(value, int) and 0 <= value <= 150:
            self._age = value
        else:
            raise ValueError("Age must be between 0 and 150")

person = Person("Alice", 25)
person.name = "Alicia"  # Using setter
print(person.name)      # Using getter

Inheritance

# Single inheritance
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}"

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name, "Dog")  # Call parent constructor
        self.breed = breed

    # Method overriding
    def make_sound(self):
        return "Woof!"

    # Additional method
    def fetch(self):
        return f"{self.name} is fetching the ball"

class Cat(Animal):
    def __init__(self, name, color):
        super().__init__(name, "Cat")
        self.color = color

    def make_sound(self):
        return "Meow!"

# Using inherited classes
dog = Dog("Buddy", "Golden Retriever")
print(dog.info())        # Inherited method
print(dog.make_sound())  # Overridden method
print(dog.fetch())       # New method

cat = Cat("Whiskers", "Orange")
print(cat.make_sound())

# Multiple inheritance
class Flyable:
    def fly(self):
        return "Flying in the air"

class Swimmable:
    def swim(self):
        return "Swimming in water"

class Duck(Animal, Flyable, Swimmable):
    def __init__(self, name):
        super().__init__(name, "Duck")

    def make_sound(self):
        return "Quack!"

duck = Duck("Donald")
print(duck.make_sound())
print(duck.fly())
print(duck.swim())

# Method Resolution Order (MRO)
print(Duck.__mro__)

Polymorphism

# Method overriding (runtime polymorphism)
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):
        return 3.14159 * self.radius ** 2

    def perimeter(self):
        return 2 * 3.14159 * self.radius

# Polymorphism in action
shapes = [
    Rectangle(5, 10),
    Circle(7),
    Rectangle(3, 4)
]

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

# Duck typing
class Dog:
    def speak(self):
        return "Woof!"

class Cat:
    def speak(self):
        return "Meow!"

class Robot:
    def speak(self):
        return "Beep boop!"

def animal_sound(animal):
    print(animal.speak())

# Works with any object that has a speak() method
animal_sound(Dog())
animal_sound(Cat())
animal_sound(Robot())

Special Methods (Magic Methods)

class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages

    # String representation
    def __str__(self):
        return f"{self.title} by {self.author}"

    def __repr__(self):
        return f"Book('{self.title}', '{self.author}', {self.pages})"

    # Length
    def __len__(self):
        return self.pages

    # Comparison operators
    def __eq__(self, other):
        return self.pages == other.pages

    def __lt__(self, other):
        return self.pages < other.pages

    def __gt__(self, other):
        return self.pages > other.pages

    # Addition
    def __add__(self, other):
        return self.pages + other.pages

    # Indexing
    def __getitem__(self, index):
        return self.title[index]

book1 = Book("Python Crash Course", "Eric Matthes", 544)
book2 = Book("Automate the Boring Stuff", "Al Sweigart", 592)

print(str(book1))      # Uses __str__
print(repr(book1))     # Uses __repr__
print(len(book1))      # Uses __len__
print(book1 == book2)  # Uses __eq__
print(book1 < book2)   # Uses __lt__
print(book1 + book2)   # Uses __add__
print(book1[0])        # Uses __getitem__

# Context manager
class FileManager:
    def __init__(self, filename, mode):
        self.filename = filename
        self.mode = mode
        self.file = None

    def __enter__(self):
        self.file = open(self.filename, self.mode)
        return self.file

    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.file:
            self.file.close()

# Usage
with FileManager('test.txt', 'w') as f:
    f.write('Hello, World!')

Class Methods and Static Methods

class MyClass:
    class_variable = 0

    def __init__(self, value):
        self.value = value

    # Instance method
    def instance_method(self):
        return f"Instance method called with value: {self.value}"

    # Class method
    @classmethod
    def class_method(cls):
        cls.class_variable += 1
        return f"Class method called. Class variable: {cls.class_variable}"

    # Static method
    @staticmethod
    def static_method(x, y):
        return x + y

    # Alternative constructor (factory method)
    @classmethod
    def from_string(cls, string_value):
        value = int(string_value)
        return cls(value)

# Usage
obj = MyClass(10)
print(obj.instance_method())

print(MyClass.class_method())
print(MyClass.class_method())

print(MyClass.static_method(5, 3))

# Using factory method
obj2 = MyClass.from_string("20")
print(obj2.value)

Abstract Classes

from abc import ABC, abstractmethod

class Vehicle(ABC):
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model

    @abstractmethod
    def start_engine(self):
        pass

    @abstractmethod
    def stop_engine(self):
        pass

    def info(self):
        return f"{self.brand} {self.model}"

class Car(Vehicle):
    def start_engine(self):
        return "Car engine started with key"

    def stop_engine(self):
        return "Car engine stopped"

class Motorcycle(Vehicle):
    def start_engine(self):
        return "Motorcycle engine started with button"

    def stop_engine(self):
        return "Motorcycle engine stopped"

# Cannot instantiate abstract class
# vehicle = Vehicle("Generic", "Model")  # TypeError

car = Car("Toyota", "Camry")
print(car.info())
print(car.start_engine())

8. File Handling & I/O

Reading Files

# Method 1: Using open() and close()
file = open('example.txt', 'r')
content = file.read()
print(content)
file.close()

# Method 2: Using with statement (recommended)
with open('example.txt', 'r') as file:
    content = file.read()
    print(content)
# File automatically closed

# Read line by line
with open('example.txt', 'r') as file:
    for line in file:
        print(line.strip())

# Read all lines into list
with open('example.txt', 'r') as file:
    lines = file.readlines()
    print(lines)

# Read specific number of characters
with open('example.txt', 'r') as file:
    chunk = file.read(100)  # Read first 100 characters

# Read one line at a time
with open('example.txt', 'r') as file:
    line1 = file.readline()
    line2 = file.readline()

Writing Files

# Write (overwrites existing content)
with open('output.txt', 'w') as file:
    file.write("Hello, World!\n")
    file.write("This is a new line\n")

# Write multiple lines
lines = ["Line 1\n", "Line 2\n", "Line 3\n"]
with open('output.txt', 'w') as file:
    file.writelines(lines)

# Append (adds to existing content)
with open('output.txt', 'a') as file:
    file.write("This is appended\n")

# Write and read
with open('data.txt', 'w+') as file:
    file.write("Some data")
    file.seek(0)  # Move to beginning
    content = file.read()
    print(content)

File Modes

"""
'r'  - Read (default)
'w'  - Write (overwrites)
'a'  - Append
'x'  - Exclusive creation (fails if file exists)
'b'  - Binary mode
't'  - Text mode (default)
'+'  - Read and write

Examples:
'rb' - Read binary
'wb' - Write binary
'r+' - Read and write
"""

# Binary files
with open('image.jpg', 'rb') as file:
    data = file.read()

with open('copy.jpg', 'wb') as file:
    file.write(data)

Working with CSV Files

import csv

# Writing CSV
data = [
    ['Name', 'Age', 'City'],
    ['Alice', 25, 'New York'],
    ['Bob', 30, 'San Francisco'],
    ['Charlie', 35, 'Chicago']
]

with open('people.csv', 'w', newline='') as file:
    writer = csv.writer(file)
    writer.writerows(data)

# Reading CSV
with open('people.csv', 'r') as file:
    reader = csv.reader(file)
    for row in reader:
        print(row)

# Using DictWriter
with open('people.csv', 'w', newline='') as file:
    fieldnames = ['Name', 'Age', 'City']
    writer = csv.DictWriter(file, fieldnames=fieldnames)

    writer.writeheader()
    writer.writerow({'Name': 'Alice', 'Age': 25, 'City': 'New York'})
    writer.writerow({'Name': 'Bob', 'Age': 30, 'City': 'San Francisco'})

# Using DictReader
with open('people.csv', 'r') as file:
    reader = csv.DictReader(file)
    for row in reader:
        print(f"{row['Name']} is {row['Age']} years old")

Working with JSON

import json

# Python to JSON
data = {
    "name": "John",
    "age": 30,
    "city": "New York",
    "hobbies": ["reading", "gaming"],
    "married": False
}

# Write JSON to file
with open('data.json', 'w') as file:
    json.dump(data, file, indent=4)

# Convert to JSON string
json_string = json.dumps(data, indent=4)
print(json_string)

# JSON to Python
with open('data.json', 'r') as file:
    loaded_data = json.load(file)
    print(loaded_data)

# Parse JSON string
json_string = '{"name": "Alice", "age": 25}'
parsed = json.loads(json_string)
print(parsed['name'])

# Pretty printing
print(json.dumps(data, indent=4, sort_keys=True))

Working with Path and Directories

import os
from pathlib import Path

# Current working directory
print(os.getcwd())

# Change directory
os.chdir('/path/to/directory')

# List files in directory
files = os.listdir('.')
print(files)

# Check if path exists
if os.path.exists('myfile.txt'):
    print("File exists")

# Check if it's a file or directory
print(os.path.isfile('myfile.txt'))
print(os.path.isdir('mydir'))

# Create directory
os.mkdir('new_directory')
os.makedirs('parent/child/grandchild')  # Create nested directories

# Remove directory
os.rmdir('directory')  # Empty directory only
import shutil
shutil.rmtree('directory')  # Directory with contents

# File operations
os.rename('old.txt', 'new.txt')
os.remove('file.txt')
shutil.copy('source.txt', 'dest.txt')
shutil.move('source.txt', 'destination/')

# Path manipulation
file_path = '/home/user/documents/file.txt'
print(os.path.basename(file_path))  # 'file.txt'
print(os.path.dirname(file_path))   # '/home/user/documents'
print(os.path.split(file_path))     # ('/home/user/documents', 'file.txt')
print(os.path.splitext(file_path))  # ('/home/user/documents/file', '.txt')

# Join paths
path = os.path.join('folder', 'subfolder', 'file.txt')

# Using pathlib (modern approach)
p = Path('documents/report.txt')
print(p.exists())
print(p.is_file())
print(p.parent)
print(p.name)
print(p.stem)  # filename without extension
print(p.suffix)  # extension

# Create file
p = Path('new_file.txt')
p.touch()

# Write to file
p.write_text('Hello, World!')

# Read from file
content = p.read_text()

# Iterate over directory
for file in Path('.').iterdir():
    if file.is_file():
        print(file.name)

# Glob patterns
for file in Path('.').glob('*.txt'):
    print(file)

for file in Path('.').rglob('*.py'):  # Recursive
    print(file)

9. Exception Handling

Try-Except Blocks

# Basic exception handling
try:
    number = int(input("Enter a number: "))
    result = 10 / number
    print(f"Result: {result}")
except:
    print("An error occurred")

# Specific exceptions
try:
    number = int(input("Enter a number: "))
    result = 10 / number
except ValueError:
    print("Invalid input! Please enter a number")
except ZeroDivisionError:
    print("Cannot divide by zero!")

# Multiple exceptions in one except
try:
    # some code
    pass
except (ValueError, TypeError, KeyError) as e:
    print(f"Error occurred: {e}")

# Generic exception with details
try:
    # some code
    pass
except Exception as e:
    print(f"An error occurred: {type(e).__name__}: {e}")

# Else clause (executes if no exception)
try:
    number = int(input("Enter a number: "))
    result = 10 / number
except ValueError:
    print("Invalid input!")
except ZeroDivisionError:
    print("Cannot divide by zero!")
else:
    print(f"Result: {result}")

# Finally clause (always executes)
try:
    file = open('data.txt', 'r')
    content = file.read()
except FileNotFoundError:
    print("File not found!")
finally:
    file.close()  # Always closes file
    print("Cleanup completed")

# Nested try-except
try:
    try:
        number = int(input("Enter a number: "))
    except ValueError:
        print("Invalid input!")
        raise  # Re-raise the exception

    result = 10 / number
except ZeroDivisionError:
    print("Cannot divide by zero!")

Raising Exceptions

# Raise built-in exception
def check_age(age):
    if age < 0:
        raise ValueError("Age cannot be negative")
    if age < 18:
        raise Exception("Must be 18 or older")
    return "Access granted"

try:
    check_age(-5)
except ValueError as e:
    print(f"ValueError: {e}")

# Raise with custom message
def divide(a, b):
    if b == 0:
        raise ZeroDivisionError("Division by zero is not allowed!")
    return a / b

# Re-raising exceptions
try:
    # some code
    pass
except ValueError:
    print("Handling error...")
    raise  # Re-raise the same exception

Custom Exceptions

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

class BankAccount:
    def __init__(self, balance):
        self.balance = balance

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

# Using custom exception
account = BankAccount(1000)
try:
    account.withdraw(1500)
except InsufficientFundsError as e:
    print(f"Error: {e}")
    print(f"Your balance: ${e.balance}")
    print(f"Amount requested: ${e.amount}")

# Exception hierarchy
class ValidationError(Exception):
    pass

class EmailValidationError(ValidationError):
    pass

class PasswordValidationError(ValidationError):
    pass

def validate_email(email):
    if '@' not in email:
        raise EmailValidationError("Invalid email format")

def validate_password(password):
    if len(password) < 8:
        raise PasswordValidationError("Password must be at least 8 characters")

try:
    validate_email("invalid-email")
except ValidationError as e:
    print(f"Validation error: {e}")

Common Built-in Exceptions

"""
Exception           - Base class for all exceptions
AttributeError      - Attribute not found
IOError             - I/O operation failed
ImportError         - Import failed
IndexError          - Index out of range
KeyError            - Key not found in dictionary
KeyboardInterrupt   - User interrupted execution (Ctrl+C)
MemoryError         - Out of memory
NameError           - Variable not found
OSError             - Operating system error
RuntimeError        - Generic runtime error
StopIteration       - Iterator has no more items
SyntaxError         - Syntax error in code
TypeError           - Operation on incompatible types
ValueError          - Invalid value
ZeroDivisionError   - Division by zero
FileNotFoundError   - File not found
PermissionError     - Permission denied
"""

# Examples
try:
    my_list = [1, 2, 3]
    print(my_list[10])
except IndexError:
    print("List index out of range")

try:
    my_dict = {'a': 1, 'b': 2}
    print(my_dict['c'])
except KeyError:
    print("Key not found in dictionary")

try:
    result = "hello" + 5
except TypeError:
    print("Cannot concatenate string and integer")

Assertions

# Assertions for debugging
def calculate_average(numbers):
    assert len(numbers) > 0, "List cannot be empty"
    return sum(numbers) / len(numbers)

# This will raise AssertionError
try:
    average = calculate_average([])
except AssertionError as e:
    print(f"Assertion failed: {e}")

# Assertions are disabled when Python runs in optimized mode
# python -O script.py

# Use assertions for:
# - Debugging
# - Checking invariants
# - Validating developer assumptions

# Don't use assertions for:
# - Data validation (use exceptions instead)
# - Production error handling

10. Advanced Python Concepts

Decorators

# Function decorator
def uppercase_decorator(function):
    def wrapper():
        result = function()
        return result.upper()
    return wrapper

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

print(greet())  # "HELLO, WORLD"

# Decorator with arguments
def repeat(times):
    def decorator(function):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = function(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def say_hello(name):
    print(f"Hello, {name}!")

say_hello("Alice")

# Preserving metadata
from functools import wraps

def my_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("Before function call")
        result = func(*args, **kwargs)
        print("After function call")
        return result
    return wrapper

@my_decorator
def example_function():
    """This is an example function"""
    print("Function is running")

print(example_function.__name__)  # 'example_function'
print(example_function.__doc__)   # 'This is an example function'

# Class decorators
def singleton(cls):
    instances = {}
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class Database:
    def __init__(self):
        print("Database connection created")

db1 = Database()
db2 = Database()  # Same instance
print(db1 is db2)  # True

# Built-in decorators
class MyClass:
    @staticmethod
    def static_method():
        return "This is a static method"

    @classmethod
    def class_method(cls):
        return f"This is a class method of {cls.__name__}"

    @property
    def my_property(self):
        return "This is a property"

Generators

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

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

for num in count_up_to(5):
    print(num)

# Generator expression
squares = (x**2 for x in range(10))
print(next(squares))  # 0
print(next(squares))  # 1

# List comprehension vs Generator expression
list_comp = [x**2 for x in range(1000000)]  # Creates full list in memory
gen_exp = (x**2 for x in range(1000000))    # Creates values on demand

# Practical generator example
def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

fib = fibonacci()
for _ in range(10):
    print(next(fib))

# Generator with send()
def echo():
    while True:
        value = yield
        print(f"Received: {value}")

gen = echo()
next(gen)  # Prime the generator
gen.send("Hello")
gen.send("World")

# Reading large files
def read_large_file(file_path):
    with open(file_path, 'r') as file:
        for line in file:
            yield line.strip()

# for line in read_large_file('huge_file.txt'):
#     process(line)

Iterators

# Creating an iterator class
class Counter:
    def __init__(self, start, end):
        self.current = start
        self.end = end

    def __iter__(self):
        return self

    def __next__(self):
        if self.current > self.end:
            raise StopIteration
        self.current += 1
        return self.current - 1

counter = Counter(1, 5)
for num in counter:
    print(num)

# Using iter() and next()
my_list = [1, 2, 3, 4]
iterator = iter(my_list)
print(next(iterator))  # 1
print(next(iterator))  # 2

# Infinite iterator
from itertools import count, cycle, repeat

# count(start, step)
for num in count(10, 2):
    if num > 20:
        break
    print(num)  # 10, 12, 14, 16, 18, 20

# cycle(iterable)
colors = cycle(['red', 'green', 'blue'])
for _ in range(7):
    print(next(colors))  # red, green, blue, red, green, blue, red

# repeat(value, times)
for num in repeat(5, 3):
    print(num)  # 5, 5, 5

Context Managers

# Using with statement
with open('file.txt', 'r') as file:
    content = file.read()
# File automatically closed

# Creating custom context manager (class-based)
class FileManager:
    def __init__(self, filename, mode):
        self.filename = filename
        self.mode = mode

    def __enter__(self):
        self.file = open(self.filename, self.mode)
        return self.file

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.file.close()
        if exc_type is not None:
            print(f"An exception occurred: {exc_val}")
        return False  # Propagate exception

with FileManager('test.txt', 'w') as f:
    f.write('Hello, World!')

# Creating context manager with contextlib
from contextlib import contextmanager

@contextmanager
def file_manager(filename, mode):
    file = open(filename, mode)
    try:
        yield file
    finally:
        file.close()

with file_manager('test.txt', 'w') as f:
    f.write('Hello, World!')

# Practical example: Database connection
@contextmanager
def database_connection(db_name):
    # Setup
    print(f"Connecting to {db_name}")
    connection = f"Connection to {db_name}"

    try:
        yield connection
    finally:
        # Teardown
        print(f"Closing connection to {db_name}")

with database_connection("mydb") as conn:
    print(f"Using {conn}")

List, Dict, and Set Comprehensions

# List comprehension
squares = [x**2 for x in range(10)]
evens = [x for x in range(20) if x % 2 == 0]
matrix = [[i*j for j in range(3)] for i in range(3)]

# With if-else
numbers = [1, 2, 3, 4, 5]
result = [x*2 if x % 2 == 0 else x for x in numbers]

# Nested comprehension
flattened = [num for row in matrix for num in row]

# Dictionary comprehension
squares_dict = {x: x**2 for x in range(5)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}

word_lengths = {word: len(word) for word in ["apple", "banana", "cherry"]}
# {'apple': 5, 'banana': 6, 'cherry': 6}

# Swap keys and values
original = {'a': 1, 'b': 2, 'c': 3}
swapped = {v: k for k, v in original.items()}
# {1: 'a', 2: 'b', 3: 'c'}

# Set comprehension
unique_squares = {x**2 for x in [1, 2, 2, 3, 3, 4]}
# {1, 4, 9, 16}

# Generator expression (not a comprehension, but similar)
gen = (x**2 for x in range(1000000))  # Memory efficient

Advanced Function Concepts

# *args and **kwargs
def flexible_function(*args, **kwargs):
    print("Positional arguments:", args)
    print("Keyword arguments:", kwargs)

flexible_function(1, 2, 3, name="John", age=30)

# Unpacking
def add(a, b, c):
    return a + b + c

numbers = [1, 2, 3]
result = add(*numbers)  # Unpacking list

person = {'a': 1, 'b': 2, 'c': 3}
result = add(**person)  # Unpacking dictionary

# Closures
def outer_function(x):
    def inner_function(y):
        return x + y
    return inner_function

add_five = outer_function(5)
print(add_five(3))  # 8
print(add_five(10))  # 15

# Partial functions
from functools import partial

def power(base, exponent):
    return base ** exponent

square = partial(power, exponent=2)
cube = partial(power, exponent=3)

print(square(5))  # 25
print(cube(3))    # 27

# Function annotations (type hints)
def greet(name: str, age: int) -> str:
    return f"{name} is {age} years old"

def calculate(x: float, y: float) -> float:
    return x + y

# Recursive functions
def factorial(n):
    if n == 0 or n == 1:
        return 1
    return n * factorial(n - 1)

print(factorial(5))  # 120

def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

# Memoization for optimization
from functools import lru_cache

@lru_cache(maxsize=None)
def fibonacci_optimized(n):
    if n <= 1:
        return n
    return fibonacci_optimized(n-1) + fibonacci_optimized(n-2)

print(fibonacci_optimized(100))  # Much faster

11. Standard Library Deep Dive

Collections Module

from collections import Counter, defaultdict, OrderedDict, deque, namedtuple

# Counter - count occurrences
text = "hello world hello"
word_count = Counter(text.split())
print(word_count)  # Counter({'hello': 2, 'world': 1})
print(word_count.most_common(1))  # [('hello', 2)]

numbers = [1, 2, 2, 3, 3, 3, 4, 4, 4, 4]
num_count = Counter(numbers)
print(num_count[4])  # 4

# defaultdict - provide default values
dd = defaultdict(list)
dd['colors'].append('red')
dd['colors'].append('blue')
print(dd['colors'])  # ['red', 'blue']
print(dd['numbers'])  # []

word_index = defaultdict(list)
text = "the quick brown fox jumps over the lazy dog"
for index, word in enumerate(text.split()):
    word_index[len(word)].append(word)

# OrderedDict - maintains insertion order (less needed in Python 3.7+)
od = OrderedDict()
od['a'] = 1
od['b'] = 2
od['c'] = 3

# deque - double-ended queue (efficient append/pop from both ends)
dq = deque([1, 2, 3, 4, 5])
dq.append(6)        # Add to right
dq.appendleft(0)    # Add to left
dq.pop()            # Remove from right
dq.popleft()        # Remove from left
dq.rotate(2)        # Rotate right
print(dq)

# namedtuple - lightweight object
Point = namedtuple('Point', ['x', 'y'])
p = Point(10, 20)
print(p.x, p.y)
print(p[0], p[1])

Person = namedtuple('Person', ['name', 'age', 'city'])
person = Person('Alice', 30, 'NYC')
print(person.name)

Datetime Module

from datetime import datetime, date, time, timedelta

# Current date and time
now = datetime.now()
print(now)

today = date.today()
print(today)

# Create specific date/time
dt = datetime(2024, 12, 25, 10, 30, 0)
print(dt)

d = date(2024, 12, 25)
t = time(10, 30, 45)

# Formatting
print(now.strftime("%Y-%m-%d %H:%M:%S"))
print(now.strftime("%B %d, %Y"))
print(now.strftime("%A, %I:%M %p"))

# Parsing
date_string = "2024-12-25 10:30:00"
dt = datetime.strptime(date_string, "%Y-%m-%d %H:%M:%S")

# Date arithmetic
tomorrow = today + timedelta(days=1)
next_week = today + timedelta(weeks=1)
past = today - timedelta(days=30)

# Time difference
date1 = datetime(2024, 1, 1)
date2 = datetime(2024, 12, 31)
diff = date2 - date1
print(diff.days)  # Days between dates

# Components
print(now.year, now.month, now.day)
print(now.hour, now.minute, now.second)
print(now.weekday())  # Monday is 0
print(now.isoweekday())  # Monday is 1

# Timezone-aware datetime
from datetime import timezone
utc_now = datetime.now(timezone.utc)
print(utc_now)

Regular Expressions (re module)

import re

# Basic matching
text = "The quick brown fox"
match = re.search(r'quick', text)
if match:
    print("Found:", match.group())

# Finding all matches
text = "cat bat rat mat"
matches = re.findall(r'.at', text)
print(matches)  # ['cat', 'bat', 'rat', 'mat']

# Substitution
text = "Hello World"
new_text = re.sub(r'World', 'Python', text)
print(new_text)  # "Hello Python"

# Splitting
text = "apple,banana;orange:grape"
fruits = re.split(r'[,;:]', text)
print(fruits)

# Pattern matching
email = "user@example.com"
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+
if re.match(pattern, email):
    print("Valid email")

# Groups
text = "John Doe, age 30"
pattern = r'(\w+) (\w+), age (\d+)'
match = re.search(pattern, text)
if match:
    print(match.group(1))  # John
    print(match.group(2))  # Doe
    print(match.group(3))  # 30
    print(match.groups())  # ('John', 'Doe', '30')

# Named groups
pattern = r'(?P<first>\w+) (?P<last>\w+), age (?P<age>\d+)'
match = re.search(pattern, text)
if match:
    print(match.group('first'))
    print(match.groupdict())

# Common patterns
"""
.       - Any character except newline
^       - Start of string
$       - End of string
*       - 0 or more repetitions
+       - 1 or more repetitions
?       - 0 or 1 repetition
{m,n}   - Between m and n repetitions
[]      - Character set
|       - OR operator
()      - Grouping
\d      - Digit [0-9]
\D      - Non-digit
\w      - Word character [a-zA-Z0-9_]
\W      - Non-word character
\s      - Whitespace
\S      - Non-whitespace
"""

# Practical examples
# Phone number
phone = "123-456-7890"
if re.match(r'\d{3}-\d{3}-\d{4}', phone):
    print("Valid phone number")

# URL
url = "https://www.example.com/page"
if re.match(r'https?://[\w\.-]+\.\w+', url):
    print("Valid URL")

# Extract numbers
text = "Price: $29.99, Discount: 15%"
numbers = re.findall(r'\d+\.?\d*', text)
print(numbers)  # ['29.99', '15']

Itertools Module

from itertools import *

# count - infinite counter
for i in count(10, 2):
    if i > 20:
        break
    print(i)  # 10, 12, 14, 16, 18, 20

# cycle - infinite loop through iterable
colors = cycle(['red', 'green', 'blue'])
for _ in range(7):
    print(next(colors))

# repeat - repeat value
for x in repeat(10, 3):
    print(x)  # 10, 10, 10

# chain - combine iterables
result = list(chain([1, 2], [3, 4], [5, 6]))
print(result)  # [1, 2, 3, 4, 5, 6]

# compress - filter based on selectors
data = ['A', 'B', 'C', 'D']
selectors = [1, 0, 1, 0]
result = list(compress(data, selectors))
print(result)  # ['A', 'C']

# dropwhile - drop while condition is true
data = [1, 4, 6, 4, 1]
result = list(dropwhile(lambda x: x < 5, data))
print(result)  # [6, 4, 1]

# takewhile - take while condition is true
result = list(takewhile(lambda x: x < 5, data))
print(result)  # [1, 4]

# groupby - group consecutive elements
data = [('A', 1), ('A', 2), ('B', 3), ('B', 4), ('C', 5)]
for key, group in groupby(data, lambda x: x[0]):
    print(key, list(group))

# combinations - all combinations
result = list(combinations([1, 2, 3], 2))
print(result)  # [(1, 2), (1, 3), (2, 3)]

# permutations - all permutations
result = list(permutations([1, 2, 3], 2))
print(result)  # [(1, 2), (1, 3), (2, 1), (2, 3), (3, 1), (3, 2)]

# product - cartesian product
result = list(product([1, 2], ['a', 'b']))
print(result)  # [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b')]

# accumulate - cumulative operation
data = [1, 2, 3, 4, 5]
result = list(accumulate(data))
print(result)  # [1, 3, 6, 10, 15]

result = list(accumulate(data, lambda x, y: x * y))
print(result)  # [1, 2, 6, 24, 120]

Random Module

import random

# Random float between 0 and 1
print(random.random())

# Random integer
print(random.randint(1, 10))  # 1 to 10 inclusive
print(random.randrange(1, 10))  # 1 to 9

# Random float in range
print(random.uniform(1.5, 10.5))

# Random choice
fruits = ['apple', 'banana', 'cherry']
print(random.choice(fruits))

# Random sample (without replacement)
numbers = list(range(1, 51))
lottery = random.sample(numbers, 6)
print(lottery)

# Random choices (with replacement)
result = random.choices(['heads', 'tails'], k=10)
print(result)

# Weighted choices
result = random.choices(['red', 'green', 'blue'], weights=[10, 1, 1], k=10)
print(result)

# Shuffle in place
deck = list(range(52))
random.shuffle(deck)
print(deck)

# Set seed for reproducibility
random.seed(42)
print(random.random())
random.seed(42)
print(random.random())  # Same value

# Gaussian distribution
for _ in range(5):
    print(random.gauss(0, 1))  # Mean 0, std dev 1

Math Module

import math

# Constants
print(math.pi)
print(math.e)
print(math.inf)
print(math.nan)

# Basic functions
print(math.sqrt(16))      # 4.0
print(math.pow(2, 3))     # 8.0
print(math.exp(2))        # e^2
print(math.log(10))       # Natural log
print(math.log10(100))    # Log base 10
print(math.log(8, 2))     # Log base 2

# Rounding
print(math.ceil(4.3))     # 5
print(math.floor(4.9))    # 4
print(math.trunc(4.9))    # 4

# Trigonometry
print(math.sin(math.pi/2))   # 1.0
print(math.cos(0))           # 1.0
print(math.tan(math.pi/4))   # 1.0
print(math.degrees(math.pi)) # 180.0
print(math.radians(180))     # pi

# Factorial and combinations
print(math.factorial(5))     # 120
print(math.comb(5, 2))       # 10 (5 choose 2)
print(math.perm(5, 2))       # 20 (5 permute 2)

# Other functions
print(math.gcd(48, 18))      # 6 (greatest common divisor)
print(math.isnan(float('nan')))  # True
print(math.isinf(math.inf))  # True
print(math.isfinite(100))    # True

12. Working with Data

Working with Lists (Advanced)

# Sorting
numbers = [3, 1, 4, 1, 5, 9, 2, 6]
numbers.sort()  # In-place sort
sorted_nums = sorted(numbers)  # Returns new sorted list

# Sort with key
students = [
    {"name": "Alice", "grade": 85},
    {"name": "Bob", "grade": 92},
    {"name": "Charlie", "grade": 78}
]
sorted_students = sorted(students, key=lambda x: x['grade'], reverse=True)

# Multiple sort keys
sorted_students = sorted(students, key=lambda x: (x['grade'], x['name']))

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

# Map
squared = list(map(lambda x: x**2, numbers))

# Reduce
from functools import reduce
product = reduce(lambda x, y: x * y, numbers)
maximum = reduce(lambda x, y: x if x > y else y, numbers)

# Zip
names = ["Alice", "Bob", "Charlie"]
ages = [25, 30, 35]
cities = ["NYC", "LA", "Chicago"]

combined = list(zip(names, ages, cities))
# [('Alice', 25, 'NYC'), ('Bob', 30, 'LA'), ('Charlie', 35, 'Chicago')]

# Unzip
names, ages, cities = zip(*combined)

# All and any
numbers = [2, 4, 6, 8]
print(all(x % 2 == 0 for x in numbers))  # True
print(any(x > 5 for x in numbers))       # True

# List slicing tricks
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(numbers[::2])     # [0, 2, 4, 6, 8]
print(numbers[1::2])    # [1, 3, 5, 7, 9]
print(numbers[::-1])    # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
print(numbers[-3:])     # [7, 8, 9]

# Flattening nested lists
nested = [[1, 2], [3, 4], [5, 6]]
flattened = [num for row in nested for num in row]
# [1, 2, 3, 4, 5, 6]

# List intersection and difference
list1 = [1, 2, 3, 4, 5]
list2 = [4, 5, 6, 7, 8]
intersection = list(set(list1) & set(list2))  # [4, 5]
difference = list(set(list1) - set(list2))    # [1, 2, 3]
union = list(set(list1) | set(list2))         # [1, 2, 3, 4, 5, 6, 7, 8]

Working with Strings (Advanced)

# String methods
text = "  Hello, World!  "
print(text.strip())           # Remove whitespace
print(text.lstrip())          # Remove left whitespace
print(text.rstrip())          # Remove right whitespace
print(text.replace("World", "Python"))
print(text.split(","))        # Split by comma
print(" ".join(["Hello", "World"]))  # Join with space

# String validation
print("123".isdigit())        # True
print("abc".isalpha())        # True
print("abc123".isalnum())     # True
print("Hello World".istitle())  # True
print("hello".islower())      # True
print("HELLO".isupper())      # True

# String formatting
name = "Alice"
age = 30
salary = 50000.5

# f-strings
print(f"{name} is {age} years old")
print(f"{name:>10}")  # Right align
print(f"{name:<10}")  # Left align
print(f"{name:^10}")  # Center align
print(f"{salary:,.2f}")  # 50,000.50
print(f"{age:05d}")   # 00030

# format()
print("{} is {} years old".format(name, age))
print("{0} is {1} years old. {0} works hard.".format(name, age))
print("{name} is {age} years old".format(name=name, age=age))

# String manipulation
text = "Hello World"
print(text.startswith("Hello"))  # True
print(text.endswith("World"))    # True
print(text.find("World"))        # 6
print(text.index("World"))       # 6
print(text.count("l"))           # 3

# String slicing
text = "Python Programming"
print(text[0:6])      # "Python"
print(text[7:])       # "Programming"
print(text[::-1])     # Reverse

# Multi-line strings
multiline = """Line 1
Line 2
Line 3"""

# Raw strings
path = r"C:\Users\name\folder"

# String translation
trans = str.maketrans("aeiou", "12345")
text = "hello world"
print(text.translate(trans))  # "h2ll4 w4rld"

13. Web Development

Flask Basics

# Install: pip install flask

from flask import Flask, request, jsonify, render_template

app = Flask(__name__)

# Simple route
@app.route('/')
def home():
    return "Hello, World!"

# Route with variable
@app.route('/user/<username>')
def show_user(username):
    return f"User: {username}"

# Route with multiple methods
@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        return f"Logging in {username}"
    return '''
        <form method="post">
            <input name="username">
            <input name="password" type="password">
            <input type="submit">
        </form>
    '''

# JSON API
@app.route('/api/users')
def get_users():
    users = [
        {"id": 1, "name": "Alice"},
        {"id": 2, "name": "Bob"}
    ]
    return jsonify(users)

# Template rendering
@app.route('/template')
def template_example():
    data = {
        "title": "My Page",
        "users": ["Alice", "Bob", "Charlie"]
    }
    return render_template('index.html', **data)

# Error handling
@app.errorhandler(404)
def not_found(error):
    return "Page not found", 404

if __name__ == '__main__':
    app.run(debug=True)

Requests Library

# Install: pip install requests

import requests

# GET request
response = requests.get('https://api.github.com')
print(response.status_code)
print(response.text)
print(response.json())

# GET with parameters
params = {'q': 'python', 'sort': 'stars'}
response = requests.get('https://api.github.com/search/repositories', params=params)
data = response.json()

# POST request
data = {'username': 'alice', 'password': 'secret'}
response = requests.post('https://httpbin.org/post', data=data)

# POST JSON
json_data = {'name': 'Alice', 'age': 30}
response = requests.post('https://httpbin.org/post', json=json_data)

# Headers
headers = {'Authorization': 'Bearer token123'}
response = requests.get('https://api.example.com/data', headers=headers)

# Timeout
response = requests.get('https://api.example.com', timeout=5)

# Error handling
try:
    response = requests.get('https://api.example.com')
    response.raise_for_status()  # Raise exception for 4xx/5xx
except requests.exceptions.RequestException as e:
    print(f"Error: {e}")

# Session (maintains cookies)
session = requests.Session()
session.get('https://httpbin.org/cookies/set/sessioncookie/123')
response = session.get('https://httpbin.org/cookies')

# Download file
response = requests.get('https://example.com/file.pdf')
with open('file.pdf', 'wb') as f:
    f.write(response.content)

14. Database Programming

SQLite

import sqlite3

# Connect to database (creates if doesn't exist)
conn = sqlite3.connect('example.db')
cursor = conn.cursor()

# Create table
cursor.execute('''
    CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        email TEXT UNIQUE,
        age INTEGER
    )
''')

# Insert data
cursor.execute('''
    INSERT INTO users (name, email, age)
    VALUES (?, ?, ?)
''', ('Alice', 'alice@example.com', 30))

# Insert multiple
users = [
    ('Bob', 'bob@example.com', 25),
    ('Charlie', 'charlie@example.com', 35)
]
cursor.executemany('INSERT INTO users (name, email, age) VALUES (?, ?, ?)', users)

# Commit changes
conn.commit()

# Query data
cursor.execute('SELECT * FROM users')
all_users = cursor.fetchall()
print(all_users)

# Query with condition
cursor.execute('SELECT * FROM users WHERE age > ?', (25,))
filtered_users = cursor.fetchall()

# Fetch one
cursor.execute('SELECT * FROM users WHERE email = ?', ('alice@example.com',))
user = cursor.fetchone()

# Update data
cursor.execute('''
    UPDATE users
    SET age = ?
    WHERE name = ?
''', (31, 'Alice'))
conn.commit()

# Delete data
cursor.execute('DELETE FROM users WHERE name = ?', ('Bob',))
conn.commit()

# Get column names
cursor.execute('SELECT * FROM users')
columns = [description[0] for description in cursor.description]

# Use Row factory for dict-like access
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('SELECT * FROM users')
row = cursor.fetchone()
print(row['name'], row['email'])

# Transaction
try:
    cursor.execute('INSERT INTO users (name, email, age) VALUES (?, ?, ?)', 
                   ('David', 'david@example.com', 40))
    cursor.execute('UPDATE users SET age = age + 1 WHERE name = ?', ('Alice',))
    conn.commit()
except sqlite3.Error as e:
    conn.rollback()
    print(f"Error: {e}")

# Close connection
conn.close()

# Context manager (auto-commit and close)
with sqlite3.connect('example.db') as conn:
    cursor = conn.cursor()
    cursor.execute('SELECT * FROM users')
    users = cursor.fetchall()

MySQL with PyMySQL

# Install: pip install pymysql

import pymysql

# Connect to database
connection = pymysql.connect(
    host='localhost',
    user='root',
    password='password',
    database='mydb',
    charset='utf8mb4',
    cursorclass=pymysql.cursors.DictCursor
)

try:
    with connection.cursor() as cursor:
        # Create table
        cursor.execute('''
            CREATE TABLE IF NOT EXISTS products (
                id INT AUTO_INCREMENT PRIMARY KEY,
                name VARCHAR(100),
                price DECIMAL(10, 2),
                quantity INT
            )
        ''')

    # Insert
    with connection.cursor() as cursor:
        sql = "INSERT INTO products (name, price, quantity) VALUES (%s, %s, %s)"
        cursor.execute(sql, ('Product A', 29.99, 100))
    connection.commit()

    # Select
    with connection.cursor() as cursor:
        cursor.execute("SELECT * FROM products WHERE price > %s", (20,))
        products = cursor.fetchall()
        for product in products:
            print(product)

finally:
    connection.close()

15. Testing & Debugging

Unit Testing

import unittest

# Code to test
def add(a, b):
    return a + b

def is_prime(n):
    if n < 2:
        return False
    for i in range(2, int(n ** 0.5) + 1):
        if n % i == 0:
            return False
    return True

# Test class
class TestMathFunctions(unittest.TestCase):

    def test_add(self):
        self.assertEqual(add(2, 3), 5)
        self.assertEqual(add(-1, 1), 0)
        self.assertEqual(add(0, 0), 0)

    def test_is_prime(self):
        self.assertTrue(is_prime(2))
        self.assertTrue(is_prime(17))
        self.assertFalse(is_prime(1))
        self.assertFalse(is_prime(4))

    def test_types(self):
        self.assertIsInstance(add(1, 2), int)
        self.assertIsInstance(is_prime(5), bool)

    def test_exceptions(self):
        with self.assertRaises(TypeError):
            add("a", "b")

    # Setup and teardown
    def setUp(self):
        """Run before each test"""
        self.test_list = [1, 2, 3]

    def tearDown(self):
        """Run after each test"""
        self.test_list = None

if __name__ == '__main__':
    unittest.main()

Pytest

# Install: pip install pytest

# test_example.py
def add(a, b):
    return a + b

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0

def test_add_strings():
    assert add("hello", " world") == "hello world"

# Parametrize tests
import pytest

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300)
])
def test_add_parametrized(a, b, expected):
    assert add(a, b) == expected

# Fixtures
@pytest.fixture
def sample_data():
    return [1, 2, 3, 4, 5]

def test_with_fixture(sample_data):
    assert len(sample_data) == 5
    assert sum(sample_data) == 15

# Test exceptions
def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError):
        divide(10, 0)

# Run tests: pytest test_example.py

Debugging

# Using print statements (basic debugging)
def calculate_total(prices):
    print(f"Input prices: {prices}")  # Debug
    total = sum(prices)
    print(f"Total: {total}")  # Debug
    return total

# Using pdb (Python debugger)
import pdb

def buggy_function(x, y):
    pdb.set_trace()  # Breakpoint
    result = x + y
    return result * 2

# pdb commands:
# n - next line
# s - step into function
# c - continue execution
# p variable - print variable
# l - list code
# q - quit debugger

# Using breakpoint() (Python 3.7+)
def another_function(x):
    breakpoint()  # Modern way
    return x * 2

# Logging for debugging
import logging

logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)

logger = logging.getLogger(__name__)

def process_data(data):
    logger.debug(f"Processing data: {data}")
    result = data * 2
    logger.info(f"Result: {result}")
    return result

# Assertions for debugging
def calculate_percentage(part, total):
    assert total != 0, "Total cannot be zero"
    assert part <= total, "Part cannot be greater than total"
    return (part / total) * 100

# Try-except for debugging
def safe_divide(a, b):
    try:
        result = a / b
    except Exception as e:
        print(f"Error: {type(e).__name__}: {e}")
        import traceback
        traceback.print_exc()
        raise
    return result

16. Best Practices & Design Patterns

Code Organization

# Good project structure
"""
project/
    __init__.py
    main.py
    config.py
    models/
        __init__.py
        user.py
        product.py
    services/
        __init__.py
        auth_service.py
        data_service.py
    utils/
        __init__.py
        helpers.py
        validators.py
    tests/
        __init__.py
        test_models.py
        test_services.py
    requirements.txt
    README.md
"""

# PEP 8 Style Guide
# - Use 4 spaces for indentation
# - Maximum line length: 79 characters
# - Use snake_case for variables and functions
# - Use PascalCase for classes
# - Use UPPER_CASE for constants

# Good naming
MAX_CONNECTIONS = 100
user_count = 0

def calculate_total_price(items):
    return sum(item.price for item in items)

class UserAccount:
    def __init__(self, username):
        self.username = username

# Type hints (Python 3.5+)
from typing import List, Dict, Optional, Union, Tuple

def greet(name: str) -> str:
    return f"Hello, {name}"

def process_items(items: List[str]) -> Dict[str, int]:
    return {item: len(item) for item in items}

def find_user(user_id: int) -> Optional[dict]:
    # Returns dict or None
    return None

def get_value(key: str) -> Union[int, str]:
    # Returns either int or str
    return 42

# Docstrings
def calculate_area(length: float, width: float) -> float:
    """
    Calculate the area of a rectangle.

    Args:
        length (float): The length of the rectangle
        width (float): The width of the rectangle

    Returns:
        float: The area of the rectangle

    Raises:
        ValueError: If length or width is negative

    Examples:
        >>> calculate_area(5, 3)
        15.0
    """
    if length < 0 or width < 0:
        raise ValueError("Dimensions cannot be negative")
    return length * width

Design Patterns

# 1. Singleton Pattern
class Singleton:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

# Usage
s1 = Singleton()
s2 = Singleton()
print(s1 is s2)  # True

# 2. Factory Pattern
class Dog:
    def speak(self):
        return "Woof!"

class Cat:
    def speak(self):
        return "Meow!"

class AnimalFactory:
    @staticmethod
    def create_animal(animal_type):
        if animal_type == "dog":
            return Dog()
        elif animal_type == "cat":
            return Cat()
        else:
            raise ValueError(f"Unknown animal type: {animal_type}")

# Usage
animal = AnimalFactory.create_animal("dog")
print(animal.speak())

# 3. Observer Pattern
class Subject:
    def __init__(self):
        self._observers = []

    def attach(self, observer):
        self._observers.append(observer)

    def detach(self, observer):
        self._observers.remove(observer)

    def notify(self, message):
        for observer in self._observers:
            observer.update(message)

class Observer:
    def __init__(self, name):
        self.name = name

    def update(self, message):
        print(f"{self.name} received: {message}")

# Usage
subject = Subject()
observer1 = Observer("Observer 1")
observer2 = Observer("Observer 2")

subject.attach(observer1)
subject.attach(observer2)
subject.notify("Hello Observers!")

# 4. Strategy Pattern
class SortStrategy:
    def sort(self, data):
        pass

class BubbleSort(SortStrategy):
    def sort(self, data):
        print("Sorting using bubble sort")
        return sorted(data)

class QuickSort(SortStrategy):
    def sort(self, data):
        print("Sorting using quick sort")
        return sorted(data)

class Sorter:
    def __init__(self, strategy: SortStrategy):
        self.strategy = strategy

    def sort(self, data):
        return self.strategy.sort(data)

# Usage
data = [3, 1, 4, 1, 5, 9, 2, 6]
sorter = Sorter(BubbleSort())
sorted_data = sorter.sort(data)

# 5. Decorator Pattern (not to confuse with @decorator)
class Coffee:
    def cost(self):
        return 5

class MilkDecorator:
    def __init__(self, coffee):
        self.coffee = coffee

    def cost(self):
        return self.coffee.cost() + 2

class SugarDecorator:
    def __init__(self, coffee):
        self.coffee = coffee

    def cost(self):
        return self.coffee.cost() + 1

# Usage
coffee = Coffee()
coffee_with_milk = MilkDecorator(coffee)
coffee_with_milk_and_sugar = SugarDecorator(coffee_with_milk)
print(coffee_with_milk_and_sugar.cost())  # 8

SOLID Principles

# S - Single Responsibility Principle
# Each class should have one responsibility

# Bad
class User:
    def __init__(self, name):
        self.name = name

    def save_to_database(self):
        # Database logic here
        pass

    def send_email(self):
        # Email logic here
        pass

# Good
class User:
    def __init__(self, name):
        self.name = name

class UserRepository:
    def save(self, user):
        # Database logic here
        pass

class EmailService:
    def send_email(self, user, message):
        # Email logic here
        pass

# O - Open/Closed Principle
# Open for extension, closed for modification

class Shape:
    def area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

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

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14 * self.radius ** 2

def calculate_total_area(shapes):
    return sum(shape.area() for shape in shapes)

# L - Liskov Substitution Principle
# Derived classes must be substitutable for base classes

class Bird:
    def move(self):
        print("Moving")

class FlyingBird(Bird):
    def move(self):
        print("Flying")

class Penguin(Bird):
    def move(self):
        print("Walking")

# I - Interface Segregation Principle
# Clients shouldn't depend on interfaces they don't use

from abc import ABC, abstractmethod

class Printer(ABC):
    @abstractmethod
    def print(self, document):
        pass

class Scanner(ABC):
    @abstractmethod
    def scan(self, document):
        pass

class SimplePrinter(Printer):
    def print(self, document):
        print(f"Printing: {document}")

class MultiFunctionDevice(Printer, Scanner):
    def print(self, document):
        print(f"Printing: {document}")

    def scan(self, document):
        print(f"Scanning: {document}")

# D - Dependency Inversion Principle
# Depend on abstractions, not concretions

class Database(ABC):
    @abstractmethod
    def save(self, data):
        pass

class MySQLDatabase(Database):
    def save(self, data):
        print(f"Saving to MySQL: {data}")

class MongoDBDatabase(Database):
    def save(self, data):
        print(f"Saving to MongoDB: {data}")

class UserService:
    def __init__(self, database: Database):
        self.database = database

    def create_user(self, user_data):
        self.database.save(user_data)

# Usage - easy to switch databases
mysql_db = MySQLDatabase()
user_service = UserService(mysql_db)

17. Real-World Projects

Project 1: Contact Management System

import json
from typing import List, Optional

class Contact:
    def __init__(self, name: str, phone: str, email: str):
        self.name = name
        self.phone = phone
        self.email = email

    def to_dict(self):
        return {
            'name': self.name,
            'phone': self.phone,
            'email': self.email
        }

    @classmethod
    def from_dict(cls, data):
        return cls(data['name'], data['phone'], data['email'])

    def __str__(self):
        return f"{self.name} - {self.phone} - {self.email}"

class ContactManager:
    def __init__(self, filename='contacts.json'):
        self.filename = filename
        self.contacts: List[Contact] = []
        self.load_contacts()

    def add_contact(self, contact: Contact):
        self.contacts.append(contact)
        self.save_contacts()
        print(f"Contact {contact.name} added successfully")

    def remove_contact(self, name: str):
        self.contacts = [c for c in self.contacts if c.name != name]
        self.save_contacts()
        print(f"Contact {name} removed successfully")

    def search_contact(self, name: str) -> Optional[Contact]:
        for contact in self.contacts:
            if contact.name.lower() == name.lower():
                return contact
        return None

    def list_contacts(self):
        if not self.contacts:
            print("No contacts found")
            return

        print("\n--- Contact List ---")
        for i, contact in enumerate(self.contacts, 1):
            print(f"{i}. {contact}")

    def update_contact(self, name: str, phone: str = None, email: str = None):
        contact = self.search_contact(name)
        if contact:
            if phone:
                contact.phone = phone
            if email:
                contact.email = email
            self.save_contacts()
            print(f"Contact {name} updated successfully")
        else:
            print(f"Contact {name} not found")

    def save_contacts(self):
        with open(self.filename, 'w') as f:
            json.dump([c.to_dict() for c in self.contacts], f, indent=4)

    def load_contacts(self):
        try:
            with open(self.filename, 'r') as f:
                data = json.load(f)
                self.contacts = [Contact.from_dict(c) for c in data]
        except FileNotFoundError:
            self.contacts = []

def main():
    manager = ContactManager()

    while True:
        print("\n=== Contact Management System ===")
        print("1. Add Contact")
        print("2. Remove Contact")
        print("3. Search Contact")
        print("4. List All Contacts")
        print("5. Update Contact")
        print("6. Exit")

        choice = input("\nEnter your choice: ")

        if choice == '1':
            name = input("Enter name: ")
            phone = input("Enter phone: ")
            email = input("Enter email: ")
            contact = Contact(name, phone, email)
            manager.add_contact(contact)

        elif choice == '2':
            name = input("Enter name to remove: ")
            manager.remove_contact(name)

        elif choice == '3':
            name = input("Enter name to search: ")
            contact = manager.search_contact(name)
            if contact:
                print(f"\nFound: {contact}")
            else:
                print("Contact not found")

        elif choice == '4':
            manager.list_contacts()

        elif choice == '5':
            name = input("Enter name to update: ")
            phone = input("Enter new phone (or press Enter to skip): ")
            email = input("Enter new email (or press Enter to skip): ")
            manager.update_contact(name, phone or None, email or None)

        elif choice == '6':
            print("Goodbye!")
            break

        else:
            print("Invalid choice. Please try again.")

if __name__ == "__main__":
    main()

Project 2: Web Scraper

import requests
from bs4 import BeautifulSoup
import csv
from typing import List, Dict

class WebScraper:
    def __init__(self, base_url: str):
        self.base_url = base_url
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
        })

    def fetch_page(self, url: str) -> BeautifulSoup:
        try:
            response = self.session.get(url, timeout=10)
            response.raise_for_status()
            return BeautifulSoup(response.content, 'html.parser')
        except requests.RequestException as e:
            print(f"Error fetching {url}: {e}")
            return None

    def scrape_quotes(self) -> List[Dict]:
        """Example: Scraping quotes from quotes.toscrape.com"""
        quotes = []
        page = 1

        while True:
            url = f"{self.base_url}/page/{page}/"
            soup = self.fetch_page(url)

            if not soup:
                break

            quote_elements = soup.find_all('div', class_='quote')

            if not quote_elements:
                break

            for quote_elem in quote_elements:
                text = quote_elem.find('span', class_='text').text
                author = quote_elem.find('small', class_='author').text
                tags = [tag.text for tag in quote_elem.find_all('a', class_='tag')]

                quotes.append({
                    'text': text,
                    'author': author,
                    'tags': ', '.join(tags)
                })

            print(f"Scraped page {page}")
            page += 1

        return quotes

    def save_to_csv(self, data: List[Dict], filename: str):
        if not data:
            print("No data to save")
            return

        keys = data[0].keys()

        with open(filename, 'w', newline='', encoding='utf-8') as f:
            writer = csv.DictWriter(f, fieldnames=keys)
            writer.writeheader()
            writer.writerows(data)

        print(f"Data saved to {filename}")

# Usage
if __name__ == "__main__":
    scraper = WebScraper("http://quotes.toscrape.com")
    quotes = scraper.scrape_quotes()
    scraper.save_to_csv(quotes, 'quotes.csv')
    print(f"Scraped {len(quotes)} quotes")

Project 3: Task Scheduler

import schedule
import time
from datetime import datetime
import logging

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)

class TaskScheduler:
    def __init__(self):
        self.tasks = []

    def add_task(self, func, interval_type, interval_value, *args, **kwargs):
        """
        Add a task to the scheduler
        interval_type: 'seconds', 'minutes', 'hours', 'days'
        """
        job = None

        if interval_type == 'seconds':
            job = schedule.every(interval_value).seconds.do(func, *args, **kwargs)
        elif interval_type == 'minutes':
            job = schedule.every(interval_value).minutes.do(func, *args, **kwargs)
        elif interval_type == 'hours':
            job = schedule.every(interval_value).hours.do(func, *args, **kwargs)
        elif interval_type == 'days':
            job = schedule.every(interval_value).days.do(func, *args, **kwargs)

        if job:
            self.tasks.append({
                'job': job,
                'function': func.__name__,
                'interval': f"{interval_value} {interval_type}"
            })
            logging.info(f"Task '{func.__name__}' scheduled every {interval_value} {interval_type}")

    def run(self):
        logging.info("Task Scheduler started")
        try:
            while True:
                schedule.run_pending()
                time.sleep(1)
        except KeyboardInterrupt:
            logging.info("Task Scheduler stopped")

# Example tasks
def backup_database():
    logging.info("Backing up database...")
    # Backup logic here
    logging.info("Database backup completed")

def send_report():
    logging.info("Sending daily report...")
    # Report logic here
    logging.info("Report sent")

def cleanup_temp_files():
    logging.info("Cleaning up temporary files...")
    # Cleanup logic here
    logging.info("Cleanup completed")

# Usage
if __name__ == "__main__":
    scheduler = TaskScheduler()

    # Schedule tasks
    scheduler.add_task(backup_database, 'hours', 1)
    scheduler.add_task(send_report, 'days', 1)
    scheduler.add_task(cleanup_temp_files, 'minutes', 30)

    # Run scheduler
    scheduler.run()

Project 4: Password Manager

import json
import hashlib
import os
from cryptography.fernet import Fernet
import getpass

class PasswordManager:
    def __init__(self, master_password: str):
        self.master_password = master_password
        self.key = self._generate_key()
        self.cipher = Fernet(self.key)
        self.passwords_file = 'passwords.enc'
        self.passwords = self.load_passwords()

    def _generate_key(self) -> bytes:
        """Generate encryption key from master password"""
        password_hash = hashlib.sha256(self.master_password.encode()).digest()
        return Fernet.generate_key()  # In production, derive from password_hash

    def add_password(self, service: str, username: str, password: str):
        encrypted_password = self.cipher.encrypt(password.encode()).decode()

        self.passwords[service] = {
            'username': username,
            'password': encrypted_password
        }

        self.save_passwords()
        print(f"Password for {service} added successfully")

    def get_password(self, service: str) -> dict:
        if service in self.passwords:
            encrypted_password = self.passwords[service]['password'].encode()
            decrypted_password = self.cipher.decrypt(encrypted_password).decode()

            return {
                'username': self.passwords[service]['username'],
                'password': decrypted_password
            }
        return None

    def list_services(self):
        if not self.passwords:
            print("No passwords stored")
            return

        print("\n--- Stored Services ---")
        for i, service in enumerate(self.passwords.keys(), 1):
            print(f"{i}. {service}")

    def remove_password(self, service: str):
        if service in self.passwords:
            del self.passwords[service]
            self.save_passwords()
            print(f"Password for {service} removed")
        else:
            print(f"Service {service} not found")

    def save_passwords(self):
        with open(self.passwords_file, 'w') as f:
            json.dump(self.passwords, f, indent=4)

    def load_passwords(self) -> dict:
        try:
            with open(self.passwords_file, 'r') as f:
                return json.load(f)
        except FileNotFoundError:
            return {}

def main():
    print("=== Password Manager ===")
    master_password = getpass.getpass("Enter master password: ")

    manager = PasswordManager(master_password)

    while True:
        print("\n1. Add Password")
        print("2. Get Password")
        print("3. List Services")
        print("4. Remove Password")
        print("5. Exit")

        choice = input("\nEnter choice: ")

        if choice == '1':
            service = input("Enter service name: ")
            username = input("Enter username: ")
            password = getpass.getpass("Enter password: ")
            manager.add_password(service, username, password)

        elif choice == '2':
            service = input("Enter service name: ")
            credentials = manager.get_password(service)
            if credentials:
                print(f"\nUsername: {credentials['username']}")
                print(f"Password: {credentials['password']}")
            else:
                print("Service not found")

        elif choice == '3':
            manager.list_services()

        elif choice == '4':
            service = input("Enter service name: ")
            manager.remove_password(service)

        elif choice == '5':
            break

if __name__ == "__main__":
    main()

18. Interview Questions & Answers

Basic Questions

Q1: What is Python? What are its key features?

A: Python is a high-level, interpreted, general-purpose programming language. Key features include:

  • Easy to learn and read
  • Interpreted (no compilation needed)
  • Dynamically typed
  • Object-oriented
  • Extensive standard library
  • Cross-platform
  • Supports multiple paradigms (OOP, functional, procedural)

Q2: What is PEP 8?

A: PEP 8 is the Python Enhancement Proposal that provides style guidelines for Python code. Key rules:

  • Use 4 spaces for indentation
  • Maximum line length of 79 characters
  • Use snake_case for functions and variables
  • Use PascalCase for classes
  • Use blank lines to separate functions and classes

Q3: What is the difference between list and tuple?

A:

  • List: Mutable, uses square brackets [], slower, more memory
  • Tuple: Immutable, uses parentheses (), faster, less memory
  • Lists for data that changes, tuples for fixed data

Q4: Explain *args and **kwargs.

A:

  • *args: Variable positional arguments (tuple)
  • **kwargs: Variable keyword arguments (dictionary)
def example(*args, **kwargs):
    print(args)    # (1, 2, 3)
    print(kwargs)  # {'a': 1, 'b': 2}

example(1, 2, 3, a=1, b=2)

Q5: What is list comprehension?

A: Concise way to create lists:

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

# List comprehension
squares = [x**2 for x in range(10)]

# With condition
evens = [x for x in range(20) if x % 2 == 0]

Intermediate Questions

Q6: Explain Python’s GIL (Global Interpreter Lock).

A: GIL is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecode simultaneously. This means:

  • Only one thread executes Python code at a time
  • Impacts CPU-bound multi-threaded programs
  • I/O-bound programs less affected
  • Use multiprocessing for true parallelism

Q7: What are decorators?

A: Functions that modify the behavior of other functions:

def uppercase_decorator(func):
    def wrapper():
        result = func()
        return result.upper()
    return wrapper

@uppercase_decorator
def greet():
    return "hello"

print(greet())  # "HELLO"

Q8: What is the difference between __str__ and __repr__?

A:

  • __str__: Human-readable string (for end users)
  • __repr__: Unambiguous representation (for developers)
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __str__(self):
        return f"{self.name}, {self.age} years old"

    def __repr__(self):
        return f"Person('{self.name}', {self.age})"

Q9: Explain the difference between deep copy and shallow copy.

A:

import copy

original = [[1, 2], [3, 4]]

# Shallow copy - copies reference
shallow = copy.copy(original)
shallow[0][0] = 99
print(original)  # [[99, 2], [3, 4]] - affected!

# Deep copy - copies all nested objects
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)
deep[0][0] = 99
print(original)  # [[1, 2], [3, 4]] - not affected

Q10: What are generators? Why use them?

A: Functions that yield values one at a time, useful for memory efficiency:

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

# Uses minimal memory
for num in count_up_to(1000000):
    print(num)

Advanced Questions

Q11: Explain metaclasses.

A: Classes that create classes. The type metaclass creates all classes in Python:

# Creating class with type
MyClass = type('MyClass', (object,), {'x': 5})

# Custom metaclass
class Meta(type):
    def __new__(cls, name, bases, attrs):
        attrs['created_by'] = 'Meta'
        return super().__new__(cls, name, bases, attrs)

class MyClass(metaclass=Meta):
    pass

print(MyClass.created_by)  # 'Meta'

Q12: What is monkey patching?

A: Dynamically modifying a class or module at runtime:

class MyClass:
    def method(self):
        return "original"

# Monkey patch
def new_method(self):
    return "patched"

MyClass.method = new_method

obj = MyClass()
print(obj.method())  # "patched"

Q13: Explain the with statement and context managers.

A: Ensures proper resource management:

# Without context manager
file = open('file.txt', 'r')
try:
    content = file.read()
finally:
    file.close()

# With context manager
with open('file.txt', 'r') as file:
    content = file.read()
# File automatically closed

# Custom context manager
from contextlib import contextmanager

@contextmanager
def my_context():
    print("Enter")
    yield
    print("Exit")

with my_context():
    print("Inside")

Q14: What is the difference between @staticmethod and @classmethod?

A:

class MyClass:
    class_var = "class variable"

    @staticmethod
    def static_method(x):
        # No access to class or instance
        return x * 2

    @classmethod
    def class_method(cls, x):
        # Has access to class
        return f"{cls.class_var}: {x}"

print(MyClass.static_method(5))  # 10
print(MyClass.class_method(5))   # "class variable: 5"

Q15: How does Python’s garbage collection work?

A: Python uses:

  1. Reference Counting: Tracks number of references to an object
  2. Cycle Detection: Finds and collects circular references
  3. Generational Collection: Objects are divided into 3 generations
import gc

# Disable automatic GC
gc.disable()

# Manual collection
gc.collect()

# Get stats
print(gc.get_stats())

Coding Questions

Q16: Reverse a string

# Method 1: Slicing
def reverse_string(s):
    return s[::-1]

# Method 2: Using reversed()
def reverse_string(s):
    return ''.join(reversed(s))

# Method 3: Manual
def reverse_string(s):
    result = ""
    for char in s:
        result = char + result
    return result

Q17: Find duplicates in a list

def find_duplicates(lst):
    seen = set()
    duplicates = set()

    for item in lst:
        if item in seen:
            duplicates.add(item)
        else:
            seen.add(item)

    return list(duplicates)

# Or using Counter
from collections import Counter

def find_duplicates(lst):
    counts = Counter(lst)
    return [item for item, count in counts.items() if count > 1]

Q18: Fibonacci sequence

“`python

Recursive (inefficient)

def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)

Iterative (efficient)

def fibonacci(n):
if n <= 1:
return n
a, b = 0, 1
for _ in range(n-1):
a, b = b, a + b
return b

Complete Python Learning Roadmap

Professional Guide from Beginner to Expert