Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
enable-cache: true # zizmor: ignore[cache-poisoning] -- Job does not produce release artifacts and does not have sensitive permissions

- name: Install dependencies
run: uv sync --python 3.12 --frozen
run: uv sync --python 3.14 --frozen

- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
with:
Expand Down
77 changes: 71 additions & 6 deletions logfire/_internal/formatter.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
from __future__ import annotations

import ast
import importlib
import types
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from functools import lru_cache
from string import Formatter
from types import CodeType
from typing import Any, Literal
from typing import TYPE_CHECKING, Any, Literal, cast

import executing
from typing_extensions import NotRequired, TypedDict
from typing_extensions import NotRequired, TypedDict, TypeGuard

import logfire

Expand All @@ -18,6 +19,18 @@
from .stack_info import warn_at_user_stacklevel
from .utils import log_internal_error

if TYPE_CHECKING:
from string.templatelib import Template

try:
_templatelib = importlib.import_module('string.templatelib')
except ImportError:
_template_class: type[Any] | None = None
_template_convert: Callable[[Any, Literal['a', 'r', 's'] | None], Any] | None = None
else:
_template_class = cast('type[Any]', getattr(_templatelib, 'Template'))
_template_convert = cast('Callable[[Any, Literal["a", "r", "s"] | None], Any]', getattr(_templatelib, 'convert'))


class LiteralChunk(TypedDict):
t: Literal['lit']
Expand All @@ -30,15 +43,33 @@ class ArgChunk(TypedDict):
spec: NotRequired[str]


def is_template_string(format_string: object) -> TypeGuard[Template]:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should then be able to drop the assert isinstance(format_string, str) call below:

Suggested change
def is_template_string(format_string: object) -> TypeGuard[Template]:
def is_template_string(format_string: object) -> TypeIs[Template]:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is ai slop, ignore for now

return _template_class is not None and isinstance(format_string, _template_class)


def template_to_format_string(format_string: Template) -> str:
result = ''
for index, literal_text in enumerate(format_string.strings):
result += literal_text
if index < len(format_string.interpolations):
result += '{' + format_string.interpolations[index].expression + '}'
return result


class ChunksFormatter(Formatter):
def chunks(
self,
format_string: str,
format_string: str | Template,
kwargs: dict[str, Any],
*,
scrubber: BaseScrubber,
fstring_frame: types.FrameType | None = None,
) -> tuple[list[LiteralChunk | ArgChunk], dict[str, Any], str]:
if is_template_string(format_string):
return self._template_chunks(format_string, scrubber=scrubber)

assert isinstance(format_string, str)

# Returns
# 1. A list of chunks
# 2. A dictionary of extra attributes to add to the span/log.
Expand All @@ -58,6 +89,37 @@ def chunks(
# When there's no f-string magic, there's no changes in the template string.
return chunks, extra_attrs, format_string

def _template_chunks(
self, format_string: Template, *, scrubber: BaseScrubber
) -> tuple[list[LiteralChunk | ArgChunk], dict[str, Any], str]:
result: list[LiteralChunk | ArgChunk] = []
new_template = ''
extra_attrs: dict[str, Any] = {}
value_cleaner = MessageValueCleaner(scrubber, check_keys=False)
assert _template_convert is not None
for index, literal_text in enumerate(format_string.strings):
if literal_text:
result.append({'v': literal_text, 't': 'lit'})
new_template += literal_text
if index == len(format_string.interpolations):
continue
interpolation = format_string.interpolations[index]
field_name = interpolation.expression
new_template += '{' + field_name + '}'
extra_attrs[field_name] = interpolation.value
try:
value = _template_convert(interpolation.value, interpolation.conversion)
except Exception as exc:
raise KnownFormattingError(f'Error converting field {{{field_name}}}: {exc}') from exc
try:
formatted = self.format_field(value, interpolation.format_spec)
except Exception as exc:
raise KnownFormattingError(f'Error formatting field {{{field_name}}}: {exc}') from exc
formatted = value_cleaner.clean_value(field_name, formatted)
result.append({'v': formatted, 't': 'arg'})
extra_attrs.update(value_cleaner.extra_attrs())
return result, extra_attrs, new_template

def _fstring_chunks(
self,
kwargs: dict[str, Any],
Expand Down Expand Up @@ -234,7 +296,7 @@ def _vformat_chunks(
chunks_formatter = ChunksFormatter()


def logfire_format(format_string: str, kwargs: dict[str, Any], scrubber: BaseScrubber) -> str:
def logfire_format(format_string: str | Template, kwargs: dict[str, Any], scrubber: BaseScrubber) -> str:
result, _extra_attrs, _new_template = logfire_format_with_magic(
format_string,
kwargs,
Expand All @@ -244,7 +306,7 @@ def logfire_format(format_string: str, kwargs: dict[str, Any], scrubber: BaseScr


def logfire_format_with_magic(
format_string: str,
format_string: str | Template,
kwargs: dict[str, Any],
scrubber: BaseScrubber,
fstring_frame: types.FrameType | None = None,
Expand Down Expand Up @@ -272,6 +334,9 @@ def logfire_format_with_magic(
log_internal_error()

# Formatting failed, so just use the original format string as the message.
if is_template_string(format_string):
format_string = template_to_format_string(format_string)
assert isinstance(format_string, str)
return format_string, {}, format_string


Expand Down
31 changes: 30 additions & 1 deletion tests/test_formatter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import contextlib
import sys
from collections import ChainMap
from collections.abc import Mapping
from types import SimpleNamespace
Expand All @@ -13,7 +14,7 @@
from logfire.testing import TestExporter


def chunks(format_string: str, kwargs: Mapping[str, Any]):
def chunks(format_string: Any, kwargs: Mapping[str, Any]):
result, _extra_attrs, _span_name = chunks_formatter.chunks(format_string, dict(kwargs), scrubber=Scrubber([]))
return result

Expand Down Expand Up @@ -102,6 +103,34 @@ def __repr__(self):
raise ValueError('bad repr')


@pytest.mark.skipif(sys.version_info < (3, 14), reason='template strings require Python 3.14')
def test_template_string():
from string.templatelib import Interpolation, Template

template = Template(
'foo ',
Interpolation(123.456, 'bar', None, '0.2f'),
' ',
Interpolation('spam', 'name', 'r'),
)

result, extra_attrs, span_name = chunks_formatter.chunks(template, {}, scrubber=Scrubber([]))

assert result == snapshot(
[{'t': 'lit', 'v': 'foo '}, {'t': 'arg', 'v': '123.46'}, {'t': 'lit', 'v': ' '}, {'t': 'arg', 'v': "'spam'"}]
)
assert extra_attrs == snapshot({'bar': 123.456, 'name': 'spam'})
assert span_name == snapshot('foo {bar} {name}')


@pytest.mark.skipif(sys.version_info < (3, 14), reason='template strings require Python 3.14')
def test_template_string_conversion_error():
from string.templatelib import Interpolation, Template

with warns_failed('Error converting field {a}: bad repr'):
logfire_format(Template(Interpolation(BadRepr(), 'a', 'r')), {}, NOOP_SCRUBBER)


def test_conversion_error():
with warns_failed('Error converting field {a}: bad repr'):
logfire_format('{a!r}', {'a': BadRepr()}, NOOP_SCRUBBER)
Expand Down
Loading