Hello! Welcome back to your Python for DevOps course.
In our last lesson, you learned how to create functions to organize your code into reusable blocks. This is a crucial step towards writing clean and maintainable automation scripts. You now know how to pass information into functions with parameters and get results back using return.
However, the real world is messy. Scripts interact with external systems like files, networks, and services, and these interactions can fail for many reasons. A file might not exist, a server might be down, or a configuration value might be invalid. A simple script would just crash, but a robust automation tool needs to handle these problems gracefully.
Today's Learning Outcome: By the end of this lesson, you will be able to handle exceptions and errors in Python programs gracefully. We'll cover how to anticipate and manage runtime errors using try...except blocks, ensuring your scripts are reliable and resilient.
1. What is an Exception?
An exception is an error that occurs during the execution of a program. When an exception happens, Python's normal flow is interrupted, and it looks for a way to handle the error. If it can't find one, the program terminates and prints a "traceback"—a detailed report of where the error occurred.
Let's see this happen. Imagine a script trying to read a server configuration value, but the value is missing, causing a division by zero.
# A function to calculate resource allocation
def calculate_ratio(total_cpu, num_services):
return total_cpu / num_services
# In a real script, this might come from a config file
available_cpu = 16
running_services = 0 # Oops, a service failed to report, so this is zero
# This line will cause the script to crash
ratio = calculate_ratio(available_cpu, running_services)
print(f"CPU per service: {ratio}")
print("Script continues...") # This line will never be reached
If you run this, the script stops dead at the division and you get a ZeroDivisionError. This is where exception handling comes in.
2. The try...except Block: Your Safety Net
To prevent a crash, you can wrap "risky" code in a try block. If an error occurs inside the try block, Python immediately jumps to a corresponding except block instead of crashing.
Python Tutorial: Using Try/Except Blocks for Error Handling
This video from Corey Schafer provides a perfect introduction to the try...except block. He demonstrates a common scenario in DevOps—trying to open a file that doesn't exist—and shows how try...except provides a clean way to handle the resulting error.
Watch from the beginning to 2:07. Notice the difference between the program crashing with a traceback and it printing a user-friendly message.
Let's apply this to our ZeroDivisionError example:
def calculate_ratio(total_cpu, num_services):
return total_cpu / num_services
available_cpu = 16
running_services = 0
try:
# We try to run the code that might fail
ratio = calculate_ratio(available_cpu, running_services)
print(f"CPU per service: {ratio}")
except:
# This block runs ONLY if an exception occurred in the try block
print("Error: Cannot calculate ratio. Are services running?")
print("Script continues...") # Now, this line is reached!
Our script no longer crashes. It prints a helpful message and continues running.
3. Handling Specific Exceptions
The except block in the example above is a "bare" except, meaning it will catch any possible error. This is generally considered bad practice because it can hide bugs you weren't expecting. A better approach is to catch only the specific exceptions you anticipate.
You can have multiple except blocks to handle different types of errors in different ways.
Python Tutorial: Using Try/Except Blocks for Error Handling
Let's continue with Corey Schafer's tutorial to see why being specific is important and how to handle multiple, specific exceptions.
Watch from 2:07 to 4:53. Pay close attention to: Why catching a general Exception can be misleading. How to stack multiple except blocks for different errors (e.g., FileNotFoundError, NameError). The rule that more specific exceptions must come before more general ones.
Accessing the Exception Object
You can also get access to the exception object itself, which often contains a useful error message. This is done using the as keyword.
user_input = "not-a-number"
try:
value = int(user_input)
result = 10 / value
except ValueError as e:
# Catches errors from trying to convert a non-number to an int
print(f"Invalid input: {e}")
except ZeroDivisionError as e:
# Catches errors from dividing by zero
print(f"Logical error: {e}")
except Exception as e:
# A catch-all for any other unexpected errors
print(f"An unexpected error occurred: {e}")
Test your understanding!
You are writing a script that reads a list of server IPs from a file, servers.txt, and tries to connect to the second server in the list (index 1). What two specific exceptions should you anticipate and handle in your try block?
Show answer
You should handle:
FileNotFoundError: This will occur ifservers.txtdoesn't exist.IndexError: This will occur if the file exists but contains fewer than two lines, so accessingservers[1]is not possible.
4. The else and finally Clauses
The try...except structure can be extended with two more optional clauses: else and finally.
else: The code in theelseblock runs only if no exceptions were raised in thetryblock. This is useful for separating your "success" logic from the code you're protecting.finally: The code in thefinallyblock always runs, regardless of whether an exception occurred or not. It's perfect for cleanup actions, like closing a file or a network connection, ensuring resources are released properly.

