Handle microseconds with custom logging.Formatter (#11047)
Added handling of %f directive to print microseconds in log format options, such as log-date-format. It is impossible to do with a standard logging.Formatter because it uses time.strftime which doesn't know about milliseconds and %f. In this PR I added a custom Formatter which converts LogRecord to a datetime.datetime object and formats it with %f flag. This behaviour is enabled only if a microsecond flag is specified in a format string. Also added a few tests to check the standard and changed behavior. Closes #10991
This commit is contained in:
parent
7c231baa64
commit
4da9026766
1
AUTHORS
1
AUTHORS
|
@ -131,6 +131,7 @@ Eric Siegerman
|
||||||
Erik Aronesty
|
Erik Aronesty
|
||||||
Erik M. Bray
|
Erik M. Bray
|
||||||
Evan Kepner
|
Evan Kepner
|
||||||
|
Evgeny Seliverstov
|
||||||
Fabien Zarifian
|
Fabien Zarifian
|
||||||
Fabio Zadrozny
|
Fabio Zadrozny
|
||||||
Felix Hofstätter
|
Felix Hofstätter
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Added handling of ``%f`` directive to print microseconds in log format options, such as ``log-date-format``.
|
|
@ -5,7 +5,11 @@ import os
|
||||||
import re
|
import re
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
from datetime import timezone
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
from logging import LogRecord
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import AbstractSet
|
from typing import AbstractSet
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
@ -53,7 +57,25 @@ def _remove_ansi_escape_sequences(text: str) -> str:
|
||||||
return _ANSI_ESCAPE_SEQ.sub("", text)
|
return _ANSI_ESCAPE_SEQ.sub("", text)
|
||||||
|
|
||||||
|
|
||||||
class ColoredLevelFormatter(logging.Formatter):
|
class DatetimeFormatter(logging.Formatter):
|
||||||
|
"""A logging formatter which formats record with
|
||||||
|
:func:`datetime.datetime.strftime` formatter instead of
|
||||||
|
:func:`time.strftime` in case of microseconds in format string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def formatTime(self, record: LogRecord, datefmt=None) -> str:
|
||||||
|
if datefmt and "%f" in datefmt:
|
||||||
|
ct = self.converter(record.created)
|
||||||
|
tz = timezone(timedelta(seconds=ct.tm_gmtoff), ct.tm_zone)
|
||||||
|
# Construct `datetime.datetime` object from `struct_time`
|
||||||
|
# and msecs information from `record`
|
||||||
|
dt = datetime(*ct[0:6], microsecond=round(record.msecs * 1000), tzinfo=tz)
|
||||||
|
return dt.strftime(datefmt)
|
||||||
|
# Use `logging.Formatter` for non-microsecond formats
|
||||||
|
return super().formatTime(record, datefmt)
|
||||||
|
|
||||||
|
|
||||||
|
class ColoredLevelFormatter(DatetimeFormatter):
|
||||||
"""A logging formatter which colorizes the %(levelname)..s part of the
|
"""A logging formatter which colorizes the %(levelname)..s part of the
|
||||||
log format passed to __init__."""
|
log format passed to __init__."""
|
||||||
|
|
||||||
|
@ -625,7 +647,7 @@ class LoggingPlugin:
|
||||||
config, "log_file_date_format", "log_date_format"
|
config, "log_file_date_format", "log_date_format"
|
||||||
)
|
)
|
||||||
|
|
||||||
log_file_formatter = logging.Formatter(
|
log_file_formatter = DatetimeFormatter(
|
||||||
log_file_format, datefmt=log_file_date_format
|
log_file_format, datefmt=log_file_date_format
|
||||||
)
|
)
|
||||||
self.log_file_handler.setFormatter(log_file_formatter)
|
self.log_file_handler.setFormatter(log_file_formatter)
|
||||||
|
@ -669,7 +691,7 @@ class LoggingPlugin:
|
||||||
create_terminal_writer(self._config), log_format, log_date_format
|
create_terminal_writer(self._config), log_format, log_date_format
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
formatter = logging.Formatter(log_format, log_date_format)
|
formatter = DatetimeFormatter(log_format, log_date_format)
|
||||||
|
|
||||||
formatter._style = PercentStyleMultiline(
|
formatter._style = PercentStyleMultiline(
|
||||||
formatter._style._fmt, auto_indent=auto_indent
|
formatter._style._fmt, auto_indent=auto_indent
|
||||||
|
|
|
@ -1234,3 +1234,100 @@ def test_log_disabling_works_with_log_cli(pytester: Pytester) -> None:
|
||||||
"WARNING disabled:test_log_disabling_works_with_log_cli.py:7 This string will be suppressed."
|
"WARNING disabled:test_log_disabling_works_with_log_cli.py:7 This string will be suppressed."
|
||||||
)
|
)
|
||||||
assert not result.stderr.lines
|
assert not result.stderr.lines
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_date_format_log(pytester: Pytester) -> None:
|
||||||
|
"""Check that date is not printed by default."""
|
||||||
|
pytester.makepyfile(
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def test_foo():
|
||||||
|
logger.warning('text')
|
||||||
|
assert False
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
result = pytester.runpytest()
|
||||||
|
assert result.ret == 1
|
||||||
|
result.stdout.fnmatch_lines(
|
||||||
|
["WARNING test_without_date_format_log:test_without_date_format_log.py:6 text"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_format_log(pytester: Pytester) -> None:
|
||||||
|
"""Check that log_date_format affects output."""
|
||||||
|
pytester.makepyfile(
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def test_foo():
|
||||||
|
logger.warning('text')
|
||||||
|
assert False
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
pytester.makeini(
|
||||||
|
"""
|
||||||
|
[pytest]
|
||||||
|
log_format=%(asctime)s; %(levelname)s; %(message)s
|
||||||
|
log_date_format=%Y-%m-%d %H:%M:%S
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
result = pytester.runpytest()
|
||||||
|
assert result.ret == 1
|
||||||
|
result.stdout.re_match_lines([r"^[0-9-]{10} [0-9:]{8}; WARNING; text"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_format_percentf_log(pytester: Pytester) -> None:
|
||||||
|
"""Make sure that microseconds are printed in log."""
|
||||||
|
pytester.makepyfile(
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def test_foo():
|
||||||
|
logger.warning('text')
|
||||||
|
assert False
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
pytester.makeini(
|
||||||
|
"""
|
||||||
|
[pytest]
|
||||||
|
log_format=%(asctime)s; %(levelname)s; %(message)s
|
||||||
|
log_date_format=%Y-%m-%d %H:%M:%S.%f
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
result = pytester.runpytest()
|
||||||
|
assert result.ret == 1
|
||||||
|
result.stdout.re_match_lines([r"^[0-9-]{10} [0-9:]{8}.[0-9]{6}; WARNING; text"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_format_percentf_tz_log(pytester: Pytester) -> None:
|
||||||
|
"""Make sure that timezone and microseconds are properly formatted together."""
|
||||||
|
pytester.makepyfile(
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def test_foo():
|
||||||
|
logger.warning('text')
|
||||||
|
assert False
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
pytester.makeini(
|
||||||
|
"""
|
||||||
|
[pytest]
|
||||||
|
log_format=%(asctime)s; %(levelname)s; %(message)s
|
||||||
|
log_date_format=%Y-%m-%d %H:%M:%S.%f%z
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
result = pytester.runpytest()
|
||||||
|
assert result.ret == 1
|
||||||
|
result.stdout.re_match_lines(
|
||||||
|
[r"^[0-9-]{10} [0-9:]{8}.[0-9]{6}[+-][0-9\.]+; WARNING; text"]
|
||||||
|
)
|
||||||
|
|
Loading…
Reference in New Issue