Code snippets and recipes for loguru
Security considerations when using Loguru
Firstly, if you use pickle
to load log messages (e.g. from the network), make sure the source is trustable or sign the data to verify its authenticity before deserializing it. If you do not take these precautions, malicious code could be executed by an attacker. You can read more details in this article: What’s so dangerous about pickles?
import hashlib
import hmac
import pickle
def client(connection):
data = pickle.dumps("Log message")
digest = hmac.digest(b"secret-shared-key", data, hashlib.sha1)
connection.send(digest + b" " + data)
def server(connection):
expected_digest, data = connection.read().split(b" ", 1)
data_digest = hmac.digest(b"secret-shared-key", data, hashlib.sha1)
if not hmac.compare_digest(data_digest, expected_digest):
print("Integrity error")
else:
message = pickle.loads(data)
logger.info(message)
You should also avoid logging a message that could be maliciously hand-crafted by an attacker. Calling logger.debug(message, value)
is roughly equivalent to calling print(message.format(value))
and the same safety rules apply. In particular, an attacker could force printing of assumed hidden variables of your application. Here is an article explaining the possible vulnerability: Be Careful with Python’s New-Style String Format.
SECRET_KEY = 'Y0UC4NTS33Th1S!'
class SomeValue:
def __init__(self, value):
self.value = value
# If user types "{value.__init__.__globals__[SECRET_KEY]}" then the secret key is displayed.
message = "[Custom message] " + input()
logger.info(message, value=SomeValue(10))
Another danger due to external input is the possibility of a log injection attack. Consider that you may need to escape user values before logging them: Is your Python code vulnerable to log injection?
logger.add("file.log", format="{level} {message}")
# If value is "Josh logged in.\nINFO User James" then there will appear to be two log entries.
username = external_data()
logger.info("User " + username + " logged in.")
Note that by default, Loguru will display the value of existing variables when an Exception
is logged. This is very useful for debugging but could lead to credentials appearing in log files. Make sure to turn it off in production (or set the LOGURU_DIAGNOSE=NO
environment variable).
logger.add("out.log", diagnose=False)
Another thing you should consider is to change the access permissions of your log file. Loguru creates files using the built-in open()
function, which means by default they might be read by a different user than the owner. If this is not desirable, be sure to modify the default access rights.
def opener(file, flags):
return os.open(file, flags, 0o600)
logger.add("combined.log", opener=opener)
Avoiding logs to be printed twice on the terminal
The logger is pre-configured for convenience with a default handler which writes messages to sys.stderr
. You should remove()
it first if you plan to add()
another handler logging messages to the console, otherwise you may end up with duplicated logs.
logger.remove() # Remove all handlers added so far, including the default one.
logger.add(sys.stderr, level="WARNING")
Changing the level of an existing handler
Once a handler has been added, it is actually not possible to update it. This is a deliberate choice in order to keep the Loguru’s API minimal. Several solutions are possible, tough, if you need to change the configured level
of a handler. Chose the one that best fits your use case.
The most straightforward workaround is to remove()
your handler and then re-add()
it with the updated level
parameter. To do so, you have to keep a reference to the identifier number returned while adding a handler:
handler_id = logger.add(sys.stderr, level="WARNING")
logger.info("Logging 'WARNING' or higher messages only")
...
logger.remove(handler_id) # For the default handler, it's actually '0'.
logger.add(sys.stderr, level="DEBUG")
logger.debug("Logging 'DEBUG' messages too")
Alternatively, you can combine the bind()
method with the filter
argument to provide a function dynamically filtering logs based on their level:
def my_filter(record):
if record["extra"].get("warn_only"): # "warn_only" is bound to the logger and set to 'True'
return record["level"].no >= logger.level("WARNING").no
return True # Fallback to default 'level' configured while adding the handler
logger.add(sys.stderr, filter=my_filter, level="DEBUG")
# Use this logger first, debug messages are filtered out
logger = logger.bind(warn_only=True)
logger.warn("Initialization in progress")
# Then you can use this one to log all messages
logger = logger.bind(warn_only=False)
logger.debug("Back to debug messages")
Finally, more advanced control over handler’s level can be achieved by using a callable object as the filter
:
class MyFilter:
def __init__(self, level):
self.level = level
def __call__(self, record):
levelno = logger.level(self.level).no
return record["level"].no >= levelno
my_filter = MyFilter("WARNING")
logger.add(sys.stderr, filter=my_filter, level=0)
logger.warning("OK")
logger.debug("NOK")
my_filter.level = "DEBUG"
logger.debug("OK")
Configuring Loguru to be used by a library or an application
A clear distinction must be made between the use of Loguru within a library or an application.
In case of an application, you can add handlers from anywhere in your code. It’s advised though to configure the logger from within a if __name__ == "__main__":
block inside the entry file of your script.
However, if your work is intended to be used as a library, you usually should not add any handler. This is user responsibility to configure logging according to its preferences, and it’s better not to interfere with that. Indeed, since Loguru is based on a single common logger, handlers added by a library will also receive user logs, which is generally not desirable.
By default, a third-library should not emit logs except if specifically requested. For this reason, there exist the disable()
and enable()
methods. Make sure to first call logger.disable("mylib")
. This avoids library logs to be mixed with those of the user. The user can always call logger.enable("mylib")
if he wants to access the logs of your library.
If you would like to ease logging configuration for your library users, it is advised to provide a function like configure_logger()
in charge of adding the desired handlers. This will allow the user to activate the logging only if he needs to.
To summarize, let’s look at this hypothetical package (none of the listed files are required, it all depends on how you plan your project to be used):
mypackage
├── __init__.py
├── __main__.py
├── main.py
└── mymodule.py
Files relate to Loguru as follows:
File
__init__.py
:It is the entry point when your project is used as a library (
import mypackage
).It should contain
logger.disable("mypackage")
unconditionally at the top level.It should not call
logger.add()
as it modifies handlers configuration.
File
__main__.py
:It is the entry point when your project is used as an application (
python -m mypackage
).It can contain logging configuration unconditionally at the top level.
File
main.py
:It is the entry point when your project is used as a script (
python mypackage/main.py
).It can contain logging configuration inside an
if __name__ == "__main__":
block.
File
mymodule.py
:It is an internal module used by your project.
It can use the
logger
simply by importing it.It does not need to configure anything.
Sending and receiving log messages across network or processes
It is possible to transmit logs between different processes and even between different computer if needed. Once the connection is established between the two Python programs, this requires serializing the logging record in one side while re-constructing the message on the other hand.
This can be achieved using a custom sink for the client and patch()
for the server.
# client.py
import sys
import socket
import struct
import time
import pickle
from loguru import logger
class SocketHandler:
def __init__(self, host, port):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((host, port))
def write(self, message):
record = message.record
data = pickle.dumps(record)
slen = struct.pack(">L", len(data))
self.sock.send(slen + data)
logger.configure(handlers=[{"sink": SocketHandler('localhost', 9999)}])
while 1:
time.sleep(1)
logger.info("Sending message from the client")
# server.py
import socketserver
import pickle
import struct
from loguru import logger
class LoggingStreamHandler(socketserver.StreamRequestHandler):
def handle(self):
while True:
chunk = self.connection.recv(4)
if len(chunk) < 4:
break
slen = struct.unpack('>L', chunk)[0]
chunk = self.connection.recv(slen)
while len(chunk) < slen:
chunk = chunk + self.connection.recv(slen - len(chunk))
record = pickle.loads(chunk)
level, message = record["level"].no, record["message"]
logger.patch(lambda record: record.update(record)).log(level, message)
server = socketserver.TCPServer(('localhost', 9999), LoggingStreamHandler)
server.serve_forever()
Keep in mind though that pickling is unsafe, use this with care.
Another possibility is to use a third party library like zmq
for example.
# client.py
import zmq
from zmq.log.handlers import PUBHandler
from loguru import logger
socket = zmq.Context().socket(zmq.PUB)
socket.connect("tcp://127.0.0.1:12345")
handler = PUBHandler(socket)
logger.add(handler)
logger.info("Logging from client")
# server.py
import sys
import zmq
from loguru import logger
socket = zmq.Context().socket(zmq.SUB)
socket.bind("tcp://127.0.0.1:12345")
socket.subscribe("")
logger.configure(handlers=[{"sink": sys.stderr, "format": "{message}"}])
while True:
_, message = socket.recv_multipart()
logger.info(message.decode("utf8").strip())
Resolving UnicodeEncodeError
and other encoding issues
When you write a log message, the handler may need to encode the received unicode string to a specific sequence of bytes. The encoding
used to perform this operation varies depending on the sink type and your environment. Problem may occur if you try to write a character which is not supported by the handler encoding
. In such case, it’s likely that Python will raise an UnicodeEncodeError
.
For example, this may happen while printing to the terminal:
print("天")
# UnicodeEncodeError: 'charmap' codec can't encode character '\u5929' in position 0: character maps to <undefined>
A similar error may occur while writing to a file which has not been opened using an appropriate encoding. Most common problem happen while logging to standard output or to a file on Windows. So, how to avoid such error? Simply by properly configuring your handler so that it can process any kind of unicode string.
If you are encountering this error while logging to stdout
, you have several options:
Use
sys.stderr
instead ofsys.stdout
(the former will escape faulty characters rather than raising exception)Set the
PYTHONIOENCODING
environment variable toutf-8
Call
sys.stdout.reconfigure()
withencoding='utf-8'
and / orerrors='backslashreplace'
If you are using a file sink, you can configure the errors
or encoding
parameter while adding the handler like logger.add("file.log", encoding="utf8")
for example. All additional **kwargs
argument are passed to the built-in open()
function.
For other types of handlers, you have to check if there is a way to parametrize encoding or fallback policy.
Logging entry and exit of functions with a decorator
In some cases, it might be useful to log entry and exit values of a function. Although Loguru doesn’t provide such feature out of the box, it can be easily implemented by using Python decorators:
import functools
from loguru import logger
def logger_wraps(*, entry=True, exit=True, level="DEBUG"):
def wrapper(func):
name = func.__name__
@functools.wraps(func)
def wrapped(*args, **kwargs):
logger_ = logger.opt(depth=1)
if entry:
logger_.log(level, "Entering '{}' (args={}, kwargs={})", name, args, kwargs)
result = func(*args, **kwargs)
if exit:
logger_.log(level, "Exiting '{}' (result={})", name, result)
return result
return wrapped
return wrapper
You could then use it like this:
@logger_wraps()
def foo(a, b, c):
logger.info("Inside the function")
return a * b * c
def bar():
foo(2, 4, c=8)
bar()
Which would result in:
2019-04-07 11:08:44.198 | DEBUG | __main__:bar:30 - Entering 'foo' (args=(2, 4), kwargs={'c': 8})
2019-04-07 11:08:44.198 | INFO | __main__:foo:26 - Inside the function
2019-04-07 11:08:44.198 | DEBUG | __main__:bar:30 - Exiting 'foo' (result=64)
Here is another simple example to record timing of a function:
def timeit(func):
def wrapped(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
logger.debug("Function '{}' executed in {:f} s", func.__name__, end - start)
return result
return wrapped
Using logging function based on custom added levels
After adding a new level, it’s habitually used with the log()
function:
logger.level("foobar", no=33, icon="🤖", color="<blue>")
logger.log("foobar", "A message")
For convenience, one can assign a new logging function which automatically uses the custom added level:
from functools import partialmethod
logger.__class__.foobar = partialmethod(logger.__class__.log, "foobar")
logger.foobar("A message")
The new method need to be added only once and will be usable across all your files importing the logger
. Assigning the method to logger.__class__
rather than logger
directly ensures that it stays available even after calling logger.bind()
, logger.patch()
and logger.opt()
(because these functions return a new logger
instance).
Setting permissions on created log files
To set desired permissions on created log files, use the opener
argument to pass in a custom opener with permissions octal:
def opener(file, flags):
return os.open(file, flags, 0o600) # read/write by owner only
logger.add("foo.log", rotation="100 kB", opener=opener)
When using an opener argument, all created log files including ones created during rotation will use the initially provided opener.
Note that the provided mode will be masked out by the OS umask value (describing which bits are not to be set when creating a file or directory). This value is conventionally equals to 0o022
, which means specifying a 0o666
mode will result in a 0o666 - 0o022 = 0o644
file permission in this case (which is actually the default). It is possible to change the umask value by first calling os.umask()
, but this needs to be done with careful consideration, as it changes the value globally and can cause security issues.
Preserving an opt()
parameter for the whole module
Supposing you wish to color each of your log messages without having to call logger.opt(colors=True)
every time, you can add this at the very beginning of your module:
logger = logger.opt(colors=True)
logger.info("It <green>works</>!")
However, it should be noted that it’s not possible to chain opt()
calls, using this method again will reset the colors
option to its default value (which is False
). For this reason, it is also necessary to patch the opt()
method so that all subsequent calls continue to use the desired value:
from functools import partial
logger = logger.opt(colors=True)
logger.opt = partial(logger.opt, colors=True)
logger.opt(raw=True).info("It <green>still</> works!\n")
Serializing log messages using a custom function
Each handler added with serialize=True
will create messages by converting the logging record to a valid JSON string. Depending on the sink for which the messages are intended, it may be useful to make changes to the generated string. Instead of using the serialize
parameter, you can implement your own serialization function and use it directly in your sink:
def serialize(record):
subset = {"timestamp": record["time"].timestamp(), "message": record["message"]}
return json.dumps(subset)
def sink(message):
serialized = serialize(message.record)
print(serialized)
logger.add(sink)
If you need to send structured logs to a file (or any kind of sink in general), a similar result can be obtained by using a custom format
function:
def formatter(record):
# Note this function returns the string to be formatted, not the actual message to be logged
record["extra"]["serialized"] = serialize(record)
return "{extra[serialized]}\n"
logger.add("file.log", format=formatter)
You can also use patch()
for this, so the serialization function will be called only once in case you want to use it in multiple sinks:
def patching(record):
record["extra"]["serialized"] = serialize(record)
logger = logger.patch(patching)
# Note that if "format" is not a function, possible exception will be appended to the message
logger.add(sys.stderr, format="{extra[serialized]}")
logger.add("file.log", format="{extra[serialized]}")
Rotating log file based on both size and time
The rotation
argument of file sinks accept size or time limits but not both for simplification reasons. However, it is possible to create a custom function to support more advanced scenarios:
import datetime
class Rotator:
def __init__(self, *, size, at):
now = datetime.datetime.now()
self._size_limit = size
self._time_limit = now.replace(hour=at.hour, minute=at.minute, second=at.second)
if now >= self._time_limit:
# The current time is already past the target time so it would rotate already.
# Add one day to prevent an immediate rotation.
self._time_limit += datetime.timedelta(days=1)
def should_rotate(self, message, file):
file.seek(0, 2)
if file.tell() + len(message) > self._size_limit:
return True
excess = message.record["time"].timestamp() - self._time_limit.timestamp()
if excess >= 0:
elapsed_days = datetime.timedelta(seconds=excess).days
self._time_limit += datetime.timedelta(days=elapsed_days + 1)
return True
return False
# Rotate file if over 500 MB or at midnight every day
rotator = Rotator(size=5e+8, at=datetime.time(0, 0, 0))
logger.add("file.log", rotation=rotator.should_rotate)
Adapting colors and format of logged messages dynamically
It is possible to customize the colors of your logs thanks to several markup tags. Those are used to configure the format
of your handler. By creating a appropriate formatting function, you can easily define colors depending on the logged message.
For example, if you want to associate each module with a unique color:
from collections import defaultdict
from random import choice
colors = ["blue", "cyan", "green", "magenta", "red", "yellow"]
color_per_module = defaultdict(lambda: choice(colors))
def formatter(record):
color_tag = color_per_module[record["name"]]
return "<" + color_tag + ">[{name}]</> <bold>{message}</>\n{exception}"
logger.add(sys.stderr, format=formatter)
If you need to dynamically colorize the record["message"]
, make sure that the color tags appear in the returned format instead of modifying the message:
def rainbow(text):
colors = ["red", "yellow", "green", "cyan", "blue", "magenta"]
chars = ("<{}>{}</>".format(colors[i % len(colors)], c) for i, c in enumerate(text))
return "".join(chars)
def formatter(record):
rainbow_message = rainbow(record["message"])
# Prevent '{}' in message (if any) to be incorrectly parsed during formatting
escaped = rainbow_message.replace("{", "{{").replace("}", "}}")
return "<b>{time}</> " + escaped + "\n{exception}"
logger.add(sys.stderr, format=formatter)
Dynamically formatting messages to properly align values with padding
The default formatter is unable to vertically align log messages because the length of {name}
, {function}
and {line}
are not fixed.
One workaround consists of using padding with some maximum value that should suffice most of the time. For this purpose, you can use Python’s string formatting directives, like in this example:
fmt = "{time} | {level: <8} | {name: ^15} | {function: ^15} | {line: >3} | {message}"
logger.add(sys.stderr, format=fmt)
Here, <
, ^
and >
will left, center, and right-align the respective keys, and pad them to a maximum length.
Other solutions are possible by using a formatting function or class. For example, it is possible to dynamically adjust the padding length based on previously encountered values:
class Formatter:
def __init__(self):
self.padding = 0
self.fmt = "{time} | {level: <8} | {name}:{function}:{line}{extra[padding]} | {message}\n{exception}"
def format(self, record):
length = len("{name}:{function}:{line}".format(**record))
self.padding = max(self.padding, length)
record["extra"]["padding"] = " " * (self.padding - length)
return self.fmt
formatter = Formatter()
logger.remove()
logger.add(sys.stderr, format=formatter.format)
Customizing the formatting of exceptions
Loguru will automatically add the traceback of occurring exception while using logger.exception()
or logger.opt(exception=True)
:
def inverse(x):
try:
1 / x
except ZeroDivisionError:
logger.exception("Oups...")
if __name__ == "__main__":
inverse(0)
2019-11-15 10:01:13.703 | ERROR | __main__:inverse:8 - Oups...
Traceback (most recent call last):
File "foo.py", line 6, in inverse
1 / x
ZeroDivisionError: division by zero
If the handler is added with backtrace=True
, the traceback is extended to see where the exception came from:
2019-11-15 10:11:32.829 | ERROR | __main__:inverse:8 - Oups...
Traceback (most recent call last):
File "foo.py", line 16, in <module>
inverse(0)
> File "foo.py", line 6, in inverse
1 / x
ZeroDivisionError: division by zero
If the handler is added with diagnose=True
, then the traceback is annotated to see what caused the problem:
Traceback (most recent call last):
File "foo.py", line 6, in inverse
1 / x
└ 0
ZeroDivisionError: division by zero
It is possible to further personalize the formatting of exception by adding an handler with a custom format
function. For example, supposing you want to format errors using the stackprinter
library:
import stackprinter
def format(record):
format_ = "{time} {message}\n"
if record["exception"] is not None:
record["extra"]["stack"] = stackprinter.format(record["exception"])
format_ += "{extra[stack]}\n"
return format_
logger.add(sys.stderr, format=format)
2019-11-15T10:46:18.059964+0100 Oups...
File foo.py, line 17, in inverse
15 def inverse(x):
16 try:
--> 17 1 / x
18 except ZeroDivisionError:
..................................................
x = 0
..................................................
ZeroDivisionError: division by zero
Displaying a stacktrace without using the error context
It may be useful in some cases to display the traceback at the time your message is logged, while no exceptions have been raised. Although this feature is not built-in into Loguru as it is more related to debugging than logging, it is possible to patch()
your logger and then display the stacktrace as needed (using the traceback
module):
import traceback
def add_traceback(record):
extra = record["extra"]
if extra.get("with_traceback", False):
extra["traceback"] = "\n" + "".join(traceback.format_stack())
else:
extra["traceback"] = ""
logger = logger.patch(add_traceback)
logger.add(sys.stderr, format="{time} - {message}{extra[traceback]}")
logger.info("No traceback")
logger.bind(with_traceback=True).info("With traceback")
Here is another example that demonstrates how to prefix the logged message with the full call stack:
import traceback
from itertools import takewhile
def tracing_formatter(record):
# Filter out frames coming from Loguru internals
frames = takewhile(lambda f: "/loguru/" not in f.filename, traceback.extract_stack())
stack = " > ".join("{}:{}:{}".format(f.filename, f.name, f.lineno) for f in frames)
record["extra"]["stack"] = stack
return "{level} | {extra[stack]} - {message}\n{exception}"
def foo():
logger.info("Deep call")
def bar():
foo()
logger.remove()
logger.add(sys.stderr, format=tracing_formatter)
bar()
# Output: "INFO | script.py:<module>:23 > script.py:bar:18 > script.py:foo:15 - Deep call"
Manipulating newline terminator to write multiple logs on the same line
You can temporarily log a message on a continuous line by combining the use of bind()
, opt()
and a custom format
function. This is especially useful if you want to illustrate a step-by-step process in progress, for example:
def formatter(record):
end = record["extra"].get("end", "\n")
return "[{time}] {message}" + end + "{exception}"
logger.add(sys.stderr, format=formatter)
logger.add("foo.log", mode="w")
logger.bind(end="").debug("Progress: ")
for _ in range(5):
logger.opt(raw=True).debug(".")
logger.opt(raw=True).debug("\n")
logger.info("Done")
[2020-03-26T22:47:01.708016+0100] Progress: .....
[2020-03-26T22:47:01.709031+0100] Done
Note, however, that you may encounter difficulties depending on the sinks you use. Logging is not always appropriate for this type of end-user message.
Capturing standard stdout
, stderr
and warnings
The use of logging should be privileged over print()
, yet, it may happen that you don’t have plain control over code executed in your application. If you wish to capture standard output, you can suppress sys.stdout
(and sys.stderr
) with a custom stream object using contextlib.redirect_stdout()
. You have to take care of first removing the default handler, and not adding a new stdout sink once redirected or that would cause dead lock (you may use sys.__stdout__
instead):
import contextlib
import sys
from loguru import logger
class StreamToLogger:
def __init__(self, level="INFO"):
self._level = level
def write(self, buffer):
for line in buffer.rstrip().splitlines():
logger.opt(depth=1).log(self._level, line.rstrip())
def flush(self):
pass
logger.remove()
logger.add(sys.__stdout__)
stream = StreamToLogger()
with contextlib.redirect_stdout(stream):
print("Standard output is sent to added handlers.")
You may also capture warnings emitted by your application by replacing warnings.showwarning()
:
import warnings
from loguru import logger
showwarning_ = warnings.showwarning
def showwarning(message, *args, **kwargs):
logger.opt(depth=2).warning(message)
showwarning_(message, *args, **kwargs)
warnings.showwarning = showwarning
Alternatively, if you want to emit warnings based on logged messages, you can simply use warnings.warn()
as a sink:
logger.add(warnings.warn, format="{message}", filter=lambda record: record["level"].name == "WARNING")
Circumventing modules whose __name__
value is absent
Loguru makes use of the global variable __name__
to determine from where the logged message is coming from. However, it may happen in very specific situation (like some Dask distributed environment) that this value is not set. In such case, Loguru will use None
to make up for the lack of the value. This implies that if you want to disable()
messages coming from such special module, you have to explicitly call logger.disable(None)
.
Similar considerations should be taken into account while dealing with the filter
attribute. As __name__
is missing, Loguru will assign the None
value to the record["name"]
entry. It also means that once formatted in your log messages, the {name}
token will be equals to "None"
. This can be worked around by manually overriding the record["name"]
value using patch()
from inside the faulty module:
# If Loguru fails to retrieve the proper "name" value, assign it manually
logger = logger.patch(lambda record: record.update(name="my_module"))
You probably should not worry about all of this except if you noticed that your code is subject to this behavior.
Interoperability with tqdm
iterations
Trying to use the Loguru’s logger
during an iteration wrapped by the tqdm
library may disturb the displayed progress bar. As a workaround, one can use the tqdm.write()
function instead of writings logs directly to sys.stderr
:
import time
from loguru import logger
from tqdm import tqdm
logger.remove()
logger.add(lambda msg: tqdm.write(msg, end=""), colorize=True)
logger.info("Initializing")
for x in tqdm(range(100)):
logger.info("Iterating #{}", x)
time.sleep(0.1)
You may encounter problems with colorization of your logs after importing tqdm
using Spyder on Windows. This issue is discussed in GH#132. You can easily circumvent the problem by calling colorama.deinit()
right after your import.
Using Loguru’s logger
within a Cython module
Loguru and Cython do not interoperate very well. This is because Loguru (and logging generally) heavily relies on Python stack frames while Cython, being an alternative Python implementation, try to get rid of these frames for optimization reasons.
Calling the logger
from code compiled with Cython may raise this kind of exception:
ValueError: call stack is not deep enough
This error happens when Loguru tries to access a stack frame which has been suppressed by Cython. There is no way for Loguru to retrieve contextual information of the logged message, but there exists a workaround that will at least prevent your application to crash:
# Add this at the start of your file
logger = logger.opt(depth=-1)
Note that logged messages should be displayed correctly, but function name and other information will be incorrect. This issue is discussed in GH#88.
Creating independent loggers with separate set of handlers
Loguru is fundamentally designed to be usable with exactly one global logger
object dispatching logging messages to the configured handlers. In some circumstances, it may be useful to have specific messages logged to specific handlers.
For example, supposing you want to split your logs in two files based on an arbitrary identifier, you can achieve that by combining bind()
and filter
:
from loguru import logger
def task_A():
logger_a = logger.bind(task="A")
logger_a.info("Starting task A")
do_something()
logger_a.success("End of task A")
def task_B():
logger_b = logger.bind(task="B")
logger_b.info("Starting task B")
do_something_else()
logger_b.success("End of task B")
logger.add("file_A.log", filter=lambda record: record["extra"]["task"] == "A")
logger.add("file_B.log", filter=lambda record: record["extra"]["task"] == "B")
task_A()
task_B()
That way, "file_A.log"
and "file_B.log"
will only contains logs from respectively the task_A()
and task_B()
function.
Now, supposing that you have a lot of these tasks. It may be a bit cumbersome to configure every handlers like this. Most importantly, it may unnecessarily slow down your application as each log will need to be checked by the filter
function of each handler. In such case, it is recommended to rely on the copy.deepcopy()
built-in method that will create an independent logger
object. If you add a handler to a deep copied logger
, it will not be shared with others functions using the original logger
:
import copy
from loguru import logger
def task(task_id, logger):
logger.info("Starting task {}", task_id)
do_something(task_id)
logger.success("End of task {}", task_id)
logger.remove()
for task_id in ["A", "B", "C", "D", "E"]:
logger_ = copy.deepcopy(logger)
logger_.add("file_%s.log" % task_id)
task(task_id, logger_)
Note that you may encounter errors if you try to copy a logger
to which non-picklable handlers have been added. For this reason, it is generally advised to remove all handlers before calling copy.deepcopy(logger)
.
Compatibility with multiprocessing
using enqueue
argument
On Linux, thanks to os.fork()
there is no pitfall while using the logger
inside another process started by the multiprocessing
module. The child process will automatically inherit added handlers, the enqueue=True
parameter is optional but is recommended as it would avoid concurrent access of your sink:
# Linux implementation
import multiprocessing
from loguru import logger
def my_process():
logger.info("Executing function in child process")
logger.complete()
if __name__ == "__main__":
logger.add("file.log", enqueue=True)
process = multiprocessing.Process(target=my_process)
process.start()
process.join()
logger.info("Done")
Things get a little more complicated on Windows. Indeed, this operating system does not support forking, so Python has to use an alternative method to create sub-processes called “spawning”. This procedure requires the whole file where the child process is created to be reloaded from scratch. This does not interoperate very well with Loguru, causing handlers to be added twice without any synchronization or, on the contrary, not being added at all (depending on add()
and remove()
being called inside or outside the __main__
branch). For this reason, the logger
object need to be explicitly passed as an initializer argument of your child process:
# Windows implementation
import multiprocessing
from loguru import logger
def my_process(logger_):
logger_.info("Executing function in child process")
logger_.complete()
if __name__ == "__main__":
logger.remove() # Default "sys.stderr" sink is not picklable
logger.add("file.log", enqueue=True)
process = multiprocessing.Process(target=my_process, args=(logger, ))
process.start()
process.join()
logger.info("Done")
Windows requires the added sinks to be picklable or otherwise will raise an error while creating the child process. Many stream objects like standard output and file descriptors are not picklable. In such case, the enqueue=True
argument is required as it will allow the child process to only inherit the queue object where logs are sent.
The multiprocessing
library is also commonly used to start a pool of workers using for example map()
or apply()
. Again, it will work flawlessly on Linux, but it will require some tinkering on Windows. You will probably not be able to pass the logger
as an argument for your worker functions because it needs to be picklable, but although handlers added using enqueue=True
are “inheritable”, they are not “picklable”. Instead, you will need to make use of the initializer
and initargs
parameters while creating the Pool
object in a way allowing your workers to access the shared logger
. You can either assign it to a class attribute or override the global logger of your child processes:
# workers_a.py
class Worker:
_logger = None
@staticmethod
def set_logger(logger_):
Worker._logger = logger_
def work(self, x):
self._logger.info("Square rooting {}", x)
return x**0.5
# workers_b.py
from loguru import logger
def set_logger(logger_):
global logger
logger = logger_
def work(x):
logger.info("Square rooting {}", x)
return x**0.5
# main.py
from multiprocessing import Pool
from loguru import logger
import workers_a
import workers_b
if __name__ == "__main__":
logger.remove()
logger.add("file.log", enqueue=True)
worker = workers_a.Worker()
with Pool(4, initializer=worker.set_logger, initargs=(logger, )) as pool:
results = pool.map(worker.work, [1, 10, 100])
with Pool(4, initializer=workers_b.set_logger, initargs=(logger, )) as pool:
results = pool.map(workers_b.work, [1, 10, 100])
logger.info("Done")
Independently of the operating system, note that the process in which a handler is added with enqueue=True
is in charge of the queue internally used. This means that you should avoid to .remove()
such handler from the parent process is any child is likely to continue using it. More importantly, note that a Thread
is started internally to consume the queue. Therefore, it is recommended to call complete()
before leaving Process
to make sure the queue is left in a stable state.
Another thing to keep in mind when dealing with multiprocessing is the fact that handlers created with enqueue=True
create a queue internally in the default multiprocessing context. If they are passed through to a subprocesses instantiated within a different context (e.g. with multiprocessing.get_context("spawn")
on linux, where the default context is "fork"
) it will most likely result in crashing the subprocess. This is also noted in the python multiprocessing docs. To prevent any problems, you should specify the context to be used by Loguru while adding the handler. This can be done by passing the context
argument to the add()
method:
import multiprocessing
from loguru import logger
import workers_a
if __name__ == "__main__":
context = multiprocessing.get_context("spawn")
logger.remove()
logger.add("file.log", enqueue=True, context=context)
worker = workers_a.Worker()
with context.Pool(4, initializer=worker.set_logger, initargs=(logger, )) as pool:
results = pool.map(worker.work, [1, 10, 100])
Testing logging
Logging calls can be tested using logot
, a high-level log testing library with built-in support for Loguru:
from logot import Logot, logged
def test_something(logot: Logot) -> None:
do_something()
logot.assert_logged(logged.info("Something was done"))
Enable Loguru log capture in your pytest
configuration:
[tool.pytest.ini_options]
logot_capturer = "logot.loguru.LoguruCapturer"
See also
See using logot with Loguru for more information about configuring pytest and configuring unittest.
Note
When migrating an existing project from standard logging
, it can be useful to migrate your existing test
cases too. See migrating assertLogs() and migrating caplog
for more information.