5 Python good practices which make life easier


Welcome to the next pikoTutorial!

Consider the following example code that raises an exception:

def send_request():
    raise RuntimeError('Send request timeout!')
    
def do_something():
    send_request()
    
def do_something_else():
    send_request()
    
do_something()
do_something_else()

When we run it, we see the following output:

Traceback (most recent call last):
  File "<main.py>", line 11, in <module>
  File "<main.py>", line 5, in do_something
  File "<main.py>", line 2, in send_request
RuntimeError: Send request timeout!

The traceback provides detailed information, including where the error occurred, the exception type, and the error message. However, the downside is that the program crashes without attempting to handle the error. This often leads developers to focus on implementing error handling without considering the need for debugging when errors persist. Let’s add error handling to catch the exception:

try:
    do_something()
    do_something_else()
except RuntimeError as e:
    print(f'Operation failed: {e}')

This code handles the exception gracefully, but the output is now:

Operation failed: Send request timeout!

While this output is clean, it lacks the valuable traceback details we had before, making it harder to identify which function caused the error. It may be fine in some cases, but here it’s especially problematic because send_request() function is used in more than one place in the code, so the exception could come either from do_something() or do_something_else() function. This information may be priceless during debugging and fortunately there’s a way to bring it back without sacraficing program’s robustness. Python provides a traceback module for that:

import traceback
    
try:
    do_something()
    do_something_else()
except RuntimeError as e:
    print(f'Operation failed: {e}\n' \
          f'{traceback.format_exc()}')

Now, the output includes the full traceback, helping us pinpoint where the exception occurred:

Operation failed: Send request timeout!
Traceback (most recent call last):
  File "<main.py>", line 13, in <module>
  File "<main.py>", line 7, in do_something
  File "<main.py>", line 4, in send_request
RuntimeError: Send request timeout!

This approach allows for effective error handling while maintaining critical debugging information.

Note for beginners: Some of you may now start asking: why bother with traceback module to obtain debugging information if just calling raise directly after printing error in except block will display them too? The thing is that in this example we don’t want to exit the application after the exception has been thrown. Re-raising the exception will indeed preserve all the display information, but it will also terminate the program.

Avoid mutable default arguments

Using mutable default arguments (like lists or dictionaries) in function definitions can lead to unexpected behavior because they maintain their state across multiple function calls. Consider the following example:

def add_item(item, items=[]):
    items.append(item)
    return items

print(add_item(2))
print(add_item(3))

You might expect each call to produce a single-element list, [2] and [3]. However, the actual output is:

[2]
[2, 3]

This is what I meant by saying about retaining state across multiple function calls. The default value of items has changed from an empty list to a list with 2 inside added there during the first function call.

Use virtual environments

Python’s virtualenv or venv modules allow you to create isolated environments for your projects. This practice keeps dependencies required by different projects separate and prevents version conflicts. To create a virtual environment:

python -m venv myenv

In order to use the environment, you need to first activate it in the currently used terminal. To do that on Windows, call:

myenv\Scripts\activate

On Unix or MacOS:

source myenv/bin/activate

From now on, you can use pip install as usual and whenever you want to save your project’s dependencies, store the in a requirements.txt file suing command:

pip freeze > requirements.txt

Commit this file to your project’s repository because it will allow everyone else to install all the necessary dependencies in their local virtual environments by calling:

pip install -r requirements.txt

Use enumerate for indexing

When iterating over a list and you need both the index and the value, use enumerate() instead of manually managing the index variable. This approach is more Pythonic and eliminates errors associated with manual index handling.

Instead of:

index = 0
for value in my_list:
    print(index, value)
    index += 1

Use:

for index, value in enumerate(my_list):
    print(index, value)

enumerate() is not only cleaner but also more readable, making your code less prone to bugs.

Use context managers for resource anagement

If you’re familiar with RAII concept (Resource Acquisition Is Initialization), context managers may be treated as RAII implementation for Python. They ensure that resources like files, sockets or database connections are properly managed and automatically cleaned up after use. The most common example can be opening a file:

with open('file.txt', 'r') as f:
    content = f.read()

After that, f is automatically closed at the end of with block.