Skip to content

Commit 19c6349

Browse files
authored
Merge branch 'main' into fix/cli-refactor
2 parents c0d72f6 + d75fd42 commit 19c6349

22 files changed

+510
-105
lines changed

docs/changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ Pydantic AI is still pre-version 1, so breaking changes will occur, however:
1212
!!! note
1313
Here's a filtered list of the breaking changes for each version to help you upgrade Pydantic AI.
1414

15+
### v0.5.0 (2025-08-04)
16+
17+
See [#2388](https://github.com/pydantic/pydantic-ai/pull/2388) - The `source` field of an `EvaluationResult` is now of type `EvaluatorSpec` rather than the actual source `Evaluator` instance, to help with serialization/deserialization.
18+
19+
See [#2163](https://github.com/pydantic/pydantic-ai/pull/2163) - The `EvaluationReport.print` and `EvaluationReport.console_table` methods now require most arguments be passed by keyword.
20+
1521
### v0.4.0 (2025-07-08)
1622

1723
See [#1799](https://github.com/pydantic/pydantic-ai/pull/1799) - Pydantic Evals `EvaluationReport` and `ReportCase` are now generic dataclasses instead of Pydantic models. If you were serializing them using `model_dump()`, you will now need to use the `EvaluationReportAdapter` and `ReportCaseAdapter` type adapters instead.

docs/tools.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ There are a number of ways to register tools with an agent:
1212
- via the [`@agent.tool_plain`][pydantic_ai.Agent.tool_plain] decorator — for tools that do not need access to the agent [context][pydantic_ai.tools.RunContext]
1313
- via the [`tools`][pydantic_ai.Agent.__init__] keyword argument to `Agent` which can take either plain functions, or instances of [`Tool`][pydantic_ai.tools.Tool]
1414

15-
For more advanced use cases, the [toolsets](toolsets.md) feature lets you manage collections of tools (built by you or provided by an [MCP server](mcp/client.md) or other [third party](#third-party-tools)) and register them with an agent in one go via the [`toolsets`][pydantic_ai.Agent.__init__] keyword argument to `Agent`.
15+
For more advanced use cases, the [toolsets](toolsets.md) feature lets you manage collections of tools (built by you or provided by an [MCP server](mcp/client.md) or other [third party](#third-party-tools)) and register them with an agent in one go via the [`toolsets`][pydantic_ai.Agent.__init__] keyword argument to `Agent`. Internally, all `tools` and `toolsets` are gathered into a single [combined toolset](toolsets.md#combining-toolsets) that's made available to the model.
1616

1717
!!! info "Function tools vs. RAG"
1818
Function tools are basically the "R" of RAG (Retrieval-Augmented Generation) — they augment what the model can do by letting it request extra information.
@@ -724,6 +724,12 @@ def my_flaky_tool(query: str) -> str:
724724

725725
Raising `ModelRetry` also generates a `RetryPromptPart` containing the exception message, which is sent back to the LLM to guide its next attempt. Both `ValidationError` and `ModelRetry` respect the `retries` setting configured on the `Tool` or `Agent`.
726726

727+
### Parallel tool calls & concurrency
728+
729+
When a model returns multiple tool calls in one response, Pydantic AI schedules them concurrently using `asyncio.create_task`.
730+
731+
Async functions are run on the event loop, while sync functions are offloaded to threads. To get the best performance, _always_ use an async function _unless_ you're doing blocking I/O (and there's no way to use a non-blocking library instead) or CPU-bound work (like `numpy` or `scikit-learn` operations), so that simple functions are not offloaded to threads unnecessarily.
732+
727733
## Third-Party Tools
728734

729735
### MCP Tools {#mcp-tools}

pydantic_ai_slim/pydantic_ai/_function_schema.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,6 @@ def _build_schema(
285285
td_schema = core_schema.typed_dict_schema(
286286
fields,
287287
config=core_config,
288-
total=var_kwargs_schema is None,
289288
extras_schema=gen_schema.generate_schema(var_kwargs_schema) if var_kwargs_schema else None,
290289
)
291290
return td_schema, None

pydantic_ai_slim/pydantic_ai/messages.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ class FileUrl(ABC):
106106
- `GoogleModel`: `VideoUrl.vendor_metadata` is used as `video_metadata`: https://ai.google.dev/gemini-api/docs/video-understanding#customize-video-processing
107107
"""
108108

109-
_media_type: str | None = field(init=False, repr=False)
109+
_media_type: str | None = field(init=False, repr=False, compare=False)
110110

111111
def __init__(
112112
self,
@@ -120,19 +120,21 @@ def __init__(
120120
self.force_download = force_download
121121
self._media_type = media_type
122122

123-
@abstractmethod
124-
def _infer_media_type(self) -> str:
125-
"""Return the media type of the file, based on the url."""
126-
127123
@property
128124
def media_type(self) -> str:
129-
"""Return the media type of the file, based on the url or the provided `_media_type`."""
125+
"""Return the media type of the file, based on the URL or the provided `media_type`."""
130126
return self._media_type or self._infer_media_type()
131127

128+
@abstractmethod
129+
def _infer_media_type(self) -> str:
130+
"""Infer the media type of the file based on the URL."""
131+
raise NotImplementedError
132+
132133
@property
133134
@abstractmethod
134135
def format(self) -> str:
135136
"""The file format."""
137+
raise NotImplementedError
136138

137139
__repr__ = _utils.dataclasses_no_defaults_repr
138140

@@ -182,7 +184,9 @@ def _infer_media_type(self) -> VideoMediaType:
182184
elif self.is_youtube:
183185
return 'video/mp4'
184186
else:
185-
raise ValueError(f'Unknown video file extension: {self.url}')
187+
raise ValueError(
188+
f'Could not infer media type from video URL: {self.url}. Explicitly provide a `media_type` instead.'
189+
)
186190

187191
@property
188192
def is_youtube(self) -> bool:
@@ -238,7 +242,9 @@ def _infer_media_type(self) -> AudioMediaType:
238242
if self.url.endswith('.aac'):
239243
return 'audio/aac'
240244

241-
raise ValueError(f'Unknown audio file extension: {self.url}')
245+
raise ValueError(
246+
f'Could not infer media type from audio URL: {self.url}. Explicitly provide a `media_type` instead.'
247+
)
242248

243249
@property
244250
def format(self) -> AudioFormat:
@@ -278,7 +284,9 @@ def _infer_media_type(self) -> ImageMediaType:
278284
elif self.url.endswith('.webp'):
279285
return 'image/webp'
280286
else:
281-
raise ValueError(f'Unknown image file extension: {self.url}')
287+
raise ValueError(
288+
f'Could not infer media type from image URL: {self.url}. Explicitly provide a `media_type` instead.'
289+
)
282290

283291
@property
284292
def format(self) -> ImageFormat:
@@ -324,10 +332,16 @@ def _infer_media_type(self) -> str:
324332
return 'application/pdf'
325333
elif self.url.endswith('.rtf'):
326334
return 'application/rtf'
335+
elif self.url.endswith('.docx'):
336+
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
337+
elif self.url.endswith('.xlsx'):
338+
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
327339

328340
type_, _ = guess_type(self.url)
329341
if type_ is None:
330-
raise ValueError(f'Unknown document file extension: {self.url}')
342+
raise ValueError(
343+
f'Could not infer media type from document URL: {self.url}. Explicitly provide a `media_type` instead.'
344+
)
331345
return type_
332346

333347
@property

pydantic_ai_slim/pydantic_ai/profiles/openai.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,13 @@ def transform(self, schema: JsonSchema) -> JsonSchema: # noqa C901
166166
schema['required'] = list(schema['properties'].keys())
167167

168168
elif self.strict is None:
169-
if (
170-
schema.get('additionalProperties') is not False
171-
or 'properties' not in schema
172-
or 'required' not in schema
173-
):
169+
if schema.get('additionalProperties', None) not in (None, False):
170+
self.is_strict_compatible = False
171+
else:
172+
# additional properties are disallowed by default
173+
schema['additionalProperties'] = False
174+
175+
if 'properties' not in schema or 'required' not in schema:
174176
self.is_strict_compatible = False
175177
else:
176178
required = schema['required']

pydantic_ai_slim/pydantic_ai/tools.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,19 @@ async def turn_on_strict_if_openai(
133133

134134
class GenerateToolJsonSchema(GenerateJsonSchema):
135135
def typed_dict_schema(self, schema: core_schema.TypedDictSchema) -> JsonSchemaValue:
136-
s = super().typed_dict_schema(schema)
137-
total = schema.get('total')
138-
if 'additionalProperties' not in s and (total is True or total is None):
139-
s['additionalProperties'] = False
140-
return s
136+
json_schema = super().typed_dict_schema(schema)
137+
# Workaround for https://github.com/pydantic/pydantic/issues/12123
138+
if 'additionalProperties' not in json_schema: # pragma: no branch
139+
extra = schema.get('extra_behavior') or schema.get('config', {}).get('extra_fields_behavior')
140+
if extra == 'allow':
141+
extras_schema = schema.get('extras_schema', None)
142+
if extras_schema is not None:
143+
json_schema['additionalProperties'] = self.generate_inner(extras_schema) or True
144+
else:
145+
json_schema['additionalProperties'] = True # pragma: no cover
146+
elif extra == 'forbid':
147+
json_schema['additionalProperties'] = False
148+
return json_schema
141149

142150
def _named_required_fields_schema(self, named_required_fields: Sequence[tuple[str, bool, Any]]) -> JsonSchemaValue:
143151
# Remove largely-useless property titles

pydantic_evals/pydantic_evals/dataset.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@
3838
from ._utils import get_unwrapped_function_name, task_group_gather
3939
from .evaluators import EvaluationResult, Evaluator
4040
from .evaluators._run_evaluator import run_evaluator
41-
from .evaluators._spec import EvaluatorSpec
4241
from .evaluators.common import DEFAULT_EVALUATORS
4342
from .evaluators.context import EvaluatorContext
43+
from .evaluators.spec import EvaluatorSpec
4444
from .otel import SpanTree
4545
from .otel._context_subtree import context_subtree
4646
from .reporting import EvaluationReport, ReportCase, ReportCaseAggregate

pydantic_evals/pydantic_evals/evaluators/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
Python,
1111
)
1212
from .context import EvaluatorContext
13-
from .evaluator import EvaluationReason, EvaluationResult, Evaluator, EvaluatorOutput
13+
from .evaluator import EvaluationReason, EvaluationResult, Evaluator, EvaluatorOutput, EvaluatorSpec
1414

1515
__all__ = (
1616
# common
@@ -27,7 +27,8 @@
2727
'EvaluatorContext',
2828
# evaluator
2929
'Evaluator',
30-
'EvaluationReason',
3130
'EvaluatorOutput',
31+
'EvaluatorSpec',
32+
'EvaluationReason',
3233
'EvaluationResult',
3334
)

pydantic_evals/pydantic_evals/evaluators/_run_evaluator.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ async def run_evaluator(
4848
for name, result in results.items():
4949
if not isinstance(result, EvaluationReason):
5050
result = EvaluationReason(value=result)
51-
details.append(EvaluationResult(name=name, value=result.value, reason=result.reason, source=evaluator))
51+
details.append(
52+
EvaluationResult(name=name, value=result.value, reason=result.reason, source=evaluator.as_spec())
53+
)
5254

5355
return details
5456

pydantic_evals/pydantic_evals/evaluators/evaluator.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,16 @@
1717
from pydantic_ai import _utils
1818

1919
from .._utils import get_event_loop
20-
from ._spec import EvaluatorSpec
2120
from .context import EvaluatorContext
21+
from .spec import EvaluatorSpec
2222

2323
__all__ = (
2424
'EvaluationReason',
2525
'EvaluationResult',
2626
'EvaluationScalar',
2727
'Evaluator',
2828
'EvaluatorOutput',
29+
'EvaluatorSpec',
2930
)
3031

3132
EvaluationScalar = Union[bool, int, float, str]
@@ -71,13 +72,13 @@ class EvaluationResult(Generic[EvaluationScalarT]):
7172
name: The name of the evaluation.
7273
value: The scalar result of the evaluation.
7374
reason: An optional explanation of the evaluation result.
74-
source: The evaluator that produced this result.
75+
source: The spec of the evaluator that produced this result.
7576
"""
7677

7778
name: str
7879
value: EvaluationScalarT
7980
reason: str | None
80-
source: Evaluator
81+
source: EvaluatorSpec
8182

8283
def downcast(self, *value_types: type[T]) -> EvaluationResult[T] | None:
8384
"""Attempt to downcast this result to a more specific type.
@@ -246,6 +247,13 @@ def serialize(self, info: SerializationInfo) -> Any:
246247
Returns:
247248
A JSON-serializable representation of this evaluator as an EvaluatorSpec.
248249
"""
250+
return to_jsonable_python(
251+
self.as_spec(),
252+
context=info.context,
253+
serialize_unknown=True,
254+
)
255+
256+
def as_spec(self) -> EvaluatorSpec:
249257
raw_arguments = self.build_serialization_arguments()
250258

251259
arguments: None | tuple[Any,] | dict[str, Any]
@@ -255,11 +263,8 @@ def serialize(self, info: SerializationInfo) -> Any:
255263
arguments = (next(iter(raw_arguments.values())),)
256264
else:
257265
arguments = raw_arguments
258-
return to_jsonable_python(
259-
EvaluatorSpec(name=self.get_serialization_name(), arguments=arguments),
260-
context=info.context,
261-
serialize_unknown=True,
262-
)
266+
267+
return EvaluatorSpec(name=self.get_serialization_name(), arguments=arguments)
263268

264269
def build_serialization_arguments(self) -> dict[str, Any]:
265270
"""Build the arguments for serialization.

0 commit comments

Comments
 (0)