From da6ce456f6db9023cf4153ea7d042dcc6e727116 Mon Sep 17 00:00:00 2001 From: Zack Cerza Date: Mon, 6 Jul 2026 14:53:37 -0600 Subject: [PATCH] exec: Clean up subprocesses on ctrl-C This fixes the "Event loop is closed" messages that often occurred if the process was interrupted. Signed-off-by: Zack Cerza --- ceph_devstack/cli.py | 8 +- ceph_devstack/exec.py | 105 ++++++++++----- ceph_devstack/host.py | 17 ++- ceph_devstack/resources/__init__.py | 6 +- ceph_devstack/resources/ceph/__init__.py | 27 ++-- pyproject.toml | 8 +- tests/test_exec.py | 163 +++++++++++++++++++++++ uv.lock | 30 +++++ 8 files changed, 302 insertions(+), 62 deletions(-) create mode 100644 tests/test_exec.py diff --git a/ceph_devstack/cli.py b/ceph_devstack/cli.py index c0794170..8658bcbc 100644 --- a/ceph_devstack/cli.py +++ b/ceph_devstack/cli.py @@ -48,9 +48,11 @@ def main() -> int: obj = CephDevStack() async def run(): - if not await asyncio.gather( - check_requirements(), - obj.check_requirements(), + if not all( + await asyncio.gather( + check_requirements(), + obj.check_requirements(), + ) ): logger.error("Requirements not met!") return 1 diff --git a/ceph_devstack/exec.py b/ceph_devstack/exec.py index bfeb9363..9cc27def 100644 --- a/ceph_devstack/exec.py +++ b/ceph_devstack/exec.py @@ -1,26 +1,74 @@ import asyncio from asyncio.subprocess import SubprocessStreamProtocol +import contextlib import functools import os import pathlib +import psutil +import signal import subprocess from typing import Dict, List, Optional from ceph_devstack import logger, VERBOSE +_TERMINATE_TIMEOUT = 3.0 +_KILL_TIMEOUT = 1.0 -class LoggingStreamProtocol(asyncio.subprocess.SubprocessStreamProtocol): - def __init__(self, limit, loop, log_level): - self.log_level = log_level - super().__init__(limit=limit, loop=loop) - def pipe_data_received(self, fd, data): - logger.log( - self.log_level, - (data.decode() if isinstance(data, bytes) else str(data)).rstrip("\n"), - ) - super().pipe_data_received(fd, data) +class Subprocess(asyncio.subprocess.Process): + async def _close_transport(self) -> None: + transport = getattr(self, "_transport", None) + if transport is not None and not transport.is_closing(): + transport.close() + + async def _wait_for_exit(self, timeout: float) -> None: + with contextlib.suppress(asyncio.TimeoutError, asyncio.CancelledError): + await asyncio.wait_for(asyncio.shield(super().wait()), timeout=timeout) + + async def _terminate(self) -> None: + if self.returncode is not None: + await self._close_transport() + return + self.signal_children(signal.SIGTERM, recursive=True) + with contextlib.suppress(ProcessLookupError): + self.kill() + await self._wait_for_exit(_TERMINATE_TIMEOUT) + if self.returncode is None: + self.signal_children(signal.SIGKILL, recursive=True) + with contextlib.suppress(ProcessLookupError): + self.kill() + await self._wait_for_exit(_KILL_TIMEOUT) + await self._close_transport() + + async def wait(self) -> int: + try: + return await super().wait() + except asyncio.CancelledError: + await self._terminate() + raise + + async def communicate(self, input=None): + try: + return await super().communicate(input) + except asyncio.CancelledError: + await self._terminate() + raise + + def child_pids(self, recursive=True): + if self.pid is None: + return [] + return [ + child.pid + for child in psutil.Process(self.pid).children(recursive=recursive) + ] + + def signal_children(self, signal: signal.Signals, recursive=True): + for pid in self.child_pids(recursive=recursive): + with contextlib.suppress(ProcessLookupError): + os.kill(pid, signal) + with contextlib.suppress(ProcessLookupError): + os.killpg(pid, signal) class Command: @@ -57,37 +105,28 @@ def run(self) -> subprocess.Popen: proc.wait() return proc - async def arun(self) -> asyncio.subprocess.Process: + async def arun(self) -> Subprocess: logger.log(VERBOSE, self._make_log_msg()) loop = asyncio.get_running_loop() - protocol_factory: ( - functools.partial[SubprocessStreamProtocol] - | functools.partial[LoggingStreamProtocol] - ) + kwargs = dict(self.kwargs) if self.stream_output: - protocol_factory = functools.partial( - LoggingStreamProtocol, - limit=2**16, - loop=loop, - log_level=VERBOSE, - ) - else: - protocol_factory = functools.partial( - asyncio.subprocess.SubprocessStreamProtocol, - limit=2**16, - loop=loop, - ) + # Inherit stdout/stderr so long-running commands (e.g. dnf builddep) + # are not blocked when their output exceeds the StreamReader limit. + kwargs["stdout"] = None + kwargs["stderr"] = None + protocol_factory = functools.partial( + SubprocessStreamProtocol, + limit=2**16, + loop=loop, + ) transport, protocol = await loop.subprocess_exec( protocol_factory, *self.args, env=self.env, - **self.kwargs, - ) - return asyncio.subprocess.Process( - transport, - protocol, - loop, + start_new_session=True, + **kwargs, ) + return Subprocess(transport, protocol, loop) def __str__(self): return " ".join(self.args) diff --git a/ceph_devstack/host.py b/ceph_devstack/host.py index 5c2f8836..90cf3477 100644 --- a/ceph_devstack/host.py +++ b/ceph_devstack/host.py @@ -1,4 +1,3 @@ -import asyncio import json import logging import os @@ -10,7 +9,7 @@ from packaging.version import parse as parse_version, Version from typing import Dict, List, Optional, Union -from .exec import Command +from .exec import Command, Subprocess logger = logging.getLogger(__name__) @@ -46,7 +45,7 @@ async def arun( cwd: Optional[pathlib.Path] = None, env: Optional[Dict] = None, stream_output: bool = False, - ) -> asyncio.subprocess.Process: + ) -> Subprocess: return await self.cmd( args, cwd=cwd, env=env, stream_output=stream_output ).arun() @@ -145,7 +144,15 @@ class LocalHost(Host): class RemoteHost(Host): type = "remote" - base_args = ["podman", "machine", "ssh", "--"] + base_args = ["podman", "machine", "ssh"] + + def _remote_args(self, args: List[str], stream_output: bool) -> List[str]: + remote = list(self.base_args) + if stream_output: + remote.append("-t") + remote.append("--") + remote.extend(args) + return remote def cmd( self, @@ -155,7 +162,7 @@ def cmd( stream_output: bool = False, ): if args[0] != "podman": - args = self.base_args + args + args = self._remote_args(args, stream_output) return super().cmd(args, cwd=cwd, env=env, stream_output=stream_output) def path_exists(self, path: Union[str, pathlib.Path]): diff --git a/ceph_devstack/resources/__init__.py b/ceph_devstack/resources/__init__.py index 65051cc3..0a3c2a10 100644 --- a/ceph_devstack/resources/__init__.py +++ b/ceph_devstack/resources/__init__.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 import argparse -import asyncio import json import os import subprocess @@ -9,6 +8,7 @@ from subprocess import CalledProcessError from typing import List, Dict, Set +from ceph_devstack.exec import Subprocess from ceph_devstack.host import host, local_host @@ -56,15 +56,13 @@ async def cmd( check: bool = False, force_local: bool = False, stream_output: bool = False, - ) -> asyncio.subprocess.Process: + ) -> Subprocess: exec_host = local_host if force_local else host proc = await exec_host.arun( args, cwd=Path(self.cwd), stream_output=stream_output, ) - assert proc.stderr is not None - assert proc.stdout is not None returncode = await proc.wait() if check and returncode != 0: # out = await proc.stderr.read() diff --git a/ceph_devstack/resources/ceph/__init__.py b/ceph_devstack/resources/ceph/__init__.py index 436eb3bb..17cc5641 100644 --- a/ceph_devstack/resources/ceph/__init__.py +++ b/ceph_devstack/resources/ceph/__init__.py @@ -205,22 +205,17 @@ async def watch(self): containers.append(object) logger.info(f"Watching {containers}") while True: - try: - for container in containers: - with contextlib.suppress(CalledProcessError): - if not await container.exists(): - logger.info( - f"Container {container.name} was removed; replacing" - ) - await container.create() - await container.start() - elif not await container.is_running(): - logger.info( - f"Container {container.name} stopped; restarting" - ) - await container.start() - except KeyboardInterrupt: - break + for container in containers: + with contextlib.suppress(CalledProcessError): + if not await container.exists(): + logger.info( + f"Container {container.name} was removed; replacing" + ) + await container.create() + await container.start() + elif not await container.is_running(): + logger.info(f"Container {container.name} stopped; restarting") + await container.start() async def wait(self, container_name: str): for spec in self.service_specs.values(): diff --git a/pyproject.toml b/pyproject.toml index d08f2667..95472fb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,13 @@ classifiers = [ keywords = ["podman", "ceph"] description = "Run a full teuthology lab on your laptop!" requires-python = ">=3.12" -dependencies = ["packaging", "pre-commit", "PyYAML", "tomlkit"] +dependencies = [ + "packaging", + "pre-commit", + "psutil>=7.2.2", + "PyYAML", + "tomlkit", +] dynamic = ["version"] [project.readme] diff --git a/tests/test_exec.py b/tests/test_exec.py new file mode 100644 index 00000000..8874a7fa --- /dev/null +++ b/tests/test_exec.py @@ -0,0 +1,163 @@ +import asyncio +import os +import sys + +import pytest + +from ceph_devstack.exec import Command +from ceph_devstack.host import RemoteHost + + +def test_remote_host_uses_tty_for_streaming(): + host = RemoteHost() + cmd = host.cmd(["python", "build.py"], stream_output=True) + assert cmd.args == ["podman", "machine", "ssh", "-t", "--", "python", "build.py"] + + +def test_remote_host_no_tty_for_buffered_remote_cmd(): + host = RemoteHost() + cmd = host.cmd(["python", "check.py"], stream_output=False) + assert cmd.args == ["podman", "machine", "ssh", "--", "python", "check.py"] + + +def test_remote_host_podman_cmd_not_wrapped(): + host = RemoteHost() + cmd = host.cmd(["podman", "build", "."], stream_output=True) + assert cmd.args == ["podman", "build", "."] + + +@pytest.mark.asyncio +async def test_cancel_kills_streaming_build_process(): + script = "import time; time.sleep(3600)" + cmd = Command([sys.executable, "-c", script], stream_output=True) + proc = await cmd.arun() + pid = proc.pid + task = asyncio.create_task(proc.wait()) + await asyncio.sleep(0.2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + + +@pytest.mark.asyncio +async def test_stream_output_writes_stdout_to_terminal(capfd): + cmd = Command( + [sys.executable, "-c", "print('build-output', flush=True)"], + stream_output=True, + ) + proc = await cmd.arun() + assert await proc.wait() == 0 + captured = capfd.readouterr() + assert "build-output" in captured.out + assert captured.err == "" + + +@pytest.mark.asyncio +async def test_stream_output_writes_stderr_to_terminal(capfd): + cmd = Command( + [ + sys.executable, + "-c", + "import sys; print('build-error', file=sys.stderr, flush=True)", + ], + stream_output=True, + ) + proc = await cmd.arun() + assert await proc.wait() == 0 + captured = capfd.readouterr() + assert captured.out == "" + assert "build-error" in captured.err + + +@pytest.mark.asyncio +async def test_stream_output_does_not_deadlock_on_large_output(capfd): + cmd = Command( + [ + sys.executable, + "-c", + "print('x' * 100_000, flush=True); print('done', flush=True)", + ], + stream_output=True, + ) + proc = await cmd.arun() + await asyncio.wait_for(proc.wait(), timeout=5) + captured = capfd.readouterr() + assert "done" in captured.out + + +@pytest.mark.asyncio +async def test_buffered_output_not_written_to_terminal(capfd): + cmd = Command( + [sys.executable, "-c", "print('hidden', flush=True)"], + stream_output=False, + ) + proc = await cmd.arun() + assert await proc.wait() == 0 + captured = capfd.readouterr() + assert captured.out == "" + assert captured.err == "" + assert proc.stdout is not None + assert (await proc.stdout.read()).decode() == "hidden\n" + + +@pytest.mark.asyncio +async def test_subprocess_transport_closed_on_cancel(): + cmd = Command( + [sys.executable, "-c", "import time; time.sleep(3600)"], + stream_output=True, + ) + proc = await cmd.arun() + task = asyncio.create_task(proc.wait()) + await asyncio.sleep(0.2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert proc.returncode is not None + assert proc._transport.is_closing() + + +def test_subprocess_cancel_does_not_error_on_loop_shutdown(): + async def run_build(): + cmd = Command( + [sys.executable, "-c", "import time; time.sleep(3600)"], + stream_output=True, + ) + proc = await cmd.arun() + await proc.wait() + return proc.pid + + with pytest.raises(TimeoutError): + asyncio.run(asyncio.wait_for(run_build(), timeout=0.2)) + + +@pytest.mark.asyncio +async def test_terminate_kills_child_in_process_group(tmp_path): + child_pid_file = tmp_path / "child.pid" + script = ( + "import subprocess, sys, time\n" + f"child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(3600)'])\n" + f"open({str(child_pid_file)!r}, 'w').write(str(child.pid))\n" + "time.sleep(3600)\n" + ) + cmd = Command([sys.executable, "-c", script], stream_output=True) + proc = await cmd.arun() + + child_pid = None + for _ in range(20): + if child_pid_file.is_file(): + child_pid = int(child_pid_file.read_text()) + break + await asyncio.sleep(0.05) + assert child_pid is not None + + task = asyncio.create_task(proc.wait()) + await asyncio.sleep(0.1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + with pytest.raises(ProcessLookupError): + os.kill(child_pid, 0) diff --git a/uv.lock b/uv.lock index 5a444801..e257aaa5 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,7 @@ source = { editable = "." } dependencies = [ { name = "packaging" }, { name = "pre-commit" }, + { name = "psutil" }, { name = "pyyaml" }, { name = "tomlkit" }, ] @@ -33,6 +34,7 @@ test = [ requires-dist = [ { name = "packaging" }, { name = "pre-commit" }, + { name = "psutil", specifier = ">=7.2.2" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-asyncio", marker = "extra == 'test'" }, { name = "pyyaml" }, @@ -148,6 +150,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "pygments" version = "2.20.0"