Welcome to IT Wallah – Learn Coding, Modern Technology, Computer Troubleshooting, and Explore Helpful Tech Blogs.

Python Tutorial

Python is an interpreted, high-level, general-purpose programming language. Created by Guido van Rossum and first released in 1991, Python's design philosophy emphasizes code readability with its notable use of significant indentation.

Why Python?

  • Readability: Python syntax is designed to be readable and straightforward, looking closely like English mathematics.
  • Multi-paradigm: Supports object-oriented, imperative, functional, and procedural programming.
  • Extensive Ecosystem: Python has the Python Package Index (PyPI), which hosts hundreds of thousands of third-party modules.
  • Interpreted & Dynamic: Python code is executed line-by-line, which makes debugging easier. Types are dynamically resolved at runtime.

Installation & Setup

Before writing any Python code, you must install the Python interpreter, which translates your human-readable code into bytecode that the computer can execute.

1. Installing Python (Windows/Mac/Linux)

Download the latest version of Python from python.org.

Critical Windows Step: When running the Windows installer, you MUST check the box that says "Add Python to PATH". This allows you to run Python from the command prompt globally.

2. The REPL (Read-Eval-Print Loop)

Python comes with an interactive shell called the REPL. Open your terminal or command prompt and type python.

$ python
Python 3.12.0 (tags/v3.12.0:0fb18b0, Oct  2 2023, 13:03:39) 
Type "help", "copyright", "credits" or "license" for more information.
>>> print("Hello from the REPL!")
Hello from the REPL!
>>> 2 + 2
4
>>> exit() # To leave the REPL
3. Virtual Environments

For project management, it is best practice to use venv to isolate project dependencies.

# Create a virtual environment named "myenv"
python -m venv myenv

# Activate it (Windows)
myenv\Scripts\activate

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

Basic Syntax & Indentation

Python is famous for its clean, uncluttered syntax. Let's explore the core rules.

1. Significant Whitespace (Indentation)

Where Java or C++ uses curly braces {} to define the scope of loops or functions, Python uses indentation. A block of code must be indented consistently (standard is 4 spaces).

# Correct Indentation
if True:
    print("This is indented correctly")
    if 5 > 2:
        print("This is a nested block")

# Incorrect Indentation (IndentationError)
if True:
print("This will crash the program!")
2. No Semicolons

Python does not require semicolons to terminate statements. A simple newline character is sufficient.

3. Line Continuation

If a line of code is too long, you can use the backslash \ character, or wrap the expression in parentheses.

# Using backslash
total = 1 + 2 + 3 + \
        4 + 5 + 6

# Using parentheses (Preferred PEP-8 style)
total = (1 + 2 + 3 +
         4 + 5 + 6)

Comments & Docstrings

Documentation is critical for writing maintainable code. Python provides multiple ways to comment.

1. Inline Comments

Created using the hash symbol #. They are ignored entirely by the interpreter.

# Calculate the area of a circle
radius = 5
area = 3.14 * radius ** 2  # The ** operator is used for exponents
2. Docstrings (Documentation Strings)

