Skip to content

Commit 46f24f0

Browse files
committed
WIP squash me or drop, get pydantic check working
1 parent 2130818 commit 46f24f0

File tree

5 files changed

+98
-6
lines changed

5 files changed

+98
-6
lines changed

util/opentelemetry-util-genai/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ dependencies = [
3434
upload = "opentelemetry.util.genai._upload:upload_completion_hook"
3535

3636
[project.optional-dependencies]
37-
test = ["pytest>=7.0.0"]
37+
test = ["pytest>=7.0.0", "pydantic>=2.0"]
3838
upload = ["fsspec>=2025.9.0"]
3939

4040
[project.urls]

util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
import logging
1818
import os
1919
from base64 import b64encode
20+
from collections.abc import Iterator
2021
from functools import partial
21-
from typing import Any
22+
from typing import Any, Protocol, runtime_checkable
2223

2324
from opentelemetry.instrumentation._semconv import (
2425
_OpenTelemetrySemanticConventionStability,
@@ -65,11 +66,24 @@ def get_content_capturing_mode() -> ContentCapturingMode:
6566
return ContentCapturingMode.NO_CONTENT
6667

6768

69+
@runtime_checkable
70+
class _PydanticModelDumpable(Protocol):
71+
"""Checkable protocol for pydantic model dump-able object.
72+
73+
See https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_dump
74+
"""
75+
76+
def model_dump_json(self, **kwargs: Any) -> str: ...
77+
78+
6879
class _GenAiJsonEncoder(json.JSONEncoder):
80+
def encode(self, o: Any) -> str:
81+
return super().encode(o)
82+
6983
def default(self, o: Any) -> Any:
7084
if isinstance(o, bytes):
7185
return b64encode(o).decode()
72-
elif isinstance(o, (datetime.datetime, datetime.date)):
86+
if isinstance(o, (datetime.datetime, datetime.date)):
7387
return o.isoformat()
7488

7589
try:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
pytest==7.4.4
22
fsspec==2025.9.0
3+
pydantic==2.11.4
34
-e opentelemetry-instrumentation

util/opentelemetry-util-genai/tests/test_utils.py

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import datetime
16+
import io
1517
import json
1618
import os
1719
import unittest
18-
from unittest.mock import patch
20+
from unittest.mock import Mock, patch
21+
22+
from pydantic import BaseModel
1923

2024
from opentelemetry import trace
2125
from opentelemetry.instrumentation._semconv import (
@@ -42,7 +46,11 @@
4246
OutputMessage,
4347
Text,
4448
)
45-
from opentelemetry.util.genai.utils import get_content_capturing_mode
49+
from opentelemetry.util.genai.utils import (
50+
gen_ai_json_dump,
51+
gen_ai_json_dumps,
52+
get_content_capturing_mode,
53+
)
4654

4755

4856
def patch_env_vars(stability_mode, content_capturing):
@@ -70,7 +78,9 @@ class TestVersion(unittest.TestCase):
7078
stability_mode="gen_ai_latest_experimental",
7179
content_capturing="SPAN_ONLY",
7280
)
73-
def test_get_content_capturing_mode_parses_valid_envvar(self): # pylint: disable=no-self-use
81+
def test_get_content_capturing_mode_parses_valid_envvar(
82+
self,
83+
): # pylint: disable=no-self-use
7484
assert get_content_capturing_mode() == ContentCapturingMode.SPAN_ONLY
7585

7686
@patch_env_vars(
@@ -287,3 +297,68 @@ class BoomError(RuntimeError):
287297
assert span.start_time is not None
288298
assert span.end_time is not None
289299
assert span.end_time >= span.start_time
300+
301+
302+
class TestGenAiJson(unittest.TestCase):
303+
def test_gen_ai_json_dumps(self):
304+
class Unserializable:
305+
# pylint: disable=no-self-use
306+
def __str__(self):
307+
return "unserializable"
308+
309+
obj = {
310+
"bytes": b"test",
311+
"datetime": datetime.datetime(2023, 1, 1, 12, 0, 0),
312+
"date": datetime.date(2023, 1, 1),
313+
"unserializable": Unserializable(),
314+
}
315+
result = gen_ai_json_dumps(obj)
316+
expected = '{"bytes":"dGVzdA==","datetime":"2023-01-01T12:00:00","date":"2023-01-01","unserializable":"unserializable"}'
317+
self.assertEqual(result, expected)
318+
319+
def test_gen_ai_json_dumps_pydantic(self):
320+
class ExamplePydanticModel(BaseModel):
321+
datetime_field: datetime.datetime
322+
string_field: str
323+
324+
pydantic_model = ExamplePydanticModel(
325+
datetime_field=datetime.datetime(2023, 1, 1, 12, 0, 0),
326+
string_field="test",
327+
)
328+
# spy the model
329+
pydantic_model = Mock(wraps=pydantic_model)
330+
331+
result = gen_ai_json_dumps({"some": {"pydantic": [pydantic_model]}})
332+
self.assertEqual(result, '{"key":"pydantic"}')
333+
334+
pydantic_model.model_dump_json.assert_called_once()
335+
336+
def test_gen_ai_json_dump(self):
337+
class Unserializable:
338+
# pylint: disable=no-self-use
339+
def __str__(self):
340+
return "unserializable"
341+
342+
obj = {
343+
"bytes": b"test",
344+
"datetime": datetime.datetime(2023, 1, 1, 12, 0, 0),
345+
"date": datetime.date(2023, 1, 1),
346+
"unserializable": Unserializable(),
347+
}
348+
string_io = io.StringIO()
349+
gen_ai_json_dump(obj, string_io)
350+
result = string_io.getvalue()
351+
expected = '{"bytes":"dGVzdA==","datetime":"2023-01-01T12:00:00","date":"2023-01-01","unserializable":"unserializable"}'
352+
self.assertEqual(result, expected)
353+
354+
def test_gen_ai_json_dump_pydantic(self):
355+
class PydanticModel:
356+
# pylint: disable=no-self-use
357+
def model_dump_json(self, **kwargs):
358+
return '{"key":"pydantic"}'
359+
360+
obj = PydanticModel()
361+
string_io = io.StringIO()
362+
gen_ai_json_dump(obj, string_io)
363+
result = string_io.getvalue()
364+
self.assertEqual(result, '{"key":"pydantic"}')

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)