'How do I assign a stacklevel to a Warning depending on the caller?

I have a Python class that issues a warning inside __init__(). It also provides a factory class method for opening and reading a file:

from warnings import warn

class MyWarning(Warning):
    """Warning issued when an invalid name is found."""
    pass

class MyClass:
    def __init__(self, names):
        # Simplified; actual code is longer
        if is_invalid(names):
            names = fix_names(names)
            warn(f'{names!r} contains invalid element(s)',
                MyWarning, stacklevel=2)
        self._names = names

    @classmethod
    def from_file(cls, filename):
        with open(filename) as file:
            names = extract_names(file)
        return cls(names)

stacklevel=2 makes the warning refer to the call to MyClass() rather than the warn() statement itself. This works when user code directly instantiates MyClass. However, when MyClass.from_file() issues the warning, MyWarning refers to return cls(names), not the user code calling from_file().

How do I ensure that the factory method also issues a warning that points to the caller? Some options I've considered:

  1. Add a "hidden" _stacklevel parameter to __init__(), and instantiate MyClass with _stacklevel=2 inside from_file().
    • This is super ugly, and exposes internal behavior to the API.
  2. Add a "hidden" _stacklevel class attribute, and access it inside __init__(). Then temporarily modify this attribute in from_file()
    • Also super ugly.
  3. Add a _set_names() method that checks/fixes the names and issues a warning when needed. Then call this method inside the constructor. For from_file(), first instantiate MyClass with empty args, then directly call _set_names() to ensure that MyWarning points to the caller.
    • Still hacky, and effectively calls _set_names() twice when from_file() is called.
  4. Catch and re-throw the warning, similar to exception chaining.
    • Sounds good, but I have no idea how to do this.

I read the warning module docs but it offers little help on safely catching and re-throwing warnings. Converting the warning to an exception using warnings.simplefilter() would interrupt MyClass() and force me to call it again.



Solution 1:[1]

You can catch warnings similar to the way you catch exceptions using warnings.catch_warnings():

import warnings

class MyWarning(Warning):
    """Warning issued when an invalid name is found."""
    pass

class MyClass:
    def __init__(self, names):
        # Simplified; actual code is longer
        if is_invalid(names):
            names = fix_names(names)
            warn(f'{names!r} contains invalid element(s)',
                MyWarning, stacklevel=2)
        self._names = names

    @classmethod
    def from_file(cls, filename):
        with open(filename) as file:
            names = extract_names(file)
        with warnings.catch_warnings(record=True) as cx_manager:
            inst = cls(names)

        #re-report warnings with the stack-level we want
        for warning in cx_manager:
            warnings.warn(warning.message, warning.category, stacklevel=2)

        return inst

Just keep in mind the following note from the documentation of warnings.catch_warnings():

Note The catch_warnings manager works by replacing and then later restoring the module’s showwarning() function and internal list of filter specifications. This means the context manager is modifying global state and therefore is not thread-safe.

Solution 2:[2]

David is right, warnings.catch_warnings(record=True) is probably what you want. Though I would write it as a function decorator instead:

def reissue_warnings(func):
    def inner(*args, **kwargs):
        with warnings.catch_warnings(record = True) as warning_list:
            result = func(*args, **kwargs)
        for warning in warning_list:
            warnings.warn(warning.message, warning.category, stacklevel = 2)
        return result
    return inner

And then in your example:

class MyClass:
    def __init__(self, names):
        # ...

    @classmethod
    @reissue_warnings
    def from_file(cls, filename):
        with open(filename) as file:
            names = extract_names(file)
        return cls(names)

inst = MyClass(['some', 'names'])   # 58: MyWarning: ['some', 'names'] contains invalid element(s)
inst = MyClass.from_file('example') # 59: MyWarning: ['example'] contains invalid element(s)

This way also allows you to cleanly collect and reissue warnings across multiple functions as well:

class Test:
    def a(self):
        warnings.warn("This is a warning issued from a()")
        
    @reissue_warnings
    def b(self):
        self.a()
    
    @reissue_warnings
    def c(self):
        warnings.warn("This is a warning issued from c()")
        self.b()
        
    @reissue_warnings
    def d(self):
        self.c()
        
test = Test()
test.d() # Line 59
# 59: UserWarning: This is a warning issued from c()
# 59: UserWarning: This is a warning issued from a()

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 buddemat
Solution 2