From 37f44eda5555ffed3b4c581925650fb939e55b94 Mon Sep 17 00:00:00 2001 From: Delgan Date: Thu, 21 Nov 2024 16:50:19 +0100 Subject: [PATCH 01/19] Mention "diagnose=True" fix for Python 3.13 in the Changelog --- CHANGELOG.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cb1d6139..e9eda00f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,7 @@ ============= - Fix possible ``RuntimeError`` when removing all handlers with ``logger.remove()`` due to thread-safety issue (`#1183 `_, thanks `@jeremyk `_). +- Fix ``diagnose=True`` option of exception formatting not working as expected with Python 3.13 (`#1235 `_, thanks `@etianen `_). - Fix non-standard level names not fully compatible with ``logging.Formatter()`` (`#1231 `_, thanks `@yechielb2000 `_). - Improve performance of ``datetime`` formatting while logging messages (`#1201 `_, thanks `@trim21 `_). - Reduce startup time in the presence of installed but unused ``IPython`` third-party library (`#1001 `_, thanks `@zakstucke `_). From 8c3c8e45448bf55d233531c32cd583eff258e11d Mon Sep 17 00:00:00 2001 From: Delgan <4193924+Delgan@users.noreply.github.com> Date: Thu, 21 Nov 2024 23:15:40 +0100 Subject: [PATCH 02/19] Fix inability to display '\' before color markups (#1236) --- CHANGELOG.rst | 1 + loguru/_colorizer.py | 19 ++++++++++++++----- loguru/_logger.py | 7 ++++--- tests/test_ansimarkup_basic.py | 5 +++++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e9eda00f..a4aa622d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,7 @@ - Fix possible ``RuntimeError`` when removing all handlers with ``logger.remove()`` due to thread-safety issue (`#1183 `_, thanks `@jeremyk `_). - Fix ``diagnose=True`` option of exception formatting not working as expected with Python 3.13 (`#1235 `_, thanks `@etianen `_). - Fix non-standard level names not fully compatible with ``logging.Formatter()`` (`#1231 `_, thanks `@yechielb2000 `_). +- Fix inability to display a literal ``"\"`` immediately before color markups (`#988 `_). - Improve performance of ``datetime`` formatting while logging messages (`#1201 `_, thanks `@trim21 `_). - Reduce startup time in the presence of installed but unused ``IPython`` third-party library (`#1001 `_, thanks `@zakstucke `_). diff --git a/loguru/_colorizer.py b/loguru/_colorizer.py index 102d72cc..d432c1d7 100644 --- a/loguru/_colorizer.py +++ b/loguru/_colorizer.py @@ -166,7 +166,7 @@ class AnsiParser: } ) - _regex_tag = re.compile(r"\\?\s]*)>") + _regex_tag = re.compile(r"(\\*)(\s]*>)") def __init__(self): self._tokens = [] @@ -221,17 +221,26 @@ def feed(self, text, *, raw=False): position = 0 for match in self._regex_tag.finditer(text): - markup, tag = match.group(0), match.group(1) + escaping, markup = match.group(1), match.group(2) self._tokens.append((TokenType.TEXT, text[position : match.start()])) position = match.end() - if markup[0] == "\\": - self._tokens.append((TokenType.TEXT, markup[1:])) + escaping_count = len(escaping) + backslashes = "\\" * (escaping_count // 2) + + if escaping_count % 2 == 1: + self._tokens.append((TokenType.TEXT, backslashes + markup)) continue - if markup[1] == "/": + if escaping_count > 0: + self._tokens.append((TokenType.TEXT, backslashes)) + + is_closing = markup[1] == "/" + tag = markup[2:-1] if is_closing else markup[1:-1] + + if is_closing: if self._tags and (tag == "" or tag == self._tags[-1]): self._tags.pop() self._color_tokens.pop() diff --git a/loguru/_logger.py b/loguru/_logger.py index b76f1af5..54f857c7 100644 --- a/loguru/_logger.py +++ b/loguru/_logger.py @@ -659,9 +659,10 @@ def add( Tags which are not recognized will raise an exception during parsing, to inform you about possible misuse. If you wish to display a markup tag literally, you can escape it by - prepending a ``\`` like for example ``\``. If, for some reason, you need to escape a - string programmatically, note that the regex used internally to parse markup tags is - ``r"\\?\s]*)>"``. + prepending a ``\`` like for example ``\``. To prevent the escaping to occur, you can + simply double the ``\`` (e.g. ``\\`` will print a literal ``\`` before colored text). + If, for some reason, you need to escape a string programmatically, note that the regex used + internally to parse markup tags is ``r"(\\*)(\s]*>)"``. Note that when logging a message with ``opt(colors=True)``, color tags present in the formatting arguments (``args`` and ``kwargs``) are completely ignored. This is important if diff --git a/tests/test_ansimarkup_basic.py b/tests/test_ansimarkup_basic.py index 008aa1d8..5c0f114e 100644 --- a/tests/test_ansimarkup_basic.py +++ b/tests/test_ansimarkup_basic.py @@ -140,10 +140,15 @@ def test_autoclose(text, expected): @pytest.mark.parametrize( "text, expected", [ + (r"\foobar\", "foobar"), + (r"\\foobar\\", "\\" + Fore.RED + "foobar\\" + Style.RESET_ALL), + (r"\\\foobar\\\", "\\foobar\\"), + (r"\\\\foobar\\\\", "\\\\" + Fore.RED + "foobar\\\\" + Style.RESET_ALL), (r"foo\bar", Fore.RED + "foobar" + Style.RESET_ALL), (r"foo\bar", Fore.RED + "foobar" + Style.RESET_ALL), (r"\\", ""), (r"foo\bar\baz", "foobarbaz"), + (r"\a \\b \\\c \\\\d", "\\a \\\\b \\\\\\c \\\\\\\\d"), ], ) def test_escaping(text, expected): From 636666265c63fbb565b56ab1f725212b26b742e5 Mon Sep 17 00:00:00 2001 From: Delgan Date: Fri, 22 Nov 2024 09:48:31 +0100 Subject: [PATCH 03/19] Enable "RUF" (Ruff-specific rules) linting rule of Ruff --- loguru/_better_exceptions.py | 75 ++++++++++++++++++++---------------- loguru/_colorizer.py | 4 +- loguru/_logger.py | 4 +- pyproject.toml | 4 +- 4 files changed, 47 insertions(+), 40 deletions(-) diff --git a/loguru/_better_exceptions.py b/loguru/_better_exceptions.py index 94610c71..e9ee1124 100644 --- a/loguru/_better_exceptions.py +++ b/loguru/_better_exceptions.py @@ -30,31 +30,36 @@ def is_exception_group(exc): class SyntaxHighlighter: - _default_style = { - "comment": "\x1b[30m\x1b[1m{}\x1b[0m", - "keyword": "\x1b[35m\x1b[1m{}\x1b[0m", - "builtin": "\x1b[1m{}\x1b[0m", - "string": "\x1b[36m{}\x1b[0m", - "number": "\x1b[34m\x1b[1m{}\x1b[0m", - "operator": "\x1b[35m\x1b[1m{}\x1b[0m", - "punctuation": "\x1b[1m{}\x1b[0m", - "constant": "\x1b[36m\x1b[1m{}\x1b[0m", - "identifier": "\x1b[1m{}\x1b[0m", - "other": "{}", - } - - _builtins = set(dir(builtins)) - _constants = {"True", "False", "None"} - _punctuation = {"(", ")", "[", "]", "{", "}", ":", ",", ";"} - _strings = {tokenize.STRING} - _fstring_middle = None + _default_style = frozenset( + { + "comment": "\x1b[30m\x1b[1m{}\x1b[0m", + "keyword": "\x1b[35m\x1b[1m{}\x1b[0m", + "builtin": "\x1b[1m{}\x1b[0m", + "string": "\x1b[36m{}\x1b[0m", + "number": "\x1b[34m\x1b[1m{}\x1b[0m", + "operator": "\x1b[35m\x1b[1m{}\x1b[0m", + "punctuation": "\x1b[1m{}\x1b[0m", + "constant": "\x1b[36m\x1b[1m{}\x1b[0m", + "identifier": "\x1b[1m{}\x1b[0m", + "other": "{}", + }.items() + ) + + _builtins = frozenset(dir(builtins)) + _constants = frozenset({"True", "False", "None"}) + _punctuation = frozenset({"(", ")", "[", "]", "{", "}", ":", ",", ";"}) if sys.version_info >= (3, 12): - _strings.update({tokenize.FSTRING_START, tokenize.FSTRING_MIDDLE, tokenize.FSTRING_END}) + _strings = frozenset( + {tokenize.STRING, tokenize.FSTRING_START, tokenize.FSTRING_MIDDLE, tokenize.FSTRING_END} + ) _fstring_middle = tokenize.FSTRING_MIDDLE + else: + _strings = frozenset({tokenize.STRING}) + _fstring_middle = None def __init__(self, style=None): - self._style = style or self._default_style + self._style = style or dict(self._default_style) def highlight(self, source): style = self._style @@ -119,19 +124,21 @@ def tokenize(source): class ExceptionFormatter: - _default_theme = { - "introduction": "\x1b[33m\x1b[1m{}\x1b[0m", - "cause": "\x1b[1m{}\x1b[0m", - "context": "\x1b[1m{}\x1b[0m", - "dirname": "\x1b[32m{}\x1b[0m", - "basename": "\x1b[32m\x1b[1m{}\x1b[0m", - "line": "\x1b[33m{}\x1b[0m", - "function": "\x1b[35m{}\x1b[0m", - "exception_type": "\x1b[31m\x1b[1m{}\x1b[0m", - "exception_value": "\x1b[1m{}\x1b[0m", - "arrows": "\x1b[36m{}\x1b[0m", - "value": "\x1b[36m\x1b[1m{}\x1b[0m", - } + _default_theme = frozenset( + { + "introduction": "\x1b[33m\x1b[1m{}\x1b[0m", + "cause": "\x1b[1m{}\x1b[0m", + "context": "\x1b[1m{}\x1b[0m", + "dirname": "\x1b[32m{}\x1b[0m", + "basename": "\x1b[32m\x1b[1m{}\x1b[0m", + "line": "\x1b[33m{}\x1b[0m", + "function": "\x1b[35m{}\x1b[0m", + "exception_type": "\x1b[31m\x1b[1m{}\x1b[0m", + "exception_value": "\x1b[1m{}\x1b[0m", + "arrows": "\x1b[36m{}\x1b[0m", + "value": "\x1b[36m\x1b[1m{}\x1b[0m", + }.items() + ) def __init__( self, @@ -147,7 +154,7 @@ def __init__( ): self._colorize = colorize self._diagnose = diagnose - self._theme = theme or self._default_theme + self._theme = theme or dict(self._default_theme) self._backtrace = backtrace self._syntax_highlighter = SyntaxHighlighter(style) self._max_length = max_length diff --git a/loguru/_colorizer.py b/loguru/_colorizer.py index d432c1d7..5d389219 100644 --- a/loguru/_colorizer.py +++ b/loguru/_colorizer.py @@ -309,11 +309,11 @@ def _get_ansicode(self, tag): 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) + return "\033[%s;2;%s;%s;%sm" % ((code, *rgb)) if 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 "\033[%s;2;%s;%s;%sm" % ((code, *colors)) return None diff --git a/loguru/_logger.py b/loguru/_logger.py index 54f857c7..def8ecac 100644 --- a/loguru/_logger.py +++ b/loguru/_logger.py @@ -1240,7 +1240,7 @@ def __exit__(self, type_, value, traceback_): if from_decorator: depth += 1 - catch_options = [(type_, value, traceback_), depth, True] + options + catch_options = [(type_, value, traceback_), depth, True, *options] logger._log(level, from_decorator, catch_options, message, (), {}) if onerror is not None: @@ -1511,7 +1511,7 @@ def patch(self, patcher): ... logger.patch(lambda r: r.update(record)).log(level, message) """ *options, patchers, extra = self._options - return Logger(self._core, *options, patchers + [patcher], extra) + return Logger(self._core, *options, [*patchers, patcher], extra) def level(self, name, no=None, color=None, icon=None): """Add, update or retrieve a logging level. diff --git a/pyproject.toml b/pyproject.toml index ec7b21a3..d901f3b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,8 +103,8 @@ exclude = ["tests/exceptions/source/*"] line-length = 100 [tool.ruff.lint] -# Enforce pyflakes(F), pycodestyle(E, W), isort (I), bugbears (B), and pep8-naming (N) rules. -select = ["F", "E", "W", "I", "B", "N", "RET"] +# See list of rules at: https://docs.astral.sh/ruff/rules/ +select = ["F", "E", "W", "I", "B", "N", "RET", "RUF"] [tool.ruff.lint.pycodestyle] max-doc-length = 100 From 620772ad0209f8b369620f668c1550cd37f845a7 Mon Sep 17 00:00:00 2001 From: Delgan Date: Fri, 22 Nov 2024 16:29:51 +0100 Subject: [PATCH 04/19] Adjust some of the raised exceptions --- loguru/_defaults.py | 2 +- loguru/_logger.py | 2 +- loguru/_string_parsers.py | 16 ++++++++-------- tests/test_levels.py | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/loguru/_defaults.py b/loguru/_defaults.py index 327e3802..7f3043a7 100644 --- a/loguru/_defaults.py +++ b/loguru/_defaults.py @@ -24,7 +24,7 @@ def env(key, type_, default=None): raise ValueError( "Invalid environment variable '%s' (expected an integer): '%s'" % (key, val) ) from None - raise ValueError("The requested type '%r' is not supported" % type_) + raise ValueError("The requested type '%s' is not supported" % type_.__name__) LOGURU_AUTOINIT = env("LOGURU_AUTOINIT", bool, True) diff --git a/loguru/_logger.py b/loguru/_logger.py index def8ecac..a0dbd1c9 100644 --- a/loguru/_logger.py +++ b/loguru/_logger.py @@ -1585,7 +1585,7 @@ def level(self, name, no=None, color=None, icon=None): ) old_color, old_icon = "", " " elif no is not None: - raise TypeError("Level '%s' already exists, you can't update its severity no" % name) + raise ValueError("Level '%s' already exists, you can't update its severity no" % name) else: _, no, old_color, old_icon = self.level(name) diff --git a/loguru/_string_parsers.py b/loguru/_string_parsers.py index 4fa67c15..d42436e4 100644 --- a/loguru/_string_parsers.py +++ b/loguru/_string_parsers.py @@ -166,20 +166,20 @@ def parse_daytime(daytime): day = time = daytime try: - day = parse_day(day) - if match and day is None: - raise ValueError + parsed_day = parse_day(day) + if match and parsed_day is None: + raise ValueError("Unparsable day") except ValueError as e: raise ValueError("Invalid day while parsing daytime: '%s'" % day) from e try: - time = parse_time(time) - if match and time is None: - raise ValueError + parsed_time = parse_time(time) + if match and parsed_time is None: + raise ValueError("Unparsable time") except ValueError as e: raise ValueError("Invalid time while parsing daytime: '%s'" % time) from e - if day is None and time is None: + if parsed_day is None and parsed_time is None: return None - return day, time + return parsed_day, parsed_time diff --git a/tests/test_levels.py b/tests/test_levels.py index b38e4d1d..849dac0f 100644 --- a/tests/test_levels.py +++ b/tests/test_levels.py @@ -181,13 +181,13 @@ def test_assign_custom_level_method(writer): def test_updating_level_no_not_allowed_default(): - with pytest.raises(TypeError, match="can't update its severity"): + with pytest.raises(ValueError, match="can't update its severity"): logger.level("DEBUG", 100) def test_updating_level_no_not_allowed_custom(): logger.level("foobar", no=33) - with pytest.raises(TypeError, match="can't update its severity"): + with pytest.raises(ValueError, match="can't update its severity"): logger.level("foobar", 100) From 11cc211966bbdd8c8a98b541c5ed841574753c6d Mon Sep 17 00:00:00 2001 From: Delgan Date: Fri, 22 Nov 2024 17:01:18 +0100 Subject: [PATCH 05/19] Enable "PT" (flake8-pytest-style) linting rule of Ruff --- pyproject.toml | 3 +- tests/conftest.py | 2 +- tests/test_activation.py | 4 +- tests/test_add_option_colorize.py | 4 +- tests/test_add_option_filter.py | 48 ++++++++++++++----- tests/test_add_option_format.py | 6 ++- tests/test_add_option_level.py | 16 +++++-- tests/test_ansimarkup_basic.py | 43 +++++++++++------ tests/test_ansimarkup_extended.py | 52 +++++++++++++++------ tests/test_colorama.py | 10 ++-- tests/test_coroutine_sink.py | 8 +++- tests/test_datetime.py | 4 +- tests/test_defaults.py | 12 +++-- tests/test_exceptions_formatting.py | 2 +- tests/test_filesink_compression.py | 8 ++-- tests/test_filesink_retention.py | 19 +++++--- tests/test_filesink_rotation.py | 56 +++++++++++++++-------- tests/test_formatting.py | 10 ++-- tests/test_levels.py | 71 ++++++++++++++++++++++------- tests/test_locks.py | 5 +- tests/test_multiprocessing.py | 4 +- tests/test_opt.py | 24 ++++++++-- 22 files changed, 288 insertions(+), 123 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d901f3b3..20f3d492 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,8 +103,9 @@ exclude = ["tests/exceptions/source/*"] line-length = 100 [tool.ruff.lint] +ignore = ["PT004", "PT005"] # Deprecated rules. # See list of rules at: https://docs.astral.sh/ruff/rules/ -select = ["F", "E", "W", "I", "B", "N", "RET", "RUF"] +select = ["F", "E", "W", "I", "B", "N", "PT", "RET", "RUF"] [tool.ruff.lint.pycodestyle] max-doc-length = 100 diff --git a/tests/conftest.py b/tests/conftest.py index 1f044177..4ca48ca1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,7 +44,7 @@ def run(coro): @pytest.fixture def tmp_path(tmp_path): - yield pathlib.Path(str(tmp_path)) + return pathlib.Path(str(tmp_path)) @contextlib.contextmanager diff --git a/tests/test_activation.py b/tests/test_activation.py index 548b7ff1..c768f49c 100644 --- a/tests/test_activation.py +++ b/tests/test_activation.py @@ -4,7 +4,7 @@ @pytest.mark.parametrize( - "name, should_log", + ("name", "should_log"), [ ("", False), ("tests", False), @@ -30,7 +30,7 @@ def test_disable(writer, name, should_log): @pytest.mark.parametrize( - "name, should_log", + ("name", "should_log"), [ ("", True), ("tests", True), diff --git a/tests/test_add_option_colorize.py b/tests/test_add_option_colorize.py index 0d1bf851..61fbbfcd 100644 --- a/tests/test_add_option_colorize.py +++ b/tests/test_add_option_colorize.py @@ -6,7 +6,7 @@ @pytest.mark.parametrize( - "format, message, expected", + ("format", "message", "expected"), [ ("{message}", "Foo", parse("Foo\n")), (lambda _: "{message}", "Bar", parse("Bar")), @@ -21,7 +21,7 @@ def test_colorized_format(format, message, expected, writer): @pytest.mark.parametrize( - "format, message, expected", + ("format", "message", "expected"), [ ("{message}", "Foo", "Foo\n"), (lambda _: "{message}", "Bar", "Bar"), diff --git a/tests/test_add_option_filter.py b/tests/test_add_option_filter.py index 12e5a130..efab25bb 100644 --- a/tests/test_add_option_filter.py +++ b/tests/test_add_option_filter.py @@ -1,3 +1,5 @@ +import re + import pytest from loguru import logger @@ -95,23 +97,41 @@ def test_invalid_filter(writer, filter): logger.add(writer, filter=filter) -@pytest.mark.parametrize( - "filter", - [{1: "DEBUG"}, {object(): 10}, {"foo": None}, {"foo": 2.5}, {"a": "DEBUG", "b": object()}], -) -def test_invalid_filter_dict_types(writer, filter): +@pytest.mark.parametrize("filter", [{"foo": None}, {"foo": 2.5}, {"a": "DEBUG", "b": object()}]) +def test_invalid_filter_dict_level_types(writer, filter): with pytest.raises(TypeError): logger.add(writer, filter=filter) -@pytest.mark.parametrize( - "filter", [{"foo": "UNKNOWN_LEVEL"}, {"tests": -1}, {"tests.test_add_option_filter": ""}] -) -def test_invalid_filter_dict_values(writer, filter): - with pytest.raises(ValueError): +@pytest.mark.parametrize("filter", [{1: "DEBUG"}, {object(): 10}]) +def test_invalid_filter_dict_module_types(writer, filter): + with pytest.raises(TypeError): + logger.add(writer, filter=filter) + + +@pytest.mark.parametrize("filter", [{"foo": "UNKNOWN_LEVEL"}, {"tests.test_add_option_filter": ""}]) +def test_invalid_filter_dict_values_unknown_level(writer, filter): + with pytest.raises( + ValueError, + match=( + "The filter dict contains a module '[^']*' associated to " + "a level name which does not exist: '[^']*'" + ), + ): logger.add(writer, filter=filter) +def test_invalid_filter_dict_values_wrong_integer_value(writer): + with pytest.raises( + ValueError, + match=( + "The filter dict contains a module '[^']*' associated to an invalid level, " + "it should be a positive integer, not: '[^']*'" + ), + ): + logger.add(writer, filter={"tests": -1}) + + def test_filter_dict_with_custom_level(writer): logger.level("MY_LEVEL", 6, color="", icon="") logger.add(writer, level=0, filter={"tests": "MY_LEVEL"}, format="{message}") @@ -121,5 +141,11 @@ def test_filter_dict_with_custom_level(writer): def test_invalid_filter_builtin(writer): - with pytest.raises(ValueError, match=r".* most likely a mistake"): + with pytest.raises( + ValueError, + match=re.escape( + "The built-in 'filter()' function cannot be used as a 'filter' parameter, this is " + "most likely a mistake (please double-check the arguments passed to 'logger.add()'" + ), + ): logger.add(writer, filter=filter) diff --git a/tests/test_add_option_format.py b/tests/test_add_option_format.py index ec74a5f5..472894ae 100644 --- a/tests/test_add_option_format.py +++ b/tests/test_add_option_format.py @@ -4,7 +4,7 @@ @pytest.mark.parametrize( - "message, format, expected", + ("message", "format", "expected"), [ ("a", "Message: {message}", "Message: a\n"), ("b", "Nope", "Nope\n"), @@ -64,7 +64,9 @@ def test_invalid_format(writer, format): @pytest.mark.parametrize("format", ["", "", "", "", ""]) def test_invalid_markups(writer, format): - with pytest.raises(ValueError, match=r"Invalid format"): + with pytest.raises( + ValueError, match="^Invalid format, color markups could not be parsed correctly$" + ): logger.add(writer, format=format) diff --git a/tests/test_add_option_level.py b/tests/test_add_option_level.py index de68af24..73d9b8f9 100644 --- a/tests/test_add_option_level.py +++ b/tests/test_add_option_level.py @@ -18,12 +18,18 @@ def test_level_too_high(writer, level): @pytest.mark.parametrize("level", [3.4, object()]) -def test_invalid_level(writer, level): +def test_invalid_level_type(writer, level): with pytest.raises(TypeError): logger.add(writer, level=level) -@pytest.mark.parametrize("level", ["foo", -1]) -def test_unknown_level(writer, level): - with pytest.raises(ValueError): - logger.add(writer, level=level) +def test_invalid_level_value(writer): + with pytest.raises( + ValueError, match="^Invalid level value, it should be a positive integer, not: -1$" + ): + logger.add(writer, level=-1) + + +def test_unknown_level(writer): + with pytest.raises(ValueError, match="^Level 'foo' does not exist$"): + logger.add(writer, level="foo") diff --git a/tests/test_ansimarkup_basic.py b/tests/test_ansimarkup_basic.py index 5c0f114e..eab7ed31 100644 --- a/tests/test_ansimarkup_basic.py +++ b/tests/test_ansimarkup_basic.py @@ -5,7 +5,7 @@ @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ("1", Style.BRIGHT + "1" + Style.RESET_ALL), ("1", Style.DIM + "1" + Style.RESET_ALL), @@ -20,7 +20,7 @@ def test_styles(text, expected): @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ("1", Back.RED + "1" + Style.RESET_ALL), ("1", Back.RED + "1" + Style.RESET_ALL), @@ -33,7 +33,7 @@ def test_background_colors(text, expected): @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ("1", Fore.YELLOW + "1" + Style.RESET_ALL), ("1", Fore.YELLOW + "1" + Style.RESET_ALL), @@ -46,7 +46,7 @@ def test_foreground_colors(text, expected): @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ( "12", @@ -94,12 +94,14 @@ def test_nested(text, expected): @pytest.mark.parametrize("text", ["", "", ""]) def test_strict_parsing(text): - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match='^Opening tag "<[^>]*>" has no corresponding closing tag$' + ): parse(text, strip=False) @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ("", Style.BRIGHT), ("", Back.YELLOW + Style.BRIGHT + Style.RESET_ALL + Back.YELLOW), @@ -111,7 +113,7 @@ def test_permissive_parsing(text, expected): @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ("foo", Fore.RED + "foo" + Style.RESET_ALL), ( @@ -138,7 +140,7 @@ def test_autoclose(text, expected): @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ (r"\foobar\", "foobar"), (r"\\foobar\\", "\\" + Fore.RED + "foobar\\" + Style.RESET_ALL), @@ -162,14 +164,17 @@ def test_escaping(text, expected): "", "1", "1", - "1", + "1", + "foo", "", "X", ], ) @pytest.mark.parametrize("strip", [True, False]) def test_mismatched_error(text, strip): - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match='^Closing tag "<[^>]*>" has no corresponding opening tag$' + ): parse(text, strip=strip) @@ -178,14 +183,16 @@ def test_mismatched_error(text, strip): ) @pytest.mark.parametrize("strip", [True, False]) def test_unbalanced_error(text, strip): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match='^Closing tag "<[^>]*>" violates nesting rules$'): 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): + with pytest.raises( + ValueError, match='^Opening tag "<[^>]*>" has no corresponding closing tag$' + ): parse(text, strip=strip) @@ -194,7 +201,6 @@ def test_unclosed_error(text, strip): [ "bar", "foobar", - "foo", "foo", "12", "12", @@ -202,18 +208,25 @@ def test_unclosed_error(text, strip): "123", "1", "1", + "1", "1", "1", ], ) @pytest.mark.parametrize("strip", [True, False]) def test_invalid_color(text, strip): - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match=( + '^Tag "<[^>]*>" does not correspond to any known color directive, ' + r"make sure you did not misspelled it \(or prepend '\\' to escape it\)$" + ), + ): parse(text, strip=strip) @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ("foo", "foo"), ("bar", "bar"), diff --git a/tests/test_ansimarkup_extended.py b/tests/test_ansimarkup_extended.py index e24c0dbf..e78d66be 100644 --- a/tests/test_ansimarkup_extended.py +++ b/tests/test_ansimarkup_extended.py @@ -5,7 +5,7 @@ @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ("1", Back.RED + "1" + Style.RESET_ALL), ("1", Back.BLACK + "1" + Style.RESET_ALL), @@ -18,7 +18,7 @@ def test_background_colors(text, expected): @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ("1", Fore.YELLOW + "1" + Style.RESET_ALL), ("1", Fore.BLUE + "1" + Style.RESET_ALL), @@ -31,7 +31,7 @@ def test_foreground_colors(text, expected): @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ("1", "\x1b[38;2;255;0;0m" "1" + Style.RESET_ALL), ("1", "\x1b[48;2;0;160;0m" "1" + Style.RESET_ALL), @@ -43,7 +43,7 @@ def test_8bit_colors(text, expected): @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ("1", "\x1b[38;2;255;0;0m" "1" + Style.RESET_ALL), ("1", "\x1b[48;2;0;160;0m" "1" + Style.RESET_ALL), @@ -56,7 +56,7 @@ def test_hex_colors(text, expected): @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ("1", "\x1b[38;5;200m" "1" + Style.RESET_ALL), ("1", "\x1b[48;5;49m" "1" + Style.RESET_ALL), @@ -67,7 +67,7 @@ def test_rgb_colors(text, expected): @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ( "1", @@ -99,7 +99,7 @@ def test_nested(text, expected): @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ("2 > 1", Fore.RED + "2 > 1" + Style.RESET_ALL), ("1 < 2", Fore.RED + "1 < 2" + Style.RESET_ALL), @@ -135,7 +135,13 @@ def test_tricky_parse(text, expected): ) @pytest.mark.parametrize("strip", [True, False]) def test_invalid_color(text, strip): - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match=( + '^Tag "<[^>]*>" does not correspond to any known color directive, ' + r"make sure you did not misspelled it \(or prepend '\\' to escape it\)$" + ), + ): parse(text, strip=strip) @@ -146,19 +152,31 @@ def test_invalid_color(text, strip): "1", "1", "1", - "fg #F2D1GZ>1", + "1", ], ) @pytest.mark.parametrize("strip", [True, False]) def test_invalid_hex(text, strip): - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match=( + '^Tag "<[^>]*>" does not correspond to any known color directive, ' + r"make sure you did not misspelled it \(or prepend '\\' to escape it\)$" + ), + ): 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): + with pytest.raises( + ValueError, + match=( + '^Tag "<[^>]*>" does not correspond to any known color directive, ' + r"make sure you did not misspelled it \(or prepend '\\' to escape it\)$" + ), + ): parse(text, strip=strip) @@ -174,12 +192,18 @@ def test_invalid_8bit(text, strip): ) @pytest.mark.parametrize("strip", [True, False]) def test_invalid_rgb(text, strip): - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match=( + '^Tag "<[^>]*>" does not correspond to any known color directive, ' + r"make sure you did not misspelled it \(or prepend '\\' to escape it\)$" + ), + ): parse(text, strip=strip) @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ("foobar", "foobar"), ("baz", "baz"), @@ -191,7 +215,7 @@ def test_strip(text, expected): @pytest.mark.parametrize( - "text, expected", + ("text", "expected"), [ ("2 > 1", "2 > 1"), ("1 < 2", "1 < 2"), diff --git a/tests/test_colorama.py b/tests/test_colorama.py index 42a30efb..cf987214 100644 --- a/tests/test_colorama.py +++ b/tests/test_colorama.py @@ -80,7 +80,7 @@ def test_is_a_tty_exception(): @pytest.mark.parametrize( - "patched, expected", + ("patched", "expected"), [ ("__stdout__", True), ("__stderr__", True), @@ -98,7 +98,7 @@ def test_pycharm_fixed(monkeypatch, patched, expected): @pytest.mark.parametrize( - "patched, expected", + ("patched", "expected"), [ ("__stdout__", True), ("__stderr__", True), @@ -117,7 +117,7 @@ def test_github_actions_fixed(monkeypatch, patched, expected): @pytest.mark.parametrize( - "patched, expected", + ("patched", "expected"), [ ("__stdout__", True), ("__stderr__", True), @@ -136,7 +136,7 @@ def test_mintty_fixed_windows(monkeypatch, patched, expected): @pytest.mark.parametrize( - "patched, expected", + ("patched", "expected"), [ ("__stdout__", False), ("__stderr__", False), @@ -155,7 +155,7 @@ def test_mintty_not_fixed_linux(monkeypatch, patched, expected): @pytest.mark.parametrize( - "patched, out_class, expected", + ("patched", "out_class", "expected"), [ ("stdout", StreamIsattyFalse, True), ("stderr", StreamIsattyFalse, True), diff --git a/tests/test_coroutine_sink.py b/tests/test_coroutine_sink.py index e1b64045..177fe887 100644 --- a/tests/test_coroutine_sink.py +++ b/tests/test_coroutine_sink.py @@ -666,5 +666,11 @@ async def complete(): @pytest.mark.skipif(sys.version_info < (3, 5, 3), reason="Coroutine can't access running loop") def test_invalid_coroutine_sink_if_no_loop_with_enqueue(): - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match=( + "^An event loop is required to add a coroutine sink with `enqueue=True`, " + "but none has been passed as argument and none is currently running.$" + ), + ): logger.add(async_writer, enqueue=True, loop=None) diff --git a/tests/test_datetime.py b/tests/test_datetime.py index ff7f6f90..19ed77e8 100644 --- a/tests/test_datetime.py +++ b/tests/test_datetime.py @@ -14,7 +14,7 @@ @pytest.mark.parametrize( - "time_format, date, timezone, expected", + ("time_format", "date", "timezone", "expected"), [ ( "%Y-%m-%d %H-%M-%S %f %Z %z", @@ -116,7 +116,7 @@ def test_formatting(writer, freeze_time, time_format, date, timezone, expected): @pytest.mark.parametrize( - "time_format, offset, expected", + ("time_format", "offset", "expected"), [ ("%Y-%m-%d %H-%M-%S %f %Z %z", 7230.099, "2018-06-09 01-02-03 000000 ABC +020030.099000"), ("YYYY-MM-DD HH-mm-ss zz Z ZZ", 6543, "2018-06-09 01-02-03 ABC +01:49:03 +014903"), diff --git a/tests/test_defaults.py b/tests/test_defaults.py index 6f50a447..b87b2ed4 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -39,7 +39,10 @@ def test_invalid_int(value, monkeypatch): with monkeypatch.context() as context: key = "INVALID_INT" context.setenv(key, value) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match=r"^Invalid environment variable 'INVALID_INT' \(expected an integer\): '[^']*'$", + ): env(key, int) @@ -48,7 +51,10 @@ def test_invalid_bool(value, monkeypatch): with monkeypatch.context() as context: key = "INVALID_BOOL" context.setenv(key, value) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match=r"^Invalid environment variable 'INVALID_BOOL' \(expected a boolean\): '[^']*'$", + ): env(key, bool) @@ -56,5 +62,5 @@ def test_invalid_type(monkeypatch): with monkeypatch.context() as context: key = "INVALID_TYPE" context.setenv(key, "42.0") - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="^The requested type '[^']+' is not supported"): env(key, float) diff --git a/tests/test_exceptions_formatting.py b/tests/test_exceptions_formatting.py index 18f18516..d49e4c2d 100644 --- a/tests/test_exceptions_formatting.py +++ b/tests/test_exceptions_formatting.py @@ -231,7 +231,7 @@ def test_exception_others(filename): @pytest.mark.parametrize( - "filename, minimum_python_version", + ("filename", "minimum_python_version"), [ ("type_hints", (3, 6)), ("positional_only_argument", (3, 8)), diff --git a/tests/test_filesink_compression.py b/tests/test_filesink_compression.py index b535aeb2..8ec3d04b 100644 --- a/tests/test_filesink_compression.py +++ b/tests/test_filesink_compression.py @@ -219,7 +219,7 @@ def test_exception_during_compression_at_rotation_not_caught(freeze_time, tmp_pa catch=False, delay=delay, ) - with pytest.raises(OSError, match="Compression error"): + with pytest.raises(OSError, match="^Compression error$"): logger.debug("AAA") frozen.tick() @@ -249,7 +249,7 @@ def test_exception_during_compression_at_remove(tmp_path, capsys, delay): ) logger.debug("AAA") - with pytest.raises(OSError, match=r"Compression error"): + with pytest.raises(OSError, match="^Compression error$"): logger.remove(i) logger.debug("Nope") @@ -266,14 +266,14 @@ def test_exception_during_compression_at_remove(tmp_path, capsys, delay): @pytest.mark.parametrize("compression", [0, True, os, object(), {"zip"}]) -def test_invalid_compression(compression): +def test_invalid_compression_type(compression): with pytest.raises(TypeError): logger.add("test.log", compression=compression) @pytest.mark.parametrize("compression", ["rar", ".7z", "tar.zip", "__dict__"]) def test_unknown_compression(compression): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="^Invalid compression format: '[^']+'$"): logger.add("test.log", compression=compression) diff --git a/tests/test_filesink_retention.py b/tests/test_filesink_retention.py index a8fab8ae..8f9d1484 100644 --- a/tests/test_filesink_retention.py +++ b/tests/test_filesink_retention.py @@ -303,7 +303,7 @@ def test_exception_during_retention_at_rotation_not_caught(freeze_time, tmp_path catch=False, delay=delay, ) - with pytest.raises(OSError, match=r"Retention error"): + with pytest.raises(OSError, match="^Retention error$"): logger.debug("AAA") frozen.tick() logger.debug("BBB") @@ -332,7 +332,7 @@ def test_exception_during_retention_at_remove(tmp_path, capsys, delay): ) logger.debug("AAA") - with pytest.raises(OSError, match=r"Retention error"): + with pytest.raises(OSError, match="^Retention error$"): logger.remove(i) logger.debug("Nope") @@ -344,15 +344,20 @@ def test_exception_during_retention_at_remove(tmp_path, capsys, delay): @pytest.mark.parametrize("retention", [datetime.time(12, 12, 12), os, object()]) -def test_invalid_retention(retention): +def test_invalid_retention_type(retention): with pytest.raises(TypeError): logger.add("test.log", retention=retention) @pytest.mark.parametrize( - "retention", - ["W5", "monday at 14:00", "sunday", "nope", "5 MB", "3 hours 2 dayz", "d", "H", "__dict__"], + "retention", ["W5", "monday at 14:00", "sunday", "nope", "d", "H", "__dict__"] ) -def test_unknown_retention(retention): - with pytest.raises(ValueError): +def test_unparsable_retention(retention): + with pytest.raises(ValueError, match="^Cannot parse retention from: '[^']+'$"): + logger.add("test.log", retention=retention) + + +@pytest.mark.parametrize("retention", ["5 MB", "3 hours 2 dayz"]) +def test_invalid_value_retention_duration(retention): + with pytest.raises(ValueError, match="^Invalid unit value while parsing duration: '[^']+'$"): logger.add("test.log", retention=retention) diff --git a/tests/test_filesink_rotation.py b/tests/test_filesink_rotation.py index 897baaf3..7e56c0f8 100644 --- a/tests/test_filesink_rotation.py +++ b/tests/test_filesink_rotation.py @@ -105,7 +105,7 @@ def test_size_rotation(freeze_time, tmp_path, size): @pytest.mark.parametrize( - "when, hours", + ("when", "hours"), [ # hours = [ # Should not trigger, should trigger, should not trigger, should trigger, should trigger @@ -1000,7 +1000,7 @@ def test_exception_during_rotation_not_caught(tmp_path, capsys): catch=False, ) - with pytest.raises(OSError, match=r"Rotation error"): + with pytest.raises(OSError, match="^Rotation error$"): logger.info("A") logger.info("B") @@ -1061,7 +1061,7 @@ def should_rotate(self, message, file): @pytest.mark.parametrize( "rotation", [object(), os, datetime.date(2017, 11, 11), datetime.datetime.now(), 1j] ) -def test_invalid_rotation(rotation): +def test_invalid_rotation_type(rotation): with pytest.raises(TypeError): logger.add("test.log", rotation=rotation) @@ -1069,31 +1069,51 @@ def test_invalid_rotation(rotation): @pytest.mark.parametrize( "rotation", [ - "w7", - "w10", "w-1", "h", "M", "w1at13", "www", - "13 at w2", "w", "K", - "tufy MB", - "111.111.111 kb", - "3 Ki", - "2017.11.12", - "11:99", + "foobar MB", "01:00:00!UTC", - "monday at 2017", - "e days", - "2 days 8 pouooi", "foobar", - "w5 at [not|a|time]", - "[not|a|day] at 12:00", "__dict__", ], ) -def test_unknown_rotation(rotation): - with pytest.raises(ValueError): +def test_unparsable_rotation(rotation): + with pytest.raises(ValueError, match="^Cannot parse rotation from: '[^']+'$"): + logger.add("test.log", rotation=rotation) + + +@pytest.mark.parametrize("rotation", ["w7", "w10", "13 at w2", "[not|a|day] at 12:00"]) +def test_invalid_day_rotation(rotation): + with pytest.raises(ValueError, match="^Invalid day while parsing daytime: '[^']+'$"): + logger.add("test.log", rotation=rotation) + + +@pytest.mark.parametrize( + "rotation", ["2017.11.12", "11:99", "monday at 2017", "w5 at [not|a|time]"] +) +def test_invalid_time_rotation(rotation): + with pytest.raises(ValueError, match="^Invalid time while parsing daytime: '[^']+'$"): + logger.add("test.log", rotation=rotation) + + +@pytest.mark.parametrize("rotation", ["111.111.111 kb", "e KB"]) +def test_invalid_value_size_rotation(rotation): + with pytest.raises(ValueError, match="^Invalid float value while parsing size: '[^']+'$"): + logger.add("test.log", rotation=rotation) + + +@pytest.mark.parametrize("rotation", ["2 days 8 foobar", "1 foobar 3 days", "3 Ki"]) +def test_invalid_unit_rotation_duration(rotation): + with pytest.raises(ValueError, match="^Invalid unit value while parsing duration: '[^']+'$"): + logger.add("test.log", rotation=rotation) + + +@pytest.mark.parametrize("rotation", ["e days", "1.2.3 days"]) +def test_invalid_value_rotation_duration(rotation): + with pytest.raises(ValueError, match="^Invalid float value while parsing duration: '[^']+'$"): logger.add("test.log", rotation=rotation) diff --git a/tests/test_formatting.py b/tests/test_formatting.py index 9c77167d..0207ccdb 100644 --- a/tests/test_formatting.py +++ b/tests/test_formatting.py @@ -7,7 +7,7 @@ @pytest.mark.parametrize( - "format, validator", + ("format", "validator"), [ ("{name}", lambda r: r == "tests.test_formatting"), ("{time}", lambda r: re.fullmatch(r"\d+-\d+-\d+T\d+:\d+:\d+[.,]\d+[+-]\d{4}", r)), @@ -49,7 +49,7 @@ def test_log_formatters(format, validator, writer, use_log_function): @pytest.mark.parametrize( - "format, validator", + ("format", "validator"), [ ("{time}.log", lambda r: re.fullmatch(r"\d+-\d+-\d+_\d+-\d+-\d+\_\d+.log", r)), ("%s_{{a}}_天_{{1}}_%d", lambda r: r == "%s_{a}_天_{1}_%d"), @@ -84,7 +84,7 @@ def test_file_formatters(tmp_path, format, validator, part): @pytest.mark.parametrize( - "message, args, kwargs, expected", + ("message", "args", "kwargs", "expected"), [ ("{1, 2, 3} - {0} - {", [], {}, "{1, 2, 3} - {0} - {"), ("{} + {} = {}", [1, 2, 3], {}, "1 + 2 = 3"), @@ -226,5 +226,7 @@ def test_not_formattable_message_with_colors(writer): def test_invalid_color_markup(writer): - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match="^Invalid format, color markups could not be parsed correctly$" + ): logger.add(writer, format="Not closed tag", colorize=True) diff --git a/tests/test_levels.py b/tests/test_levels.py index 849dac0f..4382b9eb 100644 --- a/tests/test_levels.py +++ b/tests/test_levels.py @@ -36,7 +36,7 @@ def test_add_level(writer): @pytest.mark.parametrize( - "colorize, expected", [(False, "foo | 10 | a"), (True, parse("foo | 10 | a"))] + ("colorize", "expected"), [(False, "foo | 10 | a"), (True, parse("foo | 10 | a"))] ) def test_add_level_after_add(writer, colorize, expected): fmt = "{level.name} | {level.no} | {message}" @@ -181,79 +181,118 @@ def test_assign_custom_level_method(writer): def test_updating_level_no_not_allowed_default(): - with pytest.raises(ValueError, match="can't update its severity"): + with pytest.raises( + ValueError, match="^Level 'DEBUG' already exists, you can't update its severity no$" + ): logger.level("DEBUG", 100) def test_updating_level_no_not_allowed_custom(): logger.level("foobar", no=33) - with pytest.raises(ValueError, match="can't update its severity"): + with pytest.raises( + ValueError, match="^Level 'foobar' already exists, you can't update its severity no$" + ): logger.level("foobar", 100) @pytest.mark.parametrize("level", [3.4, object(), set()]) def test_log_invalid_level_type(writer, level): logger.add(writer) - with pytest.raises(TypeError, match="Invalid level, it should be an integer or a string"): + with pytest.raises( + TypeError, match="^Invalid level, it should be an integer or a string, not: '[^']+'$" + ): logger.log(level, "test") @pytest.mark.parametrize("level", [-1, -999]) def test_log_invalid_level_value(writer, level): logger.add(writer) - with pytest.raises(ValueError, match="Invalid level value, it should be a positive integer"): + with pytest.raises( + ValueError, match="^Invalid level value, it should be a positive integer, not: -?[0-9]+" + ): logger.log(level, "test") @pytest.mark.parametrize("level", ["foo", "debug"]) def test_log_unknown_level(writer, level): logger.add(writer) - with pytest.raises(ValueError, match=r"Level '[^']+' does not exist"): + with pytest.raises(ValueError, match="^Level '[^']+' does not exist$"): logger.log(level, "test") @pytest.mark.parametrize("level_name", [10, object(), set()]) def test_add_invalid_level_name(level_name): - with pytest.raises(TypeError, match="Invalid level name, it should be a string"): + with pytest.raises(TypeError): logger.level(level_name, 11) @pytest.mark.parametrize("level_value", ["1", object(), 3.4, set()]) def test_add_invalid_level_type(level_value): - with pytest.raises(TypeError, match="Invalid level no, it should be an integer"): + with pytest.raises(TypeError): logger.level("test", level_value) @pytest.mark.parametrize("level_value", [-1, -999]) def test_add_invalid_level_value(level_value): - with pytest.raises(ValueError, match="Invalid level no, it should be a positive integer"): + with pytest.raises( + ValueError, match="^Invalid level no, it should be a positive integer, not: -?[0-9]+$" + ): logger.level("test", level_value) @pytest.mark.parametrize("level", [10, object(), set()]) def test_get_invalid_level(level): - with pytest.raises(TypeError, match="Invalid level name, it should be a string"): + with pytest.raises(TypeError): logger.level(level) def test_get_unknown_level(): - with pytest.raises(ValueError, match=r"Level '[^']+' does not exist"): + with pytest.raises(ValueError, match="^Level '[^']+' does not exist$"): logger.level("foo") @pytest.mark.parametrize("level", [10, object(), set()]) def test_edit_invalid_level(level): - with pytest.raises(TypeError, match="Invalid level name, it should be a string"): + with pytest.raises(TypeError): logger.level(level, icon="?") @pytest.mark.parametrize("level_name", ["foo", "debug"]) def test_edit_unknown_level(level_name): - with pytest.raises(ValueError, match=r"Level '[^']+' does not exist"): + with pytest.raises( + ValueError, + match="^Level '[^']+' does not exist, you have to create it by specifying a level no$", + ): logger.level(level_name, icon="?") -@pytest.mark.parametrize("color", ["", "", "", "", " "]) -def test_add_invalid_level_color(color): - with pytest.raises(ValueError): +@pytest.mark.parametrize("color", ["<>", ""]) +def test_add_level_unknown_color(color): + with pytest.raises( + ValueError, + match=( + 'Tag "<[^>]*>" does not correspond to any known color directive, ' + r"make sure you did not misspelled it \(or prepend '\\' to escape it\)" + ), + ): + logger.level("foobar", no=20, icon="", color=color) + + +@pytest.mark.parametrize("color", ["", "", ""]) +def test_add_level_invalid_markup(color): + with pytest.raises( + ValueError, match='^Closing tag "]*>" has no corresponding opening tag$' + ): + logger.level("foobar", no=20, icon="", color=color) + + +@pytest.mark.parametrize("color", ["", " "]) +def test_add_level_invalid_name(color): + with pytest.raises( + ValueError, + match=( + "^The '' color tag is not allowed in this context, " + r"it has not yet been associated to any color value\.$" + ), + ): logger.level("foobar", no=20, icon="", color=color) diff --git a/tests/test_locks.py b/tests/test_locks.py index 17029395..d9dac59c 100644 --- a/tests/test_locks.py +++ b/tests/test_locks.py @@ -28,7 +28,7 @@ def __del__(self): logger.info("tearing down") -@pytest.fixture() +@pytest.fixture def _remove_cyclic_references(): """Prevent cyclic isolate finalizers bleeding into other tests.""" try: @@ -37,7 +37,8 @@ def _remove_cyclic_references(): gc.collect() -def test_no_deadlock_on_generational_garbage_collection(_remove_cyclic_references): +@pytest.mark.usefixtures("_remove_cyclic_references") +def test_no_deadlock_on_generational_garbage_collection(): """Regression test for https://github.com/Delgan/loguru/issues/712 Assert that deadlocks do not occur when a cyclic isolate containing log output in diff --git a/tests/test_multiprocessing.py b/tests/test_multiprocessing.py index 4ee68747..966cdead 100644 --- a/tests/test_multiprocessing.py +++ b/tests/test_multiprocessing.py @@ -15,12 +15,12 @@ @pytest.fixture def fork_context(): - yield multiprocessing.get_context("fork") + return multiprocessing.get_context("fork") @pytest.fixture def spawn_context(): - yield multiprocessing.get_context("spawn") + return multiprocessing.get_context("spawn") def do_something(i): diff --git a/tests/test_opt.py b/tests/test_opt.py index 13a40e7a..6db8480f 100644 --- a/tests/test_opt.py +++ b/tests/test_opt.py @@ -21,7 +21,13 @@ def test_record(writer): def test_record_in_kwargs_too(writer): logger.add(writer, catch=False) - with pytest.raises(TypeError, match=r"The message can't be formatted"): + with pytest.raises( + TypeError, + match=( + "^The message can't be formatted: 'record' shall not be used as a keyword argument " + r"while logger has been configured with '\.opt\(record=True\)'$" + ), + ): logger.opt(record=True).info("Foo {record}", record=123) @@ -272,7 +278,10 @@ def sink(msg): @pytest.mark.parametrize("colorize", [True, False]) def test_invalid_markup_in_message(writer, message, colorize): logger.add(writer, format="{message}", colorize=colorize, catch=False) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match='(Closing|Opening) tag "[^"]*" has no corresponding (opening|closing) tag', + ): logger.opt(colors=True).debug(message) @@ -371,7 +380,9 @@ def test_colors_without_formatting_args(writer, colorize): @pytest.mark.parametrize("colorize", [True, False]) def test_colors_with_recursion_depth_exceeded_in_format(writer, colorize): - with pytest.raises(ValueError, match=r"Invalid format"): + with pytest.raises( + ValueError, match="^Invalid format, color markups could not be parsed correctly$" + ): logger.add(writer, format="{message:{message:{message:}}}", colorize=colorize) @@ -379,7 +390,7 @@ def test_colors_with_recursion_depth_exceeded_in_format(writer, colorize): def test_colors_with_recursion_depth_exceeded_in_message(writer, colorize): logger.add(writer, format="{message}", colorize=colorize) - with pytest.raises(ValueError, match=r"Max string recursion exceeded"): + with pytest.raises(ValueError, match="Max string recursion exceeded"): logger.opt(colors=True).info("{foo:{foo:{foo:}}}", foo=123) @@ -402,7 +413,10 @@ def test_colors_with_manual_indexing(writer, colorize): def test_colors_with_invalid_indexing(writer, colorize, message): logger.add(writer, format="{message}", colorize=colorize) - with pytest.raises(ValueError, match=r"cannot switch"): + with pytest.raises( + ValueError, + match="cannot switch from manual field specification to automatic field numbering", + ): logger.opt(colors=True).debug(message, 1, 2, 3) From 31d02f046d45777953c467d3527bddb5164b6f03 Mon Sep 17 00:00:00 2001 From: Delgan Date: Fri, 22 Nov 2024 17:31:00 +0100 Subject: [PATCH 06/19] Enable "D" (pydocstyle) linting rule of Ruff --- docs/_extensions/autodoc_stub_file.py | 8 ++--- docs/conf.py | 21 ++++++----- loguru/__init__.pyi | 51 ++++++++++++++------------- loguru/_logger.py | 15 +++++--- pyproject.toml | 8 ++++- tests/test_exceptions_formatting.py | 4 +-- tests/test_locks.py | 3 +- 7 files changed, 60 insertions(+), 50 deletions(-) diff --git a/docs/_extensions/autodoc_stub_file.py b/docs/_extensions/autodoc_stub_file.py index e8267be8..d9937f13 100644 --- a/docs/_extensions/autodoc_stub_file.py +++ b/docs/_extensions/autodoc_stub_file.py @@ -1,5 +1,4 @@ -""" -Small Sphinx extension intended to generate documentation for stub files. +"""Small Sphinx extension intended to generate documentation for stub files. It retrieves only the docstrings of "loguru/__init__.pyi", hence avoiding possible errors (caused by missing imports or forward references). The stub file is loaded as a dummy module which contains @@ -18,7 +17,7 @@ import types -def get_module_docstring(filepath): +def _get_module_docstring(filepath): with open(filepath) as file: source = file.read() @@ -33,9 +32,10 @@ def get_module_docstring(filepath): def setup(app): + """Configure the Sphinx plugin.""" module_name = "autodoc_stub_file.loguru" dirname = os.path.dirname(os.path.abspath(__file__)) stub_path = os.path.join(dirname, "..", "..", "loguru", "__init__.pyi") - docstring = get_module_docstring(stub_path) + docstring = _get_module_docstring(stub_path) module = types.ModuleType(module_name, docstring) sys.modules[module_name] = module diff --git a/docs/conf.py b/docs/conf.py index 90434a85..e4e551a4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,16 +1,14 @@ -# -*- coding: utf-8 -*- -# -# Configuration file for the Sphinx documentation builder. -# -# This file does only contain a selection of the most common options. For a -# full list see the documentation: -# http://www.sphinx-doc.org/en/master/config +"""Configuration file for the Sphinx documentation builder. + +This file does only contain a selection of the most common options. For a +full list see the documentation: http://www.sphinx-doc.org/en/master/config -# -- Path setup -------------------------------------------------------------- +-- Path setup -------------------------------------------------------------- -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. +If extensions (or modules to document with autodoc) are in another directory, +add these directories to sys.path here. If the directory is relative to the +documentation root, use os.path.abspath to make it absolute, like shown here. +""" import os import sys @@ -176,5 +174,6 @@ def setup(app): + """Configure the generation of docs.""" app.add_css_file("css/loguru.css") app.add_js_file("js/copybutton.js") diff --git a/loguru/__init__.pyi b/loguru/__init__.pyi index 10162914..bdecac2a 100644 --- a/loguru/__init__.pyi +++ b/loguru/__init__.pyi @@ -1,28 +1,4 @@ -""" -.. |str| replace:: :class:`str` -.. |namedtuple| replace:: :func:`namedtuple` -.. |dict| replace:: :class:`dict` - -.. |Logger| replace:: :class:`~loguru._logger.Logger` -.. |catch| replace:: :meth:`~loguru._logger.Logger.catch()` -.. |contextualize| replace:: :meth:`~loguru._logger.Logger.contextualize()` -.. |complete| replace:: :meth:`~loguru._logger.Logger.complete()` -.. |bind| replace:: :meth:`~loguru._logger.Logger.bind()` -.. |patch| replace:: :meth:`~loguru._logger.Logger.patch()` -.. |opt| replace:: :meth:`~loguru._logger.Logger.opt()` -.. |level| replace:: :meth:`~loguru._logger.Logger.level()` - -.. _stub file: https://www.python.org/dev/peps/pep-0484/#stub-files -.. _string literals: https://www.python.org/dev/peps/pep-0484/#forward-references -.. _postponed evaluation of annotations: https://www.python.org/dev/peps/pep-0563/ -.. |future| replace:: ``__future__`` -.. _future: https://www.python.org/dev/peps/pep-0563/#enabling-the-future-behavior-in-python-3-7 -.. |loguru-mypy| replace:: ``loguru-mypy`` -.. _loguru-mypy: https://github.com/kornicameister/loguru-mypy -.. |documentation of loguru-mypy| replace:: documentation of ``loguru-mypy`` -.. _documentation of loguru-mypy: - https://github.com/kornicameister/loguru-mypy/blob/master/README.md -.. _@kornicameister: https://github.com/kornicameister +"""Type hints details of the `Loguru` library. Loguru relies on a `stub file`_ to document its types. This implies that these types are not accessible during execution of your program, however they can be used by type checkers and IDE. @@ -85,6 +61,31 @@ It helps to catch several possible runtime errors by performing additional check - and even more... For more details, go to official |documentation of loguru-mypy|_. + +.. |str| replace:: :class:`str` +.. |namedtuple| replace:: :func:`namedtuple` +.. |dict| replace:: :class:`dict` + +.. |Logger| replace:: :class:`~loguru._logger.Logger` +.. |catch| replace:: :meth:`~loguru._logger.Logger.catch()` +.. |contextualize| replace:: :meth:`~loguru._logger.Logger.contextualize()` +.. |complete| replace:: :meth:`~loguru._logger.Logger.complete()` +.. |bind| replace:: :meth:`~loguru._logger.Logger.bind()` +.. |patch| replace:: :meth:`~loguru._logger.Logger.patch()` +.. |opt| replace:: :meth:`~loguru._logger.Logger.opt()` +.. |level| replace:: :meth:`~loguru._logger.Logger.level()` + +.. _stub file: https://www.python.org/dev/peps/pep-0484/#stub-files +.. _string literals: https://www.python.org/dev/peps/pep-0484/#forward-references +.. _postponed evaluation of annotations: https://www.python.org/dev/peps/pep-0563/ +.. |future| replace:: ``__future__`` +.. _future: https://www.python.org/dev/peps/pep-0563/#enabling-the-future-behavior-in-python-3-7 +.. |loguru-mypy| replace:: ``loguru-mypy`` +.. _loguru-mypy: https://github.com/kornicameister/loguru-mypy +.. |documentation of loguru-mypy| replace:: documentation of ``loguru-mypy`` +.. _documentation of loguru-mypy: + https://github.com/kornicameister/loguru-mypy/blob/master/README.md +.. _@kornicameister: https://github.com/kornicameister """ import sys diff --git a/loguru/_logger.py b/loguru/_logger.py index a0dbd1c9..abfa5196 100644 --- a/loguru/_logger.py +++ b/loguru/_logger.py @@ -1,4 +1,5 @@ -""" +"""Core logging functionalities of the `Loguru` library. + .. References and links rendered by Sphinx are kept here as "module documentation" so that they can be used in the ``Logger`` docstrings but do not pollute ``help(logger)`` output. @@ -1514,7 +1515,7 @@ def patch(self, patcher): return Logger(self._core, *options, [*patchers, patcher], extra) def level(self, name, no=None, color=None, icon=None): - """Add, update or retrieve a logging level. + r"""Add, update or retrieve a logging level. Logging levels are defined by their ``name`` to which a severity ``no``, an ansi ``color`` tag and an ``icon`` are associated and possibly modified at run-time. To |log| to a custom @@ -2065,7 +2066,7 @@ def critical(__self, __message, *args, **kwargs): # noqa: N805 __self._log("CRITICAL", False, __self._options, __message, args, kwargs) def exception(__self, __message, *args, **kwargs): # noqa: N805 - r"""Convenience method for logging an ``'ERROR'`` with exception information.""" + r"""Log an ``'ERROR'```` message while also capturing the currently handled exception.""" options = (True,) + __self._options[1:] __self._log("ERROR", False, options, __message, args, kwargs) @@ -2074,7 +2075,9 @@ def log(__self, __level, __message, *args, **kwargs): # noqa: N805 __self._log(__level, False, __self._options, __message, args, kwargs) def start(self, *args, **kwargs): - """Deprecated function to |add| a new handler. + """Add a handler sending log messages to a sink adequately configured. + + Deprecated function, use |add| instead. Warnings -------- @@ -2090,7 +2093,9 @@ def start(self, *args, **kwargs): return self.add(*args, **kwargs) def stop(self, *args, **kwargs): - """Deprecated function to |remove| an existing handler. + """Remove a previously added handler and stop sending logs to its sink. + + Deprecated function, use |remove| instead. Warnings -------- diff --git a/pyproject.toml b/pyproject.toml index 20f3d492..2667cde9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,11 +105,17 @@ line-length = 100 [tool.ruff.lint] ignore = ["PT004", "PT005"] # Deprecated rules. # See list of rules at: https://docs.astral.sh/ruff/rules/ -select = ["F", "E", "W", "I", "B", "N", "PT", "RET", "RUF"] +select = ["F", "E", "W", "I", "B", "N", "D", "PT", "RET", "RUF"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["D1"] # Do not require documentation for tests. [tool.ruff.lint.pycodestyle] max-doc-length = 100 +[tool.ruff.lint.pydocstyle] +convention = "numpy" + [tool.typos.default] extend-ignore-re = ["(?Rm)^.*# spellchecker: disable-line$"] diff --git a/tests/test_exceptions_formatting.py b/tests/test_exceptions_formatting.py index d49e4c2d..0cb8305a 100644 --- a/tests/test_exceptions_formatting.py +++ b/tests/test_exceptions_formatting.py @@ -12,7 +12,7 @@ def normalize(exception): - """Normalize exception output for reproducible test cases""" + """Normalize exception output for reproducible test cases.""" if os.name == "nt": exception = re.sub( r'File[^"]+"[^"]+\.py[^"]*"', lambda m: m.group().replace("\\", "/"), exception @@ -82,7 +82,7 @@ def fix_filepath(match): def generate(output, outpath): - """Generate new output file if exception formatting is updated""" + """Generate new output file if exception formatting is updated.""" os.makedirs(os.path.dirname(outpath), exist_ok=True) with open(outpath, "w") as file: file.write(output) diff --git a/tests/test_locks.py b/tests/test_locks.py index d9dac59c..7fc00756 100644 --- a/tests/test_locks.py +++ b/tests/test_locks.py @@ -39,12 +39,11 @@ def _remove_cyclic_references(): @pytest.mark.usefixtures("_remove_cyclic_references") def test_no_deadlock_on_generational_garbage_collection(): - """Regression test for https://github.com/Delgan/loguru/issues/712 + """Regression test for https://github.com/Delgan/loguru/issues/712. Assert that deadlocks do not occur when a cyclic isolate containing log output in finalizers is collected by generational GC, during the output of another log message. """ - # GIVEN a sink which assigns some memory output = [] From 280e584e9403d9d2e562dc73d4671884f349867f Mon Sep 17 00:00:00 2001 From: Delgan Date: Fri, 22 Nov 2024 17:42:39 +0100 Subject: [PATCH 07/19] Bump Ruff to v0.8.0 --- .pre-commit-config.yaml | 2 +- loguru/_recattrs.py | 2 +- pyproject.toml | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1075cdc9..debd311f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,7 @@ repos: hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.0 + rev: v0.8.0 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/loguru/_recattrs.py b/loguru/_recattrs.py index b09426ef..26b5bec5 100644 --- a/loguru/_recattrs.py +++ b/loguru/_recattrs.py @@ -3,7 +3,7 @@ class RecordLevel: - __slots__ = ("name", "no", "icon") + __slots__ = ("icon", "name", "no") def __init__(self, name, no, icon): self.name = name diff --git a/pyproject.toml b/pyproject.toml index 2667cde9..d039ed39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,7 +103,6 @@ exclude = ["tests/exceptions/source/*"] line-length = 100 [tool.ruff.lint] -ignore = ["PT004", "PT005"] # Deprecated rules. # See list of rules at: https://docs.astral.sh/ruff/rules/ select = ["F", "E", "W", "I", "B", "N", "D", "PT", "RET", "RUF"] From 7e5e789d31417ae97fa79cd9ea10fb973843284b Mon Sep 17 00:00:00 2001 From: Delgan Date: Fri, 22 Nov 2024 18:31:20 +0100 Subject: [PATCH 08/19] Drop unused "sphinx-autobuild" dev dependencies --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d039ed39..7f74e9e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,6 @@ dev = [ "mypy==v1.13.0 ; python_version>='3.8'", # Docs. "Sphinx==7.3.7 ; python_version>='3.9'", - "sphinx-autobuild==2024.9.19 ; python_version>='3.9'", "sphinx-rtd-theme==2.0.0 ; python_version>='3.9'" ] From 63a729a5ba8137b944ae43788de7ee860ba90431 Mon Sep 17 00:00:00 2001 From: Delgan Date: Fri, 22 Nov 2024 22:12:32 +0100 Subject: [PATCH 09/19] Add specialized TOML pre-commit hook That way, it's coherent wit the Tablo VS Code extension. There is also a linter, but it seems rather slow, therefore it's not enabled. --- .pre-commit-config.yaml | 6 ++++-- pyproject.toml | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index debd311f..1e72d8df 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,8 +18,10 @@ repos: args: [--autofix] - id: pretty-format-yaml args: [--autofix, --indent, '2'] - - id: pretty-format-toml - args: [--autofix] +- repo: https://github.com/ComPWA/taplo-pre-commit + rev: v0.9.3 + hooks: + - id: taplo-format - repo: https://github.com/ambv/black rev: 24.10.0 hooks: diff --git a/pyproject.toml b/pyproject.toml index 7f74e9e2..2f34e11f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,7 @@ build-backend = "flit_core.buildapi" requires = ["flit_core>=3,<4"] [project] -authors = [ - {name = "Delgan", email = "delgan.py@gmail.com"} -] +authors = [{ name = "Delgan", email = "delgan.py@gmail.com" }] classifiers = [ "Development Status :: 5 - Production/Stable", "Topic :: System :: Logging", @@ -26,17 +24,17 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: Implementation :: PyPy", - "Programming Language :: Python :: Implementation :: CPython" + "Programming Language :: Python :: Implementation :: CPython", ] dependencies = [ "colorama>=0.3.4 ; sys_platform=='win32'", "aiocontextvars>=0.2.0 ; python_version<'3.7'", - "win32-setctime>=1.0.0 ; sys_platform=='win32'" + "win32-setctime>=1.0.0 ; sys_platform=='win32'", ] description = "Python logging made (stupidly) simple" dynamic = ['version'] keywords = ["loguru", "logging", "logger", "log"] -license = {text = "MIT"} +license = { text = "MIT" } name = "loguru" readme = 'README.rst' requires-python = ">=3.5,<4.0" @@ -68,7 +66,7 @@ dev = [ "mypy==v1.13.0 ; python_version>='3.8'", # Docs. "Sphinx==7.3.7 ; python_version>='3.9'", - "sphinx-rtd-theme==2.0.0 ; python_version>='3.9'" + "sphinx-rtd-theme==2.0.0 ; python_version>='3.9'", ] [project.urls] @@ -93,7 +91,7 @@ filterwarnings = [ # By default all warnings are treated as errors. 'error', # Mixing threads and "fork()" is deprecated, but we need to test it anyway. - 'ignore:.*use of fork\(\) may lead to deadlocks in the child.*:DeprecationWarning' + 'ignore:.*use of fork\(\) may lead to deadlocks in the child.*:DeprecationWarning', ] testpaths = ["tests"] @@ -106,7 +104,7 @@ line-length = 100 select = ["F", "E", "W", "I", "B", "N", "D", "PT", "RET", "RUF"] [tool.ruff.lint.per-file-ignores] -"tests/**" = ["D1"] # Do not require documentation for tests. +"tests/**" = ["D1"] # Do not require documentation for tests. [tool.ruff.lint.pycodestyle] max-doc-length = 100 @@ -118,4 +116,6 @@ convention = "numpy" extend-ignore-re = ["(?Rm)^.*# spellchecker: disable-line$"] [tool.typos.files] -extend-exclude = ["tests/exceptions/output/**"] # False positive due to ansi sequences. +extend-exclude = [ + "tests/exceptions/output/**", # False positive due to ansi sequences. +] From ed0daf8b6368c810fd53a3862fce05384cd15f6e Mon Sep 17 00:00:00 2001 From: Delgan <4193924+Delgan@users.noreply.github.com> Date: Sat, 23 Nov 2024 12:07:47 +0100 Subject: [PATCH 10/19] Fix possible infinite recursion with "__repr__" and "logger.catch()" (#1237) --- CHANGELOG.rst | 1 + loguru/_logger.py | 20 +- .../others/broken_but_decorated_repr.txt | 22 +++ .../others/broken_but_decorated_repr.py | 33 ++++ tests/test_exceptions_catch.py | 172 ++++++++++++++++++ tests/test_exceptions_formatting.py | 1 + 6 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 tests/exceptions/output/others/broken_but_decorated_repr.txt create mode 100644 tests/exceptions/source/others/broken_but_decorated_repr.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a4aa622d..607efd04 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,7 @@ - Fix ``diagnose=True`` option of exception formatting not working as expected with Python 3.13 (`#1235 `_, thanks `@etianen `_). - Fix non-standard level names not fully compatible with ``logging.Formatter()`` (`#1231 `_, thanks `@yechielb2000 `_). - Fix inability to display a literal ``"\"`` immediately before color markups (`#988 `_). +- Fix possible infinite recursion when an exception is raised from a ``__repr__`` method decorated with ``logger.catch()`` (`#1044 `_). - Improve performance of ``datetime`` formatting while logging messages (`#1201 `_, thanks `@trim21 `_). - Reduce startup time in the presence of installed but unused ``IPython`` third-party library (`#1001 `_, thanks `@zakstucke `_). diff --git a/loguru/_logger.py b/loguru/_logger.py index abfa5196..80497397 100644 --- a/loguru/_logger.py +++ b/loguru/_logger.py @@ -90,6 +90,7 @@ import logging import re import sys +import threading import warnings from collections import namedtuple from inspect import isclass, iscoroutinefunction, isgeneratorfunction @@ -194,15 +195,18 @@ def __init__(self): self.activation_list = [] self.activation_none = True + self.thread_locals = threading.local() self.lock = create_logger_lock() def __getstate__(self): state = self.__dict__.copy() + state["thread_locals"] = None state["lock"] = None return state def __setstate__(self, state): self.__dict__.update(state) + self.thread_locals = threading.local() self.lock = create_logger_lock() @@ -1229,6 +1233,15 @@ def __exit__(self, type_, value, traceback_): if type_ is None: return None + # We must prevent infinite recursion in case "logger.catch()" handles an exception + # that occurs while logging another exception. This can happen for example when + # the exception formatter calls "repr(obj)" while the "__repr__" method is broken + # but decorated with "logger.catch()". In such a case, we ignore the catching + # mechanism and just let the exception be thrown (that way, the formatter will + # rightly assume the object is unprintable). + if getattr(logger._core.thread_locals, "already_logging_exception", False): + return False + if not issubclass(type_, exception): return False @@ -1242,7 +1255,12 @@ def __exit__(self, type_, value, traceback_): depth += 1 catch_options = [(type_, value, traceback_), depth, True, *options] - logger._log(level, from_decorator, catch_options, message, (), {}) + + logger._core.thread_locals.already_logging_exception = True + try: + logger._log(level, from_decorator, catch_options, message, (), {}) + finally: + logger._core.thread_locals.already_logging_exception = False if onerror is not None: onerror(value) diff --git a/tests/exceptions/output/others/broken_but_decorated_repr.txt b/tests/exceptions/output/others/broken_but_decorated_repr.txt new file mode 100644 index 00000000..aa99c76c --- /dev/null +++ b/tests/exceptions/output/others/broken_but_decorated_repr.txt @@ -0,0 +1,22 @@ + +Traceback (most recent call last): + +> File "tests/exceptions/source/others/broken_but_decorated_repr.py", line 25, in + repr(foo) + └ + + File "tests/exceptions/source/others/broken_but_decorated_repr.py", line 12, in __repr__ + raise ValueError("Something went wrong (Foo)") + +ValueError: Something went wrong (Foo) + +Traceback (most recent call last): + + File "tests/exceptions/source/others/broken_but_decorated_repr.py", line 31, in + repr(bar) + └ + +> File "tests/exceptions/source/others/broken_but_decorated_repr.py", line 18, in __repr__ + raise ValueError("Something went wrong (Bar)") + +ValueError: Something went wrong (Bar) diff --git a/tests/exceptions/source/others/broken_but_decorated_repr.py b/tests/exceptions/source/others/broken_but_decorated_repr.py new file mode 100644 index 00000000..4884bc49 --- /dev/null +++ b/tests/exceptions/source/others/broken_but_decorated_repr.py @@ -0,0 +1,33 @@ +import sys + +from loguru import logger + +logger.remove() +logger.add(sys.stderr, format="", diagnose=True, backtrace=True, colorize=False, catch=True) + + +class Foo: + @logger.catch(reraise=True) + def __repr__(self): + raise ValueError("Something went wrong (Foo)") + + +class Bar: + def __repr__(self): + with logger.catch(reraise=True): + raise ValueError("Something went wrong (Bar)") + + +foo = Foo() +bar = Bar() + +try: + repr(foo) +except ValueError: + pass + + +try: + repr(bar) +except ValueError: + pass diff --git a/tests/test_exceptions_catch.py b/tests/test_exceptions_catch.py index b2eb1d41..1e7aea97 100644 --- a/tests/test_exceptions_catch.py +++ b/tests/test_exceptions_catch.py @@ -2,6 +2,7 @@ import site import sys import sysconfig +import threading import types import pytest @@ -452,3 +453,174 @@ def test_error_when_decorating_class_with_parentheses(): @logger.catch() class Foo: pass + + +def test_unprintable_but_decorated_repr(writer): + + class Foo: + @logger.catch(reraise=True) + def __repr__(self): + raise ValueError("Something went wrong") + + logger.add(writer, backtrace=True, diagnose=True, colorize=False, format="", catch=False) + + foo = Foo() + + with pytest.raises(ValueError, match="^Something went wrong$"): + repr(foo) + + assert writer.read().endswith("ValueError: Something went wrong\n") + + +def test_unprintable_but_decorated_repr_without_reraise(writer): + class Foo: + @logger.catch(reraise=False, default="?") + def __repr__(self): + raise ValueError("Something went wrong") + + logger.add(writer, backtrace=True, diagnose=True, colorize=False, format="", catch=False) + + foo = Foo() + + repr(foo) + + assert writer.read().endswith("ValueError: Something went wrong\n") + + +def test_unprintable_but_decorated_multiple_sinks(capsys): + class Foo: + @logger.catch(reraise=True) + def __repr__(self): + raise ValueError("Something went wrong") + + logger.add(sys.stderr, backtrace=True, diagnose=True, colorize=False, format="", catch=False) + logger.add(sys.stdout, backtrace=True, diagnose=True, colorize=False, format="", catch=False) + + foo = Foo() + + with pytest.raises(ValueError, match="^Something went wrong$"): + repr(foo) + + out, err = capsys.readouterr() + assert out.endswith("ValueError: Something went wrong\n") + assert err.endswith("ValueError: Something went wrong\n") + + +def test_unprintable_but_decorated_repr_with_enqueue(writer): + class Foo: + @logger.catch(reraise=True) + def __repr__(self): + raise ValueError("Something went wrong") + + logger.add( + writer, backtrace=True, diagnose=True, colorize=False, format="", catch=False, enqueue=True + ) + + foo = Foo() + + with pytest.raises(ValueError, match="^Something went wrong$"): + repr(foo) + + logger.complete() + + assert writer.read().endswith("ValueError: Something went wrong\n") + + +def test_unprintable_but_decorated_repr_twice(writer): + class Foo: + @logger.catch(reraise=True) + @logger.catch(reraise=True) + def __repr__(self): + raise ValueError("Something went wrong") + + logger.add(writer, backtrace=True, diagnose=True, colorize=False, format="", catch=False) + + foo = Foo() + + with pytest.raises(ValueError, match="^Something went wrong$"): + repr(foo) + + assert writer.read().endswith("ValueError: Something went wrong\n") + + +def test_unprintable_with_catch_context_manager(writer): + class Foo: + def __repr__(self): + with logger.catch(reraise=True): + raise ValueError("Something went wrong") + + logger.add(writer, backtrace=True, diagnose=True, colorize=False, format="", catch=False) + + foo = Foo() + + with pytest.raises(ValueError, match="^Something went wrong$"): + repr(foo) + + assert writer.read().endswith("ValueError: Something went wrong\n") + + +def test_unprintable_with_catch_context_manager_reused(writer): + def sink(_): + raise ValueError("Sink error") + + logger.remove() + logger.add(sink, catch=False) + + catcher = logger.catch(reraise=False) + + class Foo: + def __repr__(self): + with catcher: + raise ValueError("Something went wrong") + + foo = Foo() + + with pytest.raises(ValueError, match="^Sink error$"): + repr(foo) + + logger.remove() + logger.add(writer) + + with catcher: + raise ValueError("Error") + + assert writer.read().endswith("ValueError: Error\n") + + +def test_unprintable_but_decorated_repr_multiple_threads(writer): + wait_for_repr_block = threading.Event() + wait_for_worker_finish = threading.Event() + + recursive = False + + class Foo: + @logger.catch(reraise=True) + def __repr__(self): + nonlocal recursive + if not recursive: + recursive = True + else: + wait_for_repr_block.set() + wait_for_worker_finish.wait() + raise ValueError("Something went wrong") + + def worker(): + wait_for_repr_block.wait() + with logger.catch(reraise=False): + raise ValueError("Worker error") + wait_for_worker_finish.set() + + logger.add(writer, backtrace=True, diagnose=True, colorize=False, format="", catch=False) + + thread = threading.Thread(target=worker) + thread.start() + + foo = Foo() + + with pytest.raises(ValueError, match="^Something went wrong$"): + repr(foo) + + thread.join() + + assert "ValueError: Worker error\n" in writer.read() + assert writer.read().endswith("ValueError: Something went wrong\n") diff --git a/tests/test_exceptions_formatting.py b/tests/test_exceptions_formatting.py index 0cb8305a..802cabb5 100644 --- a/tests/test_exceptions_formatting.py +++ b/tests/test_exceptions_formatting.py @@ -202,6 +202,7 @@ def test_exception_ownership(filename): "filename", [ "assertionerror_without_traceback", + "broken_but_decorated_repr", "catch_as_context_manager", "catch_as_decorator_with_parentheses", "catch_as_decorator_without_parentheses", From 65fe4a8db9a8f297ae3648f51b5e3050b30945a9 Mon Sep 17 00:00:00 2001 From: Delgan <4193924+Delgan@users.noreply.github.com> Date: Sat, 23 Nov 2024 19:57:15 +0100 Subject: [PATCH 11/19] Improve type hints of handlers (in "add()" and "configure()") (#1238) --- loguru/__init__.pyi | 66 ++++++++++++++++++++++++++------ tests/typesafety/test_logger.yml | 34 ++++++++++++++-- 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/loguru/__init__.pyi b/loguru/__init__.pyi index bdecac2a..85f6fc7c 100644 --- a/loguru/__init__.pyi +++ b/loguru/__init__.pyi @@ -194,7 +194,7 @@ class Record(TypedDict): line: int message: str module: str - name: Union[str, None] + name: Optional[str] process: RecordProcess thread: RecordThread time: datetime @@ -205,7 +205,7 @@ class Message(str): class Writable(Protocol): def write(self, message: Message) -> None: ... -FilterDict = Dict[Union[str, None], Union[str, int, bool]] +FilterDict = Dict[Optional[str], Union[str, int, bool]] FilterFunction = Callable[[Record], bool] FormatFunction = Callable[[Record], str] PatcherFunction = Callable[[Record], None] @@ -213,9 +213,10 @@ RotationFunction = Callable[[Message, TextIO], bool] RetentionFunction = Callable[[List[str]], None] CompressionFunction = Callable[[str], None] -# Actually unusable because TypedDict can't allow extra keys: python/mypy#4617 -class _HandlerConfig(TypedDict, total=False): - sink: Union[str, PathLikeStr, TextIO, Writable, Callable[[Message], None], Handler] +StandardOpener = Callable[[str, int], int] + +class BasicHandlerConfig(TypedDict, total=False): + sink: Union[TextIO, Writable, Callable[[Message], None], Handler] level: Union[str, int] format: Union[str, FormatFunction] filter: Optional[Union[str, FilterFunction, FilterDict]] @@ -226,13 +227,53 @@ class _HandlerConfig(TypedDict, total=False): enqueue: bool catch: bool +class FileHandlerConfig(TypedDict, total=False): + sink: Union[str, PathLikeStr] + level: Union[str, int] + format: Union[str, FormatFunction] + filter: Optional[Union[str, FilterFunction, FilterDict]] + colorize: Optional[bool] + serialize: bool + backtrace: bool + diagnose: bool + enqueue: bool + catch: bool + rotation: Optional[Union[str, int, time, timedelta, RotationFunction]] + retention: Optional[Union[str, int, timedelta, RetentionFunction]] + compression: Optional[Union[str, CompressionFunction]] + delay: bool + watch: bool + mode: str + buffering: int + encoding: str + errors: Optional[str] + newline: Optional[str] + closefd: bool + opener: Optional[StandardOpener] + +class AsyncHandlerConfig(TypedDict, total=False): + sink: Callable[[Message], Awaitable[None]] + level: Union[str, int] + format: Union[str, FormatFunction] + filter: Optional[Union[str, FilterFunction, FilterDict]] + colorize: Optional[bool] + serialize: bool + backtrace: bool + diagnose: bool + enqueue: bool + catch: bool + context: Optional[Union[str, BaseContext]] + loop: Optional[AbstractEventLoop] + +HandlerConfig = Union[BasicHandlerConfig, FileHandlerConfig, AsyncHandlerConfig] + class LevelConfig(TypedDict, total=False): name: str no: int color: str icon: str -ActivationConfig = Tuple[Union[str, None], bool] +ActivationConfig = Tuple[Optional[str], bool] class Logger: @overload @@ -264,8 +305,8 @@ class Logger: backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., - context: Optional[Union[str, BaseContext]] = ..., catch: bool = ..., + context: Optional[Union[str, BaseContext]] = ..., loop: Optional[AbstractEventLoop] = ... ) -> int: ... @overload @@ -291,7 +332,10 @@ class Logger: mode: str = ..., buffering: int = ..., encoding: str = ..., - **kwargs: Any + errors: Optional[str] = ..., + newline: Optional[str] = ..., + closefd: bool = ..., + opener: Optional[StandardOpener] = ..., ) -> int: ... def remove(self, handler_id: Optional[int] = ...) -> None: ... def complete(self) -> AwaitableCompleter: ... @@ -338,12 +382,12 @@ class Logger: color: Optional[str] = ..., icon: Optional[str] = ..., ) -> Level: ... - def disable(self, name: Union[str, None]) -> None: ... - def enable(self, name: Union[str, None]) -> None: ... + def disable(self, name: Optional[str]) -> None: ... + def enable(self, name: Optional[str]) -> None: ... def configure( self, *, - handlers: Sequence[Dict[str, Any]] = ..., + handlers: Optional[Sequence[HandlerConfig]] = ..., levels: Optional[Sequence[LevelConfig]] = ..., extra: Optional[Dict[Any, Any]] = ..., patcher: Optional[PatcherFunction] = ..., diff --git a/tests/typesafety/test_logger.yml b/tests/typesafety/test_logger.yml index 8e4ae136..5d02229a 100644 --- a/tests/typesafety/test_logger.yml +++ b/tests/typesafety/test_logger.yml @@ -106,6 +106,7 @@ pass logger.add( sink, + context="fork", loop=asyncio.get_event_loop(), ) @@ -266,6 +267,7 @@ logger.disable("foo") - case: configure + skip: sys.version_info < (3, 7) # Old Mypy bug: Union of TypedDict not fully supported. main: | from loguru import logger import sys @@ -279,6 +281,28 @@ out: | main:9: note: Revealed type is "builtins.list[builtins.int]" +- case: configure_stream_handler + skip: sys.version_info < (3, 7) # Old Mypy bug: Union of TypedDict not fully supported. + main: | + from loguru import logger + import sys + logger.configure(handlers=[{"sink": sys.stderr, "colorize": False}]) + + +- case: configure_file_handler + main: | + from loguru import logger + logger.configure(handlers=[{"sink": "file.log", "mode": "w", "closefd": False}]) + + +- case: configure_coroutine_handler + main: | + import loguru + from loguru import logger + async def sink(m: loguru.Message) -> None: + pass + logger.configure(handlers=[{"sink": sink, "context": "spawn", "loop": None}]) + - case: parse main: | from loguru import logger @@ -300,8 +324,8 @@ main:2: error: No overload variant of "add" of "Logger" matches argument types "Callable[[Any], None]", "int" main:2: note: Possible overload variants: main:2: note: def add(self, sink: Union[TextIO, Writable, Callable[[Message], None], Handler], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ...) -> int - main:2: note: def add(self, sink: Callable[[Message], Awaitable[None]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ..., loop: Optional[AbstractEventLoop] = ...) -> int - main:2: note: def add(self, sink: Union[str, PathLike[str]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ..., rotation: Union[str, int, time, timedelta, Callable[[Message, TextIO], bool], None] = ..., retention: Union[str, int, timedelta, Callable[[List[str]], None], None] = ..., compression: Union[str, Callable[[str], None], None] = ..., delay: bool = ..., watch: bool = ..., mode: str = ..., buffering: int = ..., encoding: str = ..., **kwargs: Any) -> int + main:2: note: def add(self, sink: Callable[[Message], Awaitable[None]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., catch: bool = ..., context: Union[str, BaseContext, None] = ..., loop: Optional[AbstractEventLoop] = ...) -> int + main:2: note: def add(self, sink: Union[str, PathLike[str]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ..., rotation: Union[str, int, time, timedelta, Callable[[Message, TextIO], bool], None] = ..., retention: Union[str, int, timedelta, Callable[[List[str]], None], None] = ..., compression: Union[str, Callable[[str], None], None] = ..., delay: bool = ..., watch: bool = ..., mode: str = ..., buffering: int = ..., encoding: str = ..., errors: Optional[str] = ..., newline: Optional[str] = ..., closefd: bool = ..., opener: Optional[Callable[[str, int], int]] = ...) -> int mypy_config: | show_error_codes = false force_uppercase_builtins = true @@ -316,8 +340,8 @@ main:2: error: No overload variant of "add" of "Logger" matches argument types "Callable[[Any], None]", "int" main:2: note: Possible overload variants: main:2: note: def add(self, sink: Union[TextIO, Writable, Callable[[Message], None], Handler], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ...) -> int - main:2: note: def add(self, sink: Callable[[Message], Awaitable[None]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ..., loop: Optional[AbstractEventLoop] = ...) -> int - main:2: note: def add(self, sink: Union[str, PathLike[str]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ..., rotation: Union[str, int, time, timedelta, Callable[[Message, TextIO], bool], None] = ..., retention: Union[str, int, timedelta, Callable[[List[str]], None], None] = ..., compression: Union[str, Callable[[str], None], None] = ..., delay: bool = ..., watch: bool = ..., mode: str = ..., buffering: int = ..., encoding: str = ..., **kwargs: Any) -> int + main:2: note: def add(self, sink: Callable[[Message], Awaitable[None]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., catch: bool = ..., context: Union[str, BaseContext, None] = ..., loop: Optional[AbstractEventLoop] = ...) -> int + main:2: note: def add(self, sink: Union[str, PathLike[str]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ..., rotation: Union[str, int, time, timedelta, Callable[[Message, TextIO], bool], None] = ..., retention: Union[str, int, timedelta, Callable[[List[str]], None], None] = ..., compression: Union[str, Callable[[str], None], None] = ..., delay: bool = ..., watch: bool = ..., mode: str = ..., buffering: int = ..., encoding: str = ..., errors: Optional[str] = ..., newline: Optional[str] = ..., closefd: bool = ..., opener: Optional[Callable[[str, int], int]] = ...) -> int mypy_config: | show_error_codes = false @@ -359,6 +383,7 @@ extra=[1], ) out: | + main:3: error: List item 0 has incompatible type "Dict[str, str]"; expected "Union[BasicHandlerConfig, FileHandlerConfig, AsyncHandlerConfig]" main:4: error: Extra key "baz" for TypedDict "LevelConfig" main:5: error: Argument "patcher" to "configure" of "Logger" has incompatible type "int"; expected "Optional[Callable[[Record], None]]" main:6: error: List item 0 has incompatible type "Dict[str, str]"; expected "Tuple[Optional[str], bool]" @@ -380,6 +405,7 @@ extra=[1], ) out: | + main:3: error: List item 0 has incompatible type "Dict[str, str]"; expected "Union[BasicHandlerConfig, FileHandlerConfig, AsyncHandlerConfig]" main:4: error: Extra key "baz" for TypedDict "LevelConfig" main:5: error: Argument "patcher" to "configure" of "Logger" has incompatible type "int"; expected "Optional[Callable[[Record], None]]" main:6: error: List item 0 has incompatible type "Dict[str, str]"; expected "Tuple[Optional[str], bool]" From a486a6c00459ff9ece2cc3141646763f8e4c362a Mon Sep 17 00:00:00 2001 From: Delgan <4193924+Delgan@users.noreply.github.com> Date: Sun, 24 Nov 2024 14:04:07 +0100 Subject: [PATCH 12/19] Fix repeated line not detected during exception formatting (#1239) --- loguru/_better_exceptions.py | 38 +- .../output/others/one_liner_recursion.txt | 79 +++ .../output/others/recursion_error.txt | 57 ++ .../output/others/repeated_lines.txt | 504 ++++++++++++++++++ .../source/others/one_liner_recursion.py | 16 + .../source/others/recursion_error.py | 21 + .../source/others/repeated_lines.py | 24 + tests/test_exceptions_formatting.py | 6 + 8 files changed, 739 insertions(+), 6 deletions(-) create mode 100644 tests/exceptions/output/others/one_liner_recursion.txt create mode 100644 tests/exceptions/output/others/recursion_error.txt create mode 100644 tests/exceptions/output/others/repeated_lines.txt create mode 100644 tests/exceptions/source/others/one_liner_recursion.py create mode 100644 tests/exceptions/source/others/recursion_error.py create mode 100644 tests/exceptions/source/others/repeated_lines.py diff --git a/loguru/_better_exceptions.py b/loguru/_better_exceptions.py index e9ee1124..17d36d61 100644 --- a/loguru/_better_exceptions.py +++ b/loguru/_better_exceptions.py @@ -534,13 +534,39 @@ def _format_exception( yield from self._indent("-" * 35, group_nesting + 1, prefix="+-") def _format_list(self, frames): - result = [] - for filename, lineno, name, line in frames: - row = [] - row.append(' File "{}", line {}, in {}\n'.format(filename, lineno, name)) + + def source_message(filename, lineno, name, line): + message = ' File "%s", line %d, in %s\n' % (filename, lineno, name) if line: - row.append(" {}\n".format(line.strip())) - result.append("".join(row)) + message += " %s\n" % line.strip() + return message + + def skip_message(count): + plural = "s" if count > 1 else "" + return " [Previous line repeated %d more time%s]\n" % (count, plural) + + result = [] + count = 0 + last_source = None + + for *source, line in frames: + if source != last_source and count > 3: + result.append(skip_message(count - 3)) + + if source == last_source: + count += 1 + if count > 3: + continue + else: + count = 1 + + result.append(source_message(*source, line)) + last_source = source + + # Add a final skip message if the iteration of frames ended mid-repetition. + if count > 3: + result.append(skip_message(count - 3)) + return result def format_exception(self, type_, value, tb, *, from_decorator=False): diff --git a/tests/exceptions/output/others/one_liner_recursion.txt b/tests/exceptions/output/others/one_liner_recursion.txt new file mode 100644 index 00000000..635d3a23 --- /dev/null +++ b/tests/exceptions/output/others/one_liner_recursion.txt @@ -0,0 +1,79 @@ + +Traceback (most recent call last): + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + [Previous line repeated 8 more times] +ZeroDivisionError: division by zero + +Traceback (most recent call last): +> File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + [Previous line repeated 8 more times] +ZeroDivisionError: division by zero + +Traceback (most recent call last): + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in  + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in  + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in  + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in  + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + [Previous line repeated 8 more times] +ZeroDivisionError: division by zero + +Traceback (most recent call last): + + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + │ └ at 0xDEADBEEF> + └ at 0xDEADBEEF> + + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + │ │ │ │ │ │ │ └ at 0xDEADBEEF> + │ │ │ │ │ │ └ at 0xDEADBEEF> + │ │ │ │ │ └ 10 + │ │ │ │ └ at 0xDEADBEEF> + │ │ │ └ at 0xDEADBEEF> + │ │ └ 10 + │ └ 10 + └ at 0xDEADBEEF> + + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + │ │ │ │ │ │ │ └ at 0xDEADBEEF> + │ │ │ │ │ │ └ at 0xDEADBEEF> + │ │ │ │ │ └ 9 + │ │ │ │ └ at 0xDEADBEEF> + │ │ │ └ at 0xDEADBEEF> + │ │ └ 9 + │ └ 9 + └ at 0xDEADBEEF> + + File "tests/exceptions/source/others/one_liner_recursion.py", line 14, in + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) + │ │ │ │ │ │ │ └ at 0xDEADBEEF> + │ │ │ │ │ │ └ at 0xDEADBEEF> + │ │ │ │ │ └ 8 + │ │ │ │ └ at 0xDEADBEEF> + │ │ │ └ at 0xDEADBEEF> + │ │ └ 8 + │ └ 8 + └ at 0xDEADBEEF> + [Previous line repeated 8 more times] + +ZeroDivisionError: division by zero diff --git a/tests/exceptions/output/others/recursion_error.txt b/tests/exceptions/output/others/recursion_error.txt new file mode 100644 index 00000000..56219c1b --- /dev/null +++ b/tests/exceptions/output/others/recursion_error.txt @@ -0,0 +1,57 @@ + +Traceback (most recent call last): + File "tests/exceptions/source/others/recursion_error.py", line 19, in + recursive() + File "tests/exceptions/source/others/recursion_error.py", line 15, in recursive + recursive() + File "tests/exceptions/source/others/recursion_error.py", line 15, in recursive + recursive() + File "tests/exceptions/source/others/recursion_error.py", line 15, in recursive + recursive() + [Previous line repeated 996 more times] +RecursionError: maximum recursion depth exceeded + +Traceback (most recent call last): +> File "tests/exceptions/source/others/recursion_error.py", line 19, in + recursive() + File "tests/exceptions/source/others/recursion_error.py", line 15, in recursive + recursive() + File "tests/exceptions/source/others/recursion_error.py", line 15, in recursive + recursive() + File "tests/exceptions/source/others/recursion_error.py", line 15, in recursive + recursive() + [Previous line repeated 996 more times] +RecursionError: maximum recursion depth exceeded + +Traceback (most recent call last): + File "tests/exceptions/source/others/recursion_error.py", line 19, in  + recursive() + File "tests/exceptions/source/others/recursion_error.py", line 15, in recursive + recursive() + File "tests/exceptions/source/others/recursion_error.py", line 15, in recursive + recursive() + File "tests/exceptions/source/others/recursion_error.py", line 15, in recursive + recursive() + [Previous line repeated 996 more times] +RecursionError: maximum recursion depth exceeded + +Traceback (most recent call last): + + File "tests/exceptions/source/others/recursion_error.py", line 19, in + recursive() + └ + + File "tests/exceptions/source/others/recursion_error.py", line 15, in recursive + recursive() + └ + + File "tests/exceptions/source/others/recursion_error.py", line 15, in recursive + recursive() + └ + + File "tests/exceptions/source/others/recursion_error.py", line 15, in recursive + recursive() + └ + [Previous line repeated 996 more times] + +RecursionError: maximum recursion depth exceeded diff --git a/tests/exceptions/output/others/repeated_lines.txt b/tests/exceptions/output/others/repeated_lines.txt new file mode 100644 index 00000000..57ed102e --- /dev/null +++ b/tests/exceptions/output/others/repeated_lines.txt @@ -0,0 +1,504 @@ + +Traceback (most recent call last): + File "tests/exceptions/source/others/repeated_lines.py", line 22, in + recursive(10, 10) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 7 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 6 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 5 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 4 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 3 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 2 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 1 more time] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 15, in recursive + raise ValueError("End of recursion") +ValueError: End of recursion + +Traceback (most recent call last): +> File "tests/exceptions/source/others/repeated_lines.py", line 22, in + recursive(10, 10) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 7 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 6 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 5 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 4 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 3 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 2 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 1 more time] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 15, in recursive + raise ValueError("End of recursion") +ValueError: End of recursion + +Traceback (most recent call last): + File "tests/exceptions/source/others/repeated_lines.py", line 22, in  + recursive(10, 10) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 7 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 6 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 5 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 4 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 3 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 2 more times] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + [Previous line repeated 1 more time] + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + File "tests/exceptions/source/others/repeated_lines.py", line 15, in recursive + raise ValueError("End of recursion") +ValueError: End of recursion + +Traceback (most recent call last): + + File "tests/exceptions/source/others/repeated_lines.py", line 22, in + recursive(10, 10) + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 10 + │ └ 10 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 9 + │ └ 10 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 8 + │ └ 10 + └ + [Previous line repeated 7 more times] + + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + │ │ └ 10 + │ └ 10 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 9 + │ └ 9 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 8 + │ └ 9 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 7 + │ └ 9 + └ + [Previous line repeated 6 more times] + + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + │ │ └ 9 + │ └ 9 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 8 + │ └ 8 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 7 + │ └ 8 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 6 + │ └ 8 + └ + [Previous line repeated 5 more times] + + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + │ │ └ 8 + │ └ 8 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 7 + │ └ 7 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 6 + │ └ 7 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 5 + │ └ 7 + └ + [Previous line repeated 4 more times] + + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + │ │ └ 7 + │ └ 7 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 6 + │ └ 6 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 5 + │ └ 6 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 4 + │ └ 6 + └ + [Previous line repeated 3 more times] + + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + │ │ └ 6 + │ └ 6 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 5 + │ └ 5 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 4 + │ └ 5 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 3 + │ └ 5 + └ + [Previous line repeated 2 more times] + + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + │ │ └ 5 + │ └ 5 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 4 + │ └ 4 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 3 + │ └ 4 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 2 + │ └ 4 + └ + [Previous line repeated 1 more time] + + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + │ │ └ 4 + │ └ 4 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 3 + │ └ 3 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 2 + │ └ 3 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 1 + │ └ 3 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + │ │ └ 3 + │ └ 3 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 2 + │ └ 2 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 1 + │ └ 2 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + │ │ └ 2 + │ └ 2 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 18, in recursive + recursive(outer=outer, inner=inner - 1) + │ │ └ 1 + │ └ 1 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 17, in recursive + recursive(outer=outer - 1, inner=outer - 1) + │ │ └ 1 + │ └ 1 + └ + + File "tests/exceptions/source/others/repeated_lines.py", line 15, in recursive + raise ValueError("End of recursion") + +ValueError: End of recursion diff --git a/tests/exceptions/source/others/one_liner_recursion.py b/tests/exceptions/source/others/one_liner_recursion.py new file mode 100644 index 00000000..91f29c02 --- /dev/null +++ b/tests/exceptions/source/others/one_liner_recursion.py @@ -0,0 +1,16 @@ +# fmt: off +from loguru import logger + +import sys + + +logger.remove() +logger.add(sys.stderr, format="", diagnose=False, backtrace=False, colorize=False) +logger.add(sys.stderr, format="", diagnose=False, backtrace=True, colorize=False) +logger.add(sys.stderr, format="", diagnose=False, backtrace=False, colorize=True) +logger.add(sys.stderr, format="", diagnose=True, backtrace=False, colorize=False) + +try: + rec = lambda r, i: 1 / 0 if i == 0 else r(r, i - 1); rec(rec, 10) +except Exception: + logger.exception("Error") diff --git a/tests/exceptions/source/others/recursion_error.py b/tests/exceptions/source/others/recursion_error.py new file mode 100644 index 00000000..15e0fea9 --- /dev/null +++ b/tests/exceptions/source/others/recursion_error.py @@ -0,0 +1,21 @@ +from loguru import logger + +import sys + +sys.setrecursionlimit(1000) + +logger.remove() +logger.add(sys.stderr, format="", diagnose=False, backtrace=False, colorize=False) +logger.add(sys.stderr, format="", diagnose=False, backtrace=True, colorize=False) +logger.add(sys.stderr, format="", diagnose=False, backtrace=False, colorize=True) +logger.add(sys.stderr, format="", diagnose=True, backtrace=False, colorize=False) + + +def recursive(): + recursive() + + +try: + recursive() +except Exception: + logger.exception("Oups") diff --git a/tests/exceptions/source/others/repeated_lines.py b/tests/exceptions/source/others/repeated_lines.py new file mode 100644 index 00000000..404ecf6e --- /dev/null +++ b/tests/exceptions/source/others/repeated_lines.py @@ -0,0 +1,24 @@ +from loguru import logger + +import sys + + +logger.remove() +logger.add(sys.stderr, format="", diagnose=False, backtrace=False, colorize=False) +logger.add(sys.stderr, format="", diagnose=False, backtrace=True, colorize=False) +logger.add(sys.stderr, format="", diagnose=False, backtrace=False, colorize=True) +logger.add(sys.stderr, format="", diagnose=True, backtrace=False, colorize=False) + + +def recursive(outer, inner): + if outer == 0: + raise ValueError("End of recursion") + if inner == 0: + recursive(outer=outer - 1, inner=outer - 1) + recursive(outer=outer, inner=inner - 1) + + +try: + recursive(10, 10) +except Exception: + logger.exception("Oups") diff --git a/tests/test_exceptions_formatting.py b/tests/test_exceptions_formatting.py index 802cabb5..47e05cff 100644 --- a/tests/test_exceptions_formatting.py +++ b/tests/test_exceptions_formatting.py @@ -219,6 +219,9 @@ def test_exception_ownership(filename): "message_formatting_with_context_manager", "message_formatting_with_decorator", "nested_with_reraise", + "one_liner_recursion", + "recursion_error", + "repeated_lines", "syntaxerror_without_traceback", "sys_tracebacklimit", "sys_tracebacklimit_negative", @@ -228,6 +231,9 @@ def test_exception_ownership(filename): ], ) def test_exception_others(filename): + if filename == "recursion_error" and platform.python_implementation() == "PyPy": + pytest.skip("RecursionError is not reliable on PyPy") + compare_exception("others", filename) From 296af975d8548883bd0fa081be9e228ed426382e Mon Sep 17 00:00:00 2001 From: Delgan Date: Sun, 24 Nov 2024 14:17:15 +0100 Subject: [PATCH 13/19] Remove type hints docstrings from the stub file (moved to docs) This was considered as bad practice, see PYI021 (Ruff rule). Type hints are now documented in a dedicated reST page. This has the advantage of simplifying the build process, since the "autodoc_stub_file" custom extension can be removed. --- docs/_extensions/autodoc_stub_file.py | 41 ------------ docs/api/type_hints.rst | 87 +++++++++++++++++++++++++- docs/conf.py | 2 - loguru/__init__.pyi | 90 --------------------------- 4 files changed, 86 insertions(+), 134 deletions(-) delete mode 100644 docs/_extensions/autodoc_stub_file.py diff --git a/docs/_extensions/autodoc_stub_file.py b/docs/_extensions/autodoc_stub_file.py deleted file mode 100644 index d9937f13..00000000 --- a/docs/_extensions/autodoc_stub_file.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Small Sphinx extension intended to generate documentation for stub files. - -It retrieves only the docstrings of "loguru/__init__.pyi", hence avoiding possible errors (caused by -missing imports or forward references). The stub file is loaded as a dummy module which contains -only the top-level docstring. All the formatting can therefore be handled by the "autodoc" -extension, which permits cross-reference. - -The docstring of the stub file should list the available type hints and add short explanation of -their usage. - -Warning: for some reason, the docs NEEDS to be re-generated for changes in the stub file to be taken -into account: ``make clean && make html``. -""" - -import os -import sys -import types - - -def _get_module_docstring(filepath): - with open(filepath) as file: - source = file.read() - - co = compile(source, filepath, "exec") - - if co.co_consts and isinstance(co.co_consts[0], str): - docstring = co.co_consts[0] - else: - docstring = None - - return docstring - - -def setup(app): - """Configure the Sphinx plugin.""" - module_name = "autodoc_stub_file.loguru" - dirname = os.path.dirname(os.path.abspath(__file__)) - stub_path = os.path.join(dirname, "..", "..", "loguru", "__init__.pyi") - docstring = _get_module_docstring(stub_path) - module = types.ModuleType(module_name, docstring) - sys.modules[module_name] = module diff --git a/docs/api/type_hints.rst b/docs/api/type_hints.rst index be12710c..97739cff 100644 --- a/docs/api/type_hints.rst +++ b/docs/api/type_hints.rst @@ -3,7 +3,92 @@ Type hints ========== -.. automodule:: autodoc_stub_file.loguru +.. |str| replace:: :class:`str` +.. |namedtuple| replace:: :func:`namedtuple` +.. |dict| replace:: :class:`dict` + +.. |Logger| replace:: :class:`~loguru._logger.Logger` +.. |catch| replace:: :meth:`~loguru._logger.Logger.catch()` +.. |contextualize| replace:: :meth:`~loguru._logger.Logger.contextualize()` +.. |complete| replace:: :meth:`~loguru._logger.Logger.complete()` +.. |bind| replace:: :meth:`~loguru._logger.Logger.bind()` +.. |patch| replace:: :meth:`~loguru._logger.Logger.patch()` +.. |opt| replace:: :meth:`~loguru._logger.Logger.opt()` +.. |level| replace:: :meth:`~loguru._logger.Logger.level()` + +.. _stub file: https://www.python.org/dev/peps/pep-0484/#stub-files +.. _string literals: https://www.python.org/dev/peps/pep-0484/#forward-references +.. _postponed evaluation of annotations: https://www.python.org/dev/peps/pep-0563/ +.. |future| replace:: ``__future__`` +.. _future: https://www.python.org/dev/peps/pep-0563/#enabling-the-future-behavior-in-python-3-7 +.. |loguru-mypy| replace:: ``loguru-mypy`` +.. _loguru-mypy: https://github.com/kornicameister/loguru-mypy +.. |documentation of loguru-mypy| replace:: documentation of ``loguru-mypy`` +.. _documentation of loguru-mypy: + https://github.com/kornicameister/loguru-mypy/blob/master/README.md +.. _@kornicameister: https://github.com/kornicameister + +Loguru relies on a `stub file`_ to document its types. This implies that these types are not +accessible during execution of your program, however they can be used by type checkers and IDE. +Also, this means that your Python interpreter has to support `postponed evaluation of annotations`_ +to prevent error at runtime. This is achieved with a |future|_ import in Python 3.7+ or by using +`string literals`_ for earlier versions. + +A basic usage example could look like this: + +.. code-block:: python + + from __future__ import annotations + + import loguru + from loguru import logger + + def good_sink(message: loguru.Message): + print("My name is", message.record["name"]) + + def bad_filter(record: loguru.Record): + return record["invalid"] + + logger.add(good_sink, filter=bad_filter) + + +.. code-block:: bash + + $ mypy test.py + test.py:8: error: TypedDict "Record" has no key 'invalid' + Found 1 error in 1 file (checked 1 source file) + +There are several internal types to which you can be exposed using Loguru's public API, they are +listed here and might be useful to type hint your code: + +- ``Logger``: the usual |logger| object (also returned by |opt|, |bind| and |patch|). +- ``Message``: the formatted logging message sent to the sinks (a |str| with ``record`` + attribute). +- ``Record``: the |dict| containing all contextual information of the logged message. +- ``Level``: the |namedtuple| returned by |level| (with ``name``, ``no``, ``color`` and ``icon`` + attributes). +- ``Catcher``: the context decorator returned by |catch|. +- ``Contextualizer``: the context decorator returned by |contextualize|. +- ``AwaitableCompleter``: the awaitable object returned by |complete|. +- ``RecordFile``: the ``record["file"]`` with ``name`` and ``path`` attributes. +- ``RecordLevel``: the ``record["level"]`` with ``name``, ``no`` and ``icon`` attributes. +- ``RecordThread``: the ``record["thread"]`` with ``id`` and ``name`` attributes. +- ``RecordProcess``: the ``record["process"]`` with ``id`` and ``name`` attributes. +- ``RecordException``: the ``record["exception"]`` with ``type``, ``value`` and ``traceback`` + attributes. + +If that is not enough, one can also use the |loguru-mypy|_ library developed by `@kornicameister`_. +Plugin can be installed separately using:: + + pip install loguru-mypy + +It helps to catch several possible runtime errors by performing additional checks like: + +- ``opt(lazy=True)`` loggers accepting only ``typing.Callable[[], typing.Any]`` arguments +- ``opt(record=True)`` loggers wrongly calling log handler like so ``logger.info(..., record={})`` +- and even more... + +For more details, go to official |documentation of loguru-mypy|_. See also: :ref:`type-hints-source`. diff --git a/docs/conf.py b/docs/conf.py index e4e551a4..19977a1f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,7 +14,6 @@ import sys sys.path.insert(0, os.path.abspath("..")) -sys.path.insert(0, os.path.abspath("_extensions")) # -- Project information ----------------------------------------------------- @@ -43,7 +42,6 @@ "sphinx.ext.napoleon", "sphinx.ext.viewcode", "sphinx.ext.intersphinx", - "autodoc_stub_file", ] # Add any paths that contain templates here, relative to this directory. diff --git a/loguru/__init__.pyi b/loguru/__init__.pyi index 85f6fc7c..401bddbf 100644 --- a/loguru/__init__.pyi +++ b/loguru/__init__.pyi @@ -1,93 +1,3 @@ -"""Type hints details of the `Loguru` library. - -Loguru relies on a `stub file`_ to document its types. This implies that these types are not -accessible during execution of your program, however they can be used by type checkers and IDE. -Also, this means that your Python interpreter has to support `postponed evaluation of annotations`_ -to prevent error at runtime. This is achieved with a |future|_ import in Python 3.7+ or by using -`string literals`_ for earlier versions. - -A basic usage example could look like this: - -.. code-block:: python - - from __future__ import annotations - - import loguru - from loguru import logger - - def good_sink(message: loguru.Message): - print("My name is", message.record["name"]) - - def bad_filter(record: loguru.Record): - return record["invalid"] - - logger.add(good_sink, filter=bad_filter) - - -.. code-block:: bash - - $ mypy test.py - test.py:8: error: TypedDict "Record" has no key 'invalid' - Found 1 error in 1 file (checked 1 source file) - -There are several internal types to which you can be exposed using Loguru's public API, they are -listed here and might be useful to type hint your code: - -- ``Logger``: the usual |logger| object (also returned by |opt|, |bind| and |patch|). -- ``Message``: the formatted logging message sent to the sinks (a |str| with ``record`` - attribute). -- ``Record``: the |dict| containing all contextual information of the logged message. -- ``Level``: the |namedtuple| returned by |level| (with ``name``, ``no``, ``color`` and ``icon`` - attributes). -- ``Catcher``: the context decorator returned by |catch|. -- ``Contextualizer``: the context decorator returned by |contextualize|. -- ``AwaitableCompleter``: the awaitable object returned by |complete|. -- ``RecordFile``: the ``record["file"]`` with ``name`` and ``path`` attributes. -- ``RecordLevel``: the ``record["level"]`` with ``name``, ``no`` and ``icon`` attributes. -- ``RecordThread``: the ``record["thread"]`` with ``id`` and ``name`` attributes. -- ``RecordProcess``: the ``record["process"]`` with ``id`` and ``name`` attributes. -- ``RecordException``: the ``record["exception"]`` with ``type``, ``value`` and ``traceback`` - attributes. - -If that is not enough, one can also use the |loguru-mypy|_ library developed by `@kornicameister`_. -Plugin can be installed separately using:: - - pip install loguru-mypy - -It helps to catch several possible runtime errors by performing additional checks like: - -- ``opt(lazy=True)`` loggers accepting only ``typing.Callable[[], typing.Any]`` arguments -- ``opt(record=True)`` loggers wrongly calling log handler like so ``logger.info(..., record={})`` -- and even more... - -For more details, go to official |documentation of loguru-mypy|_. - -.. |str| replace:: :class:`str` -.. |namedtuple| replace:: :func:`namedtuple` -.. |dict| replace:: :class:`dict` - -.. |Logger| replace:: :class:`~loguru._logger.Logger` -.. |catch| replace:: :meth:`~loguru._logger.Logger.catch()` -.. |contextualize| replace:: :meth:`~loguru._logger.Logger.contextualize()` -.. |complete| replace:: :meth:`~loguru._logger.Logger.complete()` -.. |bind| replace:: :meth:`~loguru._logger.Logger.bind()` -.. |patch| replace:: :meth:`~loguru._logger.Logger.patch()` -.. |opt| replace:: :meth:`~loguru._logger.Logger.opt()` -.. |level| replace:: :meth:`~loguru._logger.Logger.level()` - -.. _stub file: https://www.python.org/dev/peps/pep-0484/#stub-files -.. _string literals: https://www.python.org/dev/peps/pep-0484/#forward-references -.. _postponed evaluation of annotations: https://www.python.org/dev/peps/pep-0563/ -.. |future| replace:: ``__future__`` -.. _future: https://www.python.org/dev/peps/pep-0563/#enabling-the-future-behavior-in-python-3-7 -.. |loguru-mypy| replace:: ``loguru-mypy`` -.. _loguru-mypy: https://github.com/kornicameister/loguru-mypy -.. |documentation of loguru-mypy| replace:: documentation of ``loguru-mypy`` -.. _documentation of loguru-mypy: - https://github.com/kornicameister/loguru-mypy/blob/master/README.md -.. _@kornicameister: https://github.com/kornicameister -""" - import sys from asyncio import AbstractEventLoop from datetime import datetime, time, timedelta From 06e0d3adfed771a1c87fd0ca42f4b302dcdd5d5c Mon Sep 17 00:00:00 2001 From: Delgan Date: Sun, 24 Nov 2024 14:39:06 +0100 Subject: [PATCH 14/19] Enable "PYI" (flake8-pyi) linting rule of Ruff --- loguru/__init__.pyi | 3 +-- loguru/_logger.py | 2 +- loguru/_recattrs.py | 4 +++- pyproject.toml | 12 ++++++++++-- tests/conftest.py | 33 ++++++++++++++++----------------- 5 files changed, 31 insertions(+), 23 deletions(-) diff --git a/loguru/__init__.pyi b/loguru/__init__.pyi index 401bddbf..3a13ad71 100644 --- a/loguru/__init__.pyi +++ b/loguru/__init__.pyi @@ -25,7 +25,7 @@ from typing import ( overload, ) -if sys.version_info >= (3, 5, 3): +if sys.version_info >= (3, 6): from typing import Awaitable else: from typing_extensions import Awaitable @@ -69,7 +69,6 @@ class Level(NamedTuple): icon: str class _RecordAttribute: - def __repr__(self) -> str: ... def __format__(self, spec: str) -> str: ... class RecordFile(_RecordAttribute): diff --git a/loguru/_logger.py b/loguru/_logger.py index 80497397..5b2c3e24 100644 --- a/loguru/_logger.py +++ b/loguru/_logger.py @@ -118,7 +118,7 @@ from pathlib import PurePath as PathLike -Level = namedtuple("Level", ["name", "no", "color", "icon"]) +Level = namedtuple("Level", ["name", "no", "color", "icon"]) # noqa: PYI024 start_time = aware_now() diff --git a/loguru/_recattrs.py b/loguru/_recattrs.py index 26b5bec5..003d52cd 100644 --- a/loguru/_recattrs.py +++ b/loguru/_recattrs.py @@ -59,7 +59,9 @@ def __format__(self, spec): return self.id.__format__(spec) -class RecordException(namedtuple("RecordException", ("type", "value", "traceback"))): +class RecordException( + namedtuple("RecordException", ("type", "value", "traceback")) # noqa: PYI024 +): def __repr__(self): return "(type=%r, value=%r, traceback=%r)" % (self.type, self.value, self.traceback) diff --git a/pyproject.toml b/pyproject.toml index 2f34e11f..082c469e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,12 +99,20 @@ testpaths = ["tests"] exclude = ["tests/exceptions/source/*"] line-length = 100 +# Actually, we should target Python 3.5, but Ruff does not support it. +target-version = "py37" + [tool.ruff.lint] # See list of rules at: https://docs.astral.sh/ruff/rules/ -select = ["F", "E", "W", "I", "B", "N", "D", "PT", "RET", "RUF"] +select = ["F", "E", "W", "I", "B", "N", "D", "PT", "PYI", "RET", "RUF"] [tool.ruff.lint.per-file-ignores] -"tests/**" = ["D1"] # Do not require documentation for tests. +"tests/**" = [ + "D1", # Do not require documentation for tests. +] +"loguru/__init__.pyi" = [ + "PYI026", # TypeAlias is not supported by Mypy 0.910 (Python 3.5). +] [tool.ruff.lint.pycodestyle] max-doc-length = 100 diff --git a/tests/conftest.py b/tests/conftest.py index 4ca48ca1..f78d7d0d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ import time import traceback import warnings -from collections import namedtuple +from typing import NamedTuple import freezegun import pytest @@ -201,25 +201,24 @@ def fake_localtime(t=None): fix_struct = os.name == "nt" and sys.version_info < (3, 6) struct_time_attributes = [ - "tm_year", - "tm_mon", - "tm_mday", - "tm_hour", - "tm_min", - "tm_sec", - "tm_wday", - "tm_yday", - "tm_isdst", - "tm_zone", - "tm_gmtoff", + ("tm_year", int), + ("tm_mon", int), + ("tm_mday", int), + ("tm_hour", int), + ("tm_min", int), + ("tm_sec", int), + ("tm_wday", int), + ("tm_yday", int), + ("tm_isdst", int), + ("tm_zone", str), + ("tm_gmtoff", int), ] if not fakes["include_tm_zone"]: - struct_time_attributes.remove("tm_zone") - struct_time_attributes.remove("tm_gmtoff") - struct_time = namedtuple("struct_time", struct_time_attributes)._make + struct_time = NamedTuple("struct_time", struct_time_attributes)._make elif fix_struct: - struct_time = namedtuple("struct_time", struct_time_attributes)._make + struct_time_attributes = struct_time_attributes[:-2] + struct_time = NamedTuple("struct_time", struct_time_attributes)._make else: struct_time = time.struct_time @@ -227,7 +226,7 @@ def fake_localtime(t=None): override = {"tm_zone": fakes["zone"], "tm_gmtoff": fakes["offset"]} attributes = [] - for attribute in struct_time_attributes: + for attribute, _ in struct_time_attributes: if attribute in override: value = override[attribute] else: From 808a07ae0473fd222eb0ce7c559c42ace9409826 Mon Sep 17 00:00:00 2001 From: Delgan Date: Sun, 24 Nov 2024 14:50:28 +0100 Subject: [PATCH 15/19] Add stderr of failed Mypy command to tests output --- tests/test_type_hinting.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_type_hinting.py b/tests/test_type_hinting.py index 91e81195..4d452648 100644 --- a/tests/test_type_hinting.py +++ b/tests/test_type_hinting.py @@ -1,8 +1,11 @@ +import sys + import mypy.api def test_mypy_import(): # Check stub file is valid and can be imported by Mypy. # There exist others tests in "typesafety" subfolder but they aren't compatible with Python 3.5. - _, _, result = mypy.api.run(["--strict", "-c", "from loguru import logger"]) + out, _, result = mypy.api.run(["--strict", "-c", "from loguru import logger"]) + print("".join(out), file=sys.stderr) assert result == 0 From 7a7ec19061c7c41aac76a5d00a9f5f94e44245b2 Mon Sep 17 00:00:00 2001 From: Delgan Date: Sun, 24 Nov 2024 15:08:20 +0100 Subject: [PATCH 16/19] Remove unused types in one of the tested exceptions --- tests/exceptions/output/modern/positional_only_argument.txt | 6 +++--- tests/exceptions/source/modern/positional_only_argument.py | 5 ----- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/exceptions/output/modern/positional_only_argument.txt b/tests/exceptions/output/modern/positional_only_argument.txt index 522d2836..9820464a 100644 --- a/tests/exceptions/output/modern/positional_only_argument.txt +++ b/tests/exceptions/output/modern/positional_only_argument.txt @@ -1,15 +1,15 @@ Traceback (most recent call last): - File "tests/exceptions/source/modern/positional_only_argument.py", line 23, in  + File "tests/exceptions/source/modern/positional_only_argument.py", line 18, in  main() └  - File "tests/exceptions/source/modern/positional_only_argument.py", line 19, in main + File "tests/exceptions/source/modern/positional_only_argument.py", line 14, in main foo(1, 2, c=3) └  - File "tests/exceptions/source/modern/positional_only_argument.py", line 15, in foo + File "tests/exceptions/source/modern/positional_only_argument.py", line 10, in foo def foo(a, /, b, *, c, **d): 1 / 0  │ │ │ │ └ {}  │ │ │ └ 3 diff --git a/tests/exceptions/source/modern/positional_only_argument.py b/tests/exceptions/source/modern/positional_only_argument.py index 2bb5dc70..a1188137 100644 --- a/tests/exceptions/source/modern/positional_only_argument.py +++ b/tests/exceptions/source/modern/positional_only_argument.py @@ -1,6 +1,5 @@ # fmt: off import sys -from typing import TypeVar, Union from loguru import logger @@ -8,10 +7,6 @@ logger.add(sys.stderr, format="", colorize=True, backtrace=False, diagnose=True) -T = TypeVar("T") -Name = str - - def foo(a, /, b, *, c, **d): 1 / 0 From 28f5137f89ca832f0edb1a9ef654e7ac93172e2b Mon Sep 17 00:00:00 2001 From: Delgan Date: Sun, 24 Nov 2024 17:45:46 +0100 Subject: [PATCH 17/19] Fix CI warnings due to "allow-failure" being empty string --- .github/workflows/tests.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3b34fe4f..2b66cbe4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,15 +23,21 @@ jobs: - '3.12' - '3.13' - pypy-3.10 + allow-failure: + - false include: - os: ubuntu-20.04 python-version: '3.5' + allow-failure: false - os: ubuntu-20.04 python-version: '3.6' + allow-failure: false - os: windows-2022 python-version: '3.12' + allow-failure: false - os: macos-13 python-version: '3.12' + allow-failure: false - os: ubuntu-22.04 python-version: 3.14-dev allow-failure: true From a068c14ba6a5cdfb239b60e89e72262494ff653f Mon Sep 17 00:00:00 2001 From: Delgan Date: Sun, 24 Nov 2024 19:04:01 +0100 Subject: [PATCH 18/19] Remove duplication in the definition of "pytest-mypy-plugin" tests --- tests/conftest.py | 32 ++++++++++ tests/typesafety/test_logger.yml | 105 ++----------------------------- 2 files changed, 36 insertions(+), 101 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f78d7d0d..ae98e293 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,6 +47,38 @@ def tmp_path(tmp_path): return pathlib.Path(str(tmp_path)) +if sys.version_info >= (3, 6): + from pytest_mypy_plugins.item import YamlTestItem + + def _fix_positional_only_args(item: YamlTestItem): + """Remove forward-slash marker from the expected output for Python 3.6.""" + for output in item.expected_output: + output.message = output.message.replace(", /", "") + # Also patch the "severity" attribute because there is a parsing bug in the plugin. + output.severity = output.severity.replace(", /", "") + + def _add_mypy_config(item: YamlTestItem): + """Add some extra options to the mypy configuration for Python 3.7+.""" + item.additional_mypy_config += "\n".join( + [ + "show_error_codes = false", + "force_uppercase_builtins = true", + "force_union_syntax = true", + ] + ) + + def pytest_collection_modifyitems(config, items): + """Modify the tests to ensure they produce the same output regardless of Python version.""" + for item in items: + if not isinstance(item, YamlTestItem): + continue + + if sys.version_info >= (3, 7): + _add_mypy_config(item) + else: + _fix_positional_only_args(item) + + @contextlib.contextmanager def new_event_loop_context(): loop = asyncio.new_event_loop() diff --git a/tests/typesafety/test_logger.yml b/tests/typesafety/test_logger.yml index 5d02229a..319845fc 100644 --- a/tests/typesafety/test_logger.yml +++ b/tests/typesafety/test_logger.yml @@ -1,3 +1,7 @@ +# Note that tests defined in this file are slightly modified by "conftest.py". In particular, a +# specific Mypy configuration is applied to each test case to ensure that the output is consistent +# regardless of the Python version. + - case: basic_logger_usage parametrized: - method: trace @@ -195,19 +199,6 @@ main:5: note: Revealed type is "loguru.Contextualizer" - case: level_get - skip: sys.version_info < (3, 7) - main: | - from loguru import logger - import loguru - level = logger.level("INFO") - reveal_type(level) - out: | - main:4: note: Revealed type is "Tuple[builtins.str, builtins.int, builtins.str, builtins.str, fallback=loguru.Level]" - mypy_config: | - force_uppercase_builtins = true - -- case: level_get_pre37 - skip: not sys.version_info < (3, 7) main: | from loguru import logger import loguru @@ -217,19 +208,6 @@ main:4: note: Revealed type is "Tuple[builtins.str, builtins.int, builtins.str, builtins.str, fallback=loguru.Level]" - case: level_set - skip: sys.version_info < (3, 7) - main: | - from loguru import logger - import loguru - level = logger.level("FOO", no=11, icon="!", color="") - reveal_type(level) - out: | - main:4: note: Revealed type is "Tuple[builtins.str, builtins.int, builtins.str, builtins.str, fallback=loguru.Level]" - mypy_config: | - force_uppercase_builtins = true - -- case: level_set_pre37 - skip: not sys.version_info < (3, 7) main: | from loguru import logger import loguru @@ -239,19 +217,6 @@ main:4: note: Revealed type is "Tuple[builtins.str, builtins.int, builtins.str, builtins.str, fallback=loguru.Level]" - case: level_update - skip: sys.version_info < (3, 7) - main: | - from loguru import logger - import loguru - level = logger.level("INFO", color="") - reveal_type(level) - out: | - main:4: note: Revealed type is "Tuple[builtins.str, builtins.int, builtins.str, builtins.str, fallback=loguru.Level]" - mypy_config: | - force_uppercase_builtins = true - -- case: level_update_pre37 - skip: not sys.version_info < (3, 7) main: | from loguru import logger import loguru @@ -288,13 +253,11 @@ import sys logger.configure(handlers=[{"sink": sys.stderr, "colorize": False}]) - - case: configure_file_handler main: | from loguru import logger logger.configure(handlers=[{"sink": "file.log", "mode": "w", "closefd": False}]) - - case: configure_coroutine_handler main: | import loguru @@ -316,23 +279,6 @@ main:6: note: Revealed type is "builtins.dict[builtins.str, Any]" - case: invalid_add_argument - skip: sys.version_info < (3, 7) - main: | - from loguru import logger - logger.add(lambda m: None, foobar=123) - out: | - main:2: error: No overload variant of "add" of "Logger" matches argument types "Callable[[Any], None]", "int" - main:2: note: Possible overload variants: - main:2: note: def add(self, sink: Union[TextIO, Writable, Callable[[Message], None], Handler], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ...) -> int - main:2: note: def add(self, sink: Callable[[Message], Awaitable[None]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., catch: bool = ..., context: Union[str, BaseContext, None] = ..., loop: Optional[AbstractEventLoop] = ...) -> int - main:2: note: def add(self, sink: Union[str, PathLike[str]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ..., rotation: Union[str, int, time, timedelta, Callable[[Message, TextIO], bool], None] = ..., retention: Union[str, int, timedelta, Callable[[List[str]], None], None] = ..., compression: Union[str, Callable[[str], None], None] = ..., delay: bool = ..., watch: bool = ..., mode: str = ..., buffering: int = ..., encoding: str = ..., errors: Optional[str] = ..., newline: Optional[str] = ..., closefd: bool = ..., opener: Optional[Callable[[str, int], int]] = ...) -> int - mypy_config: | - show_error_codes = false - force_uppercase_builtins = true - force_union_syntax = true - -- case: invalid_add_argument_pre37 - skip: not sys.version_info < (3, 7) main: | from loguru import logger logger.add(lambda m: None, foobar=123) @@ -342,11 +288,8 @@ main:2: note: def add(self, sink: Union[TextIO, Writable, Callable[[Message], None], Handler], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ...) -> int main:2: note: def add(self, sink: Callable[[Message], Awaitable[None]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., catch: bool = ..., context: Union[str, BaseContext, None] = ..., loop: Optional[AbstractEventLoop] = ...) -> int main:2: note: def add(self, sink: Union[str, PathLike[str]], *, level: Union[str, int] = ..., format: Union[str, Callable[[Record], str]] = ..., filter: Union[str, Callable[[Record], bool], Dict[Optional[str], Union[str, int, bool]], None] = ..., colorize: Optional[bool] = ..., serialize: bool = ..., backtrace: bool = ..., diagnose: bool = ..., enqueue: bool = ..., context: Union[str, BaseContext, None] = ..., catch: bool = ..., rotation: Union[str, int, time, timedelta, Callable[[Message, TextIO], bool], None] = ..., retention: Union[str, int, timedelta, Callable[[List[str]], None], None] = ..., compression: Union[str, Callable[[str], None], None] = ..., delay: bool = ..., watch: bool = ..., mode: str = ..., buffering: int = ..., encoding: str = ..., errors: Optional[str] = ..., newline: Optional[str] = ..., closefd: bool = ..., opener: Optional[Callable[[str, int], int]] = ...) -> int - mypy_config: | - show_error_codes = false - case: invalid_logged_object_formatting - skip: sys.version_info < (3, 7) main: | from loguru import logger logger.info(123, foo=123) @@ -355,46 +298,8 @@ main:2: note: Possible overload variants: main:2: note: def info(__self, str, /, *args: Any, **kwargs: Any) -> None main:2: note: def info(__self, Any, /) -> None - mypy_config: | - show_error_codes = false - -- case: invalid_logged_object_formatting_pre37 - skip: not sys.version_info < (3, 7) - main: | - from loguru import logger - logger.info(123, foo=123) - out: | - main:2: error: No overload variant of "info" of "Logger" matches argument types "int", "int" - main:2: note: Possible overload variants: - main:2: note: def info(__self, str, *args: Any, **kwargs: Any) -> None - main:2: note: def info(__self, Any) -> None - mypy_config: | - show_error_codes = false - case: invalid_configuration - skip: sys.version_info < (3, 7) - main: | - from loguru import logger - logger.configure( - handlers=[{"x": "y"}], - levels=[{"baz": 1}], - patcher=123, - activation=[{"foo": "bar"}], - extra=[1], - ) - out: | - main:3: error: List item 0 has incompatible type "Dict[str, str]"; expected "Union[BasicHandlerConfig, FileHandlerConfig, AsyncHandlerConfig]" - main:4: error: Extra key "baz" for TypedDict "LevelConfig" - main:5: error: Argument "patcher" to "configure" of "Logger" has incompatible type "int"; expected "Optional[Callable[[Record], None]]" - main:6: error: List item 0 has incompatible type "Dict[str, str]"; expected "Tuple[Optional[str], bool]" - main:7: error: Argument "extra" to "configure" of "Logger" has incompatible type "List[int]"; expected "Optional[Dict[Any, Any]]" - mypy_config: | - show_error_codes = false - force_uppercase_builtins = true - force_union_syntax = true - -- case: invalid_configuration_pre37 - skip: not sys.version_info < (3, 7) main: | from loguru import logger logger.configure( @@ -410,5 +315,3 @@ main:5: error: Argument "patcher" to "configure" of "Logger" has incompatible type "int"; expected "Optional[Callable[[Record], None]]" main:6: error: List item 0 has incompatible type "Dict[str, str]"; expected "Tuple[Optional[str], bool]" main:7: error: Argument "extra" to "configure" of "Logger" has incompatible type "List[int]"; expected "Optional[Dict[Any, Any]]" - mypy_config: | - show_error_codes = false From 5f3c3e96878e9607c71497c6a68ea82746bc561b Mon Sep 17 00:00:00 2001 From: Delgan Date: Sun, 24 Nov 2024 20:18:04 +0100 Subject: [PATCH 19/19] Fix tests mistakenly misconfigured causing lower coverage --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index ae98e293..a688375d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -247,9 +247,9 @@ def fake_localtime(t=None): ] if not fakes["include_tm_zone"]: + struct_time_attributes = struct_time_attributes[:-2] struct_time = NamedTuple("struct_time", struct_time_attributes)._make elif fix_struct: - struct_time_attributes = struct_time_attributes[:-2] struct_time = NamedTuple("struct_time", struct_time_attributes)._make else: struct_time = time.struct_time