88from functools import lru_cache
99from string import Formatter
1010from types import CodeType
11- from typing import Any , Final , Literal , Mapping
11+ from typing import Any , Final , Literal
1212
1313import executing
1414from typing_extensions import NotRequired , TypedDict
1717from logfire ._internal .stack_info import get_user_frame_and_stacklevel
1818
1919from .constants import ATTRIBUTES_SCRUBBED_KEY , MESSAGE_FORMATTED_VALUE_LENGTH_LIMIT
20- from .scrubbing import BaseScrubber , ScrubbedNote
21- from .utils import truncate_string
20+ from .scrubbing import NOOP_SCRUBBER , BaseScrubber , ScrubbedNote
21+ from .utils import log_internal_error , truncate_string
2222
2323
2424class LiteralChunk (TypedDict ):
@@ -38,7 +38,7 @@ class ChunksFormatter(Formatter):
3838 def chunks (
3939 self ,
4040 format_string : str ,
41- kwargs : Mapping [str , Any ],
41+ kwargs : dict [str , Any ],
4242 * ,
4343 scrubber : BaseScrubber ,
4444 fstring_frame : types .FrameType | None = None ,
@@ -64,7 +64,7 @@ def chunks(
6464
6565 def _fstring_chunks (
6666 self ,
67- kwargs : Mapping [str , Any ],
67+ kwargs : dict [str , Any ],
6868 scrubber : BaseScrubber ,
6969 frame : types .FrameType ,
7070 ) -> tuple [list [LiteralChunk | ArgChunk ], dict [str , Any ], str ] | None :
@@ -236,18 +236,15 @@ def _fstring_chunks(
236236 def _vformat_chunks (
237237 self ,
238238 format_string : str ,
239- kwargs : Mapping [str , Any ],
239+ kwargs : dict [str , Any ],
240240 * ,
241241 scrubber : BaseScrubber ,
242242 recursion_depth : int = 2 ,
243- auto_arg_index : int = 0 ,
244243 ) -> tuple [list [LiteralChunk | ArgChunk ], dict [str , Any ]]:
245244 """Copied from `string.Formatter._vformat` https://github.com/python/cpython/blob/v3.11.4/Lib/string.py#L198-L247 then altered."""
246- if recursion_depth < 0 : # pragma: no cover
247- raise ValueError ('Max string recursion exceeded' )
245+ if recursion_depth < 0 :
246+ raise KnownFormattingError ('Max format spec recursion exceeded' )
248247 result : list [LiteralChunk | ArgChunk ] = []
249- # here just to satisfy the call to `_vformat` below
250- used_args : set [str | int ] = set ()
251248 # We currently don't use positional arguments
252249 args = ()
253250 scrubbed : list [ScrubbedNote ] = []
@@ -260,19 +257,8 @@ def _vformat_chunks(
260257 if field_name is not None :
261258 # this is some markup, find the object and do
262259 # the formatting
263-
264- # handle arg indexing when empty field_names are given.
265- if field_name == '' : # pragma: no cover
266- if auto_arg_index is False :
267- raise ValueError ('cannot switch from manual field specification to automatic field numbering' )
268- field_name = str (auto_arg_index )
269- auto_arg_index += 1
270- elif field_name .isdigit (): # pragma: no cover
271- if auto_arg_index :
272- raise ValueError ('cannot switch from manual field to automatic field numbering' )
273- # disable auto arg incrementing, if it gets
274- # used later on, then an exception will be raised
275- auto_arg_index = False
260+ if field_name == '' :
261+ raise KnownFormattingError ('Empty curly brackets `{}` are not allowed. A field name is required.' )
276262
277263 # ADDED BY US:
278264 if field_name .endswith ('=' ):
@@ -282,42 +268,47 @@ def _vformat_chunks(
282268 result .append ({'v' : field_name , 't' : 'lit' })
283269 field_name = field_name [:- 1 ]
284270
285- # we have lots of type ignores here since Formatter is typed as requiring kwargs to be a
286- # dict, but we expect `Sequence[dict[str, Any]]` in get_value - effectively `Formatter` is really
287- # generic over the type of the kwargs
288-
289271 # given the field_name, find the object it references
290272 # and the argument it came from
291273 try :
292274 obj , _arg_used = self .get_field (field_name , args , kwargs )
293- except KeyError as exc :
275+ except IndexError :
276+ raise KnownFormattingError ('Numeric field names are not allowed.' )
277+ except KeyError as exc1 :
278+ if str (exc1 ) == repr (field_name ):
279+ raise KnownFormattingError (f'The field {{{ field_name } }} is not defined.' ) from exc1
280+
294281 try :
295- # fall back to getting a key with the dots in the name
282+ # field_name is something like 'a.b' or 'a[b]'
283+ # Evaluating that expression failed, so now just try getting the whole thing from kwargs.
284+ # In particular, OTEL attributes with dots in their names are normal and handled here.
296285 obj = kwargs [field_name ]
297- except KeyError :
298- obj = '{' + field_name + '}'
299- field = exc . args [ 0 ]
300- _frame , stacklevel = get_user_frame_and_stacklevel ()
301- warnings . warn ( f"The field ' { field } ' is not defined." , stacklevel = stacklevel )
286+ except KeyError as exc2 :
287+ # e.g. neither 'a' nor 'a.b' is defined
288+ raise KnownFormattingError ( f'The fields { exc1 } and { exc2 } are not defined.' ) from exc2
289+ except Exception as exc :
290+ raise KnownFormattingError ( f'Error getting field {{ { field_name } }}: { exc } ' ) from exc
302291
303292 # do any conversion on the resulting object
304293 if conversion is not None :
305- obj = self .convert_field (obj , conversion )
294+ try :
295+ obj = self .convert_field (obj , conversion )
296+ except Exception as exc :
297+ raise KnownFormattingError (f'Error converting field {{{ field_name } }}: { exc } ' ) from exc
306298
307299 # expand the format spec, if needed
308- format_spec , auto_arg_index = self ._vformat (
309- format_spec , # type: ignore[arg-type]
310- args ,
311- kwargs ,
312- used_args , # TODO(lig): using `_arg_used` from above seems logical here but needs more thorough testing
313- recursion_depth - 1 ,
314- auto_arg_index = auto_arg_index ,
300+ format_spec_chunks , _ = self ._vformat_chunks (
301+ format_spec or '' , kwargs , scrubber = NOOP_SCRUBBER , recursion_depth = recursion_depth - 1
315302 )
303+ format_spec = '' .join (chunk ['v' ] for chunk in format_spec_chunks )
316304
317305 if obj is None :
318306 value = self .NONE_REPR
319307 else :
320- value = self .format_field (obj , format_spec )
308+ try :
309+ value = self .format_field (obj , format_spec )
310+ except Exception as exc :
311+ raise KnownFormattingError (f'Error formatting field {{{ field_name } }}: { exc } ' ) from exc
321312 value , value_scrubbed = self ._clean_value (field_name , value , scrubber )
322313 scrubbed += value_scrubbed
323314 d : ArgChunk = {'v' : value , 't' : 'arg' }
@@ -361,13 +352,23 @@ def logfire_format_with_magic(
361352 # 2. A dictionary of extra attributes to add to the span/log.
362353 # These can come from evaluating values in f-strings.
363354 # 3. The final message template, which may differ from `format_string` if it was an f-string.
364- chunks , extra_attrs , new_template = chunks_formatter .chunks (
365- format_string ,
366- kwargs ,
367- scrubber = scrubber ,
368- fstring_frame = fstring_frame ,
369- )
370- return '' .join (chunk ['v' ] for chunk in chunks ), extra_attrs , new_template
355+ try :
356+ chunks , extra_attrs , new_template = chunks_formatter .chunks (
357+ format_string ,
358+ kwargs ,
359+ scrubber = scrubber ,
360+ fstring_frame = fstring_frame ,
361+ )
362+ return '' .join (chunk ['v' ] for chunk in chunks ), extra_attrs , new_template
363+ except KnownFormattingError as e :
364+ warn_formatting (str (e ) or str (e .__cause__ ))
365+ except Exception :
366+ # This is an unexpected error that likely indicates a bug in our logic.
367+ # Handle it here so that the span/log still gets created, just without a nice message.
368+ log_internal_error ()
369+
370+ # Formatting failed, so just use the original format string as the message.
371+ return format_string , {}, format_string
371372
372373
373374@lru_cache
@@ -450,3 +451,29 @@ def warn_inspect_arguments(msg: str, stacklevel: int):
450451 ) + msg
451452 warnings .warn (msg , InspectArgumentsFailedWarning , stacklevel = stacklevel )
452453 logfire .log ('warn' , msg )
454+
455+
456+ class KnownFormattingError (Exception ):
457+ """An error raised when there's something wrong with a format string or the field values.
458+
459+ In other words this should correspond to errors that would be raised when using `str.format`,
460+ and generally indicate a user error, most likely that they weren't trying to pass a template string at all.
461+ """
462+
463+
464+ class FormattingFailedWarning (UserWarning ):
465+ pass
466+
467+
468+ def warn_formatting (msg : str ):
469+ _frame , stacklevel = get_user_frame_and_stacklevel ()
470+ warnings .warn (
471+ f'\n '
472+ f' Ensure you are either:\n '
473+ ' (1) passing an f-string directly, with inspect_arguments enabled and working, or\n '
474+ ' (2) passing a literal `str.format`-style template, not a preformatted string.\n '
475+ ' See https://docs.pydantic.dev/logfire/guides/onboarding_checklist/add_manual_tracing/#messages-and-span-names.\n '
476+ f' The problem was: { msg } ' ,
477+ stacklevel = stacklevel ,
478+ category = FormattingFailedWarning ,
479+ )
0 commit comments