Docstrings are a special type of multi-line comment used right after the definition of a function, method, class, or module. They are surrounded by triple quotes """ and are stored in the __doc__ attribute of the object.

def multiply(a, b):
    """
    Multiply two numbers together and return the result.
    
    Args:
        a (int, float): The first number.
        b (int, float): The second number.
        
    Returns:
        int, float: The product of a and b.
    """
    return a * b

# You can access the docstring programmatically!
print(multiply.__doc__)

Variables & Dynamic Typing

Python is dynamically and strongly typed. Variables are simply labels pointing to objects in memory.

Dynamic Typing

You do not declare a variable's type. The type is determined at runtime based on the object assigned to it. Furthermore, a variable can be reassigned to a completely different type later.

x = 100       # x points to an integer object
print(type(x)) # <class 'int'>

x = "Hello"   # x now points to a string object
print(type(x)) # <class 'str'>
Multiple Assignment

Python allows you to assign values to multiple variables in one line, which is extremely useful for unpacking data.

# Assigning different values
name, age, is_student = "Alice", 25, True

# Assigning the same value
x = y = z = 0
Memory & The `id()` Function

Variables point to memory addresses. You can use id() to see the memory address.

a = 500
b = 500
print(id(a) == id(b)) # False: They point to different objects in memory

c = a
print(id(a) == id(c)) # True: They point to the SAME object

Numbers & Advanced Math

Python handles numeric types efficiently and natively supports arbitrary-precision integers.

Numeric Types
  • int: Integers of unlimited size.
  • float: Floating point numbers (double precision).
  • complex: Complex numbers written with a "j" as the imaginary part.
Mathematical Operators
a = 15
b = 4

print(a / b)  # True Division: 3.75
print(a // b) # Floor Division (truncates decimal): 3
print(a % b)  # Modulo (remainder): 3
print(a ** b) # Exponentiation (15 to the power of 4): 50625
The `math` Module

For advanced mathematics, Python provides a built-in module.

import math

print(math.sqrt(16))      # Square root: 4.0
print(math.ceil(2.1))     # Round up: 3
print(math.floor(2.9))    # Round down: 2
print(math.pi)            # Pi constant: 3.141592653589793

Strings & Formatting

Strings are immutable sequences of Unicode characters. Python's string manipulation capabilities are incredibly powerful.

String Slicing

You can extract a substring using the slicing syntax: string[start:stop:step].

text = "Python Programming"

print(text[0:6])   # "Python"
print(text[7:])    # "Programming"
print(text[-1])    # "g" (Negative indexing gets the last character)
print(text[::-1])  # "gnimmargorP nohtyP" (Trick to reverse a string!)
String Formatting (f-strings)

Introduced in Python 3.6, formatted string literals (f-strings) are the fastest and most readable way to inject variables into strings.

name = "Alice"
score = 95.5678

# Using f-string
greeting = f"User {name} achieved a score of {score:.2f}."
print(greeting) # "User Alice achieved a score of 95.57."

# You can even evaluate expressions inside f-strings!
print(f"2 + 2 is {2 + 2}")
Useful String Methods
csv = "apple,banana,cherry"
items = csv.split(",")   # Returns a list: ['apple', 'banana', 'cherry']
joined = " | ".join(items) # Returns: "apple | banana | cherry"

print("HELLO".isupper()) # True
print("hello".capitalize()) # "Hello"

Type Casting & Conversion

Sometimes you need to convert a variable from one data type to another. In Python, this is done using constructor functions.

Implicit vs Explicit Conversion

Python automatically converts smaller data types to larger ones (e.g., int to float) when performing math, which is Implicit. Manual casting is Explicit.

# Explicit Casting Examples
num_str = "100"
num_int = int(num_str)       # Convert string to integer

pi_int = int(3.1415)         # Truncates to 3
bool_val = bool(1)           # Converts to True
str_val = str(150.5)         # Converts to "150.5"

# The input() function always returns a string, so casting is required:
# age = int(input("Enter your age: "))

# Truthy and Falsy
print(bool(""))  # False (Empty strings are falsy)
print(bool(" ")) # True (Contains a space)
print(bool(0))   # False
print(bool([]))  # False (Empty lists are falsy)

If...Elif...Else & Ternary Operator

Conditional statements evaluate boolean logic to control program flow.

Standard Flow
score = 85

if score >= 90:
    print("Grade: A")
elif score >= 80:
    print("Grade: B")
else:
    print("Grade: C or below")
Logical Operators: `and`, `or`, `not`

Python uses plain English keywords rather than symbols like && or ||.

age = 25
has_license = True

if age >= 18 and has_license:
    print("You can drive.")
    
if not False:
    print("This will execute.")
The Ternary Operator (Conditional Expressions)

You can write an if-else block on a single line for simple variable assignments.

status_code = 200
# Syntax: [value_if_true] if [condition] else [value_if_false]
message = "Success" if status_code == 200 else "Error"
print(message) # "Success"

Match-Case (Structural Pattern Matching)

Introduced in Python 3.10, Structural Pattern Matching is far more powerful than a simple switch statement; it can match structures, types, and unpack data.

Basic Usage
command = "start"

match command:
    case "start":
        print("Starting engine...")
    case "stop":
        print("Stopping engine...")
    case _:
        print("Unknown command") # The underscore acts as the default case
Advanced: Pattern Unpacking

You can match sequences (like lists) and extract variables dynamically!

user_data = ["admin", "john_doe", "john@example.com"]

match user_data:
    case ["admin", username, email]:
        print(f"Admin detected! Welcome {username}, sending email to {email}.")
    case ["guest", username]:
        print(f"Welcome guest {username}.")
    case _:
        print("Invalid user structure.")

Loops (For, While) & The Enumerate Function

Loops allow you to iterate over sequences. Python's for loop is essentially a "for-each" loop from other languages.

The `while` Loop

Executes as long as a condition remains true.

count = 3
while count > 0:
    print(f"Countdown: {count}")
    count -= 1
The `for` Loop

Iterates over items of any sequence (a list, string, dictionary, etc.).

# Iterating over a string
for char in "Python":
    print(char, end="-") # Output: P-y-t-h-o-n-

# Using range(start, stop, step)
for i in range(0, 10, 2):
    print(i) # Output: 0, 2, 4, 6, 8
The `enumerate()` Function

Often, you need both the item and its index. enumerate() is the Pythonic way to do this.

fruits = ["apple", "banana", "cherry"]
for index, fruit in enumerate(fruits):
    print(f"Index {index} is {fruit}")

Break, Continue, and `for-else`

Control loop execution flow using these keywords.

`break` and `continue`
for num in range(10):
    if num % 2 == 0:
        continue # Skip even numbers
    if num == 7:
        break    # Stop the loop completely at 7
    print(num)   # Output: 1, 3, 5
The Unique `for...else` Construct

Python has a unique feature where you can attach an else block to a loop. The else block executes ONLY IF the loop finishes naturally (i.e., it was NOT terminated by a break statement).

search_target = "Zebra"
animals = ["Cat", "Dog", "Bird"]

for animal in animals:
    if animal == search_target:
        print("Target found!")
        break
else:
    # This executes because the loop finished without hitting 'break'
    print("Target was never found in the list.")

Lists in Depth

Lists are dynamic arrays. They are mutable, ordered, and can contain mixed data types.

Advanced List Operations
matrix = [
    [1, 2, 3],
    [4, 5, 6]
]
print(matrix[1][2]) # Output: 6

numbers = [10, 20, 30]
numbers.extend([40, 50]) # Adds elements from another iterable
numbers.insert(1, 15)    # Inserts 15 at index 1

popped_item = numbers.pop() # Removes and returns the last item
List Comprehensions

A concise way to create lists. Syntax: [expression for item in iterable if condition]

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

evens = [x for x in range(20) if x % 2 == 0]
Custom Sorting

You can sort lists of complex objects using the key parameter.

words = ["banana", "pie", "Washington", "book"]
# Sort by length of the string
words.sort(key=len)
print(words) # ['pie', 'book', 'banana', 'Washington']

Tuples (Immutable Sequences)

Tuples are identical to lists, but they cannot be changed after creation (immutable). This makes them faster and memory efficient.

Creating and Packing
# Parentheses are optional, the comma makes the tuple!
point = 10, 20
singleton = (5,) # Note the trailing comma for a single-element tuple

print(type(point)) # <class 'tuple'>
Tuple Unpacking

You can extract variables directly from a tuple. You can also use the * operator to grab remaining elements.

record = ("Alice", 25, "Engineer", "New York", "USA")

name, age, *rest_of_data = record

print(name)         # "Alice"
print(rest_of_data) # ["Engineer", "New York", "USA"] (Notice it packs into a list)

Dictionaries in Depth

Dictionaries (Hash Maps) store data in key-value pairs. As of Python 3.7, they maintain insertion order.

Safe Access and Default Values
user = {"username": "admin", "id": 101}

# print(user["email"]) # KeyError!
print(user.get("email")) # Returns None, prevents crash
print(user.get("email", "Unknown")) # Returns "Unknown"

# setdefault adds the key if it doesn't exist
user.setdefault("role", "guest")
Dictionary Comprehensions

Like list comprehensions, but for dictionaries.

squares_dict = {x: x**2 for x in range(5)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
Merging Dictionaries (Python 3.9+)

Use the union operator | to merge dictionaries effortlessly.

dict1 = {"a": 1, "b": 2}
dict2 = {"b": 3, "c": 4}

merged = dict1 | dict2
print(merged) # {'a': 1, 'b': 3, 'c': 4}  (dict2 overwrites overlapping keys)

Sets & Mathematical Operations

Sets are collections of unique elements. They are highly optimized for checking membership (O(1) time complexity).

Removing Duplicates

The fastest way to remove duplicates from a list is to cast it to a set.

raw_data = [1, 2, 2, 3, 3, 3, 4]
clean_data = list(set(raw_data))
# [1, 2, 3, 4]
Set Operations

Sets support mathematical operations like Union, Intersection, and Difference.

engineers = {"Alice", "Bob", "Charlie"}
managers = {"Charlie", "David"}

# Union (All unique people)
print(engineers | managers) # {'Bob', 'Alice', 'David', 'Charlie'}

# Intersection (Who is BOTH an engineer and a manager?)
print(engineers & managers) # {'Charlie'}

# Difference (Engineers who are NOT managers)
print(engineers - managers) # {'Bob', 'Alice'}

# Symmetric Difference (People who are one or the other, but NOT both)
print(engineers ^ managers) # {'Bob', 'Alice', 'David'}

Functions: *args and **kwargs

Functions are first-class citizens in Python. You can pass them as arguments, return them from other functions, and assign them to variables.

Default Arguments & Type Hinting
# Python 3.5+ introduced Type Hinting for better readability
def greet(name: str, times: int = 1) -> None:
    for _ in range(times):
        print(f"Hello, {name}!")

greet("Alice", 2)
Arbitrary Arguments: `*args`

Use *args when you do not know how many positional arguments will be passed. It packs them into a tuple.

def sum_all(*args):
    total = 0
    for num in args:
        total += num
    return total

print(sum_all(1, 2, 3, 4, 5)) # 15
Arbitrary Keyword Arguments: `**kwargs`

Use **kwargs to handle an arbitrary number of named/keyword arguments. It packs them into a dictionary.

def build_profile(first_name, last_name, **kwargs):
    profile = {"first": first_name, "last": last_name}
    profile.update(kwargs) # Merges the kwargs dictionary
    return profile

user = build_profile("Albert", "Einstein", field="Physics", nobel_prize=True)
print(user) # {'first': 'Albert', 'last': 'Einstein', 'field': 'Physics', 'nobel_prize': True}

Lambda Functions & Higher-Order Functions

Lambda functions are anonymous, inline functions. They are most powerful when combined with functions that take other functions as arguments (Higher-Order Functions).

Map, Filter, and Reduce
  • map(): Applies a function to all items in an input list.
  • filter(): Creates a list of elements for which a function returns True.
  • reduce(): Applies a rolling computation to sequential pairs of values in a list.
numbers = [1, 2, 3, 4, 5]

# map: Square every number
squared = list(map(lambda x: x**2, numbers))
# [1, 4, 9, 16, 25]

# filter: Keep only even numbers
evens = list(filter(lambda x: x % 2 == 0, numbers))
# [2, 4]

# reduce (requires import): Multiply all numbers together
from functools import reduce
product = reduce(lambda x, y: x * y, numbers)
# 120 (1 * 2 * 3 * 4 * 5)

Scope Rules (LEGB) & Closures

Variable scope in Python follows the LEGB rule: Local, Enclosing, Global, Built-in.

Global and Nonlocal Keywords
counter = 0

def increment():
    global counter  # Declares we are modifying the global 'counter'
    counter += 1

increment()
print(counter) # 1
Closures

A closure occurs when a nested function captures and remembers the variables from its enclosing scope, even after the outer function has finished executing.

def multiplier_factory(factor):
    # This inner function remembers 'factor'
    def inner_multiplier(number):
        return number * factor
    return inner_multiplier

# Generate specific multiplier functions
double = multiplier_factory(2)
triple = multiplier_factory(3)

print(double(5)) # 10
print(triple(5)) # 15

Modules & The `__name__` Variable

Python files are called modules. You can organize code logically by grouping related functions/classes into files.

Importing Strategies
import sys                  # Imports the whole module
from math import pi, sqrt   # Imports specific functions/variables
import numpy as np          # Imports and aliases a module
The `if __name__ == '__main__':` Guard

When the Python interpreter reads a source file, it sets the special __name__ variable. If the file is being run directly, __name__ is set to "__main__". If the file is imported into another script, __name__ is set to the module's name.

def run_tests():
    print("Testing functionality...")

# This code block only executes if you run THIS script directly.
# It will NOT execute if another script imports this file.
if __name__ == '__main__':
    print("Script started directly.")
    run_tests()

Classes, Objects, and Magic (Dunder) Methods

Everything in Python is an object. Classes are blueprints for creating objects.

The Blueprint & Constructor

The __init__ method is the constructor. The self parameter is a reference to the current instance of the class.

class Book:
    def __init__(self, title, pages):
        self.title = title
        self.pages = pages
        
    def read(self):
        print(f"Reading {self.title}...")
Magic (Dunder) Methods

Methods with double underscores (e.g., __str__, __add__) allow you to override standard Python operators and behaviors for your custom classes!

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        
    # Defines what is printed when you print(object)
    def __str__(self):
        return f"Vector({self.x}, {self.y})"
        
    # Allows the use of the '+' operator between Vector objects!
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

v1 = Vector(2, 4)
v2 = Vector(3, 1)

print(v1)      # Output: Vector(2, 4)
print(v1 + v2) # Output: Vector(5, 5)

Inheritance & MRO

Inheritance promotes code reuse. Python supports both single and multiple inheritance.

Using `super()`

The super() function gives you access to methods in a superclass from the subclass. It is mostly used to extend the functionality of the parent's constructor.

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

class Developer(Employee):
    def __init__(self, name, salary, language):
        # Call the parent constructor
        super().__init__(name, salary)
        # Add new property
        self.language = language

dev = Developer("Alice", 90000, "Python")
Multiple Inheritance & MRO

If a class inherits from multiple parents, Python uses the Method Resolution Order (MRO) to determine which parent's method to call first.

class A: pass
class B: pass
class C(A, B): pass

# You can view the order Python searches for methods:
print(C.mro()) 
# [<class '__main__.C'>, <class '__main__.A'>, <class '__main__.B'>, <class 'object'>]

Polymorphism & Class Methods

Polymorphism allows methods to do different things based on the object it is acting upon.

Duck Typing

"If it walks like a duck and quacks like a duck, it must be a duck." Python doesn't care about the object's strict class, only that it has the required methods.

class AudioPlayer:
    def play(self): print("Playing mp3...")

class VideoPlayer:
    def play(self): print("Playing mp4...")

def start_media(device):
    # Python doesn't care if 'device' is an Audio or Video player,
    # as long as it has a play() method!
    device.play()

start_media(AudioPlayer())
start_media(VideoPlayer())
`@classmethod` and `@staticmethod`
class MathUtils:
    @staticmethod
    def add(x, y):
        # A utility function that doesn't need 'self'
        return x + y
        
class Date:
    def __init__(self, year, month, day):
        self.year, self.month, self.day = year, month, day
        
    @classmethod
    def from_string(cls, date_str):
        # A class method takes 'cls' instead of 'self'
        # Often used as alternate constructors
        year, month, day = map(int, date_str.split('-'))
        return cls(year, month, day)

today = Date.from_string("2025-10-25")

Encapsulation & Properties

Python uses underscores to indicate access restrictions, and the `@property` decorator to build getters and setters.

Name Mangling (Private Variables)
class Vault:
    def __init__(self):
        self._protected = "Please don't touch"
        self.__private = "Super Secret" # Name mangled

v = Vault()
print(v._protected) # Accessible, but bad practice
# print(v.__private) # AttributeError!
print(v._Vault__private) # How it's internally renamed (mangled)
The `@property` Decorator

Allows you to define methods that can be accessed like attributes, giving you control over getting and setting data.

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
        
    @property
    def fahrenheit(self):
        # Acts like a getter
        return (self._celsius * 9/5) + 32
        
    @fahrenheit.setter
    def fahrenheit(self, value):
        # Validates and sets data
        if value < -459.67:
            raise ValueError("Temperature below absolute zero!")
        self._celsius = (value - 32) * 5/9

t = Temperature(0)
print(t.fahrenheit) # 32.0 (Accessed like a property, not a method!)
t.fahrenheit = 212  # Calls the setter
print(t._celsius)   # 100.0

Exception Handling & Custom Exceptions

Robust programs anticipate failures. Python uses try/except blocks to gracefully handle runtime errors instead of crashing.

The `try-except-else-finally` Block
  • try: Code that might fail.
  • except: Code that runs if an exception occurs.
  • else: Code that runs only if NO exception occurs.
  • finally: Code that runs always (used for cleanup).
try:
    result = 10 / 2
except ZeroDivisionError:
    print("Cannot divide by zero!")
except Exception as e:
    print(f"Generic error caught: {e}")
else:
    print(f"Success! Result is {result}")
finally:
    print("Execution complete.")
Raising Custom Exceptions

You can create your own exception hierarchy by inheriting from the base Exception class.

class InvalidAgeError(Exception):
    """Raised when an age is illogical."""
    pass

def register_user(age):
    if age < 0 or age > 150:
        raise InvalidAgeError(f"Age {age} is invalid.")
    print("Registered.")

try:
    register_user(200)
except InvalidAgeError as e:
    print(e) # Output: Age 200 is invalid.

File Handling & Context Managers

Python handles file I/O safely via Context Managers (the with statement), ensuring resources are released.

Reading and Writing Files
# Writing to a file (overwrites existing content)
with open('data.txt', 'w') as file:
    file.write("Line 1\n")
    file.write("Line 2\n")

# Reading from a file line-by-line
with open('data.txt', 'r') as file:
    for line in file:
        print(line.strip()) # strip() removes the \n
Working with JSON

JSON is the standard for web data. Python's built-in json module makes serialization easy.

import json

user = {"name": "Alice", "skills": ["Python", "AWS"]}

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

# Read JSON file back into a dictionary
with open('user.json', 'r') as f:
    data = json.load(f)
    print(data["skills"]) # ['Python', 'AWS']
Custom Context Managers

You can make your own objects compatible with the with statement using __enter__ and __exit__.

class Timer:
    import time
    def __enter__(self):
        self.start = self.time.time()
        return self
        
    def __exit__(self, exc_type, exc_val, exc_tb):
        self.end = self.time.time()
        print(f"Elapsed: {self.end - self.start:.4f} seconds")

# Usage:
with Timer():
    sum([x**2 for x in range(1000000)])

Iterators, Generators & `yield`

Generators allow you to lazily produce a sequence of values over time, rather than computing them all at once and storing them in memory.

The `yield` Keyword

When a function contains yield, it becomes a generator. Instead of returning a value and destroying local variables, it yields a value and pauses execution, saving its state for the next call.

def countdown(num):
    print("Starting countdown...")
    while num > 0:
        yield num  # Pauses here, returns num
        num -= 1
    print("Blastoff!")

# The function does not run until iterated!
for n in countdown(3):
    print(n)
# Output:
# Starting countdown...
# 3
# 2
# 1
# Blastoff!
Generator Expressions

Similar to list comprehensions, but they use parentheses () and are incredibly memory efficient because they compute values on-the-fly.

import sys

# List Comprehension: Computes all 1 million numbers and stores in RAM
list_comp = [x**2 for x in range(1000000)]
print(sys.getsizeof(list_comp)) # ~8,000,000 bytes

# Generator Expression: Stores only the formula, not the data
gen_expr = (x**2 for x in range(1000000))
print(sys.getsizeof(gen_expr))  # ~104 bytes!!!

# To use it:
# print(next(gen_expr)) # 0
# print(next(gen_expr)) # 1

Advanced Decorators

Decorators dynamically alter the functionality of a function, method, or class without having to directly use subclasses or change the source code of the function being decorated.

Creating a Basic Decorator

A decorator is a function that takes another function and extends its behavior.

def logger(func):
    def wrapper(*args, **kwargs):
        print(f"Calling function: {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Function {func.__name__} finished executing.")
        return result
    return wrapper

@logger
def add(x, y):
    return x + y

add(5, 7)
Decorators with Arguments

If you want to pass arguments to a decorator, you need three levels of nested functions!

def repeat(num_times):
    def decorator_repeat(func):
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator_repeat

@repeat(num_times=3)
def greet(name):
    print(f"Hello {name}")

greet("World")
# Output:
# Hello World
# Hello World
# Hello World