diff --git a/pyproject.toml b/pyproject.toml index f6c915243..d7d3703fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "pydantic-settings>=2.5.2", "uvicorn>=0.23.1; sys_platform != 'emscripten'", "jsonschema>=4.20.0", + "pywin32>=310; sys_platform == 'win32'", ] [project.optional-dependencies] @@ -125,4 +126,6 @@ filterwarnings = [ "ignore::DeprecationWarning:websockets", "ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning", "ignore:Returning str or bytes.*:DeprecationWarning:mcp.server.lowlevel", + # pywin32 internal deprecation warning + "ignore:getargs.*The 'u' format is deprecated:DeprecationWarning" ] diff --git a/src/mcp/client/stdio/__init__.py b/src/mcp/client/stdio/__init__.py index 0fd815ac7..85738cd99 100644 --- a/src/mcp/client/stdio/__init__.py +++ b/src/mcp/client/stdio/__init__.py @@ -1,3 +1,4 @@ +import logging import os import sys from contextlib import asynccontextmanager @@ -6,17 +7,22 @@ import anyio import anyio.lowlevel +from anyio.abc import Process from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from anyio.streams.text import TextReceiveStream from pydantic import BaseModel, Field import mcp.types as types -from mcp.shared.message import SessionMessage - -from .win32 import ( +from mcp.os.posix.utilities import terminate_posix_process_tree +from mcp.os.win32.utilities import ( + FallbackProcess, create_windows_process, get_windows_executable_command, + terminate_windows_process_tree, ) +from mcp.shared.message import SessionMessage + +logger = logging.getLogger(__name__) # Environment variables to inherit by default DEFAULT_INHERITED_ENV_VARS = ( @@ -187,7 +193,7 @@ async def stdin_writer(): await process.wait() except TimeoutError: # If process doesn't terminate in time, force kill it - process.kill() + await _terminate_process_tree(process) except ProcessLookupError: # Process already exited, which is fine pass @@ -222,11 +228,38 @@ async def _create_platform_compatible_process( ): """ Creates a subprocess in a platform-compatible way. - Returns a process handle. + + Unix: Creates process in a new session/process group for killpg support + Windows: Creates process in a Job Object for reliable child termination """ if sys.platform == "win32": process = await create_windows_process(command, args, env, errlog, cwd) else: - process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd) + process = await anyio.open_process( + [command, *args], + env=env, + stderr=errlog, + cwd=cwd, + start_new_session=True, + ) return process + + +async def _terminate_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None: + """ + Terminate a process and all its children using platform-specific methods. + + Unix: Uses os.killpg() for atomic process group termination + Windows: Uses Job Objects via pywin32 for reliable child process cleanup + + Args: + process: The process to terminate + timeout_seconds: Timeout in seconds before force killing (default: 2.0) + """ + if sys.platform == "win32": + await terminate_windows_process_tree(process, timeout_seconds) + else: + # FallbackProcess should only be used for Windows compatibility + assert isinstance(process, Process) + await terminate_posix_process_tree(process, timeout_seconds) diff --git a/src/mcp/os/__init__.py b/src/mcp/os/__init__.py new file mode 100644 index 000000000..fa5dbc809 --- /dev/null +++ b/src/mcp/os/__init__.py @@ -0,0 +1 @@ +"""Platform-specific utilities for MCP.""" diff --git a/src/mcp/os/posix/__init__.py b/src/mcp/os/posix/__init__.py new file mode 100644 index 000000000..23aff8bb0 --- /dev/null +++ b/src/mcp/os/posix/__init__.py @@ -0,0 +1 @@ +"""POSIX-specific utilities for MCP.""" diff --git a/src/mcp/os/posix/utilities.py b/src/mcp/os/posix/utilities.py new file mode 100644 index 000000000..5a3ba2d7e --- /dev/null +++ b/src/mcp/os/posix/utilities.py @@ -0,0 +1,60 @@ +""" +POSIX-specific functionality for stdio client operations. +""" + +import logging +import os +import signal + +import anyio +from anyio.abc import Process + +logger = logging.getLogger(__name__) + + +async def terminate_posix_process_tree(process: Process, timeout_seconds: float = 2.0) -> None: + """ + Terminate a process and all its children on POSIX systems. + + Uses os.killpg() for atomic process group termination. + + Args: + process: The process to terminate + timeout_seconds: Timeout in seconds before force killing (default: 2.0) + """ + pid = getattr(process, "pid", None) or getattr(getattr(process, "popen", None), "pid", None) + if not pid: + # No PID means there's no process to terminate - it either never started, + # already exited, or we have an invalid process object + return + + try: + pgid = os.getpgid(pid) + os.killpg(pgid, signal.SIGTERM) + + with anyio.move_on_after(timeout_seconds): + while True: + try: + # Check if process group still exists (signal 0 = check only) + os.killpg(pgid, 0) + await anyio.sleep(0.1) + except ProcessLookupError: + return + + try: + os.killpg(pgid, signal.SIGKILL) + except ProcessLookupError: + pass + + except (ProcessLookupError, PermissionError, OSError) as e: + logger.warning(f"Process group termination failed for PID {pid}: {e}, falling back to simple terminate") + try: + process.terminate() + with anyio.fail_after(timeout_seconds): + await process.wait() + except Exception as term_error: + logger.warning(f"Process termination failed for PID {pid}: {term_error}, attempting force kill") + try: + process.kill() + except Exception as kill_error: + logger.error(f"Failed to kill process {pid}: {kill_error}") diff --git a/src/mcp/os/win32/__init__.py b/src/mcp/os/win32/__init__.py new file mode 100644 index 000000000..f1ebab98d --- /dev/null +++ b/src/mcp/os/win32/__init__.py @@ -0,0 +1 @@ +"""Windows-specific utilities for MCP.""" diff --git a/src/mcp/client/stdio/win32.py b/src/mcp/os/win32/utilities.py similarity index 64% rename from src/mcp/client/stdio/win32.py rename to src/mcp/os/win32/utilities.py index d046084bb..962be0229 100644 --- a/src/mcp/client/stdio/win32.py +++ b/src/mcp/os/win32/utilities.py @@ -2,6 +2,7 @@ Windows-specific functionality for stdio client operations. """ +import logging import shutil import subprocess import sys @@ -14,6 +15,23 @@ from anyio.streams.file import FileReadStream, FileWriteStream from typing_extensions import deprecated +logger = logging.getLogger("client.stdio.win32") + +# Windows-specific imports for Job Objects +if sys.platform == "win32": + import pywintypes + import win32api + import win32con + import win32job +else: + # Type stubs for non-Windows platforms + win32api = None + win32con = None + win32job = None + pywintypes = None + +JobHandle = int + def get_windows_executable_command(command: str) -> str: """ @@ -104,6 +122,11 @@ def kill(self) -> None: """Kill the subprocess immediately (alias for terminate).""" self.terminate() + @property + def pid(self) -> int: + """Return the process ID.""" + return self.popen.pid + # ------------------------ # Updated function @@ -118,13 +141,16 @@ async def create_windows_process( cwd: Path | str | None = None, ) -> Process | FallbackProcess: """ - Creates a subprocess in a Windows-compatible way. + Creates a subprocess in a Windows-compatible way with Job Object support. Attempt to use anyio's open_process for async subprocess creation. In some cases this will throw NotImplementedError on Windows, e.g. when using the SelectorEventLoop which does not support async subprocesses. In that case, we fall back to using subprocess.Popen. + The process is automatically added to a Job Object to ensure all child + processes are terminated when the parent is terminated. + Args: command (str): The executable to run args (list[str]): List of command line arguments @@ -133,8 +159,11 @@ async def create_windows_process( cwd (Path | str | None): Working directory for the subprocess Returns: - FallbackProcess: Async-compatible subprocess with stdin and stdout streams + Process | FallbackProcess: Async-compatible subprocess with stdin and stdout streams """ + job = _create_job_object() + process = None + try: # First try using anyio with Windows-specific flags to hide console window process = await anyio.open_process( @@ -147,10 +176,9 @@ async def create_windows_process( stderr=errlog, cwd=cwd, ) - return process except NotImplementedError: - # Windows often doesn't support async subprocess creation, use fallback - return await _create_windows_fallback_process(command, args, env, errlog, cwd) + # If Windows doesn't support async subprocess creation, use fallback + process = await _create_windows_fallback_process(command, args, env, errlog, cwd) except Exception: # Try again without creation flags process = await anyio.open_process( @@ -159,7 +187,9 @@ async def create_windows_process( stderr=errlog, cwd=cwd, ) - return process + + _maybe_assign_process_to_job(process, job) + return process async def _create_windows_fallback_process( @@ -186,8 +216,6 @@ async def _create_windows_fallback_process( bufsize=0, # Unbuffered output creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) - return FallbackProcess(popen_obj) - except Exception: # If creationflags failed, fallback without them popen_obj = subprocess.Popen( @@ -199,7 +227,90 @@ async def _create_windows_fallback_process( cwd=cwd, bufsize=0, ) - return FallbackProcess(popen_obj) + return FallbackProcess(popen_obj) + + +def _create_job_object() -> int | None: + """ + Create a Windows Job Object configured to terminate all processes when closed. + """ + if sys.platform != "win32" or not win32job: + return None + + try: + job = win32job.CreateJobObject(None, "") + extended_info = win32job.QueryInformationJobObject(job, win32job.JobObjectExtendedLimitInformation) + + extended_info["BasicLimitInformation"]["LimitFlags"] |= win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + win32job.SetInformationJobObject(job, win32job.JobObjectExtendedLimitInformation, extended_info) + return job + except Exception as e: + logger.warning(f"Failed to create Job Object for process tree management: {e}") + return None + + +def _maybe_assign_process_to_job(process: Process | FallbackProcess, job: JobHandle | None) -> None: + """ + Try to assign a process to a job object. If assignment fails + for any reason, the job handle is closed. + """ + if not job: + return + + if sys.platform != "win32" or not win32api or not win32con or not win32job: + return + + try: + process_handle = win32api.OpenProcess( + win32con.PROCESS_SET_QUOTA | win32con.PROCESS_TERMINATE, False, process.pid + ) + if not process_handle: + raise Exception("Failed to open process handle") + + try: + win32job.AssignProcessToJobObject(job, process_handle) + process._job_object = job + finally: + win32api.CloseHandle(process_handle) + except Exception as e: + logger.warning(f"Failed to assign process {process.pid} to Job Object: {e}") + if win32api: + win32api.CloseHandle(job) + + +async def terminate_windows_process_tree(process: Process | FallbackProcess, timeout_seconds: float = 2.0) -> None: + """ + Terminate a process and all its children on Windows. + + If the process has an associated job object, it will be terminated. + Otherwise, falls back to basic process termination. + + Args: + process: The process to terminate + timeout_seconds: Timeout in seconds before force killing (default: 2.0) + """ + if sys.platform != "win32": + return + + job = getattr(process, "_job_object", None) + if job and win32job: + try: + win32job.TerminateJobObject(job, 1) + except Exception: + # Job might already be terminated + pass + finally: + if win32api: + try: + win32api.CloseHandle(job) + except Exception: + pass + + # Always try to terminate the process itself as well + try: + process.terminate() + except Exception: + pass @deprecated( diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 585cb8eda..65dbed09a 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -1,5 +1,7 @@ +import os import shutil import sys +import tempfile import textwrap import time @@ -9,6 +11,7 @@ from mcp.client.session import ClientSession from mcp.client.stdio import ( StdioServerParameters, + _create_platform_compatible_process, stdio_client, ) from mcp.shared.exceptions import McpError @@ -219,3 +222,306 @@ def sigint_handler(signum, frame): ) else: raise + + +class TestChildProcessCleanup: + """ + Tests for child process cleanup functionality using _terminate_process_tree. + + These tests verify that child processes are properly terminated when the parent + is killed, addressing the issue where processes like npx spawn child processes + that need to be cleaned up. The tests cover various process tree scenarios: + + - Basic parent-child relationship (single child process) + - Multi-level process trees (parent → child → grandchild) + - Race conditions where parent exits during cleanup + + Note on Windows ResourceWarning: + On Windows, we may see ResourceWarning about subprocess still running. This is + expected behavior due to how Windows process termination works: + - anyio's process.terminate() calls Windows TerminateProcess() API + - TerminateProcess() immediately kills the process without allowing cleanup + - subprocess.Popen objects in the killed process can't run their cleanup code + - Python detects this during garbage collection and issues a ResourceWarning + + This warning does NOT indicate a process leak - the processes are properly + terminated. It only means the Popen objects couldn't clean up gracefully. + This is a fundamental difference between Windows and Unix process termination. + """ + + @staticmethod + def _escape_path_for_python(path: str) -> str: + """Escape a file path for use in Python code strings.""" + # Use forward slashes which work on all platforms and don't need escaping + return repr(path.replace("\\", "/")) + + @pytest.mark.anyio + @pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default") + async def test_basic_child_process_cleanup(self): + """ + Test basic parent-child process cleanup. + Parent spawns a single child process that writes continuously to a file. + """ + # Create a marker file for the child process to write to + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + marker_file = f.name + + # Also create a file to verify parent started + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + parent_marker = f.name + + try: + # Parent script that spawns a child process + parent_script = textwrap.dedent( + f""" + import subprocess + import sys + import time + import os + + # Mark that parent started + with open({self._escape_path_for_python(parent_marker)}, 'w') as f: + f.write('parent started\\n') + + # Child script that writes continuously + child_script = f''' + import time + with open({self._escape_path_for_python(marker_file)}, 'a') as f: + while True: + f.write(f"{time.time()}") + f.flush() + time.sleep(0.1) + ''' + + # Start the child process + child = subprocess.Popen([sys.executable, '-c', child_script]) + + # Parent just sleeps + while True: + time.sleep(0.1) + """ + ) + + print("\nStarting child process termination test...") + + # Start the parent process + proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) + + # Wait for processes to start + await anyio.sleep(0.5) + + # Verify parent started + assert os.path.exists(parent_marker), "Parent process didn't start" + + # Verify child is writing + if os.path.exists(marker_file): + initial_size = os.path.getsize(marker_file) + await anyio.sleep(0.3) + size_after_wait = os.path.getsize(marker_file) + assert size_after_wait > initial_size, "Child process should be writing" + print(f"Child is writing (file grew from {initial_size} to {size_after_wait} bytes)") + + # Terminate using our function + print("Terminating process and children...") + from mcp.client.stdio import _terminate_process_tree + + await _terminate_process_tree(proc) + + # Verify processes stopped + await anyio.sleep(0.5) + if os.path.exists(marker_file): + size_after_cleanup = os.path.getsize(marker_file) + await anyio.sleep(0.5) + final_size = os.path.getsize(marker_file) + + print(f"After cleanup: file size {size_after_cleanup} -> {final_size}") + assert final_size == size_after_cleanup, ( + f"Child process still running! File grew by {final_size - size_after_cleanup} bytes" + ) + + print("SUCCESS: Child process was properly terminated") + + finally: + # Clean up files + for f in [marker_file, parent_marker]: + try: + os.unlink(f) + except OSError: + pass + + @pytest.mark.anyio + @pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default") + async def test_nested_process_tree(self): + """ + Test nested process tree cleanup (parent → child → grandchild). + Each level writes to a different file to verify all processes are terminated. + """ + # Create temporary files for each process level + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f1: + parent_file = f1.name + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f2: + child_file = f2.name + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f3: + grandchild_file = f3.name + + try: + # Simple nested process tree test + # We create parent -> child -> grandchild, each writing to a file + parent_script = textwrap.dedent( + f""" + import subprocess + import sys + import time + import os + + # Child will spawn grandchild and write to child file + child_script = f'''import subprocess + import sys + import time + + # Grandchild just writes to file + grandchild_script = \"\"\"import time + with open({self._escape_path_for_python(grandchild_file)}, 'a') as f: + while True: + f.write(f"gc {{time.time()}}") + f.flush() + time.sleep(0.1)\"\"\" + + # Spawn grandchild + subprocess.Popen([sys.executable, '-c', grandchild_script]) + + # Child writes to its file + with open({self._escape_path_for_python(child_file)}, 'a') as f: + while True: + f.write(f"c {time.time()}") + f.flush() + time.sleep(0.1)''' + + # Spawn child process + subprocess.Popen([sys.executable, '-c', child_script]) + + # Parent writes to its file + with open({self._escape_path_for_python(parent_file)}, 'a') as f: + while True: + f.write(f"p {time.time()}") + f.flush() + time.sleep(0.1) + """ + ) + + # Start the parent process + proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) + + # Let all processes start + await anyio.sleep(1.0) + + # Verify all are writing + for file_path, name in [(parent_file, "parent"), (child_file, "child"), (grandchild_file, "grandchild")]: + if os.path.exists(file_path): + initial_size = os.path.getsize(file_path) + await anyio.sleep(0.3) + new_size = os.path.getsize(file_path) + assert new_size > initial_size, f"{name} process should be writing" + + # Terminate the whole tree + from mcp.client.stdio import _terminate_process_tree + + await _terminate_process_tree(proc) + + # Verify all stopped + await anyio.sleep(0.5) + for file_path, name in [(parent_file, "parent"), (child_file, "child"), (grandchild_file, "grandchild")]: + if os.path.exists(file_path): + size1 = os.path.getsize(file_path) + await anyio.sleep(0.3) + size2 = os.path.getsize(file_path) + assert size1 == size2, f"{name} still writing after cleanup!" + + print("SUCCESS: All processes in tree terminated") + + finally: + # Clean up all marker files + for f in [parent_file, child_file, grandchild_file]: + try: + os.unlink(f) + except OSError: + pass + + @pytest.mark.anyio + @pytest.mark.filterwarnings("ignore::ResourceWarning" if sys.platform == "win32" else "default") + async def test_early_parent_exit(self): + """ + Test cleanup when parent exits during termination sequence. + Tests the race condition where parent might die during our termination + sequence but we can still clean up the children via the process group. + """ + # Create a temporary file for the child + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + marker_file = f.name + + try: + # Parent that spawns child and waits briefly + parent_script = textwrap.dedent( + f""" + import subprocess + import sys + import time + import signal + + # Child that continues running + child_script = f'''import time + with open({self._escape_path_for_python(marker_file)}, 'a') as f: + while True: + f.write(f"child {time.time()}") + f.flush() + time.sleep(0.1)''' + + # Start child in same process group + subprocess.Popen([sys.executable, '-c', child_script]) + + # Parent waits a bit then exits on SIGTERM + def handle_term(sig, frame): + sys.exit(0) + + signal.signal(signal.SIGTERM, handle_term) + + # Wait + while True: + time.sleep(0.1) + """ + ) + + # Start the parent process + proc = await _create_platform_compatible_process(sys.executable, ["-c", parent_script]) + + # Let child start writing + await anyio.sleep(0.5) + + # Verify child is writing + if os.path.exists(marker_file): + size1 = os.path.getsize(marker_file) + await anyio.sleep(0.3) + size2 = os.path.getsize(marker_file) + assert size2 > size1, "Child should be writing" + + # Terminate - this will kill the process group even if parent exits first + from mcp.client.stdio import _terminate_process_tree + + await _terminate_process_tree(proc) + + # Verify child stopped + await anyio.sleep(0.5) + if os.path.exists(marker_file): + size3 = os.path.getsize(marker_file) + await anyio.sleep(0.3) + size4 = os.path.getsize(marker_file) + assert size3 == size4, "Child should be terminated" + + print("SUCCESS: Child terminated even with parent exit during cleanup") + + finally: + # Clean up marker file + try: + os.unlink(marker_file) + except OSError: + pass diff --git a/uv.lock b/uv.lock index c95df5b5e..7a34275ce 100644 --- a/uv.lock +++ b/uv.lock @@ -583,6 +583,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sse-starlette" }, { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, @@ -630,6 +631,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.5.2" }, { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=310" }, { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, { name = "sse-starlette", specifier = ">=1.6.1" }, { name = "starlette", specifier = ">=0.27" }, @@ -1426,6 +1428,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "pywin32" +version = "310" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/da/a5f38fffbba2fb99aa4aa905480ac4b8e83ca486659ac8c95bce47fb5276/pywin32-310-cp310-cp310-win32.whl", hash = "sha256:6dd97011efc8bf51d6793a82292419eba2c71cf8e7250cfac03bba284454abc1", size = 8848240, upload-time = "2025-03-17T00:55:46.783Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fe/d873a773324fa565619ba555a82c9dabd677301720f3660a731a5d07e49a/pywin32-310-cp310-cp310-win_amd64.whl", hash = "sha256:c3e78706e4229b915a0821941a84e7ef420bf2b77e08c9dae3c76fd03fd2ae3d", size = 9601854, upload-time = "2025-03-17T00:55:48.783Z" }, + { url = "https://files.pythonhosted.org/packages/3c/84/1a8e3d7a15490d28a5d816efa229ecb4999cdc51a7c30dd8914f669093b8/pywin32-310-cp310-cp310-win_arm64.whl", hash = "sha256:33babed0cf0c92a6f94cc6cc13546ab24ee13e3e800e61ed87609ab91e4c8213", size = 8522963, upload-time = "2025-03-17T00:55:50.969Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b1/68aa2986129fb1011dabbe95f0136f44509afaf072b12b8f815905a39f33/pywin32-310-cp311-cp311-win32.whl", hash = "sha256:1e765f9564e83011a63321bb9d27ec456a0ed90d3732c4b2e312b855365ed8bd", size = 8784284, upload-time = "2025-03-17T00:55:53.124Z" }, + { url = "https://files.pythonhosted.org/packages/b3/bd/d1592635992dd8db5bb8ace0551bc3a769de1ac8850200cfa517e72739fb/pywin32-310-cp311-cp311-win_amd64.whl", hash = "sha256:126298077a9d7c95c53823934f000599f66ec9296b09167810eb24875f32689c", size = 9520748, upload-time = "2025-03-17T00:55:55.203Z" }, + { url = "https://files.pythonhosted.org/packages/90/b1/ac8b1ffce6603849eb45a91cf126c0fa5431f186c2e768bf56889c46f51c/pywin32-310-cp311-cp311-win_arm64.whl", hash = "sha256:19ec5fc9b1d51c4350be7bb00760ffce46e6c95eaf2f0b2f1150657b1a43c582", size = 8455941, upload-time = "2025-03-17T00:55:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ec/4fdbe47932f671d6e348474ea35ed94227fb5df56a7c30cbbb42cd396ed0/pywin32-310-cp312-cp312-win32.whl", hash = "sha256:8a75a5cc3893e83a108c05d82198880704c44bbaee4d06e442e471d3c9ea4f3d", size = 8796239, upload-time = "2025-03-17T00:55:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e5/b0627f8bb84e06991bea89ad8153a9e50ace40b2e1195d68e9dff6b03d0f/pywin32-310-cp312-cp312-win_amd64.whl", hash = "sha256:bf5c397c9a9a19a6f62f3fb821fbf36cac08f03770056711f765ec1503972060", size = 9503839, upload-time = "2025-03-17T00:56:00.8Z" }, + { url = "https://files.pythonhosted.org/packages/1f/32/9ccf53748df72301a89713936645a664ec001abd35ecc8578beda593d37d/pywin32-310-cp312-cp312-win_arm64.whl", hash = "sha256:2349cc906eae872d0663d4d6290d13b90621eaf78964bb1578632ff20e152966", size = 8459470, upload-time = "2025-03-17T00:56:02.601Z" }, + { url = "https://files.pythonhosted.org/packages/1c/09/9c1b978ffc4ae53999e89c19c77ba882d9fce476729f23ef55211ea1c034/pywin32-310-cp313-cp313-win32.whl", hash = "sha256:5d241a659c496ada3253cd01cfaa779b048e90ce4b2b38cd44168ad555ce74ab", size = 8794384, upload-time = "2025-03-17T00:56:04.383Z" }, + { url = "https://files.pythonhosted.org/packages/45/3c/b4640f740ffebadd5d34df35fecba0e1cfef8fde9f3e594df91c28ad9b50/pywin32-310-cp313-cp313-win_amd64.whl", hash = "sha256:667827eb3a90208ddbdcc9e860c81bde63a135710e21e4cb3348968e4bd5249e", size = 9503039, upload-time = "2025-03-17T00:56:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f4/f785020090fb050e7fb6d34b780f2231f302609dc964672f72bfaeb59a28/pywin32-310-cp313-cp313-win_arm64.whl", hash = "sha256:e308f831de771482b7cf692a1f308f8fca701b2d8f9dde6cc440c7da17e47b33", size = 8458152, upload-time = "2025-03-17T00:56:07.819Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2"