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"