From ae9f08e16335629b99c1b20a3085e872630ed78f Mon Sep 17 00:00:00 2001 From: Tom Foster Date: Sun, 23 Feb 2025 22:00:46 +0000 Subject: [PATCH] Improve prompts, expand tests, and squash some Docker layers --- .dockerignore | 10 +++ .github/workflows/build.yml | 6 +- Dockerfile | 12 +-- mcp_server/__init__.py | 4 +- mcp_server/__main__.py | 12 ++- mcp_server/server.py | 160 +++++++++++++++++++++--------------- mcp_server/tools/prompts.py | 59 +++++++------ pyproject.toml | 1 + tests/test_server.py | 139 +++++++++++++++++++++++++++++++ tools.yaml | 59 +++++++++++++ uv.lock | 19 +++++ 11 files changed, 377 insertions(+), 104 deletions(-) create mode 100644 .dockerignore create mode 100644 tests/test_server.py create mode 100644 tools.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..207c7b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.dockerignore +.git +.github +.gitignore +.venv +.ruff_cache +.pytest_cache +__pycache__ +docs +Dockerfile diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e4ce45..e50774a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,8 +4,11 @@ concurrency: cancel-in-progress: true on: - push: pull_request: + push: + paths-ignore: + - "**/*.md" + - "docs/**" workflow_dispatch: jobs: @@ -34,6 +37,7 @@ jobs: - name: Run tests and output results run: | + set -o pipefail docker run --rm -e BUILD_ENV=dev mcp-server:test | tee pytest_output.txt echo '```' >> $GITHUB_STEP_SUMMARY cat pytest_output.txt >> $GITHUB_STEP_SUMMARY diff --git a/Dockerfile b/Dockerfile index 1666448..27a3413 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ # Build stage using uv with a frozen lockfile and dependency caching FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS uv WORKDIR /app +ARG BUILD_ENV=prod # Enable bytecode compilation and copy mode ENV UV_COMPILE_BYTECODE=1 \ @@ -16,6 +17,11 @@ COPY . . RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen ${BUILD_ENV:+"--dev"} --no-editable +# Add the source code and install dependencies +COPY . . +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen ${BUILD_ENV:+"--dev"} --no-editable + # Prepare runtime image FROM python:3.13-slim-bookworm AS runtime WORKDIR /app @@ -39,11 +45,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /root/.cache # Copy only necessary files from build stage -COPY --from=uv --chown=app:app /app/mcp_server ./mcp_server/ -COPY --from=uv --chown=app:app /app/.venv ./.venv/ -COPY --from=uv --chown=app:app /app/pyproject.toml ./ -COPY --from=uv --chown=app:app /app/pytest.ini ./ -COPY --from=uv --chown=app:app /app/tests ./tests/ +COPY --from=uv --chown=app:app /app/ . # Switch to non-root user and set up environment USER app diff --git a/mcp_server/__init__.py b/mcp_server/__init__.py index a65ffe7..a13aade 100644 --- a/mcp_server/__init__.py +++ b/mcp_server/__init__.py @@ -5,6 +5,6 @@ """ from .__main__ import main -from .server import serve +from .server import MCPServer -__all__ = ["main", "serve"] +__all__ = ["MCPServer", "main"] diff --git a/mcp_server/__main__.py b/mcp_server/__main__.py index 70b4b16..5883f57 100644 --- a/mcp_server/__main__.py +++ b/mcp_server/__main__.py @@ -9,8 +9,12 @@ from asyncio import CancelledError, run as asyncio_run from contextlib import suppress as contextlib_suppress from os import environ as os_environ +from pathlib import Path -from .server import serve +from yaml import safe_load as yaml_safe_load + +from .server import MCPServer +from .tools import tool_python, tool_web def main() -> None: @@ -28,8 +32,12 @@ def main() -> None: if args.user_agent: os_environ["USER_AGENT"] = args.user_agent + config = yaml_safe_load(Path("tools.yaml").read_text(encoding="utf-8")) + config["tools"]["python"]["method"] = tool_python + config["tools"]["web"]["method"] = tool_web + server = MCPServer(config) with contextlib_suppress(KeyboardInterrupt, CancelledError): - asyncio_run(serve()) + asyncio_run(server.serve()) if __name__ == "__main__": diff --git a/mcp_server/server.py b/mcp_server/server.py index 60b38ef..263d05c 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -1,16 +1,17 @@ -"""Core server implementation for the MCP fetch service. +"""Core MCPServer implementation for the MCP fetch service. -Implements a Model Context Protocol server that fetches and processes web content. -Supports both standard I/O and Server-Sent Events (SSE) transport modes, with -content extraction powered by trafilatura for efficient web scraping. +Provides a generic MCPServer class for serving MCP requests. Allows drop-in tool support by mapping +tool functions to configuration loaded from an external YAML file. """ from __future__ import annotations +from dataclasses import dataclass, field from os import getenv as os_getenv -from typing import TYPE_CHECKING +from pathlib import Path +from typing import TYPE_CHECKING, Any -from mcp.server import Server +from mcp.server import Server as BaseMCPServer from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server from mcp.shared.exceptions import McpError @@ -19,76 +20,107 @@ from starlette.routing import Mount, Route from uvicorn import Config as UvicornConfig, Server as UvicornServer -from .tools import TOOLS, tool_python, tool_web - if TYPE_CHECKING: from starlette.requests import Request from starlette.responses import Response - -async def serve() -> None: - """Run the fetch MCP server.""" - server = Server("mcp-fetch") - - @server.list_tools() - async def list_tools() -> list[Tool]: +# Default path for tool configuration YAML file +DEFAULT_TOOL_CONFIG_PATH = Path(__file__).parent / "tools.yaml" + + +@dataclass(slots=True) +class MCPServer: + """Define a generic MCP server class with drop-in tool support.""" + + config: dict[str, Any] + server: BaseMCPServer = field(init=False) + server_name: str = field(default="mcp-server") + tools: list[Tool] = field(default_factory=list) + + def __post_init__(self) -> None: + """Initialise the MCPServer.""" + if self.config.get("server", {}).get("name"): + self.server_name = self.config["server"]["name"] + # Create MCP server instance + self.server = BaseMCPServer(self.server_name) + # Build the tool registry and tool list + self.tools = [ + Tool(name=name, **{k: v for k, v in tool.items() if k != "method"}) + for name, tool in self.config["tools"].items() + ] + # Register the tool listing/calling methods + self.server.list_tools()(self.list_tools) + self.server.call_tool()(self.call_tool) + + async def list_tools(self) -> list[Tool]: """Return a list of available tools. Returns: A list of Tool objects representing the available tools. """ - return TOOLS - - @server.call_tool() - async def call_tool(name: str, arguments: dict) -> list[TextContent]: - """Call the specified tool with provided arguments. + return self.tools - Args: - name: The name of the tool to call. - arguments: A dictionary of arguments for the tool. + async def call_tool(self, name: str, arguments: dict) -> list[TextContent]: + """Call the tool specified by name with provided arguments. Returns: - A list of TextContent objects containing the fetched results. + A list of TextContent objects containing the tool's result Raises: - McpError: If the tool is unknown or fails to execute. + McpError: If the tool is unknown or fails to execute """ - for tool_name, tool_func in {"python": tool_python, "web": tool_web}.items(): - if name == tool_name: - try: - return [TextContent(type="text", text=await tool_func(**arguments))] - except McpError as err: - raise McpError(ErrorData(code=INVALID_PARAMS, message=str(err))) from err - # Otherwise, the tool is unknown - raise McpError(ErrorData(code=INVALID_PARAMS, message=f"Unknown tool: {name}")) - - options = server.create_initialization_options() - sse_host, sse_port = os_getenv("SSE_HOST"), os_getenv("SSE_PORT") - if sse_host and sse_port: - sse = SseServerTransport("/messages/") - - async def handle_sse(request: Request) -> Response | None: - """Handle the Server-Sent Events (SSE) connection. - - Args: - request: The incoming HTTP request. - """ - async with sse.connect_sse(request.scope, request.receive, request._send) as streams: # noqa: SLF001 - await server.run(streams[0], streams[1], options, raise_exceptions=True) - - starlette_app = Starlette( - debug=True, - routes=[ - Route("/sse", endpoint=handle_sse), - Mount("/messages/", app=sse.handle_post_message), - ], - ) - - config = UvicornConfig( - app=starlette_app, host=sse_host, port=int(sse_port), log_level="info" - ) - server_instance = UvicornServer(config) - await server_instance.serve() - else: - async with stdio_server() as (read_stream, write_stream): - await server.run(read_stream, write_stream, options, raise_exceptions=True) + if name not in self.config["tools"]: + raise McpError( + ErrorData( + code=INVALID_PARAMS, + message=f"Tool '{name}' isn't available on this server anymore", + ) + ) + if "method" not in self.config["tools"][name]: + raise McpError( + ErrorData( + code=INVALID_PARAMS, + message=( + f"Tool '{name}' has no registered method: inform the user that their MCP " + "server requires configuration to provide a function for this tool." + ), + ) + ) + try: + result = await self.config["tools"][name]["method"](**arguments) + return [TextContent(type="text", text=result)] + except McpError as err: + raise McpError(ErrorData(code=INVALID_PARAMS, message=str(err))) from err + + async def serve(self) -> None: + """Run the MCP server, using either SSE or stdio mode.""" + options = self.server.create_initialization_options() + sse_host, sse_port = os_getenv("SSE_HOST"), os_getenv("SSE_PORT") + if sse_host and sse_port: + sse = SseServerTransport("/messages/") + + async def _handle_sse(request: Request) -> Response | None: + """Handle incoming SSE connection.""" + async with sse.connect_sse( + request.scope, + request.receive, + request._send, # noqa: SLF001 + ) as streams: + await self.server.run(streams[0], streams[1], options, raise_exceptions=True) + + starlette_app = Starlette( + debug=True, + routes=[ + Route("/sse", endpoint=_handle_sse), + Mount("/messages/", app=sse.handle_post_message), + ], + ) + + config = UvicornConfig( + app=starlette_app, host=sse_host, port=int(sse_port), log_level="info" + ) + server_instance = UvicornServer(config) + await server_instance.serve() + else: + async with stdio_server() as (read_stream, write_stream): + await self.server.run(read_stream, write_stream, options, raise_exceptions=True) diff --git a/mcp_server/tools/prompts.py b/mcp_server/tools/prompts.py index 7f7534d..f54de59 100644 --- a/mcp_server/tools/prompts.py +++ b/mcp_server/tools/prompts.py @@ -12,10 +12,12 @@ Tool( name="web", description=( - "Use to access the internet when up-to-date information may help. You can navigate " - "documentation, or fetch code and data from the web, so use it whenever fresh " - "information from the internet could potentially improve the accuracy of your answer " - "to the user." + "Your knowledge is out of date and potentially flawed. This tool lets you access and " + "process web content to enhance your responses. Use this tool to:\n" + "- Check current documentation when answering questions\n" + "- Fetch example code or data to demonstrate solutions\n" + "- Navigate through documentation using extracted links\n" + "- Verify information before making recommendations" ), inputSchema={ "type": "object", @@ -23,8 +25,8 @@ "url": { "type": "string", "description": ( - "The URL to access. This can be any public web address, an API GET " - "endpoint, or even a location of a text/code file on GitHub, etc." + "URL to access - could be a web page, API endpoint, or a file on GitHub, " + "etc." ), }, "mode": { @@ -32,47 +34,44 @@ "enum": ["markdown", "raw", "links"], "default": "markdown", "description": ( - "Determines how to process the content:\n" - "'markdown' formats a HTML page into efficient markdown, removing headers, " - "navigation, ads, etc, so ideal for normal web pages;\n" - "'raw' returns the unprocessed content, if you need to see raw HTML, or " - "code, XML, JSON, etc.;\n" - "'links' extracts a list of hyperlinks (with anchor text) from a HTML " - "page, which can help you understand site structure or navigate " - "documentation." + "How to process the content:\n" + "'markdown': Convert HTML to clean markdown (best for reading)\n" + "'raw': Get unprocessed content (for non-HTML such as code, JSON, etc)\n" + "'links': Extract hyperlinks from a webpage with anchor text, which can be " + "combined with the markdown mode for navigation around a website, e.g. to " + "locate details in a repository or documentation site." ), }, "max_length": { "type": "integer", "default": 0, - "description": ( - "Limits the number of characters returned. A value of 0 means no limit. " - "You could use this if you're only interested in the start of a file, but " - "it's better to err on the side of having more context." - ), + "description": "Limit response length in characters (zero means no limit)", }, }, - "required": ["url", "mode"], + "required": ["url"], }, ), Tool( name="python", description=( - "Execute or lint Python code in a resource-limited sandbox.\n" - "It has internet access, with aiodns, aiohttp, bs4, numpy, pandas, and requests " - "installed, so you can now test and solve a number of problems without needing to " - "directly calculate it yourself.\n" - "Depending on your input parameters, this tool either runs the code or lints with " - "Ruff, so you can test code before running, or use Ruff to help debugging if you get " - "errors. The user can see the code you've submitted and the raw returned response, but " - "it's good etiquette to briefly summarise after using this tool what you asked for and " - "got back." + "Execute code in a Python 3.13 sandbox to demonstrate concepts and calculate results. " + "Instead of writing example code for users to run, use this tool directly to:\n" + "- Show pandas/numpy operations with real data\n" + "- Calculate results that would be tedious manually\n" + "- Demonstrate and verify working code examples\n\n" + "Includes: numpy, pandas, requests, bs4, aiodns, aiohttp. Can either run code or lint " + "with Ruff. The user can see your code and its output, but the output is not well " + "formatted, so it's good practice to briefly explain what you did and what the results " + "show.\n\n" + "When showing code or output to users, format it appropriately in markdown:\n" + "- Use ``` backticks for code and console output\n" + "- Use tables, lists or other markdown for structured data like pandas output" ), inputSchema={ "type": "object", "properties": { "code": {"type": "string", "description": "Python code to use"}, - "timeout": { + "time_limit": { "type": "integer", "default": 10, "description": "Timeout in seconds for execution (ignored when linting)", diff --git a/pyproject.toml b/pyproject.toml index 0805e3e..afd4e8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "aiohttp>=3.11.12", "beautifulsoup4>=4.13.3", "mcp>=1.2.1", + "pyyaml>=6.0.2", "trafilatura>=2.0.0", "uvicorn>=0.34.0", ] diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..c47e3c3 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,139 @@ +"""Test the MCP server initialization and configuration.""" + +from __future__ import annotations + +from os import environ as os_environ +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +from yaml import dump as yaml_dump, safe_load as yaml_safe_load + +from mcp_server.server import MCPServer +from mcp_server.tools import tool_python, tool_web + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + + +@pytest.fixture +def mock_yaml_file(tmp_path: Path) -> Path: + """Create a temporary tools.yaml file for testing. + + Args: + tmp_path: Pytest fixture providing temporary directory + + Returns: + Path to the temporary YAML file + """ + yaml_content = { + "tools": { + "python": { + "description": "Test Python tool", + "inputSchema": {"type": "object", "properties": {"code": {"type": "string"}}}, + }, + "web": { + "description": "Test Web tool", + "inputSchema": {"type": "object", "properties": {"url": {"type": "string"}}}, + }, + } + } + + yaml_path = tmp_path / "tools.yaml" + yaml_path.write_text(yaml_dump(yaml_content), encoding="utf-8") + return yaml_path + + +@pytest.fixture +def server_env() -> Generator[None]: + """Set up server environment variables for testing.""" + os_environ["SSE_HOST"] = "127.0.0.1" + os_environ["SSE_PORT"] = "3001" + os_environ["USER_AGENT"] = "TestAgent/1.0" + yield + for key in ["SSE_HOST", "SSE_PORT", "USER_AGENT"]: + if key in os_environ: + del os_environ[key] + + +@pytest.fixture +async def server(mock_yaml_file: Path) -> MCPServer: + """Create a test server instance. + + Args: + mock_yaml_file: Path to test YAML configuration + + Returns: + Configured MCPServer instance + """ + config = yaml_safe_load(mock_yaml_file.read_text(encoding="utf-8")) + config["tools"]["python"]["method"] = tool_python + config["tools"]["web"]["method"] = tool_web + return MCPServer(config) + + +def test_yaml_loading(mock_yaml_file: Path) -> None: + """Test that the YAML configuration can be loaded correctly.""" + config = yaml_safe_load(mock_yaml_file.read_text(encoding="utf-8")) + + if "tools" not in config: + pytest.fail("Missing 'tools' section in config") + if "python" not in config["tools"]: + pytest.fail("Missing 'python' tool in config") + if "web" not in config["tools"]: + pytest.fail("Missing 'web' tool in config") + if "description" not in config["tools"]["python"]: + pytest.fail("Missing 'description' in python tool config") + if "description" not in config["tools"]["web"]: + pytest.fail("Missing 'description' in web tool config") + + +def test_server_initialisation(server: MCPServer) -> None: + """Test that the server initializes with the correct tools.""" + if not hasattr(server, "tools"): + pytest.fail("Server missing tools attribute") + tool_names = {tool.name for tool in server.tools} + if "python" not in tool_names: + pytest.fail("Server missing python tool") + if "web" not in tool_names: + pytest.fail("Server missing web tool") + + python_tool_config = server.config["tools"]["python"] + web_tool_config = server.config["tools"]["web"] + + if python_tool_config.get("method") != tool_python: + pytest.fail("Python tool has incorrect method") + if web_tool_config.get("method") != tool_web: + pytest.fail("Web tool has incorrect method") + + +@pytest.mark.asyncio +async def test_server_environment(server_env: None) -> None: + """Test that environment variables are correctly set.""" + if os_environ["SSE_HOST"] != "127.0.0.1": + pytest.fail(f"Incorrect SSE_HOST: {os_environ['SSE_HOST']}") + if os_environ["SSE_PORT"] != "3001": + pytest.fail(f"Incorrect SSE_PORT: {os_environ['SSE_PORT']}") + if os_environ["USER_AGENT"] != "TestAgent/1.0": + pytest.fail(f"Incorrect USER_AGENT: {os_environ['USER_AGENT']}") + + +def test_live_tools_yaml_file() -> None: + """Test that the live tools.yaml file is readable and contains required keys.""" + # Determine the project root (assumed one level above the tests directory) + project_root = Path(__file__).parent.parent + tools_yaml_path = project_root / "tools.yaml" + if not tools_yaml_path.exists(): + pytest.fail(f"tools.yaml file not found at {tools_yaml_path}") + + config = yaml_safe_load(tools_yaml_path.read_text(encoding="utf-8")) + + if "tools" not in config: + pytest.fail("Missing 'tools' section in live tools.yaml") + + for tool in ("python", "web"): + if tool not in config["tools"]: + pytest.fail(f"Missing '{tool}' configuration in live tools.yaml") + if "inputSchema" not in config["tools"][tool]: + pytest.fail(f"Missing 'inputSchema' for tool '{tool}' in live tools.yaml") diff --git a/tools.yaml b/tools.yaml new file mode 100644 index 0000000..9e7b208 --- /dev/null +++ b/tools.yaml @@ -0,0 +1,59 @@ +tools: + python: + description: > + Execute code in a Python 3.13 sandbox to demonstrate concepts and calculate results. + Instead of writing example code for users to run, use this tool directly to: + - Show pandas/numpy operations with real data + - Calculate results that would be tedious manually + - Demonstrate and verify working code examples + + Includes: numpy, pandas, requests, bs4, aiodns, aiohttp. Can either run code or lint + with Ruff. When showing code or output to users, format it appropriately in markdown. + inputSchema: + type: object + properties: + code: + type: string + description: Python code to use + time_limit: + type: integer + default: 10 + description: Timeout in seconds for execution (ignored when linting) + lint: + type: boolean + default: false + description: Lint the code using Ruff instead of executing it + required: + - code + web: + description: > + Your knowledge is out of date and potentially flawed. This tool lets you access and + process web content to enhance your responses. Use this tool to: + - Check current documentation when answering questions + - Fetch example code or data to demonstrate solutions + - Navigate through documentation using extracted links + - Verify information before making recommendations + inputSchema: + type: object + properties: + url: + type: string + description: URL to access - could be a web page, API endpoint, or a file on GitHub, etc. + mode: + type: string + enum: + - markdown + - raw + - links + default: markdown + description: > + How to process the content: + - `markdown` converts HTML to clean markdown (best for reading) + - `raw` gets unprocessed content (for non-HTML such as code, JSON, etc) + - `links` extracts hyperlinks from a webpage with anchor text + max_length: + type: integer + default: 0 + description: Limit response length in characters (zero means no limit) + required: + - url diff --git a/uv.lock b/uv.lock index 09a9e93..2df9960 100644 --- a/uv.lock +++ b/uv.lock @@ -375,6 +375,7 @@ dependencies = [ { name = "aiohttp" }, { name = "beautifulsoup4" }, { name = "mcp" }, + { name = "pyyaml" }, { name = "trafilatura" }, { name = "uvicorn" }, ] @@ -391,6 +392,7 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.11.12" }, { name = "beautifulsoup4", specifier = ">=4.13.3" }, { name = "mcp", specifier = ">=1.2.1" }, + { name = "pyyaml", specifier = ">=6.0.2" }, { name = "trafilatura", specifier = ">=2.0.0" }, { name = "uvicorn", specifier = ">=0.34.0" }, ] @@ -594,6 +596,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930 }, ] +[[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 } +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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + [[package]] name = "regex" version = "2024.11.6"