Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
75 changes: 70 additions & 5 deletions logfire/_internal/formatter.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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, TypeGuard, cast

import executing
from typing_extensions import NotRequired, TypedDict
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