Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 513
Phil Kang Avatar asked Jan 28 '19 10:01

Phil Kang


People also ask

What is a runtime warning?

A RuntimeWarning is used to signal to the runtime framework that a non-fatal error has been encountered. Server startup will proceed as usual.

How do you create a warning in Python?

The warn() function defined in the ' warning ' module is used to show warning messages. The warning module is actually a subclass of Exception which is a built-in class in Python. print ( 'Geeks !' )


2 Answers

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.

like image 124
David Turley Avatar answered Oct 26 '22 00:10

David Turley


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()
like image 28
RedRuin Avatar answered Oct 26 '22 02:10

RedRuin