From 26169425bd896a172f99ca7ad594a7251ab9e068 Mon Sep 17 00:00:00 2001 From: Delgan Date: Fri, 17 Jan 2020 22:16:24 +0100 Subject: [PATCH] Improve handling of "ansi" option in messages (#197, #198) First, ansi markup in "*args" and "**kwargs" are ignored when used while "opt(ansi=True)". This caused errors, as such arguments can contains tags which are not intended to be used as color markups. Secondly, it strips the color markups present in the "record[message]" so it can be used somewhere else where the "ansi" context is lost (like when logs are emitted through socket). Actually, the colors are part of the handler so it makes sense to strip them from the record. These change required a whole refactoring of the coloration process. It has been implemented with optimization in mind: trying to parse the ansi message only once, then using the generated tokens to colorize the message appropriately in each handler. It could certainly be improved, though. --- CHANGELOG.rst | 8 +- loguru/_ansimarkup.py | 266 ------------------ loguru/_colored_string.py | 436 ++++++++++++++++++++++++++++++ loguru/_handler.py | 152 +++++------ loguru/_logger.py | 30 +- tests/conftest.py | 11 + tests/test_add_option_colorize.py | 6 +- tests/test_add_option_format.py | 12 + tests/test_ansimarkup_basic.py | 29 +- tests/test_ansimarkup_extended.py | 43 +-- tests/test_levels.py | 8 +- tests/test_opt.py | 182 ++++++++++--- tests/test_pickling.py | 21 +- tests/test_standard_handler.py | 2 +- 14 files changed, 751 insertions(+), 455 deletions(-) delete mode 100644 loguru/_ansimarkup.py create mode 100644 loguru/_colored_string.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 89fb2365..a030371a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,9 @@ -`Unreleased` -============ +`Unreleased`_ +============= -- Prevent unrelated files and directories to be incorrectly collected thus causing errors during the `retention` process (`#195 `_, thanks `@gazpachoking `_). +- Prevent unrelated files and directories to be incorrectly collected thus causing errors during the `retention` process (`#195 `_, thanks `@gazpachoking `_). +- Strip color markups contained in ``record["message"]`` when logging with ``.opt(ansi=True)`` instead of leaving them as is (`#198 `_). +- Ignore color markups contained in ``*args`` and ``**kwargs`` when logging with ``.opt(ansi=True)``, leave them as is instead of trying to use them to colorize the message which could cause undesirable errors (`#197 `_). `0.4.0`_ (2019-12-02) diff --git a/loguru/_ansimarkup.py b/loguru/_ansimarkup.py deleted file mode 100644 index b5f8d21e..00000000 --- a/loguru/_ansimarkup.py +++ /dev/null @@ -1,266 +0,0 @@ -import re - - -class Style: - RESET_ALL = 0 - BOLD = 1 - DIM = 2 - ITALIC = 3 - UNDERLINE = 4 - BLINK = 5 - REVERSE = 7 - STRIKE = 8 - HIDE = 9 - NORMAL = 22 - - -class Fore: - BLACK = 30 - RED = 31 - GREEN = 32 - YELLOW = 33 - BLUE = 34 - MAGENTA = 35 - CYAN = 36 - WHITE = 37 - RESET = 39 - - LIGHTBLACK_EX = 90 - LIGHTRED_EX = 91 - LIGHTGREEN_EX = 92 - LIGHTYELLOW_EX = 93 - LIGHTBLUE_EX = 94 - LIGHTMAGENTA_EX = 95 - LIGHTCYAN_EX = 96 - LIGHTWHITE_EX = 97 - - -class Back: - BLACK = 40 - RED = 41 - GREEN = 42 - YELLOW = 43 - BLUE = 44 - MAGENTA = 45 - CYAN = 46 - WHITE = 47 - RESET = 49 - - LIGHTBLACK_EX = 100 - LIGHTRED_EX = 101 - LIGHTGREEN_EX = 102 - LIGHTYELLOW_EX = 103 - LIGHTBLUE_EX = 104 - LIGHTMAGENTA_EX = 105 - LIGHTCYAN_EX = 106 - LIGHTWHITE_EX = 107 - - -def ansi_escape(codes): - return {name: "\033[%dm" % code for name, code in codes.items()} - - -class AnsiMarkup: - - _style = ansi_escape( - { - "b": Style.BOLD, - "d": Style.DIM, - "n": Style.NORMAL, - "h": Style.HIDE, - "i": Style.ITALIC, - "l": Style.BLINK, - "s": Style.STRIKE, - "u": Style.UNDERLINE, - "v": Style.REVERSE, - "bold": Style.BOLD, - "dim": Style.DIM, - "normal": Style.NORMAL, - "hide": Style.HIDE, - "italic": Style.ITALIC, - "blink": Style.BLINK, - "strike": Style.STRIKE, - "underline": Style.UNDERLINE, - "reverse": Style.REVERSE, - } - ) - - _foreground = ansi_escape( - { - "k": Fore.BLACK, - "r": Fore.RED, - "g": Fore.GREEN, - "y": Fore.YELLOW, - "e": Fore.BLUE, - "m": Fore.MAGENTA, - "c": Fore.CYAN, - "w": Fore.WHITE, - "lk": Fore.LIGHTBLACK_EX, - "lr": Fore.LIGHTRED_EX, - "lg": Fore.LIGHTGREEN_EX, - "ly": Fore.LIGHTYELLOW_EX, - "le": Fore.LIGHTBLUE_EX, - "lm": Fore.LIGHTMAGENTA_EX, - "lc": Fore.LIGHTCYAN_EX, - "lw": Fore.LIGHTWHITE_EX, - "black": Fore.BLACK, - "red": Fore.RED, - "green": Fore.GREEN, - "yellow": Fore.YELLOW, - "blue": Fore.BLUE, - "magenta": Fore.MAGENTA, - "cyan": Fore.CYAN, - "white": Fore.WHITE, - "light-black": Fore.LIGHTBLACK_EX, - "light-red": Fore.LIGHTRED_EX, - "light-green": Fore.LIGHTGREEN_EX, - "light-yellow": Fore.LIGHTYELLOW_EX, - "light-blue": Fore.LIGHTBLUE_EX, - "light-magenta": Fore.LIGHTMAGENTA_EX, - "light-cyan": Fore.LIGHTCYAN_EX, - "light-white": Fore.LIGHTWHITE_EX, - } - ) - - _background = ansi_escape( - { - "K": Back.BLACK, - "R": Back.RED, - "G": Back.GREEN, - "Y": Back.YELLOW, - "E": Back.BLUE, - "M": Back.MAGENTA, - "C": Back.CYAN, - "W": Back.WHITE, - "LK": Back.LIGHTBLACK_EX, - "LR": Back.LIGHTRED_EX, - "LG": Back.LIGHTGREEN_EX, - "LY": Back.LIGHTYELLOW_EX, - "LE": Back.LIGHTBLUE_EX, - "LM": Back.LIGHTMAGENTA_EX, - "LC": Back.LIGHTCYAN_EX, - "LW": Back.LIGHTWHITE_EX, - "BLACK": Back.BLACK, - "RED": Back.RED, - "GREEN": Back.GREEN, - "YELLOW": Back.YELLOW, - "BLUE": Back.BLUE, - "MAGENTA": Back.MAGENTA, - "CYAN": Back.CYAN, - "WHITE": Back.WHITE, - "LIGHT-BLACK": Back.LIGHTBLACK_EX, - "LIGHT-RED": Back.LIGHTRED_EX, - "LIGHT-GREEN": Back.LIGHTGREEN_EX, - "LIGHT-YELLOW": Back.LIGHTYELLOW_EX, - "LIGHT-BLUE": Back.LIGHTBLUE_EX, - "LIGHT-MAGENTA": Back.LIGHTMAGENTA_EX, - "LIGHT-CYAN": Back.LIGHTCYAN_EX, - "LIGHT-WHITE": Back.LIGHTWHITE_EX, - } - ) - - _regex_tag = re.compile(r"\\?\s]*)>") - - def __init__(self, custom_markups=None, strip=False): - self._custom = custom_markups or {} - self._strip = strip - self._tags = [] - self._results = [] - - @staticmethod - def parse(color): - return AnsiMarkup(strip=False).feed(color.strip(), strict=False) - - @staticmethod - def verify(format_, custom_markups_list): - custom_markups = {markup: "" for markup in custom_markups_list} - AnsiMarkup(custom_markups=custom_markups, strip=True).feed(format_, strict=True) - - def feed(self, text, *, strict=True): - if strict: - pre_tags = self._tags - self._tags = [] - text = self._regex_tag.sub(self._sub_tag, text) - if strict: - if self._tags: - faulty_tag = self._tags.pop(0) - raise ValueError('Opening tag "<%s>" has no corresponding closing tag' % faulty_tag) - self._tags = pre_tags - return text - - def get_ansicode(self, tag): - custom = self._custom - style = self._style - foreground = self._foreground - background = self._background - - # User-defined tags take preference over all other. - if tag in custom: - return custom[tag] - - # Substitute on a direct match. - elif tag in style: - return style[tag] - elif tag in foreground: - return foreground[tag] - elif tag in background: - return background[tag] - - # An alternative syntax for setting the color (e.g. , ). - elif tag.startswith("fg ") or tag.startswith("bg "): - st, color = tag[:2], tag[3:] - code = "38" if st == "fg" else "48" - - if st == "fg" and color.lower() in foreground: - return foreground[color.lower()] - elif st == "bg" and color.upper() in background: - return background[color.upper()] - elif color.isdigit() and int(color) <= 255: - return "\033[%s;5;%sm" % (code, color) - elif re.match(r"#(?:[a-fA-F0-9]{3}){1,2}$", color): - hex_color = color[1:] - if len(hex_color) == 3: - hex_color *= 2 - rgb = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) - return "\033[%s;2;%s;%s;%sm" % ((code,) + rgb) - elif color.count(",") == 2: - colors = tuple(color.split(",")) - if all(x.isdigit() and int(x) <= 255 for x in colors): - return "\033[%s;2;%s;%s;%sm" % ((code,) + colors) - - return None - - def _sub_tag(self, match): - markup, tag = match.group(0), match.group(1) - - if markup[0] == "\\": - return markup[1:] - - if markup[1] == "/": - if self._tags and (tag == "" or tag == self._tags[-1]): - self._tags.pop() - self._results.pop() - if self._strip: - return "" - else: - return "\033[0m" + "".join(self._results) - elif tag in self._tags: - raise ValueError('Closing tag "%s" violates nesting rules' % markup) - else: - raise ValueError('Closing tag "%s" has no corresponding opening tag' % markup) - - res = self.get_ansicode(tag) - - if res is None: - raise ValueError( - 'Tag "%s" does not corespond to any known ansi directive, ' - "make sure you did not misspelled it (or prepend '\\' to escape it)" % markup - ) - - self._tags.append(tag) - self._results.append(res) - - if self._strip: - return "" - else: - return res diff --git a/loguru/_colored_string.py b/loguru/_colored_string.py new file mode 100644 index 00000000..a7c234a6 --- /dev/null +++ b/loguru/_colored_string.py @@ -0,0 +1,436 @@ +import re +from string import Formatter + + +class Style: + RESET_ALL = 0 + BOLD = 1 + DIM = 2 + ITALIC = 3 + UNDERLINE = 4 + BLINK = 5 + REVERSE = 7 + STRIKE = 8 + HIDE = 9 + NORMAL = 22 + + +class Fore: + BLACK = 30 + RED = 31 + GREEN = 32 + YELLOW = 33 + BLUE = 34 + MAGENTA = 35 + CYAN = 36 + WHITE = 37 + RESET = 39 + + LIGHTBLACK_EX = 90 + LIGHTRED_EX = 91 + LIGHTGREEN_EX = 92 + LIGHTYELLOW_EX = 93 + LIGHTBLUE_EX = 94 + LIGHTMAGENTA_EX = 95 + LIGHTCYAN_EX = 96 + LIGHTWHITE_EX = 97 + + +class Back: + BLACK = 40 + RED = 41 + GREEN = 42 + YELLOW = 43 + BLUE = 44 + MAGENTA = 45 + CYAN = 46 + WHITE = 47 + RESET = 49 + + LIGHTBLACK_EX = 100 + LIGHTRED_EX = 101 + LIGHTGREEN_EX = 102 + LIGHTYELLOW_EX = 103 + LIGHTBLUE_EX = 104 + LIGHTMAGENTA_EX = 105 + LIGHTCYAN_EX = 106 + LIGHTWHITE_EX = 107 + + +def ansi_escape(codes): + return {name: "\033[%dm" % code for name, code in codes.items()} + + +class TokenType: + TEXT = 1 + ANSI = 2 + LEVEL = 3 + CLOSING = 4 + + +class AnsiParser: + + _style = ansi_escape( + { + "b": Style.BOLD, + "d": Style.DIM, + "n": Style.NORMAL, + "h": Style.HIDE, + "i": Style.ITALIC, + "l": Style.BLINK, + "s": Style.STRIKE, + "u": Style.UNDERLINE, + "v": Style.REVERSE, + "bold": Style.BOLD, + "dim": Style.DIM, + "normal": Style.NORMAL, + "hide": Style.HIDE, + "italic": Style.ITALIC, + "blink": Style.BLINK, + "strike": Style.STRIKE, + "underline": Style.UNDERLINE, + "reverse": Style.REVERSE, + } + ) + + _foreground = ansi_escape( + { + "k": Fore.BLACK, + "r": Fore.RED, + "g": Fore.GREEN, + "y": Fore.YELLOW, + "e": Fore.BLUE, + "m": Fore.MAGENTA, + "c": Fore.CYAN, + "w": Fore.WHITE, + "lk": Fore.LIGHTBLACK_EX, + "lr": Fore.LIGHTRED_EX, + "lg": Fore.LIGHTGREEN_EX, + "ly": Fore.LIGHTYELLOW_EX, + "le": Fore.LIGHTBLUE_EX, + "lm": Fore.LIGHTMAGENTA_EX, + "lc": Fore.LIGHTCYAN_EX, + "lw": Fore.LIGHTWHITE_EX, + "black": Fore.BLACK, + "red": Fore.RED, + "green": Fore.GREEN, + "yellow": Fore.YELLOW, + "blue": Fore.BLUE, + "magenta": Fore.MAGENTA, + "cyan": Fore.CYAN, + "white": Fore.WHITE, + "light-black": Fore.LIGHTBLACK_EX, + "light-red": Fore.LIGHTRED_EX, + "light-green": Fore.LIGHTGREEN_EX, + "light-yellow": Fore.LIGHTYELLOW_EX, + "light-blue": Fore.LIGHTBLUE_EX, + "light-magenta": Fore.LIGHTMAGENTA_EX, + "light-cyan": Fore.LIGHTCYAN_EX, + "light-white": Fore.LIGHTWHITE_EX, + } + ) + + _background = ansi_escape( + { + "K": Back.BLACK, + "R": Back.RED, + "G": Back.GREEN, + "Y": Back.YELLOW, + "E": Back.BLUE, + "M": Back.MAGENTA, + "C": Back.CYAN, + "W": Back.WHITE, + "LK": Back.LIGHTBLACK_EX, + "LR": Back.LIGHTRED_EX, + "LG": Back.LIGHTGREEN_EX, + "LY": Back.LIGHTYELLOW_EX, + "LE": Back.LIGHTBLUE_EX, + "LM": Back.LIGHTMAGENTA_EX, + "LC": Back.LIGHTCYAN_EX, + "LW": Back.LIGHTWHITE_EX, + "BLACK": Back.BLACK, + "RED": Back.RED, + "GREEN": Back.GREEN, + "YELLOW": Back.YELLOW, + "BLUE": Back.BLUE, + "MAGENTA": Back.MAGENTA, + "CYAN": Back.CYAN, + "WHITE": Back.WHITE, + "LIGHT-BLACK": Back.LIGHTBLACK_EX, + "LIGHT-RED": Back.LIGHTRED_EX, + "LIGHT-GREEN": Back.LIGHTGREEN_EX, + "LIGHT-YELLOW": Back.LIGHTYELLOW_EX, + "LIGHT-BLUE": Back.LIGHTBLUE_EX, + "LIGHT-MAGENTA": Back.LIGHTMAGENTA_EX, + "LIGHT-CYAN": Back.LIGHTCYAN_EX, + "LIGHT-WHITE": Back.LIGHTWHITE_EX, + } + ) + + _regex_tag = re.compile(r"\\?\s]*)>") + + def __init__(self): + self._tokens = [] + self._tags = [] + self._tokens_tags = [] + + @staticmethod + def colorize(tokens, ansi_level): + output = "" + + for type_, value in tokens: + if type_ == TokenType.LEVEL: + if ansi_level is None: + raise ValueError( + "The '' color tag is not allowed in this context, " + "it has not yet been associated to any color value." + ) + value = ansi_level + output += value + return output + + @staticmethod + def strip(tokens): + output = "" + for type_, value in tokens: + if type_ == TokenType.TEXT: + output += value + return output + + def feed(self, text, *, raw=False): + if raw: + self._tokens.append((TokenType.TEXT, text)) + return + + position = 0 + + for match in self._regex_tag.finditer(text): + markup, tag = match.group(0), match.group(1) + + self._tokens.append((TokenType.TEXT, text[position : match.start()])) + + position = match.end() + + if markup[0] == "\\": + self._tokens.append((TokenType.TEXT, markup[1:])) + continue + + if markup[1] == "/": + if self._tags and (tag == "" or tag == self._tags[-1]): + self._tags.pop() + self._tokens_tags.pop() + self._tokens.append((TokenType.CLOSING, "\033[0m")) + self._tokens.extend(self._tokens_tags) + continue + elif tag in self._tags: + raise ValueError('Closing tag "%s" violates nesting rules' % markup) + else: + raise ValueError('Closing tag "%s" has no corresponding opening tag' % markup) + + if tag in {"lvl", "level"}: + token = (TokenType.LEVEL, None) + else: + ansi = self._get_ansicode(tag) + + if ansi is None: + raise ValueError( + 'Tag "%s" does not corespond to any known ansi directive, ' + "make sure you did not misspelled it (or prepend '\\' to escape it)" + % markup + ) + + token = (TokenType.ANSI, ansi) + + self._tags.append(tag) + self._tokens_tags.append(token) + self._tokens.append(token) + + self._tokens.append((TokenType.TEXT, text[position:])) + + def wrap(self, tokens, ansi_level): + wrapped = [] + for token in tokens: + type_, _ = token + wrapped.append(token) + if type_ == TokenType.CLOSING: + wrapped.extend(self._tokens_tags) + + return AnsiParser.colorize(wrapped, ansi_level) + + def done(self, *, strict=True): + if strict and self._tags: + faulty_tag = self._tags.pop(0) + raise ValueError('Opening tag "<%s>" has no corresponding closing tag' % faulty_tag) + return self._tokens + + def _get_ansicode(self, tag): + style = self._style + foreground = self._foreground + background = self._background + + # Substitute on a direct match. + if tag in style: + return style[tag] + elif tag in foreground: + return foreground[tag] + elif tag in background: + return background[tag] + + # An alternative syntax for setting the color (e.g. , ). + elif tag.startswith("fg ") or tag.startswith("bg "): + st, color = tag[:2], tag[3:] + code = "38" if st == "fg" else "48" + + if st == "fg" and color.lower() in foreground: + return foreground[color.lower()] + elif st == "bg" and color.upper() in background: + return background[color.upper()] + elif color.isdigit() and int(color) <= 255: + return "\033[%s;5;%sm" % (code, color) + elif re.match(r"#(?:[a-fA-F0-9]{3}){1,2}$", color): + hex_color = color[1:] + if len(hex_color) == 3: + hex_color *= 2 + rgb = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + return "\033[%s;2;%s;%s;%sm" % ((code,) + rgb) + elif color.count(",") == 2: + colors = tuple(color.split(",")) + if all(x.isdigit() and int(x) <= 255 for x in colors): + return "\033[%s;2;%s;%s;%sm" % ((code,) + colors) + + return None + + +class ColoredString: + def __init__(self, string, tokens): + self.string = string + self.tokens = tokens + + def colorize(self, ansi_level): + return AnsiParser.colorize(self.tokens, ansi_level) + + def strip(self): + return AnsiParser.strip(self.tokens) + + @classmethod + def prepare_format(cls, string): + tokens = ColoredString._parse_without_formatting(string) + return cls(string, tokens) + + @classmethod + def prepare_message(cls, string, args=(), kwargs={}): + tokens = ColoredString._parse_with_formatting(string, args, kwargs) + return cls(string, tokens) + + @classmethod + def prepare_simple_message(cls, string): + parser = AnsiParser() + parser.feed(string) + tokens = parser.done() + return cls(string, tokens) + + @staticmethod + def ansify(text): + parser = AnsiParser() + parser.feed(text.strip()) + tokens = parser.done(strict=False) + return AnsiParser.colorize(tokens, None) + + @staticmethod + def format_with_colored_message(string, kwargs, *, ansi_level, colored_message): + tokens = ColoredString._parse_with_formatting( + string, (), kwargs, ansi_level=ansi_level, colored_message=colored_message + ) + return AnsiParser.colorize(tokens, ansi_level) + + @staticmethod + def _parse_with_formatting( + string, + args, + kwargs, + *, + recursion_depth=2, + auto_arg_index=0, + recursive=False, + ansi_level=None, + colored_message=None + ): + # This function re-implement Formatter._vformat() + + if recursion_depth < 0: + raise ValueError("Max string recursion exceeded") + + formatter = Formatter() + parser = AnsiParser() + + for literal_text, field_name, format_spec, conversion in formatter.parse(string): + parser.feed(literal_text, raw=recursive) + + if field_name is not None: + if field_name == "": + if auto_arg_index is False: + raise ValueError( + "cannot switch from manual field " + "specification to automatic field " + "numbering" + ) + field_name = str(auto_arg_index) + auto_arg_index += 1 + elif field_name.isdigit(): + if auto_arg_index: + raise ValueError( + "cannot switch from manual field " + "specification to automatic field " + "numbering" + ) + auto_arg_index = False + + obj, _ = formatter.get_field(field_name, args, kwargs) + + # Will not match "message.attr" / "message[0]": in this case, the record["message"] + # which contains the striped message will be used and no coloration is needed + if field_name == "message" and not recursive and colored_message: + obj = parser.wrap(colored_message.tokens, ansi_level) + + obj = formatter.convert_field(obj, conversion) + + format_spec, auto_arg_index = ColoredString._parse_with_formatting( + format_spec, + args, + kwargs, + recursion_depth=recursion_depth - 1, + auto_arg_index=auto_arg_index, + recursive=True, + ) + + formatted = formatter.format_field(obj, format_spec) + parser.feed(formatted, raw=True) + + tokens = parser.done() + + if recursive: + return AnsiParser.strip(tokens), auto_arg_index + + return tokens + + @staticmethod + def _parse_without_formatting(string): + formatter = Formatter() + parser = AnsiParser() + + for literal_text, field_name, format_spec, conversion in formatter.parse(string): + if literal_text and literal_text[-1] in "{}": + literal_text += literal_text[-1] + + parser.feed(literal_text) + + if field_name is not None: + field = "{%s" % field_name + if conversion: + field += "!%s" % conversion + if format_spec: + field += ":%s" % format_spec + field += "}" + parser.feed(field, raw=True) + + return parser.done() diff --git a/loguru/_handler.py b/loguru/_handler.py index 4c0f0711..8c6f5d19 100644 --- a/loguru/_handler.py +++ b/loguru/_handler.py @@ -6,7 +6,21 @@ import threading import traceback -from ._ansimarkup import AnsiMarkup +from ._colored_string import ColoredString + + +def prepare_colored_format(format_, ansi_level): + colored = ColoredString.prepare_format(format_) + return colored.colorize(ansi_level) + + +def prepare_stripped_format(format_): + colored = ColoredString.prepare_format(format_) + return colored.strip() + + +def memoize(function): + return functools.lru_cache(maxsize=64)(function) class Message(str): @@ -45,9 +59,9 @@ def __init__( self._id = id_ self._levels_ansi_codes = levels_ansi_codes # Warning, reference shared among handlers - self._static_format = None self._decolorized_format = None self._precolorized_formats = {} + self._memoize_dynamic_format = None self._lock = threading.Lock() self._queue = None @@ -56,12 +70,17 @@ def __init__( self._stopped = False self._owner_process = None - if not self._is_formatter_dynamic: - self._static_format = self._formatter - self._decolorized_format = self._decolorize_format(self._static_format) - - for level_name in self._levels_ansi_codes: - self.update_format(level_name) + if self._is_formatter_dynamic: + if self._colorize: + self._memoize_dynamic_format = memoize(prepare_colored_format) + else: + self._memoize_dynamic_format = memoize(prepare_stripped_format) + else: + if self._colorize: + for level_name in self._levels_ansi_codes: + self.update_format(level_name) + else: + self._decolorized_format = self._formatter.strip() if self._enqueue: self._owner_process = multiprocessing.current_process() @@ -75,7 +94,7 @@ def __init__( def __repr__(self): return "(id=%d, level=%d, sink=%s)" % (self._id, self._levelno, self._name) - def emit(self, record, level_id, from_decorator, is_ansi, is_raw): + def emit(self, record, level_id, from_decorator, is_raw, colored_message): try: if self._levelno > record["level"].no: return @@ -86,16 +105,6 @@ def emit(self, record, level_id, from_decorator, is_ansi, is_raw): if self._is_formatter_dynamic: dynamic_format = self._formatter(record) - if self._colorize: - level_ansi = self._levels_ansi_codes[level_id] - precomputed_format = self._colorize_format(dynamic_format, level_ansi) - else: - precomputed_format = self._decolorize_format(dynamic_format) - else: - if self._colorize: - precomputed_format = self._precolorized_formats[level_id] - else: - precomputed_format = self._decolorized_format formatter_record = record.copy() @@ -103,35 +112,48 @@ def emit(self, record, level_id, from_decorator, is_ansi, is_raw): formatter_record["exception"] = "" else: type_, value, tb = record["exception"] - lines = self._exception_formatter.format_exception( - type_, value, tb, from_decorator=from_decorator - ) + formatter = self._exception_formatter + lines = formatter.format_exception(type_, value, tb, from_decorator=from_decorator) formatter_record["exception"] = "".join(lines) - message = record["message"] - if is_raw: - if not is_ansi: - formatted = message - elif self._colorize: - level_ansi = self._levels_ansi_codes[level_id] - formatted = self._colorize_format(message, level_ansi) + if colored_message is None or not self._colorize: + formatted = record["message"] else: - formatted = self._decolorize_format(message) - else: - if not is_ansi: + ansi_level = self._levels_ansi_codes[level_id] + formatted = colored_message.colorize(ansi_level) + elif self._is_formatter_dynamic: + if not self._colorize: + precomputed_format = self._memoize_dynamic_format(dynamic_format) + formatted = precomputed_format.format_map(formatter_record) + elif colored_message is None: + ansi_level = self._levels_ansi_codes[level_id] + precomputed_format = self._memoize_dynamic_format(dynamic_format, ansi_level) formatted = precomputed_format.format_map(formatter_record) - elif self._colorize: - if self._is_formatter_dynamic: - format_with_tags = dynamic_format - else: - format_with_tags = self._static_format - ansi_code = self._levels_ansi_codes[level_id] - AnsiDict = self._memoize_ansi_messages(format_with_tags, ansi_code, message) - formatted = precomputed_format.format_map(AnsiDict(formatter_record)) else: - formatter_record["message"] = self._decolorize_format(message) + ansi_level = self._levels_ansi_codes[level_id] + formatted = ColoredString.format_with_colored_message( + dynamic_format, + formatter_record, + ansi_level=ansi_level, + colored_message=colored_message, + ) + else: + if not self._colorize: + precomputed_format = self._decolorized_format formatted = precomputed_format.format_map(formatter_record) + elif colored_message is None: + ansi_level = self._levels_ansi_codes[level_id] + precomputed_format = self._precolorized_formats[level_id] + formatted = precomputed_format.format_map(formatter_record) + else: + ansi_level = self._levels_ansi_codes[level_id] + formatted = ColoredString.format_with_colored_message( + self._formatter.string, + formatter_record, + ansi_level=ansi_level, + colored_message=colored_message, + ) if self._serialize: formatted = self._serialize_record(formatted, record) @@ -186,7 +208,7 @@ def update_format(self, level_id): if not self._colorize or self._is_formatter_dynamic: return ansi_code = self._levels_ansi_codes[level_id] - self._precolorized_formats[level_id] = self._colorize_format(self._static_format, ansi_code) + self._precolorized_formats[level_id] = self._formatter.colorize(ansi_code) @property def levelno(self): @@ -227,46 +249,6 @@ def _serialize_record(text, record): return json.dumps(serializable, default=str) + "\n" - @staticmethod - @functools.lru_cache(maxsize=32) - def _decolorize_format(format_): - markups = {"level": "", "lvl": ""} - return AnsiMarkup(custom_markups=markups, strip=True).feed(format_, strict=True) - - @staticmethod - @functools.lru_cache(maxsize=32) - def _colorize_format(format_, ansi_code): - markups = {"level": ansi_code, "lvl": ansi_code} - return AnsiMarkup(custom_markups=markups, strip=False).feed(format_, strict=True) - - @staticmethod - @functools.lru_cache(maxsize=32) - def _memoize_ansi_messages(format_, ansi_code, message): - markups = {"level": ansi_code, "lvl": ansi_code} - ansimarkup = AnsiMarkup(custom_markups=markups, strip=False) - - def parse(string_, *, recursive=True): - for text, name, spec, _ in string.Formatter().parse(string_): - ansimarkup.feed(text, strict=False) - if spec and recursive: - yield from parse(spec, recursive=False) - if name and name[:8] in ("message", "message.", "message["): - yield ansimarkup.feed(message, strict=True) - - messages = list(parse(format_)) - - class AnsiDict: - def __init__(self, record): - self._record = record - self._messages = iter(messages) - - def __getitem__(self, key): - if key == "message": - return next(self._messages) - return self._record[key] - - return AnsiDict - def _queued_writer(self): message = None queue = self._queue @@ -317,6 +299,7 @@ def _handle_error(self, record=None): def __getstate__(self): state = self.__dict__.copy() state["_lock"] = None + state["_memoize_dynamic_format"] = None if self._enqueue: state["_sink"] = None state["_thread"] = None @@ -326,3 +309,8 @@ def __getstate__(self): def __setstate__(self, state): self.__dict__.update(state) self._lock = threading.Lock() + if self._is_formatter_dynamic: + if self._colorize: + self._memoize_dynamic_format = memoize(prepare_colored_format) + else: + self._memoize_dynamic_format = memoize(prepare_stripped_format) diff --git a/loguru/_logger.py b/loguru/_logger.py index 48ee0df1..7c3d0469 100644 --- a/loguru/_logger.py +++ b/loguru/_logger.py @@ -55,7 +55,7 @@ .. |file-like object| replace:: ``file-like object`` .. _file-like object: https://docs.python.org/3/glossary.html#term-file-object -.. |callable| replace:: ``callable object`` +.. |callable| replace:: ``callable`` .. _callable: https://docs.python.org/3/library/functions.html#callable .. |coroutine function| replace:: ``coroutine function`` .. _coroutine function: https://docs.python.org/3/glossary.html#term-coroutine-function @@ -90,7 +90,7 @@ from . import _colorama from . import _defaults from . import _filters -from ._ansimarkup import AnsiMarkup +from ._colored_string import ColoredString from ._better_exceptions import ExceptionFormatter from ._datetime import aware_now from ._file_sink import FileSink @@ -167,7 +167,7 @@ def __init__(self): ] self.levels = {level.name: level for level in levels} self.levels_ansi_codes = { - name: AnsiMarkup.parse(level.color) for name, level in self.levels.items() + name: ColoredString.ansify(level.color) for name, level in self.levels.items() } self.levels_ansi_codes[None] = "" @@ -885,12 +885,11 @@ def add( if isinstance(format, str): try: - AnsiMarkup.verify(format, ["level", "lvl"]) + formatter = ColoredString.prepare_format(format + terminator + "{exception}") except ValueError as e: raise ValueError( "Invalid format, color markups could not be parsed correctly" ) from e - formatter = format + terminator + "{exception}" is_formatter_dynamic = False elif callable(format): if format == builtins.format: @@ -1445,7 +1444,7 @@ def level(self, name, no=None, color=None, icon=None): if no < 0: raise ValueError("Invalid level no, it should be a positive integer, not: %d" % no) - ansi = AnsiMarkup.parse(color) + ansi = ColoredString.ansify(color) level = Level(name, no, color, icon) with self._core.lock: @@ -1830,9 +1829,24 @@ def _log(self, level_id, static_level_no, from_decorator, options, message, args kwargs = {key: value() for key, value in kwargs.items()} if record: - log_record["message"] = message.format(*args, **kwargs, record=log_record) + if "record" in kwargs: + raise TypeError( + "The message can't be formatted: 'record' shall not be used as a keyword " + "argument while logger has been configured with '.opt(record=True)'" + ) + kwargs.update(record=log_record) + + if ansi: + if args or kwargs: + colored_message = ColoredString.prepare_message(message, args, kwargs) + else: + colored_message = ColoredString.prepare_simple_message(message) + log_record["message"] = colored_message.strip() elif args or kwargs: + colored_message = None log_record["message"] = message.format(*args, **kwargs) + else: + colored_message = None if core.patcher: core.patcher(log_record) @@ -1841,7 +1855,7 @@ def _log(self, level_id, static_level_no, from_decorator, options, message, args patcher(log_record) for handler in core.handlers.values(): - handler.emit(log_record, level_id, from_decorator, ansi, raw) + handler.emit(log_record, level_id, from_decorator, raw, colored_message) def trace(__self, __message, *args, **kwargs): r"""Log ``message.format(*args, **kwargs)`` with severity ``'TRACE'``.""" diff --git a/tests/conftest.py b/tests/conftest.py index 90578b85..6d15ab19 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,6 +32,17 @@ def run(coro): asyncio.run = run +def parse(text, *, strip=False, strict=True): + parser = loguru._colored_string.AnsiParser() + parser.feed(text) + tokens = parser.done(strict=strict) + + if strip: + return parser.strip(tokens) + else: + return parser.colorize(tokens, "") + + @pytest.fixture(scope="session", autouse=True) def check_env_variables(): for var in os.environ: diff --git a/tests/test_add_option_colorize.py b/tests/test_add_option_colorize.py index ae246617..c01ae1da 100644 --- a/tests/test_add_option_colorize.py +++ b/tests/test_add_option_colorize.py @@ -4,11 +4,7 @@ import loguru from unittest.mock import MagicMock from loguru import logger -from loguru._ansimarkup import AnsiMarkup - - -def parse(text): - return AnsiMarkup(strip=False).feed(text, strict=True) +from .conftest import parse class Stream: diff --git a/tests/test_add_option_format.py b/tests/test_add_option_format.py index ce26337e..3df6df62 100644 --- a/tests/test_add_option_format.py +++ b/tests/test_add_option_format.py @@ -67,6 +67,18 @@ def test_invalid_markups(writer, format): logger.add(writer, format=format) +@pytest.mark.parametrize("colorize", [True, False]) +def test_markup_in_field(writer, colorize): + class F: + def __format__(self, spec): + return spec + + logger.add(writer, format="{extra[f]:} {extra[f]: } {message}", colorize=colorize) + logger.bind(f=F()).info("Test") + + assert writer.read() == " Test\n" + + def test_invalid_format_builtin(writer): with pytest.raises(ValueError, match=r".* most likely a mistake"): logger.add(writer, format=format) diff --git a/tests/test_ansimarkup_basic.py b/tests/test_ansimarkup_basic.py index bdea5096..bb91fda0 100644 --- a/tests/test_ansimarkup_basic.py +++ b/tests/test_ansimarkup_basic.py @@ -1,7 +1,6 @@ import pytest from colorama import Style as S, Fore as F, Back as B - -from loguru._ansimarkup import AnsiMarkup +from .conftest import parse @pytest.mark.parametrize( @@ -16,7 +15,7 @@ ], ) def test_styles(text, expected): - assert AnsiMarkup(strip=False).feed(text, strict=True) == expected + assert parse(text, strip=False) == expected @pytest.mark.parametrize( @@ -29,7 +28,7 @@ def test_styles(text, expected): ], ) def test_background_colors(text, expected): - assert AnsiMarkup(strip=False).feed(text, strict=True) == expected + assert parse(text, strip=False) == expected @pytest.mark.parametrize( @@ -42,7 +41,7 @@ def test_background_colors(text, expected): ], ) def test_foreground_colors(text, expected): - assert AnsiMarkup(strip=False).feed(text, strict=True) == expected + assert parse(text, strip=False) == expected @pytest.mark.parametrize( @@ -74,13 +73,13 @@ def test_foreground_colors(text, expected): ], ) def test_nested(text, expected): - assert AnsiMarkup(strip=False).feed(text, strict=True) == expected + assert parse(text, strip=False) == expected @pytest.mark.parametrize("text", ["", "", ""]) def test_strict_parsing(text): with pytest.raises(ValueError): - AnsiMarkup(strip=False).feed(text, strict=True) + parse(text, strip=False) @pytest.mark.parametrize( @@ -92,7 +91,7 @@ def test_strict_parsing(text): ], ) def test_permissive_parsing(text, expected): - assert AnsiMarkup(strip=False).feed(text, strict=False) == expected + assert parse(text, strip=False, strict=False) == expected @pytest.mark.parametrize( @@ -119,7 +118,7 @@ def test_permissive_parsing(text, expected): ], ) def test_autoclose(text, expected): - AnsiMarkup(strip=False).feed(text, strict=True) == expected + assert parse(text, strip=False) == expected @pytest.mark.parametrize( @@ -132,7 +131,7 @@ def test_autoclose(text, expected): ], ) def test_escaping(text, expected): - AnsiMarkup(strip=False).feed(text, strict=True) == expected + assert parse(text, strip=False) == expected @pytest.mark.parametrize( @@ -150,7 +149,7 @@ def test_escaping(text, expected): @pytest.mark.parametrize("strip", [True, False]) def test_mismatched_error(text, strip): with pytest.raises(ValueError): - AnsiMarkup(strip=strip).feed(text, strict=True) + parse(text, strip=strip) @pytest.mark.parametrize( @@ -159,14 +158,14 @@ def test_mismatched_error(text, strip): @pytest.mark.parametrize("strip", [True, False]) def test_unbalanced_error(text, strip): with pytest.raises(ValueError): - AnsiMarkup(strip=strip).feed(text, strict=True) + parse(text, strip=strip) @pytest.mark.parametrize("text", ["", "", "", "1"]) @pytest.mark.parametrize("strip", [True, False]) def test_unclosed_error(text, strip): with pytest.raises(ValueError): - AnsiMarkup(strip=strip).feed(text, strict=True) + parse(text, strip=strip) @pytest.mark.parametrize( @@ -189,7 +188,7 @@ def test_unclosed_error(text, strip): @pytest.mark.parametrize("strip", [True, False]) def test_invalid_color(text, strip): with pytest.raises(ValueError): - AnsiMarkup(strip=strip).feed(text, strict=True) + parse(text, strip=strip) @pytest.mark.parametrize( @@ -203,4 +202,4 @@ def test_invalid_color(text, strip): ], ) def test_strip(text, expected): - assert AnsiMarkup(strip=True).feed(text, strict=True) == expected + assert parse(text, strip=True) == expected diff --git a/tests/test_ansimarkup_extended.py b/tests/test_ansimarkup_extended.py index 22992ec0..0842161d 100644 --- a/tests/test_ansimarkup_extended.py +++ b/tests/test_ansimarkup_extended.py @@ -1,7 +1,6 @@ import pytest from colorama import Style as S, Fore as F, Back as B - -from loguru._ansimarkup import AnsiMarkup +from .conftest import parse @pytest.mark.parametrize( @@ -14,7 +13,7 @@ ], ) def test_background_colors(text, expected): - assert AnsiMarkup(strip=False).feed(text, strict=True) == expected + assert parse(text, strip=False) == expected @pytest.mark.parametrize( @@ -27,7 +26,7 @@ def test_background_colors(text, expected): ], ) def test_foreground_colors(text, expected): - assert AnsiMarkup(strip=False).feed(text, strict=True) == expected + assert parse(text, strip=False) == expected @pytest.mark.parametrize( @@ -39,7 +38,7 @@ def test_foreground_colors(text, expected): ], ) def test_8bit_colors(text, expected): - assert AnsiMarkup(strip=False).feed(text, strict=True) == expected + assert parse(text, strip=False) == expected @pytest.mark.parametrize( @@ -52,7 +51,7 @@ def test_8bit_colors(text, expected): ], ) def test_hex_colors(text, expected): - assert AnsiMarkup(strip=False).feed(text, strict=True) == expected + assert parse(text, strip=False) == expected @pytest.mark.parametrize( @@ -63,7 +62,7 @@ def test_hex_colors(text, expected): ], ) def test_rgb_colors(text, expected): - assert AnsiMarkup(strip=False).feed(text, strict=True) == expected + assert parse(text, strip=False) == expected @pytest.mark.parametrize( @@ -89,21 +88,7 @@ def test_rgb_colors(text, expected): ], ) def test_nested(text, expected): - assert AnsiMarkup(strip=False).feed(text, strict=True) == expected - - -def test_parse_with_custom_tags(): - markups = {"info": F.GREEN + S.BRIGHT} - - am = AnsiMarkup(custom_markups=markups, strip=False) - assert am.feed("1", strict=True) == F.GREEN + S.BRIGHT + "1" + S.RESET_ALL - - -def test_strip_with_custom_markups(): - markups = {"red": "", "b,g,r": "", "fg 1,2,3": ""} - - am = AnsiMarkup(custom_markups=markups, strip=True) - assert am.feed("123", strict=True) == "123" + assert parse(text, strip=False) == expected @pytest.mark.parametrize( @@ -123,7 +108,7 @@ def test_strip_with_custom_markups(): ], ) def test_tricky_parse(text, expected): - AnsiMarkup(strip=False).feed(text, strict=True) == expected + assert parse(text, strip=False) == expected @pytest.mark.parametrize( @@ -144,7 +129,7 @@ def test_tricky_parse(text, expected): @pytest.mark.parametrize("strip", [True, False]) def test_invalid_color(text, strip): with pytest.raises(ValueError): - AnsiMarkup(strip=strip).feed(text, strict=True) + parse(text, strip=strip) @pytest.mark.parametrize( @@ -160,14 +145,14 @@ def test_invalid_color(text, strip): @pytest.mark.parametrize("strip", [True, False]) def test_invalid_hex(text, strip): with pytest.raises(ValueError): - AnsiMarkup(strip=strip).feed(text, strict=True) + parse(text, strip=strip) @pytest.mark.parametrize("text", ["1", "1", "1"]) @pytest.mark.parametrize("strip", [True, False]) def test_invalid_8bit(text, strip): with pytest.raises(ValueError): - AnsiMarkup(strip=strip).feed(text, strict=True) + parse(text, strip=strip) @pytest.mark.parametrize( @@ -183,7 +168,7 @@ def test_invalid_8bit(text, strip): @pytest.mark.parametrize("strip", [True, False]) def test_invalid_rgb(text, strip): with pytest.raises(ValueError): - AnsiMarkup(strip=strip).feed(text, strict=True) + parse(text, strip=strip) @pytest.mark.parametrize( @@ -195,7 +180,7 @@ def test_invalid_rgb(text, strip): ], ) def test_strip(text, expected): - assert AnsiMarkup(strip=True).feed(text, strict=True) == expected + assert parse(text, strip=True) == expected @pytest.mark.parametrize( @@ -215,4 +200,4 @@ def test_strip(text, expected): ], ) def test_tricky_strip(text, expected): - assert AnsiMarkup(strip=True).feed(text, strict=True) == expected + assert parse(text, strip=True) == expected diff --git a/tests/test_levels.py b/tests/test_levels.py index 70f2ddc7..2a00ce0b 100644 --- a/tests/test_levels.py +++ b/tests/test_levels.py @@ -1,11 +1,7 @@ import pytest import functools from loguru import logger -from loguru._ansimarkup import AnsiMarkup - - -def parse(text): - return AnsiMarkup(strip=False).feed(text, strict=True) +from .conftest import parse def test_log_int_level(writer): @@ -237,7 +233,7 @@ def test_edit_unknown_level(): logger.level("foo", icon="?") -@pytest.mark.parametrize("color", ["", ""]) +@pytest.mark.parametrize("color", ["", "", "", "", " "]) def test_add_invalid_level_color(color): with pytest.raises(ValueError): logger.level("foobar", no=20, icon="", color=color) diff --git a/tests/test_opt.py b/tests/test_opt.py index 5761b032..51e30d66 100644 --- a/tests/test_opt.py +++ b/tests/test_opt.py @@ -1,14 +1,8 @@ import pytest import sys +from unittest.mock import MagicMock from loguru import logger -from loguru._ansimarkup import AnsiMarkup - - -def parse(text, colorize=True): - if colorize: - return AnsiMarkup(strip=False).feed(text, strict=True) - else: - return AnsiMarkup(strip=True).feed(text, strict=True) +from .conftest import parse def test_record(writer): @@ -21,6 +15,13 @@ def test_record(writer): assert writer.read() == "1\n2 DEBUG\n3 4 5 11\n" +def test_record_in_kwargs_too(writer): + logger.add(writer, catch=False) + + with pytest.raises(TypeError, match=r"The message can't be formatted"): + logger.opt(record=True).info("Foo {record}", record=123) + + def test_exception_boolean(writer): logger.add(writer, format="{level.name}: {message}") @@ -150,57 +151,59 @@ def test_ansi(writer): logger.opt(ansi=True).debug("b") logger.opt(ansi=True).log(20, "c") - assert writer.read() == parse("a b\n" "a c\n") + assert writer.read() == parse( + "a b\n" "a c\n", strip=False + ) def test_ansi_not_colorize(writer): logger.add(writer, format="a {message}", colorize=False) logger.opt(ansi=True).debug("b") - assert writer.read() == parse("a b\n", colorize=False) + assert writer.read() == parse("a b\n", strip=True) -def test_ansi_dont_color_unrelated(writer): +def test_ansi_doesnt_color_unrelated(writer): logger.add(writer, format="{message} {extra[trap]}", colorize=True) logger.bind(trap="B").opt(ansi=True).debug("A") - assert writer.read() == parse("A") + " B\n" + assert writer.read() == parse("A", strip=False) + " B\n" -def test_ansi_dont_strip_unrelated(writer): +def test_ansi_doesnt_strip_unrelated(writer): logger.add(writer, format="{message} {extra[trap]}", colorize=False) logger.bind(trap="B").opt(ansi=True).debug("A") - assert writer.read() == parse("A", colorize=False) + " B\n" + assert writer.read() == parse("A", strip=True) + " B\n" -def test_ansi_dont_raise_unrelated_colorize(writer): +def test_ansi_doesnt_raise_unrelated_colorize(writer): logger.add(writer, format="{message} {extra[trap]}", colorize=True, catch=False) logger.bind(trap="").opt(ansi=True).debug("A") assert writer.read() == "A \n" -def test_ansi_dont_raise_unrelated_not_colorize(writer): +def test_ansi_doesnt_raise_unrelated_not_colorize(writer): logger.add(writer, format="{message} {extra[trap]}", colorize=False, catch=False) logger.bind(trap="").opt(ansi=True).debug("A") assert writer.read() == "A \n" -def test_ansi_dont_raise_unrelated_colorize_dynamic(writer): +def test_ansi_doesnt_raise_unrelated_colorize_dynamic(writer): logger.add(writer, format=lambda x: "{message} {extra[trap]}", colorize=True, catch=False) logger.bind(trap="").opt(ansi=True).debug("A") assert writer.read() == "A " -def test_ansi_dont_raise_unrelated_not_colorize_dynamic(writer): +def test_ansi_doesnt_raise_unrelated_not_colorize_dynamic(writer): logger.add(writer, format=lambda x: "{message} {extra[trap]}", colorize=False, catch=False) logger.bind(trap="").opt(ansi=True).debug("A") assert writer.read() == "A " @pytest.mark.parametrize("colorize", [True, False]) -def test_ansi_with_record(writer, colorize): +def test_ansi_within_record(writer, colorize): logger.add(writer, format="{message}", colorize=colorize) logger_ = logger.bind(start="", end="") logger_.opt(ansi=True, record=True).debug("{record[extra][start]}B{record[extra][end]}") - assert writer.read() == parse("B\n", colorize=colorize) + assert writer.read() == "B\n" @pytest.mark.parametrize("colorize", [True, False]) @@ -208,12 +211,12 @@ def test_ansi_nested(writer, colorize): logger.add(writer, format="([{message}])", colorize=colorize) logger.opt(ansi=True).debug("ABCDE") assert writer.read() == parse( - "([ABCDE])\n", colorize=colorize + "([ABCDE])\n", strip=not colorize ) @pytest.mark.parametrize("colorize", [True, False]) -def test_ansi_message_in_record(colorize): +def test_ansi_stripped_in_message_record(colorize): message = None def sink(msg): @@ -222,7 +225,7 @@ def sink(msg): logger.add(sink, colorize=colorize) logger.opt(ansi=True).debug("Test") - assert message == "Test" + assert message == "Test" @pytest.mark.parametrize("message", ["", "", "X Y"]) @@ -237,7 +240,7 @@ def test_invalid_markup_in_message(writer, message, colorize): def test_ansi_with_args(writer, colorize): logger.add(writer, format="=> {message} <=", colorize=colorize) logger.opt(ansi=True).debug("the {0}test{end}", "", end="") - assert writer.read() == parse("=> the test <=\n", colorize=colorize) + assert writer.read() == "=> the test <=\n" @pytest.mark.parametrize("colorize", [True, False]) @@ -245,7 +248,7 @@ def test_ansi_with_level(writer, colorize): logger.add(writer, format="{message}", colorize=colorize) logger.level("DEBUG", color="") logger.opt(ansi=True).debug("a level b") - assert writer.read() == parse("a level b\n", colorize=colorize) + assert writer.read() == parse("a level b\n", strip=not colorize) @pytest.mark.parametrize("colorize", [True, False]) @@ -257,7 +260,7 @@ def test_ansi_double_message(writer, colorize): assert writer.read() == parse( "foo bar baz... - ...foo bar baz\n", - colorize=colorize, + strip=not colorize, ) @@ -266,7 +269,7 @@ def test_ansi_multiple_calls(writer, colorize): logger.add(writer, format="{message}", colorize=colorize) logger.opt(ansi=True).debug("a foo b") logger.opt(ansi=True).debug("a foo b") - assert writer.read() == parse("a foo b\na foo b\n", colorize=colorize) + assert writer.read() == parse("a foo b\na foo b\n", strip=not colorize) @pytest.mark.parametrize("colorize", [True, False]) @@ -276,14 +279,14 @@ def test_ansi_multiple_calls_level_color_changed(writer, colorize): logger.opt(ansi=True).info("a foo b") logger.level("INFO", color="") logger.opt(ansi=True).info("a foo b") - assert writer.read() == parse("a foo b\na foo b\n", colorize=colorize) + assert writer.read() == parse("a foo b\na foo b\n", strip=not colorize) @pytest.mark.parametrize("colorize", [True, False]) def test_ansi_with_dynamic_formatter(writer, colorize): logger.add(writer, format=lambda r: "{message}", colorize=colorize) logger.opt(ansi=True).debug("a b") - assert writer.read() == parse("a b", colorize=colorize) + assert writer.read() == parse("a b", strip=not colorize) @pytest.mark.parametrize("colorize", [True, False]) @@ -291,7 +294,7 @@ def test_ansi_with_format_specs(writer, colorize): fmt = "{level.no:03d} {message!s:} {{nope}} {extra[a][b]!r}" logger.add(writer, colorize=colorize, format=fmt) logger.bind(a={"b": "c"}).opt(ansi=True).debug("{X}") - assert writer.read() == parse("010 {X} {nope} 'c'\n", colorize=colorize) + assert writer.read() == parse("010 {X} {nope} 'c'\n", strip=not colorize) @pytest.mark.parametrize("colorize", [True, False]) @@ -300,22 +303,47 @@ def test_ansi_with_message_specs(writer, colorize): logger.opt(ansi=True).debug("{} A {{nope}} {key:03d} {let!r}", 1, key=10, let="c") logger.opt(ansi=True).debug("{0:0{1}d}", 2, 4) assert writer.read() == parse( - "1 A {nope} 010 'c'\n0002\n", colorize=colorize + "1 A {nope} 010 'c'\n0002\n", strip=not colorize ) @pytest.mark.parametrize("colorize", [True, False]) -def test_ansi_message_used_as_spec(writer, colorize): +def test_colored_string_used_as_spec(writer, colorize): logger.add(writer, colorize=colorize, format="{level.no:{message}} {message}") logger.opt(ansi=True).log(30, "03d") - assert writer.read() == parse("030 03d\n", colorize=colorize) + assert writer.read() == parse("030 03d\n", strip=not colorize) @pytest.mark.parametrize("colorize", [True, False]) -def test_ansi_message_getitem(writer, colorize): +def test_colored_string_getitem(writer, colorize): logger.add(writer, colorize=colorize, format="{message[0]}") logger.opt(ansi=True).info("ABC") - assert writer.read() == parse("A\n", colorize=colorize) + assert writer.read() == parse("A\n", strip=not colorize) + + +@pytest.mark.parametrize("colorize", [True, False]) +def test_ansi_without_formatting_args(writer, colorize): + string = "{} This { should } not } raise {" + logger.add(writer, colorize=colorize, format="{message}") + logger.opt(ansi=True).info(string) + assert writer.read() == string + "\n" + + +@pytest.mark.parametrize("colorize", [True, False]) +def test_ansi_with_recursion_depth_exceeded(writer, colorize): + logger.add(writer, format="{message}", colorize=colorize) + + with pytest.raises(ValueError, match=r"Max string recursion exceeded"): + logger.opt(ansi=True).info("{foo:{foo:{foo:}}}", foo=123) + + +@pytest.mark.parametrize("colorize", [True, False]) +@pytest.mark.parametrize("message", ["{} {0}", "{1} {}"]) +def test_ansi_with_invalid_indexing(writer, colorize, message): + logger.add(writer, format="{message}", colorize=colorize) + + with pytest.raises(ValueError, match=r"cannot switch"): + logger.opt(ansi=True).debug(message, 1, 2, 3) def test_raw(writer): @@ -335,7 +363,89 @@ def test_raw_with_format_function(writer): def test_raw_with_ansi(writer, colorize): logger.add(writer, format="XYZ", colorize=colorize) logger.opt(raw=True, ansi=True).info("Raw colors and level") - assert writer.read() == parse("Raw colors and level", colorize=colorize) + assert writer.read() == parse("Raw colors and level", strip=not colorize) + + +@pytest.mark.parametrize("colorize", [True, False]) +def test_args_with_ansi_not_formatted_twice(writer, colorize): + logger.add(writer, format="{message}", colorize=colorize) + a = MagicMock(__format__=MagicMock(return_value="a")) + b = MagicMock(__format__=MagicMock(return_value="b")) + + logger.opt(ansi=True).info("{} {foo}", a, foo=b) + assert a.__format__.call_count == 1 + assert b.__format__.call_count == 1 + assert writer.read() == parse("a b\n", strip=not colorize) + + +@pytest.mark.parametrize("dynamic_format", [True, False]) +@pytest.mark.parametrize("colorize", [True, False]) +@pytest.mark.parametrize("ansi", [True, False]) +@pytest.mark.parametrize("raw", [True, False]) +@pytest.mark.parametrize("use_log", [True, False]) +@pytest.mark.parametrize("use_arg", [True, False]) +def test_all_ansi_combinations(writer, dynamic_format, colorize, ansi, raw, use_log, use_arg): + format_ = "{level.no} {message}" + message = "The {}" + arg = "message" + + def formatter(_): + return format_ + "\n" + + logger.add(writer, format=formatter if dynamic_format else format_, colorize=colorize) + + logger_ = logger.opt(ansi=ansi, raw=raw) + + if use_log: + if use_arg: + logger_.log(20, message, arg) + else: + logger_.log(20, message.format(arg)) + else: + if use_arg: + logger_.info(message, arg) + else: + logger_.info(message.format(arg)) + + if use_log: + if raw: + if ansi: + expected = parse("The message", strip=not colorize) + else: + expected = "The message" + else: + if ansi: + expected = parse( + "20 The message\n", + strip=not colorize, + ) + else: + expected = ( + parse("20 %s\n", strip=not colorize) + % "The message" + ) + + else: + if raw: + if ansi: + expected = parse("The message", strip=not colorize) + else: + expected = "The message" + else: + if ansi: + expected = parse( + "20 The message\n", strip=not colorize + ) + else: + expected = ( + parse("20 %s\n", strip=not colorize) + % "The message" + ) + + output = writer.read() + print(output.encode("utf8")) + print(expected.encode("utf8")) + assert writer.read() == expected def test_raw_with_record(writer): diff --git a/tests/test_pickling.py b/tests/test_pickling.py index b120b9ae..72658c4d 100644 --- a/tests/test_pickling.py +++ b/tests/test_pickling.py @@ -5,6 +5,7 @@ import datetime import pytest +from .conftest import parse from loguru import logger @@ -55,7 +56,7 @@ def createLock(self): def format_function(record): - return "-> {message}" + return "-> {message}" def filter_function(record): @@ -241,13 +242,25 @@ def test_pickling_filter_name(capsys, filter): assert err == "" -def test_pickling_format_function(capsys): - logger.add(print_, format=format_function) +@pytest.mark.parametrize("colorize", [True, False]) +def test_pickling_format_string(capsys, colorize): + logger.add(print_, format="-> {message}", colorize=colorize) pickled = pickle.dumps(logger) unpickled = pickle.loads(pickled) unpickled.info("The message") out, err = capsys.readouterr() - assert out == "-> The message" + assert out == parse("-> The message\n", strip=not colorize) + assert err == "" + + +@pytest.mark.parametrize("colorize", [True, False]) +def test_pickling_format_function(capsys, colorize): + logger.add(print_, format=format_function, colorize=colorize) + pickled = pickle.dumps(logger) + unpickled = pickle.loads(pickled) + unpickled.info("The message") + out, err = capsys.readouterr() + assert out == parse("-> The message", strip=not colorize) assert err == "" diff --git a/tests/test_standard_handler.py b/tests/test_standard_handler.py index 002c6497..dcd7790b 100644 --- a/tests/test_standard_handler.py +++ b/tests/test_standard_handler.py @@ -42,7 +42,7 @@ def test_extra_dict(capsys): handler = StreamHandler(sys.stdout) formatter = Formatter("[%(abc)s] %(message)s") handler.setFormatter(formatter) - logger.add(handler, format=r"\<{extra[abc]}> {message}", catch=False) + logger.add(handler, format="<{extra[abc]}> {message}", catch=False) logger.bind(abc=123).info("Extra!") out, err = capsys.readouterr() assert out == "[123] <123> Extra!\n"