Python Tutorial: Using Try/Except Blocks for Error Handling
This next segment will clarify the roles of else and finally. The finally clause is especially important in automation for guaranteeing cleanup.
Watch from 5:39 to 8:35. Focus on how else is used for code that should only run on success, and how finally is used for essential cleanup actions that must happen no matter what.
Here is the full structure in action:
f = None # Initialize f to None
try:
f = open('config.txt', 'r')
# This line could raise an exception if the file is empty or malformed
config_data = f.readline()
except FileNotFoundError:
print("Error: config.txt not found. Using default settings.")
except Exception as e:
print(f"An error occurred while reading the file: {e}")
else:
# This runs only if the file was opened and read successfully
print("Configuration loaded successfully.")
print(f"Config data: {config_data.strip()}")
finally:
# This ALWAYS runs, ensuring the file is closed if it was opened
if f:
print("Closing the file.")
f.close()
5. Raising Your Own Exceptions
Sometimes, your code might encounter a logical error that Python wouldn't consider an exception. For example, a function expects a positive number, but receives a negative one. You can signal this problem by manually "raising" an exception using the raise keyword.
For better readability and more specific error handling, you can also define your own custom exceptions. A custom exception is simply a class that inherits from Python's built-in Exception class.
For a deeper understanding of this, including creating custom exceptions, the following reading is highly recommended.
Python Try Except: Examples And Best Practices
The article 'Python Try Except' from Python.land provides excellent written explanations and examples for the topics we've covered, with a great section on creating and raising your own exceptions.
Read the sections titled 'Create custom exceptions' and 'Raising (or throwing) exceptions'. This will show you how to define your own error types (like UserNotFoundError) and how to use raise to trigger them.
Here's an example relevant to DevOps. Imagine a function to deploy an application, but only to a "staging" or "production" environment.
# Define a custom exception for our application
class InvalidEnvironmentError(Exception):
pass
def deploy_app(environment):
"""Deploys an application to a specified environment."""
if environment not in ["staging", "production"]:
# Raise our custom exception if the environment is invalid
raise InvalidEnvironmentError(f"'{environment}' is not a valid deployment target.")
print(f"Deploying application to {environment}...")
# ... deployment logic would go here ...
try:
deploy_app("staging") # This will succeed
deploy_app("development") # This will fail
except InvalidEnvironmentError as e:
print(f"Deployment failed: {e}")
Creating custom exceptions like InvalidEnvironmentError or ServiceUnresponsiveError makes your code much more self-documenting and easier to debug.
Practical Exercise: Robust Log Parser
Let's make the log parser from our last lesson more robust. The original function would crash if it received a malformed log line.
Your Task:
Modify the parse_log_line function to handle improperly formatted log lines.
- It should accept a log line string.
- If the line is well-formed (e.g.,
"INFO:user_login:user=admin"), it should return a dictionary as before. - If the line is malformed (e.g., missing parts, like
"WARN:disk_space"or just"some random text"), it should catch the error (IndexError,ValueError) and returnNone.
def parse_log_line_robust(log_line):
"""
Parses a log line and returns a dictionary.
Returns None if the line is malformed.
Format: "LEVEL:EVENT:key1=value1,key2=value2"
"""
try:
log_data = {}
# This part is prone to IndexError if format is wrong
parts = log_line.split(':', 2)
log_data['level'] = parts[0]
log_data['event'] = parts[1]
# Check if there are details to parse
if len(parts) > 2:
details_str = parts[2]
pairs = details_str.split(',')
for pair in pairs:
# This part is prone to ValueError if pair isn't 'k=v'
key, value = pair.split('=')
log_data[key] = value
return log_data
except (IndexError, ValueError):
# Catch errors from splitting and unpacking
print(f"Warning: Malformed log line skipped: '{log_line}'")
return None
# --- Testing the function ---
log_entries = [
"INFO:user_login:user=admin,ip=192.168.1.100",
"ERROR:db_connection_failed", # Malformed
"CRITICAL:cpu_overload:host=web-01,cpu=95",
"just some junk text" # Malformed
]
for entry in log_entries:
parsed = parse_log_line_robust(entry)
if parsed:
print(f"Successfully parsed: {parsed}")
Test your solution
When you run the test code above, your output should look similar to this:
Successfully parsed: {'level': 'INFO', 'event': 'user_login', 'user': 'admin', 'ip': '192.168.1.100'}
Warning: Malformed log line skipped: 'ERROR:db_connection_failed'
Successfully parsed: {'level': 'CRITICAL', 'event': 'cpu_overload', 'host': 'web-01', 'cpu': '95'}
Warning: Malformed log line skipped: 'just some junk text'
Your original parse_log_line function would have crashed on the second log entry. This version handles it and continues processing the rest of the logs.
Conclusion
You've now learned how to write Python code that doesn't just work on the "happy path" but is also resilient to real-world problems. This is a non-negotiable skill for writing reliable automation.
Key Takeaways:
- Exceptions are runtime errors that interrupt a program's flow.
- The
try...exceptblock allows you to catch exceptions and handle them without crashing. - It's best practice to catch specific exceptions rather than using a bare
except. - The
elseblock runs if no exception occurs, and thefinallyblock always runs, making it ideal for cleanup. - Use the
raisekeyword to trigger exceptions manually, and create custom exceptions for application-specific errors to improve clarity.
Next Up
In our next lesson, we'll put these new skills to immediate use. You will learn to read from and write to JSON and CSV files in Python. Handling files is a primary area where exceptions occur (FileNotFoundError, PermissionError), so you'll be well-prepared to write robust file-processing scripts.