From b93ceb91833bb89304eacba5e7cca2193d0dd96e Mon Sep 17 00:00:00 2001 From: Tobias Date: Tue, 11 Mar 2025 21:08:23 +0100 Subject: [PATCH 01/12] Refactor server into a library for distribution as a PyPI package --- .cursor/mcp.json | 2 +- .env.example | 7 +- .github/workflows/python-package.yml | 56 ++ pyproject.toml | 63 ++- server/__init__.py | 3 + server/server.py | 698 ++++++++----------------- src/browser_use_mcp_server/__init__.py | 25 + src/browser_use_mcp_server/__main__.py | 9 + src/browser_use_mcp_server/cli.py | 338 ++++++++++++ src/browser_use_mcp_server/server.py | 680 ++++++++++++++++++++++++ uv.lock | 523 +++++++++++++++++- 11 files changed, 1912 insertions(+), 492 deletions(-) create mode 100644 .github/workflows/python-package.yml create mode 100644 src/browser_use_mcp_server/__init__.py create mode 100644 src/browser_use_mcp_server/__main__.py create mode 100644 src/browser_use_mcp_server/cli.py create mode 100644 src/browser_use_mcp_server/server.py diff --git a/.cursor/mcp.json b/.cursor/mcp.json index f806d22..3467657 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,6 +1,6 @@ { "mcpServers": { - "browser-use-mcp=server": { + "browser-use-mcp-server": { "url": "http://localhost:8000/sse" } } diff --git a/.env.example b/.env.example index 1b049f1..68232eb 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,5 @@ -OPENAI_API_KEY=sk -CHROME_PATH=/usr/bin/chromium \ No newline at end of file +# Path to Chrome/Chromium executable +/usr/bin/chromium + +# OpenAI API key for OpenAI model access +OPENAI_API_KEY=your-api-key-here \ No newline at end of file diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..a56366b --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,56 @@ +name: Python Package + +on: + push: + branches: [ main ] + tags: + - 'v*' + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest pytest-asyncio pytest-cov + pip install -e ".[dev]" + - name: Test with pytest + run: | + pytest --cov=browser_use_mcp_server + + build-and-publish: + needs: test + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install build twine + - name: Build package + run: | + python -m build + - name: Publish to GitHub Packages + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.GITHUB_TOKEN }} + repository-url: https://pypi.pkg.github.com/${{ github.repository_owner }} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 79a1b58..2fb9f5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,20 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project] name = "browser-use-mcp-server" version = "0.1.2" -description = "MCP browser-use server" +description = "MCP browser-use server library" readme = "README.md" -requires-python = ">=3.13" +requires-python = ">=3.11,<4.0" +authors = [ + {name = "Cobrowser Team"} +] +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", +] dependencies = [ "asyncio>=3.4.3", "browser-use>=0.1.40", @@ -12,4 +23,52 @@ dependencies = [ "langchain-openai>=0.3.1", "mcp>=1.3.0", "pydantic>=2.10.6", + "anyio", + "python-dotenv", + "starlette", + "uvicorn", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "black>=23.0.0", + "isort>=5.12.0", + "mypy>=1.0.0", +] +test = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.1.0", ] + +[project.urls] +"Homepage" = "https://github.com/cobrowser/browser-use-mcp-server" +"Bug Tracker" = "https://github.com/cobrowser/browser-use-mcp-server/issues" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +asyncio_mode = "auto" + +[tool.black] +line-length = 88 +target-version = ["py311"] + +[tool.isort] +profile = "black" +line_length = 88 + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true + +[project.scripts] +browser-use-mcp-server = "browser_use_mcp_server.cli:cli" + +[tool.hatch.build] +packages = ["src/browser_use_mcp_server"] diff --git a/server/__init__.py b/server/__init__.py index e69de29..cfa8a22 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -0,0 +1,3 @@ +from .server import main + +__all__ = ["main"] diff --git a/server/server.py b/server/server.py index e411bdd..2f3c551 100644 --- a/server/server.py +++ b/server/server.py @@ -1,246 +1,24 @@ import os -import anyio import click import asyncio -import uuid -from datetime import datetime -from langchain_openai import ChatOpenAI -from browser_use import Agent -from browser_use.browser.browser import Browser, BrowserConfig -import mcp.types as types -from mcp.server.lowlevel import Server +import anyio from dotenv import load_dotenv -import json import logging -from browser_use.browser.context import BrowserContextConfig, BrowserContext +import os.path + +# Import from the browser-use-mcp-server package +from browser_use_mcp_server.server import ( + initialize_browser_context, + create_mcp_server, +) +from langchain_openai import ChatOpenAI # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) - -# Browser context configuration -config = BrowserContextConfig( - wait_for_network_idle_page_load_time=0.6, - maximum_wait_page_load_time=1.2, - minimum_wait_page_load_time=0.2, - browser_window_size={"width": 1280, "height": 1100}, - locale="en-US", - user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36", - highlight_elements=True, - viewport_expansion=0, -) - -# Initialize browser and context -browser = Browser( - config=BrowserConfig( - chrome_instance_path=os.environ.get("CHROME_PATH"), - extra_chromium_args=[ - "--no-sandbox", - "--disable-gpu", - "--disable-software-rasterizer", - "--disable-dev-shm-usage", - "--remote-debugging-port=9222", - ], - ) -) -context = BrowserContext(browser=browser, config=config) - -# Initialize LLM -llm = ChatOpenAI(model="gpt-4o", temperature=0.0) - -# Flag to track browser context health -browser_context_healthy = True - -# Task storage for async operations -task_store = {} - - -async def reset_browser_context(): - """Reset the browser context to a clean state.""" - global context, browser, browser_context_healthy - - logger.info("Resetting browser context") - try: - # Try to close the existing context - try: - await context.close() - except Exception as e: - logger.warning(f"Error closing browser context: {str(e)}") - - # Create a new context - context = BrowserContext(browser=browser, config=config) - browser_context_healthy = True - logger.info("Browser context reset successfully") - except Exception as e: - logger.error(f"Failed to reset browser context: {str(e)}") - browser_context_healthy = False - # If we can't reset the context, try to reset the browser - try: - await browser.close() - browser = Browser() - context = BrowserContext(browser=browser, config=config) - browser_context_healthy = True - logger.info("Browser reset successfully") - except Exception as e: - logger.error(f"Failed to reset browser: {str(e)}") - browser_context_healthy = False - - -async def check_browser_health(): - """Check if the browser context is healthy.""" - global browser_context_healthy - - if not browser_context_healthy: - await reset_browser_context() - return browser_context_healthy - - try: - # Simple health check - try to get the current page - await context.get_current_page() - return True - except Exception as e: - logger.warning(f"Browser health check failed: {str(e)}") - browser_context_healthy = False - await reset_browser_context() - return browser_context_healthy - - -async def run_browser_task_async(task_id, url, action): - """Run a browser task asynchronously and store the result.""" - try: - # Update task status to running - task_store[task_id]["status"] = "running" - task_store[task_id]["start_time"] = datetime.now().isoformat() - task_store[task_id]["progress"] = { - "current_step": 0, - "total_steps": 0, - "steps": [], - } - - # Reset browser context to ensure a clean state - await reset_browser_context() - - # Check browser health - if not await check_browser_health(): - task_store[task_id]["status"] = "failed" - task_store[task_id]["end_time"] = datetime.now().isoformat() - task_store[task_id]["error"] = ( - "Browser context is unhealthy and could not be reset" - ) - return - - # Define step callback function with the correct signature - async def step_callback(browser_state, agent_output, step_number): - # Update progress in task store - task_store[task_id]["progress"]["current_step"] = step_number - task_store[task_id]["progress"]["total_steps"] = max( - task_store[task_id]["progress"]["total_steps"], step_number - ) - - # Add step info with minimal details - step_info = {"step": step_number, "time": datetime.now().isoformat()} - - # Add goal if available - if agent_output and hasattr(agent_output, "current_state"): - if hasattr(agent_output.current_state, "next_goal"): - step_info["goal"] = agent_output.current_state.next_goal - - # Add to progress steps - task_store[task_id]["progress"]["steps"].append(step_info) - - # Log progress - logger.info(f"Task {task_id}: Step {step_number} completed") - - # Define done callback function with the correct signature - async def done_callback(history): - # Log completion - logger.info(f"Task {task_id}: Completed with {len(history.history)} steps") - - # Add final step - current_step = task_store[task_id]["progress"]["current_step"] + 1 - task_store[task_id]["progress"]["steps"].append( - { - "step": current_step, - "time": datetime.now().isoformat(), - "status": "completed", - } - ) - - # Use the existing browser context with callbacks - agent = Agent( - task=f"First, navigate to {url}. Then, {action}", - llm=llm, - browser_context=context, - register_new_step_callback=step_callback, - register_done_callback=done_callback, - ) - - # Run the agent - ret = await agent.run(max_steps=10) - - # Get the final result - final_result = ret.final_result() - - # Check if we have a valid result - if final_result and hasattr(final_result, "raise_for_status"): - final_result.raise_for_status() - result_text = str(final_result.text) - else: - result_text = ( - str(final_result) if final_result else "No final result available" - ) - - # Gather essential information from the agent history - is_successful = ret.is_successful() - has_errors = ret.has_errors() - errors = ret.errors() - urls_visited = ret.urls() - action_names = ret.action_names() - extracted_content = ret.extracted_content() - steps_taken = ret.number_of_steps() - - # Create a focused response with the most relevant information for an LLM - response_data = { - "final_result": result_text, - "success": is_successful, - "has_errors": has_errors, - "errors": [str(err) for err in errors if err], - "urls_visited": [str(url) for url in urls_visited if url], - "actions_performed": action_names, - "extracted_content": extracted_content, - "steps_taken": steps_taken, - } - - # Store the result - task_store[task_id]["status"] = "completed" - task_store[task_id]["end_time"] = datetime.now().isoformat() - task_store[task_id]["result"] = response_data - - except Exception as e: - logger.error(f"Error in async browser task: {str(e)}") - import traceback - - tb = traceback.format_exc() - - # Mark the browser context as unhealthy - global browser_context_healthy - browser_context_healthy = False - - # Store the error - task_store[task_id]["status"] = "failed" - task_store[task_id]["end_time"] = datetime.now().isoformat() - task_store[task_id]["error"] = str(e) - task_store[task_id]["traceback"] = tb - - finally: - # Always try to reset the browser context to a clean state after use - try: - current_page = await context.get_current_page() - await current_page.goto("about:blank") - except Exception as e: - logger.warning(f"Error resetting page state: {str(e)}") - browser_context_healthy = False +# Load environment variables +load_dotenv() @click.command() @@ -251,203 +29,184 @@ async def done_callback(history): default="stdio", help="Transport type", ) -def main(port: int, transport: str) -> int: - load_dotenv() - app = Server("browser_use") - - @app.call_tool() - async def call_tool( - name: str, arguments: dict - ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: - global browser_context_healthy - - # Handle different tool types - if name == "browser_use": - # Check required arguments - if "url" not in arguments: - raise ValueError("Missing required argument 'url'") - if "action" not in arguments: - raise ValueError("Missing required argument 'action'") - - # Generate a task ID - task_id = str(uuid.uuid4()) - - # Initialize task in store - task_store[task_id] = { - "id": task_id, - "status": "pending", - "url": arguments["url"], - "action": arguments["action"], - "created_at": datetime.now().isoformat(), - } - - # Start task in background - asyncio.create_task( - run_browser_task_async(task_id, arguments["url"], arguments["action"]) - ) - - # Estimate task duration - estimated_seconds = 60 # Default estimate of 60 seconds - - # Return task ID immediately with explicit sleep instruction - return [ - types.TextContent( - type="text", - text=json.dumps( - { - "task_id": task_id, - "status": "pending", - "message": f"Browser task started. Please wait for {estimated_seconds} seconds, then check the result using browser_get_result or the resource URI. Always wait exactly 5 seconds between status checks.", - "estimated_time": f"{estimated_seconds} seconds", - "resource_uri": f"resource://browser_task/{task_id}", - "sleep_command": "sleep 5", - "instruction": "Use the terminal command 'sleep 5' to wait 5 seconds between status checks. IMPORTANT: Always use exactly 5 seconds, no more and no less.", - }, - indent=2, - ), - ) - ] - - elif name == "browser_get_result": - # Get result of async task - if "task_id" not in arguments: - raise ValueError("Missing required argument 'task_id'") - - task_id = arguments["task_id"] - - if task_id not in task_store: - return [ - types.TextContent( - type="text", - text=json.dumps( - {"error": "Task not found", "task_id": task_id}, indent=2 - ), - ) - ] - - # Get the current task data - task_data = task_store[task_id].copy() - - # If task is still running, add simple guidance - if task_data["status"] == "running": - # Add a simple next check suggestion - progress = task_data.get("progress", {}) - current_step = progress.get("current_step", 0) - - if current_step > 0: - # Simple message based on current step - task_data["message"] = ( - f"Task is running (step {current_step}). Wait 5 seconds before checking again." - ) - task_data["sleep_command"] = "sleep 5" - task_data["instruction"] = ( - "Use the terminal command 'sleep 5' to wait 5 seconds before checking again. IMPORTANT: Always use exactly 5 seconds, no more and no less." - ) - else: - task_data["message"] = ( - "Task is starting. Wait 5 seconds before checking again." - ) - task_data["sleep_command"] = "sleep 5" - task_data["instruction"] = ( - "Use the terminal command 'sleep 5' to wait 5 seconds before checking again. IMPORTANT: Always use exactly 5 seconds, no more and no less." - ) - - # Return current task status and result if available - return [ - types.TextContent(type="text", text=json.dumps(task_data, indent=2)) - ] - +@click.option( + "--chrome-path", + default=None, + help="Path to Chrome executable", +) +@click.option( + "--window-width", + default=1280, + help="Browser window width", +) +@click.option( + "--window-height", + default=1100, + help="Browser window height", +) +@click.option( + "--locale", + default="en-US", + help="Browser locale", +) +@click.option( + "--task-expiry-minutes", + default=60, + help="Minutes after which tasks are considered expired", +) +def main( + port: int, + transport: str, + chrome_path: str, + window_width: int, + window_height: int, + locale: str, + task_expiry_minutes: int, +) -> int: + """Run the browser-use MCP server.""" + # If Chrome path is explicitly provided, use it + if chrome_path and os.path.exists(chrome_path): + chrome_executable_path = chrome_path + logger.info(f"Using explicitly provided Chrome path: {chrome_executable_path}") + else: + # Try to find Playwright's installed Chromium first + import os.path + import platform + from pathlib import Path + import subprocess + + # Try to get Playwright browsers directory + home_dir = str(Path.home()) + system = platform.system() + + if system == "Darwin": # macOS + playwright_browsers_path = os.path.join(home_dir, "Library", "Caches", "ms-playwright") + elif system == "Linux": + playwright_browsers_path = os.path.join(home_dir, ".cache", "ms-playwright") + elif system == "Windows": + playwright_browsers_path = os.path.join(home_dir, "AppData", "Local", "ms-playwright") else: - raise ValueError(f"Unknown tool: {name}") - - @app.list_tools() - async def list_tools() -> list[types.Tool]: - return [ - types.Tool( - name="browser_use", - description="Performs a browser action and returns a task ID for async execution", - inputSchema={ - "type": "object", - "required": ["url", "action"], - "properties": { - "url": { - "type": "string", - "description": "URL to navigate to", - }, - "action": { - "type": "string", - "description": "Action to perform in the browser", - }, - }, - }, - ), - types.Tool( - name="browser_get_result", - description="Gets the result of an asynchronous browser task", - inputSchema={ - "type": "object", - "required": ["task_id"], - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to get results for", - } - }, - }, - ), - ] - - @app.list_resources() - async def list_resources() -> list[types.Resource]: - # List all completed tasks as resources - resources = [] - for task_id, task_data in task_store.items(): - if task_data["status"] in ["completed", "failed"]: - resources.append( - types.Resource( - uri=f"resource://browser_task/{task_id}", - title=f"Browser Task Result: {task_id[:8]}", - description=f"Result of browser task for URL: {task_data.get('url', 'unknown')}", - ) - ) - return resources - - @app.read_resource() - async def read_resource(uri: str) -> list[types.ResourceContents]: - # Extract task ID from URI - if not uri.startswith("resource://browser_task/"): - return [ - types.ResourceContents( - type="text", - text=json.dumps( - {"error": f"Invalid resource URI: {uri}"}, indent=2 - ), - ) - ] - - task_id = uri.replace("resource://browser_task/", "") - if task_id not in task_store: - return [ - types.ResourceContents( - type="text", - text=json.dumps({"error": f"Task not found: {task_id}"}, indent=2), - ) + playwright_browsers_path = None + + # Try to find the Chromium executable in the Playwright directory + chromium_executable_path = None + if playwright_browsers_path and os.path.exists(playwright_browsers_path): + logger.info(f"Found Playwright browsers directory at {playwright_browsers_path}") + # Look for chromium directories + try: + for root, dirs, files in os.walk(playwright_browsers_path): + for dir in dirs: + if "chromium" in dir.lower(): + # Check for executable in this directory + if system == "Darwin": # macOS + exec_path = os.path.join(root, dir, "chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium") + elif system == "Linux": + exec_path = os.path.join(root, dir, "chrome-linux", "chrome") + elif system == "Windows": + exec_path = os.path.join(root, dir, "chrome-win", "chrome.exe") + else: + continue + + if os.path.exists(exec_path): + chromium_executable_path = exec_path + logger.info(f"Found Playwright Chromium at {chromium_executable_path}") + break + if chromium_executable_path: + break + except Exception as e: + logger.warning(f"Error searching for Playwright Chromium: {str(e)}") + + # If Playwright Chromium not found, try standard locations + if not chromium_executable_path: + # Try to find Chromium/Chrome in common locations + potential_paths = [ + # Environment variable + os.environ.get("CHROME_PATH"), + + # Common macOS paths + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + + # Common Linux paths + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", ] - - # Return task data - return [ - types.ResourceContents( - type="text", text=json.dumps(task_store[task_id], indent=2) - ) - ] - + + for path in potential_paths: + if path and os.path.exists(path): + logger.info(f"Found browser at {path}") + chromium_executable_path = path + break + + # Use the found path or try with Playwright's default + if chromium_executable_path: + chrome_executable_path = chromium_executable_path + logger.info(f"Using browser executable at: {chrome_executable_path}") + else: + # If no specific path found, let Playwright find its own browser + logger.warning("No Chrome/Chromium path found, will let Playwright use its default browser") + chrome_executable_path = None + + # Initialize browser context + try: + # Using the approach from backup/server.py + from browser_use.browser.context import BrowserContextConfig, BrowserContext + from browser_use.browser.browser import Browser, BrowserConfig + + # Browser context configuration + config = BrowserContextConfig( + wait_for_network_idle_page_load_time=0.6, + maximum_wait_page_load_time=1.2, + minimum_wait_page_load_time=0.2, + browser_window_size={"width": window_width, "height": window_height}, + locale=locale, + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36", + highlight_elements=True, + viewport_expansion=0, + ) + + # Initialize browser and context directly + browser_config = BrowserConfig( + extra_chromium_args=[ + "--no-sandbox", + "--disable-gpu", + "--disable-software-rasterizer", + "--disable-dev-shm-usage", + "--remote-debugging-port=9222", + ], + ) + + # Only set chrome_instance_path if we actually found a path + if chrome_executable_path: + browser_config.chrome_instance_path = chrome_executable_path + + browser = Browser(config=browser_config) + context = BrowserContext(browser=browser, config=config) + logger.info("Browser context initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize browser context: {str(e)}") + return 1 + + # Initialize LLM + llm = ChatOpenAI(model="gpt-4o", temperature=0.0) + + # Create MCP server + app = create_mcp_server( + context=context, + llm=llm, + task_expiry_minutes=task_expiry_minutes, + ) + if transport == "sse": from mcp.server.sse import SseServerTransport from starlette.applications import Starlette from starlette.routing import Mount, Route - + import uvicorn + sse = SseServerTransport("/messages/") - + async def handle_sse(request): try: async with sse.connect_sse( @@ -458,10 +217,8 @@ async def handle_sse(request): ) except Exception as e: logger.error(f"Error in handle_sse: {str(e)}") - # Ensure browser context is reset if there's an error - asyncio.create_task(reset_browser_context()) raise - + starlette_app = Starlette( debug=True, routes=[ @@ -469,84 +226,55 @@ async def handle_sse(request): Mount("/messages/", app=sse.handle_post_message), ], ) - - import uvicorn - - # Add a startup event to initialize the browser + + # Add a startup event to initialize the browser and start task cleanup @starlette_app.on_event("startup") async def startup_event(): - logger.info("Starting browser context...") - await reset_browser_context() - logger.info("Browser context started") - - # Start background task cleanup - asyncio.create_task(cleanup_old_tasks()) - + logger.info("Starting server and scheduling cleanup...") + + # Start the cleanup task now that we have an event loop + if hasattr(app, 'cleanup_old_tasks'): + asyncio.create_task(app.cleanup_old_tasks()) + logger.info("Task cleanup process scheduled") + + # Add a shutdown event to clean up browser resources @starlette_app.on_event("shutdown") async def shutdown_event(): - logger.info("Shutting down browser context...") - await browser.close() - logger.info("Browser context closed") - - async def cleanup_old_tasks(): - """Periodically clean up old completed tasks to prevent memory leaks.""" - while True: - try: - # Sleep first to avoid cleaning up tasks too early - await asyncio.sleep(3600) # Run cleanup every hour - - current_time = datetime.now() - tasks_to_remove = [] - - # Find completed tasks older than 1 hour - for task_id, task_data in task_store.items(): - if ( - task_data["status"] in ["completed", "failed"] - and "end_time" in task_data - ): - end_time = datetime.fromisoformat(task_data["end_time"]) - hours_elapsed = ( - current_time - end_time - ).total_seconds() / 3600 - - if hours_elapsed > 1: # Remove tasks older than 1 hour - tasks_to_remove.append(task_id) - - # Remove old tasks - for task_id in tasks_to_remove: - del task_store[task_id] - - if tasks_to_remove: - logger.info(f"Cleaned up {len(tasks_to_remove)} old tasks") - - except Exception as e: - logger.error(f"Error in task cleanup: {str(e)}") - + logger.info("Shutting down server and cleaning up resources...") + try: + await context.browser.close() + logger.info("Browser context closed successfully") + except Exception as e: + logger.error(f"Error closing browser: {str(e)}") + uvicorn.run(starlette_app, host="0.0.0.0", port=port) else: from mcp.server.stdio import stdio_server - + async def arun(): try: - # Ensure browser context is healthy before starting - await check_browser_health() - + # Start the cleanup task now that we have an event loop + if hasattr(app, 'cleanup_old_tasks'): + asyncio.create_task(app.cleanup_old_tasks()) + logger.info("Task cleanup process scheduled") + async with stdio_server() as streams: await app.run( streams[0], streams[1], app.create_initialization_options() ) except Exception as e: logger.error(f"Error in arun: {str(e)}") - # Ensure browser context is reset if there's an error - await reset_browser_context() finally: # Clean up resources try: - await context.close() - await browser.close() + await context.browser.close() except Exception as e: logger.error(f"Error cleaning up resources: {str(e)}") - + anyio.run(arun) - + return 0 + + +if __name__ == "__main__": + main() diff --git a/src/browser_use_mcp_server/__init__.py b/src/browser_use_mcp_server/__init__.py new file mode 100644 index 0000000..d035185 --- /dev/null +++ b/src/browser_use_mcp_server/__init__.py @@ -0,0 +1,25 @@ +""" +Browser Use MCP Server - A library for integrating browser-use with MCP. +""" + +__version__ = "0.1.2" + +from .server import ( + BrowserContext, + BrowserContextConfig, + initialize_browser_context, + run_browser_task_async, + check_browser_health, + reset_browser_context, + create_mcp_server +) + +__all__ = [ + "BrowserContext", + "BrowserContextConfig", + "initialize_browser_context", + "run_browser_task_async", + "check_browser_health", + "reset_browser_context", + "create_mcp_server", +] \ No newline at end of file diff --git a/src/browser_use_mcp_server/__main__.py b/src/browser_use_mcp_server/__main__.py new file mode 100644 index 0000000..a1335ae --- /dev/null +++ b/src/browser_use_mcp_server/__main__.py @@ -0,0 +1,9 @@ +""" +Main module for browser-use-mcp-server. +""" + +import sys +from .cli import cli + +if __name__ == "__main__": + sys.exit(cli()) \ No newline at end of file diff --git a/src/browser_use_mcp_server/cli.py b/src/browser_use_mcp_server/cli.py new file mode 100644 index 0000000..535c5ad --- /dev/null +++ b/src/browser_use_mcp_server/cli.py @@ -0,0 +1,338 @@ +""" +Command-line interface for browser-use-mcp-server. +""" + +import os +import click +import asyncio +import logging +import uvicorn +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.responses import JSONResponse, StreamingResponse +import anyio +from typing import Dict, Any, Optional, AsyncIterator +import sys +import json + +from langchain_openai import ChatOpenAI +from mcp.server.lowlevel import Server + +from .server import ( + initialize_browser_context, + create_mcp_server, + check_browser_health, + reset_browser_context, +) + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +class AsyncStdinReader: + """Async wrapper for stdin.""" + + async def receive(self) -> bytes: + line = await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline) + return line.encode() + +class AsyncStdoutWriter: + """Async wrapper for stdout.""" + + async def send(self, data: bytes) -> None: + text = data.decode() + sys.stdout.write(text) + sys.stdout.flush() + await asyncio.sleep(0) # Yield control back to the event loop + +@click.group() +def cli(): + """Browser MCP Server CLI.""" + pass + + +@cli.command() +@click.option("--port", default=8000, help="Port to listen on for SSE") +@click.option( + "--transport", + type=click.Choice(["stdio", "sse"]), + default="stdio", + help="Transport type", +) +@click.option( + "--chrome-path", + default=None, + help="Path to Chrome executable (defaults to CHROME_PATH env var)", +) +@click.option( + "--model", + default="gpt-4o", + help="OpenAI model to use", +) +@click.option( + "--window-width", + default=1280, + help="Browser window width", +) +@click.option( + "--window-height", + default=1100, + help="Browser window height", +) +@click.option( + "--task-expiry-minutes", + default=60, + help="Minutes after which tasks expire", +) +def start( + port: int, + transport: str, + chrome_path: Optional[str], + model: str, + window_width: int, + window_height: int, + task_expiry_minutes: int, +) -> int: + """Start the browser MCP server.""" + # Record tasks for SSE transport + task_store: Dict[str, Any] = {} + + # Set up browser context and LLM + if chrome_path is None: + chrome_path = os.environ.get("CHROME_PATH") + + try: + logger.info(f"Initializing browser context with Chrome path: {chrome_path or 'default'}") + context = initialize_browser_context( + chrome_path=chrome_path, + window_width=window_width, + window_height=window_height, + ) + logger.info("Browser context initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize browser context: {e}") + return 1 + + try: + logger.info(f"Initializing LLM with model: {model}") + llm = ChatOpenAI(model=model, temperature=0.0) + logger.info("LLM initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize LLM: {e}") + return 1 + + try: + # Create MCP server + logger.info("Creating MCP server") + app = create_mcp_server( + context=context, + llm=llm, + custom_task_store=task_store, + task_expiry_minutes=task_expiry_minutes, + ) + logger.info("MCP server created successfully") + except Exception as e: + logger.error(f"Failed to create MCP server: {e}") + return 1 + + if transport == "stdio": + # Run the server with stdio transport + logger.info("Starting browser MCP server with stdio transport") + return asyncio.run(_run_stdio(app)) + + else: + # Set up Starlette app for SSE transport + async def handle_sse(request): + """Handle SSE connections.""" + logger.info(f"New SSE connection from {request.client}") + logger.info(f"Request headers: {request.headers}") + + # Create a queue for sending messages + send_queue = asyncio.Queue() + + # Define message handlers for MCP server + class SSEReadStream: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + pass + + async def receive(self) -> bytes: + # For SSE, we don't receive anything from client + # Just block indefinitely + future = asyncio.Future() + await future # This will block forever + return b"" # Never reached + + class SSEWriteStream: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + pass + + async def send(self, data: bytes) -> None: + # Queue the message to be sent over SSE + await send_queue.put(data) + + # Create async generator to stream SSE responses + async def stream_response(): + """Stream SSE responses.""" + logger.info("Setting up SSE stream") + + # Start MCP server in background + read_stream = SSEReadStream() + write_stream = SSEWriteStream() + + server_task = asyncio.create_task( + app.run( + read_stream=read_stream, + write_stream=write_stream, + initialization_options={} + ) + ) + + try: + # Send initial connected event + logger.info("Sending initial connected event") + yield b"event: connected\ndata: {}\n\n" + + # Stream messages from the queue + logger.info("Starting to stream messages") + while True: + message = await send_queue.get() + logger.info(f"Sending message: {message[:100]}...") + data = f"data: {message.decode()}\n\n" + yield data.encode("utf-8") + send_queue.task_done() + except Exception as e: + logger.error(f"SSE streaming error: {e}") + finally: + # Clean up + server_task.cancel() + logger.info("SSE connection closed") + + return StreamingResponse( + stream_response(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + async def health_check(request): + """Health check endpoint.""" + try: + # Check browser health + healthy = await check_browser_health(context) + return JSONResponse({"status": "healthy" if healthy else "unhealthy"}) + except Exception as e: + return JSONResponse({"status": "error", "message": str(e)}, status_code=500) + + async def reset_context(request): + """Reset browser context endpoint.""" + try: + # Reset browser context + await reset_browser_context(context) + return JSONResponse({"status": "success", "message": "Browser context reset"}) + except Exception as e: + return JSONResponse({"status": "error", "message": str(e)}, status_code=500) + + # Define startup and shutdown events + async def startup_event(): + """Run on server startup.""" + logger.info("Starting server...") + + # Start task cleanup job + asyncio.create_task(cleanup_old_tasks()) + + logger.info(f"Server started on port {port}") + + async def shutdown_event(): + """Run on server shutdown.""" + logger.info("Shutting down server...") + + try: + # Close the browser + await context.browser.close() + logger.info("Browser closed successfully") + except Exception as e: + logger.error(f"Error closing browser: {e}") + + logger.info("Server shut down") + + async def cleanup_old_tasks(): + """Periodically clean up expired tasks.""" + from datetime import datetime + + while True: + try: + # Check for expired tasks every minute + await asyncio.sleep(60) + + # Get current time + now = datetime.now() + + # Check each task + expired_tasks = [] + for task_id, task in task_store.items(): + if "expiry_time" in task: + # Parse expiry time + expiry_time = datetime.fromisoformat(task["expiry_time"]) + + # Check if expired + if now > expiry_time: + expired_tasks.append(task_id) + + # Remove expired tasks + for task_id in expired_tasks: + logger.info(f"Removing expired task {task_id}") + task_store.pop(task_id, None) + + except Exception as e: + logger.error(f"Error cleaning up old tasks: {e}") + + # Create Starlette app with routes + routes = [ + Route("/sse", endpoint=handle_sse, methods=["GET"]), + Route("/health", endpoint=health_check, methods=["GET"]), + Route("/reset", endpoint=reset_context, methods=["POST"]), + ] + + starlette_app = Starlette( + routes=routes, + on_startup=[startup_event], + on_shutdown=[shutdown_event], + debug=True + ) + + # Run with uvicorn + logger.info(f"Starting browser MCP server with SSE transport on port {port}") + uvicorn.run(starlette_app, host="0.0.0.0", port=port) + + return 0 + +async def _run_stdio(app: Server) -> int: + """Run the server using stdio transport.""" + try: + stdin_reader = AsyncStdinReader() + stdout_writer = AsyncStdoutWriter() + + # Create initialization options + initialization_options = {} + + # Run the server + await app.run( + read_stream=stdin_reader, + write_stream=stdout_writer, + initialization_options=initialization_options + ) + return 0 + except KeyboardInterrupt: + logger.info("Server stopped by user") + return 0 + except Exception as e: + logger.error(f"Error running server: {e}") + return 1 + +if __name__ == "__main__": + cli() \ No newline at end of file diff --git a/src/browser_use_mcp_server/server.py b/src/browser_use_mcp_server/server.py new file mode 100644 index 0000000..012d244 --- /dev/null +++ b/src/browser_use_mcp_server/server.py @@ -0,0 +1,680 @@ +""" +Core functionality for integrating browser-use with MCP. +""" + +import os +import asyncio +import uuid +from datetime import datetime, timedelta +from typing import Any, Callable, Dict, List, Optional, Union, Awaitable + +from langchain_openai import ChatOpenAI +from browser_use import Agent +from browser_use.browser.browser import Browser, BrowserConfig +from browser_use.browser.context import BrowserContextConfig, BrowserContext +import mcp.types as types +from mcp.server.lowlevel import Server + +import logging +from dotenv import load_dotenv +import inspect + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Load environment variables +load_dotenv() + +# Task storage for async operations +task_store = {} + +# Flag to track browser context health +browser_context_healthy = True + + +class MockContext: + """Mock context for testing.""" + def __init__(self): + pass + + +class MockLLM: + """Mock LLM for testing.""" + def __init__(self): + pass + + # Define any necessary methods for testing here + + +def initialize_browser_context( + chrome_path: Optional[str] = None, + window_width: int = 1280, + window_height: int = 1100, + locale: str = "en-US", + user_agent: Optional[str] = None, + extra_chromium_args: Optional[List[str]] = None, +) -> BrowserContext: + """ + Initialize the browser context with specified parameters. + + Args: + chrome_path: Path to Chrome instance + window_width: Browser window width + window_height: Browser window height + locale: Browser locale + user_agent: Browser user agent + extra_chromium_args: Additional arguments for Chrome + + Returns: + Initialized BrowserContext + """ + # Browser context configuration + if not user_agent: + user_agent = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36" + ) + + if not extra_chromium_args: + extra_chromium_args = [ + "--no-sandbox", + "--disable-gpu", + "--disable-software-rasterizer", + "--disable-dev-shm-usage", + "--remote-debugging-port=9222", + ] + + config = BrowserContextConfig( + wait_for_network_idle_page_load_time=0.6, + maximum_wait_page_load_time=1.2, + minimum_wait_page_load_time=0.2, + browser_window_size={"width": window_width, "height": window_height}, + locale=locale, + user_agent=user_agent, + highlight_elements=True, + viewport_expansion=0, + ) + + # Initialize browser and context + browser = Browser( + config=BrowserConfig( + chrome_instance_path=chrome_path or os.environ.get("CHROME_PATH"), + extra_chromium_args=extra_chromium_args, + ) + ) + + return BrowserContext(browser=browser, config=config) + + +async def reset_browser_context(context: BrowserContext) -> None: + """ + Reset the browser context to a clean state. + + Args: + context: The browser context to reset + """ + global browser_context_healthy + + try: + logger.info("Resetting browser context...") + + # Since Browser doesn't have pages() or new_page() methods, + # we need to use the methods that are available + + # Try to refresh the page if possible + try: + # If the context has a current page, try to reload it + if hasattr(context, 'current_page') and context.current_page: + await context.current_page.reload() + logger.info("Current page reloaded") + + # Or navigate to a blank page to reset state + if hasattr(context, 'navigate'): + await context.navigate('about:blank') + logger.info("Navigated to blank page") + + # If we have access to create a new context, use that + if hasattr(context, 'create_new_context'): + await context.create_new_context() + logger.info("Created new context") + + # As a last resort, try to initialize a new context + if hasattr(context.browser, 'initialize'): + await context.browser.initialize() + logger.info("Re-initialized browser") + except Exception as e: + logger.warning(f"Error performing specific reset operations: {e}") + + # Mark as healthy + browser_context_healthy = True + logger.info("Browser context reset successfully") + except Exception as e: + browser_context_healthy = False + logger.error(f"Failed to reset browser context: {e}") + # Re-raise to allow caller to handle + raise + + +async def check_browser_health(context: BrowserContext) -> bool: + """ + Check if the browser context is healthy. + + Args: + context: The browser context to check + + Returns: + True if healthy, False otherwise + """ + global browser_context_healthy + + # Debug: Log available methods and attributes + try: + context_methods = [method for method in dir(context) if not method.startswith('_')] + logger.info(f"BrowserContext available methods: {context_methods}") + + if hasattr(context, 'browser'): + browser_methods = [method for method in dir(context.browser) if not method.startswith('_')] + logger.info(f"Browser available methods: {browser_methods}") + except Exception as e: + logger.warning(f"Error logging available methods: {e}") + + if not browser_context_healthy: + logger.info("Browser context marked as unhealthy, attempting reset...") + try: + await reset_browser_context(context) + return True + except Exception as e: + logger.error(f"Failed to recover browser context: {e}") + return False + + return True + + +async def run_browser_task_async( + context: BrowserContext, + llm: Any, + task_id: str, + url: str, + action: str, + custom_task_store: Optional[Dict[str, Any]] = None, + step_callback: Optional[Callable[[Dict[str, Any], Dict[str, Any], int], Awaitable[None]]] = None, + done_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None, + task_expiry_minutes: int = 60, +) -> str: + """ + Run a browser task asynchronously. + + Args: + context: Browser context for the task + llm: Language model to use for the agent + task_id: Unique identifier for the task + url: URL to navigate to + action: Action description for the agent + custom_task_store: Optional custom task store for tracking tasks + step_callback: Optional callback for each step of the task + done_callback: Optional callback for when the task is complete + task_expiry_minutes: Minutes after which the task is considered expired + + Returns: + Task ID + """ + store = custom_task_store if custom_task_store is not None else task_store + + # Define steps for tracking progress + store[task_id] = { + "id": task_id, + "url": url, + "action": action, + "status": "running", + "start_time": datetime.now().isoformat(), + "expiry_time": (datetime.now() + timedelta(minutes=task_expiry_minutes)).isoformat(), + "steps": [], + "result": None, + "error": None, + } + + # Define default callbacks if not provided + async def default_step_callback(browser_state, agent_output, step_number): + """Default step callback that updates the task store.""" + store[task_id]["steps"].append({ + "step": step_number, + "browser_state": browser_state, + "agent_output": agent_output, + "timestamp": datetime.now().isoformat(), + }) + logger.info(f"Task {task_id}: Step {step_number} completed") + + async def default_done_callback(history): + """Default done callback that updates the task store.""" + store[task_id]["status"] = "completed" + store[task_id]["result"] = history + store[task_id]["end_time"] = datetime.now().isoformat() + logger.info(f"Task {task_id}: Completed successfully") + + step_cb = step_callback if step_callback is not None else default_step_callback + done_cb = done_callback if done_callback is not None else default_done_callback + + try: + # Check and ensure browser health + browser_healthy = await check_browser_health(context) + if not browser_healthy: + raise Exception("Browser context is unhealthy") + + # Create agent and run task + try: + # Inspect Agent class initialization parameters + agent_params = inspect.signature(Agent.__init__).parameters + logger.info(f"Agent init parameters: {list(agent_params.keys())}") + + # Adapt initialization based on available parameters + agent_kwargs = {'context': context} + + if 'llm' in agent_params: + agent_kwargs['llm'] = llm + + # Add task parameter which is required based on the error message + if 'task' in agent_params: + # Create a task that combines navigation and the action + task_description = f"First, navigate to {url}. Then, {action}" + agent_kwargs['task'] = task_description + + # Add browser and browser_context parameters if they're required + if 'browser' in agent_params: + agent_kwargs['browser'] = context.browser + if 'browser_context' in agent_params: + agent_kwargs['browser_context'] = context + + # Check for callbacks + if 'step_callback' in agent_params: + agent_kwargs['step_callback'] = step_cb + if 'done_callback' in agent_params: + agent_kwargs['done_callback'] = done_cb + + # Register callbacks with the new parameter names if the old ones don't exist + if 'step_callback' not in agent_params and 'register_new_step_callback' in agent_params: + agent_kwargs['register_new_step_callback'] = step_cb + if 'done_callback' not in agent_params and 'register_done_callback' in agent_params: + agent_kwargs['register_done_callback'] = done_cb + + # Check if all required parameters are set + missing_params = [] + for param_name, param in agent_params.items(): + if param.default == inspect.Parameter.empty and param_name != 'self' and param_name not in agent_kwargs: + missing_params.append(param_name) + + if missing_params: + logger.error(f"Missing required parameters for Agent: {missing_params}") + raise Exception(f"Missing required parameters for Agent: {missing_params}") + + # Create agent with appropriate parameters + agent = Agent(**agent_kwargs) + + # Launch task asynchronously + # Don't pass any parameters to run() as they should already be set via init + asyncio.create_task(agent.run()) + return task_id + except Exception as agent_error: + logger.error(f"Error creating Agent: {str(agent_error)}") + raise Exception(f"Failed to create browser agent: {str(agent_error)}") + + except Exception as e: + # Update task store with error + store[task_id]["status"] = "error" + store[task_id]["error"] = str(e) + store[task_id]["end_time"] = datetime.now().isoformat() + logger.error(f"Task {task_id}: Error - {str(e)}") + + # Attempt one more browser reset as a last resort + if "Browser context is unhealthy" in str(e): + try: + logger.info(f"Task {task_id}: Final attempt to reset browser context...") + + # Use a simpler recovery approach + try: + # Try to use any available method to reset the context + if hasattr(context, 'current_page') and context.current_page: + await context.current_page.reload() + logger.info(f"Task {task_id}: Current page reloaded") + + if hasattr(context, 'navigate'): + await context.navigate('about:blank') + logger.info(f"Task {task_id}: Navigated to blank page") + + # Mark as healthy and retry + global browser_context_healthy + browser_context_healthy = True + logger.info(f"Task {task_id}: Browser context recovered, retrying...") + + # Retry the task + try: + # Use the same dynamic approach for agent initialization + agent_kwargs = {'context': context} + + if 'llm' in inspect.signature(Agent.__init__).parameters: + agent_kwargs['llm'] = llm + + # Check for callbacks + if 'step_callback' in inspect.signature(Agent.__init__).parameters: + agent_kwargs['step_callback'] = step_cb + if 'done_callback' in inspect.signature(Agent.__init__).parameters: + agent_kwargs['done_callback'] = done_cb + + # Create agent with appropriate parameters + agent = Agent(**agent_kwargs) + + # Launch task asynchronously + asyncio.create_task(agent.run()) + store[task_id]["status"] = "running" + store[task_id]["error"] = None + return task_id + except Exception as agent_error: + logger.error(f"Task {task_id}: Error creating Agent during retry: {str(agent_error)}") + raise + except Exception as retry_error: + logger.error(f"Task {task_id}: Retry failed - {str(retry_error)}") + except Exception as reset_error: + logger.error(f"Task {task_id}: Final reset attempt failed - {str(reset_error)}") + + # Re-raise the exception + raise + + +def create_mcp_server( + context: BrowserContext, + llm: Any, + custom_task_store: Optional[Dict[str, Any]] = None, + task_expiry_minutes: int = 60, +) -> Server: + """ + Create an MCP server with browser capabilities. + + Args: + context: Browser context for the server + llm: Language model to use for the agent + custom_task_store: Optional custom task store for tracking tasks + task_expiry_minutes: Minutes after which tasks are considered expired + + Returns: + Configured MCP server + """ + # Use provided task store or default + store = custom_task_store if custom_task_store is not None else task_store + + # Create MCP server + app = Server(name="browser-use-mcp-server") + + @app.call_tool() + async def call_tool( + name: str, arguments: dict + ) -> list[Union[types.TextContent, types.ImageContent, types.EmbeddedResource]]: + """ + Handle tool calls from the MCP client. + + Args: + name: Tool name + arguments: Tool arguments + + Returns: + List of content items + """ + logger.info(f"Tool call received: {name} with arguments: {arguments}") + + if name == "mcp__browser_navigate": + # Validate required arguments + if "url" not in arguments: + logger.error("URL argument missing in browser.navigate call") + return [types.TextContent(type="text", text="Error: URL is required")] + + url = arguments["url"] + action = arguments.get("action", "Navigate to the given URL and tell me what you see.") + + logger.info(f"Navigation request to URL: {url} with action: {action}") + + # Generate unique task ID + task_id = str(uuid.uuid4()) + + try: + # Run browser task + await run_browser_task_async( + context=context, + llm=llm, + task_id=task_id, + url=url, + action=action, + custom_task_store=store, + task_expiry_minutes=task_expiry_minutes, + ) + + logger.info(f"Navigation task {task_id} started successfully") + + # Return a simpler response with just TextContent to avoid validation errors + return [ + types.TextContent( + type="text", + text=f"Navigating to {url}. Task {task_id} started successfully. Results will be available when task completes." + ) + ] + + except Exception as e: + logger.error(f"Error executing navigation task: {str(e)}") + return [types.TextContent(type="text", text=f"Error navigating to {url}: {str(e)}")] + + elif name == "mcp__browser_health": + try: + # Check browser health + logger.info("Health check requested") + healthy = await check_browser_health(context) + status = "healthy" if healthy else "unhealthy" + logger.info(f"Browser health status: {status}") + return [types.TextContent(type="text", text=f"Browser status: {status}")] + + except Exception as e: + logger.error(f"Error checking browser health: {str(e)}") + return [types.TextContent(type="text", text=f"Error checking browser health: {str(e)}")] + + elif name == "mcp__browser_reset": + try: + # Reset browser context + logger.info("Browser reset requested") + await reset_browser_context(context) + logger.info("Browser context reset successful") + return [types.TextContent(type="text", text="Browser context reset successfully")] + + except Exception as e: + logger.error(f"Error resetting browser context: {str(e)}") + return [types.TextContent(type="text", text=f"Error resetting browser context: {str(e)}")] + + else: + logger.warning(f"Unknown tool requested: {name}") + return [types.TextContent(type="text", text=f"Unknown tool: {name}")] + + @app.list_tools() + async def list_tools() -> list[types.Tool]: + """ + List available tools for the MCP client. + + Returns: + List of available tools + """ + try: + logger.info("list_tools called - preparing to return tools") + tools = [ + types.Tool( + name="mcp__browser_navigate", + description="Navigate to a URL and perform an action", + inputSchema={ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to navigate to", + }, + "action": { + "type": "string", + "description": "The action to perform on the page", + }, + }, + "required": ["url"], + }, + ), + types.Tool( + name="mcp__browser_health", + description="Check browser health status", + inputSchema={ + "type": "object", + "properties": { + "random_string": { + "type": "string", + "description": "Dummy parameter for no-parameter tools", + }, + }, + "required": ["random_string"], + }, + ), + types.Tool( + name="mcp__browser_reset", + description="Reset browser context", + inputSchema={ + "type": "object", + "properties": { + "random_string": { + "type": "string", + "description": "Dummy parameter for no-parameter tools", + }, + }, + "required": ["random_string"], + }, + ), + ] + logger.info(f"Successfully prepared {len(tools)} tools") + return tools + except Exception as e: + logger.error(f"Error in list_tools: {str(e)}") + raise + + @app.list_resources() + async def list_resources() -> list[types.Resource]: + """ + List available resources for the MCP client. + + Returns: + List of available resources + """ + resources = [] + + # Add all completed tasks as resources + for task_id, task in store.items(): + if task["status"] in ["completed", "error"]: + resources.append( + types.Resource( + uri=f"browser-task://{task_id}", + title=f"Browser Task: {task['url']}", + description=f"Status: {task['status']}", + ) + ) + + return resources + + @app.read_resource() + async def read_resource(uri: str) -> list[types.ResourceContents]: + """ + Read resource content by URI. + + Args: + uri: Resource URI + + Returns: + Resource contents + """ + # Extract task ID from URI + if not uri.startswith("browser-task://"): + return [types.ResourceContents(error="Invalid resource URI format")] + + task_id = uri[15:] # Remove "browser-task://" prefix + + # Check if task exists + if task_id not in store: + return [types.ResourceContents(error=f"Task {task_id} not found")] + + task = store[task_id] + + # Check task status + if task["status"] == "error": + return [ + types.ResourceContents( + mimetype="text/plain", + contents=f"Error: {task['error']}", + ) + ] + + if task["status"] == "running": + # For running tasks, return the steps completed so far + steps_text = "\n".join( + [f"Step {s['step']}: {s['agent_output'].get('action', 'Unknown action')}" for s in task["steps"]] + ) + return [ + types.ResourceContents( + mimetype="text/plain", + contents=f"Task {task_id} is still running.\n\nSteps completed so far:\n{steps_text}", + ) + ] + + # For completed tasks, return the full result + if task["result"]: + # Format the result as markdown + result_text = f"# Browser Task Report\n\n" + result_text += f"URL: {task['url']}\n\n" + result_text += f"Action: {task['action']}\n\n" + result_text += f"Start Time: {task['start_time']}\n\n" + result_text += f"End Time: {task['end_time']}\n\n" + + # Add steps + result_text += "## Steps\n\n" + for step in task["steps"]: + result_text += f"### Step {step['step']}\n\n" + result_text += f"Time: {step['timestamp']}\n\n" + + # Add agent output + if "agent_output" in step and step["agent_output"]: + result_text += "#### Agent Output\n\n" + action = step["agent_output"].get("action", "Unknown action") + result_text += f"Action: {action}\n\n" + + # Add agent thoughts if available + if "thought" in step["agent_output"]: + result_text += f"Thought: {step['agent_output']['thought']}\n\n" + + # Add browser state snapshot + if "browser_state" in step and step["browser_state"]: + result_text += "#### Browser State\n\n" + + # Add page title if available + if "page_title" in step["browser_state"]: + result_text += f"Page Title: {step['browser_state']['page_title']}\n\n" + + # Add URL if available + if "url" in step["browser_state"]: + result_text += f"URL: {step['browser_state']['url']}\n\n" + + # Add screenshot if available + if "screenshot" in step["browser_state"]: + result_text += "Screenshot available but not included in text output.\n\n" + + # Return formatted result + return [ + types.ResourceContents( + mimetype="text/markdown", + contents=result_text, + ) + ] + + # Fallback for unexpected cases + return [ + types.ResourceContents( + mimetype="text/plain", + contents=f"Task {task_id} completed with status '{task['status']}' but no results are available.", + ) + ] + + return app \ No newline at end of file diff --git a/uv.lock b/uv.lock index 0b1a862..5495c75 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 1 -requires-python = ">=3.13" +requires-python = ">=3.11, <4.0" +resolution-markers = [ + "python_full_version >= '3.12.4'", + "python_full_version < '3.12.4'", +] [[package]] name = "annotated-types" @@ -36,6 +40,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } wheels = [ @@ -73,6 +78,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/49/6abb616eb3cbab6a7cca303dc02fdf3836de2e0b834bf966a7f5271a34d8/beautifulsoup4-4.13.3-py3-none-any.whl", hash = "sha256:99045d7d3f08f91f0d656bc9b7efbae189426cd913d830294a15eefa0ea4df16", size = 186015 }, ] +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372 }, + { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865 }, + { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699 }, + { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028 }, + { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988 }, + { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985 }, + { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816 }, + { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860 }, + { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673 }, + { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190 }, + { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926 }, + { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613 }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646 }, +] + [[package]] name = "browser-use" version = "0.1.40" @@ -99,8 +132,9 @@ wheels = [ [[package]] name = "browser-use-mcp-server" version = "0.1.2" -source = { virtual = "." } +source = { editable = "." } dependencies = [ + { name = "anyio" }, { name = "asyncio" }, { name = "browser-use" }, { name = "click" }, @@ -108,18 +142,48 @@ dependencies = [ { name = "langchain-openai" }, { name = "mcp" }, { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +dev = [ + { name = "black" }, + { name = "isort" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, ] [package.metadata] requires-dist = [ + { name = "anyio" }, { name = "asyncio", specifier = ">=3.4.3" }, + { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "browser-use", specifier = ">=0.1.40" }, { name = "click", specifier = ">=8.1.8" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" }, { name = "langchain-openai", specifier = ">=0.3.1" }, { name = "mcp", specifier = ">=1.3.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "pydantic", specifier = ">=2.10.6" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=7.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.21.0" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.1.0" }, + { name = "python-dotenv" }, + { name = "starlette" }, + { name = "uvicorn" }, ] +provides-extras = ["dev", "test"] [[package]] name = "certifi" @@ -139,6 +203,29 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, @@ -158,6 +245,32 @@ version = "3.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } wheels = [ + { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, + { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, + { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, + { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, + { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, + { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, + { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, + { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, + { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, + { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, + { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, + { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, + { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, @@ -195,6 +308,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "coverage" +version = "7.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/d6/2b53ab3ee99f2262e6f0b8369a43f6d66658eab45510331c0b3d5c8c4272/coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2", size = 805941 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/2d/da78abbfff98468c91fd63a73cccdfa0e99051676ded8dd36123e3a2d4d5/coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015", size = 208464 }, + { url = "https://files.pythonhosted.org/packages/31/f2/c269f46c470bdabe83a69e860c80a82e5e76840e9f4bbd7f38f8cebbee2f/coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45", size = 208893 }, + { url = "https://files.pythonhosted.org/packages/47/63/5682bf14d2ce20819998a49c0deadb81e608a59eed64d6bc2191bc8046b9/coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702", size = 241545 }, + { url = "https://files.pythonhosted.org/packages/6a/b6/6b6631f1172d437e11067e1c2edfdb7238b65dff965a12bce3b6d1bf2be2/coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0", size = 239230 }, + { url = "https://files.pythonhosted.org/packages/c7/01/9cd06cbb1be53e837e16f1b4309f6357e2dfcbdab0dd7cd3b1a50589e4e1/coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f", size = 241013 }, + { url = "https://files.pythonhosted.org/packages/4b/26/56afefc03c30871326e3d99709a70d327ac1f33da383cba108c79bd71563/coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f", size = 239750 }, + { url = "https://files.pythonhosted.org/packages/dd/ea/88a1ff951ed288f56aa561558ebe380107cf9132facd0b50bced63ba7238/coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d", size = 238462 }, + { url = "https://files.pythonhosted.org/packages/6e/d4/1d9404566f553728889409eff82151d515fbb46dc92cbd13b5337fa0de8c/coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba", size = 239307 }, + { url = "https://files.pythonhosted.org/packages/12/c1/e453d3b794cde1e232ee8ac1d194fde8e2ba329c18bbf1b93f6f5eef606b/coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f", size = 211117 }, + { url = "https://files.pythonhosted.org/packages/d5/db/829185120c1686fa297294f8fcd23e0422f71070bf85ef1cc1a72ecb2930/coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558", size = 212019 }, + { url = "https://files.pythonhosted.org/packages/e2/7f/4af2ed1d06ce6bee7eafc03b2ef748b14132b0bdae04388e451e4b2c529b/coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad", size = 208645 }, + { url = "https://files.pythonhosted.org/packages/dc/60/d19df912989117caa95123524d26fc973f56dc14aecdec5ccd7d0084e131/coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3", size = 208898 }, + { url = "https://files.pythonhosted.org/packages/bd/10/fecabcf438ba676f706bf90186ccf6ff9f6158cc494286965c76e58742fa/coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574", size = 242987 }, + { url = "https://files.pythonhosted.org/packages/4c/53/4e208440389e8ea936f5f2b0762dcd4cb03281a7722def8e2bf9dc9c3d68/coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985", size = 239881 }, + { url = "https://files.pythonhosted.org/packages/c4/47/2ba744af8d2f0caa1f17e7746147e34dfc5f811fb65fc153153722d58835/coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750", size = 242142 }, + { url = "https://files.pythonhosted.org/packages/e9/90/df726af8ee74d92ee7e3bf113bf101ea4315d71508952bd21abc3fae471e/coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea", size = 241437 }, + { url = "https://files.pythonhosted.org/packages/f6/af/995263fd04ae5f9cf12521150295bf03b6ba940d0aea97953bb4a6db3e2b/coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3", size = 239724 }, + { url = "https://files.pythonhosted.org/packages/1c/8e/5bb04f0318805e190984c6ce106b4c3968a9562a400180e549855d8211bd/coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a", size = 241329 }, + { url = "https://files.pythonhosted.org/packages/9e/9d/fa04d9e6c3f6459f4e0b231925277cfc33d72dfab7fa19c312c03e59da99/coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95", size = 211289 }, + { url = "https://files.pythonhosted.org/packages/53/40/53c7ffe3c0c3fff4d708bc99e65f3d78c129110d6629736faf2dbd60ad57/coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288", size = 212079 }, + { url = "https://files.pythonhosted.org/packages/76/89/1adf3e634753c0de3dad2f02aac1e73dba58bc5a3a914ac94a25b2ef418f/coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1", size = 208673 }, + { url = "https://files.pythonhosted.org/packages/ce/64/92a4e239d64d798535c5b45baac6b891c205a8a2e7c9cc8590ad386693dc/coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd", size = 208945 }, + { url = "https://files.pythonhosted.org/packages/b4/d0/4596a3ef3bca20a94539c9b1e10fd250225d1dec57ea78b0867a1cf9742e/coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9", size = 242484 }, + { url = "https://files.pythonhosted.org/packages/1c/ef/6fd0d344695af6718a38d0861408af48a709327335486a7ad7e85936dc6e/coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e", size = 239525 }, + { url = "https://files.pythonhosted.org/packages/0c/4b/373be2be7dd42f2bcd6964059fd8fa307d265a29d2b9bcf1d044bcc156ed/coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4", size = 241545 }, + { url = "https://files.pythonhosted.org/packages/a6/7d/0e83cc2673a7790650851ee92f72a343827ecaaea07960587c8f442b5cd3/coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6", size = 241179 }, + { url = "https://files.pythonhosted.org/packages/ff/8c/566ea92ce2bb7627b0900124e24a99f9244b6c8c92d09ff9f7633eb7c3c8/coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3", size = 239288 }, + { url = "https://files.pythonhosted.org/packages/7d/e4/869a138e50b622f796782d642c15fb5f25a5870c6d0059a663667a201638/coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc", size = 241032 }, + { url = "https://files.pythonhosted.org/packages/ae/28/a52ff5d62a9f9e9fe9c4f17759b98632edd3a3489fce70154c7d66054dd3/coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3", size = 211315 }, + { url = "https://files.pythonhosted.org/packages/bc/17/ab849b7429a639f9722fa5628364c28d675c7ff37ebc3268fe9840dda13c/coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef", size = 212099 }, + { url = "https://files.pythonhosted.org/packages/d2/1c/b9965bf23e171d98505eb5eb4fb4d05c44efd256f2e0f19ad1ba8c3f54b0/coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e", size = 209511 }, + { url = "https://files.pythonhosted.org/packages/57/b3/119c201d3b692d5e17784fee876a9a78e1b3051327de2709392962877ca8/coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703", size = 209729 }, + { url = "https://files.pythonhosted.org/packages/52/4e/a7feb5a56b266304bc59f872ea07b728e14d5a64f1ad3a2cc01a3259c965/coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0", size = 253988 }, + { url = "https://files.pythonhosted.org/packages/65/19/069fec4d6908d0dae98126aa7ad08ce5130a6decc8509da7740d36e8e8d2/coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924", size = 249697 }, + { url = "https://files.pythonhosted.org/packages/1c/da/5b19f09ba39df7c55f77820736bf17bbe2416bbf5216a3100ac019e15839/coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b", size = 252033 }, + { url = "https://files.pythonhosted.org/packages/1e/89/4c2750df7f80a7872267f7c5fe497c69d45f688f7b3afe1297e52e33f791/coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d", size = 251535 }, + { url = "https://files.pythonhosted.org/packages/78/3b/6d3ae3c1cc05f1b0460c51e6f6dcf567598cbd7c6121e5ad06643974703c/coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827", size = 249192 }, + { url = "https://files.pythonhosted.org/packages/6e/8e/c14a79f535ce41af7d436bbad0d3d90c43d9e38ec409b4770c894031422e/coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9", size = 250627 }, + { url = "https://files.pythonhosted.org/packages/cb/79/b7cee656cfb17a7f2c1b9c3cee03dd5d8000ca299ad4038ba64b61a9b044/coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3", size = 212033 }, + { url = "https://files.pythonhosted.org/packages/b6/c3/f7aaa3813f1fa9a4228175a7bd368199659d392897e184435a3b66408dd3/coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f", size = 213240 }, + { url = "https://files.pythonhosted.org/packages/fb/b2/f655700e1024dec98b10ebaafd0cedbc25e40e4abe62a3c8e2ceef4f8f0a/coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953", size = 200552 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -219,6 +386,24 @@ version = "3.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022 } wheels = [ + { url = "https://files.pythonhosted.org/packages/28/62/1c2665558618553c42922ed47a4e6d6527e2fa3516a8256c2f431c5d0441/greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70", size = 272479 }, + { url = "https://files.pythonhosted.org/packages/76/9d/421e2d5f07285b6e4e3a676b016ca781f63cfe4a0cd8eaecf3fd6f7a71ae/greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159", size = 640404 }, + { url = "https://files.pythonhosted.org/packages/e5/de/6e05f5c59262a584e502dd3d261bbdd2c97ab5416cc9c0b91ea38932a901/greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e", size = 652813 }, + { url = "https://files.pythonhosted.org/packages/49/93/d5f93c84241acdea15a8fd329362c2c71c79e1a507c3f142a5d67ea435ae/greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1", size = 648517 }, + { url = "https://files.pythonhosted.org/packages/15/85/72f77fc02d00470c86a5c982b8daafdf65d38aefbbe441cebff3bf7037fc/greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383", size = 647831 }, + { url = "https://files.pythonhosted.org/packages/f7/4b/1c9695aa24f808e156c8f4813f685d975ca73c000c2a5056c514c64980f6/greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a", size = 602413 }, + { url = "https://files.pythonhosted.org/packages/76/70/ad6e5b31ef330f03b12559d19fda2606a522d3849cde46b24f223d6d1619/greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511", size = 1129619 }, + { url = "https://files.pythonhosted.org/packages/f4/fb/201e1b932e584066e0f0658b538e73c459b34d44b4bd4034f682423bc801/greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395", size = 1155198 }, + { url = "https://files.pythonhosted.org/packages/12/da/b9ed5e310bb8b89661b80cbcd4db5a067903bbcd7fc854923f5ebb4144f0/greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39", size = 298930 }, + { url = "https://files.pythonhosted.org/packages/7d/ec/bad1ac26764d26aa1353216fcbfa4670050f66d445448aafa227f8b16e80/greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", size = 274260 }, + { url = "https://files.pythonhosted.org/packages/66/d4/c8c04958870f482459ab5956c2942c4ec35cac7fe245527f1039837c17a9/greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", size = 649064 }, + { url = "https://files.pythonhosted.org/packages/51/41/467b12a8c7c1303d20abcca145db2be4e6cd50a951fa30af48b6ec607581/greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", size = 663420 }, + { url = "https://files.pythonhosted.org/packages/27/8f/2a93cd9b1e7107d5c7b3b7816eeadcac2ebcaf6d6513df9abaf0334777f6/greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", size = 658035 }, + { url = "https://files.pythonhosted.org/packages/57/5c/7c6f50cb12be092e1dccb2599be5a942c3416dbcfb76efcf54b3f8be4d8d/greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", size = 660105 }, + { url = "https://files.pythonhosted.org/packages/f1/66/033e58a50fd9ec9df00a8671c74f1f3a320564c6415a4ed82a1c651654ba/greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", size = 613077 }, + { url = "https://files.pythonhosted.org/packages/19/c5/36384a06f748044d06bdd8776e231fadf92fc896bd12cb1c9f5a1bda9578/greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", size = 1135975 }, + { url = "https://files.pythonhosted.org/packages/38/f9/c0a0eb61bdf808d23266ecf1d63309f0e1471f284300ce6dac0ae1231881/greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", size = 1163955 }, + { url = "https://files.pythonhosted.org/packages/43/21/a5d9df1d21514883333fc86584c07c2b49ba7c602e670b174bd73cfc9c7f/greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", size = 299655 }, { url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990 }, { url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175 }, { url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425 }, @@ -292,12 +477,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "isort" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186 }, +] + [[package]] name = "jiter" version = "0.8.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f8/70/90bc7bd3932e651486861df5c8ffea4ca7c77d28e8532ddefe2abc561a53/jiter-0.8.2.tar.gz", hash = "sha256:cd73d3e740666d0e639f678adb176fad25c1bcbdae88d8d7b857e1783bb4212d", size = 163007 } wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b0/c1a7caa7f9dc5f1f6cfa08722867790fe2d3645d6e7170ca280e6e52d163/jiter-0.8.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2dd61c5afc88a4fda7d8b2cf03ae5947c6ac7516d32b7a15bf4b49569a5c076b", size = 303666 }, + { url = "https://files.pythonhosted.org/packages/f5/97/0468bc9eeae43079aaa5feb9267964e496bf13133d469cfdc135498f8dd0/jiter-0.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c710d657c8d1d2adbbb5c0b0c6bfcec28fd35bd6b5f016395f9ac43e878a15", size = 311934 }, + { url = "https://files.pythonhosted.org/packages/e5/69/64058e18263d9a5f1e10f90c436853616d5f047d997c37c7b2df11b085ec/jiter-0.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9584de0cd306072635fe4b89742bf26feae858a0683b399ad0c2509011b9dc0", size = 335506 }, + { url = "https://files.pythonhosted.org/packages/9d/14/b747f9a77b8c0542141d77ca1e2a7523e854754af2c339ac89a8b66527d6/jiter-0.8.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a90a923338531b7970abb063cfc087eebae6ef8ec8139762007188f6bc69a9f", size = 355849 }, + { url = "https://files.pythonhosted.org/packages/53/e2/98a08161db7cc9d0e39bc385415890928ff09709034982f48eccfca40733/jiter-0.8.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21974d246ed0181558087cd9f76e84e8321091ebfb3a93d4c341479a736f099", size = 381700 }, + { url = "https://files.pythonhosted.org/packages/7a/38/1674672954d35bce3b1c9af99d5849f9256ac8f5b672e020ac7821581206/jiter-0.8.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32475a42b2ea7b344069dc1e81445cfc00b9d0e3ca837f0523072432332e9f74", size = 389710 }, + { url = "https://files.pythonhosted.org/packages/f8/9b/92f9da9a9e107d019bcf883cd9125fa1690079f323f5a9d5c6986eeec3c0/jiter-0.8.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b9931fd36ee513c26b5bf08c940b0ac875de175341cbdd4fa3be109f0492586", size = 345553 }, + { url = "https://files.pythonhosted.org/packages/44/a6/6d030003394e9659cd0d7136bbeabd82e869849ceccddc34d40abbbbb269/jiter-0.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce0820f4a3a59ddced7fce696d86a096d5cc48d32a4183483a17671a61edfddc", size = 376388 }, + { url = "https://files.pythonhosted.org/packages/ad/8d/87b09e648e4aca5f9af89e3ab3cfb93db2d1e633b2f2931ede8dabd9b19a/jiter-0.8.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8ffc86ae5e3e6a93765d49d1ab47b6075a9c978a2b3b80f0f32628f39caa0c88", size = 511226 }, + { url = "https://files.pythonhosted.org/packages/77/95/8008ebe4cdc82eac1c97864a8042ca7e383ed67e0ec17bfd03797045c727/jiter-0.8.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5127dc1abd809431172bc3fbe8168d6b90556a30bb10acd5ded41c3cfd6f43b6", size = 504134 }, + { url = "https://files.pythonhosted.org/packages/26/0d/3056a74de13e8b2562e4d526de6dac2f65d91ace63a8234deb9284a1d24d/jiter-0.8.2-cp311-cp311-win32.whl", hash = "sha256:66227a2c7b575720c1871c8800d3a0122bb8ee94edb43a5685aa9aceb2782d44", size = 203103 }, + { url = "https://files.pythonhosted.org/packages/4e/1e/7f96b798f356e531ffc0f53dd2f37185fac60fae4d6c612bbbd4639b90aa/jiter-0.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:cde031d8413842a1e7501e9129b8e676e62a657f8ec8166e18a70d94d4682855", size = 206717 }, + { url = "https://files.pythonhosted.org/packages/a1/17/c8747af8ea4e045f57d6cfd6fc180752cab9bc3de0e8a0c9ca4e8af333b1/jiter-0.8.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e6ec2be506e7d6f9527dae9ff4b7f54e68ea44a0ef6b098256ddf895218a2f8f", size = 302027 }, + { url = "https://files.pythonhosted.org/packages/3c/c1/6da849640cd35a41e91085723b76acc818d4b7d92b0b6e5111736ce1dd10/jiter-0.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76e324da7b5da060287c54f2fabd3db5f76468006c811831f051942bf68c9d44", size = 310326 }, + { url = "https://files.pythonhosted.org/packages/06/99/a2bf660d8ccffee9ad7ed46b4f860d2108a148d0ea36043fd16f4dc37e94/jiter-0.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:180a8aea058f7535d1c84183c0362c710f4750bef66630c05f40c93c2b152a0f", size = 334242 }, + { url = "https://files.pythonhosted.org/packages/a7/5f/cea1c17864828731f11427b9d1ab7f24764dbd9aaf4648a7f851164d2718/jiter-0.8.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025337859077b41548bdcbabe38698bcd93cfe10b06ff66617a48ff92c9aec60", size = 356654 }, + { url = "https://files.pythonhosted.org/packages/e9/13/62774b7e5e7f5d5043efe1d0f94ead66e6d0f894ae010adb56b3f788de71/jiter-0.8.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecff0dc14f409599bbcafa7e470c00b80f17abc14d1405d38ab02e4b42e55b57", size = 379967 }, + { url = "https://files.pythonhosted.org/packages/ec/fb/096b34c553bb0bd3f2289d5013dcad6074948b8d55212aa13a10d44c5326/jiter-0.8.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffd9fee7d0775ebaba131f7ca2e2d83839a62ad65e8e02fe2bd8fc975cedeb9e", size = 389252 }, + { url = "https://files.pythonhosted.org/packages/17/61/beea645c0bf398ced8b199e377b61eb999d8e46e053bb285c91c3d3eaab0/jiter-0.8.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14601dcac4889e0a1c75ccf6a0e4baf70dbc75041e51bcf8d0e9274519df6887", size = 345490 }, + { url = "https://files.pythonhosted.org/packages/d5/df/834aa17ad5dcc3cf0118821da0a0cf1589ea7db9832589278553640366bc/jiter-0.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92249669925bc1c54fcd2ec73f70f2c1d6a817928480ee1c65af5f6b81cdf12d", size = 376991 }, + { url = "https://files.pythonhosted.org/packages/67/80/87d140399d382fb4ea5b3d56e7ecaa4efdca17cd7411ff904c1517855314/jiter-0.8.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e725edd0929fa79f8349ab4ec7f81c714df51dc4e991539a578e5018fa4a7152", size = 510822 }, + { url = "https://files.pythonhosted.org/packages/5c/37/3394bb47bac1ad2cb0465601f86828a0518d07828a650722e55268cdb7e6/jiter-0.8.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bf55846c7b7a680eebaf9c3c48d630e1bf51bdf76c68a5f654b8524335b0ad29", size = 503730 }, + { url = "https://files.pythonhosted.org/packages/f9/e2/253fc1fa59103bb4e3aa0665d6ceb1818df1cd7bf3eb492c4dad229b1cd4/jiter-0.8.2-cp312-cp312-win32.whl", hash = "sha256:7efe4853ecd3d6110301665a5178b9856be7e2a9485f49d91aa4d737ad2ae49e", size = 203375 }, + { url = "https://files.pythonhosted.org/packages/41/69/6d4bbe66b3b3b4507e47aa1dd5d075919ad242b4b1115b3f80eecd443687/jiter-0.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:83c0efd80b29695058d0fd2fa8a556490dbce9804eac3e281f373bbc99045f6c", size = 204740 }, { url = "https://files.pythonhosted.org/packages/6c/b0/bfa1f6f2c956b948802ef5a021281978bf53b7a6ca54bb126fd88a5d014e/jiter-0.8.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ca1f08b8e43dc3bd0594c992fb1fd2f7ce87f7bf0d44358198d6da8034afdf84", size = 301190 }, { url = "https://files.pythonhosted.org/packages/a4/8f/396ddb4e292b5ea57e45ade5dc48229556b9044bad29a3b4b2dddeaedd52/jiter-0.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5672a86d55416ccd214c778efccf3266b84f87b89063b582167d803246354be4", size = 309334 }, { url = "https://files.pythonhosted.org/packages/7f/68/805978f2f446fa6362ba0cc2e4489b945695940656edd844e110a61c98f8/jiter-0.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58dc9bc9767a1101f4e5e22db1b652161a225874d66f0e5cb8e2c7d1c438b587", size = 333918 }, @@ -455,6 +682,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/67/7e8406a29b6c45be7af7740456f7f37025f0506ae2e05fb9009a53946860/monotonic-1.6-py2.py3-none-any.whl", hash = "sha256:68687e19a14f11f26d140dd5c86f3dba4bf5df58003000ed467e0e2a69bca96c", size = 8154 }, ] +[[package]] +name = "mypy" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/bc/f6339726c627bd7ca1ce0fa56c9ae2d0144604a319e0e339bdadafbbb599/mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", size = 10662338 }, + { url = "https://files.pythonhosted.org/packages/e2/90/8dcf506ca1a09b0d17555cc00cd69aee402c203911410136cd716559efe7/mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", size = 9787540 }, + { url = "https://files.pythonhosted.org/packages/05/05/a10f9479681e5da09ef2f9426f650d7b550d4bafbef683b69aad1ba87457/mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", size = 11538051 }, + { url = "https://files.pythonhosted.org/packages/e9/9a/1f7d18b30edd57441a6411fcbc0c6869448d1a4bacbaee60656ac0fc29c8/mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", size = 12286751 }, + { url = "https://files.pythonhosted.org/packages/72/af/19ff499b6f1dafcaf56f9881f7a965ac2f474f69f6f618b5175b044299f5/mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", size = 12421783 }, + { url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618 }, + { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981 }, + { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175 }, + { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675 }, + { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020 }, + { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582 }, + { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614 }, + { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 }, + { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 }, + { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 }, + { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 }, + { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 }, + { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + [[package]] name = "ollama" version = "0.4.7" @@ -493,6 +760,32 @@ version = "3.10.15" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/5dea21763eeff8c1590076918a446ea3d6140743e0e36f58f369928ed0f4/orjson-3.10.15.tar.gz", hash = "sha256:05ca7fe452a2e9d8d9d706a2984c95b9c2ebc5db417ce0b7a49b91d50642a23e", size = 5282482 } wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/a2/21b25ce4a2c71dbb90948ee81bd7a42b4fbfc63162e57faf83157d5540ae/orjson-3.10.15-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c4cc83960ab79a4031f3119cc4b1a1c627a3dc09df125b27c4201dff2af7eaa6", size = 249533 }, + { url = "https://files.pythonhosted.org/packages/b2/85/2076fc12d8225698a51278009726750c9c65c846eda741e77e1761cfef33/orjson-3.10.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ddbeef2481d895ab8be5185f2432c334d6dec1f5d1933a9c83014d188e102cef", size = 125230 }, + { url = "https://files.pythonhosted.org/packages/06/df/a85a7955f11274191eccf559e8481b2be74a7c6d43075d0a9506aa80284d/orjson-3.10.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e590a0477b23ecd5b0ac865b1b907b01b3c5535f5e8a8f6ab0e503efb896334", size = 150148 }, + { url = "https://files.pythonhosted.org/packages/37/b3/94c55625a29b8767c0eed194cb000b3787e3c23b4cdd13be17bae6ccbb4b/orjson-3.10.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6be38bd103d2fd9bdfa31c2720b23b5d47c6796bcb1d1b598e3924441b4298d", size = 139749 }, + { url = "https://files.pythonhosted.org/packages/53/ba/c608b1e719971e8ddac2379f290404c2e914cf8e976369bae3cad88768b1/orjson-3.10.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ff4f6edb1578960ed628a3b998fa54d78d9bb3e2eb2cfc5c2a09732431c678d0", size = 154558 }, + { url = "https://files.pythonhosted.org/packages/b2/c4/c1fb835bb23ad788a39aa9ebb8821d51b1c03588d9a9e4ca7de5b354fdd5/orjson-3.10.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0482b21d0462eddd67e7fce10b89e0b6ac56570424662b685a0d6fccf581e13", size = 130349 }, + { url = "https://files.pythonhosted.org/packages/78/14/bb2b48b26ab3c570b284eb2157d98c1ef331a8397f6c8bd983b270467f5c/orjson-3.10.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bb5cc3527036ae3d98b65e37b7986a918955f85332c1ee07f9d3f82f3a6899b5", size = 138513 }, + { url = "https://files.pythonhosted.org/packages/4a/97/d5b353a5fe532e92c46467aa37e637f81af8468aa894cd77d2ec8a12f99e/orjson-3.10.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d569c1c462912acdd119ccbf719cf7102ea2c67dd03b99edcb1a3048651ac96b", size = 130942 }, + { url = "https://files.pythonhosted.org/packages/b5/5d/a067bec55293cca48fea8b9928cfa84c623be0cce8141d47690e64a6ca12/orjson-3.10.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1e6d33efab6b71d67f22bf2962895d3dc6f82a6273a965fab762e64fa90dc399", size = 414717 }, + { url = "https://files.pythonhosted.org/packages/6f/9a/1485b8b05c6b4c4db172c438cf5db5dcfd10e72a9bc23c151a1137e763e0/orjson-3.10.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c33be3795e299f565681d69852ac8c1bc5c84863c0b0030b2b3468843be90388", size = 141033 }, + { url = "https://files.pythonhosted.org/packages/f8/d2/fc67523656e43a0c7eaeae9007c8b02e86076b15d591e9be11554d3d3138/orjson-3.10.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eea80037b9fae5339b214f59308ef0589fc06dc870578b7cce6d71eb2096764c", size = 129720 }, + { url = "https://files.pythonhosted.org/packages/79/42/f58c7bd4e5b54da2ce2ef0331a39ccbbaa7699b7f70206fbf06737c9ed7d/orjson-3.10.15-cp311-cp311-win32.whl", hash = "sha256:d5ac11b659fd798228a7adba3e37c010e0152b78b1982897020a8e019a94882e", size = 142473 }, + { url = "https://files.pythonhosted.org/packages/00/f8/bb60a4644287a544ec81df1699d5b965776bc9848d9029d9f9b3402ac8bb/orjson-3.10.15-cp311-cp311-win_amd64.whl", hash = "sha256:cf45e0214c593660339ef63e875f32ddd5aa3b4adc15e662cdb80dc49e194f8e", size = 133570 }, + { url = "https://files.pythonhosted.org/packages/66/85/22fe737188905a71afcc4bf7cc4c79cd7f5bbe9ed1fe0aac4ce4c33edc30/orjson-3.10.15-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9d11c0714fc85bfcf36ada1179400862da3288fc785c30e8297844c867d7505a", size = 249504 }, + { url = "https://files.pythonhosted.org/packages/48/b7/2622b29f3afebe938a0a9037e184660379797d5fd5234e5998345d7a5b43/orjson-3.10.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dba5a1e85d554e3897fa9fe6fbcff2ed32d55008973ec9a2b992bd9a65d2352d", size = 125080 }, + { url = "https://files.pythonhosted.org/packages/ce/8f/0b72a48f4403d0b88b2a41450c535b3e8989e8a2d7800659a967efc7c115/orjson-3.10.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7723ad949a0ea502df656948ddd8b392780a5beaa4c3b5f97e525191b102fff0", size = 150121 }, + { url = "https://files.pythonhosted.org/packages/06/ec/acb1a20cd49edb2000be5a0404cd43e3c8aad219f376ac8c60b870518c03/orjson-3.10.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6fd9bc64421e9fe9bd88039e7ce8e58d4fead67ca88e3a4014b143cec7684fd4", size = 139796 }, + { url = "https://files.pythonhosted.org/packages/33/e1/f7840a2ea852114b23a52a1c0b2bea0a1ea22236efbcdb876402d799c423/orjson-3.10.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dadba0e7b6594216c214ef7894c4bd5f08d7c0135f4dd0145600be4fbcc16767", size = 154636 }, + { url = "https://files.pythonhosted.org/packages/fa/da/31543337febd043b8fa80a3b67de627669b88c7b128d9ad4cc2ece005b7a/orjson-3.10.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48f59114fe318f33bbaee8ebeda696d8ccc94c9e90bc27dbe72153094e26f41", size = 130621 }, + { url = "https://files.pythonhosted.org/packages/ed/78/66115dc9afbc22496530d2139f2f4455698be444c7c2475cb48f657cefc9/orjson-3.10.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:035fb83585e0f15e076759b6fedaf0abb460d1765b6a36f48018a52858443514", size = 138516 }, + { url = "https://files.pythonhosted.org/packages/22/84/cd4f5fb5427ffcf823140957a47503076184cb1ce15bcc1165125c26c46c/orjson-3.10.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d13b7fe322d75bf84464b075eafd8e7dd9eae05649aa2a5354cfa32f43c59f17", size = 130762 }, + { url = "https://files.pythonhosted.org/packages/93/1f/67596b711ba9f56dd75d73b60089c5c92057f1130bb3a25a0f53fb9a583b/orjson-3.10.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7066b74f9f259849629e0d04db6609db4cf5b973248f455ba5d3bd58a4daaa5b", size = 414700 }, + { url = "https://files.pythonhosted.org/packages/7c/0c/6a3b3271b46443d90efb713c3e4fe83fa8cd71cda0d11a0f69a03f437c6e/orjson-3.10.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88dc3f65a026bd3175eb157fea994fca6ac7c4c8579fc5a86fc2114ad05705b7", size = 141077 }, + { url = "https://files.pythonhosted.org/packages/3b/9b/33c58e0bfc788995eccd0d525ecd6b84b40d7ed182dd0751cd4c1322ac62/orjson-3.10.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b342567e5465bd99faa559507fe45e33fc76b9fb868a63f1642c6bc0735ad02a", size = 129898 }, + { url = "https://files.pythonhosted.org/packages/01/c1/d577ecd2e9fa393366a1ea0a9267f6510d86e6c4bb1cdfb9877104cac44c/orjson-3.10.15-cp312-cp312-win32.whl", hash = "sha256:0a4f27ea5617828e6b58922fdbec67b0aa4bb844e2d363b9244c47fa2180e665", size = 142566 }, + { url = "https://files.pythonhosted.org/packages/ed/eb/a85317ee1732d1034b92d56f89f1de4d7bf7904f5c8fb9dcdd5b1c83917f/orjson-3.10.15-cp312-cp312-win_amd64.whl", hash = "sha256:ef5b87e7aa9545ddadd2309efe6824bd3dd64ac101c15dae0f2f597911d46eaa", size = 133732 }, { url = "https://files.pythonhosted.org/packages/06/10/fe7d60b8da538e8d3d3721f08c1b7bff0491e8fa4dd3bf11a17e34f4730e/orjson-3.10.15-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bae0e6ec2b7ba6895198cd981b7cca95d1487d0147c8ed751e5632ad16f031a6", size = 249399 }, { url = "https://files.pythonhosted.org/packages/6b/83/52c356fd3a61abd829ae7e4366a6fe8e8863c825a60d7ac5156067516edf/orjson-3.10.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f93ce145b2db1252dd86af37d4165b6faa83072b46e3995ecc95d4b2301b725a", size = 125044 }, { url = "https://files.pythonhosted.org/packages/55/b2/d06d5901408e7ded1a74c7c20d70e3a127057a6d21355f50c90c0f337913/orjson-3.10.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c203f6f969210128af3acae0ef9ea6aab9782939f45f6fe02d05958fe761ef9", size = 150066 }, @@ -517,6 +810,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + [[package]] name = "playwright" version = "1.50.0" @@ -535,6 +846,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/2b/e944e10c9b18e77e43d3bb4d6faa323f6cc27597db37b75bc3fd796adfd5/playwright-1.50.0-py3-none-win_amd64.whl", hash = "sha256:1859423da82de631704d5e3d88602d755462b0906824c1debe140979397d2e8d", size = 34784546 }, ] +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + [[package]] name = "posthog" version = "3.18.1" @@ -584,6 +904,34 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, + { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, + { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, + { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, + { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, + { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, + { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, + { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, + { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, + { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, + { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, + { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, + { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, + { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, @@ -625,6 +973,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/68/7e150cba9eeffdeb3c5cecdb6896d70c8edd46ce41c0491e12fb2b2256ff/pyee-12.1.1-py3-none-any.whl", hash = "sha256:18a19c650556bb6b32b406d7f017c8f513aceed1ef7ca618fb65de7bd2d347ef", size = 15527 }, ] +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 }, +] + +[[package]] +name = "pytest-cov" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -652,6 +1040,24 @@ 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/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, { 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 }, @@ -669,6 +1075,36 @@ version = "2024.11.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494 } wheels = [ + { url = "https://files.pythonhosted.org/packages/58/58/7e4d9493a66c88a7da6d205768119f51af0f684fe7be7bac8328e217a52c/regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638", size = 482669 }, + { url = "https://files.pythonhosted.org/packages/34/4c/8f8e631fcdc2ff978609eaeef1d6994bf2f028b59d9ac67640ed051f1218/regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7", size = 287684 }, + { url = "https://files.pythonhosted.org/packages/c5/1b/f0e4d13e6adf866ce9b069e191f303a30ab1277e037037a365c3aad5cc9c/regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20", size = 284589 }, + { url = "https://files.pythonhosted.org/packages/25/4d/ab21047f446693887f25510887e6820b93f791992994f6498b0318904d4a/regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114", size = 792121 }, + { url = "https://files.pythonhosted.org/packages/45/ee/c867e15cd894985cb32b731d89576c41a4642a57850c162490ea34b78c3b/regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3", size = 831275 }, + { url = "https://files.pythonhosted.org/packages/b3/12/b0f480726cf1c60f6536fa5e1c95275a77624f3ac8fdccf79e6727499e28/regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f", size = 818257 }, + { url = "https://files.pythonhosted.org/packages/bf/ce/0d0e61429f603bac433910d99ef1a02ce45a8967ffbe3cbee48599e62d88/regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0", size = 792727 }, + { url = "https://files.pythonhosted.org/packages/e4/c1/243c83c53d4a419c1556f43777ccb552bccdf79d08fda3980e4e77dd9137/regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55", size = 780667 }, + { url = "https://files.pythonhosted.org/packages/c5/f4/75eb0dd4ce4b37f04928987f1d22547ddaf6c4bae697623c1b05da67a8aa/regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89", size = 776963 }, + { url = "https://files.pythonhosted.org/packages/16/5d/95c568574e630e141a69ff8a254c2f188b4398e813c40d49228c9bbd9875/regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d", size = 784700 }, + { url = "https://files.pythonhosted.org/packages/8e/b5/f8495c7917f15cc6fee1e7f395e324ec3e00ab3c665a7dc9d27562fd5290/regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34", size = 848592 }, + { url = "https://files.pythonhosted.org/packages/1c/80/6dd7118e8cb212c3c60b191b932dc57db93fb2e36fb9e0e92f72a5909af9/regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d", size = 852929 }, + { url = "https://files.pythonhosted.org/packages/11/9b/5a05d2040297d2d254baf95eeeb6df83554e5e1df03bc1a6687fc4ba1f66/regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45", size = 781213 }, + { url = "https://files.pythonhosted.org/packages/26/b7/b14e2440156ab39e0177506c08c18accaf2b8932e39fb092074de733d868/regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9", size = 261734 }, + { url = "https://files.pythonhosted.org/packages/80/32/763a6cc01d21fb3819227a1cc3f60fd251c13c37c27a73b8ff4315433a8e/regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60", size = 274052 }, + { url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781 }, + { url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455 }, + { url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759 }, + { url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976 }, + { url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077 }, + { url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160 }, + { url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896 }, + { url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997 }, + { url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725 }, + { url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481 }, + { url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896 }, + { url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138 }, + { url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692 }, + { url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135 }, + { url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567 }, { url = "https://files.pythonhosted.org/packages/90/73/bcb0e36614601016552fa9344544a3a2ae1809dc1401b100eab02e772e1f/regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", size = 483525 }, { url = "https://files.pythonhosted.org/packages/0f/3f/f1a082a46b31e25291d830b369b6b0c5576a6f7fb89d3053a354c24b8a83/regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", size = 288324 }, { url = "https://files.pythonhosted.org/packages/09/c9/4e68181a4a652fb3ef5099e077faf4fd2a694ea6e0f806a7737aff9e758a/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", size = 284617 }, @@ -793,6 +1229,18 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/ea/cf/756fedf6981e82897f2d570dd25fa597eb3f4459068ae0572d7e888cfd6f/tiktoken-0.9.0.tar.gz", hash = "sha256:d02a5ca6a938e0490e1ff957bc48c8b078c88cb83977be1625b1fd8aac792c5d", size = 35991 } wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/ae/4613a59a2a48e761c5161237fc850eb470b4bb93696db89da51b79a871f1/tiktoken-0.9.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f32cc56168eac4851109e9b5d327637f15fd662aa30dd79f964b7c39fbadd26e", size = 1065987 }, + { url = "https://files.pythonhosted.org/packages/3f/86/55d9d1f5b5a7e1164d0f1538a85529b5fcba2b105f92db3622e5d7de6522/tiktoken-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:45556bc41241e5294063508caf901bf92ba52d8ef9222023f83d2483a3055348", size = 1009155 }, + { url = "https://files.pythonhosted.org/packages/03/58/01fb6240df083b7c1916d1dcb024e2b761213c95d576e9f780dfb5625a76/tiktoken-0.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03935988a91d6d3216e2ec7c645afbb3d870b37bcb67ada1943ec48678e7ee33", size = 1142898 }, + { url = "https://files.pythonhosted.org/packages/b1/73/41591c525680cd460a6becf56c9b17468d3711b1df242c53d2c7b2183d16/tiktoken-0.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b3d80aad8d2c6b9238fc1a5524542087c52b860b10cbf952429ffb714bc1136", size = 1197535 }, + { url = "https://files.pythonhosted.org/packages/7d/7c/1069f25521c8f01a1a182f362e5c8e0337907fae91b368b7da9c3e39b810/tiktoken-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b2a21133be05dc116b1d0372af051cd2c6aa1d2188250c9b553f9fa49301b336", size = 1259548 }, + { url = "https://files.pythonhosted.org/packages/6f/07/c67ad1724b8e14e2b4c8cca04b15da158733ac60136879131db05dda7c30/tiktoken-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:11a20e67fdf58b0e2dea7b8654a288e481bb4fc0289d3ad21291f8d0849915fb", size = 893895 }, + { url = "https://files.pythonhosted.org/packages/cf/e5/21ff33ecfa2101c1bb0f9b6df750553bd873b7fb532ce2cb276ff40b197f/tiktoken-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e88f121c1c22b726649ce67c089b90ddda8b9662545a8aeb03cfef15967ddd03", size = 1065073 }, + { url = "https://files.pythonhosted.org/packages/8e/03/a95e7b4863ee9ceec1c55983e4cc9558bcfd8f4f80e19c4f8a99642f697d/tiktoken-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a6600660f2f72369acb13a57fb3e212434ed38b045fd8cc6cdd74947b4b5d210", size = 1008075 }, + { url = "https://files.pythonhosted.org/packages/40/10/1305bb02a561595088235a513ec73e50b32e74364fef4de519da69bc8010/tiktoken-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95e811743b5dfa74f4b227927ed86cbc57cad4df859cb3b643be797914e41794", size = 1140754 }, + { url = "https://files.pythonhosted.org/packages/1b/40/da42522018ca496432ffd02793c3a72a739ac04c3794a4914570c9bb2925/tiktoken-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99376e1370d59bcf6935c933cb9ba64adc29033b7e73f5f7569f3aad86552b22", size = 1196678 }, + { url = "https://files.pythonhosted.org/packages/5c/41/1e59dddaae270ba20187ceb8aa52c75b24ffc09f547233991d5fd822838b/tiktoken-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:badb947c32739fb6ddde173e14885fb3de4d32ab9d8c591cbd013c22b4c31dd2", size = 1259283 }, + { url = "https://files.pythonhosted.org/packages/5b/64/b16003419a1d7728d0d8c0d56a4c24325e7b10a21a9dd1fc0f7115c02f0a/tiktoken-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:5a62d7a25225bafed786a524c1b9f0910a1128f4232615bf3f8257a73aaa3b16", size = 894897 }, { url = "https://files.pythonhosted.org/packages/7a/11/09d936d37f49f4f494ffe660af44acd2d99eb2429d60a57c71318af214e0/tiktoken-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b0e8e05a26eda1249e824156d537015480af7ae222ccb798e5234ae0285dbdb", size = 1064919 }, { url = "https://files.pythonhosted.org/packages/80/0e/f38ba35713edb8d4197ae602e80837d574244ced7fb1b6070b31c29816e0/tiktoken-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:27d457f096f87685195eea0165a1807fae87b97b2161fe8c9b1df5bd74ca6f63", size = 1007877 }, { url = "https://files.pythonhosted.org/packages/fe/82/9197f77421e2a01373e27a79dd36efdd99e6b4115746ecc553318ecafbf0/tiktoken-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cf8ded49cddf825390e36dd1ad35cd49589e8161fdcb52aa25f0583e90a3e01", size = 1140095 }, @@ -801,6 +1249,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/a8/8f499c179ec900783ffe133e9aab10044481679bb9aad78436d239eee716/tiktoken-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5ea0edb6f83dc56d794723286215918c1cde03712cbbafa0348b33448faf5b95", size = 894669 }, ] +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -853,6 +1340,38 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/2ac0287b442160a89d726b17a9184a4c615bb5237db763791a7fd16d9df1/zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09", size = 681701 } wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/40/f67e7d2c25a0e2dc1744dd781110b0b60306657f8696cafb7ad7579469bd/zstandard-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:34895a41273ad33347b2fc70e1bff4240556de3c46c6ea430a7ed91f9042aa4e", size = 788699 }, + { url = "https://files.pythonhosted.org/packages/e8/46/66d5b55f4d737dd6ab75851b224abf0afe5774976fe511a54d2eb9063a41/zstandard-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:77ea385f7dd5b5676d7fd943292ffa18fbf5c72ba98f7d09fc1fb9e819b34c23", size = 633681 }, + { url = "https://files.pythonhosted.org/packages/63/b6/677e65c095d8e12b66b8f862b069bcf1f1d781b9c9c6f12eb55000d57583/zstandard-0.23.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:983b6efd649723474f29ed42e1467f90a35a74793437d0bc64a5bf482bedfa0a", size = 4944328 }, + { url = "https://files.pythonhosted.org/packages/59/cc/e76acb4c42afa05a9d20827116d1f9287e9c32b7ad58cc3af0721ce2b481/zstandard-0.23.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80a539906390591dd39ebb8d773771dc4db82ace6372c4d41e2d293f8e32b8db", size = 5311955 }, + { url = "https://files.pythonhosted.org/packages/78/e4/644b8075f18fc7f632130c32e8f36f6dc1b93065bf2dd87f03223b187f26/zstandard-0.23.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:445e4cb5048b04e90ce96a79b4b63140e3f4ab5f662321975679b5f6360b90e2", size = 5344944 }, + { url = "https://files.pythonhosted.org/packages/76/3f/dbafccf19cfeca25bbabf6f2dd81796b7218f768ec400f043edc767015a6/zstandard-0.23.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd30d9c67d13d891f2360b2a120186729c111238ac63b43dbd37a5a40670b8ca", size = 5442927 }, + { url = "https://files.pythonhosted.org/packages/0c/c3/d24a01a19b6733b9f218e94d1a87c477d523237e07f94899e1c10f6fd06c/zstandard-0.23.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d20fd853fbb5807c8e84c136c278827b6167ded66c72ec6f9a14b863d809211c", size = 4864910 }, + { url = "https://files.pythonhosted.org/packages/1c/a9/cf8f78ead4597264f7618d0875be01f9bc23c9d1d11afb6d225b867cb423/zstandard-0.23.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ed1708dbf4d2e3a1c5c69110ba2b4eb6678262028afd6c6fbcc5a8dac9cda68e", size = 4935544 }, + { url = "https://files.pythonhosted.org/packages/2c/96/8af1e3731b67965fb995a940c04a2c20997a7b3b14826b9d1301cf160879/zstandard-0.23.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:be9b5b8659dff1f913039c2feee1aca499cfbc19e98fa12bc85e037c17ec6ca5", size = 5467094 }, + { url = "https://files.pythonhosted.org/packages/ff/57/43ea9df642c636cb79f88a13ab07d92d88d3bfe3e550b55a25a07a26d878/zstandard-0.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:65308f4b4890aa12d9b6ad9f2844b7ee42c7f7a4fd3390425b242ffc57498f48", size = 4860440 }, + { url = "https://files.pythonhosted.org/packages/46/37/edb78f33c7f44f806525f27baa300341918fd4c4af9472fbc2c3094be2e8/zstandard-0.23.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98da17ce9cbf3bfe4617e836d561e433f871129e3a7ac16d6ef4c680f13a839c", size = 4700091 }, + { url = "https://files.pythonhosted.org/packages/c1/f1/454ac3962671a754f3cb49242472df5c2cced4eb959ae203a377b45b1a3c/zstandard-0.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8ed7d27cb56b3e058d3cf684d7200703bcae623e1dcc06ed1e18ecda39fee003", size = 5208682 }, + { url = "https://files.pythonhosted.org/packages/85/b2/1734b0fff1634390b1b887202d557d2dd542de84a4c155c258cf75da4773/zstandard-0.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:b69bb4f51daf461b15e7b3db033160937d3ff88303a7bc808c67bbc1eaf98c78", size = 5669707 }, + { url = "https://files.pythonhosted.org/packages/52/5a/87d6971f0997c4b9b09c495bf92189fb63de86a83cadc4977dc19735f652/zstandard-0.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:034b88913ecc1b097f528e42b539453fa82c3557e414b3de9d5632c80439a473", size = 5201792 }, + { url = "https://files.pythonhosted.org/packages/79/02/6f6a42cc84459d399bd1a4e1adfc78d4dfe45e56d05b072008d10040e13b/zstandard-0.23.0-cp311-cp311-win32.whl", hash = "sha256:f2d4380bf5f62daabd7b751ea2339c1a21d1c9463f1feb7fc2bdcea2c29c3160", size = 430586 }, + { url = "https://files.pythonhosted.org/packages/be/a2/4272175d47c623ff78196f3c10e9dc7045c1b9caf3735bf041e65271eca4/zstandard-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:62136da96a973bd2557f06ddd4e8e807f9e13cbb0bfb9cc06cfe6d98ea90dfe0", size = 495420 }, + { url = "https://files.pythonhosted.org/packages/7b/83/f23338c963bd9de687d47bf32efe9fd30164e722ba27fb59df33e6b1719b/zstandard-0.23.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b4567955a6bc1b20e9c31612e615af6b53733491aeaa19a6b3b37f3b65477094", size = 788713 }, + { url = "https://files.pythonhosted.org/packages/5b/b3/1a028f6750fd9227ee0b937a278a434ab7f7fdc3066c3173f64366fe2466/zstandard-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e172f57cd78c20f13a3415cc8dfe24bf388614324d25539146594c16d78fcc8", size = 633459 }, + { url = "https://files.pythonhosted.org/packages/26/af/36d89aae0c1f95a0a98e50711bc5d92c144939efc1f81a2fcd3e78d7f4c1/zstandard-0.23.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0e166f698c5a3e914947388c162be2583e0c638a4703fc6a543e23a88dea3c1", size = 4945707 }, + { url = "https://files.pythonhosted.org/packages/cd/2e/2051f5c772f4dfc0aae3741d5fc72c3dcfe3aaeb461cc231668a4db1ce14/zstandard-0.23.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a289832e520c6bd4dcaad68e944b86da3bad0d339ef7989fb7e88f92e96072", size = 5306545 }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a11c97b087f89cab030fa71206963090d2fecd8eb83e67bb8f3ffb84c024/zstandard-0.23.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d50d31bfedd53a928fed6707b15a8dbeef011bb6366297cc435accc888b27c20", size = 5337533 }, + { url = "https://files.pythonhosted.org/packages/fc/79/edeb217c57fe1bf16d890aa91a1c2c96b28c07b46afed54a5dcf310c3f6f/zstandard-0.23.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72c68dda124a1a138340fb62fa21b9bf4848437d9ca60bd35db36f2d3345f373", size = 5436510 }, + { url = "https://files.pythonhosted.org/packages/81/4f/c21383d97cb7a422ddf1ae824b53ce4b51063d0eeb2afa757eb40804a8ef/zstandard-0.23.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53dd9d5e3d29f95acd5de6802e909ada8d8d8cfa37a3ac64836f3bc4bc5512db", size = 4859973 }, + { url = "https://files.pythonhosted.org/packages/ab/15/08d22e87753304405ccac8be2493a495f529edd81d39a0870621462276ef/zstandard-0.23.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6a41c120c3dbc0d81a8e8adc73312d668cd34acd7725f036992b1b72d22c1772", size = 4936968 }, + { url = "https://files.pythonhosted.org/packages/eb/fa/f3670a597949fe7dcf38119a39f7da49a8a84a6f0b1a2e46b2f71a0ab83f/zstandard-0.23.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:40b33d93c6eddf02d2c19f5773196068d875c41ca25730e8288e9b672897c105", size = 5467179 }, + { url = "https://files.pythonhosted.org/packages/4e/a9/dad2ab22020211e380adc477a1dbf9f109b1f8d94c614944843e20dc2a99/zstandard-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9206649ec587e6b02bd124fb7799b86cddec350f6f6c14bc82a2b70183e708ba", size = 4848577 }, + { url = "https://files.pythonhosted.org/packages/08/03/dd28b4484b0770f1e23478413e01bee476ae8227bbc81561f9c329e12564/zstandard-0.23.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76e79bc28a65f467e0409098fa2c4376931fd3207fbeb6b956c7c476d53746dd", size = 4693899 }, + { url = "https://files.pythonhosted.org/packages/2b/64/3da7497eb635d025841e958bcd66a86117ae320c3b14b0ae86e9e8627518/zstandard-0.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:66b689c107857eceabf2cf3d3fc699c3c0fe8ccd18df2219d978c0283e4c508a", size = 5199964 }, + { url = "https://files.pythonhosted.org/packages/43/a4/d82decbab158a0e8a6ebb7fc98bc4d903266bce85b6e9aaedea1d288338c/zstandard-0.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9c236e635582742fee16603042553d276cca506e824fa2e6489db04039521e90", size = 5655398 }, + { url = "https://files.pythonhosted.org/packages/f2/61/ac78a1263bc83a5cf29e7458b77a568eda5a8f81980691bbc6eb6a0d45cc/zstandard-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8fffdbd9d1408006baaf02f1068d7dd1f016c6bcb7538682622c556e7b68e35", size = 5191313 }, + { url = "https://files.pythonhosted.org/packages/e7/54/967c478314e16af5baf849b6ee9d6ea724ae5b100eb506011f045d3d4e16/zstandard-0.23.0-cp312-cp312-win32.whl", hash = "sha256:dc1d33abb8a0d754ea4763bad944fd965d3d95b5baef6b121c0c9013eaf1907d", size = 430877 }, + { url = "https://files.pythonhosted.org/packages/75/37/872d74bd7739639c4553bf94c84af7d54d8211b626b352bc57f0fd8d1e3f/zstandard-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:64585e1dba664dc67c7cdabd56c1e5685233fbb1fc1966cfba2a340ec0dfff7b", size = 495595 }, { url = "https://files.pythonhosted.org/packages/80/f1/8386f3f7c10261fe85fbc2c012fdb3d4db793b921c9abcc995d8da1b7a80/zstandard-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:576856e8594e6649aee06ddbfc738fec6a834f7c85bf7cadd1c53d4a58186ef9", size = 788975 }, { url = "https://files.pythonhosted.org/packages/16/e8/cbf01077550b3e5dc86089035ff8f6fbbb312bc0983757c2d1117ebba242/zstandard-0.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38302b78a850ff82656beaddeb0bb989a0322a8bbb1bf1ab10c17506681d772a", size = 633448 }, { url = "https://files.pythonhosted.org/packages/06/27/4a1b4c267c29a464a161aeb2589aff212b4db653a1d96bffe3598f3f0d22/zstandard-0.23.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2240ddc86b74966c34554c49d00eaafa8200a18d3a5b6ffbf7da63b11d74ee2", size = 4945269 }, From 2289578e6afa8e2e84f06fa6f10f9784790388e4 Mon Sep 17 00:00:00 2001 From: Michel Osswald Date: Wed, 12 Mar 2025 15:03:34 +0100 Subject: [PATCH 02/12] fix: deployment and Chrome path issues - Add CHROME_PATH to Dockerfile to override user env var - Fix VNC password location in Dockerfile for proper connection - Update env_example CHROME_PATH handling: -- Exclude from browser_config if empty -- Override Chromium location if set - Add Playwright version condition in pyproject - Fix browser context in server: - Avoid loading Chromium via CLI instead of .app - Use CHROME_PATH if set, otherwise fallback to default --- .env.example | 4 +- Dockerfile | 9 ++-- pyproject.toml | 1 + server/server.py | 135 ++++++++++------------------------------------- uv.lock | 4 +- 5 files changed, 39 insertions(+), 114 deletions(-) diff --git a/.env.example b/.env.example index 68232eb..fc7f5e5 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ -# Path to Chrome/Chromium executable -/usr/bin/chromium +# Path to Chrome/Chromium executable leave blank to use default playwright chromium +CHROME_PATH= # OpenAI API key for OpenAI model access OPENAI_API_KEY=your-api-key-here \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3b49110..c54e263 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,5 @@ FROM ghcr.io/astral-sh/uv:bookworm-slim AS builder -ARG VNC_PASSWORD=browser-use - ENV UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy \ UV_PYTHON_INSTALL_DIR=/python \ @@ -26,6 +24,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \ FROM debian:bookworm-slim AS runtime +ARG VNC_PASSWORD="browser-use" + # Install required packages including Chromium and clean up in the same layer RUN apt-get update && \ apt-get install --no-install-recommends -y \ @@ -56,11 +56,12 @@ COPY --from=builder --chown=app:app /app /app ENV PATH="/app/.venv/bin:$PATH" \ DISPLAY=:0 \ CHROME_BIN=/usr/bin/chromium \ - CHROMIUM_FLAGS="--no-sandbox --headless --disable-gpu --disable-software-rasterizer --disable-dev-shm-usage" + CHROMIUM_FLAGS="--no-sandbox --headless --disable-gpu --disable-software-rasterizer --disable-dev-shm-usage" \ + CHROME_PATH="/usr/bin/chromium" # Combine VNC setup commands to reduce layers RUN mkdir -p ~/.vnc && \ - echo ${VNC_PASSWORD} | vncpasswd -f > /root/.vnc/passwd && \ + echo $VNC_PASSWORD | vncpasswd -f > /root/.vnc/passwd && \ chmod 600 /root/.vnc/passwd && \ printf '#!/bin/sh\nunset SESSION_MANAGER\nunset DBUS_SESSION_BUS_ADDRESS\nstartxfce4' > /root/.vnc/xstartup && \ chmod +x /root/.vnc/xstartup && \ diff --git a/pyproject.toml b/pyproject.toml index 2fb9f5e..78cb97c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "python-dotenv", "starlette", "uvicorn", + "playwright>=1.50.0", ] [project.optional-dependencies] diff --git a/server/server.py b/server/server.py index 2f3c551..9dbad44 100644 --- a/server/server.py +++ b/server/server.py @@ -8,7 +8,6 @@ # Import from the browser-use-mcp-server package from browser_use_mcp_server.server import ( - initialize_browser_context, create_mcp_server, ) from langchain_openai import ChatOpenAI @@ -64,97 +63,21 @@ def main( task_expiry_minutes: int, ) -> int: """Run the browser-use MCP server.""" - # If Chrome path is explicitly provided, use it - if chrome_path and os.path.exists(chrome_path): - chrome_executable_path = chrome_path - logger.info(f"Using explicitly provided Chrome path: {chrome_executable_path}") + # Use Chrome path from command line arg, environment variable, or None + chrome_executable_path = chrome_path or os.environ.get("CHROME_PATH") + if chrome_executable_path: + logger.info(f"Using Chrome path: {chrome_executable_path}") else: - # Try to find Playwright's installed Chromium first - import os.path - import platform - from pathlib import Path - import subprocess - - # Try to get Playwright browsers directory - home_dir = str(Path.home()) - system = platform.system() - - if system == "Darwin": # macOS - playwright_browsers_path = os.path.join(home_dir, "Library", "Caches", "ms-playwright") - elif system == "Linux": - playwright_browsers_path = os.path.join(home_dir, ".cache", "ms-playwright") - elif system == "Windows": - playwright_browsers_path = os.path.join(home_dir, "AppData", "Local", "ms-playwright") - else: - playwright_browsers_path = None - - # Try to find the Chromium executable in the Playwright directory - chromium_executable_path = None - if playwright_browsers_path and os.path.exists(playwright_browsers_path): - logger.info(f"Found Playwright browsers directory at {playwright_browsers_path}") - # Look for chromium directories - try: - for root, dirs, files in os.walk(playwright_browsers_path): - for dir in dirs: - if "chromium" in dir.lower(): - # Check for executable in this directory - if system == "Darwin": # macOS - exec_path = os.path.join(root, dir, "chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium") - elif system == "Linux": - exec_path = os.path.join(root, dir, "chrome-linux", "chrome") - elif system == "Windows": - exec_path = os.path.join(root, dir, "chrome-win", "chrome.exe") - else: - continue - - if os.path.exists(exec_path): - chromium_executable_path = exec_path - logger.info(f"Found Playwright Chromium at {chromium_executable_path}") - break - if chromium_executable_path: - break - except Exception as e: - logger.warning(f"Error searching for Playwright Chromium: {str(e)}") - - # If Playwright Chromium not found, try standard locations - if not chromium_executable_path: - # Try to find Chromium/Chrome in common locations - potential_paths = [ - # Environment variable - os.environ.get("CHROME_PATH"), - - # Common macOS paths - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - - # Common Linux paths - "/usr/bin/chromium", - "/usr/bin/chromium-browser", - "/usr/bin/google-chrome", - "/usr/bin/google-chrome-stable", - ] - - for path in potential_paths: - if path and os.path.exists(path): - logger.info(f"Found browser at {path}") - chromium_executable_path = path - break - - # Use the found path or try with Playwright's default - if chromium_executable_path: - chrome_executable_path = chromium_executable_path - logger.info(f"Using browser executable at: {chrome_executable_path}") - else: - # If no specific path found, let Playwright find its own browser - logger.warning("No Chrome/Chromium path found, will let Playwright use its default browser") - chrome_executable_path = None - + logger.info( + "No Chrome path specified, letting Playwright use its default browser" + ) + # Initialize browser context try: # Using the approach from backup/server.py from browser_use.browser.context import BrowserContextConfig, BrowserContext from browser_use.browser.browser import Browser, BrowserConfig - + # Browser context configuration config = BrowserContextConfig( wait_for_network_idle_page_load_time=0.6, @@ -166,7 +89,7 @@ def main( highlight_elements=True, viewport_expansion=0, ) - + # Initialize browser and context directly browser_config = BrowserConfig( extra_chromium_args=[ @@ -177,36 +100,36 @@ def main( "--remote-debugging-port=9222", ], ) - - # Only set chrome_instance_path if we actually found a path + + # Only set chrome_instance_path if we actually set a path in the env file if chrome_executable_path: browser_config.chrome_instance_path = chrome_executable_path - + browser = Browser(config=browser_config) context = BrowserContext(browser=browser, config=config) logger.info("Browser context initialized successfully") except Exception as e: logger.error(f"Failed to initialize browser context: {str(e)}") return 1 - + # Initialize LLM llm = ChatOpenAI(model="gpt-4o", temperature=0.0) - + # Create MCP server app = create_mcp_server( context=context, llm=llm, task_expiry_minutes=task_expiry_minutes, ) - + if transport == "sse": from mcp.server.sse import SseServerTransport from starlette.applications import Starlette from starlette.routing import Mount, Route import uvicorn - + sse = SseServerTransport("/messages/") - + async def handle_sse(request): try: async with sse.connect_sse( @@ -218,7 +141,7 @@ async def handle_sse(request): except Exception as e: logger.error(f"Error in handle_sse: {str(e)}") raise - + starlette_app = Starlette( debug=True, routes=[ @@ -226,17 +149,17 @@ async def handle_sse(request): Mount("/messages/", app=sse.handle_post_message), ], ) - + # Add a startup event to initialize the browser and start task cleanup @starlette_app.on_event("startup") async def startup_event(): logger.info("Starting server and scheduling cleanup...") - + # Start the cleanup task now that we have an event loop - if hasattr(app, 'cleanup_old_tasks'): + if hasattr(app, "cleanup_old_tasks"): asyncio.create_task(app.cleanup_old_tasks()) logger.info("Task cleanup process scheduled") - + # Add a shutdown event to clean up browser resources @starlette_app.on_event("shutdown") async def shutdown_event(): @@ -246,18 +169,18 @@ async def shutdown_event(): logger.info("Browser context closed successfully") except Exception as e: logger.error(f"Error closing browser: {str(e)}") - + uvicorn.run(starlette_app, host="0.0.0.0", port=port) else: from mcp.server.stdio import stdio_server - + async def arun(): try: # Start the cleanup task now that we have an event loop - if hasattr(app, 'cleanup_old_tasks'): + if hasattr(app, "cleanup_old_tasks"): asyncio.create_task(app.cleanup_old_tasks()) logger.info("Task cleanup process scheduled") - + async with stdio_server() as streams: await app.run( streams[0], streams[1], app.create_initialization_options() @@ -270,9 +193,9 @@ async def arun(): await context.browser.close() except Exception as e: logger.error(f"Error cleaning up resources: {str(e)}") - + anyio.run(arun) - + return 0 diff --git a/uv.lock b/uv.lock index 5495c75..95b1e1e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.11, <4.0" resolution-markers = [ "python_full_version >= '3.12.4'", @@ -141,6 +140,7 @@ dependencies = [ { name = "httpx" }, { name = "langchain-openai" }, { name = "mcp" }, + { name = "playwright" }, { name = "pydantic" }, { name = "python-dotenv" }, { name = "starlette" }, @@ -173,6 +173,7 @@ requires-dist = [ { name = "langchain-openai", specifier = ">=0.3.1" }, { name = "mcp", specifier = ">=1.3.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "playwright", specifier = ">=1.50.0" }, { name = "pydantic", specifier = ">=2.10.6" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=7.0.0" }, @@ -183,7 +184,6 @@ requires-dist = [ { name = "starlette" }, { name = "uvicorn" }, ] -provides-extras = ["dev", "test"] [[package]] name = "certifi" From 4121c8c9701bfc133f3f1a442f051594fc147362 Mon Sep 17 00:00:00 2001 From: Michel Osswald Date: Wed, 12 Mar 2025 15:37:29 +0100 Subject: [PATCH 03/12] fix: get browser result handling - Reintroduce browser navigate and status polling tools for async tasks - Update README accordingly - Remove stdio support as it's not used in server and package - Update Dockerfile --- Dockerfile | 2 +- README.md | 8 +- server/server.py | 663 +++++++++++++++++++++++---- src/browser_use_mcp_server/server.py | 375 ++++++++------- 4 files changed, 800 insertions(+), 248 deletions(-) diff --git a/Dockerfile b/Dockerfile index c54e263..f72d9df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,7 +65,7 @@ RUN mkdir -p ~/.vnc && \ chmod 600 /root/.vnc/passwd && \ printf '#!/bin/sh\nunset SESSION_MANAGER\nunset DBUS_SESSION_BUS_ADDRESS\nstartxfce4' > /root/.vnc/xstartup && \ chmod +x /root/.vnc/xstartup && \ - printf '#!/bin/bash\nvncserver -depth 24 -geometry 1920x1080 -localhost no -PasswordFile /root/.vnc/passwd :0\nproxy-login-automator\npython /app/server --transport sse --port 8000' > /app/boot.sh && \ + printf '#!/bin/bash\nvncserver -depth 24 -geometry 1920x1080 -localhost no -PasswordFile /root/.vnc/passwd :0\nproxy-login-automator\npython /app/server --port 8000' > /app/boot.sh && \ chmod +x /app/boot.sh ENTRYPOINT ["/bin/bash", "/app/boot.sh"] diff --git a/README.md b/README.md index 8b2d545..508638f 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,15 @@ curl -LsSf https://astral.sh/uv/install.sh | sh uv sync uv pip install playwright uv run playwright install --with-deps --no-shell chromium -uv run server --transport sse --port 8000 +uv run server --port 8000 ``` +### tools + +- [x] SSE transport +- [x] browser_use - Initiates browser tasks with URL and action +- [x] browser_get_result - Retrieves results of async browser tasks + ### supported clients - cursor.ai diff --git a/server/server.py b/server/server.py index 9dbad44..cf76721 100644 --- a/server/server.py +++ b/server/server.py @@ -1,15 +1,34 @@ +""" +Browser Use MCP Server + +This module implements an MCP (Model-Control-Protocol) server for browser automation +using the browser_use library. It provides functionality to interact with a browser instance +via an async task queue, allowing for long-running browser tasks to be executed asynchronously +while providing status updates and results. + +The server supports Server-Sent Events (SSE) for web-based interfaces. +""" + import os import click import asyncio -import anyio +import uuid +from datetime import datetime from dotenv import load_dotenv import logging -import os.path +import json +import traceback -# Import from the browser-use-mcp-server package -from browser_use_mcp_server.server import ( - create_mcp_server, -) +# Import from browser-use library +from browser_use.browser.context import BrowserContextConfig, BrowserContext +from browser_use.browser.browser import Browser, BrowserConfig +from browser_use import Agent + +# Import MCP server components +from mcp.server.lowlevel import Server +import mcp.types as types + +# Import LLM provider from langchain_openai import ChatOpenAI # Configure logging @@ -19,15 +38,493 @@ # Load environment variables load_dotenv() +# Task storage for async operations +task_store = {} + +# Flag to track browser context health +browser_context_healthy = True + +# Store global browser context and configuration +browser = None +context = None +config = None + + +async def reset_browser_context(): + """ + Reset the browser context to a clean state. + + This function attempts to close the existing context and create a new one. + If that fails, it tries to recreate the entire browser instance. + """ + global context, browser, browser_context_healthy, config + + logger.info("Resetting browser context") + try: + # Try to close the existing context + try: + await context.close() + except Exception as e: + logger.warning(f"Error closing browser context: {str(e)}") + + # Create a new context + context = BrowserContext(browser=browser, config=config) + browser_context_healthy = True + logger.info("Browser context reset successfully") + except Exception as e: + logger.error(f"Failed to reset browser context: {str(e)}") + browser_context_healthy = False + + # If we can't reset the context, try to reset the browser + try: + await browser.close() + + # Recreate browser with same configuration + browser_config = BrowserConfig( + extra_chromium_args=[ + "--no-sandbox", + "--disable-gpu", + "--disable-software-rasterizer", + "--disable-dev-shm-usage", + "--remote-debugging-port=9222", + ], + ) + + # Only set chrome_instance_path if we have a path from environment or command line + chrome_path = os.environ.get("CHROME_PATH") + if chrome_path: + browser_config.chrome_instance_path = chrome_path + + browser = Browser(config=browser_config) + context = BrowserContext(browser=browser, config=config) + browser_context_healthy = True + logger.info("Browser reset successfully") + except Exception as e: + logger.error(f"Failed to reset browser: {str(e)}") + browser_context_healthy = False + + +async def check_browser_health(): + """ + Check if the browser context is healthy by attempting to access the current page. + + If the context is unhealthy, attempts to reset it. + + Returns: + bool: True if the browser context is healthy, False otherwise. + """ + global browser_context_healthy + + if not browser_context_healthy: + await reset_browser_context() + return browser_context_healthy + + try: + # Simple health check - try to get the current page + await context.get_current_page() + return True + except Exception as e: + logger.warning(f"Browser health check failed: {str(e)}") + browser_context_healthy = False + await reset_browser_context() + return browser_context_healthy + + +async def run_browser_task_async(task_id, url, action, llm): + """ + Run a browser task asynchronously and store the result. + + This function executes a browser automation task with the given URL and action, + and updates the task store with progress and results. + + Args: + task_id (str): Unique identifier for the task + url (str): URL to navigate to + action (str): Action to perform after navigation + llm: Language model to use for browser agent + """ + try: + # Update task status to running + task_store[task_id]["status"] = "running" + task_store[task_id]["start_time"] = datetime.now().isoformat() + task_store[task_id]["progress"] = { + "current_step": 0, + "total_steps": 0, + "steps": [], + } + + # Reset browser context to ensure a clean state + await reset_browser_context() + + # Check browser health + if not await check_browser_health(): + task_store[task_id]["status"] = "failed" + task_store[task_id]["end_time"] = datetime.now().isoformat() + task_store[task_id]["error"] = ( + "Browser context is unhealthy and could not be reset" + ) + return + + # Define step callback function with the correct signature + async def step_callback(browser_state, agent_output, step_number): + # Update progress in task store + task_store[task_id]["progress"]["current_step"] = step_number + task_store[task_id]["progress"]["total_steps"] = max( + task_store[task_id]["progress"]["total_steps"], step_number + ) + + # Add step info with minimal details + step_info = {"step": step_number, "time": datetime.now().isoformat()} + + # Add goal if available + if agent_output and hasattr(agent_output, "current_state"): + if hasattr(agent_output.current_state, "next_goal"): + step_info["goal"] = agent_output.current_state.next_goal + + # Add to progress steps + task_store[task_id]["progress"]["steps"].append(step_info) + + # Log progress + logger.info(f"Task {task_id}: Step {step_number} completed") + + # Define done callback function with the correct signature + async def done_callback(history): + # Log completion + logger.info(f"Task {task_id}: Completed with {len(history.history)} steps") + + # Add final step + current_step = task_store[task_id]["progress"]["current_step"] + 1 + task_store[task_id]["progress"]["steps"].append( + { + "step": current_step, + "time": datetime.now().isoformat(), + "status": "completed", + } + ) + + # Use the existing browser context with callbacks + agent = Agent( + task=f"First, navigate to {url}. Then, {action}", + llm=llm, + browser_context=context, + register_new_step_callback=step_callback, + register_done_callback=done_callback, + ) + + # Run the agent with a reasonable step limit + ret = await agent.run(max_steps=10) + + # Get the final result + final_result = ret.final_result() + + # Check if we have a valid result + if final_result and hasattr(final_result, "raise_for_status"): + final_result.raise_for_status() + result_text = str(final_result.text) + else: + result_text = ( + str(final_result) if final_result else "No final result available" + ) + + # Gather essential information from the agent history + is_successful = ret.is_successful() + has_errors = ret.has_errors() + errors = ret.errors() + urls_visited = ret.urls() + action_names = ret.action_names() + extracted_content = ret.extracted_content() + steps_taken = ret.number_of_steps() + + # Create a focused response with the most relevant information + response_data = { + "final_result": result_text, + "success": is_successful, + "has_errors": has_errors, + "errors": [str(err) for err in errors if err], + "urls_visited": [str(url) for url in urls_visited if url], + "actions_performed": action_names, + "extracted_content": extracted_content, + "steps_taken": steps_taken, + } + + # Store the result + task_store[task_id]["status"] = "completed" + task_store[task_id]["end_time"] = datetime.now().isoformat() + task_store[task_id]["result"] = response_data + + except Exception as e: + logger.error(f"Error in async browser task: {str(e)}") + tb = traceback.format_exc() + + # Mark the browser context as unhealthy + global browser_context_healthy + browser_context_healthy = False + + # Store the error + task_store[task_id]["status"] = "failed" + task_store[task_id]["end_time"] = datetime.now().isoformat() + task_store[task_id]["error"] = str(e) + task_store[task_id]["traceback"] = tb + + finally: + # Always try to reset the browser context to a clean state after use + try: + current_page = await context.get_current_page() + await current_page.goto("about:blank") + except Exception as e: + logger.warning(f"Error resetting page state: {str(e)}") + browser_context_healthy = False + + +async def cleanup_old_tasks(): + """ + Periodically clean up old completed tasks to prevent memory leaks. + + This function runs continuously in the background, removing tasks that have been + completed or failed for more than 1 hour to conserve memory. + """ + while True: + try: + # Sleep first to avoid cleaning up tasks too early + await asyncio.sleep(3600) # Run cleanup every hour + + current_time = datetime.now() + tasks_to_remove = [] + + # Find completed tasks older than 1 hour + for task_id, task_data in task_store.items(): + if ( + task_data["status"] in ["completed", "failed"] + and "end_time" in task_data + ): + end_time = datetime.fromisoformat(task_data["end_time"]) + hours_elapsed = (current_time - end_time).total_seconds() / 3600 + + if hours_elapsed > 1: # Remove tasks older than 1 hour + tasks_to_remove.append(task_id) + + # Remove old tasks + for task_id in tasks_to_remove: + del task_store[task_id] + + if tasks_to_remove: + logger.info(f"Cleaned up {len(tasks_to_remove)} old tasks") + + except Exception as e: + logger.error(f"Error in task cleanup: {str(e)}") + + +def create_mcp_server(llm, task_expiry_minutes=60): + """ + Create and configure an MCP server for browser interaction. + + Args: + llm: The language model to use for browser agent + task_expiry_minutes (int): Minutes after which tasks are considered expired + + Returns: + Server: Configured MCP server instance + """ + # Create MCP server instance + app = Server("browser_use") + + @app.call_tool() + async def call_tool( + name: str, arguments: dict + ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: + global browser_context_healthy + + # Handle browser_use tool + if name == "browser_use": + # Check required arguments + if "url" not in arguments: + raise ValueError("Missing required argument 'url'") + if "action" not in arguments: + raise ValueError("Missing required argument 'action'") + + # Generate a task ID + task_id = str(uuid.uuid4()) + + # Initialize task in store + task_store[task_id] = { + "id": task_id, + "status": "pending", + "url": arguments["url"], + "action": arguments["action"], + "created_at": datetime.now().isoformat(), + } + + # Start task in background + asyncio.create_task( + run_browser_task_async( + task_id, arguments["url"], arguments["action"], llm + ) + ) + + # Estimate task duration + estimated_seconds = 60 # Default estimate of 60 seconds + + # Return task ID immediately with explicit sleep instruction + return [ + types.TextContent( + type="text", + text=json.dumps( + { + "task_id": task_id, + "status": "pending", + "message": f"Browser task started. Please wait for {estimated_seconds} seconds, then check the result using browser_get_result or the resource URI. Always wait exactly 5 seconds between status checks.", + "estimated_time": f"{estimated_seconds} seconds", + "resource_uri": f"resource://browser_task/{task_id}", + "sleep_command": "sleep 5", + "instruction": "Use the terminal command 'sleep 5' to wait 5 seconds between status checks. IMPORTANT: Always use exactly 5 seconds, no more and no less.", + }, + indent=2, + ), + ) + ] + + # Handle browser_get_result tool + elif name == "browser_get_result": + # Get result of async task + if "task_id" not in arguments: + raise ValueError("Missing required argument 'task_id'") + + task_id = arguments["task_id"] + + if task_id not in task_store: + return [ + types.TextContent( + type="text", + text=json.dumps( + {"error": "Task not found", "task_id": task_id}, indent=2 + ), + ) + ] + + # Get the current task data + task_data = task_store[task_id].copy() + + # If task is still running, add simple guidance + if task_data["status"] == "running": + # Add a simple next check suggestion + progress = task_data.get("progress", {}) + current_step = progress.get("current_step", 0) + + if current_step > 0: + # Simple message based on current step + task_data["message"] = ( + f"Task is running (step {current_step}). Wait 5 seconds before checking again." + ) + task_data["sleep_command"] = "sleep 5" + task_data["instruction"] = ( + "Use the terminal command 'sleep 5' to wait 5 seconds before checking again. IMPORTANT: Always use exactly 5 seconds, no more and no less." + ) + else: + task_data["message"] = ( + "Task is starting. Wait 5 seconds before checking again." + ) + task_data["sleep_command"] = "sleep 5" + task_data["instruction"] = ( + "Use the terminal command 'sleep 5' to wait 5 seconds before checking again. IMPORTANT: Always use exactly 5 seconds, no more and no less." + ) + + # Return current task status and result if available + return [ + types.TextContent(type="text", text=json.dumps(task_data, indent=2)) + ] + + else: + raise ValueError(f"Unknown tool: {name}") + + @app.list_tools() + async def list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="browser_use", + description="Performs a browser action and returns a task ID for async execution", + inputSchema={ + "type": "object", + "required": ["url", "action"], + "properties": { + "url": { + "type": "string", + "description": "URL to navigate to", + }, + "action": { + "type": "string", + "description": "Action to perform in the browser", + }, + }, + }, + ), + types.Tool( + name="browser_get_result", + description="Gets the result of an asynchronous browser task", + inputSchema={ + "type": "object", + "required": ["task_id"], + "properties": { + "task_id": { + "type": "string", + "description": "ID of the task to get results for", + } + }, + }, + ), + ] + + @app.list_resources() + async def list_resources() -> list[types.Resource]: + # List all completed tasks as resources + resources = [] + for task_id, task_data in task_store.items(): + if task_data["status"] in ["completed", "failed"]: + resources.append( + types.Resource( + uri=f"resource://browser_task/{task_id}", + title=f"Browser Task Result: {task_id[:8]}", + description=f"Result of browser task for URL: {task_data.get('url', 'unknown')}", + ) + ) + return resources + + @app.read_resource() + async def read_resource(uri: str) -> list[types.ResourceContents]: + # Extract task ID from URI + if not uri.startswith("resource://browser_task/"): + return [ + types.ResourceContents( + type="text", + text=json.dumps( + {"error": f"Invalid resource URI: {uri}"}, indent=2 + ), + ) + ] + + task_id = uri.replace("resource://browser_task/", "") + if task_id not in task_store: + return [ + types.ResourceContents( + type="text", + text=json.dumps({"error": f"Task not found: {task_id}"}, indent=2), + ) + ] + + # Return task data + return [ + types.ResourceContents( + type="text", text=json.dumps(task_store[task_id], indent=2) + ) + ] + + # Add cleanup_old_tasks function to app for later scheduling + app.cleanup_old_tasks = cleanup_old_tasks + + return app + @click.command() @click.option("--port", default=8000, help="Port to listen on for SSE") -@click.option( - "--transport", - type=click.Choice(["stdio", "sse"]), - default="stdio", - help="Transport type", -) @click.option( "--chrome-path", default=None, @@ -55,14 +552,20 @@ ) def main( port: int, - transport: str, chrome_path: str, window_width: int, window_height: int, locale: str, task_expiry_minutes: int, ) -> int: - """Run the browser-use MCP server.""" + """ + Run the browser-use MCP server. + + This function initializes the browser context, creates the MCP server, + and runs it with the SSE transport. + """ + global browser, context, config, browser_context_healthy + # Use Chrome path from command line arg, environment variable, or None chrome_executable_path = chrome_path or os.environ.get("CHROME_PATH") if chrome_executable_path: @@ -74,10 +577,6 @@ def main( # Initialize browser context try: - # Using the approach from backup/server.py - from browser_use.browser.context import BrowserContextConfig, BrowserContext - from browser_use.browser.browser import Browser, BrowserConfig - # Browser context configuration config = BrowserContextConfig( wait_for_network_idle_page_load_time=0.6, @@ -101,12 +600,13 @@ def main( ], ) - # Only set chrome_instance_path if we actually set a path in the env file + # Only set chrome_instance_path if we actually found a path if chrome_executable_path: browser_config.chrome_instance_path = chrome_executable_path browser = Browser(config=browser_config) context = BrowserContext(browser=browser, config=config) + browser_context_healthy = True logger.info("Browser context initialized successfully") except Exception as e: logger.error(f"Failed to initialize browser context: {str(e)}") @@ -117,84 +617,63 @@ def main( # Create MCP server app = create_mcp_server( - context=context, llm=llm, task_expiry_minutes=task_expiry_minutes, ) - if transport == "sse": - from mcp.server.sse import SseServerTransport - from starlette.applications import Starlette - from starlette.routing import Mount, Route - import uvicorn - - sse = SseServerTransport("/messages/") - - async def handle_sse(request): - try: - async with sse.connect_sse( - request.scope, request.receive, request._send - ) as streams: - await app.run( - streams[0], streams[1], app.create_initialization_options() - ) - except Exception as e: - logger.error(f"Error in handle_sse: {str(e)}") - raise - - starlette_app = Starlette( - debug=True, - routes=[ - Route("/sse", endpoint=handle_sse), - Mount("/messages/", app=sse.handle_post_message), - ], - ) + # Set up SSE transport + from mcp.server.sse import SseServerTransport + from starlette.applications import Starlette + from starlette.routing import Mount, Route + import uvicorn - # Add a startup event to initialize the browser and start task cleanup - @starlette_app.on_event("startup") - async def startup_event(): - logger.info("Starting server and scheduling cleanup...") - - # Start the cleanup task now that we have an event loop - if hasattr(app, "cleanup_old_tasks"): - asyncio.create_task(app.cleanup_old_tasks()) - logger.info("Task cleanup process scheduled") - - # Add a shutdown event to clean up browser resources - @starlette_app.on_event("shutdown") - async def shutdown_event(): - logger.info("Shutting down server and cleaning up resources...") - try: - await context.browser.close() - logger.info("Browser context closed successfully") - except Exception as e: - logger.error(f"Error closing browser: {str(e)}") - - uvicorn.run(starlette_app, host="0.0.0.0", port=port) - else: - from mcp.server.stdio import stdio_server - - async def arun(): - try: - # Start the cleanup task now that we have an event loop - if hasattr(app, "cleanup_old_tasks"): - asyncio.create_task(app.cleanup_old_tasks()) - logger.info("Task cleanup process scheduled") - - async with stdio_server() as streams: - await app.run( - streams[0], streams[1], app.create_initialization_options() - ) - except Exception as e: - logger.error(f"Error in arun: {str(e)}") - finally: - # Clean up resources - try: - await context.browser.close() - except Exception as e: - logger.error(f"Error cleaning up resources: {str(e)}") - - anyio.run(arun) + sse = SseServerTransport("/messages/") + + async def handle_sse(request): + try: + async with sse.connect_sse( + request.scope, request.receive, request._send + ) as streams: + await app.run( + streams[0], streams[1], app.create_initialization_options() + ) + except Exception as e: + logger.error(f"Error in handle_sse: {str(e)}") + # Ensure browser context is reset if there's an error + asyncio.create_task(reset_browser_context()) + raise + + starlette_app = Starlette( + debug=True, + routes=[ + Route("/sse", endpoint=handle_sse), + Mount("/messages/", app=sse.handle_post_message), + ], + ) + + # Add a startup event to initialize the browser + @starlette_app.on_event("startup") + async def startup_event(): + logger.info("Starting browser context...") + await reset_browser_context() + logger.info("Browser context started") + + # Start background task cleanup + asyncio.create_task(app.cleanup_old_tasks()) + logger.info("Task cleanup process scheduled") + + # Add a shutdown event to clean up browser resources + @starlette_app.on_event("shutdown") + async def shutdown_event(): + logger.info("Shutting down browser context...") + try: + await browser.close() + logger.info("Browser context closed successfully") + except Exception as e: + logger.error(f"Error closing browser: {str(e)}") + + # Run uvicorn server + uvicorn.run(starlette_app, host="0.0.0.0", port=port) return 0 diff --git a/src/browser_use_mcp_server/server.py b/src/browser_use_mcp_server/server.py index 012d244..ea07d80 100644 --- a/src/browser_use_mcp_server/server.py +++ b/src/browser_use_mcp_server/server.py @@ -1,5 +1,8 @@ """ Core functionality for integrating browser-use with MCP. + +This module provides the core components for integrating browser-use with the +Model-Control-Protocol (MCP) server. It supports browser automation via SSE transport. """ import os @@ -8,7 +11,6 @@ from datetime import datetime, timedelta from typing import Any, Callable, Dict, List, Optional, Union, Awaitable -from langchain_openai import ChatOpenAI from browser_use import Agent from browser_use.browser.browser import Browser, BrowserConfig from browser_use.browser.context import BrowserContextConfig, BrowserContext @@ -35,12 +37,14 @@ class MockContext: """Mock context for testing.""" + def __init__(self): pass class MockLLM: """Mock LLM for testing.""" + def __init__(self): pass @@ -57,7 +61,7 @@ def initialize_browser_context( ) -> BrowserContext: """ Initialize the browser context with specified parameters. - + Args: chrome_path: Path to Chrome instance window_width: Browser window width @@ -65,7 +69,7 @@ def initialize_browser_context( locale: Browser locale user_agent: Browser user agent extra_chromium_args: Additional arguments for Chrome - + Returns: Initialized BrowserContext """ @@ -75,7 +79,7 @@ def initialize_browser_context( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36" ) - + if not extra_chromium_args: extra_chromium_args = [ "--no-sandbox", @@ -84,7 +88,7 @@ def initialize_browser_context( "--disable-dev-shm-usage", "--remote-debugging-port=9222", ] - + config = BrowserContextConfig( wait_for_network_idle_page_load_time=0.6, maximum_wait_page_load_time=1.2, @@ -103,49 +107,49 @@ def initialize_browser_context( extra_chromium_args=extra_chromium_args, ) ) - + return BrowserContext(browser=browser, config=config) async def reset_browser_context(context: BrowserContext) -> None: """ Reset the browser context to a clean state. - + Args: context: The browser context to reset """ global browser_context_healthy - + try: logger.info("Resetting browser context...") - + # Since Browser doesn't have pages() or new_page() methods, # we need to use the methods that are available - + # Try to refresh the page if possible try: # If the context has a current page, try to reload it - if hasattr(context, 'current_page') and context.current_page: + if hasattr(context, "current_page") and context.current_page: await context.current_page.reload() logger.info("Current page reloaded") - + # Or navigate to a blank page to reset state - if hasattr(context, 'navigate'): - await context.navigate('about:blank') + if hasattr(context, "navigate"): + await context.navigate("about:blank") logger.info("Navigated to blank page") - + # If we have access to create a new context, use that - if hasattr(context, 'create_new_context'): + if hasattr(context, "create_new_context"): await context.create_new_context() logger.info("Created new context") - + # As a last resort, try to initialize a new context - if hasattr(context.browser, 'initialize'): + if hasattr(context.browser, "initialize"): await context.browser.initialize() logger.info("Re-initialized browser") except Exception as e: logger.warning(f"Error performing specific reset operations: {e}") - + # Mark as healthy browser_context_healthy = True logger.info("Browser context reset successfully") @@ -159,26 +163,30 @@ async def reset_browser_context(context: BrowserContext) -> None: async def check_browser_health(context: BrowserContext) -> bool: """ Check if the browser context is healthy. - + Args: context: The browser context to check - + Returns: True if healthy, False otherwise """ global browser_context_healthy - + # Debug: Log available methods and attributes try: - context_methods = [method for method in dir(context) if not method.startswith('_')] + context_methods = [ + method for method in dir(context) if not method.startswith("_") + ] logger.info(f"BrowserContext available methods: {context_methods}") - - if hasattr(context, 'browser'): - browser_methods = [method for method in dir(context.browser) if not method.startswith('_')] + + if hasattr(context, "browser"): + browser_methods = [ + method for method in dir(context.browser) if not method.startswith("_") + ] logger.info(f"Browser available methods: {browser_methods}") except Exception as e: logger.warning(f"Error logging available methods: {e}") - + if not browser_context_healthy: logger.info("Browser context marked as unhealthy, attempting reset...") try: @@ -187,7 +195,7 @@ async def check_browser_health(context: BrowserContext) -> bool: except Exception as e: logger.error(f"Failed to recover browser context: {e}") return False - + return True @@ -198,13 +206,15 @@ async def run_browser_task_async( url: str, action: str, custom_task_store: Optional[Dict[str, Any]] = None, - step_callback: Optional[Callable[[Dict[str, Any], Dict[str, Any], int], Awaitable[None]]] = None, + step_callback: Optional[ + Callable[[Dict[str, Any], Dict[str, Any], int], Awaitable[None]] + ] = None, done_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None, task_expiry_minutes: int = 60, ) -> str: """ Run a browser task asynchronously. - + Args: context: Browser context for the task llm: Language model to use for the agent @@ -215,12 +225,12 @@ async def run_browser_task_async( step_callback: Optional callback for each step of the task done_callback: Optional callback for when the task is complete task_expiry_minutes: Minutes after which the task is considered expired - + Returns: Task ID """ store = custom_task_store if custom_task_store is not None else task_store - + # Define steps for tracking progress store[task_id] = { "id": task_id, @@ -228,88 +238,104 @@ async def run_browser_task_async( "action": action, "status": "running", "start_time": datetime.now().isoformat(), - "expiry_time": (datetime.now() + timedelta(minutes=task_expiry_minutes)).isoformat(), + "expiry_time": ( + datetime.now() + timedelta(minutes=task_expiry_minutes) + ).isoformat(), "steps": [], "result": None, "error": None, } - + # Define default callbacks if not provided async def default_step_callback(browser_state, agent_output, step_number): """Default step callback that updates the task store.""" - store[task_id]["steps"].append({ - "step": step_number, - "browser_state": browser_state, - "agent_output": agent_output, - "timestamp": datetime.now().isoformat(), - }) + store[task_id]["steps"].append( + { + "step": step_number, + "browser_state": browser_state, + "agent_output": agent_output, + "timestamp": datetime.now().isoformat(), + } + ) logger.info(f"Task {task_id}: Step {step_number} completed") - + async def default_done_callback(history): """Default done callback that updates the task store.""" store[task_id]["status"] = "completed" store[task_id]["result"] = history store[task_id]["end_time"] = datetime.now().isoformat() logger.info(f"Task {task_id}: Completed successfully") - + step_cb = step_callback if step_callback is not None else default_step_callback done_cb = done_callback if done_callback is not None else default_done_callback - + try: # Check and ensure browser health browser_healthy = await check_browser_health(context) if not browser_healthy: raise Exception("Browser context is unhealthy") - + # Create agent and run task try: # Inspect Agent class initialization parameters agent_params = inspect.signature(Agent.__init__).parameters logger.info(f"Agent init parameters: {list(agent_params.keys())}") - + # Adapt initialization based on available parameters - agent_kwargs = {'context': context} - - if 'llm' in agent_params: - agent_kwargs['llm'] = llm - + agent_kwargs = {"context": context} + + if "llm" in agent_params: + agent_kwargs["llm"] = llm + # Add task parameter which is required based on the error message - if 'task' in agent_params: + if "task" in agent_params: # Create a task that combines navigation and the action task_description = f"First, navigate to {url}. Then, {action}" - agent_kwargs['task'] = task_description - + agent_kwargs["task"] = task_description + # Add browser and browser_context parameters if they're required - if 'browser' in agent_params: - agent_kwargs['browser'] = context.browser - if 'browser_context' in agent_params: - agent_kwargs['browser_context'] = context - + if "browser" in agent_params: + agent_kwargs["browser"] = context.browser + if "browser_context" in agent_params: + agent_kwargs["browser_context"] = context + # Check for callbacks - if 'step_callback' in agent_params: - agent_kwargs['step_callback'] = step_cb - if 'done_callback' in agent_params: - agent_kwargs['done_callback'] = done_cb - + if "step_callback" in agent_params: + agent_kwargs["step_callback"] = step_cb + if "done_callback" in agent_params: + agent_kwargs["done_callback"] = done_cb + # Register callbacks with the new parameter names if the old ones don't exist - if 'step_callback' not in agent_params and 'register_new_step_callback' in agent_params: - agent_kwargs['register_new_step_callback'] = step_cb - if 'done_callback' not in agent_params and 'register_done_callback' in agent_params: - agent_kwargs['register_done_callback'] = done_cb - + if ( + "step_callback" not in agent_params + and "register_new_step_callback" in agent_params + ): + agent_kwargs["register_new_step_callback"] = step_cb + if ( + "done_callback" not in agent_params + and "register_done_callback" in agent_params + ): + agent_kwargs["register_done_callback"] = done_cb + # Check if all required parameters are set missing_params = [] for param_name, param in agent_params.items(): - if param.default == inspect.Parameter.empty and param_name != 'self' and param_name not in agent_kwargs: + if ( + param.default == inspect.Parameter.empty + and param_name != "self" + and param_name not in agent_kwargs + ): missing_params.append(param_name) - + if missing_params: logger.error(f"Missing required parameters for Agent: {missing_params}") - raise Exception(f"Missing required parameters for Agent: {missing_params}") - + raise Exception( + f"Missing required parameters for Agent: {missing_params}" + ) + # Create agent with appropriate parameters agent = Agent(**agent_kwargs) - + # Launch task asynchronously # Don't pass any parameters to run() as they should already be set via init asyncio.create_task(agent.run()) @@ -317,65 +343,79 @@ async def default_done_callback(history): except Exception as agent_error: logger.error(f"Error creating Agent: {str(agent_error)}") raise Exception(f"Failed to create browser agent: {str(agent_error)}") - + except Exception as e: # Update task store with error store[task_id]["status"] = "error" store[task_id]["error"] = str(e) store[task_id]["end_time"] = datetime.now().isoformat() logger.error(f"Task {task_id}: Error - {str(e)}") - + # Attempt one more browser reset as a last resort if "Browser context is unhealthy" in str(e): try: - logger.info(f"Task {task_id}: Final attempt to reset browser context...") - + logger.info( + f"Task {task_id}: Final attempt to reset browser context..." + ) + # Use a simpler recovery approach try: # Try to use any available method to reset the context - if hasattr(context, 'current_page') and context.current_page: + if hasattr(context, "current_page") and context.current_page: await context.current_page.reload() logger.info(f"Task {task_id}: Current page reloaded") - - if hasattr(context, 'navigate'): - await context.navigate('about:blank') + + if hasattr(context, "navigate"): + await context.navigate("about:blank") logger.info(f"Task {task_id}: Navigated to blank page") - + # Mark as healthy and retry global browser_context_healthy browser_context_healthy = True - logger.info(f"Task {task_id}: Browser context recovered, retrying...") - + logger.info( + f"Task {task_id}: Browser context recovered, retrying..." + ) + # Retry the task try: # Use the same dynamic approach for agent initialization - agent_kwargs = {'context': context} - - if 'llm' in inspect.signature(Agent.__init__).parameters: - agent_kwargs['llm'] = llm - + agent_kwargs = {"context": context} + + if "llm" in inspect.signature(Agent.__init__).parameters: + agent_kwargs["llm"] = llm + # Check for callbacks - if 'step_callback' in inspect.signature(Agent.__init__).parameters: - agent_kwargs['step_callback'] = step_cb - if 'done_callback' in inspect.signature(Agent.__init__).parameters: - agent_kwargs['done_callback'] = done_cb - + if ( + "step_callback" + in inspect.signature(Agent.__init__).parameters + ): + agent_kwargs["step_callback"] = step_cb + if ( + "done_callback" + in inspect.signature(Agent.__init__).parameters + ): + agent_kwargs["done_callback"] = done_cb + # Create agent with appropriate parameters agent = Agent(**agent_kwargs) - + # Launch task asynchronously asyncio.create_task(agent.run()) store[task_id]["status"] = "running" store[task_id]["error"] = None return task_id except Exception as agent_error: - logger.error(f"Task {task_id}: Error creating Agent during retry: {str(agent_error)}") + logger.error( + f"Task {task_id}: Error creating Agent during retry: {str(agent_error)}" + ) raise except Exception as retry_error: logger.error(f"Task {task_id}: Retry failed - {str(retry_error)}") except Exception as reset_error: - logger.error(f"Task {task_id}: Final reset attempt failed - {str(reset_error)}") - + logger.error( + f"Task {task_id}: Final reset attempt failed - {str(reset_error)}" + ) + # Re-raise the exception raise @@ -388,52 +428,54 @@ def create_mcp_server( ) -> Server: """ Create an MCP server with browser capabilities. - + Args: context: Browser context for the server llm: Language model to use for the agent custom_task_store: Optional custom task store for tracking tasks task_expiry_minutes: Minutes after which tasks are considered expired - + Returns: Configured MCP server """ # Use provided task store or default store = custom_task_store if custom_task_store is not None else task_store - + # Create MCP server app = Server(name="browser-use-mcp-server") - + @app.call_tool() async def call_tool( name: str, arguments: dict ) -> list[Union[types.TextContent, types.ImageContent, types.EmbeddedResource]]: """ Handle tool calls from the MCP client. - + Args: name: Tool name arguments: Tool arguments - + Returns: List of content items """ logger.info(f"Tool call received: {name} with arguments: {arguments}") - + if name == "mcp__browser_navigate": # Validate required arguments if "url" not in arguments: logger.error("URL argument missing in browser.navigate call") return [types.TextContent(type="text", text="Error: URL is required")] - + url = arguments["url"] - action = arguments.get("action", "Navigate to the given URL and tell me what you see.") - + action = arguments.get( + "action", "Navigate to the given URL and tell me what you see." + ) + logger.info(f"Navigation request to URL: {url} with action: {action}") - + # Generate unique task ID task_id = str(uuid.uuid4()) - + try: # Run browser task await run_browser_task_async( @@ -445,21 +487,25 @@ async def call_tool( custom_task_store=store, task_expiry_minutes=task_expiry_minutes, ) - + logger.info(f"Navigation task {task_id} started successfully") - + # Return a simpler response with just TextContent to avoid validation errors return [ types.TextContent( type="text", - text=f"Navigating to {url}. Task {task_id} started successfully. Results will be available when task completes." + text=f"Navigating to {url}. Task {task_id} started successfully. Results will be available when task completes.", ) ] - + except Exception as e: logger.error(f"Error executing navigation task: {str(e)}") - return [types.TextContent(type="text", text=f"Error navigating to {url}: {str(e)}")] - + return [ + types.TextContent( + type="text", text=f"Error navigating to {url}: {str(e)}" + ) + ] + elif name == "mcp__browser_health": try: # Check browser health @@ -467,33 +513,47 @@ async def call_tool( healthy = await check_browser_health(context) status = "healthy" if healthy else "unhealthy" logger.info(f"Browser health status: {status}") - return [types.TextContent(type="text", text=f"Browser status: {status}")] - + return [ + types.TextContent(type="text", text=f"Browser status: {status}") + ] + except Exception as e: logger.error(f"Error checking browser health: {str(e)}") - return [types.TextContent(type="text", text=f"Error checking browser health: {str(e)}")] - + return [ + types.TextContent( + type="text", text=f"Error checking browser health: {str(e)}" + ) + ] + elif name == "mcp__browser_reset": try: # Reset browser context logger.info("Browser reset requested") await reset_browser_context(context) logger.info("Browser context reset successful") - return [types.TextContent(type="text", text="Browser context reset successfully")] - + return [ + types.TextContent( + type="text", text="Browser context reset successfully" + ) + ] + except Exception as e: logger.error(f"Error resetting browser context: {str(e)}") - return [types.TextContent(type="text", text=f"Error resetting browser context: {str(e)}")] - + return [ + types.TextContent( + type="text", text=f"Error resetting browser context: {str(e)}" + ) + ] + else: logger.warning(f"Unknown tool requested: {name}") return [types.TextContent(type="text", text=f"Unknown tool: {name}")] - + @app.list_tools() async def list_tools() -> list[types.Tool]: """ List available tools for the MCP client. - + Returns: List of available tools """ @@ -552,17 +612,17 @@ async def list_tools() -> list[types.Tool]: except Exception as e: logger.error(f"Error in list_tools: {str(e)}") raise - + @app.list_resources() async def list_resources() -> list[types.Resource]: """ List available resources for the MCP client. - + Returns: List of available resources """ resources = [] - + # Add all completed tasks as resources for task_id, task in store.items(): if task["status"] in ["completed", "error"]: @@ -573,32 +633,32 @@ async def list_resources() -> list[types.Resource]: description=f"Status: {task['status']}", ) ) - + return resources - + @app.read_resource() async def read_resource(uri: str) -> list[types.ResourceContents]: """ Read resource content by URI. - + Args: uri: Resource URI - + Returns: Resource contents """ # Extract task ID from URI if not uri.startswith("browser-task://"): return [types.ResourceContents(error="Invalid resource URI format")] - + task_id = uri[15:] # Remove "browser-task://" prefix - + # Check if task exists if task_id not in store: return [types.ResourceContents(error=f"Task {task_id} not found")] - + task = store[task_id] - + # Check task status if task["status"] == "error": return [ @@ -607,11 +667,14 @@ async def read_resource(uri: str) -> list[types.ResourceContents]: contents=f"Error: {task['error']}", ) ] - + if task["status"] == "running": # For running tasks, return the steps completed so far steps_text = "\n".join( - [f"Step {s['step']}: {s['agent_output'].get('action', 'Unknown action')}" for s in task["steps"]] + [ + f"Step {s['step']}: {s['agent_output'].get('action', 'Unknown action')}" + for s in task["steps"] + ] ) return [ types.ResourceContents( @@ -619,48 +682,52 @@ async def read_resource(uri: str) -> list[types.ResourceContents]: contents=f"Task {task_id} is still running.\n\nSteps completed so far:\n{steps_text}", ) ] - + # For completed tasks, return the full result if task["result"]: # Format the result as markdown - result_text = f"# Browser Task Report\n\n" + result_text = "# Browser Task Report\n\n" result_text += f"URL: {task['url']}\n\n" result_text += f"Action: {task['action']}\n\n" result_text += f"Start Time: {task['start_time']}\n\n" result_text += f"End Time: {task['end_time']}\n\n" - + # Add steps result_text += "## Steps\n\n" for step in task["steps"]: result_text += f"### Step {step['step']}\n\n" result_text += f"Time: {step['timestamp']}\n\n" - + # Add agent output if "agent_output" in step and step["agent_output"]: result_text += "#### Agent Output\n\n" action = step["agent_output"].get("action", "Unknown action") result_text += f"Action: {action}\n\n" - + # Add agent thoughts if available if "thought" in step["agent_output"]: result_text += f"Thought: {step['agent_output']['thought']}\n\n" - + # Add browser state snapshot if "browser_state" in step and step["browser_state"]: result_text += "#### Browser State\n\n" - + # Add page title if available if "page_title" in step["browser_state"]: - result_text += f"Page Title: {step['browser_state']['page_title']}\n\n" - + result_text += ( + f"Page Title: {step['browser_state']['page_title']}\n\n" + ) + # Add URL if available if "url" in step["browser_state"]: result_text += f"URL: {step['browser_state']['url']}\n\n" - + # Add screenshot if available if "screenshot" in step["browser_state"]: - result_text += "Screenshot available but not included in text output.\n\n" - + result_text += ( + "Screenshot available but not included in text output.\n\n" + ) + # Return formatted result return [ types.ResourceContents( @@ -668,7 +735,7 @@ async def read_resource(uri: str) -> list[types.ResourceContents]: contents=result_text, ) ] - + # Fallback for unexpected cases return [ types.ResourceContents( @@ -676,5 +743,5 @@ async def read_resource(uri: str) -> list[types.ResourceContents]: contents=f"Task {task_id} completed with status '{task['status']}' but no results are available.", ) ] - - return app \ No newline at end of file + + return app From b05f65ffacd6b83f4a1f8ad5e2df588195f0760b Mon Sep 17 00:00:00 2001 From: Ryan MacArthur Date: Wed, 12 Mar 2025 16:10:55 +0100 Subject: [PATCH 04/12] chore: lint +prose wrap --- .github/workflows/ci.yml | 8 +++- .github/workflows/python-package.yml | 71 ++++++++++++++-------------- 2 files changed, 42 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa1cb0c..10a9ca8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,9 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }} + group: + ${{ github.workflow }}-${{ github.ref_name }}-${{ + github.event.pull_request.number || github.sha }} cancel-in-progress: true env: @@ -70,7 +72,9 @@ jobs: run: uvx ruff check . - name: "Validate project metadata" - run: uvx --from 'validate-pyproject[all,store]' validate-pyproject pyproject.toml + run: + uvx --from 'validate-pyproject[all,store]' validate-pyproject + pyproject.toml build-and-publish: runs-on: ubuntu-latest diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index a56366b..65e8335 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -2,11 +2,11 @@ name: Python Package on: push: - branches: [ main ] + branches: [main] tags: - - 'v*' + - "v*" pull_request: - branches: [ main ] + branches: [main] jobs: test: @@ -16,41 +16,42 @@ jobs: python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install pytest pytest-asyncio pytest-cov - pip install -e ".[dev]" - - name: Test with pytest - run: | - pytest --cov=browser_use_mcp_server + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest pytest-asyncio pytest-cov + pip install -e ".[dev]" + - name: Test with pytest + run: | + pytest --cov=browser_use_mcp_server build-and-publish: needs: test runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') - + steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install build twine - - name: Build package - run: | - python -m build - - name: Publish to GitHub Packages - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.GITHUB_TOKEN }} - repository-url: https://pypi.pkg.github.com/${{ github.repository_owner }} \ No newline at end of file + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install build twine + - name: Build package + run: | + python -m build + - name: Publish to GitHub Packages + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.GITHUB_TOKEN }} + repository-url: + https://pypi.pkg.github.com/${{ github.repository_owner }} From 282c05533cab4822bddc86d0a0aa4796dc6dc305 Mon Sep 17 00:00:00 2001 From: Michel Osswald Date: Wed, 12 Mar 2025 16:12:26 +0100 Subject: [PATCH 05/12] bumped version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 78cb97c..9679c70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "browser-use-mcp-server" -version = "0.1.2" +version = "0.1.3" description = "MCP browser-use server library" readme = "README.md" requires-python = ">=3.11,<4.0" From 05057276a78981afcb9959f88f8a6dbc4ab743de Mon Sep 17 00:00:00 2001 From: Ryan MacArthur Date: Wed, 12 Mar 2025 16:16:50 +0100 Subject: [PATCH 06/12] chore: lint prose undo --- .github/workflows/ci.yml | 6 ++---- .github/workflows/python-package.yml | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10a9ca8..d01ecd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,7 @@ on: workflow_dispatch: concurrency: - group: - ${{ github.workflow }}-${{ github.ref_name }}-${{ + group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true @@ -72,8 +71,7 @@ jobs: run: uvx ruff check . - name: "Validate project metadata" - run: - uvx --from 'validate-pyproject[all,store]' validate-pyproject + run: uvx --from 'validate-pyproject[all,store]' validate-pyproject pyproject.toml build-and-publish: diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 65e8335..451d55b 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -53,5 +53,4 @@ jobs: with: user: __token__ password: ${{ secrets.GITHUB_TOKEN }} - repository-url: - https://pypi.pkg.github.com/${{ github.repository_owner }} + repository-url: https://pypi.pkg.github.com/${{ github.repository_owner }} From 6d361a9bf968ee545e84da319abc9675bac3adb4 Mon Sep 17 00:00:00 2001 From: Ryan MacArthur Date: Wed, 12 Mar 2025 16:20:44 +0100 Subject: [PATCH 07/12] chore: py lint --- src/browser_use_mcp_server/__init__.py | 4 +- src/browser_use_mcp_server/__main__.py | 2 +- src/browser_use_mcp_server/cli.py | 111 ++++++++++++++----------- 3 files changed, 65 insertions(+), 52 deletions(-) diff --git a/src/browser_use_mcp_server/__init__.py b/src/browser_use_mcp_server/__init__.py index d035185..35271de 100644 --- a/src/browser_use_mcp_server/__init__.py +++ b/src/browser_use_mcp_server/__init__.py @@ -11,7 +11,7 @@ run_browser_task_async, check_browser_health, reset_browser_context, - create_mcp_server + create_mcp_server, ) __all__ = [ @@ -22,4 +22,4 @@ "check_browser_health", "reset_browser_context", "create_mcp_server", -] \ No newline at end of file +] diff --git a/src/browser_use_mcp_server/__main__.py b/src/browser_use_mcp_server/__main__.py index a1335ae..1a72618 100644 --- a/src/browser_use_mcp_server/__main__.py +++ b/src/browser_use_mcp_server/__main__.py @@ -6,4 +6,4 @@ from .cli import cli if __name__ == "__main__": - sys.exit(cli()) \ No newline at end of file + sys.exit(cli()) diff --git a/src/browser_use_mcp_server/cli.py b/src/browser_use_mcp_server/cli.py index 535c5ad..585efb8 100644 --- a/src/browser_use_mcp_server/cli.py +++ b/src/browser_use_mcp_server/cli.py @@ -29,22 +29,25 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) + class AsyncStdinReader: """Async wrapper for stdin.""" - + async def receive(self) -> bytes: line = await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline) return line.encode() + class AsyncStdoutWriter: """Async wrapper for stdout.""" - + async def send(self, data: bytes) -> None: text = data.decode() sys.stdout.write(text) sys.stdout.flush() await asyncio.sleep(0) # Yield control back to the event loop + @click.group() def cli(): """Browser MCP Server CLI.""" @@ -100,9 +103,11 @@ def start( # Set up browser context and LLM if chrome_path is None: chrome_path = os.environ.get("CHROME_PATH") - + try: - logger.info(f"Initializing browser context with Chrome path: {chrome_path or 'default'}") + logger.info( + f"Initializing browser context with Chrome path: {chrome_path or 'default'}" + ) context = initialize_browser_context( chrome_path=chrome_path, window_width=window_width, @@ -112,7 +117,7 @@ def start( except Exception as e: logger.error(f"Failed to initialize browser context: {e}") return 1 - + try: logger.info(f"Initializing LLM with model: {model}") llm = ChatOpenAI(model=model, temperature=0.0) @@ -120,7 +125,7 @@ def start( except Exception as e: logger.error(f"Failed to initialize LLM: {e}") return 1 - + try: # Create MCP server logger.info("Creating MCP server") @@ -134,70 +139,70 @@ def start( except Exception as e: logger.error(f"Failed to create MCP server: {e}") return 1 - + if transport == "stdio": # Run the server with stdio transport logger.info("Starting browser MCP server with stdio transport") return asyncio.run(_run_stdio(app)) - + else: # Set up Starlette app for SSE transport async def handle_sse(request): """Handle SSE connections.""" logger.info(f"New SSE connection from {request.client}") logger.info(f"Request headers: {request.headers}") - + # Create a queue for sending messages send_queue = asyncio.Queue() - + # Define message handlers for MCP server class SSEReadStream: async def __aenter__(self): return self - + async def __aexit__(self, exc_type, exc_val, exc_tb): pass - + async def receive(self) -> bytes: # For SSE, we don't receive anything from client # Just block indefinitely future = asyncio.Future() await future # This will block forever return b"" # Never reached - + class SSEWriteStream: async def __aenter__(self): return self - + async def __aexit__(self, exc_type, exc_val, exc_tb): pass - + async def send(self, data: bytes) -> None: # Queue the message to be sent over SSE await send_queue.put(data) - + # Create async generator to stream SSE responses async def stream_response(): """Stream SSE responses.""" logger.info("Setting up SSE stream") - + # Start MCP server in background read_stream = SSEReadStream() write_stream = SSEWriteStream() - + server_task = asyncio.create_task( app.run( read_stream=read_stream, write_stream=write_stream, - initialization_options={} + initialization_options={}, ) ) - + try: # Send initial connected event logger.info("Sending initial connected event") yield b"event: connected\ndata: {}\n\n" - + # Stream messages from the queue logger.info("Starting to stream messages") while True: @@ -212,13 +217,13 @@ async def stream_response(): # Clean up server_task.cancel() logger.info("SSE connection closed") - + return StreamingResponse( stream_response(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, ) - + async def health_check(request): """Health check endpoint.""" try: @@ -226,105 +231,112 @@ async def health_check(request): healthy = await check_browser_health(context) return JSONResponse({"status": "healthy" if healthy else "unhealthy"}) except Exception as e: - return JSONResponse({"status": "error", "message": str(e)}, status_code=500) - + return JSONResponse( + {"status": "error", "message": str(e)}, status_code=500 + ) + async def reset_context(request): """Reset browser context endpoint.""" try: # Reset browser context await reset_browser_context(context) - return JSONResponse({"status": "success", "message": "Browser context reset"}) + return JSONResponse( + {"status": "success", "message": "Browser context reset"} + ) except Exception as e: - return JSONResponse({"status": "error", "message": str(e)}, status_code=500) - + return JSONResponse( + {"status": "error", "message": str(e)}, status_code=500 + ) + # Define startup and shutdown events async def startup_event(): """Run on server startup.""" logger.info("Starting server...") - + # Start task cleanup job asyncio.create_task(cleanup_old_tasks()) - + logger.info(f"Server started on port {port}") - + async def shutdown_event(): """Run on server shutdown.""" logger.info("Shutting down server...") - + try: # Close the browser await context.browser.close() logger.info("Browser closed successfully") except Exception as e: logger.error(f"Error closing browser: {e}") - + logger.info("Server shut down") - + async def cleanup_old_tasks(): """Periodically clean up expired tasks.""" from datetime import datetime - + while True: try: # Check for expired tasks every minute await asyncio.sleep(60) - + # Get current time now = datetime.now() - + # Check each task expired_tasks = [] for task_id, task in task_store.items(): if "expiry_time" in task: # Parse expiry time expiry_time = datetime.fromisoformat(task["expiry_time"]) - + # Check if expired if now > expiry_time: expired_tasks.append(task_id) - + # Remove expired tasks for task_id in expired_tasks: logger.info(f"Removing expired task {task_id}") task_store.pop(task_id, None) - + except Exception as e: logger.error(f"Error cleaning up old tasks: {e}") - + # Create Starlette app with routes routes = [ Route("/sse", endpoint=handle_sse, methods=["GET"]), Route("/health", endpoint=health_check, methods=["GET"]), Route("/reset", endpoint=reset_context, methods=["POST"]), ] - + starlette_app = Starlette( routes=routes, on_startup=[startup_event], on_shutdown=[shutdown_event], - debug=True + debug=True, ) - + # Run with uvicorn logger.info(f"Starting browser MCP server with SSE transport on port {port}") uvicorn.run(starlette_app, host="0.0.0.0", port=port) - + return 0 + async def _run_stdio(app: Server) -> int: """Run the server using stdio transport.""" try: stdin_reader = AsyncStdinReader() stdout_writer = AsyncStdoutWriter() - + # Create initialization options initialization_options = {} - + # Run the server await app.run( read_stream=stdin_reader, write_stream=stdout_writer, - initialization_options=initialization_options + initialization_options=initialization_options, ) return 0 except KeyboardInterrupt: @@ -334,5 +346,6 @@ async def _run_stdio(app: Server) -> int: logger.error(f"Error running server: {e}") return 1 + if __name__ == "__main__": - cli() \ No newline at end of file + cli() From ff8ea9c4edd2b97de2a3f1d62640ad7cb31306b1 Mon Sep 17 00:00:00 2001 From: Ryan MacArthur Date: Wed, 12 Mar 2025 16:22:43 +0100 Subject: [PATCH 08/12] chore: lint check --- src/browser_use_mcp_server/cli.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/browser_use_mcp_server/cli.py b/src/browser_use_mcp_server/cli.py index 585efb8..6aa0163 100644 --- a/src/browser_use_mcp_server/cli.py +++ b/src/browser_use_mcp_server/cli.py @@ -10,10 +10,8 @@ from starlette.applications import Starlette from starlette.routing import Route from starlette.responses import JSONResponse, StreamingResponse -import anyio -from typing import Dict, Any, Optional, AsyncIterator +from typing import Dict, Any, Optional import sys -import json from langchain_openai import ChatOpenAI from mcp.server.lowlevel import Server From 41eea216dc1c6304c314f17f36f13e1e04eb5d1a Mon Sep 17 00:00:00 2001 From: mac Date: Wed, 12 Mar 2025 16:24:01 +0100 Subject: [PATCH 09/12] Update python-package.yml --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 451d55b..7fff25f 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 From 8da272b5707512d254a9ea27a7d737cca3b12a6f Mon Sep 17 00:00:00 2001 From: Tobias Date: Wed, 12 Mar 2025 22:42:31 +0100 Subject: [PATCH 10/12] Add star history chart to README --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 87bd95e..ac3a1aa 100644 --- a/README.md +++ b/README.md @@ -85,3 +85,11 @@ then try asking your LLM the following: ### help for issues or interest reach out @ https://cobrowser.xyz + +# stars + + + + + Star History Chart + From 80b345450cfd2e3d695a19453e323743d3f2492f Mon Sep 17 00:00:00 2001 From: Tobias Date: Wed, 12 Mar 2025 22:51:37 +0100 Subject: [PATCH 11/12] Remove Python package workflow configuration from GitHub Actions --- .github/workflows/python-package.yml | 56 ---------------------------- 1 file changed, 56 deletions(-) delete mode 100644 .github/workflows/python-package.yml diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml deleted file mode 100644 index 7fff25f..0000000 --- a/.github/workflows/python-package.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Python Package - -on: - push: - branches: [main] - tags: - - "v*" - pull_request: - branches: [main] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.11", "3.12", "3.13"] - - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install pytest pytest-asyncio pytest-cov - pip install -e ".[dev]" - - name: Test with pytest - run: | - pytest --cov=browser_use_mcp_server - - build-and-publish: - needs: test - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install build twine - - name: Build package - run: | - python -m build - - name: Publish to GitHub Packages - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.GITHUB_TOKEN }} - repository-url: https://pypi.pkg.github.com/${{ github.repository_owner }} From af7120e6c4a5d6638eff313c1e58f729289be63b Mon Sep 17 00:00:00 2001 From: Tobias Date: Thu, 13 Mar 2025 21:04:30 +0100 Subject: [PATCH 12/12] feat: expose port 8000 in Dockerfile for application access --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index f72d9df..cd4ec92 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,4 +68,7 @@ RUN mkdir -p ~/.vnc && \ printf '#!/bin/bash\nvncserver -depth 24 -geometry 1920x1080 -localhost no -PasswordFile /root/.vnc/passwd :0\nproxy-login-automator\npython /app/server --port 8000' > /app/boot.sh && \ chmod +x /app/boot.sh + +EXPOSE 8000 + ENTRYPOINT ["/bin/bash", "/app/boot.sh"]