Skip to content

Commit 33ab52a

Browse files
committed
Improve suport for template strings
Notably: - Fix support for t-strings with `opt(colors=True)` - Handle nested t-strings properly - Implement formatting of t-string in tracebacks (new tokens) - Update the type hints stub to accept t-strings - Mention t-strings in documentation - Add many more unit tests in dedicated file
1 parent ea629e8 commit 33ab52a

11 files changed

Lines changed: 335 additions & 88 deletions

File tree

CHANGELOG.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
- Change the default log format to include the timezone offset since it produces less ambiguous logs (`#856 <https://github.com/Delgan/loguru/pull/856>`_, thanks `@tim-x-y-z <https://github.com/tim-x-y-z>`_).
55
- Add new ``logger.reinstall()`` method to automatically set up the ``logger`` in spawned child processes (`#818 <https://github.com/Delgan/loguru/issues/818>`_, thanks `@monchin <https://github.com/monchin>`_).
6+
- Add support for template strings used as log messages (`#1397 <https://github.com/Delgan/loguru/issues/1397>`_, thanks `@TurtleOrangina <https://github.com/TurtleOrangina>`_).
67
- Fix incorrect microsecond value when formatting the log timestamp using ``{time:x}`` (`#1440 <https://github.com/Delgan/loguru/issues/1440>`_).
78
- Fix hex color short code expansion (`#1426 <https://github.com/Delgan/loguru/issues/1426>`_, thanks `@turkoid <https://github.com/turkoid>`_).
89
- Fix possible internal error when dealing with (rare) frame objects missing a ``f_lineno`` value (`#1435 <https://github.com/Delgan/loguru/issues/1435>`_).
@@ -17,7 +18,6 @@
1718
- Make ``logger.catch()`` usable as an asynchronous context manager (`#1084 <https://github.com/Delgan/loguru/issues/1084>`_).
1819
- Make ``logger.catch()`` compatible with asynchronous generators (`#1302 <https://github.com/Delgan/loguru/issues/1302>`_).
1920
- Improve feedback for invalid format keys in logger format strings (`#1450 <https://github.com/Delgan/loguru/issues/1450>`_, thanks `@Krishnachaitanyakc <https://github.com/Krishnachaitanyakc>`_).
20-
- Support python-3.14 template strings as log messages. The comfort of f-string syntax combined with the performance of lazily evaluated formatting. (`#1397 <https://github.com/Delgan/loguru/issues/1302>`_).
2121

2222

2323
`0.7.3`_ (2024-12-06)

README.md

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
</a>
2121
</p>
2222

23-
---
23+
______________________________________________________________________
2424

2525
**Loguru** is a library which aims to bring enjoyable logging in Python.
2626

@@ -109,24 +109,17 @@ logger.add("file_Y.log", compression="zip") # Save some loved space
109109

110110
### Modern string formatting using braces style
111111

112-
For python-3.14 and higher, you can pass a template string, which will be evaluated lazily. The behavior is the same as with a f-string, but the performance is better: Messages that are never emitted won't pay the cost of evaluation.
112+
Loguru favors the much more elegant and powerful `{}` formatting over `%`, logging functions are actually equivalent to `str.format()`.
113113

114114
```python
115-
version = 3.14
116-
feature = "t-strings"
117-
logger.info(t"If you're using Python {version}, prefer {feature} of course!")
115+
logger.info("We discovered {} is the answer to {question}", 42, question="everything")
118116
```
119117

