Demonstrating handling Python errors better with reproducible scripts

4 minute read



I’ve set myself a challenge to only write a post when I’ve been able to combine three of the latest things I’ve learnt into a single example. This comes from a trick I learnt when learning the piano. In order to maximise practice time I’d practice studies or examples that would combine three new techniques or concepts.

In this post I’m writing about how uvs support for PEP 723 has helps me demonstrate how the traceback module can give more information when handling errors.

PEP 723 introduced support for metadata inside Python scripts. A common challenge in business environments is when Python scripts serving critical but hard-to-define purposes suddenly break due to changes in Python versions or dependencies. PEP 723 addresses this issue by introducing “inline script metadata” - a standard way to specify Python version requirements and dependencies directly within scripts. This feature, documented in detail here, ensures scripts remain reliable until they’re either deprecated, modified, or no longer needed. Here’s what this metadata might look like in a basic data analysis script.

1
2
3
4
5
6
# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "pandas==2.2.3",
# ]
# ///

uv has revolutionized my Python workflow in several ways. As a high-performance tool that serves as a drop-in replacement for pip, it has significantly accelerated our build processes at work. Beyond speed improvements, uv’s native support for PEP 723 makes dependency management straightforward - simply install uv and execute your script to leverage its enhanced functionality, regardless of your environment.

1
2
3
4
# install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# run your script
uv run your_fun_new_script.py

Error handling is an important concept in defensive programming. While implementing sensible ValueErrors is often sufficient for basic error handling needs, complex programs and domains may require access to the full stack trace rather than just isolated error messages. Though there are deeper concepts to explore around error handling practices and treating errors as data, I’ll focus on some fundamental approaches for now.

Here’s an example of a function that returns an error if b is zero.

1
2
3
4
def divide_numbers(a, b):
    if b == 0:
        raise ValueError("The divisor 'b' cannot be zero.")
    return a / b

This is what it returns.

1
The divisor 'b' cannot be zero.

This next function shows how the stacktrace can be returned as part of the error message.

1
2
3
4
5
6
7
8
9
import traceback

def divide_numbers_stacktrace(a, b):
    def nested_division():
        if b == 0:
            stack_trace = ''.join(traceback.format_stack())
            raise ValueError(f"The divisor 'b' cannot be zero.\nStack trace:\n{stack_trace}")
        return a / b
    return nested_division()

Which then returns the below.

1
2
3
4
5
6
7
8
9
10
Error: The divisor 'b' cannot be zero.
Stack trace:
  File "/root/snippets/scripts/error_handling.py", line 38, in <module>
    simple_example()
  File "/root/snippets/scripts/error_handling.py", line 31, in simple_example
    result = divide_numbers_stacktrace(10, 0)
  File "/root/snippets/scripts/error_handling.py", line 21, in divide_numbers_stacktrace
    return nested_division()
  File "/root/snippets/scripts/error_handling.py", line 18, in nested_division
    stack_trace = ''.join(traceback.format_stack())

This isn’t the only approach one can take to return more comprehensive error messages. You can use traceback.print_exec to return the stack trace to sys.stdout.

1
2
3
4
5
6
7
8
try:
    result = divide_numbers(10, 0)
except ValueError as e:
    # Print full exception traceback
    print("Detailed error information:")
    print(e)
    print("\nFull traceback:")
    traceback.print_exc(file=sys.stdout)

This is the script I used to demonstrate this concept - it can also be seen as a gist here.