diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..07ab434 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + lint-test-docs: + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: ['3.13'] + os: [ubuntu-latest] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + uv sync + + - name: Lint and type check + run: uv run poe lint + + - name: Run tests + run: | + uv run pytest diff --git a/nexusmcp/__init__.py b/nexusmcp/__init__.py index de60797..f60cb7b 100644 --- a/nexusmcp/__init__.py +++ b/nexusmcp/__init__.py @@ -4,6 +4,5 @@ from .inbound_gateway import InboundGateway from .service import MCPService from .service_handler import MCPServiceHandler, exclude - from .workflow_transport import WorkflowTransport -__all__ = ["MCPService", "MCPServiceHandler", "InboundGateway", "exclude", "WorkflowTransport"] +__all__ = ["MCPService", "MCPServiceHandler", "InboundGateway", "exclude"] diff --git a/nexusmcp/service_handler.py b/nexusmcp/service_handler.py index a7402c7..e70ebf9 100644 --- a/nexusmcp/service_handler.py +++ b/nexusmcp/service_handler.py @@ -9,11 +9,11 @@ - Decorators for controlling which operations are exposed as MCP tools """ +import logging +import re from collections.abc import Callable from dataclasses import dataclass from typing import Any -import re -import logging import mcp.types import nexusrpc diff --git a/nexusmcp/workflow/__init__.py b/nexusmcp/workflow/__init__.py new file mode 100644 index 0000000..31561f6 --- /dev/null +++ b/nexusmcp/workflow/__init__.py @@ -0,0 +1,3 @@ +from .mcp_client import MCPClient + +__all__ = ["MCPClient"] diff --git a/nexusmcp/workflow/mcp_client.py b/nexusmcp/workflow/mcp_client.py new file mode 100644 index 0000000..dd7a3f8 --- /dev/null +++ b/nexusmcp/workflow/mcp_client.py @@ -0,0 +1,66 @@ +from contextlib import asynccontextmanager +from typing import Any, AsyncGenerator + +import mcp.types as types +from mcp.client.session import ClientSession +from mcp.server.lowlevel import Server +from mcp.shared.memory import create_connected_server_and_client_session +from temporalio import workflow + +from nexusmcp.service import MCPService + + +class MCPClient: + """ + An MCP client for use in Temporal workflows. + + This class provides a client that proxies MCP traffic from a Temporal Workflow to a Temporal + Nexus service. It works by running an MCP server in the workflow whose handlers delegate to + nexus operations, and connecting to it via an in-memory transport. + + Example: + ```python + client = MCPClient("my-endpoint") + async with client.connect() as session: + await session.list_tools() + await session.call_tool("my-service_my-operation", {"arg": "value"}) + ``` + """ + + def __init__( + self, + endpoint: str, + ): + self.endpoint = endpoint + # Run an in-workflow MCP server whose handlers make nexus calls + self.mcp_server = Server("workflow-gateway-mcp-server") + self.mcp_server.list_tools()(self._handle_list_tools) # type: ignore[no-untyped-call] + self.mcp_server.call_tool()(self._handle_call_tool) + + @asynccontextmanager + async def connect(self) -> AsyncGenerator[ClientSession, None]: + """ + Create a connected MCP ClientSession. + + The session is automatically initialized before being yielded. + """ + async with create_connected_server_and_client_session( + self.mcp_server, + raise_exceptions=True, + ) as session: + yield session + + async def _handle_list_tools(self) -> list[types.Tool]: + nexus_client = workflow.create_nexus_client( + endpoint=self.endpoint, + service=MCPService, + ) + return await nexus_client.execute_operation(MCPService.list_tools, None) + + async def _handle_call_tool(self, name: str, arguments: dict[str, Any]) -> Any: + service, _, operation = name.partition("_") + nexus_client = workflow.create_nexus_client( + endpoint=self.endpoint, + service=service, + ) + return await nexus_client.execute_operation(operation, arguments) diff --git a/nexusmcp/workflow_transport.py b/nexusmcp/workflow_transport.py deleted file mode 100644 index 446d503..0000000 --- a/nexusmcp/workflow_transport.py +++ /dev/null @@ -1,152 +0,0 @@ -import asyncio -from contextlib import asynccontextmanager -from typing import Any, AsyncGenerator - -import anyio -import mcp.types as types -import pydantic -from mcp.shared.message import SessionMessage -from temporalio import workflow - -from .service import MCPService - - -class WorkflowTransport: - """ - An MCP Transport for use in Temporal workflows. - - This class provides a transport that proxies MCP requests from a Temporal Workflow to a Temporal - Nexus service. It can be used to make MCP calls via `mcp.ClientSession` from Temporal workflow - code. - - Example: - ```python async with WorkflowNexusTransport("my-endpoint") as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() await session.list_tools() await - session.call_tool("my-service/my-operation", {"arg": "value"}) - ``` - """ - - def __init__( - self, - endpoint: str, - ): - self.endpoint = endpoint - - @asynccontextmanager - async def connect( - self, - ) -> AsyncGenerator[ - tuple[ - anyio.streams.memory.MemoryObjectReceiveStream[SessionMessage], - anyio.streams.memory.MemoryObjectSendStream[SessionMessage], - ], - None, - ]: - client_write, transport_read = anyio.create_memory_object_stream(0) # type: ignore[var-annotated] - transport_write, client_read = anyio.create_memory_object_stream(0) # type: ignore[var-annotated] - - async def message_router() -> None: - try: - async for session_message in transport_read: - request = session_message.message.root - if not isinstance(request, types.JSONRPCRequest): - # Ignore e.g. types.JSONRPCNotification - continue - result: types.Result | types.ErrorData - try: - match request: - case types.JSONRPCRequest(method="initialize"): - result = self._handle_initialize( - types.InitializeRequestParams.model_validate(request.params) - ) - case types.JSONRPCRequest(method="tools/list"): - result = await self._handle_list_tools() - case types.JSONRPCRequest(method="tools/call"): - result = await self._handle_call_tool( - types.CallToolRequestParams.model_validate(request.params) - ) - case _: - result = types.ErrorData( - code=types.METHOD_NOT_FOUND, message=f"Unknown method: {request.method}" - ) - except pydantic.ValidationError as e: - result = types.ErrorData(code=types.INVALID_PARAMS, message=f"Invalid request: {e}") - - match result: - case types.Result(): - response = self._json_rpc_result_response(request, result) - case types.ErrorData(): - response = self._json_rpc_error_response(request, result) - - await transport_write.send(SessionMessage(types.JSONRPCMessage(root=response))) - - except anyio.ClosedResourceError: - pass - finally: - await transport_write.aclose() - - router_task = asyncio.create_task(message_router()) - - try: - yield client_read, client_write - finally: - await client_write.aclose() - router_task.cancel() - try: - await router_task - except asyncio.CancelledError: - pass - await transport_read.aclose() - - def _handle_initialize(self, params: types.InitializeRequestParams) -> types.InitializeResult: - # TODO: MCPService should implement this - return types.InitializeResult( - protocolVersion="2024-11-05", - capabilities=types.ServerCapabilities(tools=types.ToolsCapability()), - serverInfo=types.Implementation( - name="nexus-mcp-transport", - version="0.1.0", - ), - ) - - async def _handle_list_tools(self) -> types.ListToolsResult: - nexus_client = workflow.create_nexus_client( - endpoint=self.endpoint, - service=MCPService, - ) - tools = await nexus_client.execute_operation(MCPService.list_tools, None) - return types.ListToolsResult(tools=tools) - - async def _handle_call_tool(self, params: types.CallToolRequestParams) -> types.CallToolResult: - service, _, operation = params.name.partition("/") - nexus_client = workflow.create_nexus_client( - endpoint=self.endpoint, - service=service, - ) - result: Any = await nexus_client.execute_operation( - operation, - params.arguments or {}, - ) - if isinstance(result, dict): - return types.CallToolResult(content=[], structuredContent=result) - else: - return types.CallToolResult(content=[types.TextContent(type="text", text=str(result))]) - - def _json_rpc_error_response(self, request: types.JSONRPCRequest, error: types.ErrorData) -> types.JSONRPCResponse: - return types.JSONRPCResponse.model_validate( - { - "jsonrpc": "2.0", - "id": request.id, - "error": error.model_dump(), - } - ) - - def _json_rpc_result_response(self, request: types.JSONRPCRequest, result: types.Result) -> types.JSONRPCResponse: - return types.JSONRPCResponse.model_validate( - { - "jsonrpc": "2.0", - "id": request.id, - "result": result.model_dump(), - } - ) diff --git a/pyproject.toml b/pyproject.toml index 83b5a86..bcda848 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,10 +14,12 @@ dependencies = [ [dependency-groups] dev = [ - "pytest>=8.4.1", + "mypy>=1.13.0", + "poethepoet>=0.35.0", + "pyright>=1.1", "pytest-asyncio>=1.1.0", + "pytest>=8.4.1", "ruff>=0.8.0", - "mypy>=1.13.0", ] [tool.pytest.ini_options] @@ -41,5 +43,17 @@ indent-style = "space" python_version = "3.13" strict = true +[tool.poe.tasks] +lint = [ + {cmd = "uv run pyright"}, + {cmd = "uv run mypy --check-untyped-defs ."}, + {cmd = "uv run ruff check --select I"}, + {cmd = "uv run ruff format --check"}, +] +format = [ + {cmd = "uv run ruff check --select I --fix"}, + {cmd = "uv run ruff format"}, +] + [tool.uv.sources] temporalio = { git = "https://github.com/temporalio/sdk-python" } diff --git a/tests/test_inbound_gateway.py b/tests/test_inbound_gateway.py index 16385e7..3b640e3 100644 --- a/tests/test_inbound_gateway.py +++ b/tests/test_inbound_gateway.py @@ -66,8 +66,8 @@ async def test_inbound_gateway() -> None: async def call_tool( - read_stream: anyio.streams.memory.MemoryObjectReceiveStream[SessionMessage], - write_stream: anyio.streams.memory.MemoryObjectSendStream[SessionMessage], + read_stream: anyio.streams.memory.MemoryObjectReceiveStream[SessionMessage], # pyright: ignore[reportAttributeAccessIssue] + write_stream: anyio.streams.memory.MemoryObjectSendStream[SessionMessage], # pyright: ignore[reportAttributeAccessIssue] ) -> None: """Test MCP client connecting via memory streams and calling tools.""" async with ClientSession(read_stream, write_stream) as session: @@ -79,8 +79,8 @@ async def call_tool( print(f"Available tools: {[tool.name for tool in list_tools_result.tools]}") assert len(list_tools_result.tools) == 2 - assert list_tools_result.tools[0].name == "modified-service-name/modified-op-name" - assert list_tools_result.tools[1].name == "modified-service-name/op2" + assert list_tools_result.tools[0].name == "modified-service-name_modified-op-name" + assert list_tools_result.tools[1].name == "modified-service-name_op2" - call_result = await session.call_tool("modified-service-name/modified-op-name", {"name": "World"}) + call_result = await session.call_tool("modified-service-name_modified-op-name", {"name": "World"}) assert call_result.structuredContent == {"message": "Hello, World"} diff --git a/tests/test_service.py b/tests/test_service.py index 69a97a8..2fe6923 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,6 +1,6 @@ import pytest - from nexusrpc.handler import StartOperationContext + from .service import MyInput, TestServiceHandler, mcp_service @@ -27,8 +27,8 @@ async def test_list_tools() -> None: None, ) assert len(tools) == 2 - assert tools[0].name == "modified-service-name/modified-op-name" - assert tools[1].name == "modified-service-name/op2" + assert tools[0].name == "modified-service-name_modified-op-name" + assert tools[1].name == "modified-service-name_op2" assert tools[0].description == "This is a test operation." assert tools[1].description == "This is also a test operation." assert tools[0].inputSchema == MyInput.model_json_schema() diff --git a/tests/test_stateful_session.py b/tests/test_stateful_session.py new file mode 100644 index 0000000..7964233 --- /dev/null +++ b/tests/test_stateful_session.py @@ -0,0 +1,136 @@ +import asyncio +import uuid +from dataclasses import dataclass + +import pytest +from mcp.types import CallToolResult, ListToolsResult +from nexusrpc import Operation, service +from nexusrpc.handler import StartOperationContext, service_handler, sync_operation +from pydantic import BaseModel +from temporalio import nexus, workflow +from temporalio.api.nexus.v1 import EndpointSpec, EndpointTarget +from temporalio.api.operatorservice.v1 import CreateNexusEndpointRequest +from temporalio.client import WithStartWorkflowOperation +from temporalio.common import WorkflowIDConflictPolicy +from temporalio.contrib.pydantic import pydantic_data_converter +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +import nexusmcp.workflow +from nexusmcp import MCPServiceHandler + +mcp_service = MCPServiceHandler() + + +@dataclass +class AppendInput: + session_id: str + value: int + + +class AppendOutput(BaseModel): + data: list[int] + + +@service +class TestService: + append: Operation[AppendInput, AppendOutput] + + +@workflow.defn(sandboxed=False) +class AppendWorkflow: + def __init__(self): + self.data = [] + + @workflow.run + async def run(self) -> None: + await asyncio.Event().wait() + + @workflow.update + async def append(self, input: int) -> AppendOutput: + self.data.append(input) + return AppendOutput(data=self.data) + + +@mcp_service.register +@service_handler(service=TestService) +class TestServiceHandler: + @sync_operation + async def append(self, ctx: StartOperationContext, input: AppendInput) -> AppendOutput: + # TODO: Should we use a custom ClientSession and start the workflow in initialize()? + # wf = nexus.client().get_workflow_handle_for(AppendWorkflow.run, input.session_id) + with_start = WithStartWorkflowOperation( + AppendWorkflow.run, + id=input.session_id, + task_queue=nexus.info().task_queue, + id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING, + ) + return await nexus.client().execute_update_with_start_workflow( + AppendWorkflow.append, + input.value, + start_workflow_operation=with_start, + ) + + +@dataclass +class MCPCallerWorkflowInput: + endpoint: str + + +class MCPCallerWorkflowOutput(BaseModel): + list_tools_result: ListToolsResult + call_tool_results: list[CallToolResult] + + +# sandbox disabled due to use of ThreadLocal by sniffio +# TODO: make this unnecessary +@workflow.defn(sandboxed=False) +class MCPCallerWorkflow: + @workflow.run + async def run(self, input: MCPCallerWorkflowInput) -> MCPCallerWorkflowOutput: + async with nexusmcp.workflow.MCPClient(input.endpoint).connect() as session: + list_tools_result = await session.list_tools() + call_tool_result_1 = await session.call_tool("TestService_append", {"session_id": "123", "value": 1}) + call_tool_result_2 = await session.call_tool("TestService_append", {"session_id": "123", "value": 2}) + return MCPCallerWorkflowOutput( + list_tools_result=list_tools_result, + call_tool_results=[call_tool_result_1, call_tool_result_2], + ) + + +@pytest.mark.asyncio +async def test_workflow_caller() -> None: + endpoint_name = "endpoint" + task_queue = "handler-queue" + + async with await WorkflowEnvironment.start_local(data_converter=pydantic_data_converter) as env: + await env.client.operator_service.create_nexus_endpoint( + CreateNexusEndpointRequest( + spec=EndpointSpec( + name=endpoint_name, + target=EndpointTarget( + worker=EndpointTarget.Worker( + namespace=env.client.namespace, + task_queue=task_queue, + ) + ), + ) + ) + ) + + async with Worker( + env.client, + task_queue=task_queue, + workflows=[MCPCallerWorkflow, AppendWorkflow], + nexus_service_handlers=[mcp_service, TestServiceHandler()], + ): + result = await env.client.execute_workflow( + MCPCallerWorkflow.run, + arg=MCPCallerWorkflowInput(endpoint=endpoint_name), + id=str(uuid.uuid4()), + task_queue=task_queue, + ) + assert len(result.list_tools_result.tools) == 1 + assert result.list_tools_result.tools[0].name == "TestService_append" + assert result.call_tool_results[0].structuredContent == {"data": [1]} + assert result.call_tool_results[1].structuredContent == {"data": [1, 2]} diff --git a/tests/test_workflow_caller.py b/tests/test_workflow_caller.py index 0522d95..ecd4d89 100644 --- a/tests/test_workflow_caller.py +++ b/tests/test_workflow_caller.py @@ -2,7 +2,6 @@ from dataclasses import dataclass import pytest -from mcp import ClientSession from temporalio import workflow from temporalio.api.nexus.v1 import EndpointSpec, EndpointTarget from temporalio.api.operatorservice.v1 import CreateNexusEndpointRequest @@ -10,7 +9,7 @@ from temporalio.testing import WorkflowEnvironment from temporalio.worker import Worker -from nexusmcp import WorkflowTransport +import nexusmcp.workflow from .service import TestServiceHandler, mcp_service @@ -26,18 +25,15 @@ class MCPCallerWorkflowInput: class MCPCallerWorkflow: @workflow.run async def run(self, input: MCPCallerWorkflowInput) -> None: - transport = WorkflowTransport(input.endpoint) - async with transport.connect() as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() + async with nexusmcp.workflow.MCPClient(input.endpoint).connect() as session: + # Session is already initialized + list_tools_result = await session.list_tools() + assert len(list_tools_result.tools) == 2 + assert list_tools_result.tools[0].name == "modified-service-name_modified-op-name" + assert list_tools_result.tools[1].name == "modified-service-name_op2" - list_tools_result = await session.list_tools() - assert len(list_tools_result.tools) == 2 - assert list_tools_result.tools[0].name == "modified-service-name/modified-op-name" - assert list_tools_result.tools[1].name == "modified-service-name/op2" - - call_result = await session.call_tool("modified-service-name/modified-op-name", {"name": "World"}) - assert call_result.structuredContent == {"message": "Hello, World"} + call_result = await session.call_tool("modified-service-name_modified-op-name", {"name": "World"}) + assert call_result.structuredContent == {"message": "Hello, World"} @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index a665508..6d32849 100644 --- a/uv.lock +++ b/uv.lock @@ -226,6 +226,8 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "poethepoet" }, + { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -243,6 +245,8 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.13.0" }, + { name = "poethepoet", specifier = ">=0.35.0" }, + { name = "pyright", specifier = ">=1.1" }, { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-asyncio", specifier = ">=1.1.0" }, { name = "ruff", specifier = ">=0.8.0" }, @@ -260,6 +264,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/2f/9e9d0dcaa4c6ffa22b7aa31069a8a264c753ff8027b36af602cce038c92f/nexus_rpc-1.1.0-py3-none-any.whl", hash = "sha256:d1b007af2aba186a27e736f8eaae39c03aed05b488084ff6c3d1785c9ba2ad38", size = 27743, upload-time = "2025-07-07T19:03:57.556Z" }, ] +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -269,6 +282,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pastel" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, +] + [[package]] name = "pathspec" version = "0.12.1" @@ -287,6 +309,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "poethepoet" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pastel" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/f2/273fe54a78dc5c6c8dd63db71f5a6ceb95e4648516b5aeaeff4bde804e44/poethepoet-0.37.0.tar.gz", hash = "sha256:73edf458707c674a079baa46802e21455bda3a7f82a408e58c31b9f4fe8e933d", size = 68570, upload-time = "2025-08-11T18:00:29.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/1b/5337af1a6a478d25a3e3c56b9b4b42b0a160314e02f4a0498d5322c8dac4/poethepoet-0.37.0-py3-none-any.whl", hash = "sha256:861790276315abcc8df1b4bd60e28c3d48a06db273edd3092f3c94e1a46e5e22", size = 90062, upload-time = "2025-08-11T18:00:27.595Z" }, +] + [[package]] name = "protobuf" version = "5.29.5" @@ -367,6 +402,19 @@ 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 = "pyright" +version = "1.1.404" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/6e/026be64c43af681d5632722acd100b06d3d39f383ec382ff50a71a6d5bce/pyright-1.1.404.tar.gz", hash = "sha256:455e881a558ca6be9ecca0b30ce08aa78343ecc031d37a198ffa9a7a1abeb63e", size = 4065679, upload-time = "2025-08-20T18:46:14.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/30/89aa7f7d7a875bbb9a577d4b1dc5a3e404e3d2ae2657354808e905e358e0/pyright-1.1.404-py3-none-any.whl", hash = "sha256:c7b7ff1fdb7219c643079e4c3e7d4125f0dafcc19d253b47e898d130ea426419", size = 5902951, upload-time = "2025-08-20T18:46:12.096Z" }, +] + [[package]] name = "pytest" version = "8.4.1" @@ -426,6 +474,23 @@ wheels = [ { 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 = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + [[package]] name = "referencing" version = "0.36.2"