120-
Before python-3.14, you can still use lazily evaluated formatting and the elegant and powerful `{}` formatting: Use a str.format() style string and pass the parameters as additional arguments:
118+
With latest Python versions, you can also pass [a template string](https://docs.python.org/3/library/string.templatelib.html#template-strings), which will be evaluated lazily and is preferable over f-strings:
121119

122120
```python
123-
logger.info("If you're using Python {}, prefer {feature} of course!", 3.6, feature="str.format() style strings")
124-
```
125-
126-
Note that using f-strings in log messages is not considered best practice for performance reasons, as they are evaluated eagerly. Expensive conversion to string might occur even when in the end they are not needed:
127-
128-
```python
129-
logger.debug(f"obj = {large_obj}") # Do not do this, prefer the above methods!
121+
version, feature = 3.14, "t-strings"
122+
logger.debug(t"If you're using Python {version:f}, prefer {feature} of course!")
130123
```
131124

132125
### Exceptions catching within threads or main

loguru/__init__.pyi

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ if sys.version_info >= (3, 8):
4545
else:
4646
from typing_extensions import Protocol, TypedDict
4747

48+
if sys.version_info >= (3, 14):
49+
import string.templatelib
50+
51+
LoggedStr = Union[str, string.templatelib.Template]
52+
else:
53+
LoggedStr = str
54+
4855
_T = TypeVar("_T")
4956
_F = TypeVar("_F", bound=Callable[..., Any])
5057
ExcInfo = Tuple[Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType]]
@@ -276,7 +283,7 @@ class Logger:
276283
onerror: Optional[Callable[[BaseException], None]] = ...,
277284
exclude: Optional[Union[Type[BaseException], Tuple[Type[BaseException], ...]]] = ...,
278285
default: Any = ...,
279-
message: str = ...
286+
message: LoggedStr = ...
280287
) -> Catcher: ...
281288
@overload
282289
def catch(self, function: _F) -> _F: ...
@@ -344,40 +351,46 @@ class Logger:
344351
chunk: int = ...
345352
) -> Generator[Dict[str, Any], None, None]: ...
346353
@overload
347-
def trace(__self, __message: str, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
354+
def trace(__self, __message: LoggedStr, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
348355
@overload
349356
def trace(__self, __message: Any) -> None: ... # noqa: N805
350357
@overload
351-
def debug(__self, __message: str, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
358+
def debug(__self, __message: LoggedStr, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
352359
@overload
353360
def debug(__self, __message: Any) -> None: ... # noqa: N805
354361
@overload
355-
def info(__self, __message: str, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
362+
def info(__self, __message: LoggedStr, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
356363
@overload
357364
def info(__self, __message: Any) -> None: ... # noqa: N805
358365
@overload
359-
def success(__self, __message: str, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
366+
def success(__self, __message: LoggedStr, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
360367
@overload
361368
def success(__self, __message: Any) -> None: ... # noqa: N805
362369
@overload
363-
def warning(__self, __message: str, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
370+
def warning(__self, __message: LoggedStr, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
364371
@overload
365372
def warning(__self, __message: Any) -> None: ... # noqa: N805
366373
@overload
367-
def error(__self, __message: str, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
374+
def error(__self, __message: LoggedStr, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
368375
@overload
369376
def error(__self, __message: Any) -> None: ... # noqa: N805
370377
@overload
371-
def critical(__self, __message: str, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
378+
def critical(__self, __message: LoggedStr, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
372379
@overload
373380
def critical(__self, __message: Any) -> None: ... # noqa: N805
374381
@overload
375-
def exception(__self, __message: str, *args: Any, **kwargs: Any) -> None: ... # noqa: N805
382+
def exception(
383+
__self, __message: LoggedStr, *args: Any, **kwargs: Any # noqa: N805
384+
) -> None: ...
376385
@overload
377386
def exception(__self, __message: Any) -> None: ... # noqa: N805
378387
@overload
379388
def log(
380-
__self, __level: Union[int, str], __message: str, *args: Any, **kwargs: Any # noqa: N805
389+
__self, # noqa: N805
390+
__level: Union[int, str],
391+
__message: LoggedStr,
392+
*args: Any,
393+
**kwargs: Any
381394
) -> None: ...
382395
@overload
383396
def log(__self, __level: Union[int, str], __message: Any) -> None: ... # noqa: N805

loguru/_better_exceptions.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,21 @@ def is_exception_group(exc):
2929
return isinstance(exc, ExceptionGroup)
3030

3131

32+
def _get_string_tokens():
33+
strings = {tokenize.STRING}
34+
string_middles = set()
35+
36+
if sys.version_info >= (3, 12):
37+
strings |= {tokenize.FSTRING_START, tokenize.FSTRING_END, tokenize.FSTRING_MIDDLE}
38+
string_middles.add(tokenize.FSTRING_MIDDLE)
39+
40+
if sys.version_info >= (3, 14):
41+
strings |= {tokenize.TSTRING_START, tokenize.TSTRING_END, tokenize.TSTRING_MIDDLE}
42+
string_middles.add(tokenize.TSTRING_MIDDLE)
43+
44+
return frozenset(strings), frozenset(string_middles)
45+
46+
3247
class SyntaxHighlighter:
3348
_default_style = frozenset(
3449
{
@@ -49,14 +64,7 @@ class SyntaxHighlighter:
4964
_constants = frozenset({"True", "False", "None"})
5065
_punctuation = frozenset({"(", ")", "[", "]", "{", "}", ":", ",", ";"})
5166

52-
if sys.version_info >= (3, 12):
53-
_strings = frozenset(
54-
{tokenize.STRING, tokenize.FSTRING_START, tokenize.FSTRING_MIDDLE, tokenize.FSTRING_END}
55-
)
56-
_fstring_middle = tokenize.FSTRING_MIDDLE
57-
else:
58-
_strings = frozenset({tokenize.STRING})
59-
_fstring_middle = None
67+
_strings, _string_middles = _get_string_tokens()
6068

6169
def __init__(self, style=None):
6270
self._style = style or dict(self._default_style)
@@ -69,7 +77,7 @@ def highlight(self, source):
6977
for token in self.tokenize(source):
7078
type_, string, (start_row, start_column), (_, end_column), line = token
7179

72-
if type_ == self._fstring_middle:
80+
if type_ in self._string_middles:
7381
# When an f-string contains "{{" or "}}", they appear as "{" or "}" in the "string"
7482
# attribute of the token. However, they do not count in the column position.
7583
end_column += string.count("{") + string.count("}")

loguru/_logger.py

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,16 @@ def isasyncgenfunction(func):
136136
return False
137137

138138

139-
try:
140-
from string.templatelib import Interpolation as _Interpolation
141-
from string.templatelib import Template as _Template
142-
from string.templatelib import convert as _tmpl_convert
143-
except ImportError:
144-
_Template = None # type: ignore[assignment,misc]
145-
_Interpolation = None # type: ignore[assignment,misc]
146-
_tmpl_convert = None # type: ignore[assignment,misc]
139+
if sys.version_info >= (3, 14):
140+
from string import templatelib
141+
142+
def is_template(obj):
143+
return isinstance(obj, templatelib.Template)
144+
145+
else:
146+
147+
def is_template(obj):
148+
return False
147149

148150

149151
Level = namedtuple("Level", ["name", "no", "color", "icon"]) # noqa: PYI024
@@ -443,7 +445,8 @@ def add(
443445
444446
Note that while calling a logging method, the keyword arguments (if any) are automatically
445447
added to the ``extra`` dict for convenient contextualization (in addition to being used for
446-
formatting).
448+
formatting). Also, if the base message is a template string (Python 3.14+), it will first be
449+
converted to a regular string.
447450
448451
.. _levels:
449452
@@ -2036,22 +2039,16 @@ def _find_iter(fileobj, regex, chunk):
20362039
yield from matches[:-1]
20372040

20382041
@staticmethod
2039-
def _message_to_string(message):
2040-
"""Message can be a string, Any or a Template (for python>=3.14).
2041-
2042-
For templates, we convert them into a string analogously to how f-string work.
2043-
Everything else is just converted to string via str(message).
2044-
"""
2045-
if _Template is None or not isinstance(message, _Template):
2046-
return str(message)
2042+
def _template_to_string(template):
20472043

2048-
# Code follows PEP-750 example "implementing f-strings with t-strings"
20492044
def item_to_string(item):
2050-
if isinstance(item, _Interpolation):
2051-
return format(_tmpl_convert(item.value, item.conversion), item.format_spec)
2045+
if isinstance(item, templatelib.Interpolation):
2046+
if isinstance(item.value, templatelib.Template):
2047+
return Logger._template_to_string(item.value)
2048+
return format(templatelib.convert(item.value, item.conversion), item.format_spec)
20522049
return item
20532050

2054-
return "".join(item_to_string(item) for item in message)
2051+
return "".join(item_to_string(item) for item in template)
20552052

20562053
def _log(self, level, from_decorator, options, message, args, kwargs):
20572054
core = self._core
@@ -2138,6 +2135,9 @@ def _log(self, level, from_decorator, options, message, args, kwargs):
21382135
else:
21392136
exception = None
21402137

2138+
if is_template(message):
2139+
message = self._template_to_string(message)
2140+
21412141
log_record = {
21422142
"elapsed": elapsed,
21432143
"exception": exception,
@@ -2146,7 +2146,7 @@ def _log(self, level, from_decorator, options, message, args, kwargs):
21462146
"function": co_name,
21472147
"level": RecordLevel(level_name, level_no, level_icon),
21482148
"line": f_lineno,
2149-
"message": Logger._message_to_string(message),
2149+
"message": str(message),
21502150
"module": splitext(file_name)[0],
21512151
"name": name,
21522152
"process": RecordProcess(process.ident, process.name),
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
2+
Traceback (most recent call last):
3+
4+
File "tests/exceptions/source/modern/t_string.py", line 21, in <module>
5+
hello()
6+
└ <function hello at 0xDEADBEEF>
7+
8+
File "tests/exceptions/source/modern/t_string.py", line 11, in hello
9+
output = t"Hello" + t' ' + t"""World""" and world()
10+
 └ <function world at 0xDEADBEEF>
11+
12+
File "tests/exceptions/source/modern/t_string.py", line 17, in world
13+
t"{name} -> { t }" and {} or t'{{ {t / 0} }}'
14+
 │ │ └ 1
15+
 │ └ 1
16+
 └ 'world'
17+
18+
ZeroDivisionError: division by zero
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# fmt: off
2+
import sys
3+
4+
from loguru import logger
5+
6+
logger.remove()
7+
logger.add(sys.stderr, format="", colorize=True, backtrace=False, diagnose=True)
8+
9+
10+
def hello():
11+
output = t"Hello" + t' ' + t"""World""" and world()
12+
13+
14+
def world():
15+
name = "world"
16+
t = 1
17+
t"{name} -> { t }" and {} or t'{{ {t / 0} }}'
18+
19+
20+
with logger.catch():
21+
hello()

tests/test_exceptions_formatting.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ def test_exception_others(filename):
261261
("grouped_max_length", (3, 11)),
262262
("grouped_max_depth", (3, 11)),
263263
("f_string", (3, 12)), # Available since 3.6 but in 3.12 the lexer for f-string changed.
264+
("t_string", (3, 14)),
264265
],
265266
)
266267
def test_exception_modern(filename, minimum_python_version):

tests/test_formatting.py

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import pytest
55

66
from loguru import logger
7-
from loguru._logger import _Interpolation, _Template
87

98

109
@pytest.mark.parametrize(
@@ -267,35 +266,3 @@ def test_invalid_format_key_raises_enhanced_error_without_catch(format_, coloriz
267266
logger.add(lambda msg: None, format=format_, catch=False, colorize=colorize)
268267
with pytest.raises(ValueError, match=r"Failed to format log record: key 'missing' not found."):
269268
logger.opt(colors=colors).info("Hello")
270-
271-
272-
@pytest.mark.skipif(_Template is None, reason="Template Strings not supported")
273-
def test_template_string(writer):
274-
# We can't just use t"2**8 = {2**8}", because its a syntax error before python-3.14
275-
logger.add(writer)
276-
logger.info(_Template("2**8 = ", _Interpolation(2**8)))
277-
result = writer.read()
278-
assert result.endswith("2**8 = 256\n")
279-
280-
281-
@pytest.mark.skipif(_Template is None, reason="Template Strings not supported")
282-
def test_template_string_is_lazy(writer):
283-
# We can't just use t"debug = {debug_tracker}", because its a syntax error before python-3.14
284-
class StrCalledTracker:
285-
def __init__(self):
286-
self.str_called = False
287-
288-
def __str__(self):
289-
self.str_called = True
290-
return "xxx"
291-
292-
logger.add(writer, level="INFO")
293-
debug_tracker = StrCalledTracker()
294-
info_tracker = StrCalledTracker()
295-
logger.debug(_Template("debug = ", _Interpolation(debug_tracker))) # Should be ignored (debug)
296-
logger.info(_Template("info = ", _Interpolation(info_tracker))) # Should be logged (info)
297-
result = writer.read()
298-
assert len(result.strip().split("\n")) == 1
299-
assert result.endswith("info = xxx\n")
300-
assert not debug_tracker.str_called
301-
assert info_tracker.str_called

0 commit comments

Comments
 (0)