mypy and verbose logging
tl;dr:
If you want to add a custom logging level to Python (e.g. verbose
), and you want it to play nicely with mypy (e.g. to not report error: "Logger" has no attribute "verbose"
every time you call logger.verbose()
), there doesn’t seem to be a better way than appending # type: ignore
to every call to logger.verbose()
:
I’d love to find a better way to do this, so please reach out if you know a solution. I’ve done quite a lot of digging into this, but I’m new to mypy, so perhaps I’ve missed something. Regardless, read on for the story on how I came to this conclusion, and what the worse alternative solution is.
Python’s standard library logging
module comes with five log levels:
- CRITICAL
- ERROR
- WARNING
- INFO
- DEBUG
Each has a corresponding log method of the same name, but in lowercase, which is used to write to the log file at that log level. For example:
It is fairly typical behaviour to add your own custom log level. For example, a VERBOSE or TRACE level log, which may sit one level lower than DEBUG. Althrough there are many answers on how to do this, they fall into a few categories.
The monkeypatch method §
The monkeypatch method involves adding a verbose
method to the existing default logger class:
The first problemw with this is the actual monkeypatching itself on the final line. This causes mypy to report error: "Type[Logger]" has no attribute "verbose"
because we are trying to set an attribute outside of initialisation. Ideally, we would fix this by modifying the Logger
class ourselves (such as when using a mixin with mypy). But unfortunately the Logger
class exists in the Python standard library, which is outside of our reach.
We can append # type: ignore
to this line, or we could rewrite it using setattr
, e.g.:
However, neither of those solutions help mypy understand that our logger class does have a verbose
method. So any call to logger.verbose()
still results in error: "Logger" has no attribute "verbose"
.
This means that every time we call logger.verbose()
, we would have to append # type: ignore
as a comment. For example:
In a code base with lots of calls to logger.verbose()
, this is not ideal.
The class method §
The class method involves using the logging.setLoggerClass
to set the default logger class. This avoids the slightly dirty aspect of monkeypatching, but still makes use of the _log
private method:
The class method has a couple of disadvantages.
The first disadvantage of the class method is that init_verbose_logging
must be called before logging.getLogger
is called, since it will not affect any Logger
objects that already exist. For example:
Whereas the monkeypatch method will affect the existing Logger
object, and need only be called before logging.verbose
is called. For example:
However, we can easily work around that by modifying our codebase to ensure that init_verbose_logging
is called first thing.
The second disadvantage is more problematic. The use of logging.getLoggerClass()
is not supported by mypy. It will report error: Unsupported dynamic base class "logging.getLoggerClass"
. We can ignore this specific warning by applying the # type: ignore
comment to the class, e.g.:
Alternatively, it may be safe to replace logging.getLoggerClass()
with logging.Logger
, assuming that no other code in your codebase also tries to replace the logger class.
At this point we encounter the exact same problem we had with the monkeypatch method, in that every call to logger.verbose()
must have # type: ignore
afterwards, which is not ideal.
Solution §
So what is the solution?
I have found one solution, but I will say up front, it is not great.
For your own Python code, you can include the type hint annotations that mypy needs inline. However, if you are using third-party code which does not have type annotations, you would be in a pickle if it were not for stubs. Stubs allow the type hinting to be defined in separate .pyi
files. These can be included inside the Python module that requires the type hinting, or in a separate Python module with the -stubs
suffix. This latter point is especially important, because it means that without modifying the third-party library itself, you can define the type hinting for it.
For example, if there exists a foobar
library without type hinting, a foobar-stubs
package can be created which has the type hinting defined for the public interface.
Since the Python standard library does not have type hinting throughout, the typeshed package (which is included inside mypy) was developed to provide these stubs. This lets you use all of the Python standard library with mypy out-of-the-box.
If we look inside the typeshed definition for the Python standard library logging module, we can see in __init__.pyi
that only the five log levels mentioned at the start of this post are defined:
Going one step further, there is also the concept of partial stubs. As the name implies, these are stubs that only provide a type hinting definition for part of the public interface of the Python module.
Note that the type checker resolution order will check for partial stubs before it falls back to typeshed. This means it is possible to override the typeshed type hinting!
Perhaps you can see where this is going?
We can write a partial stub to define the verbose
method, which will fix the error: "Logger" has no attribute "verbose"
. This should be as easy finding a way to override the __init__.pyi
file to include:
Where this solution falls short is that partial stubs operate on a per-file basis, and according to the BDFL, there is no intention to support partial stubs at the function level.
So the “solution”, which I am not keen to present, is to create a Python package called logging-stubs
. I followed the mypy example partial stubs package, and created this:
The file contents are as follows:
In accordance with PEP-0561, this indicates the package is a partial stub.
The following __init__.pyi
file is a copy of the latest logging/__init__.pyi
file from typeshed, but with the verbose
method added into the Logger
class (in two places). It’s served as a separate file due to its size:
- ⬇️ Download
this
__init__.pyi
.
Inside this logging-stubs
package, run python setup.py bdist_wheel
to build the Python wheel, then add it as a test dependency to your code.
Needless to say (again), this is not a good solution. It is very brittle, because we would miss out on any updates the typeshed project make to the standard library logging support. Looking at the history for this file, we can see it has been fairly updated over the years, even over the past few months.
To come full circle, I regret to conclude that the best solution I could find for adding a verbose log level that plays nicely with mypy is to sprinkle a little # type: ignore
after every logging.verbose()
call.
P.S. While I ran out of time to write about it, I did explore the idea of finding a way to suppress all the errors caused by logger.verbose()
by modifying the mypy configuration, but was unsuccessful. Perhaps though this is worth exploring further?
Backlinks §
- GitHub user randolf-scholz has linked back to this page from this Python typing discussion.