diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19568692f..2e5c1375e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,7 @@ jobs: env: UV_PYTHON: ${{ matrix.python-version }} CI: true + COVERAGE_PROCESS_START: ./pyproject.toml steps: - uses: actions/checkout@v4 @@ -151,20 +152,20 @@ jobs: with: deno-version: v2.x - - run: mkdir coverage + - run: mkdir .coverage # run tests with just `pydantic-ai-slim` dependencies - - run: uv run --package pydantic-ai-slim coverage run -m pytest + - run: uv run --package pydantic-ai-slim coverage run -m pytest -n auto --dist=loadgroup env: - COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-slim + COVERAGE_FILE: .coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-slim - - run: uv run coverage run -m pytest + - run: uv run coverage run -m pytest -n auto --dist=loadgroup env: - COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-standard + COVERAGE_FILE: .coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-standard - - run: uv run --all-extras coverage run -m pytest + - run: uv run --all-extras coverage run -m pytest -n auto --dist=loadgroup env: - COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-all-extras + COVERAGE_FILE: .coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-all-extras - run: uv run --all-extras python tests/import_examples.py @@ -173,15 +174,15 @@ jobs: if: matrix.python-version != '3.9' run: | unset UV_FROZEN - uv run --all-extras --resolution lowest-direct coverage run -m pytest + uv run --all-extras --resolution lowest-direct coverage run -m pytest -n auto --dist=loadgroup env: - COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-lowest-versions + COVERAGE_FILE: .coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-lowest-versions - name: store coverage files uses: actions/upload-artifact@v4 with: name: coverage-${{ matrix.python-version }} - path: coverage + path: .coverage include-hidden-files: true coverage: @@ -197,15 +198,15 @@ jobs: uses: actions/download-artifact@v4 with: merge-multiple: true - path: coverage + path: .coverage - uses: astral-sh/setup-uv@v5 with: enable-cache: true - run: uv sync --package pydantic-ai-slim --only-dev - - run: rm coverage/.coverage.*-py3.9-* # Exclude 3.9 coverage as it gets the wrong line numbers, causing invalid failures. - - run: uv run coverage combine coverage + - run: rm .coverage/.coverage.*-py3.9-* # Exclude 3.9 coverage as it gets the wrong line numbers, causing invalid failures. + - run: uv run coverage combine - run: uv run coverage html --show-contexts --title "Pydantic AI coverage for ${{ github.sha }}" @@ -228,7 +229,10 @@ jobs: - run: uv run coverage report --fail-under 100 - run: uv run diff-cover coverage.xml --fail-under 100 + - run: uv run strict-no-cover + env: + COVERAGE_FILE: .coverage/.coverage test-mcp-run-python: runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index cdedb5dfd..8449a03f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Format code**: `make format` - **Lint code**: `make lint` - **Type checking**: `make typecheck` (uses pyright) or `make typecheck-both` (pyright + mypy) -- **Run tests**: `make test` (with coverage) or `make test-fast` (parallel, no coverage) +- **Run tests**: `make test` (with coverage) - **Build docs**: `make docs` or `make docs-serve` (local development) ### Single Test Commands diff --git a/Makefile b/Makefile index 3bbb326e0..5a4fbb251 100644 --- a/Makefile +++ b/Makefile @@ -61,13 +61,10 @@ typecheck-both: typecheck-pyright typecheck-mypy .PHONY: test test: ## Run tests and collect coverage data - uv run coverage run -m pytest + COVERAGE_PROCESS_START=./pyproject.toml uv run coverage run -m pytest -n auto --dist=loadgroup + @uv run coverage combine @uv run coverage report -.PHONY: test-fast -test-fast: ## Same as test except no coverage and 4x faster depending on hardware - uv run pytest -n auto --dist=loadgroup - .PHONY: test-all-python test-all-python: ## Run tests on Python 3.9 to 3.13 UV_PROJECT_ENVIRONMENT=.venv39 uv run --python 3.9 --all-extras --all-packages coverage run -p -m pytest diff --git a/docs/changelog.md b/docs/changelog.md index 571e4c8fd..4aec0c9f9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,6 +12,12 @@ Pydantic AI is still pre-version 1, so breaking changes will occur, however: !!! note Here's a filtered list of the breaking changes for each version to help you upgrade Pydantic AI. +### v0.5.0 (2025-08-04) + +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. + +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. + ### v0.4.0 (2025-07-08) 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. diff --git a/docs/install.md b/docs/install.md index 610b24722..7b3bca8fc 100644 --- a/docs/install.md +++ b/docs/install.md @@ -50,12 +50,18 @@ pip/uv-add "pydantic-ai-slim[openai]" * `evals` — installs [`pydantic-evals`](evals.md) [PyPI ↗](https://pypi.org/project/pydantic-evals){:target="_blank"} * `openai` — installs `openai` [PyPI ↗](https://pypi.org/project/openai){:target="_blank"} * `vertexai` — installs `google-auth` [PyPI ↗](https://pypi.org/project/google-auth){:target="_blank"} and `requests` [PyPI ↗](https://pypi.org/project/requests){:target="_blank"} +* `google` — installs `google-genai` [PyPI ↗](https://pypi.org/project/google-genai){:target="_blank"} * `anthropic` — installs `anthropic` [PyPI ↗](https://pypi.org/project/anthropic){:target="_blank"} * `groq` — installs `groq` [PyPI ↗](https://pypi.org/project/groq){:target="_blank"} * `mistral` — installs `mistralai` [PyPI ↗](https://pypi.org/project/mistralai){:target="_blank"} * `cohere` - installs `cohere` [PyPI ↗](https://pypi.org/project/cohere){:target="_blank"} +* `bedrock` - installs `boto3` [PyPI ↗](https://pypi.org/project/boto3){:target="_blank"} +* `huggingface` - installs `huggingface-hub[inference]` [PyPI ↗](https://pypi.org/project/huggingface-hub){:target="_blank"} * `duckduckgo` - installs `ddgs` [PyPI ↗](https://pypi.org/project/ddgs){:target="_blank"} * `tavily` - installs `tavily-python` [PyPI ↗](https://pypi.org/project/tavily-python){:target="_blank"} +* `cli` - installs `rich` [PyPI ↗](https://pypi.org/project/rich){:target="_blank"}, `prompt-toolkit` [PyPI ↗](https://pypi.org/project/prompt-toolkit){:target="_blank"}, and `argcomplete` [PyPI ↗](https://pypi.org/project/argcomplete){:target="_blank"} +* `mcp` - installs `mcp` [PyPI ↗](https://pypi.org/project/mcp){:target="_blank"} +* `a2a` - installs `fasta2a` [PyPI ↗](https://pypi.org/project/fasta2a){:target="_blank"} * `ag-ui` - installs `ag-ui-protocol` [PyPI ↗](https://pypi.org/project/ag-ui-protocol){:target="_blank"} and `starlette` [PyPI ↗](https://pypi.org/project/starlette){:target="_blank"} See the [models](models/index.md) documentation for information on which optional dependencies are required for each model. diff --git a/docs/tools.md b/docs/tools.md index dd5c900b0..339536f6a 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -12,7 +12,7 @@ There are a number of ways to register tools with an agent: - 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] - 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] -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`. +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. !!! info "Function tools vs. RAG" 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: 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`. +### Parallel tool calls & concurrency + +When a model returns multiple tool calls in one response, Pydantic AI schedules them concurrently using `asyncio.create_task`. + +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. + ## Third-Party Tools ### MCP Tools {#mcp-tools} diff --git a/pydantic_ai_slim/pydantic_ai/_agent_graph.py b/pydantic_ai_slim/pydantic_ai/_agent_graph.py index 12e6e07fe..2bccc8e1c 100644 --- a/pydantic_ai_slim/pydantic_ai/_agent_graph.py +++ b/pydantic_ai_slim/pydantic_ai/_agent_graph.py @@ -620,7 +620,7 @@ async def process_function_tools( # noqa: C901 result_data = await tool_manager.handle_call(call) except exceptions.UnexpectedModelBehavior as e: ctx.state.increment_retries(ctx.deps.max_result_retries, e) - raise e # pragma: no cover + raise e # pragma: lax no cover except ToolRetryError as e: ctx.state.increment_retries(ctx.deps.max_result_retries, e) yield _messages.FunctionToolCallEvent(call) diff --git a/pydantic_ai_slim/pydantic_ai/_function_schema.py b/pydantic_ai_slim/pydantic_ai/_function_schema.py index 730948c7e..cdbedb9cf 100644 --- a/pydantic_ai_slim/pydantic_ai/_function_schema.py +++ b/pydantic_ai_slim/pydantic_ai/_function_schema.py @@ -154,9 +154,13 @@ def function_schema( # noqa: C901 if p.kind == Parameter.VAR_POSITIONAL: annotation = list[annotation] - # FieldInfo.from_annotation expects a type, `annotation` is Any + required = p.default is Parameter.empty + # FieldInfo.from_annotated_attribute expects a type, `annotation` is Any annotation = cast(type[Any], annotation) - field_info = FieldInfo.from_annotation(annotation) + if required: + field_info = FieldInfo.from_annotation(annotation) + else: + field_info = FieldInfo.from_annotated_attribute(annotation, p.default) if field_info.description is None: field_info.description = field_descriptions.get(field_name) @@ -164,7 +168,7 @@ def function_schema( # noqa: C901 field_name, field_info, decorators, - required=p.default is Parameter.empty, + required=required, ) # noinspection PyTypeChecker td_schema.setdefault('metadata', {})['is_model_like'] = is_model_like(annotation) @@ -281,7 +285,6 @@ def _build_schema( td_schema = core_schema.typed_dict_schema( fields, config=core_config, - total=var_kwargs_schema is None, extras_schema=gen_schema.generate_schema(var_kwargs_schema) if var_kwargs_schema else None, ) return td_schema, None diff --git a/pydantic_ai_slim/pydantic_ai/exceptions.py b/pydantic_ai_slim/pydantic_ai/exceptions.py index 344ab94da..ee6349093 100644 --- a/pydantic_ai_slim/pydantic_ai/exceptions.py +++ b/pydantic_ai_slim/pydantic_ai/exceptions.py @@ -5,9 +5,9 @@ from typing import TYPE_CHECKING if sys.version_info < (3, 11): - from exceptiongroup import ExceptionGroup + from exceptiongroup import ExceptionGroup as ExceptionGroup # pragma: lax no cover else: - ExceptionGroup = ExceptionGroup + ExceptionGroup = ExceptionGroup # pragma: lax no cover if TYPE_CHECKING: from .messages import RetryPromptPart diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py index b5d7be285..195c4b791 100644 --- a/pydantic_ai_slim/pydantic_ai/messages.py +++ b/pydantic_ai_slim/pydantic_ai/messages.py @@ -106,7 +106,7 @@ class FileUrl(ABC): - `GoogleModel`: `VideoUrl.vendor_metadata` is used as `video_metadata`: https://ai.google.dev/gemini-api/docs/video-understanding#customize-video-processing """ - _media_type: str | None = field(init=False, repr=False) + _media_type: str | None = field(init=False, repr=False, compare=False) def __init__( self, @@ -120,19 +120,21 @@ def __init__( self.force_download = force_download self._media_type = media_type - @abstractmethod - def _infer_media_type(self) -> str: - """Return the media type of the file, based on the url.""" - @property def media_type(self) -> str: - """Return the media type of the file, based on the url or the provided `_media_type`.""" + """Return the media type of the file, based on the URL or the provided `media_type`.""" return self._media_type or self._infer_media_type() + @abstractmethod + def _infer_media_type(self) -> str: + """Infer the media type of the file based on the URL.""" + raise NotImplementedError + @property @abstractmethod def format(self) -> str: """The file format.""" + raise NotImplementedError __repr__ = _utils.dataclasses_no_defaults_repr @@ -182,7 +184,9 @@ def _infer_media_type(self) -> VideoMediaType: elif self.is_youtube: return 'video/mp4' else: - raise ValueError(f'Unknown video file extension: {self.url}') + raise ValueError( + f'Could not infer media type from video URL: {self.url}. Explicitly provide a `media_type` instead.' + ) @property def is_youtube(self) -> bool: @@ -238,7 +242,9 @@ def _infer_media_type(self) -> AudioMediaType: if self.url.endswith('.aac'): return 'audio/aac' - raise ValueError(f'Unknown audio file extension: {self.url}') + raise ValueError( + f'Could not infer media type from audio URL: {self.url}. Explicitly provide a `media_type` instead.' + ) @property def format(self) -> AudioFormat: @@ -278,7 +284,9 @@ def _infer_media_type(self) -> ImageMediaType: elif self.url.endswith('.webp'): return 'image/webp' else: - raise ValueError(f'Unknown image file extension: {self.url}') + raise ValueError( + f'Could not infer media type from image URL: {self.url}. Explicitly provide a `media_type` instead.' + ) @property def format(self) -> ImageFormat: @@ -312,9 +320,28 @@ def __init__( def _infer_media_type(self) -> str: """Return the media type of the document, based on the url.""" + # Common document types are hardcoded here as mime-type support for these + # extensions varies across operating systems. + if self.url.endswith(('.md', '.mdx', '.markdown')): + return 'text/markdown' + elif self.url.endswith('.asciidoc'): + return 'text/x-asciidoc' + elif self.url.endswith('.txt'): + return 'text/plain' + elif self.url.endswith('.pdf'): + return 'application/pdf' + elif self.url.endswith('.rtf'): + return 'application/rtf' + elif self.url.endswith('.docx'): + return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + elif self.url.endswith('.xlsx'): + return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + type_, _ = guess_type(self.url) if type_ is None: - raise ValueError(f'Unknown document file extension: {self.url}') + raise ValueError( + f'Could not infer media type from document URL: {self.url}. Explicitly provide a `media_type` instead.' + ) return type_ @property diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index 02f9111c2..3567f86c6 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -256,7 +256,7 @@ async def _messages_create( except APIStatusError as e: if (status_code := e.status_code) >= 400: raise ModelHTTPError(status_code=status_code, model_name=self.model_name, body=e.body) from e - raise # pragma: no cover + raise # pragma: lax no cover def _process_response(self, response: BetaMessage) -> ModelResponse: """Process a non-streamed response, and prepare a message to return.""" diff --git a/pydantic_ai_slim/pydantic_ai/models/bedrock.py b/pydantic_ai_slim/pydantic_ai/models/bedrock.py index b63ed4e1f..9fb152444 100644 --- a/pydantic_ai_slim/pydantic_ai/models/bedrock.py +++ b/pydantic_ai_slim/pydantic_ai/models/bedrock.py @@ -665,4 +665,4 @@ async def __anext__(self) -> T: if type(e.__cause__) is StopIteration: raise StopAsyncIteration else: - raise e # pragma: no cover + raise e # pragma: lax no cover diff --git a/pydantic_ai_slim/pydantic_ai/models/cohere.py b/pydantic_ai_slim/pydantic_ai/models/cohere.py index 994aeb991..5c4577fa5 100644 --- a/pydantic_ai_slim/pydantic_ai/models/cohere.py +++ b/pydantic_ai_slim/pydantic_ai/models/cohere.py @@ -183,7 +183,7 @@ async def _chat( except ApiError as e: if (status_code := e.status_code) and status_code >= 400: raise ModelHTTPError(status_code=status_code, model_name=self.model_name, body=e.body) from e - raise # pragma: no cover + raise # pragma: lax no cover def _process_response(self, response: V2ChatResponse) -> ModelResponse: """Process a non-streamed response, and prepare a message to return.""" diff --git a/pydantic_ai_slim/pydantic_ai/models/gemini.py b/pydantic_ai_slim/pydantic_ai/models/gemini.py index 0f81f21c3..eaf7944c2 100644 --- a/pydantic_ai_slim/pydantic_ai/models/gemini.py +++ b/pydantic_ai_slim/pydantic_ai/models/gemini.py @@ -236,7 +236,7 @@ async def _make_request( if gemini_labels := model_settings.get('gemini_labels'): if self._system == 'google-vertex': - request_data['labels'] = gemini_labels + request_data['labels'] = gemini_labels # pragma: lax no cover headers = {'Content-Type': 'application/json', 'User-Agent': get_user_agent()} url = f'/{self._model_name}:{"streamGenerateContent" if streamed else "generateContent"}' @@ -366,11 +366,11 @@ async def _map_user_prompt(self, part: UserPromptPart) -> list[_GeminiPartUnion] inline_data={'data': downloaded_item['data'], 'mime_type': downloaded_item['data_type']} ) content.append(inline_data) - else: + else: # pragma: lax no cover file_data = _GeminiFileDataPart(file_data={'file_uri': item.url, 'mime_type': item.media_type}) content.append(file_data) else: - assert_never(item) + assert_never(item) # pragma: lax no cover return content def _map_response_schema(self, o: OutputObjectDefinition) -> dict[str, Any]: diff --git a/pydantic_ai_slim/pydantic_ai/models/google.py b/pydantic_ai_slim/pydantic_ai/models/google.py index a6afab97d..4524f090a 100644 --- a/pydantic_ai_slim/pydantic_ai/models/google.py +++ b/pydantic_ai_slim/pydantic_ai/models/google.py @@ -407,7 +407,7 @@ async def _map_user_prompt(self, part: UserPromptPart) -> list[PartDict]: content.append(inline_data_dict) # type: ignore elif isinstance(item, VideoUrl) and item.is_youtube: file_data_dict = {'file_data': {'file_uri': item.url, 'mime_type': item.media_type}} - if item.vendor_metadata: + if item.vendor_metadata: # pragma: no branch file_data_dict['video_metadata'] = item.vendor_metadata content.append(file_data_dict) # type: ignore elif isinstance(item, FileUrl): @@ -421,7 +421,9 @@ async def _map_user_prompt(self, part: UserPromptPart) -> list[PartDict]: inline_data = {'data': downloaded_item['data'], 'mime_type': downloaded_item['data_type']} content.append({'inline_data': inline_data}) # type: ignore else: - content.append({'file_data': {'file_uri': item.url, 'mime_type': item.media_type}}) + content.append( + {'file_data': {'file_uri': item.url, 'mime_type': item.media_type}} + ) # pragma: lax no cover else: assert_never(item) return content diff --git a/pydantic_ai_slim/pydantic_ai/models/groq.py b/pydantic_ai_slim/pydantic_ai/models/groq.py index 3ccf0962a..15e916149 100644 --- a/pydantic_ai_slim/pydantic_ai/models/groq.py +++ b/pydantic_ai_slim/pydantic_ai/models/groq.py @@ -249,7 +249,7 @@ async def _completions_create( except APIStatusError as e: if (status_code := e.status_code) >= 400: raise ModelHTTPError(status_code=status_code, model_name=self.model_name, body=e.body) from e - raise # pragma: no cover + raise # pragma: lax no cover def _process_response(self, response: chat.ChatCompletion) -> ModelResponse: """Process a non-streamed response, and prepare a message to return.""" diff --git a/pydantic_ai_slim/pydantic_ai/models/instrumented.py b/pydantic_ai_slim/pydantic_ai/models/instrumented.py index 233020f6f..ff9d82d6a 100644 --- a/pydantic_ai_slim/pydantic_ai/models/instrumented.py +++ b/pydantic_ai_slim/pydantic_ai/models/instrumented.py @@ -18,11 +18,7 @@ from opentelemetry.util.types import AttributeValue from pydantic import TypeAdapter -from ..messages import ( - ModelMessage, - ModelRequest, - ModelResponse, -) +from ..messages import ModelMessage, ModelRequest, ModelResponse from ..settings import ModelSettings from . import KnownModelName, Model, ModelRequestParameters, StreamedResponse from .wrapper import WrapperModel @@ -138,7 +134,7 @@ def __init__( **tokens_histogram_kwargs, explicit_bucket_boundaries_advisory=TOKEN_HISTOGRAM_BOUNDARIES, ) - except TypeError: + except TypeError: # pragma: lax no cover # Older OTel/logfire versions don't support explicit_bucket_boundaries_advisory self.tokens_histogram = self.meter.create_histogram( **tokens_histogram_kwargs, # pyright: ignore diff --git a/pydantic_ai_slim/pydantic_ai/models/mistral.py b/pydantic_ai_slim/pydantic_ai/models/mistral.py index 3eb7c102c..db60b9cd0 100644 --- a/pydantic_ai_slim/pydantic_ai/models/mistral.py +++ b/pydantic_ai_slim/pydantic_ai/models/mistral.py @@ -218,7 +218,7 @@ async def _completions_create( except SDKError as e: if (status_code := e.status_code) >= 400: raise ModelHTTPError(status_code=status_code, model_name=self.model_name, body=e.body) from e - raise # pragma: no cover + raise # pragma: lax no cover assert response, 'A unexpected empty response from Mistral.' return response diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 8fabd2aad..793b9d401 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -359,7 +359,7 @@ async def _completions_create( except APIStatusError as e: if (status_code := e.status_code) >= 400: raise ModelHTTPError(status_code=status_code, model_name=self.model_name, body=e.body) from e - raise # pragma: no cover + raise # pragma: lax no cover def _process_response(self, response: chat.ChatCompletion | str) -> ModelResponse: """Process a non-streamed response, and prepare a message to return.""" @@ -814,7 +814,7 @@ async def _responses_create( except APIStatusError as e: if (status_code := e.status_code) >= 400: raise ModelHTTPError(status_code=status_code, model_name=self.model_name, body=e.body) from e - raise # pragma: no cover + raise # pragma: lax no cover def _get_reasoning(self, model_settings: OpenAIResponsesModelSettings) -> Reasoning | NotGiven: reasoning_effort = model_settings.get('openai_reasoning_effort', None) diff --git a/pydantic_ai_slim/pydantic_ai/profiles/_json_schema.py b/pydantic_ai_slim/pydantic_ai/profiles/_json_schema.py index cf13b41e9..9bb5938b6 100644 --- a/pydantic_ai_slim/pydantic_ai/profiles/_json_schema.py +++ b/pydantic_ai_slim/pydantic_ai/profiles/_json_schema.py @@ -137,8 +137,9 @@ def _handle_array(self, schema: JsonSchema) -> JsonSchema: return schema def _handle_union(self, schema: JsonSchema, union_kind: Literal['anyOf', 'oneOf']) -> JsonSchema: - members = schema.get(union_kind) - if not members: + try: + members = schema.pop(union_kind) + except KeyError: return schema handled = [self._handle(member) for member in members] @@ -149,7 +150,7 @@ def _handle_union(self, schema: JsonSchema, union_kind: Literal['anyOf', 'oneOf' if len(handled) == 1: # In this case, no need to retain the union - return handled[0] + return handled[0] | schema # If we have keys besides the union kind (such as title or discriminator), keep them without modifications schema = schema.copy() diff --git a/pydantic_ai_slim/pydantic_ai/profiles/openai.py b/pydantic_ai_slim/pydantic_ai/profiles/openai.py index 258a86305..103a1e661 100644 --- a/pydantic_ai_slim/pydantic_ai/profiles/openai.py +++ b/pydantic_ai_slim/pydantic_ai/profiles/openai.py @@ -47,11 +47,6 @@ def openai_model_profile(model_name: str) -> ModelProfile: _STRICT_INCOMPATIBLE_KEYS = [ 'minLength', 'maxLength', - 'pattern', - 'format', - 'minimum', - 'maximum', - 'multipleOf', 'patternProperties', 'unevaluatedProperties', 'propertyNames', @@ -61,11 +56,21 @@ def openai_model_profile(model_name: str) -> ModelProfile: 'contains', 'minContains', 'maxContains', - 'minItems', - 'maxItems', 'uniqueItems', ] +_STRICT_COMPATIBLE_STRING_FORMATS = [ + 'date-time', + 'time', + 'date', + 'duration', + 'email', + 'hostname', + 'ipv4', + 'ipv6', + 'uuid', +] + _sentinel = object() @@ -127,6 +132,9 @@ def transform(self, schema: JsonSchema) -> JsonSchema: # noqa C901 value = schema.get(key, _sentinel) if value is not _sentinel: incompatible_values[key] = value + if format := schema.get('format'): + if format not in _STRICT_COMPATIBLE_STRING_FORMATS: + incompatible_values['format'] = format description = schema.get('description') if incompatible_values: if self.strict is True: @@ -158,11 +166,13 @@ def transform(self, schema: JsonSchema) -> JsonSchema: # noqa C901 schema['required'] = list(schema['properties'].keys()) elif self.strict is None: - if ( - schema.get('additionalProperties') is not False - or 'properties' not in schema - or 'required' not in schema - ): + if schema.get('additionalProperties', None) not in (None, False): + self.is_strict_compatible = False + else: + # additional properties are disallowed by default + schema['additionalProperties'] = False + + if 'properties' not in schema or 'required' not in schema: self.is_strict_compatible = False else: required = schema['required'] diff --git a/pydantic_ai_slim/pydantic_ai/tools.py b/pydantic_ai_slim/pydantic_ai/tools.py index 14b5d12f3..4df9ad940 100644 --- a/pydantic_ai_slim/pydantic_ai/tools.py +++ b/pydantic_ai_slim/pydantic_ai/tools.py @@ -133,11 +133,19 @@ async def turn_on_strict_if_openai( class GenerateToolJsonSchema(GenerateJsonSchema): def typed_dict_schema(self, schema: core_schema.TypedDictSchema) -> JsonSchemaValue: - s = super().typed_dict_schema(schema) - total = schema.get('total') - if 'additionalProperties' not in s and (total is True or total is None): - s['additionalProperties'] = False - return s + json_schema = super().typed_dict_schema(schema) + # Workaround for https://github.com/pydantic/pydantic/issues/12123 + if 'additionalProperties' not in json_schema: # pragma: no branch + extra = schema.get('extra_behavior') or schema.get('config', {}).get('extra_fields_behavior') + if extra == 'allow': + extras_schema = schema.get('extras_schema', None) + if extras_schema is not None: + json_schema['additionalProperties'] = self.generate_inner(extras_schema) or True + else: + json_schema['additionalProperties'] = True # pragma: no cover + elif extra == 'forbid': + json_schema['additionalProperties'] = False + return json_schema def _named_required_fields_schema(self, named_required_fields: Sequence[tuple[str, bool, Any]]) -> JsonSchemaValue: # Remove largely-useless property titles diff --git a/pydantic_ai_slim/pyproject.toml b/pydantic_ai_slim/pyproject.toml index 17825253f..34afce9d6 100644 --- a/pydantic_ai_slim/pyproject.toml +++ b/pydantic_ai_slim/pyproject.toml @@ -51,7 +51,7 @@ dependencies = [ "eval-type-backport>=0.2.0", "griffe>=1.3.2", "httpx>=0.27", - "pydantic>=2.10", + "pydantic>=2.12.0a1", "pydantic-graph=={{ version }}", "exceptiongroup; python_version < '3.11'", "opentelemetry-api>=1.28.0", @@ -73,7 +73,7 @@ bedrock = ["boto3>=1.37.24"] huggingface = ["huggingface-hub[inference]>=0.33.5"] # Tools duckduckgo = ["ddgs>=9.0.0"] -tavily = ["tavily-python>=0.5.0"] +tavily = ["tavily-python>=0.5.0; python_version < '3.14'"] # CLI cli = ["rich>=13", "prompt-toolkit>=3", "argcomplete>=3.5.0"] # MCP @@ -92,7 +92,7 @@ dev = [ "anyio>=4.5.0", "asgi-lifespan>=2.1.0", "devtools>=0.12.2", - "coverage[toml]>=7.6.2", + "coverage[toml]>=7.10.2", "dirty-equals>=0.9.0", "duckduckgo-search>=7.0.0; python_version < '3.14'", "inline-snapshot>=0.19.3", @@ -103,8 +103,9 @@ dev = [ "pytest-recording>=0.13.2", "diff-cover>=9.2.0", "boto3-stubs[bedrock-runtime]", - "strict-no-cover>=0.1.1", + "strict-no-cover @ git+https://github.com/pydantic/strict-no-cover.git@7fc59da2c4dff919db2095a0f0e47101b657131d", "pytest-xdist>=3.6.1", + "coverage-enable-subprocess>=0.1.0", ] [tool.hatch.metadata] diff --git a/pydantic_evals/pydantic_evals/dataset.py b/pydantic_evals/pydantic_evals/dataset.py index 05d2de80a..5aa040cc2 100644 --- a/pydantic_evals/pydantic_evals/dataset.py +++ b/pydantic_evals/pydantic_evals/dataset.py @@ -38,9 +38,9 @@ from ._utils import get_unwrapped_function_name, task_group_gather from .evaluators import EvaluationResult, Evaluator from .evaluators._run_evaluator import run_evaluator -from .evaluators._spec import EvaluatorSpec from .evaluators.common import DEFAULT_EVALUATORS from .evaluators.context import EvaluatorContext +from .evaluators.spec import EvaluatorSpec from .otel import SpanTree from .otel._context_subtree import context_subtree from .reporting import EvaluationReport, ReportCase, ReportCaseAggregate @@ -1038,7 +1038,7 @@ def _get_span_duration(span: logfire_api.LogfireSpan, fallback: float) -> float: """ try: return (span.end_time - span.start_time) / 1_000_000_000 # type: ignore - except (AttributeError, TypeError): # pragma: no cover + except (AttributeError, TypeError): # pragma: lax no cover return fallback diff --git a/pydantic_evals/pydantic_evals/evaluators/__init__.py b/pydantic_evals/pydantic_evals/evaluators/__init__.py index 24ebafcfb..36aa95cae 100644 --- a/pydantic_evals/pydantic_evals/evaluators/__init__.py +++ b/pydantic_evals/pydantic_evals/evaluators/__init__.py @@ -10,7 +10,7 @@ Python, ) from .context import EvaluatorContext -from .evaluator import EvaluationReason, EvaluationResult, Evaluator, EvaluatorOutput +from .evaluator import EvaluationReason, EvaluationResult, Evaluator, EvaluatorOutput, EvaluatorSpec __all__ = ( # common @@ -27,7 +27,8 @@ 'EvaluatorContext', # evaluator 'Evaluator', - 'EvaluationReason', 'EvaluatorOutput', + 'EvaluatorSpec', + 'EvaluationReason', 'EvaluationResult', ) diff --git a/pydantic_evals/pydantic_evals/evaluators/_run_evaluator.py b/pydantic_evals/pydantic_evals/evaluators/_run_evaluator.py index bb5eef486..b5b58d8cd 100644 --- a/pydantic_evals/pydantic_evals/evaluators/_run_evaluator.py +++ b/pydantic_evals/pydantic_evals/evaluators/_run_evaluator.py @@ -48,7 +48,9 @@ async def run_evaluator( for name, result in results.items(): if not isinstance(result, EvaluationReason): result = EvaluationReason(value=result) - details.append(EvaluationResult(name=name, value=result.value, reason=result.reason, source=evaluator)) + details.append( + EvaluationResult(name=name, value=result.value, reason=result.reason, source=evaluator.as_spec()) + ) return details diff --git a/pydantic_evals/pydantic_evals/evaluators/evaluator.py b/pydantic_evals/pydantic_evals/evaluators/evaluator.py index 1dc7d6a04..8ca514c91 100644 --- a/pydantic_evals/pydantic_evals/evaluators/evaluator.py +++ b/pydantic_evals/pydantic_evals/evaluators/evaluator.py @@ -17,8 +17,8 @@ from pydantic_ai import _utils from .._utils import get_event_loop -from ._spec import EvaluatorSpec from .context import EvaluatorContext +from .spec import EvaluatorSpec __all__ = ( 'EvaluationReason', @@ -26,6 +26,7 @@ 'EvaluationScalar', 'Evaluator', 'EvaluatorOutput', + 'EvaluatorSpec', ) EvaluationScalar = Union[bool, int, float, str] @@ -71,13 +72,13 @@ class EvaluationResult(Generic[EvaluationScalarT]): name: The name of the evaluation. value: The scalar result of the evaluation. reason: An optional explanation of the evaluation result. - source: The evaluator that produced this result. + source: The spec of the evaluator that produced this result. """ name: str value: EvaluationScalarT reason: str | None - source: Evaluator + source: EvaluatorSpec def downcast(self, *value_types: type[T]) -> EvaluationResult[T] | None: """Attempt to downcast this result to a more specific type. @@ -246,6 +247,13 @@ def serialize(self, info: SerializationInfo) -> Any: Returns: A JSON-serializable representation of this evaluator as an EvaluatorSpec. """ + return to_jsonable_python( + self.as_spec(), + context=info.context, + serialize_unknown=True, + ) + + def as_spec(self) -> EvaluatorSpec: raw_arguments = self.build_serialization_arguments() arguments: None | tuple[Any,] | dict[str, Any] @@ -255,11 +263,8 @@ def serialize(self, info: SerializationInfo) -> Any: arguments = (next(iter(raw_arguments.values())),) else: arguments = raw_arguments - return to_jsonable_python( - EvaluatorSpec(name=self.get_serialization_name(), arguments=arguments), - context=info.context, - serialize_unknown=True, - ) + + return EvaluatorSpec(name=self.get_serialization_name(), arguments=arguments) def build_serialization_arguments(self) -> dict[str, Any]: """Build the arguments for serialization. diff --git a/pydantic_evals/pydantic_evals/evaluators/_spec.py b/pydantic_evals/pydantic_evals/evaluators/spec.py similarity index 94% rename from pydantic_evals/pydantic_evals/evaluators/_spec.py rename to pydantic_evals/pydantic_evals/evaluators/spec.py index d29f1d2f4..f342cd2fc 100644 --- a/pydantic_evals/pydantic_evals/evaluators/_spec.py +++ b/pydantic_evals/pydantic_evals/evaluators/spec.py @@ -30,12 +30,6 @@ class EvaluatorSpec(BaseModel): * `'MyEvaluator'` - Just the (string) name of the Evaluator subclass is used if its `__init__` takes no arguments * `{'MyEvaluator': first_arg}` - A single argument is passed as the first positional argument to `MyEvaluator.__init__` * `{'MyEvaluator': {k1: v1, k2: v2}}` - Multiple kwargs are passed to `MyEvaluator.__init__` - - Args: - name: The serialization name of the evaluator class returned by `EvaluatorClass.get_serialization_name()`; - this is usually just the class name itself. - arguments: The arguments to pass to the evaluator's constructor. Can be None (for no arguments), - a tuple (for a single positional argument), or a dict (for multiple keyword arguments). """ name: str diff --git a/pydantic_evals/pydantic_evals/reporting/__init__.py b/pydantic_evals/pydantic_evals/reporting/__init__.py index 65dae8144..d013af2fa 100644 --- a/pydantic_evals/pydantic_evals/reporting/__init__.py +++ b/pydantic_evals/pydantic_evals/reporting/__init__.py @@ -4,7 +4,7 @@ from collections.abc import Mapping from dataclasses import dataclass from io import StringIO -from typing import Any, Callable, Generic, Literal, Protocol +from typing import Any, Callable, Generic, Literal, Protocol, cast from pydantic import BaseModel, TypeAdapter from rich.console import Console @@ -168,6 +168,7 @@ def print( self, width: int | None = None, baseline: EvaluationReport[InputsT, OutputT, MetadataT] | None = None, + *, include_input: bool = False, include_metadata: bool = False, include_expected_output: bool = False, @@ -183,6 +184,7 @@ def print( label_configs: dict[str, RenderValueConfig] | None = None, metric_configs: dict[str, RenderNumberConfig] | None = None, duration_config: RenderNumberConfig | None = None, + include_reasons: bool = False, ): # pragma: no cover """Print this report to the console, optionally comparing it to a baseline report. @@ -205,12 +207,14 @@ def print( label_configs=label_configs, metric_configs=metric_configs, duration_config=duration_config, + include_reasons=include_reasons, ) Console(width=width).print(table) def console_table( self, baseline: EvaluationReport[InputsT, OutputT, MetadataT] | None = None, + *, include_input: bool = False, include_metadata: bool = False, include_expected_output: bool = False, @@ -226,6 +230,7 @@ def console_table( label_configs: dict[str, RenderValueConfig] | None = None, metric_configs: dict[str, RenderNumberConfig] | None = None, duration_config: RenderNumberConfig | None = None, + include_reasons: bool = False, ) -> Table: """Return a table containing the data from this report, or the diff between this report and a baseline report. @@ -247,6 +252,7 @@ def console_table( label_configs=label_configs or {}, metric_configs=metric_configs or {}, duration_config=duration_config or _DEFAULT_DURATION_CONFIG, + include_reasons=include_reasons, ) if baseline is None: return renderer.build_table(self) @@ -529,15 +535,16 @@ class ReportCaseRenderer: include_labels: bool include_metrics: bool include_assertions: bool + include_reasons: bool include_durations: bool include_total_duration: bool input_renderer: _ValueRenderer metadata_renderer: _ValueRenderer output_renderer: _ValueRenderer - score_renderers: dict[str, _NumberRenderer] - label_renderers: dict[str, _ValueRenderer] - metric_renderers: dict[str, _NumberRenderer] + score_renderers: Mapping[str, _NumberRenderer] + label_renderers: Mapping[str, _ValueRenderer] + metric_renderers: Mapping[str, _NumberRenderer] duration_renderer: _NumberRenderer def build_base_table(self, title: str) -> Table: @@ -581,10 +588,10 @@ def build_row(self, case: ReportCase) -> list[str]: row.append(self.output_renderer.render_value(None, case.output) or EMPTY_CELL_STR) if self.include_scores: - row.append(self._render_dict({k: v.value for k, v in case.scores.items()}, self.score_renderers)) + row.append(self._render_dict({k: v for k, v in case.scores.items()}, self.score_renderers)) if self.include_labels: - row.append(self._render_dict({k: v.value for k, v in case.labels.items()}, self.label_renderers)) + row.append(self._render_dict({k: v for k, v in case.labels.items()}, self.label_renderers)) if self.include_metrics: row.append(self._render_dict(case.metrics, self.metric_renderers)) @@ -669,7 +676,11 @@ def build_diff_row( row.append(scores_diff) if self.include_labels: # pragma: no branch - labels_diff = self._render_dicts_diff(baseline.labels, new_case.labels, self.label_renderers) + labels_diff = self._render_dicts_diff( + {k: v.value for k, v in baseline.labels.items()}, + {k: v.value for k, v in new_case.labels.items()}, + self.label_renderers, + ) row.append(labels_diff) if self.include_metrics: # pragma: no branch @@ -779,26 +790,36 @@ def _render_dicts_diff( diff_lines.append(rendered) return '\n'.join(diff_lines) if diff_lines else EMPTY_CELL_STR - @staticmethod def _render_dict( - case_dict: dict[str, T], + self, + case_dict: Mapping[str, EvaluationResult[T] | T], renderers: Mapping[str, _AbstractRenderer[T]], *, include_names: bool = True, ) -> str: diff_lines: list[str] = [] for key, val in case_dict.items(): - rendered = renderers[key].render_value(key if include_names else None, val) + value = cast(EvaluationResult[T], val).value if isinstance(val, EvaluationResult) else val + rendered = renderers[key].render_value(key if include_names else None, value) + if self.include_reasons and isinstance(val, EvaluationResult) and (reason := val.reason): + rendered += f'\n Reason: {reason}\n' diff_lines.append(rendered) return '\n'.join(diff_lines) if diff_lines else EMPTY_CELL_STR - @staticmethod def _render_assertions( + self, assertions: list[EvaluationResult[bool]], ) -> str: if not assertions: return EMPTY_CELL_STR - return ''.join(['[green]✔[/]' if a.value else '[red]✗[/]' for a in assertions]) + lines: list[str] = [] + for a in assertions: + line = '[green]✔[/]' if a.value else '[red]✗[/]' + if self.include_reasons: + line = f'{a.name}: {line}\n' + line = f'{line} Reason: {a.reason}\n\n' if a.reason else line + lines.append(line) + return ''.join(lines) @staticmethod def _render_aggregate_assertions( @@ -859,6 +880,10 @@ class EvaluationRenderer: metric_configs: dict[str, RenderNumberConfig] duration_config: RenderNumberConfig + # TODO: Make this class kw-only so we can reorder the kwargs + # Data to include + include_reasons: bool # only applies to reports, not to diffs + def include_scores(self, report: EvaluationReport, baseline: EvaluationReport | None = None): return any(case.scores for case in self._all_cases(report, baseline)) @@ -905,6 +930,7 @@ def _get_case_renderer( include_labels=self.include_labels(report, baseline), include_metrics=self.include_metrics(report, baseline), include_assertions=self.include_assertions(report, baseline), + include_reasons=self.include_reasons, include_durations=self.include_durations, include_total_duration=self.include_total_duration, input_renderer=input_renderer, diff --git a/pydantic_evals/pyproject.toml b/pydantic_evals/pyproject.toml index 5b51cf2a1..7ff9b09b3 100644 --- a/pydantic_evals/pyproject.toml +++ b/pydantic_evals/pyproject.toml @@ -50,7 +50,7 @@ requires-python = ">=3.9" dependencies = [ "rich>=13.9.4", "logfire-api>=1.2.0", - "pydantic>=2.10", + "pydantic>=2.12.0a1", "pydantic-ai-slim=={{ version }}", "anyio>=0", "eval-type-backport>=0; python_version < '3.11'", diff --git a/pydantic_graph/pyproject.toml b/pydantic_graph/pyproject.toml index a24d819c5..0d18e8d86 100644 --- a/pydantic_graph/pyproject.toml +++ b/pydantic_graph/pyproject.toml @@ -48,7 +48,7 @@ requires-python = ">=3.9" dependencies = [ "httpx>=0.27", "logfire-api>=1.2.0", - "pydantic>=2.10", + "pydantic>=2.12.0a1", "typing-inspection>=0.4.0", ] diff --git a/pyproject.toml b/pyproject.toml index 632bbea98..7aea844dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -216,6 +216,10 @@ filterwarnings = [ # https://coverage.readthedocs.io/en/latest/config.html#run [tool.coverage.run] +parallel = true +concurrency = ["multiprocessing", "thread"] +# We use a subdirectory for coverage data to avoid noisy coverage data files. +data_file = ".coverage/.coverage" # required to avoid warnings about files created by create_module fixture include = [ "pydantic_ai_slim/**/*.py", @@ -246,6 +250,7 @@ source = [ # https://coverage.readthedocs.io/en/latest/config.html#report [tool.coverage.report] +fail_under = 100 skip_covered = true show_missing = true ignore_errors = true diff --git a/tests/conftest.py b/tests/conftest.py index 0214100c9..4fcfaf20b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -251,7 +251,7 @@ def method_matcher(r1: vcr_request.Request, r2: vcr_request.Request) -> None: def mock_vcr_aiohttp_content(mocker: MockerFixture): try: from vcr.stubs import aiohttp_stubs - except ImportError: + except ImportError: # pragma: lax no cover return # google-genai calls `self.response_stream.content.readline()` where `self.response_stream` is a `MockClientResponse`, @@ -416,9 +416,9 @@ def expired(self) -> bool: @pytest.fixture() -async def vertex_provider(): +async def vertex_provider(): # pragma: lax no cover # NOTE: You need to comment out this line to rewrite the cassettes locally. - if not os.getenv('CI', False): # pragma: lax no cover + if not os.getenv('CI', False): pytest.skip('Requires properly configured local google vertex config to pass') try: diff --git a/tests/evals/test_dataset.py b/tests/evals/test_dataset.py index b1842ed44..e1c98aa55 100644 --- a/tests/evals/test_dataset.py +++ b/tests/evals/test_dataset.py @@ -7,11 +7,11 @@ from typing import Any import pytest -from dirty_equals import HasRepr, IsNumber +from dirty_equals import HasRepr, IsNumber, IsPartialDict from inline_snapshot import snapshot -from pydantic import BaseModel +from pydantic import BaseModel, TypeAdapter -from ..conftest import IsStr, try_import +from ..conftest import try_import from .utils import render_table with try_import() as imports_successful: @@ -20,7 +20,7 @@ from pydantic_evals import Case, Dataset from pydantic_evals.dataset import increment_eval_metric, set_eval_attribute - from pydantic_evals.evaluators import EvaluationResult, Evaluator, EvaluatorOutput, LLMJudge, Python + from pydantic_evals.evaluators import EvaluationResult, Evaluator, EvaluatorOutput, EvaluatorSpec, LLMJudge, Python from pydantic_evals.evaluators.context import EvaluatorContext @dataclass @@ -32,7 +32,7 @@ class MockEvaluator(Evaluator[object, object, object]): def evaluate(self, ctx: EvaluatorContext[object, object, object]) -> EvaluatorOutput: return self.output - from pydantic_evals.reporting import ReportCase, ReportCaseAdapter + from pydantic_evals.reporting import EvaluationReport, ReportCase, ReportCaseAdapter pytestmark = [pytest.mark.skipif(not imports_successful(), reason='pydantic-evals not installed'), pytest.mark.anyio] @@ -456,13 +456,13 @@ async def my_task(inputs: TaskInput) -> TaskOutput: scores={}, labels={ 'output': EvaluationResult( - name='output', value='a', reason=None, source=MockEvaluator(output={'output': 'a'}) + name='output', value='a', reason=None, source=MockEvaluator(output={'output': 'a'}).as_spec() ), 'output_2': EvaluationResult( - name='output', value='b', reason=None, source=MockEvaluator(output={'output': 'b'}) + name='output', value='b', reason=None, source=MockEvaluator(output={'output': 'b'}).as_spec() ), 'output_3': EvaluationResult( - name='output', value='c', reason=None, source=MockEvaluator(output={'output': 'c'}) + name='output', value='c', reason=None, source=MockEvaluator(output={'output': 'c'}).as_spec() ), }, assertions={}, @@ -482,13 +482,13 @@ async def my_task(inputs: TaskInput) -> TaskOutput: scores={}, labels={ 'output': EvaluationResult( - name='output', value='a', reason=None, source=MockEvaluator(output={'output': 'a'}) + name='output', value='a', reason=None, source=MockEvaluator(output={'output': 'a'}).as_spec() ), 'output_2': EvaluationResult( - name='output', value='b', reason=None, source=MockEvaluator(output={'output': 'b'}) + name='output', value='b', reason=None, source=MockEvaluator(output={'output': 'b'}).as_spec() ), 'output_3': EvaluationResult( - name='output', value='c', reason=None, source=MockEvaluator(output={'output': 'c'}) + name='output', value='c', reason=None, source=MockEvaluator(output={'output': 'c'}).as_spec() ), }, assertions={}, @@ -501,6 +501,73 @@ async def my_task(inputs: TaskInput) -> TaskOutput: ) +async def test_report_round_trip_serialization(example_dataset: Dataset[TaskInput, TaskOutput, TaskMetadata]): + """Test the increment_eval_metric function.""" + + async def my_task(inputs: TaskInput) -> TaskOutput: + return TaskOutput(answer=f'answer to {inputs.query}') + + example_dataset.add_evaluator(MockEvaluator({'output': 'a'})) + + report = await example_dataset.evaluate(my_task) + assert report == snapshot( + EvaluationReport( + name='my_task', + cases=[ + ReportCase( + name='case1', + inputs=TaskInput(query='What is 2+2?'), + metadata=TaskMetadata(difficulty='easy', category='general'), + expected_output=TaskOutput(answer='4', confidence=1.0), + output=TaskOutput(answer='answer to What is 2+2?', confidence=1.0), + metrics={}, + attributes={}, + scores={}, + labels={ + 'output': EvaluationResult( + name='output', + value='a', + reason=None, + source=EvaluatorSpec(name='MockEvaluator', arguments=({'output': 'a'},)), + ) + }, + assertions={}, + task_duration=1.0, + total_duration=6.0, + trace_id='00000000000000000000000000000001', + span_id='0000000000000003', + ), + ReportCase( + name='case2', + inputs=TaskInput(query='What is the capital of France?'), + metadata=TaskMetadata(difficulty='medium', category='geography'), + expected_output=TaskOutput(answer='Paris', confidence=1.0), + output=TaskOutput(answer='answer to What is the capital of France?', confidence=1.0), + metrics={}, + attributes={}, + scores={}, + labels={ + 'output': EvaluationResult( + name='output', + value='a', + reason=None, + source=EvaluatorSpec(name='MockEvaluator', arguments=({'output': 'a'},)), + ) + }, + assertions={}, + task_duration=1.0, + total_duration=4.0, + trace_id='00000000000000000000000000000001', + span_id='0000000000000007', + ), + ], + ) + ) + + report_adapter = TypeAdapter(EvaluationReport[TaskInput, TaskOutput, TaskMetadata]) + assert report == report_adapter.validate_json(report_adapter.dump_json(report, indent=2)) + + async def test_genai_attribute_collection(example_dataset: Dataset[TaskInput, TaskOutput, TaskMetadata]): async def my_task(inputs: TaskInput) -> TaskOutput: with logfire.span( @@ -1092,7 +1159,7 @@ async def test_evaluate_async_logfire( example_dataset: Dataset[TaskInput, TaskOutput, TaskMetadata], simple_evaluator: type[Evaluator[TaskInput, TaskOutput, TaskMetadata]], capfire: CaptureLogfire, -): +): # pragma: lax no cover """Test evaluating a dataset.""" example_dataset.add_evaluator(simple_evaluator()) @@ -1130,90 +1197,90 @@ async def mock_async_task(inputs: TaskInput) -> TaskOutput: 'parent': None, 'start_time': 1000000000, }, - { - 'attributes': { - 'assertions': '{"correct":{"name":"correct","value":true,"reason":null,"source":{"name":"SimpleEvaluator","arguments":null}}}', - 'attributes': '{}', - 'case_name': 'case1', - 'code.filepath': IsStr(), - 'code.lineno': 123, - 'expected_output': '{"answer":"4","confidence":1.0}', - 'inputs': '{"query":"What is 2+2?"}', - 'labels': '{}', - 'logfire.json_schema': '{"type":"object","properties":{"task_name":{},"case_name":{},"inputs":{"type":"object","title":"TaskInput","x-python-datatype":"PydanticModel"},"metadata":{"type":"object","title":"TaskMetadata","x-python-datatype":"PydanticModel"},"expected_output":{"type":"object","title":"TaskOutput","x-python-datatype":"PydanticModel"},"output":{"type":"object","title":"TaskOutput","x-python-datatype":"PydanticModel"},"task_duration":{},"metrics":{"type":"object"},"attributes":{"type":"object"},"assertions":{"type":"object"},"scores":{"type":"object"},"labels":{"type":"object"}}}', - 'logfire.msg': 'case: case1', - 'logfire.msg_template': 'case: {case_name}', - 'logfire.span_type': 'span', - 'metadata': '{"difficulty":"easy","category":"general"}', - 'metrics': '{}', - 'output': '{"answer":"4","confidence":1.0}', - 'scores': '{"confidence":{"name":"confidence","value":1.0,"reason":null,"source":{"name":"SimpleEvaluator","arguments":null}}}', - 'task_duration': 1.0, - 'task_name': 'mock_async_task', - }, - 'context': {'is_remote': False, 'span_id': 3, 'trace_id': 1}, - 'end_time': 8000000000, - 'name': 'case: {case_name}', - 'parent': {'is_remote': False, 'span_id': 1, 'trace_id': 1}, - 'start_time': 2000000000, - }, - { - 'attributes': { - 'code.filepath': IsStr(), - 'code.lineno': 123, - 'logfire.json_schema': '{"type":"object","properties":{"task":{}}}', - 'logfire.msg': 'execute mock_async_task', - 'logfire.msg_template': 'execute {task}', - 'logfire.span_type': 'span', - 'task': 'mock_async_task', - }, - 'context': {'is_remote': False, 'span_id': 5, 'trace_id': 1}, - 'end_time': 4000000000, - 'name': 'execute {task}', - 'parent': {'is_remote': False, 'span_id': 3, 'trace_id': 1}, - 'start_time': 3000000000, - }, - { - 'attributes': { - 'assertions': '{"correct":{"name":"correct","value":true,"reason":null,"source":{"name":"SimpleEvaluator","arguments":null}}}', - 'attributes': '{}', - 'case_name': 'case2', - 'code.filepath': IsStr(), - 'code.lineno': 123, - 'expected_output': '{"answer":"Paris","confidence":1.0}', - 'inputs': '{"query":"What is the capital of France?"}', - 'labels': '{}', - 'logfire.json_schema': '{"type":"object","properties":{"task_name":{},"case_name":{},"inputs":{"type":"object","title":"TaskInput","x-python-datatype":"PydanticModel"},"metadata":{"type":"object","title":"TaskMetadata","x-python-datatype":"PydanticModel"},"expected_output":{"type":"object","title":"TaskOutput","x-python-datatype":"PydanticModel"},"output":{"type":"object","title":"TaskOutput","x-python-datatype":"PydanticModel"},"task_duration":{},"metrics":{"type":"object"},"attributes":{"type":"object"},"assertions":{"type":"object"},"scores":{"type":"object"},"labels":{"type":"object"}}}', - 'logfire.msg': 'case: case2', - 'logfire.msg_template': 'case: {case_name}', - 'logfire.span_type': 'span', - 'metadata': '{"difficulty":"medium","category":"geography"}', - 'metrics': '{}', - 'output': '{"answer":"Paris","confidence":1.0}', - 'scores': '{"confidence":{"name":"confidence","value":1.0,"reason":null,"source":{"name":"SimpleEvaluator","arguments":null}}}', - 'task_duration': 1.0, - 'task_name': 'mock_async_task', - }, - 'context': {'is_remote': False, 'span_id': 7, 'trace_id': 1}, - 'end_time': 9000000000, - 'name': 'case: {case_name}', - 'parent': {'is_remote': False, 'span_id': 1, 'trace_id': 1}, - 'start_time': 5000000000, - }, - { - 'attributes': { - 'code.filepath': IsStr(), - 'code.lineno': 123, - 'logfire.json_schema': '{"type":"object","properties":{"task":{}}}', - 'logfire.msg': 'execute mock_async_task', - 'logfire.msg_template': 'execute {task}', - 'logfire.span_type': 'span', - 'task': 'mock_async_task', - }, - 'context': {'is_remote': False, 'span_id': 9, 'trace_id': 1}, - 'end_time': 7000000000, - 'name': 'execute {task}', - 'parent': {'is_remote': False, 'span_id': 7, 'trace_id': 1}, - 'start_time': 6000000000, - }, + IsPartialDict( + { + 'attributes': { + 'assertions': '{"correct":{"name":"correct","value":true,"reason":null,"source":{"name":"SimpleEvaluator","arguments":null}}}', + 'attributes': '{}', + 'case_name': 'case1', + 'expected_output': '{"answer":"4","confidence":1.0}', + 'inputs': '{"query":"What is 2+2?"}', + 'labels': '{}', + 'logfire.json_schema': '{"type":"object","properties":{"task_name":{},"case_name":{},"inputs":{"type":"object","title":"TaskInput","x-python-datatype":"PydanticModel"},"metadata":{"type":"object","title":"TaskMetadata","x-python-datatype":"PydanticModel"},"expected_output":{"type":"object","title":"TaskOutput","x-python-datatype":"PydanticModel"},"output":{"type":"object","title":"TaskOutput","x-python-datatype":"PydanticModel"},"task_duration":{},"metrics":{"type":"object"},"attributes":{"type":"object"},"assertions":{"type":"object"},"scores":{"type":"object"},"labels":{"type":"object"}}}', + 'logfire.msg': 'case: case1', + 'logfire.msg_template': 'case: {case_name}', + 'logfire.span_type': 'span', + 'metadata': '{"difficulty":"easy","category":"general"}', + 'metrics': '{}', + 'output': '{"answer":"4","confidence":1.0}', + 'scores': '{"confidence":{"name":"confidence","value":1.0,"reason":null,"source":{"name":"SimpleEvaluator","arguments":null}}}', + 'task_duration': 1.0, + 'task_name': 'mock_async_task', + }, + 'context': {'is_remote': False, 'span_id': 3, 'trace_id': 1}, + 'end_time': 8000000000, + 'name': 'case: {case_name}', + 'parent': {'is_remote': False, 'span_id': 1, 'trace_id': 1}, + 'start_time': 2000000000, + } + ), + IsPartialDict( + { + 'attributes': { + 'logfire.json_schema': '{"type":"object","properties":{"task":{}}}', + 'logfire.msg': 'execute mock_async_task', + 'logfire.msg_template': 'execute {task}', + 'logfire.span_type': 'span', + 'task': 'mock_async_task', + }, + 'context': {'is_remote': False, 'span_id': 5, 'trace_id': 1}, + 'end_time': 4000000000, + 'name': 'execute {task}', + 'parent': {'is_remote': False, 'span_id': 3, 'trace_id': 1}, + 'start_time': 3000000000, + } + ), + IsPartialDict( + { + 'attributes': { + 'assertions': '{"correct":{"name":"correct","value":true,"reason":null,"source":{"name":"SimpleEvaluator","arguments":null}}}', + 'attributes': '{}', + 'case_name': 'case2', + 'expected_output': '{"answer":"Paris","confidence":1.0}', + 'inputs': '{"query":"What is the capital of France?"}', + 'labels': '{}', + 'logfire.json_schema': '{"type":"object","properties":{"task_name":{},"case_name":{},"inputs":{"type":"object","title":"TaskInput","x-python-datatype":"PydanticModel"},"metadata":{"type":"object","title":"TaskMetadata","x-python-datatype":"PydanticModel"},"expected_output":{"type":"object","title":"TaskOutput","x-python-datatype":"PydanticModel"},"output":{"type":"object","title":"TaskOutput","x-python-datatype":"PydanticModel"},"task_duration":{},"metrics":{"type":"object"},"attributes":{"type":"object"},"assertions":{"type":"object"},"scores":{"type":"object"},"labels":{"type":"object"}}}', + 'logfire.msg': 'case: case2', + 'logfire.msg_template': 'case: {case_name}', + 'logfire.span_type': 'span', + 'metadata': '{"difficulty":"medium","category":"geography"}', + 'metrics': '{}', + 'output': '{"answer":"Paris","confidence":1.0}', + 'scores': '{"confidence":{"name":"confidence","value":1.0,"reason":null,"source":{"name":"SimpleEvaluator","arguments":null}}}', + 'task_duration': 1.0, + 'task_name': 'mock_async_task', + }, + 'context': {'is_remote': False, 'span_id': 7, 'trace_id': 1}, + 'end_time': 9000000000, + 'name': 'case: {case_name}', + 'parent': {'is_remote': False, 'span_id': 1, 'trace_id': 1}, + 'start_time': 5000000000, + } + ), + IsPartialDict( + { + 'attributes': { + 'logfire.json_schema': '{"type":"object","properties":{"task":{}}}', + 'logfire.msg': 'execute mock_async_task', + 'logfire.msg_template': 'execute {task}', + 'logfire.span_type': 'span', + 'task': 'mock_async_task', + }, + 'context': {'is_remote': False, 'span_id': 9, 'trace_id': 1}, + 'end_time': 7000000000, + 'name': 'execute {task}', + 'parent': {'is_remote': False, 'span_id': 7, 'trace_id': 1}, + 'start_time': 6000000000, + } + ), ] diff --git a/tests/evals/test_evaluator_base.py b/tests/evals/test_evaluator_base.py index 06a29f368..850fcc24f 100644 --- a/tests/evals/test_evaluator_base.py +++ b/tests/evals/test_evaluator_base.py @@ -52,11 +52,11 @@ def evaluate(self, ctx: EvaluatorContext) -> bool: evaluator = DummyEvaluator() # Test basic result - result = EvaluationResult(name='test', value=True, reason='Success', source=evaluator) + result = EvaluationResult(name='test', value=True, reason='Success', source=evaluator.as_spec()) assert result.name == 'test' assert result.value is True assert result.reason == 'Success' - assert result.source == evaluator + assert result.source == evaluator.as_spec() # Test downcast with matching type downcast = result.downcast(bool) diff --git a/tests/evals/test_evaluator_spec.py b/tests/evals/test_evaluator_spec.py index 68763b40d..5a11be1f6 100644 --- a/tests/evals/test_evaluator_spec.py +++ b/tests/evals/test_evaluator_spec.py @@ -6,7 +6,7 @@ from ..conftest import try_import with try_import() as imports_successful: - from pydantic_evals.evaluators._spec import ( + from pydantic_evals.evaluators.spec import ( EvaluatorSpec, _SerializedEvaluatorSpec, # pyright: ignore[reportPrivateUsage] ) diff --git a/tests/evals/test_evaluators.py b/tests/evals/test_evaluators.py index 235296c4a..c6d352979 100644 --- a/tests/evals/test_evaluators.py +++ b/tests/evals/test_evaluators.py @@ -19,7 +19,6 @@ from logfire.testing import CaptureLogfire from pydantic_evals.evaluators._run_evaluator import run_evaluator - from pydantic_evals.evaluators._spec import EvaluatorSpec from pydantic_evals.evaluators.common import ( Contains, Equals, @@ -36,6 +35,7 @@ Evaluator, EvaluatorOutput, ) + from pydantic_evals.evaluators.spec import EvaluatorSpec from pydantic_evals.otel._context_in_memory_span_exporter import context_subtree from pydantic_evals.otel.span_tree import SpanQuery, SpanTree @@ -162,7 +162,7 @@ def evaluate(self, ctx: EvaluatorContext[TaskInput, TaskOutput, TaskMetadata]) - assert results[0].name == 'result' assert results[0].value == 'passed' assert results[0].reason is None - assert results[0].source is evaluator + assert results[0].source == EvaluatorSpec(name='ExampleEvaluator', arguments=None) async def test_is_instance_evaluator(): @@ -242,7 +242,14 @@ def evaluate(self, ctx: EvaluatorContext[TaskInput, TaskOutput, TaskMetadata]) - evaluator = CustomNameFieldEvaluator(result=123, evaluation_name='abc') assert to_jsonable_python(await run_evaluator(evaluator, test_context)) == snapshot( - [{'name': 'abc', 'reason': None, 'source': {'evaluation_name': 'abc', 'result': 123}, 'value': 123}] + [ + { + 'name': 'abc', + 'reason': None, + 'source': {'arguments': {'evaluation_name': 'abc', 'result': 123}, 'name': 'CustomNameFieldEvaluator'}, + 'value': 123, + } + ] ) @dataclass @@ -260,7 +267,14 @@ def evaluate(self, ctx: EvaluatorContext[TaskInput, TaskOutput, TaskMetadata]) - evaluator = CustomNamePropertyEvaluator(result=123, my_name='marcelo') assert to_jsonable_python(await run_evaluator(evaluator, test_context)) == snapshot( - [{'name': 'hello marcelo', 'reason': None, 'source': {'my_name': 'marcelo', 'result': 123}, 'value': 123}] + [ + { + 'name': 'hello marcelo', + 'reason': None, + 'source': {'arguments': {'my_name': 'marcelo', 'result': 123}, 'name': 'CustomNamePropertyEvaluator'}, + 'value': 123, + } + ] ) diff --git a/tests/evals/test_reporting.py b/tests/evals/test_reporting.py index 50c93a2c5..55cde3c38 100644 --- a/tests/evals/test_reporting.py +++ b/tests/evals/test_reporting.py @@ -48,7 +48,7 @@ def sample_assertion(mock_evaluator: Evaluator[TaskInput, TaskOutput, TaskMetada name='MockEvaluator', value=True, reason=None, - source=mock_evaluator, + source=mock_evaluator.as_spec(), ) @@ -57,8 +57,8 @@ def sample_score(mock_evaluator: Evaluator[TaskInput, TaskOutput, TaskMetadata]) return EvaluationResult( name='MockEvaluator', value=2.5, - reason=None, - source=mock_evaluator, + reason='my reason', + source=mock_evaluator.as_spec(), ) @@ -68,7 +68,7 @@ def sample_label(mock_evaluator: Evaluator[TaskInput, TaskOutput, TaskMetadata]) name='MockEvaluator', value='hello', reason=None, - source=mock_evaluator, + source=mock_evaluator.as_spec(), ) @@ -120,6 +120,7 @@ async def test_evaluation_renderer_basic(sample_report: EvaluationReport): label_configs={}, metric_configs={}, duration_config={}, + include_reasons=False, ) table = renderer.build_table(sample_report) @@ -137,6 +138,43 @@ async def test_evaluation_renderer_basic(sample_report: EvaluationReport): """) +async def test_evaluation_renderer_with_reasons(sample_report: EvaluationReport): + """Test basic functionality of EvaluationRenderer.""" + renderer = EvaluationRenderer( + include_input=True, + include_output=True, + include_metadata=True, + include_expected_output=True, + include_durations=True, + include_total_duration=True, + include_removed_cases=False, + include_averages=True, + input_config={}, + metadata_config={}, + output_config={}, + score_configs={}, + label_configs={}, + metric_configs={}, + duration_config={}, + include_reasons=True, + ) + + table = renderer.build_table(sample_report) + assert render_table(table) == snapshot("""\ + Evaluation Summary: test_report +┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓ +┃ Case ID ┃ Inputs ┃ Metadata ┃ Expected Output ┃ Outputs ┃ Scores ┃ Labels ┃ Metrics ┃ Assertions ┃ Durations ┃ +┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩ +│ test_case │ {'query': 'What is 2+2?'} │ {'difficulty': 'easy'} │ {'answer': '4'} │ {'answer': '4'} │ score1: 2.50 │ label1: hello │ accuracy: 0.950 │ MockEvaluator: ✔ │ task: 0.100 │ +│ │ │ │ │ │ Reason: my reason │ │ │ │ total: 0.200 │ +│ │ │ │ │ │ │ │ │ │ │ +├───────────┼───────────────────────────┼────────────────────────┼─────────────────┼─────────────────┼─────────────────────┼────────────────────────┼─────────────────┼──────────────────┼──────────────┤ +│ Averages │ │ │ │ │ score1: 2.50 │ label1: {'hello': 1.0} │ accuracy: 0.950 │ 100.0% ✔ │ task: 0.100 │ +│ │ │ │ │ │ │ │ │ │ total: 0.200 │ +└───────────┴───────────────────────────┴────────────────────────┴─────────────────┴─────────────────┴─────────────────────┴────────────────────────┴─────────────────┴──────────────────┴──────────────┘ +""") + + async def test_evaluation_renderer_with_baseline(sample_report: EvaluationReport): """Test EvaluationRenderer with baseline comparison.""" baseline_report = EvaluationReport( @@ -191,20 +229,21 @@ async def test_evaluation_renderer_with_baseline(sample_report: EvaluationReport label_configs={}, metric_configs={}, duration_config={}, + include_reasons=False, ) table = renderer.build_diff_table(sample_report, baseline_report) assert render_table(table) == snapshot("""\ - Evaluation Diff: baseline_report → test_report -┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ Case ID ┃ Inputs ┃ Metadata ┃ Expected Output ┃ Outputs ┃ Scores ┃ Labels ┃ Metrics ┃ Assertions ┃ Durations ┃ -┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ -│ test_case │ {'query': 'What is 2+2?'} │ {'difficulty': 'easy'} │ {'answer': '4'} │ {'answer': '4'} │ score1: 2.50 │ label1: EvaluationResult(name='MockEvaluator', value='hello', reason=None, │ accuracy: 0.900 → 0.950 (+0.05 / +5.6%) │ → ✔ │ task: 0.150 → 0.100 (-0.05 / -33.3%) │ -│ │ │ │ │ │ │ source=mock_evaluator..MockEvaluator()) │ │ │ total: 0.250 → 0.200 (-0.05 / -20.0%) │ -├───────────┼───────────────────────────┼────────────────────────┼─────────────────┼─────────────────┼──────────────┼─────────────────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────┼──────────────┼───────────────────────────────────────┤ -│ Averages │ │ │ │ │ score1: 2.50 │ label1: {'hello': 1.0} │ accuracy: 0.900 → 0.950 (+0.05 / +5.6%) │ - → 100.0% ✔ │ task: 0.150 → 0.100 (-0.05 / -33.3%) │ -│ │ │ │ │ │ │ │ │ │ total: 0.250 → 0.200 (-0.05 / -20.0%) │ -└───────────┴───────────────────────────┴────────────────────────┴─────────────────┴─────────────────┴──────────────┴─────────────────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────────┴──────────────┴───────────────────────────────────────┘ + Evaluation Diff: baseline_report → test_report +┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ Case ID ┃ Inputs ┃ Metadata ┃ Expected Output ┃ Outputs ┃ Scores ┃ Labels ┃ Metrics ┃ Assertions ┃ Durations ┃ +┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ +│ test_case │ {'query': 'What is 2+2?'} │ {'difficulty': 'easy'} │ {'answer': '4'} │ {'answer': '4'} │ score1: 2.50 │ label1: hello │ accuracy: 0.900 → 0.950 (+0.05 / +5.6%) │ → ✔ │ task: 0.150 → 0.100 (-0.05 / -33.3%) │ +│ │ │ │ │ │ │ │ │ │ total: 0.250 → 0.200 (-0.05 / -20.0%) │ +├───────────┼───────────────────────────┼────────────────────────┼─────────────────┼─────────────────┼──────────────┼────────────────────────┼─────────────────────────────────────────┼──────────────┼───────────────────────────────────────┤ +│ Averages │ │ │ │ │ score1: 2.50 │ label1: {'hello': 1.0} │ accuracy: 0.900 → 0.950 (+0.05 / +5.6%) │ - → 100.0% ✔ │ task: 0.150 → 0.100 (-0.05 / -33.3%) │ +│ │ │ │ │ │ │ │ │ │ total: 0.250 → 0.200 (-0.05 / -20.0%) │ +└───────────┴───────────────────────────┴────────────────────────┴─────────────────┴─────────────────┴──────────────┴────────────────────────┴─────────────────────────────────────────┴──────────────┴───────────────────────────────────────┘ """) @@ -248,6 +287,7 @@ async def test_evaluation_renderer_with_removed_cases(sample_report: EvaluationR label_configs={}, metric_configs={}, duration_config={}, + include_reasons=False, ) table = renderer.build_diff_table(sample_report, baseline_report) @@ -311,6 +351,7 @@ async def test_evaluation_renderer_with_custom_configs(sample_report: Evaluation 'diff_increase_style': 'bold red', 'diff_decrease_style': 'bold green', }, + include_reasons=False, ) table = renderer.build_table(sample_report) @@ -350,7 +391,7 @@ def evaluate(self, ctx: EvaluatorContext[TaskInput, TaskOutput, TaskMetadata]) - name='MockEvaluator', value=0.8, reason=None, - source=MockEvaluator(), + source=MockEvaluator().as_spec(), ) }, labels={ @@ -358,7 +399,7 @@ def evaluate(self, ctx: EvaluatorContext[TaskInput, TaskOutput, TaskMetadata]) - name='MockEvaluator', value='good', reason=None, - source=MockEvaluator(), + source=MockEvaluator().as_spec(), ) }, assertions={ @@ -366,7 +407,7 @@ def evaluate(self, ctx: EvaluatorContext[TaskInput, TaskOutput, TaskMetadata]) - name='MockEvaluator', value=True, reason=None, - source=MockEvaluator(), + source=MockEvaluator().as_spec(), ) }, task_duration=0.1, @@ -387,7 +428,7 @@ def evaluate(self, ctx: EvaluatorContext[TaskInput, TaskOutput, TaskMetadata]) - name='MockEvaluator', value=0.7, reason=None, - source=MockEvaluator(), + source=MockEvaluator().as_spec(), ) }, labels={ @@ -395,7 +436,7 @@ def evaluate(self, ctx: EvaluatorContext[TaskInput, TaskOutput, TaskMetadata]) - name='MockEvaluator', value='good', reason=None, - source=MockEvaluator(), + source=MockEvaluator().as_spec(), ) }, assertions={ @@ -403,7 +444,7 @@ def evaluate(self, ctx: EvaluatorContext[TaskInput, TaskOutput, TaskMetadata]) - name='MockEvaluator', value=False, reason=None, - source=MockEvaluator(), + source=MockEvaluator().as_spec(), ) }, task_duration=0.15, diff --git a/tests/evals/test_reports.py b/tests/evals/test_reports.py index 9f7ba8a9e..2d06b99d4 100644 --- a/tests/evals/test_reports.py +++ b/tests/evals/test_reports.py @@ -57,7 +57,7 @@ def sample_evaluation_result( name='MockEvaluator', value=True, reason=None, - source=mock_evaluator, + source=mock_evaluator.as_spec(), ) @@ -177,7 +177,7 @@ async def test_report_with_error(mock_evaluator: Evaluator[TaskInput, TaskOutput name='error_evaluator', value=False, # No result reason='Test error message', - source=mock_evaluator, + source=mock_evaluator.as_spec(), ) # Create a case diff --git a/tests/models/test_fallback.py b/tests/models/test_fallback.py index 89709d4a2..2b5b5a781 100644 --- a/tests/models/test_fallback.py +++ b/tests/models/test_fallback.py @@ -3,6 +3,7 @@ import sys from collections.abc import AsyncIterator from datetime import timezone +from typing import Any import pytest from inline_snapshot import snapshot @@ -247,6 +248,14 @@ def test_all_failed() -> None: assert exceptions[0].body == {'error': 'test error'} +def add_missing_response_model(spans: list[dict[str, Any]]) -> list[dict[str, Any]]: + for span in spans: + attrs = span.setdefault('attributes', {}) + if 'gen_ai.request.model' in attrs: + attrs.setdefault('gen_ai.response.model', attrs['gen_ai.request.model']) + return spans + + @pytest.mark.skipif(not logfire_imports_successful(), reason='logfire not installed') def test_all_failed_instrumented(capfire: CaptureLogfire) -> None: fallback_model = FallbackModel(failure_model, failure_model) @@ -260,7 +269,7 @@ def test_all_failed_instrumented(capfire: CaptureLogfire) -> None: assert exceptions[0].status_code == 500 assert exceptions[0].model_name == 'test-function-model' assert exceptions[0].body == {'error': 'test error'} - assert capfire.exporter.exported_spans_as_dict() == snapshot( + assert add_missing_response_model(capfire.exporter.exported_spans_as_dict()) == snapshot( [ { 'name': 'chat fallback:function:failure_response:,function:failure_response:', @@ -277,6 +286,7 @@ def test_all_failed_instrumented(capfire: CaptureLogfire) -> None: 'logfire.span_type': 'span', 'logfire.msg': 'chat fallback:function:failure_response:,function:failure_response:', 'logfire.level_num': 17, + 'gen_ai.response.model': 'fallback:function:failure_response:,function:failure_response:', }, 'events': [ { diff --git a/tests/models/test_gemini.py b/tests/models/test_gemini.py index 7d628df31..181c7baca 100644 --- a/tests/models/test_gemini.py +++ b/tests/models/test_gemini.py @@ -360,24 +360,25 @@ class QueryDetails(BaseModel): # This tests that the enum values are properly converted to strings for Gemini assert m._get_tools(mrp) == snapshot( - _GeminiTools( - function_declarations=[ - _GeminiFunction( - name='result', - description='This is the tool for the final Result', - parameters={ + { + 'function_declarations': [ + { + 'name': 'result', + 'description': 'This is the tool for the final Result', + 'parameters': { 'properties': { 'progress': { 'items': {'enum': ['100', '80', '60', '40', '20'], 'type': 'string'}, 'type': 'array', 'nullable': True, + 'default': None, } }, 'type': 'object', }, - ) + } ] - ) + } ) @@ -406,12 +407,12 @@ class Locations(BaseModel): ) mrp = m.customize_request_parameters(mrp) assert m._get_tools(mrp) == snapshot( - _GeminiTools( - function_declarations=[ - _GeminiFunction( - name='result', - description='This is the tool for the final Result', - parameters={ + { + 'function_declarations': [ + { + 'name': 'result', + 'description': 'This is the tool for the final Result', + 'parameters': { 'properties': { 'op_location': { 'properties': { @@ -421,13 +422,14 @@ class Locations(BaseModel): 'required': ['lat', 'lng'], 'nullable': True, 'type': 'object', + 'default': None, } }, 'type': 'object', }, - ) + } ] - ) + } ) diff --git a/tests/models/test_google.py b/tests/models/test_google.py index 7e1f372bc..7e51b6142 100644 --- a/tests/models/test_google.py +++ b/tests/models/test_google.py @@ -318,7 +318,9 @@ async def test_google_model_gla_labels_raises_value_error(allow_model_requests: await agent.run('What is the capital of France?') -async def test_google_model_vertex_provider(allow_model_requests: None, vertex_provider: GoogleProvider): +async def test_google_model_vertex_provider( + allow_model_requests: None, vertex_provider: GoogleProvider +): # pragma: lax no cover model = GoogleModel('gemini-2.0-flash', provider=vertex_provider) agent = Agent(model=model, system_prompt='You are a helpful chatbot.') result = await agent.run('What is the capital of France?') @@ -774,7 +776,7 @@ async def test_google_url_input( expected_output: str, allow_model_requests: None, vertex_provider: GoogleProvider, -) -> None: +) -> None: # pragma: lax no cover m = GoogleModel('gemini-2.0-flash', provider=vertex_provider) agent = Agent(m) result = await agent.run(['What is the main content of this URL?', url]) @@ -806,7 +808,7 @@ async def test_google_url_input( not os.getenv('CI', False), reason='Requires properly configured local google vertex config to pass' ) @pytest.mark.vcr() -async def test_google_url_input_force_download(allow_model_requests: None) -> None: +async def test_google_url_input_force_download(allow_model_requests: None) -> None: # pragma: lax no cover provider = GoogleProvider(project='pydantic-ai', location='us-central1') m = GoogleModel('gemini-2.0-flash', provider=provider) agent = Agent(m) diff --git a/tests/models/test_openai.py b/tests/models/test_openai.py index 880c70eff..8df65544b 100644 --- a/tests/models/test_openai.py +++ b/tests/models/test_openai.py @@ -12,8 +12,8 @@ import pytest from dirty_equals import IsListOrTuple from inline_snapshot import snapshot -from pydantic import BaseModel, Discriminator, Field, Tag -from typing_extensions import TypedDict +from pydantic import AnyUrl, BaseModel, Discriminator, Field, Tag +from typing_extensions import NotRequired, TypedDict from pydantic_ai import Agent, ModelHTTPError, ModelRetry, UnexpectedModelBehavior from pydantic_ai.messages import ( @@ -1082,7 +1082,37 @@ class MyDefaultRecursiveDc: field: MyDefaultRecursiveDc | None = None -class MyModel(BaseModel, extra='allow'): +class MyModel(BaseModel): + foo: str + + +class MyDc(BaseModel): + foo: str + + +class MyOptionalDc(BaseModel): + foo: str | None + bar: str + + +class MyExtrasDc(BaseModel, extra='allow'): + foo: str + + +class MyNormalTypedDict(TypedDict): + foo: str + + +class MyOptionalTypedDict(TypedDict): + foo: NotRequired[str] + bar: str + + +class MyPartialTypedDict(TypedDict, total=False): + foo: str + + +class MyExtrasModel(BaseModel, extra='allow'): pass @@ -1094,11 +1124,47 @@ def tool_with_default(x: int = 1) -> str: return f'{x}' # pragma: no cover +def tool_with_datetime(x: datetime) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_url(x: AnyUrl) -> str: + return f'{x}' # pragma: no cover + + def tool_with_recursion(x: MyRecursiveDc, y: MyDefaultRecursiveDc): return f'{x} {y}' # pragma: no cover -def tool_with_additional_properties(x: MyModel) -> str: +def tool_with_model(x: MyModel) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_dataclass(x: MyDc) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_optional_dataclass(x: MyOptionalDc) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_dataclass_with_extras(x: MyExtrasDc) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_typed_dict(x: MyNormalTypedDict) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_optional_typed_dict(x: MyOptionalTypedDict) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_partial_typed_dict(x: MyPartialTypedDict) -> str: + return f'{x}' # pragma: no cover + + +def tool_with_model_with_extras(x: MyExtrasModel) -> str: return f'{x}' # pragma: no cover @@ -1106,6 +1172,10 @@ def tool_with_kwargs(x: int, **kwargs: Any) -> str: return f'{x} {kwargs}' # pragma: no cover +def tool_with_typed_kwargs(x: int, **kwargs: int) -> str: + return f'{x} {kwargs}' # pragma: no cover + + def tool_with_union(x: int | MyDefaultDc) -> str: return f'{x}' # pragma: no cover @@ -1144,12 +1214,50 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: snapshot(None), ), ( - strict_compatible_tool, + tool_with_default, None, snapshot( { 'additionalProperties': False, - 'properties': {'x': {'type': 'integer'}}, + 'properties': {'x': {'default': 1, 'type': 'integer'}}, + 'type': 'object', + } + ), + snapshot(None), + ), + ( + tool_with_datetime, + None, + snapshot( + { + 'additionalProperties': False, + 'properties': {'x': {'format': 'date-time', 'type': 'string'}}, + 'required': ['x'], + 'type': 'object', + } + ), + snapshot(True), + ), + ( + tool_with_url, + None, + snapshot( + { + 'additionalProperties': False, + 'properties': {'x': {'format': 'uri', 'minLength': 1, 'type': 'string'}}, + 'required': ['x'], + 'type': 'object', + } + ), + snapshot(None), + ), + ( + tool_with_url, + True, + snapshot( + { + 'additionalProperties': False, + 'properties': {'x': {'type': 'string', 'description': 'minLength=1, format=uri'}}, 'required': ['x'], 'type': 'object', } @@ -1170,6 +1278,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: } }, 'type': 'object', + 'additionalProperties': False, }, 'MyEnum': {'enum': ['a', 'b'], 'type': 'string'}, 'MyRecursiveDc': { @@ -1179,6 +1288,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: }, 'required': ['field', 'my_enum'], 'type': 'object', + 'additionalProperties': False, }, }, 'additionalProperties': False, @@ -1229,7 +1339,97 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: snapshot(True), ), ( - tool_with_additional_properties, + tool_with_model, + None, + snapshot( + { + 'additionalProperties': False, + 'properties': {'foo': {'type': 'string'}}, + 'required': ['foo'], + 'type': 'object', + } + ), + snapshot(True), + ), + ( + tool_with_dataclass, + None, + snapshot( + { + 'additionalProperties': False, + 'properties': {'foo': {'type': 'string'}}, + 'required': ['foo'], + 'type': 'object', + } + ), + snapshot(True), + ), + ( + tool_with_optional_dataclass, + None, + snapshot( + { + 'additionalProperties': False, + 'properties': {'foo': {'anyOf': [{'type': 'string'}, {'type': 'null'}]}, 'bar': {'type': 'string'}}, + 'required': ['foo', 'bar'], + 'type': 'object', + } + ), + snapshot(True), + ), + ( + tool_with_dataclass_with_extras, + None, + snapshot( + { + 'additionalProperties': True, + 'properties': {'foo': {'type': 'string'}}, + 'required': ['foo'], + 'type': 'object', + } + ), + snapshot(None), + ), + ( + tool_with_typed_dict, + None, + snapshot( + { + 'additionalProperties': False, + 'properties': {'foo': {'type': 'string'}}, + 'required': ['foo'], + 'type': 'object', + } + ), + snapshot(True), + ), + ( + tool_with_optional_typed_dict, + None, + snapshot( + { + 'additionalProperties': False, + 'properties': {'foo': {'type': 'string'}, 'bar': {'type': 'string'}}, + 'required': ['bar'], + 'type': 'object', + } + ), + snapshot(None), + ), + ( + tool_with_partial_typed_dict, + None, + snapshot( + { + 'additionalProperties': False, + 'properties': {'foo': {'type': 'string'}}, + 'type': 'object', + } + ), + snapshot(None), + ), + ( + tool_with_model_with_extras, None, snapshot( { @@ -1241,7 +1441,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: snapshot(None), ), ( - tool_with_additional_properties, + tool_with_model_with_extras, True, snapshot( { @@ -1258,6 +1458,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: None, snapshot( { + 'additionalProperties': True, 'properties': {'x': {'type': 'integer'}}, 'required': ['x'], 'type': 'object', @@ -1278,6 +1479,19 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: ), snapshot(True), ), + ( + tool_with_typed_kwargs, + None, + snapshot( + { + 'additionalProperties': {'type': 'integer'}, + 'properties': {'x': {'type': 'integer'}}, + 'required': ['x'], + 'type': 'object', + } + ), + snapshot(None), + ), ( tool_with_union, None, @@ -1287,6 +1501,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: 'MyDefaultDc': { 'properties': {'x': {'default': 1, 'type': 'integer'}}, 'type': 'object', + 'additionalProperties': False, } }, 'additionalProperties': False, @@ -1327,6 +1542,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: 'MyDefaultDc': { 'properties': {'x': {'default': 1, 'type': 'integer'}}, 'type': 'object', + 'additionalProperties': False, } }, 'additionalProperties': False, @@ -1367,6 +1583,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: 'MyDefaultDc': { 'properties': {'x': {'default': 1, 'type': 'integer'}}, 'type': 'object', + 'additionalProperties': False, } }, 'additionalProperties': False, @@ -1413,6 +1630,7 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: 'properties': { 'x': {'maxItems': 1, 'minItems': 1, 'prefixItems': [{'type': 'integer'}], 'type': 'array'}, 'y': { + 'default': ['abc'], 'maxItems': 1, 'minItems': 1, 'prefixItems': [{'type': 'string'}], @@ -1432,16 +1650,8 @@ def tool_with_tuples(x: tuple[int], y: tuple[str] = ('abc',)) -> str: { 'additionalProperties': False, 'properties': { - 'x': { - 'prefixItems': [{'type': 'integer'}], - 'type': 'array', - 'description': 'minItems=1, maxItems=1', - }, - 'y': { - 'prefixItems': [{'type': 'string'}], - 'type': 'array', - 'description': 'minItems=1, maxItems=1', - }, + 'x': {'maxItems': 1, 'minItems': 1, 'prefixItems': [{'type': 'integer'}], 'type': 'array'}, + 'y': {'maxItems': 1, 'minItems': 1, 'prefixItems': [{'type': 'string'}], 'type': 'array'}, }, 'required': ['x', 'y'], 'type': 'object', @@ -1537,9 +1747,10 @@ class MyModel(BaseModel): }, 'my_recursive': {'anyOf': [{'$ref': '#'}, {'type': 'null'}]}, 'my_tuple': { + 'maxItems': 1, + 'minItems': 1, 'prefixItems': [{'type': 'integer'}], 'type': 'array', - 'description': 'minItems=1, maxItems=1', }, }, 'required': ['my_recursive', 'my_patterns', 'my_tuple', 'my_list', 'my_discriminated_union'], @@ -1555,11 +1766,7 @@ class MyModel(BaseModel): 'properties': {}, 'required': [], }, - 'my_tuple': { - 'prefixItems': [{'type': 'integer'}], - 'type': 'array', - 'description': 'minItems=1, maxItems=1', - }, + 'my_tuple': {'maxItems': 1, 'minItems': 1, 'prefixItems': [{'type': 'integer'}], 'type': 'array'}, 'my_list': {'items': {'type': 'number'}, 'type': 'array'}, 'my_discriminated_union': {'anyOf': [{'$ref': '#/$defs/Apple'}, {'$ref': '#/$defs/Banana'}]}, }, diff --git a/tests/test_agent.py b/tests/test_agent.py index 0b37d1041..796fc4516 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -387,7 +387,6 @@ def test_response_tuple(): name='final_result', description='The final response which ends this conversation', parameters_json_schema={ - 'additionalProperties': False, 'properties': { 'response': { 'maxItems': 2, @@ -637,7 +636,6 @@ class Bar(BaseModel): 'type': 'object', }, }, - 'additionalProperties': False, 'properties': {'response': {'anyOf': [{'$ref': '#/$defs/Foo'}, {'$ref': '#/$defs/Bar'}]}}, 'required': ['response'], 'type': 'object', @@ -2924,7 +2922,7 @@ def validate_output(ctx: RunContext[None], o: Any) -> Any: # pragma: no cover agent.run_sync('Hello', output_type=int) -def test_binary_content_all_messages_json(): +def test_binary_content_serializable(): agent = Agent('test') content = BinaryContent(data=b'Hello', media_type='text/plain') @@ -2976,6 +2974,57 @@ def test_binary_content_all_messages_json(): assert messages == result.all_messages() +def test_image_url_serializable(): + agent = Agent('test') + + content = ImageUrl('https://example.com/chart', media_type='image/jpeg') + result = agent.run_sync(['Hello', content]) + + serialized = result.all_messages_json() + assert json.loads(serialized) == snapshot( + [ + { + 'parts': [ + { + 'content': [ + 'Hello', + { + 'url': 'https://example.com/chart', + 'force_download': False, + 'vendor_metadata': None, + 'kind': 'image-url', + }, + ], + 'timestamp': IsStr(), + 'part_kind': 'user-prompt', + } + ], + 'instructions': None, + 'kind': 'request', + }, + { + 'parts': [{'content': 'success (no tool calls)', 'part_kind': 'text'}], + 'usage': { + 'requests': 1, + 'request_tokens': 51, + 'response_tokens': 4, + 'total_tokens': 55, + 'details': None, + }, + 'model_name': 'test', + 'timestamp': IsStr(), + 'kind': 'response', + 'vendor_details': None, + 'vendor_id': None, + }, + ] + ) + + # We also need to be able to round trip the serialized messages. + messages = ModelMessagesTypeAdapter.validate_json(serialized) + assert messages == result.all_messages() + + def test_tool_return_part_binary_content_serialization(): """Test that ToolReturnPart can properly serialize BinaryContent.""" png_data = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01\xf6\x178\x00\x00\x00\x00IEND\xaeB`\x82' diff --git a/tests/test_messages.py b/tests/test_messages.py index 1cd4ffd94..ec4d1231c 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -41,6 +41,24 @@ def test_youtube_video_url(url: str, is_youtube: bool): assert video_url.format == 'mp4' +@pytest.mark.parametrize( + 'url, expected_data_type', + [ + ('https://raw.githubusercontent.com/pydantic/pydantic-ai/refs/heads/main/docs/help.md', 'text/markdown'), + ('https://raw.githubusercontent.com/pydantic/pydantic-ai/refs/heads/main/docs/help.txt', 'text/plain'), + ('https://raw.githubusercontent.com/pydantic/pydantic-ai/refs/heads/main/docs/help.pdf', 'application/pdf'), + ('https://raw.githubusercontent.com/pydantic/pydantic-ai/refs/heads/main/docs/help.rtf', 'application/rtf'), + ( + 'https://raw.githubusercontent.com/pydantic/pydantic-ai/refs/heads/main/docs/help.asciidoc', + 'text/x-asciidoc', + ), + ], +) +def test_document_url_other_types(url: str, expected_data_type: str) -> None: + document_url = DocumentUrl(url=url) + assert document_url.media_type == expected_data_type + + def test_document_url(): document_url = DocumentUrl(url='https://example.com/document.pdf') assert document_url.media_type == 'application/pdf' @@ -135,7 +153,7 @@ def test_audio_url(audio_url: AudioUrl, media_type: str, format: str): def test_audio_url_invalid(): - with pytest.raises(ValueError, match='Unknown audio file extension: foobar.potato'): + with pytest.raises(ValueError, match='Could not infer media type from audio URL: foobar.potato'): AudioUrl('foobar.potato').media_type @@ -155,10 +173,10 @@ def test_image_url_formats(image_url: ImageUrl, media_type: str, format: str): def test_image_url_invalid(): - with pytest.raises(ValueError, match='Unknown image file extension: foobar.potato'): + with pytest.raises(ValueError, match='Could not infer media type from image URL: foobar.potato'): ImageUrl('foobar.potato').media_type - with pytest.raises(ValueError, match='Unknown image file extension: foobar.potato'): + with pytest.raises(ValueError, match='Could not infer media type from image URL: foobar.potato'): ImageUrl('foobar.potato').format @@ -195,7 +213,7 @@ def test_document_url_formats(document_url: DocumentUrl, media_type: str, format def test_document_url_invalid(): - with pytest.raises(ValueError, match='Unknown document file extension: foobar.potato'): + with pytest.raises(ValueError, match='Could not infer media type from document URL: foobar.potato'): DocumentUrl('foobar.potato').media_type with pytest.raises(ValueError, match='Unknown document media type: text/x-python'): @@ -283,7 +301,7 @@ def test_video_url_formats(video_url: VideoUrl, media_type: str, format: str): def test_video_url_invalid(): - with pytest.raises(ValueError, match='Unknown video file extension: foobar.potato'): + with pytest.raises(ValueError, match='Could not infer media type from video URL: foobar.potato'): VideoUrl('foobar.potato').media_type diff --git a/tests/test_tools.py b/tests/test_tools.py index 7f4a45804..eb01352e4 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -387,7 +387,7 @@ def test_docstring_unknown(): { 'name': 'unknown_docstring', 'description': 'Unknown style docstring.', - 'parameters_json_schema': {'properties': {}, 'type': 'object'}, + 'parameters_json_schema': {'additionalProperties': {'type': 'integer'}, 'properties': {}, 'type': 'object'}, 'outer_typed_dict_key': None, 'strict': None, 'kind': 'function', @@ -932,7 +932,7 @@ def my_tool_plain(*, a: int = 1, b: int) -> int: 'outer_typed_dict_key': None, 'parameters_json_schema': { 'additionalProperties': False, - 'properties': {'a': {'type': 'integer'}, 'b': {'type': 'integer'}}, + 'properties': {'a': {'type': 'integer'}, 'b': {'default': 1, 'type': 'integer'}}, 'required': ['a'], 'type': 'object', }, @@ -945,7 +945,7 @@ def my_tool_plain(*, a: int = 1, b: int) -> int: 'outer_typed_dict_key': None, 'parameters_json_schema': { 'additionalProperties': False, - 'properties': {'a': {'type': 'integer'}, 'b': {'type': 'integer'}}, + 'properties': {'a': {'default': 1, 'type': 'integer'}, 'b': {'type': 'integer'}}, 'required': ['b'], 'type': 'object', }, @@ -1031,7 +1031,8 @@ def my_tool(x: Annotated[Union[str, None], WithJsonSchema({'type': 'string'})] = 'name': 'my_tool_1', 'outer_typed_dict_key': None, 'parameters_json_schema': { - 'properties': {'x': {'type': 'string'}}, + 'additionalProperties': True, + 'properties': {'x': {'default': None, 'type': 'string'}}, 'type': 'object', }, 'strict': None, @@ -1042,7 +1043,7 @@ def my_tool(x: Annotated[Union[str, None], WithJsonSchema({'type': 'string'})] = 'name': 'my_tool_2', 'outer_typed_dict_key': None, 'parameters_json_schema': { - 'properties': {'x': {'type': 'string', 'title': 'X title'}}, + 'properties': {'x': {'default': None, 'type': 'string', 'title': 'X title'}}, 'type': 'object', }, 'strict': None, @@ -1071,7 +1072,6 @@ def get_score(data: Data) -> int: ... # pragma: no branch 'name': 'get_score', 'description': None, 'parameters_json_schema': { - 'additionalProperties': False, 'properties': { 'a': {'description': 'The first parameter', 'type': 'integer'}, 'b': {'description': 'The second parameter', 'type': 'integer'}, diff --git a/uv.lock b/uv.lock index 3618f4457..ae38cd4fb 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", @@ -753,72 +753,97 @@ wheels = [ [[package]] name = "coverage" -version = "7.6.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0c/d6/2b53ab3ee99f2262e6f0b8369a43f6d66658eab45510331c0b3d5c8c4272/coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2", size = 805941, upload-time = "2025-02-11T14:47:03.797Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/67/81dc41ec8f548c365d04a29f1afd492d3176b372c33e47fa2a45a01dc13a/coverage-7.6.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:704c8c8c6ce6569286ae9622e534b4f5b9759b6f2cd643f1c1a61f666d534fe8", size = 208345, upload-time = "2025-02-11T14:44:51.83Z" }, - { url = "https://files.pythonhosted.org/packages/33/43/17f71676016c8829bde69e24c852fef6bd9ed39f774a245d9ec98f689fa0/coverage-7.6.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ad7525bf0241e5502168ae9c643a2f6c219fa0a283001cee4cf23a9b7da75879", size = 208775, upload-time = "2025-02-11T14:44:54.852Z" }, - { url = "https://files.pythonhosted.org/packages/86/25/c6ff0775f8960e8c0840845b723eed978d22a3cd9babd2b996e4a7c502c6/coverage-7.6.12-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06097c7abfa611c91edb9e6920264e5be1d6ceb374efb4986f38b09eed4cb2fe", size = 237925, upload-time = "2025-02-11T14:44:56.675Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3d/5f5bd37046243cb9d15fff2c69e498c2f4fe4f9b42a96018d4579ed3506f/coverage-7.6.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220fa6c0ad7d9caef57f2c8771918324563ef0d8272c94974717c3909664e674", size = 235835, upload-time = "2025-02-11T14:44:59.007Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f1/9e6b75531fe33490b910d251b0bf709142e73a40e4e38a3899e6986fe088/coverage-7.6.12-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3688b99604a24492bcfe1c106278c45586eb819bf66a654d8a9a1433022fb2eb", size = 236966, upload-time = "2025-02-11T14:45:02.744Z" }, - { url = "https://files.pythonhosted.org/packages/4f/bc/aef5a98f9133851bd1aacf130e754063719345d2fb776a117d5a8d516971/coverage-7.6.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d1a987778b9c71da2fc8948e6f2656da6ef68f59298b7e9786849634c35d2c3c", size = 236080, upload-time = "2025-02-11T14:45:05.416Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d0/56b4ab77f9b12aea4d4c11dc11cdcaa7c29130b837eb610639cf3400c9c3/coverage-7.6.12-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cec6b9ce3bd2b7853d4a4563801292bfee40b030c05a3d29555fd2a8ee9bd68c", size = 234393, upload-time = "2025-02-11T14:45:08.627Z" }, - { url = "https://files.pythonhosted.org/packages/0d/77/28ef95c5d23fe3dd191a0b7d89c82fea2c2d904aef9315daf7c890e96557/coverage-7.6.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ace9048de91293e467b44bce0f0381345078389814ff6e18dbac8fdbf896360e", size = 235536, upload-time = "2025-02-11T14:45:10.313Z" }, - { url = "https://files.pythonhosted.org/packages/29/62/18791d3632ee3ff3f95bc8599115707d05229c72db9539f208bb878a3d88/coverage-7.6.12-cp310-cp310-win32.whl", hash = "sha256:ea31689f05043d520113e0552f039603c4dd71fa4c287b64cb3606140c66f425", size = 211063, upload-time = "2025-02-11T14:45:12.278Z" }, - { url = "https://files.pythonhosted.org/packages/fc/57/b3878006cedfd573c963e5c751b8587154eb10a61cc0f47a84f85c88a355/coverage-7.6.12-cp310-cp310-win_amd64.whl", hash = "sha256:676f92141e3c5492d2a1596d52287d0d963df21bf5e55c8b03075a60e1ddf8aa", size = 211955, upload-time = "2025-02-11T14:45:14.579Z" }, - { url = "https://files.pythonhosted.org/packages/64/2d/da78abbfff98468c91fd63a73cccdfa0e99051676ded8dd36123e3a2d4d5/coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015", size = 208464, upload-time = "2025-02-11T14:45:18.314Z" }, - { url = "https://files.pythonhosted.org/packages/31/f2/c269f46c470bdabe83a69e860c80a82e5e76840e9f4bbd7f38f8cebbee2f/coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45", size = 208893, upload-time = "2025-02-11T14:45:19.881Z" }, - { url = "https://files.pythonhosted.org/packages/47/63/5682bf14d2ce20819998a49c0deadb81e608a59eed64d6bc2191bc8046b9/coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702", size = 241545, upload-time = "2025-02-11T14:45:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b6/6b6631f1172d437e11067e1c2edfdb7238b65dff965a12bce3b6d1bf2be2/coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0", size = 239230, upload-time = "2025-02-11T14:45:24.864Z" }, - { url = "https://files.pythonhosted.org/packages/c7/01/9cd06cbb1be53e837e16f1b4309f6357e2dfcbdab0dd7cd3b1a50589e4e1/coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f", size = 241013, upload-time = "2025-02-11T14:45:27.203Z" }, - { url = "https://files.pythonhosted.org/packages/4b/26/56afefc03c30871326e3d99709a70d327ac1f33da383cba108c79bd71563/coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f", size = 239750, upload-time = "2025-02-11T14:45:29.577Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ea/88a1ff951ed288f56aa561558ebe380107cf9132facd0b50bced63ba7238/coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d", size = 238462, upload-time = "2025-02-11T14:45:31.096Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d4/1d9404566f553728889409eff82151d515fbb46dc92cbd13b5337fa0de8c/coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba", size = 239307, upload-time = "2025-02-11T14:45:32.713Z" }, - { url = "https://files.pythonhosted.org/packages/12/c1/e453d3b794cde1e232ee8ac1d194fde8e2ba329c18bbf1b93f6f5eef606b/coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f", size = 211117, upload-time = "2025-02-11T14:45:34.228Z" }, - { url = "https://files.pythonhosted.org/packages/d5/db/829185120c1686fa297294f8fcd23e0422f71070bf85ef1cc1a72ecb2930/coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558", size = 212019, upload-time = "2025-02-11T14:45:35.724Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7f/4af2ed1d06ce6bee7eafc03b2ef748b14132b0bdae04388e451e4b2c529b/coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad", size = 208645, upload-time = "2025-02-11T14:45:37.95Z" }, - { url = "https://files.pythonhosted.org/packages/dc/60/d19df912989117caa95123524d26fc973f56dc14aecdec5ccd7d0084e131/coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3", size = 208898, upload-time = "2025-02-11T14:45:40.27Z" }, - { url = "https://files.pythonhosted.org/packages/bd/10/fecabcf438ba676f706bf90186ccf6ff9f6158cc494286965c76e58742fa/coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574", size = 242987, upload-time = "2025-02-11T14:45:43.982Z" }, - { url = "https://files.pythonhosted.org/packages/4c/53/4e208440389e8ea936f5f2b0762dcd4cb03281a7722def8e2bf9dc9c3d68/coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985", size = 239881, upload-time = "2025-02-11T14:45:45.537Z" }, - { url = "https://files.pythonhosted.org/packages/c4/47/2ba744af8d2f0caa1f17e7746147e34dfc5f811fb65fc153153722d58835/coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750", size = 242142, upload-time = "2025-02-11T14:45:47.069Z" }, - { url = "https://files.pythonhosted.org/packages/e9/90/df726af8ee74d92ee7e3bf113bf101ea4315d71508952bd21abc3fae471e/coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea", size = 241437, upload-time = "2025-02-11T14:45:48.602Z" }, - { url = "https://files.pythonhosted.org/packages/f6/af/995263fd04ae5f9cf12521150295bf03b6ba940d0aea97953bb4a6db3e2b/coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3", size = 239724, upload-time = "2025-02-11T14:45:51.333Z" }, - { url = "https://files.pythonhosted.org/packages/1c/8e/5bb04f0318805e190984c6ce106b4c3968a9562a400180e549855d8211bd/coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a", size = 241329, upload-time = "2025-02-11T14:45:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9d/fa04d9e6c3f6459f4e0b231925277cfc33d72dfab7fa19c312c03e59da99/coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95", size = 211289, upload-time = "2025-02-11T14:45:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/53/40/53c7ffe3c0c3fff4d708bc99e65f3d78c129110d6629736faf2dbd60ad57/coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288", size = 212079, upload-time = "2025-02-11T14:45:57.22Z" }, - { url = "https://files.pythonhosted.org/packages/76/89/1adf3e634753c0de3dad2f02aac1e73dba58bc5a3a914ac94a25b2ef418f/coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1", size = 208673, upload-time = "2025-02-11T14:45:59.618Z" }, - { url = "https://files.pythonhosted.org/packages/ce/64/92a4e239d64d798535c5b45baac6b891c205a8a2e7c9cc8590ad386693dc/coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd", size = 208945, upload-time = "2025-02-11T14:46:01.869Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d0/4596a3ef3bca20a94539c9b1e10fd250225d1dec57ea78b0867a1cf9742e/coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9", size = 242484, upload-time = "2025-02-11T14:46:03.527Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ef/6fd0d344695af6718a38d0861408af48a709327335486a7ad7e85936dc6e/coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e", size = 239525, upload-time = "2025-02-11T14:46:05.973Z" }, - { url = "https://files.pythonhosted.org/packages/0c/4b/373be2be7dd42f2bcd6964059fd8fa307d265a29d2b9bcf1d044bcc156ed/coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4", size = 241545, upload-time = "2025-02-11T14:46:07.79Z" }, - { url = "https://files.pythonhosted.org/packages/a6/7d/0e83cc2673a7790650851ee92f72a343827ecaaea07960587c8f442b5cd3/coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6", size = 241179, upload-time = "2025-02-11T14:46:11.853Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8c/566ea92ce2bb7627b0900124e24a99f9244b6c8c92d09ff9f7633eb7c3c8/coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3", size = 239288, upload-time = "2025-02-11T14:46:13.411Z" }, - { url = "https://files.pythonhosted.org/packages/7d/e4/869a138e50b622f796782d642c15fb5f25a5870c6d0059a663667a201638/coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc", size = 241032, upload-time = "2025-02-11T14:46:15.005Z" }, - { url = "https://files.pythonhosted.org/packages/ae/28/a52ff5d62a9f9e9fe9c4f17759b98632edd3a3489fce70154c7d66054dd3/coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3", size = 211315, upload-time = "2025-02-11T14:46:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/bc/17/ab849b7429a639f9722fa5628364c28d675c7ff37ebc3268fe9840dda13c/coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef", size = 212099, upload-time = "2025-02-11T14:46:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/d2/1c/b9965bf23e171d98505eb5eb4fb4d05c44efd256f2e0f19ad1ba8c3f54b0/coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e", size = 209511, upload-time = "2025-02-11T14:46:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/57/b3/119c201d3b692d5e17784fee876a9a78e1b3051327de2709392962877ca8/coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703", size = 209729, upload-time = "2025-02-11T14:46:22.258Z" }, - { url = "https://files.pythonhosted.org/packages/52/4e/a7feb5a56b266304bc59f872ea07b728e14d5a64f1ad3a2cc01a3259c965/coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0", size = 253988, upload-time = "2025-02-11T14:46:23.999Z" }, - { url = "https://files.pythonhosted.org/packages/65/19/069fec4d6908d0dae98126aa7ad08ce5130a6decc8509da7740d36e8e8d2/coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924", size = 249697, upload-time = "2025-02-11T14:46:25.617Z" }, - { url = "https://files.pythonhosted.org/packages/1c/da/5b19f09ba39df7c55f77820736bf17bbe2416bbf5216a3100ac019e15839/coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b", size = 252033, upload-time = "2025-02-11T14:46:28.069Z" }, - { url = "https://files.pythonhosted.org/packages/1e/89/4c2750df7f80a7872267f7c5fe497c69d45f688f7b3afe1297e52e33f791/coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d", size = 251535, upload-time = "2025-02-11T14:46:29.818Z" }, - { url = "https://files.pythonhosted.org/packages/78/3b/6d3ae3c1cc05f1b0460c51e6f6dcf567598cbd7c6121e5ad06643974703c/coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827", size = 249192, upload-time = "2025-02-11T14:46:31.563Z" }, - { url = "https://files.pythonhosted.org/packages/6e/8e/c14a79f535ce41af7d436bbad0d3d90c43d9e38ec409b4770c894031422e/coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9", size = 250627, upload-time = "2025-02-11T14:46:33.145Z" }, - { url = "https://files.pythonhosted.org/packages/cb/79/b7cee656cfb17a7f2c1b9c3cee03dd5d8000ca299ad4038ba64b61a9b044/coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3", size = 212033, upload-time = "2025-02-11T14:46:35.79Z" }, - { url = "https://files.pythonhosted.org/packages/b6/c3/f7aaa3813f1fa9a4228175a7bd368199659d392897e184435a3b66408dd3/coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f", size = 213240, upload-time = "2025-02-11T14:46:38.119Z" }, - { url = "https://files.pythonhosted.org/packages/6c/eb/cf062b1c3dbdcafd64a2a154beea2e4aa8e9886c34e41f53fa04925c8b35/coverage-7.6.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e7575ab65ca8399c8c4f9a7d61bbd2d204c8b8e447aab9d355682205c9dd948d", size = 208343, upload-time = "2025-02-11T14:46:39.744Z" }, - { url = "https://files.pythonhosted.org/packages/95/42/4ebad0ab065228e29869a060644712ab1b0821d8c29bfefa20c2118c9e19/coverage-7.6.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8161d9fbc7e9fe2326de89cd0abb9f3599bccc1287db0aba285cb68d204ce929", size = 208769, upload-time = "2025-02-11T14:46:41.548Z" }, - { url = "https://files.pythonhosted.org/packages/44/9f/421e84f7f9455eca85ff85546f26cbc144034bb2587e08bfc214dd6e9c8f/coverage-7.6.12-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a1e465f398c713f1b212400b4e79a09829cd42aebd360362cd89c5bdc44eb87", size = 237553, upload-time = "2025-02-11T14:46:44.96Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c4/a2c4f274bcb711ed5db2ccc1b851ca1c45f35ed6077aec9d6c61845d80e3/coverage-7.6.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25d8b92a4e31ff1bd873654ec367ae811b3a943583e05432ea29264782dc32c", size = 235473, upload-time = "2025-02-11T14:46:47.023Z" }, - { url = "https://files.pythonhosted.org/packages/e0/10/a3d317e38e5627b06debe861d6c511b1611dd9dc0e2a47afbe6257ffd341/coverage-7.6.12-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a936309a65cc5ca80fa9f20a442ff9e2d06927ec9a4f54bcba9c14c066323f2", size = 236575, upload-time = "2025-02-11T14:46:48.697Z" }, - { url = "https://files.pythonhosted.org/packages/4d/49/51cd991b56257d2e07e3d5cb053411e9de5b0f4e98047167ec05e4e19b55/coverage-7.6.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa6f302a3a0b5f240ee201297fff0bbfe2fa0d415a94aeb257d8b461032389bd", size = 235690, upload-time = "2025-02-11T14:46:51.262Z" }, - { url = "https://files.pythonhosted.org/packages/f7/87/631e5883fe0a80683a1f20dadbd0f99b79e17a9d8ea9aff3a9b4cfe50b93/coverage-7.6.12-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f973643ef532d4f9be71dd88cf7588936685fdb576d93a79fe9f65bc337d9d73", size = 234040, upload-time = "2025-02-11T14:46:52.962Z" }, - { url = "https://files.pythonhosted.org/packages/7c/34/edd03f6933f766ec97dddd178a7295855f8207bb708dbac03777107ace5b/coverage-7.6.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78f5243bb6b1060aed6213d5107744c19f9571ec76d54c99cc15938eb69e0e86", size = 235048, upload-time = "2025-02-11T14:46:54.65Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1e/d45045b7d3012fe518c617a57b9f9396cdaebe6455f1b404858b32c38cdd/coverage-7.6.12-cp39-cp39-win32.whl", hash = "sha256:69e62c5034291c845fc4df7f8155e8544178b6c774f97a99e2734b05eb5bed31", size = 211085, upload-time = "2025-02-11T14:46:56.233Z" }, - { url = "https://files.pythonhosted.org/packages/df/ea/086cb06af14a84fe773b86aa140892006a906c5ec947e609ceb6a93f6257/coverage-7.6.12-cp39-cp39-win_amd64.whl", hash = "sha256:b01a840ecc25dce235ae4c1b6a0daefb2a203dba0e6e980637ee9c2f6ee0df57", size = 211965, upload-time = "2025-02-11T14:46:57.84Z" }, - { url = "https://files.pythonhosted.org/packages/7a/7f/05818c62c7afe75df11e0233bd670948d68b36cdbf2a339a095bc02624a8/coverage-7.6.12-pp39.pp310-none-any.whl", hash = "sha256:7e39e845c4d764208e7b8f6a21c541ade741e2c41afabdfa1caa28687a3c98cf", size = 200558, upload-time = "2025-02-11T14:47:00.292Z" }, - { url = "https://files.pythonhosted.org/packages/fb/b2/f655700e1024dec98b10ebaafd0cedbc25e40e4abe62a3c8e2ceef4f8f0a/coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953", size = 200552, upload-time = "2025-02-11T14:47:01.999Z" }, +version = "7.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/76/17780846fc7aade1e66712e1e27dd28faa0a5d987a1f433610974959eaa8/coverage-7.10.2.tar.gz", hash = "sha256:5d6e6d84e6dd31a8ded64759626627247d676a23c1b892e1326f7c55c8d61055", size = 820754, upload-time = "2025-08-04T00:35:17.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/5f/5ce748ab3f142593698aff5f8a0cf020775aa4e24b9d8748b5a56b64d3f8/coverage-7.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:79f0283ab5e6499fd5fe382ca3d62afa40fb50ff227676a3125d18af70eabf65", size = 215003, upload-time = "2025-08-04T00:33:02.977Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ed/507088561217b000109552139802fa99c33c16ad19999c687b601b3790d0/coverage-7.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4545e906f595ee8ab8e03e21be20d899bfc06647925bc5b224ad7e8c40e08b8", size = 215391, upload-time = "2025-08-04T00:33:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/79/1b/0f496259fe137c4c5e1e8eaff496fb95af88b71700f5e57725a4ddbe742b/coverage-7.10.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ae385e1d58fbc6a9b1c315e5510ac52281e271478b45f92ca9b5ad42cf39643f", size = 242367, upload-time = "2025-08-04T00:33:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/b9/8e/5a8835fb0122a2e2a108bf3527931693c4625fdc4d953950a480b9625852/coverage-7.10.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f0cbe5f7dd19f3a32bac2251b95d51c3b89621ac88a2648096ce40f9a5aa1e7", size = 243627, upload-time = "2025-08-04T00:33:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/c3/96/6a528429c2e0e8d85261764d0cd42e51a429510509bcc14676ee5d1bb212/coverage-7.10.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd17f427f041f6b116dc90b4049c6f3e1230524407d00daa2d8c7915037b5947", size = 245485, upload-time = "2025-08-04T00:33:10.29Z" }, + { url = "https://files.pythonhosted.org/packages/bf/82/1fba935c4d02c33275aca319deabf1f22c0f95f2c0000bf7c5f276d6f7b4/coverage-7.10.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7f10ca4cde7b466405cce0a0e9971a13eb22e57a5ecc8b5f93a81090cc9c7eb9", size = 243429, upload-time = "2025-08-04T00:33:11.909Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a8/c8dc0a57a729fc93be33ab78f187a8f52d455fa8f79bfb379fe23b45868d/coverage-7.10.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3b990df23dd51dccce26d18fb09fd85a77ebe46368f387b0ffba7a74e470b31b", size = 242104, upload-time = "2025-08-04T00:33:13.467Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6f/0b7da1682e2557caeed299a00897b42afde99a241a01eba0197eb982b90f/coverage-7.10.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc3902584d25c7eef57fb38f440aa849a26a3a9f761a029a72b69acfca4e31f8", size = 242397, upload-time = "2025-08-04T00:33:14.682Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e4/54dc833dadccd519c04a28852f39a37e522bad35d70cfe038817cdb8f168/coverage-7.10.2-cp310-cp310-win32.whl", hash = "sha256:9dd37e9ac00d5eb72f38ed93e3cdf2280b1dbda3bb9b48c6941805f265ad8d87", size = 217502, upload-time = "2025-08-04T00:33:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e7/2f78159c4c127549172f427dff15b02176329327bf6a6a1fcf1f603b5456/coverage-7.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:99d16f15cb5baf0729354c5bd3080ae53847a4072b9ba1e10957522fb290417f", size = 218388, upload-time = "2025-08-04T00:33:17.4Z" }, + { url = "https://files.pythonhosted.org/packages/6e/53/0125a6fc0af4f2687b4e08b0fb332cd0d5e60f3ca849e7456f995d022656/coverage-7.10.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c3b210d79925a476dfc8d74c7d53224888421edebf3a611f3adae923e212b27", size = 215119, upload-time = "2025-08-04T00:33:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2e/960d9871de9152dbc9ff950913c6a6e9cf2eb4cc80d5bc8f93029f9f2f9f/coverage-7.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf67d1787cd317c3f8b2e4c6ed1ae93497be7e30605a0d32237ac37a37a8a322", size = 215511, upload-time = "2025-08-04T00:33:20.32Z" }, + { url = "https://files.pythonhosted.org/packages/3f/34/68509e44995b9cad806d81b76c22bc5181f3535bca7cd9c15791bfd8951e/coverage-7.10.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:069b779d03d458602bc0e27189876e7d8bdf6b24ac0f12900de22dd2154e6ad7", size = 245513, upload-time = "2025-08-04T00:33:21.896Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d4/9b12f357413248ce40804b0f58030b55a25b28a5c02db95fb0aa50c5d62c/coverage-7.10.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c2de4cb80b9990e71c62c2d3e9f3ec71b804b1f9ca4784ec7e74127e0f42468", size = 247350, upload-time = "2025-08-04T00:33:23.917Z" }, + { url = "https://files.pythonhosted.org/packages/b6/40/257945eda1f72098e4a3c350b1d68fdc5d7d032684a0aeb6c2391153ecf4/coverage-7.10.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75bf7ab2374a7eb107602f1e07310cda164016cd60968abf817b7a0b5703e288", size = 249516, upload-time = "2025-08-04T00:33:25.5Z" }, + { url = "https://files.pythonhosted.org/packages/ff/55/8987f852ece378cecbf39a367f3f7ec53351e39a9151b130af3a3045b83f/coverage-7.10.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3f37516458ec1550815134937f73d6d15b434059cd10f64678a2068f65c62406", size = 247241, upload-time = "2025-08-04T00:33:26.767Z" }, + { url = "https://files.pythonhosted.org/packages/df/ae/da397de7a42a18cea6062ed9c3b72c50b39e0b9e7b2893d7172d3333a9a1/coverage-7.10.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:de3c6271c482c250d3303fb5c6bdb8ca025fff20a67245e1425df04dc990ece9", size = 245274, upload-time = "2025-08-04T00:33:28.494Z" }, + { url = "https://files.pythonhosted.org/packages/4e/64/7baa895eb55ec0e1ec35b988687ecd5d4475ababb0d7ae5ca3874dd90ee7/coverage-7.10.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:98a838101321ac3089c9bb1d4bfa967e8afed58021fda72d7880dc1997f20ae1", size = 245882, upload-time = "2025-08-04T00:33:30.048Z" }, + { url = "https://files.pythonhosted.org/packages/24/6c/1fd76a0bd09ae75220ae9775a8290416d726f0e5ba26ea72346747161240/coverage-7.10.2-cp311-cp311-win32.whl", hash = "sha256:f2a79145a531a0e42df32d37be5af069b4a914845b6f686590739b786f2f7bce", size = 217541, upload-time = "2025-08-04T00:33:31.376Z" }, + { url = "https://files.pythonhosted.org/packages/5f/2d/8c18fb7a6e74c79fd4661e82535bc8c68aee12f46c204eabf910b097ccc9/coverage-7.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:e4f5f1320f8ee0d7cfa421ceb257bef9d39fd614dd3ddcfcacd284d4824ed2c2", size = 218426, upload-time = "2025-08-04T00:33:32.976Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/425bb35e4ff7c7af177edf5dffd4154bc2a677b27696afe6526d75c77fec/coverage-7.10.2-cp311-cp311-win_arm64.whl", hash = "sha256:d8f2d83118f25328552c728b8e91babf93217db259ca5c2cd4dd4220b8926293", size = 217116, upload-time = "2025-08-04T00:33:34.302Z" }, + { url = "https://files.pythonhosted.org/packages/4e/1e/2c752bdbbf6f1199c59b1a10557fbb6fb3dc96b3c0077b30bd41a5922c1f/coverage-7.10.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:890ad3a26da9ec7bf69255b9371800e2a8da9bc223ae5d86daeb940b42247c83", size = 215311, upload-time = "2025-08-04T00:33:35.524Z" }, + { url = "https://files.pythonhosted.org/packages/68/6a/84277d73a2cafb96e24be81b7169372ba7ff28768ebbf98e55c85a491b0f/coverage-7.10.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38fd1ccfca7838c031d7a7874d4353e2f1b98eb5d2a80a2fe5732d542ae25e9c", size = 215550, upload-time = "2025-08-04T00:33:37.109Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e7/5358b73b46ac76f56cc2de921eeabd44fabd0b7ff82ea4f6b8c159c4d5dc/coverage-7.10.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:76c1ffaaf4f6f0f6e8e9ca06f24bb6454a7a5d4ced97a1bc466f0d6baf4bd518", size = 246564, upload-time = "2025-08-04T00:33:38.33Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0e/b0c901dd411cb7fc0cfcb28ef0dc6f3049030f616bfe9fc4143aecd95901/coverage-7.10.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:86da8a3a84b79ead5c7d0e960c34f580bc3b231bb546627773a3f53c532c2f21", size = 248993, upload-time = "2025-08-04T00:33:39.555Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4e/a876db272072a9e0df93f311e187ccdd5f39a190c6d1c1f0b6e255a0d08e/coverage-7.10.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99cef9731c8a39801830a604cc53c93c9e57ea8b44953d26589499eded9576e0", size = 250454, upload-time = "2025-08-04T00:33:41.023Z" }, + { url = "https://files.pythonhosted.org/packages/64/d6/1222dc69f8dd1be208d55708a9f4a450ad582bf4fa05320617fea1eaa6d8/coverage-7.10.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ea58b112f2966a8b91eb13f5d3b1f8bb43c180d624cd3283fb33b1cedcc2dd75", size = 248365, upload-time = "2025-08-04T00:33:42.376Z" }, + { url = "https://files.pythonhosted.org/packages/62/e3/40fd71151064fc315c922dd9a35e15b30616f00146db1d6a0b590553a75a/coverage-7.10.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:20f405188d28da9522b7232e51154e1b884fc18d0b3a10f382d54784715bbe01", size = 246562, upload-time = "2025-08-04T00:33:43.663Z" }, + { url = "https://files.pythonhosted.org/packages/fc/14/8aa93ddcd6623ddaef5d8966268ac9545b145bce4fe7b1738fd1c3f0d957/coverage-7.10.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:64586ce42bbe0da4d9f76f97235c545d1abb9b25985a8791857690f96e23dc3b", size = 247772, upload-time = "2025-08-04T00:33:45.068Z" }, + { url = "https://files.pythonhosted.org/packages/07/4e/dcb1c01490623c61e2f2ea85cb185fa6a524265bb70eeb897d3c193efeb9/coverage-7.10.2-cp312-cp312-win32.whl", hash = "sha256:bc2e69b795d97ee6d126e7e22e78a509438b46be6ff44f4dccbb5230f550d340", size = 217710, upload-time = "2025-08-04T00:33:46.378Z" }, + { url = "https://files.pythonhosted.org/packages/79/16/e8aab4162b5f80ad2e5e1f54b1826e2053aa2f4db508b864af647f00c239/coverage-7.10.2-cp312-cp312-win_amd64.whl", hash = "sha256:adda2268b8cf0d11f160fad3743b4dfe9813cd6ecf02c1d6397eceaa5b45b388", size = 218499, upload-time = "2025-08-04T00:33:48.048Z" }, + { url = "https://files.pythonhosted.org/packages/06/7f/c112ec766e8f1131ce8ce26254be028772757b2d1e63e4f6a4b0ad9a526c/coverage-7.10.2-cp312-cp312-win_arm64.whl", hash = "sha256:164429decd0d6b39a0582eaa30c67bf482612c0330572343042d0ed9e7f15c20", size = 217154, upload-time = "2025-08-04T00:33:49.299Z" }, + { url = "https://files.pythonhosted.org/packages/8d/04/9b7a741557f93c0ed791b854d27aa8d9fe0b0ce7bb7c52ca1b0f2619cb74/coverage-7.10.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:aca7b5645afa688de6d4f8e89d30c577f62956fefb1bad021490d63173874186", size = 215337, upload-time = "2025-08-04T00:33:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/02/a4/8d1088cd644750c94bc305d3cf56082b4cdf7fb854a25abb23359e74892f/coverage-7.10.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:96e5921342574a14303dfdb73de0019e1ac041c863743c8fe1aa6c2b4a257226", size = 215596, upload-time = "2025-08-04T00:33:52.33Z" }, + { url = "https://files.pythonhosted.org/packages/01/2f/643a8d73343f70e162d8177a3972b76e306b96239026bc0c12cfde4f7c7a/coverage-7.10.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:11333094c1bff621aa811b67ed794865cbcaa99984dedea4bd9cf780ad64ecba", size = 246145, upload-time = "2025-08-04T00:33:53.641Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4a/722098d1848db4072cda71b69ede1e55730d9063bf868375264d0d302bc9/coverage-7.10.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6eb586fa7d2aee8d65d5ae1dd71414020b2f447435c57ee8de8abea0a77d5074", size = 248492, upload-time = "2025-08-04T00:33:55.366Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b0/8a6d7f326f6e3e6ed398cde27f9055e860a1e858317001835c521673fb60/coverage-7.10.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d358f259d8019d4ef25d8c5b78aca4c7af25e28bd4231312911c22a0e824a57", size = 249927, upload-time = "2025-08-04T00:33:57.042Z" }, + { url = "https://files.pythonhosted.org/packages/bb/21/1aaadd3197b54d1e61794475379ecd0f68d8fc5c2ebd352964dc6f698a3d/coverage-7.10.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5250bda76e30382e0a2dcd68d961afcab92c3a7613606e6269855c6979a1b0bb", size = 248138, upload-time = "2025-08-04T00:33:58.329Z" }, + { url = "https://files.pythonhosted.org/packages/48/65/be75bafb2bdd22fd8bf9bf63cd5873b91bb26ec0d68f02d4b8b09c02decb/coverage-7.10.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a91e027d66eff214d88d9afbe528e21c9ef1ecdf4956c46e366c50f3094696d0", size = 246111, upload-time = "2025-08-04T00:33:59.899Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/a4f0c5e249c3cc60e6c6f30d8368e372f2d380eda40e0434c192ac27ccf5/coverage-7.10.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:228946da741558904e2c03ce870ba5efd9cd6e48cbc004d9a27abee08100a15a", size = 247493, upload-time = "2025-08-04T00:34:01.619Z" }, + { url = "https://files.pythonhosted.org/packages/85/99/f09b9493e44a75cf99ca834394c12f8cb70da6c1711ee296534f97b52729/coverage-7.10.2-cp313-cp313-win32.whl", hash = "sha256:95e23987b52d02e7c413bf2d6dc6288bd5721beb518052109a13bfdc62c8033b", size = 217756, upload-time = "2025-08-04T00:34:03.277Z" }, + { url = "https://files.pythonhosted.org/packages/2d/bb/cbcb09103be330c7d26ff0ab05c4a8861dd2e254656fdbd3eb7600af4336/coverage-7.10.2-cp313-cp313-win_amd64.whl", hash = "sha256:f35481d42c6d146d48ec92d4e239c23f97b53a3f1fbd2302e7c64336f28641fe", size = 218526, upload-time = "2025-08-04T00:34:04.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/8f/8bfb4e0bca52c00ab680767c0dd8cfd928a2a72d69897d9b2d5d8b5f63f5/coverage-7.10.2-cp313-cp313-win_arm64.whl", hash = "sha256:65b451949cb789c346f9f9002441fc934d8ccedcc9ec09daabc2139ad13853f7", size = 217176, upload-time = "2025-08-04T00:34:05.973Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/d458ba0bf16a8204a88d74dbb7ec5520f29937ffcbbc12371f931c11efd2/coverage-7.10.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e8415918856a3e7d57a4e0ad94651b761317de459eb74d34cc1bb51aad80f07e", size = 216058, upload-time = "2025-08-04T00:34:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1c/af4dfd2d7244dc7610fed6d59d57a23ea165681cd764445dc58d71ed01a6/coverage-7.10.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f287a25a8ca53901c613498e4a40885b19361a2fe8fbfdbb7f8ef2cad2a23f03", size = 216273, upload-time = "2025-08-04T00:34:09.073Z" }, + { url = "https://files.pythonhosted.org/packages/8e/67/ec5095d4035c6e16368226fa9cb15f77f891194c7e3725aeefd08e7a3e5a/coverage-7.10.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:75cc1a3f8c88c69bf16a871dab1fe5a7303fdb1e9f285f204b60f1ee539b8fc0", size = 257513, upload-time = "2025-08-04T00:34:10.403Z" }, + { url = "https://files.pythonhosted.org/packages/1c/47/be5550b57a3a8ba797de4236b0fd31031f88397b2afc84ab3c2d4cf265f6/coverage-7.10.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca07fa78cc9d26bc8c4740de1abd3489cf9c47cc06d9a8ab3d552ff5101af4c0", size = 259377, upload-time = "2025-08-04T00:34:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/37/50/b12a4da1382e672305c2d17cd3029dc16b8a0470de2191dbf26b91431378/coverage-7.10.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2e117e64c26300032755d4520cd769f2623cde1a1d1c3515b05a3b8add0ade1", size = 261516, upload-time = "2025-08-04T00:34:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/db/41/4d3296dbd33dd8da178171540ca3391af7c0184c0870fd4d4574ac290290/coverage-7.10.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:daaf98009977f577b71f8800208f4d40d4dcf5c2db53d4d822787cdc198d76e1", size = 259110, upload-time = "2025-08-04T00:34:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/b409959ecbc0cec0e61e65683b22bacaa4a3b11512f834e16dd8ffbc37db/coverage-7.10.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ea8d8fe546c528535c761ba424410bbeb36ba8a0f24be653e94b70c93fd8a8ca", size = 257248, upload-time = "2025-08-04T00:34:16.501Z" }, + { url = "https://files.pythonhosted.org/packages/48/ab/7076dc1c240412e9267d36ec93e9e299d7659f6a5c1e958f87e998b0fb6d/coverage-7.10.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fe024d40ac31eb8d5aae70215b41dafa264676caa4404ae155f77d2fa95c37bb", size = 258063, upload-time = "2025-08-04T00:34:18.338Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/f6b51a0288f8f5f7dcc7c89abdd22cf514f3bc5151284f5cd628917f8e10/coverage-7.10.2-cp313-cp313t-win32.whl", hash = "sha256:8f34b09f68bdadec122ffad312154eda965ade433559cc1eadd96cca3de5c824", size = 218433, upload-time = "2025-08-04T00:34:19.71Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6d/547a86493e25270ce8481543e77f3a0aa3aa872c1374246b7b76273d66eb/coverage-7.10.2-cp313-cp313t-win_amd64.whl", hash = "sha256:71d40b3ac0f26fa9ffa6ee16219a714fed5c6ec197cdcd2018904ab5e75bcfa3", size = 219523, upload-time = "2025-08-04T00:34:21.171Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d5/3c711e38eaf9ab587edc9bed232c0298aed84e751a9f54aaa556ceaf7da6/coverage-7.10.2-cp313-cp313t-win_arm64.whl", hash = "sha256:abb57fdd38bf6f7dcc66b38dafb7af7c5fdc31ac6029ce373a6f7f5331d6f60f", size = 217739, upload-time = "2025-08-04T00:34:22.514Z" }, + { url = "https://files.pythonhosted.org/packages/71/53/83bafa669bb9d06d4c8c6a055d8d05677216f9480c4698fb183ba7ec5e47/coverage-7.10.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a3e853cc04987c85ec410905667eed4bf08b1d84d80dfab2684bb250ac8da4f6", size = 215328, upload-time = "2025-08-04T00:34:23.991Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6c/30827a9c5a48a813e865fbaf91e2db25cce990bd223a022650ef2293fe11/coverage-7.10.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0100b19f230df72c90fdb36db59d3f39232391e8d89616a7de30f677da4f532b", size = 215608, upload-time = "2025-08-04T00:34:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a0/c92d85948056ddc397b72a3d79d36d9579c53cb25393ed3c40db7d33b193/coverage-7.10.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9c1cd71483ea78331bdfadb8dcec4f4edfb73c7002c1206d8e0af6797853f5be", size = 246111, upload-time = "2025-08-04T00:34:26.857Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/d695cf86b2559aadd072c91720a7844be4fb82cb4a3b642a2c6ce075692d/coverage-7.10.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9f75dbf4899e29a37d74f48342f29279391668ef625fdac6d2f67363518056a1", size = 248419, upload-time = "2025-08-04T00:34:28.726Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0a/03206aec4a05986e039418c038470d874045f6e00426b0c3879adc1f9251/coverage-7.10.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7df481e7508de1c38b9b8043da48d94931aefa3e32b47dd20277e4978ed5b95", size = 250038, upload-time = "2025-08-04T00:34:30.061Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9b/b3bd6bd52118c12bc4cf319f5baba65009c9beea84e665b6b9f03fa3f180/coverage-7.10.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:835f39e618099325e7612b3406f57af30ab0a0af350490eff6421e2e5f608e46", size = 248066, upload-time = "2025-08-04T00:34:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/bfa92e261d3e055c851a073e87ba6a3bff12a1f7134233e48a8f7d855875/coverage-7.10.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:12e52b5aa00aa720097d6947d2eb9e404e7c1101ad775f9661ba165ed0a28303", size = 245909, upload-time = "2025-08-04T00:34:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/12/80/c8df15db4847710c72084164f615ae900af1ec380dce7f74a5678ccdf5e1/coverage-7.10.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:718044729bf1fe3e9eb9f31b52e44ddae07e434ec050c8c628bf5adc56fe4bdd", size = 247329, upload-time = "2025-08-04T00:34:34.388Z" }, + { url = "https://files.pythonhosted.org/packages/04/6f/cb66e1f7124d5dd9ced69f889f02931419cb448125e44a89a13f4e036124/coverage-7.10.2-cp314-cp314-win32.whl", hash = "sha256:f256173b48cc68486299d510a3e729a96e62c889703807482dbf56946befb5c8", size = 218007, upload-time = "2025-08-04T00:34:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e1/3d4be307278ce32c1b9d95cc02ee60d54ddab784036101d053ec9e4fe7f5/coverage-7.10.2-cp314-cp314-win_amd64.whl", hash = "sha256:2e980e4179f33d9b65ac4acb86c9c0dde904098853f27f289766657ed16e07b3", size = 218802, upload-time = "2025-08-04T00:34:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/ec/66/1e43bbeb66c55a5a5efec70f1c153cf90cfc7f1662ab4ebe2d844de9122c/coverage-7.10.2-cp314-cp314-win_arm64.whl", hash = "sha256:14fb5b6641ab5b3c4161572579f0f2ea8834f9d3af2f7dd8fbaecd58ef9175cc", size = 217397, upload-time = "2025-08-04T00:34:39.15Z" }, + { url = "https://files.pythonhosted.org/packages/81/01/ae29c129217f6110dc694a217475b8aecbb1b075d8073401f868c825fa99/coverage-7.10.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e96649ac34a3d0e6491e82a2af71098e43be2874b619547c3282fc11d3840a4b", size = 216068, upload-time = "2025-08-04T00:34:40.648Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/6e9221d4139f357258f36dfa1d8cac4ec56d9d5acf5fdcc909bb016954d7/coverage-7.10.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1a2e934e9da26341d342d30bfe91422bbfdb3f1f069ec87f19b2909d10d8dcc4", size = 216285, upload-time = "2025-08-04T00:34:42.441Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ec/89d1d0c0ece0d296b4588e0ef4df185200456d42a47f1141335f482c2fc5/coverage-7.10.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:651015dcd5fd9b5a51ca79ece60d353cacc5beaf304db750407b29c89f72fe2b", size = 257603, upload-time = "2025-08-04T00:34:43.899Z" }, + { url = "https://files.pythonhosted.org/packages/82/06/c830af66734671c778fc49d35b58339e8f0687fbd2ae285c3f96c94da092/coverage-7.10.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81bf6a32212f9f66da03d63ecb9cd9bd48e662050a937db7199dbf47d19831de", size = 259568, upload-time = "2025-08-04T00:34:45.519Z" }, + { url = "https://files.pythonhosted.org/packages/60/57/f280dd6f1c556ecc744fbf39e835c33d3ae987d040d64d61c6f821e87829/coverage-7.10.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d800705f6951f75a905ea6feb03fff8f3ea3468b81e7563373ddc29aa3e5d1ca", size = 261691, upload-time = "2025-08-04T00:34:47.019Z" }, + { url = "https://files.pythonhosted.org/packages/54/2b/c63a0acbd19d99ec32326164c23df3a4e18984fb86e902afdd66ff7b3d83/coverage-7.10.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:248b5394718e10d067354448dc406d651709c6765669679311170da18e0e9af8", size = 259166, upload-time = "2025-08-04T00:34:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c5/cd2997dcfcbf0683634da9df52d3967bc1f1741c1475dd0e4722012ba9ef/coverage-7.10.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5c61675a922b569137cf943770d7ad3edd0202d992ce53ac328c5ff68213ccf4", size = 257241, upload-time = "2025-08-04T00:34:51.038Z" }, + { url = "https://files.pythonhosted.org/packages/16/26/c9e30f82fdad8d47aee90af4978b18c88fa74369ae0f0ba0dbf08cee3a80/coverage-7.10.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:52d708b5fd65589461381fa442d9905f5903d76c086c6a4108e8e9efdca7a7ed", size = 258139, upload-time = "2025-08-04T00:34:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/c9/99/bdb7bd00bebcd3dedfb895fa9af8e46b91422993e4a37ac634a5f1113790/coverage-7.10.2-cp314-cp314t-win32.whl", hash = "sha256:916369b3b914186b2c5e5ad2f7264b02cff5df96cdd7cdad65dccd39aa5fd9f0", size = 218809, upload-time = "2025-08-04T00:34:54.075Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5e/56a7852e38a04d1520dda4dfbfbf74a3d6dec932c20526968f7444763567/coverage-7.10.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5b9d538e8e04916a5df63052d698b30c74eb0174f2ca9cd942c981f274a18eaf", size = 219926, upload-time = "2025-08-04T00:34:55.643Z" }, + { url = "https://files.pythonhosted.org/packages/e0/12/7fbe6b9c52bb9d627e9556f9f2edfdbe88b315e084cdecc9afead0c3b36a/coverage-7.10.2-cp314-cp314t-win_arm64.whl", hash = "sha256:04c74f9ef1f925456a9fd23a7eef1103126186d0500ef9a0acb0bd2514bdc7cc", size = 217925, upload-time = "2025-08-04T00:34:57.564Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/139fa9f64edfa5bae1492a4efecef7209f59ba5f9d862db594be7a85d7fb/coverage-7.10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:765b13b164685a2f8b2abef867ad07aebedc0e090c757958a186f64e39d63dbd", size = 215003, upload-time = "2025-08-04T00:34:59.079Z" }, + { url = "https://files.pythonhosted.org/packages/fd/9f/8682ccdd223c2ab34de6575ef3c78fae9bdaece1710b4d95bb9b0abd4d2f/coverage-7.10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a219b70100500d0c7fd3ebb824a3302efb6b1a122baa9d4eb3f43df8f0b3d899", size = 215382, upload-time = "2025-08-04T00:35:00.772Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4e/45b9658499db7149e1ed5b46ccac6101dc5c0ddb786a0304f7bb0c0d90d4/coverage-7.10.2-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e33e79a219105aa315439ee051bd50b6caa705dc4164a5aba6932c8ac3ce2d98", size = 241457, upload-time = "2025-08-04T00:35:02.696Z" }, + { url = "https://files.pythonhosted.org/packages/dd/66/aaf159bfe94ee3996b8786034a8e713bc68cd650aa7c1a41b612846cdc41/coverage-7.10.2-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc3945b7bad33957a9eca16e9e5eae4b17cb03173ef594fdaad228f4fc7da53b", size = 243354, upload-time = "2025-08-04T00:35:04.238Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/8fd2f67d8580380e7b19b23838e308b6757197e94a1b3b87e0ad483f70c8/coverage-7.10.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bdff88e858ee608a924acfad32a180d2bf6e13e059d6a7174abbae075f30436", size = 244923, upload-time = "2025-08-04T00:35:06.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/90/67b129b08200e08962961f56604083923bc8484bc641c92ee6801c1ae822/coverage-7.10.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:44329cbed24966c0b49acb386352c9722219af1f0c80db7f218af7793d251902", size = 242856, upload-time = "2025-08-04T00:35:07.735Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8f/3f428363f713ab3432e602665cdefe436fd427263471644dd3742b6eebd8/coverage-7.10.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:be127f292496d0fbe20d8025f73221b36117b3587f890346e80a13b310712982", size = 241092, upload-time = "2025-08-04T00:35:09.381Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e8531ea19f047b8b1d1d1c85794e4b35ae762e570f072ca2afbce67be176/coverage-7.10.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6c031da749a05f7a01447dd7f47beedb498edd293e31e1878c0d52db18787df0", size = 242044, upload-time = "2025-08-04T00:35:10.929Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/22cb6281b4d06b73edae2facc7935a15151ddb8e8d8928a184b7a3100289/coverage-7.10.2-cp39-cp39-win32.whl", hash = "sha256:22aca3e691c7709c5999ccf48b7a8ff5cf5a8bd6fe9b36efbd4993f5a36b2fcf", size = 217512, upload-time = "2025-08-04T00:35:12.801Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/bce22e6880837de640d6ff630c7493709a3511f93c5154a326b337f01a81/coverage-7.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:c7195444b932356055a8e287fa910bf9753a84a1bc33aeb3770e8fca521e032e", size = 218406, upload-time = "2025-08-04T00:35:14.351Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/9b768ac73a8ac2d10c080af23937212434a958c8d2a1c84e89b450237942/coverage-7.10.2-py3-none-any.whl", hash = "sha256:95db3750dd2e6e93d99fa2498f3a1580581e49c494bddccc6f85c5c21604921f", size = 206973, upload-time = "2025-08-04T00:35:15.918Z" }, ] [package.optional-dependencies] @@ -826,6 +851,18 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "coverage-enable-subprocess" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/f4/57693bcf041ba641501b7a2fafc9d3d2de647355d78c6a2e07fb53648eaa/coverage_enable_subprocess-1.0.tar.gz", hash = "sha256:fdbd3dc9532007cd87ef84f38e16024c5b0ccb4ab2d1755225a7edf937acc011", size = 2695, upload-time = "2016-05-11T18:49:48.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/58/d8dd7edbf5e120942b6395b4c034506c68e56f656074522c83b59d9a4991/coverage_enable_subprocess-1.0-py2.py3-none-any.whl", hash = "sha256:27982522339ec77662965e0d859da5662162962c874d54d2250426506818cbdc", size = 4033, upload-time = "2016-05-11T18:48:49.701Z" }, +] + [[package]] name = "cssselect2" version = "0.7.0" @@ -1696,7 +1733,7 @@ wheels = [ [[package]] name = "logfire" -version = "3.17.0" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "executing" }, @@ -1708,9 +1745,9 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/3e/0367dc7cf0636c2baf4c5b58fa2b0d837302d71362c58f21472beddd271d/logfire-3.17.0.tar.gz", hash = "sha256:5c7230b373d9994b61466ec8a2462957826c4ca1d8351af7f43dde3e54f6f072", size = 486770, upload-time = "2025-06-03T15:26:49.148Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/85/4ee1ced49f2c378fd7df9f507d6426da3c3520957bfe56e6c049ccacd4e4/logfire-4.0.0.tar.gz", hash = "sha256:64d95fbf0f05c99a8b4c99a35b5b2971f11adbfbe9a73726df11d01c12f9959c", size = 512056, upload-time = "2025-07-22T15:12:05.951Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/81/e8d76d8b6c0d6d855df0e328af2ab68030b40f502002a5293f3b36c33b03/logfire-3.17.0-py3-none-any.whl", hash = "sha256:8ebccdb01e3af12ecf271246c2a6761ea35c109ef94f4d7520db6b7c3532cd05", size = 197653, upload-time = "2025-06-03T15:26:45.103Z" }, + { url = "https://files.pythonhosted.org/packages/b3/06/377ff0eb5d78ba893025eafed6104088eccefb0e538a9bed24e1f5d4fe53/logfire-4.0.0-py3-none-any.whl", hash = "sha256:4e50887d61954f849ec05343ca71b29fec5c0b6e4e945cabbceed664e37966e7", size = 211515, upload-time = "2025-07-22T15:12:02.113Z" }, ] [package.optional-dependencies] @@ -3425,6 +3462,7 @@ dev = [ { name = "asgi-lifespan" }, { name = "boto3-stubs", extra = ["bedrock-runtime"] }, { name = "coverage", extra = ["toml"] }, + { name = "coverage-enable-subprocess" }, { name = "devtools" }, { name = "diff-cover", version = "9.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9.17'" }, { name = "diff-cover", version = "9.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9.17'" }, @@ -3480,7 +3518,8 @@ dev = [ { name = "anyio", specifier = ">=4.5.0" }, { name = "asgi-lifespan", specifier = ">=2.1.0" }, { name = "boto3-stubs", extras = ["bedrock-runtime"] }, - { name = "coverage", extras = ["toml"], specifier = ">=7.6.2" }, + { name = "coverage", extras = ["toml"], specifier = ">=7.10.2" }, + { name = "coverage-enable-subprocess", specifier = ">=0.1.0" }, { name = "devtools", specifier = ">=0.12.2" }, { name = "diff-cover", specifier = ">=9.2.0" }, { name = "dirty-equals", specifier = ">=0.9.0" }, @@ -3492,7 +3531,7 @@ dev = [ { name = "pytest-pretty", specifier = ">=1.3.0" }, { name = "pytest-recording", specifier = ">=0.13.2" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, - { name = "strict-no-cover", specifier = ">=0.1.1" }, + { name = "strict-no-cover", git = "https://github.com/pydantic/strict-no-cover.git?rev=7fc59da2c4dff919db2095a0f0e47101b657131d" }, ] [[package]] @@ -4349,14 +4388,10 @@ wheels = [ [[package]] name = "strict-no-cover" version = "0.1.1" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/pydantic/strict-no-cover.git?rev=7fc59da2c4dff919db2095a0f0e47101b657131d#7fc59da2c4dff919db2095a0f0e47101b657131d" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/9c/0d3da51690d0d6d9114dacaacf80d8d7f7bfa2226ebbee4ac113ce872fed/strict_no_cover-0.1.1.tar.gz", hash = "sha256:428512000c2f8407008d4834619869b222f88124a0f330d7578caffa6447ea6b", size = 4671, upload-time = "2025-05-09T14:08:37.661Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/19/dc7194d30abe5850324f4829b240f4a0afb37ff57082eb9fdc993054a7ec/strict_no_cover-0.1.1-py3-none-any.whl", hash = "sha256:0af14678bbe6ec079931a2f3a4cbe4e1a53e41ccf6d3ffb21f042820acba948b", size = 5041, upload-time = "2025-05-09T14:08:36.4Z" }, -] [[package]] name = "synchronicity"