Chapter 2
Functions, Modules & Packages, Exceptional Handling
2.1. Function Basics: Scope, Nested Function, Non-local Statements
Scope
- Local Scope: Variables defined inside a function. They are only accessible within
that function.
- Global Scope: Variables defined outside of all functions. They are accessible
anywhere in the code.
- Non-local Scope: Variables in the enclosing scope of a nested function (a function
defined inside another function).
Example:
x = "global"
def outer():
x = "outer"
def inner():
x = "inner" # Local scope of `inner`
print(x)
inner()
print(x) # Outer scope
outer()
print(x) # Global scope
Nested Function
A function defined within another function. It can access variables of the enclosing
function (but not global variables unless specifically declared global).
Example:
def outer_function():
x = "Hello"
def inner_function():
print(x) # Accesses the `x` from the outer function
inner_function()
outer_function()
Non-local Statements
The `nonlocal` keyword allows you to modify variables in the nearest enclosing scope
(but not global scope).
Example:
def outer():
x = "outer"
def inner():
nonlocal x # Refers to the `x` in outer function
x = "inner"
print(x)
inner()
print(x)
outer()
2.2. Built-in Functions
Python provides numerous built-in functions. These are always available for use
without importing any module.
- Common Built-in Functions:
- `print()`: Outputs data to the console.
- `len()`: Returns the length of an object.
- `type()`: Returns the type of an object.
- `max()`, `min()`: Find the maximum or minimum value.
- `sum()`: Returns the sum of all elements in an iterable.
- `sorted()`: Sorts an iterable.
- `input()`: Reads input from the user.
- `map()`, `filter()`: Apply a function to every item in an iterable or filter items based
on a function.
Example:
numbers = [1, 2, 3, 4, 5]
print(sum(numbers)) # Output: 15
print(sorted(numbers, reverse=True)) # Output: [5, 4, 3, 2, 1]
2.3. Types of Functions
User-defined Functions: Functions that are created by the user.
def greet(name):
return f"Hello, {name}"
- Anonymous Functions (Lambda): Functions defined without a name using the
`lambda` keyword. They are typically used for small, simple operations.
A lambda function is a small anonymous function.
Syntax: lambda arguments: expression
The expression is executed and the result is returned.
Lambda function can have any number of arguments but can have only one
expression.
Example of a Lambda Function:
# Lambda function to double a number
double = lambda x: x * 2
print(double(5)) # Output: 10
Lambda functions are often used with functions like `map()`, `filter()`, and `sorted()`.
Example:
numbers = [1, 2, 3, 4, 5]
# Use lambda with map to square each number
squared = map(lambda x: x ** 2, numbers)
print(list(squared)) # Output: [1, 4, 9, 16, 25]
Summary:
- Scope refers to the accessibility of variables.
- Nested functions allow functions to be defined inside other functions.
- Non-local allows access to variables from an enclosing scope.
- Python provides numerous built-in functions for common tasks.
- There are different types of functions, including anonymous functions like `lambda`
that are concise and often used for short operations.
2.4. Decorators and Generators
Decorators
A decorator is a function that modifies the behaviour of another function or class.
Decorators are often used to add functionality to existing functions in a clean and
reusable way.
- Syntax: A decorator is applied to a function using the `@decorator_name` syntax.
In python, a decorator is a design pattern that allows you to modify the
functionality of a function by wrapping it in another function.
The outer function is called the decorator, which takes the original function as an
argument and returns a modified version of it.
Example of a Simple Decorator:
def make_pretty(func):
# define the inner function
def inner():
# add some additional behaviour to decorated function
print("I got decorated")
# call original function
func()
# return the inner function
return inner
# define ordinary function
def ordinary():
print("I am ordinary")
# decorate the ordinary function
decorated_func = make_pretty(ordinary)
# call the decorated function
decorated_func()
Output
I got decorated
I am ordinary
In the example shown above, make_pretty() is a decorator.
decorated_func = make_pretty(ordinary)
We are now passing the ordinary() function as the argument to the make_pretty().
The make_pretty() function returns the inner function, and it is now assigned to the
decorated_func variable.
decorated_func()
Here, we are actually calling the inner() function, where we are printing
@ Symbol With Decorator
Instead of assigning the function call to a variable, Python provides a much more
elegant way to achieve this functionality using the @ symbol. For example,
def make_pretty(func):
def inner():
print("I got decorated")
func()
return inner
@make_pretty # ordinary = make_pretty(ordinary).
def ordinary():
print("I am ordinary")
ordinary()
Output
I got decorated
I am ordinary
Here, the ordinary() function is decorated with the make_pretty() decorator using the
@make_pretty syntax, which is equivalent to calling
ordinary = make_pretty(ordinary).
Decorators are commonly used for tasks like logging, authentication, and access
control.
Another Example of a Decorator:
def my_decorator(func):
def wrapper():
print("Before the function runs")
func()
print("After the function runs")
return wrapper
@my_decorator
def say_hello():
print("Hello!")
say_hello()
- Output:
Before the function runs
Hello!
After the function runs
Generator
A Generator in Python is a function that returns an iterator using the Yield
keyword.
In Python, a generator is a function that returns an iterator that produces a
sequence of values when iterated over.
Generators are useful when we want to produce a large sequence of values, but
we don't want to store all of them in memory at once.
Generators are defined using the `yield` keyword.
Create Python Generator
In Python, similar to defining a normal function, we can define a generator
function using the def keyword, but instead of the return statement we use the
yield statement.
If the body of a def contains yield, the function automatically becomes a Python
generator function.
def generator_name(arg):
# statements
yield something
Here, the yield keyword is used to produce a value from the generator.
When the generator function is called, it does not execute the function body
immediately. Instead, it returns a generator object that can be iterated over to
produce the values.
Example: Here's an example of a generator function that produces a sequence
of numbers,
def my_generator(n):
# initialize counter
value = 0
# loop until counter is less than n
while value < n:
# produce the current value of the counter
yield value
# increment the counter
value += 1
# iterate over the generator object produced by my_generator
for value in my_generator(3):
# print each value produced by generator
print(value)
Output
In the above example, the my_generator() generator function takes an integer
n as an argument and produces a sequence of numbers from
0 to n-1 using while loop.
The yield keyword is used to produce a value from the generator and pause the
generator function's execution until the next value is requested.
The for loop iterates over the generator object produced by my_generator() and
the print statement prints each value produced by the generator.
2.5. Modules
Basic Module Usage
A module is a Python file that contains Python code (such as functions, classes, or
variables). It helps organize code into separate files and reuse it in different programs.
- Creating a Module: You create a module by saving Python code in a `.py` file.
Example (file: `mymodule.py`):
def greet(name):
return f"Hello, {name}"
age = 25
Importing Modules
You can import a module using the `import` keyword. Once imported, you can access
the functions and variables defined in the module.
Example:
import mymodule
print(mymodule.greet("Alice")) # Output: Hello, Alice
print(mymodule.age) # Output: 25
You can also give the module a different name using the `as` keyword:
import mymodule as mm
print(mm.greet("Bob")) # Output: Hello, Bob
Types of Imports
1. Import Entire Module:
import math
print(math.sqrt(16)) # Output: 4.0
2. Import Specific Functions or Variables:
You can import specific items from a module using the `from ... import` syntax.
from math import sqrt, pi
print(sqrt(25)) # Output: 5.0
print(pi) # Output: 3.141592653589793
3. Import All Functions/Variables:
You can import everything from a module using `*`. However, this is not
recommended because it can lead to conflicts between names.
from math import *
print(sqrt(36)) # Output: 6.0
Creating and Importing Your Own Modules
To create your own module, just save a Python file with functions and variables you
want to reuse. You can import it in another script using the same syntax as built-in
modules.
1. Step 1: Create a Module File
- Save the following code in a file named `my_module.py`:
def add(a, b):
return a + b
def multiply(a, b):
return a * b
2. Step 2: Import and Use the Module
- In another Python file, you can import and use the functions:
import my_module
print(my_module.add(5, 3)) # Output: 8
print(my_module.multiply(5, 3)) # Output: 15
Importing from Submodules
If you have a package (a directory containing multiple Python files), you can organize
modules into submodules.
Example:
- Directory structure:
mypackage/
__init__.py
module1.py
module2.py
To import functions from `module1.py`:
from mypackage.module1 import my_function
2.6. Importing Functions and Variables from Different Modules
You can import functions or variables from different modules in the same program.
1. Example 1:
from math import sqrt
from random import randint
print(sqrt(49)) # Output: 7.0
print(randint(1, 10)) # Output: Random number between 1 and 10
2. Example 2:
You can import from custom modules.
- Assume you have two files: `math_operations.py` and `string_operations.py`.
- File: `math_operations.py`
def add(a, b):
return a + b
- File: `string_operations.py`
def greet(name):
return f"Hello, {name}!"
- Importing functions from both modules in another script:
from math_operations import add
from string_operations import greet
print(add(10, 5)) # Output: 15
print(greet("Alice")) # Output: Hello, Alice!
This modularity helps in organizing and maintaining your code in an efficient way,
especially for large projects.
2.7. Python Built-in Modules
Python has a wide array of built-in modules that provide various functions to help
with math, random number generation, date/time manipulation, and much more.
1. `math` Module
The `math` module provides mathematical functions.
Common functions:
- `math.sqrt(x)`: Returns the square root of `x`.
- `math.pow(x, y)`: Returns `x` raised to the power of `y`.
- `math.factorial(x)`: Returns the factorial of `x`.
- `math.pi`: Returns the value of pi.
Example:
import math
print(math.sqrt(16)) # Output: 4.0
print(math.pi) # Output: 3.141592653589793
2. `random` Module
The `random` module is used for generating random numbers.
Common functions:
- `random.random()`: Returns a random float between 0 and 1.
- `random.randint(a, b)`: Returns a random integer between `a` and `b`.
- `random.choice(sequence)`: Returns a random element from a sequence (like a list).
- `random.shuffle(list)`: Shuffles the list in place.
Example:
import random
print(random.random()) # Output: Random float between 0 and 1
print(random.randint(1, 10)) # Output: Random integer between 1 and 10
print(random.choice([1, 2, 3])) # Output: Random choice from the list
3. `datetime` Module
The `datetime` module provides classes for manipulating dates and times.
Common functions:
- `datetime.datetime.now()`: Returns the current date and time.
- `datetime.datetime.strptime()`: Parses a string into a `datetime` object.
- `datetime.timedelta()`: Represents the difference between two dates/times.
Example:
import datetime
now = datetime.datetime.now()
print(now) # Output: Current date and time
Other built-in modules include:
- `os`: Interacting with the operating system.
- `sys`: Provides access to system-specific parameters and functions.
- `time`: Provides time-related functions.
2.8. Package: Import Basics
A package is a collection of Python modules organized in directories that allow for
hierarchical structuring of modules. A package typically contains an `__init__.py` file
that makes the directory a Python package.
Creating and Importing a Package
1. Creating a Package:
- Directory structure:
mypackage/
__init__.py
module1.py
module2.py
- `module1.py`:
def hello():
return "Hello from module1!"
- `module2.py`:
def greet(name):
return f"Hello, {name}, from module2!"
2. Importing a Package:
from mypackage import module1, module2
print(module1.hello()) # Output: Hello from module1!
print(module2.greet("Alice")) # Output: Hello, Alice, from module2!
You can also import specific functions or variables from modules in the package:
from mypackage.module1 import hello
2.9. Python Namespace Packages
Namespace packages allow the distribution of a single package across multiple
directories or distributions. This is useful when multiple developers or projects want
to maintain separate sub-packages.
- Namespace Packages do not require an `__init__.py` file in their directories. They
allow different portions of a package to reside in different locations on the filesystem.
Example:
You might have:
- `mypackage/subpackage1/`
- `mypackage/subpackage2/`
Each subpackage can be in different directories, but they can still be imported as part
of the same package.
from mypackage.subpackage1 import module_a
from mypackage.subpackage2 import module_b
Namespace packages make it possible to split large packages across multiple
directories without needing to modify how they are imported.
2.10. User-Defined Modules and Packages
User-Defined Modules
A module is just a Python file (`.py`) containing Python code like functions, variables,
or classes. You can create a user-defined module by writing your Python code in a file
and importing it into other files.
1. Step 1: Create a Module File
- Example (save this code in `my_math.py`):
def add(a, b):
return a + b
def subtract(a, b):
return a - b
2. Step 2: Import and Use the Module
- In another file, you can import and use the functions:
import my_math
print(my_math.add(5, 3)) # Output: 8
print(my_math.subtract(5, 3)) # Output: 2
User-Defined Packages
A package is a collection of user-defined modules organized in a directory.
1. Step 1: Create a Package
- Directory structure:
mypackage/
__init__.py # Optional in modern Python, but used to mark a directory as a
package
my_math.py
my_string.py
- `my_math.py`:
def multiply(a, b):
return a * b
- `my_string.py`:
def uppercase(s):
return s.upper()
2. Step 2: Import and Use the Package
from mypackage import my_math, my_string
print(my_math.multiply(2, 3)) # Output: 6
print(my_string.uppercase("hello")) # Output: HELLO
Using `__init__.py`:
- If you include an `__init__.py` file in the package directory, you can also define the
package's public API or initialization code.
Example `__init__.py`:
from .my_math import multiply
from .my_string import uppercase
Now you can import from the package directly:
import mypackage
print(mypackage.multiply(2, 3)) # Output: 6
print(mypackage.uppercase("hello")) # Output: HELLO
In summary:
- Python modules and packages help organize and reuse code.
- You can create your own modules by writing Python code in `.py` files and
importing them.
- Packages allow you to organize multiple related modules into a single directory
structure.
2.11. Exception Handling
Exception handling in Python allows you to manage runtime errors gracefully,
preventing your program from crashing unexpectedly and providing useful feedback
for debugging. It uses the `try`, `except`, `else`, and `finally` blocks to handle
exceptions effectively.
2.11.1. Avoiding Code Break Using Exception Handling
When an error occurs during the execution of a program, it raises an **exception**. If
the exception is not handled, the program will terminate. Exception handling allows
us to avoid this by catching the error and taking appropriate actions.
Basic Syntax:
try:
# Code that might raise an exception
except SomeException as e:
# Code to handle the exception
Example:
try:
x = 1 / 0 # Division by zero raises an exception
except ZeroDivisionError:
print("You cannot divide by zero!")
Without exception handling, the program would crash. With exception handling, it
shows a user-friendly message.
2.11.2. Safeguarding File Operation Using Exception Handling
File operations can easily fail due to reasons such as missing files, insufficient
permissions, or incorrect file modes. To avoid these issues, you can use exception
handling to safeguard file operations.
Example:
try:
file = open('non_existent_file.txt', 'r')
content = file.read()
except FileNotFoundError:
print("The file does not exist.")
finally:
try:
file.close() # Ensure file is closed if it was opened
except NameError:
pass # File was not opened, so no need to close
In this example, if the file does not exist, the `FileNotFoundError` is caught, and an
appropriate message is shown instead of the program crashing.
2.11.3. Handling Multiple and User-Defined Exceptions
Sometimes, different exceptions may occur within the same block of code. You can
handle multiple exceptions by specifying multiple `except` clauses. Additionally,
Python allows you to define your own exceptions by creating custom exception
classes.
Handling Multiple Exceptions:
try:
x = int(input("Enter a number: "))
result = 10 / x
except ZeroDivisionError:
print("Division by zero is not allowed!")
except ValueError:
print("Invalid input! Please enter a valid number.")
User-Defined Exceptions:
You can create a custom exception by subclassing the built-in `Exception` class.
class CustomError(Exception):
pass
def check_positive(number):
if number <= 0:
raise CustomError("Number must be positive!")
try:
check_positive(-5)
except CustomError as e:
print(f"Custom Error: {e}")
2.11.4. Handling and Helping Developer with Error Code
Providing meaningful error codes and messages can help developers understand what
went wrong. When an exception is raised, you can include information about the error
to assist in debugging.
Example with Error Messages:
class InvalidAgeError(Exception):
def __init__(self, age):
self.age = age
super().__init__(f"Invalid age: {age}. Age must be between 0 and 120.")
def validate_age(age):
if age < 0 or age > 120:
raise InvalidAgeError(age)
try:
validate_age(150)
except InvalidAgeError as e:
print(f"Error: {e}")
In this case, the custom `InvalidAgeError` exception includes the invalid age and
provides a clear error message.
2.11.5. Programming Using Exception Handling
Exception handling is critical for writing robust and resilient programs. Here are some
best practices for using exception handling effectively:
1. Use Specific Exceptions: Catch specific exceptions rather than using a general
`except` block. This makes your error handling more precise and avoids masking
unexpected errors.
try:
# Code that might raise an exception
except ValueError:
# Handle ValueError specifically
2. Use `else` for Code Without Exceptions: The `else` block is executed only if no
exceptions occur, allowing you to separate exception-prone code from the rest of the
logic.
try:
result = 10 / 2
except ZeroDivisionError:
print("Cannot divide by zero.")
else:
print("Division successful:", result)
3. Use `finally` for Cleanup: The `finally` block is always executed, regardless of
whether an exception occurred. It’s useful for releasing resources like file handles,
network connections, or databases.
try:
file = open('example.txt', 'r')
content = file.read()
except FileNotFoundError:
print("File not found.")
finally:
file.close() # This will always execute, ensuring the file is closed
4. Avoid Empty `except` Blocks: Avoid using an empty `except` block that catches all
exceptions without handling them, as it can make debugging harder.
try:
# Some risky code
except Exception as e:
print(f"Error occurred: {e}") # Always print or log the error
5. Raise Exceptions When Needed: Don’t hesitate to raise exceptions in your code if
certain conditions are not met. This helps make your program more robust and error-
aware.
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero!")
return a / b
Summary of Exception Handling Techniques:
- Use `try`, `except` blocks to catch and handle exceptions without breaking the
program.
- Safeguard file operations to avoid file access issues.
- Handle multiple exceptions and define custom exceptions for more precise error
handling.
- Provide meaningful error messages and codes to assist developers with debugging.
- Use `finally` to ensure necessary cleanup actions, such as closing files or network
connections.
This structured approach to exception handling helps create resilient and user-friendly
programs.
Lab Assignments:
Lab Assignment 1: Scope and Nested Functions
Problem: Write a Python program demonstrating the use of global, local, and
nonlocal scopes. Also, include a nested function that modifies a variable from the
outer function using the `nonlocal` keyword.
Solution:
# Global scope
x = "global"
def outer():
# Local scope of 'outer'
x = "outer"
def inner():
nonlocal x # Refers to the 'x' in outer function
x = "inner"
print(f"Inner function scope: {x}")
inner()
print(f"Outer function scope: {x}")
outer()
print(f"Global scope: {x}")
Expected Output:
Inner function scope: inner
Outer function scope: inner
Global scope: global
Lab Assignment 2: Built-in Functions
Problem:
Use built-in functions to perform the following tasks:
1. Find the maximum and minimum of a list.
2. Sort a list in reverse order.
3. Use `map()` to square each element in a list.
Solution:
numbers = [1, 2, 3, 4, 5]
# Finding the maximum and minimum values
print(f"Max: {max(numbers)}, Min: {min(numbers)}")
# Sorting in reverse order
print(f"Sorted in reverse: {sorted(numbers, reverse=True)}")
# Using map() to square each number
squared_numbers = list(map(lambda x: x ** 2, numbers))
print(f"Squared numbers: {squared_numbers}")
**Expected Output:**
Max: 5, Min: 1
Sorted in reverse: [5, 4, 3, 2, 1]
Squared numbers: [1, 4, 9, 16, 25]
Lab Assignment 3: Decorators
Problem:
Create a decorator that adds functionality to a function by printing "Before" and
"After" the function execution.
Solution:
def my_decorator(func):
def wrapper():
print("Before the function runs")
func()
print("After the function runs")
return wrapper
@my_decorator
def greet():
print("Hello, World!")
greet()
Expected Output:
Before the function runs
Hello, World!
After the function runs
Lab Assignment 4: Generators
Problem:
Write a generator function that yields the first 5 square numbers. Also, use a generator
expression to achieve the same result.
Solution:
# Generator function
def square_numbers():
for i in range(5):
yield i ** 2
gen = square_numbers()
for num in gen:
print(num)
# Generator expression
gen_expr = (x ** 2 for x in range(5))
for num in gen_expr:
print(num)
Expected Output:
16
Lab Assignment 5: Creating and Importing Modules
Problem:
1. Create a Python module named `mymath.py` with two functions `add()` and
`multiply()`.
2. Import this module in another script and use the functions.
Solution:
mymath.py:
def add(a, b):
return a + b
def multiply(a, b):
return a * b
Main Script:
import mymath
print(mymath.add(10, 5)) # Output: 15
print(mymath.multiply(10, 5)) # Output: 50
Expected Output:
15
50
Lab Assignment 6: Exception Handling
Problem:
Write a Python program that:
1. Takes a number as input and raises a `ValueError` if the number is negative.
2. Use `try-except` to handle multiple exceptions for invalid inputs.
Solution:
def check_positive(number):
if number < 0:
raise ValueError("Number must be positive!")
try:
num = int(input("Enter a positive number: "))
check_positive(num)
print(f"Number is: {num}")
except ValueError as e:
print(f"Error: {e}")
Expected Output (for invalid input -5):
Error: Number must be positive!