Skip to content

Commit 0c3fc81

Browse files
authored
Fix Root Model Not Rendering As Expected (#1396)
1 parent 17de64c commit 0c3fc81

File tree

8 files changed

+325
-3
lines changed

8 files changed

+325
-3
lines changed

docs/reference/self-hosted/local-quickstart.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ For a production setup, including detailed configuration and prerequisites, plea
1111

1212
Before deploying, you will need the following:
1313

14-
- A Logfire Access Key, you'll need to get in contact with [[email protected]](mailto:[email protected]) to get one. **Remember you need to be on a trail for self-hosted enterprise Logfire to run logfire locally.**
14+
- A Logfire Access Key, you'll need to get in contact with [[email protected]](mailto:[email protected]) to get one. **Remember you need to be on a trial for self-hosted enterprise Logfire to run logfire locally.**
1515
- A local Kubernetes cluster, we will be using [Kind](https://kind.sigs.k8s.io/) in this example.
1616
- [Helm](https://helm.sh) CLI installed.
1717
- (Optional) [Tilt](https://tilt.dev/), as we will provide an optional convenience `Tiltfile` to automate the setup.

logfire/_internal/json_encoder.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ def _pydantic_model_encoder(o: Any, seen: set[int]) -> JsonValue:
155155
import pydantic
156156

157157
assert isinstance(o, pydantic.BaseModel)
158+
159+
if isinstance(o, pydantic.RootModel):
160+
return to_json_value(o.root, seen) # type: ignore
161+
158162
try:
159163
dump = o.model_dump()
160164
except AttributeError: # pragma: no cover

logfire/_internal/json_formatter.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ def __init__(self, *, indent: int):
1919
self._indent_step = indent
2020
self._newlines = indent != 0
2121
self._data_type_map: dict[DataType, Callable[[int, Any, JSONSchema | None], None]] = {
22+
'str': self._format_string,
23+
'int': self._format_number,
24+
'float': self._format_number,
2225
'PydanticModel': partial(self._format_items, '(', '=', ')', False),
2326
'dataclass': partial(self._format_items, '(', '=', ')', False),
2427
'Mapping': partial(self._format_items, '({', ': ', '})', True),
@@ -204,6 +207,38 @@ def _format_bytes(self, _indent_current: int, value: Any, schema: JSONSchema | N
204207
output = f'{cls}({value})' if cls else value
205208
self._stream.write(output)
206209

210+
def _format_string(self, _indent_current: int, value: Any, schema: JSONSchema | None) -> None:
211+
"""Format string value.
212+
213+
Examples:
214+
>>> value = 'hello'
215+
>>> schema = {'type': 'string', 'x-python-datatype': 'str'}
216+
>>> _format_string(0, value, schema)
217+
"hello"
218+
>>> schema = {'type': 'string', 'x-python-datatype': 'str', 'title': 'MyString'}
219+
>>> _format_string(0, value, schema)
220+
MyString("hello")
221+
"""
222+
cls = schema and schema.get('title')
223+
output = f'{cls}({repr(value)})' if cls else repr(value)
224+
self._stream.write(output)
225+
226+
def _format_number(self, _indent_current: int, value: Any, schema: JSONSchema | None) -> None:
227+
"""Format number value. Supports both integer and float types.
228+
229+
Examples:
230+
>>> value = 42
231+
>>> schema = {'type': 'integer', 'x-python-datatype': 'int'}
232+
>>> _format_number(0, value, schema)
233+
42
234+
>>> schema = {'type': 'number', 'x-python-datatype': 'float', 'title': 'MyNumber'}
235+
>>> _format_number(0, value, schema)
236+
MyNumber(42)
237+
"""
238+
cls = schema and schema.get('title')
239+
output = f'{cls}({value})' if cls else str(value)
240+
self._stream.write(output)
241+
207242
def _format_table(
208243
self, columns: list[Any], indices: list[Any], rows: list[Any], real_column_count: int, real_row_count: int
209244
) -> None:

logfire/_internal/json_schema.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def type_to_schema() -> dict[type[Any], JsonDict | Callable[[Any, set[int]], Jso
7272
pydantic.SecretBytes: {'type': 'string', 'x-python-datatype': 'SecretBytes'},
7373
pydantic.AnyUrl: {'type': 'string', 'x-python-datatype': 'AnyUrl'},
7474
pydantic.BaseModel: _pydantic_model_schema,
75+
pydantic.RootModel: _pydantic_root_model_schema,
7576
}
7677
)
7778

@@ -111,6 +112,7 @@ def create_json_schema(obj: Any, seen: set[int]) -> JsonDict:
111112
try:
112113
# cover common types first before calling `type_to_schema` to avoid the overhead of imports if not necessary
113114
obj_type = obj.__class__
115+
114116
if obj_type in {str, int, bool, float}:
115117
return {}
116118

@@ -282,10 +284,49 @@ def _exception_schema(obj: Exception, _seen: set[int]) -> JsonDict:
282284
return {'type': 'object', 'title': obj.__class__.__name__, 'x-python-datatype': 'Exception'}
283285

284286

287+
def _pydantic_root_model_schema(obj: Any, seen: set[int]) -> JsonDict:
288+
import pydantic
289+
290+
assert isinstance(obj, pydantic.RootModel)
291+
292+
root = obj.root # type: ignore
293+
294+
if isinstance(root, type(None)):
295+
return {'type': 'null'}
296+
297+
schema: JsonDict = {}
298+
299+
# return a complex schema to ensure JSON parsing for simple objects inside RootModel since they get an
300+
# extra layer of JSON encoding and to handle subclass representations correctly
301+
primitive_types = (str, bool, int, float)
302+
if isinstance(root, primitive_types):
303+
datatype = None
304+
if isinstance(root, str):
305+
type_ = 'string'
306+
datatype = 'str'
307+
elif isinstance(root, bool):
308+
type_ = 'boolean'
309+
elif isinstance(root, int):
310+
type_ = 'integer'
311+
datatype = 'int'
312+
else:
313+
type_ = 'number'
314+
datatype = 'float'
315+
316+
schema = {'type': type_, 'x-python-datatype': datatype}
317+
if root.__class__ not in primitive_types:
318+
schema['title'] = root.__class__.__name__
319+
320+
return schema
321+
322+
return create_json_schema(obj.root, seen) # type: ignore
323+
324+
285325
def _pydantic_model_schema(obj: Any, seen: set[int]) -> JsonDict:
286326
import pydantic
287327

288328
assert isinstance(obj, pydantic.BaseModel)
329+
289330
try:
290331
fields = type(obj).model_fields
291332
extra = obj.model_extra or {}

logfire/_internal/json_types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
'Decimal',
1212
'UUID',
1313
'Enum',
14+
# string
15+
'str',
16+
# number
17+
'int',
18+
'float',
1419
# bytes
1520
'bytes',
1621
# temporal types

tests/test_console_exporter.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import re
99
import sys
1010
from datetime import datetime
11+
from enum import Enum
1112
from typing import Any
1213
from unittest import mock
1314

@@ -31,6 +32,15 @@
3132
from logfire.testing import TestExporter
3233
from tests.utils import ReadableSpanModel, SpanContextModel, exported_spans_as_models
3334

35+
if sys.version_info >= (3, 11): # pragma: no branch
36+
from enum import IntEnum, StrEnum
37+
else: # pragma: no cover
38+
39+
class StrEnum(str, Enum): ...
40+
41+
class IntEnum(int, Enum): ...
42+
43+
3444
tracer = trace.get_tracer('test')
3545

3646
NANOSECONDS_PER_SECOND = int(1e9)
@@ -1019,3 +1029,84 @@ def test_console_exporter_list_data_with_object_schema_mismatch(capsys: pytest.C
10191029
"│ bar={'name': 'Alice', 'age': 30}",
10201030
]
10211031
)
1032+
1033+
1034+
def test_console_exporter_log_pydantic_root_model(capsys: pytest.CaptureFixture[str]) -> None:
1035+
from pydantic import BaseModel, RootModel
1036+
1037+
logfire.configure(
1038+
send_to_logfire=False,
1039+
console=ConsoleOptions(verbose=True, colors='never', include_timestamps=False),
1040+
)
1041+
1042+
class Model(BaseModel):
1043+
name: str
1044+
1045+
class Color(StrEnum):
1046+
red = 'RED'
1047+
1048+
class Order(IntEnum):
1049+
one = 1
1050+
1051+
RootWithModel = RootModel[Model]
1052+
RootWithStr = RootModel[str]
1053+
RootWithInt = RootModel[int]
1054+
RootWithFloat = RootModel[float]
1055+
RootWithBool = RootModel[bool]
1056+
RootWithNone = RootModel[None]
1057+
# enums (which are subclasses of their base types)
1058+
RootWithColor = RootModel[Color]
1059+
RootWithOrder = RootModel[Order]
1060+
1061+
model = Model(name='with_model')
1062+
root_with_model = RootWithModel(root=model)
1063+
root_with_str = RootWithStr('with_str')
1064+
root_with_int = RootWithInt(-150)
1065+
root_with_float = RootWithFloat(2.0)
1066+
root_with_bool = RootWithBool(False)
1067+
root_with_none = RootWithNone(None)
1068+
root_with_color = RootWithColor(Color.red)
1069+
root_with_order = RootWithOrder(Order.one)
1070+
1071+
logfire.info(
1072+
'hi',
1073+
with_model=root_with_model,
1074+
with_str=root_with_str,
1075+
with_str_inner=root_with_str.root,
1076+
with_int=root_with_int,
1077+
with_int_inner=root_with_int.root,
1078+
with_float=root_with_float,
1079+
with_float_inner=root_with_float.root,
1080+
with_bool=root_with_bool,
1081+
with_bool_inner=root_with_bool.root,
1082+
with_none=root_with_none,
1083+
with_none_inner=root_with_none.root,
1084+
with_color=root_with_color,
1085+
with_color_inner=root_with_color.root,
1086+
with_order=root_with_order,
1087+
with_order_inner=root_with_order.root,
1088+
)
1089+
1090+
assert capsys.readouterr().out.splitlines() == snapshot(
1091+
[
1092+
'hi',
1093+
IsStr(),
1094+
'│ with_model=Model(',
1095+
"│ name='with_model',",
1096+
'│ )',
1097+
"│ with_str='with_str'",
1098+
"│ with_str_inner='with_str'",
1099+
'│ with_int=-150',
1100+
'│ with_int_inner=-150',
1101+
'│ with_float=2.0',
1102+
'│ with_float_inner=2.0',
1103+
'│ with_bool=False',
1104+
'│ with_bool_inner=False',
1105+
'│ with_none=None',
1106+
'│ with_none_inner=None',
1107+
"│ with_color=Color('RED')",
1108+
"│ with_color_inner=Color('RED')",
1109+
'│ with_order=Order(1)',
1110+
'│ with_order_inner=Order(1)',
1111+
]
1112+
)

0 commit comments

Comments
 (0)