From 04d61a461dff5fcd01c307999489deca6eb9f812 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Mon, 26 Jan 2026 21:46:49 -0500 Subject: [PATCH 01/15] Initial commit on MCP implementation --- .../src/data_designer/config/__init__.py | 3 + .../data_designer/config/column_configs.py | 4 + .../src/data_designer/config/mcp.py | 36 + .../tests/config/test_mcp.py | 36 + packages/data-designer-engine/pyproject.toml | 1 + .../generators/llm_completion.py | 1 + .../src/data_designer/engine/mcp/__init__.py | 4 + .../src/data_designer/engine/mcp/errors.py | 18 + .../src/data_designer/engine/mcp/manager.py | 195 +++++ .../src/data_designer/engine/models/facade.py | 130 ++- .../data_designer/engine/models/factory.py | 9 +- .../engine/resources/resource_provider.py | 7 + .../test_llm_completion_generators.py | 14 + .../tests/engine/mcp/test_manager.py | 62 ++ .../tests/engine/models/test_facade.py | 87 ++ .../data_designer/interface/data_designer.py | 6 + .../mcp_demo_server.py | 30 + tests_e2e/tests/test_mcp_demo.py | 62 ++ uv.lock | 770 +++++++++++------- 19 files changed, 1188 insertions(+), 287 deletions(-) create mode 100644 packages/data-designer-config/src/data_designer/config/mcp.py create mode 100644 packages/data-designer-config/tests/config/test_mcp.py create mode 100644 packages/data-designer-engine/src/data_designer/engine/mcp/__init__.py create mode 100644 packages/data-designer-engine/src/data_designer/engine/mcp/errors.py create mode 100644 packages/data-designer-engine/src/data_designer/engine/mcp/manager.py create mode 100644 packages/data-designer-engine/tests/engine/mcp/test_manager.py create mode 100644 tests_e2e/src/data_designer_e2e_tests/mcp_demo_server.py create mode 100644 tests_e2e/tests/test_mcp_demo.py diff --git a/packages/data-designer-config/src/data_designer/config/__init__.py b/packages/data-designer-config/src/data_designer/config/__init__.py index 96c5c2e2..38978b0c 100644 --- a/packages/data-designer-config/src/data_designer/config/__init__.py +++ b/packages/data-designer-config/src/data_designer/config/__init__.py @@ -20,6 +20,7 @@ from data_designer.config.config_builder import DataDesignerConfigBuilder from data_designer.config.data_designer_config import DataDesignerConfig from data_designer.config.dataset_builders import BuildStage +from data_designer.config.mcp import MCPServerConfig, MCPToolConfig from data_designer.config.models import ( ChatCompletionInferenceParams, EmbeddingInferenceParams, @@ -118,6 +119,8 @@ def get_config_exports() -> list[str]: LocalFileSeedSource.__name__, ManualDistribution.__name__, ManualDistributionParams.__name__, + MCPServerConfig.__name__, + MCPToolConfig.__name__, Modality.__name__, ModalityContext.__name__, ModalityDataType.__name__, diff --git a/packages/data-designer-config/src/data_designer/config/column_configs.py b/packages/data-designer-config/src/data_designer/config/column_configs.py index ca773633..6549a950 100644 --- a/packages/data-designer-config/src/data_designer/config/column_configs.py +++ b/packages/data-designer-config/src/data_designer/config/column_configs.py @@ -11,6 +11,7 @@ from data_designer.config.base import ConfigBase from data_designer.config.errors import InvalidConfigError +from data_designer.config.mcp import MCPToolConfig from data_designer.config.models import ImageContext from data_designer.config.sampler_params import SamplerParamsT, SamplerType from data_designer.config.utils.code_lang import CodeLang @@ -159,6 +160,8 @@ class LLMTextColumnConfig(SingleColumnConfig): `LLMStructuredColumnConfig` for structured output, `LLMCodeColumnConfig` for code. multi_modal_context: Optional list of image contexts for multi-modal generation. Enables vision-capable models to generate text based on image inputs. + tool_config: Optional MCP tool configuration. When provided, the model may call + permitted tools from the configured MCP server during generation. column_type: Discriminator field, always "llm-text" for this configuration type. """ @@ -166,6 +169,7 @@ class LLMTextColumnConfig(SingleColumnConfig): model_alias: str system_prompt: str | None = None multi_modal_context: list[ImageContext] | None = None + tool_config: MCPToolConfig | None = None column_type: Literal["llm-text"] = "llm-text" @staticmethod diff --git a/packages/data-designer-config/src/data_designer/config/mcp.py b/packages/data-designer-config/src/data_designer/config/mcp.py new file mode 100644 index 00000000..ad5f0338 --- /dev/null +++ b/packages/data-designer-config/src/data_designer/config/mcp.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pydantic import Field, model_validator +from typing_extensions import Self + +from data_designer.config.base import ConfigBase +from data_designer.config.errors import InvalidConfigError + + +class MCPServerConfig(ConfigBase): + """Configuration for a single MCP server connection.""" + + name: str + command: str | None = None + args: list[str] = Field(default_factory=list) + url: str | None = None + env: dict[str, str] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_transport(self) -> Self: + if bool(self.command) == bool(self.url): + raise InvalidConfigError("MCP server config must define exactly one of 'command' or 'url'.") + if self.url and self.args: + raise InvalidConfigError("MCP server config 'args' is only valid when using 'command'.") + return self + + +class MCPToolConfig(ConfigBase): + """Configuration for permitting MCP tools on an LLM column.""" + + server_name: str + tool_names: list[str] | None = None + max_tool_calls: int = Field(default=5, ge=1) diff --git a/packages/data-designer-config/tests/config/test_mcp.py b/packages/data-designer-config/tests/config/test_mcp.py new file mode 100644 index 00000000..876c32d8 --- /dev/null +++ b/packages/data-designer-config/tests/config/test_mcp.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from pydantic import ValidationError + +from data_designer.config.errors import InvalidConfigError +from data_designer.config.mcp import MCPServerConfig, MCPToolConfig + + +def test_mcp_server_config_validation() -> None: + with pytest.raises(InvalidConfigError): + MCPServerConfig(name="missing") + + with pytest.raises(InvalidConfigError): + MCPServerConfig(name="both", command="python", url="http://localhost:8080") + + server = MCPServerConfig(name="stdio", command="python", args=["-m", "server"]) + assert server.command == "python" + assert server.url is None + + server = MCPServerConfig(name="sse", url="http://localhost:8080") + assert server.url == "http://localhost:8080" + assert server.command is None + + with pytest.raises(InvalidConfigError): + MCPServerConfig(name="invalid", url="http://localhost:8080", args=["--flag"]) + + +def test_mcp_tool_config_defaults() -> None: + tool_config = MCPToolConfig(server_name="tools") + assert tool_config.tool_names is None + assert tool_config.max_tool_calls == 5 + + with pytest.raises(ValidationError): + MCPToolConfig(server_name="tools", max_tool_calls=0) diff --git a/packages/data-designer-engine/pyproject.toml b/packages/data-designer-engine/pyproject.toml index bf879468..2a0cdfd0 100644 --- a/packages/data-designer-engine/pyproject.toml +++ b/packages/data-designer-engine/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "litellm>=1.73.6,<1.80.12", "lxml>=6.0.2,<7", "marko>=2.1.2,<3", + "mcp", "networkx>=3.0,<4", "ruff>=0.14.10,<1", "scipy>=1.11.0,<2", diff --git a/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py b/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py index 668172a3..80b25261 100644 --- a/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py +++ b/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py @@ -79,6 +79,7 @@ def generate(self, data: dict) -> dict: ), parser=self.response_recipe.parse, multi_modal_context=multi_modal_context, + tool_config=self.config.tool_config, max_correction_steps=self.max_conversation_correction_steps, max_conversation_restarts=self.max_conversation_restarts, purpose=f"running generation for column '{self.config.name}'", diff --git a/packages/data-designer-engine/src/data_designer/engine/mcp/__init__.py b/packages/data-designer-engine/src/data_designer/engine/mcp/__init__.py new file mode 100644 index 00000000..3d2894b7 --- /dev/null +++ b/packages/data-designer-engine/src/data_designer/engine/mcp/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations diff --git a/packages/data-designer-engine/src/data_designer/engine/mcp/errors.py b/packages/data-designer-engine/src/data_designer/engine/mcp/errors.py new file mode 100644 index 00000000..9e35d6c0 --- /dev/null +++ b/packages/data-designer-engine/src/data_designer/engine/mcp/errors.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from data_designer.errors import DataDesignerError + + +class MCPError(DataDesignerError): ... + + +class MCPConfigurationError(MCPError): ... + + +class MCPClientUnavailableError(MCPError): ... + + +class MCPToolError(MCPError): ... diff --git a/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py b/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py new file mode 100644 index 00000000..39ba7396 --- /dev/null +++ b/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py @@ -0,0 +1,195 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import json +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from typing import Any + +from data_designer.config.mcp import MCPServerConfig, MCPToolConfig +from data_designer.engine.mcp.errors import MCPClientUnavailableError, MCPConfigurationError, MCPToolError + + +@dataclass(frozen=True) +class MCPToolDefinition: + name: str + description: str | None + input_schema: dict[str, Any] | None + + +@dataclass(frozen=True) +class MCPToolResult: + content: str + is_error: bool = False + + +class MCPClientManager: + def __init__(self, *, server_configs: list[MCPServerConfig]): + self._server_configs = self._build_server_map(server_configs) + self._tool_cache: dict[str, list[MCPToolDefinition]] = {} + self._async_executor = ThreadPoolExecutor(max_workers=1) + + def get_tool_schemas(self, tool_config: MCPToolConfig) -> list[dict[str, Any]]: + server = self._get_server(tool_config.server_name) + tools = self._list_tools(server.name) + allowed_names = set(tool_config.tool_names) if tool_config.tool_names else None + if allowed_names is not None: + available = {tool.name for tool in tools} + missing = allowed_names.difference(available) + if missing: + raise MCPConfigurationError(f"Tool(s) {sorted(missing)!r} not found on MCP server {server.name!r}.") + tools = [tool for tool in tools if tool.name in allowed_names] + return [self._to_openai_tool_schema(tool) for tool in tools] + + def call_tool(self, server_name: str, tool_name: str, arguments: dict[str, Any]) -> MCPToolResult: + server = self._get_server(server_name) + result = self._run_async(self._call_tool_async(server, tool_name, arguments)) + return result + + def _build_server_map(self, server_configs: list[MCPServerConfig]) -> dict[str, MCPServerConfig]: + server_map: dict[str, MCPServerConfig] = {} + for config in server_configs: + if config.name in server_map: + raise MCPConfigurationError(f"Duplicate MCP server name {config.name!r} detected.") + server_map[config.name] = config + return server_map + + def _get_server(self, name: str) -> MCPServerConfig: + try: + return self._server_configs[name] + except KeyError as exc: + raise MCPConfigurationError(f"No MCP server named {name!r} is configured.") from exc + + def _list_tools(self, server_name: str) -> list[MCPToolDefinition]: + if server_name in self._tool_cache: + return self._tool_cache[server_name] + server = self._get_server(server_name) + tools = self._run_async(self._list_tools_async(server)) + self._tool_cache[server_name] = tools + return tools + + def _run_async(self, coro: Any) -> Any: + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + future = self._async_executor.submit(asyncio.run, coro) + return future.result() + + async def _list_tools_async(self, server: MCPServerConfig) -> list[MCPToolDefinition]: + ClientSession, StdioServerParameters, stdio_client, sse_client = _resolve_mcp_imports() + if server.command: + params = StdioServerParameters(command=server.command, args=server.args, env=server.env) + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.list_tools() + else: + async with sse_client(server.url) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.list_tools() + + raw_tools = getattr(result, "tools", result) + if not isinstance(raw_tools, list): + raise MCPToolError("Unexpected response from MCP server when listing tools.") + return [self._coerce_tool_definition(tool) for tool in raw_tools] + + async def _call_tool_async( + self, server: MCPServerConfig, tool_name: str, arguments: dict[str, Any] + ) -> MCPToolResult: + ClientSession, StdioServerParameters, stdio_client, sse_client = _resolve_mcp_imports() + if server.command: + params = StdioServerParameters(command=server.command, args=server.args, env=server.env) + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool(tool_name, arguments) + else: + async with sse_client(server.url) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool(tool_name, arguments) + + content = _serialize_tool_result_content(result) + is_error = getattr(result, "isError", None) + if is_error is None: + is_error = getattr(result, "is_error", False) + return MCPToolResult(content=content, is_error=bool(is_error)) + + def _coerce_tool_definition(self, tool: Any) -> MCPToolDefinition: + if isinstance(tool, dict): + name = tool.get("name") + description = tool.get("description") + input_schema = tool.get("inputSchema") or tool.get("input_schema") + else: + name = getattr(tool, "name", None) + description = getattr(tool, "description", None) + input_schema = getattr(tool, "inputSchema", None) or getattr(tool, "input_schema", None) + + if not name: + raise MCPToolError("Encountered MCP tool without a name.") + return MCPToolDefinition(name=name, description=description, input_schema=input_schema) + + def _to_openai_tool_schema(self, tool: MCPToolDefinition) -> dict[str, Any]: + schema = tool.input_schema or {"type": "object", "properties": {}} + return { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description or "", + "parameters": schema, + }, + } + + +def _serialize_tool_result_content(result: Any) -> str: + content = getattr(result, "content", result) + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, dict): + return json.dumps(content) + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + continue + if isinstance(item, dict): + if item.get("type") == "text": + parts.append(str(item.get("text", ""))) + else: + parts.append(json.dumps(item)) + continue + text_value = getattr(item, "text", None) + if text_value is not None: + parts.append(str(text_value)) + else: + parts.append(str(item)) + return "\n".join(parts) + return str(content) + + +def _resolve_mcp_imports() -> tuple[Any, Any, Any, Any]: + try: + from mcp import ClientSession, StdioServerParameters + from mcp.client.sse import sse_client + from mcp.client.stdio import stdio_client + + return ClientSession, StdioServerParameters, stdio_client, sse_client + except ImportError: + try: + from mcp.client.session import ClientSession + from mcp.client.sse import sse_client + from mcp.client.stdio import StdioServerParameters, stdio_client + + return ClientSession, StdioServerParameters, stdio_client, sse_client + except ImportError as exc: + raise MCPClientUnavailableError( + "MCP client dependencies are not installed. Install the 'mcp' package to enable tool calling." + ) from exc diff --git a/packages/data-designer-engine/src/data_designer/engine/models/facade.py b/packages/data-designer-engine/src/data_designer/engine/models/facade.py index 103ab83c..0993136d 100644 --- a/packages/data-designer-engine/src/data_designer/engine/models/facade.py +++ b/packages/data-designer-engine/src/data_designer/engine/models/facade.py @@ -3,12 +3,16 @@ from __future__ import annotations +import json import logging +import uuid from collections.abc import Callable from copy import deepcopy from typing import TYPE_CHECKING, Any +from data_designer.config.mcp import MCPToolConfig from data_designer.config.models import GenerationType, ModelConfig, ModelProvider +from data_designer.engine.mcp.errors import MCPConfigurationError, MCPToolError from data_designer.engine.model_provider import ModelProviderRegistry from data_designer.engine.models.errors import ( GenerationValidationFailureError, @@ -24,6 +28,7 @@ if TYPE_CHECKING: import litellm + from data_designer.engine.mcp.manager import MCPClientManager logger = logging.getLogger(__name__) @@ -34,10 +39,13 @@ def __init__( model_config: ModelConfig, secret_resolver: SecretResolver, model_provider_registry: ModelProviderRegistry, + *, + mcp_client_manager: MCPClientManager | None = None, ): self._model_config = model_config self._secret_resolver = secret_resolver self._model_provider_registry = model_provider_registry + self._mcp_client_manager = mcp_client_manager self._litellm_deployment = self._get_litellm_deployment(model_config) self._router = CustomRouter([self._litellm_deployment], **LiteLLMRouterDefaultKwargs().model_dump()) self._usage_stats = ModelUsageStats() @@ -144,6 +152,7 @@ def generate( parser: Callable[[str], Any], system_prompt: str | None = None, multi_modal_context: list[dict[str, Any]] | None = None, + tool_config: MCPToolConfig | None = None, max_correction_steps: int = 0, max_conversation_restarts: int = 0, skip_usage_tracking: bool = False, @@ -170,6 +179,8 @@ def generate( prompt. parser (func(str) -> Any): A function applied to the LLM response which processes an LLM response into some output object. + tool_config (MCPToolConfig | None): Optional MCP tool configuration. When provided, + the model may call permitted tools from the configured MCP server. max_correction_steps (int): Maximum number of correction rounds permitted within a single conversation. Note, many rounds can lead to increasing context size without necessarily improving performance -- small language @@ -188,6 +199,8 @@ def generate( generation validation. """ output_obj = None + tool_schemas = None + tool_calls_used = 0 curr_num_correction_steps = 0 curr_num_restarts = 0 curr_generation_attempt = 0 @@ -198,13 +211,23 @@ def generate( ) messages = deepcopy(starting_messages) + if tool_config is not None: + tool_schemas = self._get_tool_schemas(tool_config) + while True: curr_generation_attempt += 1 logger.debug( f"Starting generation attempt {curr_generation_attempt} of {max_generation_attempts} attempts." ) - completion_response = self.completion(messages, skip_usage_tracking=skip_usage_tracking, **kwargs) + completion_kwargs = dict(kwargs) + if tool_schemas is not None: + completion_kwargs["tools"] = tool_schemas + completion_response = self.completion( + messages, + skip_usage_tracking=skip_usage_tracking, + **completion_kwargs, + ) response = completion_response.choices[0].message.content or "" reasoning_trace = getattr(completion_response.choices[0].message, "reasoning_content", None) @@ -213,6 +236,18 @@ def generate( response = response.strip() reasoning_trace = reasoning_trace.strip() + tool_calls = self._extract_tool_calls(completion_response.choices[0].message) + if tool_config is not None and len(tool_calls) > 0: + tool_calls_used += len(tool_calls) + if tool_calls_used > tool_config.max_tool_calls: + raise MCPToolError( + f"Exceeded maximum MCP tool calls ({tool_config.max_tool_calls}) for server " + f"{tool_config.server_name!r}." + ) + messages.append(self._build_assistant_tool_message(response, tool_calls)) + messages.extend(self._execute_tool_calls(tool_config, tool_calls)) + continue + curr_num_correction_steps += 1 try: @@ -239,6 +274,99 @@ def generate( ) from exc return output_obj, reasoning_trace + def _get_tool_schemas(self, tool_config: MCPToolConfig) -> list[dict[str, Any]]: + if self._mcp_client_manager is None: + raise MCPConfigurationError("MCP tool configuration was provided but no MCP servers were configured.") + return self._mcp_client_manager.get_tool_schemas(tool_config) + + def _extract_tool_calls(self, message: Any) -> list[dict[str, Any]]: + raw_tool_calls = getattr(message, "tool_calls", None) + if raw_tool_calls is None and isinstance(message, dict): + raw_tool_calls = message.get("tool_calls") + if not raw_tool_calls: + return [] + tool_calls: list[dict[str, Any]] = [] + for raw_tool_call in raw_tool_calls: + tool_calls.append(self._normalize_tool_call(raw_tool_call)) + return tool_calls + + def _normalize_tool_call(self, raw_tool_call: Any) -> dict[str, Any]: + if isinstance(raw_tool_call, dict): + tool_call_id = raw_tool_call.get("id") + function = raw_tool_call.get("function") or {} + name = function.get("name") or raw_tool_call.get("name") + arguments = function.get("arguments") or raw_tool_call.get("arguments") + else: + tool_call_id = getattr(raw_tool_call, "id", None) + function = getattr(raw_tool_call, "function", None) + name = getattr(function, "name", None) if function is not None else getattr(raw_tool_call, "name", None) + arguments = ( + getattr(function, "arguments", None) + if function is not None + else getattr(raw_tool_call, "arguments", None) + ) + + if not name: + raise MCPToolError("MCP tool call is missing a tool name.") + + arguments_payload: dict[str, Any] + arguments_json: str + if arguments is None or arguments == "": + arguments_payload = {} + arguments_json = "{}" + elif isinstance(arguments, str): + try: + arguments_payload = json.loads(arguments) + except json.JSONDecodeError as exc: + raise MCPToolError(f"Invalid tool arguments for '{name}': {arguments}") from exc + arguments_json = arguments + elif isinstance(arguments, dict): + arguments_payload = arguments + arguments_json = json.dumps(arguments_payload) + else: + raise MCPToolError(f"Unsupported tool arguments type for '{name}': {type(arguments)!r}") + + return { + "id": tool_call_id or uuid.uuid4().hex, + "name": name, + "arguments": arguments_payload, + "arguments_json": arguments_json, + } + + def _build_assistant_tool_message(self, response: str, tool_calls: list[dict[str, Any]]) -> dict[str, Any]: + return { + "role": "assistant", + "content": response or "", + "tool_calls": [ + { + "id": tool_call["id"], + "type": "function", + "function": {"name": tool_call["name"], "arguments": tool_call["arguments_json"]}, + } + for tool_call in tool_calls + ], + } + + def _execute_tool_calls(self, tool_config: MCPToolConfig, tool_calls: list[dict[str, Any]]) -> list[dict[str, Any]]: + if self._mcp_client_manager is None: + raise MCPConfigurationError("MCP tool configuration was provided but no MCP servers were configured.") + + allowed_tools = set(tool_config.tool_names) if tool_config.tool_names else None + tool_messages: list[dict[str, Any]] = [] + for tool_call in tool_calls: + tool_name = tool_call["name"] + if allowed_tools is not None and tool_name not in allowed_tools: + raise MCPToolError(f"Tool {tool_name!r} is not permitted for server {tool_config.server_name!r}.") + result = self._mcp_client_manager.call_tool(tool_config.server_name, tool_name, tool_call["arguments"]) + tool_messages.append( + { + "role": "tool", + "tool_call_id": tool_call["id"], + "content": result.content, + } + ) + return tool_messages + def _get_litellm_deployment(self, model_config: ModelConfig) -> litellm.DeploymentTypedDict: provider = self._model_provider_registry.get_provider(model_config.provider) api_key = None diff --git a/packages/data-designer-engine/src/data_designer/engine/models/factory.py b/packages/data-designer-engine/src/data_designer/engine/models/factory.py index 5f9b30ae..f18a4e6f 100644 --- a/packages/data-designer-engine/src/data_designer/engine/models/factory.py +++ b/packages/data-designer-engine/src/data_designer/engine/models/factory.py @@ -10,6 +10,7 @@ from data_designer.engine.secret_resolver import SecretResolver if TYPE_CHECKING: + from data_designer.engine.mcp.manager import MCPClientManager from data_designer.engine.models.registry import ModelRegistry @@ -18,6 +19,7 @@ def create_model_registry( model_configs: list[ModelConfig] | None = None, secret_resolver: SecretResolver, model_provider_registry: ModelProviderRegistry, + mcp_client_manager: MCPClientManager | None = None, ) -> ModelRegistry: """Factory function for creating a ModelRegistry instance. @@ -32,7 +34,12 @@ def create_model_registry( apply_litellm_patches() def model_facade_factory(model_config, secret_resolver, model_provider_registry): - return ModelFacade(model_config, secret_resolver, model_provider_registry) + return ModelFacade( + model_config, + secret_resolver, + model_provider_registry, + mcp_client_manager=mcp_client_manager, + ) return ModelRegistry( model_configs=model_configs, diff --git a/packages/data-designer-engine/src/data_designer/engine/resources/resource_provider.py b/packages/data-designer-engine/src/data_designer/engine/resources/resource_provider.py index b7184597..d04dbe40 100644 --- a/packages/data-designer-engine/src/data_designer/engine/resources/resource_provider.py +++ b/packages/data-designer-engine/src/data_designer/engine/resources/resource_provider.py @@ -4,12 +4,14 @@ from __future__ import annotations from data_designer.config.base import ConfigBase +from data_designer.config.mcp import MCPServerConfig from data_designer.config.dataset_metadata import DatasetMetadata from data_designer.config.models import ModelConfig from data_designer.config.run_config import RunConfig from data_designer.config.seed_source import SeedSource from data_designer.config.utils.type_helpers import StrEnum from data_designer.engine.dataset_builders.artifact_storage import ArtifactStorage +from data_designer.engine.mcp.manager import MCPClientManager from data_designer.engine.model_provider import ModelProviderRegistry from data_designer.engine.models.factory import create_model_registry from data_designer.engine.models.registry import ModelRegistry @@ -28,6 +30,7 @@ class ResourceProvider(ConfigBase): artifact_storage: ArtifactStorage blob_storage: ManagedBlobStorage | None = None model_registry: ModelRegistry | None = None + mcp_manager: MCPClientManager | None = None run_config: RunConfig = RunConfig() seed_reader: SeedReader | None = None @@ -53,6 +56,7 @@ def create_resource_provider( blob_storage: ManagedBlobStorage | None = None, seed_dataset_source: SeedSource | None = None, run_config: RunConfig | None = None, + mcp_servers: list[MCPServerConfig] | None = None, ) -> ResourceProvider: """Factory function for creating a ResourceProvider instance. This function triggers lazy loading of heavy dependencies like litellm. @@ -64,14 +68,17 @@ def create_resource_provider( secret_resolver, ) + mcp_manager = MCPClientManager(server_configs=mcp_servers or []) if mcp_servers else None return ResourceProvider( artifact_storage=artifact_storage, model_registry=create_model_registry( model_configs=model_configs, secret_resolver=secret_resolver, model_provider_registry=model_provider_registry, + mcp_client_manager=mcp_manager, ), blob_storage=blob_storage or init_managed_blob_storage(), + mcp_manager=mcp_manager, seed_reader=seed_reader, run_config=run_config or RunConfig(), ) diff --git a/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py b/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py index 7c060ce3..14575b37 100644 --- a/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py +++ b/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py @@ -11,6 +11,7 @@ LLMStructuredColumnConfig, LLMTextColumnConfig, ) +from data_designer.config.mcp import MCPToolConfig from data_designer.config.run_config import RunConfig from data_designer.engine.column_generators.generators.base import GenerationStrategy from data_designer.engine.column_generators.generators.llm_completion import ( @@ -271,6 +272,19 @@ def test_generate_with_json_deserialization(): assert result["test_column"] == {"result": "json_output"} +def test_generate_passes_tool_config() -> None: + tool_config = MCPToolConfig(server_name="tools", tool_names=["lookup"]) + generator, _, mock_model, _, _, mock_prompt_renderer, mock_response_recipe = _create_generator_with_mocks( + tool_config=tool_config + ) + _setup_generate_mocks(mock_prompt_renderer, mock_response_recipe, mock_model) + + data = {"input": "test_input"} + _ = generator.generate(data) + + assert mock_model.generate.call_args[1]["tool_config"] == tool_config + + @pytest.mark.parametrize( "generator_class,config_class,config_kwargs,serialized_output,expected_output", [ diff --git a/packages/data-designer-engine/tests/engine/mcp/test_manager.py b/packages/data-designer-engine/tests/engine/mcp/test_manager.py new file mode 100644 index 00000000..81092d72 --- /dev/null +++ b/packages/data-designer-engine/tests/engine/mcp/test_manager.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import pytest + +from data_designer.config.mcp import MCPServerConfig, MCPToolConfig +from data_designer.engine.mcp.errors import MCPConfigurationError +from data_designer.engine.mcp.manager import MCPClientManager, MCPToolDefinition + + +def test_get_tool_schemas_filters_tools(monkeypatch: pytest.MonkeyPatch) -> None: + server = MCPServerConfig(name="tools", command="python") + manager = MCPClientManager(server_configs=[server]) + + tools = [ + MCPToolDefinition(name="lookup", description="Lookup", input_schema={"type": "object"}), + MCPToolDefinition(name="other", description="Other", input_schema={"type": "object"}), + ] + + def _list_tools(_: str) -> list[MCPToolDefinition]: + return tools + + monkeypatch.setattr(manager, "_list_tools", _list_tools) + + tool_config = MCPToolConfig(server_name="tools", tool_names=["lookup"]) + schemas = manager.get_tool_schemas(tool_config) + + assert len(schemas) == 1 + assert schemas[0]["function"]["name"] == "lookup" + + +def test_get_tool_schemas_missing_tool(monkeypatch: pytest.MonkeyPatch) -> None: + server = MCPServerConfig(name="tools", command="python") + manager = MCPClientManager(server_configs=[server]) + + def _list_tools(_: str) -> list[MCPToolDefinition]: + return [MCPToolDefinition(name="lookup", description="Lookup", input_schema={"type": "object"})] + + monkeypatch.setattr(manager, "_list_tools", _list_tools) + + tool_config = MCPToolConfig(server_name="tools", tool_names=["missing"]) + with pytest.raises(MCPConfigurationError): + manager.get_tool_schemas(tool_config) + + +def test_duplicate_server_names_rejected() -> None: + with pytest.raises(MCPConfigurationError): + MCPClientManager( + server_configs=[ + MCPServerConfig(name="tools", command="python"), + MCPServerConfig(name="tools", command="python"), + ] + ) + + +def test_call_tool_missing_server() -> None: + manager = MCPClientManager(server_configs=[MCPServerConfig(name="tools", command="python")]) + + with pytest.raises(MCPConfigurationError): + manager.call_tool("missing", "lookup", {"query": "x"}) diff --git a/packages/data-designer-engine/tests/engine/models/test_facade.py b/packages/data-designer-engine/tests/engine/models/test_facade.py index a5ee709a..321768c7 100644 --- a/packages/data-designer-engine/tests/engine/models/test_facade.py +++ b/packages/data-designer-engine/tests/engine/models/test_facade.py @@ -2,11 +2,15 @@ # SPDX-License-Identifier: Apache-2.0 from collections import namedtuple +from types import SimpleNamespace +from typing import Any from unittest.mock import patch import pytest from litellm.types.utils import Choices, EmbeddingResponse, Message, ModelResponse +from data_designer.config.mcp import MCPToolConfig +from data_designer.engine.mcp.errors import MCPConfigurationError from data_designer.engine.models.errors import ModelGenerationValidationFailureError from data_designer.engine.models.facade import ModelFacade from data_designer.engine.models.parsers.errors import ParserException @@ -16,6 +20,23 @@ MockCompletion = namedtuple("MockCompletion", ["choices"]) +class FakeMessage: + def __init__(self, content: str | None, tool_calls: list[dict] | None = None) -> None: + self.content = content + self.tool_calls = tool_calls + self.reasoning_content = None + + +class FakeChoice: + def __init__(self, message: FakeMessage) -> None: + self.message = message + + +class FakeResponse: + def __init__(self, message: FakeMessage) -> None: + self.choices = [FakeChoice(message)] + + def mock_oai_response_object(response_text: str) -> MockCompletion: return MockCompletion(choices=[MockChoice(message=MockMessage(content=response_text))]) @@ -231,3 +252,69 @@ def mock_embedding(self, model, input, **kwargs): kwargs = {"temperature": 0.7, "max_tokens": 100, "input_type": "query"} _ = stub_model_facade.generate_text_embeddings(["test1", "test2"], **kwargs) assert captured_kwargs == {**stub_model_configs[0].inference_parameters.generate_kwargs, **kwargs} + + +def test_generate_with_mcp_tools(stub_model_configs, stub_secrets_resolver, stub_model_provider_registry) -> None: + tool_config = MCPToolConfig(server_name="tools", tool_names=["lookup"], max_tool_calls=3) + tool_call = { + "id": "call-1", + "type": "function", + "function": {"name": "lookup", "arguments": '{"query": "foo"}'}, + } + responses = [ + FakeResponse(FakeMessage(content=None, tool_calls=[tool_call])), + FakeResponse(FakeMessage(content="final result")), + ] + captured_calls: list[tuple[list[dict[str, Any]], dict[str, Any]]] = [] + + class FakeMCPManager: + def __init__(self) -> None: + self.calls: list[tuple[str, str, dict]] = [] + + def get_tool_schemas(self, config: MCPToolConfig) -> list[dict]: + return [ + { + "type": "function", + "function": {"name": "lookup", "description": "Lookup", "parameters": {"type": "object"}}, + } + ] + + def call_tool(self, server_name: str, tool_name: str, arguments: dict) -> SimpleNamespace: + self.calls.append((server_name, tool_name, arguments)) + return SimpleNamespace(content="tool-output") + + def _completion(self, messages: list[dict[str, Any]], **kwargs: Any) -> FakeResponse: + captured_calls.append((messages, kwargs)) + return responses.pop(0) + + model = ModelFacade( + model_config=stub_model_configs[0], + secret_resolver=stub_secrets_resolver, + model_provider_registry=stub_model_provider_registry, + mcp_client_manager=FakeMCPManager(), + ) + + with patch.object(ModelFacade, "completion", new=_completion): + result, _ = model.generate(prompt="question", parser=lambda x: x, tool_config=tool_config) + + assert result == "final result" + assert len(captured_calls) == 2 + assert "tools" in captured_calls[0][1] + assert captured_calls[0][1]["tools"][0]["function"]["name"] == "lookup" + assert any(message.get("role") == "tool" for message in captured_calls[1][0]) + assert model._mcp_client_manager.calls == [("tools", "lookup", {"query": "foo"})] + + +def test_generate_with_tools_missing_manager( + stub_model_configs, stub_secrets_resolver, stub_model_provider_registry +) -> None: + tool_config = MCPToolConfig(server_name="tools", tool_names=["lookup"]) + model = ModelFacade( + model_config=stub_model_configs[0], + secret_resolver=stub_secrets_resolver, + model_provider_registry=stub_model_provider_registry, + mcp_client_manager=None, + ) + + with pytest.raises(MCPConfigurationError): + model.generate(prompt="question", parser=lambda x: x, tool_config=tool_config) diff --git a/packages/data-designer/src/data_designer/interface/data_designer.py b/packages/data-designer/src/data_designer/interface/data_designer.py index d54b5ea1..284757e7 100644 --- a/packages/data-designer/src/data_designer/interface/data_designer.py +++ b/packages/data-designer/src/data_designer/interface/data_designer.py @@ -17,6 +17,7 @@ get_providers_with_missing_api_keys, ) from data_designer.config.interface import DataDesignerInterface +from data_designer.config.mcp import MCPServerConfig from data_designer.config.models import ( ModelConfig, ModelProvider, @@ -98,6 +99,8 @@ class DataDesigner(DataDesignerInterface[DatasetCreationResults]): If not provided, will check for an environment variable called DATA_DESIGNER_MANAGED_ASSETS_PATH. If the environment variable is not set, will use the default managed assets directory, which is defined in `data_designer.config.utils.constants`. + mcp_servers: Optional list of MCP server configurations to enable tool-calling for + LLM generation columns. """ def __init__( @@ -108,12 +111,14 @@ def __init__( secret_resolver: SecretResolver | None = None, seed_readers: list[SeedReader] | None = None, managed_assets_path: Path | str | None = None, + mcp_servers: list[MCPServerConfig] | None = None, ): self._secret_resolver = secret_resolver or DEFAULT_SECRET_RESOLVER self._artifact_path = Path(artifact_path) if artifact_path is not None else Path.cwd() / "artifacts" self._run_config = RunConfig() self._managed_assets_path = Path(managed_assets_path or MANAGED_ASSETS_PATH) self._model_providers = self._resolve_model_providers(model_providers) + self._mcp_servers = mcp_servers or [] self._model_provider_registry = resolve_model_provider_registry( self._model_providers, get_default_provider_name() ) @@ -382,6 +387,7 @@ def _create_resource_provider( seed_dataset_source=seed_dataset_source, seed_reader_registry=self._seed_reader_registry, run_config=self._run_config, + mcp_servers=self._mcp_servers, ) def _get_interface_info(self, model_providers: list[ModelProvider]) -> InterfaceInfo: diff --git a/tests_e2e/src/data_designer_e2e_tests/mcp_demo_server.py b/tests_e2e/src/data_designer_e2e_tests/mcp_demo_server.py new file mode 100644 index 00000000..9c679ac8 --- /dev/null +++ b/tests_e2e/src/data_designer_e2e_tests/mcp_demo_server.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +mcp_server = FastMCP("data-designer-e2e-mcp") + + +@mcp_server.tool() +def get_fact(topic: str) -> str: + facts = { + "mcp": "MCP lets models call tools over standardized transports.", + "data-designer": "Data Designer generates structured synthetic datasets.", + } + return facts.get(topic.lower(), f"{topic} is interesting.") + + +@mcp_server.tool() +def add_numbers(a: int, b: int) -> int: + return a + b + + +def main() -> None: + mcp_server.run() + + +if __name__ == "__main__": + main() diff --git a/tests_e2e/tests/test_mcp_demo.py b/tests_e2e/tests/test_mcp_demo.py new file mode 100644 index 00000000..7c020c45 --- /dev/null +++ b/tests_e2e/tests/test_mcp_demo.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +from data_designer.essentials import ( + CategorySamplerParams, + DataDesigner, + DataDesignerConfigBuilder, + LLMTextColumnConfig, + MCPServerConfig, + MCPToolConfig, + SamplerColumnConfig, + SamplerType, +) + + +def test_mcp_server_tool_usage_with_nvidia_text() -> None: + if os.environ.get("NVIDIA_API_KEY") is None: + pytest.skip("NVIDIA_API_KEY must be set to run the MCP demo with nvidia-text.") + + e2e_root = Path(__file__).resolve().parents[1] + e2e_src = e2e_root / "src" + existing_pythonpath = os.environ.get("PYTHONPATH") + pythonpath = str(e2e_src) if not existing_pythonpath else f"{e2e_src}{os.pathsep}{existing_pythonpath}" + + mcp_server = MCPServerConfig( + name="demo-mcp", + command=sys.executable, + args=["-m", "data_designer_e2e_tests.mcp_demo_server"], + env={"PYTHONPATH": pythonpath}, + ) + + data_designer = DataDesigner(mcp_servers=[mcp_server]) + + config_builder = DataDesignerConfigBuilder() + config_builder.add_column( + SamplerColumnConfig( + name="topic", + sampler_type=SamplerType.CATEGORY, + params=CategorySamplerParams(values=["MCP", "Data-Designer"]), + ) + ) + config_builder.add_column( + LLMTextColumnConfig( + name="summary", + prompt="Use the get_fact tool to fetch a fact about {{ topic }}. Respond with one sentence.", + system_prompt="You must call the get_fact tool exactly once before answering.", + model_alias="nvidia-text", + tool_config=MCPToolConfig(server_name="demo-mcp", tool_names=["get_fact"]), + ) + ) + + preview = data_designer.preview(config_builder, num_records=2) + + assert "summary" in preview.dataset.columns diff --git a/uv.lock b/uv.lock index 279f21de..f3ee62a2 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", @@ -582,101 +582,101 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" }, - { url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" }, - { url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" }, - { url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" }, - { url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" }, - { url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" }, - { url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" }, - { url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" }, - { url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" }, - { url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" }, - { url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" }, - { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, - { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, - { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, - { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, - { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, - { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, - { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, - { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, - { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, - { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, - { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, - { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, - { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, - { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, - { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, - { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, - { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, - { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, - { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, - { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, - { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, - { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, - { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, - { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, - { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, - { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, - { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, - { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, - { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, - { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, - { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, - { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, - { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, - { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, - { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, - { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, - { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, - { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, - { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, - { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, - { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, - { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, - { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, - { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, - { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, - { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, - { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, - { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, - { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, - { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, - { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, - { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +version = "7.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/49/349848445b0e53660e258acbcc9b0d014895b6739237920886672240f84b/coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3", size = 826523, upload-time = "2026-01-25T13:00:04.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/2d/63e37369c8e81a643afe54f76073b020f7b97ddbe698c5c944b51b0a2bc5/coverage-7.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4af3b01763909f477ea17c962e2cca8f39b350a4e46e3a30838b2c12e31b81b", size = 218842, upload-time = "2026-01-25T12:57:15.3Z" }, + { url = "https://files.pythonhosted.org/packages/57/06/86ce882a8d58cbcb3030e298788988e618da35420d16a8c66dac34f138d0/coverage-7.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36393bd2841fa0b59498f75466ee9bdec4f770d3254f031f23e8fd8e140ffdd2", size = 219360, upload-time = "2026-01-25T12:57:17.572Z" }, + { url = "https://files.pythonhosted.org/packages/cd/84/70b0eb1ee19ca4ef559c559054c59e5b2ae4ec9af61398670189e5d276e9/coverage-7.13.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cc7573518b7e2186bd229b1a0fe24a807273798832c27032c4510f47ffdb896", size = 246123, upload-time = "2026-01-25T12:57:19.087Z" }, + { url = "https://files.pythonhosted.org/packages/35/fb/05b9830c2e8275ebc031e0019387cda99113e62bb500ab328bb72578183b/coverage-7.13.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca9566769b69a5e216a4e176d54b9df88f29d750c5b78dbb899e379b4e14b30c", size = 247930, upload-time = "2026-01-25T12:57:20.929Z" }, + { url = "https://files.pythonhosted.org/packages/81/aa/3f37858ca2eed4f09b10ca3c6ddc9041be0a475626cd7fd2712f4a2d526f/coverage-7.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c9bdea644e94fd66d75a6f7e9a97bb822371e1fe7eadae2cacd50fcbc28e4dc", size = 249804, upload-time = "2026-01-25T12:57:22.904Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b3/c904f40c56e60a2d9678a5ee8df3d906d297d15fb8bec5756c3b0a67e2df/coverage-7.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5bd447332ec4f45838c1ad42268ce21ca87c40deb86eabd59888859b66be22a5", size = 246815, upload-time = "2026-01-25T12:57:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/41/91/ddc1c5394ca7fd086342486440bfdd6b9e9bda512bf774599c7c7a0081e0/coverage-7.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7c79ad5c28a16a1277e1187cf83ea8dafdcc689a784228a7d390f19776db7c31", size = 247843, upload-time = "2026-01-25T12:57:26.544Z" }, + { url = "https://files.pythonhosted.org/packages/87/d2/cdff8f4cd33697883c224ea8e003e9c77c0f1a837dc41d95a94dd26aad67/coverage-7.13.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:76e06ccacd1fb6ada5d076ed98a8c6f66e2e6acd3df02819e2ee29fd637b76ad", size = 245850, upload-time = "2026-01-25T12:57:28.507Z" }, + { url = "https://files.pythonhosted.org/packages/f5/42/e837febb7866bf2553ab53dd62ed52f9bb36d60c7e017c55376ad21fbb05/coverage-7.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:49d49e9a5e9f4dc3d3dac95278a020afa6d6bdd41f63608a76fa05a719d5b66f", size = 246116, upload-time = "2026-01-25T12:57:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/09/b1/4a3f935d7df154df02ff4f71af8d61298d713a7ba305d050ae475bfbdde2/coverage-7.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed2bce0e7bfa53f7b0b01c722da289ef6ad4c18ebd52b1f93704c21f116360c8", size = 246720, upload-time = "2026-01-25T12:57:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/538a6fd44c515f1c5197a3f078094cbaf2ce9f945df5b44e29d95c864bff/coverage-7.13.2-cp310-cp310-win32.whl", hash = "sha256:1574983178b35b9af4db4a9f7328a18a14a0a0ce76ffaa1c1bacb4cc82089a7c", size = 221465, upload-time = "2026-01-25T12:57:33.511Z" }, + { url = "https://files.pythonhosted.org/packages/5e/09/4b63a024295f326ec1a40ec8def27799300ce8775b1cbf0d33b1790605c4/coverage-7.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:a360a8baeb038928ceb996f5623a4cd508728f8f13e08d4e96ce161702f3dd99", size = 222397, upload-time = "2026-01-25T12:57:34.927Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/abca50583a8975bb6e1c59eff67ed8e48bb127c07dad5c28d9e96ccc09ec/coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e", size = 218971, upload-time = "2026-01-25T12:57:36.953Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0e/b6489f344d99cd1e5b4d5e1be52dfd3f8a3dc5112aa6c33948da8cabad4e/coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e", size = 219473, upload-time = "2026-01-25T12:57:38.934Z" }, + { url = "https://files.pythonhosted.org/packages/17/11/db2f414915a8e4ec53f60b17956c27f21fb68fcf20f8a455ce7c2ccec638/coverage-7.13.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508", size = 249896, upload-time = "2026-01-25T12:57:40.365Z" }, + { url = "https://files.pythonhosted.org/packages/80/06/0823fe93913663c017e508e8810c998c8ebd3ec2a5a85d2c3754297bdede/coverage-7.13.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b", size = 251810, upload-time = "2026-01-25T12:57:42.045Z" }, + { url = "https://files.pythonhosted.org/packages/61/dc/b151c3cc41b28cdf7f0166c5fa1271cbc305a8ec0124cce4b04f74791a18/coverage-7.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b", size = 253920, upload-time = "2026-01-25T12:57:44.026Z" }, + { url = "https://files.pythonhosted.org/packages/2d/35/e83de0556e54a4729a2b94ea816f74ce08732e81945024adee46851c2264/coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f", size = 250025, upload-time = "2026-01-25T12:57:45.624Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/af2eb9c3926ce3ea0d58a0d2516fcbdacf7a9fc9559fe63076beaf3f2596/coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3", size = 251612, upload-time = "2026-01-25T12:57:47.713Z" }, + { url = "https://files.pythonhosted.org/packages/26/62/5be2e25f3d6c711d23b71296f8b44c978d4c8b4e5b26871abfc164297502/coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b", size = 249670, upload-time = "2026-01-25T12:57:49.378Z" }, + { url = "https://files.pythonhosted.org/packages/b3/51/400d1b09a8344199f9b6a6fc1868005d766b7ea95e7882e494fa862ca69c/coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1", size = 249395, upload-time = "2026-01-25T12:57:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/f02234bc6e5230e2f0a63fd125d0a2093c73ef20fdf681c7af62a140e4e7/coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059", size = 250298, upload-time = "2026-01-25T12:57:52.287Z" }, + { url = "https://files.pythonhosted.org/packages/b0/06/713110d3dd3151b93611c9cbfc65c15b4156b44f927fced49ac0b20b32a4/coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031", size = 221485, upload-time = "2026-01-25T12:57:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/16/0c/3ae6255fa1ebcb7dec19c9a59e85ef5f34566d1265c70af5b2fc981da834/coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e", size = 222421, upload-time = "2026-01-25T12:57:55.433Z" }, + { url = "https://files.pythonhosted.org/packages/b5/37/fabc3179af4d61d89ea47bd04333fec735cd5e8b59baad44fed9fc4170d7/coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28", size = 221088, upload-time = "2026-01-25T12:57:57.41Z" }, + { url = "https://files.pythonhosted.org/packages/46/39/e92a35f7800222d3f7b2cbb7bbc3b65672ae8d501cb31801b2d2bd7acdf1/coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d", size = 219142, upload-time = "2026-01-25T12:58:00.448Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/8bf9e9309c4c996e65c52a7c5a112707ecdd9fbaf49e10b5a705a402bbb4/coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3", size = 219503, upload-time = "2026-01-25T12:58:02.451Z" }, + { url = "https://files.pythonhosted.org/packages/87/93/17661e06b7b37580923f3f12406ac91d78aeed293fb6da0b69cc7957582f/coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99", size = 251006, upload-time = "2026-01-25T12:58:04.059Z" }, + { url = "https://files.pythonhosted.org/packages/12/f0/f9e59fb8c310171497f379e25db060abef9fa605e09d63157eebec102676/coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f", size = 253750, upload-time = "2026-01-25T12:58:05.574Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b1/1935e31add2232663cf7edd8269548b122a7d100047ff93475dbaaae673e/coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f", size = 254862, upload-time = "2026-01-25T12:58:07.647Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b5e97071ec13df5f45da2b3391b6cdbec78ba20757bc92580a5b3d5fa53c/coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa", size = 251420, upload-time = "2026-01-25T12:58:09.309Z" }, + { url = "https://files.pythonhosted.org/packages/3f/75/9495932f87469d013dc515fb0ce1aac5fa97766f38f6b1a1deb1ee7b7f3a/coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce", size = 252786, upload-time = "2026-01-25T12:58:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/6a/59/af550721f0eb62f46f7b8cb7e6f1860592189267b1c411a4e3a057caacee/coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94", size = 250928, upload-time = "2026-01-25T12:58:12.449Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b1/21b4445709aae500be4ab43bbcfb4e53dc0811c3396dcb11bf9f23fd0226/coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5", size = 250496, upload-time = "2026-01-25T12:58:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/0f5d89dfe0392990e4f3980adbde3eb34885bc1effb2dc369e0bf385e389/coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b", size = 252373, upload-time = "2026-01-25T12:58:15.976Z" }, + { url = "https://files.pythonhosted.org/packages/01/c9/0cf1a6a57a9968cc049a6b896693faa523c638a5314b1fc374eb2b2ac904/coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41", size = 221696, upload-time = "2026-01-25T12:58:17.517Z" }, + { url = "https://files.pythonhosted.org/packages/4d/05/d7540bf983f09d32803911afed135524570f8c47bb394bf6206c1dc3a786/coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e", size = 222504, upload-time = "2026-01-25T12:58:19.115Z" }, + { url = "https://files.pythonhosted.org/packages/15/8b/1a9f037a736ced0a12aacf6330cdaad5008081142a7070bc58b0f7930cbc/coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894", size = 221120, upload-time = "2026-01-25T12:58:21.334Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f0/3d3eac7568ab6096ff23791a526b0048a1ff3f49d0e236b2af6fb6558e88/coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6", size = 219168, upload-time = "2026-01-25T12:58:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a6/f8b5cfeddbab95fdef4dcd682d82e5dcff7a112ced57a959f89537ee9995/coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc", size = 219537, upload-time = "2026-01-25T12:58:24.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e6/8d8e6e0c516c838229d1e41cadcec91745f4b1031d4db17ce0043a0423b4/coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f", size = 250528, upload-time = "2026-01-25T12:58:26.567Z" }, + { url = "https://files.pythonhosted.org/packages/8e/78/befa6640f74092b86961f957f26504c8fba3d7da57cc2ab7407391870495/coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1", size = 253132, upload-time = "2026-01-25T12:58:28.251Z" }, + { url = "https://files.pythonhosted.org/packages/9d/10/1630db1edd8ce675124a2ee0f7becc603d2bb7b345c2387b4b95c6907094/coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9", size = 254374, upload-time = "2026-01-25T12:58:30.294Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1d/0d9381647b1e8e6d310ac4140be9c428a0277330991e0c35bdd751e338a4/coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c", size = 250762, upload-time = "2026-01-25T12:58:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5636dfc9a7c871ee8776af83ee33b4c26bc508ad6cee1e89b6419a366582/coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5", size = 252502, upload-time = "2026-01-25T12:58:33.961Z" }, + { url = "https://files.pythonhosted.org/packages/02/2a/7ff2884d79d420cbb2d12fed6fff727b6d0ef27253140d3cdbbd03187ee0/coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4", size = 250463, upload-time = "2026-01-25T12:58:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/ba51087db645b6c7261570400fc62c89a16278763f36ba618dc8657a187b/coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c", size = 250288, upload-time = "2026-01-25T12:58:37.226Z" }, + { url = "https://files.pythonhosted.org/packages/03/07/44e6f428551c4d9faf63ebcefe49b30e5c89d1be96f6a3abd86a52da9d15/coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31", size = 252063, upload-time = "2026-01-25T12:58:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/c2/67/35b730ad7e1859dd57e834d1bc06080d22d2f87457d53f692fce3f24a5a9/coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8", size = 221716, upload-time = "2026-01-25T12:58:40.484Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/e5fcf5a97c72f45fc14829237a6550bf49d0ab882ac90e04b12a69db76b4/coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb", size = 222522, upload-time = "2026-01-25T12:58:43.247Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/25d7b2f946d239dd2d6644ca2cc060d24f97551e2af13b6c24c722ae5f97/coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557", size = 221145, upload-time = "2026-01-25T12:58:45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f7/080376c029c8f76fadfe43911d0daffa0cbdc9f9418a0eead70c56fb7f4b/coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e", size = 219861, upload-time = "2026-01-25T12:58:46.586Z" }, + { url = "https://files.pythonhosted.org/packages/42/11/0b5e315af5ab35f4c4a70e64d3314e4eec25eefc6dec13be3a7d5ffe8ac5/coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7", size = 220207, upload-time = "2026-01-25T12:58:48.277Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0c/0874d0318fb1062117acbef06a09cf8b63f3060c22265adaad24b36306b7/coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3", size = 261504, upload-time = "2026-01-25T12:58:49.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/5e/1cd72c22ecb30751e43a72f40ba50fcef1b7e93e3ea823bd9feda8e51f9a/coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3", size = 263582, upload-time = "2026-01-25T12:58:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/8acf356707c7a42df4d0657020308e23e5a07397e81492640c186268497c/coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421", size = 266008, upload-time = "2026-01-25T12:58:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/41/41/ea1730af99960309423c6ea8d6a4f1fa5564b2d97bd1d29dda4b42611f04/coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5", size = 260762, upload-time = "2026-01-25T12:58:55.372Z" }, + { url = "https://files.pythonhosted.org/packages/22/fa/02884d2080ba71db64fdc127b311db60e01fe6ba797d9c8363725e39f4d5/coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23", size = 263571, upload-time = "2026-01-25T12:58:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/4083aaaeba9b3112f55ac57c2ce7001dc4d8fa3fcc228a39f09cc84ede27/coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c", size = 261200, upload-time = "2026-01-25T12:58:59.255Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d2/aea92fa36d61955e8c416ede9cf9bf142aa196f3aea214bb67f85235a050/coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f", size = 260095, upload-time = "2026-01-25T12:59:01.066Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ae/04ffe96a80f107ea21b22b2367175c621da920063260a1c22f9452fd7866/coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573", size = 262284, upload-time = "2026-01-25T12:59:02.802Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7a/6f354dcd7dfc41297791d6fb4e0d618acb55810bde2c1fd14b3939e05c2b/coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343", size = 222389, upload-time = "2026-01-25T12:59:04.563Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d5/080ad292a4a3d3daf411574be0a1f56d6dee2c4fdf6b005342be9fac807f/coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47", size = 223450, upload-time = "2026-01-25T12:59:06.677Z" }, + { url = "https://files.pythonhosted.org/packages/88/96/df576fbacc522e9fb8d1c4b7a7fc62eb734be56e2cba1d88d2eabe08ea3f/coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7", size = 221707, upload-time = "2026-01-25T12:59:08.363Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/1da9e51a0775634b04fcc11eb25c002fc58ee4f92ce2e8512f94ac5fc5bf/coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef", size = 219213, upload-time = "2026-01-25T12:59:11.909Z" }, + { url = "https://files.pythonhosted.org/packages/46/35/b3caac3ebbd10230fea5a33012b27d19e999a17c9285c4228b4b2e35b7da/coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f", size = 219549, upload-time = "2026-01-25T12:59:13.638Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/e1cf7def1bdc72c1907e60703983a588f9558434a2ff94615747bd73c192/coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5", size = 250586, upload-time = "2026-01-25T12:59:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ba/49/f54ec02ed12be66c8d8897270505759e057b0c68564a65c429ccdd1f139e/coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4", size = 253093, upload-time = "2026-01-25T12:59:17.491Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5e/aaf86be3e181d907e23c0f61fccaeb38de8e6f6b47aed92bf57d8fc9c034/coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27", size = 254446, upload-time = "2026-01-25T12:59:19.752Z" }, + { url = "https://files.pythonhosted.org/packages/28/c8/a5fa01460e2d75b0c853b392080d6829d3ca8b5ab31e158fa0501bc7c708/coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548", size = 250615, upload-time = "2026-01-25T12:59:21.928Z" }, + { url = "https://files.pythonhosted.org/packages/86/0b/6d56315a55f7062bb66410732c24879ccb2ec527ab6630246de5fe45a1df/coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660", size = 252452, upload-time = "2026-01-25T12:59:23.592Z" }, + { url = "https://files.pythonhosted.org/packages/30/19/9bc550363ebc6b0ea121977ee44d05ecd1e8bf79018b8444f1028701c563/coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92", size = 250418, upload-time = "2026-01-25T12:59:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/580530a31ca2f0cc6f07a8f2ab5460785b02bb11bdf815d4c4d37a4c5169/coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82", size = 250231, upload-time = "2026-01-25T12:59:27.888Z" }, + { url = "https://files.pythonhosted.org/packages/e2/42/dd9093f919dc3088cb472893651884bd675e3df3d38a43f9053656dca9a2/coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892", size = 251888, upload-time = "2026-01-25T12:59:29.636Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a6/0af4053e6e819774626e133c3d6f70fae4d44884bfc4b126cb647baee8d3/coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe", size = 221968, upload-time = "2026-01-25T12:59:31.424Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/5aff1e1f80d55862442855517bb8ad8ad3a68639441ff6287dde6a58558b/coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859", size = 222783, upload-time = "2026-01-25T12:59:33.118Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/09abafb24f84b3292cc658728803416c15b79f9ee5e68d25238a895b07d9/coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6", size = 221348, upload-time = "2026-01-25T12:59:34.939Z" }, + { url = "https://files.pythonhosted.org/packages/b6/60/a3820c7232db63be060e4019017cd3426751c2699dab3c62819cdbcea387/coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b", size = 219950, upload-time = "2026-01-25T12:59:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/fd/37/e4ef5975fdeb86b1e56db9a82f41b032e3d93a840ebaf4064f39e770d5c5/coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417", size = 220209, upload-time = "2026-01-25T12:59:38.339Z" }, + { url = "https://files.pythonhosted.org/packages/54/df/d40e091d00c51adca1e251d3b60a8b464112efa3004949e96a74d7c19a64/coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee", size = 261576, upload-time = "2026-01-25T12:59:40.446Z" }, + { url = "https://files.pythonhosted.org/packages/c5/44/5259c4bed54e3392e5c176121af9f71919d96dde853386e7730e705f3520/coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1", size = 263704, upload-time = "2026-01-25T12:59:42.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/ae9f005827abcbe2c70157459ae86053971c9fa14617b63903abbdce26d9/coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d", size = 266109, upload-time = "2026-01-25T12:59:44.073Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c0/8e279c1c0f5b1eaa3ad9b0fb7a5637fc0379ea7d85a781c0fe0bb3cfc2ab/coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6", size = 260686, upload-time = "2026-01-25T12:59:45.804Z" }, + { url = "https://files.pythonhosted.org/packages/b2/47/3a8112627e9d863e7cddd72894171c929e94491a597811725befdcd76bce/coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a", size = 263568, upload-time = "2026-01-25T12:59:47.929Z" }, + { url = "https://files.pythonhosted.org/packages/92/bc/7ea367d84afa3120afc3ce6de294fd2dcd33b51e2e7fbe4bbfd200f2cb8c/coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04", size = 261174, upload-time = "2026-01-25T12:59:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/33/b7/f1092dcecb6637e31cc2db099581ee5c61a17647849bae6b8261a2b78430/coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f", size = 260017, upload-time = "2026-01-25T12:59:51.463Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/f3d07d4b95fbe1a2ef0958c15da614f7e4f557720132de34d2dc3aa7e911/coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f", size = 262337, upload-time = "2026-01-25T12:59:53.407Z" }, + { url = "https://files.pythonhosted.org/packages/e0/db/b0d5b2873a07cb1e06a55d998697c0a5a540dcefbf353774c99eb3874513/coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3", size = 222749, upload-time = "2026-01-25T12:59:56.316Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2f/838a5394c082ac57d85f57f6aba53093b30d9089781df72412126505716f/coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba", size = 223857, upload-time = "2026-01-25T12:59:58.201Z" }, + { url = "https://files.pythonhosted.org/packages/44/d4/b608243e76ead3a4298824b50922b89ef793e50069ce30316a65c1b4d7ef/coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c", size = 221881, upload-time = "2026-01-25T13:00:00.449Z" }, + { url = "https://files.pythonhosted.org/packages/d2/db/d291e30fdf7ea617a335531e72294e0c723356d7fdde8fba00610a76bda9/coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", size = 210943, upload-time = "2026-01-25T13:00:02.388Z" }, ] [package.optional-dependencies] @@ -684,6 +684,71 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163, upload-time = "2025-10-15T23:18:13.821Z" }, + { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474, upload-time = "2025-10-15T23:18:15.477Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, + { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, +] + [[package]] name = "data-designer" source = { editable = "packages/data-designer" } @@ -748,6 +813,7 @@ dependencies = [ { name = "litellm" }, { name = "lxml" }, { name = "marko" }, + { name = "mcp" }, { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "ruff" }, @@ -772,6 +838,7 @@ requires-dist = [ { name = "litellm", specifier = ">=1.73.6,<1.80.12" }, { name = "lxml", specifier = ">=6.0.2,<7" }, { name = "marko", specifier = ">=2.1.2,<3" }, + { name = "mcp" }, { name = "networkx", specifier = ">=3.0,<4" }, { name = "ruff", specifier = ">=0.14.10,<1" }, { name = "scipy", specifier = ">=1.11.0,<2" }, @@ -967,44 +1034,44 @@ wheels = [ [[package]] name = "duckdb" -version = "1.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7f/da/17c3eb5458af69d54dedc8d18e4a32ceaa8ce4d4c699d45d6d8287e790c3/duckdb-1.4.3.tar.gz", hash = "sha256:fea43e03604c713e25a25211ada87d30cd2a044d8f27afab5deba26ac49e5268", size = 18478418, upload-time = "2025-12-09T10:59:22.945Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/3a/ea8e237e1ba40203dea4ed6a8798ea51e66a4c4f34605697025e5fa06fdd/duckdb-1.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:efa7f1191c59e34b688fcd4e588c1b903a4e4e1f4804945902cf0b20e08a9001", size = 29016021, upload-time = "2025-12-09T10:57:46.847Z" }, - { url = "https://files.pythonhosted.org/packages/48/88/07615298a2871362b454237b6a2d7724e6ba0afba2bddedddde5bbf129d5/duckdb-1.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4fef6a053a1c485292000bf0c338bba60f89d334f6a06fc76ba4085a5a322b76", size = 15405906, upload-time = "2025-12-09T10:57:49.213Z" }, - { url = "https://files.pythonhosted.org/packages/fa/66/b407ab3cd4822191aa5defb27522213b6ba670437c7da09a062d8b75b0a4/duckdb-1.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:702dabbc22b27dc5b73e7599c60deef3d8c59968527c36b391773efddd8f4cf1", size = 13732991, upload-time = "2025-12-09T10:57:51.189Z" }, - { url = "https://files.pythonhosted.org/packages/33/f0/e8edab80446d87b4e0faf3aaa440f9cfd9d0609c21a4be56174c8ba7d23c/duckdb-1.4.3-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:854b79375fa618f6ffa8d84fb45cbc9db887f6c4834076ea10d20bc106f1fd90", size = 18471503, upload-time = "2025-12-09T10:57:53.186Z" }, - { url = "https://files.pythonhosted.org/packages/8c/7a/8d257bc847f0ac6a6639ae0a6e7f35f0b5bfbae472ee4846ee32404670a6/duckdb-1.4.3-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bb8bd5a3dd205983726185b280a211eacc9f5bc0c4d4505bec8c87ac33a8ccb", size = 20466012, upload-time = "2025-12-09T10:57:55.702Z" }, - { url = "https://files.pythonhosted.org/packages/cf/d1/8f6bdaf2da6a076dd63c84ed87fb82d0741c9f4acb3dd476d73ca0a08ffe/duckdb-1.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:d0ff08388ef8b1d1a4c95c321d6c5fa11201b241036b1ee740f9d841df3d6ba2", size = 12328392, upload-time = "2025-12-09T10:57:57.718Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bc/7c5e50e440c8629495678bc57bdfc1bb8e62f61090f2d5441e2bd0a0ed96/duckdb-1.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:366bf607088053dce845c9d24c202c04d78022436cc5d8e4c9f0492de04afbe7", size = 29019361, upload-time = "2025-12-09T10:57:59.845Z" }, - { url = "https://files.pythonhosted.org/packages/26/15/c04a4faf0dfddad2259cab72bf0bd4b3d010f2347642541bd254d516bf93/duckdb-1.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8d080e8d1bf2d226423ec781f539c8f6b6ef3fd42a9a58a7160de0a00877a21f", size = 15407465, upload-time = "2025-12-09T10:58:02.465Z" }, - { url = "https://files.pythonhosted.org/packages/cb/54/a049490187c9529932fc153f7e1b92a9e145586281fe4e03ce0535a0497c/duckdb-1.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9dc049ba7e906cb49ca2b6d4fbf7b6615ec3883193e8abb93f0bef2652e42dda", size = 13735781, upload-time = "2025-12-09T10:58:04.847Z" }, - { url = "https://files.pythonhosted.org/packages/14/b7/ee594dcecbc9469ec3cd1fb1f81cb5fa289ab444b80cfb5640c8f467f75f/duckdb-1.4.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b30245375ea94ab528c87c61fc3ab3e36331180b16af92ee3a37b810a745d24", size = 18470729, upload-time = "2025-12-09T10:58:07.116Z" }, - { url = "https://files.pythonhosted.org/packages/df/5f/a6c1862ed8a96d8d930feb6af5e55aadd983310aab75142468c2cb32a2a3/duckdb-1.4.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7c864df027da1ee95f0c32def67e15d02cd4a906c9c1cbae82c09c5112f526b", size = 20471399, upload-time = "2025-12-09T10:58:09.714Z" }, - { url = "https://files.pythonhosted.org/packages/5b/80/c05c0b6a6107b618927b7dcabe3bba6a7eecd951f25c9dbcd9c1f9577cc8/duckdb-1.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:813f189039b46877b5517f1909c7b94a8fe01b4bde2640ab217537ea0fe9b59b", size = 12329359, upload-time = "2025-12-09T10:58:12.147Z" }, - { url = "https://files.pythonhosted.org/packages/b0/83/9d8fc3413f854effa680dcad1781f68f3ada8679863c0c94ba3b36bae6ff/duckdb-1.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:fbc63ffdd03835f660155b37a1b6db2005bcd46e5ad398b8cac141eb305d2a3d", size = 13070898, upload-time = "2025-12-09T10:58:14.301Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d7/fdc2139b94297fc5659110a38adde293d025e320673ae5e472b95d323c50/duckdb-1.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6302452e57aef29aae3977063810ed7b2927967b97912947b9cca45c1c21955f", size = 29033112, upload-time = "2025-12-09T10:58:16.52Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d9/ca93df1ce19aef8f799e3aaacf754a4dde7e9169c0b333557752d21d076a/duckdb-1.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:deab351ac43b6282a3270e3d40e3d57b3b50f472d9fd8c30975d88a31be41231", size = 15414646, upload-time = "2025-12-09T10:58:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/16/90/9f2748e740f5fc05b739e7c5c25aab6ab4363e5da4c3c70419c7121dc806/duckdb-1.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5634e40e1e2d972e4f75bced1fbdd9e9e90faa26445c1052b27de97ee546944a", size = 13740477, upload-time = "2025-12-09T10:58:21.778Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ec/279723615b4fb454efd823b7efe97cf2504569e2e74d15defbbd6b027901/duckdb-1.4.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:274d4a31aba63115f23e7e7b401e3e3a937f3626dc9dea820a9c7d3073f450d2", size = 18483715, upload-time = "2025-12-09T10:58:24.346Z" }, - { url = "https://files.pythonhosted.org/packages/10/63/af20cd20fd7fd6565ea5a1578c16157b6a6e07923e459a6f9b0dc9ada308/duckdb-1.4.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f868a7e6d9b37274a1aa34849ea92aa964e9bd59a5237d6c17e8540533a1e4f", size = 20495188, upload-time = "2025-12-09T10:58:26.806Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ab/0acb4b64afb2cc6c1d458a391c64e36be40137460f176c04686c965ce0e0/duckdb-1.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:ef7ef15347ce97201b1b5182a5697682679b04c3374d5a01ac10ba31cf791b95", size = 12335622, upload-time = "2025-12-09T10:58:29.707Z" }, - { url = "https://files.pythonhosted.org/packages/50/d5/2a795745f6597a5e65770141da6efdc4fd754e5ee6d652f74bcb7f9c7759/duckdb-1.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:1b9b445970fd18274d5ac07a0b24c032e228f967332fb5ebab3d7db27738c0e4", size = 13075834, upload-time = "2025-12-09T10:58:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/fd/76/288cca43a10ddd082788e1a71f1dc68d9130b5d078c3ffd0edf2f3a8719f/duckdb-1.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16952ac05bd7e7b39946695452bf450db1ebbe387e1e7178e10f593f2ea7b9a8", size = 29033392, upload-time = "2025-12-09T10:58:34.631Z" }, - { url = "https://files.pythonhosted.org/packages/64/07/cbad3d3da24af4d1add9bccb5fb390fac726ffa0c0cebd29bf5591cef334/duckdb-1.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de984cd24a6cbefdd6d4a349f7b9a46e583ca3e58ce10d8def0b20a6e5fcbe78", size = 15414567, upload-time = "2025-12-09T10:58:37.051Z" }, - { url = "https://files.pythonhosted.org/packages/c4/19/57af0cc66ba2ffb8900f567c9aec188c6ab2a7b3f2260e9c6c3c5f9b57b1/duckdb-1.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e5457dda91b67258aae30fb1a0df84183a9f6cd27abac1d5536c0d876c6dfa1", size = 13740960, upload-time = "2025-12-09T10:58:39.658Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/23152458cf5fd51e813fadda60b9b5f011517634aa4bb9301f5f3aa951d8/duckdb-1.4.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:006aca6a6d6736c441b02ff5c7600b099bb8b7f4de094b8b062137efddce42df", size = 18484312, upload-time = "2025-12-09T10:58:42.054Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7b/adf3f611f11997fc429d4b00a730604b65d952417f36a10c4be6e38e064d/duckdb-1.4.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2813f4635f4d6681cc3304020374c46aca82758c6740d7edbc237fe3aae2744", size = 20495571, upload-time = "2025-12-09T10:58:44.646Z" }, - { url = "https://files.pythonhosted.org/packages/40/d5/6b7ddda7713a788ab2d622c7267ec317718f2bdc746ce1fca49b7ff0e50f/duckdb-1.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:6db124f53a3edcb32b0a896ad3519e37477f7e67bf4811cb41ab60c1ef74e4c8", size = 12335680, upload-time = "2025-12-09T10:58:46.883Z" }, - { url = "https://files.pythonhosted.org/packages/e8/28/0670135cf54525081fded9bac1254f78984e3b96a6059cd15aca262e3430/duckdb-1.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:a8b0a8764e1b5dd043d168c8f749314f7a1252b5a260fa415adaa26fa3b958fd", size = 13075161, upload-time = "2025-12-09T10:58:49.47Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f4/a38651e478fa41eeb8e43a0a9c0d4cd8633adea856e3ac5ac95124b0fdbf/duckdb-1.4.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:316711a9e852bcfe1ed6241a5f654983f67e909e290495f3562cccdf43be8180", size = 29042272, upload-time = "2025-12-09T10:58:51.826Z" }, - { url = "https://files.pythonhosted.org/packages/16/de/2cf171a66098ce5aeeb7371511bd2b3d7b73a2090603b0b9df39f8aaf814/duckdb-1.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9e625b2b4d52bafa1fd0ebdb0990c3961dac8bb00e30d327185de95b68202131", size = 15419343, upload-time = "2025-12-09T10:58:54.439Z" }, - { url = "https://files.pythonhosted.org/packages/35/28/6b0a7830828d4e9a37420d87e80fe6171d2869a9d3d960bf5d7c3b8c7ee4/duckdb-1.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:130c6760f6c573f9c9fe9aba56adba0fab48811a4871b7b8fd667318b4a3e8da", size = 13748905, upload-time = "2025-12-09T10:58:56.656Z" }, - { url = "https://files.pythonhosted.org/packages/15/4d/778628e194d63967870873b9581c8a6b4626974aa4fbe09f32708a2d3d3a/duckdb-1.4.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c88effaa557a11267706b01419c542fe42f893dee66e5a6daa5974ea2d4a46", size = 18487261, upload-time = "2025-12-09T10:58:58.866Z" }, - { url = "https://files.pythonhosted.org/packages/c6/5f/87e43af2e4a0135f9675449563e7c2f9b6f1fe6a2d1691c96b091f3904dd/duckdb-1.4.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b35491db98ccd11d151165497c084a9d29d3dc42fc80abea2715a6c861ca43d", size = 20497138, upload-time = "2025-12-09T10:59:01.241Z" }, - { url = "https://files.pythonhosted.org/packages/94/41/abec537cc7c519121a2a83b9a6f180af8915fabb433777dc147744513e74/duckdb-1.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:23b12854032c1a58d0452e2b212afa908d4ce64171862f3792ba9a596ba7c765", size = 12836056, upload-time = "2025-12-09T10:59:03.388Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5a/8af5b96ce5622b6168854f479ce846cf7fb589813dcc7d8724233c37ded3/duckdb-1.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:90f241f25cffe7241bf9f376754a5845c74775e00e1c5731119dc88cd71e0cb2", size = 13527759, upload-time = "2025-12-09T10:59:05.496Z" }, +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9d/ab66a06e416d71b7bdcb9904cdf8d4db3379ef632bb8e9495646702d9718/duckdb-1.4.4.tar.gz", hash = "sha256:8bba52fd2acb67668a4615ee17ee51814124223de836d9e2fdcbc4c9021b3d3c", size = 18419763, upload-time = "2026-01-26T11:50:37.68Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/9f/67a75f1e88f84946909826fa7aadd0c4b0dc067f24956142751fd9d59fe6/duckdb-1.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e870a441cb1c41d556205deb665749f26347ed13b3a247b53714f5d589596977", size = 28884338, upload-time = "2026-01-26T11:48:41.591Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7a/e9277d0567884c21f345ad43cc01aeaa2abe566d5fdf22e35c3861dd44fa/duckdb-1.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:49123b579e4a6323e65139210cd72dddc593a72d840211556b60f9703bda8526", size = 15339148, upload-time = "2026-01-26T11:48:45.343Z" }, + { url = "https://files.pythonhosted.org/packages/4a/96/3a7630d2779d2bae6f3cdf540a088ed45166adefd3c429971e5b85ce8f84/duckdb-1.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e1933fac5293fea5926b0ee75a55b8cfe7f516d867310a5b251831ab61fe62b", size = 13668431, upload-time = "2026-01-26T11:48:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ad/f62a3a65d200e8afc1f75cf0dd3f0aa84ef0dd07c484414a11f2abed810e/duckdb-1.4.4-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:707530f6637e91dc4b8125260595299ec9dd157c09f5d16c4186c5988bfbd09a", size = 18409546, upload-time = "2026-01-26T11:48:51.142Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5f/23bd586ecb21273b41b5aa4b16fd88b7fecb53ed48d897273651c0c3d66f/duckdb-1.4.4-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:453b115f4777467f35103d8081770ac2f223fb5799178db5b06186e3ab51d1f2", size = 20407046, upload-time = "2026-01-26T11:48:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/4ce78bf341c930d4a22a56cb686bfc2c975eaf25f653a7ac25e3929d98bb/duckdb-1.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a3c8542db7ffb128aceb7f3b35502ebaddcd4f73f1227569306cc34bad06680c", size = 12256576, upload-time = "2026-01-26T11:48:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/04/68/19233412033a2bc5a144a3f531f64e3548d4487251e3f16b56c31411a06f/duckdb-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5ba684f498d4e924c7e8f30dd157da8da34c8479746c5011b6c0e037e9c60ad2", size = 28883816, upload-time = "2026-01-26T11:49:01.009Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3e/cec70e546c298ab76d80b990109e111068d82cca67942c42328eaa7d6fdb/duckdb-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5536eb952a8aa6ae56469362e344d4e6403cc945a80bc8c5c2ebdd85d85eb64b", size = 15339662, upload-time = "2026-01-26T11:49:04.058Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f0/cf4241a040ec4f571859a738007ec773b642fbc27df4cbcf34b0c32ea559/duckdb-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:47dd4162da6a2be59a0aef640eb08d6360df1cf83c317dcc127836daaf3b7f7c", size = 13670044, upload-time = "2026-01-26T11:49:06.627Z" }, + { url = "https://files.pythonhosted.org/packages/11/64/de2bb4ec1e35ec9ebf6090a95b930fc56934a0ad6f34a24c5972a14a77ef/duckdb-1.4.4-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6cb357cfa3403910e79e2eb46c8e445bb1ee2fd62e9e9588c6b999df4256abc1", size = 18409951, upload-time = "2026-01-26T11:49:09.808Z" }, + { url = "https://files.pythonhosted.org/packages/79/a2/ac0f5ee16df890d141304bcd48733516b7202c0de34cd3555634d6eb4551/duckdb-1.4.4-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c25d5b0febda02b7944e94fdae95aecf952797afc8cb920f677b46a7c251955", size = 20411739, upload-time = "2026-01-26T11:49:12.652Z" }, + { url = "https://files.pythonhosted.org/packages/37/a2/9a3402edeedaecf72de05fe9ff7f0303d701b8dfc136aea4a4be1a5f7eee/duckdb-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6703dd1bb650025b3771552333d305d62ddd7ff182de121483d4e042ea6e2e00", size = 12256972, upload-time = "2026-01-26T11:49:15.468Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/052ea6dcdf35b259fd182eff3efd8d75a071de4010c9807556098df137b9/duckdb-1.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:bf138201f56e5d6fc276a25138341b3523e2f84733613fc43f02c54465619a95", size = 13006696, upload-time = "2026-01-26T11:49:18.054Z" }, + { url = "https://files.pythonhosted.org/packages/58/33/beadaa69f8458afe466126f2c5ee48c4759cc9d5d784f8703d44e0b52c3c/duckdb-1.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ddcfd9c6ff234da603a1edd5fd8ae6107f4d042f74951b65f91bc5e2643856b3", size = 28896535, upload-time = "2026-01-26T11:49:21.232Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/82413f386df10467affc87f65bac095b7c88dbd9c767584164d5f4dc4cb8/duckdb-1.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6792ca647216bd5c4ff16396e4591cfa9b4a72e5ad7cdd312cec6d67e8431a7c", size = 15349716, upload-time = "2026-01-26T11:49:23.989Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8c/c13d396fd4e9bf970916dc5b4fea410c1b10fe531069aea65f1dcf849a71/duckdb-1.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f8d55843cc940e36261689054f7dfb6ce35b1f5b0953b0d355b6adb654b0d52", size = 13672403, upload-time = "2026-01-26T11:49:26.741Z" }, + { url = "https://files.pythonhosted.org/packages/db/77/2446a0b44226bb95217748d911c7ca66a66ca10f6481d5178d9370819631/duckdb-1.4.4-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c65d15c440c31e06baaebfd2c06d71ce877e132779d309f1edf0a85d23c07e92", size = 18419001, upload-time = "2026-01-26T11:49:29.353Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a3/97715bba30040572fb15d02c26f36be988d48bc00501e7ac02b1d65ef9d0/duckdb-1.4.4-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b297eff642503fd435a9de5a9cb7db4eccb6f61d61a55b30d2636023f149855f", size = 20437385, upload-time = "2026-01-26T11:49:32.302Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0a/18b9167adf528cbe3867ef8a84a5f19f37bedccb606a8a9e59cfea1880c8/duckdb-1.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d525de5f282b03aa8be6db86b1abffdceae5f1055113a03d5b50cd2fb8cf2ef8", size = 12267343, upload-time = "2026-01-26T11:49:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/f8/15/37af97f5717818f3d82d57414299c293b321ac83e048c0a90bb8b6a09072/duckdb-1.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:50f2eb173c573811b44aba51176da7a4e5c487113982be6a6a1c37337ec5fa57", size = 13007490, upload-time = "2026-01-26T11:49:37.413Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fe/64810fee20030f2bf96ce28b527060564864ce5b934b50888eda2cbf99dd/duckdb-1.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:337f8b24e89bc2e12dadcfe87b4eb1c00fd920f68ab07bc9b70960d6523b8bc3", size = 28899349, upload-time = "2026-01-26T11:49:40.294Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9b/3c7c5e48456b69365d952ac201666053de2700f5b0144a699a4dc6854507/duckdb-1.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0509b39ea7af8cff0198a99d206dca753c62844adab54e545984c2e2c1381616", size = 15350691, upload-time = "2026-01-26T11:49:43.242Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7b/64e68a7b857ed0340045501535a0da99ea5d9d5ea3708fec0afb8663eb27/duckdb-1.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fb94de6d023de9d79b7edc1ae07ee1d0b4f5fa8a9dcec799650b5befdf7aafec", size = 13672311, upload-time = "2026-01-26T11:49:46.069Z" }, + { url = "https://files.pythonhosted.org/packages/09/5b/3e7aa490841784d223de61beb2ae64e82331501bf5a415dc87a0e27b4663/duckdb-1.4.4-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d636ceda422e7babd5e2f7275f6a0d1a3405e6a01873f00d38b72118d30c10b", size = 18422740, upload-time = "2026-01-26T11:49:49.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/32/256df3dbaa198c58539ad94f9a41e98c2c8ff23f126b8f5f52c7dcd0a738/duckdb-1.4.4-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df7351328ffb812a4a289732f500d621e7de9942a3a2c9b6d4afcf4c0e72526", size = 20435578, upload-time = "2026-01-26T11:49:51.946Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f0/620323fd87062ea43e527a2d5ed9e55b525e0847c17d3b307094ddab98a2/duckdb-1.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:6fb1225a9ea5877421481d59a6c556a9532c32c16c7ae6ca8d127e2b878c9389", size = 12268083, upload-time = "2026-01-26T11:49:54.615Z" }, + { url = "https://files.pythonhosted.org/packages/e5/07/a397fdb7c95388ba9c055b9a3d38dfee92093f4427bc6946cf9543b1d216/duckdb-1.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:f28a18cc790217e5b347bb91b2cab27aafc557c58d3d8382e04b4fe55d0c3f66", size = 13006123, upload-time = "2026-01-26T11:49:57.092Z" }, + { url = "https://files.pythonhosted.org/packages/97/a6/f19e2864e651b0bd8e4db2b0c455e7e0d71e0d4cd2cd9cc052f518e43eb3/duckdb-1.4.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:25874f8b1355e96178079e37312c3ba6d61a2354f51319dae860cf21335c3a20", size = 28909554, upload-time = "2026-01-26T11:50:00.107Z" }, + { url = "https://files.pythonhosted.org/packages/0e/93/8a24e932c67414fd2c45bed83218e62b73348996bf859eda020c224774b2/duckdb-1.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:452c5b5d6c349dc5d1154eb2062ee547296fcbd0c20e9df1ed00b5e1809089da", size = 15353804, upload-time = "2026-01-26T11:50:03.382Z" }, + { url = "https://files.pythonhosted.org/packages/62/13/e5378ff5bb1d4397655d840b34b642b1b23cdd82ae19599e62dc4b9461c9/duckdb-1.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8e5c2d8a0452df55e092959c0bfc8ab8897ac3ea0f754cb3b0ab3e165cd79aff", size = 13676157, upload-time = "2026-01-26T11:50:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/2d/94/24364da564b27aeebe44481f15bd0197a0b535ec93f188a6b1b98c22f082/duckdb-1.4.4-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1af6e76fe8bd24875dc56dd8e38300d64dc708cd2e772f67b9fbc635cc3066a3", size = 18426882, upload-time = "2026-01-26T11:50:08.97Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/6ae31b2914b4dc34243279b2301554bcbc5f1a09ccc82600486c49ab71d1/duckdb-1.4.4-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0440f59e0cd9936a9ebfcf7a13312eda480c79214ffed3878d75947fc3b7d6d", size = 20435641, upload-time = "2026-01-26T11:50:12.188Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b1/fd5c37c53d45efe979f67e9bd49aaceef640147bb18f0699a19edd1874d6/duckdb-1.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:59c8d76016dde854beab844935b1ec31de358d4053e792988108e995b18c08e7", size = 12762360, upload-time = "2026-01-26T11:50:14.76Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2d/13e6024e613679d8a489dd922f199ef4b1d08a456a58eadd96dc2f05171f/duckdb-1.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:53cd6423136ab44383ec9955aefe7599b3fb3dd1fe006161e6396d8167e0e0d4", size = 13458633, upload-time = "2026-01-26T11:50:17.657Z" }, ] [[package]] @@ -1493,9 +1560,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/0a/2626b5a2678f8072ba3174d3e40f81429fdc41d1cb993280dbc7ba3c4e3f/httpx_retries-0.4.5-py3-none-any.whl", hash = "sha256:ae22d6ef197a2da49242246a01d721474cbd6516b1fef155f6da694ee410bb37", size = 8301, upload-time = "2025-10-17T15:55:22.869Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "huggingface-hub" -version = "1.3.3" +version = "1.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -1509,9 +1585,9 @@ dependencies = [ { name = "typer-slim" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/c3/544cd4cdd4b3c6de8591b56bb69efc3682e9ac81e36135c02e909dd98c5b/huggingface_hub-1.3.3.tar.gz", hash = "sha256:f8be6f468da4470db48351e8c77d6d8115dff9b3daeb30276e568767b1ff7574", size = 627649, upload-time = "2026-01-22T13:59:46.931Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/25/74af9d16cd59ae15b12467a79a84aa0fe24be4aba68fc4da0c1864d49c17/huggingface_hub-1.3.4.tar.gz", hash = "sha256:c20d5484a611b7b7891d272e8fc9f77d5de025b0480bdacfa858efb3780b455f", size = 627683, upload-time = "2026-01-26T14:05:10.656Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/e8/0d032698916b9773b710c46e3b8e0154fc34cd017b151cc316c84c6c34fe/huggingface_hub-1.3.3-py3-none-any.whl", hash = "sha256:44af7b62380efc87c1c3bde7e1bf0661899b5bdfca1fc60975c61ee68410e10e", size = 536604, upload-time = "2026-01-22T13:59:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/55/07/3d0c34c345043c6a398a5882e196b2220dc5861adfa18322448b90908f26/huggingface_hub-1.3.4-py3-none-any.whl", hash = "sha256:a0c526e76eb316e96a91e8a1a7a93cf66b0dd210be1a17bd5fc5ae53cba76bfd", size = 536611, upload-time = "2026-01-26T14:05:08.549Z" }, ] [[package]] @@ -2191,7 +2267,7 @@ wheels = [ [[package]] name = "jupytext" -version = "1.19.0" +version = "1.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, @@ -2201,9 +2277,9 @@ dependencies = [ { name = "pyyaml" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/84/79a28abd8e6a9376fa623670ab8ac7ebcf45b10f2974e0121bb5e8e086a2/jupytext-1.19.0.tar.gz", hash = "sha256:724c1f75c850a12892ccbcdff33004ede33965d0da8520ab9ea74b39ff51283a", size = 4306554, upload-time = "2026-01-18T17:41:58.959Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/a5/80c02f307c8ce863cb33e27daf049315e9d96979e14eead700923b5ec9cc/jupytext-1.19.1.tar.gz", hash = "sha256:82587c07e299173c70ed5e8ec7e75183edf1be289ed518bab49ad0d4e3d5f433", size = 4307829, upload-time = "2026-01-25T21:35:13.276Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/8c/e27aaea0a3fbea002f0e138902432e64f35b39d942cfa13bdc5dd63ce310/jupytext-1.19.0-py3-none-any.whl", hash = "sha256:6e82527920600883088c5825f5d4a5bd06a2676d4958d4f3bc622bad2439c0ac", size = 169904, upload-time = "2026-01-18T17:41:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/16/5a/736dd2f4535dbf3bf26523f9158c011389ef88dd06ec2eef67fd744f1c7b/jupytext-1.19.1-py3-none-any.whl", hash = "sha256:d8975035155d034bdfde5c0c37891425314b7ea8d3a6c4b5d18c294348714cd9", size = 170478, upload-time = "2026-01-25T21:35:11.17Z" }, ] [[package]] @@ -2491,6 +2567,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + [[package]] name = "mdit-py-plugins" version = "0.5.0" @@ -2698,140 +2799,140 @@ wheels = [ [[package]] name = "multidict" -version = "6.7.0" +version = "6.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/63/7bdd4adc330abcca54c85728db2327130e49e52e8c3ce685cec44e0f2e9f/multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349", size = 77153, upload-time = "2025-10-06T14:48:26.409Z" }, - { url = "https://files.pythonhosted.org/packages/3f/bb/b6c35ff175ed1a3142222b78455ee31be71a8396ed3ab5280fbe3ebe4e85/multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e", size = 44993, upload-time = "2025-10-06T14:48:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/e0/1f/064c77877c5fa6df6d346e68075c0f6998547afe952d6471b4c5f6a7345d/multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3", size = 44607, upload-time = "2025-10-06T14:48:29.581Z" }, - { url = "https://files.pythonhosted.org/packages/04/7a/bf6aa92065dd47f287690000b3d7d332edfccb2277634cadf6a810463c6a/multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046", size = 241847, upload-time = "2025-10-06T14:48:32.107Z" }, - { url = "https://files.pythonhosted.org/packages/94/39/297a8de920f76eda343e4ce05f3b489f0ab3f9504f2576dfb37b7c08ca08/multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32", size = 242616, upload-time = "2025-10-06T14:48:34.054Z" }, - { url = "https://files.pythonhosted.org/packages/39/3a/d0eee2898cfd9d654aea6cb8c4addc2f9756e9a7e09391cfe55541f917f7/multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73", size = 222333, upload-time = "2025-10-06T14:48:35.9Z" }, - { url = "https://files.pythonhosted.org/packages/05/48/3b328851193c7a4240815b71eea165b49248867bbb6153a0aee227a0bb47/multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc", size = 253239, upload-time = "2025-10-06T14:48:37.302Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ca/0706a98c8d126a89245413225ca4a3fefc8435014de309cf8b30acb68841/multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62", size = 251618, upload-time = "2025-10-06T14:48:38.963Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/9c7992f245554d8b173f6f0a048ad24b3e645d883f096857ec2c0822b8bd/multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84", size = 241655, upload-time = "2025-10-06T14:48:40.312Z" }, - { url = "https://files.pythonhosted.org/packages/31/79/26a85991ae67efd1c0b1fc2e0c275b8a6aceeb155a68861f63f87a798f16/multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0", size = 239245, upload-time = "2025-10-06T14:48:41.848Z" }, - { url = "https://files.pythonhosted.org/packages/14/1e/75fa96394478930b79d0302eaf9a6c69f34005a1a5251ac8b9c336486ec9/multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e", size = 233523, upload-time = "2025-10-06T14:48:43.749Z" }, - { url = "https://files.pythonhosted.org/packages/b2/5e/085544cb9f9c4ad2b5d97467c15f856df8d9bac410cffd5c43991a5d878b/multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4", size = 243129, upload-time = "2025-10-06T14:48:45.225Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c3/e9d9e2f20c9474e7a8fcef28f863c5cbd29bb5adce6b70cebe8bdad0039d/multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648", size = 248999, upload-time = "2025-10-06T14:48:46.703Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3f/df171b6efa3239ae33b97b887e42671cd1d94d460614bfb2c30ffdab3b95/multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111", size = 243711, upload-time = "2025-10-06T14:48:48.146Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2f/9b5564888c4e14b9af64c54acf149263721a283aaf4aa0ae89b091d5d8c1/multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36", size = 237504, upload-time = "2025-10-06T14:48:49.447Z" }, - { url = "https://files.pythonhosted.org/packages/6c/3a/0bd6ca0f7d96d790542d591c8c3354c1e1b6bfd2024d4d92dc3d87485ec7/multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85", size = 41422, upload-time = "2025-10-06T14:48:50.789Z" }, - { url = "https://files.pythonhosted.org/packages/00/35/f6a637ea2c75f0d3b7c7d41b1189189acff0d9deeb8b8f35536bb30f5e33/multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7", size = 46050, upload-time = "2025-10-06T14:48:51.938Z" }, - { url = "https://files.pythonhosted.org/packages/e7/b8/f7bf8329b39893d02d9d95cf610c75885d12fc0f402b1c894e1c8e01c916/multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0", size = 43153, upload-time = "2025-10-06T14:48:53.146Z" }, - { url = "https://files.pythonhosted.org/packages/34/9e/5c727587644d67b2ed479041e4b1c58e30afc011e3d45d25bbe35781217c/multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc", size = 76604, upload-time = "2025-10-06T14:48:54.277Z" }, - { url = "https://files.pythonhosted.org/packages/17/e4/67b5c27bd17c085a5ea8f1ec05b8a3e5cba0ca734bfcad5560fb129e70ca/multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721", size = 44715, upload-time = "2025-10-06T14:48:55.445Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e1/866a5d77be6ea435711bef2a4291eed11032679b6b28b56b4776ab06ba3e/multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6", size = 44332, upload-time = "2025-10-06T14:48:56.706Z" }, - { url = "https://files.pythonhosted.org/packages/31/61/0c2d50241ada71ff61a79518db85ada85fdabfcf395d5968dae1cbda04e5/multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c", size = 245212, upload-time = "2025-10-06T14:48:58.042Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e0/919666a4e4b57fff1b57f279be1c9316e6cdc5de8a8b525d76f6598fefc7/multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7", size = 246671, upload-time = "2025-10-06T14:49:00.004Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cc/d027d9c5a520f3321b65adea289b965e7bcbd2c34402663f482648c716ce/multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7", size = 225491, upload-time = "2025-10-06T14:49:01.393Z" }, - { url = "https://files.pythonhosted.org/packages/75/c4/bbd633980ce6155a28ff04e6a6492dd3335858394d7bb752d8b108708558/multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9", size = 257322, upload-time = "2025-10-06T14:49:02.745Z" }, - { url = "https://files.pythonhosted.org/packages/4c/6d/d622322d344f1f053eae47e033b0b3f965af01212de21b10bcf91be991fb/multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8", size = 254694, upload-time = "2025-10-06T14:49:04.15Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/78f8761c2705d4c6d7516faed63c0ebdac569f6db1bef95e0d5218fdc146/multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd", size = 246715, upload-time = "2025-10-06T14:49:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/950818e04f91b9c2b95aab3d923d9eabd01689d0dcd889563988e9ea0fd8/multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb", size = 243189, upload-time = "2025-10-06T14:49:07.37Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3d/77c79e1934cad2ee74991840f8a0110966d9599b3af95964c0cd79bb905b/multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6", size = 237845, upload-time = "2025-10-06T14:49:08.759Z" }, - { url = "https://files.pythonhosted.org/packages/63/1b/834ce32a0a97a3b70f86437f685f880136677ac00d8bce0027e9fd9c2db7/multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2", size = 246374, upload-time = "2025-10-06T14:49:10.574Z" }, - { url = "https://files.pythonhosted.org/packages/23/ef/43d1c3ba205b5dec93dc97f3fba179dfa47910fc73aaaea4f7ceb41cec2a/multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff", size = 253345, upload-time = "2025-10-06T14:49:12.331Z" }, - { url = "https://files.pythonhosted.org/packages/6b/03/eaf95bcc2d19ead522001f6a650ef32811aa9e3624ff0ad37c445c7a588c/multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b", size = 246940, upload-time = "2025-10-06T14:49:13.821Z" }, - { url = "https://files.pythonhosted.org/packages/e8/df/ec8a5fd66ea6cd6f525b1fcbb23511b033c3e9bc42b81384834ffa484a62/multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34", size = 242229, upload-time = "2025-10-06T14:49:15.603Z" }, - { url = "https://files.pythonhosted.org/packages/8a/a2/59b405d59fd39ec86d1142630e9049243015a5f5291ba49cadf3c090c541/multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff", size = 41308, upload-time = "2025-10-06T14:49:16.871Z" }, - { url = "https://files.pythonhosted.org/packages/32/0f/13228f26f8b882c34da36efa776c3b7348455ec383bab4a66390e42963ae/multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81", size = 46037, upload-time = "2025-10-06T14:49:18.457Z" }, - { url = "https://files.pythonhosted.org/packages/84/1f/68588e31b000535a3207fd3c909ebeec4fb36b52c442107499c18a896a2a/multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912", size = 43023, upload-time = "2025-10-06T14:49:19.648Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, - { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, - { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, - { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, - { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, - { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, - { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, - { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, - { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, - { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, - { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, - { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, - { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, - { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, - { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, - { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, - { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, - { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, - { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, - { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, - { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, - { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, - { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, - { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, - { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, - { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, - { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, - { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, - { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, - { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, - { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, - { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, - { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, - { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, - { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, - { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, - { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, - { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, - { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, - { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, - { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, - { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, - { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, - { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, - { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, - { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, - { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, - { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, - { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, - { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, - { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, - { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, - { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, - { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, - { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, - { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, - { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, - { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, - { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, - { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, - { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] [[package]] @@ -2953,7 +3054,7 @@ wheels = [ [[package]] name = "notebook" -version = "7.5.2" +version = "7.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jupyter-server" }, @@ -2962,9 +3063,9 @@ dependencies = [ { name = "notebook-shim" }, { name = "tornado" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/b6/6b2c653570b02e4ec2a94c0646a4a25132be0749617776d0b72a2bcedb9b/notebook-7.5.2.tar.gz", hash = "sha256:83e82f93c199ca730313bea1bb24bc279ea96f74816d038a92d26b6b9d5f3e4a", size = 14059605, upload-time = "2026-01-12T14:56:53.483Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/cb/cc7f4df5cee315dd126a47eb60890690a0438d5e0dd40c32d60ce16de377/notebook-7.5.3.tar.gz", hash = "sha256:393ceb269cf9fdb02a3be607a57d7bd5c2c14604f1818a17dbeb38e04f98cbfa", size = 14073140, upload-time = "2026-01-26T07:28:36.605Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/55/b754cd51c6011d90ef03e3f06136f1ebd44658b9529dbcf0c15fc0d6a0b7/notebook-7.5.2-py3-none-any.whl", hash = "sha256:17d078a98603d70d62b6b4b3fcb67e87d7a68c398a7ae9b447eb2d7d9aec9979", size = 14468915, upload-time = "2026-01-12T14:56:47.87Z" }, + { url = "https://files.pythonhosted.org/packages/96/98/9286e7f35e5584ebb79f997f2fb0cb66745c86f6c5fccf15ba32aac5e908/notebook-7.5.3-py3-none-any.whl", hash = "sha256:c997bfa1a2a9eb58c9bbb7e77d50428befb1033dd6f02c482922e96851d67354", size = 14481744, upload-time = "2026-01-26T07:28:31.867Z" }, ] [[package]] @@ -3786,6 +3887,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -3795,17 +3910,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymdown-extensions" -version = "10.20" +version = "10.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/35/e3814a5b7df295df69d035cfb8aab78b2967cdf11fcfae7faed726b66664/pymdown_extensions-10.20.tar.gz", hash = "sha256:5c73566ab0cf38c6ba084cb7c5ea64a119ae0500cce754ccb682761dfea13a52", size = 852774, upload-time = "2025-12-31T19:59:42.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/6c/9e370934bfa30e889d12e61d0dae009991294f40055c238980066a7fbd83/pymdown_extensions-10.20.1.tar.gz", hash = "sha256:e7e39c865727338d434b55f1dd8da51febcffcaebd6e1a0b9c836243f660740a", size = 852860, upload-time = "2026-01-24T05:56:56.758Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/10/47caf89cbb52e5bb764696fd52a8c591a2f0e851a93270c05a17f36000b5/pymdown_extensions-10.20-py3-none-any.whl", hash = "sha256:ea9e62add865da80a271d00bfa1c0fa085b20d133fb3fc97afdc88e682f60b2f", size = 268733, upload-time = "2025-12-31T19:59:40.652Z" }, + { url = "https://files.pythonhosted.org/packages/40/6d/b6ee155462a0156b94312bdd82d2b92ea56e909740045a87ccb98bf52405/pymdown_extensions-10.20.1-py3-none-any.whl", hash = "sha256:24af7feacbca56504b313b7b418c4f5e1317bb5fea60f03d57be7fcc40912aa0", size = 268768, upload-time = "2026-01-24T05:56:54.537Z" }, ] [[package]] @@ -3917,6 +4046,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/20/0f2523b9e50a8052bc6a8b732dfc8568abbdc42010aef03a2d750bdab3b2/python_json_logger-3.3.0-py3-none-any.whl", hash = "sha256:dd980fae8cffb24c13caf6e158d3d61c0d6d22342f932cb6e9deedab3d35eec7", size = 15163, upload-time = "2025-03-07T07:08:25.627Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + [[package]] name = "pytz" version = "2025.2" @@ -3926,6 +4064,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pywinpty" version = "3.0.2" @@ -4275,15 +4435,15 @@ wheels = [ [[package]] name = "rich" -version = "14.2.0" +version = "14.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/84/4831f881aa6ff3c976f6d6809b58cdfa350593ffc0dc3c58f5f6586780fb/rich-14.3.1.tar.gz", hash = "sha256:b8c5f568a3a749f9290ec6bddedf835cec33696bfc1e48bcfecb276c7386e4b8", size = 230125, upload-time = "2026-01-24T21:40:44.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/87/2a/a1810c8627b9ec8c57ec5ec325d306701ae7be50235e8fd81266e002a3cc/rich-14.3.1-py3-none-any.whl", hash = "sha256:da750b1aebbff0b372557426fb3f35ba56de8ef954b3190315eb64076d6fb54e", size = 309952, upload-time = "2026-01-24T21:40:42.969Z" }, ] [[package]] @@ -4580,11 +4740,11 @@ wheels = [ [[package]] name = "setuptools" -version = "80.10.1" +version = "80.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/ff/f75651350db3cf2ef767371307eb163f3cc1ac03e16fdf3ac347607f7edb/setuptools-80.10.1.tar.gz", hash = "sha256:bf2e513eb8144c3298a3bd28ab1a5edb739131ec5c22e045ff93cd7f5319703a", size = 1229650, upload-time = "2026-01-21T09:42:03.061Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl", hash = "sha256:fc30c51cbcb8199a219c12cc9c281b5925a4978d212f84229c909636d9f6984e", size = 1099859, upload-time = "2026-01-21T09:42:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, ] [[package]] @@ -4647,6 +4807,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/d5/83c3eacdd6c3249fb5f8a0b5612ab10b661862e0df869951f45fd837448d/sqlfluff-3.5.0-py3-none-any.whl", hash = "sha256:6e5fb7a0c491676ded68912245fc0627e88f8b0e6290bd4b54a65ce735f69716", size = 921597, upload-time = "2025-10-18T19:33:05.839Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -4661,6 +4834,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + [[package]] name = "tblib" version = "3.2.2" @@ -4957,6 +5143,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + [[package]] name = "verspec" version = "0.1.0" @@ -5015,11 +5215,11 @@ wheels = [ [[package]] name = "wcwidth" -version = "0.3.1" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/6f/e1ea6dcb21da43d581284d8d5a715c2affb906aa3ed301f77f7f5ae0e7d5/wcwidth-0.3.1.tar.gz", hash = "sha256:5aedb626a9c0d941b990cfebda848d538d45c9493a3384d080aff809143bd3be", size = 233057, upload-time = "2026-01-22T22:08:25.231Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/6e/62daec357285b927e82263a81f3b4c1790215bc77c42530ce4a69d501a43/wcwidth-0.5.0.tar.gz", hash = "sha256:f89c103c949a693bf563377b2153082bf58e309919dfb7f27b04d862a0089333", size = 246585, upload-time = "2026-01-27T01:31:44.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/9c/9d951691bf1224772f6082d3b2e8c110edfd9622627908ad75bb0f691979/wcwidth-0.3.1-py3-none-any.whl", hash = "sha256:b2d355df3ec5d51bfc973a22fb4ea9a03b12fdcbf00d0abd22a2c78b12ccc177", size = 85746, upload-time = "2026-01-22T22:08:23.564Z" }, + { url = "https://files.pythonhosted.org/packages/f2/3e/45583b67c2ff08ad5a582d316fcb2f11d6cf0a50c7707ac09d212d25bc98/wcwidth-0.5.0-py3-none-any.whl", hash = "sha256:1efe1361b83b0ff7877b81ba57c8562c99cf812158b778988ce17ec061095695", size = 93772, upload-time = "2026-01-27T01:31:43.432Z" }, ] [[package]] From 0d6af2ddd8230f8fe8c4c89bc2941346fb4c3888 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Tue, 27 Jan 2026 09:56:57 -0500 Subject: [PATCH 02/15] Change output trace formatting --- docs/concepts/columns.md | 6 +- .../data_designer/config/column_configs.py | 10 +- .../src/data_designer/config/run_config.py | 4 + .../data_designer/config/utils/constants.py | 2 +- .../tests/config/test_columns.py | 2 +- .../generators/llm_completion.py | 10 +- .../src/data_designer/engine/models/facade.py | 39 +++-- .../test_llm_completion_generators.py | 21 +-- .../engine/dataset_builders/utils/test_dag.py | 2 +- .../tests/engine/models/test_facade.py | 2 +- .../mcp_demo_server.py | 23 ++- tests_e2e/tests/test_mcp_demo.py | 133 ++++++++++++++++-- 12 files changed, 211 insertions(+), 43 deletions(-) diff --git a/docs/concepts/columns.md b/docs/concepts/columns.md index 17123299..161d4487 100644 --- a/docs/concepts/columns.md +++ b/docs/concepts/columns.md @@ -38,8 +38,8 @@ LLM-Text columns generate natural language text: product descriptions, customer Use **Jinja2 templating** in prompts to reference other columns. Data Designer automatically manages dependencies and injects the referenced column values into the prompt. -!!! note "Reasoning Traces" - Models that support extended thinking (chain-of-thought reasoning) can capture their reasoning process in a separate `{column_name}__reasoning_trace` columnโ€“useful for understanding *why* the model generated specific content. This column is automatically added to the dataset if the model and service provider parse and return reasoning content. +!!! note "Generation Traces" + LLM columns can optionally capture a full message trace in a separate `{column_name}__trace` column. When enabled via `RunConfig(include_full_traces=True)`, this trace includes the ordered message history for the final generation attempt (system/user/assistant/tool calls/tool results), and may include model reasoning fields when the provider exposes them. ### ๐Ÿ’ป LLM-Code Columns @@ -147,6 +147,6 @@ You read this property for introspection but never set itโ€”always computed from ### `side_effect_columns` -Computed property listing columns created implicitly alongside the primary column. Currently, only LLM columns produce side effects (reasoning trace columns like `{name}__reasoning_trace` when models use extended thinking). +Computed property listing columns created implicitly alongside the primary column. Currently, only LLM columns produce side effects (trace columns like `{name}__trace` when `include_full_traces` is enabled). For detailed information on each column type, refer to the [column configuration code reference](../code_reference/column_configs.md). diff --git a/packages/data-designer-config/src/data_designer/config/column_configs.py b/packages/data-designer-config/src/data_designer/config/column_configs.py index 6549a950..ecc82169 100644 --- a/packages/data-designer-config/src/data_designer/config/column_configs.py +++ b/packages/data-designer-config/src/data_designer/config/column_configs.py @@ -15,7 +15,7 @@ from data_designer.config.models import ImageContext from data_designer.config.sampler_params import SamplerParamsT, SamplerType from data_designer.config.utils.code_lang import CodeLang -from data_designer.config.utils.constants import REASONING_TRACE_COLUMN_POSTFIX +from data_designer.config.utils.constants import TRACE_COLUMN_POSTFIX from data_designer.config.utils.misc import assert_valid_jinja2_template, extract_keywords_from_jinja2_template from data_designer.config.validator_params import ValidatorParamsT, ValidatorType @@ -190,14 +190,14 @@ def required_columns(self) -> list[str]: @property def side_effect_columns(self) -> list[str]: - """Returns the reasoning trace column, which may be generated alongside the main column. + """Returns the trace column, which may be generated alongside the main column. - Reasoning traces are only returned if the served model parses and returns reasoning content. + Full traces are only returned when enabled via `RunConfig.include_full_traces`. Returns: - List containing the reasoning trace column name. + List containing the trace column name. """ - return [f"{self.name}{REASONING_TRACE_COLUMN_POSTFIX}"] + return [f"{self.name}{TRACE_COLUMN_POSTFIX}"] @model_validator(mode="after") def assert_prompt_valid_jinja(self) -> Self: diff --git a/packages/data-designer-config/src/data_designer/config/run_config.py b/packages/data-designer-config/src/data_designer/config/run_config.py index a670b026..63de3267 100644 --- a/packages/data-designer-config/src/data_designer/config/run_config.py +++ b/packages/data-designer-config/src/data_designer/config/run_config.py @@ -33,6 +33,9 @@ class RunConfig(ConfigBase): max_conversation_correction_steps: Maximum number of correction rounds permitted within a single conversation when generation tasks call `ModelFacade.generate(...)`. Must be >= 0. Default is 0. + include_full_traces: If True, includes an additional `__trace` column for LLM generations, + containing the full ordered message history (system/user/assistant/tool) for the final + generation attempt. Default is False. """ disable_early_shutdown: bool = False @@ -42,6 +45,7 @@ class RunConfig(ConfigBase): non_inference_max_parallel_workers: int = Field(default=4, ge=1) max_conversation_restarts: int = Field(default=5, ge=0) max_conversation_correction_steps: int = Field(default=0, ge=0) + include_full_traces: bool = False @model_validator(mode="after") def normalize_shutdown_settings(self) -> Self: diff --git a/packages/data-designer-config/src/data_designer/config/utils/constants.py b/packages/data-designer-config/src/data_designer/config/utils/constants.py index 1a838f47..ab999354 100644 --- a/packages/data-designer-config/src/data_designer/config/utils/constants.py +++ b/packages/data-designer-config/src/data_designer/config/utils/constants.py @@ -166,7 +166,7 @@ class NordColor(Enum): MAX_TOP_P = 1.0 MIN_TOP_P = 0.0 MIN_MAX_TOKENS = 1 -REASONING_TRACE_COLUMN_POSTFIX = "__reasoning_trace" +TRACE_COLUMN_POSTFIX = "__trace" AVAILABLE_LOCALES = [ "ar_AA", diff --git a/packages/data-designer-config/tests/config/test_columns.py b/packages/data-designer-config/tests/config/test_columns.py index 96ead406..a32e8f77 100644 --- a/packages/data-designer-config/tests/config/test_columns.py +++ b/packages/data-designer-config/tests/config/test_columns.py @@ -85,7 +85,7 @@ def test_llm_text_column_config(): assert llm_text_column_config.system_prompt == stub_system_prompt assert llm_text_column_config.column_type == DataDesignerColumnType.LLM_TEXT assert set(llm_text_column_config.required_columns) == {"some_column", "some_other_column"} - assert llm_text_column_config.side_effect_columns == ["test_llm_text__reasoning_trace"] + assert llm_text_column_config.side_effect_columns == ["test_llm_text__trace"] # invalid prompt with pytest.raises( diff --git a/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py b/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py index 80b25261..1c7ec2f0 100644 --- a/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py +++ b/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py @@ -12,7 +12,7 @@ LLMStructuredColumnConfig, LLMTextColumnConfig, ) -from data_designer.config.utils.constants import REASONING_TRACE_COLUMN_POSTFIX +from data_designer.config.utils.constants import TRACE_COLUMN_POSTFIX from data_designer.engine.column_generators.generators.base import ColumnGeneratorWithModel, GenerationStrategy from data_designer.engine.column_generators.utils.prompt_renderer import ( PromptType, @@ -66,7 +66,8 @@ def generate(self, data: dict) -> dict: context.get_context(deserialized_record) for context in self.config.multi_modal_context ] - response, reasoning_trace = self.model.generate( + include_full_traces = self.resource_provider.run_config.include_full_traces + response, _, trace = self.model.generate( prompt=self.prompt_renderer.render( record=deserialized_record, prompt_template=self.config.prompt, @@ -80,6 +81,7 @@ def generate(self, data: dict) -> dict: parser=self.response_recipe.parse, multi_modal_context=multi_modal_context, tool_config=self.config.tool_config, + include_full_traces=include_full_traces, max_correction_steps=self.max_conversation_correction_steps, max_conversation_restarts=self.max_conversation_restarts, purpose=f"running generation for column '{self.config.name}'", @@ -88,8 +90,8 @@ def generate(self, data: dict) -> dict: serialized_output = self.response_recipe.serialize_output(response) data[self.config.name] = self._process_serialized_output(serialized_output) - if reasoning_trace: - data[self.config.name + REASONING_TRACE_COLUMN_POSTFIX] = reasoning_trace + if include_full_traces and trace is not None: + data[self.config.name + TRACE_COLUMN_POSTFIX] = trace return data diff --git a/packages/data-designer-engine/src/data_designer/engine/models/facade.py b/packages/data-designer-engine/src/data_designer/engine/models/facade.py index 0993136d..10c35e95 100644 --- a/packages/data-designer-engine/src/data_designer/engine/models/facade.py +++ b/packages/data-designer-engine/src/data_designer/engine/models/facade.py @@ -153,12 +153,13 @@ def generate( system_prompt: str | None = None, multi_modal_context: list[dict[str, Any]] | None = None, tool_config: MCPToolConfig | None = None, + include_full_traces: bool = False, max_correction_steps: int = 0, max_conversation_restarts: int = 0, skip_usage_tracking: bool = False, purpose: str | None = None, **kwargs, - ) -> tuple[Any, str | None]: + ) -> tuple[Any, str | None, list[dict[str, Any]] | None]: """Generate a parsed output with correction steps. This generation call will attempt to generate an output which is @@ -210,6 +211,7 @@ def generate( user_prompt=prompt, system_prompt=system_prompt, multi_modal_context=multi_modal_context ) messages = deepcopy(starting_messages) + trace_messages: list[dict[str, Any]] | None = deepcopy(starting_messages) if include_full_traces else None if tool_config is not None: tool_schemas = self._get_tool_schemas(tool_config) @@ -244,14 +246,29 @@ def generate( f"Exceeded maximum MCP tool calls ({tool_config.max_tool_calls}) for server " f"{tool_config.server_name!r}." ) - messages.append(self._build_assistant_tool_message(response, tool_calls)) - messages.extend(self._execute_tool_calls(tool_config, tool_calls)) + assistant_tool_message = self._build_assistant_tool_message(response, tool_calls) + tool_messages = self._execute_tool_calls(tool_config, tool_calls) + + messages.append(assistant_tool_message) + messages.extend(tool_messages) + + if trace_messages is not None: + assistant_trace_message = dict(assistant_tool_message) + if reasoning_trace: + assistant_trace_message["reasoning_content"] = reasoning_trace + trace_messages.append(assistant_trace_message) + trace_messages.extend(tool_messages) continue curr_num_correction_steps += 1 try: output_obj = parser(response) # type: ignore - if not a string will cause a ParserException below + if trace_messages is not None: + assistant_trace_message: dict[str, Any] = {"role": "assistant", "content": response} + if reasoning_trace: + assistant_trace_message["reasoning_content"] = reasoning_trace + trace_messages.append(assistant_trace_message) break except ParserException as exc: if max_correction_steps == 0 and max_conversation_restarts == 0: @@ -260,19 +277,25 @@ def generate( ) from exc if curr_num_correction_steps <= max_correction_steps: ## Add turns to loop-back errors for correction - messages += [ - str_to_message(content=response, role="assistant"), - str_to_message(content=str(get_exception_primary_cause(exc)), role="user"), - ] + assistant_message = str_to_message(content=response, role="assistant") + user_message = str_to_message(content=str(get_exception_primary_cause(exc)), role="user") + messages += [assistant_message, user_message] + if trace_messages is not None: + assistant_trace_message = dict(assistant_message) + if reasoning_trace: + assistant_trace_message["reasoning_content"] = reasoning_trace + trace_messages += [assistant_trace_message, user_message] elif curr_num_restarts < max_conversation_restarts: curr_num_correction_steps = 0 curr_num_restarts += 1 messages = deepcopy(starting_messages) + if trace_messages is not None: + trace_messages = deepcopy(starting_messages) else: raise GenerationValidationFailureError( f"Unsuccessful generation attempt despite {max_generation_attempts} attempts." ) from exc - return output_obj, reasoning_trace + return output_obj, reasoning_trace, trace_messages def _get_tool_schemas(self, tool_config: MCPToolConfig) -> list[dict[str, Any]]: if self._mcp_client_manager is None: diff --git a/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py b/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py index 14575b37..47be13d2 100644 --- a/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py +++ b/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py @@ -15,12 +15,12 @@ from data_designer.config.run_config import RunConfig from data_designer.engine.column_generators.generators.base import GenerationStrategy from data_designer.engine.column_generators.generators.llm_completion import ( - REASONING_TRACE_COLUMN_POSTFIX, LLMCodeCellGenerator, LLMJudgeCellGenerator, LLMStructuredCellGenerator, LLMTextCellGenerator, ) +from data_designer.config.utils.constants import TRACE_COLUMN_POSTFIX def _create_generator_with_mocks(config_class=LLMTextColumnConfig, **config_kwargs): @@ -72,7 +72,7 @@ def _setup_generate_mocks(mock_prompt_renderer, mock_response_recipe, mock_model """Helper function to setup common generate method mocks.""" mock_prompt_renderer.render.side_effect = ["rendered_user_prompt", "rendered_system_prompt"] mock_response_recipe.serialize_output.return_value = {"result": output} - mock_model.generate.return_value = ({"result": output}, reasoning) + mock_model.generate.return_value = ({"result": output}, reasoning, None) def test_generate_method(): @@ -88,16 +88,19 @@ def test_generate_method(): assert mock_model.generate.call_args[1]["max_correction_steps"] == 2 assert mock_model.generate.call_args[1]["max_conversation_restarts"] == 7 assert result["test_column"] == {"result": "test_output"} - assert "test_column" + REASONING_TRACE_COLUMN_POSTFIX not in result + assert "test_column" + TRACE_COLUMN_POSTFIX not in result - # Test with reasoning trace + # Test with full trace enabled mock_model.reset_mock() mock_prompt_renderer.reset_mock() - _setup_generate_mocks(mock_prompt_renderer, mock_response_recipe, mock_model, reasoning="reasoning_trace") + generator.resource_provider.run_config.include_full_traces = True + mock_prompt_renderer.render.side_effect = ["rendered_user_prompt", "rendered_system_prompt"] + mock_response_recipe.serialize_output.return_value = {"result": "test_output"} + mock_model.generate.return_value = ({"result": "test_output"}, None, [{"role": "user", "content": "x"}]) result = generator.generate(data) assert result["test_column"] == {"result": "test_output"} - assert result["test_column" + REASONING_TRACE_COLUMN_POSTFIX] == "reasoning_trace" + assert result["test_column" + TRACE_COLUMN_POSTFIX] == [{"role": "user", "content": "x"}] # Test multi-modal context is None call_args = mock_model.generate.call_args @@ -236,7 +239,7 @@ def test_generate_with_errors(error_type, error_message): if error_type == "serialization": mock_response_recipe.serialize_output.side_effect = Exception(error_message) - mock_model.generate.return_value = ({"result": "test_output"}, None) + mock_model.generate.return_value = ({"result": "test_output"}, None, None) elif error_type == "model": mock_model.generate.side_effect = Exception(error_message) elif error_type == "prompt_render": @@ -250,13 +253,12 @@ def test_generate_with_errors(error_type, error_message): def test_generate_with_complex_data(): generator, _, mock_model, _, _, mock_prompt_renderer, mock_response_recipe = _create_generator_with_mocks() - _setup_generate_mocks(mock_prompt_renderer, mock_response_recipe, mock_model, "complex_output", "complex_reasoning") + _setup_generate_mocks(mock_prompt_renderer, mock_response_recipe, mock_model, "complex_output", None) data = {"input": "test_input", "nested": {"key": "value"}, "list": [1, 2, 3], "json_string": '{"key": "value"}'} result = generator.generate(data) assert result["test_column"] == {"result": "complex_output"} - assert result["test_column" + REASONING_TRACE_COLUMN_POSTFIX] == "complex_reasoning" assert result["input"] == "test_input" assert result["nested"] == {"key": "value"} assert result["list"] == [1, 2, 3] @@ -356,6 +358,7 @@ def test_generator_output_type_handling( stub_resource_provider.model_registry.get_model.return_value.generate.return_value = ( {"result": "raw_output"}, None, + None, ) data = {"input": "test_input"} diff --git a/packages/data-designer-engine/tests/engine/dataset_builders/utils/test_dag.py b/packages/data-designer-engine/tests/engine/dataset_builders/utils/test_dag.py index 2834f97c..8328a8f9 100644 --- a/packages/data-designer-engine/tests/engine/dataset_builders/utils/test_dag.py +++ b/packages/data-designer-engine/tests/engine/dataset_builders/utils/test_dag.py @@ -62,7 +62,7 @@ def test_dag_construction(): column_configs.append( ExpressionColumnConfig( name="test_code_and_depends_on_validation_reasoning_traces", - expr="{{ test_code__reasoning_trace }} {{ depends_on_validation }}", + expr="{{ test_code__trace }} {{ depends_on_validation }}", ) ) column_configs.append( diff --git a/packages/data-designer-engine/tests/engine/models/test_facade.py b/packages/data-designer-engine/tests/engine/models/test_facade.py index 321768c7..85ccf74f 100644 --- a/packages/data-designer-engine/tests/engine/models/test_facade.py +++ b/packages/data-designer-engine/tests/engine/models/test_facade.py @@ -295,7 +295,7 @@ def _completion(self, messages: list[dict[str, Any]], **kwargs: Any) -> FakeResp ) with patch.object(ModelFacade, "completion", new=_completion): - result, _ = model.generate(prompt="question", parser=lambda x: x, tool_config=tool_config) + result, _, _ = model.generate(prompt="question", parser=lambda x: x, tool_config=tool_config) assert result == "final result" assert len(captured_calls) == 2 diff --git a/tests_e2e/src/data_designer_e2e_tests/mcp_demo_server.py b/tests_e2e/src/data_designer_e2e_tests/mcp_demo_server.py index 9c679ac8..52b68f84 100644 --- a/tests_e2e/src/data_designer_e2e_tests/mcp_demo_server.py +++ b/tests_e2e/src/data_designer_e2e_tests/mcp_demo_server.py @@ -3,23 +3,42 @@ from __future__ import annotations +import json +import os +from pathlib import Path + from mcp.server.fastmcp import FastMCP +LOG_ENV_VAR = "MCP_DEMO_LOG_PATH" + mcp_server = FastMCP("data-designer-e2e-mcp") +def _log_tool_call(tool_name: str, arguments: dict[str, object], result: object) -> None: + log_path = os.environ.get(LOG_ENV_VAR) + if not log_path: + return + payload = {"tool": tool_name, "arguments": arguments, "result": result} + with Path(log_path).open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, default=str) + "\n") + + @mcp_server.tool() def get_fact(topic: str) -> str: facts = { "mcp": "MCP lets models call tools over standardized transports.", "data-designer": "Data Designer generates structured synthetic datasets.", } - return facts.get(topic.lower(), f"{topic} is interesting.") + result = facts.get(topic.lower(), f"{topic} is interesting.") + _log_tool_call("get_fact", {"topic": topic}, result) + return result @mcp_server.tool() def add_numbers(a: int, b: int) -> int: - return a + b + result = a + b + _log_tool_call("add_numbers", {"a": a, "b": b}, result) + return result def main() -> None: diff --git a/tests_e2e/tests/test_mcp_demo.py b/tests_e2e/tests/test_mcp_demo.py index 7c020c45..6a40578f 100644 --- a/tests_e2e/tests/test_mcp_demo.py +++ b/tests_e2e/tests/test_mcp_demo.py @@ -3,25 +3,27 @@ from __future__ import annotations +import json import os import sys from pathlib import Path import pytest -from data_designer.essentials import ( +from data_designer.config import ( CategorySamplerParams, - DataDesigner, DataDesignerConfigBuilder, LLMTextColumnConfig, MCPServerConfig, MCPToolConfig, + RunConfig, SamplerColumnConfig, SamplerType, ) +from data_designer.interface import DataDesigner -def test_mcp_server_tool_usage_with_nvidia_text() -> None: +def test_mcp_server_tool_usage_with_nvidia_text(tmp_path: Path) -> None: if os.environ.get("NVIDIA_API_KEY") is None: pytest.skip("NVIDIA_API_KEY must be set to run the MCP demo with nvidia-text.") @@ -30,14 +32,20 @@ def test_mcp_server_tool_usage_with_nvidia_text() -> None: existing_pythonpath = os.environ.get("PYTHONPATH") pythonpath = str(e2e_src) if not existing_pythonpath else f"{e2e_src}{os.pathsep}{existing_pythonpath}" + log_path = tmp_path / "mcp_tool_calls.jsonl" + mcp_server = MCPServerConfig( name="demo-mcp", command=sys.executable, args=["-m", "data_designer_e2e_tests.mcp_demo_server"], - env={"PYTHONPATH": pythonpath}, + env={ + "PYTHONPATH": pythonpath, + "MCP_DEMO_LOG_PATH": str(log_path), + }, ) data_designer = DataDesigner(mcp_servers=[mcp_server]) + data_designer.set_run_config(RunConfig(include_full_traces=True)) config_builder = DataDesignerConfigBuilder() config_builder.add_column( @@ -50,13 +58,122 @@ def test_mcp_server_tool_usage_with_nvidia_text() -> None: config_builder.add_column( LLMTextColumnConfig( name="summary", - prompt="Use the get_fact tool to fetch a fact about {{ topic }}. Respond with one sentence.", - system_prompt="You must call the get_fact tool exactly once before answering.", + prompt="Call get_fact for {{ topic }}, then call add_numbers with a=2 and b=3.", + system_prompt=( + "You must call the tools in sequence: first get_fact, then add_numbers. " + "Do not answer before calling both tools." + ), model_alias="nvidia-text", - tool_config=MCPToolConfig(server_name="demo-mcp", tool_names=["get_fact"]), + tool_config=MCPToolConfig(server_name="demo-mcp", tool_names=["get_fact", "add_numbers"]), ) ) - preview = data_designer.preview(config_builder, num_records=2) + preview = data_designer.preview(config_builder, num_records=1) + + expected_facts = { + "MCP": "MCP lets models call tools over standardized transports.", + "Data-Designer": "Data Designer generates structured synthetic datasets.", + } + assert preview.dataset is not None assert "summary" in preview.dataset.columns + assert "summary__trace" in preview.dataset.columns + assert "topic" in preview.dataset.columns + + for _, row in preview.dataset.iterrows(): + summary = row["summary"] + assert summary is not None + assert str(summary).strip() + + trace = row["summary__trace"] + assert isinstance(trace, list) + assert trace + + tool_call_messages = [msg for msg in trace if isinstance(msg, dict) and msg.get("role") == "assistant" and msg.get("tool_calls")] + assert tool_call_messages + + tool_calls: list[dict[str, object]] = [] + tool_call_indices: dict[str, int] = {} + for msg_index, msg in enumerate(trace): + if not isinstance(msg, dict): + continue + if msg.get("role") != "assistant": + continue + for tool_call in msg.get("tool_calls") or []: + if not isinstance(tool_call, dict): + continue + tool_calls.append(tool_call) + function = tool_call.get("function") or {} + if isinstance(function, dict): + name = function.get("name") + if isinstance(name, str) and name not in tool_call_indices: + tool_call_indices[name] = msg_index + + assert tool_call_indices.get("get_fact") is not None + assert tool_call_indices.get("add_numbers") is not None + assert tool_call_indices["get_fact"] < tool_call_indices["add_numbers"] + + def _tool_call_to_name_args(tool_call: dict[str, object]) -> tuple[str | None, dict[str, object]]: + function = tool_call.get("function") + if not isinstance(function, dict): + return None, {} + name = function.get("name") + if not isinstance(name, str): + return None, {} + raw_args = function.get("arguments") + if isinstance(raw_args, str) and raw_args.strip(): + return name, json.loads(raw_args) + return name, {} + + expected_topic = str(row["topic"]) + observed_calls: dict[str, dict[str, object]] = {} + tool_call_ids: dict[str, str] = {} + for tool_call in tool_calls: + tool_call_id = tool_call.get("id") + if isinstance(tool_call_id, str): + name, args = _tool_call_to_name_args(tool_call) + if name is not None and name not in observed_calls: + observed_calls[name] = args + tool_call_ids[name] = tool_call_id + + assert observed_calls.get("get_fact") == {"topic": expected_topic} + assert observed_calls.get("add_numbers") == {"a": 2, "b": 3} + + tool_messages = [msg for msg in trace if isinstance(msg, dict) and msg.get("role") == "tool"] + tool_message_ids = {msg.get("tool_call_id") for msg in tool_messages} + assert tool_call_ids["get_fact"] in tool_message_ids + assert tool_call_ids["add_numbers"] in tool_message_ids + + assert log_path.exists() + log_entries = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() if line.strip()] + assert log_entries + + fact_entries = [entry for entry in log_entries if entry.get("tool") == "get_fact"] + assert fact_entries + + add_entries = [entry for entry in log_entries if entry.get("tool") == "add_numbers"] + assert add_entries + + first_fact_index = next(index for index, entry in enumerate(log_entries) if entry.get("tool") == "get_fact") + first_add_index = next(index for index, entry in enumerate(log_entries) if entry.get("tool") == "add_numbers") + assert first_fact_index < first_add_index + + observed_topics: set[str] = set() + for entry in fact_entries: + arguments = entry.get("arguments") or {} + topic = arguments.get("topic") + assert isinstance(topic, str) + assert topic in expected_facts + observed_topics.add(topic) + assert entry.get("result") == expected_facts[topic] + + for entry in add_entries: + arguments = entry.get("arguments") or {} + a_value = arguments.get("a") + b_value = arguments.get("b") + assert isinstance(a_value, int) + assert isinstance(b_value, int) + assert entry.get("result") == a_value + b_value + + generated_topics = set(preview.dataset["topic"].astype(str).tolist()) + assert generated_topics.issubset(observed_topics) From 0e31cf5d43af21b848324f68aa1b452294e1f567 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Tue, 27 Jan 2026 10:21:52 -0500 Subject: [PATCH 03/15] Better docstrings --- .../src/data_designer/config/mcp.py | 47 ++++++++++++++++++- tests_e2e/tests/test_mcp_demo.py | 4 +- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/data-designer-config/src/data_designer/config/mcp.py b/packages/data-designer-config/src/data_designer/config/mcp.py index ad5f0338..ef03d536 100644 --- a/packages/data-designer-config/src/data_designer/config/mcp.py +++ b/packages/data-designer-config/src/data_designer/config/mcp.py @@ -11,7 +11,35 @@ class MCPServerConfig(ConfigBase): - """Configuration for a single MCP server connection.""" + """Configuration for a single MCP server connection. + + MCP servers can be launched locally via stdio (command/args) or accessed remotely + over SSE (url), allowing the same configuration type to cover both deployment modes. + + Attributes: + name (str): Unique name used to reference this MCP server. + command (str | None): Executable to launch the MCP server via stdio transport. Defaults to None. + args (list[str]): Arguments passed to the MCP server executable. Defaults to []. + url (str | None): SSE endpoint URL for connecting to a remote MCP server. Defaults to None. + env (dict[str, str]): Environment variables passed to the MCP server subprocess. Defaults to {}. + + Examples: + Stdio (subprocess) transport: + + >>> MCPServerConfig( + ... name="demo-mcp", + ... command="python", + ... args=["-m", "data_designer_e2e_tests.mcp_demo_server"], + ... env={"PYTHONPATH": "/path/to/project"}, + ... ) + + SSE (HTTP) transport: + + >>> MCPServerConfig( + ... name="remote-mcp", + ... url="http://localhost:8080/sse", + ... ) + """ name: str command: str | None = None @@ -21,6 +49,15 @@ class MCPServerConfig(ConfigBase): @model_validator(mode="after") def validate_transport(self) -> Self: + """Validate that exactly one transport is configured. + + Returns: + The validated MCPServerConfig instance. + + Raises: + InvalidConfigError: If both or neither of `command` and `url` are provided, + or if `args` are supplied for an SSE-based server. + """ if bool(self.command) == bool(self.url): raise InvalidConfigError("MCP server config must define exactly one of 'command' or 'url'.") if self.url and self.args: @@ -29,7 +66,13 @@ def validate_transport(self) -> Self: class MCPToolConfig(ConfigBase): - """Configuration for permitting MCP tools on an LLM column.""" + """Configuration for permitting MCP tools on an LLM column. + + Attributes: + server_name (str): Name of the MCP server to use for tool calls. + tool_names (list[str] | None): Optional allowlist of tool names. If None, all tools are allowed. Defaults to None. + max_tool_calls (int): Maximum number of tool calls permitted in a single generation. Defaults to 5. + """ server_name: str tool_names: list[str] | None = None diff --git a/tests_e2e/tests/test_mcp_demo.py b/tests_e2e/tests/test_mcp_demo.py index 6a40578f..f4e8ccc9 100644 --- a/tests_e2e/tests/test_mcp_demo.py +++ b/tests_e2e/tests/test_mcp_demo.py @@ -89,7 +89,9 @@ def test_mcp_server_tool_usage_with_nvidia_text(tmp_path: Path) -> None: assert isinstance(trace, list) assert trace - tool_call_messages = [msg for msg in trace if isinstance(msg, dict) and msg.get("role") == "assistant" and msg.get("tool_calls")] + tool_call_messages = [ + msg for msg in trace if isinstance(msg, dict) and msg.get("role") == "assistant" and msg.get("tool_calls") + ] assert tool_call_messages tool_calls: list[dict[str, object]] = [] From 7e9a6cd57b4857cd90c28c40f14824c074075cc9 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Tue, 27 Jan 2026 10:28:13 -0500 Subject: [PATCH 04/15] Add documentation --- docs/concepts/tool_use_and_mcp.md | 130 ++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 131 insertions(+) create mode 100644 docs/concepts/tool_use_and_mcp.md diff --git a/docs/concepts/tool_use_and_mcp.md b/docs/concepts/tool_use_and_mcp.md new file mode 100644 index 00000000..979fa128 --- /dev/null +++ b/docs/concepts/tool_use_and_mcp.md @@ -0,0 +1,130 @@ +# Tool Use & MCP + +Tool use lets LLM columns call external tools during generation (e.g., lookups, calculations, retrieval, domain services). Data Designer supports tool use via the **Model Context Protocol (MCP)**, which standardizes how tools are discovered and invoked. + +!!! note "Two deployment modes" + MCP servers can be configured in two ways: + + - **Local stdio**: run a server as a subprocess (configured via `command` + `args`) + - **Remote SSE**: connect to a server over HTTP Server-Sent Events (configured via `url`) + +## Overview + +At a high level: + +- You configure one or more MCP servers on the `DataDesigner` instance. +- You enable tools per LLM column via `MCPToolConfig`. +- During generation, the model can request tool calls; Data Designer executes them and feeds tool outputs back to the model until it produces a final answer. + +## Configuring MCP servers + +Use `MCPServerConfig` to define how to connect to each server. + +### Local stdio (subprocess) server + +```python +import data_designer.config as dd +from data_designer.interface import DataDesigner + +mcp_server = dd.MCPServerConfig( + name="demo-mcp", + command="python", + args=["-m", "my_mcp_server_module"], + env={"MY_SERVICE_TOKEN": "..."}, # Optional +) + +data_designer = DataDesigner(mcp_servers=[mcp_server]) +``` + +### Remote SSE server + +```python +import data_designer.config as dd +from data_designer.interface import DataDesigner + +mcp_server = dd.MCPServerConfig( + name="remote-mcp", + url="http://localhost:8080/sse", +) + +data_designer = DataDesigner(mcp_servers=[mcp_server]) +``` + +## Enabling tools on an LLM column + +Tool permissions are configured per column using `MCPToolConfig`. + +```python +import data_designer.config as dd + +tool_config = dd.MCPToolConfig( + server_name="demo-mcp", + tool_names=["get_fact", "add_numbers"], # None = allow all tools on that server + max_tool_calls=5, +) +``` + +Then attach it to an LLM column: + +```python +import data_designer.config as dd + +builder = dd.DataDesignerConfigBuilder() + +builder.add_column( + dd.LLMTextColumnConfig( + name="answer", + prompt="Use tools as needed to answer: {{ question }}", + model_alias="nvidia-text", + tool_config=tool_config, + ) +) +``` + +!!! tip "Make tool usage explicit in prompts" + If you want deterministic tool behavior (for testing or pipelines), explicitly instruct the model which tools to call and in what order. + +## Full message traces (`*__trace`) + +When tool use is enabled, many users want to inspect the full interaction history: prompts, tool calls, tool results, and the final answer. Data Designer supports this via **optional trace capture**. + +### Enabling trace capture + +Set `RunConfig(include_full_traces=True)` on your `DataDesigner` instance: + +```python +import data_designer.config as dd +from data_designer.interface import DataDesigner + +data_designer = DataDesigner(mcp_servers=[...]) +data_designer.set_run_config(dd.RunConfig(include_full_traces=True)) +``` + +### What is stored + +When enabled, LLM columns produce an additional side-effect column named: + +- `{column_name}__trace` + +This value is a `list[dict]` containing the ordered message history for the **final generation attempt**, including: + +- system + user messages (the rendered prompts) +- assistant messages that include `tool_calls` +- tool messages (`role="tool"`, `tool_call_id`, `content`) +- the final assistant message + +If the provider exposes it, assistant messages may also include a `reasoning_content` field. + +!!! note "Replacement for `__reasoning_trace`" + The previous `{column_name}__reasoning_trace` side-effect column has been replaced by `{column_name}__trace`, which captures the entire conversation rather than only reasoning. + +## Safety and limits + +- **Tool allowlists**: Restrict which tools can be used via `MCPToolConfig(tool_names=[...])`. +- **Tool call budgets**: Use `MCPToolConfig(max_tool_calls=...)` to prevent runaway loops. +- **Provider support**: Tool calling behavior depends on model/provider capability and prompt design. + +## See Also + +- [Columns](columns.md): Overview of LLM columns and side effect columns +- [Run Config](../code_reference/run_config.md): Runtime options including `include_full_traces` diff --git a/mkdocs.yml b/mkdocs.yml index 0107566b..601e4fb8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -20,6 +20,7 @@ nav: - Validators: concepts/validators.md - Processors: concepts/processors.md - Person Sampling: concepts/person_sampling.md + - Tool Use & MCP: concepts/tool_use_and_mcp.md - Tutorials: - Overview: notebooks/README.md - The Basics: notebooks/1-the-basics.ipynb From d13b6b9287d1b814ac9a7b97ba885461caae9355 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Tue, 27 Jan 2026 12:39:08 -0500 Subject: [PATCH 05/15] Add example --- .../assets/recipes/mcp_and_tooluse/.gitignore | 4 + .../recipes/mcp_and_tooluse/dnd_rules_qa.py | 408 ++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 docs/assets/recipes/mcp_and_tooluse/.gitignore create mode 100644 docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py diff --git a/docs/assets/recipes/mcp_and_tooluse/.gitignore b/docs/assets/recipes/mcp_and_tooluse/.gitignore new file mode 100644 index 00000000..a3745ece --- /dev/null +++ b/docs/assets/recipes/mcp_and_tooluse/.gitignore @@ -0,0 +1,4 @@ +artifacts/ +downloads/ +docs_mcp_store/ +docs_mcp_config.yaml \ No newline at end of file diff --git a/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py b/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py new file mode 100644 index 00000000..6ebb1c4b --- /dev/null +++ b/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py @@ -0,0 +1,408 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""MCP + Tool Use Recipe (D&D Q&A) + +This recipe demonstrates an end-to-end MCP tool-calling workflow: + +1) Download the Dungeons & Dragons v1 rules PDF. +2) Index it with `docs-mcp-server` (MCP search). +3) Use Data Designer tool calls (`search_docs`) to generate grounded Q&A pairs. + +Prerequisites: +- Node.js 20+ (for `npx @arabold/docs-mcp-server`) +- `OPENAI_API_KEY` for docs-mcp-server embeddings +- `NVIDIA_API_KEY` if using `--model-alias nvidia-text` (default) + +Run: + uv run docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py + +Common flags: + # First run: scrape the PDF (default) + uv run docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py --num-records 3 + + # Subsequent runs: reuse the indexed corpus + uv run docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py --skip-scrape --num-records 5 + + # Customize embeddings + uv run docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py \ + --embedding-model text-embedding-3-small \ + --embedding-api-key-env OPENAI_API_KEY + + # Increase PDF size limit if needed (default: 40MB) + uv run docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py --document-max-size-mb 60 + +Notes: +- The script writes a docs-mcp config file to `docs_mcp_store/docs_mcp_config.yaml` to raise the PDF size limit. +- Downloads and artifacts are stored locally under this directory. +- If you want to use a different LLM provider for generation, set `--model-alias` and the + corresponding API key for that provider. +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import socket +import subprocess +import time +from pathlib import Path +from urllib.request import urlretrieve + +from pydantic import BaseModel, Field + +import data_designer.config as dd +from data_designer.interface import DataDesigner, DatasetCreationResults + +PDF_URL = "https://idiscepolidellamanticora.wordpress.com/wp-content/uploads/2012/09/tsr2010-players-handbook.pdf" +PDF_FILENAME = "tsr2010-players-handbook.pdf" + +DOCS_MCP_HOST = "127.0.0.1" +DOCS_MCP_PORT = 6280 +DOCS_MCP_SERVER_NAME = "docs-mcp-server" + +DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" + +DEFAULT_LIBRARY = "dnd-basic-rules" +DEFAULT_LIBRARY_VERSION = "1" +DEFAULT_DOCS_MCP_MAX_SIZE_MB = 40 + + +class DndQAPair(BaseModel): + question: str = Field(..., description="A question grounded in the D&D rules text.") + answer: str = Field(..., description="A concise answer grounded in the supporting passage.") + supporting_passage: str = Field(..., description="A short excerpt (2-4 sentences) copied from the search result.") + source_url: str = Field(..., description="The URL for the supporting passage.") + + +def resolve_embedding_provider(embedding_model: str) -> str: + if ":" in embedding_model: + return embedding_model.split(":", 1)[0] + return "openai" + + +def build_docs_mcp_env( + store_path: Path, + embedding_model: str, + embedding_api_base: str | None, + embedding_api_key_env: str, +) -> dict[str, str]: + env = os.environ.copy() + env["DOCS_MCP_STORE_PATH"] = str(store_path) + env["DOCS_MCP_TELEMETRY"] = "false" + env["DOCS_MCP_EMBEDDING_MODEL"] = embedding_model + + provider = resolve_embedding_provider(embedding_model) + if provider == "openai": + api_key = os.environ.get(embedding_api_key_env) + if not api_key: + raise RuntimeError( + f"{embedding_api_key_env} must be set to use embeddings with provider 'openai'." + ) + env["OPENAI_API_KEY"] = api_key + if embedding_api_base: + env["OPENAI_API_BASE"] = embedding_api_base + return env + + +def download_pdf(pdf_url: str, destination_dir: Path) -> Path: + destination_dir.mkdir(parents=True, exist_ok=True) + pdf_path = destination_dir / PDF_FILENAME + if not pdf_path.exists() or pdf_path.stat().st_size == 0: + urlretrieve(pdf_url, pdf_path) + return pdf_path + + +def scrape_pdf_with_docs_mcp( + npx_path: str, + env: dict[str, str], + library: str, + version: str | None, + pdf_uri: str, + config_path: Path, +) -> None: + command = [ + npx_path, + "--yes", + "@arabold/docs-mcp-server@latest", + "scrape", + library, + pdf_uri, + "--max-pages", + "1", + "--max-depth", + "1", + "--scope", + "subpages", + ] + if version: + command.extend(["--version", version]) + command.extend(["--config", str(config_path)]) + subprocess.run(command, env=env, check=True) + + +def start_docs_mcp_server( + npx_path: str, + env: dict[str, str], + host: str, + port: int, + config_path: Path, +) -> subprocess.Popen[str]: + command = [ + npx_path, + "--yes", + "@arabold/docs-mcp-server@latest", + "--protocol", + "http", + "--host", + host, + "--port", + str(port), + "--config", + str(config_path), + ] + return subprocess.Popen(command, env=env) + + +def wait_for_port(host: str, port: int, timeout_sec: float) -> None: + deadline = time.monotonic() + timeout_sec + while time.monotonic() < deadline: + try: + with socket.create_connection((host, port), timeout=1.0): + return + except OSError: + time.sleep(0.5) + raise TimeoutError(f"docs-mcp-server did not start on {host}:{port} within {timeout_sec} seconds.") + + +def stop_process(process: subprocess.Popen[str]) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +def write_docs_mcp_config(store_path: Path, max_document_size_mb: int) -> Path: + config_path = store_path / "docs_mcp_config.yaml" + max_size_bytes = max_document_size_mb * 1024 * 1024 + config_contents = f"document:\n maxSize: {max_size_bytes}\n" + config_path.write_text(config_contents, encoding="utf-8") + return config_path + + +def build_config( + model_alias: str, + server_name: str, + library: str, + version: str | None, +) -> dd.DataDesignerConfigBuilder: + config_builder = dd.DataDesignerConfigBuilder() + config_builder.add_column( + dd.SamplerColumnConfig( + name="topic", + sampler_type=dd.SamplerType.CATEGORY, + params=dd.CategorySamplerParams( + values=[ + "ability scores", + "saving throws", + "combat rounds", + "spell casting", + "equipment and encumbrance", + "hit points and healing", + "alignment", + "exploration turns", + ] + ), + ) + ) + + tool_config = dd.MCPToolConfig(server_name=server_name, tool_names=["search_docs"], max_tool_calls=5) + + prompt_lines = [ + "You are generating Q&A pairs grounded in the Dungeons & Dragons Basic Rules (v1).", + "First, call the MCP tool `search_docs` to retrieve relevant rules text.", + "Use the tool with:", + f'- library: "{library}"', + ] + if version: + prompt_lines.append(f'- version: "{version}"') + prompt_lines.extend( + [ + '- query: "Dungeons & Dragons basic rules {{ topic }}"', + "- limit: 3", + "", + "If the tool returns no results, broaden the query and try again.", + "Then choose one result and create a grounded Q&A pair.", + "", + "Return JSON with keys: question, answer, supporting_passage, source_url.", + "The supporting_passage must be a 2-4 sentence excerpt copied from the tool result.", + ] + ) + + config_builder.add_column( + dd.LLMStructuredColumnConfig( + name="qa_pair", + model_alias=model_alias, + prompt="\n".join(prompt_lines), + system_prompt=( + "You must call the search_docs tool before answering. " + "Do not use outside knowledge; only use tool results." + ), + output_format=DndQAPair, + tool_config=tool_config, + ) + ) + + config_builder.add_column( + dd.ExpressionColumnConfig( + name="question", + expr="{{ qa_pair.question }}", + ) + ) + config_builder.add_column( + dd.ExpressionColumnConfig( + name="answer", + expr="{{ qa_pair.answer }}", + ) + ) + config_builder.add_column( + dd.ExpressionColumnConfig( + name="supporting_passage", + expr="{{ qa_pair.supporting_passage }}", + ) + ) + config_builder.add_column( + dd.ExpressionColumnConfig( + name="source_url", + expr="{{ qa_pair.source_url }}", + ) + ) + return config_builder + + +def create_dataset( + config_builder: dd.DataDesignerConfigBuilder, + num_records: int, + artifact_path: Path | str | None, + mcp_server: dd.MCPServerConfig, + dataset_name: str, +) -> DatasetCreationResults: + data_designer = DataDesigner(artifact_path=artifact_path, mcp_servers=[mcp_server]) + data_designer.set_run_config(dd.RunConfig(include_full_traces=True)) + return data_designer.create(config_builder, num_records=num_records, dataset_name=dataset_name) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate D&D Q&A pairs using MCP tool calls.") + parser.add_argument("--model-alias", type=str, default="nvidia-text") + parser.add_argument("--num-records", type=int, default=5) + parser.add_argument("--artifact-path", type=str, default=None) + parser.add_argument("--dataset-name", type=str, default="dnd_rules_qa") + parser.add_argument("--library", type=str, default=DEFAULT_LIBRARY) + parser.add_argument("--version", type=str, default=DEFAULT_LIBRARY_VERSION) + parser.add_argument("--skip-scrape", action="store_true") + parser.add_argument("--port", type=int, default=DOCS_MCP_PORT) + parser.add_argument( + "--document-max-size-mb", + type=int, + default=DEFAULT_DOCS_MCP_MAX_SIZE_MB, + help="Docs MCP max document size for PDFs (default: 40MB).", + ) + parser.add_argument( + "--embedding-model", + type=str, + default="text-embedding-3-small", + help="Docs MCP embedding model (default: text-embedding-3-small).", + ) + parser.add_argument( + "--embedding-api-base", + type=str, + default=None, + help=f"Optional OpenAI-compatible base URL (omit to use {DEFAULT_OPENAI_BASE_URL}).", + ) + parser.add_argument( + "--embedding-api-key-env", + type=str, + default="OPENAI_API_KEY", + help="Env var name holding the embeddings API key (default: OPENAI_API_KEY).", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + if os.environ.get("NVIDIA_API_KEY") is None and args.model_alias.startswith("nvidia"): + raise RuntimeError("NVIDIA_API_KEY must be set when using NVIDIA model aliases.") + + npx_path = shutil.which("npx") + if not npx_path: + raise RuntimeError("npx was not found. Install Node.js 20+ and ensure npx is on PATH.") + + base_dir = Path(__file__).resolve().parent + downloads_dir = base_dir / "downloads" + store_path = base_dir / "docs_mcp_store" + store_path.mkdir(parents=True, exist_ok=True) + config_path = write_docs_mcp_config(store_path, args.document_max_size_mb) + + pdf_path = download_pdf(PDF_URL, downloads_dir) + pdf_uri = pdf_path.resolve().as_uri() + + docs_mcp_env = build_docs_mcp_env( + store_path=store_path, + embedding_model=args.embedding_model, + embedding_api_base=args.embedding_api_base, + embedding_api_key_env=args.embedding_api_key_env, + ) + + if not args.skip_scrape: + scrape_pdf_with_docs_mcp( + npx_path=npx_path, + env=docs_mcp_env, + library=args.library, + version=args.version, + pdf_uri=pdf_uri, + config_path=config_path, + ) + + server_process = start_docs_mcp_server( + npx_path=npx_path, + env=docs_mcp_env, + host=DOCS_MCP_HOST, + port=args.port, + config_path=config_path, + ) + + try: + wait_for_port(DOCS_MCP_HOST, args.port, timeout_sec=60) + mcp_server = dd.MCPServerConfig( + name=DOCS_MCP_SERVER_NAME, + url=f"http://{DOCS_MCP_HOST}:{args.port}/sse", + ) + + config_builder = build_config( + model_alias=args.model_alias, + server_name=DOCS_MCP_SERVER_NAME, + library=args.library, + version=args.version, + ) + results = create_dataset( + config_builder=config_builder, + num_records=args.num_records, + artifact_path=args.artifact_path or base_dir / "artifacts", + mcp_server=mcp_server, + dataset_name=args.dataset_name, + ) + + print(f"Dataset saved to: {results.artifact_storage.final_dataset_path}") + finally: + stop_process(server_process) + + +if __name__ == "__main__": + main() From ad2089540345ad9723681a8d4269b19e6b005eb2 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Tue, 27 Jan 2026 14:14:26 -0500 Subject: [PATCH 06/15] Update with tracking and the working dnd example --- .../recipes/mcp_and_tooluse/dnd_rules_qa.py | 99 ++++++++++++++----- docs/concepts/tool_use_and_mcp.md | 2 + .../src/data_designer/config/mcp.py | 2 + .../dataset_builders/column_wise_builder.py | 66 ++++++++++++- .../src/data_designer/engine/mcp/manager.py | 88 ++++++++++++++--- .../src/data_designer/engine/models/facade.py | 7 +- .../tests/engine/models/test_facade.py | 13 ++- 7 files changed, 233 insertions(+), 44 deletions(-) diff --git a/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py b/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py index 6ebb1c4b..78c6b553 100644 --- a/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py +++ b/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py @@ -42,6 +42,7 @@ from __future__ import annotations import argparse +import json import os import shutil import socket @@ -53,7 +54,8 @@ from pydantic import BaseModel, Field import data_designer.config as dd -from data_designer.interface import DataDesigner, DatasetCreationResults +from data_designer.config.preview_results import PreviewResults +from data_designer.interface import DataDesigner PDF_URL = "https://idiscepolidellamanticora.wordpress.com/wp-content/uploads/2012/09/tsr2010-players-handbook.pdf" PDF_FILENAME = "tsr2010-players-handbook.pdf" @@ -76,6 +78,13 @@ class DndQAPair(BaseModel): source_url: str = Field(..., description="The URL for the supporting passage.") +class DndTopicList(BaseModel): + topics: list[str] = Field( + ..., + description="High-level topics from the D&D rules table of contents (up to 10).", + ) + + def resolve_embedding_provider(embedding_model: str) -> str: if ":" in embedding_model: return embedding_model.split(":", 1)[0] @@ -204,24 +213,58 @@ def build_config( config_builder = dd.DataDesignerConfigBuilder() config_builder.add_column( dd.SamplerColumnConfig( - name="topic", - sampler_type=dd.SamplerType.CATEGORY, - params=dd.CategorySamplerParams( - values=[ - "ability scores", - "saving throws", - "combat rounds", - "spell casting", - "equipment and encumbrance", - "hit points and healing", - "alignment", - "exploration turns", - ] + name="seed_id", + sampler_type=dd.SamplerType.UUID, + params=dd.UUIDSamplerParams(), + drop=True, + ) + ) + + tool_config = dd.MCPToolConfig( + server_name=server_name, + tool_names=["search_docs"], + max_tool_calls=5, + timeout_sec=15.0, + ) + + topic_prompt_lines = [ + "You are extracting high-level topics from the Dungeons & Dragons Basic Rules (v1).", + "First, call the MCP tool `search_docs` to find the table of contents or overview sections.", + "Use the tool with:", + f'- library: "{library}"', + ] + if version: + topic_prompt_lines.append(f'- version: "{version}"') + # topic_prompt_lines.extend( + # [ + # '- query: "Dungeons & Dragons basic rules table of contents"', + # "- limit: 5", + # "", + # "From the tool results, list up to 10 high-level topics.", + # "Return JSON with key: topics (a list of strings).", + # ] + # ) + + config_builder.add_column( + dd.LLMStructuredColumnConfig( + name="topic_candidates", + model_alias=model_alias, + prompt="\n".join(topic_prompt_lines), + system_prompt=( + "You must call the search_docs tool before answering. " + "Do not use outside knowledge; only use tool results." ), + output_format=DndTopicList, + tool_config=tool_config, ) ) - tool_config = dd.MCPToolConfig(server_name=server_name, tool_names=["search_docs"], max_tool_calls=5) + config_builder.add_column( + dd.ExpressionColumnConfig( + name="topic", + expr="{{ topic_candidates.topics | random }}", + ) + ) prompt_lines = [ "You are generating Q&A pairs grounded in the Dungeons & Dragons Basic Rules (v1).", @@ -285,24 +328,32 @@ def build_config( return config_builder -def create_dataset( +def generate_preview( config_builder: dd.DataDesignerConfigBuilder, num_records: int, artifact_path: Path | str | None, mcp_server: dd.MCPServerConfig, - dataset_name: str, -) -> DatasetCreationResults: +) -> PreviewResults: data_designer = DataDesigner(artifact_path=artifact_path, mcp_servers=[mcp_server]) data_designer.set_run_config(dd.RunConfig(include_full_traces=True)) - return data_designer.create(config_builder, num_records=num_records, dataset_name=dataset_name) + return data_designer.preview(config_builder, num_records=num_records) + + +def display_preview_record(preview_results: PreviewResults) -> None: + dataset = preview_results.dataset + if dataset is None or dataset.empty: + print("No preview records generated.") + return + record = dataset.iloc[0].to_dict() + print("Sample record:") + print(json.dumps(record, indent=2, default=str)) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Generate D&D Q&A pairs using MCP tool calls.") parser.add_argument("--model-alias", type=str, default="nvidia-text") - parser.add_argument("--num-records", type=int, default=5) + parser.add_argument("--num-records", type=int, default=4) parser.add_argument("--artifact-path", type=str, default=None) - parser.add_argument("--dataset-name", type=str, default="dnd_rules_qa") parser.add_argument("--library", type=str, default=DEFAULT_LIBRARY) parser.add_argument("--version", type=str, default=DEFAULT_LIBRARY_VERSION) parser.add_argument("--skip-scrape", action="store_true") @@ -391,15 +442,13 @@ def main() -> None: library=args.library, version=args.version, ) - results = create_dataset( + preview_results = generate_preview( config_builder=config_builder, num_records=args.num_records, artifact_path=args.artifact_path or base_dir / "artifacts", mcp_server=mcp_server, - dataset_name=args.dataset_name, ) - - print(f"Dataset saved to: {results.artifact_storage.final_dataset_path}") + display_preview_record(preview_results) finally: stop_process(server_process) diff --git a/docs/concepts/tool_use_and_mcp.md b/docs/concepts/tool_use_and_mcp.md index 979fa128..03cbcde9 100644 --- a/docs/concepts/tool_use_and_mcp.md +++ b/docs/concepts/tool_use_and_mcp.md @@ -61,6 +61,7 @@ tool_config = dd.MCPToolConfig( server_name="demo-mcp", tool_names=["get_fact", "add_numbers"], # None = allow all tools on that server max_tool_calls=5, + timeout_sec=45.0, ) ``` @@ -122,6 +123,7 @@ If the provider exposes it, assistant messages may also include a `reasoning_con - **Tool allowlists**: Restrict which tools can be used via `MCPToolConfig(tool_names=[...])`. - **Tool call budgets**: Use `MCPToolConfig(max_tool_calls=...)` to prevent runaway loops. +- **Tool timeouts**: Use `MCPToolConfig(timeout_sec=...)` to cap MCP call latency. - **Provider support**: Tool calling behavior depends on model/provider capability and prompt design. ## See Also diff --git a/packages/data-designer-config/src/data_designer/config/mcp.py b/packages/data-designer-config/src/data_designer/config/mcp.py index ef03d536..920e51d3 100644 --- a/packages/data-designer-config/src/data_designer/config/mcp.py +++ b/packages/data-designer-config/src/data_designer/config/mcp.py @@ -72,8 +72,10 @@ class MCPToolConfig(ConfigBase): server_name (str): Name of the MCP server to use for tool calls. tool_names (list[str] | None): Optional allowlist of tool names. If None, all tools are allowed. Defaults to None. max_tool_calls (int): Maximum number of tool calls permitted in a single generation. Defaults to 5. + timeout_sec (float | None): Timeout in seconds for MCP tool calls. Defaults to None (no timeout). """ server_name: str tool_names: list[str] | None = None max_tool_calls: int = Field(default=5, ge=1) + timeout_sec: float | None = Field(default=None, gt=0) diff --git a/packages/data-designer-engine/src/data_designer/engine/dataset_builders/column_wise_builder.py b/packages/data-designer-engine/src/data_designer/engine/dataset_builders/column_wise_builder.py index 78741943..a7db69d5 100644 --- a/packages/data-designer-engine/src/data_designer/engine/dataset_builders/column_wise_builder.py +++ b/packages/data-designer-engine/src/data_designer/engine/dataset_builders/column_wise_builder.py @@ -10,6 +10,7 @@ import time import uuid from pathlib import Path +from threading import Lock from typing import TYPE_CHECKING, Callable from data_designer.config.column_types import ColumnConfigT @@ -221,16 +222,74 @@ def _fan_out_with_threads(self, generator: ColumnGeneratorWithModelRegistry, max "generator so concurrency through threads is not supported." ) + total_records = self.batch_manager.num_records_batch + progress_interval = max(1, (total_records + 9) // 10) if total_records > 0 else 1 + progress_label = f"{generator.config.column_type} column '{generator.config.name}'" + progress_lock = Lock() + start_time = time.perf_counter() + completed = 0 + success = 0 + failed = 0 + next_log_at = progress_interval + + def _log_progress(snapshot: tuple[int, int, int]) -> None: + completed_count, success_count, failed_count = snapshot + elapsed = time.perf_counter() - start_time + rate = completed_count / elapsed if elapsed > 0 else 0.0 + remaining = max(0, total_records - completed_count) + eta = f"{(remaining / rate):.1f}s" if rate > 0 else "unknown" + percent = (completed_count / total_records) * 100 if total_records else 100.0 + logger.info( + "๐Ÿ“ˆ %s progress: %d/%d (%.0f%%) complete, %d ok, %d failed, %.2f rec/s, eta %s", + progress_label, + completed_count, + total_records, + percent, + success_count, + failed_count, + rate, + eta, + ) + + def _update_progress(*, success_item: bool) -> None: + nonlocal completed, success, failed, next_log_at + snapshot: tuple[int, int, int] | None = None + with progress_lock: + completed += 1 + if success_item: + success += 1 + else: + failed += 1 + if completed >= next_log_at: + snapshot = (completed, success, failed) + while next_log_at <= completed: + next_log_at += progress_interval + if snapshot is not None: + _log_progress(snapshot) + + def _result_callback(result: dict, *, context: dict | None = None) -> None: + self._worker_result_callback(result, context=context) + _update_progress(success_item=True) + + def _error_callback(exc: Exception, *, context: dict | None = None) -> None: + self._worker_error_callback(exc, context=context) + _update_progress(success_item=False) + logger.info( f"๐Ÿ™ Processing {generator.config.column_type} column '{generator.config.name}' " f"with {max_workers} concurrent workers" ) + logger.info( + "๐Ÿงญ %s will report progress every %d record(s).", + progress_label, + progress_interval, + ) settings = self._resource_provider.run_config with ConcurrentThreadExecutor( max_workers=max_workers, column_name=generator.config.name, - result_callback=self._worker_result_callback, - error_callback=self._worker_error_callback, + result_callback=_result_callback, + error_callback=_error_callback, shutdown_error_rate=settings.shutdown_error_rate, shutdown_error_window=settings.shutdown_error_window, disable_early_shutdown=settings.disable_early_shutdown, @@ -238,6 +297,9 @@ def _fan_out_with_threads(self, generator: ColumnGeneratorWithModelRegistry, max for i, record in self.batch_manager.iter_current_batch(): executor.submit(lambda record: generator.generate(record), record, context={"index": i}) + if total_records > 0 and completed < total_records: + _log_progress((completed, success, failed)) + if len(self._records_to_drop) > 0: self.batch_manager.drop_records(self._records_to_drop) self._records_to_drop.clear() diff --git a/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py b/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py index 39ba7396..b367ecc7 100644 --- a/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py +++ b/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py @@ -5,13 +5,19 @@ import asyncio import json +import logging +import os +import time from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass +from threading import Lock from typing import Any from data_designer.config.mcp import MCPServerConfig, MCPToolConfig from data_designer.engine.mcp.errors import MCPClientUnavailableError, MCPConfigurationError, MCPToolError +logger = logging.getLogger(__name__) + @dataclass(frozen=True) class MCPToolDefinition: @@ -27,14 +33,36 @@ class MCPToolResult: class MCPClientManager: - def __init__(self, *, server_configs: list[MCPServerConfig]): + def __init__( + self, + *, + server_configs: list[MCPServerConfig], + max_async_workers: int | None = None, + ): self._server_configs = self._build_server_map(server_configs) self._tool_cache: dict[str, list[MCPToolDefinition]] = {} - self._async_executor = ThreadPoolExecutor(max_workers=1) + self._tool_cache_lock = Lock() + self._async_executor = ThreadPoolExecutor( + max_workers=self._resolve_async_workers(max_async_workers), + thread_name_prefix="MCPClientManager", + ) + + @staticmethod + def _resolve_async_workers(max_async_workers: int | None) -> int: + if max_async_workers is not None: + return max(1, max_async_workers) + env_value = os.environ.get("DATA_DESIGNER_MCP_ASYNC_WORKERS") + if env_value: + try: + return max(1, int(env_value)) + except ValueError: + pass + cpu_count = os.cpu_count() or 4 + return max(4, min(32, cpu_count)) def get_tool_schemas(self, tool_config: MCPToolConfig) -> list[dict[str, Any]]: server = self._get_server(tool_config.server_name) - tools = self._list_tools(server.name) + tools = self._list_tools(server.name, timeout_sec=tool_config.timeout_sec) allowed_names = set(tool_config.tool_names) if tool_config.tool_names else None if allowed_names is not None: available = {tool.name for tool in tools} @@ -44,9 +72,23 @@ def get_tool_schemas(self, tool_config: MCPToolConfig) -> list[dict[str, Any]]: tools = [tool for tool in tools if tool.name in allowed_names] return [self._to_openai_tool_schema(tool) for tool in tools] - def call_tool(self, server_name: str, tool_name: str, arguments: dict[str, Any]) -> MCPToolResult: + def call_tool( + self, + server_name: str, + tool_name: str, + arguments: dict[str, Any], + *, + timeout_sec: float | None = None, + ) -> MCPToolResult: server = self._get_server(server_name) - result = self._run_async(self._call_tool_async(server, tool_name, arguments)) + start_time = time.monotonic() + result = self._run_async( + self._call_tool_async(server, tool_name, arguments), + operation=f"calling tool {tool_name!r} on {server.name!r}", + timeout_sec=timeout_sec, + ) + elapsed = time.monotonic() - start_time + logger.debug("MCP tool %s on %s completed in %.2fs.", tool_name, server.name, elapsed) return result def _build_server_map(self, server_configs: list[MCPServerConfig]) -> dict[str, MCPServerConfig]: @@ -63,21 +105,41 @@ def _get_server(self, name: str) -> MCPServerConfig: except KeyError as exc: raise MCPConfigurationError(f"No MCP server named {name!r} is configured.") from exc - def _list_tools(self, server_name: str) -> list[MCPToolDefinition]: + def _list_tools(self, server_name: str, *, timeout_sec: float | None = None) -> list[MCPToolDefinition]: if server_name in self._tool_cache: return self._tool_cache[server_name] - server = self._get_server(server_name) - tools = self._run_async(self._list_tools_async(server)) - self._tool_cache[server_name] = tools - return tools + with self._tool_cache_lock: + if server_name in self._tool_cache: + return self._tool_cache[server_name] + server = self._get_server(server_name) + start_time = time.monotonic() + tools = self._run_async( + self._list_tools_async(server), + operation=f"listing tools on {server.name!r}", + timeout_sec=timeout_sec, + ) + elapsed = time.monotonic() - start_time + logger.debug("MCP tool list for %s completed in %.2fs.", server.name, elapsed) + self._tool_cache[server_name] = tools + return tools - def _run_async(self, coro: Any) -> Any: + def _run_async(self, coro: Any, *, operation: str, timeout_sec: float | None = None) -> Any: + if timeout_sec is not None: + coro = asyncio.wait_for(coro, timeout=timeout_sec) try: asyncio.get_running_loop() except RuntimeError: - return asyncio.run(coro) + try: + return asyncio.run(coro) + except TimeoutError as exc: + timeout_label = f"{timeout_sec:.1f}" if timeout_sec is not None else "unknown" + raise MCPToolError(f"Timed out after {timeout_label}s while {operation}.") from exc future = self._async_executor.submit(asyncio.run, coro) - return future.result() + try: + return future.result() + except TimeoutError as exc: + timeout_label = f"{timeout_sec:.1f}" if timeout_sec is not None else "unknown" + raise MCPToolError(f"Timed out after {timeout_label}s while {operation}.") from exc async def _list_tools_async(self, server: MCPServerConfig) -> list[MCPToolDefinition]: ClientSession, StdioServerParameters, stdio_client, sse_client = _resolve_mcp_imports() diff --git a/packages/data-designer-engine/src/data_designer/engine/models/facade.py b/packages/data-designer-engine/src/data_designer/engine/models/facade.py index 10c35e95..1585400f 100644 --- a/packages/data-designer-engine/src/data_designer/engine/models/facade.py +++ b/packages/data-designer-engine/src/data_designer/engine/models/facade.py @@ -380,7 +380,12 @@ def _execute_tool_calls(self, tool_config: MCPToolConfig, tool_calls: list[dict[ tool_name = tool_call["name"] if allowed_tools is not None and tool_name not in allowed_tools: raise MCPToolError(f"Tool {tool_name!r} is not permitted for server {tool_config.server_name!r}.") - result = self._mcp_client_manager.call_tool(tool_config.server_name, tool_name, tool_call["arguments"]) + result = self._mcp_client_manager.call_tool( + tool_config.server_name, + tool_name, + tool_call["arguments"], + timeout_sec=tool_config.timeout_sec, + ) tool_messages.append( { "role": "tool", diff --git a/packages/data-designer-engine/tests/engine/models/test_facade.py b/packages/data-designer-engine/tests/engine/models/test_facade.py index 85ccf74f..acb82b6c 100644 --- a/packages/data-designer-engine/tests/engine/models/test_facade.py +++ b/packages/data-designer-engine/tests/engine/models/test_facade.py @@ -279,8 +279,15 @@ def get_tool_schemas(self, config: MCPToolConfig) -> list[dict]: } ] - def call_tool(self, server_name: str, tool_name: str, arguments: dict) -> SimpleNamespace: - self.calls.append((server_name, tool_name, arguments)) + def call_tool( + self, + server_name: str, + tool_name: str, + arguments: dict, + *, + timeout_sec: float | None = None, + ) -> SimpleNamespace: + self.calls.append((server_name, tool_name, arguments, timeout_sec)) return SimpleNamespace(content="tool-output") def _completion(self, messages: list[dict[str, Any]], **kwargs: Any) -> FakeResponse: @@ -302,7 +309,7 @@ def _completion(self, messages: list[dict[str, Any]], **kwargs: Any) -> FakeResp assert "tools" in captured_calls[0][1] assert captured_calls[0][1]["tools"][0]["function"]["name"] == "lookup" assert any(message.get("role") == "tool" for message in captured_calls[1][0]) - assert model._mcp_client_manager.calls == [("tools", "lookup", {"query": "foo"})] + assert model._mcp_client_manager.calls == [("tools", "lookup", {"query": "foo"}, None)] def test_generate_with_tools_missing_manager( From 53aac80f8e16ea289295c785e84847cc85ff87f0 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Tue, 27 Jan 2026 14:16:34 -0500 Subject: [PATCH 07/15] Remove comments --- docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py b/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py index 78c6b553..36abfa52 100644 --- a/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py +++ b/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py @@ -235,15 +235,6 @@ def build_config( ] if version: topic_prompt_lines.append(f'- version: "{version}"') - # topic_prompt_lines.extend( - # [ - # '- query: "Dungeons & Dragons basic rules table of contents"', - # "- limit: 5", - # "", - # "From the tool results, list up to 10 high-level topics.", - # "Return JSON with key: topics (a list of strings).", - # ] - # ) config_builder.add_column( dd.LLMStructuredColumnConfig( From f118fe65ce644ee16a54ee99d5fa47a6439bb436 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Tue, 27 Jan 2026 15:04:19 -0500 Subject: [PATCH 08/15] Update tests and link recipe --- docs/recipes/cards.md | 18 ++++++++++++++++++ docs/recipes/mcp_and_tooluse/dnd_rules_qa.md | 5 +++++ mkdocs.yml | 2 ++ .../test_column_wise_builder.py | 1 + .../tests/engine/mcp/test_manager.py | 4 ++-- 5 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 docs/recipes/mcp_and_tooluse/dnd_rules_qa.md diff --git a/docs/recipes/cards.md b/docs/recipes/cards.md index 881541a7..4d5ab499 100644 --- a/docs/recipes/cards.md +++ b/docs/recipes/cards.md @@ -81,4 +81,22 @@ Each recipe is a self-contained example that can be run independently. [:material-book-open-page-variant: View Recipe](qa_and_chat/multi_turn_chat.md){ .md-button } [Download Code :octicons-download-24:](../assets/recipes/qa_and_chat/multi_turn_chat.py){ .md-button download="multi_turn_chat.py" } + +- :material-tools:{ .lg .middle } **D&D Rules QA (MCP + Tool Use)** + + Generate grounded Q&A pairs from the D&D rules using MCP tool calls and document search. + + --- + + **Demonstrates:** + + - MCP tool calling + - Retrieval-grounded QA + - Document indexing + + --- + + [:material-book-open-page-variant: View Recipe](mcp_and_tooluse/dnd_rules_qa.md){ .md-button } + [Download Code :octicons-download-24:](../assets/recipes/mcp_and_tooluse/dnd_rules_qa.py){ .md-button download="dnd_rules_qa.py" } + diff --git a/docs/recipes/mcp_and_tooluse/dnd_rules_qa.md b/docs/recipes/mcp_and_tooluse/dnd_rules_qa.md new file mode 100644 index 00000000..36a2c45d --- /dev/null +++ b/docs/recipes/mcp_and_tooluse/dnd_rules_qa.md @@ -0,0 +1,5 @@ +[Download Code :octicons-download-24:](../../../assets/recipes/mcp_and_tooluse/dnd_rules_qa.py){ .md-button download="dnd_rules_qa.py" } + +```python +--8<-- "assets/recipes/mcp_and_tooluse/dnd_rules_qa.py" +``` diff --git a/mkdocs.yml b/mkdocs.yml index 601e4fb8..7aac584c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,6 +35,8 @@ nav: - QA and Chat: - Product Info QA: recipes/qa_and_chat/product_info_qa.md - Multi-Turn Chat: recipes/qa_and_chat/multi_turn_chat.md + - MCP and Tool Use: + - "D&D Rules QA": recipes/mcp_and_tooluse/dnd_rules_qa.md - Plugins: - Overview: plugins/overview.md - Example Plugin: plugins/example.md diff --git a/packages/data-designer-engine/tests/engine/dataset_builders/test_column_wise_builder.py b/packages/data-designer-engine/tests/engine/dataset_builders/test_column_wise_builder.py index 6df6c811..194be76e 100644 --- a/packages/data-designer-engine/tests/engine/dataset_builders/test_column_wise_builder.py +++ b/packages/data-designer-engine/tests/engine/dataset_builders/test_column_wise_builder.py @@ -379,6 +379,7 @@ def test_fan_out_with_threads_uses_early_shutdown_settings_from_resource_provide builder.batch_manager = Mock() builder.batch_manager.iter_current_batch.return_value = [] + builder.batch_manager.num_records_batch = 0 builder._fan_out_with_threads(mock_generator, max_workers=4) diff --git a/packages/data-designer-engine/tests/engine/mcp/test_manager.py b/packages/data-designer-engine/tests/engine/mcp/test_manager.py index 81092d72..f1d167a9 100644 --- a/packages/data-designer-engine/tests/engine/mcp/test_manager.py +++ b/packages/data-designer-engine/tests/engine/mcp/test_manager.py @@ -19,7 +19,7 @@ def test_get_tool_schemas_filters_tools(monkeypatch: pytest.MonkeyPatch) -> None MCPToolDefinition(name="other", description="Other", input_schema={"type": "object"}), ] - def _list_tools(_: str) -> list[MCPToolDefinition]: + def _list_tools(_: str, *, timeout_sec: float | None = None) -> list[MCPToolDefinition]: return tools monkeypatch.setattr(manager, "_list_tools", _list_tools) @@ -35,7 +35,7 @@ def test_get_tool_schemas_missing_tool(monkeypatch: pytest.MonkeyPatch) -> None: server = MCPServerConfig(name="tools", command="python") manager = MCPClientManager(server_configs=[server]) - def _list_tools(_: str) -> list[MCPToolDefinition]: + def _list_tools(_: str, *, timeout_sec: float | None = None) -> list[MCPToolDefinition]: return [MCPToolDefinition(name="lookup", description="Lookup", input_schema={"type": "object"})] monkeypatch.setattr(manager, "_list_tools", _list_tools) From 0b0c9f501b8b8c94bd3e1e11dbf424b5133c416e Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Tue, 27 Jan 2026 15:05:15 -0500 Subject: [PATCH 09/15] Lint fixes --- .../src/data_designer/engine/models/facade.py | 1 + .../src/data_designer/engine/resources/resource_provider.py | 2 +- .../generators/test_llm_completion_generators.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/data-designer-engine/src/data_designer/engine/models/facade.py b/packages/data-designer-engine/src/data_designer/engine/models/facade.py index 1585400f..c0a2a7fb 100644 --- a/packages/data-designer-engine/src/data_designer/engine/models/facade.py +++ b/packages/data-designer-engine/src/data_designer/engine/models/facade.py @@ -28,6 +28,7 @@ if TYPE_CHECKING: import litellm + from data_designer.engine.mcp.manager import MCPClientManager logger = logging.getLogger(__name__) diff --git a/packages/data-designer-engine/src/data_designer/engine/resources/resource_provider.py b/packages/data-designer-engine/src/data_designer/engine/resources/resource_provider.py index d04dbe40..ceb18318 100644 --- a/packages/data-designer-engine/src/data_designer/engine/resources/resource_provider.py +++ b/packages/data-designer-engine/src/data_designer/engine/resources/resource_provider.py @@ -4,8 +4,8 @@ from __future__ import annotations from data_designer.config.base import ConfigBase -from data_designer.config.mcp import MCPServerConfig from data_designer.config.dataset_metadata import DatasetMetadata +from data_designer.config.mcp import MCPServerConfig from data_designer.config.models import ModelConfig from data_designer.config.run_config import RunConfig from data_designer.config.seed_source import SeedSource diff --git a/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py b/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py index 47be13d2..33f69614 100644 --- a/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py +++ b/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py @@ -13,6 +13,7 @@ ) from data_designer.config.mcp import MCPToolConfig from data_designer.config.run_config import RunConfig +from data_designer.config.utils.constants import TRACE_COLUMN_POSTFIX from data_designer.engine.column_generators.generators.base import GenerationStrategy from data_designer.engine.column_generators.generators.llm_completion import ( LLMCodeCellGenerator, @@ -20,7 +21,6 @@ LLMStructuredCellGenerator, LLMTextCellGenerator, ) -from data_designer.config.utils.constants import TRACE_COLUMN_POSTFIX def _create_generator_with_mocks(config_class=LLMTextColumnConfig, **config_kwargs): From 7e4169e9936b34e51bc0c3e6f8fe2e7180ebac3e Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Tue, 27 Jan 2026 15:15:52 -0500 Subject: [PATCH 10/15] Get a version onto the MCP dependency --- packages/data-designer-engine/pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/data-designer-engine/pyproject.toml b/packages/data-designer-engine/pyproject.toml index 2a0cdfd0..a3786314 100644 --- a/packages/data-designer-engine/pyproject.toml +++ b/packages/data-designer-engine/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "litellm>=1.73.6,<1.80.12", "lxml>=6.0.2,<7", "marko>=2.1.2,<3", - "mcp", + "mcp>=1.26.0", "networkx>=3.0,<4", "ruff>=0.14.10,<1", "scipy>=1.11.0,<2", diff --git a/uv.lock b/uv.lock index f3ee62a2..f13e8de1 100644 --- a/uv.lock +++ b/uv.lock @@ -838,7 +838,7 @@ requires-dist = [ { name = "litellm", specifier = ">=1.73.6,<1.80.12" }, { name = "lxml", specifier = ">=6.0.2,<7" }, { name = "marko", specifier = ">=2.1.2,<3" }, - { name = "mcp" }, + { name = "mcp", specifier = ">=1.26.0" }, { name = "networkx", specifier = ">=3.0,<4" }, { name = "ruff", specifier = ">=0.14.10,<1" }, { name = "scipy", specifier = ">=1.11.0,<2" }, From ff13c7cc421bbcc2b82721f5229137a83b77bc3f Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Tue, 27 Jan 2026 18:19:56 -0500 Subject: [PATCH 11/15] Clean ups --- .../generators/llm_completion.py | 8 +- .../src/data_designer/engine/mcp/manager.py | 28 +- .../data_designer/engine/mcp/tool_executor.py | 357 ++++++++++++++++++ .../src/data_designer/engine/models/facade.py | 207 +++------- .../src/data_designer/engine/models/utils.py | 105 ++++-- .../test_llm_completion_generators.py | 16 +- .../tests/engine/models/test_facade.py | 78 ++-- .../tests/engine/models/test_model_utils.py | 29 +- 8 files changed, 564 insertions(+), 264 deletions(-) create mode 100644 packages/data-designer-engine/src/data_designer/engine/mcp/tool_executor.py diff --git a/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py b/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py index 1c7ec2f0..305b7fcb 100644 --- a/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py +++ b/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py @@ -66,8 +66,7 @@ def generate(self, data: dict) -> dict: context.get_context(deserialized_record) for context in self.config.multi_modal_context ] - include_full_traces = self.resource_provider.run_config.include_full_traces - response, _, trace = self.model.generate( + response, trace = self.model.generate( prompt=self.prompt_renderer.render( record=deserialized_record, prompt_template=self.config.prompt, @@ -81,7 +80,6 @@ def generate(self, data: dict) -> dict: parser=self.response_recipe.parse, multi_modal_context=multi_modal_context, tool_config=self.config.tool_config, - include_full_traces=include_full_traces, max_correction_steps=self.max_conversation_correction_steps, max_conversation_restarts=self.max_conversation_restarts, purpose=f"running generation for column '{self.config.name}'", @@ -90,8 +88,8 @@ def generate(self, data: dict) -> dict: serialized_output = self.response_recipe.serialize_output(response) data[self.config.name] = self._process_serialized_output(serialized_output) - if include_full_traces and trace is not None: - data[self.config.name + TRACE_COLUMN_POSTFIX] = trace + if self.resource_provider.run_config.include_full_traces: + data[self.config.name + TRACE_COLUMN_POSTFIX] = [message.to_dict() for message in trace] return data diff --git a/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py b/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py index b367ecc7..687b0e5f 100644 --- a/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py +++ b/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py @@ -13,8 +13,12 @@ from threading import Lock from typing import Any +from mcp import ClientSession, StdioServerParameters +from mcp.client.sse import sse_client +from mcp.client.stdio import stdio_client + from data_designer.config.mcp import MCPServerConfig, MCPToolConfig -from data_designer.engine.mcp.errors import MCPClientUnavailableError, MCPConfigurationError, MCPToolError +from data_designer.engine.mcp.errors import MCPConfigurationError, MCPToolError logger = logging.getLogger(__name__) @@ -142,7 +146,6 @@ def _run_async(self, coro: Any, *, operation: str, timeout_sec: float | None = N raise MCPToolError(f"Timed out after {timeout_label}s while {operation}.") from exc async def _list_tools_async(self, server: MCPServerConfig) -> list[MCPToolDefinition]: - ClientSession, StdioServerParameters, stdio_client, sse_client = _resolve_mcp_imports() if server.command: params = StdioServerParameters(command=server.command, args=server.args, env=server.env) async with stdio_client(params) as (read, write): @@ -163,7 +166,6 @@ async def _list_tools_async(self, server: MCPServerConfig) -> list[MCPToolDefini async def _call_tool_async( self, server: MCPServerConfig, tool_name: str, arguments: dict[str, Any] ) -> MCPToolResult: - ClientSession, StdioServerParameters, stdio_client, sse_client = _resolve_mcp_imports() if server.command: params = StdioServerParameters(command=server.command, args=server.args, env=server.env) async with stdio_client(params) as (read, write): @@ -235,23 +237,3 @@ def _serialize_tool_result_content(result: Any) -> str: parts.append(str(item)) return "\n".join(parts) return str(content) - - -def _resolve_mcp_imports() -> tuple[Any, Any, Any, Any]: - try: - from mcp import ClientSession, StdioServerParameters - from mcp.client.sse import sse_client - from mcp.client.stdio import stdio_client - - return ClientSession, StdioServerParameters, stdio_client, sse_client - except ImportError: - try: - from mcp.client.session import ClientSession - from mcp.client.sse import sse_client - from mcp.client.stdio import StdioServerParameters, stdio_client - - return ClientSession, StdioServerParameters, stdio_client, sse_client - except ImportError as exc: - raise MCPClientUnavailableError( - "MCP client dependencies are not installed. Install the 'mcp' package to enable tool calling." - ) from exc diff --git a/packages/data-designer-engine/src/data_designer/engine/mcp/tool_executor.py b/packages/data-designer-engine/src/data_designer/engine/mcp/tool_executor.py new file mode 100644 index 00000000..c9bebabc --- /dev/null +++ b/packages/data-designer-engine/src/data_designer/engine/mcp/tool_executor.py @@ -0,0 +1,357 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from data_designer.config.mcp import MCPToolConfig +from data_designer.engine.mcp.errors import MCPConfigurationError, MCPToolError +from data_designer.engine.models.utils import ChatMessage + +if TYPE_CHECKING: + from data_designer.engine.mcp.manager import MCPClientManager + + +@dataclass +class ToolExecutionResult: + """Result of executing tool calls within an LLM generation loop. + + This dataclass encapsulates all the outputs from executing one or more + MCP tool calls, providing the messages needed to continue the conversation + with the LLM. + + Attributes: + assistant_message: The assistant message containing the tool call requests. + This message should be appended to the conversation history before + the tool response messages. + tool_messages: List of tool response messages, one per executed tool call. + tool_calls_count: The number of tool calls that were executed. + """ + + assistant_message: ChatMessage + tool_messages: list[ChatMessage] + tool_calls_count: int + + +class MCPToolExecutor: + """Handles MCP tool call extraction, normalization, and execution. + + This class extracts tool-related logic from the ModelFacade to keep + the facade focused on LLM generation while delegating tool execution + to a dedicated component. It provides methods for: + + - Retrieving tool schemas in OpenAI-compatible format + - Extracting and normalizing tool calls from LLM responses + - Executing tool calls via the MCP client manager + + Attributes: + _mcp_client_manager: The MCP client manager for communicating with MCP servers. + May be None if MCP is not configured. + """ + + def __init__(self, mcp_client_manager: MCPClientManager | None = None) -> None: + """Initialize the MCPToolExecutor. + + Args: + mcp_client_manager: The MCP client manager instance for communicating + with configured MCP servers. If None, tool operations will raise + MCPConfigurationError when attempted. + """ + self._mcp_client_manager = mcp_client_manager + + def get_tool_schemas(self, tool_config: MCPToolConfig) -> list[dict[str, Any]]: + """Get OpenAI-compatible tool schemas for the given tool configuration. + + Retrieves the available tools from the configured MCP server and converts + them to the OpenAI function calling format expected by LiteLLM. + + Args: + tool_config: The MCP tool configuration specifying which server to use + and optionally which tools to include. + + Returns: + A list of tool schema dictionaries in OpenAI function calling format. + Each schema contains 'type' (always 'function') and 'function' keys, + where 'function' includes 'name', 'description', and 'parameters'. + + Raises: + MCPConfigurationError: If no MCP client manager was configured. + """ + if self._mcp_client_manager is None: + raise MCPConfigurationError("MCP tool configuration was provided but no MCP servers were configured.") + return self._mcp_client_manager.get_tool_schemas(tool_config) + + def process_completion_response( + self, completion_response: Any, tool_config: MCPToolConfig + ) -> ToolExecutionResult | None: + """Process an LLM completion response and execute any tool calls. + + This is the primary method for handling tool calls from an LLM response. + It extracts the response content, reasoning content, and all tool calls + from the completion response, executes each tool call (including parallel + tool calls), and returns the results packaged for continuing the conversation. + + Args: + completion_response: The completion response object from the LLM, + typically from `router.completion()`. Expected to have a + `choices[0].message` structure with optional `content`, + `reasoning_content`, and `tool_calls` attributes. + tool_config: The MCP tool configuration specifying the server, + allowed tools, and timeout settings. + + Returns: + A ToolExecutionResult if tool calls were present and executed, + containing: + - assistant_message: The assistant message with tool call requests + and optional reasoning content + - tool_messages: List of tool response messages (one per tool call) + - tool_calls_count: Number of tools that were called + + Returns None if no tool calls were present in the response. + + Raises: + MCPConfigurationError: If no MCP client manager was configured. + MCPToolError: If a requested tool is not in the allowed tools list + or if tool execution fails. + """ + message = completion_response.choices[0].message + + # Extract response content and reasoning content + response_content = message.content or "" + reasoning_content = getattr(message, "reasoning_content", None) + + # Strip whitespace if reasoning is present (models often add extra newlines) + if reasoning_content: + response_content = response_content.strip() + reasoning_content = reasoning_content.strip() + + # Extract and normalize tool calls + tool_calls = self._extract_tool_calls(message) + if not tool_calls: + return None + + # Execute all tool calls (handles parallel tool calling) + return self._execute_tool_calls(tool_config, tool_calls, response_content, reasoning_content) + + def _extract_tool_calls(self, message: Any) -> list[dict[str, Any]]: + """Extract and normalize tool calls from an LLM response message. + + Handles various LLM response formats (dict or object with attributes) + and normalizes them into a consistent dictionary format. Supports + parallel tool calling where the model returns multiple tool calls + in a single response. + + Args: + message: The LLM response message, either as a dictionary or an object + with a 'tool_calls' attribute. Typically this is the message object + from `completion_response.choices[0].message`. + + Returns: + A list of normalized tool call dictionaries. Each dictionary contains: + - 'id': Unique identifier for the tool call (generated if not provided) + - 'name': The name of the tool to call + - 'arguments': Parsed arguments as a dictionary + - 'arguments_json': Arguments serialized as a JSON string + + Returns an empty list if no tool calls are present in the message. + """ + raw_tool_calls = getattr(message, "tool_calls", None) + if raw_tool_calls is None and isinstance(message, dict): + raw_tool_calls = message.get("tool_calls") + if not raw_tool_calls: + return [] + tool_calls: list[dict[str, Any]] = [] + for raw_tool_call in raw_tool_calls: + tool_calls.append(self._normalize_tool_call(raw_tool_call)) + return tool_calls + + def _execute_tool_calls( + self, + tool_config: MCPToolConfig, + tool_calls: list[dict[str, Any]], + response_content: str, + reasoning_content: str | None = None, + ) -> ToolExecutionResult: + """Execute tool calls and return messages to append to the conversation. + + This method executes each tool call against the configured MCP server + and packages the results into messages suitable for continuing the + LLM conversation. + + Args: + tool_config: The MCP tool configuration specifying the server, + allowed tools, and timeout settings. + tool_calls: List of normalized tool calls to execute, as returned + by `extract_tool_calls()`. + response_content: The assistant's response content that accompanied + the tool calls. May be empty string if the LLM only returned + tool calls without additional text. + reasoning_content: Optional reasoning content from the assistant's + response (e.g., from extended thinking / chain-of-thought models). + If provided, will be included in the assistant message. + + Returns: + A ToolExecutionResult containing: + - assistant_message: The assistant message with tool call requests + (includes reasoning_content if provided) + - tool_messages: List of tool response messages + - tool_calls_count: Number of tools that were called + + Raises: + MCPConfigurationError: If no MCP client manager was configured. + MCPToolError: If a requested tool is not in the allowed tools list + or if tool execution fails. + """ + if self._mcp_client_manager is None: + raise MCPConfigurationError("MCP tool configuration was provided but no MCP servers were configured.") + + assistant_message = self._build_assistant_tool_message(response_content, tool_calls, reasoning_content) + tool_messages = self._execute_tool_calls_internal(tool_config, tool_calls) + + return ToolExecutionResult( + assistant_message=assistant_message, + tool_messages=tool_messages, + tool_calls_count=len(tool_calls), + ) + + def _normalize_tool_call(self, raw_tool_call: Any) -> dict[str, Any]: + """Normalize a tool call from various LLM response formats. + + Handles both dictionary and object representations of tool calls, + supporting the OpenAI format (with nested 'function' key) and + flattened formats. + + Args: + raw_tool_call: A tool call in any supported format. Can be a dictionary + with 'id', 'function.name', 'function.arguments' keys, or an object + with corresponding attributes. + + Returns: + A normalized tool call dictionary with keys: + - 'id': Tool call identifier (UUID generated if not provided) + - 'name': The tool name + - 'arguments': Parsed arguments dictionary + - 'arguments_json': JSON string of arguments + + Raises: + MCPToolError: If the tool call is missing a name or has invalid + arguments that cannot be parsed as JSON. + """ + if isinstance(raw_tool_call, dict): + tool_call_id = raw_tool_call.get("id") + function = raw_tool_call.get("function") or {} + name = function.get("name") or raw_tool_call.get("name") + arguments = function.get("arguments") or raw_tool_call.get("arguments") + else: + tool_call_id = getattr(raw_tool_call, "id", None) + function = getattr(raw_tool_call, "function", None) + name = getattr(function, "name", None) if function is not None else getattr(raw_tool_call, "name", None) + arguments = ( + getattr(function, "arguments", None) + if function is not None + else getattr(raw_tool_call, "arguments", None) + ) + + if not name: + raise MCPToolError("MCP tool call is missing a tool name.") + + arguments_payload: dict[str, Any] + arguments_json: str + if arguments is None or arguments == "": + arguments_payload = {} + arguments_json = "{}" + elif isinstance(arguments, str): + try: + arguments_payload = json.loads(arguments) + except json.JSONDecodeError as exc: + raise MCPToolError(f"Invalid tool arguments for '{name}': {arguments}") from exc + arguments_json = arguments + elif isinstance(arguments, dict): + arguments_payload = arguments + arguments_json = json.dumps(arguments_payload) + else: + raise MCPToolError(f"Unsupported tool arguments type for '{name}': {type(arguments)!r}") + + return { + "id": tool_call_id or uuid.uuid4().hex, + "name": name, + "arguments": arguments_payload, + "arguments_json": arguments_json, + } + + def _build_assistant_tool_message( + self, response: str | None, tool_calls: list[dict[str, Any]], reasoning_content: str | None = None + ) -> ChatMessage: + """Build the assistant message containing tool call requests. + + Constructs a message in the format expected by the LLM conversation + history, representing the assistant's request to call tools. + + Args: + response: The assistant's text response content. May be empty if + the assistant only requested tool calls without additional text. + tool_calls: List of normalized tool call dictionaries as returned + by `_normalize_tool_call()`. + reasoning_content: Optional reasoning content from the assistant's + response. If provided, will be included under the 'reasoning_content' key. + + Returns: + A ChatMessage representing the assistant message with tool call requests. + """ + tool_calls_payload = [ + { + "id": tool_call["id"], + "type": "function", + "function": {"name": tool_call["name"], "arguments": tool_call["arguments_json"]}, + } + for tool_call in tool_calls + ] + return ChatMessage.assistant( + content=response or "", + reasoning_content=reasoning_content or None, + tool_calls=tool_calls_payload, + ) + + def _execute_tool_calls_internal( + self, tool_config: MCPToolConfig, tool_calls: list[dict[str, Any]] + ) -> list[ChatMessage]: + """Execute tool calls and return tool response messages. + + Iterates through the tool calls, validates each against the allowed + tools list, executes them via the MCP client manager, and collects + the responses. + + Args: + tool_config: The MCP tool configuration specifying the server name, + allowed tools, and timeout settings. + tool_calls: List of normalized tool call dictionaries to execute. + + Returns: + A list of tool response messages, one per tool call. + + Raises: + MCPConfigurationError: If no MCP client manager was configured. + MCPToolError: If a tool is not in the allowed tools list or if + the MCP server returns an error. + """ + if self._mcp_client_manager is None: + raise MCPConfigurationError("MCP tool configuration was provided but no MCP servers were configured.") + + allowed_tools = set(tool_config.tool_names) if tool_config.tool_names else None + tool_messages: list[ChatMessage] = [] + for tool_call in tool_calls: + tool_name = tool_call["name"] + if allowed_tools is not None and tool_name not in allowed_tools: + raise MCPToolError(f"Tool {tool_name!r} is not permitted for server {tool_config.server_name!r}.") + result = self._mcp_client_manager.call_tool( + tool_config.server_name, + tool_name, + tool_call["arguments"], + timeout_sec=tool_config.timeout_sec, + ) + tool_messages.append(ChatMessage.tool(content=result.content, tool_call_id=tool_call["id"])) + return tool_messages diff --git a/packages/data-designer-engine/src/data_designer/engine/models/facade.py b/packages/data-designer-engine/src/data_designer/engine/models/facade.py index c0a2a7fb..7115fd5d 100644 --- a/packages/data-designer-engine/src/data_designer/engine/models/facade.py +++ b/packages/data-designer-engine/src/data_designer/engine/models/facade.py @@ -3,16 +3,15 @@ from __future__ import annotations -import json import logging -import uuid from collections.abc import Callable from copy import deepcopy from typing import TYPE_CHECKING, Any from data_designer.config.mcp import MCPToolConfig from data_designer.config.models import GenerationType, ModelConfig, ModelProvider -from data_designer.engine.mcp.errors import MCPConfigurationError, MCPToolError +from data_designer.engine.mcp.errors import MCPToolError +from data_designer.engine.mcp.tool_executor import MCPToolExecutor from data_designer.engine.model_provider import ModelProviderRegistry from data_designer.engine.models.errors import ( GenerationValidationFailureError, @@ -22,7 +21,7 @@ from data_designer.engine.models.litellm_overrides import CustomRouter, LiteLLMRouterDefaultKwargs from data_designer.engine.models.parsers.errors import ParserException from data_designer.engine.models.usage import ModelUsageStats, RequestUsageStats, TokenUsageStats -from data_designer.engine.models.utils import prompt_to_messages, str_to_message +from data_designer.engine.models.utils import ChatMessage, prompt_to_messages from data_designer.engine.secret_resolver import SecretResolver from data_designer.lazy_heavy_imports import litellm @@ -47,6 +46,7 @@ def __init__( self._secret_resolver = secret_resolver self._model_provider_registry = model_provider_registry self._mcp_client_manager = mcp_client_manager + self._tool_executor = MCPToolExecutor(mcp_client_manager) self._litellm_deployment = self._get_litellm_deployment(model_config) self._router = CustomRouter([self._litellm_deployment], **LiteLLMRouterDefaultKwargs().model_dump()) self._usage_stats = ModelUsageStats() @@ -76,16 +76,17 @@ def usage_stats(self) -> ModelUsageStats: return self._usage_stats def completion( - self, messages: list[dict[str, str]], skip_usage_tracking: bool = False, **kwargs + self, messages: list[ChatMessage], skip_usage_tracking: bool = False, **kwargs ) -> litellm.ModelResponse: + message_payloads = [message.to_dict() for message in messages] logger.debug( f"Prompting model {self.model_name!r}...", - extra={"model": self.model_name, "messages": messages}, + extra={"model": self.model_name, "messages": message_payloads}, ) response = None kwargs = self.consolidate_kwargs(**kwargs) try: - response = self._router.completion(model=self.model_name, messages=messages, **kwargs) + response = self._router.completion(model=self.model_name, messages=message_payloads, **kwargs) logger.debug( f"Received completion from model {self.model_name!r}", extra={ @@ -154,13 +155,12 @@ def generate( system_prompt: str | None = None, multi_modal_context: list[dict[str, Any]] | None = None, tool_config: MCPToolConfig | None = None, - include_full_traces: bool = False, max_correction_steps: int = 0, max_conversation_restarts: int = 0, skip_usage_tracking: bool = False, purpose: str | None = None, **kwargs, - ) -> tuple[Any, str | None, list[dict[str, Any]] | None]: + ) -> tuple[Any, list[ChatMessage]]: """Generate a parsed output with correction steps. This generation call will attempt to generate an output which is @@ -195,6 +195,12 @@ def generate( It is expected to be used by the @catch_llm_exceptions decorator. **kwargs: Additional arguments to pass to the model. + Returns: + A tuple containing: + - The parsed output object from the parser. + - The full trace of ChatMessage entries in the conversation, including any tool calls, + corrections, and reasoning traces. Callers can decide whether to store this. + Raises: GenerationValidationFailureError: If the maximum number of retries or correction steps are met and the last response failures on @@ -211,190 +217,75 @@ def generate( starting_messages = prompt_to_messages( user_prompt=prompt, system_prompt=system_prompt, multi_modal_context=multi_modal_context ) - messages = deepcopy(starting_messages) - trace_messages: list[dict[str, Any]] | None = deepcopy(starting_messages) if include_full_traces else None + messages: list[ChatMessage] = deepcopy(starting_messages) if tool_config is not None: - tool_schemas = self._get_tool_schemas(tool_config) + tool_schemas = self._tool_executor.get_tool_schemas(tool_config) while True: - curr_generation_attempt += 1 - logger.debug( - f"Starting generation attempt {curr_generation_attempt} of {max_generation_attempts} attempts." - ) - completion_kwargs = dict(kwargs) if tool_schemas is not None: completion_kwargs["tools"] = tool_schemas + completion_response = self.completion( messages, skip_usage_tracking=skip_usage_tracking, **completion_kwargs, ) - response = completion_response.choices[0].message.content or "" - reasoning_trace = getattr(completion_response.choices[0].message, "reasoning_content", None) - if reasoning_trace: - ## There are generally some extra newlines with how these get parsed. - response = response.strip() - reasoning_trace = reasoning_trace.strip() - - tool_calls = self._extract_tool_calls(completion_response.choices[0].message) - if tool_config is not None and len(tool_calls) > 0: - tool_calls_used += len(tool_calls) - if tool_calls_used > tool_config.max_tool_calls: - raise MCPToolError( - f"Exceeded maximum MCP tool calls ({tool_config.max_tool_calls}) for server " - f"{tool_config.server_name!r}." - ) - assistant_tool_message = self._build_assistant_tool_message(response, tool_calls) - tool_messages = self._execute_tool_calls(tool_config, tool_calls) - - messages.append(assistant_tool_message) - messages.extend(tool_messages) - - if trace_messages is not None: - assistant_trace_message = dict(assistant_tool_message) - if reasoning_trace: - assistant_trace_message["reasoning_content"] = reasoning_trace - trace_messages.append(assistant_trace_message) - trace_messages.extend(tool_messages) - continue + # Process any tool calls in the response (handles parallel tool calling) + if tool_config is not None: + execution_result = self._tool_executor.process_completion_response(completion_response, tool_config) + if execution_result is not None: + tool_calls_used += execution_result.tool_calls_count + if tool_calls_used > tool_config.max_tool_calls: + raise MCPToolError( + f"Exceeded maximum MCP tool calls ({tool_config.max_tool_calls}) for server " + f"{tool_config.server_name!r}." + ) + + messages.append(execution_result.assistant_message) + messages.extend(execution_result.tool_messages) + continue + + curr_generation_attempt += 1 + logger.debug( + f"Starting generation attempt {curr_generation_attempt} of {max_generation_attempts} attempts." + ) + # No tool calls remaining to process + response = completion_response.choices[0].message.content or "" + reasoning_trace = getattr(completion_response.choices[0].message, "reasoning_content", None) curr_num_correction_steps += 1 try: output_obj = parser(response) # type: ignore - if not a string will cause a ParserException below - if trace_messages is not None: - assistant_trace_message: dict[str, Any] = {"role": "assistant", "content": response} - if reasoning_trace: - assistant_trace_message["reasoning_content"] = reasoning_trace - trace_messages.append(assistant_trace_message) + messages.append(ChatMessage.assistant(content=response, reasoning_content=reasoning_trace or None)) break except ParserException as exc: if max_correction_steps == 0 and max_conversation_restarts == 0: raise GenerationValidationFailureError( "Unsuccessful generation attempt. No retries were attempted." ) from exc + if curr_num_correction_steps <= max_correction_steps: ## Add turns to loop-back errors for correction - assistant_message = str_to_message(content=response, role="assistant") - user_message = str_to_message(content=str(get_exception_primary_cause(exc)), role="user") - messages += [assistant_message, user_message] - if trace_messages is not None: - assistant_trace_message = dict(assistant_message) - if reasoning_trace: - assistant_trace_message["reasoning_content"] = reasoning_trace - trace_messages += [assistant_trace_message, user_message] + messages += [ + ChatMessage.assistant(content=response, reasoning_content=reasoning_trace or None), + ChatMessage.user(content=str(get_exception_primary_cause(exc))), + ] + elif curr_num_restarts < max_conversation_restarts: curr_num_correction_steps = 0 curr_num_restarts += 1 messages = deepcopy(starting_messages) - if trace_messages is not None: - trace_messages = deepcopy(starting_messages) + else: raise GenerationValidationFailureError( f"Unsuccessful generation attempt despite {max_generation_attempts} attempts." ) from exc - return output_obj, reasoning_trace, trace_messages - - def _get_tool_schemas(self, tool_config: MCPToolConfig) -> list[dict[str, Any]]: - if self._mcp_client_manager is None: - raise MCPConfigurationError("MCP tool configuration was provided but no MCP servers were configured.") - return self._mcp_client_manager.get_tool_schemas(tool_config) - - def _extract_tool_calls(self, message: Any) -> list[dict[str, Any]]: - raw_tool_calls = getattr(message, "tool_calls", None) - if raw_tool_calls is None and isinstance(message, dict): - raw_tool_calls = message.get("tool_calls") - if not raw_tool_calls: - return [] - tool_calls: list[dict[str, Any]] = [] - for raw_tool_call in raw_tool_calls: - tool_calls.append(self._normalize_tool_call(raw_tool_call)) - return tool_calls - - def _normalize_tool_call(self, raw_tool_call: Any) -> dict[str, Any]: - if isinstance(raw_tool_call, dict): - tool_call_id = raw_tool_call.get("id") - function = raw_tool_call.get("function") or {} - name = function.get("name") or raw_tool_call.get("name") - arguments = function.get("arguments") or raw_tool_call.get("arguments") - else: - tool_call_id = getattr(raw_tool_call, "id", None) - function = getattr(raw_tool_call, "function", None) - name = getattr(function, "name", None) if function is not None else getattr(raw_tool_call, "name", None) - arguments = ( - getattr(function, "arguments", None) - if function is not None - else getattr(raw_tool_call, "arguments", None) - ) - if not name: - raise MCPToolError("MCP tool call is missing a tool name.") - - arguments_payload: dict[str, Any] - arguments_json: str - if arguments is None or arguments == "": - arguments_payload = {} - arguments_json = "{}" - elif isinstance(arguments, str): - try: - arguments_payload = json.loads(arguments) - except json.JSONDecodeError as exc: - raise MCPToolError(f"Invalid tool arguments for '{name}': {arguments}") from exc - arguments_json = arguments - elif isinstance(arguments, dict): - arguments_payload = arguments - arguments_json = json.dumps(arguments_payload) - else: - raise MCPToolError(f"Unsupported tool arguments type for '{name}': {type(arguments)!r}") - - return { - "id": tool_call_id or uuid.uuid4().hex, - "name": name, - "arguments": arguments_payload, - "arguments_json": arguments_json, - } - - def _build_assistant_tool_message(self, response: str, tool_calls: list[dict[str, Any]]) -> dict[str, Any]: - return { - "role": "assistant", - "content": response or "", - "tool_calls": [ - { - "id": tool_call["id"], - "type": "function", - "function": {"name": tool_call["name"], "arguments": tool_call["arguments_json"]}, - } - for tool_call in tool_calls - ], - } - - def _execute_tool_calls(self, tool_config: MCPToolConfig, tool_calls: list[dict[str, Any]]) -> list[dict[str, Any]]: - if self._mcp_client_manager is None: - raise MCPConfigurationError("MCP tool configuration was provided but no MCP servers were configured.") - - allowed_tools = set(tool_config.tool_names) if tool_config.tool_names else None - tool_messages: list[dict[str, Any]] = [] - for tool_call in tool_calls: - tool_name = tool_call["name"] - if allowed_tools is not None and tool_name not in allowed_tools: - raise MCPToolError(f"Tool {tool_name!r} is not permitted for server {tool_config.server_name!r}.") - result = self._mcp_client_manager.call_tool( - tool_config.server_name, - tool_name, - tool_call["arguments"], - timeout_sec=tool_config.timeout_sec, - ) - tool_messages.append( - { - "role": "tool", - "tool_call_id": tool_call["id"], - "content": result.content, - } - ) - return tool_messages + return output_obj, messages def _get_litellm_deployment(self, model_config: ModelConfig) -> litellm.DeploymentTypedDict: provider = self._model_provider_registry.get_provider(model_config.provider) diff --git a/packages/data-designer-engine/src/data_designer/engine/models/utils.py b/packages/data-designer-engine/src/data_designer/engine/models/utils.py index f6de25ab..d4578165 100644 --- a/packages/data-designer-engine/src/data_designer/engine/models/utils.py +++ b/packages/data-designer-engine/src/data_designer/engine/models/utils.py @@ -3,7 +3,81 @@ from __future__ import annotations -from typing import Any +from dataclasses import dataclass, field +from typing import Any, Literal + + +@dataclass +class ChatMessage: + """A chat message in an LLM conversation. + + This dataclass represents messages exchanged in a conversation with an LLM, + supporting various message types including user prompts, assistant responses, + system instructions, and tool interactions. + + Attributes: + role: The role of the message sender. One of 'user', 'assistant', 'system', or 'tool'. + content: The message content. Can be a string or a list of content blocks + for multimodal messages (e.g., text + images). + reasoning_content: Optional reasoning/thinking content from the assistant, + typically from extended thinking or chain-of-thought models. + tool_calls: Optional list of tool calls requested by the assistant. + Each tool call contains 'id', 'type', and 'function' keys. + tool_call_id: Optional ID linking a tool response to its corresponding + tool call. Required for messages with role='tool'. + """ + + role: Literal["user", "assistant", "system", "tool"] + content: str | list[dict[str, Any]] = "" + reasoning_content: str | None = None + tool_calls: list[dict[str, Any]] = field(default_factory=list) + tool_call_id: str | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert the message to a dictionary format for API calls. + + Returns: + A dictionary containing the message fields. Only includes non-empty + optional fields to keep the output clean. + """ + result: dict[str, Any] = {"role": self.role, "content": self.content} + if self.reasoning_content: + result["reasoning_content"] = self.reasoning_content + if self.tool_calls: + result["tool_calls"] = self.tool_calls + if self.tool_call_id: + result["tool_call_id"] = self.tool_call_id + return result + + @classmethod + def user(cls, content: str | list[dict[str, Any]]) -> ChatMessage: + """Create a user message.""" + return cls(role="user", content=content) + + @classmethod + def assistant( + cls, + content: str = "", + reasoning_content: str | None = None, + tool_calls: list[dict[str, Any]] | None = None, + ) -> ChatMessage: + """Create an assistant message.""" + return cls( + role="assistant", + content=content, + reasoning_content=reasoning_content, + tool_calls=tool_calls or [], + ) + + @classmethod + def system(cls, content: str) -> ChatMessage: + """Create a system message.""" + return cls(role="system", content=content) + + @classmethod + def tool(cls, content: str, tool_call_id: str) -> ChatMessage: + """Create a tool response message.""" + return cls(role="tool", content=content, tool_call_id=tool_call_id) def prompt_to_messages( @@ -11,28 +85,17 @@ def prompt_to_messages( user_prompt: str, system_prompt: str | None = None, multi_modal_context: list[dict[str, Any]] | None = None, -) -> list[dict[str, str | list[dict]]]: - """Convert a user and system prompt into Messages format. +) -> list[ChatMessage]: + """Convert a user and system prompt into ChatMessage list. Args: user_prompt (str): A user prompt. system_prompt (str, optional): An optional system prompt. """ - user_content = user_prompt - if multi_modal_context and len(multi_modal_context) > 0: - user_content = [] - user_content.append({"type": "text", "text": user_prompt}) - for context in multi_modal_context: - user_content.append(context) - return ( - [ - str_to_message(content=system_prompt, role="system"), - str_to_message(content=user_content, role="user"), - ] - if system_prompt - else [str_to_message(content=user_content, role="user")] - ) - - -def str_to_message(content: str | list[dict], role: str = "user") -> dict[str, str | list[dict]]: - return {"content": content, "role": role} + user_content: str | list[dict[str, Any]] = user_prompt + if multi_modal_context: + user_content = [{"type": "text", "text": user_prompt}, *multi_modal_context] + + if system_prompt: + return [ChatMessage.system(system_prompt), ChatMessage.user(user_content)] + return [ChatMessage.user(user_content)] diff --git a/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py b/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py index 33f69614..ab1b02be 100644 --- a/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py +++ b/packages/data-designer-engine/tests/engine/column_generators/generators/test_llm_completion_generators.py @@ -21,6 +21,7 @@ LLMStructuredCellGenerator, LLMTextCellGenerator, ) +from data_designer.engine.models.utils import ChatMessage def _create_generator_with_mocks(config_class=LLMTextColumnConfig, **config_kwargs): @@ -68,14 +69,14 @@ def _create_generator_with_mocks(config_class=LLMTextColumnConfig, **config_kwar ) -def _setup_generate_mocks(mock_prompt_renderer, mock_response_recipe, mock_model, output="test_output", reasoning=None): +def _setup_generate_mocks(mock_prompt_renderer, mock_response_recipe, mock_model, output="test_output"): """Helper function to setup common generate method mocks.""" mock_prompt_renderer.render.side_effect = ["rendered_user_prompt", "rendered_system_prompt"] mock_response_recipe.serialize_output.return_value = {"result": output} - mock_model.generate.return_value = ({"result": output}, reasoning, None) + mock_model.generate.return_value = ({"result": output}, []) -def test_generate_method(): +def test_generate_method() -> None: generator, _, mock_model, _, _, mock_prompt_renderer, mock_response_recipe = _create_generator_with_mocks() # Test basic generation @@ -96,7 +97,7 @@ def test_generate_method(): generator.resource_provider.run_config.include_full_traces = True mock_prompt_renderer.render.side_effect = ["rendered_user_prompt", "rendered_system_prompt"] mock_response_recipe.serialize_output.return_value = {"result": "test_output"} - mock_model.generate.return_value = ({"result": "test_output"}, None, [{"role": "user", "content": "x"}]) + mock_model.generate.return_value = ({"result": "test_output"}, [ChatMessage.user("x")]) result = generator.generate(data) assert result["test_column"] == {"result": "test_output"} @@ -239,7 +240,7 @@ def test_generate_with_errors(error_type, error_message): if error_type == "serialization": mock_response_recipe.serialize_output.side_effect = Exception(error_message) - mock_model.generate.return_value = ({"result": "test_output"}, None, None) + mock_model.generate.return_value = ({"result": "test_output"}, []) elif error_type == "model": mock_model.generate.side_effect = Exception(error_message) elif error_type == "prompt_render": @@ -253,7 +254,7 @@ def test_generate_with_errors(error_type, error_message): def test_generate_with_complex_data(): generator, _, mock_model, _, _, mock_prompt_renderer, mock_response_recipe = _create_generator_with_mocks() - _setup_generate_mocks(mock_prompt_renderer, mock_response_recipe, mock_model, "complex_output", None) + _setup_generate_mocks(mock_prompt_renderer, mock_response_recipe, mock_model, "complex_output") data = {"input": "test_input", "nested": {"key": "value"}, "list": [1, 2, 3], "json_string": '{"key": "value"}'} result = generator.generate(data) @@ -357,8 +358,7 @@ def test_generator_output_type_handling( mock_response_recipe.serialize_output.return_value = serialized_output stub_resource_provider.model_registry.get_model.return_value.generate.return_value = ( {"result": "raw_output"}, - None, - None, + [], ) data = {"input": "test_input"} diff --git a/packages/data-designer-engine/tests/engine/models/test_facade.py b/packages/data-designer-engine/tests/engine/models/test_facade.py index acb82b6c..9bfe5a57 100644 --- a/packages/data-designer-engine/tests/engine/models/test_facade.py +++ b/packages/data-designer-engine/tests/engine/models/test_facade.py @@ -14,6 +14,7 @@ from data_designer.engine.models.errors import ModelGenerationValidationFailureError from data_designer.engine.models.facade import ModelFacade from data_designer.engine.models.parsers.errors import ParserException +from data_designer.engine.models.utils import ChatMessage MockMessage = namedtuple("MockMessage", ["content"]) MockChoice = namedtuple("MockChoice", ["message"]) @@ -51,8 +52,8 @@ def stub_model_facade(stub_model_configs, stub_secrets_resolver, stub_model_prov @pytest.fixture -def stub_completion_messages(): - return [{"role": "user", "content": "test"}] +def stub_completion_messages() -> list[ChatMessage]: + return [ChatMessage.user("test")] @pytest.fixture @@ -114,17 +115,29 @@ def _failing_parser(response: str): @pytest.mark.parametrize( "system_prompt,expected_messages", [ - ("", [{"role": "user", "content": "does not matter"}]), - ("hello!", [{"content": "hello!", "role": "system"}, {"role": "user", "content": "does not matter"}]), + ("", [ChatMessage.user("does not matter")]), + ("hello!", [ChatMessage.system("hello!"), ChatMessage.user("does not matter")]), ], ) @patch("data_designer.engine.models.facade.ModelFacade.completion", autospec=True) -def test_generate_with_system_prompt(mock_completion, stub_model_facade, system_prompt, expected_messages): - mock_completion.return_value = ModelResponse(choices=Choices(message=Message(content="Hello!"))) +def test_generate_with_system_prompt( + mock_completion: Any, + stub_model_facade: ModelFacade, + system_prompt: str, + expected_messages: list[ChatMessage], +) -> None: + # Capture messages at call time since they get mutated after the call + captured_messages = [] + + def capture_and_return(*args: Any, **kwargs: Any) -> ModelResponse: + captured_messages.append(list(args[1])) # Copy the messages list + return ModelResponse(choices=Choices(message=Message(content="Hello!"))) + + mock_completion.side_effect = capture_and_return stub_model_facade.generate(prompt="does not matter", system_prompt=system_prompt, parser=lambda x: x) assert mock_completion.call_count == 1 - assert mock_completion.call_args[0][1] == expected_messages + assert captured_messages[0] == expected_messages def test_model_alias_property(stub_model_facade, stub_model_configs): @@ -172,26 +185,31 @@ def test_consolidate_kwargs(stub_model_configs, stub_model_facade): ) @patch("data_designer.engine.models.facade.CustomRouter.completion", autospec=True) def test_completion_success( - mock_router_completion, - stub_completion_messages, - stub_model_configs, - stub_model_facade, - stub_expected_completion_response, - skip_usage_tracking, -): + mock_router_completion: Any, + stub_completion_messages: list[ChatMessage], + stub_model_configs: Any, + stub_model_facade: ModelFacade, + stub_expected_completion_response: ModelResponse, + skip_usage_tracking: bool, +) -> None: mock_router_completion.side_effect = lambda self, model, messages, **kwargs: stub_expected_completion_response result = stub_model_facade.completion(stub_completion_messages, skip_usage_tracking=skip_usage_tracking) + expected_messages = [message.to_dict() for message in stub_completion_messages] assert result == stub_expected_completion_response assert mock_router_completion.call_count == 1 assert mock_router_completion.call_args[1] == { "model": "stub-model-text", - "messages": stub_completion_messages, + "messages": expected_messages, **stub_model_configs[0].inference_parameters.generate_kwargs, } @patch("data_designer.engine.models.facade.CustomRouter.completion", autospec=True) -def test_completion_with_exception(mock_router_completion, stub_completion_messages, stub_model_facade): +def test_completion_with_exception( + mock_router_completion: Any, + stub_completion_messages: list[ChatMessage], + stub_model_facade: ModelFacade, +) -> None: mock_router_completion.side_effect = Exception("Router error") with pytest.raises(Exception, match="Router error"): @@ -200,15 +218,15 @@ def test_completion_with_exception(mock_router_completion, stub_completion_messa @patch("data_designer.engine.models.facade.CustomRouter.completion", autospec=True) def test_completion_with_kwargs( - mock_router_completion, - stub_completion_messages, - stub_model_configs, - stub_model_facade, - stub_expected_completion_response, -): + mock_router_completion: Any, + stub_completion_messages: list[ChatMessage], + stub_model_configs: Any, + stub_model_facade: ModelFacade, + stub_expected_completion_response: ModelResponse, +) -> None: captured_kwargs = {} - def mock_completion(self, model, messages, **kwargs): + def mock_completion(self: Any, model: str, messages: list[dict[str, Any]], **kwargs: Any) -> ModelResponse: captured_kwargs.update(kwargs) return stub_expected_completion_response @@ -254,7 +272,11 @@ def mock_embedding(self, model, input, **kwargs): assert captured_kwargs == {**stub_model_configs[0].inference_parameters.generate_kwargs, **kwargs} -def test_generate_with_mcp_tools(stub_model_configs, stub_secrets_resolver, stub_model_provider_registry) -> None: +def test_generate_with_mcp_tools( + stub_model_configs: Any, + stub_secrets_resolver: Any, + stub_model_provider_registry: Any, +) -> None: tool_config = MCPToolConfig(server_name="tools", tool_names=["lookup"], max_tool_calls=3) tool_call = { "id": "call-1", @@ -265,7 +287,7 @@ def test_generate_with_mcp_tools(stub_model_configs, stub_secrets_resolver, stub FakeResponse(FakeMessage(content=None, tool_calls=[tool_call])), FakeResponse(FakeMessage(content="final result")), ] - captured_calls: list[tuple[list[dict[str, Any]], dict[str, Any]]] = [] + captured_calls: list[tuple[list[ChatMessage], dict[str, Any]]] = [] class FakeMCPManager: def __init__(self) -> None: @@ -290,7 +312,7 @@ def call_tool( self.calls.append((server_name, tool_name, arguments, timeout_sec)) return SimpleNamespace(content="tool-output") - def _completion(self, messages: list[dict[str, Any]], **kwargs: Any) -> FakeResponse: + def _completion(self: Any, messages: list[ChatMessage], **kwargs: Any) -> FakeResponse: captured_calls.append((messages, kwargs)) return responses.pop(0) @@ -302,13 +324,13 @@ def _completion(self, messages: list[dict[str, Any]], **kwargs: Any) -> FakeResp ) with patch.object(ModelFacade, "completion", new=_completion): - result, _, _ = model.generate(prompt="question", parser=lambda x: x, tool_config=tool_config) + result, _ = model.generate(prompt="question", parser=lambda x: x, tool_config=tool_config) assert result == "final result" assert len(captured_calls) == 2 assert "tools" in captured_calls[0][1] assert captured_calls[0][1]["tools"][0]["function"]["name"] == "lookup" - assert any(message.get("role") == "tool" for message in captured_calls[1][0]) + assert any(message.role == "tool" for message in captured_calls[1][0]) assert model._mcp_client_manager.calls == [("tools", "lookup", {"query": "foo"}, None)] diff --git a/packages/data-designer-engine/tests/engine/models/test_model_utils.py b/packages/data-designer-engine/tests/engine/models/test_model_utils.py index 2c56b4ae..75b7bfaa 100644 --- a/packages/data-designer-engine/tests/engine/models/test_model_utils.py +++ b/packages/data-designer-engine/tests/engine/models/test_model_utils.py @@ -1,36 +1,23 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from data_designer.engine.models.utils import prompt_to_messages, str_to_message +from data_designer.engine.models.utils import ChatMessage, prompt_to_messages -def test_str_to_message(): - assert str_to_message("hello") == {"content": "hello", "role": "user"} - assert str_to_message("hello", role="system") == {"content": "hello", "role": "system"} - assert str_to_message([{"type": "text", "text": "hello"}]) == { - "content": [{"type": "text", "text": "hello"}], - "role": "user", - } - assert str_to_message([{"type": "text", "text": "hello"}], role="system") == { - "content": [{"type": "text", "text": "hello"}], - "role": "system", - } - - -def test_prompt_to_messages(): +def test_prompt_to_messages() -> None: stub_system_prompt = "some system prompt" mult_modal_context = {"type": "image_url", "image_url": {"url": "http://example.com/image.png"}} - assert prompt_to_messages(user_prompt="hello") == [{"content": "hello", "role": "user"}] + assert prompt_to_messages(user_prompt="hello") == [ChatMessage.user("hello")] assert prompt_to_messages(user_prompt="hello", system_prompt=stub_system_prompt) == [ - {"content": stub_system_prompt, "role": "system"}, - {"content": "hello", "role": "user"}, + ChatMessage.system(stub_system_prompt), + ChatMessage.user("hello"), ] assert prompt_to_messages(user_prompt="hello", multi_modal_context=[mult_modal_context]) == [ - {"content": [{"type": "text", "text": "hello"}, mult_modal_context], "role": "user"} + ChatMessage.user([{"type": "text", "text": "hello"}, mult_modal_context]) ] assert prompt_to_messages( user_prompt="hello", system_prompt=stub_system_prompt, multi_modal_context=[mult_modal_context] ) == [ - {"content": stub_system_prompt, "role": "system"}, - {"content": [{"type": "text", "text": "hello"}, mult_modal_context], "role": "user"}, + ChatMessage.system(stub_system_prompt), + ChatMessage.user([{"type": "text", "text": "hello"}, mult_modal_context]), ] From 05129e74f6122ea64c6f3b521ee01e18892e9b4a Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Wed, 28 Jan 2026 09:27:51 -0500 Subject: [PATCH 12/15] Update license headers --- packages/data-designer-config/src/data_designer/config/mcp.py | 2 +- packages/data-designer-config/tests/config/test_mcp.py | 2 +- .../src/data_designer/engine/mcp/manager.py | 2 +- .../src/data_designer/engine/mcp/tool_executor.py | 2 +- packages/data-designer-engine/tests/engine/mcp/test_manager.py | 2 +- tests_e2e/src/data_designer_e2e_tests/mcp_demo_server.py | 2 +- tests_e2e/tests/test_mcp_demo.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/data-designer-config/src/data_designer/config/mcp.py b/packages/data-designer-config/src/data_designer/config/mcp.py index 920e51d3..0128b4ea 100644 --- a/packages/data-designer-config/src/data_designer/config/mcp.py +++ b/packages/data-designer-config/src/data_designer/config/mcp.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations diff --git a/packages/data-designer-config/tests/config/test_mcp.py b/packages/data-designer-config/tests/config/test_mcp.py index 876c32d8..9817c4a0 100644 --- a/packages/data-designer-config/tests/config/test_mcp.py +++ b/packages/data-designer-config/tests/config/test_mcp.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import pytest diff --git a/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py b/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py index 687b0e5f..880b0763 100644 --- a/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py +++ b/packages/data-designer-engine/src/data_designer/engine/mcp/manager.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations diff --git a/packages/data-designer-engine/src/data_designer/engine/mcp/tool_executor.py b/packages/data-designer-engine/src/data_designer/engine/mcp/tool_executor.py index c9bebabc..bcfdd15e 100644 --- a/packages/data-designer-engine/src/data_designer/engine/mcp/tool_executor.py +++ b/packages/data-designer-engine/src/data_designer/engine/mcp/tool_executor.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations diff --git a/packages/data-designer-engine/tests/engine/mcp/test_manager.py b/packages/data-designer-engine/tests/engine/mcp/test_manager.py index f1d167a9..61cdde56 100644 --- a/packages/data-designer-engine/tests/engine/mcp/test_manager.py +++ b/packages/data-designer-engine/tests/engine/mcp/test_manager.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations diff --git a/tests_e2e/src/data_designer_e2e_tests/mcp_demo_server.py b/tests_e2e/src/data_designer_e2e_tests/mcp_demo_server.py index 52b68f84..7e1d2a40 100644 --- a/tests_e2e/src/data_designer_e2e_tests/mcp_demo_server.py +++ b/tests_e2e/src/data_designer_e2e_tests/mcp_demo_server.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations diff --git a/tests_e2e/tests/test_mcp_demo.py b/tests_e2e/tests/test_mcp_demo.py index f4e8ccc9..a564a3ee 100644 --- a/tests_e2e/tests/test_mcp_demo.py +++ b/tests_e2e/tests/test_mcp_demo.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations From c6fc66b42934051d793a2e54a3d16ffddbe20483 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Wed, 28 Jan 2026 11:39:23 -0500 Subject: [PATCH 13/15] Rewrite with local mcp --- Makefile | 15 +- .../assets/recipes/mcp_and_tooluse/.gitignore | 5 +- .../recipes/mcp_and_tooluse/dnd_rules_qa.py | 576 +++++++++--------- pyproject.toml | 4 + uv.lock | 38 ++ 5 files changed, 356 insertions(+), 282 deletions(-) diff --git a/Makefile b/Makefile index 88b04aa1..146c6055 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,7 @@ help: @echo " install - Install all packages (config โ†’ engine โ†’ interface)" @echo " install-dev - Install all packages + dev tools (pytest, etc.)" @echo " install-dev-notebooks - Install all packages + dev + notebook tools" + @echo " install-dev-recipes - Install all packages + dev + recipe dependencies" @echo "" @echo "๐Ÿงช Testing (all packages):" @echo " test - Run all unit tests" @@ -150,6 +151,16 @@ install-dev-notebooks: @echo "" @echo "๐Ÿ’ก Run 'make test-run-tutorials' to test notebook tutorials" +install-dev-recipes: + @echo "๐Ÿ“ฆ Installing DataDesigner workspace with recipe dependencies..." + @echo " Packages: data-designer-config โ†’ data-designer-engine โ†’ data-designer" + @echo " Groups: dev + recipes (bm25s, pymupdf, etc.)" + uv sync --all-packages --group dev --group recipes + $(call install-pre-commit-hooks) + @echo "โœ… Dev + recipes installation complete!" + @echo "" + @echo "๐Ÿ’ก Run 'make test-run-recipes' to test recipe scripts" + # ============================================================================== # TESTING # ============================================================================== @@ -286,7 +297,7 @@ test-run-recipes: trap "rm -rf $$RECIPE_WORKDIR" EXIT; \ for f in docs/assets/recipes/**/*.py; do \ echo " ๐Ÿ“œ Running $$f..."; \ - (cd "$$RECIPE_WORKDIR" && uv run --project "$(REPO_PATH)" --group notebooks python "$(REPO_PATH)/$$f" --model-alias nvidia-text --artifact-path "$$RECIPE_WORKDIR" --num-records 5) || exit 1; \ + (cd "$$RECIPE_WORKDIR" && uv run --project "$(REPO_PATH)" --group notebooks --group recipes python "$(REPO_PATH)/$$f" --model-alias nvidia-text --artifact-path "$$RECIPE_WORKDIR" --num-records 5) || exit 1; \ done; \ echo "๐Ÿงน Cleaning up recipe artifacts..."; \ rm -rf "$$RECIPE_WORKDIR"; \ @@ -576,7 +587,7 @@ clean-test-coverage: format format-check format-check-config format-check-engine format-check-interface \ format-config format-engine format-interface \ generate-colab-notebooks help \ - install install-dev install-dev-notebooks \ + install install-dev install-dev-notebooks install-dev-recipes \ lint lint-config lint-engine lint-fix lint-fix-config lint-fix-engine lint-fix-interface lint-interface \ perf-import publish serve-docs-locally show-versions \ test test-config test-config-isolated test-e2e test-engine test-engine-isolated \ diff --git a/docs/assets/recipes/mcp_and_tooluse/.gitignore b/docs/assets/recipes/mcp_and_tooluse/.gitignore index a3745ece..a1363379 100644 --- a/docs/assets/recipes/mcp_and_tooluse/.gitignore +++ b/docs/assets/recipes/mcp_and_tooluse/.gitignore @@ -1,4 +1 @@ -artifacts/ -downloads/ -docs_mcp_store/ -docs_mcp_config.yaml \ No newline at end of file +*.pdf diff --git a/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py b/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py index 36abfa52..5c34c1bf 100644 --- a/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py +++ b/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py @@ -1,42 +1,41 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""MCP + Tool Use Recipe (D&D Q&A) +"""MCP + Tool Use Recipe (D&D Q&A) with BM25S Lexical Search This recipe demonstrates an end-to-end MCP tool-calling workflow: 1) Download the Dungeons & Dragons v1 rules PDF. -2) Index it with `docs-mcp-server` (MCP search). +2) Index it with BM25S for fast lexical search. 3) Use Data Designer tool calls (`search_docs`) to generate grounded Q&A pairs. Prerequisites: -- Node.js 20+ (for `npx @arabold/docs-mcp-server`) -- `OPENAI_API_KEY` for docs-mcp-server embeddings - `NVIDIA_API_KEY` if using `--model-alias nvidia-text` (default) +- Recipe dependencies: Install with `make install-dev-recipes` Run: + # Install recipe dependencies (preserves workspace packages) + make install-dev-recipes + + # Then run the recipe uv run docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py + # Or run all recipes via Makefile + make test-run-recipes + Common flags: - # First run: scrape the PDF (default) + # Generate a few Q&A pairs uv run docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py --num-records 3 - # Subsequent runs: reuse the indexed corpus - uv run docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py --skip-scrape --num-records 5 - - # Customize embeddings - uv run docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py \ - --embedding-model text-embedding-3-small \ - --embedding-api-key-env OPENAI_API_KEY + # Use a different model + uv run docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py --model-alias gpt-4o - # Increase PDF size limit if needed (default: 40MB) - uv run docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py --document-max-size-mb 60 +Server mode (used internally by Data Designer): + python docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py serve Notes: -- The script writes a docs-mcp config file to `docs_mcp_store/docs_mcp_config.yaml` to raise the PDF size limit. -- Downloads and artifacts are stored locally under this directory. -- If you want to use a different LLM provider for generation, set `--model-alias` and the - corresponding API key for that provider. +- Downloads are stored locally under this directory. +- The BM25S index is built at server startup from the PDF. """ from __future__ import annotations @@ -44,13 +43,13 @@ import argparse import json import os -import shutil -import socket -import subprocess -import time +import sys from pathlib import Path from urllib.request import urlretrieve +import bm25s +import fitz +from mcp.server.fastmcp import FastMCP from pydantic import BaseModel, Field import data_designer.config as dd @@ -59,63 +58,33 @@ PDF_URL = "https://idiscepolidellamanticora.wordpress.com/wp-content/uploads/2012/09/tsr2010-players-handbook.pdf" PDF_FILENAME = "tsr2010-players-handbook.pdf" +MCP_SERVER_NAME = "dnd-bm25-search" -DOCS_MCP_HOST = "127.0.0.1" -DOCS_MCP_PORT = 6280 -DOCS_MCP_SERVER_NAME = "docs-mcp-server" - -DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" - -DEFAULT_LIBRARY = "dnd-basic-rules" -DEFAULT_LIBRARY_VERSION = "1" -DEFAULT_DOCS_MCP_MAX_SIZE_MB = 40 +# Global state for the BM25 index (populated at server startup) +_bm25_retriever: bm25s.BM25 | None = None +_corpus: list[dict[str, str]] = [] class DndQAPair(BaseModel): question: str = Field(..., description="A question grounded in the D&D rules text.") answer: str = Field(..., description="A concise answer grounded in the supporting passage.") - supporting_passage: str = Field(..., description="A short excerpt (2-4 sentences) copied from the search result.") - source_url: str = Field(..., description="The URL for the supporting passage.") + supporting_passage: str = Field( + ..., description="A short excerpt (2-4 sentences) copied from the search result that supports the answer." + ) + citation: str = Field( + ..., description="The citation (e.g. source url, page number, etc) of the supporting passage." + ) class DndTopicList(BaseModel): topics: list[str] = Field( ..., - description="High-level topics from the D&D rules table of contents (up to 10).", + description="High-level topics from the D&D rulebook.", ) -def resolve_embedding_provider(embedding_model: str) -> str: - if ":" in embedding_model: - return embedding_model.split(":", 1)[0] - return "openai" - - -def build_docs_mcp_env( - store_path: Path, - embedding_model: str, - embedding_api_base: str | None, - embedding_api_key_env: str, -) -> dict[str, str]: - env = os.environ.copy() - env["DOCS_MCP_STORE_PATH"] = str(store_path) - env["DOCS_MCP_TELEMETRY"] = "false" - env["DOCS_MCP_EMBEDDING_MODEL"] = embedding_model - - provider = resolve_embedding_provider(embedding_model) - if provider == "openai": - api_key = os.environ.get(embedding_api_key_env) - if not api_key: - raise RuntimeError( - f"{embedding_api_key_env} must be set to use embeddings with provider 'openai'." - ) - env["OPENAI_API_KEY"] = api_key - if embedding_api_base: - env["OPENAI_API_BASE"] = embedding_api_base - return env - - def download_pdf(pdf_url: str, destination_dir: Path) -> Path: + """Download the PDF if not already cached.""" destination_dir.mkdir(parents=True, exist_ok=True) pdf_path = destination_dir / PDF_FILENAME if not pdf_path.exists() or pdf_path.stat().st_size == 0: @@ -123,93 +92,116 @@ def download_pdf(pdf_url: str, destination_dir: Path) -> Path: return pdf_path -def scrape_pdf_with_docs_mcp( - npx_path: str, - env: dict[str, str], - library: str, - version: str | None, - pdf_uri: str, - config_path: Path, -) -> None: - command = [ - npx_path, - "--yes", - "@arabold/docs-mcp-server@latest", - "scrape", - library, - pdf_uri, - "--max-pages", - "1", - "--max-depth", - "1", - "--scope", - "subpages", - ] - if version: - command.extend(["--version", version]) - command.extend(["--config", str(config_path)]) - subprocess.run(command, env=env, check=True) - - -def start_docs_mcp_server( - npx_path: str, - env: dict[str, str], - host: str, - port: int, - config_path: Path, -) -> subprocess.Popen[str]: - command = [ - npx_path, - "--yes", - "@arabold/docs-mcp-server@latest", - "--protocol", - "http", - "--host", - host, - "--port", - str(port), - "--config", - str(config_path), - ] - return subprocess.Popen(command, env=env) - - -def wait_for_port(host: str, port: int, timeout_sec: float) -> None: - deadline = time.monotonic() + timeout_sec - while time.monotonic() < deadline: - try: - with socket.create_connection((host, port), timeout=1.0): - return - except OSError: - time.sleep(0.5) - raise TimeoutError(f"docs-mcp-server did not start on {host}:{port} within {timeout_sec} seconds.") - - -def stop_process(process: subprocess.Popen[str]) -> None: - if process.poll() is not None: - return - process.terminate() - try: - process.wait(timeout=10) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=5) - - -def write_docs_mcp_config(store_path: Path, max_document_size_mb: int) -> Path: - config_path = store_path / "docs_mcp_config.yaml" - max_size_bytes = max_document_size_mb * 1024 * 1024 - config_contents = f"document:\n maxSize: {max_size_bytes}\n" - config_path.write_text(config_contents, encoding="utf-8") - return config_path - - -def build_config( - model_alias: str, - server_name: str, - library: str, - version: str | None, -) -> dd.DataDesignerConfigBuilder: +def extract_pdf_text(pdf_path: Path) -> list[dict[str, str]]: + """Extract text from PDF, returning a list of passages with metadata. + + Each passage corresponds to a page from the PDF. + """ + passages: list[dict[str, str]] = [] + doc = fitz.open(pdf_path) + + for page_num in range(len(doc)): + page = doc[page_num] + text = page.get_text("text").strip() + if text: + passages.append( + { + "text": text, + "page": str(page_num + 1), + "source": pdf_path.name, + } + ) + + doc.close() + return passages + + +def build_bm25_index(passages: list[dict[str, str]]) -> bm25s.BM25: + """Build a BM25S index from the extracted passages.""" + corpus_texts = [p["text"] for p in passages] + corpus_tokens = bm25s.tokenize(corpus_texts, stopwords="en") + + retriever = bm25s.BM25() + retriever.index(corpus_tokens) + + return retriever + + +def initialize_search_index(downloads_dir: Path) -> None: + """Download PDF and build the BM25 index.""" + global _bm25_retriever, _corpus + + pdf_path = download_pdf(PDF_URL, downloads_dir) + _corpus = extract_pdf_text(pdf_path) + _bm25_retriever = build_bm25_index(_corpus) + + +# MCP Server Definition +mcp_server = FastMCP(MCP_SERVER_NAME) + + +@mcp_server.tool() +def search_docs(query: str, limit: int = 5) -> str: + """Search through documents using BM25 lexical search. + + BM25 is a keyword-based retrieval algorithm that matches exact terms. For best results: + + - Use specific keywords, not full questions (e.g., "fireball damage radius" not "How much damage does fireball do?") + - Include domain-specific terms that would appear in the source text (e.g., "THAC0", "saving throw", "armor class") + - Combine multiple relevant terms to narrow results (e.g., "cleric spell healing cure") + - Try synonyms or alternative phrasings if initial searches return poor results + - Avoid filler words and focus on content-bearing terms + + Examples: + Good queries: + - "ranger tracking wilderness survival" + - "magic missile automatic hit" + - "dwarf constitution bonus saving throw" + + Less effective queries: + - "What are the rules for rangers?" + - "Tell me about magic missile" + - "How do dwarves work?" + + Args: + query: Search query string - use specific keywords for best results + limit: Maximum number of results to return (default: 5) + + Returns: + JSON string with search results including text excerpts and page numbers + """ + global _bm25_retriever, _corpus + + if _bm25_retriever is None or not _corpus: + return json.dumps({"error": "Search index not initialized"}) + + query_tokens = bm25s.tokenize([query], stopwords="en") + results, scores = _bm25_retriever.retrieve(query_tokens, k=min(limit, len(_corpus))) + + search_results: list[dict[str, str | float]] = [] + for i in range(results.shape[1]): + doc_idx = results[0, i] + score = float(scores[0, i]) + + if score <= 0: + continue + + passage = _corpus[doc_idx] + search_results.append( + { + "text": passage["text"][:2000], + "page": passage["page"], + "source": passage["source"], + "score": round(score, 4), + "url": f"file://{passage['source']}#page={passage['page']}", + } + ) + + return json.dumps({"results": search_results, "query": query, "total": len(search_results)}) + + +def build_config(model_alias: str, server_name: str) -> dd.DataDesignerConfigBuilder: + """Build the Data Designer configuration for D&D Q&A generation.""" config_builder = dd.DataDesignerConfigBuilder() config_builder.add_column( dd.SamplerColumnConfig( @@ -223,27 +215,21 @@ def build_config( tool_config = dd.MCPToolConfig( server_name=server_name, tool_names=["search_docs"], - max_tool_calls=5, - timeout_sec=15.0, + max_tool_calls=100, + timeout_sec=30.0, ) - topic_prompt_lines = [ - "You are extracting high-level topics from the Dungeons & Dragons Basic Rules (v1).", - "First, call the MCP tool `search_docs` to find the table of contents or overview sections.", - "Use the tool with:", - f'- library: "{library}"', - ] - if version: - topic_prompt_lines.append(f'- version: "{version}"') + topic_prompt = "Extract a high-level list of all topics covered by this document." config_builder.add_column( dd.LLMStructuredColumnConfig( name="topic_candidates", model_alias=model_alias, - prompt="\n".join(topic_prompt_lines), + prompt=topic_prompt, system_prompt=( "You must call the search_docs tool before answering. " - "Do not use outside knowledge; only use tool results." + "Do not use outside knowledge; only use tool results. " + "You can use as many tool calls as required to answer the user query." ), output_format=DndTopicList, tool_config=tool_config, @@ -257,35 +243,21 @@ def build_config( ) ) - prompt_lines = [ - "You are generating Q&A pairs grounded in the Dungeons & Dragons Basic Rules (v1).", - "First, call the MCP tool `search_docs` to retrieve relevant rules text.", - "Use the tool with:", - f'- library: "{library}"', - ] - if version: - prompt_lines.append(f'- version: "{version}"') - prompt_lines.extend( - [ - '- query: "Dungeons & Dragons basic rules {{ topic }}"', - "- limit: 3", - "", - "If the tool returns no results, broaden the query and try again.", - "Then choose one result and create a grounded Q&A pair.", - "", - "Return JSON with keys: question, answer, supporting_passage, source_url.", - "The supporting_passage must be a 2-4 sentence excerpt copied from the tool result.", - ] - ) + qa_prompt = """\ +Create a question-answer pair on the topic "{{topic}}", with supporting text and citation. +The supporting_passage must be a 2-4 sentence excerpt copied from the tool result that demonstrates +why the answer is correct. +""" config_builder.add_column( dd.LLMStructuredColumnConfig( name="qa_pair", model_alias=model_alias, - prompt="\n".join(prompt_lines), + prompt=qa_prompt, system_prompt=( "You must call the search_docs tool before answering. " - "Do not use outside knowledge; only use tool results." + "Do not use outside knowledge; only use tool results. " + "You can use as many tool calls as required to answer the user query." ), output_format=DndQAPair, tool_config=tool_config, @@ -312,8 +284,8 @@ def build_config( ) config_builder.add_column( dd.ExpressionColumnConfig( - name="source_url", - expr="{{ qa_pair.source_url }}", + name="citation", + expr="{{ qa_pair.citation }}", ) ) return config_builder @@ -322,126 +294,178 @@ def build_config( def generate_preview( config_builder: dd.DataDesignerConfigBuilder, num_records: int, - artifact_path: Path | str | None, - mcp_server: dd.MCPServerConfig, + mcp_server_config: dd.MCPServerConfig, ) -> PreviewResults: - data_designer = DataDesigner(artifact_path=artifact_path, mcp_servers=[mcp_server]) + """Run Data Designer preview with the MCP server.""" + data_designer = DataDesigner(mcp_servers=[mcp_server_config]) data_designer.set_run_config(dd.RunConfig(include_full_traces=True)) return data_designer.preview(config_builder, num_records=num_records) +def _truncate(text: str, max_length: int = 100) -> str: + """Truncate text to max_length, adding ellipsis if needed.""" + text = text.replace("\n", " ").strip() + if len(text) <= max_length: + return text + return text[: max_length - 3] + "..." + + +def _format_trace_step(msg: dict[str, object]) -> str: + """Format a single trace message as a concise one-liner.""" + role = msg.get("role", "unknown") + content = msg.get("content", "") + reasoning = msg.get("reasoning_content") + tool_calls = msg.get("tool_calls") + tool_call_id = msg.get("tool_call_id") + + if role == "system": + return f"[bold cyan]system[/]({_truncate(str(content))})" + + if role == "user": + return f"[bold green]user[/]({_truncate(str(content))})" + + if role == "assistant": + parts: list[str] = [] + if reasoning: + parts.append(f"[bold magenta]reasoning[/]({_truncate(str(reasoning))})") + if tool_calls and isinstance(tool_calls, list): + for tc in tool_calls: + if isinstance(tc, dict): + func = tc.get("function", {}) + if isinstance(func, dict): + name = func.get("name", "?") + args = func.get("arguments", "") + parts.append(f"[bold yellow]tool_call[/]({name}: {_truncate(str(args), 60)})") + if content: + parts.append(f"[bold blue]content[/]({_truncate(str(content))})") + return "\n".join(parts) if parts else "[bold blue]assistant[/](empty)" + + if role == "tool": + tool_id = str(tool_call_id or "?")[:8] + return f"[bold red]tool_response[/]([{tool_id}] {_truncate(str(content), 80)})" + + return f"[dim]{role}[/]({_truncate(str(content))})" + + +def _display_column_trace(column_name: str, trace: list[dict[str, object]]) -> None: + """Display a trace for a single column using Rich Panel.""" + from rich.console import Console + from rich.panel import Panel + + console = Console() + lines: list[str] = [] + + for msg in trace: + if not isinstance(msg, dict): + continue + formatted = _format_trace_step(msg) + for line in formatted.split("\n"): + lines.append(f" * {line}") + + trace_content = "\n".join(lines) if lines else " (no trace messages)" + panel = Panel( + trace_content, + title=f"[bold]Column Trace: {column_name}[/]", + border_style="blue", + padding=(0, 1), + ) + console.print(panel) + + def display_preview_record(preview_results: PreviewResults) -> None: + """Display a sample record from the preview results with trace visualization.""" + from rich.console import Console + + console = Console() dataset = preview_results.dataset + if dataset is None or dataset.empty: - print("No preview records generated.") + console.print("[red]No preview records generated.[/]") return + record = dataset.iloc[0].to_dict() - print("Sample record:") - print(json.dumps(record, indent=2, default=str)) + + # Find trace columns and their base column names + trace_columns = [col for col in dataset.columns if col.endswith("__trace")] + + # Display non-trace columns as summary + non_trace_record = {k: v for k, v in record.items() if not k.endswith("__trace")} + console.print("\n[bold]Sample Record (data columns):[/]") + console.print(json.dumps(non_trace_record, indent=2, default=str)) + + # Display each trace column in its own panel + if trace_columns: + console.print("\n[bold]Generation Traces:[/]") + for trace_col in trace_columns: + base_name = trace_col.replace("__trace", "") + trace_data = record.get(trace_col) + if isinstance(trace_data, list): + _display_column_trace(base_name, trace_data) + + preview_results.display_sample_record() + + +def serve() -> None: + """Run the MCP server (called when launched as subprocess by Data Designer).""" + downloads_dir = Path(os.environ.get("PDF_CACHE_DIR", Path(__file__).resolve().parent / "downloads")) + initialize_search_index(downloads_dir) + mcp_server.run() def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Generate D&D Q&A pairs using MCP tool calls.") - parser.add_argument("--model-alias", type=str, default="nvidia-text") - parser.add_argument("--num-records", type=int, default=4) - parser.add_argument("--artifact-path", type=str, default=None) - parser.add_argument("--library", type=str, default=DEFAULT_LIBRARY) - parser.add_argument("--version", type=str, default=DEFAULT_LIBRARY_VERSION) - parser.add_argument("--skip-scrape", action="store_true") - parser.add_argument("--port", type=int, default=DOCS_MCP_PORT) - parser.add_argument( - "--document-max-size-mb", - type=int, - default=DEFAULT_DOCS_MCP_MAX_SIZE_MB, - help="Docs MCP max document size for PDFs (default: 40MB).", - ) - parser.add_argument( - "--embedding-model", - type=str, - default="text-embedding-3-small", - help="Docs MCP embedding model (default: text-embedding-3-small).", - ) - parser.add_argument( - "--embedding-api-base", - type=str, - default=None, - help=f"Optional OpenAI-compatible base URL (omit to use {DEFAULT_OPENAI_BASE_URL}).", - ) - parser.add_argument( - "--embedding-api-key-env", - type=str, - default="OPENAI_API_KEY", - help="Env var name holding the embeddings API key (default: OPENAI_API_KEY).", - ) + """Parse command line arguments.""" + parser = argparse.ArgumentParser(description="Generate D&D Q&A pairs using MCP tool calls with BM25S search.") + subparsers = parser.add_subparsers(dest="command") + + # 'serve' subcommand for running the MCP server + subparsers.add_parser("serve", help="Run the MCP server (used by Data Designer)") + + # Default command arguments (demo mode) + parser.add_argument("--model-alias", type=str, default="nvidia-text", help="Model alias to use for generation") + parser.add_argument("--num-records", type=int, default=4, help="Number of Q&A pairs to generate") + return parser.parse_args() def main() -> None: + """Main entry point for the demo.""" args = parse_args() + # Handle 'serve' subcommand + if args.command == "serve": + serve() + return + + # Demo mode: run Data Designer with the BM25S MCP server if os.environ.get("NVIDIA_API_KEY") is None and args.model_alias.startswith("nvidia"): raise RuntimeError("NVIDIA_API_KEY must be set when using NVIDIA model aliases.") - npx_path = shutil.which("npx") - if not npx_path: - raise RuntimeError("npx was not found. Install Node.js 20+ and ensure npx is on PATH.") - base_dir = Path(__file__).resolve().parent downloads_dir = base_dir / "downloads" - store_path = base_dir / "docs_mcp_store" - store_path.mkdir(parents=True, exist_ok=True) - config_path = write_docs_mcp_config(store_path, args.document_max_size_mb) - pdf_path = download_pdf(PDF_URL, downloads_dir) - pdf_uri = pdf_path.resolve().as_uri() + # Ensure PDF is downloaded before starting server + download_pdf(PDF_URL, downloads_dir) - docs_mcp_env = build_docs_mcp_env( - store_path=store_path, - embedding_model=args.embedding_model, - embedding_api_base=args.embedding_api_base, - embedding_api_key_env=args.embedding_api_key_env, + # Configure MCP server to run via stdio transport + mcp_server_config = dd.MCPServerConfig( + name=MCP_SERVER_NAME, + command=sys.executable, + args=[str(Path(__file__).resolve()), "serve"], + env={"PDF_CACHE_DIR": str(downloads_dir)}, ) - if not args.skip_scrape: - scrape_pdf_with_docs_mcp( - npx_path=npx_path, - env=docs_mcp_env, - library=args.library, - version=args.version, - pdf_uri=pdf_uri, - config_path=config_path, - ) - - server_process = start_docs_mcp_server( - npx_path=npx_path, - env=docs_mcp_env, - host=DOCS_MCP_HOST, - port=args.port, - config_path=config_path, + config_builder = build_config( + model_alias=args.model_alias, + server_name=MCP_SERVER_NAME, ) - try: - wait_for_port(DOCS_MCP_HOST, args.port, timeout_sec=60) - mcp_server = dd.MCPServerConfig( - name=DOCS_MCP_SERVER_NAME, - url=f"http://{DOCS_MCP_HOST}:{args.port}/sse", - ) + preview_results = generate_preview( + config_builder=config_builder, + num_records=args.num_records, + mcp_server_config=mcp_server_config, + ) - config_builder = build_config( - model_alias=args.model_alias, - server_name=DOCS_MCP_SERVER_NAME, - library=args.library, - version=args.version, - ) - preview_results = generate_preview( - config_builder=config_builder, - num_records=args.num_records, - artifact_path=args.artifact_path or base_dir / "artifacts", - mcp_server=mcp_server, - ) - display_preview_record(preview_results) - finally: - stop_process(server_process) + display_preview_record(preview_results) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index f2ff9ce5..6b25c924 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,10 @@ notebooks = [ "jupyter>=1.0.0,<2", "pillow>=12.0.0,<13", ] +recipes = [ + "bm25s>=0.2.0", + "pymupdf>=1.24.0", +] [tool.pytest.ini_options] testpaths = [ diff --git a/uv.lock b/uv.lock index f13e8de1..f4ee7959 100644 --- a/uv.lock +++ b/uv.lock @@ -352,6 +352,21 @@ css = [ { name = "tinycss2" }, ] +[[package]] +name = "bm25s" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/72/5ad06c30991ba494242785a3ab8987deb01c07dfc1c492847bde221e62bf/bm25s-0.2.14.tar.gz", hash = "sha256:7b6717770fffbdb3b962e5fe8ef1e6eac7f285d0fbc14484b321e136df837139", size = 59266, upload-time = "2025-09-08T17:06:30.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/3e/e3ae2f0fb0f8f46f9c787fa419ca5203ff850d0630749a26baf0a6570453/bm25s-0.2.14-py3-none-any.whl", hash = "sha256:76cdb70ae40747941b150a1ec16a9c20c576d6534d0a3c3eebb303c779b3cf65", size = 55128, upload-time = "2025-09-08T17:06:29.324Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -878,6 +893,10 @@ notebooks = [ { name = "jupyter" }, { name = "pillow" }, ] +recipes = [ + { name = "bm25s" }, + { name = "pymupdf" }, +] [package.metadata] @@ -908,6 +927,10 @@ notebooks = [ { name = "jupyter", specifier = ">=1.0.0,<2" }, { name = "pillow", specifier = ">=12.0.0,<13" }, ] +recipes = [ + { name = "bm25s", specifier = ">=0.2.0" }, + { name = "pymupdf", specifier = ">=1.24.0" }, +] [[package]] name = "datasets" @@ -3937,6 +3960,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/6d/b6ee155462a0156b94312bdd82d2b92ea56e909740045a87ccb98bf52405/pymdown_extensions-10.20.1-py3-none-any.whl", hash = "sha256:24af7feacbca56504b313b7b418c4f5e1317bb5fea60f03d57be7fcc40912aa0", size = 268768, upload-time = "2026-01-24T05:56:54.537Z" }, ] +[[package]] +name = "pymupdf" +version = "1.26.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/d6/09b28f027b510838559f7748807192149c419b30cb90e6d5f0cf916dc9dc/pymupdf-1.26.7.tar.gz", hash = "sha256:71add8bdc8eb1aaa207c69a13400693f06ad9b927bea976f5d5ab9df0bb489c3", size = 84327033, upload-time = "2025-12-11T21:48:50.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/35/cd74cea1787b2247702ef8522186bdef32e9cb30a099e6bb864627ef6045/pymupdf-1.26.7-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:07085718dfdae5ab83b05eb5eb397f863bcc538fe05135318a01ea353e7a1353", size = 23179369, upload-time = "2025-12-11T21:47:21.587Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/448b6172927c829c6a3fba80078d7b0a016ebbe2c9ee528821f5ea21677a/pymupdf-1.26.7-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:31aa9c8377ea1eea02934b92f4dcf79fb2abba0bf41f8a46d64c3e31546a3c02", size = 22470101, upload-time = "2025-12-11T21:47:37.105Z" }, + { url = "https://files.pythonhosted.org/packages/65/e7/47af26f3ac76be7ac3dd4d6cc7ee105948a8355d774e5ca39857bf91c11c/pymupdf-1.26.7-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e419b609996434a14a80fa060adec72c434a1cca6a511ec54db9841bc5d51b3c", size = 23502486, upload-time = "2025-12-12T09:51:25.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6b/3de1714d734ff949be1e90a22375d0598d3540b22ae73eb85c2d7d1f36a9/pymupdf-1.26.7-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:69dfc78f206a96e5b3ac22741263ebab945fdf51f0dbe7c5757c3511b23d9d72", size = 24115727, upload-time = "2025-12-11T21:47:51.274Z" }, + { url = "https://files.pythonhosted.org/packages/62/9b/f86224847949577a523be2207315ae0fd3155b5d909cd66c274d095349a3/pymupdf-1.26.7-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1d5106f46e1ca0d64d46bd51892372a4f82076bdc14a9678d33d630702abca36", size = 24324386, upload-time = "2025-12-12T14:58:45.483Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/a117d39092ca645fde8b903f4a941d9aa75b370a67b4f1f435f56393dc5a/pymupdf-1.26.7-cp310-abi3-win32.whl", hash = "sha256:7c9645b6f5452629c747690190350213d3e5bbdb6b2eca227d82702b327f6eee", size = 17203888, upload-time = "2025-12-12T13:59:57.613Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c3/d0047678146c294469c33bae167c8ace337deafb736b0bf97b9bc481aa65/pymupdf-1.26.7-cp310-abi3-win_amd64.whl", hash = "sha256:425b1befe40d41b72eb0fe211711c7ae334db5eb60307e9dd09066ed060cceba", size = 18405952, upload-time = "2025-12-11T21:48:02.947Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" From 321f925eca67b3f2055b31cacdc86c7a6b18864e Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Wed, 28 Jan 2026 14:41:18 -0500 Subject: [PATCH 14/15] Update approach --- .../assets/recipes/mcp_and_tooluse/.gitignore | 1 - .../{dnd_rules_qa.py => pdf_qa.py} | 212 ++++++++++++------ 2 files changed, 149 insertions(+), 64 deletions(-) delete mode 100644 docs/assets/recipes/mcp_and_tooluse/.gitignore rename docs/assets/recipes/mcp_and_tooluse/{dnd_rules_qa.py => pdf_qa.py} (66%) diff --git a/docs/assets/recipes/mcp_and_tooluse/.gitignore b/docs/assets/recipes/mcp_and_tooluse/.gitignore deleted file mode 100644 index a1363379..00000000 --- a/docs/assets/recipes/mcp_and_tooluse/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.pdf diff --git a/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py b/docs/assets/recipes/mcp_and_tooluse/pdf_qa.py similarity index 66% rename from docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py rename to docs/assets/recipes/mcp_and_tooluse/pdf_qa.py index 5c34c1bf..9fd74c8e 100644 --- a/docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py +++ b/docs/assets/recipes/mcp_and_tooluse/pdf_qa.py @@ -1,12 +1,12 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""MCP + Tool Use Recipe (D&D Q&A) with BM25S Lexical Search +"""MCP + Tool Use Recipe: Document Q&A with BM25S Lexical Search This recipe demonstrates an end-to-end MCP tool-calling workflow: -1) Download the Dungeons & Dragons v1 rules PDF. -2) Index it with BM25S for fast lexical search. +1) Load one or more PDF documents from URLs or local paths. +2) Index them with BM25S for fast lexical search. 3) Use Data Designer tool calls (`search_docs`) to generate grounded Q&A pairs. Prerequisites: @@ -17,35 +17,42 @@ # Install recipe dependencies (preserves workspace packages) make install-dev-recipes - # Then run the recipe - uv run docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py + # Then run the recipe (uses default sample PDF) + uv run docs/assets/recipes/mcp_and_tooluse/pdf_qa.py + + # With custom PDFs (can specify multiple) + uv run docs/assets/recipes/mcp_and_tooluse/pdf_qa.py --pdf /path/to/doc.pdf + uv run docs/assets/recipes/mcp_and_tooluse/pdf_qa.py --pdf https://example.com/doc.pdf --pdf ./local.pdf # Or run all recipes via Makefile make test-run-recipes Common flags: # Generate a few Q&A pairs - uv run docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py --num-records 3 + uv run docs/assets/recipes/mcp_and_tooluse/pdf_qa.py --num-records 3 # Use a different model - uv run docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py --model-alias gpt-4o + uv run docs/assets/recipes/mcp_and_tooluse/pdf_qa.py --model-alias gpt-4o Server mode (used internally by Data Designer): - python docs/assets/recipes/mcp_and_tooluse/dnd_rules_qa.py serve + python docs/assets/recipes/mcp_and_tooluse/pdf_qa.py serve Notes: -- Downloads are stored locally under this directory. -- The BM25S index is built at server startup from the PDF. +- URLs are streamed directly into memory (no local download required). +- Local file paths are read directly from disk. +- The BM25S index is built at server startup from all provided PDFs. """ from __future__ import annotations import argparse +import io import json import os import sys from pathlib import Path -from urllib.request import urlretrieve +from urllib.parse import urlparse +from urllib.request import urlopen import bm25s import fitz @@ -56,17 +63,16 @@ from data_designer.config.preview_results import PreviewResults from data_designer.interface import DataDesigner -PDF_URL = "https://idiscepolidellamanticora.wordpress.com/wp-content/uploads/2012/09/tsr2010-players-handbook.pdf" -PDF_FILENAME = "tsr2010-players-handbook.pdf" -MCP_SERVER_NAME = "dnd-bm25-search" +DEFAULT_PDF_URL = "https://research.nvidia.com/labs/nemotron/files/NVIDIA-Nemotron-3-Nano-Technical-Report.pdf" +MCP_SERVER_NAME = "doc-bm25-search" # Global state for the BM25 index (populated at server startup) _bm25_retriever: bm25s.BM25 | None = None _corpus: list[dict[str, str]] = [] -class DndQAPair(BaseModel): - question: str = Field(..., description="A question grounded in the D&D rules text.") +class QAPair(BaseModel): + question: str = Field(..., description="A question grounded in the document text.") answer: str = Field(..., description="A concise answer grounded in the supporting passage.") supporting_passage: str = Field( ..., description="A short excerpt (2-4 sentences) copied from the search result that supports the answer." @@ -76,29 +82,48 @@ class DndQAPair(BaseModel): ) -class DndTopicList(BaseModel): +class TopicList(BaseModel): topics: list[str] = Field( ..., - description="High-level topics from the D&D rulebook.", + description="High-level topics covered by the document.", ) -def download_pdf(pdf_url: str, destination_dir: Path) -> Path: - """Download the PDF if not already cached.""" - destination_dir.mkdir(parents=True, exist_ok=True) - pdf_path = destination_dir / PDF_FILENAME - if not pdf_path.exists() or pdf_path.stat().st_size == 0: - urlretrieve(pdf_url, pdf_path) - return pdf_path +def _is_url(path_or_url: str) -> bool: + """Check if the given string is a URL.""" + parsed = urlparse(path_or_url) + return parsed.scheme in ("http", "https") + + +def _get_source_name(path_or_url: str) -> str: + """Extract a human-readable source name from a path or URL.""" + if _is_url(path_or_url): + parsed = urlparse(path_or_url) + return Path(parsed.path).name or parsed.netloc + return Path(path_or_url).name -def extract_pdf_text(pdf_path: Path) -> list[dict[str, str]]: - """Extract text from PDF, returning a list of passages with metadata. +def extract_pdf_text(path_or_url: str) -> list[dict[str, str]]: + """Extract text from a PDF file or URL, returning a list of passages with metadata. Each passage corresponds to a page from the PDF. + + Args: + path_or_url: Either a local file path or a URL to a PDF document. + URLs are streamed directly into memory without saving to disk. + + Returns: + List of passage dictionaries with 'text', 'page', and 'source' keys. """ passages: list[dict[str, str]] = [] - doc = fitz.open(pdf_path) + source_name = _get_source_name(path_or_url) + + if _is_url(path_or_url): + with urlopen(path_or_url) as response: + pdf_bytes = response.read() + doc = fitz.open(stream=io.BytesIO(pdf_bytes), filetype="pdf") + else: + doc = fitz.open(path_or_url) for page_num in range(len(doc)): page = doc[page_num] @@ -108,7 +133,7 @@ def extract_pdf_text(pdf_path: Path) -> list[dict[str, str]]: { "text": text, "page": str(page_num + 1), - "source": pdf_path.name, + "source": source_name, } ) @@ -127,13 +152,21 @@ def build_bm25_index(passages: list[dict[str, str]]) -> bm25s.BM25: return retriever -def initialize_search_index(downloads_dir: Path) -> None: - """Download PDF and build the BM25 index.""" +def initialize_search_index(pdf_sources: list[str]) -> None: + """Load PDFs from paths/URLs and build the BM25 index. + + Args: + pdf_sources: List of PDF file paths or URLs to index. + """ global _bm25_retriever, _corpus - pdf_path = download_pdf(PDF_URL, downloads_dir) - _corpus = extract_pdf_text(pdf_path) - _bm25_retriever = build_bm25_index(_corpus) + _corpus = [] + for source in pdf_sources: + passages = extract_pdf_text(source) + _corpus.extend(passages) + + if _corpus: + _bm25_retriever = build_bm25_index(_corpus) # MCP Server Definition @@ -141,31 +174,33 @@ def initialize_search_index(downloads_dir: Path) -> None: @mcp_server.tool() -def search_docs(query: str, limit: int = 5) -> str: +def search_docs(query: str, limit: int = 5, document: str = "", page: str = "") -> str: """Search through documents using BM25 lexical search. BM25 is a keyword-based retrieval algorithm that matches exact terms. For best results: - - Use specific keywords, not full questions (e.g., "fireball damage radius" not "How much damage does fireball do?") - - Include domain-specific terms that would appear in the source text (e.g., "THAC0", "saving throw", "armor class") - - Combine multiple relevant terms to narrow results (e.g., "cleric spell healing cure") + - Use specific keywords, not full questions (e.g., "configuration parameters timeout" not "How do I set the timeout?") + - Include domain-specific terms that would appear in the source text + - Combine multiple relevant terms to narrow results (e.g., "installation requirements dependencies") - Try synonyms or alternative phrasings if initial searches return poor results - Avoid filler words and focus on content-bearing terms Examples: Good queries: - - "ranger tracking wilderness survival" - - "magic missile automatic hit" - - "dwarf constitution bonus saving throw" + - "error handling retry mechanism" + - "authentication token expiration" + - "memory allocation buffer size" Less effective queries: - - "What are the rules for rangers?" - - "Tell me about magic missile" - - "How do dwarves work?" + - "What are the error handling options?" + - "Tell me about authentication" + - "How does memory work?" Args: query: Search query string - use specific keywords for best results limit: Maximum number of results to return (default: 5) + document: Optional document source name to restrict search to (use list_docs to see available documents) + page: Optional page number to restrict search to (requires document to be specified) Returns: JSON string with search results including text excerpts and page numbers @@ -175,8 +210,15 @@ def search_docs(query: str, limit: int = 5) -> str: if _bm25_retriever is None or not _corpus: return json.dumps({"error": "Search index not initialized"}) + # Validate that page requires document + if page and not document: + return json.dumps({"error": "The 'page' parameter requires 'document' to be specified"}) + query_tokens = bm25s.tokenize([query], stopwords="en") - results, scores = _bm25_retriever.retrieve(query_tokens, k=min(limit, len(_corpus))) + + # When filtering, retrieve more results to ensure we have enough after filtering + retrieve_limit = len(_corpus) if (document or page) else limit + results, scores = _bm25_retriever.retrieve(query_tokens, k=min(retrieve_limit, len(_corpus))) search_results: list[dict[str, str | float]] = [] for i in range(results.shape[1]): @@ -187,6 +229,15 @@ def search_docs(query: str, limit: int = 5) -> str: continue passage = _corpus[doc_idx] + + # Apply document filter + if document and passage["source"] != document: + continue + + # Apply page filter + if page and passage["page"] != page: + continue + search_results.append( { "text": passage["text"][:2000], @@ -197,11 +248,40 @@ def search_docs(query: str, limit: int = 5) -> str: } ) + # Stop once we have enough results + if len(search_results) >= limit: + break + return json.dumps({"results": search_results, "query": query, "total": len(search_results)}) +@mcp_server.tool() +def list_docs() -> str: + """List all documents in the search index with their page counts. + + Returns: + JSON string with a list of documents, each containing the source name and page count. + """ + global _corpus + + if not _corpus: + return json.dumps({"error": "Search index not initialized", "documents": []}) + + doc_pages: dict[str, set[str]] = {} + for passage in _corpus: + source = passage["source"] + page = passage["page"] + if source not in doc_pages: + doc_pages[source] = set() + doc_pages[source].add(page) + + documents = [{"source": source, "page_count": len(pages)} for source, pages in sorted(doc_pages.items())] + + return json.dumps({"documents": documents, "total_documents": len(documents)}) + + def build_config(model_alias: str, server_name: str) -> dd.DataDesignerConfigBuilder: - """Build the Data Designer configuration for D&D Q&A generation.""" + """Build the Data Designer configuration for document Q&A generation.""" config_builder = dd.DataDesignerConfigBuilder() config_builder.add_column( dd.SamplerColumnConfig( @@ -214,24 +294,22 @@ def build_config(model_alias: str, server_name: str) -> dd.DataDesignerConfigBui tool_config = dd.MCPToolConfig( server_name=server_name, - tool_names=["search_docs"], + tool_names=["list_docs", "search_docs"], max_tool_calls=100, timeout_sec=30.0, ) - topic_prompt = "Extract a high-level list of all topics covered by this document." - config_builder.add_column( dd.LLMStructuredColumnConfig( name="topic_candidates", model_alias=model_alias, - prompt=topic_prompt, + prompt="Extract a high-level list of all topics covered by documents our knowledge base.", system_prompt=( - "You must call the search_docs tool before answering. " + "You must call tools before answering. " "Do not use outside knowledge; only use tool results. " "You can use as many tool calls as required to answer the user query." ), - output_format=DndTopicList, + output_format=TopicList, tool_config=tool_config, ) ) @@ -255,11 +333,11 @@ def build_config(model_alias: str, server_name: str) -> dd.DataDesignerConfigBui model_alias=model_alias, prompt=qa_prompt, system_prompt=( - "You must call the search_docs tool before answering. " + "You must call tools before answering. " "Do not use outside knowledge; only use tool results. " "You can use as many tool calls as required to answer the user query." ), - output_format=DndQAPair, + output_format=QAPair, tool_config=tool_config, ) ) @@ -407,14 +485,17 @@ def display_preview_record(preview_results: PreviewResults) -> None: def serve() -> None: """Run the MCP server (called when launched as subprocess by Data Designer).""" - downloads_dir = Path(os.environ.get("PDF_CACHE_DIR", Path(__file__).resolve().parent / "downloads")) - initialize_search_index(downloads_dir) + pdf_sources_json = os.environ.get("PDF_SOURCES", "[]") + pdf_sources = json.loads(pdf_sources_json) + if not pdf_sources: + pdf_sources = [DEFAULT_PDF_URL] + initialize_search_index(pdf_sources) mcp_server.run() def parse_args() -> argparse.Namespace: """Parse command line arguments.""" - parser = argparse.ArgumentParser(description="Generate D&D Q&A pairs using MCP tool calls with BM25S search.") + parser = argparse.ArgumentParser(description="Generate document Q&A pairs using MCP tool calls with BM25S search.") subparsers = parser.add_subparsers(dest="command") # 'serve' subcommand for running the MCP server @@ -423,6 +504,14 @@ def parse_args() -> argparse.Namespace: # Default command arguments (demo mode) parser.add_argument("--model-alias", type=str, default="nvidia-text", help="Model alias to use for generation") parser.add_argument("--num-records", type=int, default=4, help="Number of Q&A pairs to generate") + parser.add_argument( + "--pdf", + type=str, + action="append", + dest="pdfs", + metavar="PATH_OR_URL", + help="PDF file path or URL to index (can be specified multiple times). Defaults to a sample PDF if not provided.", + ) return parser.parse_args() @@ -440,18 +529,15 @@ def main() -> None: if os.environ.get("NVIDIA_API_KEY") is None and args.model_alias.startswith("nvidia"): raise RuntimeError("NVIDIA_API_KEY must be set when using NVIDIA model aliases.") - base_dir = Path(__file__).resolve().parent - downloads_dir = base_dir / "downloads" - - # Ensure PDF is downloaded before starting server - download_pdf(PDF_URL, downloads_dir) + # Use provided PDFs or fall back to default + pdf_sources = args.pdfs if args.pdfs else [DEFAULT_PDF_URL] # Configure MCP server to run via stdio transport mcp_server_config = dd.MCPServerConfig( name=MCP_SERVER_NAME, command=sys.executable, args=[str(Path(__file__).resolve()), "serve"], - env={"PDF_CACHE_DIR": str(downloads_dir)}, + env={"PDF_SOURCES": json.dumps(pdf_sources)}, ) config_builder = build_config( From ecb40342253ce7f28f9dad9fe782df9c23c50317 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Wed, 28 Jan 2026 14:46:02 -0500 Subject: [PATCH 15/15] Upper bound on mcp --- packages/data-designer-engine/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/data-designer-engine/pyproject.toml b/packages/data-designer-engine/pyproject.toml index a3786314..b0068a49 100644 --- a/packages/data-designer-engine/pyproject.toml +++ b/packages/data-designer-engine/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "litellm>=1.73.6,<1.80.12", "lxml>=6.0.2,<7", "marko>=2.1.2,<3", - "mcp>=1.26.0", + "mcp>=1.26.0,<2", "networkx>=3.0,<4", "ruff>=0.14.10,<1", "scipy>=1.11.0,<2",